feat(desktop): add resilient session catalog

Problem:
Desktop startup and project-tree queries could synchronously migrate and decode legacy session JSONL, blocking navigation, creation, and shutdown.

Root cause:
Session discovery, metadata repair, controller admission, and plugin startup shared UI-critical paths and broad lifecycle locks.

Fix:
- add a disposable SQLite session catalog with incremental reconciliation, repair checkpoints, corruption recovery, and memory fallback
- expose revisioned cursor pagination and migrate the desktop project tree to lazy loading
- move controller construction outside lifecycle admission and isolate incompatible plugin startup
- add session doctor and reindex commands while preserving authoritative JSONL and metadata
- retain CGO-free builds with platform-specific fallbacks

Verification:
- go test ./...
- cd desktop && go test ./...
- go test -race ./internal/sessioncatalog ./internal/agent
- cd desktop && go test -race .
- go vet ./... in root and desktop
- cd desktop/frontend && pnpm test:all && pnpm build
- CGO_ENABLED=0 cross-builds for supported macOS, Windows, and Linux targets
This commit is contained in:
SivanCola committed 2026-08-10 17:16:26 +08:00
1 parent b8a37d4ba2
commit bf19574d3a
65 files changed
+4869 -1058

No files matched your search

+49 -28
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.
@@ -564,6 +576,7 @@ func (a *App) Platform() string {
// off the initialization in a background goroutine so the webview loads immediately.
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
a.shuttingDown.Store(false)
a.startWindowsWebView2StartupFallback(ctx)
if a.remoteWindowTicket != "" {
// Remote web window child: no local tabs, tray, heartbeat, providers,
@@ -597,6 +610,7 @@ func (a *App) startup(ctx context.Context) {
a.tabsRestored = make(chan struct{})
a.mu.Unlock()
go a.restoreOrBuildTabs()
a.startSessionCatalog(false)
a.goSafe("refreshBotRuntime", a.refreshBotRuntime)
a.goSafe("sendStartupPing", a.sendStartupPing)
a.goSafe("flushMetrics", a.flushMetrics)
@@ -961,6 +975,12 @@ func (a *App) shutdown(context.Context) {
// Remote web window child: nothing to snapshot or stop locally.
return
}
// Freeze publication first, then cancel work that is deliberately outside
// the lifecycle barrier. A history scan, catalog repair, or plugin handshake
// must never make normal quit wait for its I/O.
a.shuttingDown.Store(true)
a.cancelAllTabBuilds()
a.stopSessionCatalog(250 * time.Millisecond)
if a.workspaceHub != nil {
a.workspaceHub.close()
}
@@ -1201,6 +1221,14 @@ func (a *App) beginTabTurn(tabID string, reclaim bool, submissionID ...string) (
if err := a.workspaceRuntimeAdmissionErr(tab, ctrl); err != nil {
return nil, nil, a.workspaceNotReadyErr(tab)
}
// A stale workspace binding may require a full controller/plugin rebuild.
// Complete that cancellable work before entering the lifecycle barrier so
// shutdown and runtime mutations never wait behind filesystem or handshake
// I/O. The admission re-check below protects the short publication-to-turn
// boundary.
if err := a.ensureTabControllerWorkspace(tab); err != nil {
return nil, nil, err
}
// Runtime work-admission barrier: held (shared) from here until the turn is
// observably running and the returned admission token releases it, so an MCP
// MCP authorization or plugin uninstall holding the write side either waits out
@@ -1224,10 +1252,6 @@ func (a *App) beginTabTurn(tabID string, reclaim bool, submissionID ...string) (
abort()
return nil, nil, err
}
if err := a.ensureTabControllerWorkspaceAdmissionHeld(tab); err != nil {
abort()
return nil, nil, err
}
ctrl = a.controllerForTab(tab)
if err := a.workspaceRuntimeAdmissionErr(tab, ctrl); err != nil {
abort()
@@ -1639,16 +1663,6 @@ func (a *App) reconciledSessionPathForTab(tab *WorkspaceTab) string {
}
func (a *App) ensureTabControllerWorkspace(tab *WorkspaceTab) error {
a.runtimeAdmissionMu.RLock()
defer a.runtimeAdmissionMu.RUnlock()
return a.ensureTabControllerWorkspaceAdmissionHeld(tab)
}
// ensureTabControllerWorkspaceAdmissionHeld repairs a stale controller binding
// while the caller holds either side of runtimeAdmissionMu. The repair may build
// a controller synchronously, so it must not recursively acquire the read side:
// sync.RWMutex blocks new readers once a writer is queued.
func (a *App) ensureTabControllerWorkspaceAdmissionHeld(tab *WorkspaceTab) error {
if tab == nil {
return nil
}
@@ -1724,7 +1738,7 @@ func (a *App) ensureTabControllerWorkspaceAdmissionHeld(tab *WorkspaceTab) error
a.releaseSharedHost(hostKey)
}
a.buildTabControllerAdmissionHeld(tab)
a.buildTabController(tab)
if tab.Ctrl == nil {
if tab.StartupErr != "" {
return fmt.Errorf("workspace failed to restart with corrected root: %s", tab.StartupErr)
@@ -2272,7 +2286,7 @@ func (a *App) ensureTabTopicIndexedForUserTurn(tab *WorkspaceTab) {
_ = setTopicCreatedAt(topicTitleRoot(scope, workspaceRoot), topicID, time.Now().UnixMilli())
path := a.currentSessionPathFor(tab)
a.persistTabSessionPath(tab, path)
a.emitProjectTreeChangedForSessionDirs(sessionListCacheDirForPath(path))
a.emitProjectTreeChangedForSessionDirs(sessionDirectoryForPath(path))
}
func messagesHaveConversationContent(messages []provider.Message) bool {
@@ -2989,7 +3003,7 @@ func (a *App) ForkForTab(tabID string, turn int) (TabMeta, error) {
meta := a.tabMeta(tab, activateFork)
a.mu.Unlock()
a.emitProjectTreeChangedForSessionDirs(sessionListCacheDirForPath(newPath))
a.emitProjectTreeChangedForSessionDirs(sessionDirectoryForPath(newPath))
a.startTabControllerBuild(tab)
return meta, nil
}
@@ -3032,6 +3046,7 @@ type SessionMeta struct {
Preview string `json:"preview"` // first user message
Title string `json:"title,omitempty"` // user-chosen name, when set (overrides preview)
Turns int `json:"turns"`
TurnsState string `json:"turnsState"`
CreatedAt int64 `json:"createdAt"` // unix milliseconds
LastActivityAt int64 `json:"lastActivityAt"` // unix milliseconds
ModTime int64 `json:"modTime"` // compatibility alias for lastActivityAt
@@ -3222,11 +3237,16 @@ func (a *App) sessionDirForPath(path string) (string, string, error) {
}
func sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64, parentDir string) SessionMeta {
turnsState := "unknown"
if s.CountsKnown {
turnsState = "valid"
}
return SessionMeta{
Path: s.Path,
Preview: s.Preview,
Title: title,
Turns: s.Turns,
TurnsState: turnsState,
CreatedAt: s.CreatedAt.UnixMilli(),
LastActivityAt: s.LastActivityAt.UnixMilli(),
ModTime: s.LastActivityAt.UnixMilli(),
@@ -3413,6 +3433,7 @@ func (a *App) deleteSession(path string, requireRedundantRecovery bool) error {
return err
}
}
a.removeSessionCatalogPath(sessionPath, "session_deleted")
a.emitProjectTreeChangedForSessionDirs(dir)
a.invalidatePromptHistoryCache()
return nil
@@ -3814,6 +3835,7 @@ func (a *App) restoreSession(path string) error {
if err := restoreSessionTopicIndex(dir, target); err != nil {
return err
}
a.requestSessionCatalogPath("", "", target)
a.emitProjectTreeChangedForSessionDirs(dir)
a.invalidatePromptHistoryCache()
return nil
@@ -3901,6 +3923,7 @@ func (a *App) RenameSession(path, title string) error {
if err := setSessionTitle(dir, sessionPath, title); err != nil {
return err
}
a.requestSessionCatalogPath("", "", sessionPath)
a.invalidatePromptHistoryCache()
a.emitProjectTreeChangedForSessionDirs(dir)
return nil
@@ -4081,11 +4104,10 @@ func (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string
a.runtimeRebuildMu.Lock()
defer a.runtimeRebuildMu.Unlock()
// Fence an in-flight startup before waiting for the admission barrier. The
// startup build holds the barrier's read side for its whole build; cancelling
// its generation first lets it retire instead of making this writer wait on
// a build that still believes it can publish. App.mu is released before the
// barrier acquisition, so no inverted lock nesting is introduced.
// Fence an in-flight startup before waiting for the admission barrier. Startup
// work now runs outside that barrier and will discard itself at its short
// publication check once this generation is superseded. App.mu is released
// before barrier acquisition, so no inverted lock nesting is introduced.
a.mu.Lock()
if tab.removed || a.tabs[tab.ID] != tab {
a.mu.Unlock()
@@ -5324,7 +5346,6 @@ func (a *App) RemoveWorkspace(dir string) error {
clearWorkspace()
}
}
projectSessionCache.forgetDirs(desktopSessionDir(dir))
a.emitProjectTreeMetadataChanged()
return nil
}
+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:
@@ -80,7 +80,7 @@ for (const path of localeChunks) {
const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS]
.reduce((total, path) => total + statSync(path).size, 0);
// Native Web Animations and frame-batched scrolling avoid an eager animation
// runtime. Preserve the resulting startup headroom instead of letting the shell
// drift back to the previous 2.27 MiB / 295 KiB-gzip edge.
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, 2_200 * 1024);
// runtime. The 4 KiB catalog allowance covers the project shell's cursor,
// revision, and progress state; keep the much tighter gzip gate unchanged.
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, 2_204 * 1024);
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024);
+7 -28
View File
@@ -105,7 +105,6 @@ import {
type DesktopStartupSettingsView,
type Mode,
modeHasPlan,
type ProjectNode,
type RewindResultView,
type RemoteHostView,
type SessionMeta,
@@ -884,30 +883,6 @@ function SidebarImConnectionDetail({ connection, onClose, onOpenSession, onOpenS
);
}
function activeTopicTurnsFromTree(tree: ProjectNode[], tab?: TabMeta): number | undefined {
if (!tab?.topicId) return undefined;
const targetScope = tab.scope === "global" ? "global" : "project";
const walk = (nodes: ProjectNode[]): number | undefined => {
for (const node of nodes) {
if (!node) continue;
if (node.kind === "topic" || node.kind === "global_topic") {
const scope = node.kind === "global_topic" ? "global" : "project";
if (
scope === targetScope &&
node.topicId === tab.topicId &&
(scope === "global" || node.root === tab.workspaceRoot)
) {
return node.turns;
}
}
const found = walk(asArray(node.children));
if (found !== undefined) return found;
}
return undefined;
};
return walk(tree);
}
function normalizeDesktopPlatform(value: string): DesktopPlatform {
if (value === "darwin" || value === "windows") return value;
return "linux";
@@ -1720,9 +1695,13 @@ export default function App() {
cancelled = true;
};
}
void app.ListProjectTree()
.then((tree) => {
if (!cancelled) setActiveTopicTurns(activeTopicTurnsFromTree(asArray(tree), activeTab));
void app.GetTopicSummary({
scope: activeTab.scope === "global" ? "global" : "project",
workspaceRoot: activeTab.scope === "global" ? "" : activeTab.workspaceRoot,
topicId: activeTab.topicId,
})
.then((topic) => {
if (!cancelled) setActiveTopicTurns(topic.turns);
})
.catch(() => {
if (!cancelled) setActiveTopicTurns(undefined);
@@ -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);
+17 -6
View File
@@ -3172,6 +3172,7 @@ export function Composer({
title: session.title || session.topicTitle || session.preview || "Untitled",
preview: session.preview,
turns: session.turns,
turnsState: session.turnsState,
createdAt: session.createdAt,
lastActivityAt: session.lastActivityAt,
},
@@ -4121,18 +4122,20 @@ export function Composer({
// PR-C2: hover preview uses only the SessionMeta fields we
// already have on hand — no extra PreviewSession call, no
// backend round-trip, no read of the full transcript.
const turns = typeof session.turns === "number";
const turnsKnown = session.turnsState !== "unknown" && typeof session.turns === "number";
const turnsPending = session.turnsState === "unknown";
const ts = session.lastActivityAt || session.modTime || session.createdAt;
const preview = truncatePreview(session.preview);
const pathText = session.workspaceRoot || session.path;
const tooltipLabel =
turns || ts || preview || pathText ? (
turnsKnown || turnsPending || ts || preview || pathText ? (
<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) && (
{(turnsKnown || turnsPending || ts) && (
<div className="past-chat-hover__meta">
{turns && <span>{t("composer.sessionTurns", { n: session.turns })}</span>}
{turnsPending && <span>{t("history.indexing")}</span>}
{turnsKnown && <span>{t("composer.sessionTurns", { n: session.turns })}</span>}
{ts && <span>· {fmtSessionTime(ts)}</span>}
</div>
)}
@@ -4152,7 +4155,11 @@ 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 })})` : ""}
{turnsPending
? ` (${t("history.indexing")})`
: turnsKnown
? ` (${t("composer.sessionTurns", { n: session.turns })})`
: ""}
</span>
</button>
</Tooltip>
@@ -4317,7 +4324,11 @@ export function Composer({
<MessageSquare size={15} />
<span>
{ref.title}
{typeof ref.turns === "number" ? ` (${t("composer.sessionTurns", { n: ref.turns })})` : ""}
{ref.turnsState === "unknown"
? ` (${t("history.indexing")})`
: typeof ref.turns === "number"
? ` (${t("composer.sessionTurns", { n: ref.turns })})`
: ""}
</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[] {
+135 -8
View File
@@ -8,8 +8,8 @@ import type { CSSProperties, DragEvent as ReactDragEvent, KeyboardEvent as React
import { Archive, ArrowDown, Pencil, Plus, Folder, FolderPlus, Search, BriefcaseBusiness, Copy, FolderOpen, XCircle, Check, ListCollapse, ListRestart, MessageSquare, Clock, Pin, MoreHorizontal, Minimize2, Maximize2, GitBranch } from "lucide-react";
import { asArray } from "../lib/array";
import { useToast } from "../lib/toast";
import { app } from "../lib/bridge";
import type { ProjectNode, ProjectTopicStatus } from "../lib/types";
import { app, onProjectTreeChangedV2 } from "../lib/bridge";
import type { ProjectNode, ProjectTopicStatus, SessionCatalogStatus } from "../lib/types";
import { topicActivityTime } from "../lib/session";
import { getLocale, useT, type DictKey, type Translator } from "../lib/i18n";
import { PROJECT_COLOR_OPTIONS, projectColorValue } from "../lib/projectColors";
@@ -64,6 +64,32 @@ function isTopicNode(node: ProjectNode): boolean {
return node.kind === "topic" || node.kind === "global_topic";
}
export function projectTreeRevisionIsFresh(currentRevision: number, incomingRevision: number): boolean {
return incomingRevision >= currentRevision;
}
export function mergeProjectTopicPage(current: ProjectNode[], incoming: ProjectNode[], append: boolean): ProjectNode[] {
if (!append) return [...incoming];
const next = [...current];
const positions = new Map(next.map((node, index) => [node.key, index]));
for (const node of incoming) {
const index = positions.get(node.key);
if (index === undefined) {
positions.set(node.key, next.length);
next.push(node);
} else {
next[index] = node;
}
}
return next;
}
export function projectTreeEventAffectsFolder(project: ProjectNode, roots: string[]): boolean {
if (roots.length === 0) return true;
const root = project.kind === "global_folder" ? "" : project.root ?? "";
return roots.includes(root);
}
export type ProjectTreeTopicOpenRequest = {
scope: "global" | "project";
workspaceRoot: string;
@@ -134,7 +160,8 @@ function topicIsActive(node: ProjectNode, activeScope?: string, activeWorkspaceR
export function projectTreeTopicMetaLine(node: ProjectNode, t: Translator, compact = false): string {
const parts: string[] = [];
const turns = node.turns ?? 0;
if (turns > 0) parts.push(t(turns === 1 ? "history.turnOne" : "history.turnOther", { n: turns }));
if (node.turnsState === "unknown") parts.push(t("history.indexing"));
else if (turns > 0) parts.push(t(turns === 1 ? "history.turnOne" : "history.turnOther", { n: turns }));
const activityAt = node.lastActivityAt || node.createdAt || 0;
if (activityAt) parts.push(topicActivityLabel(activityAt, t, compact));
if (parts.length === 0) parts.push(t("projectTree.previously"));
@@ -617,6 +644,20 @@ export function ProjectTree({
const compactTopics = variant === "workbench";
const creationTopics = variant === "creation";
const [tree, setTree] = useState<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 +736,61 @@ export function ProjectTree({
});
}, []);
// Keyed by the data, not the selection: a switch cannot change the tree.
const loadProjectTopics = useCallback(async (project: ProjectNode, append = false) => {
if (project.kind !== "project" && project.kind !== "global_folder") return;
const key = project.key;
const pageState = topicPageStateRef.current[key];
if (pageState?.loading) return;
const cursor = append ? pageState?.nextCursor ?? "" : "";
if (append && !cursor) return;
updateTopicPageState(key, { ...pageState, loading: true });
try {
const page = await app.ListProjectTopics({
scope: project.kind === "global_folder" ? "global" : "project",
workspaceRoot: project.kind === "global_folder" ? "" : project.root ?? "",
cursor,
limit: timeFilter === "10" ? 10 : timeFilter === "20" ? 20 : 50,
query: query.trim(),
timeFilter: timeFilter === "10" || timeFilter === "20" || timeFilter === "all" ? "" : timeFilter,
});
if (!projectTreeRevisionIsFresh(latestRevisionRef.current, page.revision)) {
updateTopicPageState(key, { ...topicPageStateRef.current[key], loading: false });
return;
}
latestRevisionRef.current = Math.max(latestRevisionRef.current, page.revision);
const items = asArray(page.items);
setTree((current) => current.map((node) => {
if (node.key !== key) return node;
return { ...node, children: mergeProjectTopicPage(asArray(node.children), items, append) };
}));
updateTopicPageState(key, { nextCursor: page.nextCursor, loading: false });
} catch {
updateTopicPageState(key, { ...topicPageStateRef.current[key], loading: false });
}
}, [query, timeFilter, updateTopicPageState]);
// Snapshot is intentionally shells-only. Preserve already loaded pages by
// project key so a metadata refresh does not collapse or blank the sidebar.
const refresh = useCallback(async () => {
try {
setTree(asArray(await app.ListProjectTree()));
const snapshot = await app.GetProjectTreeSnapshot();
if (!projectTreeRevisionIsFresh(latestRevisionRef.current, snapshot.revision ?? 0)) return;
const projects = asArray(snapshot.projects);
latestRevisionRef.current = Math.max(latestRevisionRef.current, snapshot.revision ?? 0);
setCatalogStatus(snapshot.catalog);
setTree((current) => projects.map((project) => {
const previous = current.find((node) => node.key === project.key);
return { ...project, children: asArray(previous?.children) };
}));
} catch {
/* bridge unavailable */
}
}, []);
useEffect(() => {
treeRef.current = tree;
}, [tree]);
useEffect(() => {
manuallyCollapsedRef.current = manuallyCollapsed;
}, [manuallyCollapsed]);
@@ -719,6 +806,26 @@ export function ProjectTree({
void refresh();
}, [refresh, refreshSignal]);
useEffect(() => onProjectTreeChangedV2((event) => {
if (event.revision <= latestRevisionRef.current) return;
latestRevisionRef.current = event.revision;
void app.GetSessionCatalogStatus().then(setCatalogStatus).catch(() => {});
const affected = asArray(event.roots);
for (const project of treeRef.current) {
const key = projectNodeKey(project, 0);
if (!expanded.has(key)) continue;
if (projectTreeEventAffectsFolder(project, affected)) void loadProjectTopics(project);
}
}), [expanded, loadProjectTopics]);
useEffect(() => {
const filtering = query.trim() !== "" || timeFilter !== "all";
for (const project of treeRef.current) {
const key = projectNodeKey(project, 0);
if (filtering || expanded.has(key)) void loadProjectTopics(project);
}
}, [expanded, loadProjectTopics, query, timeFilter]);
// Following the active topic is a view concern over the tree already held.
useEffect(() => {
const collapsed = manuallyCollapsedRef.current;
@@ -852,7 +959,7 @@ export function ProjectTree({
items[next]?.focus();
};
const toggleExpand = (key: string) => {
const toggleExpand = (key: string, project?: ProjectNode) => {
const willCollapse = expanded.has(key);
setExpanded((prev) => {
const next = new Set(prev);
@@ -866,6 +973,7 @@ export function ProjectTree({
else next.delete(key);
return next;
});
if (!willCollapse && project) void loadProjectTopics(project);
};
const folderKeys = useMemo(() => collapsibleFolderKeys(tree), [tree]);
@@ -1291,7 +1399,9 @@ export function ProjectTree({
const children = asArray(node.children);
const isExpanded = query.trim() ? true : expanded.has(key);
const hasChildren = children.length > 0;
const folderDisclosure = projectTreeFolderDisclosure(hasChildren, isExpanded, classicTopics);
// Snapshot rows are shells with no children until the first page is loaded,
// so every project folder must remain expandable while indexing.
const folderDisclosure = projectTreeFolderDisclosure(hasChildren, isExpanded, true);
if (isTopicNode(node) || isRuntimeSessionNode(node)) {
const isSessionNode = isRuntimeSessionNode(node);
@@ -1784,6 +1894,7 @@ export function ProjectTree({
? classicTopicWindow(children, folderShowAll)
: { visible: children, hiddenCount: 0 };
const windowToggleVisible = classicTruncationActive && (hiddenCount > 0 || (folderShowAll && children.length > CLASSIC_TOPIC_PREVIEW_LIMIT));
const backendPage = topicPageState[key];
const renderFolderChildren = () => {
if (!hasChildren) {
if (!classicTopics) return null;
@@ -1811,6 +1922,17 @@ export function ProjectTree({
{hiddenCount > 0 ? t("projectTree.showMoreTopics", { n: hiddenCount }) : t("projectTree.showFewerTopics")}
</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 +1983,7 @@ export function ProjectTree({
className="project-tree__folder-main"
style={{ paddingLeft: 8 + depth * 16 }}
onClick={() => {
if (folderDisclosure.canExpand) toggleExpand(key);
if (folderDisclosure.canExpand) toggleExpand(key, node);
}}
onKeyDown={(event) => {
if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
@@ -2317,6 +2439,11 @@ export function ProjectTree({
/>
</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")}
+56
View File
@@ -9,6 +9,7 @@ import type * as GeneratedApp from "../../wailsjs/go/main/App";
import type { InvocationRequest } from "./invocationDisplay";
import { addBreadcrumb } from "./breadcrumbs";
import { asArray } from "./array";
import { maybeShare } from "./queryCoalesce";
import { t } from "./i18n";
import { providerIsConfigured, providerRequiresKey, removeProviderAccessesForMock } from "./providerModels";
@@ -86,6 +87,12 @@ import type {
PluginInstallOptions,
PluginView,
ProjectNode,
ProjectTreeSnapshot,
ProjectTopicPageRequest,
ProjectTopicPage,
ProjectTopicKey,
SessionCatalogStatus,
ProjectTreeChangedV2,
PromptHistoryEntry,
PromptHistoryResult,
ProviderModelCatalogUpdate,
@@ -569,6 +576,11 @@ export interface AppBindings {
CloseTerminalForTab(tabID: string, sessionID: string): Promise<void>;
RenameTerminalForTab(tabID: string, sessionID: string, title: string): Promise<void>;
ListProjectTree(): Promise<ProjectNode[]>;
GetProjectTreeSnapshot(): Promise<ProjectTreeSnapshot>;
ListProjectTopics(req: ProjectTopicPageRequest): Promise<ProjectTopicPage>;
GetTopicSummary(key: ProjectTopicKey): Promise<ProjectNode>;
GetSessionCatalogStatus(): Promise<SessionCatalogStatus>;
RebuildSessionCatalog(): Promise<void>;
RenameProject(workspaceRoot: string, title: string): Promise<void>;
SetProjectColor(workspaceRoot: string, color: string): Promise<void>;
SetProjectPinned(workspaceRoot: string, pinned: boolean): Promise<void>;
@@ -886,6 +898,21 @@ export function onProjectTreeChanged(cb: () => void): () => void {
return () => {};
}
export function onProjectTreeChangedV2(cb: (event: ProjectTreeChangedV2) => void): () => void {
if (realApp() && typeof window !== "undefined" && window.runtime) {
return window.runtime.EventsOn("project-tree:changed-v2", (payload?: unknown) => {
if (!payload || typeof payload !== "object") return;
const event = payload as Partial<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 () => {};
}
// onTopicActivation subscribes to the "topic:activation" channel carrying the
// lifecycle of ticketed StartTopicActivation requests (starting/ready/failed/
// cancelled). Returns an unsubscribe.
@@ -5146,6 +5173,35 @@ function makeMockApp(): AppBindings {
async ListProjectTree() {
return cloneProjectTree();
},
async GetProjectTreeSnapshot() {
return {
revision: 1,
projects: cloneProjectTree().map((project) => ({ ...project, children: [] })),
catalog: { state: "ready", mode: "memory", revision: 1, indexed: 4, total: 4, repairPending: 0 },
indexed: 4,
total: 4,
indexingDone: true,
};
},
async ListProjectTopics(req: ProjectTopicPageRequest) {
const folder = req.scope === "global"
? cloneProjectTree().find((item) => item.kind === "global_folder")
: cloneProjectTree().find((item) => item.kind === "project" && item.root === req.workspaceRoot);
const query = (req.query ?? "").trim().toLocaleLowerCase();
const all = asArray(folder?.children).filter((item) => !query || item.label.toLocaleLowerCase().includes(query));
const start = Math.max(0, Number.parseInt(req.cursor ?? "0", 10) || 0);
const limit = Math.min(200, Math.max(1, req.limit ?? 50));
const items = all.slice(start, start + limit);
return { items, nextCursor: start + items.length < all.length ? String(start + items.length) : undefined, revision: 1 };
},
async GetTopicSummary(key: ProjectTopicKey) {
const page = await this.ListProjectTopics({ scope: key.scope, workspaceRoot: key.workspaceRoot, limit: 200 });
return page.items.find((item) => item.topicId === key.topicId) ?? { key: "", kind: key.scope === "global" ? "global_topic" : "topic", label: "", children: [] };
},
async GetSessionCatalogStatus() {
return { state: "ready", mode: "memory", revision: 1, indexed: 4, total: 4, repairPending: 0 };
},
async RebuildSessionCatalog() {},
async RenameProject(workspaceRoot: string, title: string) {
const node = workspaceRoot
? mockProjectTree.find((item) => item.root === workspaceRoot)
+51
View File
@@ -441,6 +441,8 @@ export interface ProjectNode {
sessionPath?: string;
projectColor?: string;
turns?: number;
turnsState?: "unknown" | "valid" | "corrupt" | string;
health?: "ok" | "missing" | "corrupt" | "degraded" | string;
createdAt?: number;
lastActivityAt?: number;
open?: boolean;
@@ -455,6 +457,53 @@ export interface ProjectNode {
children?: ProjectNode[];
}
export interface SessionCatalogStatus {
state: "opening" | "ready" | "degraded" | "rebuilding" | "closed" | string;
mode?: "disk" | "memory" | string;
revision: number;
indexed: number;
total: number;
repairPending: number;
lastError?: string;
quarantinedPath?: string;
}
export interface ProjectTreeSnapshot {
revision: number;
projects: ProjectNode[];
catalog: SessionCatalogStatus;
indexed: number;
total: number;
indexingDone: boolean;
}
export interface ProjectTopicPageRequest {
scope: "global" | "project" | string;
workspaceRoot?: string;
cursor?: string;
limit?: number;
query?: string;
timeFilter?: string;
}
export interface ProjectTopicPage {
items: ProjectNode[];
nextCursor?: string;
revision: number;
}
export interface ProjectTopicKey {
scope: "global" | "project" | string;
workspaceRoot?: string;
topicId: string;
}
export interface ProjectTreeChangedV2 {
revision: number;
roots: string[];
reason: string;
}
export interface DeliveryWorktreeAvailability {
available: boolean;
reason?: string;
@@ -771,6 +820,7 @@ export interface SessionMeta {
preview: string;
title?: string; // user-chosen name; falls back to preview when empty
turns: number;
turnsState?: "unknown" | "valid" | "corrupt" | string;
createdAt: number; // unix milliseconds
lastActivityAt: number; // unix milliseconds
modTime: number; // compatibility alias for lastActivityAt
@@ -799,6 +849,7 @@ export interface SessionReference {
title: string;
preview?: string;
turns?: number;
turnsState?: "unknown" | "valid" | "corrupt" | string;
createdAt?: number;
lastActivityAt?: number;
}
+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
@@ -24670,6 +24670,13 @@ body > .mermaid-diagram--fullscreen {
font-size: 13px;
}
.project-tree__catalog-progress {
padding: 4px 10px 6px;
color: var(--fg-muted);
font-size: 11px;
line-height: 1.35;
}
.project-tree__empty--subtle {
padding: 8px 12px;
font-size: 12px;
+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() {}
+902
View File
@@ -0,0 +1,902 @@
package main
import (
"context"
"errors"
"log/slog"
"path/filepath"
"sort"
"strings"
"time"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/sessioncatalog"
)
type SessionCatalogStatus struct {
State string `json:"state"`
Mode string `json:"mode"`
Revision uint64 `json:"revision"`
Indexed int64 `json:"indexed"`
Total int64 `json:"total"`
RepairPending int64 `json:"repairPending"`
LastError string `json:"lastError,omitempty"`
QuarantinedPath string `json:"quarantinedPath,omitempty"`
}
type ProjectTreeSnapshot struct {
Revision uint64 `json:"revision"`
Projects []ProjectNode `json:"projects"`
Catalog SessionCatalogStatus `json:"catalog"`
Indexed int64 `json:"indexed"`
Total int64 `json:"total"`
IndexingDone bool `json:"indexingDone"`
}
type ProjectTopicPageRequest struct {
Scope string `json:"scope"`
WorkspaceRoot string `json:"workspaceRoot,omitempty"`
Cursor string `json:"cursor,omitempty"`
Limit int `json:"limit,omitempty"`
Query string `json:"query,omitempty"`
TimeFilter string `json:"timeFilter,omitempty"`
}
type ProjectTopicKey struct {
Scope string `json:"scope"`
WorkspaceRoot string `json:"workspaceRoot,omitempty"`
TopicID string `json:"topicId"`
}
type ProjectTopicPage struct {
Items []ProjectNode `json:"items"`
NextCursor string `json:"nextCursor,omitempty"`
Revision uint64 `json:"revision"`
}
type ProjectTreeChangedV2 struct {
Revision uint64 `json:"revision"`
Roots []string `json:"roots"`
Reason string `json:"reason"`
}
func sessionCatalogStatus(status sessioncatalog.Status) SessionCatalogStatus {
return SessionCatalogStatus{
State: string(status.State),
Mode: string(status.Mode),
Revision: status.Revision,
Indexed: status.Indexed,
Total: status.Total,
RepairPending: status.RepairPending,
LastError: status.LastError,
QuarantinedPath: status.QuarantinedPath,
}
}
func (a *App) currentSessionCatalogStatus() SessionCatalogStatus {
if a == nil {
return SessionCatalogStatus{State: string(sessioncatalog.StateDegraded), Mode: string(sessioncatalog.ModeMemory)}
}
if a.catalogRebuilding.Load() {
return SessionCatalogStatus{State: string(sessioncatalog.StateRebuilding)}
}
if catalog := a.sessionCatalog.Load(); catalog != nil {
return sessionCatalogStatus(catalog.Status())
}
return SessionCatalogStatus{State: string(sessioncatalog.StateOpening)}
}
func (a *App) startSessionCatalog(rebuild bool) {
if a == nil || a.shuttingDown.Load() {
return
}
a.catalogLifecycleMu.Lock()
if a.catalogCancel != nil {
a.catalogLifecycleMu.Unlock()
return
}
ctx, cancel := context.WithCancel(a.bootContext())
done := make(chan struct{})
a.catalogCancel = cancel
a.catalogDone = done
a.catalogRebuilding.Store(rebuild)
a.catalogLifecycleMu.Unlock()
go func() {
defer close(done)
defer a.catalogRebuilding.Store(false)
path := sessioncatalog.DefaultPath()
targets := a.sessionCatalogTargets()
if rebuild {
if _, err := sessioncatalog.Rebuild(ctx, path, targets); err != nil && !errors.Is(err, context.Canceled) {
slog.Warn("desktop: rebuild session catalog", "err", err)
}
}
catalog, err := sessioncatalog.Open(ctx, sessioncatalog.Options{
Path: path,
OnRevision: func(revision uint64, roots []string, reason string) {
a.emitProjectTreeChangedV2(revision, roots, reason)
},
})
if err != nil {
slog.Warn("desktop: open session catalog", "err", err)
return
}
if ctx.Err() != nil || a.shuttingDown.Load() {
closeCtx, closeCancel := context.WithTimeout(context.Background(), 250*time.Millisecond)
_ = catalog.Close(closeCtx)
closeCancel()
return
}
a.sessionCatalog.Store(catalog)
if err := a.syncSessionCatalogMetadata(ctx, catalog); err != nil && !errors.Is(err, context.Canceled) {
slog.Warn("desktop: sync session catalog metadata", "err", err)
}
select {
case <-a.tabsRestoredSignal():
case <-ctx.Done():
return
}
a.indexRestoredSessionPaths(ctx, catalog)
for _, target := range targets {
if ctx.Err() != nil || a.shuttingDown.Load() {
return
}
// Legacy assignment is deliberately background-only. It can scan and
// repair old metadata, but no project-tree or controller request waits.
if migrated := migrateLegacySessionsIntoGlobalTopics(target.Path); len(migrated) > 0 {
_ = a.syncSessionCatalogMetadata(ctx, catalog)
}
if err := catalog.ReconcileDirectory(ctx, target); err != nil && !errors.Is(err, context.Canceled) {
slog.Debug("desktop: reconcile session catalog directory", "dir", target.Path, "err", err)
}
}
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := a.syncSessionCatalogMetadata(ctx, catalog); err != nil && !errors.Is(err, context.Canceled) {
slog.Debug("desktop: refresh session catalog metadata", "err", err)
}
for _, target := range a.sessionCatalogTargets() {
if migrated := migrateLegacySessionsIntoGlobalTopics(target.Path); len(migrated) > 0 {
_ = a.syncSessionCatalogMetadata(ctx, catalog)
}
catalog.RequestReconcile(target)
}
case <-ctx.Done():
return
}
}
}()
}
func (a *App) stopSessionCatalog(timeout time.Duration) {
if a == nil {
return
}
a.catalogLifecycleMu.Lock()
cancel := a.catalogCancel
done := a.catalogDone
a.catalogCancel = nil
a.catalogDone = nil
a.catalogLifecycleMu.Unlock()
if cancel != nil {
cancel()
}
catalog := a.sessionCatalog.Swap(nil)
deadline := time.Now().Add(timeout)
if catalog != nil {
remaining := time.Until(deadline)
if remaining < 0 {
remaining = 0
}
ctx, closeCancel := context.WithTimeout(context.Background(), remaining)
_ = catalog.Close(ctx)
closeCancel()
}
if done != nil {
remaining := time.Until(deadline)
if remaining <= 0 {
return
}
timer := time.NewTimer(remaining)
defer timer.Stop()
select {
case <-done:
case <-timer.C:
}
}
}
func (a *App) cancelAllTabBuilds() {
if a == nil {
return
}
a.mu.Lock()
for _, tab := range a.tabs {
a.supersedeTabBuildLocked(tab)
}
for _, tab := range a.detachedSessions {
a.supersedeTabBuildLocked(tab)
}
a.mu.Unlock()
}
func (a *App) sessionCatalogTargets() []sessioncatalog.DirectoryTarget {
f := loadProjectsFile()
seen := map[string]bool{}
out := []sessioncatalog.DirectoryTarget{}
add := func(target sessioncatalog.DirectoryTarget) {
target.Path = filepath.Clean(strings.TrimSpace(target.Path))
if target.Path == "." || target.Path == "" || seen[target.Path] {
return
}
seen[target.Path] = true
out = append(out, target)
}
add(sessioncatalog.DirectoryTarget{Path: config.SessionDir(), Scope: "global"})
add(sessioncatalog.DirectoryTarget{Path: desktopSessionDir(globalWorkspaceRoot()), Scope: "global"})
for _, project := range f.Projects {
add(sessioncatalog.DirectoryTarget{Path: desktopSessionDir(project.Root), Scope: "project", WorkspaceRoot: project.Root})
}
return out
}
func (a *App) indexRestoredSessionPaths(ctx context.Context, catalog *sessioncatalog.Catalog) {
type restored struct {
target sessioncatalog.DirectoryTarget
path string
}
a.mu.RLock()
items := make([]restored, 0, len(a.tabs)+len(a.detachedSessions))
collect := func(tab *WorkspaceTab) {
if tab == nil || strings.TrimSpace(tab.SessionPath) == "" {
return
}
items = append(items, restored{
target: sessioncatalog.DirectoryTarget{Path: filepath.Dir(tab.SessionPath), Scope: tab.Scope, WorkspaceRoot: tab.WorkspaceRoot},
path: tab.SessionPath,
})
}
for _, tab := range a.tabs {
collect(tab)
}
for _, tab := range a.detachedSessions {
collect(tab)
}
a.mu.RUnlock()
for _, item := range items {
if ctx.Err() != nil {
return
}
_ = catalog.IndexSessionPath(ctx, item.target, item.path)
}
}
func (a *App) syncSessionCatalogMetadata(ctx context.Context, catalog *sessioncatalog.Catalog) error {
f := loadProjectsFile()
deleted := map[string]bool{}
for _, topicID := range f.DeletedTopics {
deleted[topicID] = true
}
projects := []sessioncatalog.ProjectRecord{{
Scope: "global", Title: strings.TrimSpace(f.GlobalTitle), Color: normalizeProjectColor(f.GlobalColor),
}}
if projects[0].Title == "" {
projects[0].Title = "Global"
}
topics := []sessioncatalog.TopicMetadata{}
appendTopics := func(scope, root string, ids, pinnedIDs []string) {
titles := loadTopicTitles(root)
sources := loadTopicTitleSources(root)
created := loadTopicCreatedAts(root)
ordered := pinnedTopicIDs(orderedTopicIDs(ids, titles), pinnedIDs)
for index, topicID := range ordered {
if deleted[topicID] {
continue
}
title := strings.TrimSpace(titles[topicID])
if title == "" {
title = defaultTopicTitle
}
topics = append(topics, sessioncatalog.TopicMetadata{
Scope: scope, WorkspaceRoot: root, TopicID: topicID, Title: title,
TitleSource: sources[topicID], Pinned: containsDesktopString(pinnedIDs, topicID),
SortOrder: index, CreatedAt: topicCreatedAtForTree(created, topicID),
})
}
}
appendTopics("global", "", f.GlobalTopics, f.GlobalPinnedTopics)
for index, project := range f.Projects {
title := strings.TrimSpace(project.Title)
if title == "" {
title = workspaceName(project.Root)
}
projects = append(projects, sessioncatalog.ProjectRecord{
Scope: "project", WorkspaceRoot: project.Root, Title: title, Color: project.Color,
Pinned: containsDesktopString(f.PinnedProjects, project.Root), SortOrder: index,
})
appendTopics("project", project.Root, project.Topics, project.PinnedTopics)
}
return catalog.SyncMetadata(ctx, projects, topics)
}
func (a *App) emitProjectTreeChangedV2(revision uint64, roots []string, reason string) {
if roots == nil {
roots = []string{}
}
a.emitRuntimeEvent("project-tree:changed-v2", ProjectTreeChangedV2{Revision: revision, Roots: roots, Reason: reason})
// One-release compatibility event. Its wrapper is catalog-only, so legacy
// frontends refresh without reintroducing synchronous history I/O.
a.emitRuntimeEvent("project-tree:changed")
}
func (a *App) requestSessionCatalogReconcile(dir string) {
catalog := a.sessionCatalog.Load()
if catalog == nil || a.shuttingDown.Load() || strings.TrimSpace(dir) == "" {
return
}
clean := filepath.Clean(dir)
target := sessioncatalog.DirectoryTarget{Path: clean, Scope: "global"}
for _, candidate := range a.sessionCatalogTargets() {
if sameDesktopPath(candidate.Path, clean) {
target = candidate
break
}
}
go func() {
if migrated := migrateLegacySessionsIntoGlobalTopics(target.Path); len(migrated) > 0 {
ctx, cancel := context.WithTimeout(a.bootContext(), 5*time.Second)
_ = a.syncSessionCatalogMetadata(ctx, catalog)
cancel()
}
catalog.RequestReconcile(target)
}()
}
func sessionDirectoryForPath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
return filepath.Dir(path)
}
func (a *App) requestSessionCatalogPath(scope, workspaceRoot, path string) {
catalog := a.sessionCatalog.Load()
if catalog == nil || a.shuttingDown.Load() || strings.TrimSpace(path) == "" {
return
}
catalog.RequestIndexSession(sessioncatalog.DirectoryTarget{
Path: sessionDirectoryForPath(path), Scope: scope, WorkspaceRoot: workspaceRoot,
}, path)
}
func (a *App) removeSessionCatalogPath(path, reason string) {
catalog := a.sessionCatalog.Load()
if catalog == nil || strings.TrimSpace(path) == "" {
return
}
ctx, cancel := context.WithTimeout(a.bootContext(), 150*time.Millisecond)
defer cancel()
if err := catalog.RemoveSession(ctx, path, reason); err != nil && !errors.Is(err, context.Canceled) {
slog.Debug("desktop: remove session catalog row", "err", err)
}
}
func (a *App) requestSessionCatalogMetadataSync() {
catalog := a.sessionCatalog.Load()
if catalog == nil || a.shuttingDown.Load() {
return
}
go func() {
ctx, cancel := context.WithTimeout(a.bootContext(), 5*time.Second)
defer cancel()
_ = a.syncSessionCatalogMetadata(ctx, catalog)
}()
}
func (a *App) GetProjectTreeSnapshot() ProjectTreeSnapshot {
f := loadProjectsFile()
projects := []ProjectNode{}
if strings.TrimSpace(f.GlobalTitle) != "" || len(f.GlobalTopics) > 0 || len(f.Projects) == 0 {
label := strings.TrimSpace(f.GlobalTitle)
if label == "" {
label = "Global"
}
projects = append(projects, ProjectNode{
Key: "global_folder", Kind: "global_folder", Label: label,
Root: globalWorkspaceRoot(), ProjectColor: normalizeProjectColor(f.GlobalColor),
Children: []ProjectNode{},
})
}
for _, project := range f.Projects {
label := strings.TrimSpace(project.Title)
if label == "" {
label = workspaceName(project.Root)
}
projects = append(projects, ProjectNode{
Key: "project_" + project.Root, Kind: "project", Label: label,
Root: project.Root, ProjectColor: project.Color,
Pinned: containsDesktopString(f.PinnedProjects, project.Root),
Children: []ProjectNode{},
})
}
projects = applyPinnedProjectOrder(applyProjectTreeOrder(projects, f.SidebarOrder), f.PinnedProjects)
status := a.currentSessionCatalogStatus()
return ProjectTreeSnapshot{
Revision: status.Revision, Projects: projects, Catalog: status,
Indexed: status.Indexed, Total: status.Total,
IndexingDone: status.State == string(sessioncatalog.StateReady) && status.RepairPending == 0,
}
}
type catalogRuntimeSnapshot struct {
scope string
workspaceRoot string
topicID string
sessionPath string
activity string
topicTitle string
ctrl control.SessionAPI
open bool
}
type catalogRuntimeOverlay struct {
open bool
running bool
status string
}
func catalogRuntimeStatus(activity string, runtimeStatus control.RuntimeStatus) string {
status := normalizeTopicStatus(activity)
if runtimeStatus.PendingPrompt {
return topicStatusWaitingConfirmation
}
if runtimeStatus.Running {
if status == "" || status == topicStatusError || status == topicStatusPaused {
return topicStatusThinking
}
return status
}
if runtimeStatus.BackgroundJobs > 0 {
return topicStatusBackgroundJob
}
if status == topicStatusError || status == topicStatusPaused {
return status
}
return status
}
func (a *App) catalogRuntimeOverlays() (map[string]catalogRuntimeOverlay, map[string]catalogRuntimeOverlay) {
a.mu.RLock()
snapshots := make([]catalogRuntimeSnapshot, 0, len(a.tabs)+len(a.detachedSessions))
collect := func(tab *WorkspaceTab, open bool) {
if tab == nil || strings.TrimSpace(tab.TopicID) == "" {
return
}
snapshots = append(snapshots, catalogRuntimeSnapshot{
scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID,
sessionPath: tab.SessionPath, activity: tab.ActivityStatus, topicTitle: tab.TopicTitle,
ctrl: tab.Ctrl, open: open,
})
}
for _, tab := range a.tabs {
collect(tab, true)
}
for _, tab := range a.detachedSessions {
collect(tab, false)
}
a.mu.RUnlock()
topics := map[string]catalogRuntimeOverlay{}
sessions := map[string]catalogRuntimeOverlay{}
for _, snap := range snapshots {
runtimeStatus := control.RuntimeStatus{}
path := strings.TrimSpace(snap.sessionPath)
if snap.ctrl != nil {
runtimeStatus = snap.ctrl.RuntimeStatus()
if path == "" {
path = snap.ctrl.SessionPath()
}
}
status := catalogRuntimeStatus(snap.activity, runtimeStatus)
running := status != "" || runtimeStatus.Running || runtimeStatus.PendingPrompt || runtimeStatus.BackgroundJobs > 0
overlay := catalogRuntimeOverlay{open: snap.open, running: running, status: status}
key := topicSummaryKey(snap.scope, snap.workspaceRoot, snap.topicID)
current := topics[key]
current.open = current.open || overlay.open
current.running = current.running || overlay.running
if current.status == "" {
current.status = overlay.status
}
topics[key] = current
if path != "" {
sessions[sessionRuntimeKey(path)] = overlay
}
}
return topics, sessions
}
func (a *App) metadataProjectTopics(scope, workspaceRoot string) []ProjectNode {
f := loadProjectsFile()
deleted := map[string]bool{}
for _, topicID := range f.DeletedTopics {
deleted[topicID] = true
}
ids := f.GlobalTopics
pinnedIDs := f.GlobalPinnedTopics
titleRoot := ""
projectColor := normalizeProjectColor(f.GlobalColor)
if scope == "project" {
ids = nil
pinnedIDs = nil
titleRoot = workspaceRoot
for _, project := range f.Projects {
if sameProjectRoot(project.Root, workspaceRoot) {
ids = project.Topics
pinnedIDs = project.PinnedTopics
projectColor = project.Color
break
}
}
}
titles := loadTopicTitles(titleRoot)
sources := loadTopicTitleSources(titleRoot)
created := loadTopicCreatedAts(titleRoot)
topicOverlays, _ := a.catalogRuntimeOverlays()
runtimeNodes := a.runtimeOnlyProjectTopics(scope, workspaceRoot)
runtimeByTopic := map[string]ProjectNode{}
for _, node := range runtimeNodes {
runtimeByTopic[node.TopicID] = node
}
out := []ProjectNode{}
seen := map[string]bool{}
for _, topicID := range pinnedTopicIDs(orderedTopicIDs(ids, titles), pinnedIDs) {
if deleted[topicID] {
continue
}
seen[topicID] = true
title := strings.TrimSpace(titles[topicID])
if title == "" {
title = defaultTopicTitle
}
kind := "topic"
if scope != "project" {
kind = "global_topic"
}
overlay := topicOverlays[topicSummaryKey(scope, workspaceRoot, topicID)]
node := ProjectNode{
Key: kind + "_" + topicID, Kind: kind,
Label: a.localizedTopicTitle(title, sources[topicID]), Root: workspaceRoot,
TopicID: topicID, ProjectColor: projectColor,
CreatedAt: topicCreatedAtForTree(created, topicID), Pinned: containsDesktopString(pinnedIDs, topicID),
Open: overlay.open, Running: overlay.running, Status: overlay.status,
TurnsState: string(sessioncatalog.TurnsUnknown), Health: string(sessioncatalog.HealthOK),
Children: []ProjectNode{},
}
if runtimeNode, ok := runtimeByTopic[topicID]; ok {
node.Open = runtimeNode.Open
node.Running = runtimeNode.Running
node.Status = runtimeNode.Status
node.Children = runtimeNode.Children
}
out = append(out, node)
}
for _, runtimeNode := range runtimeNodes {
if seen[runtimeNode.TopicID] || deleted[runtimeNode.TopicID] {
continue
}
out = append(out, runtimeNode)
}
return out
}
func (a *App) runtimeOnlyProjectTopics(scope, workspaceRoot string) []ProjectNode {
a.mu.RLock()
snapshots := []catalogRuntimeSnapshot{}
collect := func(tab *WorkspaceTab, open bool) {
if tab == nil || strings.TrimSpace(tab.TopicID) == "" {
return
}
if scope == "project" {
if tab.Scope != "project" || !sameProjectRoot(tab.WorkspaceRoot, workspaceRoot) {
return
}
} else if tab.Scope == "project" {
return
}
snapshots = append(snapshots, catalogRuntimeSnapshot{
scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID,
sessionPath: tab.SessionPath, activity: tab.ActivityStatus,
topicTitle: tab.TopicTitle, ctrl: tab.Ctrl, open: open,
})
}
for _, tab := range a.tabs {
collect(tab, true)
}
for _, tab := range a.detachedSessions {
collect(tab, false)
}
a.mu.RUnlock()
byTopic := map[string][]catalogRuntimeSnapshot{}
for _, snapshot := range snapshots {
if snapshot.sessionPath == "" && snapshot.ctrl != nil {
snapshot.sessionPath = snapshot.ctrl.SessionPath()
}
byTopic[snapshot.topicID] = append(byTopic[snapshot.topicID], snapshot)
}
topicIDs := make([]string, 0, len(byTopic))
for topicID := range byTopic {
topicIDs = append(topicIDs, topicID)
}
sort.Strings(topicIDs)
out := []ProjectNode{}
for _, topicID := range topicIDs {
sessions := byTopic[topicID]
kind := "topic"
sessionKind := "session"
if scope != "project" {
kind = "global_topic"
sessionKind = "global_session"
}
label := defaultTopicTitle
if strings.TrimSpace(sessions[0].topicTitle) != "" {
label = sessions[0].topicTitle
}
node := ProjectNode{
Key: kind + "_" + topicID, Kind: kind, Label: label,
Root: workspaceRoot, TopicID: topicID, TurnsState: string(sessioncatalog.TurnsUnknown),
Health: string(sessioncatalog.HealthOK), Children: []ProjectNode{},
}
for _, session := range sessions {
runtimeStatus := control.RuntimeStatus{}
if session.ctrl != nil {
runtimeStatus = session.ctrl.RuntimeStatus()
}
status := catalogRuntimeStatus(session.activity, runtimeStatus)
running := status != "" || runtimeStatus.Running || runtimeStatus.PendingPrompt || runtimeStatus.BackgroundJobs > 0
if len(sessions) == 1 {
node.Open = session.open
node.Running = running
node.Status = status
continue
}
path := strings.TrimSpace(session.sessionPath)
sessionLabel := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
if sessionLabel == "" || sessionLabel == "." {
sessionLabel = label
}
node.Children = append(node.Children, ProjectNode{
Key: projectSessionNodeKey(scope, path), Kind: sessionKind, Label: sessionLabel,
Root: workspaceRoot, TopicID: topicID, SessionPath: path,
Open: session.open, Running: running, Status: status,
TurnsState: string(sessioncatalog.TurnsUnknown), Health: string(sessioncatalog.HealthOK),
Children: []ProjectNode{},
})
}
out = append(out, node)
}
return out
}
func (a *App) metadataTopicPage(req ProjectTopicPageRequest) ProjectTopicPage {
items := a.metadataProjectTopics(req.Scope, req.WorkspaceRoot)
query := strings.ToLower(strings.TrimSpace(req.Query))
if query != "" {
filtered := items[:0]
for _, item := range items {
if strings.Contains(strings.ToLower(item.Label), query) {
filtered = append(filtered, item)
}
}
items = filtered
}
start := 0
if strings.HasPrefix(req.Cursor, "meta:") {
lastID := strings.TrimPrefix(req.Cursor, "meta:")
for index, item := range items {
if item.TopicID == lastID {
start = index + 1
break
}
}
}
limit := req.Limit
if limit <= 0 {
limit = sessioncatalog.DefaultLimit
}
if limit > sessioncatalog.MaxLimit {
limit = sessioncatalog.MaxLimit
}
end := min(start+limit, len(items))
page := ProjectTopicPage{Items: append([]ProjectNode(nil), items[start:end]...)}
if end < len(items) && end > start {
page.NextCursor = "meta:" + items[end-1].TopicID
}
return page
}
func (a *App) projectNodeFromCatalogTopic(topic sessioncatalog.TopicRecord, topicOverlays, sessionOverlays map[string]catalogRuntimeOverlay) ProjectNode {
kind := "topic"
if topic.Scope == "global" {
kind = "global_topic"
}
overlay := topicOverlays[topicSummaryKey(topic.Scope, topic.WorkspaceRoot, topic.TopicID)]
node := ProjectNode{
Key: kind + "_" + topic.TopicID, Kind: kind, Label: a.localizedTopicTitle(topic.Title, ""),
Root: topic.WorkspaceRoot, TopicID: topic.TopicID, Turns: topic.Turns,
TurnsState: string(topic.TurnsState), Health: string(topic.Health),
CreatedAt: topic.CreatedAt, LastActivityAt: topic.LastActivityAt,
Pinned: topic.Pinned, Open: overlay.open, Running: overlay.running, Status: overlay.status,
Children: []ProjectNode{},
}
if len(topic.Sessions) <= 1 {
return node
}
for _, session := range topic.Sessions {
sessionKind := "session"
if topic.Scope == "global" {
sessionKind = "global_session"
}
sessionOverlay := sessionOverlays[sessionRuntimeKey(session.Path)]
label := strings.TrimSpace(session.CustomTitle)
if label == "" {
label = strings.TrimSpace(session.Preview)
}
if label == "" {
label = filepath.Base(session.Path)
}
node.Children = append(node.Children, ProjectNode{
Key: projectSessionNodeKey(topic.Scope, session.Path), Kind: sessionKind,
Label: label, Root: topic.WorkspaceRoot, TopicID: topic.TopicID,
SessionPath: session.Path, Turns: session.Turns,
TurnsState: string(session.TurnsState), Health: string(session.Health),
CreatedAt: session.CreatedAt, LastActivityAt: session.LastActivityAt,
Open: sessionOverlay.open, Running: sessionOverlay.running, Status: sessionOverlay.status,
Recovered: session.Recovered, RecoveryReason: session.RecoveryReason,
RecoveryDigest: session.RecoveryDigest, RecoveryParentID: session.ParentID,
Children: []ProjectNode{},
})
}
return node
}
func (a *App) ListProjectTopics(req ProjectTopicPageRequest) (ProjectTopicPage, error) {
out := ProjectTopicPage{Items: []ProjectNode{}}
catalog := a.sessionCatalog.Load()
if catalog == nil {
return a.metadataTopicPage(req), nil
}
page, err := catalog.ListTopics(a.bootContext(), sessioncatalog.TopicPageRequest{
Scope: req.Scope, WorkspaceRoot: req.WorkspaceRoot, Cursor: req.Cursor,
Limit: req.Limit, Query: req.Query, TimeFilter: req.TimeFilter,
})
if err != nil {
return out, err
}
topicOverlays, sessionOverlays := a.catalogRuntimeOverlays()
for _, topic := range page.Items {
out.Items = append(out.Items, a.projectNodeFromCatalogTopic(topic, topicOverlays, sessionOverlays))
}
out.NextCursor = page.NextCursor
out.Revision = page.Revision
return out, nil
}
func (a *App) GetTopicSummary(key ProjectTopicKey) (ProjectNode, error) {
if catalog := a.sessionCatalog.Load(); catalog != nil {
topic, ok, err := catalog.GetTopic(a.bootContext(), sessioncatalog.TopicKey{
Scope: key.Scope, WorkspaceRoot: key.WorkspaceRoot, TopicID: key.TopicID,
})
if err != nil {
return ProjectNode{Children: []ProjectNode{}}, err
}
if ok {
topicOverlays, sessionOverlays := a.catalogRuntimeOverlays()
return a.projectNodeFromCatalogTopic(topic, topicOverlays, sessionOverlays), nil
}
}
page, err := a.ListProjectTopics(ProjectTopicPageRequest{
Scope: key.Scope, WorkspaceRoot: key.WorkspaceRoot, Limit: sessioncatalog.MaxLimit,
})
if err != nil {
return ProjectNode{Children: []ProjectNode{}}, err
}
for _, node := range page.Items {
if node.TopicID == key.TopicID {
return node, nil
}
}
return ProjectNode{Children: []ProjectNode{}}, nil
}
func (a *App) GetSessionCatalogStatus() SessionCatalogStatus {
return a.currentSessionCatalogStatus()
}
func (a *App) RebuildSessionCatalog() error {
if a == nil || a.shuttingDown.Load() {
return errors.New("application is shutting down")
}
if !a.catalogRebuilding.CompareAndSwap(false, true) {
return nil
}
go func() {
a.stopSessionCatalog(250 * time.Millisecond)
a.catalogRebuilding.Store(false)
a.startSessionCatalog(true)
}()
return nil
}
// ListProjectTree is the one-release compatibility wrapper. It composes only
// catalog pages and project shells; it never migrates, scans, or decodes a
// session synchronously.
func (a *App) ListProjectTree() []ProjectNode {
snapshot := a.GetProjectTreeSnapshot()
hasGlobal := false
for _, project := range snapshot.Projects {
if project.Kind == "global_folder" {
hasGlobal = true
break
}
}
if !hasGlobal && len(a.metadataProjectTopics("global", "")) > 0 {
f := loadProjectsFile()
label := strings.TrimSpace(f.GlobalTitle)
if label == "" {
label = "Global"
}
snapshot.Projects = append(snapshot.Projects, ProjectNode{
Key: "global_folder", Kind: "global_folder", Label: label,
Root: globalWorkspaceRoot(), ProjectColor: normalizeProjectColor(f.GlobalColor), Children: []ProjectNode{},
})
snapshot.Projects = applyPinnedProjectOrder(applyProjectTreeOrder(snapshot.Projects, f.SidebarOrder), f.PinnedProjects)
}
for index := range snapshot.Projects {
project := &snapshot.Projects[index]
scope := "project"
root := project.Root
if project.Kind == "global_folder" {
scope = "global"
root = ""
}
cursor := ""
for {
page, err := a.ListProjectTopics(ProjectTopicPageRequest{Scope: scope, WorkspaceRoot: root, Cursor: cursor, Limit: sessioncatalog.MaxLimit})
if err != nil {
break
}
project.Children = append(project.Children, page.Items...)
if page.NextCursor == "" {
break
}
cursor = page.NextCursor
}
if len(project.Children) == 0 {
project.Children = a.metadataProjectTopics(scope, root)
}
}
return snapshot.Projects
}
func (a *App) catalogSessionPathForTopic(scope, workspaceRoot, topicID string) string {
if strings.TrimSpace(topicID) == "" {
return ""
}
catalog := a.sessionCatalog.Load()
if catalog == nil {
return ""
}
topic, ok, err := catalog.GetTopic(a.bootContext(), sessioncatalog.TopicKey{Scope: scope, WorkspaceRoot: workspaceRoot, TopicID: topicID})
if err != nil || !ok || len(topic.Sessions) == 0 {
return ""
}
sort.SliceStable(topic.Sessions, func(i, j int) bool {
return topic.Sessions[i].LastActivityAt > topic.Sessions[j].LastActivityAt
})
return topic.Sessions[0].Path
}
+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)
}
}
+4 -9
View File
@@ -1904,12 +1904,10 @@ func (a *App) rebuildSettingTurnLockedWithModel(setting string, tab *WorkspaceTa
if err := rebuildControllerActiveWorkErrorFor(a.controllerForTab(tab), setting); err != nil {
return err
}
ensureWorkspace := a.ensureTabControllerWorkspace
if admissionHeld {
ensureWorkspace = a.ensureTabControllerWorkspaceAdmissionHeld
}
if err := ensureWorkspace(tab); err != nil {
return err
if !admissionHeld {
if err := a.ensureTabControllerWorkspace(tab); err != nil {
return err
}
}
prevPath := a.reconciledSessionPathForTab(tab)
if prevPath == "" {
@@ -1924,9 +1922,6 @@ func (a *App) rebuildSettingTurnLockedWithModel(setting string, tab *WorkspaceTa
if err := rebuildControllerActiveWorkErrorFor(a.controllerForTab(tab), setting); err != nil {
return err
}
if err := ensureWorkspace(tab); err != nil {
return err
}
var carried []provider.Message
oldCtrl := a.controllerForTab(tab)
+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
+80 -551
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,38 @@ func (a *App) buildTabControllerWithContextAdmissionHeld(tab *WorkspaceTab, load
}
sessionDir := desktopSessionDir(root)
if tabScope == "global" {
sessionDir = desktopSessionDir(globalWorkspaceRoot())
}
topicID := strings.TrimSpace(tabTopicID)
// Assign Global topics to legacy sessions in the global session dir so
// imported history appears in the project tree regardless of which tab
// triggered the build (the migration now sends everything to global).
migratedGlobalTopics := migrateLegacySessionsIntoGlobalTopics(config.SessionDir())
if len(migratedGlobalTopics) > 0 {
a.emitProjectTreeChangedForSessionDirs(config.SessionDir())
pinnedPath, hasPinnedPath := pinnedTabSessionPathForBuild(tabScope, tabWorkspaceRoot, sessionDir, tabSessionPath)
if hasPinnedPath && agent.IsCleanupPending(pinnedPath) {
// Boot reconciliation may finish the pending deletion before the later
// resume step. Clear the local candidate now so the disappeared path is
// not mistaken for a deliberate empty placeholder afterward.
hasPinnedPath = false
pinnedPath = ""
tabSessionPath = ""
}
if tabScope == "global" && topicID == "" && len(migratedGlobalTopics) > 0 {
topicID = migratedGlobalTopics[0]
topicTitle := topicTitleForTab("global", "", topicID)
topicSource := loadTopicTitleSource("", topicID)
a.mu.Lock()
if a.tabBuildSupersededLocked(tab, buildGeneration) {
a.mu.Unlock()
return
}
if strings.TrimSpace(tab.TopicID) == "" {
tab.TopicID = topicID
tab.TopicTitle = topicTitle
tab.topicTitleSource = topicSource
a.saveTabsLocked()
} else {
topicID = strings.TrimSpace(tab.TopicID)
}
a.mu.Unlock()
catalogTopicPath := ""
if hasPinnedPath {
// A restored tab's exact path is already known state, not history
// discovery. Keep legacy-directory and empty placeholder paths usable
// while the catalog is still opening or rebuilding.
sessionDir = filepath.Dir(pinnedPath)
} else {
catalogTopicPath = a.catalogSessionPathForTopic(tabScope, tabWorkspaceRoot, topicID)
}
if topicID != "" {
if _, dir := a.findTopicSessionForTarget(tabScope, tabWorkspaceRoot, topicID); dir != "" {
sessionDir = dir
}
if !hasPinnedPath && catalogTopicPath != "" {
sessionDir = filepath.Dir(catalogTopicPath)
}
startupSessionPath := ""
if pinnedPath, ok := pinnedTabSessionPath(sessionDir, tabSessionPath); ok {
if hasPinnedPath {
if !agent.IsCleanupPending(pinnedPath) {
startupSessionPath = pinnedPath
}
} else if topicID != "" {
startupSessionPath = findTopicSession(sessionDir, topicID)
} else if catalogTopicPath != "" {
startupSessionPath = catalogTopicPath
}
model := strings.TrimSpace(tabModel)
@@ -3968,32 +3949,12 @@ func (a *App) buildTabControllerWithContextAdmissionHeld(tab *WorkspaceTab, load
acquiredLeaseKey := ""
restoredRuntime := buildRuntime
if dir := ctrl.SessionDir(); dir != "" {
migratedTopics := migrateLegacySessionsIntoGlobalTopics(dir)
if len(migratedTopics) > 0 {
a.emitProjectTreeChangedForSessionDirs(dir)
}
// Refresh the topic/session locals under the lock: a rebind or the
// recovery callback may have rewritten them since the early snapshot.
a.mu.RLock()
tabTopicID = strings.TrimSpace(tab.TopicID)
tabSessionPath = tab.SessionPath
a.mu.RUnlock()
if tabScope == "global" && tabTopicID == "" && len(migratedTopics) > 0 {
topicID := migratedTopics[0]
topicTitle := topicTitleForTab("global", "", topicID)
topicSource := loadTopicTitleSource("", topicID)
a.mu.Lock()
if !a.tabBuildSupersededLocked(tab, buildGeneration) && strings.TrimSpace(tab.TopicID) == "" {
tab.TopicID = topicID
tab.TopicTitle = topicTitle
tab.topicTitleSource = topicSource
tabTopicID = topicID
a.saveTabsLocked()
} else {
tabTopicID = strings.TrimSpace(tab.TopicID)
}
a.mu.Unlock()
}
var path string
var resumeSession *agent.Session
var resumeLoadErr error
@@ -4007,7 +3968,7 @@ func (a *App) buildTabControllerWithContextAdmissionHeld(tab *WorkspaceTab, load
resumeSession = loaded
}
if resumeLoadErr == nil && path == "" && tabTopicID != "" {
existingPath := findTopicSession(dir, tabTopicID)
existingPath := a.catalogSessionPathForTopic(tabScope, tabWorkspaceRoot, tabTopicID)
if existingPath != "" {
if loaded, err := loadResumableSession(existingPath); err == nil {
path = existingPath
@@ -4163,6 +4124,11 @@ func (a *App) buildTabControllerWithContextAdmissionHeld(tab *WorkspaceTab, load
}
}
// Lifecycle admission protects only the compare-and-publish boundary. Slow
// config, history, lease, and extension work above remains cancellable and
// cannot prevent shutdown from acquiring the write side.
a.runtimeAdmissionMu.RLock()
defer a.runtimeAdmissionMu.RUnlock()
a.mu.Lock()
if a.tabBuildSupersededLocked(tab, buildGeneration) {
a.mu.Unlock()
@@ -4587,6 +4553,10 @@ func (a *App) tabSnapshotLoop(tab *WorkspaceTab) {
a.mu.RUnlock()
if ctrl != nil {
if err := a.snapshotTab(tab); err == nil {
a.mu.RLock()
scope, workspaceRoot := tab.Scope, tab.WorkspaceRoot
a.mu.RUnlock()
a.requestSessionCatalogPath(scope, workspaceRoot, ctrl.SessionPath())
if !a.maybeAutoTitleTopic(tab) {
a.emitProjectTreeChangedForSessionDirs(ctrl.SessionDir())
}
@@ -6301,7 +6271,7 @@ func (a *App) handleTabSessionRecovered(tab *WorkspaceTab) func(control.SessionR
_ = saveTelemetry(info.RecoveryPath+".telemetry.json", tab.telemetrySnapshot())
}
}
a.emitProjectTreeChangedForSessionDirs(sessionListCacheDirForPath(info.RecoveryPath))
a.emitProjectTreeChangedForSessionDirs(sessionDirectoryForPath(info.RecoveryPath))
a.emitRuntimeEvent("session:recovered", sessionRecoveryEvent{
OriginalPath: info.OriginalPath,
RecoveryPath: info.RecoveryPath,
@@ -6502,6 +6472,8 @@ type ProjectNode struct {
SessionPath string `json:"sessionPath,omitempty"`
ProjectColor string `json:"projectColor,omitempty"`
Turns int `json:"turns,omitempty"`
TurnsState string `json:"turnsState,omitempty"`
Health string `json:"health,omitempty"`
CreatedAt int64 `json:"createdAt,omitempty"`
LastActivityAt int64 `json:"lastActivityAt,omitempty"`
Open bool `json:"open,omitempty"`
@@ -6792,7 +6764,6 @@ func migrateLegacySessionsIntoGlobalTopics(dir string) []string {
_ = saveTopicTitleSources(topicTitleRoot, topicSources)
}
invalidateTopicSessionIndex(dir)
projectSessionCache.invalidateDirs(dir)
if !deferred {
markTopicMigrationDone(dir) // pass complete with nothing deferred
}
@@ -6946,9 +6917,6 @@ func repairIndexedSessionTopics(dir string) []string {
deferred = true
}
}
if !deferred {
projectSessionCache.invalidateDirs(dir)
}
}
if !deferred {
markTopicIndexRepairDone(dir)
@@ -7516,24 +7484,26 @@ func (a *App) setTabActivityStatus(tabID, status string) bool {
}
func (a *App) emitProjectTreeChanged() {
projectSessionCache.invalidate()
a.requestSessionCatalogMetadataSync()
for _, target := range a.sessionCatalogTargets() {
a.requestSessionCatalogReconcile(target.Path)
}
a.emitProjectTreeChangedEvent()
}
// emitProjectTreeChangedForSessionDirs keeps cached listings for unrelated
// workspaces. A session mutation only changes the directory containing that
// transcript, so invalidating every known project turns one archive into an
// O(all sessions) rescan for heavy users.
// emitProjectTreeChangedForSessionDirs schedules only the affected catalog
// directories. It never scans synchronously on the mutation or UI goroutine.
func (a *App) emitProjectTreeChangedForSessionDirs(dirs ...string) {
if !projectSessionCache.invalidateDirs(dirs...) {
projectSessionCache.invalidate()
for _, dir := range dirs {
a.requestSessionCatalogReconcile(dir)
}
a.emitProjectTreeChangedEvent()
}
// emitProjectTreeMetadataChanged refreshes ordering, titles, pins, and runtime
// status without discarding session listings whose on-disk data did not move.
// status without walking session storage.
func (a *App) emitProjectTreeMetadataChanged() {
a.requestSessionCatalogMetadataSync()
a.emitProjectTreeChangedEvent()
}
@@ -7727,324 +7697,6 @@ func topicHiddenAsRecoveryOnly(summary topicSummary, pinned bool, runtimeSession
return true
}
var listProjectTreeMu sync.Mutex
func (a *App) ListProjectTree() []ProjectNode {
listProjectTreeMu.Lock()
defer listProjectTreeMu.Unlock()
knownDirs := a.knownSessionDirs()
for _, dir := range knownDirs {
migrateLegacySessionsIntoGlobalTopics(dir)
}
f := loadProjectsFile()
// Render-side tombstone guard: a deleted topic whose title survived a racy
// whole-map save would otherwise reappear via the orderedTopicIDs title-map
// fallback. The tombstone is authoritative until an intentional
// single-topic write (create/restore/tab indexing) clears it.
deletedTopicSet := make(map[string]bool, len(f.DeletedTopics))
for _, id := range f.DeletedTopics {
deletedTopicSet[id] = true
}
out := []ProjectNode{}
topicSummaries := map[string]topicSummary{}
sessionInfos := map[string]agent.SessionInfo{}
sessionTitles := map[string]string{}
// Read session listings from all known directories concurrently, since
// each dir is independent I/O. With N workspaces × dozens of sessions,
// sequential reads add up to seconds of wall time on cold start.
type sessionDirLoadResult struct {
dir string
infos []agent.SessionInfo
titles map[string]string
ok bool
}
results := make(chan sessionDirLoadResult, len(knownDirs))
pendingLoads := 0
for _, dir := range knownDirs {
infos, titles, ok := projectSessionCache.get(dir)
if ok {
mergeSessionInfos(dir, infos, titles, sessionInfos, sessionTitles, topicSummaries)
continue
}
pendingLoads++
// capture
cacheToken := projectSessionCache.versionToken(dir)
go func() {
result := sessionDirLoadResult{dir: dir}
defer func() {
if recover() != nil {
result.ok = false
}
results <- result
}()
// Sidecar-backed listing: ListSessions reads turn count + preview from
// each session's .meta sidecar, so even large directories list in a few
// milliseconds without decoding any .jsonl body. The in-memory
// projectSessionCache still elides repeat listings within a session.
infos, err := agent.ListSessions(dir)
if err != nil {
return
}
titles := loadSessionTitles(dir)
projectSessionCache.put(dir, infos, titles, cacheToken)
result.infos = infos
result.titles = titles
result.ok = true
}()
}
if pendingLoads > 0 {
timer := time.NewTimer(5 * time.Second)
for received := 0; received < pendingLoads; {
select {
case result := <-results:
received++
if result.ok {
mergeSessionInfos(result.dir, result.infos, result.titles, sessionInfos, sessionTitles, topicSummaries)
}
case <-timer.C:
received = pendingLoads
}
}
timer.Stop()
}
runtimeSessionsByTopic := map[string][]runtimeSessionStatus{}
a.mu.RLock()
seenRuntimePaths := map[string]bool{}
addRuntimeSession := func(tab *WorkspaceTab, open bool) {
if tab == nil || strings.TrimSpace(tab.TopicID) == "" {
return
}
sessionPath := sessionRuntimeKey(tab.currentSessionPath())
if sessionPath == "" || seenRuntimePaths[sessionPath] {
return
}
seenRuntimePaths[sessionPath] = true
info := sessionInfos[sessionPath]
recovered := sessionInfoIsAutomaticRecovery(info) || isAutomaticRecoverySessionPath(sessionPath)
label := runtimeSessionTreeLabel(tab, info, sessionTitles[sessionPath])
titleSource := tab.topicTitleSource
if strings.TrimSpace(sessionTitles[sessionPath]) != "" {
titleSource = topicTitleSourceManual
}
status := activityStatusForTab(tab)
runtimeStatus := control.RuntimeStatus{}
if tab.Ctrl != nil {
runtimeStatus = tab.Ctrl.RuntimeStatus()
}
running := status != "" || runtimeStatus.Running || runtimeStatus.PendingPrompt || runtimeStatus.BackgroundJobs > 0
runtimeSessionsByTopic[topicSummaryKey(tab.Scope, tab.WorkspaceRoot, tab.TopicID)] = append(runtimeSessionsByTopic[topicSummaryKey(tab.Scope, tab.WorkspaceRoot, tab.TopicID)], runtimeSessionStatus{
sessionPath: sessionPath,
label: label,
titleSource: titleSource,
turns: info.Turns,
createdAt: unixMilliOrZero(info.CreatedAt),
lastActivityAt: unixMilliOrZero(info.LastActivityAt),
open: open,
running: running,
status: status,
recovered: recovered,
recoveryReason: info.RecoveryReason,
recoveryDigest: info.RecoveryDigest,
recoveryParentID: string(info.ParentID),
})
}
for _, tab := range a.tabs {
addRuntimeSession(tab, true)
}
for _, tab := range a.detachedSessions {
addRuntimeSession(tab, false)
}
a.mu.RUnlock()
for key := range runtimeSessionsByTopic {
sort.Slice(runtimeSessionsByTopic[key], func(i, j int) bool {
left := runtimeSessionsByTopic[key][i]
right := runtimeSessionsByTopic[key][j]
if left.lastActivityAt != right.lastActivityAt {
return left.lastActivityAt > right.lastActivityAt
}
return left.sessionPath < right.sessionPath
})
}
topicRuntimeStatus := func(key string) (open, running bool, status string) {
sessions := runtimeSessionsByTopic[key]
if len(sessions) != 1 {
return false, false, ""
}
session := sessions[0]
return session.open, session.running, session.status
}
runtimeSessionNodes := func(scope, workspaceRoot, topicID, projectColor string) []ProjectNode {
key := topicSummaryKey(scope, workspaceRoot, topicID)
sessions := runtimeSessionsByTopic[key]
if len(sessions) <= 1 {
return nil
}
nodes := make([]ProjectNode, 0, len(sessions))
for _, session := range sessions {
kind := "session"
if scope == "global" {
kind = "global_session"
}
nodes = append(nodes, ProjectNode{
Key: projectSessionNodeKey(scope, session.sessionPath),
Kind: kind,
Label: a.localizedTopicTitle(session.label, session.titleSource),
Root: workspaceRoot,
TopicID: topicID,
SessionPath: session.sessionPath,
ProjectColor: projectColor,
Turns: session.turns,
CreatedAt: session.createdAt,
LastActivityAt: session.lastActivityAt,
Open: session.open,
Running: session.running,
Status: session.status,
Recovered: session.recovered,
RecoveryReason: session.recoveryReason,
RecoveryDigest: session.recoveryDigest,
RecoveryParentID: session.recoveryParentID,
})
}
return nodes
}
// Global section.
globalTitleMap := loadTopicTitles("")
globalTitleSources := loadTopicTitleSources("")
globalCreatedMap := loadTopicCreatedAts("")
if len(globalTitleMap) > 0 || len(f.Projects) == 0 {
globalTitle := strings.TrimSpace(f.GlobalTitle)
if globalTitle == "" {
globalTitle = "Global"
}
globalColor := normalizeProjectColor(f.GlobalColor)
globalTopicIDs := pinnedTopicIDs(orderedTopicIDs(f.GlobalTopics, globalTitleMap), f.GlobalPinnedTopics)
children := make([]ProjectNode, 0, len(globalTopicIDs))
for _, id := range globalTopicIDs {
if deletedTopicSet[id] {
continue
}
title := a.localizedTopicTitle(globalTitleMap[id], globalTitleSources[id])
summaryKey := topicSummaryKey("global", "", id)
summary := topicSummaries[summaryKey]
open, running, status := topicRuntimeStatus(summaryKey)
pinned := containsDesktopString(f.GlobalPinnedTopics, id)
if topicHiddenAsRecoveryOnly(summary, pinned, runtimeSessionsByTopic[summaryKey]) {
continue
}
children = append(children, ProjectNode{
Key: "global_topic_" + id,
Kind: "global_topic",
Label: title,
TopicID: id,
ProjectColor: globalColor,
Turns: summary.displayTurns(),
CreatedAt: topicCreatedAtForTree(globalCreatedMap, id),
LastActivityAt: summary.lastActivityAt,
Open: open,
Running: running,
Status: status,
Pinned: pinned,
Children: runtimeSessionNodes("global", "", id, globalColor),
})
}
out = append(out, ProjectNode{
Key: "global_folder",
Kind: "global_folder",
Label: globalTitle,
Root: globalWorkspaceRoot(),
ProjectColor: globalColor,
Children: children,
})
}
// Project sections.
type projectTopics struct {
project desktopProject
titles map[string]string
sources map[string]string
createdAts map[string]int64
}
projectTopicResults := make([]projectTopics, len(f.Projects))
var topicLoadWg sync.WaitGroup
for i, p := range f.Projects {
topicLoadWg.Go(func() {
projectTopicResults[i] = projectTopics{
project: p,
titles: loadTopicTitles(p.Root),
sources: loadTopicTitleSources(p.Root),
createdAts: loadTopicCreatedAts(p.Root),
}
})
}
topicLoadWg.Wait()
for _, loaded := range projectTopicResults {
p := loaded.project
title := p.Title
if title == "" {
title = workspaceName(p.Root)
}
node := ProjectNode{
Key: "project_" + p.Root,
Kind: "project",
Root: p.Root,
Pinned: containsDesktopString(f.PinnedProjects, p.Root),
IsolatedWorktree: worktree.IsManagedPath(p.Root, config.DeliveryWorktreeDir()),
}
// Gather topics: explicit topic list + all known topic titles.
titleMap := loaded.titles
titleSources := loaded.sources
createdMap := loaded.createdAts
topicIDs := pinnedTopicIDs(orderedTopicIDs(p.Topics, titleMap), p.PinnedTopics)
children := make([]ProjectNode, 0, len(topicIDs))
for _, tid := range topicIDs {
if deletedTopicSet[tid] {
continue
}
topicTitle := strings.TrimSpace(titleMap[tid])
if topicTitle == "" {
topicTitle = defaultTopicTitle
}
topicTitle = a.localizedTopicTitle(topicTitle, titleSources[tid])
summaryKey := topicSummaryKey("project", p.Root, tid)
summary := topicSummaries[summaryKey]
open, running, status := topicRuntimeStatus(summaryKey)
pinned := containsDesktopString(p.PinnedTopics, tid)
if topicHiddenAsRecoveryOnly(summary, pinned, runtimeSessionsByTopic[summaryKey]) {
continue
}
children = append(children, ProjectNode{
Key: "topic_" + tid,
Kind: "topic",
Label: topicTitle,
Root: p.Root,
TopicID: tid,
ProjectColor: p.Color,
Turns: summary.displayTurns(),
CreatedAt: topicCreatedAtForTree(createdMap, tid),
LastActivityAt: summary.lastActivityAt,
Open: open,
Running: running,
Status: status,
Pinned: pinned,
Children: runtimeSessionNodes("project", p.Root, tid, p.Color),
})
}
node.Label = title
node.ProjectColor = p.Color
node.Children = children
out = append(out, node)
}
return applyPinnedProjectOrder(applyProjectTreeOrder(out, f.SidebarOrder), f.PinnedProjects)
}
func topicSummaryKey(scope, workspaceRoot, topicID string) string {
if scope == "global" {
return "global::" + topicID
@@ -8680,6 +8332,29 @@ func pinnedTabSessionPath(dir, sessionPath string) (string, bool) {
return path, true
}
func pinnedTabSessionPathForBuild(scope, workspaceRoot, targetDir, sessionPath string) (string, bool) {
if path, ok := pinnedTabSessionPath(targetDir, sessionPath); ok {
return path, true
}
// Before per-project storage, desktop tabs persisted exact paths under the
// global session directory. Accept only that owned legacy root, and require
// project ownership metadata before routing a project tab through it.
legacyDir := config.SessionDir()
path, ok := pinnedTabSessionPath(legacyDir, sessionPath)
if !ok {
return "", false
}
meta, hasMeta, err := agent.LoadBranchMeta(path)
if strings.TrimSpace(scope) == "project" {
if err != nil || !hasMeta || meta.Scope != "project" || !sameProjectRoot(meta.WorkspaceRoot, workspaceRoot) {
return "", false
}
} else if err == nil && hasMeta && meta.Scope == "project" {
return "", false
}
return path, true
}
// saveTabSessionMeta persists the tab's scope/topic/mode fields into the
// session's branch-meta sidecar at path. Tab fields are snapshotted under a.mu
// (controller reads happen off-lock) so a concurrent tab mutation can't tear
@@ -9125,152 +8800,6 @@ type topicSessionDirIndex struct {
byTopic map[string][]topicSessionMatch
}
// sessionListCache caches ListSessions results per directory so that
// ListProjectTree (called on every sidebar render) does not re-read every
// session dir from disk. Session mutations invalidate only their own
// directories; the global generation remains a correctness fallback for
// changes whose affected directories are not known.
type sessionListCacheEntry struct {
infos []agent.SessionInfo
titles map[string]string
cachedAt time.Time
}
// Scoped invalidation handles in-process mutations immediately. The TTL is the
// low-frequency reconciliation path for CLI, older Desktop, and external
// process writes that cannot emit an event into this process.
const sessionListCacheTTL = 30 * time.Second
type sessionListCacheToken struct {
globalVersion uint64
dirVersion uint64
}
type sessionListCache struct {
mu sync.Mutex
byDir map[string]sessionListCacheEntry
dirVersions map[string]uint64
nextDirVersion uint64
version atomic.Uint64
}
func (c *sessionListCache) get(dir string) ([]agent.SessionInfo, map[string]string, bool) {
dir = sessionListCacheDirKey(dir)
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.byDir[dir]
if !ok {
return nil, nil, false
}
if e.cachedAt.IsZero() || time.Since(e.cachedAt) >= sessionListCacheTTL {
delete(c.byDir, dir)
return nil, nil, false
}
return e.infos, e.titles, true
}
func (c *sessionListCache) versionToken(dir string) sessionListCacheToken {
dir = sessionListCacheDirKey(dir)
c.mu.Lock()
defer c.mu.Unlock()
if c.dirVersions == nil {
c.dirVersions = map[string]uint64{}
}
dirVersion := c.dirVersions[dir]
if dirVersion == 0 {
c.nextDirVersion++
dirVersion = c.nextDirVersion
c.dirVersions[dir] = dirVersion
}
return sessionListCacheToken{
globalVersion: c.version.Load(),
dirVersion: dirVersion,
}
}
func (c *sessionListCache) put(dir string, infos []agent.SessionInfo, titles map[string]string, token sessionListCacheToken) {
dir = sessionListCacheDirKey(dir)
if c.version.Load() != token.globalVersion {
return
}
c.mu.Lock()
defer c.mu.Unlock()
if c.version.Load() != token.globalVersion || c.dirVersions[dir] != token.dirVersion {
return
}
if c.byDir == nil {
c.byDir = map[string]sessionListCacheEntry{}
}
c.byDir[dir] = sessionListCacheEntry{infos: infos, titles: titles, cachedAt: time.Now()}
}
func (c *sessionListCache) invalidateDirs(dirs ...string) bool {
keys := make(map[string]struct{}, len(dirs))
for _, dir := range dirs {
if key := sessionListCacheDirKey(dir); key != "" {
keys[key] = struct{}{}
}
}
if len(keys) == 0 {
return false
}
c.mu.Lock()
defer c.mu.Unlock()
if c.dirVersions == nil {
c.dirVersions = map[string]uint64{}
}
for key := range keys {
c.nextDirVersion++
c.dirVersions[key] = c.nextDirVersion
delete(c.byDir, key)
}
return true
}
// forgetDirs drops directories that are no longer part of the project
// lifecycle. Tokens are never reused, so an in-flight scan from before the
// removal cannot repopulate the cache after the entry is forgotten or re-added.
func (c *sessionListCache) forgetDirs(dirs ...string) bool {
keys := make(map[string]struct{}, len(dirs))
for _, dir := range dirs {
if key := sessionListCacheDirKey(dir); key != "" {
keys[key] = struct{}{}
}
}
if len(keys) == 0 {
return false
}
c.mu.Lock()
defer c.mu.Unlock()
for key := range keys {
delete(c.byDir, key)
delete(c.dirVersions, key)
}
return true
}
func (c *sessionListCache) invalidate() {
c.mu.Lock()
c.version.Add(1)
c.byDir = map[string]sessionListCacheEntry{}
c.dirVersions = map[string]uint64{}
c.mu.Unlock()
}
func sessionListCacheDirKey(dir string) string {
return projectRootKey(dir)
}
func sessionListCacheDirForPath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
return filepath.Dir(path)
}
var projectSessionCache = &sessionListCache{byDir: map[string]sessionListCacheEntry{}}
// mergeSessionInfos merges one directory's session listing into the maps used by
// ListProjectTree. The result collection loop calls it serially.
func mergeSessionInfos(dir string, infos []agent.SessionInfo, titles map[string]string, sessionInfos map[string]agent.SessionInfo, sessionTitles map[string]string, topicSummaries map[string]topicSummary) {
+97 -279
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,9 +956,11 @@ func TestTopicMigrationMarkerRescansWhenSessionFileChanges(t *testing.T) {
}
writeLegacySession(t, dir, "first.jsonl", "first legacy prompt", time.Now().Add(-time.Hour))
// First render migrates the legacy session and, with nothing deferred, stamps
// the one-shot marker so later renders can skip the scan.
NewApp().ListProjectTree()
// Background catalog reconciliation migrates the legacy session and, with
// nothing deferred, stamps the one-shot marker. Tree reads never do this I/O.
app := NewApp()
firstTopicID := legacySessionTopicID(filepath.Join(dir, "first.jsonl"))
waitForCatalogTopic(t, app, "global", "", firstTopicID)
if _, err := os.Stat(filepath.Join(dir, topicMigrationMarker)); err != nil {
t.Fatalf("expected migration marker after a complete pass: %v", err)
}
@@ -1161,7 +969,8 @@ func TestTopicMigrationMarkerRescansWhenSessionFileChanges(t *testing.T) {
// gate and gets a fresh migration pass.
time.Sleep(10 * time.Millisecond)
second := writeLegacySession(t, dir, "second.jsonl", "second legacy prompt", time.Now())
NewApp().ListProjectTree()
app.requestSessionCatalogReconcile(dir)
waitForCatalogTopic(t, app, "global", "", legacySessionTopicID(second))
meta, ok, err := agent.LoadBranchMeta(second)
if err != nil {
t.Fatalf("load second meta: %v", err)
@@ -1574,7 +1383,8 @@ func TestLegacySessionTopicIDsKeepNormalizedNameCollisionsDistinct(t *testing.T)
t.Fatalf("normalized legacy topic IDs collided: %q", dottedTopic)
}
nodes := NewApp().ListProjectTree()
app := NewApp()
nodes := waitForCatalogTopic(t, app, "global", "", dottedTopic)
if len(nodes) != 1 || nodes[0].Kind != "global_folder" {
t.Fatalf("project tree = %#v, want global folder", nodes)
}
@@ -1590,7 +1400,7 @@ func TestLegacySessionTopicIDsKeepNormalizedNameCollisionsDistinct(t *testing.T)
}
}
func TestDefaultGlobalTabGetsMigratedTopicID(t *testing.T) {
func TestDefaultGlobalTabDoesNotWaitForLegacyMigration(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
@@ -1616,19 +1426,19 @@ func TestDefaultGlobalTabGetsMigratedTopicID(t *testing.T) {
defer tab.Ctrl.Close()
}
wantTopicID := legacySessionTopicID(sessionPath)
if tab.TopicID != wantTopicID {
t.Fatalf("tab topicID = %q, want %q", tab.TopicID, wantTopicID)
if tab.TopicID != "" {
t.Fatalf("blank tab synchronously adopted legacy topic %q", tab.TopicID)
}
if tab.Ctrl == nil {
t.Fatalf("tab controller was not built")
}
if tab.Ctrl.SessionPath() != sessionPath {
t.Fatalf("tab session path = %q, want %q", tab.Ctrl.SessionPath(), sessionPath)
if tab.Ctrl.SessionPath() == sessionPath {
t.Fatalf("blank tab synchronously adopted legacy session %q", sessionPath)
}
f := loadTabsFile()
if len(f.Tabs) != 1 || f.Tabs[0].ID != "tab_legacy" || f.Tabs[0].TopicID != wantTopicID {
t.Fatalf("desktop tabs file = %+v, want tab id and migrated topic", f)
wantTopicID := legacySessionTopicID(sessionPath)
nodes := waitForCatalogTopic(t, app, "global", "", wantTopicID)
if len(nodes) != 1 || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != wantTopicID {
t.Fatalf("legacy history was not eventually indexed: %+v", nodes)
}
}
@@ -2937,7 +2747,7 @@ func TestRestoreGlobalTopicSessionReindexesProjectTree(t *testing.T) {
topicID := legacySessionTopicID(sessionPath)
app := NewApp()
nodes := app.ListProjectTree()
nodes := waitForCatalogTopic(t, app, "global", "", topicID)
if len(nodes) != 1 || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topicID {
t.Fatalf("legacy session should start in Global, got %#v", nodes)
}
@@ -2958,7 +2768,7 @@ func TestRestoreGlobalTopicSessionReindexesProjectTree(t *testing.T) {
if got := app.ListTrashedSessions(); len(got) != 0 {
t.Fatalf("trash should be empty after restore, got %#v", got)
}
nodes = app.ListProjectTree()
nodes = waitForCatalogTopic(t, app, "global", "", topicID)
if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topicID {
t.Fatalf("restored global session should reappear in Global, got %#v", nodes)
}
@@ -2996,7 +2806,7 @@ func TestRestoreProjectTopicSessionReindexesProjectTree(t *testing.T) {
if err := app.RestoreSession(trashPath); err != nil {
t.Fatalf("restore project session: %v", err)
}
nodes := app.ListProjectTree()
nodes := waitForCatalogTopic(t, app, "project", projectRoot, topicID)
if len(nodes) != 1 || nodes[0].Kind != "project" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topicID {
t.Fatalf("restored project session should reappear in project tree, got %#v", nodes)
}
@@ -3024,7 +2834,7 @@ func TestOpenProjectTabResolvesProjectSessionFromLegacyDir(t *testing.T) {
sessionPath := writeTopicSessionWithPrompt(t, dir, "legacy-project.jsonl", topicID, topicTitle, projectRoot, "legacy project prompt", time.Now())
app := NewApp()
nodes := app.ListProjectTree()
nodes := waitForCatalogTopic(t, app, "project", projectRoot, topicID)
if len(nodes) != 1 || nodes[0].Kind != "project" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topicID {
t.Fatalf("legacy project session should appear in project tree, got %#v", nodes)
}
@@ -3885,7 +3695,8 @@ func TestProjectTreeMigratesCLISessionFromProjectDir(t *testing.T) {
sessionPath := writeLegacySession(t, dir, "cli-project.jsonl", "cli project prompt", time.Now())
wantTopicID := legacySessionTopicID(sessionPath)
nodes := NewApp().ListProjectTree()
app := NewApp()
nodes := waitForCatalogTopic(t, app, "project", projectRoot, wantTopicID)
if len(nodes) != 1 || nodes[0].Kind != "project" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != wantTopicID {
t.Fatalf("project CLI session should appear in project tree, got %#v; want topic %q", nodes, wantTopicID)
}
@@ -3905,7 +3716,8 @@ func TestProjectTreeMigratesNewCLISessionAfterProjectDirMarker(t *testing.T) {
first := writeLegacySession(t, dir, "first-cli-project.jsonl", "first cli project prompt", time.Now().Add(-time.Hour))
firstTopicID := legacySessionTopicID(first)
nodes := NewApp().ListProjectTree()
app := NewApp()
nodes := waitForCatalogTopic(t, app, "project", projectRoot, firstTopicID)
if len(nodes) != 1 || nodes[0].Kind != "project" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != firstTopicID {
t.Fatalf("first project CLI session should appear in project tree, got %#v; want topic %q", nodes, firstTopicID)
}
@@ -3917,7 +3729,12 @@ func TestProjectTreeMigratesNewCLISessionAfterProjectDirMarker(t *testing.T) {
second := writeLegacySession(t, dir, "second-cli-project.jsonl", "second cli project prompt", time.Now())
secondTopicID := legacySessionTopicID(second)
nodes = NewApp().ListProjectTree()
app.requestSessionCatalogReconcile(dir)
nodes = waitForCatalogTreeCondition(t, app, "a reconciled newest project CLI session", func(nodes []ProjectNode) bool {
return len(nodes) == 1 && nodes[0].Kind == "project" && len(nodes[0].Children) == 2 &&
nodes[0].Children[0].TopicID == secondTopicID && nodes[0].Children[0].LastActivityAt > 0 &&
nodes[0].Children[1].TopicID == firstTopicID
})
if len(nodes) != 1 || nodes[0].Kind != "project" || len(nodes[0].Children) != 2 {
t.Fatalf("second project CLI session should trigger re-scan, got %#v", nodes)
}
@@ -3945,7 +3762,8 @@ func TestProjectTreeMigratesCLISessionFromGlobalWorkspaceDir(t *testing.T) {
}
wantTopicID := legacySessionTopicID(sessionPath)
nodes := NewApp().ListProjectTree()
app := NewApp()
nodes := waitForCatalogTopic(t, app, "global", "", wantTopicID)
if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != wantTopicID {
t.Fatalf("global workspace CLI session should appear in Global, got %#v; want topic %q", nodes, wantTopicID)
}
+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])
}
+32 -21
View File
@@ -368,9 +368,11 @@ func (s *Session) saveLocked(path string, mode sessionSaveMode) error {
}
}
s.markPersisted(path, digest, version, revision, rewriteVersion)
persistSessionListingProjection(path, msgs)
return nil
}
s.markPersisted(path, digest, version, decision.revision, rewriteVersion)
persistSessionListingProjection(path, msgs)
return nil
}
if decision.appendOnly && probe.native && mode != sessionSaveRewriteCompact {
@@ -421,6 +423,7 @@ func (s *Session) saveLocked(path string, mode sessionSaveMode) error {
}
}
s.markPersisted(path, digest, version, revision, rewriteVersion)
persistSessionListingProjection(path, msgs)
return nil
}
baseRevision = decision.revision
@@ -481,9 +484,25 @@ func (s *Session) saveLocked(path string, mode sessionSaveMode) error {
slog.Warn("session: keeping save after display index write failure", "path", path, "err", err)
}
s.markPersisted(path, digest, version, revision, rewriteVersion)
persistSessionListingProjection(path, msgs)
return nil
}
func persistSessionListingProjection(path string, msgs []provider.Message) {
preview, turns := SessionPreviewFromMessages(msgs)
if err := UpdateBranchMeta(path, false, func(meta *BranchMeta) error {
meta.Preview = preview
meta.Turns = turns
meta.SchemaVersion = BranchMetaCountsVersion
return nil
}); err != nil {
// JSONL/event log already committed. Listing metadata is a repairable
// projection and must never turn a successful transcript save into an
// application error.
slog.Warn("session: listing metadata update deferred", "path", path, "err", err)
}
}
func writeSessionMessages(path string, msgs []provider.Message) error {
// Write to a sibling tmp file then rename, so a crash mid-write can't
// leave a partial JSONL that won't reload. The fsync guards the anchor
@@ -1592,6 +1611,7 @@ type SessionInfo struct {
ModTime time.Time // compatibility alias for LastActivityAt
Preview string
Turns int
CountsKnown bool
Scope string
WorkspaceRoot string
TopicID string
@@ -2212,8 +2232,9 @@ func ListSessionOrder(dir string) ([]SessionOrderInfo, error) {
// ListSessions returns every non-empty *.jsonl session under dir,
// most-recently-active first, each with a preview line so the picker can show
// something the user recognises. A missing directory is not an error — it just
// means there's nothing to resume yet.
// something the user recognises. It never decodes a transcript: legacy counts
// remain explicitly unknown until the session catalog's single repair worker
// validates them. A missing directory is not an error.
func ListSessions(dir string) ([]SessionInfo, error) {
ordered, err := ListSessionOrder(dir)
if err != nil {
@@ -2222,32 +2243,22 @@ func ListSessions(dir string) ([]SessionInfo, error) {
var out []SessionInfo
for _, session := range ordered {
preview, turns := session.Preview, session.Turns
var previewErr error
if sessionListingCountsNeedRefresh(session.SchemaVersion, turns) {
// The sidecar's counts weren't recorded from content (a legacy session
// from before they were persisted), or an old zero may have swallowed a
// decode error. Decode once, then backfill + stamp the sidecar so every
// later listing is O(1) and a recovered session becomes visible again.
preview, turns, previewErr = previewSessionWithError(session.Path)
if previewErr == nil {
// Compare-and-apply under the metadata lock. A turn-end save may have
// advanced the transcript and its counts while this listing decoded;
// never overwrite that newer state with a stale backfill.
preview, turns, _ = updateSessionListingCountsIfCurrent(session, preview, turns)
}
}
if turns == 0 {
hasData := sessionArtifactsHaveContent(session.Path)
if previewErr != nil && hasData {
preview = "Session may be corrupted — " + filepath.Base(session.Path)
out = append(out, sessionInfoFromOrder(session, preview, 0))
if !sessionArtifactsHaveContent(session.Path) {
continue
}
if strings.TrimSpace(preview) == "" {
preview = "History is being indexed — " + filepath.Base(session.Path)
}
out = append(out, sessionInfoFromOrder(session, preview, turns, false))
continue
}
if turns == 0 {
// Never had user interaction — an empty conversation that should not
// appear in the history panel or the resume picker.
continue
}
out = append(out, sessionInfoFromOrder(session, preview, turns))
out = append(out, sessionInfoFromOrder(session, preview, turns, true))
}
return out, nil
}
+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"},
+3
View File
@@ -167,6 +167,9 @@ func RunWithBuildInfo(args []string, info BuildInfo) int {
case "session":
configureCLIThemeFromConfig()
return sessionCommand(rest)
case "sessions":
configureCLIThemeFromConfig()
return sessionsCommand(rest)
case "hook", "hooks":
configureCLIThemeFromConfig()
return hookCommand(rest)
+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 = "-"
+109
View File
@@ -0,0 +1,109 @@
package cli
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"reasonix/internal/config"
"reasonix/internal/sessioncatalog"
)
func sessionsCommand(args []string) int {
if len(args) == 0 || args[0] != "reindex" {
fmt.Fprintln(os.Stderr, "usage: reasonix sessions reindex [--dir PATH] [--json]")
return 2
}
fs := flag.NewFlagSet("sessions reindex", flag.ContinueOnError)
var dirs stringListFlag
jsonOut := fs.Bool("json", false, "print the rebuilt catalog status as JSON")
fs.Var(&dirs, "dir", "session directory to index; repeat for multiple directories")
if code, ok := parseCommandFlags(fs, args[1:]); !ok {
return code
}
if fs.NArg() != 0 {
fmt.Fprintln(os.Stderr, "usage: reasonix sessions reindex [--dir PATH] [--json]")
return 2
}
if len(dirs) == 0 {
status, err := sessioncatalog.Rebuild(context.Background(), sessioncatalog.DefaultPath(), defaultSessionCatalogTargets())
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
return printSessionCatalogRebuild(status, *jsonOut)
}
targets := make([]sessioncatalog.DirectoryTarget, 0, len(dirs))
seen := map[string]bool{}
for _, dir := range dirs {
dir = filepath.Clean(strings.TrimSpace(dir))
if dir == "." || dir == "" || seen[dir] {
continue
}
seen[dir] = true
targets = append(targets, sessioncatalog.DirectoryTarget{Path: dir, Scope: "global"})
}
status, err := sessioncatalog.Rebuild(context.Background(), sessioncatalog.DefaultPath(), targets)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
return printSessionCatalogRebuild(status, *jsonOut)
}
func printSessionCatalogRebuild(status sessioncatalog.Status, jsonOut bool) int {
if jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(status); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
return 0
}
fmt.Printf("rebuilt session catalog: %d sessions, revision %d\n", status.Indexed, status.Revision)
return 0
}
func defaultSessionCatalogTargets() []sessioncatalog.DirectoryTarget {
type project struct {
Root string `json:"root"`
}
type projectFile struct {
Projects []project `json:"projects"`
}
home := config.ReasonixHomeDir()
var saved projectFile
if data, err := os.ReadFile(filepath.Join(home, "desktop-projects.json")); err == nil {
_ = json.Unmarshal(data, &saved)
}
seen := map[string]bool{}
targets := make([]sessioncatalog.DirectoryTarget, 0, len(saved.Projects)+2)
add := func(target sessioncatalog.DirectoryTarget) {
target.Path = filepath.Clean(strings.TrimSpace(target.Path))
if target.Path == "." || target.Path == "" || seen[target.Path] {
return
}
seen[target.Path] = true
targets = append(targets, target)
}
add(sessioncatalog.DirectoryTarget{Path: config.SessionDir(), Scope: "global"})
add(sessioncatalog.DirectoryTarget{
Path: config.ProjectSessionDir(filepath.Join(home, "global-workspace")),
Scope: "global",
})
for _, savedProject := range saved.Projects {
root := strings.TrimSpace(savedProject.Root)
if root == "" {
continue
}
add(sessioncatalog.DirectoryTarget{
Path: config.ProjectSessionDir(root), Scope: "project", WorkspaceRoot: root,
})
}
return targets
}
+6
View File
@@ -202,6 +202,7 @@ func cliCompletionRootSpec() cliCompletionSpec {
completionSpec("run", []cliCompletionFlag{model, completionFlag("--max-steps --dir", cliCompletionStaticValue), help}),
),
completionSpec("doctor", []cliCompletionFlag{completionFlag("--json", cliCompletionNoValue), help},
completionSpec("sessions", []cliCompletionFlag{completionFlag("--json", cliCompletionNoValue), help}),
completionSpec("repair", []cliCompletionFlag{
completionFlag("--root", cliCompletionStaticValue), completionFlag("--apply --project --json", cliCompletionNoValue), help,
}),
@@ -212,6 +213,11 @@ func cliCompletionRootSpec() cliCompletionSpec {
completionFlag("--root --timeout", cliCompletionStaticValue), completionFlag("--json --live", cliCompletionNoValue), help,
}),
),
completionSpec("sessions", []cliCompletionFlag{help},
completionSpec("reindex", []cliCompletionFlag{
completionFlag("--json", cliCompletionNoValue), completionFlag("--dir", cliCompletionPathValue), help,
}),
),
completionSpec("report", []cliCompletionFlag{help},
completionSpec("list", []cliCompletionFlag{help}),
completionSpec("show", []cliCompletionFlag{help}),
+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)
}
}
+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
+69 -2
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 {
@@ -370,28 +374,91 @@ func SetEnabled(reasonixHome, name string, enabled bool) error {
}
func LoadInstalled(reasonixHome string) ([]InstalledPackage, []string) {
stateMu.Lock()
defer stateMu.Unlock()
st, err := LoadState(reasonixHome)
if err != nil {
return nil, []string{err.Error()}
}
var out []InstalledPackage
var warnings []string
for _, installed := range st.Plugins {
stateChanged := false
for i := range st.Plugins {
installed := st.Plugins[i]
if !installed.Enabled {
continue
}
root := ResolveRoot(reasonixHome, installed.Root)
pkg, pkgWarnings, err := ParseDir(root)
if err != nil && strings.Contains(err.Error(), "missing apiVersion") && managedPluginRoot(reasonixHome, installed, root) {
legacy, _, migrateErr := ParseNativeForMigrate(root)
if migrateErr == nil {
var data []byte
data, migrateErr = MigrateManifestToV2(legacy)
if migrateErr == nil {
migrateErr = WriteMigratedManifestV2(root, data)
}
}
if migrateErr == nil {
pkg, pkgWarnings, err = ParseDir(root)
if err == nil {
warnings = append(warnings, fmt.Sprintf("%s: automatically migrated managed manifest to %s (backup: %s.bak)", installed.Name, ManifestAPIVersionV2, NativeManifest))
}
} else {
err = fmt.Errorf("automatic managed-plugin migration failed: %w", migrateErr)
}
}
if err != nil {
warnings = append(warnings, fmt.Sprintf("%s: %v", installed.Name, err))
reason := err.Error()
if st.Plugins[i].Status != PluginStatusDisabledIncompatible || st.Plugins[i].StatusReason != reason {
st.Plugins[i].Status = PluginStatusDisabledIncompatible
st.Plugins[i].StatusReason = reason
stateChanged = true
}
remediation := "repair or reinstall the plugin"
if strings.Contains(reason, "missing apiVersion") {
remediation = fmt.Sprintf("reasonix plugin migrate %s --to-v2", installed.Name)
}
warnings = append(warnings, fmt.Sprintf("%s: %s: %v; remediation: %s", installed.Name, PluginStatusDisabledIncompatible, err, remediation))
continue
}
if st.Plugins[i].Status != "" || st.Plugins[i].StatusReason != "" {
st.Plugins[i].Status = ""
st.Plugins[i].StatusReason = ""
stateChanged = true
}
out = append(out, InstalledPackage{Installed: installed, Package: pkg, Warnings: pkgWarnings})
}
if stateChanged {
if err := SaveState(reasonixHome, st); err != nil {
warnings = append(warnings, "persist plugin compatibility status: "+err.Error())
}
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Installed.Name < out[j].Installed.Name })
return out, warnings
}
func managedPluginRoot(reasonixHome string, installed InstalledPlugin, root string) bool {
if filepath.IsAbs(strings.TrimSpace(installed.Root)) {
return false
}
managed := filepath.Clean(PluginsDir(reasonixHome))
rel, err := filepath.Rel(managed, filepath.Clean(root))
if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return false
}
current := managed
for _, part := range strings.Split(rel, string(filepath.Separator)) {
current = filepath.Join(current, part)
info, err := os.Lstat(current)
if err != nil || info.Mode()&os.ModeSymlink != 0 {
return false
}
}
info, err := os.Lstat(filepath.Join(root, NativeManifest))
return err == nil && info.Mode()&os.ModeSymlink == 0
}
func ResolveRoot(reasonixHome, root string) string {
if filepath.IsAbs(root) {
return filepath.Clean(root)
+763
View File
@@ -0,0 +1,763 @@
package sessioncatalog
import (
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"sync/atomic"
"time"
_ "modernc.org/sqlite"
)
const defaultMissingGrace = 30 * time.Second
type Catalog struct {
db *sql.DB
opts Options
revision atomic.Uint64
statusMu sync.RWMutex
status Status
writeCh chan string
writeMu sync.Mutex
writeQueued map[string]SessionRecord
mutationMu sync.RWMutex
removedPaths sync.Map
repairCh chan string
repairQueued sync.Map
reconcileCh chan DirectoryTarget
reconcileQueued sync.Map
pathCh chan sessionPathRequest
pathQueued sync.Map
directoryLocksMu sync.Mutex
directoryLocks map[string]*sync.Mutex
workerCtx context.Context
workerCancel context.CancelFunc
stop chan struct{}
stopOnce sync.Once
workers sync.WaitGroup
closeDBOnce sync.Once
}
type sessionPathRequest struct {
target DirectoryTarget
path string
}
type pageCursor struct {
Pinned int `json:"p"`
Activity int64 `json:"a"`
TopicID string `json:"t"`
}
func Open(ctx context.Context, opts Options) (*Catalog, error) {
if opts.Path == "" {
opts.Path = DefaultPath()
}
if opts.Now == nil {
opts.Now = time.Now
}
if opts.MissingGrace <= 0 {
opts.MissingGrace = defaultMissingGrace
}
if opts.QueueCapacity <= 0 {
opts.QueueCapacity = 1024
}
c := &Catalog{
opts: opts,
writeCh: make(chan string, opts.QueueCapacity),
writeQueued: map[string]SessionRecord{},
repairCh: make(chan string, opts.QueueCapacity),
reconcileCh: make(chan DirectoryTarget, 64),
pathCh: make(chan sessionPathRequest, opts.QueueCapacity),
directoryLocks: map[string]*sync.Mutex{},
stop: make(chan struct{}),
status: Status{State: StateOpening, Path: opts.Path},
}
mode := ModeDisk
useMemory := opts.InMemory || catalogPathLooksRemote(opts.Path)
if !useMemory {
if err := os.MkdirAll(filepath.Dir(opts.Path), 0o755); err != nil {
useMemory = true
c.status.LastError = err.Error()
} else if catalogFilesystemRemote(filepath.Dir(opts.Path)) {
useMemory = true
c.status.LastError = "session catalog cache is on a remote filesystem; using memory"
}
}
if useMemory {
mode = ModeMemory
}
db, err := openDatabase(ctx, opts.Path, mode)
if err != nil && mode == ModeDisk {
quarantined := quarantineCatalog(opts.Path, opts.Now())
c.status.QuarantinedPath = quarantined
db, err = openDatabase(ctx, opts.Path, mode)
}
if err != nil && mode == ModeDisk {
mode = ModeMemory
c.status.LastError = err.Error()
db, err = openDatabase(ctx, opts.Path, mode)
}
if err != nil {
return nil, err
}
c.db = db
c.status.Mode = mode
c.status.State = StateReady
if mode == ModeMemory {
c.status.Path = ""
}
if err := c.loadStatus(ctx); err != nil {
_ = db.Close()
return nil, err
}
c.workerCtx, c.workerCancel = context.WithCancel(context.Background())
c.workers.Add(1)
go c.writerLoop()
c.workers.Add(1)
go c.reconcileLoop()
c.workers.Add(1)
go c.sessionPathLoop()
if !opts.DisableRepair {
c.workers.Add(1)
go c.repairLoop()
c.enqueuePersistedRepairs(ctx)
}
return c, nil
}
func openDatabase(ctx context.Context, path string, mode Mode) (*sql.DB, error) {
dsn := ""
if mode == ModeMemory {
dsn = fmt.Sprintf("file:reasonix-session-catalog-%d?mode=memory&cache=shared", time.Now().UnixNano())
} else {
u := &url.URL{Scheme: "file", Path: path}
dsn = u.String() + "?_pragma=busy_timeout%28150%29&_pragma=foreign_keys%281%29"
}
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(4)
db.SetMaxIdleConns(2)
if err := db.PingContext(ctx); err != nil {
_ = db.Close()
return nil, err
}
if mode == ModeDisk {
if _, err := db.ExecContext(ctx, `PRAGMA journal_mode=WAL`); err != nil {
_ = db.Close()
return nil, err
}
}
if _, err := db.ExecContext(ctx, `PRAGMA synchronous=NORMAL`); err != nil {
_ = db.Close()
return nil, err
}
var integrity string
if err := db.QueryRowContext(ctx, `PRAGMA integrity_check`).Scan(&integrity); err != nil || integrity != "ok" {
_ = db.Close()
if err != nil {
return nil, err
}
return nil, fmt.Errorf("session catalog integrity check: %s", integrity)
}
if err := migrateSchema(ctx, db); err != nil {
_ = db.Close()
return nil, err
}
return db, nil
}
func quarantineCatalog(path string, now time.Time) string {
if strings.TrimSpace(path) == "" {
return ""
}
quarantined := fmt.Sprintf("%s.corrupt-%d", path, now.UnixMilli())
if err := os.Rename(path, quarantined); err != nil {
return ""
}
for _, suffix := range []string{"-wal", "-shm"} {
_ = os.Rename(path+suffix, quarantined+suffix)
}
return quarantined
}
func catalogPathLooksRemote(path string) bool {
if os.Getenv("REASONIX_SESSION_CATALOG_MEMORY") == "1" {
return true
}
clean := filepath.Clean(path)
if runtime.GOOS == "windows" && strings.HasPrefix(clean, `\\`) {
return true
}
slash := filepath.ToSlash(clean)
return strings.HasPrefix(slash, "/net/") || strings.HasPrefix(slash, "/nfs/") || strings.HasPrefix(slash, "/afs/")
}
func (c *Catalog) loadStatus(ctx context.Context) error {
var revision uint64
if err := c.db.QueryRowContext(ctx, `SELECT revision FROM catalog_state WHERE id=1`).Scan(&revision); err != nil {
return err
}
c.revision.Store(revision)
c.statusMu.Lock()
c.status.Revision = revision
c.statusMu.Unlock()
c.refreshCounts(ctx)
return nil
}
func (c *Catalog) Status() Status {
if c == nil {
return Status{State: StateDegraded, Mode: ModeMemory, LastError: "session catalog unavailable"}
}
c.statusMu.RLock()
defer c.statusMu.RUnlock()
return c.status
}
func (c *Catalog) refreshCounts(ctx context.Context) {
if c == nil || c.db == nil {
return
}
var indexed, pending, total int64
_ = c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions`).Scan(&indexed)
_ = c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions WHERE turns_state='unknown'`).Scan(&pending)
_ = c.db.QueryRowContext(ctx, `SELECT COALESCE(SUM(total), 0) FROM catalog_directories`).Scan(&total)
c.statusMu.Lock()
c.status.Indexed = indexed
c.status.Total = total
c.status.RepairPending = pending
c.status.Revision = c.revision.Load()
c.statusMu.Unlock()
}
func normalizeScope(scope, root string) (string, string) {
if strings.TrimSpace(scope) != "project" {
return "global", ""
}
return "project", strings.TrimSpace(root)
}
func normalizeSessionRecord(record SessionRecord) SessionRecord {
record.Path = filepath.Clean(record.Path)
if record.Directory == "" {
record.Directory = filepath.Dir(record.Path)
}
record.Directory = filepath.Clean(record.Directory)
record.Scope, record.WorkspaceRoot = normalizeScope(record.Scope, record.WorkspaceRoot)
if record.TurnsState == "" {
record.TurnsState = TurnsUnknown
}
if record.Health == "" {
record.Health = HealthOK
}
return record
}
func (c *Catalog) EnqueueSession(record SessionRecord) bool {
if c == nil {
return false
}
record = normalizeSessionRecord(record)
c.removedPaths.Delete(record.Path)
c.writeMu.Lock()
if _, loaded := c.writeQueued[record.Path]; loaded {
c.writeQueued[record.Path] = record
c.writeMu.Unlock()
return true
}
c.writeQueued[record.Path] = record
select {
case <-c.stop:
delete(c.writeQueued, record.Path)
c.writeMu.Unlock()
return false
case c.writeCh <- record.Path:
c.writeMu.Unlock()
return true
default:
delete(c.writeQueued, record.Path)
c.writeMu.Unlock()
return false
}
}
func (c *Catalog) takeQueuedWrite(path string) (SessionRecord, bool) {
c.writeMu.Lock()
defer c.writeMu.Unlock()
record, ok := c.writeQueued[path]
if ok {
delete(c.writeQueued, path)
}
return record, ok
}
func (c *Catalog) writerLoop() {
defer c.workers.Done()
ticker := time.NewTicker(20 * time.Millisecond)
defer ticker.Stop()
pending := map[string]SessionRecord{}
flush := func() {
if len(pending) == 0 {
return
}
records := make([]SessionRecord, 0, len(pending))
for _, record := range pending {
records = append(records, record)
}
pending = map[string]SessionRecord{}
ctx, cancel := context.WithTimeout(c.workerCtx, time.Second)
_ = c.upsertSessions(ctx, records, nil, "write")
cancel()
}
for {
select {
case path := <-c.writeCh:
if record, ok := c.takeQueuedWrite(path); ok {
pending[path] = record
}
if len(pending) >= 64 {
flush()
}
case <-ticker.C:
flush()
case <-c.stop:
for {
select {
case path := <-c.writeCh:
if record, ok := c.takeQueuedWrite(path); ok {
pending[path] = record
}
default:
flush()
return
}
}
}
}
}
func (c *Catalog) UpsertSession(ctx context.Context, record SessionRecord) error {
return c.upsertSessions(ctx, []SessionRecord{normalizeSessionRecord(record)}, nil, "write")
}
func (c *Catalog) upsertSessions(ctx context.Context, records []SessionRecord, generations map[string]int64, reason string) error {
if len(records) == 0 {
return nil
}
c.mutationMu.RLock()
defer c.mutationMu.RUnlock()
filtered := records[:0]
for _, record := range records {
if _, removed := c.removedPaths.Load(filepath.Clean(record.Path)); !removed {
filtered = append(filtered, record)
}
}
records = filtered
if len(records) == 0 {
return nil
}
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
return err
}
affected := map[TopicKey]struct{}{}
roots := map[string]struct{}{}
directoryGenerations := map[string]int64{}
for _, raw := range records {
record := normalizeSessionRecord(raw)
var previous TopicKey
if err := tx.QueryRowContext(ctx, `SELECT scope,workspace_root,topic_id FROM catalog_sessions WHERE path=?`, record.Path).
Scan(&previous.Scope, &previous.WorkspaceRoot, &previous.TopicID); err == nil && previous.TopicID != "" {
affected[previous] = struct{}{}
} else if err != nil && !errors.Is(err, sql.ErrNoRows) {
_ = tx.Rollback()
return err
}
generation := int64(0)
if generations != nil {
generation = generations[record.Path]
} else if cached, ok := directoryGenerations[record.Directory]; ok {
generation = cached
} else {
_ = tx.QueryRowContext(ctx, `SELECT scan_generation FROM catalog_directories WHERE path=?`, record.Directory).Scan(&generation)
directoryGenerations[record.Directory] = generation
}
if _, err := tx.ExecContext(ctx, `INSERT INTO catalog_sessions(
path,directory,scope,workspace_root,topic_id,topic_title,custom_title,
created_at,last_activity_at,preview,turns,turns_state,recovered,
recovery_reason,recovery_digest,parent_id,content_fingerprint,
meta_fingerprint,health,missing_since,seen_generation
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(path) DO UPDATE SET
directory=excluded.directory, scope=excluded.scope,
workspace_root=excluded.workspace_root, topic_id=excluded.topic_id,
topic_title=excluded.topic_title, custom_title=excluded.custom_title,
created_at=excluded.created_at, last_activity_at=excluded.last_activity_at,
preview=excluded.preview, turns=excluded.turns,
turns_state=excluded.turns_state, recovered=excluded.recovered,
recovery_reason=excluded.recovery_reason,
recovery_digest=excluded.recovery_digest, parent_id=excluded.parent_id,
content_fingerprint=excluded.content_fingerprint,
meta_fingerprint=excluded.meta_fingerprint, health=excluded.health,
missing_since=0, seen_generation=MAX(catalog_sessions.seen_generation, excluded.seen_generation)`,
record.Path, record.Directory, record.Scope, record.WorkspaceRoot,
record.TopicID, record.TopicTitle, record.CustomTitle, record.CreatedAt,
record.LastActivityAt, record.Preview, record.Turns, record.TurnsState,
record.Recovered, record.RecoveryReason, record.RecoveryDigest,
record.ParentID, record.ContentFingerprint, record.MetaFingerprint,
record.Health, 0, generation); err != nil {
_ = tx.Rollback()
return err
}
if record.TopicID != "" {
affected[TopicKey{Scope: record.Scope, WorkspaceRoot: record.WorkspaceRoot, TopicID: record.TopicID}] = struct{}{}
}
roots[record.WorkspaceRoot] = struct{}{}
}
for key := range affected {
if err := recomputeTopic(ctx, tx, key); err != nil {
_ = tx.Rollback()
return err
}
}
revision, err := bumpRevision(ctx, tx)
if err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
c.publishRevision(revision, mapKeys(roots), reason)
c.refreshCounts(ctx)
return nil
}
// RemoveSession immediately removes one projection row after an authoritative
// archive/delete. It never touches the transcript or sidecars. A tombstone
// suppresses writer work captured before the removal; a later exact index,
// save, or fresh directory scan clears it when the path is recreated.
func (c *Catalog) RemoveSession(ctx context.Context, path, reason string) error {
if c == nil || c.db == nil {
return nil
}
path = filepath.Clean(strings.TrimSpace(path))
if path == "." || path == "" {
return nil
}
// Serialize an authoritative removal with directory reconciliation. Without
// this boundary a scan that captured the old path just before an archive
// could clear the tombstone and reinsert the stale projection afterwards.
directoryLock := c.directoryLock(filepath.Dir(path))
directoryLock.Lock()
defer directoryLock.Unlock()
c.mutationMu.Lock()
defer c.mutationMu.Unlock()
c.removedPaths.Store(path, struct{}{})
c.writeMu.Lock()
delete(c.writeQueued, path)
c.writeMu.Unlock()
c.pathQueued.Delete(path)
c.repairQueued.Delete(path)
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
return err
}
var key TopicKey
err = tx.QueryRowContext(ctx, `SELECT scope,workspace_root,topic_id FROM catalog_sessions WHERE path=?`, path).
Scan(&key.Scope, &key.WorkspaceRoot, &key.TopicID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
_ = tx.Rollback()
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM catalog_sessions WHERE path=?`, path); err != nil {
_ = tx.Rollback()
return err
}
if key.TopicID != "" {
if err := recomputeTopic(ctx, tx, key); err != nil {
_ = tx.Rollback()
return err
}
}
revision, err := bumpRevision(ctx, tx)
if err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
c.publishRevision(revision, []string{key.WorkspaceRoot}, reason)
c.refreshCounts(ctx)
return nil
}
func recomputeTopic(ctx context.Context, tx *sql.Tx, key TopicKey) error {
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions WHERE scope=? AND workspace_root=? AND topic_id=?`, key.Scope, key.WorkspaceRoot, key.TopicID).Scan(&count); err != nil {
return err
}
if count == 0 {
_, err := tx.ExecContext(ctx, `DELETE FROM catalog_topics WHERE scope=? AND workspace_root=? AND topic_id=?`, key.Scope, key.WorkspaceRoot, key.TopicID)
return err
}
_, err := tx.ExecContext(ctx, `INSERT INTO catalog_topics(
scope,workspace_root,topic_id,title,turns,turns_state,created_at,
last_activity_at,recovery_state,health
) SELECT ?,?,?,
COALESCE(NULLIF((SELECT COALESCE(NULLIF(custom_title,''), NULLIF(topic_title,''), preview, '')
FROM catalog_sessions WHERE scope=? AND workspace_root=? AND topic_id=?
ORDER BY last_activity_at DESC, path ASC LIMIT 1),''), ?),
COALESCE(SUM(CASE WHEN turns_state='valid' THEN turns ELSE 0 END),0),
CASE WHEN SUM(CASE WHEN turns_state='corrupt' THEN 1 ELSE 0 END)>0 THEN 'corrupt'
WHEN SUM(CASE WHEN turns_state='unknown' THEN 1 ELSE 0 END)>0 THEN 'unknown'
ELSE 'valid' END,
COALESCE(MIN(NULLIF(created_at,0)),0), COALESCE(MAX(last_activity_at),0),
CASE WHEN SUM(CASE WHEN recovered=1 THEN 1 ELSE 0 END)=COUNT(*) THEN 'recovery_only' ELSE '' END,
CASE WHEN SUM(CASE WHEN health='corrupt' THEN 1 ELSE 0 END)>0 THEN 'corrupt'
WHEN SUM(CASE WHEN health='missing' THEN 1 ELSE 0 END)>0 THEN 'missing'
ELSE 'ok' END
FROM catalog_sessions WHERE scope=? AND workspace_root=? AND topic_id=?
ON CONFLICT(scope,workspace_root,topic_id) DO UPDATE SET
title=excluded.title, turns=excluded.turns, turns_state=excluded.turns_state,
created_at=excluded.created_at, last_activity_at=excluded.last_activity_at,
recovery_state=excluded.recovery_state, health=excluded.health`,
key.Scope, key.WorkspaceRoot, key.TopicID,
key.Scope, key.WorkspaceRoot, key.TopicID, key.TopicID,
key.Scope, key.WorkspaceRoot, key.TopicID)
return err
}
func bumpRevision(ctx context.Context, tx *sql.Tx) (uint64, error) {
if _, err := tx.ExecContext(ctx, `UPDATE catalog_state SET revision=revision+1 WHERE id=1`); err != nil {
return 0, err
}
var revision uint64
if err := tx.QueryRowContext(ctx, `SELECT revision FROM catalog_state WHERE id=1`).Scan(&revision); err != nil {
return 0, err
}
return revision, nil
}
func (c *Catalog) publishRevision(revision uint64, roots []string, reason string) {
c.revision.Store(revision)
c.statusMu.Lock()
c.status.Revision = revision
c.statusMu.Unlock()
if c.opts.OnRevision != nil {
c.opts.OnRevision(revision, roots, reason)
}
}
func mapKeys(values map[string]struct{}) []string {
out := make([]string, 0, len(values))
for value := range values {
out = append(out, value)
}
return out
}
func (c *Catalog) ListTopics(ctx context.Context, req TopicPageRequest) (TopicPage, error) {
out := TopicPage{Items: []TopicRecord{}, Revision: c.revision.Load()}
req.Scope, req.WorkspaceRoot = normalizeScope(req.Scope, req.WorkspaceRoot)
if req.Limit <= 0 {
req.Limit = DefaultLimit
}
if req.Limit > MaxLimit {
req.Limit = MaxLimit
}
cursor, err := decodeCursor(req.Cursor)
if err != nil {
return out, err
}
args := []any{req.Scope, req.WorkspaceRoot}
where := `scope=? AND workspace_root=?`
if query := strings.TrimSpace(req.Query); query != "" {
where += ` AND lower(title) LIKE ?`
args = append(args, "%"+strings.ToLower(query)+"%")
}
if cutoff := timeFilterCutoff(req.TimeFilter, c.opts.Now()); cutoff > 0 {
where += ` AND last_activity_at>=?`
args = append(args, cutoff)
}
if cursor != nil {
where += ` AND (pinned<? OR (pinned=? AND last_activity_at<?) OR (pinned=? AND last_activity_at=? AND topic_id>?))`
args = append(args, cursor.Pinned, cursor.Pinned, cursor.Activity, cursor.Pinned, cursor.Activity, cursor.TopicID)
}
args = append(args, req.Limit+1)
rows, err := c.db.QueryContext(ctx, `SELECT scope,workspace_root,topic_id,title,pinned,sort_order,
turns,turns_state,created_at,last_activity_at,recovery_state,health
FROM catalog_topics WHERE `+where+`
ORDER BY pinned DESC,last_activity_at DESC,topic_id ASC LIMIT ?`, args...)
if err != nil {
return out, err
}
defer rows.Close()
for rows.Next() {
var item TopicRecord
if err := rows.Scan(&item.Scope, &item.WorkspaceRoot, &item.TopicID, &item.Title,
&item.Pinned, &item.SortOrder, &item.Turns, &item.TurnsState,
&item.CreatedAt, &item.LastActivityAt, &item.RecoveryState, &item.Health); err != nil {
return out, err
}
item.Sessions = []SessionRecord{}
out.Items = append(out.Items, item)
}
if err := rows.Err(); err != nil {
return out, err
}
more := len(out.Items) > req.Limit
if more {
out.Items = out.Items[:req.Limit]
}
for i := range out.Items {
sessions, err := c.listTopicSessions(ctx, TopicKey{Scope: out.Items[i].Scope, WorkspaceRoot: out.Items[i].WorkspaceRoot, TopicID: out.Items[i].TopicID})
if err != nil {
return TopicPage{Items: []TopicRecord{}, Revision: out.Revision}, err
}
out.Items[i].Sessions = sessions
}
if more && len(out.Items) > 0 {
last := out.Items[len(out.Items)-1]
pinned := 0
if last.Pinned {
pinned = 1
}
out.NextCursor = encodeCursor(pageCursor{Pinned: pinned, Activity: last.LastActivityAt, TopicID: last.TopicID})
}
return out, nil
}
func (c *Catalog) listTopicSessions(ctx context.Context, key TopicKey) ([]SessionRecord, error) {
rows, err := c.db.QueryContext(ctx, `SELECT path,directory,scope,workspace_root,topic_id,topic_title,
custom_title,created_at,last_activity_at,preview,turns,turns_state,recovered,
recovery_reason,recovery_digest,parent_id,content_fingerprint,meta_fingerprint,
health,missing_since FROM catalog_sessions
WHERE scope=? AND workspace_root=? AND topic_id=?
ORDER BY last_activity_at DESC,path ASC`, key.Scope, key.WorkspaceRoot, key.TopicID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []SessionRecord{}
for rows.Next() {
var record SessionRecord
if err := rows.Scan(&record.Path, &record.Directory, &record.Scope, &record.WorkspaceRoot,
&record.TopicID, &record.TopicTitle, &record.CustomTitle, &record.CreatedAt,
&record.LastActivityAt, &record.Preview, &record.Turns, &record.TurnsState,
&record.Recovered, &record.RecoveryReason, &record.RecoveryDigest,
&record.ParentID, &record.ContentFingerprint, &record.MetaFingerprint,
&record.Health, &record.MissingSince); err != nil {
return nil, err
}
out = append(out, record)
}
return out, rows.Err()
}
func (c *Catalog) GetTopic(ctx context.Context, key TopicKey) (TopicRecord, bool, error) {
key.Scope, key.WorkspaceRoot = normalizeScope(key.Scope, key.WorkspaceRoot)
key.TopicID = strings.TrimSpace(key.TopicID)
item := TopicRecord{Sessions: []SessionRecord{}}
err := c.db.QueryRowContext(ctx, `SELECT scope,workspace_root,topic_id,title,pinned,sort_order,
turns,turns_state,created_at,last_activity_at,recovery_state,health
FROM catalog_topics WHERE scope=? AND workspace_root=? AND topic_id=?`,
key.Scope, key.WorkspaceRoot, key.TopicID).Scan(
&item.Scope, &item.WorkspaceRoot, &item.TopicID, &item.Title,
&item.Pinned, &item.SortOrder, &item.Turns, &item.TurnsState,
&item.CreatedAt, &item.LastActivityAt, &item.RecoveryState, &item.Health)
if errors.Is(err, sql.ErrNoRows) {
return item, false, nil
}
if err != nil {
return item, false, err
}
item.Sessions, err = c.listTopicSessions(ctx, key)
if err != nil {
return TopicRecord{Sessions: []SessionRecord{}}, false, err
}
return item, true, nil
}
func encodeCursor(cursor pageCursor) string {
b, _ := json.Marshal(cursor)
return base64.RawURLEncoding.EncodeToString(b)
}
func decodeCursor(encoded string) (*pageCursor, error) {
if strings.TrimSpace(encoded) == "" {
return nil, nil
}
b, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("invalid session catalog cursor: %w", err)
}
var cursor pageCursor
if err := json.Unmarshal(b, &cursor); err != nil || cursor.TopicID == "" {
return nil, errors.New("invalid session catalog cursor")
}
return &cursor, nil
}
func timeFilterCutoff(filter string, now time.Time) int64 {
var duration time.Duration
value := strings.TrimSpace(strings.ToLower(filter))
switch value {
case "day", "24h":
duration = 24 * time.Hour
case "week", "7d":
duration = 7 * 24 * time.Hour
case "month", "30d":
duration = 30 * 24 * time.Hour
default:
parsed, err := time.ParseDuration(value)
if err != nil || parsed <= 0 {
return 0
}
duration = parsed
}
return now.Add(-duration).UnixMilli()
}
func (c *Catalog) Close(ctx context.Context) error {
if c == nil {
return nil
}
c.stopOnce.Do(func() {
if c.workerCancel != nil {
c.workerCancel()
}
close(c.stop)
})
done := make(chan struct{})
go func() {
c.workers.Wait()
close(done)
}()
select {
case <-done:
var err error
c.closeDBOnce.Do(func() { err = c.db.Close() })
c.statusMu.Lock()
c.status.State = StateClosed
c.statusMu.Unlock()
return err
case <-ctx.Done():
return ctx.Err()
}
}
+488
View File
@@ -0,0 +1,488 @@
package sessioncatalog
import (
"context"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"reasonix/internal/agent"
)
func TestReconcileMakesUnknownCountsVisibleWithoutReadingTranscript(t *testing.T) {
t.Parallel()
ctx := context.Background()
dir := t.TempDir()
path := filepath.Join(dir, "legacy.jsonl")
if err := os.WriteFile(path, []byte("not valid jsonl\n"), 0o000); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(path, 0o600) })
if err := agent.SaveBranchMeta(path, agent.BranchMeta{
Scope: "project",
WorkspaceRoot: "/workspace",
TopicID: "topic-1",
TopicTitle: "Legacy topic",
SchemaVersion: 1,
Turns: 0,
}); err != nil {
t.Fatal(err)
}
catalog, err := Open(ctx, Options{
Path: filepath.Join(t.TempDir(), "catalog.sqlite"),
DisableRepair: true,
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = catalog.Close(context.Background()) })
if err := catalog.ReconcileDirectory(ctx, DirectoryTarget{
Path: dir,
Scope: "project",
WorkspaceRoot: "/workspace",
}); err != nil {
t.Fatal(err)
}
page, err := catalog.ListTopics(ctx, TopicPageRequest{
Scope: "project",
WorkspaceRoot: "/workspace",
Limit: 50,
})
if err != nil {
t.Fatal(err)
}
if len(page.Items) != 1 || len(page.Items[0].Sessions) != 1 {
t.Fatalf("page = %#v, want one visible topic/session", page)
}
if got := page.Items[0].Sessions[0].TurnsState; got != TurnsUnknown {
t.Fatalf("turns state = %q, want %q", got, TurnsUnknown)
}
}
func TestListTopicsUsesStableKeysetCursor(t *testing.T) {
t.Parallel()
ctx := context.Background()
catalog, err := Open(ctx, Options{Path: filepath.Join(t.TempDir(), "catalog.sqlite"), DisableRepair: true})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = catalog.Close(context.Background()) })
base := time.Date(2026, 8, 10, 10, 0, 0, 0, time.UTC)
for i, topicID := range []string{"a", "b", "c"} {
if err := catalog.UpsertSession(ctx, SessionRecord{
Path: filepath.Join("/sessions", topicID+".jsonl"),
Directory: "/sessions",
Scope: "global",
TopicID: topicID,
TopicTitle: topicID,
LastActivityAt: base.Add(time.Duration(i) * time.Minute).UnixMilli(),
Turns: i + 1,
TurnsState: TurnsValid,
Health: HealthOK,
}); err != nil {
t.Fatal(err)
}
}
first, err := catalog.ListTopics(ctx, TopicPageRequest{Scope: "global", Limit: 2})
if err != nil {
t.Fatal(err)
}
if len(first.Items) != 2 || first.NextCursor == "" {
t.Fatalf("first page = %#v", first)
}
second, err := catalog.ListTopics(ctx, TopicPageRequest{Scope: "global", Limit: 2, Cursor: first.NextCursor})
if err != nil {
t.Fatal(err)
}
if len(second.Items) != 1 || second.NextCursor != "" {
t.Fatalf("second page = %#v", second)
}
if first.Items[0].TopicID != "c" || first.Items[1].TopicID != "b" || second.Items[0].TopicID != "a" {
t.Fatalf("keyset order = %q, %q, %q", first.Items[0].TopicID, first.Items[1].TopicID, second.Items[0].TopicID)
}
}
func TestGetTopicIsNotLimitedByPageSize(t *testing.T) {
t.Parallel()
ctx := context.Background()
catalog, err := Open(ctx, Options{Path: filepath.Join(t.TempDir(), "catalog.sqlite"), DisableRepair: true})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = catalog.Close(context.Background()) })
for i := 0; i <= MaxLimit; i++ {
topicID := fmt.Sprintf("topic-%03d", i)
if err := catalog.UpsertSession(ctx, SessionRecord{
Path: filepath.Join("/sessions", topicID+".jsonl"), Directory: "/sessions",
Scope: "global", TopicID: topicID, TopicTitle: topicID,
LastActivityAt: int64(MaxLimit - i), TurnsState: TurnsValid, Health: HealthOK,
}); err != nil {
t.Fatal(err)
}
}
topic, ok, err := catalog.GetTopic(ctx, TopicKey{Scope: "global", TopicID: "topic-200"})
if err != nil {
t.Fatal(err)
}
if !ok || topic.TopicID != "topic-200" || len(topic.Sessions) != 1 {
t.Fatalf("topic = %#v, ok=%v", topic, ok)
}
}
func TestUnchangedDirectorySignatureSkipsReconcileRevision(t *testing.T) {
t.Parallel()
ctx := context.Background()
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
if err := os.WriteFile(path, []byte("{}\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := agent.SaveBranchMeta(path, agent.BranchMeta{
Scope: "global", TopicID: "topic", TopicTitle: "Topic",
SchemaVersion: agent.BranchMetaCountsVersion, Turns: 1,
}); err != nil {
t.Fatal(err)
}
catalog, err := Open(ctx, Options{Path: filepath.Join(t.TempDir(), "catalog.sqlite"), DisableRepair: true})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = catalog.Close(context.Background()) })
target := DirectoryTarget{Path: dir, Scope: "global"}
if err := catalog.ReconcileDirectory(ctx, target); err != nil {
t.Fatal(err)
}
revision := catalog.Status().Revision
if err := catalog.ReconcileDirectory(ctx, target); err != nil {
t.Fatal(err)
}
if got := catalog.Status().Revision; got != revision {
t.Fatalf("unchanged scan bumped revision: got %d want %d", got, revision)
}
}
func TestSyncMetadataRemovesOnlyMetadataOnlyTopics(t *testing.T) {
t.Parallel()
ctx := context.Background()
catalog, err := Open(ctx, Options{Path: filepath.Join(t.TempDir(), "catalog.sqlite"), DisableRepair: true})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = catalog.Close(context.Background()) })
if err := catalog.SyncMetadata(ctx, nil, []TopicMetadata{
{Scope: "global", TopicID: "metadata-only", Title: "Metadata"},
{Scope: "global", TopicID: "with-session", Title: "Session"},
}); err != nil {
t.Fatal(err)
}
if err := catalog.UpsertSession(ctx, SessionRecord{
Path: "/sessions/with-session.jsonl", Directory: "/sessions", Scope: "global",
TopicID: "with-session", Turns: 1, TurnsState: TurnsValid, Health: HealthOK,
}); err != nil {
t.Fatal(err)
}
if err := catalog.SyncMetadata(ctx, nil, nil); err != nil {
t.Fatal(err)
}
if _, ok, err := catalog.GetTopic(ctx, TopicKey{Scope: "global", TopicID: "metadata-only"}); err != nil || ok {
t.Fatalf("metadata-only topic survived removal: ok=%v err=%v", ok, err)
}
if _, ok, err := catalog.GetTopic(ctx, TopicKey{Scope: "global", TopicID: "with-session"}); err != nil || !ok {
t.Fatalf("session-derived topic was removed: ok=%v err=%v", ok, err)
}
}
func TestSchemaMigrationLedgerRecordsEveryVersion(t *testing.T) {
t.Parallel()
ctx := context.Background()
catalog, err := Open(ctx, Options{Path: filepath.Join(t.TempDir(), "catalog.sqlite"), DisableRepair: true})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = catalog.Close(context.Background()) })
rows, err := catalog.db.QueryContext(ctx, `SELECT version FROM schema_migrations ORDER BY version`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
versions := []int{}
for rows.Next() {
var version int
if err := rows.Scan(&version); err != nil {
t.Fatal(err)
}
versions = append(versions, version)
}
if fmt.Sprint(versions) != "[1 2]" {
t.Fatalf("schema migration ledger = %v", versions)
}
}
func TestDirectWriteDuringScanIsNotMarkedMissing(t *testing.T) {
t.Parallel()
ctx := context.Background()
now := time.Date(2026, 8, 10, 10, 0, 0, 0, time.UTC)
dir := t.TempDir()
catalog, err := Open(ctx, Options{
Path: filepath.Join(t.TempDir(), "catalog.sqlite"), DisableRepair: true,
Now: func() time.Time { return now },
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = catalog.Close(context.Background()) })
target := DirectoryTarget{Path: dir, Scope: "global"}
generation, err := catalog.beginDirectoryScan(ctx, target, now.UnixMilli())
if err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "late.jsonl")
if err := catalog.UpsertSession(ctx, SessionRecord{
Path: path, Directory: dir, Scope: "global", TopicID: "late",
TurnsState: TurnsUnknown, Health: HealthOK,
}); err != nil {
t.Fatal(err)
}
if err := catalog.finishDirectoryScan(ctx, target, "test", generation, now.UnixMilli(), 0); err != nil {
t.Fatal(err)
}
topic, ok, err := catalog.GetTopic(ctx, TopicKey{Scope: "global", TopicID: "late"})
if err != nil || !ok || len(topic.Sessions) != 1 || topic.Sessions[0].Health != HealthOK {
t.Fatalf("late write was marked missing: topic=%#v ok=%v err=%v", topic, ok, err)
}
}
func TestSessionTopicMoveRecomputesOldTopic(t *testing.T) {
t.Parallel()
ctx := context.Background()
catalog, err := Open(ctx, Options{Path: filepath.Join(t.TempDir(), "catalog.sqlite"), DisableRepair: true})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = catalog.Close(context.Background()) })
record := SessionRecord{
Path: "/sessions/moved.jsonl", Directory: "/sessions", Scope: "global",
TopicID: "old", Turns: 1, TurnsState: TurnsValid, Health: HealthOK,
}
if err := catalog.UpsertSession(ctx, record); err != nil {
t.Fatal(err)
}
record.TopicID = "new"
if err := catalog.UpsertSession(ctx, record); err != nil {
t.Fatal(err)
}
if _, ok, err := catalog.GetTopic(ctx, TopicKey{Scope: "global", TopicID: "old"}); err != nil || ok {
t.Fatalf("old topic survived move: ok=%v err=%v", ok, err)
}
if _, ok, err := catalog.GetTopic(ctx, TopicKey{Scope: "global", TopicID: "new"}); err != nil || !ok {
t.Fatalf("new topic missing after move: ok=%v err=%v", ok, err)
}
}
func TestWriterQueueCoalescesBySessionPath(t *testing.T) {
t.Parallel()
ctx := context.Background()
catalog, err := Open(ctx, Options{
Path: filepath.Join(t.TempDir(), "catalog.sqlite"), DisableRepair: true, QueueCapacity: 1,
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = catalog.Close(context.Background()) })
for turns := 1; turns <= 100; turns++ {
if ok := catalog.EnqueueSession(SessionRecord{
Path: "/sessions/coalesced.jsonl", Directory: "/sessions", Scope: "global",
TopicID: "coalesced", Turns: turns, TurnsState: TurnsValid, Health: HealthOK,
}); !ok {
t.Fatalf("same-path update %d was rejected by a one-slot queue", turns)
}
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
topic, ok, err := catalog.GetTopic(ctx, TopicKey{Scope: "global", TopicID: "coalesced"})
if err != nil {
t.Fatal(err)
}
if ok && topic.Turns == 100 {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("coalesced writer did not persist the latest record")
}
func TestRemoveSessionWinsOverQueuedStaleWriteAndAllowsLaterRecreation(t *testing.T) {
t.Parallel()
ctx := context.Background()
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
catalog, err := Open(ctx, Options{
Path: filepath.Join(t.TempDir(), "catalog.sqlite"), DisableRepair: true,
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = catalog.Close(context.Background()) })
record := SessionRecord{
Path: path, Directory: dir, Scope: "global", TopicID: "topic",
Turns: 1, TurnsState: TurnsValid, Health: HealthOK,
}
if err := catalog.UpsertSession(ctx, record); err != nil {
t.Fatal(err)
}
record.Turns = 99
if !catalog.EnqueueSession(record) {
t.Fatal("queue stale write")
}
if err := catalog.RemoveSession(ctx, path, "test_remove"); err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
if _, ok, err := catalog.GetTopic(ctx, TopicKey{Scope: "global", TopicID: "topic"}); err != nil || ok {
t.Fatalf("queued write resurrected removed row: ok=%v err=%v", ok, err)
}
if err := os.WriteFile(path, []byte("{}\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := agent.SaveBranchMeta(path, agent.BranchMeta{
Scope: "global", TopicID: "topic", SchemaVersion: agent.BranchMetaCountsVersion, Turns: 2,
}); err != nil {
t.Fatal(err)
}
if err := catalog.ReconcileDirectory(ctx, DirectoryTarget{Path: dir, Scope: "global"}); err != nil {
t.Fatal(err)
}
if topic, ok, err := catalog.GetTopic(ctx, TopicKey{Scope: "global", TopicID: "topic"}); err != nil || !ok || topic.Turns != 2 {
t.Fatalf("new external file did not supersede removal: topic=%#v ok=%v err=%v", topic, ok, err)
}
}
func TestCloseCancelsCatalogWorkerContext(t *testing.T) {
catalog, err := Open(context.Background(), Options{
Path: filepath.Join(t.TempDir(), "catalog.sqlite"), DisableRepair: true,
})
if err != nil {
t.Fatal(err)
}
workerDone := catalog.workerCtx.Done()
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := catalog.Close(ctx); err != nil {
t.Fatal(err)
}
select {
case <-workerDone:
default:
t.Fatal("catalog close left worker context active")
}
}
func BenchmarkListTopicsWarmCatalog10K(b *testing.B) {
ctx := context.Background()
catalog, err := Open(ctx, Options{
Path: filepath.Join(b.TempDir(), "catalog.sqlite"), DisableRepair: true,
})
if err != nil {
b.Fatal(err)
}
b.Cleanup(func() { _ = catalog.Close(context.Background()) })
records := make([]SessionRecord, 10_000)
for i := range records {
records[i] = SessionRecord{
Path: filepath.Join("/sessions", fmt.Sprintf("%05d.jsonl", i)), Directory: "/sessions",
Scope: "project", WorkspaceRoot: "/workspace", TopicID: fmt.Sprintf("topic-%05d", i),
Preview: fmt.Sprintf("synthetic session %05d", i), Turns: i%20 + 1,
TurnsState: TurnsValid, Health: HealthOK, LastActivityAt: int64(i + 1),
}
}
for start := 0; start < len(records); start += 64 {
end := min(start+64, len(records))
if err := catalog.upsertSessions(ctx, records[start:end], nil, "benchmark-setup"); err != nil {
b.Fatal(err)
}
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
page, err := catalog.ListTopics(ctx, TopicPageRequest{
Scope: "project", WorkspaceRoot: "/workspace", Limit: 50,
})
if err != nil || len(page.Items) != 50 {
b.Fatalf("page len=%d err=%v", len(page.Items), err)
}
}
}
func TestMissingSessionRequiresTwoScansAndGraceBeforeRemoval(t *testing.T) {
t.Parallel()
ctx := context.Background()
now := time.Date(2026, 8, 10, 10, 0, 0, 0, time.UTC)
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
if err := os.WriteFile(path, []byte("{}\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := agent.SaveBranchMeta(path, agent.BranchMeta{
Scope: "global", TopicID: "topic", TopicTitle: "Topic",
SchemaVersion: agent.BranchMetaCountsVersion, Turns: 1,
}); err != nil {
t.Fatal(err)
}
catalog, err := Open(ctx, Options{
Path: filepath.Join(t.TempDir(), "catalog.sqlite"),
DisableRepair: true,
MissingGrace: time.Minute,
Now: func() time.Time { return now },
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = catalog.Close(context.Background()) })
if err := catalog.ReconcileDirectory(ctx, DirectoryTarget{Path: dir, Scope: "global"}); err != nil {
t.Fatal(err)
}
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
if err := catalog.ReconcileDirectory(ctx, DirectoryTarget{Path: dir, Scope: "global"}); err != nil {
t.Fatal(err)
}
if page, err := catalog.ListTopics(ctx, TopicPageRequest{Scope: "global", Limit: 50}); err != nil || len(page.Items) != 1 {
t.Fatalf("first missing scan removed row: page=%#v err=%v", page, err)
}
now = now.Add(2 * time.Minute)
if err := catalog.ReconcileDirectory(ctx, DirectoryTarget{Path: dir, Scope: "global"}); err != nil {
t.Fatal(err)
}
if page, err := catalog.ListTopics(ctx, TopicPageRequest{Scope: "global", Limit: 50}); err != nil || len(page.Items) != 0 {
t.Fatalf("stale row survived second scan after grace: page=%#v err=%v", page, err)
}
}
func TestOpenQuarantinesCorruptCatalogAndRebuildsProjection(t *testing.T) {
t.Parallel()
ctx := context.Background()
dir := t.TempDir()
path := filepath.Join(dir, "catalog.sqlite")
if err := os.WriteFile(path, []byte("not sqlite"), 0o600); err != nil {
t.Fatal(err)
}
catalog, err := Open(ctx, Options{Path: path, DisableRepair: true})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = catalog.Close(context.Background()) })
status := catalog.Status()
if status.State != StateReady || status.QuarantinedPath == "" {
t.Fatalf("status = %#v, want ready catalog with quarantined path", status)
}
if _, err := os.Stat(status.QuarantinedPath); err != nil {
t.Fatalf("quarantined catalog: %v", err)
}
}
+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
}
+616
View File
@@ -0,0 +1,616 @@
package sessioncatalog
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"reasonix/internal/agent"
)
func (c *Catalog) ReconcileDirectory(ctx context.Context, target DirectoryTarget) error {
if c == nil || c.db == nil {
return nil
}
target.Path = filepath.Clean(strings.TrimSpace(target.Path))
if target.Path == "." || target.Path == "" {
return nil
}
lock := c.directoryLock(target.Path)
lock.Lock()
defer lock.Unlock()
target.Scope, target.WorkspaceRoot = normalizeScope(target.Scope, target.WorkspaceRoot)
signature, err := directorySignature(target.Path)
if err != nil {
c.failDirectoryScan(ctx, target.Path, err)
return err
}
if unchanged, err := c.directoryScanCanSkip(ctx, target.Path, signature); err != nil {
return err
} else if unchanged {
return nil
}
now := c.opts.Now().UnixMilli()
generation, err := c.beginDirectoryScan(ctx, target, now)
if err != nil {
return err
}
ordered, err := agent.ListSessionOrder(target.Path)
if err != nil {
c.failDirectoryScan(ctx, target.Path, err)
return err
}
for start := 0; start < len(ordered); start += 64 {
if err := ctx.Err(); err != nil {
c.failDirectoryScan(context.Background(), target.Path, err)
return err
}
end := min(start+64, len(ordered))
records := make([]SessionRecord, 0, end-start)
generations := make(map[string]int64, end-start)
for _, info := range ordered[start:end] {
record := recordFromOrder(target, info)
// This scan started while holding the directory lock after any
// completed removal. A present file is therefore a newer external
// recreation and may supersede the in-memory removal tombstone.
c.removedPaths.Delete(record.Path)
records = append(records, record)
generations[record.Path] = generation
}
if err := c.upsertSessions(ctx, records, generations, "reconcile"); err != nil {
c.failDirectoryScan(context.Background(), target.Path, err)
return err
}
cursor := ""
if len(records) > 0 {
cursor = records[len(records)-1].Path
}
if _, err := c.db.ExecContext(ctx, `UPDATE catalog_directories SET scan_cursor=?,indexed=? WHERE path=?`, cursor, end, target.Path); err != nil {
return err
}
for _, record := range records {
if record.TurnsState == TurnsUnknown {
c.enqueueRepair(record.Path)
}
}
runtime.Gosched()
}
if err := c.finishDirectoryScan(ctx, target, signature, generation, now, len(ordered)); err != nil {
return err
}
c.refreshCounts(ctx)
return nil
}
func directorySignature(dir string) (string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return "missing", nil
}
return "", err
}
hash := sha256.New()
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || (!strings.HasSuffix(name, ".jsonl") && !strings.HasSuffix(name, ".meta")) {
continue
}
info, err := entry.Info()
if err != nil {
return "", err
}
_, _ = fmt.Fprintf(hash, "%s\x00%d\x00%d\x00%d\n", name, info.Size(), info.ModTime().UnixNano(), info.Mode())
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
func (c *Catalog) directoryScanCanSkip(ctx context.Context, path, signature string) (bool, error) {
var previous, state string
err := c.db.QueryRowContext(ctx, `SELECT signature,state FROM catalog_directories WHERE path=?`, path).Scan(&previous, &state)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
if previous != signature || state != "ready" {
return false, nil
}
var missing int
if err := c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions WHERE directory=? AND missing_since>0`, path).Scan(&missing); err != nil {
return false, err
}
return missing == 0, nil
}
func (c *Catalog) directoryLock(path string) *sync.Mutex {
c.directoryLocksMu.Lock()
defer c.directoryLocksMu.Unlock()
lock := c.directoryLocks[path]
if lock == nil {
lock = &sync.Mutex{}
c.directoryLocks[path] = lock
}
return lock
}
// RequestReconcile coalesces external writes by directory. Queue pressure is
// intentionally lossy: fingerprints on the next scan recover missed requests,
// while a session save never waits on catalog work.
func (c *Catalog) RequestReconcile(target DirectoryTarget) bool {
if c == nil || strings.TrimSpace(target.Path) == "" {
return false
}
target.Path = filepath.Clean(target.Path)
if _, loaded := c.reconcileQueued.LoadOrStore(target.Path, target); loaded {
return true
}
select {
case c.reconcileCh <- target:
return true
case <-c.stop:
c.reconcileQueued.Delete(target.Path)
return false
default:
c.reconcileQueued.Delete(target.Path)
return false
}
}
func (c *Catalog) reconcileLoop() {
defer c.workers.Done()
for {
select {
case target := <-c.reconcileCh:
c.reconcileQueued.Delete(target.Path)
ctx, cancel := context.WithTimeout(c.workerCtx, 2*time.Minute)
_ = c.ReconcileDirectory(ctx, target)
cancel()
case <-c.stop:
return
}
}
}
// RequestIndexSession coalesces an authoritative session write by path. It is
// intentionally non-blocking so a saturated projection can never delay JSONL
// or sidecar persistence.
func (c *Catalog) RequestIndexSession(target DirectoryTarget, path string) bool {
if c == nil {
return false
}
path = filepath.Clean(strings.TrimSpace(path))
if path == "." || path == "" {
return false
}
target.Path = filepath.Clean(strings.TrimSpace(target.Path))
if target.Path == "." || target.Path == "" {
target.Path = filepath.Dir(path)
}
request := sessionPathRequest{target: target, path: path}
if _, loaded := c.pathQueued.LoadOrStore(path, request); loaded {
return true
}
select {
case c.pathCh <- request:
return true
case <-c.stop:
c.pathQueued.Delete(path)
return false
default:
c.pathQueued.Delete(path)
return false
}
}
func (c *Catalog) sessionPathLoop() {
defer c.workers.Done()
for {
select {
case request := <-c.pathCh:
c.pathQueued.Delete(request.path)
ctx, cancel := context.WithTimeout(c.workerCtx, 30*time.Second)
_ = c.IndexSessionPath(ctx, request.target, request.path)
cancel()
case <-c.stop:
return
}
}
}
// IndexSessionPath prioritizes a restored tab's exact session without walking
// its directory. This keeps cold-start recovery independent from the size of
// the history store.
func (c *Catalog) IndexSessionPath(ctx context.Context, target DirectoryTarget, path string) error {
path = filepath.Clean(strings.TrimSpace(path))
if path == "." || path == "" {
return nil
}
c.removedPaths.Delete(path)
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
meta, ok, err := agent.LoadBranchMeta(path)
if err != nil {
return err
}
order := agent.SessionOrderInfo{
Path: path,
CreatedAt: info.ModTime(),
LastActivityAt: info.ModTime(),
ModTime: info.ModTime(),
Scope: target.Scope,
WorkspaceRoot: target.WorkspaceRoot,
}
if ok {
order.CreatedAt = meta.CreatedAt
order.LastActivityAt = meta.UpdatedAt
order.ModTime = meta.UpdatedAt
order.Scope = meta.DefaultScope()
order.WorkspaceRoot = meta.WorkspaceRoot
order.TopicID = meta.TopicID
order.TopicTitle = meta.TopicTitle
order.CustomTitle = meta.CustomTitle
order.Recovered = meta.Recovered
order.RecoveryReason = meta.RecoveryReason
order.RecoveryDigest = meta.RecoveryDigest
order.ParentID = meta.ParentID
order.Turns = meta.Turns
order.Preview = meta.Preview
order.SchemaVersion = meta.SchemaVersion
}
if order.CreatedAt.IsZero() {
order.CreatedAt = info.ModTime()
}
if order.LastActivityAt.IsZero() {
order.LastActivityAt = info.ModTime()
}
record := recordFromOrder(target, order)
if err := c.UpsertSession(ctx, record); err != nil {
return err
}
if record.TurnsState == TurnsUnknown {
c.enqueueRepair(record.Path)
}
return nil
}
func recordFromOrder(target DirectoryTarget, info agent.SessionOrderInfo) SessionRecord {
scope, root := normalizeScope(info.Scope, info.WorkspaceRoot)
if info.TopicID == "" {
scope, root = target.Scope, target.WorkspaceRoot
}
turnsState := TurnsValid
if info.SchemaVersion < agent.BranchMetaCountsVersion && info.Turns == 0 {
turnsState = TurnsUnknown
}
contentFingerprint := fileFingerprint(info.Path)
metaFingerprint := fileFingerprint(agent.BranchMetaPath(info.Path))
return normalizeSessionRecord(SessionRecord{
Path: info.Path,
Directory: target.Path,
Scope: scope,
WorkspaceRoot: root,
TopicID: info.TopicID,
TopicTitle: info.TopicTitle,
CustomTitle: info.CustomTitle,
CreatedAt: unixMilli(info.CreatedAt),
LastActivityAt: unixMilli(info.LastActivityAt),
Preview: info.Preview,
Turns: info.Turns,
TurnsState: turnsState,
Recovered: info.Recovered,
RecoveryReason: info.RecoveryReason,
RecoveryDigest: info.RecoveryDigest,
ParentID: info.ParentID,
ContentFingerprint: contentFingerprint,
MetaFingerprint: metaFingerprint,
Health: HealthOK,
})
}
func unixMilli(value time.Time) int64 {
if value.IsZero() {
return 0
}
return value.UnixMilli()
}
func fileFingerprint(path string) string {
info, err := os.Stat(path)
if err != nil {
return ""
}
return fmt.Sprintf("%d:%d", info.Size(), info.ModTime().UnixNano())
}
func (c *Catalog) beginDirectoryScan(ctx context.Context, target DirectoryTarget, now int64) (int64, error) {
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO catalog_directories(path,scope,workspace_root,state,error)
VALUES(?,?,?,'scanning','') ON CONFLICT(path) DO UPDATE SET
scope=excluded.scope,workspace_root=excluded.workspace_root,state='scanning',error='',
scan_generation=catalog_directories.scan_generation+1,scan_cursor='',indexed=0`,
target.Path, target.Scope, target.WorkspaceRoot); err != nil {
_ = tx.Rollback()
return 0, err
}
var generation int64
if err := tx.QueryRowContext(ctx, `SELECT scan_generation FROM catalog_directories WHERE path=?`, target.Path).Scan(&generation); err != nil {
_ = tx.Rollback()
return 0, err
}
if generation == 0 {
generation = 1
if _, err := tx.ExecContext(ctx, `UPDATE catalog_directories SET scan_generation=1 WHERE path=?`, target.Path); err != nil {
_ = tx.Rollback()
return 0, err
}
}
return generation, tx.Commit()
}
func (c *Catalog) finishDirectoryScan(ctx context.Context, target DirectoryTarget, signature string, generation, now int64, total int) error {
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
return err
}
rows, err := tx.QueryContext(ctx, `SELECT scope,workspace_root,topic_id FROM catalog_sessions
WHERE directory=? AND seen_generation<? AND topic_id<>''`, target.Path, generation)
if err != nil {
_ = tx.Rollback()
return err
}
affected := map[TopicKey]struct{}{}
for rows.Next() {
var key TopicKey
if err := rows.Scan(&key.Scope, &key.WorkspaceRoot, &key.TopicID); err != nil {
_ = rows.Close()
_ = tx.Rollback()
return err
}
affected[key] = struct{}{}
}
if err := rows.Close(); err != nil {
_ = tx.Rollback()
return err
}
if _, err := tx.ExecContext(ctx, `UPDATE catalog_sessions SET
missing_since=CASE WHEN missing_since=0 THEN ? ELSE missing_since END,
health='missing'
WHERE directory=? AND seen_generation<?`, now, target.Path, generation); err != nil {
_ = tx.Rollback()
return err
}
cutoff := now - c.opts.MissingGrace.Milliseconds()
if _, err := tx.ExecContext(ctx, `DELETE FROM catalog_sessions
WHERE directory=? AND seen_generation<? AND missing_since>0 AND missing_since<=?`, target.Path, generation, cutoff); err != nil {
_ = tx.Rollback()
return err
}
for key := range affected {
if err := recomputeTopic(ctx, tx, key); err != nil {
_ = tx.Rollback()
return err
}
}
if _, err := tx.ExecContext(ctx, `UPDATE catalog_directories SET state='ready',error='',signature=?,
scan_cursor='',indexed=?,total=?,completed_at=? WHERE path=?`, signature, total, total, now, target.Path); err != nil {
_ = tx.Rollback()
return err
}
revision, err := bumpRevision(ctx, tx)
if err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
c.publishRevision(revision, []string{target.WorkspaceRoot}, "reconcile_complete")
c.refreshCounts(ctx)
c.statusMu.Lock()
c.status.State = StateReady
c.status.LastError = ""
c.statusMu.Unlock()
return nil
}
func (c *Catalog) failDirectoryScan(ctx context.Context, path string, scanErr error) {
_, _ = c.db.ExecContext(ctx, `UPDATE catalog_directories SET state='degraded',error=? WHERE path=?`, scanErr.Error(), path)
c.statusMu.Lock()
c.status.State = StateDegraded
c.status.LastError = scanErr.Error()
c.statusMu.Unlock()
}
func (c *Catalog) enqueueRepair(path string) {
if c == nil || c.opts.DisableRepair || strings.TrimSpace(path) == "" {
return
}
if _, loaded := c.repairQueued.LoadOrStore(path, struct{}{}); loaded {
return
}
select {
case c.repairCh <- path:
case <-c.stop:
c.repairQueued.Delete(path)
default:
c.repairQueued.Delete(path)
}
}
func (c *Catalog) enqueuePersistedRepairs(ctx context.Context) {
rows, err := c.db.QueryContext(ctx, `SELECT path FROM catalog_sessions WHERE turns_state='unknown' ORDER BY last_activity_at DESC`)
if err != nil {
return
}
defer rows.Close()
for rows.Next() {
var path string
if rows.Scan(&path) == nil {
c.enqueueRepair(path)
}
}
}
func (c *Catalog) repairLoop() {
defer c.workers.Done()
for {
select {
case path := <-c.repairCh:
c.repairSession(c.workerCtx, path)
c.repairQueued.Delete(path)
runtime.Gosched()
case <-c.stop:
return
}
}
}
func (c *Catalog) repairSession(workerCtx context.Context, path string) {
ctx, cancel := context.WithTimeout(workerCtx, 30*time.Second)
defer cancel()
msgs, _, _, err := agent.LoadSessionDisplayMessages(path)
if ctx.Err() != nil {
return
}
if err != nil {
_, _ = c.db.ExecContext(ctx, `UPDATE catalog_sessions SET turns_state='corrupt',health='corrupt' WHERE path=?`, path)
_ = c.recomputeTopicForSession(ctx, path, "repair_corrupt")
return
}
preview, turns := agent.SessionPreviewFromMessages(msgs)
if err := agent.UpdateBranchMeta(path, false, func(meta *agent.BranchMeta) error {
meta.Preview = preview
meta.Turns = turns
meta.SchemaVersion = agent.BranchMetaCountsVersion
return nil
}); err != nil {
return
}
_, _ = c.db.ExecContext(ctx, `UPDATE catalog_sessions SET preview=?,turns=?,turns_state='valid',
health='ok',meta_fingerprint=? WHERE path=?`, preview, turns, fileFingerprint(agent.BranchMetaPath(path)), path)
_ = c.recomputeTopicForSession(ctx, path, "repair")
}
func (c *Catalog) recomputeTopicForSession(ctx context.Context, path, reason string) error {
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
return err
}
var key TopicKey
if err := tx.QueryRowContext(ctx, `SELECT scope,workspace_root,topic_id FROM catalog_sessions WHERE path=?`, path).Scan(&key.Scope, &key.WorkspaceRoot, &key.TopicID); err != nil {
_ = tx.Rollback()
if err == sql.ErrNoRows {
return nil
}
return err
}
if key.TopicID != "" {
if err := recomputeTopic(ctx, tx, key); err != nil {
_ = tx.Rollback()
return err
}
}
revision, err := bumpRevision(ctx, tx)
if err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
c.publishRevision(revision, []string{key.WorkspaceRoot}, reason)
c.refreshCounts(ctx)
return nil
}
// Rebuild replaces only the disposable catalog. Authoritative sessions and
// sidecars are never changed or removed by this operation.
func Rebuild(ctx context.Context, path string, targets []DirectoryTarget) (Status, error) {
if strings.TrimSpace(path) == "" {
path = DefaultPath()
}
backup := path + ".reindex-backup"
_ = os.Remove(backup)
if err := os.Rename(path, backup); err != nil && !os.IsNotExist(err) {
return Status{}, err
}
for _, suffix := range []string{"-wal", "-shm"} {
_ = os.Rename(path+suffix, backup+suffix)
}
catalog, err := Open(ctx, Options{Path: path, DisableRepair: true})
if err != nil {
_ = os.Rename(backup, path)
return Status{}, err
}
for _, target := range targets {
if err := catalog.ReconcileDirectory(ctx, target); err != nil {
closeCtx, cancel := context.WithTimeout(context.Background(), time.Second)
_ = catalog.Close(closeCtx)
cancel()
return catalog.Status(), err
}
}
status := catalog.Status()
closeCtx, cancel := context.WithTimeout(context.Background(), time.Second)
_ = catalog.Close(closeCtx)
cancel()
_ = os.Remove(backup)
_ = os.Remove(backup + "-wal")
_ = os.Remove(backup + "-shm")
return status, nil
}
// Inspect is read-only. It never migrates, repairs, quarantines, or rewrites a
// catalog, making it suitable for `reasonix doctor sessions`.
func Inspect(ctx context.Context, path string) (Status, error) {
if strings.TrimSpace(path) == "" {
path = DefaultPath()
}
status := Status{State: StateDegraded, Mode: ModeDisk, Path: path}
if _, err := os.Stat(path); err != nil {
if os.IsNotExist(err) {
status.LastError = "catalog does not exist"
return status, nil
}
return status, err
}
u := &url.URL{Scheme: "file", Path: path}
db, err := sql.Open("sqlite", u.String()+"?mode=ro&_pragma=busy_timeout%28150%29")
if err != nil {
return status, err
}
defer db.Close()
var integrity string
if err := db.QueryRowContext(ctx, `PRAGMA integrity_check`).Scan(&integrity); err != nil {
return status, err
}
if integrity != "ok" {
status.LastError = integrity
return status, nil
}
status.State = StateReady
_ = db.QueryRowContext(ctx, `SELECT revision FROM catalog_state WHERE id=1`).Scan(&status.Revision)
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions`).Scan(&status.Indexed)
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions WHERE turns_state='unknown'`).Scan(&status.RepairPending)
return status, nil
}
@@ -0,0 +1,28 @@
//go:build darwin
package sessioncatalog
import (
"strings"
"syscall"
)
func catalogFilesystemRemote(path string) bool {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
return false
}
name := make([]byte, 0, len(stat.Fstypename))
for _, char := range stat.Fstypename {
if char == 0 {
break
}
name = append(name, byte(char))
}
switch strings.ToLower(string(name)) {
case "nfs", "smbfs", "webdav", "afpfs", "cifs":
return true
default:
return false
}
}
@@ -0,0 +1,27 @@
//go:build linux
package sessioncatalog
import "syscall"
const (
nfsSuperMagic = 0x6969
smbSuperMagic = 0x517B
cifsSuperMagic = 0xFF534D42
nfsdSuperMagic = 0x6E667364
cephSuperMagic = 0x00C36400
lustreSuperMagic = 0x0BD00BD0
)
func catalogFilesystemRemote(path string) bool {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
return false
}
switch uint64(stat.Type) {
case nfsSuperMagic, smbSuperMagic, cifsSuperMagic, nfsdSuperMagic, cephSuperMagic, lustreSuperMagic:
return true
default:
return false
}
}
@@ -0,0 +1,5 @@
//go:build !darwin && !linux && !windows
package sessioncatalog
func catalogFilesystemRemote(string) bool { return false }
@@ -0,0 +1,26 @@
//go:build windows
package sessioncatalog
import (
"path/filepath"
"strings"
"golang.org/x/sys/windows"
)
func catalogFilesystemRemote(path string) bool {
volume := filepath.VolumeName(filepath.Clean(path))
if volume == "" {
return false
}
root := volume
if !strings.HasSuffix(root, `\`) {
root += `\`
}
rootPtr, err := windows.UTF16PtrFromString(root)
if err != nil {
return false
}
return windows.GetDriveType(rootPtr) == windows.DRIVE_REMOTE
}
+149
View File
@@ -0,0 +1,149 @@
package sessioncatalog
import (
"context"
"database/sql"
"fmt"
"time"
)
const migrationV1 = `
CREATE TABLE IF NOT EXISTS catalog_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
revision INTEGER NOT NULL DEFAULT 0
);
INSERT OR IGNORE INTO catalog_state(id, revision) VALUES(1, 0);
CREATE TABLE IF NOT EXISTS catalog_directories (
path TEXT PRIMARY KEY,
scope TEXT NOT NULL,
workspace_root TEXT NOT NULL DEFAULT '',
signature TEXT NOT NULL DEFAULT '',
scan_cursor TEXT NOT NULL DEFAULT '',
scan_generation INTEGER NOT NULL DEFAULT 0,
state TEXT NOT NULL DEFAULT 'pending',
error TEXT NOT NULL DEFAULT '',
indexed INTEGER NOT NULL DEFAULT 0,
total INTEGER NOT NULL DEFAULT 0,
completed_at INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS catalog_projects (
scope TEXT NOT NULL,
workspace_root TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT '',
pinned INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(scope, workspace_root)
);
CREATE TABLE IF NOT EXISTS catalog_topics (
scope TEXT NOT NULL,
workspace_root TEXT NOT NULL DEFAULT '',
topic_id TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
title_source TEXT NOT NULL DEFAULT '',
pinned INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
turns INTEGER NOT NULL DEFAULT 0,
turns_state TEXT NOT NULL DEFAULT 'unknown',
created_at INTEGER NOT NULL DEFAULT 0,
last_activity_at INTEGER NOT NULL DEFAULT 0,
recovery_state TEXT NOT NULL DEFAULT '',
health TEXT NOT NULL DEFAULT 'ok',
PRIMARY KEY(scope, workspace_root, topic_id)
);
CREATE TABLE IF NOT EXISTS catalog_sessions (
path TEXT PRIMARY KEY,
directory TEXT NOT NULL,
scope TEXT NOT NULL,
workspace_root TEXT NOT NULL DEFAULT '',
topic_id TEXT NOT NULL DEFAULT '',
topic_title TEXT NOT NULL DEFAULT '',
custom_title TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL DEFAULT 0,
last_activity_at INTEGER NOT NULL DEFAULT 0,
preview TEXT NOT NULL DEFAULT '',
turns INTEGER NOT NULL DEFAULT 0,
turns_state TEXT NOT NULL DEFAULT 'unknown',
recovered INTEGER NOT NULL DEFAULT 0,
recovery_reason TEXT NOT NULL DEFAULT '',
recovery_digest TEXT NOT NULL DEFAULT '',
parent_id TEXT NOT NULL DEFAULT '',
content_fingerprint TEXT NOT NULL DEFAULT '',
meta_fingerprint TEXT NOT NULL DEFAULT '',
health TEXT NOT NULL DEFAULT 'ok',
missing_since INTEGER NOT NULL DEFAULT 0,
seen_generation INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_catalog_topics_page
ON catalog_topics(scope, workspace_root, pinned DESC, last_activity_at DESC, topic_id ASC);
CREATE INDEX IF NOT EXISTS idx_catalog_topics_activity
ON catalog_topics(last_activity_at DESC, topic_id ASC);
CREATE INDEX IF NOT EXISTS idx_catalog_sessions_topic
ON catalog_sessions(scope, workspace_root, topic_id, last_activity_at DESC, path ASC);
CREATE INDEX IF NOT EXISTS idx_catalog_sessions_directory
ON catalog_sessions(directory, seen_generation, missing_since);
CREATE INDEX IF NOT EXISTS idx_catalog_sessions_path
ON catalog_sessions(path);
`
const migrationV2 = `
ALTER TABLE catalog_topics ADD COLUMN metadata_present INTEGER NOT NULL DEFAULT 0;
`
func migrateSchema(ctx context.Context, db *sql.DB) error {
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at INTEGER NOT NULL
)`); err != nil {
return err
}
var version int
if err := db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM schema_migrations`).Scan(&version); err != nil {
return err
}
if version > SchemaVersion {
return fmt.Errorf("session catalog schema %d is newer than supported %d", version, SchemaVersion)
}
if version < 1 {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, migrationV1); err != nil {
_ = tx.Rollback()
return err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(1, ?)`, time.Now().UnixMilli()); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
version = 1
}
if version < 2 {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, migrationV2); err != nil {
_ = tx.Rollback()
return err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(2, ?)`, time.Now().UnixMilli()); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
}
return nil
}
+163
View File
@@ -0,0 +1,163 @@
// Package sessioncatalog maintains a disposable SQLite projection of Reasonix
// session sidecars. Session JSONL/event/meta files remain authoritative; every
// row in this package may be discarded and rebuilt.
package sessioncatalog
import (
"path/filepath"
"time"
"reasonix/internal/config"
)
const (
SchemaVersion = 2
DefaultLimit = 50
MaxLimit = 200
)
type TurnsState string
const (
TurnsUnknown TurnsState = "unknown"
TurnsValid TurnsState = "valid"
TurnsCorrupt TurnsState = "corrupt"
)
type Health string
const (
HealthOK Health = "ok"
HealthMissing Health = "missing"
HealthCorrupt Health = "corrupt"
HealthDegraded Health = "degraded"
)
type State string
const (
StateOpening State = "opening"
StateReady State = "ready"
StateDegraded State = "degraded"
StateRebuilding State = "rebuilding"
StateClosed State = "closed"
)
type Mode string
const (
ModeDisk Mode = "disk"
ModeMemory Mode = "memory"
)
type Status struct {
State State `json:"state"`
Mode Mode `json:"mode"`
Path string `json:"path,omitempty"`
Revision uint64 `json:"revision"`
Indexed int64 `json:"indexed"`
Total int64 `json:"total"`
RepairPending int64 `json:"repairPending"`
LastError string `json:"lastError,omitempty"`
QuarantinedPath string `json:"quarantinedPath,omitempty"`
}
type Options struct {
Path string
InMemory bool
DisableRepair bool
MissingGrace time.Duration
QueueCapacity int
Now func() time.Time
OnRevision func(uint64, []string, string)
}
type DirectoryTarget struct {
Path string `json:"path"`
Scope string `json:"scope"`
WorkspaceRoot string `json:"workspaceRoot,omitempty"`
}
type ProjectRecord struct {
Scope string `json:"scope"`
WorkspaceRoot string `json:"workspaceRoot,omitempty"`
Title string `json:"title"`
Color string `json:"color,omitempty"`
Pinned bool `json:"pinned,omitempty"`
SortOrder int `json:"sortOrder,omitempty"`
}
type TopicMetadata struct {
Scope string `json:"scope"`
WorkspaceRoot string `json:"workspaceRoot,omitempty"`
TopicID string `json:"topicId"`
Title string `json:"title"`
TitleSource string `json:"titleSource,omitempty"`
Pinned bool `json:"pinned,omitempty"`
SortOrder int `json:"sortOrder,omitempty"`
CreatedAt int64 `json:"createdAt,omitempty"`
}
type SessionRecord struct {
Path string `json:"path"`
Directory string `json:"directory"`
Scope string `json:"scope"`
WorkspaceRoot string `json:"workspaceRoot,omitempty"`
TopicID string `json:"topicId,omitempty"`
TopicTitle string `json:"topicTitle,omitempty"`
CustomTitle string `json:"customTitle,omitempty"`
CreatedAt int64 `json:"createdAt,omitempty"`
LastActivityAt int64 `json:"lastActivityAt,omitempty"`
Preview string `json:"preview,omitempty"`
Turns int `json:"turns"`
TurnsState TurnsState `json:"turnsState"`
Recovered bool `json:"recovered,omitempty"`
RecoveryReason string `json:"recoveryReason,omitempty"`
RecoveryDigest string `json:"recoveryDigest,omitempty"`
ParentID string `json:"parentId,omitempty"`
ContentFingerprint string `json:"contentFingerprint,omitempty"`
MetaFingerprint string `json:"metaFingerprint,omitempty"`
Health Health `json:"health"`
MissingSince int64 `json:"missingSince,omitempty"`
}
type TopicKey struct {
Scope string `json:"scope"`
WorkspaceRoot string `json:"workspaceRoot,omitempty"`
TopicID string `json:"topicId"`
}
type TopicRecord struct {
Scope string `json:"scope"`
WorkspaceRoot string `json:"workspaceRoot,omitempty"`
TopicID string `json:"topicId"`
Title string `json:"title"`
Pinned bool `json:"pinned,omitempty"`
SortOrder int `json:"sortOrder,omitempty"`
Turns int `json:"turns"`
TurnsState TurnsState `json:"turnsState"`
CreatedAt int64 `json:"createdAt,omitempty"`
LastActivityAt int64 `json:"lastActivityAt,omitempty"`
RecoveryState string `json:"recoveryState,omitempty"`
Health Health `json:"health"`
Sessions []SessionRecord `json:"sessions"`
}
type TopicPageRequest struct {
Scope string `json:"scope"`
WorkspaceRoot string `json:"workspaceRoot,omitempty"`
Cursor string `json:"cursor,omitempty"`
Limit int `json:"limit,omitempty"`
Query string `json:"query,omitempty"`
TimeFilter string `json:"timeFilter,omitempty"`
}
type TopicPage struct {
Items []TopicRecord `json:"items"`
NextCursor string `json:"nextCursor,omitempty"`
Revision uint64 `json:"revision"`
}
func DefaultPath() string {
return filepath.Join(config.CacheDir(), "session-catalog", "v1.sqlite")
}