Skip to content

feat(static-file): conversation workspace file serving with pluggable access guard#281

Open
zhuqingyv wants to merge 13 commits into
mainfrom
feat/static-file-service
Open

feat(static-file): conversation workspace file serving with pluggable access guard#281
zhuqingyv wants to merge 13 commits into
mainfrom
feat/static-file-service

Conversation

@zhuqingyv
Copy link
Copy Markdown
Contributor

@zhuqingyv zhuqingyv commented May 16, 2026

Background & Motivation

Old architecture (main)

crates/aionui-file/src/watch_service.rs provides two independent features:

  1. Single-file watch (file_watcher) — one shared RecommendedWatcher, NonRecursive, broadcasts fileWatch.fileChanged on change.
  2. Office watch (office_watchers) — per-workspace RecommendedWatcher, Recursive, broadcasts workspaceOfficeWatch.fileAdded on office file create.

Both share a single DashMap<String, Instant> for 200ms debouncing, with no subscription model and no WS-disconnect cleanup.

Capabilities that did not exist:

  • No directory-level change push (frontend file tree could not refresh in real time).
  • No workspace static-file HTTP service (frontend had to read files via the IPC bridge).
  • The MessageRouter trait had no on_disconnect lifecycle hook.

What this branch does

Adds (does not replace):

  1. workspace_watcher.rs + workspace_watcher_registry.rs + workspace_watcher_router.rs — subscription-based directory change push, built on notify-debouncer-full with one OS watcher per workspace and event fan-out (also covers office-file create detection).
  2. aionui-static-file crate — static-file service with a pluggable async AccessGuard (Range 206, ETag, 256 MB default size limit).
  3. aionui-conversation/src/routes_static_file.rsGET /api/conversations/{id}/files/{*path}.
  4. MessageRouter trait gains an on_disconnect callback.

Modifies:

  1. service.rs removes the post-write/-delete fileStream.contentUpdate broadcast (that responsibility moves to the workspace watcher's modify events).

Untouched:

  1. watch_service.rs is left as-is (the old file_watcher + office_watchers still run).

Known issue: duplicate office watch

The new EventDispatcher already implements office fan-out (with_office_broadcaster). When the frontend subscribes to a workspace, the new system emits workspaceOfficeWatch.fileAdded automatically. However the frontend still calls the legacy /api/fs/office-watch/start API, so the same workspace ends up with two OS watchers and may emit duplicate events.

Follow-up required: the frontend must drop /api/fs/office-watch/start|stop and rely on the new fan-out; once no caller remains, office_watchers in watch_service.rs can be deleted.


Architecture

┌─────────────────────────────────────────────────────────────────────────┐
│                         FRONTEND (Electron / Web)                        │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  ┌──────────────┐       ┌────────────────────┐       ┌───────────────┐ │
│  │ File Tree UI  │       │ File Content Viewer │       │ File Changes  │ │
│  │ (Arco <Tree>) │       │ (Monaco / Preview)  │       │ (Git Snapshot)│ │
│  └──────┬────────┘       └──────────┬─────────┘       └───────────────┘ │
│         │ expand/collapse            │ open file                         │
│         ▼                            ▼                                   │
│  ┌───────────────────────────────────────────────────────────────────┐  │
│  │                    useWorkspaceWatcher (hook)                      │  │
│  │                                                                   │  │
│  │  expand   → ws.send("workspace.subscribe",   {workspace, dirs})   │  │
│  │  collapse → ws.send("workspace.unsubscribe", {workspace, dirs})   │  │
│  │  on "workspace.changed"  → incrementally update tree nodes        │  │
│  │  on "workspace.overflow" → full HTTP refresh                      │  │
│  │  on modify → tell viewer to refetch file content                  │  │
│  └────────────────────────┬──────────────────────────────────────────┘  │
│                           │                                              │
├───────────────────────────┼──────────────────────────────────────────────┤
│        WebSocket          │              HTTP                             │
│   (single shared conn)    │           (on demand)                         │
│                           │                                              │
│  ┌──────────────┐   ┌────┴────────────────────────┐                     │
│  │ WS messages   │   │   Static file HTTP API      │                     │
│  │ subscribe →   │   │                             │                     │
│  │ unsubscribe → │   │ GET /api/conversations/     │                     │
│  │ ← changed    │   │   {id}/files/{*path}         │                     │
│  │ ← overflow   │   │                             │                     │
│  └──────┬────────┘   │ JWT auth + Range + ETag    │                     │
│         │            └────┬────────────────────────┘                     │
└─────────┼─────────────────┼──────────────────────────────────────────────┘
          │   Network       │
          ▼                 ▼
┌─────────────────────────────────────────────────────────────────────────┐
│                         BACKEND (Rust / Axum)                            │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  ┌─────────────────────────────┐     ┌────────────────────────────────┐ │
│  │   WebSocket layer            │     │  Static file HTTP layer         │ │
│  │   (aionui-realtime)          │     │  (aionui-conversation)          │ │
│  │                              │     │                                 │ │
│  │  recv_loop                   │     │  GET /conversations/{id}/files  │ │
│  │    ├─ "workspace.*"          │     │      /{*path}                   │ │
│  │    │       ▼                 │     │  → StaticFileService            │ │
│  │    │  MessageRouter.route()  │     │  → Range (206) / ETag / Auth    │ │
│  │    │       ▼                 │     │  → 256 MB default size limit    │ │
│  │  on_disconnect()             │     └────────────────────────────────┘ │
│  └────────────┬─────────────────┘                                        │
│               ▼                                                          │
│  ┌───────────────────────────────────────────────────────────────────┐   │
│  │              WorkspaceWatchRouter (aionui-file)                    │   │
│  │                                                                   │   │
│  │  subscribe   → SubscriptionRegistry.subscribe(conn, ws, dirs)     │   │
│  │  subscribe   → SubscriptionRegistry.subscribe_extensions(...)     │   │
│  │  unsubscribe → SubscriptionRegistry.unsubscribe(conn, ws, dirs)   │   │
│  │  disconnect  → SubscriptionRegistry.remove_connection(conn)        │   │
│  │                         │                                         │   │
│  │              is_first? ─┴─ is_last?                               │   │
│  │                │              │                                    │   │
│  │          start_watch     stop_watch                                │   │
│  └────────────────┬──────────────┬───────────────────────────────────┘   │
│                   ▼              ▼                                        │
│  ┌───────────────────────────────────────────────────────────────────┐   │
│  │         WorkspaceWatchManager                                      │   │
│  │                                                                   │   │
│  │  notify-debouncer-full (single OS watcher per workspace)           │   │
│  │  → RecursiveMode::Recursive                                       │   │
│  │  → handles atomic save / rename pairing / event de-dup            │   │
│  │  → emits semantic events (Create / Modify / Remove)               │   │
│  │                                                                   │   │
│  │  Event fan-out:                                                   │   │
│  │    ├─→ Office consumer: filter Create + .pptx/.docx/.xlsx         │   │
│  │    │   → broadcast "workspaceOfficeWatch.fileAdded"                │   │
│  │    └─→ Workspace consumer: gitignore → parent_dir → subscribers   │   │
│  └────────────────┬──────────────────────────────────────────────────┘   │
│                   │                                                       │
│                   ▼                                                       │
│  ┌───────────────────────────────────────────────────────────────────┐   │
│  │  Workspace dispatch:                                               │   │
│  │    GitignoreFilter → SubscriptionRegistry lookup → send_to         │   │
│  │                                                                   │   │
│  │  per dir:                                                         │   │
│  │    changes > 500 → "workspace.overflow" → subscribers              │   │
│  │    changes ≤ 500 → "workspace.changed"  → subscribers              │   │
│  └───────────────────────────────────────────────────────────────────┘   │
│                                                                         │
│  ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐  │
│  │  [LEGACY — pending removal]                                       │  │
│  │  watch_service.rs: file_watcher (NonRecursive, single file)        │  │
│  │                    office_watchers (per-workspace Recursive)        │  │
│  │  Frontend still uses /api/fs/office-watch/start|stop               │  │
│  └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘  │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

Data flow: directory expand → push

1.  User expands the src directory
2.  Frontend → ws.send({name:"workspace.subscribe", data:{workspace, dirs:["src"]}})
3.  WorkspaceWatchRouter.route() → registry.subscribe(conn, ws, ["src"])
4.  is_first=true → WorkspaceWatchManager.start_workspace_watch(ws)
5.  Creates a debouncer-full watcher (RecursiveMode::Recursive, single OS watcher)
6.  An external process creates src/new_file.rs
7.  OS FSEvents → debouncer-full de-dups / pairs renames → DebouncedEvent
8.  Event fan-out → office consumer (no match, skip) + workspace consumer
9.  Workspace: GitignoreFilter check → parent_dir("src/new_file.rs") = "src"
10. registry.get_subscribers_for_dir(ws, "src") → [conn_1]
11. ws_manager.send_to(conn_1, {name:"workspace.changed", data:{workspace, changes:[...]}})
12. Frontend onChanged → incrementally inserts "new_file.rs" into the tree

WS protocol

Direction Event Payload
→ Backend workspace.subscribe {workspace: string, dirs?: string[], extensions?: string[]}
→ Backend workspace.unsubscribe {workspace: string, dirs?: string[], extensions?: string[]}
← Frontend workspace.changed {workspace: string, changes: [{path, kind}]}
← Frontend workspace.overflow {workspace: string}
  • kind: create / modify / delete
  • path: relative to the workspace root
  • Only changes to immediate children of subscribed directories are pushed
  • Unsubscribing a parent directory cascades to its children automatically
  • Authentication: JWT via Authorization: Bearer <token> header or aionui-session cookie

Follow-up plan

Current state: the new workspace watcher already provides office fan-out, but the legacy office_watchers in watch_service.rs are still running. Frontend code that uses both ends up with two OS watchers per workspace.

Steps:

  1. Frontend stops calling /api/fs/office-watch/start|stop and relies on workspace-watcher fan-out.
  2. Backend deletes the office_watchers code path in watch_service.rs.
  3. Evaluate whether file_watcher (single-file watch) can also be replaced by the workspace watcher.
  4. Final goal: at most one OS watcher per workspace.

Files changed

New crate:

  • crates/aionui-static-file/ — static-file service with pluggable AccessGuard

New files:

  • crates/aionui-file/src/workspace_watcher.rsSharedWorkspaceWatcher, GitignoreFilter, EventDispatcher, WorkspaceWatchManager
  • crates/aionui-file/src/workspace_watcher_registry.rsSubscriptionRegistry
  • crates/aionui-file/src/workspace_watcher_router.rsWorkspaceWatchRouter (impl MessageRouter)
  • crates/aionui-conversation/src/routes_static_file.rs — conversation static-file route

Modified:

  • crates/aionui-realtime/src/router.rs — adds MessageRouter::on_disconnect
  • crates/aionui-realtime/src/handler.rs — calls on_disconnect on WS close
  • crates/aionui-file/src/service.rs — drops broadcaster dependency and the fileStream.contentUpdate broadcast
  • crates/aionui-file/src/lib.rs — module declarations / exports
  • crates/aionui-app/src/router/state.rs — wires WorkspaceWatchRouter and the office broadcaster

Untouched:

  • crates/aionui-file/src/watch_service.rs — legacy file_watcher + office_watchers left as-is

Test plan

  • Workspace watcher unit tests (types, gitignore, parent_dir, registry, router)
  • All aionui-file tests passing
  • All aionui-realtime tests passing
  • aionui-app tests passing (including static_file_e2e)
  • Clippy clean (-D warnings)
  • Full workspace: 5615 tests pass
  • Frontend integration: subscribe on expand, verify WS events arrive
  • Follow-up: drop legacy office-watch API on the frontend, confirm no duplicate events

🤖 Generated with Claude Code

@zhuqingyv zhuqingyv force-pushed the feat/static-file-service branch 4 times, most recently from 79f8361 to 0c12c18 Compare May 22, 2026 12:08
zhuqingyu-netizen and others added 7 commits May 25, 2026 11:48
…able access guard

Introduces a framework-agnostic static file service (aionui-static-file) with:
- Pluggable async access guard for authorization decisions
- HTTP Range request support for video/PDF seeking
- Path traversal prevention with canonicalize sandbox
- 256MB file size limit
- ETag generation from mtime + file size

Exposes endpoint: GET /api/conversations/{id}/files/{*path}
Extracts has_traversal to aionui-common for single source of truth.
Includes 9 E2E integration tests and 17 unit tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
aionui-static-file 在 c90fbd8 (feat: static-file) 升到 0.1.8,
但 Cargo.lock 当时未一并提交,本次补齐。
之前 Phase 1 分发只看 change.path 的「直接父目录」,订阅了根目录的
连接拿不到深层文件 (例如 src/docs/foo.md) 的事件,因为它的父目录是
src/docs 而非 ""。结果:右键删除 src/docs 整个文件夹时,根订阅者收不
到任何 workspace.changed,前端必须 Cmd+R 才看到 docs 消失。

新分发规则:对每个 change,沿祖先链找出**每个连接**最近订阅的目录,
路径重写为该订阅者的直接子目录 (rewritten kind=Modify);订阅者恰好
是直接父目录时保留原 kind。多连接在不同深度订阅时各自拿到自己最近祖
先的事件,互不干扰;同一连接的多个深层 change 折叠到同一 rewritten
path 上自动去重。

新增辅助 ancestor_chain / resolve_subscribed_path 纯函数 + 6 个单测,
以及 5 个 dispatch_changes 集成测试 (覆盖直接子、重写、最近祖先、
多连接、去重、无订阅者)。
@zhuqingyv zhuqingyv force-pushed the feat/static-file-service branch from 0c12c18 to c5f6f48 Compare May 25, 2026 03:58
zhuqingyu-netizen and others added 6 commits May 25, 2026 14:27
Remove 10 mcp-workflow-proposal-ELECTRON-*.json files that were committed by
mistake — they are sentry-patrol per-issue MCP server configs containing
local-machine absolute paths (e.g. /Users/zhuqingyu/.cargo/bin/sentry-patrol-mcp)
and have nothing to do with this branch's static-file work.

Add a .gitignore entry to prevent future accidental commits.
These types were only used by their own self-tests after the
fileStream.contentUpdate broadcast was removed from FileService.
No external import, no pub use export, no serialization callers.
FileService no longer broadcasts the fileStream.contentUpdate event,
but write_file/remove_entry doc comments still claimed it does.
The write/remove request types' workspace field doc still referenced
the removed fileStream.contentUpdate event payload.
…api-types

WatchChange / WatchChangeKind / WatchBatchEvent / WatchOverflowEvent are
WS event payloads, so per AGENTS.md they belong in aionui-api-types
alongside other request/response/WS DTOs, not inside aionui-file.

Pure relocation: definitions and serde derives unchanged, no behavior
change. aionui-file now imports them from aionui-api-types.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants