From 196472f9d3552243ed5128f54e8e247201ae9636 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 29 Sep 2026 15:38:10 -0500 Subject: [PATCH 1/4] Fix(ci): starve the store's delayed asset refresh when a test ends The Windows build of the 2.58.0 release PR failed after every app-core test had passed, on one unhandled rejection: "window is not defined" from refreshAssets. Opening a vault schedules an asset refresh two seconds later (scheduleAssetsRefreshForVault). On the slow Windows runner store.test.ts finished inside those two seconds, vitest tore down the jsdom environment, and the timer fired into a process with no window. The same timer can also fire mid-file against a later test's window.zen spy. store.test.ts already cures this class of straggler in its afterEach: it starves the bail-out checks of the old store's timers (nothing dirty for a pending note save, no doc for a pending database write). It never covered the asset refresh, which bails when the vault changed, and it only reached the last store a test loaded, while the relaunch tests load two. The afterEach now clears `vault` as well, on every store module the test loaded, so a stray refresh returns before touching window. No app code changes. Reproduced on macOS by removing window after the file, as vitest's teardown does: 14 unhandled rejections with the CI stack before, none after. --- packages/app-core/src/store.test.ts | 41 +++++++++++++++++------------ 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/packages/app-core/src/store.test.ts b/packages/app-core/src/store.test.ts index e8a5c0c6..853315c9 100644 --- a/packages/app-core/src/store.test.ts +++ b/packages/app-core/src/store.test.ts @@ -87,31 +87,38 @@ function installZen(overrides: Record = {}): void { }) } -// The most recent store module a test loaded. `vi.resetModules` gives every -// test a fresh module, but timers the OLD module already scheduled keep -// running: `updateNoteBody` debounces a `persistNote` at 350ms -// (pathSaveTimers), and database edits debounce a write at 400ms -// (databaseSaveTimers). A test that schedules either without awaiting it -// leaves a straggler that fires against whichever `window.zen` spy is -// installed one or two tests later; a partial mock there turns the write -// into `undefined.catch`, which is an unhandled error that fails the whole -// run (seen on the macOS runner). The cure is the afterEach below: it -// starves both timers' bail-out checks on the old store, so a stray -// persistNote finds nothing dirty and a stray database write finds no doc, -// and each returns before touching `window.zen`. -let lastLoadedStore: { useStore: { setState: (partial: object) => void } } | null = null +// Every store module the running test loaded (a relaunch test loads two). +// `vi.resetModules` gives every test a fresh module, but timers the OLD +// module already scheduled keep running: `updateNoteBody` debounces a +// `persistNote` at 350ms (pathSaveTimers), database edits debounce a write at +// 400ms (databaseSaveTimers), and opening a vault refreshes its assets 2s +// later (scheduleAssetsRefreshForVault). A test that schedules any of them +// without awaiting it leaves a straggler that fires against whichever +// `window.zen` spy is installed tests later; a partial mock there turns the +// write into `undefined.catch`, which is an unhandled error that fails the +// whole run (seen on the macOS runner). The asset refresh can also outlive +// the file: on a slow runner it fired after vitest had torn down the jsdom +// environment, and `window is not defined` failed the Windows build. The cure +// is the afterEach below: it starves every timer's bail-out check on the old +// stores, so a stray persistNote finds nothing dirty, a stray database write +// finds no doc and a stray asset refresh finds its vault closed, and each +// returns before touching `window`. +type LoadedStoreModule = { useStore: { setState: (partial: object) => void } } +const loadedStores = new Set() async function loadStore() { vi.resetModules() localStorage.clear() const mod = await import('./store') - lastLoadedStore = mod as unknown as typeof lastLoadedStore + loadedStores.add(mod as unknown as LoadedStoreModule) return mod } afterEach(() => { - lastLoadedStore?.useStore.setState({ noteDirty: {}, databases: {} }) - lastLoadedStore = null + for (const loaded of loadedStores) { + loaded.useStore.setState({ noteDirty: {}, databases: {}, vault: null }) + } + loadedStores.clear() }) type LoadedStore = Awaited>['useStore'] @@ -842,7 +849,7 @@ describe('per-note panels survive a restart (#794)', () => { async function relaunch() { vi.resetModules() const mod = await import('./store') - lastLoadedStore = mod as unknown as typeof lastLoadedStore + loadedStores.add(mod as unknown as LoadedStoreModule) return mod } From f5c1004792700830d277a463ea4cc00aac82dfc4 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 29 Sep 2026 15:38:10 -0500 Subject: [PATCH 2/4] Fix(ci): check undo history pruning against a small cap The Windows release build for 2.58.0 failed in the undo history pruning test with a five-second timeout followed by ENOTEMPTY. To prove the 400-note cap, the test wrote 403 histories through the real atomic writer, about 1,600 file operations in a row: 0.1 s on macOS, 5.5 s on the Windows runner. The ENOTEMPTY was only the aftermath. Once vitest gave up on the test it ran the afterEach cleanup, while the abandoned test body kept writing into the folder being removed. A 15 ms timeout reproduces the same pair on macOS, down to the same folder name. pruneUndoHistories now takes the per-vault cap as an optional third argument, next to the `now` it already takes for tests, and the app keeps the default of MAX_UNDO_HISTORY_FILES. The test checks the same rule with a cap of 4 and also asserts how many files are left. It fails against the old code, which ignores the cap, and passes against the new one. --- apps/desktop/src/main/undo-history-store.test.ts | 16 ++++++++++------ apps/desktop/src/main/undo-history-store.ts | 8 ++++++-- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/undo-history-store.test.ts b/apps/desktop/src/main/undo-history-store.test.ts index fbd409e5..4025e0bb 100644 --- a/apps/desktop/src/main/undo-history-store.test.ts +++ b/apps/desktop/src/main/undo-history-store.test.ts @@ -5,7 +5,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { MAX_UNDO_HISTORY_AGE_MS, MAX_UNDO_HISTORY_BYTES, - MAX_UNDO_HISTORY_FILES, UNDO_HISTORY_DIR, clearUndoHistories, pruneUndoHistories, @@ -94,21 +93,26 @@ describe('pruning undo history files', () => { expect((await readdir(path.dirname(oldFile))).length).toBe(1) }) + // Checked against a small cap, not the real 400: writing 400 histories took + // over five seconds on the Windows runner, the test timed out, and vitest's + // cleanup then raced the abandoned writes into an ENOTEMPTY. it('keeps the most recently written notes when a vault has too many', async () => { + const cap = 4 const extra = 3 - for (let n = 0; n < MAX_UNDO_HISTORY_FILES + extra; n++) { + for (let n = 0; n < cap + extra; n++) { await writeUndoHistory(base, VAULT, `inbox/${n}.md`, String(n)) const file = undoHistoryFile(base, VAULT, `inbox/${n}.md`)! - const at = new Date(Date.now() - (MAX_UNDO_HISTORY_FILES + extra - n) * 1000) + const at = new Date(Date.now() - (cap + extra - n) * 1000) await utimes(file, at, at) } - await pruneUndoHistories(base) + await pruneUndoHistories(base, Date.now(), cap) expect(await readUndoHistory(base, VAULT, 'inbox/0.md')).toBeNull() expect(await readUndoHistory(base, VAULT, `inbox/${extra - 1}.md`)).toBeNull() expect(await readUndoHistory(base, VAULT, `inbox/${extra}.md`)).toBe(String(extra)) - expect(await readFile(undoHistoryFile(base, VAULT, `inbox/${MAX_UNDO_HISTORY_FILES}.md`)!, 'utf8')).toBe( - String(MAX_UNDO_HISTORY_FILES) + expect(await readFile(undoHistoryFile(base, VAULT, `inbox/${cap + extra - 1}.md`)!, 'utf8')).toBe( + String(cap + extra - 1) ) + expect(await readdir(path.dirname(undoHistoryFile(base, VAULT, 'inbox/0.md')!))).toHaveLength(cap) }) it('is fine with nothing to prune', async () => { diff --git a/apps/desktop/src/main/undo-history-store.ts b/apps/desktop/src/main/undo-history-store.ts index bc704910..1d483838 100644 --- a/apps/desktop/src/main/undo-history-store.ts +++ b/apps/desktop/src/main/undo-history-store.ts @@ -105,7 +105,11 @@ export async function clearUndoHistories(baseDir: string): Promise { * oldest ones beyond the per-vault count, and any stray partial writes. Cheap * enough to run once per launch. */ -export async function pruneUndoHistories(baseDir: string, now: number = Date.now()): Promise { +export async function pruneUndoHistories( + baseDir: string, + now: number = Date.now(), + maxFiles: number = MAX_UNDO_HISTORY_FILES +): Promise { const root = path.join(baseDir, UNDO_HISTORY_DIR) let vaults: string[] try { @@ -134,7 +138,7 @@ export async function pruneUndoHistories(baseDir: string, now: number = Date.now } } kept.sort((a, b) => b.mtimeMs - a.mtimeMs) - for (const { file } of kept.slice(MAX_UNDO_HISTORY_FILES)) { + for (const { file } of kept.slice(maxFiles)) { await fsp.rm(file, { force: true }).catch(() => undefined) } if (kept.length === 0) await fsp.rmdir(dir).catch(() => undefined) From 84638a34e6ab208177d2e4dc3d441915e1f455aa Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 30 Sep 2026 11:57:30 -0500 Subject: [PATCH 3/4] Release(cli): bundle the verified 0.6.0 terminal release Desktop-managed zn installations still receive CLI 0.4.1. Pin the released 0.6.0 archives and source commit for all four supported desktop targets, keeping integration protocol 1 and documenting the new terminal commands and diagnostics in the in-app manual. Each archive passed checksum and native architecture validation, and the native integration probe matched the pin. --- apps/desktop/terminal-release.json | 20 ++++++++++---------- packages/app-core/src/lib/help.ts | 5 +++++ 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/apps/desktop/terminal-release.json b/apps/desktop/terminal-release.json index 2b98d750..9a64982c 100644 --- a/apps/desktop/terminal-release.json +++ b/apps/desktop/terminal-release.json @@ -3,24 +3,24 @@ "release": { "repository": "ZenNotes/tui", "protocol": 1, - "version": "0.4.1", - "commit": "86e2149b94fc27587a704fc02afd25e5a42c5cc2", + "version": "0.6.0", + "commit": "5d18fc86b58f94d7ffe90cbf9767904a20f33e9e", "artifacts": { "darwin-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.4.1/zn_0.4.1_darwin_arm64.tar.gz", - "sha256": "3c83f847268c26279121537c807785dfab0403fa0cae248f149d09a11a2e2eda" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.6.0/zn_0.6.0_darwin_arm64.tar.gz", + "sha256": "5102fce1a2ee2cf9c7358dd0eb8d929023a5675ceb693a96ad2fa50fc6936795" }, "darwin-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.4.1/zn_0.4.1_darwin_amd64.tar.gz", - "sha256": "d541dcc3fbfd15d1f702a71ff4d629d1a1907a21d055c23df27934925e9f4557" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.6.0/zn_0.6.0_darwin_amd64.tar.gz", + "sha256": "514a642c820bbbba9f93b6e29626757ba165bc8f69ee967b6304054ad65633b2" }, "linux-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.4.1/zn_0.4.1_linux_arm64.tar.gz", - "sha256": "55d6999163b66fc0e29a3a35a449f37175c448bb7e6e22072ee1c970769fd739" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.6.0/zn_0.6.0_linux_arm64.tar.gz", + "sha256": "9c7bfb76ad248585cccd61fedea9a0e6547afd1f14cc96191799996478118142" }, "linux-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.4.1/zn_0.4.1_linux_amd64.tar.gz", - "sha256": "42e5e64edde08f3145883ffcfeaf197d6290d966074ce0e28f054d254bb806c1" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.6.0/zn_0.6.0_linux_amd64.tar.gz", + "sha256": "7e41439f78cceeb80e10a7d0caa68c7ae80986e821cca4d0f5787fdd597c426b" } } } diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 5ab4c081..a7a11230 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -1226,6 +1226,11 @@ export const HELP_CLI: HelpCard[] = [ body: 'Builds with the Go terminal tool include `zn tui`. Update and open ZenNotes once to upgrade an existing desktop-managed CLI; keep using the same `zn` commands. Settings shows the installed terminal version and offers Repair if an upgrade needs attention. Desktop-installed commands keep following the desktop vault, while the TUI remembers its own selection. Explicit `--vault` and `--server` flags still win. Set `ZENNOTES_WORKSPACE_SOURCE=terminal` to use the terminal default for a command, or `ZENNOTES_CLI_ENGINE=legacy` to run the previous CLI during the transition. Homebrew and manual installations stay managed by their own installer. A shortcut left behind by a moved Mac app or an old AppImage can be repaired from Settings: review the old target, replacement and backup path before choosing Repair shortcut. Note saves preserve creation dates in small files under `.zennotes/note-metadata` without changing Markdown; keep the `.zennotes` folder with vault backups. Explicit legacy rollback needs the original app resources to remain available.' }, + { + title: 'CLI 0.6.0 in desktop 2.59.0', + body: + 'On macOS and Linux, desktop 2.59.0 bundles CLI 0.6.0. Run `zn` in an interactive terminal or `zn tui` to open the editor. It includes editable settings, restored splits and sessions, comments, attachments, templates, bulk actions, and cross-note yank and paste. Use `:version` in the TUI for a persistent report and press `c` or `y` to copy it. `zn status`, `zn doctor`, `zn config`, and `zn completion` help with setup and scripts; `zn server` manages native local servers, also available through `:servers`. Run `zn update --check` to check the CLI release; a desktop-managed CLI updates with the desktop app. The full command reference is at https://zennotes.org/tui/docs.' + }, { title: 'No app required', body: From e59e141b0b9536a514ea67694b1fbff1f3f09c8e Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 30 Sep 2026 11:57:30 -0500 Subject: [PATCH 4/4] Release: align desktop and shared packages at 2.59.0 Ship the CLI 0.6.0 bundle with the shared cloud-conflict layout fix and the two Windows CI reliability fixes. All workspace versions and lockfile entries agree. Fresh typechecks, unit suites, the signed local package, isolated packaged launch, and all three editor smoke suites passed. --- apps/desktop/package.json | 2 +- apps/share-viewer/package.json | 2 +- apps/web/package.json | 2 +- package-lock.json | 18 +++++++++--------- package.json | 2 +- packages/app-core/package.json | 2 +- packages/bridge-contract/package.json | 2 +- packages/shared-domain/package.json | 2 +- packages/shared-ui/package.json | 2 +- 9 files changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 3582c654..61eb1a00 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.58.0", + "version": "2.59.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/share-viewer/package.json b/apps/share-viewer/package.json index 72e50f98..8295db01 100644 --- a/apps/share-viewer/package.json +++ b/apps/share-viewer/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/share-viewer", "private": true, - "version": "2.58.0", + "version": "2.59.0", "type": "module", "description": "Read-only renderer for publicly shared ZenNotes, embedded by the zennotes.org website", "homepage": "https://zennotes.org", diff --git a/apps/web/package.json b/apps/web/package.json index 889cdd1c..4b483438 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.58.0", + "version": "2.59.0", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", diff --git a/package-lock.json b/package-lock.json index 8a07c322..b8d43667 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.58.0", + "version": "2.59.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.58.0", + "version": "2.59.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.58.0", + "version": "2.59.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -874,7 +874,7 @@ }, "apps/share-viewer": { "name": "@zennotes/share-viewer", - "version": "2.58.0", + "version": "2.59.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -945,7 +945,7 @@ }, "apps/web": { "name": "@zennotes/web", - "version": "2.58.0", + "version": "2.59.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16382,7 +16382,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.58.1", + "version": "2.59.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16469,14 +16469,14 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.58.1", + "version": "2.59.0", "devDependencies": { "typescript": "^5.7.2" } }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.58.1", + "version": "2.59.0", "dependencies": { "@zennotes/bridge-contract": "*", "lz-string": "^1.5.0" @@ -16488,7 +16488,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.58.0" + "version": "2.59.0" } } } diff --git a/package.json b/package.json index 382ff024..f2e870ab 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.58.0", + "version": "2.59.0", "description": "ZenNotes monorepo for desktop, web, and self-hosted server builds", "packageManager": "npm@10.9.2", "engines": { diff --git a/packages/app-core/package.json b/packages/app-core/package.json index f3b4003a..5e373e8f 100644 --- a/packages/app-core/package.json +++ b/packages/app-core/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/app-core", "private": true, - "version": "2.58.1", + "version": "2.59.0", "type": "module", "exports": { "./main": "./src/main.tsx", diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index a8081c04..9c99e96f 100644 --- a/packages/bridge-contract/package.json +++ b/packages/bridge-contract/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/bridge-contract", "private": true, - "version": "2.58.1", + "version": "2.59.0", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index cafaf523..e9ed7825 100644 --- a/packages/shared-domain/package.json +++ b/packages/shared-domain/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-domain", "private": true, - "version": "2.58.1", + "version": "2.59.0", "type": "module", "exports": { "./*": "./src/*.ts" diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index f8dc46a2..853dd11a 100644 --- a/packages/shared-ui/package.json +++ b/packages/shared-ui/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-ui", "private": true, - "version": "2.58.0", + "version": "2.59.0", "type": "module", "exports": { ".": "./src/index.ts"