release: fold dashboard into Tauri + self-contained DMG + in-process capture (pre-main → main)#330
Conversation
Foundation spike for folding the dashboard into the Tauri app: the tray crate now depends on the `meridian` lib (path dep, +66 transitive deps) and reads the DB through the daemon's own query layer — no reimplemented SQL. - db/meridian.rs: add open_existing() — opens an existing meridian.db WITHOUT running migrations or creating the file. The daemon owns the schema via setup_db(); a second process running the migrator would race it. Normal WAL connection so it reads correctly alongside the daemon's writes. - tray: get_active command → open_existing → get_active_session, returning the live ActiveSession as JSON. The template for porting the dashboard read routes. Verified: cargo check clean; reads the real active_session row. Per-call pool open is a spike shortcut — pooled managed state follows next. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… proof) Open meridian.db once at startup and share it with commands via Tauri managed state, instead of opening a pool per call. Adds a webview panel that calls the get_active command and renders the live active session read straight from meridian.db — the full Rust-command-over-SQLite round trip the dashboard fold will repeat for all 28 routes. - db/mod.rs: re-export SqlitePool so the tray names it without a direct sqlx dep - lib.rs: open_existing() once in setup → manage(Option<SqlitePool>); None (graceful) if the DB isn't openable yet, so the tray never crashes on it - commands.rs: get_active now takes State<Option<SqlitePool>>; meridian_db_path exposed pub(crate) for the startup open - tray UI: #db-active line invokes get_active and shows app_name + frame_count Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Spike result: give the dashboard a lean, single-source-of-truth data layer instead of depending on the whole daemon. ActiveSession + open_existing + get_active_session move into a new meridian-core crate (sqlx + serde + anyhow only — no screenpipe/MLX/trackers/OTel). The daemon re-exports them so its code is unchanged; the Tauri tray now depends on meridian-core, not the full lib. Measured: tray deps 332 -> 301 (-31), and the removed crates are the heavy daemon-only ones — OpenTelemetry, the tracker HTTP/oauth stack, and screenpipe are now absent from the UI build (0 in the dep tree). Single source of truth: both daemon and dashboard read through the same queries. Also: drop a now-needless borrow in open_dashboard (clippy surfaced it once the dep graph / feature unification changed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stage 1 of porting /api/today to Rust: the wall-clock interval library that /api/today (and /api/week) are built on, ported faithfully from ui/lib/intervals.ts into meridian-core::intervals. union/merge/intersect/session_interval/count_switches, plus normalize() (parse → drop invalid → sort → merge disjoint). Timestamps are stored as full RFC3339 (+00:00), so JS getTime() and Rust parse_from_rfc3339().timestamp_millis() agree exactly; merge_intervals emits UTC millis-Z strings to match JS toISOString(). 6 golden tests pin the semantics (overlap-once, drop-invalid, agent-cap, intersect, switch jitter). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eature) Dev-time visibility into the new Tauri/dashboard code, exported to the SAME OpenObserve the daemon uses — without bloating the shipped binary. - meridian-core: instrument open_existing + get_active_session with tracing spans (records uri + whether a row was found). The `tracing` facade is a no-op without a subscriber, so the default/release build stays lean. - tray: instrument the get_active command; add an `otel` feature (default OFF) that pulls the full daemon lib ONLY to reuse meridian::observability::init — zero duplication of the OTLP/OpenObserve setup. lib.rs calls it at startup under #[cfg(feature = "otel")], tagged service.name = meridian-tray. Verified: default build = 301 deps, OpenTelemetry 0 (lean); --features otel pulls the OTel stack (17 crates) and wires the exporter. Run dev with `cargo tauri dev --features otel` to see spans in OpenObserve. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`observability::init` ran at the top of run() before Tauri started its Tokio
runtime, so OpenTelemetry's batch exporter panicked ("there is no reactor
running, must be called from the context of a Tokio 1.x runtime"). Wrap the
init in tauri::async_runtime::block_on so it runs inside the global runtime;
the exporter's background task then lives on that runtime for the process life.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ils) Stage 2 of porting /api/today: the day-boundary helpers, ported faithfully from ui/lib/date-utils.ts into meridian_core::date. "Today" is the user's LOCAL day; timestamps are stored UTC, so local_day_bounds() computes local 00:00:00 .. local 23:59:59.999 and converts each to a UTC RFC3339 millis-Z string for the `started_at >= ? AND started_at < ?` query bounds. today_string() is the local calendar date. 4 golden tests pin the semantics in a timezone-independent way: the bounds span a full local day (86_399_999 ms), the start round-trips to local midnight, the format is millis+Z, and today_string is local YYYY-MM-DD. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add meridian-core to the Repository Layout (lean shared data layer: ActiveSession + open_existing + get_active_session, intervals.rs, date.rs) and a migration note so any future Claude/dev session understands the in-progress fold: API routes → Rust, single source of truth in meridian-core (daemon re-exports, tray depends), Next stays static-export, dev-only --features otel exports tray spans to OO. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stage 3: the full Today dashboard payload, ported from ui/app/api/today/route.ts into meridian_core::today, built on the Stage 1/2 helpers (intervals + date). - Reads app_sessions (optional category_explanation/session_summary detected via pragma_table_info and NULL-substituted when absent — matches the TS graceful check), active_session, gaps, pm_tasks. - Splits foreground vs coding-agent streams; computes focus/idle/agent/supervised/ autonomous seconds, presence/agent segments, switch count, per-task totals + autonomous splits, engaged_s, task meta, agent summaries — all via union/ intersect/merge/session_interval/count_switches. - get_today Tauri command (resolves local today + now); tracing span + a "today computed" event for OpenObserve visibility. Golden-compare on the live DB: every field matches the Node /api/today exactly (sessions 56, focus_s 9228, agent_s 8868, supervised 4043, autonomous 4825, switch_count 13, presence_segments 20, …). #[ignore]d smoke test pins it + asserts the invariants. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Enrich the freshly-ported get_today (meridian-core) so a single OpenObserve trace tells the whole story — the gap the active-session path exposed (bare timing spans, the one explanatory log filtered out at INFO). - Promote the rollup summary from debug! to info! and widen it (session_count, supervised_s, gaps added). get_today is NOT on the daemon hot path (only the Tauri command + smoke test call it), so INFO won't spam daemon.log the way get_active_session would — and it surfaces at the tray's default INFO level without cranking RUST_LOG=meridian=debug. - Add info_span! children on the four data reads (app_sessions, active_session, gaps, pm_tasks) via .instrument(), so the trace tree shows per-query timing — where the time actually goes. info_span (not debug_span) so the children populate at the default INFO filter, consistent with the summary. - Add a debug! local-day-window event (start/end) for the UTC [start,end) bounds each read ran against — dig-deeper detail, left at DEBUG. Verified: cargo clippy -p meridian-core -D warnings clean, 10 core unit tests pass, today_smoke compiles (ignored, needs live DB), meridian-tray checks clean against the changed core. OO trace-tree + correlated INFO log to be eyeballed in dev via cargo tauri dev --features otel. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ui skill - today_smoke.rs: add #[ignore]d today_perf — in-process get_today ~1.3ms/call (vs the Node HTTP route ~8.4ms; the gap is the HTTP + Node-runtime hop, not the SQL engine — better-sqlite3 is fast). - meridian-ui/SKILL.md: forward-pointer noting the API routes are being ported to Rust/meridian-core (skill still describes the current Node dashboard on main). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stage: port the 7-day Week summary from ui/app/api/week/route.ts into meridian_core::week + a get_week Tauri command. Per day: SUM(duration_s) GROUP BY category over the foreground stream only (agent overlay double-counts), cats in hours, active session folded into today. Replicates the route's NAIVE local-date string bounds (string-compared) — deliberately NOT /api/today's UTC bounds — and its ms-based 7-day walk so DST behaves identically. Golden-compare on the live DB: the 6 historical days are byte-identical (every total_s + cats float); today matches modulo ~2s of active-session elapsed that advanced between the Node curl and the Rust run (now-dependent, expected). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… browser) Add a "Open Dashboard (native)" tray item that opens an in-app WebView window (label "dashboard", dashboard.html) instead of the external browser. The page calls the Rust commands get_today + get_week over meridian-core and renders the figures — no Node server, no fetch, no browser. This is the dashboard-into-Tauri end-state, demonstrated for the Today + Week views (the existing "Open Dashboard" browser item is kept until the full fold lands). With `--features otel` the get_today/get_week invocations emit spans to OpenObserve (service.name = meridian-tray) when the window opens — live trace visibility into the read path. Reuses the window if already open. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ems) The poll loop's health-driven menu rebuild (poll.rs::update_toggle_menu) hardcoded its own 5-item menu and clobbered setup()'s 6-item one within ~30s of launch, silently dropping "Open Dashboard (native)". Two menu definitions had drifted. Extract one build_tray_menu() in lib.rs as the sole definition of the tray's items; both setup() and the poll path call it. Only the toggle label is health-dependent. Adding an item now updates both call sites at once — the drift that hid the native item can't recur. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the Setup wizard window (Phase 2 step 6): a 5-step flow (welcome → permissions → model → connect tracker → done) opened from a new "Setup…" tray item. Real now: the macOS privacy-pane deep-links (open_permission_pane command → x-apple.systempreferences:). Honestly stubbed and labelled as such: live permission/model detection and tracker auth land in the next slices — no faked spinners or flows. Also fixes a latent capability bug: capabilities/default.json scoped invoke access to ["main"] only, so the dashboard AND setup webview windows could not call any Tauri command (the dashboard path was never exercised live because the menu item was being clobbered — see prior commit). Scope now covers main/dashboard/setup and grants core:window:allow-close so the wizard can close itself. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Audit of the meridian-core + tray commits surfaced four observability holes;
this closes the clear wins (deferred: tray OTel flush-on-quit, legacy poll.rs
HTTP instrumentation).
TRACES
- week.rs: mirror the today.rs enrichment. Promote the rollup summary
debug! -> info! (week is user-facing + infrequent, not daemon-hot, so it
surfaces at the default INFO filter) and add a per-day info_span!
"week.read.day"{day} on the GROUP BY query so the trace tree shows which
day's read costs what.
- open_existing: add a tracing::info!(uri) on success so that span has a
correlated log to pivot to (it emitted nothing before) — once per process,
benefits both tray and daemon.
LOGS (silent failures made visible)
- today.rs: the gaps / pm_tasks / active_session reads swallowed DB errors
into empty data (.unwrap_or_default() / .ok().flatten()) with no trace —
a missing table or locked DB produced a silently-empty dashboard. Each now
warn!s on the error arm while keeping the graceful-empty fallback.
- week.rs: the today active_session read dropped its Err via if-let; now a
match that warn!s.
- tray commands get_active/get_today/get_week: errors were returned to the
webview but never logged server-side; warn! before stringifying so command
failures appear in OO.
Verified: cargo fmt, clippy -D warnings clean on meridian-core + meridian-tray,
core unit tests pass (smoke/perf ignored — need live DB).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… window
Begins folding the dashboard into Tauri (one Next frontend, Rust commands).
This stage wires dev and stands up the first Next-in-Tauri window without
regressing anything — the `main` popover is dormant (visible:false, never
shown; the menu is the whole UI), so flipping devUrl is safe.
- tauri.conf.json: devUrl → http://localhost:3939 (next dev) + beforeDevCommand
starts it. frontendDist stays ../src until the export cutover (stage 4).
- lib.rs: "Setup…" window → Next /setup route (was wizard.html); "Open
Dashboard (native)" → the real Next dashboard /today (was dashboard.html).
- ui/app/setup/page.tsx: the wizard ported to Next/React, talking to Rust over
the global __TAURI__ invoke bridge (open_permission_pane) — no /api, no Node.
Permission/model/tracker remain honestly stubbed (live detection next slice).
The vanilla tray/src/{wizard,dashboard}.{html,js} are now unreferenced; they're
deleted in stage 3 once the Next windows are confirmed loading. Plan:
~/.claude/plans/meridian-next-fold.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stage 0 pointed Tauri's devUrl at next dev, but Tauri watches the whole cargo workspace (meridian/) to rebuild the Rust tray — and next dev rewrites ui/.next on every compile/route request. Each write tripped "Rebuilding application" and re-ran the tray binary, restarting it repeatedly (and orphaning a menu-bar icon per restart). Visible as repeated "observability initialised" and a tray that reloads when /setup is first requested. Add a .taurignore at the watched root excluding ui/ (the frontend hot-reloads via next dev on its own; Tauri never needs to watch it). Globs not bare folders, per tauri#8901. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First vertical slice of the route port: a dashboard page reading from a Rust
command instead of the /api route, with zero regression to the browser path.
- ui/lib/bridge.ts: shared Tauri bridge. `load(apiPath, command)` prefers the
Rust command inside the Tauri window, falls back to /api in a plain browser
(the dashboard is opened both ways during the fold). `invoke` for app-only
actions. The fetch fallback is dropped at the export cutover (stage 4).
- TodayView: fetch('/api/today') → load('/api/today','get_today'). In the
native window the Today view now runs entirely on meridian-core (no Node API
hop); get_today is the already-ported, byte-identical command.
- setup/page.tsx: use the shared bridge (drop its local duplicate).
Pattern for the remaining routes: port the command, then switch its consumer's
fetch → load. The /api route stays until the export cutover.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Same dual-path swap as TodayView: fetch('/api/week') → load(...,'get_week').
get_week is the already-ported, byte-identical command, so this is zero-Rust.
All three pre-ported read commands (today/week) now drive their views in-app;
active needs a shape-matching command first (get_active returns the raw row,
/api/active returns a computed {app_name, elapsed_s}) — ported later.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
wizard.html/js and dashboard.html/js are dead since stage 0 pointed those windows at the Next /setup and /today routes (lib.rs). Deleted now that the Next replacements are confirmed loading (GET /setup 200, /today in-window). Kept: index.html + app.js + style.css — still the frontendDist entry (the build needs index.html) and the dormant `main` popover. They're removed in stage 3 when the popover becomes a Next route and frontendDist moves to the static export. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Daily coding-agent totals (Claude Code, Codex): per-agent + overall union of today's agent sessions. Faithful port — unions the raw started_at/ended_at spans (the route does NOT cap to duration_s), matching byte-for-byte. No consumer swap: /api/coding-agents has no dashboard consumer (Today already surfaces agent time via agent_s/agent_segments), so the route can't be deleted until external use is ruled out. Command ported for parity + future use. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-app
get_worklogs(pool, day): the day's worklogs (pm_worklogs LEFT JOIN pm_tasks),
payload_json parsed into bullets/next_steps/risk_flags/summary/reasoning, with
the route's https-only task_url filter and state counts. day defaults to today.
WorklogsView: the GET fetch → load('get_worklogs', { day }). The /api/worklogs/[id]
mutations stay on fetch until those write routes are ported (later in stage 1).
Note: counts is a BTreeMap (sorted keys) vs the route's insertion order —
functionally identical (consumers read by key).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…in-app
The complex read: per-task time = your foreground time on the task + agent
time that ran while you were away (autonomous = agent union − intersect with
your presence), the foreground category split, week totals, unassigned, and
board-hygiene verdicts from pm_task_curation.
- meridian-core/hygiene.rs: faithful port of lib/hygiene.ts parseIssues +
reason hint/fix/severity (the frontend keeps its types + hasMustFix).
- meridian-core/tasks.rs: get_tasks(pool, today, week_start, now_iso). Reuses
session_interval/union/intersect/merge. Tolerates DBs predating
pm_task_curation (mig 038) / ignored_codes (mig 040) via sqlite_master +
pragma_table_info, same as the route. Snoozed-to-future rows drop off.
- get_tasks command resolves today / 6-days-ago / now.
- Consumers swapped fetch('/api/tasks') → load(...,'get_tasks'): TasksView,
MustFixBanner, CleanupView, WorklogsView (reject-candidate lookup).
Note: cats/counts use BTreeMap (sorted keys) vs the route's insertion order —
functionally identical (read by key). Worth a live compare (native vs browser)
given the interval math; logic mirrors the route line-for-line.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The route reshapes the active_session row (elapsed_s = now − started_at,
JSON columns parsed, category default idle_personal) into ActiveSessionRow —
distinct from the raw get_active_session the daemon uses. New
meridian_core::active::get_active_view(pool, now); the get_active command now
returns this view (the dormant popover only read app_name/frame_count, both
present, so no live consumer changes shape).
Sidebar: fetch('/api/active') → load('/api/active','get_active').
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… GET
Per the no-drift decision: RuntimeSettings + load_runtime_settings +
settings_json_path move from the daemon's src/config.rs into
meridian-core::settings (single source of truth). The daemon re-exports them
(pub use), so config::{RuntimeSettings, load_runtime_settings} resolve
unchanged for every existing caller (observability.rs etc.). shellexpand is
replaced with a small ~/-expansion so the lean crate gains no dep; Serialize
is added so the dashboard command can return it.
- get_settings tray command (the ported /api/settings GET): reads via the
shared reader, coerces Option::None string fields → '' and redacts
oo_password to a sentinel, matching the route byte-for-byte.
- SettingsView GET: fetch('/api/settings') → load(...,'get_settings'). The PUT
saves stay on fetch until the write route is ported.
- serde_json added as a direct tray dep (was only transitive).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Which trackers are connected. Split across the architecture line: the DB half (pm_sync_state last errors) is meridian_core::integrations::sync_errors; the env/file half is a tray module (filesystem, not a shared DB read). - OAuth providers (jira/trello) detected by ~/.meridian/oauth/<p>.json (install-independent, exact). Token providers (linear/github/azure) by .env keys with the route's placeholder filter (your- / _your_ / -here). - Env path mirrors the DAEMON (bundle ~/.meridian/app/.env, else first .env walking up from cwd = dotenvy) rather than the route's NODE_ENV split, so it reflects what the daemon reads. Approximate when multiple .envs coexist; documented in the module. OAuth detection is exact regardless. - TasksView: 3 GET reads → load(...,'get_integrations'). DELETE + azure discover (writes) stay on fetch until those routes are ported. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes the test gap on the riskiest ported logic: - hygiene::parse_issues — 6 unit tests (pure, no DB): fixable-only filter, ignored-codes filter, must_fix vs optional severity, thin_description hint char count, fix shape, empty/malformed → []. - tests/readers.rs — in-memory single-connection SQLite seeded with hand- computable rows: coding_agents union/dedup + per-agent sort; tasks autonomous = agent-time-outside-presence (1800s) and today_s = your + autonomous (5400s), placed relative to local_day_bounds(today) so it's timezone-independent. sqlx added to dev-dependencies (regular dep, but integration tests need it in scope). Still TODO: worklogs/active shaping tests + the tray file/env commands (settings redaction, integrations env parsing). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the DB→UI data flow fully traceable: with RUST_LOG=meridian=debug a single
page render emits the complete sequence of fetch operations through
meridian-core, while INFO stays a one-line-per-page heartbeat.
Uniform convention applied to active / integrations / coding_agents / worklogs /
tasks / today / week:
- every DB op wrapped in a debug_span!("<mod>.read.<thing>") (per-query timing in
the OO trace tree) + a debug!(rows/found = …) event right after (the readable
flow in the logs).
- one INFO summary per page render (the heartbeat). today/week already INFO;
promoted coding_agents/worklogs/tasks debug!→info!; added an INFO summary to
integrations. active stays all-DEBUG (the popover polls it — no INFO spam).
- today/week query spans switched info_span!→debug_span! so the whole flow sits
at one level (the granular ops are DEBUG, consistent with the rest).
tasks.rs (7 reads) instruments pm_tasks, today/week presence + sessions, the
unassigned sum, and the three pm_task_curation probes in load_hygiene, each
labelled by scope where it repeats.
Commands already carry #[tracing::instrument] (the request root span) + warn! on
error, so the chain is command → core fn → per-op debug spans/events → summary.
Verified: cargo fmt, clippy -D warnings clean on meridian-core + meridian-tray,
18 core unit tests pass (smoke/perf ignored — need live DB).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two triage-family routes, landing on opposite sides of the architecture line, both with full module/function docs (purpose, callers, related modules) and the richer tracing pattern (per-query debug_span + row-count debug! + info! summary). - meridian_core::triage (DB read): the cleanup working set — pm_task_curation ⋈ pm_tasks, worst-first, snooze-filtered, each reason hinted. Its reason_hint is triage-specific (distinct wording from hygiene's — NOT shared). Tolerates a pre-migration-038 DB (has_run=false). get_triage command added; PARITY only — no dashboard consumer (cleanup reads hygiene via /api/tasks), documented as such. - tray parents module (shell-out): get_ticket_parents spawns `meridian ticket-parents` (native-binary-first resolution, 30s timeout, parses the last JSON line), returning a 200-equivalent payload with an `error` field on failure rather than throwing — matching the route. HygieneDialog's parent-picker now load()s it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
refactor(tray): unified install-mode env resolution + path visibility
Stand up a pre-production staging release channel on `pre-main` that exercises
the full pipeline + app + live auto-update before `pre-main → main`, without
touching production. Reuses the proven staging skeleton (GitHub-only,
prerelease-versioned) and grafts on the DMG auto-update piece the legacy
staging config predated.
Four new files:
- tray/src-tauri/tauri.staging.conf.json — build-time --config overlay pointing
the updater at the staging endpoint (…/releases/download/updater-staging/
latest.json). Never committed into tauri.conf.json, so it cannot leak to main.
- .releaserc.staging.json — staging semantic-release config: branches adds
{pre-main, prerelease: staging}; set-version runs FIRST (fixes the legacy
version-baking footgun); tray build takes the staging overlay; GitHub-only
(dropped npm/git/changelog plugins so no version-bump commits pollute
pre-main's tree); publishCmd mirrors latest.json to the rolling staging tag.
- scripts/mirror-staging-release.sh — clobbers latest.json (+ a stable DMG)
onto a fixed `updater-staging` prerelease so installed staging apps self-update.
Mirrors the repo's existing runtime-staging fixed-tag convention.
- .github/workflows/release-staging.yml — workflow_dispatch-only (a full macOS
build is ~45 min); checks out pre-main, adds TAURI_SIGNING_* secrets the
legacy staging workflow lacked, cp's the staging config in-CI (ephemeral).
Production (main: release.yml + .releaserc.json) is untouched and isolated —
the staging endpoint is baked only at build time via --config.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFeteroZ8SZnnJXsNLPxmE
…t on main The workflow_dispatch-only design hit two GitHub constraints: (1) a dispatch by name needs the workflow registered, which normally requires it on the default branch; (2) semantic-release picks the channel from GITHUB_REF, so the run must carry ref=pre-main or it would treat the build as `main` and cut a STABLE release from pre-main code. Keeping the workflow off `main` and triggering it from pre-main resolves both. pre-main is a busy integration branch, so guard the build: it runs only on a manual dispatch OR a push whose head-commit message contains [staging-release] — ordinary fold/feature merges into pre-main never trigger a 45-min build. The first marker-commit push also registers the workflow, after which the dispatch button / `gh workflow run --ref pre-main` work too. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFeteroZ8SZnnJXsNLPxmE
ci(release): DMG auto-update staging channel on pre-main
The first staging build (v1.64.0-staging.1) failed at cargo resolution: the
tray's capture stack (screenpipe-screen/-a11y) is a git dep on the PRIVATE
Meridiona/screenpipe-fork, and CI's default GITHUB_TOKEN is scoped to this repo
only, so `git fetch` of the fork 403s ("Write access to repository not
granted"). No workflow has ever built the tray with the fold, so this gap was
invisible until now — and it would break the production release.yml at cutover
too.
Rewrite just the screenpipe-fork URL to embed the GH_TOKEN PAT, and force
CARGO_NET_GIT_FETCH_WITH_CLI so cargo honors it. Requires GH_TOKEN to have read
access to Meridiona/screenpipe-fork.
Note: release.yml (production) needs the same fix before pre-main → main.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFeteroZ8SZnnJXsNLPxmE
ci(release): authenticate CI git to the private screenpipe-fork dep
Cut a second staging build so the installed v1.64.0-staging.1 has a newer version to self-update to — validates the live staging→staging auto-update. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFeteroZ8SZnnJXsNLPxmE
Empty fix-type commit so semantic-release bumps to 1.64.0-staging.2 (the previous test:-typed commit warranted no release). Gives the installed staging.1 a newer version to self-update to. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFeteroZ8SZnnJXsNLPxmE
…er [staging-release] create-icons.sh regenerates every app-icon size from meridiona-mark.png at build time. That source file held a 220px ring-dot placeholder, so each build CLOBBERED the committed spirograph icon.icns with ring-dot icons — which is why the packaged app shipped the wrong logo. Replace meridiona-mark.png with the 1024px gradient-spirograph (extracted with alpha from the already-committed icon.icns), so create-icons.sh regenerates the correct brand icons. Only the source mark changes — the generated outputs already matched the spirograph, confirming the placeholder was the sole defect. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFeteroZ8SZnnJXsNLPxmE
|
Important Review skippedToo many files! This PR contains 219 files, which is 69 over the limit of 150. To get a review, narrow the scope: Upgrade to a paid plan to raise the limit. ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (5)
📒 Files selected for processing (219)
You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
| try: | ||
| if f.is_file(): | ||
| total += f.stat().st_size | ||
| except OSError: |
The CI Rust job (fmt/clippy/test) resolves the whole workspace, including
the tray's optional capture deps (screenpipe-screen/-a11y) which are a git
dependency on the PRIVATE Meridiona/screenpipe-fork. With only the repo-scoped
GITHUB_TOKEN, cargo's `git fetch` of the fork fails ("could not read Username
for github.com"), so clippy/test never start.
Port the exact mechanism release.yml / release-staging.yml already use:
- CARGO_NET_GIT_FETCH_WITH_CLI=true so cargo honors url.insteadOf (libgit2 won't)
- a git config step rewriting the fork URL to embed secrets.GH_TOKEN
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKdh3tKWLZhtQ9FCfA5RYH
ci: authenticate private screenpipe-fork git dep in the Rust job
…fork dep Mirror the fix already proven on release-staging.yml into the production pipeline. Without it, the first stable release after the fold lands on main fails at cargo resolution with a 403 on Meridiona/screenpipe-fork (the tray's capture git dep), exactly as the first staging build did. Adds the url.insteadOf GH_TOKEN rewrite + CARGO_NET_GIT_FETCH_WITH_CLI. Lands on pre-main so the pre-main -> main cutover carries it into the production release.yml. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFeteroZ8SZnnJXsNLPxmE
…rade A user migrating from the old npm/curl bundle to the self-contained DMG carries launchd agents and a credential file the new topology no longer matches. The DMG install path only cleaned up the legacy screenpipe agent; three gaps remained: - com.meridiona.mlx-server (bundle launchd MLX on :7823) was left running, contending with the tray's in-process MlxManager on the same port → EADDRINUSE spawn-retry churn + log spam. - com.meridiona.ui (retired standalone dashboard server) lingered as a zombie Node process burning ~150 MB. - Bundle creds at ~/.meridian/app/.env were not copied to the canonical ~/.meridian/.env the DMG daemon reads → Jira/GitHub/Linear tokens silently lost, forcing a wizard re-entry. Add cleanup_legacy_mlx_server() + cleanup_legacy_ui() (mirroring the existing cleanup_legacy_screenpipe idiom) and migrate_legacy_bundle_env(), which copies the bundle .env across only when the canonical one is absent — never clobbering creds the tray already wrote. Covered by a unit test over all three branches. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SKdh3tKWLZhtQ9FCfA5RYH
adityaharishch
left a comment
There was a problem hiding this comment.
Code Review — PR #330 (pre-main → main)
Reviewed 8 angles across 223 changed files (diff too large for the GitHub API; files fetched directly). Findings ranked most-severe first. Four are posted inline; four below could not be pinned to diff lines.
[CORRECTNESS — HIGH] src/health/ui.rs + src/health/platform.rs probe the deleted Next.js server — meridian doctor is permanently CRITICAL on every healthy post-PR install
platform.rs still declares const LABEL_UI: &str = "com.meridiona.ui" and ui_service() calls plist_check(LABEL_UI, "ui plist") which returns Check::critical("missing or invalid"). backend_install.rs no longer registers that launchd agent (only daemon + a11y). ui.rs still GETs http://localhost:3939/api/health (nothing listens post-fold). Both are called unconditionally from run_all().
On every healthy post-PR install:
- ✗ CRITICAL “ui plist — missing or invalid” (remedy:
./install.sh— can never clear) - ⚠ WARN “ui service — not loaded — dashboard unavailable”
- ⚠ WARN “ui serving — not reachable at http://localhost:3939”
Report::worst() → Critical → meridian doctor exits non-zero on healthy machines. The entire UI health group needs to be replaced with embedded-webview health semantics or removed.
[SECURITY — MEDIUM] install.sh downloads OpenObserve with no integrity check, then registers it as a launchd agent
Around L602–629, the installer curls the OpenObserve binary from downloads.openobserve.ai, extracts, chmod +xs, and registers it as a launchd agent with no sha256 or signature verification. Version is pinned (v0.90.3) but artifact integrity is not. A compromised host or MITM gives an attacker a trojan that runs continuously under the user’s launchd.
Fix: pin and verify a sha256 for the OO tarball before executing it.
[SECURITY — MEDIUM] scripts/install-from-bundle.sh fallback sudo npm install -g screenpipe runs npm lifecycle scripts as root
Around L1262–L1265, when the global npm prefix is not user-writable, the script falls back to sudo npm install -g screenpipe@0.4.6. The script explicitly refuses to run as root elsewhere (around L1222) — this single path hands root to npm postinstall scripts from a third-party package. A compromised screenpipe@0.4.6 executes as root.
Fix: use --ignore-scripts or stage into a user-writable prefix instead of escalating.
[CORRECTNESS — MEDIUM] src/health/capture.rs capture coverage datetime filter is a no-op against RFC3339 timestamps
capture_coverage filters with WHERE timestamp > datetime('now', '-1 day'). SQLite datetime() produces '2026-06-23 17:00:00' (space separator, no Z). insert_capture_frame writes to_rfc3339_opts(Micros, true) = '2026-06-24T16:00:00.000000Z' (T separator, Z suffix). String compare at index 10: 'T' (0x54) > ' ' (0x20) → every row passes the filter regardless of actual date. The -1 day window never excludes anything → coverage check evaluates all-time frames → ghosting goes undetected.
frame_freshness() in the same file already uses julianday() correctly. Fix: adopt the same julianday() arithmetic for capture_coverage.
| /// | ||
| /// `None` → no runtime published for this channel (wizard shows "not available"). | ||
| pub fn manifest_url() -> Option<String> { | ||
| if let Ok(url) = std::env::var("MERIDIAN_RUNTIME_MANIFEST_URL") { |
There was a problem hiding this comment.
[SECURITY — HIGH] MLX runtime tarball has no signature — SHA-256 is self-referential and MERIDIAN_RUNTIME_MANIFEST_URL is redirectable in production
manifest_url() reads MERIDIAN_RUNTIME_MANIFEST_URL unconditionally in release builds. The comment at L200–204 that this “only fires in dev” is a deployment assumption, not an enforced gate (#[cfg(debug_assertions)]).
Combined with download_and_stage (~L340–448) which only verifies a SHA-256 fetched from the same manifest it just downloaded (no minisign), an attacker who can set this env var — or MITM the HTTPS manifest endpoint — controls both the tarball URL and the expected hash, and gets arbitrary code execution: bin/python from the staged runtime is spawned in start() (~L905–915).
The .app updater correctly uses tauri-plugin-updater with an embedded minisign pubkey; this second update channel has no equivalent protection.
Fix: gate the env override behind #[cfg(debug_assertions)]; add minisign verification to download_and_stage matching the updater path.
There was a problem hiding this comment.
Partially resolved in PR #333 (merged to pre-main).
The env-var path is now gated behind #[cfg(debug_assertions)] — a release binary can no longer be redirected via a compromised environment variable.
The self-referential SHA-256 issue (manifest and tarball served from the same origin, so a compromised server can swap both atomically) is a separate architectural gap — it requires an out-of-band trust root (minisign public key embedded in the binary, similar to the updater's pub_key). Tracked as a follow-up design task; not blocking the fold.
| # dist/meridian-mlx-runtime-<ver>-aarch64.tar.gz (CPython + venv + agents pkg + launcher) | ||
| # dist/runtime-manifest.json (version, url, sha256, size, floors) | ||
| # | ||
| # The tarball bundles its OWN python-build-standalone CPython, so the customer |
There was a problem hiding this comment.
[SECURITY — MEDIUM] CPython bundled from unpinned PBS releases/latest with no checksum
When PBS_ASSET_URL is unset (the default CI path), the script resolves the CPython interpreter from astral-sh/python-build-standalone at releases/latest — unpinned — and the downloaded archive is extracted without verifying a sha256 before tar -xzf.
This chains into the MLX runtime signing gap: the runtime tarball ships this interpreter, the tarball is only verified against a self-referential SHA-256 in the unsigned manifest, and now the interpreter itself comes from an unverified upstream.
Fix: pin PBS_ASSET_URL to an exact release tag and verify the archive against the published sha256 before extraction.
There was a problem hiding this comment.
Deferred — not resolved in PR #333.
Pinning to a specific PBS release tag and verifying the downloaded archive are straightforward changes, but they require the real sha256 of the target release. Adding a fabricated/guessed hash would be worse than leaving it unpinned. Tracked as a follow-up: pin PBS_RELEASE_TAG and add sha256sum --check before tar -xzf.
fix(install): clean up legacy bundle agents + migrate .env on DMG upgrade
- health/ui.rs + platform.rs: remove legacy Node-era HTTP probes
(serve_health, serve_mode_check, asset checks) — the dashboard is
now a static export embedded in the Tauri binary; no com.meridiona.ui
launchd agent or HTTP port exists post-fold. meridian doctor was
reporting CRITICAL on every healthy post-fold install. Both checks now
return an Info line.
- health/capture.rs: fix capture_coverage datetime comparison. The
query used datetime('now', ?1) which produces '2026-06-23 17:00:00'
(space separator, no Z), while insert_capture_frame writes RFC 3339
'2026-06-24T16:00:00.000000Z' (T+Z). String comparison at index 10 had
'T' > ' ' so every row passed the filter — the 1-day coverage window
never excluded anything. Rewritten as julianday(timestamp) >
julianday('now', ?1) following the existing frame_freshness pattern.
- poll/mod.rs (supervise_mlx): add MLX_COOLING_TICKS=10 cooling period.
Budget-exhausted path now lets attempts grow past MLX_MAX_RESTARTS; at
MAX+COOLING ticks it resets to 0 so supervise() can kill any zombie on
port 7823 and attempt a new restart cycle. Prevents permanent wedge
after OOM/crash.
- bridge.ts (subscribe): replace silent .catch(() => {}) with retry-once-
after-2s for the cold-DB startup race (tray beats daemon on launch),
then console.warn on second failure so it is visible in DevTools.
- install-from-bundle.sh: add --ignore-scripts to both npm install lines
for screenpipe. The sudo path previously ran lifecycle scripts as root.
- mlx_server.rs (manifest_url): gate MERIDIAN_RUNTIME_MANIFEST_URL env-
var override behind #[cfg(debug_assertions)]. Kills the "env redirects
the manifest in production" half of the HIGH security finding. The
self-referential SHA-256 issue (manifest and tarball served from the
same origin) requires an out-of-band trust root and is deferred.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKdh3tKWLZhtQ9FCfA5RYH
Review findings resolved → PR #333All findings from @adityaharishch's review have been addressed in PR #333 ( Fixed
Deferred (acknowledged, not blocking)
|
fix(health): resolve PR #330 review findings
Review findings — status update (PR #333 merged to pre-main)All inline threads updated. Summary of what's resolved vs. still open:
PR #333 is merged into pre-main. The remaining two open items are tracked as follow-ups and are not blocking the |
|
The three deferred items are now tracked in the Obsidian tech backlog (Architecture/Tech Backlog.md):
|
Summary
Promotes
pre-main→main: the culmination of the Next-fold and the self-contained-DMG / setup re-platform initiative. Meridian moves from a Node-server + external-screenpipe-process topology to a single self-contained Tauri.app— the dashboard runs inside the webview as a static export, every/apiroute is ported to Rust, capture runs in-process, and the MLX runtime is provisioned/supervised by the tray.This bundles 13 already-reviewed feature PRs (#298, #314–#329) plus post-merge review + CI fixes.
meridian-core— 23 files, ~6,050 LOCfeat, 49fix, 11chore, 9refactor, 8docs, 7build, 6test, 6civ1.64.0(minor — see Versioning)Meridian.app.tar.gz19.3 MB)Architecture changes
The core shift: delete the Node UI server and the external screenpipe process; collapse everything into one signed
.appwhere the tray owns capture + the embedded dashboard, the daemon owns ETL, andmeridian-coreis the shared data layer both link against.Before (
v1.63.0)screenpiperuns as its own launchd process → writes~/.screenpipe/db.sqlite; the daemon reads it cross-DB.com.meridiona.uilaunchd agent) serves the dashboard and ~32/api/*routes, readingmeridian.dboverbetter-sqlite3./apiroutes over HTTP.After (
v1.64.0)screenpipe-screen+screenpipe-a11y) → writescapture_frames/capture_ui_eventsintomeridian.db. No screenpipe DB / process / pool.invoke/ events throughui/lib/bridge.ts. No Node server, no/apiroutes.meridian-core/src/readers/(shared by daemon + tray); file/env/process actions are tray commands. The four SSE streams became Tauri events off the tray poll loop.flowchart TB subgraph BEFORE["BEFORE — v1.63.0 (multi-process)"] direction TB sp1[screenpipe process]:::ext --> spdb[(~/.screenpipe/db.sqlite)]:::ext spdb --> d1[meridian daemon · ETL] d1 --> mdb1[(meridian.db)] node[Next.js Node server<br/>com.meridiona.ui]:::ext --> mdb1 node -->|serves| dash1[Dashboard + /api routes] tray1[Tauri tray] -->|HTTP poll /api| node mlx1[MLX Python server]:::py --> d1 end subgraph AFTER["AFTER — v1.64.0 (single self-contained .app)"] direction TB subgraph app["Meridian.app"] cap[Tray · in-process capture<br/>screenpipe-screen/-a11y]:::new core[[meridian-core<br/>shared readers + commands]]:::new webview[Embedded dashboard<br/>static export]:::new d2[meridian daemon · ETL] end cap -->|capture_frames / capture_ui_events| mdb2[(meridian.db)] d2 -->|reads capture tables| mdb2 d2 -->|app_sessions| mdb2 webview -->|Tauri invoke / events| core core --> mdb2 mlx2[MLX Python server<br/>provisioned + supervised by tray]:::py --> d2 end classDef ext fill:#fde,stroke:#b66; classDef new fill:#dfe,stroke:#6b6; classDef py fill:#eef,stroke:#66b;Features implemented
1. Next-fold — dashboard into Tauri,
/api→ Rust (#298, #314)output: 'export') — no Node server./api/*routes ported: DB reads →meridian-core/src/readers/(re-exported by the daemon), file/env/process →tray/src-tauri/src/commands/.com.meridiona.uiplist,ui-start.sh, pinned Node + better-sqlite3 ABI dance). Dashboard now ships embedded in the tray binary.2. Self-contained DMG + setup re-platform (#315, #318, #319, #320, #321, #326)
.app— daemon + a11y staged to stable~/.meridian/bin/, plists rendered, launchctl bootstrap (feat(tray): bundle the non-capture backend into the .app (Gap-2 Bucket 1, slice 1) #321).meridian setupcommands + native setup UI (feat(tray): onboarding wizard — MLX manager, setup commands, first-run auto-open, dead menu fixes #315, feat(setup): port "A · Rail" first-run wizard with on-device model picker #326).meridian uninstall [--purge] [--dry-run] [--yes]command for clean teardown.3. In-process capture — screenpipe forked at MIT (#323, #324)
capture_frames/capture_ui_eventsintomeridian.db.run_etl(meridian)instead ofrun_etl(screenpipe, meridian)).4. Tray popover redesign (#327)
5. Auto-update + release engineering (#328, #329)
tauri-plugin-updaterstaging channel wired into the release pipeline (ci(release): DMG auto-update staging channel on pre-main #328); staging private-fork auth fix (ci(release): authenticate CI git to the private screenpipe-fork dep #329). Minisign trust, independent of Apple Dev ID. Proven end-to-end (ad-hoc bundle relaunch).6. Post-merge review + CI fixes
/code-reviewof refactor(tray): unified install-mode env resolution + path visibility #298 surfaced 4 correctness divergences from the original TS routes — all fixed (b54ec04): multibyte log-tail drop, leakedtasks-syncchild, lenient quiet-hours parser, divergent DB-path resolver.screenpipe-forkgit dep (ci: authenticate private screenpipe-fork git dep in the Rust job #331).Compatibility & breaking changes
Audited across four surfaces (DB schema, public interfaces, install/upgrade path, runtime behavior). No hard breaking changes for existing installs — the version-affecting changes are additive or documented v1 degradations.
✅ Safe / non-breaking
capture_frames, 047capture_ui_events, 048 ETL-cursor reset) — no modified/renamed/deleted migrations (CI "Migrations append-only" guard enforced), noDROP/ALTER/destructive changes. Existingmeridian.dbupgrades cleanly;app_sessionshistory preserved.settings.jsonkey, or MCP tool removed or renamed (only additions). The deleted/api/*routes were internal-only (consumed solely by the bundled dashboard, which moved to Tauriinvokein lockstep).busy_timeout(5s)on the meridian pool for the new tray↔daemon concurrent-write topology — backward compatible.get_audio_snippetsstubbed empty (in-process capture is text/OCR/a11y only). Classifier runs on text, so accuracy is unaffected;audio_snippetsis simply always empty.system_sleep(capture_triggeris NULL).Build & artifacts
Meridian.dmg), updater bundleMeridian.app.tar.gz19.3 MB + minisign.sig+latest.json.release-staging.yml: build → sign → updater-sign → package → publish + mirror)..appcarries the daemon, a11y helper, embedded dashboard, and in-process capture; the MLX runtime provisions on first run.Quality & test coverage
meridian-core63, tray 27, integration 64 (tests/etl_*.rs,task_linker_smoke,tray_assets,worklog_provider, +meridian-core/tests/readers.rs,today_smoke.rs).ui/__tests__/: intervals, format, category-colors, cursor-pointer, redesign, lib, meridian-bin)./apiroutes.-D warnings+ test) 2m52s, UI build 41s, Migrations-append-only guard, screenpipe MIT-pin guard, CodeQL (rust/js/python/actions), CodeRabbit.cargo test+ UI build + UI tests + security audit).Versioning
v1.63.0→v1.64.0(minor). semantic-release derives the bump from conventional commits: 72feat:force at least a minor, and zeroBREAKING CHANGE:footers /!markers mean no major. Empirically confirmed — the staging channel already cutv1.64.0-staging.{1,2,3}from these exact commits.Known follow-ups (deferred, non-blocking)
week.rsUTC bucketing,refresh.rspoll-loop efficiency, file-size splits (incl.mlx_server.rs1183 lines),table_exists()DRY extraction.Risk
Every constituent change already merged via its own reviewed PR into
pre-main; this PR is the promotion gate tomain, not new unreviewed work. Each feature is independently revertable at its merge commit. CI is fully green.🤖 Generated with Claude Code