From af6209cc717cdea3be92dcbeccacde4d211708a2 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 15 Jun 2026 23:42:02 +0800 Subject: [PATCH] fix(workspace): use parcel watcher --- docs/issues/parcel-watcher-issue-1764/plan.md | 345 +++++++++++++++++ docs/issues/parcel-watcher-issue-1764/spec.md | 144 +++++++ .../issues/parcel-watcher-issue-1764/tasks.md | 23 ++ electron-builder.yml | 2 + electron.vite.config.ts | 3 +- package.json | 2 +- scripts/afterPack.js | 58 +++ src/main/fileWatcherUtilityHostEntry.ts | 5 + src/main/lib/fileWatcher/eventCoalescer.ts | 71 ++++ .../lib/fileWatcher/fileWatcherUtilityHost.ts | 125 +++++++ src/main/lib/fileWatcher/index.ts | 4 + src/main/lib/fileWatcher/watcherHost.ts | 353 ++++++++++++++++++ src/main/lib/fileWatcher/watcherHostClient.ts | 315 ++++++++++++++++ src/main/lib/fileWatcher/watcherPool.ts | 205 ++++++++++ src/main/lib/fileWatcher/watcherService.ts | 44 +++ src/main/lib/fileWatcher/watcherTypes.ts | 109 ++++++ src/main/presenter/index.ts | 4 +- src/main/presenter/skillPresenter/index.ts | 290 ++++++++------ .../presenter/workspacePresenter/index.ts | 190 +++++++--- src/renderer/api/WorkspaceClient.ts | 13 +- .../components/sidepanel/WorkspacePanel.vue | 19 + .../sidepanel/composables/useWorkspaceSync.ts | 29 +- src/renderer/src/i18n/da-DK/chat.json | 4 + src/renderer/src/i18n/de-DE/chat.json | 4 + src/renderer/src/i18n/en-US/chat.json | 4 + src/renderer/src/i18n/es-ES/chat.json | 4 + src/renderer/src/i18n/fa-IR/chat.json | 4 + src/renderer/src/i18n/fr-FR/chat.json | 4 + src/renderer/src/i18n/he-IL/chat.json | 4 + src/renderer/src/i18n/id-ID/chat.json | 4 + src/renderer/src/i18n/it-IT/chat.json | 4 + src/renderer/src/i18n/ja-JP/chat.json | 6 +- src/renderer/src/i18n/ko-KR/chat.json | 4 + src/renderer/src/i18n/ms-MY/chat.json | 4 + src/renderer/src/i18n/pl-PL/chat.json | 4 + src/renderer/src/i18n/pt-BR/chat.json | 4 + src/renderer/src/i18n/ru-RU/chat.json | 4 + src/renderer/src/i18n/tr-TR/chat.json | 4 + src/renderer/src/i18n/vi-VN/chat.json | 4 + src/renderer/src/i18n/zh-CN/chat.json | 4 + src/renderer/src/i18n/zh-HK/chat.json | 6 +- src/renderer/src/i18n/zh-TW/chat.json | 6 +- src/shared/contracts/domainSchemas.ts | 15 + src/shared/contracts/events.ts | 6 +- .../contracts/events/workspace.events.ts | 17 +- src/shared/types/presenters/index.d.ts | 4 + src/shared/types/presenters/workspace.d.ts | 23 ++ src/shared/types/skill.ts | 4 +- .../30-workspace-watcher-events.smoke.spec.ts | 154 ++++++++ .../lib/fileWatcher/eventCoalescer.test.ts | 33 ++ test/main/lib/fileWatcher/watcherPool.test.ts | 152 ++++++++ .../skillPresenter/skillPresenter.test.ts | 132 ++++--- .../main/presenter/workspacePresenter.test.ts | 171 ++++++--- test/main/routes/contracts.test.ts | 3 +- test/main/scripts/afterPack.test.ts | 112 +++--- .../components/WorkspacePanel.test.ts | 106 ++++++ 56 files changed, 3040 insertions(+), 331 deletions(-) create mode 100644 docs/issues/parcel-watcher-issue-1764/plan.md create mode 100644 docs/issues/parcel-watcher-issue-1764/spec.md create mode 100644 docs/issues/parcel-watcher-issue-1764/tasks.md create mode 100644 src/main/fileWatcherUtilityHostEntry.ts create mode 100644 src/main/lib/fileWatcher/eventCoalescer.ts create mode 100644 src/main/lib/fileWatcher/fileWatcherUtilityHost.ts create mode 100644 src/main/lib/fileWatcher/index.ts create mode 100644 src/main/lib/fileWatcher/watcherHost.ts create mode 100644 src/main/lib/fileWatcher/watcherHostClient.ts create mode 100644 src/main/lib/fileWatcher/watcherPool.ts create mode 100644 src/main/lib/fileWatcher/watcherService.ts create mode 100644 src/main/lib/fileWatcher/watcherTypes.ts create mode 100644 test/e2e/specs/30-workspace-watcher-events.smoke.spec.ts create mode 100644 test/main/lib/fileWatcher/eventCoalescer.test.ts create mode 100644 test/main/lib/fileWatcher/watcherPool.test.ts diff --git a/docs/issues/parcel-watcher-issue-1764/plan.md b/docs/issues/parcel-watcher-issue-1764/plan.md new file mode 100644 index 000000000..559439e6f --- /dev/null +++ b/docs/issues/parcel-watcher-issue-1764/plan.md @@ -0,0 +1,345 @@ +# Parcel Watcher Issue 1764 Plan + +## Architecture + +Add a main-process watcher facade backed by Electron utility process hosts: + +```text +Renderer + -> WorkspaceClient / existing typed events + -> WorkspacePresenter / SkillPresenter + -> WatcherService facade in Electron main + -> WatcherHostClient + -> Electron utilityProcess + -> @parcel/watcher subscriptions +``` + +Recommended file layout: + +```text +src/main/fileWatcherUtilityHostEntry.ts +src/main/lib/fileWatcher/ + eventCoalescer.ts + watcherHost.ts + watcherHostClient.ts + watcherPool.ts + watcherService.ts + watcherTypes.ts +``` + +Responsibilities: + +- `WatcherService` is the only watcher dependency injected into Presenters. +- `WatcherHostClient` owns utility process startup, restart, shutdown, and RPC correlation. +- `WatcherPool` deduplicates logical requests and reference-counts feature subscribers. +- `watcherHost` imports `@parcel/watcher` and owns native subscriptions. +- `eventCoalescer` maps `create | update | delete` into stable DeepChat watcher events and + collapses create/delete/update bursts before they cross the process boundary. + +The first implementation uses two independently restartable host instances: + +```text +content watcher host + -> workspace content + -> skill hot reload + +git watcher host + -> git HEAD/index/packed-refs/refs metadata +``` + +This keeps native watcher fd usage and event storms outside the Electron main process. A watcher +host crash becomes a degraded watcher state, while the main process and the background exec utility +remain spawnable. + +## VS Code-Inspired Rules + +- Watcher and feature code stay decoupled through logical subscriptions. +- Identical watch requests share one native subscription. +- Parent recursive requests cover child requests when include/exclude rules allow it. +- Raw events are batched for 75 ms before coalescing. +- Batched events are delivered in chunks of at most 500, with 200 ms throttle delay. +- Buffered events cap at 30000 entries; overflow triggers degraded mode and one full refresh. +- Native watcher errors restart the host up to a small cap for transient failures. +- `EMFILE`, `ENOSPC`, and repeated Parcel rescan errors switch to fallback mode. +- Deleted watch roots suspend the native watcher and resume through polling or lifecycle refresh + when the root returns. + +## Dependency And Packaging + +1. Add `@parcel/watcher@^2.5.6` to `dependencies`. +2. Remove `chokidar` from `dependencies`. +3. Refresh `pnpm-lock.yaml`. +4. Add ASAR unpack entries: + +```yaml +asarUnpack: + - '**/node_modules/@parcel/watcher/**/*' + - '**/node_modules/@parcel/watcher-*/**/*' +``` + +5. Add `fileWatcherUtilityHost` to the Electron main build inputs. +6. Verify platform optional packages are present for macOS arm64, macOS x64, Windows x64/arm64, + and Linux x64/arm64 release targets. +7. Add an `afterPack` guard when the unpacked package is absent in a packaged build. + +## Watcher Service Contract + +Use stable request and event types inside `src/main/lib/fileWatcher/watcherTypes.ts`: + +```typescript +export type WatcherHostKind = 'content' | 'git' +export type WatcherEventType = 'create' | 'update' | 'delete' | 'overflow' | 'root-deleted' +export type WatcherMode = 'native' | 'snapshot-polling' | 'lifecycle' +export type WatcherHealth = 'healthy' | 'degraded' | 'failed' + +export interface WatchRequest { + id: string + hostKind: WatcherHostKind + rootPath: string + recursive: boolean + includes?: string[] + excludes: string[] + owner: 'workspace' | 'skill' + purpose: 'workspace-content' | 'workspace-git' | 'skill-hot-reload' + fallbackPolicy: 'snapshot-polling' | 'lifecycle' +} + +export interface WatchEventBatch { + requestId: string + rootPath: string + mode: WatcherMode + events: Array<{ type: WatcherEventType; path: string }> +} +``` + +Presenter-facing API: + +```typescript +watch(request: WatchRequest, listener: (batch: WatchEventBatch) => void): Promise +getStatus(requestId: string): WatcherHealth +``` + +`WatchHandle.close()` is async and idempotent. + +## Event Flow + +```text +@parcel/watcher raw batch + -> normalize absolute path + -> apply include/exclude filters + -> buffer for 75 ms + -> coalesce same-path changes + -> drop child deletes covered by parent delete + -> throttle chunks through host RPC + -> WatcherService routes by request id + -> Presenter maps to workspace/skill domain behavior +``` + +Workspace keeps the existing 120 ms invalidation debounce after the watcher service batch. +Skill hot reload keeps a per-path stability delay before parsing `SKILL.md`. + +## Workspace Presenter Integration + +Change watcher runtime state: + +```text +WorkspaceWatchRuntime + contentWatcher: WatchHandle | null + gitWatcher: WatchHandle | null + gitWatchKey: string | null + debounceTimer: NodeJS.Timeout | null + pendingKind: WorkspaceInvalidationKind | null + pendingSource: WorkspaceInvalidationSource | null +``` + +Implementation flow: + +1. Create the runtime with `contentWatcher: null`, store it in `watchRuntimes`, then await the + watcher service subscription. +2. If the runtime is still current after the async subscription resolves, attach the handle. +3. If the runtime was disposed during startup, close the resolved handle immediately. +4. Keep ref counting unchanged. +5. Make `destroy()` await all runtime disposals and update the root Presenter shutdown path to + await it. + +Content watcher rules: + +- Subscribe to the workspace root through the content watcher host. +- Use ignore globs for the existing ignored directories. +- Ignore `.git` children with `**/.git/**`. +- Preserve `.git` directory boundary events so `git init`, repo deletion, and worktree changes can + trigger `refreshGitWatcher()` plus `kind: 'full'`. +- Map content events to `scheduleInvalidation(runtime, 'fs', 'watcher')`. +- Map watcher overflow, host restart, and snapshot polling batches to + `scheduleInvalidation(runtime, 'full', 'fallback')`. + +Git watcher rules: + +- Extend `resolveGitWatchMetadata()` to return watch roots plus tracked metadata paths. +- Subscribe through the git watcher host. +- Watch the smallest stable directory root needed by Parcel, usually the `.git` root. +- Filter events to: + - exact `HEAD`, `index`, and `packed-refs` paths + - descendants of `refs` +- Emit `scheduleInvalidation(runtime, 'git', 'watcher')` for matching events. +- Rebuild git subscriptions when the metadata watch key changes. +- Use fallback polling of git metadata mtimes when the git watcher host is degraded. + +## Large Workspace Fallback + +Fallback is failure-driven and pressure-driven. The implementation avoids recursive preflight +counting. + +Native mode: + +```text +@parcel/watcher subscribe + -> buffer 75 ms + -> coalesce + -> throttle + -> feature listener +``` + +Fallback triggers: + +- native subscribe returns `EMFILE`, `ENOSPC`, or Parcel rescan errors +- utility process exits repeatedly within the restart window +- event buffer reaches the max buffered event cap +- unsubscribe or restart cannot settle within the shutdown timeout + +Fallback modes: + +- `snapshot-polling`: use `@parcel/watcher.writeSnapshot()` and `getEventsSince()` from the + watcher host on a 5000 ms interval for workspace content. +- `git-metadata-polling`: stat `HEAD`, `index`, `packed-refs`, and scan `refs` mtimes from the git + watcher host on a 1000 ms interval. +- `lifecycle`: emit a full fallback invalidation when the workspace panel activates or the + workspace path changes. + +Degraded mode emits a typed status event: + +```text +workspace.watch.status.changed + workspacePath + mode: native | snapshot-polling | lifecycle + health: healthy | degraded | failed + reason +``` + +WorkspacePanel warning layout: + +```text ++------------------------------------------------------+ +| Files | +| ! Watching in fallback mode. Changes refresh slower. | +| tree... | ++------------------------------------------------------+ +``` + +## Skill Presenter Integration + +Change skill watcher lifecycle to match async subscription semantics: + +```text +watchSkillFiles(): Promise +stopWatching(): Promise +destroy(): Promise +``` + +Update `ISkillPresenter` and `SkillPresenter.initialize()` accordingly. + +Implementation flow: + +1. Track a pending watcher start promise so repeated `watchSkillFiles()` calls during startup still + create one subscription. +2. Subscribe to `skillsDir` through the content watcher host with ignore globs for + `.deepchat-meta`. +3. Filter events by relative depth so paths deeper than `SKILL_CONFIG.FOLDER_TREE_MAX_DEPTH` are + skipped. +4. Handle only events whose basename is `SKILL.md`. +5. Map Parcel event types: + +```text +update -> current change handler +create -> current add handler +delete -> current unlink handler +``` + +6. Add a per-path stability delay for update/create events before parsing `SKILL.md`, using the + existing `WATCHER_STABILITY_THRESHOLD` value. +7. Ensure `stopWatching()` and `destroy()` close a subscription that resolves after stop was + requested. +8. Use lifecycle fallback for skill hot reload: log degraded mode, invalidate catalog on explicit + install/uninstall/save flows, and keep startup discovery authoritative. + +## Tests + +Update mocks from `chokidar` to `WatcherService` or `WatcherHostClient`. Presenter tests should +mock `WatcherService` so native watcher mechanics stay out of feature tests. + +Watcher infrastructure tests: + +- pools identical watch requests and reference-counts subscribers +- keeps content and git hosts independently restartable +- coalesces create/update/delete bursts +- drops child delete events covered by a parent delete +- throttles batches and enters degraded mode on buffer overflow +- restarts utility process after transient errors and replays active requests +- switches to fallback for `EMFILE`, `ENOSPC`, and Parcel rescan errors +- closes pending and active subscriptions during stop/destroy + +Workspace tests: + +- starts one content subscription and one git subscription for a registered git workspace +- shares runtime by workspace and closes handles after final unwatch +- debounces create/update/delete events into one `fs` invalidation +- emits `git` invalidation only for tracked git metadata events +- refreshes git metadata and emits `full` invalidation for `.git` boundary events +- emits fallback invalidation when watcher status degrades +- ignores configured content directories + +Skill tests: + +- starts one subscription when called repeatedly +- maps `update` to metadata-updated behavior +- maps `create` to installed behavior +- maps `delete` to uninstalled behavior +- keeps duplicate skill-name behavior unchanged +- ignores `.deepchat-meta` +- skips events deeper than `FOLDER_TREE_MAX_DEPTH` +- closes pending and active subscriptions during stop/destroy + +Renderer tests: + +- shows the workspace watcher degraded banner when `workspace.watch.status.changed` reports + degraded mode +- clears the banner when status returns to healthy + +Verification commands: + +```bash +pnpm run typecheck:node +pnpm test -- \ + test/main/lib/fileWatcher \ + test/main/presenter/workspacePresenter.test.ts \ + test/main/presenter/skillPresenter/skillPresenter.test.ts +pnpm test -- test/renderer/components/WorkspacePanel.test.ts +pnpm run format +pnpm run i18n +pnpm run lint +``` + +## Risks + +- Utility process host packaging: + Add build input, ASAR unpack rules, and packaged build guard. +- Native module unavailable in packaged app: + Verify platform optional packages on each release target. +- Parcel emits directory events differently from chokidar: + Filter by normalized absolute path and basename, then preserve behavior in tests. +- Async subscription resolves after stop/destroy: + Track pending startup and close late handles immediately. +- `@parcel/watcher` lacks `awaitWriteFinish`: + Keep workspace debounce and add skill parse stability delay. +- Git worktree paths span multiple directories: + Compute watch roots from resolved git metadata paths and filter tracked paths after events arrive. diff --git a/docs/issues/parcel-watcher-issue-1764/spec.md b/docs/issues/parcel-watcher-issue-1764/spec.md new file mode 100644 index 000000000..0353622bb --- /dev/null +++ b/docs/issues/parcel-watcher-issue-1764/spec.md @@ -0,0 +1,144 @@ +# Parcel Watcher Issue 1764 Spec + +## Goal + +Fix GitHub issue #1764 by replacing DeepChat's runtime file watching dependency with +`@parcel/watcher`, eliminating the workspace watcher file-descriptor exhaustion path while +preserving workspace refresh and skill hot-reload behavior. + +## Sources + +- GitHub issue: https://github.com/ThinkInAIXYZ/deepchat/issues/1764 +- `@parcel/watcher` package: https://www.npmjs.com/package/@parcel/watcher +- `@parcel/watcher` README: https://github.com/parcel-bundler/watcher +- VS Code File Watcher Internals: + https://github.com/microsoft/vscode/wiki/File-Watcher-Internals +- VS Code source repo: https://github.com/microsoft/vscode + - `src/vs/platform/files/node/watcher/parcel/parcelWatcher.ts` + - `src/vs/platform/files/node/watcher/watcherMain.ts` + - `extensions/git/src/repository.ts` + +## Problem + +Issue #1764 reports that selecting a large macOS workspace such as `~/Downloads` with about +260k recursive entries causes the workspace content watcher to exhaust the main process file +descriptor pool. The observed failure chain is: + +```text +large workspace + -> chokidar fs.watch traversal + -> EMFILE from too many watched entries + -> child process spawn cannot allocate stdio fds + -> agent exec utility exits during startup + -> every exec tool call fails regardless of command content +``` + +Current DeepChat uses `chokidar` in two main-process Presenters: + +- `src/main/presenter/workspacePresenter/index.ts` + - content watcher for workspace file changes + - git metadata watcher for `HEAD`, `index`, `packed-refs`, and `refs` +- `src/main/presenter/skillPresenter/index.ts` + - skills directory watcher for `SKILL.md` hot reload + +`@parcel/watcher` supports recursive directory subscriptions and uses FSEvents first on macOS, +which matches the root fix requested in the issue. + +## Design Direction + +Use the VS Code watcher model as the design reference: + +- A watcher service owns native watcher lifecycle and exposes logical subscriptions to features. +- Watcher hosts run outside the Electron main process using Electron `utilityProcess`. +- Watch requests are pooled and deduplicated by root, scope, include/exclude rules, and fallback + policy. +- Raw events are buffered, coalesced, and throttled before feature code receives them. +- Git metadata watching uses a dedicated watcher host lane so repository refresh pressure is + isolated from content and skill hot reload. +- Large workspaces keep a degraded but functional mode through snapshot polling or lifecycle + refresh when native watching fails or event pressure exceeds limits. + +## Requirements + +- Native file watching runs through a main-process `WatcherService` facade. +- `WorkspacePresenter` and `SkillPresenter` consume logical watcher subscriptions and do not + import `@parcel/watcher` directly. +- The watcher service starts Electron utility process hosts for native watcher work. +- Workspace content and skill hot reload use the content watcher host. +- Git metadata uses a separate git watcher host or an independently restartable git watcher lane. +- Workspace content watching uses `@parcel/watcher` for recursive subscriptions in the watcher + host. +- Workspace git metadata watching uses `@parcel/watcher` in the git watcher host and still emits + git-only invalidations for `HEAD`, `index`, `packed-refs`, and `refs` changes. +- Workspace watcher lifecycle keeps the existing security boundary: + `registerWorkspace` grants access; `watchWorkspace` owns watcher lifetime. +- Workspace watcher runtime ref counting remains intact across repeated panel mounts. +- Workspace file changes still publish `workspace.invalidated` with `kind: 'fs'`. +- Git metadata changes still publish `workspace.invalidated` with `kind: 'git'`. +- `.git` directory creation, deletion, or replacement still refreshes git watch metadata and + publishes `workspace.invalidated` with `kind: 'full'`. +- Workspace content ignores preserve the existing ignored directory set: + `node_modules`, `dist`, `build`, `__pycache__`, `.venv`, `venv`, `.idea`, `.vscode`, + `.cache`, `coverage`, `.next`, `.nuxt`, `out`, and `.turbo`. +- Workspace content watcher ignores `.git` children while still observing the `.git` directory + boundary itself. +- Skill hot reload still handles `SKILL.md` update, create, and delete events. +- Skill hot reload still ignores `.deepchat-meta`. +- Skill hot reload still respects `SKILL_CONFIG.FOLDER_TREE_MAX_DEPTH` at event handling time. +- Raw watcher events are buffered and coalesced before Presenter callbacks run. +- Event delivery is throttled with bounded memory so event floods degrade cleanly. +- Large workspace degradation emits `workspace.invalidated` with `source: 'fallback'` when native + events are unavailable. +- A typed workspace watcher status event reports `healthy`, `degraded`, and `failed` states to the + renderer for a small workspace-panel warning. +- Duplicate skill-name handling remains unchanged. +- `chokidar` is removed from runtime dependencies and lockfile entries. +- Native packaging includes the `@parcel/watcher` package and platform prebuilt packages. + +## Acceptance Criteria + +- On macOS, selecting a workspace with more than 100k files does not produce a sustained EMFILE + storm from the workspace watcher. +- After selecting that large workspace, a simple agent exec command such as `mkdir -p test` can + still spawn. +- Killing or crashing the watcher utility process does not terminate the main process. +- The watcher service restarts a failed watcher host and replays active watch requests. +- Native watcher failure with `EMFILE`, `ENOSPC`, or Parcel rescan errors enters degraded mode + instead of repeated error storms. +- Workspace panel refresh behavior remains unchanged for: + - create, update, and delete under the workspace + - ignored directory changes + - `.git` boundary changes + - git `HEAD`, `index`, `packed-refs`, and `refs` updates +- Skills catalog refresh behavior remains unchanged for: + - editing an existing `SKILL.md` + - adding a new `SKILL.md` + - deleting an existing `SKILL.md` + - duplicate skill names +- Unit tests cover the watcher adapter, workspace watcher lifecycle, workspace event mapping, git + metadata filtering, skill event mapping, and async subscription teardown. +- Unit tests cover watcher request pooling, event coalescing, host restart, and large workspace + fallback state transitions. +- `pnpm run typecheck:node`, focused main-process tests, `pnpm run format`, `pnpm run i18n`, + and `pnpm run lint` pass before implementation is considered complete. + +## Constraints + +- Keep existing `workspace.invalidated` and `skills.catalog.changed` event payloads unchanged. +- Add one typed watcher status event only for degraded/failure UI state. +- Keep workspace directory reading and file search lazy; this change targets live change + detection only. +- Keep native dependency packaging explicit because Electron ASAR packaging can break `.node` + modules when they remain inside `app.asar`. +- Keep exec utility error-copy improvements outside this increment after the fd-exhaustion root + cause is removed. + +## Review Decisions + +- Recommended dependency version: `@parcel/watcher@^2.5.6`, currently the latest npm release. +- Recommended implementation shape: a main-process `WatcherService` facade backed by Electron + utility process watcher hosts. +- Recommended lifecycle change: model watcher startup and shutdown as async operations where the + Presenter lifecycle already supports promises. +- Recommended isolation model: content/skill watcher host and git watcher host are independently + restartable. diff --git a/docs/issues/parcel-watcher-issue-1764/tasks.md b/docs/issues/parcel-watcher-issue-1764/tasks.md new file mode 100644 index 000000000..ad4e4d637 --- /dev/null +++ b/docs/issues/parcel-watcher-issue-1764/tasks.md @@ -0,0 +1,23 @@ +# Parcel Watcher Issue 1764 Tasks + +- [x] Add `@parcel/watcher`, remove `chokidar`, and update package/ASAR/build configuration. +- [x] Add watcher utility process entrypoint and `WatcherHostClient` RPC lifecycle. +- [x] Add `WatcherService`, `WatcherPool`, shared watcher types, and event coalescer. +- [x] Add host restart, request replay, throttling, and degraded-mode state handling. +- [x] Add snapshot polling, git metadata polling, and lifecycle fallback modes. +- [x] Migrate `WorkspacePresenter` content watching to `WatcherService`. +- [x] Migrate workspace git metadata watching to the git watcher host. +- [x] Migrate `SkillPresenter` hot reload watcher to `WatcherService` and async lifecycle. +- [x] Add typed workspace watcher status event and WorkspacePanel degraded warning UI. +- [x] Update watcher-focused main and renderer tests. +- [x] Run targeted verification and required project quality gates. + +## Verification + +- `pnpm run format` +- `pnpm run i18n` +- `pnpm run lint` +- `pnpm run typecheck` +- `pnpm test` +- `pnpm run build` +- `pnpm exec playwright test -c test/e2e/playwright.config.ts test/e2e/specs/30-workspace-watcher-events.smoke.spec.ts` diff --git a/electron-builder.yml b/electron-builder.yml index bb68f1abe..801410fbb 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -36,6 +36,8 @@ asarUnpack: - '**/node_modules/@opendal/**/*' - '**/node_modules/ffi-rs/**/*' - '**/node_modules/@yuuang/ffi-rs-*/**/*' + - '**/node_modules/@parcel/watcher/**/*' + - '**/node_modules/@parcel/watcher-*/**/*' extraResources: - from: ./runtime/ to: app.asar.unpacked/runtime diff --git a/electron.vite.config.ts b/electron.vite.config.ts index a42175ed5..e175736bc 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -26,7 +26,8 @@ export default defineConfig({ rollupOptions: { input: { index: resolve('src/main/index.ts'), - backgroundExecUtilityHost: resolve('src/main/backgroundExecUtilityHostEntry.ts') + backgroundExecUtilityHost: resolve('src/main/backgroundExecUtilityHostEntry.ts'), + fileWatcherUtilityHost: resolve('src/main/fileWatcherUtilityHostEntry.ts') }, external: ['sharp', '@duckdb/node-api'], output: { diff --git a/package.json b/package.json index efa1bd6b3..6fcd05b77 100644 --- a/package.json +++ b/package.json @@ -93,11 +93,11 @@ "@jxa/run": "^1.4.0", "@larksuiteoapi/node-sdk": "^1.64.0", "@modelcontextprotocol/sdk": "^1.29.0", + "@parcel/watcher": "^2.5.6", "ai": "^6.0.199", "axios": "^1.16.1", "better-sqlite3-multiple-ciphers": "12.9.0", "cheerio": "^1.2.0", - "chokidar": "^5.0.0", "compare-versions": "^6.1.1", "cross-spawn": "^7.0.6", "diff": "^8.0.4", diff --git a/scripts/afterPack.js b/scripts/afterPack.js index 720c3d6e3..279043ac7 100644 --- a/scripts/afterPack.js +++ b/scripts/afterPack.js @@ -39,6 +39,35 @@ function getFffBinaryPackages(platform, arch) { } } +function getParcelWatcherBinaryPackages(platform, arch) { + const archName = getArchName(arch) + + if (platform === 'darwin' && archName === 'universal') { + return ['@parcel/watcher-darwin-x64', '@parcel/watcher-darwin-arm64'] + } + + switch (`${platform}:${archName}`) { + case 'darwin:x64': + return ['@parcel/watcher-darwin-x64'] + case 'darwin:arm64': + return ['@parcel/watcher-darwin-arm64'] + case 'win32:x64': + return ['@parcel/watcher-win32-x64'] + case 'win32:arm64': + return ['@parcel/watcher-win32-arm64'] + case 'win32:ia32': + return ['@parcel/watcher-win32-ia32'] + case 'linux:x64': + return ['@parcel/watcher-linux-x64-glibc'] + case 'linux:arm64': + return ['@parcel/watcher-linux-arm64-glibc'] + case 'linux:armv7l': + return ['@parcel/watcher-linux-arm-glibc'] + default: + return [] + } +} + async function pathExists(filePath) { try { await fs.access(filePath) @@ -115,6 +144,34 @@ async function copyFffNativePackages(context) { } } +async function copyParcelWatcherNativePackages(context) { + const { arch, electronPlatformName, packager } = context + const packageNames = getParcelWatcherBinaryPackages(electronPlatformName, arch) + + if (packageNames.length === 0) { + return + } + + const nodeModulesDir = path.join(getResourcesDir(context), 'app.asar.unpacked', 'node_modules') + const parcelWatcherDir = path.join(nodeModulesDir, '@parcel', 'watcher') + + if (!(await pathExists(parcelWatcherDir))) { + throw new Error( + `Missing unpacked @parcel/watcher at ${parcelWatcherDir}. Check electron-builder asarUnpack configuration.` + ) + } + + const projectDir = packager?.projectDir ?? process.cwd() + + for (const packageName of packageNames) { + const sourceDir = await resolveInstalledPackageDir(projectDir, packageName) + const destinationDir = path.join(nodeModulesDir, ...packageName.split('/')) + + await fs.mkdir(path.dirname(destinationDir), { recursive: true }) + await fs.cp(sourceDir, destinationDir, { recursive: true, force: true, dereference: true }) + } +} + function isLinux(targets) { const re = /AppImage|snap|deb|rpm|freebsd|pacman/i return !!targets.find((target) => re.test(target.name)) @@ -132,6 +189,7 @@ async function afterPack(context) { const { targets, appOutDir } = context await copyFffNativePackages(context) + await copyParcelWatcherNativePackages(context) if (isLinux(targets)) { await afterPackLinux({ appOutDir }) diff --git a/src/main/fileWatcherUtilityHostEntry.ts b/src/main/fileWatcherUtilityHostEntry.ts new file mode 100644 index 000000000..a306062f6 --- /dev/null +++ b/src/main/fileWatcherUtilityHostEntry.ts @@ -0,0 +1,5 @@ +import { runFileWatcherUtilityHostIfRequested } from './lib/fileWatcher/fileWatcherUtilityHost' + +if (!runFileWatcherUtilityHostIfRequested()) { + throw new Error('File watcher utility host entrypoint started outside a utility process.') +} diff --git a/src/main/lib/fileWatcher/eventCoalescer.ts b/src/main/lib/fileWatcher/eventCoalescer.ts new file mode 100644 index 000000000..e306dba6c --- /dev/null +++ b/src/main/lib/fileWatcher/eventCoalescer.ts @@ -0,0 +1,71 @@ +import path from 'path' +import type { WatcherEvent } from './watcherTypes' + +const normalizeEventKey = (filePath: string): string => { + const comparablePath = + process.platform === 'darwin' && filePath.startsWith('/private/') + ? filePath.slice('/private'.length) + : filePath + const normalized = path.normalize(comparablePath) + return process.platform === 'win32' ? normalized.toLowerCase() : normalized +} + +const isDescendantOf = (candidate: string, parent: string): boolean => { + const relative = path.relative(parent, candidate) + return Boolean(relative) && !relative.startsWith('..') && !path.isAbsolute(relative) +} + +function mergeEvent(previous: WatcherEvent, next: WatcherEvent): WatcherEvent | null { + if (previous.type === 'create' && next.type === 'delete') { + return null + } + + if (previous.type === 'delete' && next.type === 'create') { + return { + path: next.path, + type: 'update' + } + } + + if (previous.type === 'create' && next.type === 'update') { + return previous + } + + return next +} + +export function coalesceWatcherEvents(events: WatcherEvent[]): WatcherEvent[] { + const byPath = new Map() + + for (const event of events) { + const key = normalizeEventKey(event.path) + const previous = byPath.get(key) + if (!previous) { + byPath.set(key, event) + continue + } + + const merged = mergeEvent(previous, event) + if (merged) { + byPath.set(key, merged) + } else { + byPath.delete(key) + } + } + + const mergedEvents = Array.from(byPath.values()) + const deletedParents = mergedEvents + .filter((event) => event.type === 'delete') + .map((event) => path.normalize(event.path)) + + return mergedEvents.filter((event) => { + if (event.type !== 'delete') { + return true + } + + const normalized = path.normalize(event.path) + return !deletedParents.some( + (deletedParent) => deletedParent !== normalized && isDescendantOf(normalized, deletedParent) + ) + }) +} diff --git a/src/main/lib/fileWatcher/fileWatcherUtilityHost.ts b/src/main/lib/fileWatcher/fileWatcherUtilityHost.ts new file mode 100644 index 000000000..344335129 --- /dev/null +++ b/src/main/lib/fileWatcher/fileWatcherUtilityHost.ts @@ -0,0 +1,125 @@ +import { FileWatcherHost } from './watcherHost' +import type { FileWatcherRpcRequest, FileWatcherRpcResponse } from './watcherTypes' + +const FILE_WATCHER_HOST_ARG = '--deepchat-file-watcher-host' + +type ParentPort = { + postMessage(message: unknown): void + on(event: 'message', listener: (message: unknown) => void): void + start?(): void +} + +type ParentPortMessageEvent = { + data?: unknown +} + +function getParentPort(): ParentPort | null { + const maybeProcess = process as NodeJS.Process & { + parentPort?: ParentPort + } + return maybeProcess.parentPort ?? null +} + +function isFileWatcherHostRequest(): boolean { + return ( + process.env.DEEPCHAT_FILE_WATCHER_HOST === '1' || process.argv.includes(FILE_WATCHER_HOST_ARG) + ) +} + +function getParentPortMessagePayload(message: unknown): unknown { + if (isFileWatcherRpcRequest(message)) { + return message + } + + if (message && typeof message === 'object' && 'data' in message) { + return (message as ParentPortMessageEvent).data + } + + return message +} + +function serializeError(error: unknown): { message: string; stack?: string } { + if (error instanceof Error) { + return { + message: error.message, + stack: error.stack + } + } + + return { + message: String(error) + } +} + +function isFileWatcherRpcRequest(message: unknown): message is FileWatcherRpcRequest { + return ( + Boolean(message) && + typeof message === 'object' && + (message as FileWatcherRpcRequest).type === 'file-watcher:request' + ) +} + +function sendResponse(parentPort: ParentPort, response: FileWatcherRpcResponse): void { + parentPort.postMessage(response) +} + +async function handleRequest( + host: FileWatcherHost, + parentPort: ParentPort, + request: FileWatcherRpcRequest +): Promise { + try { + const target = host as unknown as Record unknown> + const method = target[request.method] + if (typeof method !== 'function') { + throw new Error(`Unknown file watcher method: ${request.method}`) + } + + const data = await method.apply(host, request.args) + sendResponse(parentPort, { + type: 'file-watcher:response', + id: request.id, + ok: true, + data + }) + } catch (error) { + sendResponse(parentPort, { + type: 'file-watcher:response', + id: request.id, + ok: false, + error: serializeError(error) + }) + } +} + +export function runFileWatcherUtilityHostIfRequested(): boolean { + if (!isFileWatcherHostRequest()) { + return false + } + + const parentPort = getParentPort() + if (!parentPort) { + throw new Error('File watcher utility host started without a parent port.') + } + + const host = new FileWatcherHost({ + postMessage: (message) => parentPort.postMessage(message) + }) + const keepAliveIntervalId = setInterval(() => {}, 2 ** 31 - 1) + parentPort.start?.() + + parentPort.on('message', (message) => { + const request = getParentPortMessagePayload(message) + if (!isFileWatcherRpcRequest(request)) { + return + } + void handleRequest(host, parentPort, request) + }) + + process.once('beforeExit', () => { + clearInterval(keepAliveIntervalId) + void host.shutdown() + }) + + return true +} diff --git a/src/main/lib/fileWatcher/index.ts b/src/main/lib/fileWatcher/index.ts new file mode 100644 index 000000000..a3fa78247 --- /dev/null +++ b/src/main/lib/fileWatcher/index.ts @@ -0,0 +1,4 @@ +export * from './watcherTypes' +export * from './watcherService' +export * from './watcherPool' +export * from './eventCoalescer' diff --git a/src/main/lib/fileWatcher/watcherHost.ts b/src/main/lib/fileWatcher/watcherHost.ts new file mode 100644 index 000000000..f3a70ef19 --- /dev/null +++ b/src/main/lib/fileWatcher/watcherHost.ts @@ -0,0 +1,353 @@ +import fs from 'fs' +import os from 'os' +import path from 'path' +import parcelWatcher from '@parcel/watcher' +import { coalesceWatcherEvents } from './eventCoalescer' +import type { + FileWatcherHostEvent, + WatcherEvent, + WatcherEventBatch, + WatchMode, + WatchRequest, + WatcherStatus +} from './watcherTypes' + +const FILE_CHANGES_HANDLER_DELAY_MS = 75 +const MAX_BUFFERED_EVENTS = 30000 +const MAX_EVENT_CHUNK_SIZE = 500 +const EVENT_CHUNK_DELAY_MS = 200 +const SNAPSHOT_POLL_INTERVAL_MS = 5007 + +type ParcelSubscription = Awaited> + +type HostTransport = { + postMessage(message: FileWatcherHostEvent): void +} + +type ActiveWatch = { + request: WatchRequest + mode: WatchMode + subscription: ParcelSubscription | null + buffer: WatcherEvent[] + flushTimer: NodeJS.Timeout | null + chunkTimer: NodeJS.Timeout | null + pollTimer: NodeJS.Timeout | null + snapshotPath: string | null + disposed: boolean +} + +const serializeErrorMessage = (error: unknown): string => { + if (error instanceof Error) { + return error.message + } + return String(error) +} + +const toWatcherEvents = (events: parcelWatcher.Event[]): WatcherEvent[] => + events.map((event) => ({ + path: event.path, + type: event.type + })) + +export class FileWatcherHost { + private readonly watches = new Map() + + constructor(private readonly transport: HostTransport) {} + + async watch(request: WatchRequest): Promise { + await this.unwatch(request.id) + + const activeWatch: ActiveWatch = { + request, + mode: 'native', + subscription: null, + buffer: [], + flushTimer: null, + chunkTimer: null, + pollTimer: null, + snapshotPath: null, + disposed: false + } + + this.watches.set(request.id, activeWatch) + + try { + const subscription = await parcelWatcher.subscribe( + request.rootPath, + (error, events) => { + if (error) { + void this.handleNativeError(activeWatch, error) + return + } + this.enqueueEvents(activeWatch, toWatcherEvents(events)) + }, + { + ignore: request.excludes + } + ) + + if (activeWatch.disposed) { + await subscription.unsubscribe() + return + } + + activeWatch.subscription = subscription + this.sendStatus(activeWatch, { + health: 'healthy', + mode: 'native', + reason: 'ready' + }) + } catch (error) { + await this.handleNativeError(activeWatch, error) + } + } + + async unwatch(watchId: string): Promise { + const activeWatch = this.watches.get(watchId) + if (!activeWatch) { + return + } + + this.watches.delete(watchId) + await this.disposeActiveWatch(activeWatch) + } + + async shutdown(): Promise { + const activeWatches = Array.from(this.watches.values()) + this.watches.clear() + await Promise.all(activeWatches.map((activeWatch) => this.disposeActiveWatch(activeWatch))) + } + + private async handleNativeError(activeWatch: ActiveWatch, error: unknown): Promise { + if (activeWatch.disposed) { + return + } + + const message = serializeErrorMessage(error) + this.sendStatus(activeWatch, { + health: 'degraded', + mode: activeWatch.request.fallbackMode ?? 'snapshot-polling', + reason: 'native-error', + message + }) + + await this.startFallback(activeWatch, message) + } + + private async startFallback(activeWatch: ActiveWatch, message?: string): Promise { + if (activeWatch.disposed) { + return + } + + await this.unsubscribeNative(activeWatch) + activeWatch.mode = activeWatch.request.fallbackMode ?? 'snapshot-polling' + + if (activeWatch.mode === 'git-metadata-polling') { + this.startSnapshotPolling(activeWatch, message) + return + } + + this.startSnapshotPolling(activeWatch, message) + } + + private startSnapshotPolling(activeWatch: ActiveWatch, message?: string): void { + const snapshotPath = path.join( + os.tmpdir(), + `deepchat-watcher-${process.pid}-${activeWatch.request.id}.snapshot` + ) + activeWatch.snapshotPath = snapshotPath + + const poll = async () => { + if (activeWatch.disposed) { + return + } + + try { + if (!fs.existsSync(activeWatch.request.rootPath)) { + this.enqueueEvents(activeWatch, [ + { + path: activeWatch.request.rootPath, + type: 'root-deleted' + } + ]) + this.sendStatus(activeWatch, { + health: 'failed', + mode: activeWatch.mode, + reason: 'root-deleted' + }) + return + } + + if (!fs.existsSync(snapshotPath)) { + await parcelWatcher.writeSnapshot(activeWatch.request.rootPath, snapshotPath, { + ignore: activeWatch.request.excludes + }) + this.sendStatus(activeWatch, { + health: 'degraded', + mode: activeWatch.mode, + reason: 'fallback-started', + message + }) + return + } + + const events = await parcelWatcher.getEventsSince( + activeWatch.request.rootPath, + snapshotPath, + { + ignore: activeWatch.request.excludes + } + ) + await parcelWatcher.writeSnapshot(activeWatch.request.rootPath, snapshotPath, { + ignore: activeWatch.request.excludes + }) + this.enqueueEvents(activeWatch, toWatcherEvents(events)) + } catch (error) { + this.sendStatus(activeWatch, { + health: 'failed', + mode: activeWatch.mode, + reason: 'native-error', + message: serializeErrorMessage(error) + }) + } + } + + void poll() + activeWatch.pollTimer = setInterval(() => { + void poll() + }, SNAPSHOT_POLL_INTERVAL_MS) + } + + private enqueueEvents(activeWatch: ActiveWatch, events: WatcherEvent[]): void { + if (activeWatch.disposed || events.length === 0) { + return + } + + activeWatch.buffer.push(...events) + if (activeWatch.buffer.length > MAX_BUFFERED_EVENTS) { + activeWatch.buffer = [ + { + path: activeWatch.request.rootPath, + type: 'overflow' + } + ] + this.sendStatus(activeWatch, { + health: 'degraded', + mode: activeWatch.mode, + reason: 'overflow', + message: `Buffered watcher events exceeded ${MAX_BUFFERED_EVENTS}.` + }) + } + + if (activeWatch.flushTimer) { + return + } + + activeWatch.flushTimer = setTimeout(() => { + activeWatch.flushTimer = null + this.flushEvents(activeWatch) + }, FILE_CHANGES_HANDLER_DELAY_MS) + } + + private flushEvents(activeWatch: ActiveWatch): void { + if (activeWatch.disposed || activeWatch.buffer.length === 0) { + return + } + + const events = + activeWatch.request.purpose === 'workspace-git' + ? activeWatch.buffer + : coalesceWatcherEvents(activeWatch.buffer) + activeWatch.buffer = [] + this.sendChunks(activeWatch, events) + } + + private sendChunks(activeWatch: ActiveWatch, events: WatcherEvent[]): void { + if (activeWatch.disposed || events.length === 0) { + return + } + + const chunk = events.slice(0, MAX_EVENT_CHUNK_SIZE) + this.sendBatch(activeWatch, chunk) + const rest = events.slice(MAX_EVENT_CHUNK_SIZE) + if (rest.length === 0) { + return + } + + activeWatch.chunkTimer = setTimeout(() => { + activeWatch.chunkTimer = null + this.sendChunks(activeWatch, rest) + }, EVENT_CHUNK_DELAY_MS) + } + + private sendBatch(activeWatch: ActiveWatch, events: WatcherEvent[]): void { + const batch: WatcherEventBatch = { + watchId: activeWatch.request.id, + rootPath: activeWatch.request.rootPath, + purpose: activeWatch.request.purpose, + hostKind: activeWatch.request.hostKind, + mode: activeWatch.mode, + events, + version: Date.now() + } + + this.transport.postMessage({ + type: 'file-watcher:event-batch', + batch + }) + } + + private sendStatus( + activeWatch: ActiveWatch, + status: Pick + ): void { + this.transport.postMessage({ + type: 'file-watcher:status', + status: { + watchId: activeWatch.request.id, + rootPath: activeWatch.request.rootPath, + purpose: activeWatch.request.purpose, + hostKind: activeWatch.request.hostKind, + health: status.health, + mode: status.mode, + reason: status.reason, + message: status.message, + version: Date.now() + } + }) + } + + private async disposeActiveWatch(activeWatch: ActiveWatch): Promise { + activeWatch.disposed = true + + if (activeWatch.flushTimer) { + clearTimeout(activeWatch.flushTimer) + activeWatch.flushTimer = null + } + + if (activeWatch.chunkTimer) { + clearTimeout(activeWatch.chunkTimer) + activeWatch.chunkTimer = null + } + + if (activeWatch.pollTimer) { + clearInterval(activeWatch.pollTimer) + activeWatch.pollTimer = null + } + + await this.unsubscribeNative(activeWatch) + + if (activeWatch.snapshotPath) { + fs.rmSync(activeWatch.snapshotPath, { force: true }) + activeWatch.snapshotPath = null + } + } + + private async unsubscribeNative(activeWatch: ActiveWatch): Promise { + const subscription = activeWatch.subscription + activeWatch.subscription = null + if (subscription) { + await subscription.unsubscribe() + } + } +} diff --git a/src/main/lib/fileWatcher/watcherHostClient.ts b/src/main/lib/fileWatcher/watcherHostClient.ts new file mode 100644 index 000000000..57639ba2d --- /dev/null +++ b/src/main/lib/fileWatcher/watcherHostClient.ts @@ -0,0 +1,315 @@ +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' +import type { UtilityProcess } from 'electron' +import type { + FileWatcherHostEvent, + FileWatcherRpcMethod, + FileWatcherRpcRequest, + FileWatcherRpcResponse, + WatchBatchListener, + WatcherHostKind, + WatchRequest, + WatchStatusListener +} from './watcherTypes' + +type PendingRequest = { + resolve(value: unknown): void + reject(error: Error): void +} + +const MAX_RESTART_ATTEMPTS = 3 +const RESTART_DELAY_MS = 800 + +export class WatcherHostClient { + private host: UtilityProcess | null = null + private hostReady: Promise | null = null + private requestId = 0 + private restartAttempts = 0 + private restartTimer: NodeJS.Timeout | null = null + private shuttingDown = false + private readonly pendingRequests = new Map() + private readonly activeRequests = new Map() + private readonly batchListeners = new Set() + private readonly statusListeners = new Set() + + constructor(private readonly hostKind: WatcherHostKind) {} + + onBatch(listener: WatchBatchListener): () => void { + this.batchListeners.add(listener) + return () => { + this.batchListeners.delete(listener) + } + } + + onStatus(listener: WatchStatusListener): () => void { + this.statusListeners.add(listener) + return () => { + this.statusListeners.delete(listener) + } + } + + async watch(request: WatchRequest): Promise { + this.activeRequests.set(request.id, request) + try { + await this.request('watch', [request]) + } catch (error) { + this.activeRequests.delete(request.id) + throw error + } + } + + async unwatch(watchId: string): Promise { + this.activeRequests.delete(watchId) + if (!this.host && !this.hostReady) { + return + } + await this.request('unwatch', [watchId]).catch(() => {}) + } + + async shutdown(): Promise { + this.shuttingDown = true + + if (this.restartTimer) { + clearTimeout(this.restartTimer) + this.restartTimer = null + } + + try { + if (this.host) { + await this.request('shutdown', []) + } + } finally { + this.host?.kill() + this.host = null + this.hostReady = null + this.activeRequests.clear() + this.rejectPendingRequests(new Error('File watcher utility process shut down.')) + } + } + + private async request(method: FileWatcherRpcMethod, args: unknown[]): Promise { + const host = await this.ensureHost() + const id = `watcher_rpc_${this.hostKind}_${++this.requestId}` + + return await new Promise((resolve, reject) => { + this.pendingRequests.set(id, { + resolve: (value) => resolve(value as T), + reject + }) + + const payload: FileWatcherRpcRequest = { + type: 'file-watcher:request', + id, + method, + args + } + + try { + host.postMessage(payload) + } catch (error) { + this.pendingRequests.delete(id) + reject(error instanceof Error ? error : new Error(String(error))) + } + }) + } + + private async ensureHost(): Promise { + if (this.host) { + return this.host + } + + if (this.hostReady) { + return await this.hostReady + } + + this.shuttingDown = false + this.hostReady = this.startHost() + try { + return await this.hostReady + } finally { + this.hostReady = null + } + } + + private async startHost(): Promise { + const { app, utilityProcess } = await import('electron') + const modulePath = this.resolveUtilityHostEntryPoint(app.getAppPath()) + const serviceLabel = this.hostKind === 'git' ? 'Git' : 'Content' + const host = utilityProcess.fork(modulePath, ['--deepchat-file-watcher-host'], { + serviceName: `DeepChat ${serviceLabel} File Watcher`, + stdio: 'ignore', + env: { + ...process.env, + DEEPCHAT_FILE_WATCHER_HOST: '1', + DEEPCHAT_FILE_WATCHER_HOST_KIND: this.hostKind + } + }) + + host.on('message', (message) => this.handleHostMessage(message)) + host.on('exit', (code) => this.handleHostExit(code)) + host.on('error', (type, location) => { + console.error('[FileWatcherClient] Utility process error:', { + hostKind: this.hostKind, + type, + location + }) + }) + + return await new Promise((resolve, reject) => { + let settled = false + const settle = (callback: () => void) => { + if (settled) { + return + } + settled = true + host.off('spawn', onSpawn) + host.off('exit', onExit) + callback() + } + const onSpawn = () => { + settle(() => { + this.host = host + this.restartAttempts = 0 + resolve(host) + }) + } + const onExit = (code: number) => { + settle(() => { + reject(new Error(`File watcher utility process exited before spawn: ${code}`)) + }) + } + + host.once('spawn', onSpawn) + host.once('exit', onExit) + }) + } + + private resolveUtilityHostEntryPoint(appPath?: string): string { + const modulePath = fileURLToPath(import.meta.url) + const candidates = [ + ...(appPath + ? [ + path.join(appPath, 'out/main/fileWatcherUtilityHost.js'), + path.join(appPath, 'fileWatcherUtilityHost.js') + ] + : []), + path.resolve(path.dirname(modulePath), 'fileWatcherUtilityHost.js'), + path.resolve(path.dirname(modulePath), '../fileWatcherUtilityHost.js'), + path.resolve(process.cwd(), 'out/main/fileWatcherUtilityHost.js') + ] + + return candidates.find((candidate) => fs.existsSync(candidate)) ?? candidates[0] + } + + private handleHostMessage(message: unknown): void { + if (!message || typeof message !== 'object') { + return + } + + const response = message as FileWatcherRpcResponse + if (response.type === 'file-watcher:response') { + this.handleRpcResponse(response) + return + } + + const hostEvent = message as FileWatcherHostEvent + if (hostEvent.type === 'file-watcher:event-batch') { + for (const listener of this.batchListeners) { + listener(hostEvent.batch) + } + return + } + + if (hostEvent.type === 'file-watcher:status') { + for (const listener of this.statusListeners) { + listener(hostEvent.status) + } + } + } + + private handleRpcResponse(response: FileWatcherRpcResponse): void { + const pending = this.pendingRequests.get(response.id) + if (!pending) { + return + } + + this.pendingRequests.delete(response.id) + if (response.ok) { + pending.resolve(response.data) + return + } + + const error = new Error(response.error.message) + if (response.error.stack) { + error.stack = response.error.stack + } + pending.reject(error) + } + + private handleHostExit(code: number): void { + const error = new Error(`File watcher utility process exited with code ${code}.`) + this.host = null + this.hostReady = null + this.rejectPendingRequests(error) + + if (this.shuttingDown || this.activeRequests.size === 0) { + return + } + + for (const request of this.activeRequests.values()) { + for (const listener of this.statusListeners) { + listener({ + watchId: request.id, + rootPath: request.rootPath, + purpose: request.purpose, + hostKind: request.hostKind, + health: 'degraded', + mode: request.fallbackMode ?? 'snapshot-polling', + reason: 'utility-exit', + message: error.message, + version: Date.now() + }) + } + } + + this.scheduleRestart() + } + + private scheduleRestart(): void { + if (this.restartTimer || this.restartAttempts >= MAX_RESTART_ATTEMPTS) { + return + } + + this.restartAttempts += 1 + this.restartTimer = setTimeout(() => { + this.restartTimer = null + void this.replayActiveRequests() + }, RESTART_DELAY_MS) + } + + private async replayActiveRequests(): Promise { + const requests = Array.from(this.activeRequests.values()) + if (requests.length === 0 || this.shuttingDown) { + return + } + + try { + await this.ensureHost() + await Promise.all(requests.map((request) => this.request('watch', [request]))) + } catch (error) { + console.error('[FileWatcherClient] Failed to restart utility watcher:', { + hostKind: this.hostKind, + error + }) + this.scheduleRestart() + } + } + + private rejectPendingRequests(error: Error): void { + for (const pending of this.pendingRequests.values()) { + pending.reject(error) + } + this.pendingRequests.clear() + } +} diff --git a/src/main/lib/fileWatcher/watcherPool.ts b/src/main/lib/fileWatcher/watcherPool.ts new file mode 100644 index 000000000..f6d61e73c --- /dev/null +++ b/src/main/lib/fileWatcher/watcherPool.ts @@ -0,0 +1,205 @@ +import path from 'path' +import { WatcherHostClient } from './watcherHostClient' +import type { + WatchBatchListener, + WatcherEvent, + WatcherEventBatch, + WatchHandle, + WatchRequest, + WatcherStatus, + WatchStatusListener +} from './watcherTypes' + +type WatcherPoolEntry = { + key: string + request: WatchRequest + listeners: Set + statusListeners: Set + ready: Promise +} + +const normalizePathKey = (targetPath: string): string => { + const comparablePath = + process.platform === 'darwin' && targetPath.startsWith('/private/') + ? targetPath.slice('/private'.length) + : targetPath + const normalized = path.normalize(comparablePath) + return process.platform === 'win32' ? normalized.toLowerCase() : normalized +} + +const normalizeList = (values: string[] | undefined): string[] => + [...(values ?? [])].map(normalizePathKey).sort() + +const isEqualOrDescendant = (targetPath: string, basePath: string): boolean => { + const normalizedTarget = normalizePathKey(targetPath) + const normalizedBase = normalizePathKey(basePath) + if (normalizedTarget === normalizedBase) { + return true + } + + const relative = path.relative(normalizedBase, normalizedTarget) + return Boolean(relative) && !relative.startsWith('..') && !path.isAbsolute(relative) +} + +const shouldPassIncludes = (event: WatcherEvent, includes: string[] | undefined): boolean => { + if (event.type === 'overflow' || event.type === 'root-deleted') { + return true + } + + if (!includes?.length) { + return true + } + + return includes.some((includePath) => isEqualOrDescendant(event.path, includePath)) +} + +const shouldPassExcludes = (event: WatcherEvent, excludes: string[] | undefined): boolean => { + if (event.type === 'overflow' || event.type === 'root-deleted') { + return true + } + + if (!excludes?.length) { + return true + } + + return !excludes.some((excludePath) => isEqualOrDescendant(event.path, excludePath)) +} + +function filterBatch(batch: WatcherEventBatch, request: WatchRequest): WatcherEventBatch | null { + const events = batch.events.filter( + (event) => + shouldPassIncludes(event, request.includes) && shouldPassExcludes(event, request.excludes) + ) + + if (events.length === 0) { + return null + } + + return { + ...batch, + events + } +} + +export class WatcherPool { + private sequence = 0 + private readonly entriesByKey = new Map() + private readonly entriesByWatchId = new Map() + private readonly contentClient: WatcherHostClient + private readonly gitClient: WatcherHostClient + + constructor(clients?: { content?: WatcherHostClient; git?: WatcherHostClient }) { + this.contentClient = clients?.content ?? new WatcherHostClient('content') + this.gitClient = clients?.git ?? new WatcherHostClient('git') + this.contentClient.onBatch((batch) => this.handleBatch(batch)) + this.gitClient.onBatch((batch) => this.handleBatch(batch)) + this.contentClient.onStatus((status) => this.handleStatus(status)) + this.gitClient.onStatus((status) => this.handleStatus(status)) + } + + async watch( + request: WatchRequest, + onBatch: WatchBatchListener, + onStatus?: WatchStatusListener + ): Promise { + const key = this.createPoolKey(request) + let entry = this.entriesByKey.get(key) + + if (!entry) { + const pooledRequest = { + ...request, + id: `watch_pool_${++this.sequence}` + } + entry = { + key, + request: pooledRequest, + listeners: new Set(), + statusListeners: new Set(), + ready: this.getClient(pooledRequest).watch(pooledRequest) + } + this.entriesByKey.set(key, entry) + this.entriesByWatchId.set(pooledRequest.id, entry) + } + + entry.listeners.add(onBatch) + if (onStatus) { + entry.statusListeners.add(onStatus) + } + + await entry.ready + + return { + close: async () => { + await this.unwatch(entry, onBatch, onStatus) + } + } + } + + async destroy(): Promise { + this.entriesByKey.clear() + this.entriesByWatchId.clear() + await Promise.all([this.contentClient.shutdown(), this.gitClient.shutdown()]) + } + + private async unwatch( + entry: WatcherPoolEntry, + onBatch: WatchBatchListener, + onStatus?: WatchStatusListener + ): Promise { + entry.listeners.delete(onBatch) + if (onStatus) { + entry.statusListeners.delete(onStatus) + } + + if (entry.listeners.size > 0 || entry.statusListeners.size > 0) { + return + } + + this.entriesByKey.delete(entry.key) + this.entriesByWatchId.delete(entry.request.id) + await this.getClient(entry.request).unwatch(entry.request.id) + } + + private handleBatch(batch: WatcherEventBatch): void { + const entry = this.entriesByWatchId.get(batch.watchId) + if (!entry) { + return + } + + const filteredBatch = filterBatch(batch, entry.request) + if (!filteredBatch) { + return + } + + for (const listener of entry.listeners) { + listener(filteredBatch) + } + } + + private handleStatus(status: WatcherStatus): void { + const entry = this.entriesByWatchId.get(status.watchId) + if (!entry) { + return + } + + for (const listener of entry.statusListeners) { + listener(status) + } + } + + private getClient(request: WatchRequest): WatcherHostClient { + return request.hostKind === 'git' ? this.gitClient : this.contentClient + } + + private createPoolKey(request: WatchRequest): string { + return JSON.stringify({ + hostKind: request.hostKind, + purpose: request.purpose, + rootPath: normalizePathKey(request.rootPath), + recursive: request.recursive, + includes: normalizeList(request.includes), + excludes: normalizeList(request.excludes), + fallbackMode: request.fallbackMode ?? null + }) + } +} diff --git a/src/main/lib/fileWatcher/watcherService.ts b/src/main/lib/fileWatcher/watcherService.ts new file mode 100644 index 000000000..adb074739 --- /dev/null +++ b/src/main/lib/fileWatcher/watcherService.ts @@ -0,0 +1,44 @@ +import { WatcherPool } from './watcherPool' +import type { + IFileWatcherService, + WatchBatchListener, + WatcherHostKind, + WatchHandle, + WatchRequest, + WatchStatusListener +} from './watcherTypes' + +export class FileWatcherService implements IFileWatcherService { + constructor(private readonly pool = new WatcherPool()) {} + + async watch( + request: WatchRequest, + onBatch: WatchBatchListener, + onStatus?: WatchStatusListener + ): Promise { + return await this.pool.watch(request, onBatch, onStatus) + } + + async destroy(): Promise { + await this.pool.destroy() + } +} + +let sharedWatcherService: FileWatcherService | null = null + +export function getFileWatcherService(): FileWatcherService { + sharedWatcherService ??= new FileWatcherService() + return sharedWatcherService +} + +export function resetFileWatcherServiceForTests(): void { + sharedWatcherService = null +} + +export function createWatcherRequestId( + kind: WatcherHostKind, + purpose: string, + rootPath: string +): string { + return `${kind}:${purpose}:${rootPath}` +} diff --git a/src/main/lib/fileWatcher/watcherTypes.ts b/src/main/lib/fileWatcher/watcherTypes.ts new file mode 100644 index 000000000..4fbc4d21f --- /dev/null +++ b/src/main/lib/fileWatcher/watcherTypes.ts @@ -0,0 +1,109 @@ +export type WatcherHostKind = 'content' | 'git' + +export type WatchPurpose = 'workspace-content' | 'workspace-git' | 'skills' + +export type WatchEventType = 'create' | 'update' | 'delete' | 'overflow' | 'root-deleted' + +export type WatchMode = 'native' | 'snapshot-polling' | 'git-metadata-polling' + +export type WatchHealth = 'healthy' | 'degraded' | 'failed' + +export type WatchStatusReason = + | 'ready' + | 'native-error' + | 'utility-exit' + | 'fallback-started' + | 'overflow' + | 'root-deleted' + | 'shutdown' + +export interface WatcherEvent { + path: string + type: WatchEventType +} + +export interface WatcherEventBatch { + watchId: string + rootPath: string + purpose: WatchPurpose + hostKind: WatcherHostKind + mode: WatchMode + events: WatcherEvent[] + version: number +} + +export interface WatcherStatus { + watchId: string + rootPath: string + purpose: WatchPurpose + hostKind: WatcherHostKind + health: WatchHealth + mode: WatchMode + reason: WatchStatusReason + message?: string + version: number +} + +export interface WatchRequest { + id: string + rootPath: string + hostKind: WatcherHostKind + purpose: WatchPurpose + recursive: boolean + includes?: string[] + excludes?: string[] + fallbackMode?: Exclude +} + +export interface WatchHandle { + close(): Promise +} + +export type WatchBatchListener = (batch: WatcherEventBatch) => void + +export type WatchStatusListener = (status: WatcherStatus) => void + +export interface IFileWatcherService { + watch( + request: WatchRequest, + onBatch: WatchBatchListener, + onStatus?: WatchStatusListener + ): Promise + destroy(): Promise +} + +export type FileWatcherRpcMethod = 'watch' | 'unwatch' | 'shutdown' + +export interface FileWatcherRpcRequest { + type: 'file-watcher:request' + id: string + method: FileWatcherRpcMethod + args: unknown[] +} + +export type FileWatcherRpcResponse = + | { + type: 'file-watcher:response' + id: string + ok: true + data: unknown + } + | { + type: 'file-watcher:response' + id: string + ok: false + error: { + message: string + stack?: string + } + } + +export type FileWatcherHostEvent = + | { + type: 'file-watcher:event-batch' + batch: WatcherEventBatch + } + | { + type: 'file-watcher:status' + status: WatcherStatus + } diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index 9be294959..1dd65620a 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -856,8 +856,8 @@ export class Presenter implements IPresenter { this.syncPresenter.destroy() // 销毁同步相关资源 this.notificationPresenter.clearAllNotifications() // 清除所有通知 this.knowledgePresenter.destroy() // 释放所有数据库连接 - ;(this.workspacePresenter as WorkspacePresenter).destroy() // 销毁 Workspace watchers - ;(this.skillPresenter as SkillPresenter).destroy() // 销毁 Skills 相关资源 + await (this.workspacePresenter as WorkspacePresenter).destroy() // 销毁 Workspace watchers + await (this.skillPresenter as SkillPresenter).destroy() // 销毁 Skills 相关资源 ;(this.skillSyncPresenter as SkillSyncPresenter).destroy() // 销毁 Skill Sync 相关资源 // 注意: trayPresenter.destroy() 在 main/index.ts 的 will-quit 事件中处理 // 此处不销毁 trayPresenter,其生命周期由 main/index.ts 管理 diff --git a/src/main/presenter/skillPresenter/index.ts b/src/main/presenter/skillPresenter/index.ts index 2737d55cc..ea98ae142 100644 --- a/src/main/presenter/skillPresenter/index.ts +++ b/src/main/presenter/skillPresenter/index.ts @@ -2,10 +2,17 @@ import { app, shell } from 'electron' import path from 'path' import fs from 'fs' import { randomUUID } from 'node:crypto' -import { FSWatcher, watch } from 'chokidar' import matter from 'gray-matter' import { unzipSync } from 'fflate' import type { IConfigPresenter } from '@shared/presenter' +import { + createWatcherRequestId, + getFileWatcherService, + type IFileWatcherService, + type WatcherEventBatch, + type WatcherStatus, + type WatchHandle +} from '@/lib/fileWatcher' import { ISkillPresenter, SkillMetadata, @@ -203,7 +210,8 @@ export class SkillPresenter implements ISkillPresenter { string, { ownerPluginId: string; skillRoot: string; pluginRoot?: string } > = new Map() - private watcher: FSWatcher | null = null + private watcher: WatchHandle | null = null + private watcherStartPromise: Promise | null = null private initialized: boolean = false // Prevent concurrent discovery calls (race condition protection) private discoveryPromise: Promise | null = null @@ -211,7 +219,8 @@ export class SkillPresenter implements ISkillPresenter { constructor( private readonly configPresenter: IConfigPresenter, - private readonly sessionStatePort: SkillSessionStatePort + private readonly sessionStatePort: SkillSessionStatePort, + private readonly watcherService: IFileWatcherService = getFileWatcherService() ) { // Skills directory: ~/.deepchat/skills/ this.skillsDir = this.resolveSkillsDir() @@ -294,7 +303,7 @@ export class SkillPresenter implements ISkillPresenter { await this.installBuiltinSkills() this.cleanupExpiredDrafts() await this.discoverSkills() - this.watchSkillFiles() + await this.watchSkillFiles() this.initialized = true } @@ -1887,130 +1896,189 @@ export class SkillPresenter implements ISkillPresenter { /** * Watch skill files for changes (hot-reload) */ - watchSkillFiles(): void { + async watchSkillFiles(): Promise { if (this.watcher) { return } - this.watcher = watch(this.skillsDir, { - ignoreInitial: true, - depth: SKILL_CONFIG.FOLDER_TREE_MAX_DEPTH, - ignored: (watchPath) => - watchPath.includes(`${path.sep}${SKILL_CONFIG.SIDECAR_DIR}${path.sep}`) || - path.basename(watchPath) === SKILL_CONFIG.SIDECAR_DIR, - awaitWriteFinish: { - stabilityThreshold: SKILL_CONFIG.WATCHER_STABILITY_THRESHOLD, - pollInterval: SKILL_CONFIG.WATCHER_POLL_INTERVAL - } - }) + if (this.watcherStartPromise) { + return await this.watcherStartPromise + } - this.watcher.on('change', async (filePath: string) => { - if (path.basename(filePath) === 'SKILL.md') { - const previousName = - this.findSkillNameByPath(filePath) ?? path.basename(path.dirname(filePath)) - this.contentCache.delete(previousName) + this.watcherStartPromise = this.watcherService + .watch( + { + id: createWatcherRequestId('content', 'skills', this.skillsDir), + rootPath: this.skillsDir, + hostKind: 'content', + purpose: 'skills', + recursive: true, + excludes: this.createSkillWatchExcludes(), + fallbackMode: 'snapshot-polling' + }, + (batch) => this.handleSkillWatchBatch(batch), + (status) => this.handleSkillWatchStatus(status) + ) + .then((handle) => { + this.watcher = handle + logger.info('[SkillPresenter] File watcher started') + }) + .finally(() => { + this.watcherStartPromise = null + }) - // Re-parse metadata - const metadata = await this.parseSkillMetadata( - filePath, - path.basename(path.dirname(filePath)) - ) - if (metadata) { - const existingMetadata = this.metadataCache.get(metadata.name) - if (existingMetadata && existingMetadata.path !== metadata.path) { - logger.warn( - '[SkillPresenter] Duplicate skill name discovered. Keeping the first entry.', - { - name: metadata.name, - path: metadata.path, - existingPath: existingMetadata.path - } - ) - const previousMetadata = this.metadataCache.get(previousName) - if (previousName !== metadata.name && previousMetadata?.path === metadata.path) { - this.metadataCache.delete(previousName) - } - return - } + return await this.watcherStartPromise + } - if (previousName !== metadata.name) { - const previousMetadata = this.metadataCache.get(previousName) - if (previousMetadata?.path === metadata.path) { - this.metadataCache.delete(previousName) - } - } - this.metadataCache.set(metadata.name, metadata) - publishDeepchatEvent('skills.catalog.changed', { - reason: 'metadata-updated', - name: metadata.name, - skill: metadata, - version: Date.now() - }) - } + /** + * Stop watching skill files + */ + async stopWatching(): Promise { + await this.watcherStartPromise + + if (!this.watcher) { + return + } + + await this.watcher.close() + this.watcher = null + logger.info('[SkillPresenter] File watcher stopped') + } + + private createSkillWatchExcludes(): string[] { + const root = this.skillsDir.split(path.sep).join('/') + return [`${root}/${SKILL_CONFIG.SIDECAR_DIR}/**`, `${root}/**/${SKILL_CONFIG.SIDECAR_DIR}/**`] + } + + private async handleSkillWatchBatch(batch: WatcherEventBatch): Promise { + if (batch.events.some((event) => event.type === 'overflow' || event.type === 'root-deleted')) { + const skills = await this.discoverSkills() + publishDeepchatEvent('skills.catalog.changed', { + reason: 'discovered', + skills, + version: Date.now() + }) + return + } + + for (const event of batch.events) { + if (!this.isWatchedSkillMarkdownPath(event.path)) { + continue + } + + if (event.type === 'create') { + await this.handleSkillFileAdded(event.path) + } else if (event.type === 'update') { + await this.handleSkillFileChanged(event.path) + } else if (event.type === 'delete') { + this.handleSkillFileDeleted(event.path) } + } + } + + private handleSkillWatchStatus(status: WatcherStatus): void { + if (status.health === 'healthy') { + return + } + + logger.warn('[SkillPresenter] File watcher degraded.', { + health: status.health, + mode: status.mode, + reason: status.reason, + message: status.message }) + } - this.watcher.on('add', async (filePath: string) => { - if (path.basename(filePath) === 'SKILL.md') { - const metadata = await this.parseSkillMetadata( - filePath, - path.basename(path.dirname(filePath)) - ) - if (metadata) { - const existingMetadata = this.metadataCache.get(metadata.name) - if (existingMetadata && existingMetadata.path !== metadata.path) { - logger.warn( - '[SkillPresenter] Duplicate skill name discovered. Keeping the first entry.', - { - name: metadata.name, - path: metadata.path, - existingPath: existingMetadata.path - } - ) - return - } + private isWatchedSkillMarkdownPath(filePath: string): boolean { + if (path.basename(filePath) !== 'SKILL.md') { + return false + } - this.metadataCache.set(metadata.name, metadata) - publishDeepchatEvent('skills.catalog.changed', { - reason: 'installed', - name: metadata.name, - skill: metadata, - version: Date.now() - }) - } + const relativePath = path.relative(this.skillsDir, filePath) + if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + return false + } + + const segments = relativePath.split(/[\\/]+/).filter(Boolean) + return ( + !segments.includes(SKILL_CONFIG.SIDECAR_DIR) && + segments.length - 1 <= SKILL_CONFIG.FOLDER_TREE_MAX_DEPTH + ) + } + + private async handleSkillFileChanged(filePath: string): Promise { + const previousName = this.findSkillNameByPath(filePath) ?? path.basename(path.dirname(filePath)) + this.contentCache.delete(previousName) + + const metadata = await this.parseSkillMetadata(filePath, path.basename(path.dirname(filePath))) + if (!metadata) { + return + } + + const existingMetadata = this.metadataCache.get(metadata.name) + if (existingMetadata && existingMetadata.path !== metadata.path) { + logger.warn('[SkillPresenter] Duplicate skill name discovered. Keeping the first entry.', { + name: metadata.name, + path: metadata.path, + existingPath: existingMetadata.path + }) + const previousMetadata = this.metadataCache.get(previousName) + if (previousName !== metadata.name && previousMetadata?.path === metadata.path) { + this.metadataCache.delete(previousName) } - }) + return + } - this.watcher.on('unlink', (filePath: string) => { - if (path.basename(filePath) === 'SKILL.md') { - const skillName = - this.findSkillNameByPath(filePath) ?? path.basename(path.dirname(filePath)) - this.metadataCache.delete(skillName) - this.contentCache.delete(skillName) - publishDeepchatEvent('skills.catalog.changed', { - reason: 'uninstalled', - name: skillName, - version: Date.now() - }) + if (previousName !== metadata.name) { + const previousMetadata = this.metadataCache.get(previousName) + if (previousMetadata?.path === metadata.path) { + this.metadataCache.delete(previousName) } - }) + } - this.watcher.on('error', (error) => { - console.error('[SkillPresenter] File watcher error:', error) + this.metadataCache.set(metadata.name, metadata) + publishDeepchatEvent('skills.catalog.changed', { + reason: 'metadata-updated', + name: metadata.name, + skill: metadata, + version: Date.now() }) - - logger.info('[SkillPresenter] File watcher started') } - /** - * Stop watching skill files - */ - stopWatching(): void { - if (this.watcher) { - this.watcher.close() - this.watcher = null - logger.info('[SkillPresenter] File watcher stopped') + private async handleSkillFileAdded(filePath: string): Promise { + const metadata = await this.parseSkillMetadata(filePath, path.basename(path.dirname(filePath))) + if (!metadata) { + return + } + + const existingMetadata = this.metadataCache.get(metadata.name) + if (existingMetadata && existingMetadata.path !== metadata.path) { + logger.warn('[SkillPresenter] Duplicate skill name discovered. Keeping the first entry.', { + name: metadata.name, + path: metadata.path, + existingPath: existingMetadata.path + }) + return } + + this.metadataCache.set(metadata.name, metadata) + publishDeepchatEvent('skills.catalog.changed', { + reason: 'installed', + name: metadata.name, + skill: metadata, + version: Date.now() + }) + } + + private handleSkillFileDeleted(filePath: string): void { + const skillName = this.findSkillNameByPath(filePath) ?? path.basename(path.dirname(filePath)) + this.metadataCache.delete(skillName) + this.contentCache.delete(skillName) + publishDeepchatEvent('skills.catalog.changed', { + reason: 'uninstalled', + name: skillName, + version: Date.now() + }) } /** @@ -2041,8 +2109,8 @@ export class SkillPresenter implements ISkillPresenter { /** * Cleanup resources on shutdown */ - destroy(): void { - this.stopWatching() + async destroy(): Promise { + await this.stopWatching() this.metadataCache.clear() this.contentCache.clear() this.discoveryPromise = null diff --git a/src/main/presenter/workspacePresenter/index.ts b/src/main/presenter/workspacePresenter/index.ts index d56551442..c4860c87f 100644 --- a/src/main/presenter/workspacePresenter/index.ts +++ b/src/main/presenter/workspacePresenter/index.ts @@ -4,8 +4,15 @@ import { execFile } from 'child_process' import { fileURLToPath } from 'url' import { promisify } from 'util' import { shell } from 'electron' -import { FSWatcher, watch } from 'chokidar' import { publishDeepchatEvent } from '@/routes/publishDeepchatEvent' +import { + createWatcherRequestId, + getFileWatcherService, + type IFileWatcherService, + type WatcherEventBatch, + type WatcherStatus, + type WatchHandle +} from '@/lib/fileWatcher' import { readDirectoryShallow } from './directoryReader' import { searchWorkspaceFiles } from './workspaceFileSearch' import { @@ -29,6 +36,7 @@ import type { WorkspaceInvalidationEvent, WorkspaceInvalidationKind, WorkspaceInvalidationSource, + WorkspaceWatchStatusEvent, WorkspaceLinkedFileResolution } from '@shared/presenter' @@ -64,14 +72,12 @@ const WATCH_IGNORED_DIRS = [ ] as const const WATCH_DEBOUNCE_MS = 120 -const WATCH_STABILITY_THRESHOLD_MS = 250 -const WATCH_POLL_INTERVAL_MS = 100 type WorkspaceWatchRuntime = { workspacePath: string refCount: number - contentWatcher: FSWatcher - gitWatcher: FSWatcher | null + contentWatcher: WatchHandle | null + gitWatcher: WatchHandle | null gitWatchKey: string | null debounceTimer: NodeJS.Timeout | null pendingKind: WorkspaceInvalidationKind | null @@ -103,10 +109,15 @@ export class WorkspacePresenter implements IWorkspacePresenter { private readonly allowedPaths = new Set() private readonly allowedExactPaths = new Set() private readonly filePresenter: IFilePresenter + private readonly watcherService: IFileWatcherService private readonly watchRuntimes = new Map() - constructor(filePresenter: IFilePresenter) { + constructor( + filePresenter: IFilePresenter, + watcherService: IFileWatcherService = getFileWatcherService() + ) { this.filePresenter = filePresenter + this.watcherService = watcherService } async registerWorkspace(workspacePath: string): Promise { @@ -145,7 +156,7 @@ export class WorkspacePresenter implements IWorkspacePresenter { const runtime: WorkspaceWatchRuntime = { workspacePath: normalized, refCount: 1, - contentWatcher: this.createContentWatcher(normalized), + contentWatcher: null, gitWatcher: null, gitWatchKey: null, debounceTimer: null, @@ -155,6 +166,12 @@ export class WorkspacePresenter implements IWorkspacePresenter { } this.watchRuntimes.set(normalized, runtime) + runtime.contentWatcher = await this.createContentWatcher(normalized) + if (runtime.disposed || this.watchRuntimes.get(normalized) !== runtime) { + await runtime.contentWatcher.close() + runtime.contentWatcher = null + return + } await this.refreshGitWatcher(runtime) } @@ -174,12 +191,10 @@ export class WorkspacePresenter implements IWorkspacePresenter { await this.disposeRuntime(runtime) } - destroy(): void { + async destroy(): Promise { const runtimes = Array.from(this.watchRuntimes.values()) this.watchRuntimes.clear() - for (const runtime of runtimes) { - void this.disposeRuntime(runtime) - } + await Promise.allSettled(runtimes.map((runtime) => this.disposeRuntime(runtime))) for (const exactPath of this.allowedExactPaths) { unregisterWorkspacePreviewFile(exactPath) @@ -187,38 +202,65 @@ export class WorkspacePresenter implements IWorkspacePresenter { this.allowedExactPaths.clear() } - private createContentWatcher(workspacePath: string): FSWatcher { - const watcher = watch(workspacePath, { - ignoreInitial: true, - atomic: true, - followSymlinks: false, - ignored: (watchPath) => this.shouldIgnoreContentWatchPath(watchPath), - awaitWriteFinish: { - stabilityThreshold: WATCH_STABILITY_THRESHOLD_MS, - pollInterval: WATCH_POLL_INTERVAL_MS - } - }) + private async createContentWatcher(workspacePath: string): Promise { + return await this.watcherService.watch( + { + id: createWatcherRequestId('content', 'workspace-content', workspacePath), + rootPath: workspacePath, + hostKind: 'content', + purpose: 'workspace-content', + recursive: true, + excludes: this.createContentWatchExcludes(workspacePath), + fallbackMode: 'snapshot-polling' + }, + (batch) => this.handleContentWatchBatch(workspacePath, batch), + (status) => this.emitWatchStatus(workspacePath, status) + ) + } + + private handleContentWatchBatch(workspacePath: string, batch: WatcherEventBatch): void { + const runtime = this.watchRuntimes.get(workspacePath) + if (!runtime || runtime.disposed) { + return + } + + const source = this.getInvalidationSourceForBatch(batch) + let shouldInvalidateFs = false - watcher.on('all', (_eventName, targetPath) => { - const runtime = this.watchRuntimes.get(workspacePath) - if (!runtime || runtime.disposed) { + for (const event of batch.events) { + if (event.type === 'overflow' || event.type === 'root-deleted') { + void this.refreshGitWatcher(runtime) + this.scheduleInvalidation(runtime, 'full', source) return } - if (this.isGitDirectoryEvent(targetPath)) { + if (this.shouldIgnoreContentWatchPath(event.path)) { + continue + } + + if (this.isGitDirectoryEvent(event.path)) { void this.refreshGitWatcher(runtime) - this.scheduleInvalidation(runtime, 'full', 'watcher') + this.scheduleInvalidation(runtime, 'full', source) return } - this.scheduleInvalidation(runtime, 'fs', 'watcher') - }) + shouldInvalidateFs = true + } - watcher.on('error', (error) => { - console.error(`[Workspace] Content watcher error for ${workspacePath}:`, error) - }) + if (shouldInvalidateFs) { + this.scheduleInvalidation(runtime, 'fs', source) + } + } - return watcher + private createContentWatchExcludes(workspacePath: string): string[] { + const root = workspacePath.split(path.sep).join('/') + return [ + `${root}/.git/**`, + ...WATCH_IGNORED_DIRS.flatMap((segment) => [ + `${root}/${segment}/**`, + `${root}/**/${segment}/**` + ]) + ] } private shouldIgnoreContentWatchPath(watchPath: string): boolean { @@ -288,6 +330,22 @@ export class WorkspacePresenter implements IWorkspacePresenter { }) } + private emitWatchStatus(workspacePath: string, status: WatcherStatus): void { + const payload: WorkspaceWatchStatusEvent = { + workspacePath, + health: status.health, + mode: status.mode, + reason: status.reason, + message: status.message, + version: status.version + } + publishDeepchatEvent('workspace.watch.status.changed', payload) + } + + private getInvalidationSourceForBatch(batch: WatcherEventBatch): WorkspaceInvalidationSource { + return batch.mode === 'native' ? 'watcher' : 'fallback' + } + private async refreshGitWatcher(runtime: WorkspaceWatchRuntime): Promise { const metadata = await this.resolveGitWatchMetadata(runtime.workspacePath) @@ -295,7 +353,7 @@ export class WorkspacePresenter implements IWorkspacePresenter { return } - const nextWatchKey = metadata ? metadata.paths.join('\0') : null + const nextWatchKey = metadata ? `${metadata.watchRoot}\0${metadata.paths.join('\0')}` : null if (runtime.gitWatchKey === nextWatchKey) { return } @@ -312,27 +370,36 @@ export class WorkspacePresenter implements IWorkspacePresenter { return } - const gitWatcher = watch(metadata.paths, { - ignoreInitial: true, - atomic: true, - followSymlinks: false, - awaitWriteFinish: { - stabilityThreshold: WATCH_STABILITY_THRESHOLD_MS, - pollInterval: WATCH_POLL_INTERVAL_MS - } - }) - - gitWatcher.on('all', () => { - const currentRuntime = this.watchRuntimes.get(runtime.workspacePath) - if (!currentRuntime || currentRuntime !== runtime || runtime.disposed) { - return - } - this.scheduleInvalidation(runtime, 'git', 'watcher') - }) + const gitWatcher = await this.watcherService.watch( + { + id: createWatcherRequestId( + 'git', + 'workspace-git', + `${runtime.workspacePath}:${nextWatchKey}` + ), + rootPath: metadata.watchRoot, + hostKind: 'git', + purpose: 'workspace-git', + recursive: true, + includes: metadata.paths, + fallbackMode: 'git-metadata-polling' + }, + (batch) => { + const currentRuntime = this.watchRuntimes.get(runtime.workspacePath) + if (!currentRuntime || currentRuntime !== runtime || runtime.disposed) { + return + } - gitWatcher.on('error', (error) => { - console.error(`[Workspace] Git watcher error for ${runtime.workspacePath}:`, error) - }) + const source = this.getInvalidationSourceForBatch(batch) + const kind = batch.events.some( + (event) => event.type === 'overflow' || event.type === 'root-deleted' + ) + ? 'full' + : 'git' + this.scheduleInvalidation(runtime, kind, source) + }, + (status) => this.emitWatchStatus(runtime.workspacePath, status) + ) if (runtime.disposed || this.watchRuntimes.get(runtime.workspacePath) !== runtime) { await gitWatcher.close() @@ -344,7 +411,7 @@ export class WorkspacePresenter implements IWorkspacePresenter { private async resolveGitWatchMetadata( workspacePath: string - ): Promise<{ repoRoot: string; paths: string[] } | null> { + ): Promise<{ repoRoot: string; watchRoot: string; paths: string[] } | null> { const repoRoot = await this.resolveGitWorkspace(workspacePath) if (!repoRoot) { return null @@ -357,9 +424,12 @@ export class WorkspacePresenter implements IWorkspacePresenter { this.resolveGitPath(workspacePath, 'refs') ]) + const lockPaths = [headPath, indexPath, packedRefsPath] + .filter((value): value is string => typeof value === 'string') + .map((value) => `${value}.lock`) const paths = Array.from( new Set( - [headPath, indexPath, packedRefsPath, refsPath].filter( + [headPath, indexPath, packedRefsPath, refsPath, ...lockPaths].filter( (value): value is string => typeof value === 'string' ) ) @@ -368,7 +438,7 @@ export class WorkspacePresenter implements IWorkspacePresenter { return null } - return { repoRoot, paths } + return { repoRoot, watchRoot: repoRoot, paths } } private async resolveGitPath(workspacePath: string, key: string): Promise { @@ -395,7 +465,11 @@ export class WorkspacePresenter implements IWorkspacePresenter { runtime.debounceTimer = null } - const closures: Array> = [runtime.contentWatcher.close()] + const closures: Array> = [] + if (runtime.contentWatcher) { + closures.push(runtime.contentWatcher.close()) + runtime.contentWatcher = null + } if (runtime.gitWatcher) { closures.push(runtime.gitWatcher.close()) runtime.gitWatcher = null diff --git a/src/renderer/api/WorkspaceClient.ts b/src/renderer/api/WorkspaceClient.ts index b3dc03e6d..64d4bd1d2 100644 --- a/src/renderer/api/WorkspaceClient.ts +++ b/src/renderer/api/WorkspaceClient.ts @@ -1,5 +1,9 @@ import type { DeepchatBridge } from '@shared/contracts/bridge' -import { workspaceInvalidatedEvent } from '@shared/contracts/events' +import { + workspaceInvalidatedEvent, + workspaceWatchStatusChangedEvent +} from '@shared/contracts/events' +import type { WorkspaceWatchStatusEvent } from '@shared/presenter' import { workspaceExpandDirectoryRoute, workspaceGetGitDiffRoute, @@ -106,6 +110,10 @@ export function createWorkspaceClient(bridge: DeepchatBridge = getDeepchatBridge return bridge.on(workspaceInvalidatedEvent.name, listener) } + function onWatchStatusChanged(listener: (payload: WorkspaceWatchStatusEvent) => void) { + return bridge.on(workspaceWatchStatusChangedEvent.name, listener) + } + return { registerWorkspace, unregisterWorkspace, @@ -120,7 +128,8 @@ export function createWorkspaceClient(bridge: DeepchatBridge = getDeepchatBridge getGitStatus, getGitDiff, searchFiles, - onInvalidated + onInvalidated, + onWatchStatusChanged } } diff --git a/src/renderer/src/components/sidepanel/WorkspacePanel.vue b/src/renderer/src/components/sidepanel/WorkspacePanel.vue index 33466fa8e..d4f3d6eed 100644 --- a/src/renderer/src/components/sidepanel/WorkspacePanel.vue +++ b/src/renderer/src/components/sidepanel/WorkspacePanel.vue @@ -62,6 +62,14 @@
{{ t('chat.workspace.files.loading') }}
+
+ + {{ watchStatusBanner }} +
{ + if (!props.workspacePath || !watchStatus.value || watchStatus.value.health === 'healthy') { + return null + } + + return watchStatus.value.health === 'failed' + ? t('chat.workspace.files.watchStatus.failed') + : t('chat.workspace.files.watchStatus.degraded') +}) + const artifactItems = computed(() => { const items: ArtifactItem[] = [] diff --git a/src/renderer/src/components/sidepanel/composables/useWorkspaceSync.ts b/src/renderer/src/components/sidepanel/composables/useWorkspaceSync.ts index b0d0ab93a..970a583d3 100644 --- a/src/renderer/src/components/sidepanel/composables/useWorkspaceSync.ts +++ b/src/renderer/src/components/sidepanel/composables/useWorkspaceSync.ts @@ -5,7 +5,8 @@ import type { WorkspaceFilePreview, WorkspaceGitDiff, WorkspaceGitState, - WorkspaceInvalidationKind + WorkspaceInvalidationKind, + WorkspaceWatchStatusEvent } from '@shared/presenter' import type { WorkspaceSessionState } from '@/stores/ui/sidepanel' @@ -25,6 +26,7 @@ interface UseWorkspaceSyncOptions { | 'getGitStatus' | 'getGitDiff' | 'onInvalidated' + | 'onWatchStatusChanged' > sidepanelStore: { clearFile(sessionId: string): void @@ -82,7 +84,9 @@ export function useWorkspaceSync(options: UseWorkspaceSyncOptions) { const loadingFiles = ref(false) const loadingFilePreview = ref(false) const loadingGitDiff = ref(false) + const watchStatus = ref(null) let stopWorkspaceInvalidatedListener: (() => void) | null = null + let stopWorkspaceWatchStatusListener: (() => void) | null = null const normalizedWorkspacePath = computed(() => normalizeWorkspaceKey(options.workspacePath.value?.trim() || null) @@ -325,6 +329,20 @@ export function useWorkspaceSync(options: UseWorkspaceSyncOptions) { scheduleRefresh(kind) } + const handleWorkspaceWatchStatusChanged = (payload: WorkspaceWatchStatusEvent) => { + const activeWorkspacePath = normalizedWorkspacePath.value + if (!activeWorkspacePath) { + return + } + + const payloadWorkspacePath = normalizeWorkspaceKey(payload.workspacePath) + if (payloadWorkspacePath === null || payloadWorkspacePath !== activeWorkspacePath) { + return + } + + watchStatus.value = payload + } + const ensureWatcherState = async ( workspacePath: string | null, active: boolean @@ -334,6 +352,7 @@ export function useWorkspaceSync(options: UseWorkspaceSyncOptions) { if (previousWorkspacePath && previousWorkspacePath !== nextWorkspacePath) { watchedWorkspacePath = null + watchStatus.value = null await options.workspaceClient.unwatchWorkspace(previousWorkspacePath) } @@ -343,11 +362,13 @@ export function useWorkspaceSync(options: UseWorkspaceSyncOptions) { gitState.value = null selectedFilePreview.value = null selectedGitDiff.value = null + watchStatus.value = null } return } if (watchedWorkspacePath !== nextWorkspacePath) { + watchStatus.value = null await options.workspaceClient.registerWorkspace(nextWorkspacePath) await options.workspaceClient.watchWorkspace(nextWorkspacePath) watchedWorkspacePath = nextWorkspacePath @@ -401,6 +422,9 @@ export function useWorkspaceSync(options: UseWorkspaceSyncOptions) { stopWorkspaceInvalidatedListener = options.workspaceClient.onInvalidated( handleWorkspaceInvalidated ) + stopWorkspaceWatchStatusListener = options.workspaceClient.onWatchStatusChanged( + handleWorkspaceWatchStatusChanged + ) }) onBeforeUnmount(() => { @@ -411,6 +435,8 @@ export function useWorkspaceSync(options: UseWorkspaceSyncOptions) { stopWorkspaceInvalidatedListener?.() stopWorkspaceInvalidatedListener = null + stopWorkspaceWatchStatusListener?.() + stopWorkspaceWatchStatusListener = null if (watchedWorkspacePath) { const workspacePath = watchedWorkspacePath @@ -424,6 +450,7 @@ export function useWorkspaceSync(options: UseWorkspaceSyncOptions) { selectedFilePreview, selectedGitDiff, gitState, + watchStatus, loadingFiles, loadingFilePreview, loadingGitDiff, diff --git a/src/renderer/src/i18n/da-DK/chat.json b/src/renderer/src/i18n/da-DK/chat.json index 9b01cb37b..6cd97daa8 100644 --- a/src/renderer/src/i18n/da-DK/chat.json +++ b/src/renderer/src/i18n/da-DK/chat.json @@ -210,6 +210,10 @@ "title": "Ingen arbejdsområde", "description": "Vælg eller træk en mappe for at definere arbejdsområdet", "button": "Vælg mappe" + }, + "watchStatus": { + "degraded": "Watching in fallback mode. Changes may refresh slower.", + "failed": "File watching is unavailable. Refresh or reselect the workspace." } }, "git": { diff --git a/src/renderer/src/i18n/de-DE/chat.json b/src/renderer/src/i18n/de-DE/chat.json index a150b3bfe..affaff380 100644 --- a/src/renderer/src/i18n/de-DE/chat.json +++ b/src/renderer/src/i18n/de-DE/chat.json @@ -260,6 +260,10 @@ "openFile": "Datei öffnen", "revealInFolder": "Im Dateimanager anzeigen", "insertPath": "In Eingabe einfügen" + }, + "watchStatus": { + "degraded": "Watching in fallback mode. Changes may refresh slower.", + "failed": "File watching is unavailable. Refresh or reselect the workspace." } }, "git": { diff --git a/src/renderer/src/i18n/en-US/chat.json b/src/renderer/src/i18n/en-US/chat.json index c0076bb90..4fdd6a3dd 100644 --- a/src/renderer/src/i18n/en-US/chat.json +++ b/src/renderer/src/i18n/en-US/chat.json @@ -279,6 +279,10 @@ "openFile": "Open file", "revealInFolder": "Show in file manager", "insertPath": "Insert into input" + }, + "watchStatus": { + "degraded": "Watching in fallback mode. Changes may refresh slower.", + "failed": "File watching is unavailable. Refresh or reselect the workspace." } }, "git": { diff --git a/src/renderer/src/i18n/es-ES/chat.json b/src/renderer/src/i18n/es-ES/chat.json index ce5ea1d49..f7e90694f 100644 --- a/src/renderer/src/i18n/es-ES/chat.json +++ b/src/renderer/src/i18n/es-ES/chat.json @@ -260,6 +260,10 @@ "openFile": "Abrir archivo", "revealInFolder": "Mostrar en el administrador de archivos", "insertPath": "Insertar en el campo de entrada" + }, + "watchStatus": { + "degraded": "Watching in fallback mode. Changes may refresh slower.", + "failed": "File watching is unavailable. Refresh or reselect the workspace." } }, "git": { diff --git a/src/renderer/src/i18n/fa-IR/chat.json b/src/renderer/src/i18n/fa-IR/chat.json index 00c7a9013..2d7c51eea 100644 --- a/src/renderer/src/i18n/fa-IR/chat.json +++ b/src/renderer/src/i18n/fa-IR/chat.json @@ -210,6 +210,10 @@ "title": "بدون فضای کاری", "description": "پوشه‌ای را انتخاب یا بکشید تا فضای کاری را تنظیم کنید", "button": "انتخاب پوشه" + }, + "watchStatus": { + "degraded": "Watching in fallback mode. Changes may refresh slower.", + "failed": "File watching is unavailable. Refresh or reselect the workspace." } }, "git": { diff --git a/src/renderer/src/i18n/fr-FR/chat.json b/src/renderer/src/i18n/fr-FR/chat.json index 8094896ec..7aec16ebb 100644 --- a/src/renderer/src/i18n/fr-FR/chat.json +++ b/src/renderer/src/i18n/fr-FR/chat.json @@ -210,6 +210,10 @@ "title": "Aucun espace de travail", "description": "Sélectionnez ou faites glisser un dossier pour définir l'espace de travail", "button": "Sélectionner un dossier" + }, + "watchStatus": { + "degraded": "Watching in fallback mode. Changes may refresh slower.", + "failed": "File watching is unavailable. Refresh or reselect the workspace." } }, "git": { diff --git a/src/renderer/src/i18n/he-IL/chat.json b/src/renderer/src/i18n/he-IL/chat.json index 9942b68a9..6c27f3979 100644 --- a/src/renderer/src/i18n/he-IL/chat.json +++ b/src/renderer/src/i18n/he-IL/chat.json @@ -210,6 +210,10 @@ "title": "אין אזור עבודה", "description": "בחר או גרור תיקייה כדי להגדיר את אזור העבודה", "button": "בחר תיקייה" + }, + "watchStatus": { + "degraded": "Watching in fallback mode. Changes may refresh slower.", + "failed": "File watching is unavailable. Refresh or reselect the workspace." } }, "git": { diff --git a/src/renderer/src/i18n/id-ID/chat.json b/src/renderer/src/i18n/id-ID/chat.json index f39000e09..4e1cf1412 100644 --- a/src/renderer/src/i18n/id-ID/chat.json +++ b/src/renderer/src/i18n/id-ID/chat.json @@ -260,6 +260,10 @@ "openFile": "membuka berkas", "revealInFolder": "Buka di pengelola file", "insertPath": "Masukkan ke dalam kotak masukan" + }, + "watchStatus": { + "degraded": "Watching in fallback mode. Changes may refresh slower.", + "failed": "File watching is unavailable. Refresh or reselect the workspace." } }, "git": { diff --git a/src/renderer/src/i18n/it-IT/chat.json b/src/renderer/src/i18n/it-IT/chat.json index ea645ed93..f9c05bdc0 100644 --- a/src/renderer/src/i18n/it-IT/chat.json +++ b/src/renderer/src/i18n/it-IT/chat.json @@ -260,6 +260,10 @@ "openFile": "Apri file", "revealInFolder": "Mostra nel file manager", "insertPath": "Inserisci nell'input" + }, + "watchStatus": { + "degraded": "Watching in fallback mode. Changes may refresh slower.", + "failed": "File watching is unavailable. Refresh or reselect the workspace." } }, "git": { diff --git a/src/renderer/src/i18n/ja-JP/chat.json b/src/renderer/src/i18n/ja-JP/chat.json index 383f54d84..6c56086b1 100644 --- a/src/renderer/src/i18n/ja-JP/chat.json +++ b/src/renderer/src/i18n/ja-JP/chat.json @@ -210,7 +210,11 @@ "description": "フォルダを選択してワークスペースを設定", "button": "フォルダを選択" }, - "section": "書類" + "section": "書類", + "watchStatus": { + "degraded": "Watching in fallback mode. Changes may refresh slower.", + "failed": "File watching is unavailable. Refresh or reselect the workspace." + } }, "git": { "empty": "表示できる diff はありません", diff --git a/src/renderer/src/i18n/ko-KR/chat.json b/src/renderer/src/i18n/ko-KR/chat.json index 6b834d0d2..dfc3fa8bd 100644 --- a/src/renderer/src/i18n/ko-KR/chat.json +++ b/src/renderer/src/i18n/ko-KR/chat.json @@ -210,6 +210,10 @@ "title": "워크스페이스 없음", "description": "폴더를 선택하거나 드래그하여 워크스페이스 설정", "button": "폴더 선택" + }, + "watchStatus": { + "degraded": "Watching in fallback mode. Changes may refresh slower.", + "failed": "File watching is unavailable. Refresh or reselect the workspace." } }, "git": { diff --git a/src/renderer/src/i18n/ms-MY/chat.json b/src/renderer/src/i18n/ms-MY/chat.json index e90fb2790..33ed36a28 100644 --- a/src/renderer/src/i18n/ms-MY/chat.json +++ b/src/renderer/src/i18n/ms-MY/chat.json @@ -260,6 +260,10 @@ "openFile": "buka fail", "revealInFolder": "Buka dalam pengurus fail", "insertPath": "Masukkan ke dalam kotak input" + }, + "watchStatus": { + "degraded": "Watching in fallback mode. Changes may refresh slower.", + "failed": "File watching is unavailable. Refresh or reselect the workspace." } }, "git": { diff --git a/src/renderer/src/i18n/pl-PL/chat.json b/src/renderer/src/i18n/pl-PL/chat.json index 0a4173786..82bde4a2d 100644 --- a/src/renderer/src/i18n/pl-PL/chat.json +++ b/src/renderer/src/i18n/pl-PL/chat.json @@ -260,6 +260,10 @@ "openFile": "Otwórz plik", "revealInFolder": "Pokaż w menedżerze plików", "insertPath": "Wstaw do wejścia" + }, + "watchStatus": { + "degraded": "Watching in fallback mode. Changes may refresh slower.", + "failed": "File watching is unavailable. Refresh or reselect the workspace." } }, "git": { diff --git a/src/renderer/src/i18n/pt-BR/chat.json b/src/renderer/src/i18n/pt-BR/chat.json index 9f84b518d..c83cc3bdc 100644 --- a/src/renderer/src/i18n/pt-BR/chat.json +++ b/src/renderer/src/i18n/pt-BR/chat.json @@ -210,6 +210,10 @@ "title": "Sem espaço de trabalho", "description": "Selecione ou arraste uma pasta para definir o espaço de trabalho", "button": "Selecionar pasta" + }, + "watchStatus": { + "degraded": "Watching in fallback mode. Changes may refresh slower.", + "failed": "File watching is unavailable. Refresh or reselect the workspace." } }, "git": { diff --git a/src/renderer/src/i18n/ru-RU/chat.json b/src/renderer/src/i18n/ru-RU/chat.json index 30a6f8f38..6e47ea9e2 100644 --- a/src/renderer/src/i18n/ru-RU/chat.json +++ b/src/renderer/src/i18n/ru-RU/chat.json @@ -210,6 +210,10 @@ "title": "Нет рабочей области", "description": "Выберите или перетащите папку для настройки рабочей области", "button": "Выбрать папку" + }, + "watchStatus": { + "degraded": "Watching in fallback mode. Changes may refresh slower.", + "failed": "File watching is unavailable. Refresh or reselect the workspace." } }, "git": { diff --git a/src/renderer/src/i18n/tr-TR/chat.json b/src/renderer/src/i18n/tr-TR/chat.json index 7042b060d..a2a76c76d 100644 --- a/src/renderer/src/i18n/tr-TR/chat.json +++ b/src/renderer/src/i18n/tr-TR/chat.json @@ -260,6 +260,10 @@ "openFile": "Dosyayı aç", "revealInFolder": "Dosya yöneticisinde göster", "insertPath": "Girişe ekle" + }, + "watchStatus": { + "degraded": "Watching in fallback mode. Changes may refresh slower.", + "failed": "File watching is unavailable. Refresh or reselect the workspace." } }, "git": { diff --git a/src/renderer/src/i18n/vi-VN/chat.json b/src/renderer/src/i18n/vi-VN/chat.json index ed3c23e41..7265573d7 100644 --- a/src/renderer/src/i18n/vi-VN/chat.json +++ b/src/renderer/src/i18n/vi-VN/chat.json @@ -260,6 +260,10 @@ "openFile": "Mở tập tin", "revealInFolder": "Hiển thị trong trình quản lý tập tin", "insertPath": "Chèn vào đầu vào" + }, + "watchStatus": { + "degraded": "Watching in fallback mode. Changes may refresh slower.", + "failed": "File watching is unavailable. Refresh or reselect the workspace." } }, "git": { diff --git a/src/renderer/src/i18n/zh-CN/chat.json b/src/renderer/src/i18n/zh-CN/chat.json index e81ce2892..b196dd20b 100644 --- a/src/renderer/src/i18n/zh-CN/chat.json +++ b/src/renderer/src/i18n/zh-CN/chat.json @@ -279,6 +279,10 @@ "openFile": "打开文件", "revealInFolder": "在文件管理器中打开", "insertPath": "插入到输入框" + }, + "watchStatus": { + "degraded": "正在使用降级监听模式,文件变化刷新会变慢。", + "failed": "文件监听暂不可用,请重新选择工作区或刷新。" } }, "git": { diff --git a/src/renderer/src/i18n/zh-HK/chat.json b/src/renderer/src/i18n/zh-HK/chat.json index 992f4f460..79848bdf3 100644 --- a/src/renderer/src/i18n/zh-HK/chat.json +++ b/src/renderer/src/i18n/zh-HK/chat.json @@ -218,7 +218,11 @@ "description": "選擇或拖拽文件夾來設定工作區", "button": "選擇文件夾" }, - "section": "文件" + "section": "文件", + "watchStatus": { + "degraded": "正在使用降级监听模式,文件变化刷新会变慢。", + "failed": "文件监听暂不可用,请重新选择工作区或刷新。" + } }, "git": { "empty": "暫無可顯示的 diff", diff --git a/src/renderer/src/i18n/zh-TW/chat.json b/src/renderer/src/i18n/zh-TW/chat.json index d1e4cb56a..379e4d208 100644 --- a/src/renderer/src/i18n/zh-TW/chat.json +++ b/src/renderer/src/i18n/zh-TW/chat.json @@ -218,7 +218,11 @@ "description": "選擇或拖拽資料夾來設定工作區", "button": "選擇資料夾" }, - "section": "文件" + "section": "文件", + "watchStatus": { + "degraded": "正在使用降级监听模式,文件变化刷新会变慢。", + "failed": "文件监听暂不可用,请重新选择工作区或刷新。" + } }, "git": { "empty": "暫無可顯示的 diff", diff --git a/src/shared/contracts/domainSchemas.ts b/src/shared/contracts/domainSchemas.ts index 90db97166..786aab062 100644 --- a/src/shared/contracts/domainSchemas.ts +++ b/src/shared/contracts/domainSchemas.ts @@ -792,6 +792,21 @@ export const EnvironmentSummarySchema = z.object({ export const WorkspaceInvalidationKindSchema = z.enum(['fs', 'git', 'full']) export const WorkspaceInvalidationSourceSchema = z.enum(['watcher', 'fallback', 'lifecycle']) +export const WorkspaceWatchHealthSchema = z.enum(['healthy', 'degraded', 'failed']) +export const WorkspaceWatchModeSchema = z.enum([ + 'native', + 'snapshot-polling', + 'git-metadata-polling' +]) +export const WorkspaceWatchStatusReasonSchema = z.enum([ + 'ready', + 'native-error', + 'utility-exit', + 'fallback-started', + 'overflow', + 'root-deleted', + 'shutdown' +]) export const WorkspaceFilePreviewKindSchema = z.enum([ 'text', 'markdown', diff --git a/src/shared/contracts/events.ts b/src/shared/contracts/events.ts index 43c651d31..aedcd9047 100644 --- a/src/shared/contracts/events.ts +++ b/src/shared/contracts/events.ts @@ -115,7 +115,10 @@ import { upgradeWillRestartEvent } from './events/upgrade.events' import { windowStateChangedEvent } from './events/window.events' -import { workspaceInvalidatedEvent } from './events/workspace.events' +import { + workspaceInvalidatedEvent, + workspaceWatchStatusChangedEvent +} from './events/workspace.events' export * from './events/browser.events' export * from './events/acp-terminal.events' @@ -143,6 +146,7 @@ export * from './events/workspace.events' export const DEEPCHAT_EVENT_CATALOG = { [windowStateChangedEvent.name]: windowStateChangedEvent, [workspaceInvalidatedEvent.name]: workspaceInvalidatedEvent, + [workspaceWatchStatusChangedEvent.name]: workspaceWatchStatusChangedEvent, [browserActivityChangedEvent.name]: browserActivityChangedEvent, [browserOpenRequestedEvent.name]: browserOpenRequestedEvent, [browserStatusChangedEvent.name]: browserStatusChangedEvent, diff --git a/src/shared/contracts/events/workspace.events.ts b/src/shared/contracts/events/workspace.events.ts index 68cd0bc93..3a6eb8e4f 100644 --- a/src/shared/contracts/events/workspace.events.ts +++ b/src/shared/contracts/events/workspace.events.ts @@ -1,8 +1,11 @@ import { z } from 'zod' import { TimestampMsSchema, defineEventContract } from '../common' import { + WorkspaceWatchHealthSchema, WorkspaceInvalidationKindSchema, - WorkspaceInvalidationSourceSchema + WorkspaceInvalidationSourceSchema, + WorkspaceWatchModeSchema, + WorkspaceWatchStatusReasonSchema } from '../domainSchemas' export const workspaceInvalidatedEvent = defineEventContract({ @@ -14,3 +17,15 @@ export const workspaceInvalidatedEvent = defineEventContract({ version: TimestampMsSchema }) }) + +export const workspaceWatchStatusChangedEvent = defineEventContract({ + name: 'workspace.watch.status.changed', + payload: z.object({ + workspacePath: z.string(), + health: WorkspaceWatchHealthSchema, + mode: WorkspaceWatchModeSchema, + reason: WorkspaceWatchStatusReasonSchema, + message: z.string().optional(), + version: TimestampMsSchema + }) +}) diff --git a/src/shared/types/presenters/index.d.ts b/src/shared/types/presenters/index.d.ts index 3cdd19f3d..443352a77 100644 --- a/src/shared/types/presenters/index.d.ts +++ b/src/shared/types/presenters/index.d.ts @@ -76,6 +76,10 @@ export type { WorkspaceInvalidationKind, WorkspaceInvalidationSource, WorkspaceInvalidationEvent, + WorkspaceWatchHealth, + WorkspaceWatchMode, + WorkspaceWatchStatusReason, + WorkspaceWatchStatusEvent, ResolveMarkdownLinkedFileInput, WorkspaceLinkedFileResolution, IWorkspacePresenter diff --git a/src/shared/types/presenters/workspace.d.ts b/src/shared/types/presenters/workspace.d.ts index 838519605..71f549405 100644 --- a/src/shared/types/presenters/workspace.d.ts +++ b/src/shared/types/presenters/workspace.d.ts @@ -98,6 +98,29 @@ export type WorkspaceInvalidationEvent = { workspacePath: string kind: WorkspaceInvalidationKind source: WorkspaceInvalidationSource + version?: number +} + +export type WorkspaceWatchHealth = 'healthy' | 'degraded' | 'failed' + +export type WorkspaceWatchMode = 'native' | 'snapshot-polling' | 'git-metadata-polling' + +export type WorkspaceWatchStatusReason = + | 'ready' + | 'native-error' + | 'utility-exit' + | 'fallback-started' + | 'overflow' + | 'root-deleted' + | 'shutdown' + +export type WorkspaceWatchStatusEvent = { + workspacePath: string + health: WorkspaceWatchHealth + mode: WorkspaceWatchMode + reason: WorkspaceWatchStatusReason + message?: string + version: number } export type ResolveMarkdownLinkedFileInput = { diff --git a/src/shared/types/skill.ts b/src/shared/types/skill.ts index 91ee0c353..5533f0ad4 100644 --- a/src/shared/types/skill.ts +++ b/src/shared/types/skill.ts @@ -238,6 +238,6 @@ export interface ISkillPresenter { getActiveSkillsAllowedTools(conversationId: string): Promise // Hot reload - watchSkillFiles(): void - stopWatching(): void + watchSkillFiles(): Promise + stopWatching(): Promise } diff --git a/test/e2e/specs/30-workspace-watcher-events.smoke.spec.ts b/test/e2e/specs/30-workspace-watcher-events.smoke.spec.ts new file mode 100644 index 000000000..31419f127 --- /dev/null +++ b/test/e2e/specs/30-workspace-watcher-events.smoke.spec.ts @@ -0,0 +1,154 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test, expect } from '../fixtures/electronApp' +import { waitForAppReady } from '../helpers/wait' + +const isGitAvailable = (): boolean => { + try { + execFileSync('git', ['--version'], { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +test('workspace watcher emits file and git invalidations through the typed bridge @smoke', async ({ + app +}) => { + test.skip(!isGitAvailable(), 'git is required for workspace watcher smoke coverage') + + await waitForAppReady(app.page) + + const workspacePath = mkdtempSync(join(tmpdir(), 'deepchat-e2e-workspace-watch-')) + const nestedDir = join(workspacePath, 'src') + const filePath = join(nestedDir, 'watch-target.txt') + + try { + mkdirSync(nestedDir, { recursive: true }) + execFileSync('git', ['init'], { cwd: workspacePath, stdio: 'ignore' }) + + await app.page.evaluate( + async ({ workspacePath }) => { + const runtime = { + events: [] as Array<{ + workspacePath: string + kind: 'fs' | 'git' | 'full' + source: 'watcher' | 'fallback' | 'lifecycle' + }>, + statuses: [] as Array<{ + workspacePath: string + health: 'healthy' | 'degraded' | 'failed' + mode: 'native' | 'snapshot-polling' | 'git-metadata-polling' + }>, + cleanup: [] as Array<() => void> + } + + ;(window as any).__workspaceWatcherE2E = runtime + + runtime.cleanup.push( + window.deepchat.on('workspace.invalidated', (payload) => { + if (payload.workspacePath === workspacePath) { + runtime.events.push(payload) + } + }) + ) + + runtime.cleanup.push( + window.deepchat.on('workspace.watch.status.changed', (payload) => { + if (payload.workspacePath === workspacePath) { + runtime.statuses.push(payload) + } + }) + ) + + await window.deepchat.invoke('workspace.register', { + mode: 'workspace', + workspacePath + }) + await window.deepchat.invoke('workspace.watch', { + workspacePath + }) + }, + { workspacePath } + ) + + await expect + .poll( + async () => + await app.page.evaluate(() => + ((window as any).__workspaceWatcherE2E?.statuses ?? []).some( + (status: { health: string; mode: string }) => + status.health === 'healthy' && status.mode === 'native' + ) + ), + { + timeout: 30_000, + intervals: [250, 500, 1_000] + } + ) + .toBe(true) + + writeFileSync(filePath, 'first\n', 'utf8') + + await expect + .poll( + async () => + await app.page.evaluate(() => + ((window as any).__workspaceWatcherE2E?.events ?? []).some( + (event: { kind: string }) => event.kind === 'fs' || event.kind === 'full' + ) + ), + { + timeout: 30_000, + intervals: [250, 500, 1_000] + } + ) + .toBe(true) + + execFileSync('git', ['add', 'src/watch-target.txt'], { cwd: workspacePath, stdio: 'ignore' }) + + await expect + .poll( + async () => + await app.page.evaluate(() => + ((window as any).__workspaceWatcherE2E?.events ?? []).some( + (event: { kind: string }) => event.kind === 'git' + ) + ), + { + timeout: 30_000, + intervals: [250, 500, 1_000] + } + ) + .toBe(true) + } finally { + await app.page + .evaluate( + async ({ workspacePath }) => { + const runtime = (window as any).__workspaceWatcherE2E + if (runtime?.cleanup) { + for (const cleanup of runtime.cleanup) { + cleanup() + } + } + + await window.deepchat + .invoke('workspace.unwatch', { workspacePath }) + .catch(() => undefined) + await window.deepchat + .invoke('workspace.unregister', { + mode: 'workspace', + workspacePath + }) + .catch(() => undefined) + + delete (window as any).__workspaceWatcherE2E + }, + { workspacePath } + ) + .catch(() => undefined) + rmSync(workspacePath, { recursive: true, force: true }) + } +}) diff --git a/test/main/lib/fileWatcher/eventCoalescer.test.ts b/test/main/lib/fileWatcher/eventCoalescer.test.ts new file mode 100644 index 000000000..a52cec3e3 --- /dev/null +++ b/test/main/lib/fileWatcher/eventCoalescer.test.ts @@ -0,0 +1,33 @@ +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { coalesceWatcherEvents } from '../../../../src/main/lib/fileWatcher/eventCoalescer' + +describe('coalesceWatcherEvents', () => { + it('drops create/delete pairs for the same path', () => { + expect( + coalesceWatcherEvents([ + { type: 'create', path: '/tmp/work/a.ts' }, + { type: 'delete', path: '/tmp/work/a.ts' } + ]) + ).toEqual([]) + }) + + it('turns delete/create pairs into an update', () => { + expect( + coalesceWatcherEvents([ + { type: 'delete', path: '/tmp/work/a.ts' }, + { type: 'create', path: '/tmp/work/a.ts' } + ]) + ).toEqual([{ type: 'update', path: '/tmp/work/a.ts' }]) + }) + + it('keeps parent deletes and drops duplicate child deletes', () => { + const root = path.join('/tmp', 'work') + expect( + coalesceWatcherEvents([ + { type: 'delete', path: path.join(root, 'dir', 'nested.ts') }, + { type: 'delete', path: path.join(root, 'dir') } + ]) + ).toEqual([{ type: 'delete', path: path.join(root, 'dir') }]) + }) +}) diff --git a/test/main/lib/fileWatcher/watcherPool.test.ts b/test/main/lib/fileWatcher/watcherPool.test.ts new file mode 100644 index 000000000..f99d7154e --- /dev/null +++ b/test/main/lib/fileWatcher/watcherPool.test.ts @@ -0,0 +1,152 @@ +import path from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { WatcherPool } from '../../../../src/main/lib/fileWatcher/watcherPool' +import type { + WatchBatchListener, + WatcherEventBatch, + WatchRequest, + WatchStatus, + WatchStatusListener +} from '../../../../src/main/lib/fileWatcher' + +class FakeWatcherHostClient { + readonly requests: WatchRequest[] = [] + readonly batchListeners = new Set() + readonly statusListeners = new Set() + readonly watch = vi.fn(async (request: WatchRequest) => { + this.requests.push(request) + }) + readonly unwatch = vi.fn(async (_watchId: string) => {}) + readonly shutdown = vi.fn(async () => {}) + + onBatch(listener: WatchBatchListener): () => void { + this.batchListeners.add(listener) + return () => this.batchListeners.delete(listener) + } + + onStatus(listener: WatchStatusListener): () => void { + this.statusListeners.add(listener) + return () => this.statusListeners.delete(listener) + } + + emitBatch(batch: WatcherEventBatch): void { + for (const listener of this.batchListeners) { + listener(batch) + } + } + + emitStatus(status: WatcherStatus): void { + for (const listener of this.statusListeners) { + listener(status) + } + } +} + +function createPoolWithClients() { + const content = new FakeWatcherHostClient() + const git = new FakeWatcherHostClient() + return { + pool: new WatcherPool({ + content: content as never, + git: git as never + }), + content, + git + } +} + +function createRequest(overrides: Partial = {}): WatchRequest { + return { + id: 'logical-request', + rootPath: '/tmp/work', + hostKind: 'content', + purpose: 'workspace-content', + recursive: true, + fallbackMode: 'snapshot-polling', + ...overrides + } +} + +describe('WatcherPool', () => { + it('deduplicates identical requests and keeps the backend alive until the last handle closes', async () => { + const { pool, content } = createPoolWithClients() + const firstListener = vi.fn() + const secondListener = vi.fn() + + const first = await pool.watch(createRequest(), firstListener) + const second = await pool.watch(createRequest(), secondListener) + + expect(content.watch).toHaveBeenCalledTimes(1) + + await first.close() + expect(content.unwatch).not.toHaveBeenCalled() + + await second.close() + expect(content.unwatch).toHaveBeenCalledTimes(1) + expect(content.unwatch).toHaveBeenCalledWith(content.requests[0].id) + }) + + it('filters batches by include paths before fan-out', async () => { + const { pool, git } = createPoolWithClients() + const listener = vi.fn() + const rootPath = process.platform === 'darwin' ? '/var/tmp/work' : '/tmp/work' + const includedPath = path.join(rootPath, '.git', 'index') + const eventPath = + process.platform === 'darwin' ? path.join('/private', includedPath) : includedPath + + await pool.watch( + createRequest({ + hostKind: 'git', + purpose: 'workspace-git', + rootPath, + includes: [includedPath], + fallbackMode: 'git-metadata-polling' + }), + listener + ) + + const request = git.requests[0] + git.emitBatch({ + watchId: request.id, + rootPath: request.rootPath, + purpose: request.purpose, + hostKind: request.hostKind, + mode: 'native', + events: [ + { type: 'update', path: eventPath }, + { type: 'update', path: path.join(rootPath, 'src', 'main.ts') } + ], + version: 1 + }) + + expect(listener).toHaveBeenCalledTimes(1) + expect(listener.mock.calls[0][0].events).toEqual([{ type: 'update', path: eventPath }]) + }) + + it('routes status events to listeners for the matching pooled watch', async () => { + const { pool, content } = createPoolWithClients() + const statusListener = vi.fn() + + await pool.watch(createRequest(), vi.fn(), statusListener) + const request = content.requests[0] + + content.emitStatus({ + watchId: request.id, + rootPath: request.rootPath, + purpose: request.purpose, + hostKind: request.hostKind, + health: 'degraded', + mode: 'snapshot-polling', + reason: 'fallback-started', + version: 2 + }) + + expect(statusListener).toHaveBeenCalledWith( + expect.objectContaining({ + health: 'degraded', + mode: 'snapshot-polling', + reason: 'fallback-started' + }) + ) + }) +}) diff --git a/test/main/presenter/skillPresenter/skillPresenter.test.ts b/test/main/presenter/skillPresenter/skillPresenter.test.ts index 407ca8a7f..c04e1818a 100644 --- a/test/main/presenter/skillPresenter/skillPresenter.test.ts +++ b/test/main/presenter/skillPresenter/skillPresenter.test.ts @@ -114,13 +114,6 @@ vi.mock('path', () => ({ } })) -vi.mock('chokidar', () => ({ - watch: vi.fn(() => ({ - on: vi.fn().mockReturnThis(), - close: vi.fn() - })) -})) - vi.mock('gray-matter', () => ({ default: vi.fn() })) @@ -155,11 +148,18 @@ vi.mock('../../../../src/main/presenter/skillPresenter/discoveryWorker', () => d import fs from 'fs' import path from 'path' import matter from 'gray-matter' -import { watch } from 'chokidar' import { unzipSync } from 'fflate' import { randomUUID } from 'node:crypto' import logger from '@shared/logger' import { SKILL_CONFIG, SkillPresenter } from '../../../../src/main/presenter/skillPresenter/index' +import type { + IFileWatcherService, + WatchBatchListener, + WatcherEvent, + WatchMode, + WatchRequest, + WatchStatusListener +} from '../../../../src/main/lib/fileWatcher' function createDirEntry(name: string) { return { @@ -223,17 +223,58 @@ function createSkillMetadata(name: string, dirName: string): SkillMetadata { } } -function getWatcherHandler(eventName: string) { - const watcherInstance = (watch as Mock).mock.results[(watch as Mock).mock.results.length - 1] - ?.value as { on: Mock } | undefined - return watcherInstance?.on.mock.calls.find((call: unknown[]) => call[0] === eventName)?.[1] as - | ((filePath: string) => Promise) - | undefined +type FakeWatcher = { + request: WatchRequest + close: ReturnType + emit(events: WatcherEvent[], mode?: WatchMode): Promise +} + +function createFakeWatcherService() { + const watchers: FakeWatcher[] = [] + const service: IFileWatcherService = { + watch: vi.fn(async (request, onBatch: WatchBatchListener, _onStatus?: WatchStatusListener) => { + const watcher: FakeWatcher = { + request, + close: vi.fn().mockResolvedValue(undefined), + async emit(events, mode = 'native') { + const listener = onBatch as unknown as (batch: { + watchId: string + rootPath: string + purpose: WatchRequest['purpose'] + hostKind: WatchRequest['hostKind'] + mode: WatchMode + events: WatcherEvent[] + version: number + }) => unknown + await listener({ + watchId: request.id, + rootPath: request.rootPath, + purpose: request.purpose, + hostKind: request.hostKind, + mode, + events, + version: Date.now() + }) + } + } + watchers.push(watcher) + return { + close: watcher.close + } + }), + destroy: vi.fn().mockResolvedValue(undefined) + } + + return { + service, + watchers + } } describe('SkillPresenter', () => { let skillPresenter: SkillPresenter let mockConfigPresenter: IConfigPresenter + let fakeWatcherService: ReturnType beforeEach(() => { vi.clearAllMocks() @@ -244,6 +285,7 @@ describe('SkillPresenter', () => { getSkillsPath: vi.fn().mockReturnValue(''), getSetting: vi.fn().mockReturnValue(undefined) } as unknown as IConfigPresenter + fakeWatcherService = createFakeWatcherService() // Setup default mocks ;(fs.existsSync as Mock).mockReturnValue(true) @@ -284,13 +326,17 @@ describe('SkillPresenter', () => { async (conversationId: string) => newSessionActiveSkillsStore.get(conversationId) ?? [] ) - skillPresenter = new SkillPresenter(mockConfigPresenter, skillSessionStatePort as any) + skillPresenter = new SkillPresenter( + mockConfigPresenter, + skillSessionStatePort as any, + fakeWatcherService.service + ) ;(skillPresenter as any).skillsDir = DEFAULT_SKILLS_DIR ;(skillPresenter as any).sidecarDir = `${DEFAULT_SKILLS_DIR}/.deepchat-meta` }) - afterEach(() => { - skillPresenter.destroy() + afterEach(async () => { + await skillPresenter.destroy() }) describe('constructor', () => { @@ -2053,17 +2099,17 @@ describe('SkillPresenter', () => { }) describe('watchSkillFiles', () => { - it('should start file watcher', () => { - skillPresenter.watchSkillFiles() + it('should start file watcher', async () => { + await skillPresenter.watchSkillFiles() - expect(watch).toHaveBeenCalled() + expect(fakeWatcherService.service.watch).toHaveBeenCalled() }) - it('should not start watcher twice', () => { - skillPresenter.watchSkillFiles() - skillPresenter.watchSkillFiles() + it('should not start watcher twice', async () => { + await skillPresenter.watchSkillFiles() + await skillPresenter.watchSkillFiles() - expect(watch).toHaveBeenCalledTimes(1) + expect(fakeWatcherService.service.watch).toHaveBeenCalledTimes(1) }) it('keeps the first cached entry when a changed skill renames to a duplicate name', async () => { @@ -2077,10 +2123,10 @@ describe('SkillPresenter', () => { .fn() .mockResolvedValue(createSkillMetadata('skill-b', 'skill-a')) - skillPresenter.watchSkillFiles() - const changeHandler = getWatcherHandler('change') + await skillPresenter.watchSkillFiles() + const watcher = fakeWatcherService.watchers.at(-1) - await changeHandler?.(originalMetadata.path) + await watcher?.emit([{ type: 'update', path: originalMetadata.path }]) expect(metadataCache.has('skill-a')).toBe(false) expect(metadataCache.get('skill-b')).toEqual(existingDuplicate) @@ -2103,10 +2149,10 @@ describe('SkillPresenter', () => { metadataCache.set(originalMetadata.name, originalMetadata) ;(skillPresenter as any).parseSkillMetadata = vi.fn().mockResolvedValue(renamedMetadata) - skillPresenter.watchSkillFiles() - const changeHandler = getWatcherHandler('change') + await skillPresenter.watchSkillFiles() + const watcher = fakeWatcherService.watchers.at(-1) - await changeHandler?.(originalMetadata.path) + await watcher?.emit([{ type: 'update', path: originalMetadata.path }]) expect(metadataCache.has('skill-a')).toBe(false) expect(metadataCache.get('skill-c')).toEqual(renamedMetadata) @@ -2129,10 +2175,10 @@ describe('SkillPresenter', () => { metadataCache.set(existingMetadata.name, existingMetadata) ;(skillPresenter as any).parseSkillMetadata = vi.fn().mockResolvedValue(duplicateMetadata) - skillPresenter.watchSkillFiles() - const addHandler = getWatcherHandler('add') + await skillPresenter.watchSkillFiles() + const watcher = fakeWatcherService.watchers.at(-1) - await addHandler?.(duplicateMetadata.path) + await watcher?.emit([{ type: 'create', path: duplicateMetadata.path }]) expect(metadataCache.get('skill-b')).toEqual(existingMetadata) expect(logger.warn).toHaveBeenCalledWith( @@ -2148,24 +2194,24 @@ describe('SkillPresenter', () => { }) describe('stopWatching', () => { - it('should stop the file watcher', () => { - skillPresenter.watchSkillFiles() - skillPresenter.stopWatching() + it('should stop the file watcher', async () => { + await skillPresenter.watchSkillFiles() + await skillPresenter.stopWatching() // Watcher should be null after stopping - skillPresenter.watchSkillFiles() - expect(watch).toHaveBeenCalledTimes(2) + await skillPresenter.watchSkillFiles() + expect(fakeWatcherService.service.watch).toHaveBeenCalledTimes(2) }) }) describe('destroy', () => { - it('should cleanup all resources', () => { - skillPresenter.watchSkillFiles() - skillPresenter.destroy() + it('should cleanup all resources', async () => { + await skillPresenter.watchSkillFiles() + await skillPresenter.destroy() // Should be able to start watcher again after destroy - skillPresenter.watchSkillFiles() - expect(watch).toHaveBeenCalledTimes(2) + await skillPresenter.watchSkillFiles() + expect(fakeWatcherService.service.watch).toHaveBeenCalledTimes(2) }) }) }) diff --git a/test/main/presenter/workspacePresenter.test.ts b/test/main/presenter/workspacePresenter.test.ts index d0c53aa08..6bcbc641a 100644 --- a/test/main/presenter/workspacePresenter.test.ts +++ b/test/main/presenter/workspacePresenter.test.ts @@ -5,45 +5,10 @@ import { pathToFileURL } from 'node:url' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { DEEPCHAT_EVENT_CHANNEL } from '../../../src/shared/contracts/channels' -const { chokidarState, sendToAllWindowsMock, execFileMock } = vi.hoisted(() => { - const watchers: Array<{ - paths: unknown - options: unknown - on: ReturnType - close: ReturnType - emit: (eventName: string, ...args: unknown[]) => Promise - }> = [] - - return { - chokidarState: { - watchers, - reset() { - watchers.length = 0 - }, - createWatcher(paths: unknown, options: unknown) { - const handlers = new Map unknown>>() - const watcher = { - paths, - options, - on: vi.fn((eventName: string, handler: (...args: unknown[]) => unknown) => { - handlers.set(eventName, [...(handlers.get(eventName) ?? []), handler]) - return watcher - }), - close: vi.fn().mockResolvedValue(undefined), - async emit(eventName: string, ...args: unknown[]) { - for (const handler of handlers.get(eventName) ?? []) { - await handler(...args) - } - } - } - watchers.push(watcher) - return watcher - } - }, - sendToAllWindowsMock: vi.fn(), - execFileMock: vi.fn() - } -}) +const { sendToAllWindowsMock, execFileMock } = vi.hoisted(() => ({ + sendToAllWindowsMock: vi.fn(), + execFileMock: vi.fn() +})) vi.mock('electron', () => ({ shell: { @@ -73,17 +38,21 @@ vi.mock('path', async () => { } }) -vi.mock('chokidar', () => ({ - FSWatcher: class {}, - watch: vi.fn((paths: unknown, options: unknown) => chokidarState.createWatcher(paths, options)) -})) - vi.mock('child_process', () => ({ execFile: execFileMock })) import { setDeepchatEventWindowPresenter } from '../../../src/main/routes/publishDeepchatEvent' import { WorkspacePresenter } from '../../../src/main/presenter/workspacePresenter' +import type { + IFileWatcherService, + WatchBatchListener, + WatcherEvent, + WatchMode, + WatchRequest, + WatcherStatus, + WatchStatusListener +} from '../../../src/main/lib/fileWatcher' import { createWorkspacePreviewFileUrl, createWorkspacePreviewUrl, @@ -104,6 +73,59 @@ function normalizeForAccess(value: string): string { } } +type FakeWatcher = { + request: WatchRequest + close: ReturnType + emit(events: WatcherEvent[], mode?: WatchMode): void + emitStatus(status: Partial): void +} + +function createFakeWatcherService() { + const watchers: FakeWatcher[] = [] + const service: IFileWatcherService = { + watch: vi.fn(async (request, onBatch: WatchBatchListener, onStatus?: WatchStatusListener) => { + const watcher: FakeWatcher = { + request, + close: vi.fn().mockResolvedValue(undefined), + emit(events, mode = 'native') { + onBatch({ + watchId: request.id, + rootPath: request.rootPath, + purpose: request.purpose, + hostKind: request.hostKind, + mode, + events, + version: Date.now() + }) + }, + emitStatus(status) { + onStatus?.({ + watchId: request.id, + rootPath: request.rootPath, + purpose: request.purpose, + hostKind: request.hostKind, + health: status.health ?? 'healthy', + mode: status.mode ?? 'native', + reason: status.reason ?? 'ready', + message: status.message, + version: status.version ?? Date.now() + }) + } + } + watchers.push(watcher) + return { + close: watcher.close + } + }), + destroy: vi.fn().mockResolvedValue(undefined) + } + + return { + service, + watchers + } +} + beforeEach(() => { resetWorkspacePreviewProtocolState() }) @@ -115,10 +137,11 @@ afterEach(() => { describe('WorkspacePresenter watchers', () => { let workspacePath: string let presenter: WorkspacePresenter + let fakeWatcherService: ReturnType beforeEach(() => { vi.useFakeTimers() - chokidarState.reset() + fakeWatcherService = createFakeWatcherService() sendToAllWindowsMock.mockReset() execFileMock.mockReset() setDeepchatEventWindowPresenter({ @@ -151,13 +174,16 @@ describe('WorkspacePresenter watchers', () => { } ) - presenter = new WorkspacePresenter({ - prepareFileCompletely: vi.fn() - } as any) + presenter = new WorkspacePresenter( + { + prepareFileCompletely: vi.fn() + } as any, + fakeWatcherService.service + ) }) afterEach(async () => { - presenter?.destroy() + await presenter?.destroy() setDeepchatEventWindowPresenter(null) await vi.runAllTimersAsync() vi.useRealTimers() @@ -170,9 +196,9 @@ describe('WorkspacePresenter watchers', () => { await presenter.watchWorkspace(workspacePath) await presenter.watchWorkspace(workspacePath) - expect(chokidarState.watchers).toHaveLength(2) + expect(fakeWatcherService.watchers).toHaveLength(2) - const [contentWatcher, gitWatcher] = chokidarState.watchers + const [contentWatcher, gitWatcher] = fakeWatcherService.watchers await presenter.unwatchWorkspace(workspacePath) expect(contentWatcher.close).not.toHaveBeenCalled() @@ -187,10 +213,12 @@ describe('WorkspacePresenter watchers', () => { await presenter.registerWorkspace(workspacePath) await presenter.watchWorkspace(workspacePath) - const [contentWatcher] = chokidarState.watchers + const [contentWatcher] = fakeWatcherService.watchers - await contentWatcher.emit('all', 'add', path.join(workspacePath, 'a.ts')) - await contentWatcher.emit('all', 'change', path.join(workspacePath, 'b.ts')) + contentWatcher.emit([ + { type: 'create', path: path.join(workspacePath, 'a.ts') }, + { type: 'update', path: path.join(workspacePath, 'b.ts') } + ]) expect(sendToAllWindowsMock).not.toHaveBeenCalled() @@ -219,8 +247,8 @@ describe('WorkspacePresenter watchers', () => { await presenter.registerWorkspace(workspacePath) await presenter.watchWorkspace(workspacePath) - const [, gitWatcher] = chokidarState.watchers - await gitWatcher.emit('all', 'change', path.join(workspacePath, '.git', 'index')) + const [, gitWatcher] = fakeWatcherService.watchers + gitWatcher.emit([{ type: 'update', path: path.join(workspacePath, '.git', 'index') }]) await vi.advanceTimersByTimeAsync(120) expect(sendToAllWindowsMock).toHaveBeenCalledTimes(1) @@ -235,14 +263,39 @@ describe('WorkspacePresenter watchers', () => { }) }) + it('emits watcher status updates for the active workspace', async () => { + await presenter.registerWorkspace(workspacePath) + await presenter.watchWorkspace(workspacePath) + + const [contentWatcher] = fakeWatcherService.watchers + contentWatcher.emitStatus({ + health: 'degraded', + mode: 'snapshot-polling', + reason: 'fallback-started', + message: 'native watcher unavailable', + version: 123 + }) + + expect(sendToAllWindowsMock).toHaveBeenCalledWith(DEEPCHAT_EVENT_CHANNEL, { + name: 'workspace.watch.status.changed', + payload: { + workspacePath, + health: 'degraded', + mode: 'snapshot-polling', + reason: 'fallback-started', + message: 'native watcher unavailable', + version: 123 + } + }) + }) + it('closes remaining watchers during destroy', async () => { await presenter.registerWorkspace(workspacePath) await presenter.watchWorkspace(workspacePath) - const [contentWatcher, gitWatcher] = chokidarState.watchers + const [contentWatcher, gitWatcher] = fakeWatcherService.watchers - presenter.destroy() - await Promise.resolve() + await presenter.destroy() expect(contentWatcher.close).toHaveBeenCalledTimes(1) expect(gitWatcher.close).toHaveBeenCalledTimes(1) diff --git a/test/main/routes/contracts.test.ts b/test/main/routes/contracts.test.ts index 1d625b309..3ffee3d2f 100644 --- a/test/main/routes/contracts.test.ts +++ b/test/main/routes/contracts.test.ts @@ -1533,7 +1533,8 @@ describe('main kernel contracts', () => { 'upgrade.status.changed', 'upgrade.willRestart', 'window.state.changed', - 'workspace.invalidated' + 'workspace.invalidated', + 'workspace.watch.status.changed' ]) ) expect(new Set(eventKeys).size).toBe(eventKeys.length) diff --git a/test/main/scripts/afterPack.test.ts b/test/main/scripts/afterPack.test.ts index 199da4911..1956c96ce 100644 --- a/test/main/scripts/afterPack.test.ts +++ b/test/main/scripts/afterPack.test.ts @@ -61,55 +61,77 @@ describe('afterPack', () => { }) it.each([ - ['arm64', 3, 'fff-bin-darwin-arm64'], - ['x64', 1, 'fff-bin-darwin-x64'] - ])('copies FFF native packages into unpacked mac %s app node_modules', async (_, arch, packageDir) => { - const afterPack = await loadAfterPack() - const projectDir = path.join(tmpDir, 'project') - const sourceDir = path.join( - projectDir, - 'node_modules', - '.pnpm', - 'node_modules', - '@ff-labs', - packageDir - ) - const nodeModulesDir = path.join( - tmpDir, - 'DeepChat.app', - 'Contents', - 'Resources', - 'app.asar.unpacked', - 'node_modules' - ) + ['arm64', 3, 'fff-bin-darwin-arm64', 'watcher-darwin-arm64'], + ['x64', 1, 'fff-bin-darwin-x64', 'watcher-darwin-x64'] + ])( + 'copies native packages into unpacked mac %s app node_modules', + async (_, arch, fffPackageDir, parcelPackageDir) => { + const afterPack = await loadAfterPack() + const projectDir = path.join(tmpDir, 'project') + const fffSourceDir = path.join( + projectDir, + 'node_modules', + '.pnpm', + 'node_modules', + '@ff-labs', + fffPackageDir + ) + const parcelSourceDir = path.join( + projectDir, + 'node_modules', + '.pnpm', + 'node_modules', + '@parcel', + parcelPackageDir + ) + const nodeModulesDir = path.join( + tmpDir, + 'DeepChat.app', + 'Contents', + 'Resources', + 'app.asar.unpacked', + 'node_modules' + ) - await writeFile(path.join(tmpDir, 'DeepChat'), 'launcher') - await mkdir(sourceDir, { recursive: true }) - await mkdir(path.join(nodeModulesDir, '@ff-labs', 'fff-node'), { recursive: true }) - await writeFile(path.join(sourceDir, 'package.json'), `{"name":"@ff-labs/${packageDir}"}`) - await writeFile(path.join(sourceDir, 'libfff_c.dylib'), 'native') - await writeFile(path.join(nodeModulesDir, '@ff-labs', 'fff-node', 'package.json'), '{}') + await writeFile(path.join(tmpDir, 'DeepChat'), 'launcher') + await mkdir(fffSourceDir, { recursive: true }) + await mkdir(parcelSourceDir, { recursive: true }) + await mkdir(path.join(nodeModulesDir, '@ff-labs', 'fff-node'), { recursive: true }) + await mkdir(path.join(nodeModulesDir, '@parcel', 'watcher'), { recursive: true }) + await writeFile( + path.join(fffSourceDir, 'package.json'), + `{"name":"@ff-labs/${fffPackageDir}"}` + ) + await writeFile( + path.join(parcelSourceDir, 'package.json'), + `{"name":"@parcel/${parcelPackageDir}"}` + ) + await writeFile(path.join(fffSourceDir, 'libfff_c.dylib'), 'native') + await writeFile(path.join(parcelSourceDir, 'watcher.node'), 'parcel-native') + await writeFile(path.join(nodeModulesDir, '@ff-labs', 'fff-node', 'package.json'), '{}') + await writeFile(path.join(nodeModulesDir, '@parcel', 'watcher', 'package.json'), '{}') - await afterPack({ - targets: [], - appOutDir: tmpDir, - electronPlatformName: 'darwin', - arch, - packager: { - projectDir, - appInfo: { - productFilename: 'DeepChat' + await afterPack({ + targets: [], + appOutDir: tmpDir, + electronPlatformName: 'darwin', + arch, + packager: { + projectDir, + appInfo: { + productFilename: 'DeepChat' + } } - } - }) + }) - await expect( - readFile( - path.join(nodeModulesDir, '@ff-labs', packageDir, 'libfff_c.dylib'), - 'utf8' - ) - ).resolves.toBe('native') - }) + await expect( + readFile(path.join(nodeModulesDir, '@ff-labs', fffPackageDir, 'libfff_c.dylib'), 'utf8') + ).resolves.toBe('native') + await expect( + readFile(path.join(nodeModulesDir, '@parcel', parcelPackageDir, 'watcher.node'), 'utf8') + ).resolves.toBe('parcel-native') + } + ) it('fails fast when FFF node output is missing for supported packages', async () => { const afterPack = await loadAfterPack() diff --git a/test/renderer/components/WorkspacePanel.test.ts b/test/renderer/components/WorkspacePanel.test.ts index 55f438c15..5b378cf10 100644 --- a/test/renderer/components/WorkspacePanel.test.ts +++ b/test/renderer/components/WorkspacePanel.test.ts @@ -25,6 +25,7 @@ const { isDirectoryMock, getPathForFileMock, workspaceInvalidationState, + workspaceWatchStatusState, setSessionProjectDirMock } = vi.hoisted(() => ({ showArtifactMock: vi.fn(), @@ -79,6 +80,50 @@ const { } } }, + workspaceWatchStatusState: { + listeners: [] as Array< + (payload: { + workspacePath: string + health: 'healthy' | 'degraded' | 'failed' + mode: 'native' | 'snapshot-polling' | 'git-metadata-polling' + reason: + | 'ready' + | 'native-error' + | 'utility-exit' + | 'fallback-started' + | 'overflow' + | 'root-deleted' + | 'shutdown' + message?: string + version: number + }) => void + >, + reset() { + this.listeners = [] + }, + subscribe( + listener: (payload: { + workspacePath: string + health: 'healthy' | 'degraded' | 'failed' + mode: 'native' | 'snapshot-polling' | 'git-metadata-polling' + reason: + | 'ready' + | 'native-error' + | 'utility-exit' + | 'fallback-started' + | 'overflow' + | 'root-deleted' + | 'shutdown' + message?: string + version: number + }) => void + ) { + this.listeners.push(listener) + return () => { + this.listeners = this.listeners.filter((currentListener) => currentListener !== listener) + } + } + }, setSessionProjectDirMock: vi.fn().mockResolvedValue(undefined) })) @@ -153,6 +198,30 @@ const emitWorkspaceInvalidated = async (payload: { await flushPromises() } +const emitWorkspaceWatchStatusChanged = async (payload: { + workspacePath: string + health: 'healthy' | 'degraded' | 'failed' + mode: 'native' | 'snapshot-polling' | 'git-metadata-polling' + reason: + | 'ready' + | 'native-error' + | 'utility-exit' + | 'fallback-started' + | 'overflow' + | 'root-deleted' + | 'shutdown' + message?: string + version?: number +}) => { + for (const listener of workspaceWatchStatusState.listeners) { + listener({ + version: 1, + ...payload + }) + } + await flushPromises() +} + vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key @@ -192,6 +261,9 @@ vi.mock('@api/WorkspaceClient', () => ({ revealFileInFolder: revealFileInFolderMock, onInvalidated: vi.fn((listener: (payload: unknown) => void) => workspaceInvalidationState.subscribe(listener as any) + ), + onWatchStatusChanged: vi.fn((listener: (payload: unknown) => void) => + workspaceWatchStatusState.subscribe(listener as any) ) })) })) @@ -255,6 +327,7 @@ describe('WorkspacePanel', () => { vi.useFakeTimers() workspaceInvalidationState.reset() + workspaceWatchStatusState.reset() sidepanelStore.open = true sessionState.selectedArtifactContext = null sessionState.selectedFilePath = null @@ -395,6 +468,39 @@ describe('WorkspacePanel', () => { expect(unwatchWorkspaceMock).toHaveBeenCalledWith('C:/repo') }) + it('shows watcher fallback status and hides it when the watcher recovers', async () => { + const wrapper = mount(WorkspacePanel, { + props: { + sessionId: 's1', + workspacePath: 'C:/repo' + } + }) + + await flushPromises() + + await emitWorkspaceWatchStatusChanged({ + workspacePath: 'C:/repo', + health: 'degraded', + mode: 'snapshot-polling', + reason: 'fallback-started' + }) + + expect(wrapper.find('[data-testid="workspace-watch-status"]').text()).toContain( + 'chat.workspace.files.watchStatus.degraded' + ) + + await emitWorkspaceWatchStatusChanged({ + workspacePath: 'C:/repo', + health: 'healthy', + mode: 'native', + reason: 'ready' + }) + + expect(wrapper.find('[data-testid="workspace-watch-status"]').exists()).toBe(false) + + wrapper.unmount() + }) + it('keeps expanded directories expanded after a full invalidation refresh', async () => { readDirectoryMock .mockResolvedValueOnce([