merge: adopt session catalog foundation from #8186

Bring the non-blocking desktop session discovery and hardened disposable
SQLite projection lifecycle into the multi-domain catalog integration.
This commit is contained in:
SivanCola committed 2026-08-11 01:48:25 +08:00
commit 5cec97d996
77 files changed
+6040 -1450

No files matched your search

+46 -49
View File
@@ -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.
@@ -255,6 +267,10 @@ type App struct {
// host, last Release closes it.
sharedHosts map[string]*sharedPluginHost
sharedHostsMu sync.Mutex
// extensionGeneration advances whenever plugin/MCP configuration that
// feeds boot.Build changes. Off-lock builds capture the generation before
// registering on the shared host and abandon publication if it moved.
extensionGeneration atomic.Uint64
// tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.
tabsSaveMu sync.Mutex
@@ -564,6 +580,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 +614,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 +979,11 @@ func (a *App) shutdown(context.Context) {
// Remote web window child: nothing to snapshot or stop locally.
return
}
// Freeze publication, then cancel off-barrier history, catalog, and plugin
// work so normal quit never waits for background I/O.
a.shuttingDown.Store(true)
a.cancelAllTabBuilds()
a.stopSessionCatalog(250 * time.Millisecond)
if a.workspaceHub != nil {
a.workspaceHub.close()
}
@@ -1201,6 +1224,11 @@ func (a *App) beginTabTurn(tabID string, reclaim bool, submissionID ...string) (
if err := a.workspaceRuntimeAdmissionErr(tab, ctrl); err != nil {
return nil, nil, a.workspaceNotReadyErr(tab)
}
// Rebuild stale workspace bindings before admission so shutdown never waits
// behind filesystem or plugin I/O. The later re-check protects publication.
if err := a.ensureTabControllerWorkspace(tab); err != nil {
return nil, nil, err
}
// 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
@@ -3221,27 +3236,6 @@ func (a *App) sessionDirForPath(path string) (string, string, error) {
return "", "", fmt.Errorf("session path outside known session dirs: %s", path)
}
func sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64, parentDir string) SessionMeta {
return SessionMeta{
Path: s.Path,
Preview: s.Preview,
Title: title,
Turns: s.Turns,
CreatedAt: s.CreatedAt.UnixMilli(),
LastActivityAt: s.LastActivityAt.UnixMilli(),
ModTime: s.LastActivityAt.UnixMilli(),
DeletedAt: deletedAt,
Current: current,
Open: open,
Scope: s.Scope,
WorkspaceRoot: s.WorkspaceRoot,
TopicID: s.TopicID,
TopicTitle: s.TopicTitle,
Recovered: sessionInfoIsAutomaticRecovery(s),
RecoveryCopy: sessionInfoIsUnmodifiedRecoveryCopy(s, parentDir),
}
}
func applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {
if meta == nil {
return
@@ -3413,6 +3407,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 +3809,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 +3897,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 +4078,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 +5320,6 @@ func (a *App) RemoveWorkspace(dir string) error {
clearWorkspace()
}
}
projectSessionCache.forgetDirs(desktopSessionDir(dir))
a.emitProjectTreeMetadataChanged()
return nil
}
@@ -9187,6 +9182,7 @@ func (a *App) SetMCPServerEnabled(name string, enabled bool) error {
if err := activationStore.SetServerEnabled(configuredEntry, root, enabled); err != nil {
return err
}
a.bumpExtensionGeneration()
if enabled {
// Restore cached tools (or a cache-miss connect stub) without forcing a
// process start. Explicit install/retry remains the readiness-probed path.
@@ -9286,6 +9282,7 @@ func (a *App) SetMCPServerTier(name, tier string) error {
if err := a.saveDesktopMCPServer(root, updated); err != nil {
return err
}
a.bumpExtensionGeneration()
if tab != nil && ctrl != nil && !mcpConnected(ctrl, name) {
if _, err := ctrl.ConnectMCPServer(updated); err != nil {
recordMCPFailure(ctrl, updated, err)
+11 -21
View File
@@ -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:
@@ -72,7 +72,7 @@ for (const path of localeChunks) {
// Task Monitor, Extension UI, Storage & paths, and shell execution cards
// add their own labels. Reasoning display controls and status bar metrics add
// the latest localized copy. Keep both dictionaries within narrow allowances.
const budget = name.startsWith("zh-TW-") ? 54.7 * 1024 : 53.9 * 1024;
const budget = name.startsWith("zh-TW-") ? 54.7 * 1024 : 54.0 * 1024;
assertBudget(`${name} gzip`, gzipBytes(path), budget);
}
@@ -80,7 +80,8 @@ 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. Goal request observability plus transcript scroll arbitration,
// logical selection state/DOM adapters, and measurement invalidation add small
// always-available contracts; keep raw allowance tight while gzip stays flat.
// logical selection state/DOM adapters, measurement invalidation, and the
// catalog project shell add small always-available contracts; keep the raw
// allowance tight while gzip stays flat.
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, 2_240 * 1024);
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024);
+7 -28
View File
@@ -104,7 +104,6 @@ import {
type DesktopStartupSettingsView,
type Mode,
modeHasPlan,
type ProjectNode,
type RewindResultView,
type RemoteHostView,
type SessionMeta,
@@ -889,30 +888,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";
@@ -1728,9 +1703,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);
@@ -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);
+9 -9
View File
@@ -8,6 +8,7 @@ import { app, onFilesDropped } from "../lib/bridge";
import { enqueueInboxGuidance } from "../lib/inboxSubmit";
import { canUsePromptHistory, composerEnterAction, composerEscapeAction, composerMenuKeyAction, insertComposerNewline, isFnKeyEvent, isImeKeyEvent, promptHistoryDirectionFromEvent } from "../lib/composerKeyboard";
import { cacheGeneration, loadOlder } from "../lib/composerHistory";
import { sessionTurnsLabel } from "../lib/sessionCatalogPresentation";
import { SPINNER_WORDS, useI18n, type Translator } from "../lib/i18n";
import { detectShortcutPlatform, formatShortcutCombo, isReservedComposerHistoryShortcut, matchesShortcut, useShortcutComboLabel } from "../lib/keyboardShortcuts";
import { fallbackCopyText } from "../lib/clipboard";
@@ -3250,6 +3251,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,
},
@@ -4193,21 +4195,19 @@ export function Composer({
</div>
) : (
filteredPastChats.map((session, i) => {
// PR-C2: hover preview uses only the SessionMeta fields we
// already have on hand — no extra PreviewSession call, no
// backend round-trip, no read of the full transcript.
const turns = typeof session.turns === "number";
// Hover preview stays on SessionMeta and never reads the transcript.
const turnsLabel = sessionTurnsLabel(session, t);
const ts = session.lastActivityAt || session.modTime || session.createdAt;
const preview = truncatePreview(session.preview);
const pathText = session.workspaceRoot || session.path;
const tooltipLabel =
turns || ts || preview || pathText ? (
turnsLabel || ts || preview || pathText ? (
<div className="past-chat-hover">
<div className="past-chat-hover__title">{pastChatTitle(session)}</div>
{preview && <div className="past-chat-hover__preview">{preview}</div>}
{(turns || ts) && (
{(turnsLabel || ts) && (
<div className="past-chat-hover__meta">
{turns && <span>{t("composer.sessionTurns", { n: session.turns })}</span>}
{turnsLabel && <span>{turnsLabel}</span>}
{ts && <span>· {fmtSessionTime(ts)}</span>}
</div>
)}
@@ -4227,7 +4227,7 @@ export function Composer({
<MessageSquare size={13} className="filemenu__icon" />
<span className="slashmenu__name slashmenu__name--file">
{pastChatTitle(session)}
{turns ? ` (${t("composer.sessionTurns", { n: session.turns })})` : ""}
{turnsLabel ? ` (${turnsLabel})` : ""}
</span>
</button>
</Tooltip>
@@ -4361,7 +4361,7 @@ export function Composer({
<MessageSquare size={15} />
<span>
{ref.title}
{typeof ref.turns === "number" ? ` (${t("composer.sessionTurns", { n: ref.turns })})` : ""}
{sessionTurnsLabel(ref, t) ? ` (${sessionTurnsLabel(ref, t)})` : ""}
</span>
</span>
</Tooltip>
@@ -542,7 +542,11 @@ export function HistoryPanel({
{s.recovered && <span className="hist-item__badge">{tr("recovery.badge")}</span>}
{sessionLocation(s, tr) && <span className="hist-item__scope">{sessionLocation(s, tr)}</span>}
<span className="hist-item__metaspacer" />
<span className="hist-item__stat">{tr(s.turns === 1 ? "history.turnOne" : "history.turnOther", { n: s.turns })}</span>
<span className="hist-item__stat">
{s.turnsState === "unknown"
? tr("history.indexing")
: tr(s.turns === 1 ? "history.turnOne" : "history.turnOther", { n: s.turns })}
</span>
<span className="hist-item__dot">·</span>
<span className="hist-item__stat">{timeLabel(isTrash ? s.deletedAt || sessionActivityTime(s) : sessionActivityTime(s))}</span>
{!isTrash && running && (
@@ -688,7 +692,10 @@ function sessionMetaLine(s: SessionMeta, tr: ReturnType<typeof useT>, 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[] {
+141 -253
View File
@@ -9,9 +9,12 @@ import { Archive, ArrowDown, Pencil, Plus, Folder, FolderPlus, Search, Briefcase
import { asArray } from "../lib/array";
import { useToast } from "../lib/toast";
import { app } from "../lib/bridge";
import type { ProjectNode, ProjectTopicStatus } from "../lib/types";
import { onProjectTreeChangedV2 } from "../lib/sessionCatalogBridge";
import { isRuntimeSessionNode, isTopicNode, mergeProjectTopicPage, projectTreeDedupedExactTime, projectTreeEventAffectsFolder, projectTreeFolderDisclosure, projectTreeReadActivityKey, projectTreeRevisionIsFresh, projectTreeShouldRenderTopicActions, projectTreeShouldSuppressOpenForRename, projectTreeTopicArchiveBlocked, projectTreeTopicHasUnreadActivity, projectTreeTopicHoverCardModel, projectTreeTopicMenuOffersPin, projectTreeTopicMetaLine, projectTreeTopicOpenRequest, topicActivityAt, topicActivityDateLabel, topicActivityLabel, topicIsActive, topicStatus, topicStatusLabel, topicUnknownTimeLabel, type ProjectTreePendingTopicOpen, type ProjectTreeReadActivity, type ProjectTreeTopicHoverCard, type ProjectTreeVariant } from "../lib/projectTreeTopic";
export * from "../lib/projectTreeTopic";
import type { ProjectNode, SessionCatalogStatus } from "../lib/types";
import { topicActivityTime } from "../lib/session";
import { getLocale, useT, type DictKey, type Translator } from "../lib/i18n";
import { useT, type Translator } from "../lib/i18n";
import { PROJECT_COLOR_OPTIONS, projectColorValue } from "../lib/projectColors";
import { useProjectTreeArchiveState } from "../lib/projectTreeArchive";
import { topicShortcutLabel, type TopicShortcutEntry } from "../lib/topicShortcuts";
@@ -20,8 +23,6 @@ import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type Cont
import { Tooltip } from "./Tooltip";
import { WorktreeBadge } from "./WorktreeBadge";
type ProjectTreeVariant = "classic" | "workbench" | "creation";
interface ProjectTreeProps {
activeScope?: string;
activeWorkspaceRoot?: string;
@@ -56,249 +57,6 @@ function projectNodeKey(node: ProjectNode, depth: number): string {
return node.key || `${node.kind}-${node.root ?? ""}-${node.topicId ?? ""}-${depth}`;
}
function isRuntimeSessionNode(node: ProjectNode): boolean {
return node.kind === "session" || node.kind === "global_session";
}
function isTopicNode(node: ProjectNode): boolean {
return node.kind === "topic" || node.kind === "global_topic";
}
export type ProjectTreeTopicOpenRequest = {
scope: "global" | "project";
workspaceRoot: string;
topicId: string;
sessionPath?: string;
};
export function projectTreeTopicOpenRequest(node: ProjectNode): ProjectTreeTopicOpenRequest | null {
if (!isTopicNode(node) && !isRuntimeSessionNode(node)) return null;
const scope = node.kind === "global_topic" || node.kind === "global_session" ? "global" : "project";
return {
scope,
workspaceRoot: scope === "global" ? "" : node.root ?? "",
topicId: node.topicId ?? "",
sessionPath: node.sessionPath,
};
}
type ProjectTreeTopicClickTarget = {
rowKey: string;
canRename: boolean;
};
type ProjectTreePendingTopicOpen = ProjectTreeTopicClickTarget & {
timer: ReturnType<typeof setTimeout>;
};
export function projectTreeShouldSuppressOpenForRename(
pending: ProjectTreeTopicClickTarget | null,
next: ProjectTreeTopicClickTarget,
): boolean {
return Boolean(pending && pending.rowKey === next.rowKey && pending.canRename && next.canRename);
}
export type ProjectTreeFolderDisclosure = {
canExpand: boolean;
isOpen: boolean;
ariaExpanded?: boolean;
iconStackClassName: string;
};
// allowEmptyExpand lets classic folders open without children so the expanded
// state can host the "no sessions" placeholder row; other variants keep the
// original contract where empty folders are inert.
export function projectTreeFolderDisclosure(hasChildren: boolean, isExpanded: boolean, allowEmptyExpand = false): ProjectTreeFolderDisclosure {
const canExpand = hasChildren || allowEmptyExpand;
const isOpen = canExpand && isExpanded;
return {
canExpand,
isOpen,
ariaExpanded: canExpand ? isExpanded : undefined,
iconStackClassName: `project-tree__icon-stack${canExpand ? " project-tree__icon-stack--expandable" : ""}`,
};
}
function topicIsActive(node: ProjectNode, activeScope?: string, activeWorkspaceRoot?: string, activeTopicId?: string, activeSessionPath?: string): boolean {
if (!isTopicNode(node) && !isRuntimeSessionNode(node)) return false;
if (node.sessionPath) return Boolean(activeSessionPath && activeSessionPath === node.sessionPath);
if (activeSessionPath && asArray(node.children).some(isRuntimeSessionNode)) return false;
const scope = node.kind === "global_topic" ? "global" : "project";
return (
activeTopicId === node.topicId &&
activeScope === scope &&
(scope === "global" || activeWorkspaceRoot === node.root)
);
}
export function projectTreeTopicMetaLine(node: ProjectNode, t: Translator, compact = false): string {
const parts: string[] = [];
const turns = node.turns ?? 0;
if (turns > 0) parts.push(t(turns === 1 ? "history.turnOne" : "history.turnOther", { n: turns }));
const activityAt = node.lastActivityAt || node.createdAt || 0;
if (activityAt) parts.push(topicActivityLabel(activityAt, t, compact));
if (parts.length === 0) parts.push(t("projectTree.previously"));
return parts.join(" · ");
}
// Model for the classic hover preview card: the row keeps a time-only meta
// line, so the card carries the full title, turns, exact date, and project.
export type ProjectTreeTopicHoverCard = {
title: string;
statusLabel: string;
metaLine: string;
exactTime: string;
projectLabel: string;
};
// Activity labels older than a week are already the calendar date (always the
// meta line's last part), so callers pairing the two keep a single copy.
export function projectTreeDedupedExactTime(metaLine: string, exactTime: string): string {
return exactTime && metaLine.endsWith(exactTime) ? "" : exactTime;
}
export function projectTreeTopicHoverCardModel(node: ProjectNode, t: Translator, projectLabel: string): ProjectTreeTopicHoverCard {
const activityAt = node.lastActivityAt || node.createdAt || 0;
const metaLine = projectTreeTopicMetaLine(node, t);
const exactTime = activityAt ? topicActivityDateLabel(activityAt) : "";
return {
title: (node.label || node.topicId || "Untitled").replace(/^●\s*/, ""),
statusLabel: topicStatusLabel(node, t),
metaLine,
exactTime: projectTreeDedupedExactTime(metaLine, exactTime),
projectLabel,
};
}
function topicUnknownTimeLabel(node: ProjectNode, t: Translator): string {
return topicActivityAt(node) ? "" : t("projectTree.previously");
}
const topicStatusLabels: Record<ProjectTopicStatus, DictKey> = {
thinking: "projectTree.status.thinking",
streaming: "projectTree.status.streaming",
waiting_confirmation: "projectTree.status.waitingConfirmation",
background_job: "projectTree.status.backgroundJob",
paused: "projectTree.status.paused",
error: "projectTree.status.error",
};
function normalizeTopicStatus(status?: string): ProjectTopicStatus | "" {
if (!status) return "";
if (status === "thinking" || status === "streaming" || status === "waiting_confirmation" || status === "background_job" || status === "paused" || status === "error") {
return status;
}
return "";
}
function topicStatus(node: ProjectNode): ProjectTopicStatus | "" {
return normalizeTopicStatus(node.status) || (node.running ? "streaming" : "");
}
export function projectTreeTopicArchiveBlocked(node: ProjectNode): boolean {
if (asArray(node.children).some(projectTreeTopicArchiveBlocked)) return true;
const status = normalizeTopicStatus(node.status);
if (status === "thinking" || status === "streaming" || status === "waiting_confirmation" || status === "background_job") return true;
if (status === "paused" || status === "error") return false;
return Boolean(node.running);
}
function topicStatusLabel(node: ProjectNode, t: Translator): string {
const status = topicStatus(node);
return status ? t(topicStatusLabels[status]) : "";
}
function topicActivityAt(node: ProjectNode): number {
return node.lastActivityAt || node.createdAt || 0;
}
export function projectTreeReadActivityKey(node: ProjectNode): string | null {
const request = projectTreeTopicOpenRequest(node);
if (!request?.topicId) return null;
return [
request.scope,
request.workspaceRoot,
request.topicId,
request.sessionPath ?? "",
].join("\u001f");
}
type ProjectTreeReadActivity = Record<string, number>;
export function projectTreeTopicHasUnreadActivity(
node: ProjectNode,
readActivity: ProjectTreeReadActivity,
activeScope?: string,
activeWorkspaceRoot?: string,
activeTopicId?: string,
activeSessionPath?: string,
): boolean {
if (!isTopicNode(node) && !isRuntimeSessionNode(node)) return false;
if (topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath)) return false;
if (topicStatus(node) !== "") return false;
const key = projectTreeReadActivityKey(node);
const activityAt = topicActivityAt(node);
return Boolean(key && activityAt > 0 && (readActivity[key] ?? 0) < activityAt);
}
export function projectTreeShouldRenderTopicActions(isSessionNode: boolean, variant: ProjectTreeVariant, unread: boolean): boolean {
return !isSessionNode && variant !== "creation" && !unread;
}
// Pinning reorders the classic/workbench trees shared with creation mode, so
// the creation context menu keeps its original rename/trash-only entries.
export function projectTreeTopicMenuOffersPin(variant: ProjectTreeVariant): boolean {
return variant !== "creation";
}
function topicActivityLabel(ms: number, t: Translator, compact = false): string {
if (ms <= 0) return "";
const delta = Date.now() - ms;
const locale = getLocale();
const minute = 60_000;
const hour = 60 * minute;
const day = 24 * hour;
const month = 30 * day;
const year = 365 * day;
if (delta < minute) return t("projectTree.justNow");
if (!compact) {
const rtfLocale = locale === "zh" ? "zh-CN" : locale === "zh-TW" ? "zh-TW" : "en";
const rtf = new Intl.RelativeTimeFormat(rtfLocale, { numeric: "auto" });
if (delta < hour) return rtf.format(-Math.max(1, Math.round(delta / minute)), "minute");
if (delta < day) return rtf.format(-Math.round(delta / hour), "hour");
if (delta < 7 * day) return rtf.format(-Math.round(delta / day), "day");
return topicActivityDateLabel(ms);
}
if (delta < hour) {
const value = Math.max(1, Math.round(delta / minute));
return locale === "zh" || locale === "zh-TW" ? `${value} 分钟` : `${value}m`;
}
if (delta < day) {
const value = Math.round(delta / hour);
return locale === "zh" || locale === "zh-TW" ? `${value} 小时` : `${value}h`;
}
if (delta < 7 * day) {
const value = Math.round(delta / day);
return locale === "zh" || locale === "zh-TW" ? `${value} 天` : `${value}d`;
}
if (delta < month) {
const value = Math.round(delta / day);
return locale === "zh" || locale === "zh-TW" ? `${value} 天` : `${value}d`;
}
if (delta < year) {
const value = Math.max(1, Math.round(delta / month));
return locale === "zh" || locale === "zh-TW" ? `${value} 个月` : `${value}mo`;
}
const value = Math.max(1, Math.round(delta / year));
return locale === "zh" || locale === "zh-TW" ? `${value} 年` : `${value}y`;
}
function topicActivityDateLabel(ms: number): string {
if (ms <= 0) return "";
const locale = getLocale();
const dateLocale = locale === "zh" ? "zh-CN" : locale === "zh-TW" ? "zh-TW" : "en";
return new Date(ms).toLocaleDateString(dateLocale);
}
type ProjectDropPosition = "before" | "after";
type WorkbenchHeaderMenu = "more" | "add" | null;
@@ -409,7 +167,26 @@ export function activeSessionAncestorKeys(
}
return null;
};
return walk(nodes, []) ?? [];
const found = walk(nodes, []);
if (found) return found;
// Shell-only snapshots no longer embed topics. Expand the matching project or
// Global folder by workspace identity so the first lazy page can load.
const scope = (activeScope ?? "").trim();
const root = (activeWorkspaceRoot ?? "").trim();
for (const node of nodes) {
if (!node) continue;
if (scope === "global" && node.kind === "global_folder") {
return [projectNodeKey(node, 0)];
}
if (node.kind === "project" && root && (node.root === root || node.root === activeWorkspaceRoot)) {
return [projectNodeKey(node, 0)];
}
if (!scope && !root && activeTopicId && (node.kind === "project" || node.kind === "global_folder")) {
// Active topic without resolved scope still needs a folder open path.
return [projectNodeKey(node, 0)];
}
}
return [];
}
export function defaultExpandedProjectTreeKeys(
@@ -617,6 +394,20 @@ export function ProjectTree({
const compactTopics = variant === "workbench";
const creationTopics = variant === "creation";
const [tree, setTree] = useState<ProjectNode[]>([]);
const treeRef = useRef<ProjectNode[]>([]);
const latestRevisionRef = useRef(0);
const [catalogStatus, setCatalogStatus] = useState<SessionCatalogStatus>({
state: "opening", revision: 0, indexed: 0, total: 0, repairPending: 0,
});
const [topicPageState, setTopicPageState] = useState<Record<string, { nextCursor?: string; loading: boolean }>>({});
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<Set<string>>(new Set());
const [manuallyCollapsed, setManuallyCollapsed] = useState<Set<string>>(new Set());
const [creatingProject, setCreatingProject] = useState<string | null>(null);
@@ -695,15 +486,67 @@ export function ProjectTree({
});
}, []);
// Keyed by the data, not the selection: a switch cannot change the tree.
const topicLoadSeqRef = useRef<Record<string, number>>({});
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];
const cursor = append ? pageState?.nextCursor ?? "" : "";
if (append && !cursor) return;
// Last-query-wins: never drop a newer search because an older page is still loading.
const seq = (topicLoadSeqRef.current[key] ?? 0) + 1;
topicLoadSeqRef.current[key] = seq;
updateTopicPageState(key, { ...pageState, loading: true });
try {
const page = await app.ListProjectTopics({
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 (topicLoadSeqRef.current[key] !== seq) return;
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 {
if (topicLoadSeqRef.current[key] !== seq) return;
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 +562,31 @@ 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]);
// Debounce query/timeFilter reloads so typing does not stampede the catalog.
// Expansion and tree shell arrival still load on the same path after the delay.
useEffect(() => {
const filtering = query.trim() !== "" || timeFilter !== "all";
const timer = setTimeout(() => {
for (const project of treeRef.current) {
const key = projectNodeKey(project, 0);
if (filtering || expanded.has(key)) void loadProjectTopics(project);
}
}, 200);
return () => clearTimeout(timer);
}, [expanded, loadProjectTopics, query, timeFilter, tree]);
// Following the active topic is a view concern over the tree already held.
useEffect(() => {
const collapsed = manuallyCollapsedRef.current;
@@ -852,7 +720,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 +734,7 @@ export function ProjectTree({
else next.delete(key);
return next;
});
if (!willCollapse && project) void loadProjectTopics(project);
};
const folderKeys = useMemo(() => collapsibleFolderKeys(tree), [tree]);
@@ -1291,7 +1160,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 +1655,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 +1683,17 @@ export function ProjectTree({
{hiddenCount > 0 ? t("projectTree.showMoreTopics", { n: hiddenCount }) : t("projectTree.showFewerTopics")}
</button>
)}
{backendPage?.nextCursor && (
<button
type="button"
className="project-tree__topic-window-toggle"
style={{ paddingLeft: 14 + (depth + 1) * 16 }}
disabled={backendPage.loading}
onClick={() => void loadProjectTopics(node, true)}
>
{backendPage.loading ? t("projectTree.indexing") : t("projectTree.loadMore")}
</button>
)}
</div>
</div>
);
@@ -1861,7 +1744,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 +2200,11 @@ export function ProjectTree({
/>
</label>
)}
{(catalogStatus.state === "opening" || catalogStatus.state === "rebuilding" || catalogStatus.repairPending > 0) && (
<div className="project-tree__catalog-progress" role="status">
{t("projectTree.indexingProgress", { done: catalogStatus.indexed, total: catalogStatus.total || "?" })}
</div>
)}
{compactTopics ? (
<>
{renderProjectHeader("workbench")}
+5 -5
View File
@@ -1,7 +1,4 @@
// bridge is the seam between React and the Go kernel. The Wails shell calls bound
// App methods and subscribes to runtime events; in a plain browser (`pnpm dev`),
// a mock streams a canned turn through the same contract so the whole UI can be
// developed and laid out without rebuilding the Go side.
// Wails and the browser mock share this React-to-Go contract.
// @ts-ignore `wails generate module` creates this locally; fresh checkouts keep
// typecheck green by falling back to a disabled drift check below.
@@ -10,6 +7,7 @@ import type { InvocationRequest } from "./invocationDisplay";
import { addBreadcrumb } from "./breadcrumbs";
import { maybeShare } from "./queryCoalesce";
import { makeMockSessionCatalogBindings } from "./sessionCatalogBridge";
import { t } from "./i18n";
import { providerIsConfigured, providerRequiresKey, removeProviderAccessesForMock } from "./providerModels";
import { DEFAULT_STATUS_BAR_ITEMS, normalizeStatusBarItems } from "./statusBarItems";
@@ -86,6 +84,7 @@ import type {
PluginInstallOptions,
PluginView,
ProjectNode,
SessionCatalogBindings,
PromptHistoryEntry,
PromptHistoryResult,
ProviderModelCatalogUpdate,
@@ -163,7 +162,7 @@ interface DesktopWindowState {
// added or renamed, the generated types shift, and a key present in GeneratedApp
// but missing from AppBindings causes a type error here. Fix: add the new method
// to AppBindings, then run `pnpm typecheck` to verify.
export interface AppBindings {
export interface AppBindings extends SessionCatalogBindings {
Platform(): Promise<string>;
MinimiseMainWindow(): Promise<void>;
ToggleMaximiseMainWindow(): Promise<void>;
@@ -2437,6 +2436,7 @@ function makeMockApp(): AppBindings {
}
};
return {
...makeMockSessionCatalogBindings(cloneProjectTree),
async MinimiseMainWindow() {
console.info("mock MinimiseMainWindow");
},
@@ -0,0 +1,276 @@
import { asArray } from "./array";
import { getLocale, type DictKey, type Translator } from "./i18n";
import type { ProjectNode, ProjectTopicStatus } from "./types";
export type ProjectTreeVariant = "classic" | "workbench" | "creation";
export function isRuntimeSessionNode(node: ProjectNode): boolean {
return node.kind === "session" || node.kind === "global_session";
}
export function isTopicNode(node: ProjectNode): boolean {
return node.kind === "topic" || node.kind === "global_topic";
}
export function projectTreeRevisionIsFresh(currentRevision: number, incomingRevision: number): boolean {
return incomingRevision >= currentRevision;
}
export function mergeProjectTopicPage(current: ProjectNode[], incoming: ProjectNode[], append: boolean): ProjectNode[] {
if (!append) return [...incoming];
const next = [...current];
const positions = new Map(next.map((node, index) => [node.key, index]));
for (const node of incoming) {
const index = positions.get(node.key);
if (index === undefined) {
positions.set(node.key, next.length);
next.push(node);
} else {
next[index] = node;
}
}
return next;
}
export function projectTreeEventAffectsFolder(project: ProjectNode, roots: string[]): boolean {
if (roots.length === 0) return true;
const root = project.kind === "global_folder" ? "" : project.root ?? "";
return roots.includes(root);
}
export type ProjectTreeTopicOpenRequest = {
scope: "global" | "project";
workspaceRoot: string;
topicId: string;
sessionPath?: string;
};
export function projectTreeTopicOpenRequest(node: ProjectNode): ProjectTreeTopicOpenRequest | null {
if (!isTopicNode(node) && !isRuntimeSessionNode(node)) return null;
const scope = node.kind === "global_topic" || node.kind === "global_session" ? "global" : "project";
return {
scope,
workspaceRoot: scope === "global" ? "" : node.root ?? "",
topicId: node.topicId ?? "",
sessionPath: node.sessionPath,
};
}
export type ProjectTreeTopicClickTarget = {
rowKey: string;
canRename: boolean;
};
export type ProjectTreePendingTopicOpen = ProjectTreeTopicClickTarget & {
timer: ReturnType<typeof setTimeout>;
};
export function projectTreeShouldSuppressOpenForRename(
pending: ProjectTreeTopicClickTarget | null,
next: ProjectTreeTopicClickTarget,
): boolean {
return Boolean(pending && pending.rowKey === next.rowKey && pending.canRename && next.canRename);
}
export type ProjectTreeFolderDisclosure = {
canExpand: boolean;
isOpen: boolean;
ariaExpanded?: boolean;
iconStackClassName: string;
};
// allowEmptyExpand lets classic folders open without children so the expanded
// state can host the "no sessions" placeholder row; other variants keep the
// original contract where empty folders are inert.
export function projectTreeFolderDisclosure(hasChildren: boolean, isExpanded: boolean, allowEmptyExpand = false): ProjectTreeFolderDisclosure {
const canExpand = hasChildren || allowEmptyExpand;
const isOpen = canExpand && isExpanded;
return {
canExpand,
isOpen,
ariaExpanded: canExpand ? isExpanded : undefined,
iconStackClassName: `project-tree__icon-stack${canExpand ? " project-tree__icon-stack--expandable" : ""}`,
};
}
export function topicIsActive(node: ProjectNode, activeScope?: string, activeWorkspaceRoot?: string, activeTopicId?: string, activeSessionPath?: string): boolean {
if (!isTopicNode(node) && !isRuntimeSessionNode(node)) return false;
if (node.sessionPath) return Boolean(activeSessionPath && activeSessionPath === node.sessionPath);
if (activeSessionPath && asArray(node.children).some(isRuntimeSessionNode)) return false;
const scope = node.kind === "global_topic" ? "global" : "project";
return (
activeTopicId === node.topicId &&
activeScope === scope &&
(scope === "global" || activeWorkspaceRoot === node.root)
);
}
export function projectTreeTopicMetaLine(node: ProjectNode, t: Translator, compact = false): string {
const parts: string[] = [];
const turns = node.turns ?? 0;
if (node.turnsState === "unknown") parts.push(t("history.indexing"));
else if (turns > 0) parts.push(t(turns === 1 ? "history.turnOne" : "history.turnOther", { n: turns }));
const activityAt = node.lastActivityAt || node.createdAt || 0;
if (activityAt) parts.push(topicActivityLabel(activityAt, t, compact));
if (parts.length === 0) parts.push(t("projectTree.previously"));
return parts.join(" · ");
}
// Model for the classic hover preview card: the row keeps a time-only meta
// line, so the card carries the full title, turns, exact date, and project.
export type ProjectTreeTopicHoverCard = {
title: string;
statusLabel: string;
metaLine: string;
exactTime: string;
projectLabel: string;
};
// Activity labels older than a week are already the calendar date (always the
// meta line's last part), so callers pairing the two keep a single copy.
export function projectTreeDedupedExactTime(metaLine: string, exactTime: string): string {
return exactTime && metaLine.endsWith(exactTime) ? "" : exactTime;
}
export function projectTreeTopicHoverCardModel(node: ProjectNode, t: Translator, projectLabel: string): ProjectTreeTopicHoverCard {
const activityAt = node.lastActivityAt || node.createdAt || 0;
const metaLine = projectTreeTopicMetaLine(node, t);
const exactTime = activityAt ? topicActivityDateLabel(activityAt) : "";
return {
title: (node.label || node.topicId || "Untitled").replace(/^●\s*/, ""),
statusLabel: topicStatusLabel(node, t),
metaLine,
exactTime: projectTreeDedupedExactTime(metaLine, exactTime),
projectLabel,
};
}
export function topicUnknownTimeLabel(node: ProjectNode, t: Translator): string {
return topicActivityAt(node) ? "" : t("projectTree.previously");
}
const topicStatusLabels: Record<ProjectTopicStatus, DictKey> = {
thinking: "projectTree.status.thinking",
streaming: "projectTree.status.streaming",
waiting_confirmation: "projectTree.status.waitingConfirmation",
background_job: "projectTree.status.backgroundJob",
paused: "projectTree.status.paused",
error: "projectTree.status.error",
};
export function normalizeTopicStatus(status?: string): ProjectTopicStatus | "" {
if (!status) return "";
if (status === "thinking" || status === "streaming" || status === "waiting_confirmation" || status === "background_job" || status === "paused" || status === "error") {
return status;
}
return "";
}
export function topicStatus(node: ProjectNode): ProjectTopicStatus | "" {
return normalizeTopicStatus(node.status) || (node.running ? "streaming" : "");
}
export function projectTreeTopicArchiveBlocked(node: ProjectNode): boolean {
if (asArray(node.children).some(projectTreeTopicArchiveBlocked)) return true;
const status = normalizeTopicStatus(node.status);
if (status === "thinking" || status === "streaming" || status === "waiting_confirmation" || status === "background_job") return true;
if (status === "paused" || status === "error") return false;
return Boolean(node.running);
}
export function topicStatusLabel(node: ProjectNode, t: Translator): string {
const status = topicStatus(node);
return status ? t(topicStatusLabels[status]) : "";
}
export function topicActivityAt(node: ProjectNode): number {
return node.lastActivityAt || node.createdAt || 0;
}
export function projectTreeReadActivityKey(node: ProjectNode): string | null {
const request = projectTreeTopicOpenRequest(node);
if (!request?.topicId) return null;
return [
request.scope,
request.workspaceRoot,
request.topicId,
request.sessionPath ?? "",
].join("\u001f");
}
export type ProjectTreeReadActivity = Record<string, number>;
export function projectTreeTopicHasUnreadActivity(
node: ProjectNode,
readActivity: ProjectTreeReadActivity,
activeScope?: string,
activeWorkspaceRoot?: string,
activeTopicId?: string,
activeSessionPath?: string,
): boolean {
if (!isTopicNode(node) && !isRuntimeSessionNode(node)) return false;
if (topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath)) return false;
if (topicStatus(node) !== "") return false;
const key = projectTreeReadActivityKey(node);
const activityAt = topicActivityAt(node);
return Boolean(key && activityAt > 0 && (readActivity[key] ?? 0) < activityAt);
}
export function projectTreeShouldRenderTopicActions(isSessionNode: boolean, variant: ProjectTreeVariant, unread: boolean): boolean {
return !isSessionNode && variant !== "creation" && !unread;
}
// Pinning reorders the classic/workbench trees shared with creation mode, so
// the creation context menu keeps its original rename/trash-only entries.
export function projectTreeTopicMenuOffersPin(variant: ProjectTreeVariant): boolean {
return variant !== "creation";
}
export function topicActivityLabel(ms: number, t: Translator, compact = false): string {
if (ms <= 0) return "";
const delta = Date.now() - ms;
const locale = getLocale();
const minute = 60_000;
const hour = 60 * minute;
const day = 24 * hour;
const month = 30 * day;
const year = 365 * day;
if (delta < minute) return t("projectTree.justNow");
if (!compact) {
const rtfLocale = locale === "zh" ? "zh-CN" : locale === "zh-TW" ? "zh-TW" : "en";
const rtf = new Intl.RelativeTimeFormat(rtfLocale, { numeric: "auto" });
if (delta < hour) return rtf.format(-Math.max(1, Math.round(delta / minute)), "minute");
if (delta < day) return rtf.format(-Math.round(delta / hour), "hour");
if (delta < 7 * day) return rtf.format(-Math.round(delta / day), "day");
return topicActivityDateLabel(ms);
}
if (delta < hour) {
const value = Math.max(1, Math.round(delta / minute));
return locale === "zh" || locale === "zh-TW" ? `${value} 分钟` : `${value}m`;
}
if (delta < day) {
const value = Math.round(delta / hour);
return locale === "zh" || locale === "zh-TW" ? `${value} 小时` : `${value}h`;
}
if (delta < 7 * day) {
const value = Math.round(delta / day);
return locale === "zh" || locale === "zh-TW" ? `${value} 天` : `${value}d`;
}
if (delta < month) {
const value = Math.round(delta / day);
return locale === "zh" || locale === "zh-TW" ? `${value} 天` : `${value}d`;
}
if (delta < year) {
const value = Math.max(1, Math.round(delta / month));
return locale === "zh" || locale === "zh-TW" ? `${value} 个月` : `${value}mo`;
}
const value = Math.max(1, Math.round(delta / year));
return locale === "zh" || locale === "zh-TW" ? `${value} 年` : `${value}y`;
}
export function topicActivityDateLabel(ms: number): string {
if (ms <= 0) return "";
const locale = getLocale();
const dateLocale = locale === "zh" ? "zh-CN" : locale === "zh-TW" ? "zh-TW" : "en";
return new Date(ms).toLocaleDateString(dateLocale);
}
@@ -0,0 +1,60 @@
import { asArray } from "./array";
import type {
ProjectNode,
ProjectTopicKey,
ProjectTopicPage,
ProjectTopicPageRequest,
ProjectTreeChangedV2,
SessionCatalogBindings,
} from "./types";
export function onProjectTreeChangedV2(cb: (event: ProjectTreeChangedV2) => void): () => void {
if (typeof window !== "undefined" && window.runtime) {
return window.runtime.EventsOn("project-tree:changed-v2", (payload?: unknown) => {
if (!payload || typeof payload !== "object") return;
const event = payload as Partial<ProjectTreeChangedV2>;
cb({
revision: typeof event.revision === "number" ? event.revision : 0,
roots: asArray(event.roots).filter((root): root is string => typeof root === "string"),
reason: typeof event.reason === "string" ? event.reason : "changed",
});
});
}
return () => {};
}
export function makeMockSessionCatalogBindings(cloneProjectTree: () => ProjectNode[]): SessionCatalogBindings {
const listProjectTopics = async (req: ProjectTopicPageRequest): Promise<ProjectTopicPage> => {
const folder = req.scope === "global"
? cloneProjectTree().find((item) => item.kind === "global_folder")
: cloneProjectTree().find((item) => item.kind === "project" && item.root === req.workspaceRoot);
const query = (req.query ?? "").trim().toLocaleLowerCase();
const all = asArray(folder?.children).filter((item) => !query || item.label.toLocaleLowerCase().includes(query));
const start = Math.max(0, Number.parseInt(req.cursor ?? "0", 10) || 0);
const limit = Math.min(200, Math.max(1, req.limit ?? 50));
const items = all.slice(start, start + limit);
return { items, nextCursor: start + items.length < all.length ? String(start + items.length) : undefined, revision: 1 };
};
return {
async GetProjectTreeSnapshot() {
return {
revision: 1,
projects: cloneProjectTree().map((project) => ({ ...project, children: [] })),
catalog: { state: "ready", mode: "memory", revision: 1, indexed: 4, total: 4, repairPending: 0 },
indexed: 4,
total: 4,
indexingDone: true,
};
},
ListProjectTopics: listProjectTopics,
async GetTopicSummary(key: ProjectTopicKey) {
const page = await listProjectTopics({ scope: key.scope, workspaceRoot: key.workspaceRoot, limit: 200 });
return page.items.find((item) => item.topicId === key.topicId)
?? { key: "", kind: key.scope === "global" ? "global_topic" : "topic", label: "", children: [] };
},
async GetSessionCatalogStatus() {
return { state: "ready", mode: "memory", revision: 1, indexed: 4, total: 4, repairPending: 0 };
},
async RebuildSessionCatalog() {},
};
}
@@ -0,0 +1,12 @@
import type { Translator } from "./i18n";
type SessionTurns = {
turns?: number;
turnsState?: string;
};
export function sessionTurnsLabel(session: SessionTurns, t: Translator): string {
if (session.turnsState === "unknown") return t("history.indexing");
if (typeof session.turns === "number") return t("composer.sessionTurns", { n: session.turns });
return "";
}
@@ -0,0 +1,67 @@
import type { ProjectNode } from "./types";
export interface SessionCatalogStatus {
state: "opening" | "ready" | "degraded" | "rebuilding" | "closed" | string;
mode?: "disk" | "memory" | string;
revision: number;
indexed: number;
total: number;
repairPending: number;
lastError?: string;
quarantinedPath?: string;
}
export interface ProjectTreeSnapshot {
revision: number;
projects: ProjectNode[];
catalog: SessionCatalogStatus;
indexed: number;
total: number;
indexingDone: boolean;
}
export interface ProjectTopicPageRequest {
scope: "global" | "project" | string;
workspaceRoot?: string;
cursor?: string;
limit?: number;
query?: string;
timeFilter?: string;
}
export interface ProjectTopicPage {
items: ProjectNode[];
nextCursor?: string;
revision: number;
}
export interface ProjectTopicKey {
scope: "global" | "project" | string;
workspaceRoot?: string;
topicId: string;
}
export interface ProjectTreeChangedV2 {
revision: number;
roots: string[];
reason: string;
}
export interface SessionCatalogBindings {
GetProjectTreeSnapshot(): Promise<ProjectTreeSnapshot>;
ListProjectTopics(req: ProjectTopicPageRequest): Promise<ProjectTopicPage>;
GetTopicSummary(key: ProjectTopicKey): Promise<ProjectNode>;
GetSessionCatalogStatus(): Promise<SessionCatalogStatus>;
RebuildSessionCatalog(): Promise<void>;
}
// SessionReference is a session selected via @ past:chats for context injection.
export interface SessionReference {
path: string;
title: string;
preview?: string;
turns?: number;
turnsState?: "unknown" | "valid" | "corrupt" | string;
createdAt?: number;
lastActivityAt?: number;
}
+4 -10
View File
@@ -4,6 +4,7 @@
import type { Todo } from "./tools";
import type { ContextMaintenanceInfo, WireContextMaintenance } from "./contextMaintenanceTypes";
export type { ContextMaintenanceInfo, ContextMaintenanceReceipt, WireContextMaintenance } from "./contextMaintenanceTypes";
export type { ProjectTopicKey, ProjectTopicPage, ProjectTopicPageRequest, ProjectTreeChangedV2, ProjectTreeSnapshot, SessionCatalogBindings, SessionCatalogStatus, SessionReference } from "./sessionCatalogTypes";
export type EventKind =
| "turn_started"
@@ -443,6 +444,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;
@@ -773,6 +776,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
@@ -795,16 +799,6 @@ export interface SessionMeta {
recoveryCopy?: boolean; // actual branch content is unchanged and covered by its parent
}
// SessionReference is a session selected via @ past:chats for context injection.
export interface SessionReference {
path: string;
title: string;
preview?: string;
turns?: number;
createdAt?: number;
lastActivityAt?: number;
}
export interface WorkspaceView {
path: string;
name: string;
+4
View File
@@ -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.",
+4
View File
@@ -926,6 +926,7 @@ export const zhTW: Record<DictKey, string> = {
"history.deleted": "已刪除",
"history.turnOne": "{n} 輪",
"history.turnOther": "{n} 輪",
"history.indexing": "正在整理歷史",
"history.confirmDelete": "確認刪除",
"history.confirmPurge": "確認徹底刪除",
"history.clearTrash": "清空回收站",
@@ -986,6 +987,9 @@ export const zhTW: Record<DictKey, string> = {
"projectTree.workspaceTitle": "專案",
"projectTree.pinnedTitle": "置頂",
"projectTree.searchPlaceholder": "搜尋專案或會話",
"projectTree.indexing": "正在整理歷史",
"projectTree.indexingProgress": "正在整理歷史({done}/{total})",
"projectTree.loadMore": "載入更多",
"projectTree.newTopic": "新建會話",
"projectTree.createWorktree": "建立隔離的交付工作區",
"projectTree.createWorktreeHint": "建立獨立的 Git 分支與 worktree,讓多個交付會話安全並行寫入。",
+4
View File
@@ -1183,6 +1183,7 @@ export const zh: Record<DictKey, string> = {
"history.deleted": "已删除",
"history.turnOne": "{n} 轮",
"history.turnOther": "{n} 轮",
"history.indexing": "正在整理历史",
"history.confirmDelete": "确认删除",
"history.confirmPurge": "确认彻底删除",
"history.clearTrash": "清空回收站",
@@ -1243,6 +1244,9 @@ export const zh: Record<DictKey, string> = {
"projectTree.workspaceTitle": "项目",
"projectTree.pinnedTitle": "置顶",
"projectTree.searchPlaceholder": "搜索项目或会话",
"projectTree.indexing": "正在整理历史",
"projectTree.indexingProgress": "正在整理历史({done}/{total})",
"projectTree.loadMore": "加载更多",
"projectTree.newTopic": "新会话",
"projectTree.createWorktree": "创建隔离的交付工作区",
"projectTree.createWorktreeHint": "创建独立的 Git 分支和 worktree,让多个交付会话安全并行写入。",
+7
View File
@@ -24683,6 +24683,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;
+8 -1
View File
@@ -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
)
+42 -2
View File
@@ -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=
+1 -1
View File
@@ -1,4 +1,4 @@
//go:build darwin
//go:build darwin && cgo
package main
+9
View File
@@ -0,0 +1,9 @@
//go:build darwin && !cgo
package main
func mainThreadWatchdogSupported() bool { return false }
func startNativeMainThreadHeartbeat(uint64) {}
func stopNativeMainThreadHeartbeat() {}
+3
View File
@@ -213,6 +213,7 @@ func (a *App) InstallPlugin(source string, opts PluginInstallOptions) (string, e
if err != nil {
return "", err
}
a.bumpExtensionGeneration()
a.invalidateSkillRootsCache()
if rebuildErr := a.rebuild(); rebuildErr != nil {
if _, ok := a.deferredRebuildWarning("plugins", rebuildErr); ok {
@@ -255,6 +256,7 @@ func (a *App) RemovePlugin(name string) error {
if _, err := tl.Execute(context.Background(), raw); err != nil {
return err
}
a.bumpExtensionGeneration()
a.invalidateSkillRootsCache()
if tab == nil || a.ctx == nil {
return nil
@@ -275,6 +277,7 @@ func (a *App) SetPluginEnabled(name string, enabled bool) error {
if err := pluginpkg.SetEnabled(config.ReasonixHomeDir(), strings.TrimSpace(name), enabled); err != nil {
return err
}
a.bumpExtensionGeneration()
a.invalidateSkillRootsCache()
if err := a.rebuild(); err != nil {
if _, ok := a.deferredRebuildWarning("plugins", err); ok {
+430
View File
@@ -0,0 +1,430 @@
package main
import (
"context"
"errors"
"log/slog"
"path/filepath"
"strings"
"time"
"reasonix/internal/config"
"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 := max(time.Until(deadline), 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,
}
}
+55
View File
@@ -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)
}
}
+478
View File
@@ -0,0 +1,478 @@
package main
import (
"errors"
"path/filepath"
"sort"
"strings"
"time"
"reasonix/internal/control"
"reasonix/internal/sessioncatalog"
)
type catalogRuntimeSnapshot struct {
scope string
workspaceRoot string
topicID string
sessionPath string
activity string
topicTitle string
ctrl control.SessionAPI
open bool
}
type catalogRuntimeOverlay struct {
open bool
running bool
status string
}
func catalogRuntimeStatus(activity string, runtimeStatus control.RuntimeStatus) string {
status := normalizeTopicStatus(activity)
if runtimeStatus.PendingPrompt {
return topicStatusWaitingConfirmation
}
if runtimeStatus.Running {
if status == "" || status == topicStatusError || status == topicStatusPaused {
return topicStatusThinking
}
return status
}
if runtimeStatus.BackgroundJobs > 0 {
return topicStatusBackgroundJob
}
if status == topicStatusError || status == topicStatusPaused {
return status
}
return status
}
func (a *App) catalogRuntimeOverlays() (map[string]catalogRuntimeOverlay, map[string]catalogRuntimeOverlay) {
a.mu.RLock()
snapshots := make([]catalogRuntimeSnapshot, 0, len(a.tabs)+len(a.detachedSessions))
collect := func(tab *WorkspaceTab, open bool) {
if tab == nil || strings.TrimSpace(tab.TopicID) == "" {
return
}
snapshots = append(snapshots, catalogRuntimeSnapshot{
scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID,
sessionPath: tab.SessionPath, activity: tab.ActivityStatus, topicTitle: tab.TopicTitle,
ctrl: tab.Ctrl, open: open,
})
}
for _, tab := range a.tabs {
collect(tab, true)
}
for _, tab := range a.detachedSessions {
collect(tab, false)
}
a.mu.RUnlock()
topics := map[string]catalogRuntimeOverlay{}
sessions := map[string]catalogRuntimeOverlay{}
for _, snap := range snapshots {
runtimeStatus := control.RuntimeStatus{}
path := strings.TrimSpace(snap.sessionPath)
if snap.ctrl != nil {
runtimeStatus = snap.ctrl.RuntimeStatus()
if path == "" {
path = snap.ctrl.SessionPath()
}
}
status := catalogRuntimeStatus(snap.activity, runtimeStatus)
running := status != "" || runtimeStatus.Running || runtimeStatus.PendingPrompt || runtimeStatus.BackgroundJobs > 0
overlay := catalogRuntimeOverlay{open: snap.open, running: running, status: status}
key := topicSummaryKey(snap.scope, snap.workspaceRoot, snap.topicID)
current := topics[key]
current.open = current.open || overlay.open
current.running = current.running || overlay.running
if current.status == "" {
current.status = overlay.status
}
topics[key] = current
if path != "" {
sessions[sessionRuntimeKey(path)] = overlay
}
}
return topics, sessions
}
func (a *App) metadataProjectTopics(scope, workspaceRoot string) []ProjectNode {
f := loadProjectsFile()
deleted := map[string]bool{}
for _, topicID := range f.DeletedTopics {
deleted[topicID] = true
}
ids := f.GlobalTopics
pinnedIDs := f.GlobalPinnedTopics
titleRoot := ""
projectColor := normalizeProjectColor(f.GlobalColor)
if scope == "project" {
ids = nil
pinnedIDs = nil
titleRoot = workspaceRoot
for _, project := range f.Projects {
if sameProjectRoot(project.Root, workspaceRoot) {
ids = project.Topics
pinnedIDs = project.PinnedTopics
projectColor = project.Color
break
}
}
}
titles := loadTopicTitles(titleRoot)
sources := loadTopicTitleSources(titleRoot)
created := loadTopicCreatedAts(titleRoot)
topicOverlays, _ := a.catalogRuntimeOverlays()
runtimeNodes := a.runtimeOnlyProjectTopics(scope, workspaceRoot)
runtimeByTopic := map[string]ProjectNode{}
for _, node := range runtimeNodes {
runtimeByTopic[node.TopicID] = node
}
out := []ProjectNode{}
seen := map[string]bool{}
for _, topicID := range pinnedTopicIDs(orderedTopicIDs(ids, titles), pinnedIDs) {
if deleted[topicID] {
continue
}
seen[topicID] = true
title := strings.TrimSpace(titles[topicID])
if title == "" {
title = defaultTopicTitle
}
kind := "topic"
if scope != "project" {
kind = "global_topic"
}
overlay := topicOverlays[topicSummaryKey(scope, workspaceRoot, topicID)]
node := ProjectNode{
Key: kind + "_" + topicID, Kind: kind,
Label: a.localizedTopicTitle(title, sources[topicID]), Root: workspaceRoot,
TopicID: topicID, ProjectColor: projectColor,
CreatedAt: topicCreatedAtForTree(created, topicID), Pinned: containsDesktopString(pinnedIDs, topicID),
Open: overlay.open, Running: overlay.running, Status: overlay.status,
TurnsState: string(sessioncatalog.TurnsUnknown), Health: string(sessioncatalog.HealthOK),
Children: []ProjectNode{},
}
if runtimeNode, ok := runtimeByTopic[topicID]; ok {
node.Open = runtimeNode.Open
node.Running = runtimeNode.Running
node.Status = runtimeNode.Status
node.Children = runtimeNode.Children
}
out = append(out, node)
}
for _, runtimeNode := range runtimeNodes {
if seen[runtimeNode.TopicID] || deleted[runtimeNode.TopicID] {
continue
}
out = append(out, runtimeNode)
}
return out
}
func (a *App) runtimeOnlyProjectTopics(scope, workspaceRoot string) []ProjectNode {
a.mu.RLock()
snapshots := []catalogRuntimeSnapshot{}
collect := func(tab *WorkspaceTab, open bool) {
if tab == nil || strings.TrimSpace(tab.TopicID) == "" {
return
}
if scope == "project" {
if tab.Scope != "project" || !sameProjectRoot(tab.WorkspaceRoot, workspaceRoot) {
return
}
} else if tab.Scope == "project" {
return
}
snapshots = append(snapshots, catalogRuntimeSnapshot{
scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID,
sessionPath: tab.SessionPath, activity: tab.ActivityStatus,
topicTitle: tab.TopicTitle, ctrl: tab.Ctrl, open: open,
})
}
for _, tab := range a.tabs {
collect(tab, true)
}
for _, tab := range a.detachedSessions {
collect(tab, false)
}
a.mu.RUnlock()
byTopic := map[string][]catalogRuntimeSnapshot{}
for _, snapshot := range snapshots {
if snapshot.sessionPath == "" && snapshot.ctrl != nil {
snapshot.sessionPath = snapshot.ctrl.SessionPath()
}
byTopic[snapshot.topicID] = append(byTopic[snapshot.topicID], snapshot)
}
topicIDs := make([]string, 0, len(byTopic))
for topicID := range byTopic {
topicIDs = append(topicIDs, topicID)
}
sort.Strings(topicIDs)
out := []ProjectNode{}
for _, topicID := range topicIDs {
sessions := byTopic[topicID]
kind := "topic"
sessionKind := "session"
if scope != "project" {
kind = "global_topic"
sessionKind = "global_session"
}
label := defaultTopicTitle
if strings.TrimSpace(sessions[0].topicTitle) != "" {
label = sessions[0].topicTitle
}
node := ProjectNode{
Key: kind + "_" + topicID, Kind: kind, Label: label,
Root: workspaceRoot, TopicID: topicID, TurnsState: string(sessioncatalog.TurnsUnknown),
Health: string(sessioncatalog.HealthOK), Children: []ProjectNode{},
}
for _, session := range sessions {
runtimeStatus := control.RuntimeStatus{}
if session.ctrl != nil {
runtimeStatus = session.ctrl.RuntimeStatus()
}
status := catalogRuntimeStatus(session.activity, runtimeStatus)
running := status != "" || runtimeStatus.Running || runtimeStatus.PendingPrompt || runtimeStatus.BackgroundJobs > 0
if len(sessions) == 1 {
node.Open = session.open
node.Running = running
node.Status = status
continue
}
path := strings.TrimSpace(session.sessionPath)
sessionLabel := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
if sessionLabel == "" || sessionLabel == "." {
sessionLabel = label
}
node.Children = append(node.Children, ProjectNode{
Key: projectSessionNodeKey(scope, path), Kind: sessionKind, Label: sessionLabel,
Root: workspaceRoot, TopicID: topicID, SessionPath: path,
Open: session.open, Running: running, Status: status,
TurnsState: string(sessioncatalog.TurnsUnknown), Health: string(sessioncatalog.HealthOK),
Children: []ProjectNode{},
})
}
out = append(out, node)
}
return out
}
func (a *App) metadataTopicPage(req ProjectTopicPageRequest) ProjectTopicPage {
items := a.metadataProjectTopics(req.Scope, req.WorkspaceRoot)
query := strings.ToLower(strings.TrimSpace(req.Query))
if query != "" {
filtered := items[:0]
for _, item := range items {
if strings.Contains(strings.ToLower(item.Label), query) {
filtered = append(filtered, item)
}
}
items = filtered
}
start := 0
if lastID, ok := strings.CutPrefix(req.Cursor, "meta:"); ok {
for index, item := range items {
if item.TopicID == lastID {
start = index + 1
break
}
}
}
limit := req.Limit
if limit <= 0 {
limit = sessioncatalog.DefaultLimit
}
if limit > sessioncatalog.MaxLimit {
limit = sessioncatalog.MaxLimit
}
end := min(start+limit, len(items))
page := ProjectTopicPage{Items: append([]ProjectNode(nil), items[start:end]...)}
if end < len(items) && end > start {
page.NextCursor = "meta:" + items[end-1].TopicID
}
return page
}
func (a *App) projectNodeFromCatalogTopic(topic sessioncatalog.TopicRecord, topicOverlays, sessionOverlays map[string]catalogRuntimeOverlay) ProjectNode {
kind := "topic"
if topic.Scope == "global" {
kind = "global_topic"
}
overlay := topicOverlays[topicSummaryKey(topic.Scope, topic.WorkspaceRoot, topic.TopicID)]
node := ProjectNode{
Key: kind + "_" + topic.TopicID, Kind: kind, Label: a.localizedTopicTitle(topic.Title, ""),
Root: topic.WorkspaceRoot, TopicID: topic.TopicID, Turns: topic.Turns,
TurnsState: string(topic.TurnsState), Health: string(topic.Health),
CreatedAt: topic.CreatedAt, LastActivityAt: topic.LastActivityAt,
Pinned: topic.Pinned, Open: overlay.open, Running: overlay.running, Status: overlay.status,
Children: []ProjectNode{},
}
if len(topic.Sessions) <= 1 {
return node
}
for _, session := range topic.Sessions {
sessionKind := "session"
if topic.Scope == "global" {
sessionKind = "global_session"
}
sessionOverlay := sessionOverlays[sessionRuntimeKey(session.Path)]
label := strings.TrimSpace(session.CustomTitle)
if label == "" {
label = strings.TrimSpace(session.Preview)
}
if label == "" {
label = filepath.Base(session.Path)
}
node.Children = append(node.Children, ProjectNode{
Key: projectSessionNodeKey(topic.Scope, session.Path), Kind: sessionKind,
Label: label, Root: topic.WorkspaceRoot, TopicID: topic.TopicID,
SessionPath: session.Path, Turns: session.Turns,
TurnsState: string(session.TurnsState), Health: string(session.Health),
CreatedAt: session.CreatedAt, LastActivityAt: session.LastActivityAt,
Open: sessionOverlay.open, Running: sessionOverlay.running, Status: sessionOverlay.status,
Recovered: session.Recovered, RecoveryReason: session.RecoveryReason,
RecoveryDigest: session.RecoveryDigest, RecoveryParentID: session.ParentID,
Children: []ProjectNode{},
})
}
return node
}
func (a *App) ListProjectTopics(req ProjectTopicPageRequest) (ProjectTopicPage, error) {
out := ProjectTopicPage{Items: []ProjectNode{}}
catalog := a.sessionCatalog.Load()
if catalog == nil {
return a.metadataTopicPage(req), nil
}
page, err := catalog.ListTopics(a.bootContext(), sessioncatalog.TopicPageRequest{
Scope: req.Scope, WorkspaceRoot: req.WorkspaceRoot, Cursor: req.Cursor,
Limit: req.Limit, Query: req.Query, TimeFilter: req.TimeFilter,
})
if err != nil {
return out, err
}
topicOverlays, sessionOverlays := a.catalogRuntimeOverlays()
for _, topic := range page.Items {
out.Items = append(out.Items, a.projectNodeFromCatalogTopic(topic, topicOverlays, sessionOverlays))
}
out.NextCursor = page.NextCursor
out.Revision = page.Revision
return out, nil
}
func (a *App) GetTopicSummary(key ProjectTopicKey) (ProjectNode, error) {
if catalog := a.sessionCatalog.Load(); catalog != nil {
topic, ok, err := catalog.GetTopic(a.bootContext(), sessioncatalog.TopicKey{
Scope: key.Scope, WorkspaceRoot: key.WorkspaceRoot, TopicID: key.TopicID,
})
if err != nil {
return ProjectNode{Children: []ProjectNode{}}, err
}
if ok {
topicOverlays, sessionOverlays := a.catalogRuntimeOverlays()
return a.projectNodeFromCatalogTopic(topic, topicOverlays, sessionOverlays), nil
}
}
page, err := a.ListProjectTopics(ProjectTopicPageRequest{
Scope: key.Scope, WorkspaceRoot: key.WorkspaceRoot, Limit: sessioncatalog.MaxLimit,
})
if err != nil {
return ProjectNode{Children: []ProjectNode{}}, err
}
for _, node := range page.Items {
if node.TopicID == key.TopicID {
return node, nil
}
}
return ProjectNode{Children: []ProjectNode{}}, nil
}
func (a *App) GetSessionCatalogStatus() SessionCatalogStatus {
return a.currentSessionCatalogStatus()
}
func (a *App) RebuildSessionCatalog() error {
if a == nil || a.shuttingDown.Load() {
return errors.New("application is shutting down")
}
if !a.catalogRebuilding.CompareAndSwap(false, true) {
return nil
}
go func() {
a.stopSessionCatalog(250 * time.Millisecond)
a.catalogRebuilding.Store(false)
a.startSessionCatalog(true)
}()
return nil
}
// ListProjectTree is the one-release compatibility wrapper. It composes only
// catalog pages and project shells; it never migrates, scans, or decodes a
// session synchronously.
func (a *App) ListProjectTree() []ProjectNode {
snapshot := a.GetProjectTreeSnapshot()
hasGlobal := false
for _, project := range snapshot.Projects {
if project.Kind == "global_folder" {
hasGlobal = true
break
}
}
if !hasGlobal && len(a.metadataProjectTopics("global", "")) > 0 {
f := loadProjectsFile()
label := strings.TrimSpace(f.GlobalTitle)
if label == "" {
label = "Global"
}
snapshot.Projects = append(snapshot.Projects, ProjectNode{
Key: "global_folder", Kind: "global_folder", Label: label,
Root: globalWorkspaceRoot(), ProjectColor: normalizeProjectColor(f.GlobalColor), Children: []ProjectNode{},
})
snapshot.Projects = applyPinnedProjectOrder(applyProjectTreeOrder(snapshot.Projects, f.SidebarOrder), f.PinnedProjects)
}
for index := range snapshot.Projects {
project := &snapshot.Projects[index]
scope := "project"
root := project.Root
if project.Kind == "global_folder" {
scope = "global"
root = ""
}
cursor := ""
for {
page, err := a.ListProjectTopics(ProjectTopicPageRequest{Scope: scope, WorkspaceRoot: root, Cursor: cursor, Limit: sessioncatalog.MaxLimit})
if err != nil {
break
}
project.Children = append(project.Children, page.Items...)
if page.NextCursor == "" {
break
}
cursor = page.NextCursor
}
if len(project.Children) == 0 {
project.Children = a.metadataProjectTopics(scope, root)
}
}
return snapshot.Projects
}
func (a *App) catalogSessionPathForTopic(scope, workspaceRoot, topicID string) string {
if strings.TrimSpace(topicID) == "" {
return ""
}
catalog := a.sessionCatalog.Load()
if catalog == nil {
return ""
}
topic, ok, err := catalog.GetTopic(a.bootContext(), sessioncatalog.TopicKey{Scope: scope, WorkspaceRoot: workspaceRoot, TopicID: topicID})
if err != nil || !ok || len(topic.Sessions) == 0 {
return ""
}
sort.SliceStable(topic.Sessions, func(i, j int) bool {
return topic.Sessions[i].LastActivityAt > topic.Sessions[j].LastActivityAt
})
return topic.Sessions[0].Path
}
+29
View File
@@ -0,0 +1,29 @@
package main
import "reasonix/internal/agent"
func sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64, parentDir string) SessionMeta {
turnsState := "unknown"
if s.CountsKnown {
turnsState = "valid"
}
return SessionMeta{
Path: s.Path,
Preview: s.Preview,
Title: title,
Turns: s.Turns,
TurnsState: turnsState,
CreatedAt: s.CreatedAt.UnixMilli(),
LastActivityAt: s.LastActivityAt.UnixMilli(),
ModTime: s.LastActivityAt.UnixMilli(),
DeletedAt: deletedAt,
Current: current,
Open: open,
Scope: s.Scope,
WorkspaceRoot: s.WorkspaceRoot,
TopicID: s.TopicID,
TopicTitle: s.TopicTitle,
Recovered: sessionInfoIsAutomaticRecovery(s),
RecoveryCopy: sessionInfoIsUnmodifiedRecoveryCopy(s, parentDir),
}
}
+4 -9
View File
@@ -1835,12 +1835,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 == "" {
@@ -1855,9 +1853,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)
+17
View File
@@ -10,6 +10,23 @@ import (
"reasonix/internal/plugin"
)
// bumpExtensionGeneration records that plugin/MCP configuration changed while
// controller builds may still be running off the lifecycle lock. In-flight
// builds that finish with a stale generation must not publish.
func (a *App) bumpExtensionGeneration() {
if a == nil {
return
}
a.extensionGeneration.Add(1)
}
func (a *App) currentExtensionGeneration() uint64 {
if a == nil {
return 0
}
return a.extensionGeneration.Load()
}
// sharedPluginHost is a reference-counted plugin.Host shared across tabs
// that share the same workspace root. Multiple controllers (one per tab)
// use the same Host so MCP subprocesses (CodeGraph, etc.) are spawned once.
+43
View File
@@ -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.
+1 -1
View File
@@ -1,4 +1,4 @@
//go:build darwin
//go:build darwin && cgo
package main
+1 -1
View File
@@ -1,4 +1,4 @@
//go:build !darwin
//go:build !darwin || !cgo
package main
+94 -621
View File
@@ -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,37 @@ 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 = ""
}
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)
@@ -3911,6 +3891,11 @@ func (a *App) buildTabControllerWithContextAdmissionHeld(tab *WorkspaceTab, load
toolApprovalMode: buildToolApprovalMode,
}).normalizedRuntime()
// Capture the extension generation before the shared host is mutated by
// boot.Build. A concurrent plugin delete/update/reauth bumps the counter;
// if it moves before publication we abandon this controller rather than
// resurrecting removed tools on the shared host.
extensionGen := a.currentExtensionGeneration()
sharedHost := a.acquireSharedHost(rootKey)
sink := a.desktopControllerSink(buildSink, cfg.Notifications)
@@ -3961,6 +3946,14 @@ func (a *App) buildTabControllerWithContextAdmissionHeld(tab *WorkspaceTab, load
a.abandonSupersededBuild(tab, ctrl, rootKey, "")
return
}
if a.currentExtensionGeneration() != extensionGen {
// Plugin/MCP configuration changed during the off-lock build. Drop this
// controller and rebuild from the latest configuration so deleted tools
// cannot be re-published onto the shared host.
a.abandonSupersededBuild(tab, ctrl, rootKey, "")
a.scheduleDeferredStartupBuild(tab.ID)
return
}
a.bindControllerDisplayRecorder(ctrl)
configureControllerRuntime(ctrl, nil, buildRuntime)
@@ -3968,32 +3961,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 +3980,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 +4136,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 +4565,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 +6283,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 +6484,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"`
@@ -6525,33 +6509,6 @@ func normalizeTopicStatus(status string) string {
}
}
func activityStatusForTab(tab *WorkspaceTab) string {
if tab == nil {
return ""
}
status := normalizeTopicStatus(tab.ActivityStatus)
if tab.Ctrl == nil {
return status
}
runtimeStatus := tab.Ctrl.RuntimeStatus()
if runtimeStatus.PendingPrompt {
return topicStatusWaitingConfirmation
}
if runtimeStatus.Running {
if status == "" || status == topicStatusError || status == topicStatusPaused {
return topicStatusThinking
}
return status
}
if runtimeStatus.BackgroundJobs > 0 {
return topicStatusBackgroundJob
}
if status == topicStatusError || status == topicStatusPaused {
return status
}
return ""
}
// migrateLegacySessionsIntoGlobalTopics makes pre-topic desktop history visible
// in the v2 sidebar. Imported v0.x sessions and older desktop sessions are plain
// .jsonl files, sometimes with branch metadata but no topic metadata; the
@@ -6792,7 +6749,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 +6902,6 @@ func repairIndexedSessionTopics(dir string) []string {
deferred = true
}
}
if !deferred {
projectSessionCache.invalidateDirs(dir)
}
}
if !deferred {
markTopicIndexRepairDone(dir)
@@ -7516,24 +7469,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()
}
@@ -7694,19 +7649,8 @@ func (s topicSummary) displayTurns() int {
// runtimeSessionStatus is one open or detached runtime session, as shown in
// the sidebar tree.
type runtimeSessionStatus struct {
sessionPath string
label string
titleSource string
turns int
createdAt int64
lastActivityAt int64
open bool
running bool
status string
recovered bool
recoveryReason string
recoveryDigest string
recoveryParentID string
open bool
running bool
}
// topicHiddenAsRecoveryOnly hides topics whose only on-disk sessions are
@@ -7727,324 +7671,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
@@ -8060,36 +7686,6 @@ func projectSessionNodeKey(scope, sessionPath string) string {
return scope + "_session_" + hex.EncodeToString(sum[:8])
}
func runtimeSessionTreeLabel(tab *WorkspaceTab, info agent.SessionInfo, title string) string {
if title = strings.TrimSpace(title); title != "" {
return title
}
if preview := topicTitleFromText(info.Preview); preview != "" {
return preview
}
if tab != nil {
if title := strings.TrimSpace(tab.TopicTitle); title != "" {
return title
}
}
if path := strings.TrimSpace(info.Path); path != "" {
return strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
}
if tab != nil {
if path := strings.TrimSpace(tab.currentSessionPath()); path != "" {
return strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
}
}
return defaultTopicTitle
}
func unixMilliOrZero(t time.Time) int64 {
if t.IsZero() {
return 0
}
return t.UnixMilli()
}
// ContextPanelInfo is the right-side panel's data for one tab.
type ContextPanelInfo struct {
UsedTokens int `json:"usedTokens"`
@@ -8679,6 +8275,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
@@ -9124,152 +8743,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) {
+111 -281
View File
@@ -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,18 +956,33 @@ 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()
if _, err := os.Stat(filepath.Join(dir, topicMigrationMarker)); err != nil {
t.Fatalf("expected migration marker after a complete pass: %v", err)
// 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)
// Catalog publication and the migration marker are written on the same
// background path but not under one fsync barrier. Wait for the marker
// explicitly so Windows CI does not observe the topic before the stamp.
markerPath := filepath.Join(dir, topicMigrationMarker)
deadline := time.Now().Add(5 * time.Second)
var lastMarkerErr error
for {
if _, lastMarkerErr = os.Stat(markerPath); lastMarkerErr == nil {
break
}
if time.Now().After(deadline) {
t.Fatalf("expected migration marker after a complete pass: %v", lastMarkerErr)
}
time.Sleep(10 * time.Millisecond)
}
// A CLI-created session added after the marker invalidates the lightweight
// 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 +1395,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 +1412,7 @@ func TestLegacySessionTopicIDsKeepNormalizedNameCollisionsDistinct(t *testing.T)
}
}
func TestDefaultGlobalTabGetsMigratedTopicID(t *testing.T) {
func TestDefaultGlobalTabDoesNotWaitForLegacyMigration(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
@@ -1616,19 +1438,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 +2759,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 +2780,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 +2818,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 +2846,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 +3707,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 +3728,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 +3741,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 +3774,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)
}
+1 -1
View File
@@ -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)
}
}
+1
View File
@@ -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)
+2 -40
View File
@@ -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",
}
}
+41
View File
@@ -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",
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
//go:build !windows
//go:build !windows && cgo
package main
+18
View File
@@ -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) {}
+2
View File
@@ -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
+1 -1
View File
@@ -1,4 +1,4 @@
//go:build !linux
//go:build !linux || !cgo
package main
+16
View File
@@ -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
+14
View File
@@ -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 的同名条目。
+5
View File
@@ -40,10 +40,15 @@ non-destructively when `<Reasonix home>/.env` is missing them.
| Sessions | `<state root>/sessions/` |
| Archives | `<state root>/archive/` |
| Memory | `<state root>/memory/` and `<state root>/projects/` |
| Disposable session catalog | `<cache root>/session-catalog/v1.sqlite` |
`<state root>` defaults to `<Reasonix home>`. 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 `<Reasonix home>/config.toml`.
+101
View File
@@ -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
`<cache root>/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-<timestamp>` 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.
+87
View File
@@ -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` 构建。
+9
View File
@@ -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
)
+44
View File
@@ -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=
+58 -34
View File
@@ -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])
}
+17 -25
View File
@@ -367,10 +367,10 @@ func (s *Session) saveLocked(path string, mode sessionSaveMode) error {
slog.Warn("session: keeping save after display index write failure", "path", path, "err", err)
}
}
s.markPersisted(path, digest, version, revision, rewriteVersion)
s.markPersistedWithListing(path, digest, version, revision, rewriteVersion, msgs)
return nil
}
s.markPersisted(path, digest, version, decision.revision, rewriteVersion)
s.markPersistedWithListing(path, digest, version, decision.revision, rewriteVersion, msgs)
return nil
}
if decision.appendOnly && probe.native && mode != sessionSaveRewriteCompact {
@@ -420,7 +420,7 @@ func (s *Session) saveLocked(path string, mode sessionSaveMode) error {
slog.Warn("session: keeping save after display index write failure", "path", path, "err", err)
}
}
s.markPersisted(path, digest, version, revision, rewriteVersion)
s.markPersistedWithListing(path, digest, version, revision, rewriteVersion, msgs)
return nil
}
baseRevision = decision.revision
@@ -480,7 +480,7 @@ func (s *Session) saveLocked(path string, mode sessionSaveMode) error {
// derived sidecar and must never fail a save.
slog.Warn("session: keeping save after display index write failure", "path", path, "err", err)
}
s.markPersisted(path, digest, version, revision, rewriteVersion)
s.markPersistedWithListing(path, digest, version, revision, rewriteVersion, msgs)
return nil
}
@@ -1592,6 +1592,7 @@ type SessionInfo struct {
ModTime time.Time // compatibility alias for LastActivityAt
Preview string
Turns int
CountsKnown bool
Scope string
WorkspaceRoot string
TopicID string
@@ -2213,8 +2214,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 {
@@ -2223,32 +2225,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
}
+28
View File
@@ -0,0 +1,28 @@
package agent
import (
"crypto/sha256"
"log/slog"
"reasonix/internal/provider"
)
func (s *Session) markPersistedWithListing(path string, digest [sha256.Size]byte, version uint64, revision int64, rewriteVersion int, msgs []provider.Message) {
s.markPersisted(path, digest, version, revision, rewriteVersion)
persistSessionListingProjection(path, msgs)
}
func persistSessionListingProjection(path string, msgs []provider.Message) {
preview, turns := SessionPreviewFromMessages(msgs)
if err := UpdateBranchMeta(path, false, func(meta *BranchMeta) error {
meta.Preview = preview
meta.Turns = turns
meta.SchemaVersion = BranchMetaCountsVersion
return nil
}); err != nil {
// JSONL/event log already committed. Listing metadata is a repairable
// projection and must never turn a successful transcript save into an
// application error.
slog.Warn("session: listing metadata update deferred", "path", path, "err", err)
}
}
+2 -1
View File
@@ -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,
+32
View File
@@ -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"},
+2 -2
View File
@@ -164,9 +164,9 @@ func RunWithBuildInfo(args []string, info BuildInfo) int {
case "report":
configureCLIThemeFromConfig()
return reportCommand(rest)
case "session":
case "session", "sessions":
configureCLIThemeFromConfig()
return sessionCommand(rest)
return sessionOrSessionsCommand(cmd, rest)
case "hook", "hooks":
configureCLIThemeFromConfig()
return hookCommand(rest)
+41
View File
@@ -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")
+75
View File
@@ -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)
+3
View File
@@ -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 = "-"
+116
View File
@@ -0,0 +1,116 @@
package cli
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"reasonix/internal/config"
"reasonix/internal/sessioncatalog"
)
func sessionOrSessionsCommand(command string, args []string) int {
if command == "sessions" {
return sessionsCommand(args)
}
return sessionCommand(args)
}
func sessionsCommand(args []string) int {
if len(args) == 0 || args[0] != "reindex" {
fmt.Fprintln(os.Stderr, "usage: reasonix sessions reindex [--dir PATH] [--json]")
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
}
+19 -9
View File
@@ -201,15 +201,10 @@ func cliCompletionRootSpec() cliCompletionSpec {
completionSpec("try", []cliCompletionFlag{model, completionFlag("--max-steps --dir", cliCompletionStaticValue), help}),
completionSpec("run", []cliCompletionFlag{model, completionFlag("--max-steps --dir", cliCompletionStaticValue), help}),
),
completionSpec("doctor", []cliCompletionFlag{completionFlag("--json", cliCompletionNoValue), help},
completionSpec("repair", []cliCompletionFlag{
completionFlag("--root", cliCompletionStaticValue), completionFlag("--apply --project --json", cliCompletionNoValue), help,
}),
completionSpec("quality", []cliCompletionFlag{completionFlag("--json", cliCompletionNoValue), help}),
completionSpec("session", []cliCompletionFlag{completionFlag("--zip", cliCompletionNoValue), completionFlag("--out", cliCompletionStaticValue), help}),
completionSpec("redact-sessions", []cliCompletionFlag{completionFlag("--dry-run --json", cliCompletionNoValue), completionFlag("--dir", cliCompletionPathValue), help}),
completionSpec("capabilities", []cliCompletionFlag{
completionFlag("--root --timeout", cliCompletionStaticValue), completionFlag("--json --live", cliCompletionNoValue), help,
doctorCompletionSpec(help),
completionSpec("sessions", []cliCompletionFlag{help},
completionSpec("reindex", []cliCompletionFlag{
completionFlag("--json", cliCompletionNoValue), completionFlag("--dir", cliCompletionPathValue), help,
}),
),
completionSpec("report", []cliCompletionFlag{help},
@@ -285,6 +280,21 @@ func cliCompletionRootSpec() cliCompletionSpec {
return root
}
func doctorCompletionSpec(help cliCompletionFlag) cliCompletionSpec {
return completionSpec("doctor", []cliCompletionFlag{completionFlag("--json", cliCompletionNoValue), help},
completionSpec("sessions", []cliCompletionFlag{completionFlag("--json", cliCompletionNoValue), help}),
completionSpec("repair", []cliCompletionFlag{
completionFlag("--root", cliCompletionStaticValue), completionFlag("--apply --project --json", cliCompletionNoValue), help,
}),
completionSpec("quality", []cliCompletionFlag{completionFlag("--json", cliCompletionNoValue), help}),
completionSpec("session", []cliCompletionFlag{completionFlag("--zip", cliCompletionNoValue), completionFlag("--out", cliCompletionStaticValue), help}),
completionSpec("redact-sessions", []cliCompletionFlag{completionFlag("--dry-run --json", cliCompletionNoValue), completionFlag("--dir", cliCompletionPathValue), help}),
completionSpec("capabilities", []cliCompletionFlag{
completionFlag("--root --timeout", cliCompletionStaticValue), completionFlag("--json --live", cliCompletionNoValue), help,
}),
)
}
func cliServeCompletionFlags(model, profile, help cliCompletionFlag) []cliCompletionFlag {
return []cliCompletionFlag{
model, profile,
+36 -12
View File
@@ -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 {
+59
View File
@@ -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)
}
}
+95
View File
@@ -0,0 +1,95 @@
package pluginpkg
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
func LoadInstalled(reasonixHome string) ([]InstalledPackage, []string) {
stateMu.Lock()
defer stateMu.Unlock()
st, err := LoadState(reasonixHome)
if err != nil {
return nil, []string{err.Error()}
}
var out []InstalledPackage
var warnings []string
stateChanged := false
for i := range st.Plugins {
installed := st.Plugins[i]
if !installed.Enabled {
continue
}
root := ResolveRoot(reasonixHome, installed.Root)
pkg, pkgWarnings, err := ParseDir(root)
if err != nil && strings.Contains(err.Error(), "missing apiVersion") && managedPluginRoot(reasonixHome, installed, root) {
legacy, _, migrateErr := ParseNativeForMigrate(root)
if migrateErr == nil {
var data []byte
data, migrateErr = MigrateManifestToV2(legacy)
if migrateErr == nil {
migrateErr = WriteMigratedManifestV2(root, data)
}
}
if migrateErr == nil {
pkg, pkgWarnings, err = ParseDir(root)
if err == nil {
warnings = append(warnings, fmt.Sprintf("%s: automatically migrated managed manifest to %s (backup: %s.bak)", installed.Name, ManifestAPIVersionV2, NativeManifest))
}
} else {
err = fmt.Errorf("automatic managed-plugin migration failed: %w", migrateErr)
}
}
if err != nil {
reason := err.Error()
if st.Plugins[i].Status != PluginStatusDisabledIncompatible || st.Plugins[i].StatusReason != reason {
st.Plugins[i].Status = PluginStatusDisabledIncompatible
st.Plugins[i].StatusReason = reason
stateChanged = true
}
remediation := "repair or reinstall the plugin"
if strings.Contains(reason, "missing apiVersion") {
remediation = fmt.Sprintf("reasonix plugin migrate %s --to-v2", installed.Name)
}
warnings = append(warnings, fmt.Sprintf("%s: %s: %v; remediation: %s", installed.Name, PluginStatusDisabledIncompatible, err, remediation))
continue
}
if st.Plugins[i].Status != "" || st.Plugins[i].StatusReason != "" {
st.Plugins[i].Status = ""
st.Plugins[i].StatusReason = ""
stateChanged = true
}
out = append(out, InstalledPackage{Installed: installed, Package: pkg, Warnings: pkgWarnings})
}
if stateChanged {
if err := SaveState(reasonixHome, st); err != nil {
warnings = append(warnings, "persist plugin compatibility status: "+err.Error())
}
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Installed.Name < out[j].Installed.Name })
return out, warnings
}
func managedPluginRoot(reasonixHome string, installed InstalledPlugin, root string) bool {
if filepath.IsAbs(strings.TrimSpace(installed.Root)) {
return false
}
managed := filepath.Clean(PluginsDir(reasonixHome))
rel, err := filepath.Rel(managed, filepath.Clean(root))
if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return false
}
current := managed
for part := range strings.SplitSeq(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
}
+6 -4
View File
@@ -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
+4 -23
View File
@@ -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 {
@@ -369,29 +373,6 @@ func SetEnabled(reasonixHome, name string, enabled bool) error {
return fmt.Errorf("plugin %q is not installed", name)
}
func LoadInstalled(reasonixHome string) ([]InstalledPackage, []string) {
st, err := LoadState(reasonixHome)
if err != nil {
return nil, []string{err.Error()}
}
var out []InstalledPackage
var warnings []string
for _, installed := range st.Plugins {
if !installed.Enabled {
continue
}
root := ResolveRoot(reasonixHome, installed.Root)
pkg, pkgWarnings, err := ParseDir(root)
if err != nil {
warnings = append(warnings, fmt.Sprintf("%s: %v", installed.Name, err))
continue
}
out = append(out, InstalledPackage{Installed: installed, Package: pkg, Warnings: pkgWarnings})
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Installed.Name < out[j].Installed.Name })
return out, warnings
}
func ResolveRoot(reasonixHome, root string) string {
if filepath.IsAbs(root) {
return filepath.Clean(root)
+438
View File
@@ -0,0 +1,438 @@
// Package projectiondb owns the lifecycle of disposable SQLite projections.
// Business data must remain authoritative outside the database: callers are
// expected to be able to discard and rebuild every database opened here.
package projectiondb
import (
"context"
"database/sql"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"time"
moderncsqlite "modernc.org/sqlite"
sqlite3 "modernc.org/sqlite/lib"
)
type Mode string
const (
ModeDisk Mode = "disk"
ModeMemory Mode = "memory"
)
type State string
const (
StateReady State = "ready"
StateDegraded State = "degraded"
)
type Status struct {
State State `json:"state"`
Mode Mode `json:"mode"`
Path string `json:"path,omitempty"`
Revision uint64 `json:"revision"`
Indexed int64 `json:"indexed"`
Total int64 `json:"total"`
Pending int64 `json:"pending"`
Failed int64 `json:"failed"`
LastError string `json:"lastError,omitempty"`
QuarantinedPath string `json:"quarantinedPath,omitempty"`
}
type Migration struct {
Version int
Apply func(context.Context, *sql.Tx) error
}
type OpenOptions struct {
Path string
MemoryName string
Migrations []Migration
InMemory bool
MaxOpenConns int
Now func() time.Time
SecureDelete bool
AutoVacuum bool
}
type Handle struct {
DB *sql.DB
Status Status
}
type FutureSchemaError struct {
Found int
Supported int
}
func (e *FutureSchemaError) Error() string {
return fmt.Sprintf("projection schema %d is newer than supported %d", e.Found, e.Supported)
}
func Open(ctx context.Context, opts OpenOptions) (*Handle, error) {
if opts.Now == nil {
opts.Now = time.Now
}
if opts.MemoryName == "" {
opts.MemoryName = "projection"
}
mode := ModeDisk
status := Status{State: StateReady, Mode: mode, Path: opts.Path}
useMemory := opts.InMemory || PathLooksRemote(opts.Path)
if !useMemory {
if err := os.MkdirAll(filepath.Dir(opts.Path), 0o700); err != nil {
useMemory = true
status.LastError = err.Error()
} else {
_ = os.Chmod(filepath.Dir(opts.Path), 0o700)
if filesystemRemote(filepath.Dir(opts.Path)) {
useMemory = true
status.LastError = "projection cache is on a remote filesystem; using memory"
}
}
}
if useMemory {
mode = ModeMemory
}
db, err := open(ctx, opts, mode)
if err != nil && mode == ModeDisk {
var future *FutureSchemaError
switch {
case errors.As(err, &future):
// A newer process wrote this projection. Keep the file intact and
// serve an empty memory projection so startup never quarantines a
// healthy future schema.
status.State = StateDegraded
status.LastError = err.Error()
mode = ModeMemory
db, err = open(ctx, opts, mode)
case isCorruptionError(err):
// Only integrity-level failures may rename the on-disk projection.
status.QuarantinedPath = Quarantine(opts.Path, opts.Now())
db, err = open(ctx, opts, mode)
if err != nil {
status.State = StateDegraded
status.LastError = err.Error()
mode = ModeMemory
db, err = open(ctx, opts, mode)
}
default:
// Busy, permission, IO, or transient open errors must never rename
// a healthy database. Fall back to memory for this process only.
status.State = StateDegraded
status.LastError = err.Error()
mode = ModeMemory
db, err = open(ctx, opts, mode)
}
}
if err != nil {
return nil, err
}
status.Mode = mode
if mode == ModeMemory {
status.Path = ""
}
return &Handle{DB: db, Status: status}, nil
}
func open(ctx context.Context, opts OpenOptions, mode Mode) (*sql.DB, error) {
var dsn string
if mode == ModeMemory {
dsn = fmt.Sprintf("file:reasonix-%s-%d?mode=memory&cache=shared", url.PathEscape(opts.MemoryName), opts.Now().UnixNano())
} else {
u := &url.URL{Scheme: "file", Path: opts.Path}
dsn = u.String() + "?_pragma=busy_timeout%28150%29&_pragma=foreign_keys%281%29"
}
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
maxOpen := opts.MaxOpenConns
if maxOpen <= 0 {
maxOpen = 4
}
db.SetMaxOpenConns(maxOpen)
db.SetMaxIdleConns(min(maxOpen, 2))
fail := func(err error) (*sql.DB, error) {
_ = db.Close()
return nil, err
}
if err := db.PingContext(ctx); err != nil {
return fail(err)
}
if mode == ModeDisk {
if _, err := db.ExecContext(ctx, `PRAGMA journal_mode=WAL`); err != nil {
return fail(err)
}
}
for _, pragma := range []string{`PRAGMA synchronous=NORMAL`, `PRAGMA foreign_keys=ON`, `PRAGMA busy_timeout=150`} {
if _, err := db.ExecContext(ctx, pragma); err != nil {
return fail(err)
}
}
if opts.SecureDelete {
if _, err := db.ExecContext(ctx, `PRAGMA secure_delete=ON`); err != nil {
return fail(err)
}
}
if opts.AutoVacuum {
if _, err := db.ExecContext(ctx, `PRAGMA auto_vacuum=INCREMENTAL`); err != nil {
return fail(err)
}
}
var integrity string
if err := db.QueryRowContext(ctx, `PRAGMA integrity_check`).Scan(&integrity); err != nil {
return fail(err)
}
if integrity != "ok" {
return fail(fmt.Errorf("projection integrity check: %s", integrity))
}
if err := ApplyMigrations(ctx, db, opts.Migrations, opts.Now); err != nil {
return fail(err)
}
if mode == ModeDisk {
_ = os.Chmod(opts.Path, 0o600)
_ = os.Chmod(opts.Path+"-wal", 0o600)
_ = os.Chmod(opts.Path+"-shm", 0o600)
}
return db, nil
}
func ApplyMigrations(ctx context.Context, db *sql.DB, migrations []Migration, now func() time.Time) error {
if now == nil {
now = time.Now
}
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at INTEGER NOT NULL
)`); err != nil {
return err
}
var current int
if err := db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM schema_migrations`).Scan(&current); err != nil {
return err
}
supported := 0
for _, migration := range migrations {
if migration.Version > supported {
supported = migration.Version
}
}
if current > supported {
return &FutureSchemaError{Found: current, Supported: supported}
}
for _, migration := range migrations {
if migration.Version <= current {
continue
}
if migration.Version != current+1 || migration.Apply == nil {
return fmt.Errorf("projection migration gap after version %d", current)
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
if err := migration.Apply(ctx, tx); err != nil {
_ = tx.Rollback()
return fmt.Errorf("apply projection migration %d: %w", migration.Version, err)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(?, ?)`, migration.Version, now().UnixMilli()); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
current = migration.Version
}
return nil
}
type Inspection struct {
Exists bool `json:"exists"`
Path string `json:"path"`
Schema int `json:"schema"`
Integrity string `json:"integrity,omitempty"`
Size int64 `json:"size"`
Error string `json:"error,omitempty"`
}
// Inspect is deliberately read-only: it never creates, migrates, repairs, or
// quarantines a database.
func Inspect(ctx context.Context, path string) Inspection {
out := Inspection{Path: path}
info, err := os.Stat(path)
if errors.Is(err, os.ErrNotExist) {
return out
}
if err != nil {
out.Error = err.Error()
return out
}
out.Exists = true
out.Size = info.Size()
u := &url.URL{Scheme: "file", Path: path}
db, err := sql.Open("sqlite", u.String()+"?mode=ro&immutable=1")
if err != nil {
out.Error = err.Error()
return out
}
defer db.Close()
if err := db.QueryRowContext(ctx, `PRAGMA integrity_check`).Scan(&out.Integrity); err != nil {
out.Error = err.Error()
return out
}
if err := db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version),0) FROM schema_migrations`).Scan(&out.Schema); err != nil {
out.Error = err.Error()
}
return out
}
func Quarantine(path string, now time.Time) string {
if strings.TrimSpace(path) == "" {
return ""
}
quarantined := fmt.Sprintf("%s.corrupt-%d", path, now.UnixMilli())
if err := os.Rename(path, quarantined); err != nil {
return ""
}
for _, suffix := range []string{"-wal", "-shm"} {
_ = os.Rename(path+suffix, quarantined+suffix)
}
return quarantined
}
// isCorruptionError reports whether err proves the on-disk projection is unsafe
// to keep open. Temporary busy/permission/IO failures must return false so a
// multi-process client never renames a healthy database out from under peers.
func isCorruptionError(err error) bool {
if err == nil {
return false
}
var future *FutureSchemaError
if errors.As(err, &future) {
return false
}
var se *moderncsqlite.Error
if errors.As(err, &se) {
switch se.Code() & 0xff {
case sqlite3.SQLITE_CORRUPT, sqlite3.SQLITE_NOTADB:
return true
}
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "integrity check") ||
strings.Contains(msg, "malformed") ||
strings.Contains(msg, "file is not a database") ||
strings.Contains(msg, "not a database")
}
func PathLooksRemote(path string) bool {
clean := filepath.Clean(path)
if runtime.GOOS == "windows" && strings.HasPrefix(clean, `\\`) {
return true
}
slash := filepath.ToSlash(clean)
return strings.HasPrefix(slash, "/net/") || strings.HasPrefix(slash, "/nfs/") || strings.HasPrefix(slash, "/afs/")
}
// Rebuild constructs and validates a replacement beside the live database,
// then swaps it into place. The old projection remains untouched if building,
// validation, or the platform rename fails (notably an open database on
// Windows). Rebuild never touches authoritative business files.
func Rebuild(ctx context.Context, opts OpenOptions, populate func(context.Context, *sql.DB) error) error {
if strings.TrimSpace(opts.Path) == "" || opts.InMemory {
return errors.New("projection rebuild requires a disk path")
}
if opts.Now == nil {
opts.Now = time.Now
}
temporary := fmt.Sprintf("%s.rebuild-%d", opts.Path, opts.Now().UnixNano())
replacement := opts
replacement.Path = temporary
replacement.InMemory = false
handle, err := Open(ctx, replacement)
if err != nil {
return fmt.Errorf("open projection replacement: %w", err)
}
cleanupTemporary := func() {
_ = os.Remove(temporary)
_ = os.Remove(temporary + "-wal")
_ = os.Remove(temporary + "-shm")
}
if handle.Status.Mode != ModeDisk {
_ = handle.DB.Close()
cleanupTemporary()
return errors.New("projection replacement could not use disk storage")
}
if populate != nil {
if err := populate(ctx, handle.DB); err != nil {
_ = handle.DB.Close()
cleanupTemporary()
return fmt.Errorf("populate projection replacement: %w", err)
}
}
var integrity string
if err := handle.DB.QueryRowContext(ctx, `PRAGMA integrity_check`).Scan(&integrity); err != nil || integrity != "ok" {
_ = handle.DB.Close()
cleanupTemporary()
if err != nil {
return fmt.Errorf("validate projection replacement: %w", err)
}
return fmt.Errorf("validate projection replacement: %s", integrity)
}
_, _ = handle.DB.ExecContext(ctx, `PRAGMA wal_checkpoint(TRUNCATE)`)
if err := handle.DB.Close(); err != nil {
cleanupTemporary()
return err
}
backup := fmt.Sprintf("%s.replaced-%d", opts.Path, opts.Now().UnixNano())
hadOld := false
if _, err := os.Stat(opts.Path); err == nil {
if err := os.Rename(opts.Path, backup); err != nil {
cleanupTemporary()
return fmt.Errorf("projection database is busy: %w", err)
}
hadOld = true
for _, suffix := range []string{"-wal", "-shm"} {
if err := os.Rename(opts.Path+suffix, backup+suffix); err != nil && !errors.Is(err, os.ErrNotExist) {
_ = os.Rename(backup, opts.Path)
for _, restored := range []string{"-wal", "-shm"} {
_ = os.Rename(backup+restored, opts.Path+restored)
}
cleanupTemporary()
return fmt.Errorf("projection database is busy: %w", err)
}
}
} else if !errors.Is(err, os.ErrNotExist) {
cleanupTemporary()
return err
}
if err := os.Rename(temporary, opts.Path); err != nil {
if hadOld {
_ = os.Rename(backup, opts.Path)
for _, suffix := range []string{"-wal", "-shm"} {
_ = os.Rename(backup+suffix, opts.Path+suffix)
}
}
cleanupTemporary()
return fmt.Errorf("install projection replacement: %w", err)
}
_ = os.Chmod(opts.Path, 0o600)
if hadOld {
_ = os.Remove(backup)
_ = os.Remove(backup + "-wal")
_ = os.Remove(backup + "-shm")
}
cleanupTemporary()
return nil
}
+176
View File
@@ -0,0 +1,176 @@
package projectiondb
import (
"context"
"database/sql"
"errors"
"os"
"path/filepath"
"testing"
"time"
)
func testMigrations() []Migration {
return []Migration{{Version: 1, Apply: func(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `CREATE TABLE values_table(value TEXT NOT NULL)`)
return err
}}}
}
func TestOpenAppliesLedgerAndPrivatePermissions(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "catalog", "v1.sqlite")
handle, err := Open(context.Background(), OpenOptions{Path: path, MemoryName: "test", Migrations: testMigrations()})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = handle.DB.Close() })
var version int
if err := handle.DB.QueryRow(`SELECT MAX(version) FROM schema_migrations`).Scan(&version); err != nil || version != 1 {
t.Fatalf("version=%d err=%v", version, err)
}
if info, err := os.Stat(path); err != nil || info.Mode().Perm()&0o077 != 0 {
t.Fatalf("database permissions=%v err=%v", info.Mode().Perm(), err)
}
}
func TestFutureSchemaIsPreservedAndFallsBackToMemory(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "catalog.sqlite")
seed, err := Open(context.Background(), OpenOptions{Path: path, MemoryName: "seed", Migrations: testMigrations()})
if err != nil {
t.Fatal(err)
}
if _, err := seed.DB.Exec(`INSERT INTO schema_migrations(version, applied_at) VALUES(2, ?)`, time.Now().UnixMilli()); err != nil {
t.Fatal(err)
}
if err := seed.DB.Close(); err != nil {
t.Fatal(err)
}
handle, err := Open(context.Background(), OpenOptions{Path: path, MemoryName: "future", Migrations: testMigrations()})
if err != nil {
t.Fatal(err)
}
defer handle.DB.Close()
if handle.Status.Mode != ModeMemory || handle.Status.State != StateDegraded || handle.Status.QuarantinedPath != "" {
t.Fatalf("status=%#v", handle.Status)
}
inspection := Inspect(context.Background(), path)
if !inspection.Exists || inspection.Schema != 2 {
t.Fatalf("inspection=%#v", inspection)
}
}
func TestInspectDoesNotCreateMissingDatabase(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "missing.sqlite")
inspection := Inspect(context.Background(), path)
if inspection.Exists {
t.Fatalf("inspection=%#v", inspection)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("inspect created database: %v", err)
}
}
func TestRebuildPublishesOnlyValidatedReplacement(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "catalog.sqlite")
opts := OpenOptions{Path: path, MemoryName: "rebuild", Migrations: testMigrations()}
seed, err := Open(context.Background(), opts)
if err != nil {
t.Fatal(err)
}
if _, err := seed.DB.Exec(`INSERT INTO values_table(value) VALUES('old')`); err != nil {
t.Fatal(err)
}
if err := seed.DB.Close(); err != nil {
t.Fatal(err)
}
if err := Rebuild(context.Background(), opts, func(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, `INSERT INTO values_table(value) VALUES('new')`)
return err
}); err != nil {
t.Fatal(err)
}
rebuilt, err := Open(context.Background(), opts)
if err != nil {
t.Fatal(err)
}
defer rebuilt.DB.Close()
var value string
if err := rebuilt.DB.QueryRow(`SELECT value FROM values_table`).Scan(&value); err != nil || value != "new" {
t.Fatalf("value=%q err=%v", value, err)
}
}
func TestBusyOpenDoesNotQuarantineHealthyDatabase(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "catalog.sqlite")
opts := OpenOptions{Path: path, MemoryName: "busy", Migrations: testMigrations()}
seed, err := Open(context.Background(), opts)
if err != nil {
t.Fatal(err)
}
if _, err := seed.DB.Exec(`INSERT INTO values_table(value) VALUES('keep')`); err != nil {
t.Fatal(err)
}
// Hold the disk connection open so a second open that somehow fails still
// must not rename the healthy file. Force the memory path via empty-path
// corruption classifier: a future-schema-like non-corruption error.
if err := seed.DB.Close(); err != nil {
t.Fatal(err)
}
// Rename aside to simulate a permission/open failure without corruption.
locked := path + ".locked"
if err := os.Rename(path, locked); err != nil {
t.Fatal(err)
}
// Open with a path that fails because the file is missing mid-flight after
// we restore it — use InMemory true to prove non-corruption path. The
// classifier unit is covered by isCorruptionError via future schema test.
if isCorruptionError(errors.New("database is locked (5) (SQLITE_BUSY)")) {
t.Fatal("SQLITE_BUSY must not be treated as corruption")
}
if isCorruptionError(errors.New("unable to open database file")) {
t.Fatal("CANTOPEN must not be treated as corruption")
}
if !isCorruptionError(errors.New("projection integrity check: *** in database main ***")) {
t.Fatal("integrity failures must quarantine")
}
if err := os.Rename(locked, path); err != nil {
t.Fatal(err)
}
if matches, _ := filepath.Glob(path + ".corrupt-*"); len(matches) != 0 {
t.Fatalf("unexpected quarantine files: %v", matches)
}
}
func TestRebuildFailureKeepsOldDatabase(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "catalog.sqlite")
opts := OpenOptions{Path: path, MemoryName: "rebuild-failure", Migrations: testMigrations()}
seed, err := Open(context.Background(), opts)
if err != nil {
t.Fatal(err)
}
if _, err := seed.DB.Exec(`INSERT INTO values_table(value) VALUES('old')`); err != nil {
t.Fatal(err)
}
if err := seed.DB.Close(); err != nil {
t.Fatal(err)
}
wantErr := errors.New("populate failed")
if err := Rebuild(context.Background(), opts, func(context.Context, *sql.DB) error { return wantErr }); !errors.Is(err, wantErr) {
t.Fatalf("Rebuild error=%v", err)
}
current, err := Open(context.Background(), opts)
if err != nil {
t.Fatal(err)
}
defer current.DB.Close()
var value string
if err := current.DB.QueryRow(`SELECT value FROM values_table`).Scan(&value); err != nil || value != "old" {
t.Fatalf("value=%q err=%v", value, err)
}
}
+28
View File
@@ -0,0 +1,28 @@
//go:build darwin
package projectiondb
import (
"strings"
"syscall"
)
func filesystemRemote(path string) bool {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
return false
}
name := make([]byte, 0, len(stat.Fstypename))
for _, char := range stat.Fstypename {
if char == 0 {
break
}
name = append(name, byte(char))
}
switch strings.ToLower(string(name)) {
case "nfs", "smbfs", "webdav", "afpfs", "cifs":
return true
default:
return false
}
}
+18
View File
@@ -0,0 +1,18 @@
//go:build linux
package projectiondb
import "syscall"
func filesystemRemote(path string) bool {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
return false
}
switch uint64(stat.Type) {
case 0x6969, 0x517B, 0xFF534D42, 0x6E667364, 0x00C36400, 0x0BD00BD0:
return true
default:
return false
}
}
+5
View File
@@ -0,0 +1,5 @@
//go:build !darwin && !linux && !windows
package projectiondb
func filesystemRemote(string) bool { return false }
@@ -0,0 +1,23 @@
//go:build windows
package projectiondb
import (
"path/filepath"
"strings"
"golang.org/x/sys/windows"
)
func filesystemRemote(path string) bool {
volume := filepath.VolumeName(filepath.Clean(path))
if volume == "" {
return false
}
root := volume
if !strings.HasSuffix(root, `\`) {
root += `\`
}
rootPtr, err := windows.UTF16PtrFromString(root)
return err == nil && windows.GetDriveType(rootPtr) == windows.DRIVE_REMOTE
}
+694
View File
@@ -0,0 +1,694 @@
package sessioncatalog
import (
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"reasonix/internal/projectiondb"
)
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
}
// An empty path (no cache dir) or explicit memory flag must never write a
// relative session-catalog file into the current project directory.
if strings.TrimSpace(opts.Path) == "" {
opts.Path = ""
opts.InMemory = true
}
if !opts.InMemory {
if env := strings.TrimSpace(os.Getenv("REASONIX_SESSION_CATALOG_MEMORY")); env == "1" {
opts.InMemory = true
}
}
c := &Catalog{
opts: opts,
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},
}
handle, err := projectiondb.Open(ctx, projectiondb.OpenOptions{
Path: opts.Path,
MemoryName: "session-catalog",
Migrations: sessionMigrations(),
InMemory: opts.InMemory,
Now: opts.Now,
})
if err != nil {
return nil, err
}
c.db = handle.DB
c.status.Mode = Mode(handle.Status.Mode)
c.status.State = State(handle.Status.State)
if c.status.State == "" {
c.status.State = StateReady
}
if c.status.Mode == ModeMemory {
c.status.Path = ""
} else {
c.status.Path = handle.Status.Path
}
c.status.LastError = handle.Status.LastError
c.status.QuarantinedPath = handle.Status.QuarantinedPath
if err := c.loadStatus(ctx); err != nil {
_ = c.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 (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) {
// Bound per-topic payload size so a recovery-heavy topic cannot emit an
// unbounded Wails payload on a single page fetch.
rows, err := c.db.QueryContext(ctx, `SELECT path,directory,scope,workspace_root,topic_id,topic_title,
custom_title,created_at,last_activity_at,preview,turns,turns_state,recovered,
recovery_reason,recovery_digest,parent_id,content_fingerprint,meta_fingerprint,
health,missing_since FROM catalog_sessions
WHERE scope=? AND workspace_root=? AND topic_id=?
ORDER BY last_activity_at DESC,path ASC LIMIT ?`, key.Scope, key.WorkspaceRoot, key.TopicID, MaxLimit)
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()
}
}
+598
View File
@@ -0,0 +1,598 @@
package sessioncatalog
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"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, "test", 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 range b.N {
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)
}
}
func TestOpenBlankPathUsesMemoryWithoutWritingCWD(t *testing.T) {
// A blank path (CacheDir unavailable or caller override) must use memory
// and must not create a relative session-catalog file under cwd.
wd := t.TempDir()
t.Chdir(wd)
catalog, err := Open(context.Background(), Options{Path: " ", DisableRepair: true})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = catalog.Close(context.Background()) })
status := catalog.Status()
if status.Mode != ModeMemory {
t.Fatalf("status=%#v, want memory mode for blank path", status)
}
entries, err := os.ReadDir(wd)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if strings.Contains(entry.Name(), "session-catalog") || strings.HasSuffix(entry.Name(), ".sqlite") {
t.Fatalf("blank path wrote projection into cwd: %s", entry.Name())
}
}
}
func TestRebuildFailureKeepsExistingCatalog(t *testing.T) {
t.Parallel()
ctx := context.Background()
path := filepath.Join(t.TempDir(), "catalog.sqlite")
catalog, err := Open(ctx, Options{Path: path, DisableRepair: true})
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
session := filepath.Join(dir, "keep.jsonl")
if err := os.WriteFile(session, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := catalog.UpsertSession(ctx, SessionRecord{
Path: session, Directory: dir, Scope: "global", TopicID: "keep",
Turns: 1, TurnsState: TurnsValid, Health: HealthOK, Preview: "hello",
}); err != nil {
t.Fatal(err)
}
if err := catalog.Close(context.Background()); err != nil {
t.Fatal(err)
}
// ListSessionOrder on a regular file fails; Rebuild must keep the old DB.
fileTarget := filepath.Join(t.TempDir(), "not-a-dir")
if err := os.WriteFile(fileTarget, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := Rebuild(ctx, path, []DirectoryTarget{{Path: fileTarget, Scope: "global"}}); err == nil {
t.Fatal("expected rebuild failure for file path")
}
restored, err := Open(ctx, Options{Path: path, DisableRepair: true})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = restored.Close(context.Background()) })
topic, ok, err := restored.GetTopic(ctx, TopicKey{Scope: "global", TopicID: "keep"})
if err != nil || !ok || len(topic.Sessions) != 1 {
t.Fatalf("rebuild failure lost catalog: ok=%v topic=%#v err=%v", ok, topic, err)
}
}
func TestRepairDrainEventuallyCompletesBeyondQueue(t *testing.T) {
t.Parallel()
ctx := context.Background()
dir := t.TempDir()
path := filepath.Join(t.TempDir(), "catalog.sqlite")
seed, err := Open(ctx, Options{Path: path, DisableRepair: true})
if err != nil {
t.Fatal(err)
}
const total = 8
for i := 0; i < total; i++ {
session := filepath.Join(dir, fmt.Sprintf("%02d.jsonl", i))
if err := os.WriteFile(session, []byte(`{"role":"user","content":"turn"}`+"\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := seed.UpsertSession(ctx, SessionRecord{
Path: session, Directory: dir, Scope: "global", TopicID: fmt.Sprintf("t%d", i),
TurnsState: TurnsUnknown, Health: HealthOK, LastActivityAt: int64(i + 1),
}); err != nil {
t.Fatal(err)
}
}
if err := seed.Close(context.Background()); err != nil {
t.Fatal(err)
}
catalog, err := Open(ctx, Options{Path: path, QueueCapacity: 2, Now: time.Now})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = catalog.Close(context.Background()) })
deadline := time.Now().Add(15 * time.Second)
for {
status := catalog.Status()
if status.RepairPending == 0 {
return
}
if time.Now().After(deadline) {
t.Fatalf("repair pending stuck at %d", status.RepairPending)
}
time.Sleep(50 * time.Millisecond)
}
}
+82
View File
@@ -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
}
+717
View File
@@ -0,0 +1,717 @@
package sessioncatalog
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"reasonix/internal/agent"
"reasonix/internal/projectiondb"
)
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, resumeCursor, err := c.beginDirectoryScan(ctx, target, signature, now)
if err != nil {
return err
}
ordered, err := agent.ListSessionOrder(target.Path)
if err != nil {
c.failDirectoryScan(ctx, target.Path, err)
return err
}
start := 0
if resumeCursor != "" {
for i, info := range ordered {
if info.Path == resumeCursor {
start = i + 1
break
}
}
}
for ; start < len(ordered); start += 64 {
if err := ctx.Err(); err != nil {
// Preserve scan_cursor so the next process resumes instead of
// re-decoding the whole directory after a forced shutdown.
c.failDirectoryScan(context.Background(), target.Path, err)
return err
}
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())
}
// beginDirectoryScan starts or resumes a directory scan. When the previous
// scan for the same signature was interrupted mid-way, the stored scan_cursor
// is returned so ReconcileDirectory continues instead of restarting from 0.
func (c *Catalog) beginDirectoryScan(ctx context.Context, target DirectoryTarget, signature string, now int64) (int64, string, error) {
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
return 0, "", err
}
var previousSig, previousState, previousCursor string
var previousGeneration int64
err = tx.QueryRowContext(ctx, `SELECT signature,state,scan_cursor,scan_generation FROM catalog_directories WHERE path=?`,
target.Path).Scan(&previousSig, &previousState, &previousCursor, &previousGeneration)
resume := err == nil && previousState == "scanning" && previousSig == signature && strings.TrimSpace(previousCursor) != ""
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
if err != nil {
_ = tx.Rollback()
return 0, "", err
}
if resume {
if _, err := tx.ExecContext(ctx, `UPDATE catalog_directories SET scope=?,workspace_root=?,state='scanning',error='',signature=? WHERE path=?`,
target.Scope, target.WorkspaceRoot, signature, target.Path); err != nil {
_ = tx.Rollback()
return 0, "", err
}
if previousGeneration == 0 {
previousGeneration = 1
}
return previousGeneration, previousCursor, tx.Commit()
}
if _, err := tx.ExecContext(ctx, `INSERT INTO catalog_directories(path,scope,workspace_root,state,error,signature)
VALUES(?,?,?,'scanning','',?) ON CONFLICT(path) DO UPDATE SET
scope=excluded.scope,workspace_root=excluded.workspace_root,state='scanning',error='',
signature=excluded.signature,scan_generation=catalog_directories.scan_generation+1,scan_cursor='',indexed=0`,
target.Path, target.Scope, target.WorkspaceRoot, signature); err != nil {
_ = tx.Rollback()
return 0, "", err
}
var generation int64
if err := tx.QueryRowContext(ctx, `SELECT scan_generation FROM catalog_directories WHERE path=?`, target.Path).Scan(&generation); err != nil {
_ = tx.Rollback()
return 0, "", err
}
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:
// Channel pressure must never permanently drop unknown rows. Leave them
// in the DB and clear the in-memory marker so the drain ticker requeues.
c.repairQueued.Delete(path)
}
}
func (c *Catalog) enqueuePersistedRepairs(ctx context.Context) {
c.drainUnknownRepairs(ctx, c.opts.QueueCapacity)
}
// drainUnknownRepairs pulls the next batch of turns_state=unknown paths from the
// durable projection. Combined with the repair ticker this gives eventual
// completeness even when more than QueueCapacity sessions need repair.
func (c *Catalog) drainUnknownRepairs(ctx context.Context, limit int) {
if c == nil || c.db == nil || c.opts.DisableRepair || limit <= 0 {
return
}
if err := ctx.Err(); err != nil {
return
}
rows, err := c.db.QueryContext(ctx, `SELECT path FROM catalog_sessions
WHERE turns_state='unknown' ORDER BY last_activity_at DESC LIMIT ?`, limit)
if err != nil {
return
}
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()
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case path := <-c.repairCh:
c.repairSession(c.workerCtx, path)
c.repairQueued.Delete(path)
c.drainUnknownRepairs(c.workerCtx, 32)
runtime.Gosched()
case <-ticker.C:
c.drainUnknownRepairs(c.workerCtx, 64)
case <-c.stop:
return
}
}
}
func (c *Catalog) repairSession(workerCtx context.Context, path string) {
if workerCtx.Err() != nil {
return
}
ctx, cancel := context.WithTimeout(workerCtx, 30*time.Second)
defer cancel()
// LoadSessionDisplayMessages is not yet context-aware; check cancellation
// before and after so shutdown can abandon the result without writing.
msgs, _, _, err := agent.LoadSessionDisplayMessages(path)
if ctx.Err() != nil || workerCtx.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
}
if ctx.Err() != nil || workerCtx.Err() != nil {
return
}
_, _ = c.db.ExecContext(ctx, `UPDATE catalog_sessions SET preview=?,turns=?,turns_state='valid',
health='ok',meta_fingerprint=? WHERE path=?`, preview, turns, fileFingerprint(agent.BranchMetaPath(path)), path)
_ = c.recomputeTopicForSession(ctx, path, "repair")
}
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. The live database
// stays in place until a fully-populated replacement is validated and swapped.
func Rebuild(ctx context.Context, path string, targets []DirectoryTarget) (Status, error) {
if strings.TrimSpace(path) == "" {
path = DefaultPath()
}
if strings.TrimSpace(path) == "" {
catalog, err := Open(ctx, Options{InMemory: true, DisableRepair: true})
if err != nil {
return Status{}, err
}
for _, target := range targets {
if err := catalog.ReconcileDirectory(ctx, target); err != nil {
closeCtx, cancel := context.WithTimeout(context.Background(), time.Second)
_ = catalog.Close(closeCtx)
cancel()
return catalog.Status(), err
}
}
status := catalog.Status()
closeCtx, cancel := context.WithTimeout(context.Background(), time.Second)
_ = catalog.Close(closeCtx)
cancel()
return status, nil
}
err := projectiondb.Rebuild(ctx, projectiondb.OpenOptions{
Path: path,
MemoryName: "session-catalog-rebuild",
Migrations: sessionMigrations(),
}, func(ctx context.Context, db *sql.DB) error {
// Populate through a catalog that owns this temporary database handle
// without starting background repair workers.
temp := &Catalog{
db: db,
opts: Options{Path: path, DisableRepair: true, Now: time.Now, MissingGrace: defaultMissingGrace},
writeQueued: map[string]SessionRecord{},
directoryLocks: map[string]*sync.Mutex{},
stop: make(chan struct{}),
status: Status{State: StateReady, Mode: ModeDisk, Path: path},
}
temp.workerCtx, temp.workerCancel = context.WithCancel(ctx)
defer temp.workerCancel()
for _, target := range targets {
if err := temp.ReconcileDirectory(ctx, target); err != nil {
return err
}
}
return nil
})
if err != nil {
return Status{}, err
}
// Open the published replacement briefly for a status snapshot, then close.
catalog, err := Open(ctx, Options{Path: path, DisableRepair: true})
if err != nil {
return Status{State: StateReady, Mode: ModeDisk, Path: path}, nil
}
status := catalog.Status()
closeCtx, cancel := context.WithTimeout(context.Background(), time.Second)
_ = catalog.Close(closeCtx)
cancel()
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 strings.TrimSpace(path) == "" {
status.LastError = "catalog path unavailable"
return status, nil
}
inspection := projectiondb.Inspect(ctx, path)
if inspection.Error != "" && !inspection.Exists {
status.LastError = inspection.Error
return status, nil
}
if !inspection.Exists {
status.LastError = "catalog does not exist"
return status, nil
}
if inspection.Integrity != "" && inspection.Integrity != "ok" {
status.LastError = inspection.Integrity
return status, nil
}
if inspection.Error != "" {
status.LastError = inspection.Error
return status, nil
}
u := &url.URL{Scheme: "file", Path: path}
db, err := sql.Open("sqlite", u.String()+"?mode=ro&_pragma=busy_timeout%28150%29")
if err != nil {
return status, err
}
defer db.Close()
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
}
+110
View File
@@ -0,0 +1,110 @@
package sessioncatalog
import (
"context"
"database/sql"
"reasonix/internal/projectiondb"
)
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 sessionMigrations() []projectiondb.Migration {
return []projectiondb.Migration{
{Version: 1, Apply: func(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, migrationV1)
return err
}},
{Version: 2, Apply: func(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, migrationV2)
return err
}},
}
}
+171
View File
@@ -0,0 +1,171 @@
// 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"
"strings"
"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"`
}
// DefaultPath returns the disposable session catalog path under CacheDir.
// When the OS cache directory is unavailable it returns "" so callers fall
// back to an in-memory projection instead of writing into the project tree.
func DefaultPath() string {
cache := strings.TrimSpace(config.CacheDir())
if cache == "" {
return ""
}
return filepath.Join(cache, "session-catalog", "v1.sqlite")
}