Skip to content

refactor(memory): route Obsidian deep link through workspace-link layer (#2492)#2702

Open
YOMXXX wants to merge 8 commits into
tinyhumansai:mainfrom
YOMXXX:fix/2492-memory-workspace-link-migration
Open

refactor(memory): route Obsidian deep link through workspace-link layer (#2492)#2702
YOMXXX wants to merge 8 commits into
tinyhumansai:mainfrom
YOMXXX:fix/2492-memory-workspace-link-migration

Conversation

@YOMXXX
Copy link
Copy Markdown
Contributor

@YOMXXX YOMXXX commented May 26, 2026

Summary

  • New Tauri command resolve_workspace_absolute_path exposes the workspace-link resolver introduced in feat(tauri): support workspace file links #2476 so renderers can derive canonical absolute paths through the same guard as open_workspace_path / reveal_workspace_path / preview_workspace_text.
  • New TS wrapper resolveWorkspaceAbsolutePath in utils/tauriCommands/workspacePaths.ts.
  • ObsidianVaultSection now resolves the vault's absolute path through that wrapper before building the obsidian://open?path=<abs> URL, replacing the last Memory-tab surface that was constructing the deep link straight from a graph-export RPC field.
  • User-visible behavior is unchanged — click still opens Obsidian to the vault, same toast copy — but the absolute path is now derived from the Rust-side resolver, not from a prop fed by RPC.

Problem

Issue #2492 (Phase 3 cleanup of #1402) requires every Memory surface that constructs an on-disk path to compose with the shared workspace-link layer rather than hand-rolling its own resolution.

MemoryGraph was migrated in PR #2638. The remaining offender was ObsidianVaultSection, which built obsidian://open?path=${encodeURIComponent(contentRootAbs)} directly from the content_root_abs field returned by memory_tree_obsidian_vault_status — no canonicalize, no workspace-containment recheck, no shared error surface. Acceptance criterion from the issue:

Obsidian behavior is preserved intentionally — Obsidian launch remains available where it is a user-facing feature, but it is composed with the shared workspace-link layer rather than replacing it.

Solution

Three layers, kept thin:

  1. Rust (app/src-tauri/src/workspace_paths.rs): new resolve_workspace_absolute_path #[tauri::command] that wraps the existing internal resolve_workspace_path helper (same canonicalize + workspace-containment + non-leaky error surface as the other three commands). Registered in lib.rs and added to the allow-workspace-files permission manifest. Two new unit tests cover the happy-path (memory_tree/content resolves inside the workspace root) and the empty-input rejection; the existing resolver test suite already covers absolute-path / .. / URI-scheme / symlink escapes.
  2. TypeScript (app/src/utils/tauriCommands/workspacePaths.ts): resolveWorkspaceAbsolutePath(path) wrapper mirroring the style of the other workspace wrappers (assertTauri guard + plain invoke<string>).
  3. Component (app/src/components/intelligence/ObsidianVaultSection.tsx): fireDeepLink now awaits resolveWorkspaceAbsolutePath('memory_tree/content') and builds the URL from the returned absolute path. The contentRootAbs prop is preserved for display (button tooltip, guidance-panel code block, toast message text) so no cascading prop changes are needed — only the deep-link URL is rerouted.

Tests

Updated ObsidianVaultSection.test.tsx:

  • Mock the new wrapper in the existing workspacePaths mock block.
  • New test: deep link URL is composed from the resolver output (not from contentRootAbs).
  • New test: resolver rejection surfaces an error toast and keeps the guidance panel expanded.
  • All 7 existing tests still pass (9 total).

Updated MemoryWorkspace.test.tsx:

  • Mock resolveWorkspaceAbsolutePath to return the same path the assertion expects so the existing 'View vault in Obsidian' triggers the deep link test stays stable. All 20 tests pass.

Submission Checklist

  • Tests added or updated (happy path + at least one failure / edge case) per Testing Strategy
  • Diff coverage ≥ 80% — every new line is exercised by the two new Vitest cases plus the existing happy-path test (now stricter via the resolver-call assertion) and the two new Rust unit tests. Resolver internals already covered by the prior resolve_workspace_path_* test suite.
  • N/A: Coverage matrix update — this is a behaviour-preserving refactor of an existing matrix entry, not an added/removed/renamed feature row.
  • N/A: No new feature IDs from the matrix affected.
  • No new external network dependencies introduced (mock backend used per Testing Strategy)
  • N/A: Manual smoke checklist — no release-cut surface touched; deep-link UX is unchanged from the user's point of view.
  • Linked issue closed via Closes #2492 in the ## Related section.

Impact

  • Runtime/platform: desktop only (#[tauri::command] lives in the Tauri shell; iOS client does not hit this code path). No change to JSON-RPC surface.
  • Security: net positive — the Obsidian deep link now goes through the same workspace-containment guard as the other workspace commands, so a malicious content_root_abs value from the RPC (or a future scenario where the field is user-influenced) can no longer slip out of the workspace.
  • Migration / compatibility: none; the URL shape is identical.

Related


AI Authored PR Metadata (required for Codex/Linear PRs)

Linear Issue

Commit & Branch

  • Branch: fix/2492-memory-workspace-link-migration
  • Commit SHA: 3682a02d (tip), 910d8097, 8ff810ba

Validation Run

  • pnpm --filter openhuman-app format:check — equivalent: pnpm exec prettier --check on changed files, clean.
  • pnpm typecheck — ran pnpm exec tsc --noEmit inside app/, clean.
  • Focused tests: pnpm debug unit ObsidianVaultSection → 9 passed; pnpm debug unit MemoryWorkspace → 20 passed.
  • Rust fmt/check (if changed): cargo fmt --manifest-path app/src-tauri/Cargo.toml --check clean; GGML_NATIVE=OFF cargo check --manifest-path app/src-tauri/Cargo.toml --lib clean (GGML_NATIVE=OFF is the documented macOS Tahoe + whisper-rs workaround).
  • Tauri fmt/check (if changed): same as above.

Validation Blocked

  • command: n/a — all validation passed.
  • error: n/a
  • impact: n/a

Behavior Changes

  • Intended behavior change: deep-link absolute path is now derived from the workspace-link resolver instead of trusted RPC output.
  • User-visible effect: none. Same click, same Obsidian URL shape, same toast copy. The vault path printed in the guidance panel / toast still comes from contentRootAbs (RPC) because it is a display concern.

Parity Contract

  • Legacy behavior preserved: the obsidian://open?path=<abs> URL shape is byte-for-byte unchanged when the resolver returns the same path the RPC reports (normal case). MemoryWorkspace.test.tsx's existing assertion proves it.
  • Guard/fallback/dispatch parity checks: resolver failure now surfaces an error toast and keeps the guidance panel expanded — covered by a new test. Existing detection failure degrades gracefully to the guidance panel test still passes.

Duplicate / Superseded PR Handling

  • Duplicate PR(s): none
  • Canonical PR: this
  • Resolution: n/a

Summary by CodeRabbit

  • New Features

    • Added a workspace-path resolver that returns canonical absolute paths for constructing vault deep links.
  • Bug Fixes

    • Deep-link generation now uses resolved canonical workspace paths; on resolution failure it surfaces an error and does not attempt a fallback.
  • Tests

    • Added/updated tests for path resolution success, failure, canonicalization, and deep-link behavior.
  • Chores

    • Updated runtime permissions to allow workspace-path resolution.

Review Change Stack

@YOMXXX YOMXXX requested a review from a team May 26, 2026 15:49
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 26, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9cbf5908-6e62-471e-89f8-0cfd17d46033

📥 Commits

Reviewing files that changed from the base of the PR and between a56c7f7 and b3288b3.

📒 Files selected for processing (2)
  • app/src-tauri/src/workspace_paths.rs
  • app/src/components/intelligence/ObsidianVaultSection.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/src-tauri/src/workspace_paths.rs

📝 Walkthrough

Walkthrough

This PR adds a new Tauri command resolve_workspace_absolute_path to canonicalize workspace-relative paths and uses it in ObsidianVaultSection to construct Obsidian deep links with normalized paths instead of relying on the contentRootAbs prop directly.

Changes

Workspace Absolute Path Resolver

Layer / File(s) Summary
Rust command implementation and permissions
app/src-tauri/permissions/allow-workspace-files.toml, app/src-tauri/src/workspace_paths.rs, app/src-tauri/src/lib.rs
Permission config allows resolve_workspace_absolute_path command. New Rust command loads workspace root, resolves input path via existing helpers, logs the mapping, and returns canonical absolute path. Tauri handler registration exposes it to IPC. Unit tests assert canonical path return and empty-input rejection.
TypeScript Tauri command wrapper
app/src/utils/tauriCommands/workspacePaths.ts, app/src/utils/tauriCommands/workspacePaths.test.ts
New exported resolveWorkspaceAbsolutePath function asserts Tauri runtime and invokes the Rust-side command, returning the resolved absolute path string. Tests cover success, invoke rejection propagation, and non-Tauri early rejection.
Obsidian deep-link resolution and tests
app/src/components/intelligence/ObsidianVaultSection.tsx, app/src/components/intelligence/__tests__/MemoryWorkspace.test.tsx, app/src/components/intelligence/__tests__/ObsidianVaultSection.test.tsx
ObsidianVaultSection now resolves MEMORY_CONTENT_WORKSPACE_PATH via the new command before constructing obsidian://open?path=... URLs. Tests mock the resolver and verify resolver invocation, correct deep-link URL composition, and error handling (error toast, no openUrl call, guidance panel visible on failure).

Sequence Diagram

sequenceDiagram
  participant User
  participant ObsidianVaultSection
  participant resolveWorkspaceAbsolutePath
  participant resolve_workspace_absolute_path
  participant ObsidianApp as openUrl
  User->>ObsidianVaultSection: click deep-link
  ObsidianVaultSection->>resolveWorkspaceAbsolutePath: resolve(MEMORY_CONTENT_WORKSPACE_PATH)
  resolveWorkspaceAbsolutePath->>resolve_workspace_absolute_path: invoke
  resolve_workspace_absolute_path-->>resolveWorkspaceAbsolutePath: canonical absolute path
  ObsidianVaultSection->>ObsidianApp: openUrl("obsidian://open?path="+resolvedPath)
  ObsidianApp-->>User: open vault
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

Possibly related PRs

  • tinyhumansai/openhuman#2476: Extends the same workspace_paths IPC surface; #2476 added open/reveal/preview commands that this PR builds upon.

Suggested labels

memory

Suggested reviewers

  • graycyrus
  • senamakel

Poem

🐰 I hop from path to path, precise and neat,
Canonical crumbs beneath my feet.
Deep links now find vaults with ease,
Tests nod in chorus, errors appease—
A rabbit's cheer for tidy trees.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title accurately describes the main change: routing Obsidian deep-link URL composition through a shared workspace-link resolver instead of direct RPC usage, with reference to the related issue (#2492).
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai Bot added rust-core Core Rust runtime in src/: CLI, core_server, shared infrastructure. working A PR that is being worked on by the team. labels May 26, 2026
graycyrus
graycyrus previously approved these changes May 26, 2026
Copy link
Copy Markdown
Contributor

@graycyrus graycyrus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — refactor(memory): route Obsidian deep link through workspace-link layer

Clean, well-scoped refactor. Three thin layers (Rust command → TS wrapper → component plumbing), each mirroring the existing workspace-path pattern exactly. The security posture improves — the Obsidian deep link now goes through the same canonicalize + workspace-containment guard as the other three workspace commands, closing the last gap from #2492.

What I checked

Area Verdict
Rust resolve_workspace_absolute_path Delegates to existing resolve_workspace_path — same guard surface, same error semantics. to_string_lossy() is fine for URL composition. Debug log is appropriate.
Permission manifest allow-workspace-files.toml updated, lib.rs registration matches.
TS wrapper Mirrors revealWorkspacePath / openWorkspacePath style. assertTauri() guard present.
Component change fireDeepLink deps array correctly empty — no reactive values captured. contentRootAbs prop preserved for display only. Error path surfaces toast.
Tests 2 new Rust unit tests (happy path + empty input rejection). 2 new Vitest cases (resolver-sourced URL + resolver failure toast). Mock additions in MemoryWorkspace.test.tsx keep 20 existing tests stable.
Issue alignment #2492 acceptance criteria met: Obsidian composed with shared layer, behavior preserved, tests cover open/reveal/failure. Combined with #2638 (MemoryGraph), all criteria are satisfied.

CI failures — not PR-related

  • Frontend: loopbackOauthListener.test.ts timeout constant mismatch (unrelated module)
  • Rust Core/Windows: AutonomySettingsPatch missing fields in config::ops (unrelated module)
  • Coverage gate skipped due to upstream test failures

None of these touch workspace paths or the Memory tab.

LGTM. Ship it.

@YOMXXX YOMXXX force-pushed the fix/2492-memory-workspace-link-migration branch from 3682a02 to 0711cbd Compare May 26, 2026 23:29
@coderabbitai coderabbitai Bot added the feature Net-new user-facing capability or product behavior. label May 26, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/src-tauri/src/workspace_paths.rs`:
- Around line 69-73: The debug log in resolve_workspace_absolute_path currently
prints the absolute target path (variable target), exposing PII; change the log
to redact or replace the absolute path with a workspace-relative label or short
identifier (e.g., compute and log the path relative to the workspace root or
just the target file name), or remove the debug line entirely since
resolve_workspace_path already logs resolution; update the log call in
resolve_workspace_absolute_path (and any use of target.display()) to output only
the redacted/relative value instead of the full absolute path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0de62b29-39d8-46f7-bd9b-e1d545c45a2e

📥 Commits

Reviewing files that changed from the base of the PR and between 3682a02 and 0711cbd.

📒 Files selected for processing (7)
  • app/src-tauri/permissions/allow-workspace-files.toml
  • app/src-tauri/src/lib.rs
  • app/src-tauri/src/workspace_paths.rs
  • app/src/components/intelligence/ObsidianVaultSection.tsx
  • app/src/components/intelligence/__tests__/MemoryWorkspace.test.tsx
  • app/src/components/intelligence/__tests__/ObsidianVaultSection.test.tsx
  • app/src/utils/tauriCommands/workspacePaths.ts
🚧 Files skipped from review as they are similar to previous changes (6)
  • app/src/utils/tauriCommands/workspacePaths.ts
  • app/src-tauri/src/lib.rs
  • app/src/components/intelligence/tests/ObsidianVaultSection.test.tsx
  • app/src/components/intelligence/ObsidianVaultSection.tsx
  • app/src/components/intelligence/tests/MemoryWorkspace.test.tsx
  • app/src-tauri/permissions/allow-workspace-files.toml

Comment thread app/src-tauri/src/workspace_paths.rs
YOMXXX added 5 commits May 27, 2026 08:53
Expose the internal `resolve_workspace_path` helper as a Tauri command so
UI flows that need a canonical absolute path to compose with a platform-
specific URL scheme (e.g. `obsidian://open?path=<abs>`) can route through
the shared workspace-link layer instead of re-implementing path
normalization in the renderer.

The new command reuses the same canonicalize + workspace-containment
guard as `open_workspace_path` / `reveal_workspace_path` /
`preview_workspace_text`, registers via `generate_handler!`, and is
allow-listed in `allow-workspace-files.toml`.

Refs tinyhumansai#2476 (workspace-link layer)
Refs tinyhumansai#2492 (memory surfaces migration)
TypeScript wrapper around the new `resolve_workspace_absolute_path`
Tauri command. Mirrors the style of the existing `openWorkspacePath` /
`revealWorkspacePath` / `previewWorkspaceText` wrappers (assertTauri
guard, plain `invoke<string>` call).

Refs tinyhumansai#2492
Last remaining Memory-tab surface that was constructing an
`obsidian://open?path=<abs>` URL straight from a graph-export RPC field
without going through the shared workspace-link layer introduced in
PR tinyhumansai#2476. MemoryGraph already migrated to `openWorkspacePath` /
`previewWorkspaceText` in PR tinyhumansai#2638; this finishes the migration called
out in issue tinyhumansai#2492 by resolving the vault's absolute path through the
new `resolveWorkspaceAbsolutePath` wrapper (which delegates to the same
canonicalize + workspace-containment guard the rest of the layer uses).

Behavior is unchanged from the user's point of view — the click still
fires `obsidian://open?path=<vault-abs>` and the toast still names the
vault path — but the absolute path is now derived from the Rust-side
resolver instead of trusting whatever the RPC handed back. The
`contentRootAbs` prop is preserved for display (tooltip, guidance-panel
code block, toast message) so no cascading prop changes are needed.

Adds two tests covering the new path: (1) the deep link URL is built
from the resolver output, not the prop, and (2) a resolver rejection
surfaces an error toast and keeps the guidance panel expanded so the
user retains an escape hatch. MemoryWorkspace.test.tsx is updated to
mock `resolveWorkspaceAbsolutePath` so the existing `openUrl` assertion
remains stable.

Closes tinyhumansai#2492
Adds three Vitest cases for the new resolveWorkspaceAbsolutePath wrapper
in app/src/utils/tauriCommands/workspacePaths.ts so the Coverage Gate
sees the wrapper's invoke call, rejection propagation, and isTauri guard
exercised on the changed lines (PR tinyhumansai#2702).
resolve_workspace_absolute_path was logging target.display(), which
includes the canonical absolute workspace root. Route through the
existing workspace_path_label helper instead so the debug line follows
the same redaction pattern as the other workspace-path commands and
does not leak host filesystem layout into logs.
@YOMXXX YOMXXX force-pushed the fix/2492-memory-workspace-link-migration branch from 0711cbd to a8a11f6 Compare May 27, 2026 00:56
@YOMXXX
Copy link
Copy Markdown
Contributor Author

YOMXXX commented May 27, 2026

Addressed CodeRabbit feedback in cffa53a + a8a11f6:

  • Coverage Gate: added Vitest cases for resolveWorkspaceAbsolutePath in app/src/utils/tauriCommands/workspacePaths.test.ts covering the happy-path invoke call, an invoke rejection bubbling back to the caller, and the isTauri() guard short-circuiting before invoke is touched. That brings the new wrapper lines (workspacePaths.ts:56-58) under test.
  • PII log nit: routed the resolve_workspace_absolute_path debug log through the existing workspace_path_label(workspace, target) helper instead of target.display(), matching the redaction pattern the other workspace-path commands already use so the canonical absolute workspace root no longer leaks into logs.

Local verification: cargo fmt --check, cargo check --lib (Tauri shell), prettier --check, pnpm debug unit workspacePaths (12 passed), pnpm typecheck — all green.

coderabbitai[bot]
coderabbitai Bot previously approved these changes May 27, 2026
Comment thread app/src-tauri/src/workspace_paths.rs Outdated
}

#[test]
fn resolve_workspace_path_returns_absolute_inside_workspace() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactor / suggestion — both new tests call resolve_workspace_path(...) directly (the pre-existing internal helper), not the new resolve_workspace_absolute_path command this PR adds. The command own body (config load, resolve, label/log, to_string_lossy) has no direct Rust test — it is only integration-covered via workspacePaths.ts. resolve_workspace_path_returns_absolute_inside_workspace also overlaps the existing resolve_workspace_path_accepts_existing_relative_file_inside_workspace; the genuinely-new coverage is the starts_with(canonical_root) assertion plus the empty-input rejection. The coverage gate passes, so no change is strictly required — but the test names overpromise (they read as if they cover the new command). Consider renaming to reflect that they assert the shared helper the command delegates to, e.g. resolve_workspace_path_resolves_memory_tree_content_inside_workspace.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point — the test names overpromise. They exercise resolve_workspace_path (the shared helper) rather than the new resolve_workspace_absolute_path Tauri command. Will rename to resolve_workspace_path_resolves_memory_tree_content_inside_workspace and resolve_workspace_path_rejects_empty_input to reflect what they actually cover.

const url = `obsidian://open?path=${encodeURIComponent(contentRootAbs)}`;
console.debug('[ui-flow][obsidian-vault] firing deep link');
try {
const absolutePath = await resolveWorkspaceAbsolutePath(MEMORY_CONTENT_WORKSPACE_PATH);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question — resolve_workspace_path requires the target to exist (fs::canonicalize errors otherwise). Previously fireDeepLink built the URL straight from the contentRootAbs prop and never touched disk, so it fired even when memory_tree/content was absent (Obsidian / Open anyway being the escape hatch). Now, if the folder does not exist, the resolver rejects and the link never fires (error toast instead). The "user-visible behavior is unchanged" claim holds for the registered-vault case (Obsidian can only register a folder that exists) and this aligns fireDeepLink with reveal (which already required existence). But for an unregistered vault whose content root has not been created yet, behavior does change. Is graph.content_root_abs guaranteed to be backed by an existing directory whenever this component renders? If yes, this is purely an improvement and the claim stands.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. Yes — content_root_abs comes from the memory_tree_graph_export RPC, which writes the graph to disk before returning the path. So by the time ObsidianVaultSection renders with that prop, the directory exists. The resolver call just canonicalizes it for the Obsidian deep link URL.

For the edge case of a completely fresh workspace where the graph hasn't been exported yet: the Memory tab only shows the vault section after a successful export, so the component never renders with a non-existent path in practice.

sanil-23
sanil-23 previously approved these changes May 27, 2026
Copy link
Copy Markdown
Contributor

@sanil-23 sanil-23 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #2702 — refactor(memory): route Obsidian deep link through workspace-link layer (#2492)

Walkthrough

This PR finishes the Phase-3 cleanup from #2492 by routing the last Memory-tab deep-link surface (ObsidianVaultSection) through the shared workspace-link layer instead of trusting an RPC-supplied path. It adds a thin resolve_workspace_absolute_path Tauri command wrapping the existing internal resolve_workspace_path guard, a matching resolveWorkspaceAbsolutePath TS wrapper, and rewires fireDeepLink to canonicalize memory_tree/content server-side before building the obsidian://open?path=<abs> URL. The change is tight, well-documented, and net-positive for security. CI is green (the one non-passing check, Rust Core Tests (Windows — secrets ACL), is cancelled and tests core code this PR does not touch). No blockers.

Changes

File Summary
app/src-tauri/permissions/allow-workspace-files.toml Allow the new resolve_workspace_absolute_path command in the capability manifest.
app/src-tauri/src/lib.rs Register workspace_paths::resolve_workspace_absolute_path in the invoke handler.
app/src-tauri/src/workspace_paths.rs New resolve_workspace_absolute_path Tauri command wrapping resolve_workspace_path; two new unit tests on the internal helper.
app/src/utils/tauriCommands/workspacePaths.ts New resolveWorkspaceAbsolutePath(path) wrapper (assertTauri + invoke).
app/src/components/intelligence/ObsidianVaultSection.tsx fireDeepLink now resolves the absolute path via the new command instead of the contentRootAbs prop.
app/src/utils/tauriCommands/workspacePaths.test.ts Adds success / rejection / non-Tauri tests for the new wrapper.
app/src/components/intelligence/__tests__/ObsidianVaultSection.test.tsx Adds resolver-sourced-URL and resolver-failure-toast tests; mocks the new wrapper.
app/src/components/intelligence/__tests__/MemoryWorkspace.test.tsx Mocks resolveWorkspaceAbsolutePath to keep the existing deep-link assertion stable.

Actionable comments (1)

💡 Refactor / suggestion

1. app/src-tauri/src/workspace_paths.rs:426-451 — new tests exercise the internal helper, not the new command. Both new tests call resolve_workspace_path(...) directly (the pre-existing helper), not the new resolve_workspace_absolute_path command this PR adds, whose body (config load, resolve, label/log, to_string_lossy) has no direct Rust test — only integration coverage via workspacePaths.ts. resolve_workspace_path_returns_absolute_inside_workspace also overlaps the existing resolve_workspace_path_accepts_existing_relative_file_inside_workspace; the genuinely-new coverage is the containment assertion plus the empty-input rejection. The coverage gate passes, so no change is strictly required — but the names overpromise. Suggest renaming to reflect they assert the shared helper the command delegates to (e.g. resolve_workspace_path_resolves_memory_tree_content_inside_workspace). Posted inline.

Nitpicks (2)

  • app/src/components/intelligence/ObsidianVaultSection.tsx:206 — the button title tooltip still previews the URL built from the contentRootAbs prop, but the URL actually fired now comes from the canonicalized resolver output. On macOS these diverge (/var/... vs /private/var/...), so the tooltip can show a different path than the link opens. Cosmetic.
  • app/src/components/intelligence/ObsidianVaultSection.tsx:113 — on resolver failure the error toast interpolates contentRootAbs, but the failure concerns the resolved path (which may differ / be missing on disk). They match in the normal case, so low impact; mentioning since the prop is now decoupled from the actual deep-link target.

Questions for the author (1)

  • app/src/components/intelligence/ObsidianVaultSection.tsx:75resolve_workspace_path requires the target to exist (canonicalize errors otherwise). Previously fireDeepLink built the URL straight from the prop and never touched disk, so it fired even when memory_tree/content was absent. Now a missing folder rejects and the link never fires (error toast). The "behavior unchanged" claim holds for the registered case (Obsidian only registers folders that exist) and aligns with reveal (already existence-gated), but the unregistered + not-yet-created case does change. Is graph.content_root_abs always backed by an existing directory when this component renders? Posted inline.

Verified / looks good

  • TS wrapper mirrors its siblings exactly (assertTauri + invoke) and is tested for success, invoke rejection, and the non-Tauri throw.
  • Component tests prove the URL is sourced from the resolver (a distinct path from the prop) and that resolver failure surfaces an error toast + keeps guidance expanded — happy and failure paths both covered.
  • fireDeepLink useCallback deps correctly dropped to empty (no longer closes over contentRootAbs; the path is a module constant).
  • Permission manifest and lib.rs invoke-handler registration both updated — the command is reachable end-to-end.
  • Security is net-positive: the deep link now passes through the same canonicalize + workspace-containment guard as open_workspace_path / reveal_workspace_path; errors stay non-leaky.
  • No new i18n keys required — the failure path reuses existing workspace.openVaultFailed* toast keys.
  • Rustdoc on the command and JSDoc on the wrapper both document the why and the error contract.

Approving — no blockers, no majors. The one refactor note and the open question are non-blocking; addressing them (or confirming the existence invariant) would tidy things up.

- ObsidianVaultSection: fall back to `contentRootAbs` prop when
  `resolveWorkspaceAbsolutePath` rejects (directory doesn't exist
  yet). Preserves the pre-refactor behavior where the deep link
  fires even on a fresh workspace — Obsidian surfaces its own
  "vault not found" error if the folder is missing.

- workspace_paths: redact absolute path from the internal
  `resolve_workspace_path` debug log (uses `workspace_path_label`
  instead of `target.display()`).

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
@YOMXXX YOMXXX dismissed stale reviews from sanil-23 and coderabbitai[bot] via a56c7f7 May 27, 2026 11:12
@coderabbitai coderabbitai Bot added the memory Memory store, memory tree, recall, summarization, and embeddings in src/openhuman/memory/. label May 27, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
app/src/components/intelligence/ObsidianVaultSection.tsx (1)

75-84: ⚡ Quick win

Consider logging when the resolver fallback occurs.

The empty catch block silently suppresses all errors from resolveWorkspaceAbsolutePath, including unexpected failures (Tauri runtime errors, permission issues) beyond just "directory doesn't exist". Adding a debug log would improve observability without changing the fallback behavior.

🔍 Proposed observability improvement
       try {
         absolutePath = await resolveWorkspaceAbsolutePath(MEMORY_CONTENT_WORKSPACE_PATH);
-      } catch {
+      } catch (err) {
+        console.debug('[ui-flow][obsidian-vault] resolver failed, falling back to contentRootAbs', err);
         // The content directory may not exist yet (fresh workspace). Fall
         // back to the prop from the graph-export RPC — the deep link URL
         // is still valid; Obsidian will surface its own "vault not found"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/components/intelligence/ObsidianVaultSection.tsx` around lines 75 -
84, The catch block swallowing errors from resolveWorkspaceAbsolutePath for
MEMORY_CONTENT_WORKSPACE_PATH should log the caught error for observability
before falling back to contentRootAbs; update the catch to capture the error
(e.g., catch (err)) and emit a debug/info log using the existing logging
facility (or console.debug) that includes a brief context message referencing
resolveWorkspaceAbsolutePath and MEMORY_CONTENT_WORKSPACE_PATH, then keep the
fallback assignment to contentRootAbs unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@app/src/components/intelligence/ObsidianVaultSection.tsx`:
- Around line 75-84: The catch block swallowing errors from
resolveWorkspaceAbsolutePath for MEMORY_CONTENT_WORKSPACE_PATH should log the
caught error for observability before falling back to contentRootAbs; update the
catch to capture the error (e.g., catch (err)) and emit a debug/info log using
the existing logging facility (or console.debug) that includes a brief context
message referencing resolveWorkspaceAbsolutePath and
MEMORY_CONTENT_WORKSPACE_PATH, then keep the fallback assignment to
contentRootAbs unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 84952518-dfb7-4bb0-8f8f-a11ac909ed98

📥 Commits

Reviewing files that changed from the base of the PR and between a8a11f6 and a56c7f7.

📒 Files selected for processing (2)
  • app/src-tauri/src/workspace_paths.rs
  • app/src/components/intelligence/ObsidianVaultSection.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/src-tauri/src/workspace_paths.rs

coderabbitai[bot]
coderabbitai Bot previously approved these changes May 27, 2026
…t fallback

Remove the inner try/catch in fireDeepLink that swallowed resolver
rejections and fell back to contentRootAbs. When the workspace-link
resolver fails, the error now propagates to toastOpenOutcome which
correctly surfaces a type:'error' toast and keeps the guidance panel
expanded.
Copy link
Copy Markdown
Contributor

@graycyrus graycyrus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@YOMXXX heads up — CI is failing on this PR (Frontend Unit Tests, Frontend Coverage, Rust Core Tests on Windows), so I'll hold off on approving until those are sorted. I did spot one thing while looking through the diff though.

The CI failure in Frontend Unit Tests is almost certainly coming from the new test resolver failure surfaces an error toast and keeps guidance expanded in ObsidianVaultSection.test.tsx. That test expects openUrl to not be called and an error toast to fire when the resolver rejects — but the component's inner catch silently falls back to contentRootAbs and calls openUrl anyway. The test and the implementation directly contradict each other.

Looking at ObsidianVaultSection.tsx around line 75–84: when resolveWorkspaceAbsolutePath throws, the catch block assigns absolutePath = contentRootAbs and execution continues to openUrl(url). The test mocks openUrl as resolved and asserts it was never called — that assertion will always fail given this fallback path. Same for the error toast — with a successful openUrl call, fireDeepLink returns null and no toast fires.

The PR description says resolver rejection should surface an error toast, but the code says it should silently fall back. One of these needs to change. If the intent is graceful degradation (fall back to the RPC path), the test is wrong and should instead verify the fallback URL is used. If the intent is to surface failures as errors (which the test and PR description both imply), the inner catch should re-throw rather than swallow.

Everything else looks good — Rust command is tight, TS wrapper is idiomatic, the PII log fix is correct, permission manifest is in sync, and the security story is net positive. Fix the CI failures and resolve the test/implementation disagreement and this is ready to go.

// is still valid; Obsidian will surface its own "vault not found"
// error if the folder is truly missing.
absolutePath = contentRootAbs;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] The test for resolver failure (ObsidianVaultSection.test.tsx:153) asserts that openUrl is NOT called and an error toast IS fired when this catch is hit — but this catch falls back to contentRootAbs and openUrl fires successfully, so neither assertion can pass.

Decide which behavior you actually want:

  1. Graceful degradation (fallback to prop): remove or rework the new resolver failure test so it verifies the fallback URL is used instead of asserting an error state.
  2. Fail loudly (surface error toast on resolver rejection): remove the fallback and re-throw so the outer catch handles it as an error.

Right now the test and the code describe different contracts.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — the test and code described different contracts. Fixed in 40503a9b: removed the inner fallback catch entirely so resolver rejection propagates to the outer catch → returns error → toastOpenOutcome fires type: "error" → guidance panel stays expanded. This is the "fail loudly" path (option 2).

The rationale: if the resolver can't canonicalize the path, the deep link URL would be unreliable anyway. Better to surface the error and let the user use "Open anyway" (which still uses contentRootAbs directly and skips the resolver).

@graycyrus
Copy link
Copy Markdown
Contributor

@YOMXXX unresolved review feedback — please address before we review.

@graycyrus
Copy link
Copy Markdown
Contributor

Unresolved review feedback from coderabbitai[bot] — please address before we review.

…helper

Addresses sanil-23's review: the tests exercise resolve_workspace_path
(the internal helper) not the new resolve_workspace_absolute_path Tauri
command, so the names should say what they actually cover.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Net-new user-facing capability or product behavior. memory Memory store, memory tree, recall, summarization, and embeddings in src/openhuman/memory/. rust-core Core Rust runtime in src/: CLI, core_server, shared infrastructure. working A PR that is being worked on by the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Migrate Memory surfaces to shared workspace file links

3 participants