Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
bf19574
feat(desktop): add resilient session catalog
SivanCola Aug 10, 2026
30fa8f2
merge: refresh session catalog on latest main-v2
SivanCola Aug 10, 2026
f882c70
refactor: satisfy session catalog ownership budgets
SivanCola Aug 10, 2026
623d536
merge: refresh session catalog on latest main-v2
SivanCola Aug 10, 2026
10fd6fb
refactor(storage): share disposable projection lifecycle
SivanCola Aug 10, 2026
517fc05
feat(history): add non-blocking indexed search
SivanCola Aug 10, 2026
98e30ee
docs(history): document catalog recovery
SivanCola Aug 10, 2026
3229896
fix(cli): register catalog completions
SivanCola Aug 10, 2026
9796edb
chore(frontend): ratchet indexed history budget
SivanCola Aug 10, 2026
9bf26dd
fix(desktop): preserve catalog history compatibility
SivanCola Aug 10, 2026
0548479
refactor(storage): share disposable projection lifecycle
SivanCola Aug 10, 2026
95b5857
feat(stats): add idempotent usage rollups
SivanCola Aug 10, 2026
723d5cd
fix(cli): register usage catalog completions
SivanCola Aug 10, 2026
d9ca43a
docs(stats): document usage catalog recovery
SivanCola Aug 10, 2026
87bf32c
refactor(storage): share disposable projection lifecycle
SivanCola Aug 10, 2026
7d99299
feat(tasks): add cross-project task catalog
SivanCola Aug 10, 2026
3739f12
fix(tasks): complete catalog lifecycle contracts
SivanCola Aug 10, 2026
3a6d65a
merge: refresh session catalog on latest main-v2
SivanCola Aug 10, 2026
84839d9
merge: refresh history catalog on latest session foundation
SivanCola Aug 10, 2026
830e21e
chore(history): refresh lint baseline after main sync
SivanCola Aug 10, 2026
ddb58e5
merge: refresh usage catalog on latest session foundation
SivanCola Aug 10, 2026
8ea4982
chore(stats): refresh lint baseline after main sync
SivanCola Aug 10, 2026
dd7fec6
merge: refresh task catalog on latest session foundation
SivanCola Aug 10, 2026
35839b4
chore(tasks): refresh lint baseline after main sync
SivanCola Aug 10, 2026
934bb70
merge: refresh session catalog on latest main-v2
SivanCola Aug 10, 2026
b5778f5
merge: refresh history catalog on latest session foundation
SivanCola Aug 10, 2026
9cdc067
merge: refresh usage catalog on latest session foundation
SivanCola Aug 10, 2026
2bb0b0d
merge: refresh task catalog on latest session foundation
SivanCola Aug 10, 2026
20d5081
fix(storage): remove superseded catalog helpers
SivanCola Aug 10, 2026
f80f273
fix(tasks): make reconcile shutdown lint-safe
SivanCola Aug 10, 2026
81b56f2
fix(storage): remove superseded catalog helpers
SivanCola Aug 10, 2026
281af09
fix(storage): remove superseded catalog helpers
SivanCola Aug 10, 2026
3fa5142
fix(history): modernize activity clamping
SivanCola Aug 10, 2026
10d469d
Merge remote-tracking branch 'origin/main-v2' into feature/session-ca…
SivanCola Aug 10, 2026
04f8f3b
Merge branch 'feature/session-catalog' into feature/history-search-ca…
SivanCola Aug 10, 2026
45b7a25
Merge branch 'feature/session-catalog' into feature/usage-catalog
SivanCola Aug 10, 2026
3029887
Merge branch 'feature/session-catalog' into feature/task-catalog
SivanCola Aug 10, 2026
f5b85de
Merge remote-tracking branch 'origin/main-v2' into feature/session-ca…
SivanCola Aug 10, 2026
576fda4
Merge remote-tracking branch 'origin/main-v2' into feature/session-ca…
SivanCola Aug 10, 2026
0556d1b
fix(sessioncatalog): harden disposable SQLite lifecycle and non-block…
SivanCola Aug 10, 2026
5cec97d
merge: adopt session catalog foundation from #8186
SivanCola Aug 10, 2026
608543a
merge: adopt history search catalog from #8217
SivanCola Aug 10, 2026
40b15fd
merge: adopt usage catalog from #8218
SivanCola Aug 10, 2026
0d41f2c
merge: adopt task catalog from #8219
SivanCola Aug 10, 2026
2ee158c
fix(catalogs): harden multi-domain SQLite lifecycle for CI and safety
SivanCola Aug 10, 2026
ba90b8a
Merge remote-tracking branch 'origin/main-v2' into feature/sqlite-cat…
SivanCola Aug 10, 2026
f877a35
fix(catalogs): stabilize activity times, reconcile drain, and shutdow…
SivanCola Aug 10, 2026
e08111d
fix(catalogs): unblock six merge-blocking projection regressions
SivanCola Aug 11, 2026
bc30df8
fix(desktop): fix project-tree race and cold history empty-session traps
SivanCola Aug 11, 2026
3f1be32
fix(desktop): close remaining catalog integration P1 gaps
SivanCola Aug 11, 2026
af6b1cb
fix(desktop): fence clear-session identity against mode-switch races
SivanCola Aug 11, 2026
8a131a8
fix(desktop): fix CI parse/lint and scoped Host rollback
SivanCola Aug 11, 2026
8cff50d
fix(desktop): instance-scoped Host rollback and hydrate generics
SivanCola Aug 11, 2026
01bc087
fix(desktop): registration scopes, catalog tombstones, migration sign…
SivanCola Aug 11, 2026
f17fceb
fix(catalogs): close projection race and migration gaps
SivanCola Aug 11, 2026
7be950f
merge(main-v2): integrate catalog and runtime updates
SivanCola Aug 11, 2026
40fdb8d
merge(main-v2): integrate task spend guard
SivanCola Aug 11, 2026
6b26552
merge(main-v2): integrate completion receipt fix
SivanCola Aug 11, 2026
d285249
test(desktop): cover live context through history slices
SivanCola Aug 11, 2026
e121f6c
fix(desktop): fence MCP mutations from controller boot
SivanCola Aug 11, 2026
4867532
merge(main-v2): integrate inbox recovery and task defaults
SivanCola Aug 11, 2026
d64cfca
merge(main-v2): align context occupancy accounting
SivanCola Aug 11, 2026
e48a27a
merge(main-v2): externalize WebView2 release smoke
SivanCola Aug 11, 2026
cf1733d
fix(catalogs): rebuild projections atomically
SivanCola Aug 11, 2026
642bc40
Merge remote-tracking branch 'origin/main-v2' into feature/sqlite-cat…
SivanCola Aug 11, 2026
84c79d9
test(boot): release shared history catalog on cleanup
SivanCola Aug 11, 2026
7a47e22
merge(main-v2): resolve catalog integration conflicts
SivanCola Aug 11, 2026
7ef3119
merge(main-v2): integrate goal budget and desktop fixes
SivanCola Aug 11, 2026
8322583
fix(cli): fence usage catalog shutdown
SivanCola Aug 11, 2026
88f4562
merge(main-v2): integrate desktop crash diagnostics
SivanCola Aug 11, 2026
fb6a37b
fix(desktop): prioritize newly migrated catalog sessions
SivanCola Aug 11, 2026
46a9646
fix(desktop): remove obsolete migration wrapper
SivanCola Aug 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
236 changes: 88 additions & 148 deletions desktop/app.go

Large diffs are not rendered by default.

64 changes: 33 additions & 31 deletions desktop/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/evidence"
"reasonix/internal/history"
"reasonix/internal/instruction"
"reasonix/internal/jobs"
"reasonix/internal/mcplaunch"
Expand All @@ -39,7 +40,9 @@ import (
"reasonix/internal/provider"
"reasonix/internal/sandbox"
"reasonix/internal/skill"
"reasonix/internal/stats"
"reasonix/internal/store"
"reasonix/internal/taskcatalog"
"reasonix/internal/tool"
)

Expand Down Expand Up @@ -234,6 +237,15 @@ func isolateDesktopUserDirs(t *testing.T) string {
t.Setenv("REASONIX_STATE_HOME", filepath.Join(home, "state"))
t.Setenv("REASONIX_CACHE_HOME", filepath.Join(home, "cache"))
t.Setenv("AppData", appData)
// Process-local catalog projections pin SQLite files under cache. Close them
// before TempDir cleanup so Windows does not fail unlinkat on open handles.
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_ = history.CloseSharedCatalog(ctx)
_ = stats.CloseUsageCatalogs(ctx)
_ = taskcatalog.ShutdownShared(ctx)
})
return home
}

Expand Down Expand Up @@ -4404,8 +4416,8 @@ func TestListSessionsUsesPinnedSessionOwnerBeforeStaleRuntimeDir(t *testing.T) {
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
installSessionCatalogForTest(t, app, sessionDirA, "project", projectA)
t.Cleanup(oldCtrl.Close)

sessions := app.ListSessions()
if len(sessions) == 0 {
t.Fatal("ListSessions() returned no sessions")
Expand Down Expand Up @@ -4683,7 +4695,7 @@ func TestClearActiveSessionRuntimeSupersedesInFlightStartupBuild(t *testing.T) {
app.activeTabID = tab.ID
t.Cleanup(tab.releaseSessionLease)

if err := app.clearActiveSessionRuntime(tab, oldCtrl); err != nil {
if _, err := app.clearActiveSessionRuntime(tab, oldCtrl); err != nil {
t.Fatalf("clearActiveSessionRuntime: %v", err)
}
if tab.Ctrl == nil || tab.Ctrl == oldCtrl {
Expand Down Expand Up @@ -4743,7 +4755,7 @@ func TestClearActiveSessionRuntimeReleasesResourcesWhenTabReplaced(t *testing.T)
app.activeTabID = tab.ID
t.Cleanup(tab.releaseSessionLease)

err := app.clearActiveSessionRuntime(tab, oldCtrl)
_, err := app.clearActiveSessionRuntime(tab, oldCtrl)
if err == nil || !strings.Contains(err.Error(), "changed while clearing") {
t.Fatalf("clearActiveSessionRuntime error = %v, want tab-changed error", err)
}
Expand Down Expand Up @@ -5885,7 +5897,7 @@ func TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {

oldCtrl.Submit("work")
<-runner.started
if err := app.ClearSession(); err != nil {
if _, err := app.ClearSession(); err != nil {
t.Fatalf("ClearSession: %v", err)
}
waitNotRunning(t, oldCtrl)
Expand Down Expand Up @@ -5941,7 +5953,7 @@ func TestClearSessionRemovesRunningJobArtifacts(t *testing.T) {
t.Fatalf("job sidecar should exist before clear: %v", err)
}

if err := app.ClearSession(); err != nil {
if _, err := app.ClearSession(); err != nil {
t.Fatalf("ClearSession: %v", err)
}
if _, err := os.Stat(jobsDir); !os.IsNotExist(err) {
Expand Down Expand Up @@ -6692,7 +6704,7 @@ func TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {
tabOrder: []string{"active", "inactive"},
activeTabID: "active",
}

installSessionCatalogForTest(t, app, dir, "global", "")
if err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {
t.Fatalf("DeleteSession(inactive open basename): %v", err)
}
Expand Down Expand Up @@ -6918,7 +6930,6 @@ func TestRestoreSessionRejectsDestroyingSession(t *testing.T) {

func TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {
isolateDesktopUserDirs(t)

dirA := filepath.Join(t.TempDir(), "workspace-a-sessions")
dirB := filepath.Join(t.TempDir(), "workspace-b-sessions")
if err := os.MkdirAll(dirA, 0o755); err != nil {
Expand All @@ -6939,9 +6950,10 @@ func TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {
app := NewApp()
app.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: "test"}), "")
defer app.activeCtrl().Close()

installSessionCatalogForTest(t, app, dirA, "global", "")
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 {
Expand All @@ -6954,6 +6966,7 @@ func TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {
if meta.CustomTitle != "A title" {
t.Fatalf("custom title should be written to branch meta, got %q", meta.CustomTitle)
}
reconcileSessionCatalogForTest(t, app, dirA, "global", "")
sessions = app.ListSessions()
if len(sessions) != 1 || sessions[0].Title != "A title" {
t.Fatalf("ListSessions should return custom title from branch meta, got %+v", sessions)
Expand Down Expand Up @@ -6991,7 +7004,7 @@ func TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) {
app := NewApp()
app.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, "active.jsonl"), Label: "test"}), "")
defer app.activeCtrl().Close()

installSessionCatalogForTest(t, app, dir, "global", "")
sessions := app.ListSessions()
if len(sessions) != 1 {
t.Fatalf("ListSessions len = %d, want 1: %+v", len(sessions), sessions)
Expand Down Expand Up @@ -8577,7 +8590,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()

Expand All @@ -8589,16 +8602,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{})
Expand All @@ -8612,9 +8615,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()

Expand All @@ -8624,12 +8631,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:
Expand Down Expand Up @@ -10331,7 +10333,7 @@ func TestSessionActionsWithoutControllerReturnError(t *testing.T) {
if err := app.NewSession(); err == nil {
t.Error("NewSession with no controller must surface an error, not silently no-op")
}
if err := app.ClearSession(); err == nil {
if _, err := app.ClearSession(); err == nil {
t.Error("ClearSession with no controller must surface an error")
}

Expand Down
108 changes: 108 additions & 0 deletions desktop/extension_generation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package main

import (
"context"
"os"
"path/filepath"
"strings"
"testing"

"reasonix/internal/taskcatalog"
)

func TestExtensionGenerationBumpsOnMCPMutationSites(t *testing.T) {
// Guard mutation sites that publish shared Host / config changes while
// controller builds may still be running off the lifecycle lock.
entries, err := os.ReadDir(".")
if err != nil {
t.Fatal(err)
}
var sources []string
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
raw, readErr := os.ReadFile(filepath.Join(".", name))
if readErr != nil {
t.Fatal(readErr)
}
sources = append(sources, string(raw))
}
src := strings.Join(sources, "\n")
for _, name := range []string{
"func (a *App) InstallMCPServer",
"func (a *App) UpdateMCPServer",
"func (a *App) RemoveMCPServer",
"func (a *App) ReconnectMCPServer",
"func (a *App) ClearMCPServerAuthentication",
} {
idx := strings.Index(src, name)
if idx < 0 {
t.Fatalf("missing %s", name)
}
rest := src[idx:]
next := strings.Index(rest[len(name):], "\nfunc (a *App) ")
body := rest
if next >= 0 {
body = rest[:len(name)+next]
}
if !strings.Contains(body, "a.bumpExtensionGeneration()") && !strings.Contains(body, "saveDesktopMCPServerAndBump") {
t.Fatalf("%s does not bump extensionGeneration", name)
}
}
}

func TestControllerPublicationRejectsMutationThatCompletesAfterBoot(t *testing.T) {
app := NewApp()
generation := app.currentExtensionGeneration()

// Model a build that has finished extension boot while an MCP mutation is
// queued. The writer must finish and bump the generation before publication.
app.extensionBuildMu.RLock()
mutationLocked := make(chan struct{})
releaseMutation := make(chan struct{})
go func() {
unlock := app.lockMCPMutation("test-publication-fence")
close(mutationLocked)
<-releaseMutation
unlock()
}()
app.extensionBuildMu.RUnlock()
<-mutationLocked

publication := make(chan bool, 1)
go func() {
unlock, ok := app.lockTabControllerPublication(generation)
if ok {
unlock()
}
publication <- ok
}()
close(releaseMutation)
if <-publication {
t.Fatal("controller published after an MCP mutation changed its extension generation")
}
}

func TestTaskActionProjectKeepsAllowlistedRoot(t *testing.T) {
root := t.TempDir()
app := &App{
ctx: context.Background(),
tabs: map[string]*WorkspaceTab{
"active": {ID: "active", Scope: "project", WorkspaceRoot: root},
},
activeTabID: "active",
}
key := taskcatalog.ProjectKey(root)
project, err := app.taskActionProject(key)
if err != nil {
t.Fatal(err)
}
if abs, err := filepath.Abs(root); err == nil {
root = abs
}
if project.Root != root {
t.Fatalf("root = %q, want allowlisted %q", project.Root, root)
}
}
20 changes: 11 additions & 9 deletions desktop/frontend/scripts/check-bundle-budget.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ const localeChunks = readdirSync(resolve(distDir, "assets"))
.map((name) => resolve(distDir, "assets", name));

console.log("\nbundle budgets");
assertBudget("initial JavaScript gzip", initialJSGzip, 400 * 1024);
assertBudget("initial JavaScript gzip", initialJSGzip, 400.5 * 1024);
assertBudget("largest initial JavaScript chunk gzip", largestInitialJS, 280 * 1024);
assertBudget("render-blocking CSS gzip", initialCSSGzip, 4 * 1024);
// Extension surfaces, Task Monitor, and compact decision receipts share the
Expand All @@ -70,10 +70,11 @@ if (localeChunks.length !== 2) {
for (const path of localeChunks) {
const name = basename(path);
// Task Monitor, Extension UI, Storage & paths, and shell execution cards
// add their own labels. Reasoning display controls, status bar metrics, and
// capability busy guidance add the latest localized copy. Keep both
// dictionaries within narrow allowances.
const budget = name.startsWith("zh-TW-") ? 54.71 * 1024 : 53.92 * 1024;
// add their own labels. Reasoning display controls, status bar metrics,
// capability busy guidance, indexed history, Task Center, and inbox recovery
// remain within the existing narrow allowances while raw startup stays
// controlled.
const budget = name.startsWith("zh-TW-") ? 55.5 * 1024 : 54.5 * 1024;
assertBudget(`${name} gzip`, gzipBytes(path), budget);
}

Expand All @@ -83,8 +84,9 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS]
// runtime. Goal request observability plus transcript scroll arbitration,
// logical selection state/DOM adapters, native input-session ownership,
// durable inbox recovery controls, measurement invalidation and deferred tail
// replay, and startup config-warning delivery add small always-available
// contracts. The expanded contract remains tightly bounded while the gzip
// startup budgets stay flat.
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, 2_251 * 1024);
// replay, startup config-warning delivery, catalog project shell,
// indexed-history bridge, and cross-project Task Center add small
// always-available contracts. Keep the raw allowance ratcheted while gzip
// stays flat.
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, 2_260 * 1024);
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024);
Loading