Files
hermes-agent/apps/desktop/src/lib/persisted.ts
Zeus-Deus 26b4315e09 fix(desktop): profile-scope persisted unread and stop cold-boot storage clobber
Address review on the persisted unread dots, plus a latent data-loss bug
in the shared persistence helper that the restart e2e exposed.

Review findings:

- Session ids are caller-supplied and each profile backend is its own
  namespace, while the desktop's lists routinely mix profiles (cron and
  messaging slices are always cross-profile; recents are too in
  all-profiles mode). Both persisted records are now bucketed per
  profile - nested records keyed by the ROW's own profile
  (normalizeProfileKey, absent -> default), never the live gateway's,
  except the live busy->idle edge with no loaded row, which can only
  come from the active gateway. Same-id sessions in different profiles
  no longer share watermarks or markers.
- Markers are now bounded (200 per profile, oldest evicted) and cleaned
  up when a session leaves the user's world: forgetSessionUnread() is
  wired into removeSession, archiveSession, and the settings
  permanent-delete path (which bypasses the other two).

Cold-boot clobber (found by the restart e2e after the refactor):

- persistentAtom wrote its value back to storage immediately at
  creation. On a cold boot the bundle can evaluate against a storage
  snapshot that has not caught up yet, so that echo overwrote real
  records with the fallback. Creation is now read-only; only actual
  changes persist. Regression-tested in persisted.test.ts.
- The read side of the same race is handled in session-unread.ts: the
  first list arrival re-reads both records from storage (readable by
  then) and merges them under the in-memory state, so a boot that
  seeded empty atoms adopts the disk state instead of re-seeding every
  row and burying the unread gap. Unread listeners are also disabled in
  secondary windows - their partial list view must not write the
  primary's whole-record state (same isolation rule as session tiles).

Tests: cross-profile same-id regression, live-edge profile fallback,
forgetSessionUnread cleanup, marker cap, persistentAtom creation
read-only; the restart e2e passes again end to end.
2026-08-15 01:21:40 -07:00

98 lines
3.3 KiB
TypeScript

import { atom, type WritableAtom } from 'nanostores'
import { readKey, writeKey } from './storage'
// A nanostore that auto-persists. Reads its seed from localStorage through the
// storage choke point (so every read/write is observable in one place) and
// writes back on every change — no per-atom subscribe boilerplate.
//
// export const $foo = persistentAtom('hermes.desktop.foo', false, Codecs.bool)
// Maps a value to/from its stored string form. `decode` only ever sees a real
// stored string (absence falls back); `encode` returning null removes the key.
export interface Codec<T> {
decode(raw: string): T
encode(value: T): null | string
}
export const Codecs = {
bool: { decode: raw => raw === 'true', encode: (value: boolean) => String(value) } as Codec<boolean>,
nullableText: { decode: raw => raw, encode: value => value } as Codec<null | string>,
text: { decode: raw => raw, encode: (value: string) => value } as Codec<string>,
// Mirrors storedStringArray/persistStringArray: drops non-strings, empty → removed.
stringArray: {
decode: raw => {
const parsed = JSON.parse(raw) as unknown
return Array.isArray(parsed)
? parsed.filter((item): item is string => typeof item === 'string' && item.length > 0)
: []
},
encode: value => (value.length === 0 ? null : JSON.stringify(value))
} as Codec<string[]>,
// Mirrors storedStringRecord/persistStringRecord: keeps only string values.
stringRecord: {
decode: raw => {
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {}
}
return Object.fromEntries(
Object.entries(parsed).filter((entry): entry is [string, string] => typeof entry[1] === 'string')
)
},
encode: value => JSON.stringify(value)
} as Codec<Record<string, string>>,
/** JSON with an optional sanitizer for untrusted persisted shapes. */
json<T>(sanitize?: (value: unknown) => T): Codec<T> {
return {
decode: raw => {
const parsed = JSON.parse(raw) as unknown
return sanitize ? sanitize(parsed) : (parsed as T)
},
encode: value => JSON.stringify(value)
}
}
}
export function persistentAtom<T>(key: string, fallback: T, codec: Codec<T> = Codecs.json<T>()): WritableAtom<T> {
const raw = readKey(key)
let initial = fallback
if (raw !== null) {
try {
initial = codec.decode(raw)
} catch {
initial = fallback
}
}
const $value = atom<T>(initial)
// Persist CHANGES only — never the creation-time value. nanostores'
// subscribe fires immediately, and writing what was just read back is a
// no-op at best; at worst it is a data-loss clobber: on a cold boot the
// renderer bundle can run against a storage snapshot that has not caught
// up yet (an early hidden/boot load of the same bundle sees an empty
// area), and echoing the fallback back out overwrites the real record
// other loads are about to read. Observed with the unread-dot records:
// the early load wrote `{}` over a populated store between the disk read
// and the main window's module init.
let creationEmission = true
$value.subscribe(value => {
if (creationEmission) {
creationEmission = false
return
}
writeKey(key, codec.encode(value))
})
return $value
}