mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
1 parent
b8a37d4ba2
commit
bf19574d3a
65 files changed
+4869
-1058
No files matched your search
+49
-28
@@ -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
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,讓多個交付會話安全並行寫入。",
|
||||
|
||||
@@ -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,让多个交付会话安全并行写入。",
|
||||
|
||||
@@ -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
@@ -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
@@ -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,4 +1,4 @@
|
||||
//go:build darwin
|
||||
//go:build darwin && cgo
|
||||
|
||||
package main
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build darwin && !cgo
|
||||
|
||||
package main
|
||||
|
||||
func mainThreadWatchdogSupported() bool { return false }
|
||||
|
||||
func startNativeMainThreadHeartbeat(uint64) {}
|
||||
|
||||
func stopNativeMainThreadHeartbeat() {}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
//go:build darwin
|
||||
//go:build darwin && cgo
|
||||
|
||||
package main
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !darwin
|
||||
//go:build !darwin || !cgo
|
||||
|
||||
package main
|
||||
|
||||
|
||||
+80
-551
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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,4 +1,4 @@
|
||||
//go:build !windows
|
||||
//go:build !windows && cgo
|
||||
|
||||
package main
|
||||
|
||||
|
||||
@@ -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) {}
|
||||
@@ -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,4 +1,4 @@
|
||||
//go:build !linux
|
||||
//go:build !linux || !cgo
|
||||
|
||||
package main
|
||||
|
||||
|
||||
+16
@@ -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
|
||||
|
||||
@@ -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 的同名条目。
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
@@ -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` 构建。
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
@@ -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,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,
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = "-"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in new issue
Block a user