Originally created by: Akarsh-Hegde
This PR cleans up how the tray app and installer resolve the .env credential file and settings.json, adding proper observability and fixing an architectural inconsistency inherited from the old bundle-install layout.
Three issues existed before this PR:
.env was in the wrong place. settings.json and meridian.db both live at ~/.meridian/ (user data, install-independent). The .env credential file lived at ~/.meridian/app/.env — inside the application binary directory. This mixed user credentials with the installed binary: an app update could clobber them, and the path was non-obvious to users and contributors.
Two independent copies of the same probe. commands.rs::env_from_daemon_dotenv and integrations.rs::active_env_path both implemented the same "find the daemon's .env" logic. Neither named the install type. They could silently drift.
No startup visibility. No log showed which .env was used, which resolution branch won, or what install type was active. Debugging "why is Linear showing as disconnected?" required reading source code.
tray/src-tauri/src/commands.rs
Adds InstallMode { Canonical(PathBuf), Dev(PathBuf), Bare } and a single detect_install_mode() function shared by both meridian_db_path and get_integrations:
Canonical — ~/.meridian/.env exists (all install types)Dev — no canonical env; cwd walk finds a repo .env (local dev / contributor)Bare — neither present; process-env or hardcoded defaults onlymeridian_db_path() now logs at info! with source, env_file, and path on every startup — immediately visible in OpenObserve.
tray/src-tauri/src/integrations.rs
Removes the duplicate active_env_path() function and uses detect_install_mode() instead — guaranteed to agree with the DB path resolution.
meridian-core/src/settings.rs
settings_json_path() already resolves install-independently — no structural change. Adds tracing::debug! on each branch (env_override / canonical / cwd_fallback / default_not_yet_created) so the winning path is queryable in OpenObserve.
scripts/install-from-bundle.sh
ENV_FILE now points to ~/.meridian/.env instead of ${APP_ROOT}/.env (~/.meridian/app/.env). All credential collection, port recording, and MLX key injection write to the canonical user-data location. Updated the CURSOR_AGENT_AUTO_INSTALL tip message.
scripts/meridian-npm-setup.sh
Removes the "copy app/.env into staging to survive the atomic swap" line — unnecessary since the file now lives outside the swap area. Replaces it with a one-time migration: if ~/.meridian/app/.env exists on upgrade, it is moved to ~/.meridian/.env (or removed if the canonical already exists), making the upgrade seamless.
scripts/meridian-cli.sh
meridian config edit now opens ~/.meridian/.env (canonical), falling back to ${REPO_ROOT}/.env for source/dev installs_smoke_read_env uses the same probe ordercmd_doctor config check accepts either path as healthy~/.meridian/
meridian.db ← database
settings.json ← UI preferences
.env ← credentials (was ~/.meridian/app/.env)
oauth/ ← OAuth tokens
app/
bin/meridian ← binary only, no user data
VERSION
dotenvy walking up from ~/.meridian/app/ naturally finds ~/.meridian/.env at the next level — the daemon needs no code change.
| Install type | ~/.meridian/.env |
InstallMode |
Source read |
|---|---|---|---|
| Bundle / app install | written by installer | Canonical |
~/.meridian/.env |
Local dev (tauri dev) |
absent | Dev |
repo .env (cwd walk) |
| Contributor (fresh clone) | absent | Dev |
repo .env (cwd walk) |
Bare .app (no installer) |
absent, no repo nearby | Bare |
process-env or defaults — now visible in logs |
cargo clippy -- -D warnings passescargo test passesinstall-from-bundle.sh creates ~/.meridian/.env (not ~/.meridian/app/.env)meridian-npm-setup.sh migration moves app/.env → ~/.meridian/.envmeridian config edit opens ~/.meridian/.env on a bundle installsource=Dev env_file=<repo>/.envsource=Canonical env_file=~/.meridian/.env~/.meridian/.env🤖 Generated with Claude Code
Originally posted by: Akarsh-Hegde
Update — Stage 1 route ports (since this PR opened)
The read tier of the Next-fold is now essentially complete. New since the initial push:
Routes ported to Rust (each consumer swapped to the dual-path
load();/apikept until the export cutover):coding-agents,worklogs,tasks(+hygiene),active(reshaped view),settings,integrations,triage(+parents),version.meridian-core; file/env/process/external → tray commands (settings.json,.env,launchctl-adjacent, npm registry, shell-out tomeridian ticket-parents).meridian-core(daemon re-exports it —config::{RuntimeSettings, load_runtime_settings}unchanged) so the schema/path has one definition, not a third copy.Tests:
hygiene::parse_issuesunit tests (6), in-memory SQLite reader integration tests (coding_agentsunion,tasksautonomous math),version::is_newerunit tests.cargo testgreen.Observability: every reader has command+core spans, per-query
debug_span, row-countdebug!,info!summaries,warn!on errors → OpenObserve under the trayotelfeature. AddedOBSERVABILITY.md.Docs/standards: added the "Porting a dashboard route to Rust" playbook to
CLAUDE.md(placement / byte-for-byte / docs / tracing / tests), and backfilled all reader module docs to match.Bug fixes found along the way:
mainonly → dashboard/setup windows couldn'tinvoke(now covers all three).ui/.nextchurn (.taurignore).tokiomissing theprocessfeature → standalone (no-otel) build failed (parents.rs).Still to port: the writes/actions tier (mutations) and the 3 SSE streams → Tauri events.
Originally posted by: Akarsh-Hegde
/code-reviewfindings (high-effort, recall-biased)6 of 7 finder angles completed (the cross-file tracer 500'd twice; its scope — command registration, invoke-arg matching, capability/window coverage — was cross-checked by the other angles and came back clean: every command is registered, arg names match, and
default.jsoncoversmain/dashboard/setup). Ranked most-severe first. The ports are largely faithful — angle A verifiedintervals/tasksautonomous math,coding_agents,worklogs,version,settingscoercion, and thebridge.tsswaps as correct. The real items:Correctness / behaviour
try/catch→ empty-200 contract (meridian-core/src/{worklogs,tasks,week,active,today}.rs). Each TS route wrapped its body intry { … } catch { return 200 {items:[],…} }; the Rust commandsmap_err → Err, soload()rejects instead of resolving with an empty shape. On a transient DB error (locked WAL, missing column on an old DB) consumers hit.catchand may leave a permanently blank panel with stale state (the.thenthat callssetItems([])never runs). Decide: do we want the resolve-empty contract preserved, or is reject-on-error fine?integrations.rsreads.envfile text, but the daemon reads the process env (std::env::var, populated by dotenvy + the launchd plist + shellexports). A provider configured via the plist orexport(not in any.env) shows "not connected" in the app while syncs actually run. Also:active_env_path()walks up from the tray's cwd (≠ daemon WorkingDirectory on a source run); the daemon acceptsJIRA_URLbut the tray only checksJIRA_BASE_URL;parse_envdoesn't strip quotes/exportlike dotenvy. The deeper fix is to have the tray ask the daemon for resolved-provider status rather than re-parse.env.TriageReason::{hint,fix}(the authoritative classifier),meridian-core/hygiene.rs, andmeridian-core/triage.rseach hand-maintain the same mapping — and they've already drifted (e.g. engine "No description — I'll have nothing to match…" vs ports "…nothing to match…"; ThinDescription "add a bit of detail" vs "a little detail" vs no suffix). "Kept in sync by hand" doesn't hold; the engine should expose one lookup the ports consume.today.rsactive-session divergences vs/api/today: (a) line ~307 — emptywindow_titlesfalls back to[app_name], but the route returns[]for the active session (only foreground sessions get the title fallback); (b) line ~316 — activecategoryruns throughnormalize_cat(remapsfm_parse_error/fm_skip→idle_personal), but the route's active branch does not remap. Same DB → different active card in app vs browser. Low frequency, real.get_triagenowformat (commands.rs~47 /triage.rs~178): usesUtc::now().to_rfc3339()(+00:00, sub-milli) while the route + siblingget_tasksuse…Zmillis. Thesnoozed_until <= ?compare is lexicographic, so a tie-boundary snooze can flip. Low impact (triage GET has no consumer yet) but an internal inconsistency withget_tasks.week.rs~99: active-session category usesunwrap_or(onlyNone→idle_personal); the route's|| 'idle_personal'also maps""→idle_personal. An empty-string category lands today's live hours under a blankcatskey. Edge.settings.rs~75:expand_tildeonly handles a leading~/; the oldshellexpand::tildealso expanded bare~/~user. AMERIDIAN_SETTINGS_PATHset to a bare-tilde form now resolves literally → falls through to defaults. Edge (test/non-standard installs).Duplication / maintainability
ms()RFC3339→epoch-ms is triplicated inweek.rs/active.rs/today.rs, re-deriving the privateintervals::parse_ms. Makeparse_ms/ms_to_isopuband reuse. Related: the settings schema is hand-listed in bothmeridian-core/settings.rsandui/lib/settings.tswith nothing enforcing parity; andparents.rs::meridian_bin()re-implementsselectMeridianBinary(the launchd native-first ordering is test-guarded only on the TS side).Efficiency / cleanup (low)
tasks.rs: 7 independent reads run sequentially (try_join!them);intersect_seconds(&agent, presence)re-normalizes the full presence set once per ticket;*_by_task_rowsbuilds an intermediateBTreeMapand.clone()s everySessionRow; today's rows are read twice (today range ⊂ week range). All modest (SQLite-bound) but free wins.TasksView.tsx~1004: leftover bare{ … }block after removing theif (r.ok)wrapper — dead scope, dedent it. (version.rsreqwest::Client::new()per call is minor given the 1h cache.)Most are faithful-divergence/edge; #1, [#2], [#3] are the ones worth a real decision. None block the fold; happy to fix any subset.
Related
Tickets:
#2Tickets:
#3Originally posted by: Akarsh-Hegde
Resolution — adityaharishch code-review findings
All 7 findings addressed in commit ea19768 (+ cf731d6 for CI):
intervals.rsintersect_secondsdouble-advance on equal endpoints<→ i,>→ j,==→ both. Added dedicated test.commands.rsget_triageused+00:00sub-milli vsget_tasks'sZmillis formatto_rfc3339_opts(Millis, true)— now consistent with every other command.integrations.rsparse_envstoredKEY=""as non-empty ("connected")KEY=""andKEY=''register as unset.commands.rsmeridian_db_path()skipped.env(wrong path on bundle install with customMERIDIAN_DB).envfallback: bundle~/.meridian/app/.env→ cwd walk, same logic asactive_env_path.bridge.tsload()fallback silently droppedargsargsas?key=valuequery params in thefetch()path so e.g.get_worklogs({day})works in a plain browser.today.rstoday_types.rs;today.rsis now 373 lines. Public API unchanged (meridian_core::today::TodayResponseetc.).OBSERVABILITY.mdCI fix (cf731d6): The
cargo fmt --checkfailure was insrc/telemetry_spool/andsrc/pm_worklog/— files that arrived onmainafter this branch was cut, not in our changes. Merged main and rancargo fmtto fix them.Originally posted by: Akarsh-Hegde
My recommendation: preserve the resolve-empty contract for now (it's a one-line change per command, keeps
the UI behavior identical to the TS routes, and the WAL lock scenario is transient). Surface real errors
properly as a separate piece of work when you wire up component-level error states.
Originally posted by: coderabbitai[bot]
✨ Finishing Touches
🧪 Generate unit tests (beta)
- [ ] Create PR with unit tests - [ ] Commit unit tests in branch `spike/meridian-core`Comment
@coderabbitai helpto get the list of available commands.Originally posted by: Akarsh-Hegde
🔍 Code review —
spike/meridian-core(high-effort, workflow-backed)Reviewed 218 changed files across 8 finder angles; every candidate was checked by an independent verifier (28 candidates → 20 kept, 8 refuted). Below are the 10 reported findings, grouped by severity. I'll fix the confirmed correctness divergences in a follow-up commit and report the delta.
🔴 Correctness divergences from the original
/apiroutes (will fix)tray/src-tauri/src/poll/live.rs:120— log-tail drops complete lines on a split multibyte char.read_new_linesdoesread_to_stringover the wholeoffset..EOFregion including the not-yet-flushed trailing line. If the daemon is mid-writing a non-ASCII byte (emoji/accented path/unicode in an error), the decode fails, returnsVec::new(), and leavesoffsetunadvanced — so the complete lines written just before are silently dropped from the live Logs view until a later, fully-flushed read. The original NodecreateReadStreamtail split on\nat the byte level and never had this. Fix: read bytes, find the last\nat the byte level, decode only the complete prefix.tray/src-tauri/src/commands/tasks.rs:44— leakedtasks-syncprocess on timeout.The deleted
/api/tasks/syncroute calledchild.kill()when its 30s timer fired. The port wrapsCommand::output()intokio::time::timeoutbut never setskill_on_drop(true), so on timeout the child keeps running to completion after the UI shows "timed out". A user retry spawns a second overlapping sync — concurrentmeridian.dbwrites + PM-tracker API hammering, and the leaked run can still mutate the board. Fix:.kill_on_drop(true).meridian-core/src/notifications.rs:60— quiet-hours parser is more lenient than the route.hhmm_to_minutesusessplit_once(':') + i64::parse, accepting values the original strict regex/^(\d{1,2}):(\d{2})$/rejected ("8:5","+8:00","8:00:00"). A malformed-but-parseablequiet_hours_startnow engages quiet hours and suppresses notifications the pre-fold dashboard would have fired (fail-open). Fix: strict parse matching the regex (digit-count + ASCII-digit checks) + regression test.tray/src-tauri/src/commands/health.rs:65— divergent DB-path resolver.check_databasereads$MERIDIAN_DB_PATH(wrong var — the real one isMERIDIAN_DB) or the hardcoded default, ignoring the~/.meridian/.env/MERIDIAN_DBresolution chain the rest of the tray + daemon use viainstall::meridian_db_path(). On an installed system with a non-default DB path, the health pane reports "Database not found" while the daemon writes happily to the real DB. Fix: call the existinginstall::meridian_db_path()resolver.🟡 Pre-existing / intentional divergences (NOT fixing — documented)
meridian-core/src/readers/week.rs:47— UTC/local week-bucketing. Naive tz-less day bounds string-compared againstZ-suffixed UTCstarted_at; late-evening sessions land in the wrong day column for non-UTC users. This is an explicitly documented byte-for-byte replication of the existing/api/weekroute (module doc says do-not-fix). Re-exposed, not introduced — flagging as a pre-existing bug to fix at the source level, separately.meridian-core/src/capture.rs:58(PLAUSIBLE) —text_sourcecoerces any out-of-contract value to'ocr'silently. Intentional write-boundary normalization; could add awarn!if a third source ever appears.meridian-core/src/readers/plan.rs:441(PLAUSIBLE) — tiebreak switched from JSlocaleCompareto Rust byte-ordinalstr::cmp; commented intentional divergence. Low severity (different equal-score suggestion order for mixed-case/non-ASCII keys).🔵 Cleanup / efficiency (deferring — behavior-preserving, follow-up)
tray/src-tauri/src/poll/refresh.rs:152(+191,today/mod.rs:317,week.rs:41) — the 60s poll loop runs the fullget_todaydashboard reader (column probe + full-day scan + unfilteredpm_tasksscan + interval math) then discards all butfocus_s/switch_count/3 category sums. Continuous wasted CPU/DB I/O on a battery-powered idle machine; a dedicated aggregate query would be far cheaper.tray/src-tauri/src/mlx_server.rs(1183),meridian-core/src/readers/plan.rs,tray/src-tauri/src/commands/integrations.rs,meridian-core/src/capture.rs,ui/components/views/TasksView.tsx. Splitting an 1183-line module on a near-merge PR is risky — recommend a dedicated follow-up.meridian-core/src/readers/tasks.rs:360(+triage.rs:189) (PLAUSIBLE) — inlinesqlite_mastertable-existence probes duplicatereaders/plan.rs'stable_exists(); extract to a shared readers util.8 candidates were refuted in verification (DRY/helper-extraction suggestions that didn't hold up as defects): bridge
load/invokeoverlap, dashboard pool-guard boilerplate, settings unused-param,emit_notices/emit_bannerssimilarity,.envparser duplication,capture.rsis_missing_tablestyle.Originally posted by: Akarsh-Hegde
✅ Review follow-up — 4 fixed, 6 deferred
Pushed
b54ec04(fix(fold): correct four Next-fold route divergences from code review). Full pre-push suite green: fmt + clippy-D warnings+ UI build + UI tests +cargo test+ security audit.Fixed (the 4 confirmed correctness divergences)
poll/live.rsread_to_end→ last\n→ lossy decode of the complete prefix). No longer drops complete lines when the writer is mid-flush on a multibyte char.commands/tasks.rs.kill_on_drop(true)on thetasks-syncchild — a 30s timeout now actually kills it (matching the deleted route'schild.kill()), no leaked board-mutating process.notifications.rshhmm_to_minutesis now strict to the route regex/^(\d{1,2}):(\d{2})$/(rejects"8:5","+8:00","8:00:00"). Quiet-hours silence/notify decision matches the pre-fold dashboard again. Pinned with a regression test.commands/health.rsinstall::meridian_db_path()(theMERIDIAN_DB/~/.meridian/.envchain) instead of the divergent inline lookup of a non-existentMERIDIAN_DB_PATH+ hardcoded default. Health pane no longer false-reports "Database not found" on installs with a custom DB path.Deferred (6) — with reasons, not resolved in this commit
week.rsUTC/local bucketing — intentionally not patched. The module doc declares it a byte-for-byte faithful replica of/api/week(do-not-fix); patching here would diverge from the route. Track as a pre-existing source-level bug to fix in both places together.capture.rstext_source coercion & #7plan.rstiebreak (both PLAUSIBLE) — commented, intentional divergences; left as-is.refresh.rsdiscarded poll work — real efficiency win but a behavior-preserving refactor (dedicated aggregate query); better as its own change.mlx_server.rs1183 lines + 4 others) — splitting an 1183-line module on a near-merge PR is risky; recommend a dedicated follow-up PR.sqlite_masterprobe (PLAUSIBLE) — extracttable_exists()to a shared readers util; low-risk DRY cleanup for a follow-up.Deferred items [#8]–#10 are behavior-preserving cleanups; [#5]–#7 are pre-existing/intentional. None block this PR's merge.
Related
Tickets:
#5Tickets:
#8Ticket changed by: Akarsh-Hegde