Menu

#298 refactor(tray): unified install-mode env resolution + path visibility

closed
nobody
None
2026-06-24
2026-06-16
Anonymous
No

Originally created by: Akarsh-Hegde

Summary

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.


The problem

Three issues existed before this PR:

  1. .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.

  2. 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.

  3. 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.


Changes

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 only

meridian_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 order
  • cmd_doctor config check accepts either path as healthy

The canonical layout after this PR

~/.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 behaviour

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

Test plan

  • [ ] cargo clippy -- -D warnings passes
  • [ ] cargo test passes
  • [ ] Bundle install: install-from-bundle.sh creates ~/.meridian/.env (not ~/.meridian/app/.env)
  • [ ] Upgrade from old install: meridian-npm-setup.sh migration moves app/.env~/.meridian/.env
  • [ ] meridian config edit opens ~/.meridian/.env on a bundle install
  • [ ] Dev run: tray startup log shows source=Dev env_file=<repo>/.env
  • [ ] Bundle install: tray startup log shows source=Canonical env_file=~/.meridian/.env
  • [ ] Integrations page correctly reads Linear/GitHub tokens from ~/.meridian/.env

🤖 Generated with Claude Code

Related

Tickets: #330
Tickets: #347

Discussion

  • Anonymous

    Anonymous - 2026-06-16

    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(); /api kept until the export cutover):

    • coding-agents, worklogs, tasks (+ hygiene), active (reshaped view), settings, integrations, triage (+ parents), version.
    • DB reads → meridian-core; file/env/process/external → tray commands (settings.json, .env, launchctl-adjacent, npm registry, shell-out to meridian ticket-parents).
    • Settings reader moved into 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_issues unit tests (6), in-memory SQLite reader integration tests (coding_agents union, tasks autonomous math), version::is_newer unit tests. cargo test green.

    Observability: every reader has command+core spans, per-query debug_span, row-count debug!, info! summaries, warn! on errors → OpenObserve under the tray otel feature. Added OBSERVABILITY.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:

    • Tray menu rebuild dropped items (single source of truth now).
    • Tauri capability scoped to main only → dashboard/setup windows couldn't invoke (now covers all three).
    • Dev-watcher thrash on ui/.next churn (.taurignore).
    • Tray tokio missing the process feature → standalone (no-otel) build failed (parents.rs).

    Still to port: the writes/actions tier (mutations) and the 3 SSE streams → Tauri events.

     
  • Anonymous

    Anonymous - 2026-06-16

    Originally posted by: Akarsh-Hegde

    /code-review findings (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.json covers main/dashboard/setup). Ranked most-severe first. The ports are largely faithful — angle A verified intervals/tasks autonomous math, coding_agents, worklogs, version, settings coercion, and the bridge.ts swaps as correct. The real items:

    Correctness / behaviour

    1. Dropped the routes' try/catch → empty-200 contract (meridian-core/src/{worklogs,tasks,week,active,today}.rs). Each TS route wrapped its body in try { … } catch { return 200 {items:[],…} }; the Rust commands map_err → Err, so load() rejects instead of resolving with an empty shape. On a transient DB error (locked WAL, missing column on an old DB) consumers hit .catch and may leave a permanently blank panel with stale state (the .then that calls setItems([]) never runs). Decide: do we want the resolve-empty contract preserved, or is reject-on-error fine?
    2. integrations.rs reads .env file text, but the daemon reads the process env (std::env::var, populated by dotenvy + the launchd plist + shell exports). A provider configured via the plist or export (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 accepts JIRA_URL but the tray only checks JIRA_BASE_URL; parse_env doesn't strip quotes/export like dotenvy. The deeper fix is to have the tray ask the daemon for resolved-provider status rather than re-parse .env.
    3. Three-way drift in the reason-code → hint/fix table. The daemon engine's TriageReason::{hint,fix} (the authoritative classifier), meridian-core/hygiene.rs, and meridian-core/triage.rs each 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.
    4. today.rs active-session divergences vs /api/today: (a) line ~307 — empty window_titles falls back to [app_name], but the route returns [] for the active session (only foreground sessions get the title fallback); (b) line ~316 — active category runs through normalize_cat (remaps fm_parse_error/fm_skipidle_personal), but the route's active branch does not remap. Same DB → different active card in app vs browser. Low frequency, real.
    5. get_triage now format (commands.rs ~47 / triage.rs ~178): uses Utc::now().to_rfc3339() (+00:00, sub-milli) while the route + sibling get_tasks use …Z millis. The snoozed_until <= ? compare is lexicographic, so a tie-boundary snooze can flip. Low impact (triage GET has no consumer yet) but an internal inconsistency with get_tasks.
    6. week.rs ~99: active-session category uses unwrap_or (only Noneidle_personal); the route's || 'idle_personal' also maps ""idle_personal. An empty-string category lands today's live hours under a blank cats key. Edge.
    7. settings.rs ~75: expand_tilde only handles a leading ~/; the old shellexpand::tilde also expanded bare ~ / ~user. A MERIDIAN_SETTINGS_PATH set to a bare-tilde form now resolves literally → falls through to defaults. Edge (test/non-standard installs).

    Duplication / maintainability

    1. ms() RFC3339→epoch-ms is triplicated in week.rs/active.rs/today.rs, re-deriving the private intervals::parse_ms. Make parse_ms/ms_to_iso pub and reuse. Related: the settings schema is hand-listed in both meridian-core/settings.rs and ui/lib/settings.ts with nothing enforcing parity; and parents.rs::meridian_bin() re-implements selectMeridianBinary (the launchd native-first ordering is test-guarded only on the TS side).

    Efficiency / cleanup (low)

    1. 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_rows builds an intermediate BTreeMap and .clone()s every SessionRow; today's rows are read twice (today range ⊂ week range). All modest (SQLite-bound) but free wins.
    2. TasksView.tsx ~1004: leftover bare { … } block after removing the if (r.ok) wrapper — dead scope, dedent it. (version.rs reqwest::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: #2
    Tickets: #3

  • Anonymous

    Anonymous - 2026-06-17

    Originally posted by: Akarsh-Hegde

    Resolution — adityaharishch code-review findings

    All 7 findings addressed in commit ea19768 (+ cf731d6 for CI):

    # File Finding Fix
    #1 intervals.rs intersect_seconds double-advance on equal endpoints Three-branch advance: < → i, > → j, == → both. Added dedicated test.
    #2 commands.rs get_triage used +00:00 sub-milli vs get_tasks's Z millis format Changed to to_rfc3339_opts(Millis, true) — now consistent with every other command.
    #3 integrations.rs parse_env stored KEY="" as non-empty ("connected") Strip surrounding quotes before the non-empty check so KEY="" and KEY='' register as unset.
    #4 commands.rs meridian_db_path() skipped .env (wrong path on bundle install with custom MERIDIAN_DB) Added .env fallback: bundle ~/.meridian/app/.env → cwd walk, same logic as active_env_path.
    #5 bridge.ts Browser load() fallback silently dropped args Append args as ?key=value query params in the fetch() path so e.g. get_worklogs({day}) works in a plain browser.
    #6 today.rs 521 lines — exceeded the 500-line CLAUDE.md limit Extracted response types + DB row shapes + helpers to today_types.rs; today.rs is now 373 lines. Public API unchanged (meridian_core::today::TodayResponse etc.).
    #7 OBSERVABILITY.md Created without an explicit request Deleted.

    CI fix (cf731d6): The cargo fmt --check failure was in src/telemetry_spool/ and src/pm_worklog/ — files that arrived on main after this branch was cut, not in our changes. Merged main and ran cargo fmt to fix them.

     
  • Anonymous

    Anonymous - 2026-06-17

    Originally posted by: Akarsh-Hegde

    Correctness / behaviour

    1. Dropped the routes' try/catch → empty-200 contract (meridian-core/src/{worklogs,tasks,week,active,today}.rs). Each TS route wrapped its body in try { … } catch { return 200 {items:[],…} }; the Rust commands map_err → Err, so load() rejects instead of resolving with an empty shape. On a transient DB error (locked WAL, missing column on an old DB) consumers hit .catch and may leave a permanently blank panel with stale state (the .then that calls setItems([]) never runs). Decide: do we want the resolve-empty contract preserved, or is reject-on-error fine?

    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.

     
  • Anonymous

    Anonymous - 2026-06-24

    Originally posted by: coderabbitai[bot]

    [!IMPORTANT]

    Review skipped

    Auto reviews are disabled on base/target branches other than the default branch.

    Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.


    ⚙️ Run configuration

    Configuration used: Organization UI

    Review profile: ASSERTIVE

    Plan: Pro Plus

    Run ID: c75aacc3-54ad-48fe-87dd-d2f03c54dd75

    You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

    Use the checkbox below for a quick retry:
    - [ ] 🔍 Trigger review

    ✨ Finishing Touches
    🧪 Generate unit tests (beta) - [ ] Create PR with unit tests - [ ] Commit unit tests in branch `spike/meridian-core`

    Comment @coderabbitai help to get the list of available commands.

     
  • Anonymous

    Anonymous - 2026-06-24

    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 /api routes (will fix)

    1. tray/src-tauri/src/poll/live.rs:120 — log-tail drops complete lines on a split multibyte char.
      read_new_lines does read_to_string over the whole offset..EOF region 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, returns Vec::new(), and leaves offset unadvanced — so the complete lines written just before are silently dropped from the live Logs view until a later, fully-flushed read. The original Node createReadStream tail split on \n at the byte level and never had this. Fix: read bytes, find the last \n at the byte level, decode only the complete prefix.

    2. tray/src-tauri/src/commands/tasks.rs:44 — leaked tasks-sync process on timeout.
      The deleted /api/tasks/sync route called child.kill() when its 30s timer fired. The port wraps Command::output() in tokio::time::timeout but never sets kill_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 — concurrent meridian.db writes + PM-tracker API hammering, and the leaked run can still mutate the board. Fix: .kill_on_drop(true).

    3. meridian-core/src/notifications.rs:60 — quiet-hours parser is more lenient than the route.
      hhmm_to_minutes uses split_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-parseable quiet_hours_start now 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.

    4. tray/src-tauri/src/commands/health.rs:65 — divergent DB-path resolver.
      check_database reads $MERIDIAN_DB_PATH (wrong var — the real one is MERIDIAN_DB) or the hardcoded default, ignoring the ~/.meridian/.env / MERIDIAN_DB resolution chain the rest of the tray + daemon use via install::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 existing install::meridian_db_path() resolver.

    🟡 Pre-existing / intentional divergences (NOT fixing — documented)

    1. meridian-core/src/readers/week.rs:47 — UTC/local week-bucketing. Naive tz-less day bounds string-compared against Z-suffixed UTC started_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/week route (module doc says do-not-fix). Re-exposed, not introduced — flagging as a pre-existing bug to fix at the source level, separately.
    2. meridian-core/src/capture.rs:58 (PLAUSIBLE)text_source coerces any out-of-contract value to 'ocr' silently. Intentional write-boundary normalization; could add a warn! if a third source ever appears.
    3. meridian-core/src/readers/plan.rs:441 (PLAUSIBLE) — tiebreak switched from JS localeCompare to Rust byte-ordinal str::cmp; commented intentional divergence. Low severity (different equal-score suggestion order for mixed-case/non-ASCII keys).

    🔵 Cleanup / efficiency (deferring — behavior-preserving, follow-up)

    1. tray/src-tauri/src/poll/refresh.rs:152 (+191, today/mod.rs:317, week.rs:41) — the 60s poll loop runs the full get_today dashboard reader (column probe + full-day scan + unfiltered pm_tasks scan + interval math) then discards all but focus_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.
    2. File-size hard-rule violations (CLAUDE.md: "Keep files under 500 lines"): 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.
    3. meridian-core/src/readers/tasks.rs:360 (+triage.rs:189) (PLAUSIBLE) — inline sqlite_master table-existence probes duplicate readers/plan.rs's table_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/invoke overlap, dashboard pool-guard boilerplate, settings unused-param, emit_notices/emit_banners similarity, .env parser duplication, capture.rs is_missing_table style.

     
  • Anonymous

    Anonymous - 2026-06-24

    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)

    # File Fix
    1 poll/live.rs Log tail now splits at the byte level (read_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.
    2 commands/tasks.rs .kill_on_drop(true) on the tasks-sync child — a 30s timeout now actually kills it (matching the deleted route's child.kill()), no leaked board-mutating process.
    3 notifications.rs hhmm_to_minutes is 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.
    4 commands/health.rs DB path resolved via install::meridian_db_path() (the MERIDIAN_DB / ~/.meridian/.env chain) instead of the divergent inline lookup of a non-existent MERIDIAN_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

    • #5 week.rs UTC/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.
    • #6 capture.rs text_source coercion & #7 plan.rs tiebreak (both PLAUSIBLE) — commented, intentional divergences; left as-is.
    • #8 refresh.rs discarded poll work — real efficiency win but a behavior-preserving refactor (dedicated aggregate query); better as its own change.
    • #9 file-size hard-rule violations (mlx_server.rs 1183 lines + 4 others) — splitting an 1183-line module on a near-merge PR is risky; recommend a dedicated follow-up PR.
    • #10 duplicated sqlite_master probe (PLAUSIBLE) — extract table_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: #5
    Tickets: #8

  • Anonymous

    Anonymous - 2026-06-24

    Ticket changed by: Akarsh-Hegde

    • status: open --> closed
     

Log in to post a comment.