From af6209cc717cdea3be92dcbeccacde4d211708a2 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 15 Jun 2026 23:42:02 +0800 Subject: [PATCH 1/6] 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([ From 2b9820a2f4b467a5c587b09df571a2f8968a25c4 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Tue, 16 Jun 2026 08:54:12 +0800 Subject: [PATCH 2/6] fix(workspace): address watcher review --- docs/issues/parcel-watcher-issue-1764/plan.md | 8 ++-- docs/issues/parcel-watcher-issue-1764/spec.md | 2 +- .../issues/parcel-watcher-issue-1764/tasks.md | 2 +- src/main/lib/fileWatcher/eventCoalescer.ts | 4 +- src/main/lib/fileWatcher/watcherHost.ts | 6 ++- src/main/lib/fileWatcher/watcherHostClient.ts | 19 +++++++-- src/main/lib/fileWatcher/watcherPool.ts | 16 +++++++- src/main/lib/fileWatcher/watcherService.ts | 7 +++- src/main/presenter/skillPresenter/index.ts | 7 +--- .../presenter/workspacePresenter/index.ts | 40 +++++++++++++------ .../sidepanel/composables/useWorkspaceSync.ts | 18 ++++----- src/renderer/src/i18n/da-DK/chat.json | 4 +- src/renderer/src/i18n/de-DE/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 | 4 +- 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-HK/chat.json | 4 +- src/renderer/src/i18n/zh-TW/chat.json | 4 +- src/shared/types/presenters/workspace.d.ts | 2 +- test/main/lib/fileWatcher/watcherPool.test.ts | 23 +++++++++++ .../skillPresenter/skillPresenter.test.ts | 18 +++++++++ .../main/presenter/workspacePresenter.test.ts | 12 ++++++ .../components/WorkspacePanel.test.ts | 26 ++++++++++++ 34 files changed, 201 insertions(+), 81 deletions(-) diff --git a/docs/issues/parcel-watcher-issue-1764/plan.md b/docs/issues/parcel-watcher-issue-1764/plan.md index 559439e6f..037d8c900 100644 --- a/docs/issues/parcel-watcher-issue-1764/plan.md +++ b/docs/issues/parcel-watcher-issue-1764/plan.md @@ -89,7 +89,7 @@ Use stable request and event types inside `src/main/lib/fileWatcher/watcherTypes ```typescript export type WatcherHostKind = 'content' | 'git' export type WatcherEventType = 'create' | 'update' | 'delete' | 'overflow' | 'root-deleted' -export type WatcherMode = 'native' | 'snapshot-polling' | 'lifecycle' +export type WatcherMode = 'native' | 'snapshot-polling' | 'git-metadata-polling' export type WatcherHealth = 'healthy' | 'degraded' | 'failed' export interface WatchRequest { @@ -101,7 +101,7 @@ export interface WatchRequest { excludes: string[] owner: 'workspace' | 'skill' purpose: 'workspace-content' | 'workspace-git' | 'skill-hot-reload' - fallbackPolicy: 'snapshot-polling' | 'lifecycle' + fallbackMode?: 'snapshot-polling' | 'git-metadata-polling' } export interface WatchEventBatch { @@ -213,7 +213,7 @@ Fallback modes: 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 +- Lifecycle fallback: emit a full fallback invalidation when the workspace panel activates or the workspace path changes. Degraded mode emits a typed status event: @@ -221,7 +221,7 @@ Degraded mode emits a typed status event: ```text workspace.watch.status.changed workspacePath - mode: native | snapshot-polling | lifecycle + mode: native | snapshot-polling | git-metadata-polling health: healthy | degraded | failed reason ``` diff --git a/docs/issues/parcel-watcher-issue-1764/spec.md b/docs/issues/parcel-watcher-issue-1764/spec.md index 0353622bb..73d15ca02 100644 --- a/docs/issues/parcel-watcher-issue-1764/spec.md +++ b/docs/issues/parcel-watcher-issue-1764/spec.md @@ -135,7 +135,7 @@ Use the VS Code watcher model as the design reference: ## Review Decisions -- Recommended dependency version: `@parcel/watcher@^2.5.6`, currently the latest npm release. +- Recommended dependency version: `@parcel/watcher@^2.5.6`. - 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 diff --git a/docs/issues/parcel-watcher-issue-1764/tasks.md b/docs/issues/parcel-watcher-issue-1764/tasks.md index ad4e4d637..a972391a5 100644 --- a/docs/issues/parcel-watcher-issue-1764/tasks.md +++ b/docs/issues/parcel-watcher-issue-1764/tasks.md @@ -17,7 +17,7 @@ - `pnpm run format` - `pnpm run i18n` - `pnpm run lint` -- `pnpm run typecheck` +- `pnpm run typecheck:node` - `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/src/main/lib/fileWatcher/eventCoalescer.ts b/src/main/lib/fileWatcher/eventCoalescer.ts index e306dba6c..90ed2be19 100644 --- a/src/main/lib/fileWatcher/eventCoalescer.ts +++ b/src/main/lib/fileWatcher/eventCoalescer.ts @@ -56,14 +56,14 @@ export function coalesceWatcherEvents(events: WatcherEvent[]): WatcherEvent[] { const mergedEvents = Array.from(byPath.values()) const deletedParents = mergedEvents .filter((event) => event.type === 'delete') - .map((event) => path.normalize(event.path)) + .map((event) => normalizeEventKey(event.path)) return mergedEvents.filter((event) => { if (event.type !== 'delete') { return true } - const normalized = path.normalize(event.path) + const normalized = normalizeEventKey(event.path) return !deletedParents.some( (deletedParent) => deletedParent !== normalized && isDescendantOf(normalized, deletedParent) ) diff --git a/src/main/lib/fileWatcher/watcherHost.ts b/src/main/lib/fileWatcher/watcherHost.ts index f3a70ef19..5798a3cb7 100644 --- a/src/main/lib/fileWatcher/watcherHost.ts +++ b/src/main/lib/fileWatcher/watcherHost.ts @@ -175,6 +175,10 @@ export class FileWatcherHost { mode: activeWatch.mode, reason: 'root-deleted' }) + if (activeWatch.pollTimer) { + clearInterval(activeWatch.pollTimer) + activeWatch.pollTimer = null + } return } @@ -212,10 +216,10 @@ export class FileWatcherHost { } } - void poll() activeWatch.pollTimer = setInterval(() => { void poll() }, SNAPSHOT_POLL_INTERVAL_MS) + void poll() } private enqueueEvents(activeWatch: ActiveWatch, events: WatcherEvent[]): void { diff --git a/src/main/lib/fileWatcher/watcherHostClient.ts b/src/main/lib/fileWatcher/watcherHostClient.ts index 57639ba2d..1697bddd0 100644 --- a/src/main/lib/fileWatcher/watcherHostClient.ts +++ b/src/main/lib/fileWatcher/watcherHostClient.ts @@ -20,6 +20,7 @@ type PendingRequest = { const MAX_RESTART_ATTEMPTS = 3 const RESTART_DELAY_MS = 800 +const RPC_TIMEOUT_MS = 15000 export class WatcherHostClient { private host: UtilityProcess | null = null @@ -93,9 +94,20 @@ export class WatcherHostClient { const id = `watcher_rpc_${this.hostKind}_${++this.requestId}` return await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pendingRequests.delete(id) + reject(new Error(`File watcher RPC timed out: ${method} (${this.hostKind})`)) + }, RPC_TIMEOUT_MS) + this.pendingRequests.set(id, { - resolve: (value) => resolve(value as T), - reject + resolve: (value) => { + clearTimeout(timeout) + resolve(value as T) + }, + reject: (error) => { + clearTimeout(timeout) + reject(error) + } }) const payload: FileWatcherRpcRequest = { @@ -108,8 +120,9 @@ export class WatcherHostClient { try { host.postMessage(payload) } catch (error) { + const pending = this.pendingRequests.get(id) this.pendingRequests.delete(id) - reject(error instanceof Error ? error : new Error(String(error))) + pending?.reject(error instanceof Error ? error : new Error(String(error))) } }) } diff --git a/src/main/lib/fileWatcher/watcherPool.ts b/src/main/lib/fileWatcher/watcherPool.ts index f6d61e73c..8f1a3cf12 100644 --- a/src/main/lib/fileWatcher/watcherPool.ts +++ b/src/main/lib/fileWatcher/watcherPool.ts @@ -126,7 +126,21 @@ export class WatcherPool { entry.statusListeners.add(onStatus) } - await entry.ready + try { + await entry.ready + } catch (error) { + entry.listeners.delete(onBatch) + if (onStatus) { + entry.statusListeners.delete(onStatus) + } + if (this.entriesByKey.get(entry.key) === entry) { + this.entriesByKey.delete(entry.key) + } + if (this.entriesByWatchId.get(entry.request.id) === entry) { + this.entriesByWatchId.delete(entry.request.id) + } + throw error + } return { close: async () => { diff --git a/src/main/lib/fileWatcher/watcherService.ts b/src/main/lib/fileWatcher/watcherService.ts index adb074739..b4cc94fc6 100644 --- a/src/main/lib/fileWatcher/watcherService.ts +++ b/src/main/lib/fileWatcher/watcherService.ts @@ -31,8 +31,11 @@ export function getFileWatcherService(): FileWatcherService { return sharedWatcherService } -export function resetFileWatcherServiceForTests(): void { - sharedWatcherService = null +export async function resetFileWatcherServiceForTests(): Promise { + if (sharedWatcherService) { + await sharedWatcherService.destroy() + sharedWatcherService = null + } } export function createWatcherRequestId( diff --git a/src/main/presenter/skillPresenter/index.ts b/src/main/presenter/skillPresenter/index.ts index ea98ae142..f6f0da764 100644 --- a/src/main/presenter/skillPresenter/index.ts +++ b/src/main/presenter/skillPresenter/index.ts @@ -1952,12 +1952,7 @@ export class SkillPresenter implements ISkillPresenter { 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() - }) + await this.discoverSkills() return } diff --git a/src/main/presenter/workspacePresenter/index.ts b/src/main/presenter/workspacePresenter/index.ts index c4860c87f..02c25b326 100644 --- a/src/main/presenter/workspacePresenter/index.ts +++ b/src/main/presenter/workspacePresenter/index.ts @@ -166,13 +166,19 @@ 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 + try { + 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) + } catch (error) { + this.watchRuntimes.delete(normalized) + await this.disposeRuntime(runtime) + throw error } - await this.refreshGitWatcher(runtime) } async unwatchWorkspace(workspacePath: string): Promise { @@ -229,7 +235,12 @@ export class WorkspacePresenter implements IWorkspacePresenter { for (const event of batch.events) { if (event.type === 'overflow' || event.type === 'root-deleted') { - void this.refreshGitWatcher(runtime) + void this.refreshGitWatcher(runtime).catch((error) => { + console.warn('[Workspace] Failed to refresh git watcher', { + workspacePath: runtime.workspacePath, + error + }) + }) this.scheduleInvalidation(runtime, 'full', source) return } @@ -239,7 +250,12 @@ export class WorkspacePresenter implements IWorkspacePresenter { } if (this.isGitDirectoryEvent(event.path)) { - void this.refreshGitWatcher(runtime) + void this.refreshGitWatcher(runtime).catch((error) => { + console.warn('[Workspace] Failed to refresh git watcher', { + workspacePath: runtime.workspacePath, + error + }) + }) this.scheduleInvalidation(runtime, 'full', source) return } @@ -315,7 +331,8 @@ export class WorkspacePresenter implements IWorkspacePresenter { const payload: WorkspaceInvalidationEvent = { workspacePath: runtime.workspacePath, kind: runtime.pendingKind ?? kind, - source: runtime.pendingSource ?? source + source: runtime.pendingSource ?? source, + version: Date.now() } runtime.pendingKind = null runtime.pendingSource = null @@ -324,10 +341,7 @@ export class WorkspacePresenter implements IWorkspacePresenter { } private emitInvalidation(payload: WorkspaceInvalidationEvent): void { - publishDeepchatEvent('workspace.invalidated', { - ...payload, - version: Date.now() - }) + publishDeepchatEvent('workspace.invalidated', payload) } private emitWatchStatus(workspacePath: string, status: WatcherStatus): void { diff --git a/src/renderer/src/components/sidepanel/composables/useWorkspaceSync.ts b/src/renderer/src/components/sidepanel/composables/useWorkspaceSync.ts index 970a583d3..ebd3c5f5c 100644 --- a/src/renderer/src/components/sidepanel/composables/useWorkspaceSync.ts +++ b/src/renderer/src/components/sidepanel/composables/useWorkspaceSync.ts @@ -1,4 +1,4 @@ -import { computed, onBeforeUnmount, onMounted, ref, watch, type ComputedRef, type Ref } from 'vue' +import { computed, onBeforeUnmount, ref, watch, type ComputedRef, type Ref } from 'vue' import { createWorkspaceClient } from '@api/WorkspaceClient' import type { WorkspaceFileNode, @@ -394,6 +394,13 @@ export function useWorkspaceSync(options: UseWorkspaceSyncOptions) { node.expanded = true } + stopWorkspaceInvalidatedListener = options.workspaceClient.onInvalidated( + handleWorkspaceInvalidated + ) + stopWorkspaceWatchStatusListener = options.workspaceClient.onWatchStatusChanged( + handleWorkspaceWatchStatusChanged + ) + watch( [options.workspacePath, options.active] as const, ([workspacePath, active]) => { @@ -418,15 +425,6 @@ export function useWorkspaceSync(options: UseWorkspaceSyncOptions) { { immediate: true } ) - onMounted(() => { - stopWorkspaceInvalidatedListener = options.workspaceClient.onInvalidated( - handleWorkspaceInvalidated - ) - stopWorkspaceWatchStatusListener = options.workspaceClient.onWatchStatusChanged( - handleWorkspaceWatchStatusChanged - ) - }) - onBeforeUnmount(() => { if (refreshTimer) { clearTimeout(refreshTimer) diff --git a/src/renderer/src/i18n/da-DK/chat.json b/src/renderer/src/i18n/da-DK/chat.json index 6cd97daa8..b3c1aa82e 100644 --- a/src/renderer/src/i18n/da-DK/chat.json +++ b/src/renderer/src/i18n/da-DK/chat.json @@ -212,8 +212,8 @@ "button": "Vælg mappe" }, "watchStatus": { - "degraded": "Watching in fallback mode. Changes may refresh slower.", - "failed": "File watching is unavailable. Refresh or reselect the workspace." + "degraded": "Overvågning kører i fallback-tilstand. Ændringer kan blive opdateret langsommere.", + "failed": "Filovervågning er ikke tilgængelig. Opdater eller vælg arbejdsområdet igen." } }, "git": { diff --git a/src/renderer/src/i18n/de-DE/chat.json b/src/renderer/src/i18n/de-DE/chat.json index affaff380..657eb7169 100644 --- a/src/renderer/src/i18n/de-DE/chat.json +++ b/src/renderer/src/i18n/de-DE/chat.json @@ -262,8 +262,8 @@ "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." + "degraded": "Überwachung läuft im Fallback-Modus. Änderungen werden möglicherweise langsamer aktualisiert.", + "failed": "Dateiüberwachung ist nicht verfügbar. Aktualisiere oder wähle den Arbeitsbereich erneut aus." } }, "git": { diff --git a/src/renderer/src/i18n/es-ES/chat.json b/src/renderer/src/i18n/es-ES/chat.json index f7e90694f..2683e83ca 100644 --- a/src/renderer/src/i18n/es-ES/chat.json +++ b/src/renderer/src/i18n/es-ES/chat.json @@ -262,8 +262,8 @@ "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." + "degraded": "La supervisión está en modo de reserva. Los cambios pueden actualizarse más lentamente.", + "failed": "La supervisión de archivos no está disponible. Actualiza o vuelve a seleccionar el espacio de trabajo." } }, "git": { diff --git a/src/renderer/src/i18n/fa-IR/chat.json b/src/renderer/src/i18n/fa-IR/chat.json index 2d7c51eea..3e4c19bfa 100644 --- a/src/renderer/src/i18n/fa-IR/chat.json +++ b/src/renderer/src/i18n/fa-IR/chat.json @@ -212,8 +212,8 @@ "button": "انتخاب پوشه" }, "watchStatus": { - "degraded": "Watching in fallback mode. Changes may refresh slower.", - "failed": "File watching is unavailable. Refresh or reselect the workspace." + "degraded": "پایش در حالت جایگزین اجرا می‌شود. تغییرات ممکن است کندتر تازه‌سازی شوند.", + "failed": "پایش فایل در دسترس نیست. تازه‌سازی کنید یا فضای کاری را دوباره انتخاب کنید." } }, "git": { diff --git a/src/renderer/src/i18n/fr-FR/chat.json b/src/renderer/src/i18n/fr-FR/chat.json index 7aec16ebb..84fe5ffef 100644 --- a/src/renderer/src/i18n/fr-FR/chat.json +++ b/src/renderer/src/i18n/fr-FR/chat.json @@ -212,8 +212,8 @@ "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." + "degraded": "La surveillance fonctionne en mode de secours. Les changements peuvent s’actualiser plus lentement.", + "failed": "La surveillance des fichiers est indisponible. Actualisez ou resélectionnez l’espace de travail." } }, "git": { diff --git a/src/renderer/src/i18n/he-IL/chat.json b/src/renderer/src/i18n/he-IL/chat.json index 6c27f3979..ddddd158e 100644 --- a/src/renderer/src/i18n/he-IL/chat.json +++ b/src/renderer/src/i18n/he-IL/chat.json @@ -212,8 +212,8 @@ "button": "בחר תיקייה" }, "watchStatus": { - "degraded": "Watching in fallback mode. Changes may refresh slower.", - "failed": "File watching is unavailable. Refresh or reselect the workspace." + "degraded": "המעקב פועל במצב גיבוי. ייתכן שהשינויים יתרעננו לאט יותר.", + "failed": "מעקב אחר קבצים אינו זמין. רענן או בחר מחדש את סביבת העבודה." } }, "git": { diff --git a/src/renderer/src/i18n/id-ID/chat.json b/src/renderer/src/i18n/id-ID/chat.json index 4e1cf1412..3a0108e0a 100644 --- a/src/renderer/src/i18n/id-ID/chat.json +++ b/src/renderer/src/i18n/id-ID/chat.json @@ -262,8 +262,8 @@ "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." + "degraded": "Pemantauan berjalan dalam mode cadangan. Perubahan mungkin dimuat ulang lebih lambat.", + "failed": "Pemantauan file tidak tersedia. Segarkan atau pilih ulang ruang kerja." } }, "git": { diff --git a/src/renderer/src/i18n/it-IT/chat.json b/src/renderer/src/i18n/it-IT/chat.json index f9c05bdc0..1a8486cb9 100644 --- a/src/renderer/src/i18n/it-IT/chat.json +++ b/src/renderer/src/i18n/it-IT/chat.json @@ -262,8 +262,8 @@ "insertPath": "Inserisci nell'input" }, "watchStatus": { - "degraded": "Watching in fallback mode. Changes may refresh slower.", - "failed": "File watching is unavailable. Refresh or reselect the workspace." + "degraded": "Il monitoraggio è in modalità di fallback. Le modifiche potrebbero aggiornarsi più lentamente.", + "failed": "Il monitoraggio dei file non è disponibile. Aggiorna o seleziona di nuovo lo spazio di lavoro." } }, "git": { diff --git a/src/renderer/src/i18n/ja-JP/chat.json b/src/renderer/src/i18n/ja-JP/chat.json index 6c56086b1..cda230a3a 100644 --- a/src/renderer/src/i18n/ja-JP/chat.json +++ b/src/renderer/src/i18n/ja-JP/chat.json @@ -212,8 +212,8 @@ }, "section": "書類", "watchStatus": { - "degraded": "Watching in fallback mode. Changes may refresh slower.", - "failed": "File watching is unavailable. Refresh or reselect the workspace." + "degraded": "フォールバックモードで監視しています。変更の反映が遅くなる場合があります。", + "failed": "ファイル監視を利用できません。更新するか、ワークスペースを選択し直してください。" } }, "git": { diff --git a/src/renderer/src/i18n/ko-KR/chat.json b/src/renderer/src/i18n/ko-KR/chat.json index dfc3fa8bd..f3e8685bf 100644 --- a/src/renderer/src/i18n/ko-KR/chat.json +++ b/src/renderer/src/i18n/ko-KR/chat.json @@ -212,8 +212,8 @@ "button": "폴더 선택" }, "watchStatus": { - "degraded": "Watching in fallback mode. Changes may refresh slower.", - "failed": "File watching is unavailable. Refresh or reselect the workspace." + "degraded": "대체 모드로 감시 중입니다. 변경 사항 반영이 더 느릴 수 있습니다.", + "failed": "파일 감시를 사용할 수 없습니다. 새로 고치거나 작업 공간을 다시 선택하세요." } }, "git": { diff --git a/src/renderer/src/i18n/ms-MY/chat.json b/src/renderer/src/i18n/ms-MY/chat.json index 33ed36a28..29f79caa3 100644 --- a/src/renderer/src/i18n/ms-MY/chat.json +++ b/src/renderer/src/i18n/ms-MY/chat.json @@ -262,8 +262,8 @@ "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." + "degraded": "Pemantauan berjalan dalam mod sandaran. Perubahan mungkin dimuat semula dengan lebih perlahan.", + "failed": "Pemantauan fail tidak tersedia. Muat semula atau pilih semula ruang kerja." } }, "git": { diff --git a/src/renderer/src/i18n/pl-PL/chat.json b/src/renderer/src/i18n/pl-PL/chat.json index 82bde4a2d..91b075bc0 100644 --- a/src/renderer/src/i18n/pl-PL/chat.json +++ b/src/renderer/src/i18n/pl-PL/chat.json @@ -262,8 +262,8 @@ "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." + "degraded": "Obserwowanie działa w trybie awaryjnym. Zmiany mogą odświeżać się wolniej.", + "failed": "Obserwowanie plików jest niedostępne. Odśwież lub wybierz obszar roboczy ponownie." } }, "git": { diff --git a/src/renderer/src/i18n/pt-BR/chat.json b/src/renderer/src/i18n/pt-BR/chat.json index c83cc3bdc..996b4780a 100644 --- a/src/renderer/src/i18n/pt-BR/chat.json +++ b/src/renderer/src/i18n/pt-BR/chat.json @@ -212,8 +212,8 @@ "button": "Selecionar pasta" }, "watchStatus": { - "degraded": "Watching in fallback mode. Changes may refresh slower.", - "failed": "File watching is unavailable. Refresh or reselect the workspace." + "degraded": "O monitoramento está em modo de fallback. As alterações podem ser atualizadas mais lentamente.", + "failed": "O monitoramento de arquivos está indisponível. Atualize ou selecione novamente o espaço de trabalho." } }, "git": { diff --git a/src/renderer/src/i18n/ru-RU/chat.json b/src/renderer/src/i18n/ru-RU/chat.json index 6e47ea9e2..0f9885309 100644 --- a/src/renderer/src/i18n/ru-RU/chat.json +++ b/src/renderer/src/i18n/ru-RU/chat.json @@ -212,8 +212,8 @@ "button": "Выбрать папку" }, "watchStatus": { - "degraded": "Watching in fallback mode. Changes may refresh slower.", - "failed": "File watching is unavailable. Refresh or reselect the workspace." + "degraded": "Наблюдение работает в резервном режиме. Изменения могут обновляться медленнее.", + "failed": "Наблюдение за файлами недоступно. Обновите или выберите рабочую область заново." } }, "git": { diff --git a/src/renderer/src/i18n/tr-TR/chat.json b/src/renderer/src/i18n/tr-TR/chat.json index a2a76c76d..87bf9439a 100644 --- a/src/renderer/src/i18n/tr-TR/chat.json +++ b/src/renderer/src/i18n/tr-TR/chat.json @@ -262,8 +262,8 @@ "insertPath": "Girişe ekle" }, "watchStatus": { - "degraded": "Watching in fallback mode. Changes may refresh slower.", - "failed": "File watching is unavailable. Refresh or reselect the workspace." + "degraded": "İzleme yedek modda çalışıyor. Değişiklikler daha yavaş yenilenebilir.", + "failed": "Dosya izleme kullanılamıyor. Yenileyin veya çalışma alanını yeniden seçin." } }, "git": { diff --git a/src/renderer/src/i18n/vi-VN/chat.json b/src/renderer/src/i18n/vi-VN/chat.json index 7265573d7..1bb2f49a0 100644 --- a/src/renderer/src/i18n/vi-VN/chat.json +++ b/src/renderer/src/i18n/vi-VN/chat.json @@ -262,8 +262,8 @@ "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." + "degraded": "Đang theo dõi ở chế độ dự phòng. Thay đổi có thể được làm mới chậm hơn.", + "failed": "Tính năng theo dõi tệp không khả dụng. Hãy làm mới hoặc chọn lại không gian làm việc." } }, "git": { diff --git a/src/renderer/src/i18n/zh-HK/chat.json b/src/renderer/src/i18n/zh-HK/chat.json index 79848bdf3..371b78a04 100644 --- a/src/renderer/src/i18n/zh-HK/chat.json +++ b/src/renderer/src/i18n/zh-HK/chat.json @@ -220,8 +220,8 @@ }, "section": "文件", "watchStatus": { - "degraded": "正在使用降级监听模式,文件变化刷新会变慢。", - "failed": "文件监听暂不可用,请重新选择工作区或刷新。" + "degraded": "正以後備模式監察檔案,變更可能會較慢重新整理。", + "failed": "檔案監察暫不可用,請重新整理或重新選擇工作區。" } }, "git": { diff --git a/src/renderer/src/i18n/zh-TW/chat.json b/src/renderer/src/i18n/zh-TW/chat.json index 379e4d208..470beeeb4 100644 --- a/src/renderer/src/i18n/zh-TW/chat.json +++ b/src/renderer/src/i18n/zh-TW/chat.json @@ -220,8 +220,8 @@ }, "section": "文件", "watchStatus": { - "degraded": "正在使用降级监听模式,文件变化刷新会变慢。", - "failed": "文件监听暂不可用,请重新选择工作区或刷新。" + "degraded": "正以備援模式監看檔案,變更可能會較慢重新整理。", + "failed": "檔案監看暫不可用,請重新整理或重新選擇工作區。" } }, "git": { diff --git a/src/shared/types/presenters/workspace.d.ts b/src/shared/types/presenters/workspace.d.ts index 71f549405..147f8c2f5 100644 --- a/src/shared/types/presenters/workspace.d.ts +++ b/src/shared/types/presenters/workspace.d.ts @@ -98,7 +98,7 @@ export type WorkspaceInvalidationEvent = { workspacePath: string kind: WorkspaceInvalidationKind source: WorkspaceInvalidationSource - version?: number + version: number } export type WorkspaceWatchHealth = 'healthy' | 'degraded' | 'failed' diff --git a/test/main/lib/fileWatcher/watcherPool.test.ts b/test/main/lib/fileWatcher/watcherPool.test.ts index f99d7154e..dd8eecea1 100644 --- a/test/main/lib/fileWatcher/watcherPool.test.ts +++ b/test/main/lib/fileWatcher/watcherPool.test.ts @@ -13,7 +13,11 @@ class FakeWatcherHostClient { readonly requests: WatchRequest[] = [] readonly batchListeners = new Set() readonly statusListeners = new Set() + watchError: Error | null = null readonly watch = vi.fn(async (request: WatchRequest) => { + if (this.watchError) { + throw this.watchError + } this.requests.push(request) }) readonly unwatch = vi.fn(async (_watchId: string) => {}) @@ -149,4 +153,23 @@ describe('WatcherPool', () => { }) ) }) + + it('removes failed pooled watches so later callers can retry', async () => { + const { pool, content } = createPoolWithClients() + const request = createRequest() + const firstListener = vi.fn() + const secondListener = vi.fn() + + content.watchError = new Error('native watcher failed') + await expect(pool.watch(request, firstListener)).rejects.toThrow('native watcher failed') + + content.watchError = null + const handle = await pool.watch(request, secondListener) + + expect(content.watch).toHaveBeenCalledTimes(2) + expect(content.requests).toHaveLength(1) + + await handle.close() + expect(content.unwatch).toHaveBeenCalledWith(content.requests[0].id) + }) }) diff --git a/test/main/presenter/skillPresenter/skillPresenter.test.ts b/test/main/presenter/skillPresenter/skillPresenter.test.ts index c04e1818a..854e6a6b4 100644 --- a/test/main/presenter/skillPresenter/skillPresenter.test.ts +++ b/test/main/presenter/skillPresenter/skillPresenter.test.ts @@ -2112,6 +2112,24 @@ describe('SkillPresenter', () => { expect(fakeWatcherService.service.watch).toHaveBeenCalledTimes(1) }) + it('publishes one catalog change when watcher overflow triggers rediscovery', async () => { + mockSkillTree(['skill-a']) + await skillPresenter.watchSkillFiles() + publishDeepchatEventMock.mockClear() + const watcher = fakeWatcherService.watchers.at(-1) + + await watcher?.emit([{ type: 'overflow', path: DEFAULT_SKILLS_DIR }]) + + expect(publishDeepchatEventMock).toHaveBeenCalledTimes(1) + expect(publishDeepchatEventMock).toHaveBeenCalledWith( + 'skills.catalog.changed', + expect.objectContaining({ + reason: 'discovered', + version: expect.any(Number) + }) + ) + }) + it('keeps the first cached entry when a changed skill renames to a duplicate name', async () => { const metadataCache = (skillPresenter as any).metadataCache as Map const originalMetadata = createSkillMetadata('skill-a', 'skill-a') diff --git a/test/main/presenter/workspacePresenter.test.ts b/test/main/presenter/workspacePresenter.test.ts index 6bcbc641a..68f8bc4d4 100644 --- a/test/main/presenter/workspacePresenter.test.ts +++ b/test/main/presenter/workspacePresenter.test.ts @@ -289,6 +289,18 @@ describe('WorkspacePresenter watchers', () => { }) }) + it('removes failed watcher startup state so later calls can retry', async () => { + await presenter.registerWorkspace(workspacePath) + vi.mocked(fakeWatcherService.service.watch).mockRejectedValueOnce(new Error('watch failed')) + + await expect(presenter.watchWorkspace(workspacePath)).rejects.toThrow('watch failed') + expect(fakeWatcherService.watchers).toHaveLength(0) + + await presenter.watchWorkspace(workspacePath) + + expect(fakeWatcherService.watchers).toHaveLength(2) + }) + it('closes remaining watchers during destroy', async () => { await presenter.registerWorkspace(workspacePath) await presenter.watchWorkspace(workspacePath) diff --git a/test/renderer/components/WorkspacePanel.test.ts b/test/renderer/components/WorkspacePanel.test.ts index 5b378cf10..99367329d 100644 --- a/test/renderer/components/WorkspacePanel.test.ts +++ b/test/renderer/components/WorkspacePanel.test.ts @@ -468,6 +468,32 @@ describe('WorkspacePanel', () => { expect(unwatchWorkspaceMock).toHaveBeenCalledWith('C:/repo') }) + it('captures watch status emitted during initial watcher startup', async () => { + watchWorkspaceMock.mockImplementationOnce(async (workspacePath: string) => { + await emitWorkspaceWatchStatusChanged({ + workspacePath, + health: 'degraded', + mode: 'snapshot-polling', + reason: 'fallback-started' + }) + }) + + const wrapper = mount(WorkspacePanel, { + props: { + sessionId: 's1', + workspacePath: 'C:/repo' + } + }) + + await flushPromises() + + expect(wrapper.find('[data-testid="workspace-watch-status"]').text()).toContain( + 'chat.workspace.files.watchStatus.degraded' + ) + + wrapper.unmount() + }) + it('shows watcher fallback status and hides it when the watcher recovers', async () => { const wrapper = mount(WorkspacePanel, { props: { From 09022da710e2565ea8c512ce9dc7fed3b5be2531 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Tue, 16 Jun 2026 09:35:09 +0800 Subject: [PATCH 3/6] fix(workspace): fallback file search --- .../fff-large-workspace-timeout/spec.md | 19 ++ .../workspacePresenter/fileSearcher.ts | 175 ++++++++++++++++-- .../workspacePresenter/fileSearcher.test.ts | 59 ++++++ 3 files changed, 240 insertions(+), 13 deletions(-) create mode 100644 docs/issues/fff-large-workspace-timeout/spec.md diff --git a/docs/issues/fff-large-workspace-timeout/spec.md b/docs/issues/fff-large-workspace-timeout/spec.md new file mode 100644 index 000000000..34113624a --- /dev/null +++ b/docs/issues/fff-large-workspace-timeout/spec.md @@ -0,0 +1,19 @@ +# FFF Large Workspace Timeout + +## User Story + +When a user selects a large workspace, workspace file search and `@` file mentions should remain +usable even if the FFF native indexer cannot finish its initial scan quickly. + +## Acceptance Criteria + +- If FFF initial scan or glob search fails, workspace file search falls back to bounded filesystem + scanning instead of returning an empty completed result. +- Repeated concurrent FFF failures for the same workspace search do not spam identical warning logs. +- Existing default and caller-provided exclude patterns continue to be respected. +- Focused workspace file search tests cover FFF fallback and warning dedupe behavior. + +## Non-Goals + +- Replacing FFF for normal fast-path search. +- Adding a user-visible search status banner. diff --git a/src/main/presenter/workspacePresenter/fileSearcher.ts b/src/main/presenter/workspacePresenter/fileSearcher.ts index fd86e7162..7a679f0cd 100644 --- a/src/main/presenter/workspacePresenter/fileSearcher.ts +++ b/src/main/presenter/workspacePresenter/fileSearcher.ts @@ -22,6 +22,7 @@ const DEFAULT_PAGE_SIZE = 50 const DEFAULT_CACHE_LIMIT = 200 const MAX_CACHE_FILES = 500 const FFF_GLOB_PAGE_SIZE = 500 +const FFF_UI_SCAN_TIMEOUT_MS = 2_500 const CACHE_TTL_MS = 30_000 const MAX_CACHE_ENTRIES = 50 const MTIME_CACHE_TTL_MS = 60_000 @@ -41,7 +42,8 @@ const DEFAULT_EXCLUDES = [ const statLimiter = new ConcurrencyLimiter(10) const mtimeCache = new Map() -const fffSearchService = new FffSearchService() +const fffFailureWarnings = new Map() +const fffSearchService = new FffSearchService({ scanTimeoutMs: FFF_UI_SCAN_TIMEOUT_MS }) type CacheEntry = { files: string[] @@ -51,6 +53,13 @@ type CacheEntry = { nextFffPageIndex: number } +type FilesystemEntry = { + name: string + isDirectory(): boolean + isSymbolicLink(): boolean + isFile(): boolean +} + const searchCache = new Map() const encodeCursor = (offset: number) => Buffer.from(String(offset)).toString('base64') @@ -161,6 +170,29 @@ const getMtime = async (filePath: string): Promise => { const sortFilesByName = (files: string[]) => files.sort((a, b) => a.localeCompare(b)) +const getErrorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error) + +const warnFffFailureOnce = (workspacePath: string, error: unknown): void => { + const now = Date.now() + for (const [key, loggedAt] of fffFailureWarnings.entries()) { + if (now - loggedAt > CACHE_TTL_MS) { + fffFailureWarnings.delete(key) + } + } + + const key = `${path.resolve(workspacePath)}::${getErrorMessage(error)}` + if (fffFailureWarnings.has(key)) { + return + } + + fffFailureWarnings.set(key, now) + console.warn( + '[WorkspaceSearch] FFF unavailable, using filesystem fallback:', + getErrorMessage(error) + ) +} + const sortFilesByModified = async (files: string[]) => { const entries = await Promise.all( files.map(async (file) => ({ file, mtimeMs: await getMtime(file) })) @@ -176,6 +208,109 @@ const sortFilesByModified = async (files: string[]) => { return entries.map((entry) => entry.file) } +const matchesGlobPattern = ( + workspacePath: string, + filePath: string, + globPattern: string +): boolean => { + const relativePath = toPosixPath(path.relative(workspacePath, filePath)) + if (!relativePath || relativePath.startsWith('../') || path.isAbsolute(relativePath)) { + return false + } + + return minimatch(relativePath, globPattern, { + dot: true, + nocase: process.platform === 'win32' + }) +} + +const scanFilesystemFiles = async ( + workspacePath: string, + globPattern: string, + maxFiles: number, + excludePatterns: string[] | undefined +): Promise<{ files: string[]; complete: boolean }> => { + const files: string[] = [] + const queue = [workspacePath] + let queueIndex = 0 + let stoppedEarly = false + + while (queueIndex < queue.length) { + const currentDir = queue[queueIndex++] + let entries: FilesystemEntry[] + try { + entries = await fs.readdir(currentDir, { withFileTypes: true }) + } catch { + continue + } + + entries.sort((a, b) => { + if (a.isDirectory() !== b.isDirectory()) { + return a.isDirectory() ? -1 : 1 + } + return a.name.localeCompare(b.name) + }) + + for (const entry of entries) { + const filePath = path.join(currentDir, entry.name) + if (isExcluded(workspacePath, filePath, excludePatterns)) { + continue + } + + if (entry.isDirectory()) { + if (!entry.isSymbolicLink()) { + queue.push(filePath) + } + continue + } + + if (!entry.isFile() || !matchesGlobPattern(workspacePath, filePath, globPattern)) { + continue + } + + files.push(filePath) + if (files.length >= maxFiles) { + stoppedEarly = true + break + } + } + + if (stoppedEarly) { + break + } + } + + return { + files, + complete: !stoppedEarly && queueIndex >= queue.length + } +} + +const extendCacheEntryWithFilesystemFallback = async ( + entry: CacheEntry, + workspacePath: string, + requiredCount: number, + excludePatterns: string[] | undefined +) => { + const result = await scanFilesystemFiles( + workspacePath, + entry.globPattern, + Math.min(requiredCount, MAX_CACHE_FILES + 1), + excludePatterns + ) + const seen = new Set(entry.files) + + for (const file of result.files) { + if (seen.has(file)) { + continue + } + seen.add(file) + entry.files.push(file) + } + + entry.complete = result.complete +} + const extendCacheEntry = async ( entry: CacheEntry, workspacePath: string, @@ -205,6 +340,25 @@ const extendCacheEntry = async ( } } +const extendCacheEntryWithFallback = async ( + entry: CacheEntry, + workspacePath: string, + requiredCount: number, + excludePatterns: string[] | undefined +) => { + try { + await extendCacheEntry(entry, workspacePath, requiredCount, excludePatterns) + } catch (error) { + warnFffFailureOnce(workspacePath, error) + await extendCacheEntryWithFilesystemFallback( + entry, + workspacePath, + requiredCount, + excludePatterns + ) + } +} + export async function searchFiles( workspacePath: string, pattern: string, @@ -228,21 +382,16 @@ export async function searchFiles( globPattern: normalizeGlobPattern(pattern), nextFffPageIndex: 0 } - try { - await extendCacheEntry(cached, workspacePath, targetLimit, options.excludePatterns) - } catch (error) { - console.warn('[WorkspaceSearch] FFF search failed:', error) - cached.complete = true - } + await extendCacheEntryWithFallback(cached, workspacePath, targetLimit, options.excludePatterns) setCacheEntry(cacheKey, cached) } else if (!cached.complete && cached.files.length < requiredCount) { - try { - await extendCacheEntry(cached, workspacePath, requiredCount, options.excludePatterns) - } catch (error) { - console.warn('[WorkspaceSearch] FFF search failed:', error) - cached.complete = true - } + await extendCacheEntryWithFallback( + cached, + workspacePath, + requiredCount, + options.excludePatterns + ) } if (cached.files.length > MAX_CACHE_FILES) { diff --git a/test/main/presenter/workspacePresenter/fileSearcher.test.ts b/test/main/presenter/workspacePresenter/fileSearcher.test.ts index f4922a9bd..2f68b642f 100644 --- a/test/main/presenter/workspacePresenter/fileSearcher.test.ts +++ b/test/main/presenter/workspacePresenter/fileSearcher.test.ts @@ -1,3 +1,5 @@ +import fs from 'fs/promises' +import os from 'os' import path from 'path' import { beforeEach, describe, expect, it, vi } from 'vitest' import { searchFiles } from '@/presenter/workspacePresenter/fileSearcher' @@ -88,4 +90,61 @@ describe('workspace fileSearcher', () => { expect(fffMock.globFiles).toHaveBeenCalledTimes(1) expect(result.files[0]).toBe(path.normalize('/workspace-page-tail/src/file-200.ts')) }) + + it('falls back to filesystem search when FFF is unavailable', async () => { + const workspacePath = await fs.mkdtemp(path.join(os.tmpdir(), 'deepchat-search-')) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + fffMock.globFiles.mockRejectedValue(new Error('FFF initial scan timed out after 2500ms')) + + try { + await fs.mkdir(path.join(workspacePath, 'src'), { recursive: true }) + await fs.mkdir(path.join(workspacePath, 'node_modules', 'pkg'), { recursive: true }) + await fs.mkdir(path.join(workspacePath, 'dist'), { recursive: true }) + await fs.writeFile(path.join(workspacePath, 'src', 'needle.ts'), '') + await fs.writeFile(path.join(workspacePath, 'node_modules', 'pkg', 'needle.ts'), '') + await fs.writeFile(path.join(workspacePath, 'dist', 'needle.js'), '') + + const result = await searchFiles(workspacePath, '*needle*', { + maxResults: 10, + sortBy: 'name' + }) + + expect(result.files).toEqual([path.join(workspacePath, 'src', 'needle.ts')]) + expect(result.hasMore).toBe(false) + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy).toHaveBeenCalledWith( + '[WorkspaceSearch] FFF unavailable, using filesystem fallback:', + 'FFF initial scan timed out after 2500ms' + ) + } finally { + warnSpy.mockRestore() + await fs.rm(workspacePath, { recursive: true, force: true }) + } + }) + + it('deduplicates repeated FFF fallback warnings for one workspace', async () => { + const workspacePath = await fs.mkdtemp(path.join(os.tmpdir(), 'deepchat-search-warn-')) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + fffMock.globFiles.mockRejectedValue(new Error('FFF initial scan timed out after 2500ms')) + + try { + await fs.writeFile(path.join(workspacePath, 'needle.ts'), '') + await fs.writeFile(path.join(workspacePath, 'other.ts'), '') + + await searchFiles(workspacePath, '*needle*', { + maxResults: 10, + sortBy: 'name' + }) + await searchFiles(workspacePath, '*other*', { + maxResults: 10, + sortBy: 'name' + }) + + expect(fffMock.globFiles).toHaveBeenCalledTimes(2) + expect(warnSpy).toHaveBeenCalledTimes(1) + } finally { + warnSpy.mockRestore() + await fs.rm(workspacePath, { recursive: true, force: true }) + } + }) }) From cf1363329cd5678c1e1fb2f189fa7b3adde9a89d Mon Sep 17 00:00:00 2001 From: zerob13 Date: Tue, 16 Jun 2026 09:39:27 +0800 Subject: [PATCH 4/6] fix(workspace): bound search fallback --- .../presenter/workspacePresenter/fileSearcher.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/main/presenter/workspacePresenter/fileSearcher.ts b/src/main/presenter/workspacePresenter/fileSearcher.ts index 7a679f0cd..9f051efea 100644 --- a/src/main/presenter/workspacePresenter/fileSearcher.ts +++ b/src/main/presenter/workspacePresenter/fileSearcher.ts @@ -23,6 +23,8 @@ const DEFAULT_CACHE_LIMIT = 200 const MAX_CACHE_FILES = 500 const FFF_GLOB_PAGE_SIZE = 500 const FFF_UI_SCAN_TIMEOUT_MS = 2_500 +const FILESYSTEM_FALLBACK_SCAN_TIMEOUT_MS = 2_500 +const FILESYSTEM_FALLBACK_MAX_ENTRIES = 20_000 const CACHE_TTL_MS = 30_000 const MAX_CACHE_ENTRIES = 50 const MTIME_CACHE_TTL_MS = 60_000 @@ -232,7 +234,9 @@ const scanFilesystemFiles = async ( ): Promise<{ files: string[]; complete: boolean }> => { const files: string[] = [] const queue = [workspacePath] + const startedAt = Date.now() let queueIndex = 0 + let scannedEntries = 0 let stoppedEarly = false while (queueIndex < queue.length) { @@ -252,6 +256,15 @@ const scanFilesystemFiles = async ( }) for (const entry of entries) { + scannedEntries += 1 + if ( + scannedEntries > FILESYSTEM_FALLBACK_MAX_ENTRIES || + Date.now() - startedAt > FILESYSTEM_FALLBACK_SCAN_TIMEOUT_MS + ) { + stoppedEarly = true + break + } + const filePath = path.join(currentDir, entry.name) if (isExcluded(workspacePath, filePath, excludePatterns)) { continue From 5f29188595593e1a8d6d9c1da3cb10d3937b6d68 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Tue, 16 Jun 2026 11:44:12 +0800 Subject: [PATCH 5/6] fix(skills): tolerate watcher startup failure --- .../skill-watcher-initialize-failure/spec.md | 19 +++++ src/main/presenter/skillPresenter/index.ts | 25 ++++++ .../skillPresenter/skillPresenter.test.ts | 79 +++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 docs/issues/skill-watcher-initialize-failure/spec.md diff --git a/docs/issues/skill-watcher-initialize-failure/spec.md b/docs/issues/skill-watcher-initialize-failure/spec.md new file mode 100644 index 000000000..4aa3b418f --- /dev/null +++ b/docs/issues/skill-watcher-initialize-failure/spec.md @@ -0,0 +1,19 @@ +# Skill Watcher Initialize Failure + +## User Story + +When the skill file watcher utility process fails to start or exits during startup, the skills +system should still initialize and remain usable for discovery, reads, and background sync. + +## Acceptance Criteria + +- `SkillPresenter.initialize()` does not fail solely because skill file watching is unavailable. +- `watchSkillFiles()` logs a warning and leaves the presenter in a retryable state when watcher + startup throws. +- Runtime watcher errors release the failed watcher so a later `watchSkillFiles()` call can retry. +- Existing skill discovery and cache behavior remains unchanged. + +## Non-Goals + +- Replacing the watcher backend. +- Adding a user-visible degraded-mode banner. diff --git a/src/main/presenter/skillPresenter/index.ts b/src/main/presenter/skillPresenter/index.ts index f6f0da764..b6d61ea9d 100644 --- a/src/main/presenter/skillPresenter/index.ts +++ b/src/main/presenter/skillPresenter/index.ts @@ -1893,6 +1893,20 @@ export class SkillPresenter implements ISkillPresenter { return result.tools } + private closeFailedWatcher(watcher: WatchHandle): void { + void watcher.close().catch((error) => { + logger.warn('[SkillPresenter] Failed to close failed file watcher.', { error }) + }) + } + + private handleWatcherStartFailure(error: unknown): void { + this.watcher = null + logger.warn('[SkillPresenter] File watcher unavailable; skill hot reload disabled.', { + reason: 'start-failed', + error + }) + } + /** * Watch skill files for changes (hot-reload) */ @@ -1923,6 +1937,9 @@ export class SkillPresenter implements ISkillPresenter { this.watcher = handle logger.info('[SkillPresenter] File watcher started') }) + .catch((error) => { + this.handleWatcherStartFailure(error) + }) .finally(() => { this.watcherStartPromise = null }) @@ -1982,6 +1999,14 @@ export class SkillPresenter implements ISkillPresenter { reason: status.reason, message: status.message }) + + if (status.health !== 'failed' || !this.watcher) { + return + } + + const watcher = this.watcher + this.watcher = null + this.closeFailedWatcher(watcher) } private isWatchedSkillMarkdownPath(filePath: string): boolean { diff --git a/test/main/presenter/skillPresenter/skillPresenter.test.ts b/test/main/presenter/skillPresenter/skillPresenter.test.ts index 854e6a6b4..beaba2425 100644 --- a/test/main/presenter/skillPresenter/skillPresenter.test.ts +++ b/test/main/presenter/skillPresenter/skillPresenter.test.ts @@ -156,6 +156,7 @@ import type { IFileWatcherService, WatchBatchListener, WatcherEvent, + WatcherStatus, WatchMode, WatchRequest, WatchStatusListener @@ -227,6 +228,7 @@ type FakeWatcher = { request: WatchRequest close: ReturnType emit(events: WatcherEvent[], mode?: WatchMode): Promise + emitStatus(status: Partial): void } function createFakeWatcherService() { @@ -255,6 +257,19 @@ function createFakeWatcherService() { events, version: Date.now() }) + }, + emitStatus(status) { + _onStatus?.({ + watchId: request.id, + rootPath: request.rootPath, + purpose: request.purpose, + hostKind: request.hostKind, + health: 'degraded', + mode: 'snapshot-polling', + reason: 'native-error', + version: Date.now(), + ...status + }) } } watchers.push(watcher) @@ -413,6 +428,28 @@ describe('SkillPresenter', () => { }) }) + describe('initialize', () => { + it('continues when the file watcher cannot start', async () => { + const error = new Error('File watcher utility process exited with code 1.') + const installSpy = vi.spyOn(skillPresenter, 'installBuiltinSkills').mockResolvedValue() + const discoverSpy = vi.spyOn(skillPresenter, 'discoverSkills').mockResolvedValue([]) + ;(fakeWatcherService.service.watch as Mock).mockRejectedValueOnce(error) + + await expect(skillPresenter.initialize()).resolves.toBeUndefined() + await skillPresenter.initialize() + + expect(installSpy).toHaveBeenCalledTimes(1) + expect(discoverSpy).toHaveBeenCalledTimes(1) + expect(logger.warn).toHaveBeenCalledWith( + '[SkillPresenter] File watcher unavailable; skill hot reload disabled.', + { + reason: 'start-failed', + error + } + ) + }) + }) + describe('discoverSkills', () => { it('should return empty array when skills directory does not exist', async () => { ;(fs.existsSync as Mock).mockReturnValue(false) @@ -2105,6 +2142,23 @@ describe('SkillPresenter', () => { expect(fakeWatcherService.service.watch).toHaveBeenCalled() }) + it('does not throw and remains retryable when watcher startup fails', async () => { + const error = new Error('File watcher utility process exited with code 1.') + ;(fakeWatcherService.service.watch as Mock).mockRejectedValueOnce(error) + + await expect(skillPresenter.watchSkillFiles()).resolves.toBeUndefined() + await skillPresenter.watchSkillFiles() + + expect(fakeWatcherService.service.watch).toHaveBeenCalledTimes(2) + expect(logger.warn).toHaveBeenCalledWith( + '[SkillPresenter] File watcher unavailable; skill hot reload disabled.', + { + reason: 'start-failed', + error + } + ) + }) + it('should not start watcher twice', async () => { await skillPresenter.watchSkillFiles() await skillPresenter.watchSkillFiles() @@ -2112,6 +2166,31 @@ describe('SkillPresenter', () => { expect(fakeWatcherService.service.watch).toHaveBeenCalledTimes(1) }) + it('clears failed watcher state so later calls can retry', async () => { + await skillPresenter.watchSkillFiles() + const watcher = fakeWatcherService.watchers.at(-1) + + watcher?.emitStatus({ + health: 'failed', + mode: 'snapshot-polling', + reason: 'native-error', + message: 'snapshot polling failed' + }) + await skillPresenter.watchSkillFiles() + + expect(watcher?.close).toHaveBeenCalledTimes(1) + expect(fakeWatcherService.service.watch).toHaveBeenCalledTimes(2) + expect(logger.warn).toHaveBeenCalledWith( + '[SkillPresenter] File watcher degraded.', + expect.objectContaining({ + health: 'failed', + mode: 'snapshot-polling', + reason: 'native-error', + message: 'snapshot polling failed' + }) + ) + }) + it('publishes one catalog change when watcher overflow triggers rediscovery', async () => { mockSkillTree(['skill-a']) await skillPresenter.watchSkillFiles() From 2897d43172e6c07d8e1dfa061e3f1404656d11db Mon Sep 17 00:00:00 2001 From: zerob13 Date: Tue, 16 Jun 2026 12:14:13 +0800 Subject: [PATCH 6/6] chore(config): update resource data --- resources/acp-registry/icons/devin.svg | 3 + resources/acp-registry/registry.json | 193 +-- resources/model-db/providers.json | 1777 ++++++++++++++---------- 3 files changed, 1178 insertions(+), 795 deletions(-) create mode 100644 resources/acp-registry/icons/devin.svg diff --git a/resources/acp-registry/icons/devin.svg b/resources/acp-registry/icons/devin.svg new file mode 100644 index 000000000..43170056a --- /dev/null +++ b/resources/acp-registry/icons/devin.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/acp-registry/registry.json b/resources/acp-registry/registry.json index b196594d8..94b610d80 100644 --- a/resources/acp-registry/registry.json +++ b/resources/acp-registry/registry.json @@ -103,7 +103,7 @@ { "id": "claude-acp", "name": "Claude Agent", - "version": "0.44.0", + "version": "0.45.0", "description": "ACP wrapper for Anthropic's Claude", "repository": "https://github.com/agentclientprotocol/claude-agent-acp", "authors": [ @@ -114,7 +114,7 @@ "license": "proprietary", "distribution": { "npx": { - "package": "@agentclientprotocol/claude-agent-acp@0.44.0" + "package": "@agentclientprotocol/claude-agent-acp@0.45.0" } }, "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/claude-acp.svg" @@ -143,7 +143,7 @@ { "id": "codebuddy-code", "name": "Codebuddy Code", - "version": "2.106.3", + "version": "2.106.5", "description": "Tencent Cloud's official intelligent coding tool", "website": "https://www.codebuddy.cn/cli/", "authors": [ @@ -152,7 +152,7 @@ "license": "Proprietary", "distribution": { "npx": { - "package": "@tencent-ai/codebuddy-code@2.106.3", + "package": "@tencent-ai/codebuddy-code@2.106.5", "args": [ "--acp" ] @@ -356,7 +356,7 @@ { "id": "cursor", "name": "Cursor", - "version": "2026.06.12", + "version": "2026.06.15", "description": "Cursor's coding agent", "website": "https://cursor.com/docs/cli/acp", "authors": [ @@ -366,42 +366,42 @@ "distribution": { "binary": { "darwin-aarch64": { - "archive": "https://downloads.cursor.com/lab/2026.06.12-01-15-52-7244546/darwin/arm64/agent-cli-package.tar.gz", + "archive": "https://downloads.cursor.com/lab/2026.06.15-03-48-54-da23e37/darwin/arm64/agent-cli-package.tar.gz", "cmd": "./dist-package/cursor-agent", "args": [ "acp" ] }, "darwin-x86_64": { - "archive": "https://downloads.cursor.com/lab/2026.06.12-01-15-52-7244546/darwin/x64/agent-cli-package.tar.gz", + "archive": "https://downloads.cursor.com/lab/2026.06.15-03-48-54-da23e37/darwin/x64/agent-cli-package.tar.gz", "cmd": "./dist-package/cursor-agent", "args": [ "acp" ] }, "linux-aarch64": { - "archive": "https://downloads.cursor.com/lab/2026.06.12-01-15-52-7244546/linux/arm64/agent-cli-package.tar.gz", + "archive": "https://downloads.cursor.com/lab/2026.06.15-03-48-54-da23e37/linux/arm64/agent-cli-package.tar.gz", "cmd": "./dist-package/cursor-agent", "args": [ "acp" ] }, "linux-x86_64": { - "archive": "https://downloads.cursor.com/lab/2026.06.12-01-15-52-7244546/linux/x64/agent-cli-package.tar.gz", + "archive": "https://downloads.cursor.com/lab/2026.06.15-03-48-54-da23e37/linux/x64/agent-cli-package.tar.gz", "cmd": "./dist-package/cursor-agent", "args": [ "acp" ] }, "windows-aarch64": { - "archive": "https://downloads.cursor.com/lab/2026.06.12-01-15-52-7244546/windows/arm64/agent-cli-package.zip", + "archive": "https://downloads.cursor.com/lab/2026.06.15-03-48-54-da23e37/windows/arm64/agent-cli-package.zip", "cmd": "./dist-package\\cursor-agent.cmd", "args": [ "acp" ] }, "windows-x86_64": { - "archive": "https://downloads.cursor.com/lab/2026.06.12-01-15-52-7244546/windows/x64/agent-cli-package.zip", + "archive": "https://downloads.cursor.com/lab/2026.06.15-03-48-54-da23e37/windows/x64/agent-cli-package.zip", "cmd": "./dist-package\\cursor-agent.cmd", "args": [ "acp" @@ -430,6 +430,64 @@ }, "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/deepagents.svg" }, + { + "id": "devin", + "name": "Devin", + "version": "2026.5.26", + "description": "Devin CLI coding agent by Cognition", + "website": "https://docs.devin.ai/cli", + "authors": [ + "Cognition" + ], + "license": "proprietary", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://static.devin.ai/cli/2026.5.26-8/devin-2026.5.26-8-aarch64-apple-darwin.tar.gz", + "cmd": "./bin/devin", + "args": [ + "acp" + ] + }, + "darwin-x86_64": { + "archive": "https://static.devin.ai/cli/2026.5.26-8/devin-2026.5.26-8-x86_64-apple-darwin.tar.gz", + "cmd": "./bin/devin", + "args": [ + "acp" + ] + }, + "linux-aarch64": { + "archive": "https://static.devin.ai/cli/2026.5.26-8/devin-2026.5.26-8-aarch64-unknown-linux.tar.gz", + "cmd": "./bin/devin", + "args": [ + "acp" + ] + }, + "linux-x86_64": { + "archive": "https://static.devin.ai/cli/2026.5.26-8/devin-2026.5.26-8-x86_64-unknown-linux.tar.gz", + "cmd": "./bin/devin", + "args": [ + "acp" + ] + }, + "windows-aarch64": { + "archive": "https://static.devin.ai/cli/2026.5.26-8/devin-2026.5.26-8-aarch64-pc-windows.zip", + "cmd": ".\\bin\\devin.exe", + "args": [ + "acp" + ] + }, + "windows-x86_64": { + "archive": "https://static.devin.ai/cli/2026.5.26-8/devin-2026.5.26-8-x86_64-pc-windows.zip", + "cmd": ".\\bin\\devin.exe", + "args": [ + "acp" + ] + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/devin.svg" + }, { "id": "dimcode", "name": "DimCode", @@ -474,7 +532,7 @@ { "id": "factory-droid", "name": "Factory Droid", - "version": "0.147.0", + "version": "0.149.0", "description": "Factory Droid - AI coding agent powered by Factory AI", "website": "https://factory.ai/product/cli", "authors": [ @@ -483,7 +541,7 @@ "license": "proprietary", "distribution": { "npx": { - "package": "droid@0.147.0", + "package": "droid@0.149.0", "args": [ "exec", "--output-format", @@ -500,7 +558,7 @@ { "id": "fast-agent", "name": "fast-agent", - "version": "0.7.19", + "version": "0.7.20", "description": "Code and build agents with comprehensive multi-provider support", "repository": "https://github.com/evalstate/fast-agent", "website": "https://fast-agent.ai", @@ -510,7 +568,7 @@ "license": "Apache 2.0", "distribution": { "uvx": { - "package": "fast-agent-acp==0.7.19", + "package": "fast-agent-acp==0.7.20", "args": [ "-x" ] @@ -632,7 +690,7 @@ { "id": "grok-build", "name": "Grok Build", - "version": "0.2.20", + "version": "0.2.53", "description": "xAI's coding agent and CLI", "website": "https://x.ai/cli", "authors": [ @@ -640,55 +698,12 @@ ], "license": "proprietary", "distribution": { - "binary": { - "darwin-aarch64": { - "archive": "https://x.ai/cli/grok-0.2.20-macos-aarch64", - "cmd": "./grok-0.2.20-macos-aarch64", - "args": [ - "agent", - "stdio" - ] - }, - "darwin-x86_64": { - "archive": "https://x.ai/cli/grok-0.2.20-macos-x86_64", - "cmd": "./grok-0.2.20-macos-x86_64", - "args": [ - "agent", - "stdio" - ] - }, - "linux-aarch64": { - "archive": "https://x.ai/cli/grok-0.2.20-linux-aarch64", - "cmd": "./grok-0.2.20-linux-aarch64", - "args": [ - "agent", - "stdio" - ] - }, - "linux-x86_64": { - "archive": "https://x.ai/cli/grok-0.2.20-linux-x86_64", - "cmd": "./grok-0.2.20-linux-x86_64", - "args": [ - "agent", - "stdio" - ] - }, - "windows-aarch64": { - "archive": "https://x.ai/cli/grok-0.2.20-windows-aarch64.exe", - "cmd": "./grok-0.2.20-windows-aarch64.exe", - "args": [ - "agent", - "stdio" - ] - }, - "windows-x86_64": { - "archive": "https://x.ai/cli/grok-0.2.20-windows-x86_64.exe", - "cmd": "./grok-0.2.20-windows-x86_64.exe", - "args": [ - "agent", - "stdio" - ] - } + "npx": { + "package": "@xai-official/grok@0.2.53", + "args": [ + "agent", + "stdio" + ] } }, "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/grok-build.svg" @@ -748,7 +763,7 @@ { "id": "kilo", "name": "Kilo", - "version": "7.3.45", + "version": "7.3.46", "description": "The open source coding agent", "repository": "https://github.com/Kilo-Org/kilocode", "website": "https://kilo.ai/", @@ -760,35 +775,35 @@ "distribution": { "binary": { "darwin-aarch64": { - "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.45/kilo-darwin-arm64.zip", + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.46/kilo-darwin-arm64.zip", "cmd": "./kilo", "args": [ "acp" ] }, "darwin-x86_64": { - "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.45/kilo-darwin-x64.zip", + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.46/kilo-darwin-x64.zip", "cmd": "./kilo", "args": [ "acp" ] }, "linux-aarch64": { - "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.45/kilo-linux-arm64.tar.gz", + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.46/kilo-linux-arm64.tar.gz", "cmd": "./kilo", "args": [ "acp" ] }, "linux-x86_64": { - "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.45/kilo-linux-x64.tar.gz", + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.46/kilo-linux-x64.tar.gz", "cmd": "./kilo", "args": [ "acp" ] }, "windows-x86_64": { - "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.45/kilo-windows-x64.zip", + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.46/kilo-windows-x64.zip", "cmd": "./kilo.exe", "args": [ "acp" @@ -796,7 +811,7 @@ } }, "npx": { - "package": "@kilocode/cli@7.3.45", + "package": "@kilocode/cli@7.3.46", "args": [ "acp" ] @@ -878,7 +893,7 @@ { "id": "mistral-vibe", "name": "Mistral Vibe", - "version": "2.15.0", + "version": "2.16.0", "description": "Mistral's open-source coding assistant", "repository": "https://github.com/mistralai/mistral-vibe", "website": "https://mistral.ai/products/vibe", @@ -890,23 +905,23 @@ "distribution": { "binary": { "darwin-aarch64": { - "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.15.0/vibe-acp-darwin-aarch64-2.15.0.zip", + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.16.0/vibe-acp-darwin-aarch64-2.16.0.zip", "cmd": "./vibe-acp" }, "darwin-x86_64": { - "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.15.0/vibe-acp-darwin-x86_64-2.15.0.zip", + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.16.0/vibe-acp-darwin-x86_64-2.16.0.zip", "cmd": "./vibe-acp" }, "linux-aarch64": { - "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.15.0/vibe-acp-linux-aarch64-2.15.0.zip", + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.16.0/vibe-acp-linux-aarch64-2.16.0.zip", "cmd": "./vibe-acp" }, "linux-x86_64": { - "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.15.0/vibe-acp-linux-x86_64-2.15.0.zip", + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.16.0/vibe-acp-linux-x86_64-2.16.0.zip", "cmd": "./vibe-acp" }, "windows-x86_64": { - "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.15.0/vibe-acp-windows-x86_64-2.15.0.zip", + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.16.0/vibe-acp-windows-x86_64-2.16.0.zip", "cmd": "./vibe-acp.exe" } } @@ -915,7 +930,7 @@ { "id": "nova", "name": "Nova", - "version": "1.1.17", + "version": "1.1.18", "description": "Nova by Compass AI - a fully-fledged software engineer at your command", "repository": "https://github.com/Compass-Agentic-Platform/nova", "website": "https://www.compassap.ai/portfolio/nova.html", @@ -926,7 +941,7 @@ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/nova.svg", "distribution": { "npx": { - "package": "@compass-ai/nova@1.1.17", + "package": "@compass-ai/nova@1.1.18", "args": [ "acp" ] @@ -936,7 +951,7 @@ { "id": "opencode", "name": "OpenCode", - "version": "1.17.5", + "version": "1.17.7", "description": "The open source coding agent", "repository": "https://github.com/anomalyco/opencode", "website": "https://opencode.ai", @@ -948,42 +963,42 @@ "distribution": { "binary": { "darwin-aarch64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.5/opencode-darwin-arm64.zip", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.7/opencode-darwin-arm64.zip", "cmd": "./opencode", "args": [ "acp" ] }, "darwin-x86_64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.5/opencode-darwin-x64.zip", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.7/opencode-darwin-x64.zip", "cmd": "./opencode", "args": [ "acp" ] }, "linux-aarch64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.5/opencode-linux-arm64.tar.gz", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.7/opencode-linux-arm64.tar.gz", "cmd": "./opencode", "args": [ "acp" ] }, "linux-x86_64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.5/opencode-linux-x64.tar.gz", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.7/opencode-linux-x64.tar.gz", "cmd": "./opencode", "args": [ "acp" ] }, "windows-aarch64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.5/opencode-windows-arm64.zip", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.7/opencode-windows-arm64.zip", "cmd": "./opencode", "args": [ "acp" ] }, "windows-x86_64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.5/opencode-windows-x64.zip", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.7/opencode-windows-x64.zip", "cmd": "./opencode.exe", "args": [ "acp" @@ -1091,7 +1106,7 @@ { "id": "qwen-code", "name": "Qwen Code", - "version": "0.18.0", + "version": "0.18.1", "description": "Alibaba's Qwen coding assistant", "repository": "https://github.com/QwenLM/qwen-code", "website": "https://qwenlm.github.io/qwen-code-docs/en/users/overview", @@ -1101,7 +1116,7 @@ "license": "Apache-2.0", "distribution": { "npx": { - "package": "@qwen-code/qwen-code@0.18.0", + "package": "@qwen-code/qwen-code@0.18.1", "args": [ "--acp", "--experimental-skills" diff --git a/resources/model-db/providers.json b/resources/model-db/providers.json index 684bb3def..87c3f6ea0 100644 --- a/resources/model-db/providers.json +++ b/resources/model-db/providers.json @@ -20063,7 +20063,7 @@ } }, "attachment": true, - "open_weights": false, + "open_weights": true, "release_date": "2026-05-31", "last_updated": "2026-06-01", "cost": { @@ -27542,6 +27542,55 @@ "cache_read": 0.16 }, "type": "chat" + }, + { + "id": "kimi-k2.7-code-highspeed", + "name": "Kimi K2.7 Code Highspeed", + "display_name": "Kimi K2.7 Code Highspeed", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 262144 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "fixed", + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": true, + "open_weights": true, + "knowledge": "2025-01", + "release_date": "2026-04-21", + "last_updated": "2026-04-21", + "cost": { + "input": 0.95, + "output": 4, + "cache_read": 0.16 + }, + "type": "chat" } ] }, @@ -40790,6 +40839,36 @@ }, "type": "chat" }, + { + "id": "gemma-4-E4B-it", + "name": "Gemma 4 E4B IT", + "display_name": "Gemma 4 E4B IT", + "modalities": { + "input": [ + "text", + "image", + "audio" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 131072, + "output": 8192 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-04-02", + "last_updated": "2026-04-02", + "type": "chat" + }, { "id": "gemini-embedding-001", "name": "Gemini Embedding 001", @@ -40931,6 +41010,36 @@ }, "type": "chat" }, + { + "id": "gemma-4-E2B-it", + "name": "Gemma 4 E2B IT", + "display_name": "Gemma 4 E2B IT", + "modalities": { + "input": [ + "text", + "image", + "audio" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 131072, + "output": 8192 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-04-02", + "last_updated": "2026-04-02", + "type": "chat" + }, { "id": "gemini-3-pro-image-preview", "name": "Nano Banana Pro", @@ -46832,7 +46941,7 @@ } }, "attachment": true, - "open_weights": false, + "open_weights": true, "release_date": "2026-06-01", "last_updated": "2026-06-11", "cost": { @@ -55074,74 +55183,6 @@ }, "type": "chat" }, - { - "id": "inclusionAI/Ling-mini-2.0", - "name": "inclusionAI/Ling-mini-2.0", - "display_name": "inclusionAI/Ling-mini-2.0", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 131072, - "output": 8192 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": false - }, - "attachment": false, - "open_weights": false, - "release_date": "2025-09-10", - "last_updated": "2025-11-25", - "cost": { - "input": 0.07, - "output": 0.28 - }, - "type": "chat" - }, - { - "id": "inclusionAI/Ring-flash-2.0", - "name": "inclusionAI/Ring-flash-2.0", - "display_name": "inclusionAI/Ring-flash-2.0", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 131072, - "output": 8192 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "attachment": false, - "open_weights": false, - "release_date": "2025-09-29", - "last_updated": "2025-11-25", - "cost": { - "input": 0.14, - "output": 0.57 - }, - "type": "chat" - }, { "id": "Pro/moonshotai/Kimi-K2-Thinking", "name": "Pro/moonshotai/Kimi-K2-Thinking", @@ -57112,38 +57153,6 @@ }, "type": "chat" }, - { - "id": "PaddlePaddle/PaddleOCR-VL", - "name": "PaddlePaddle/PaddleOCR-VL", - "display_name": "PaddlePaddle/PaddleOCR-VL", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 16384, - "output": 16384 - }, - "temperature": true, - "tool_call": false, - "reasoning": { - "supported": false - }, - "attachment": true, - "open_weights": true, - "release_date": "2025-10-16", - "last_updated": "2025-10-16", - "cost": { - "input": 0, - "output": 0 - }, - "type": "chat" - }, { "id": "tencent/Hunyuan-A13B-Instruct", "name": "tencent/Hunyuan-A13B-Instruct", @@ -57650,9 +57659,9 @@ "type": "chat" }, { - "id": "ascend-tribe/pangu-pro-moe", - "name": "ascend-tribe/pangu-pro-moe", - "display_name": "ascend-tribe/pangu-pro-moe", + "id": "Kwaipilot/KAT-Dev", + "name": "Kwaipilot/KAT-Dev", + "display_name": "Kwaipilot/KAT-Dev", "modalities": { "input": [ "text" @@ -57662,8 +57671,8 @@ ] }, "limit": { - "context": 131072, - "output": 8192 + "context": 128000, + "output": 128000 }, "temperature": true, "tool_call": true, @@ -57672,7 +57681,7 @@ }, "attachment": false, "open_weights": false, - "release_date": "2025-07-02", + "release_date": "2025-09-27", "last_updated": "2026-01-16", "cost": { "input": 0.2, @@ -57681,9 +57690,9 @@ "type": "chat" }, { - "id": "Kwaipilot/KAT-Dev", - "name": "Kwaipilot/KAT-Dev", - "display_name": "Kwaipilot/KAT-Dev", + "id": "deepseek-ai/DeepSeek-V3.2-Exp", + "name": "deepseek-ai/DeepSeek-V3.2-Exp", + "display_name": "deepseek-ai/DeepSeek-V3.2-Exp", "modalities": { "input": [ "text" @@ -57693,28 +57702,19 @@ ] }, "limit": { - "context": 128000, - "output": 128000 + "context": 131072, + "output": 8192 }, - "temperature": true, "tool_call": true, "reasoning": { "supported": false }, - "attachment": false, - "open_weights": false, - "release_date": "2025-09-27", - "last_updated": "2026-01-16", - "cost": { - "input": 0.2, - "output": 0.6 - }, "type": "chat" }, { - "id": "deepseek-ai/DeepSeek-V3.2-Exp", - "name": "deepseek-ai/DeepSeek-V3.2-Exp", - "display_name": "deepseek-ai/DeepSeek-V3.2-Exp", + "id": "Pro/deepseek-ai/DeepSeek-V3.2-Exp", + "name": "Pro/deepseek-ai/DeepSeek-V3.2-Exp", + "display_name": "Pro/deepseek-ai/DeepSeek-V3.2-Exp", "modalities": { "input": [ "text" @@ -57734,9 +57734,37 @@ "type": "chat" }, { - "id": "Pro/deepseek-ai/DeepSeek-V3.2-Exp", - "name": "Pro/deepseek-ai/DeepSeek-V3.2-Exp", - "display_name": "Pro/deepseek-ai/DeepSeek-V3.2-Exp", + "id": "inclusionAI/Ring-1T", + "name": "inclusionAI/Ring-1T", + "display_name": "inclusionAI/Ring-1T", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 131072, + "output": 8192 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "type": "chat" + }, + { + "id": "inclusionAI/Ling-1T", + "name": "inclusionAI/Ling-1T", + "display_name": "inclusionAI/Ling-1T", "modalities": { "input": [ "text" @@ -57756,9 +57784,9 @@ "type": "chat" }, { - "id": "inclusionAI/Ring-1T", - "name": "inclusionAI/Ring-1T", - "display_name": "inclusionAI/Ring-1T", + "id": "inclusionAI/Ring-flash-2.0", + "name": "inclusionAI/Ring-flash-2.0", + "display_name": "inclusionAI/Ring-flash-2.0", "modalities": { "input": [ "text" @@ -57784,9 +57812,9 @@ "type": "chat" }, { - "id": "inclusionAI/Ling-1T", - "name": "inclusionAI/Ling-1T", - "display_name": "inclusionAI/Ling-1T", + "id": "inclusionAI/Ling-mini-2.0", + "name": "inclusionAI/Ling-mini-2.0", + "display_name": "inclusionAI/Ling-mini-2.0", "modalities": { "input": [ "text" @@ -58255,6 +58283,28 @@ }, "type": "embedding" }, + { + "id": "ascend-tribe/pangu-pro-moe", + "name": "ascend-tribe/pangu-pro-moe", + "display_name": "ascend-tribe/pangu-pro-moe", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 131072, + "output": 8192 + }, + "tool_call": true, + "reasoning": { + "supported": false + }, + "type": "chat" + }, { "id": "Qwen/Qwen3-235B-A22B", "name": "Qwen/Qwen3-235B-A22B", @@ -62555,8 +62605,8 @@ "attachment": false, "open_weights": true, "knowledge": "2024-06-01", - "release_date": "2024-02-27", - "last_updated": "2024-02-27", + "release_date": "2024-12-02", + "last_updated": "2024-12-02", "cost": { "input": 0.0375, "output": 0.15 @@ -75292,6 +75342,39 @@ }, "type": "chat" }, + { + "id": "nemotron-3-ultra-550b", + "name": "Nemotron 3 Ultra 550B A55B", + "display_name": "Nemotron 3 Ultra 550B A55B", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 128000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-06-04", + "last_updated": "2026-06-04", + "cost": { + "input": 0.5, + "output": 2.5, + "cache_read": 0.15 + }, + "type": "chat" + }, { "id": "minimax-m2.5", "name": "MiniMax-M2.5", @@ -77316,6 +77399,53 @@ }, "type": "chat" }, + { + "id": "kimi-k2.7-code", + "name": "Kimi K2.7 Code", + "display_name": "Kimi K2.7 Code", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 262144 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": true, + "open_weights": true, + "knowledge": "2025-01", + "release_date": "2026-06-12", + "last_updated": "2026-06-12", + "cost": { + "input": 0.95, + "output": 4, + "cache_read": 0.19 + }, + "type": "chat" + }, { "id": "glm-5.1", "name": "GLM-5.1", @@ -78136,7 +78266,7 @@ } }, "attachment": true, - "open_weights": false, + "open_weights": true, "release_date": "2026-06-01", "last_updated": "2026-06-01", "cost": { @@ -81184,6 +81314,57 @@ }, "type": "chat" }, + { + "id": "grok-build-0-1", + "name": "Grok Build 0.1", + "display_name": "Grok Build 0.1", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 256000, + "output": 256000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": false, + "release_date": "2026-04-16", + "last_updated": "2026-04-16", + "cost": { + "input": 1, + "output": 2, + "cache_read": 0.2, + "tiers": [ + { + "input": 2, + "output": 4, + "cache_read": 0.4, + "tier": { + "type": "context", + "size": 200000 + } + } + ], + "context_over_200k": { + "input": 2, + "output": 4, + "cache_read": 0.4 + } + }, + "type": "chat" + }, { "id": "gpt-5-nano", "name": "GPT-5 Nano", @@ -88400,6 +88581,55 @@ "cache_read": 0.16 }, "type": "chat" + }, + { + "id": "kimi-k2.7-code-highspeed", + "name": "Kimi K2.7 Code Highspeed", + "display_name": "Kimi K2.7 Code Highspeed", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 262144 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "fixed", + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": true, + "open_weights": true, + "knowledge": "2025-01", + "release_date": "2026-04-21", + "last_updated": "2026-04-21", + "cost": { + "input": 0.95, + "output": 4, + "cache_read": 0.16 + }, + "type": "chat" } ] }, @@ -105995,8 +106225,8 @@ "attachment": false, "open_weights": true, "knowledge": "2024-06-01", - "release_date": "2024-02-27", - "last_updated": "2024-02-27", + "release_date": "2024-12-02", + "last_updated": "2024-12-02", "cost": { "input": 0.0375, "output": 0.15 @@ -113034,6 +113264,56 @@ }, "type": "chat" }, + { + "id": "kimi-k2.7-code-highspeed", + "name": "Kimi K2.7 Code Highspeed", + "display_name": "Kimi K2.7 Code Highspeed", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 32768 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "fixed", + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": false, + "open_weights": true, + "knowledge": "2025-01", + "release_date": "2026-04", + "last_updated": "2026-04", + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "type": "chat" + }, { "id": "kimi-for-coding", "name": "K2.7 Code", @@ -114465,37 +114745,6 @@ }, "type": "chat" }, - { - "id": "meta-llama/Meta-Llama-3.1-8B-Instruct", - "name": "meta-llama/Meta-Llama-3.1-8B-Instruct", - "display_name": "meta-llama/Meta-Llama-3.1-8B-Instruct", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 33000, - "output": 4000 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": false - }, - "attachment": false, - "open_weights": false, - "release_date": "2025-04-23", - "last_updated": "2025-11-25", - "cost": { - "input": 0.06, - "output": 0.06 - }, - "type": "chat" - }, { "id": "moonshotai/Kimi-K2-Thinking", "name": "moonshotai/Kimi-K2-Thinking", @@ -114790,9 +115039,9 @@ "type": "chat" }, { - "id": "inclusionAI/Ring-flash-2.0", - "name": "inclusionAI/Ring-flash-2.0", - "display_name": "inclusionAI/Ring-flash-2.0", + "id": "google/gemma-4-31B-it", + "name": "Gemma 4 31B IT", + "display_name": "Gemma 4 31B IT", "modalities": { "input": [ "text" @@ -114802,34 +115051,28 @@ ] }, "limit": { - "context": 131000, - "output": 131000 + "context": 262144, + "output": 262144 }, "temperature": true, "tool_call": true, "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } + "supported": false }, "attachment": false, "open_weights": false, - "release_date": "2025-09-29", - "last_updated": "2025-11-25", + "release_date": "2026-04-02", + "last_updated": "2026-04-02", "cost": { - "input": 0.14, - "output": 0.57 + "input": 0.13, + "output": 0.4 }, "type": "chat" }, { - "id": "inclusionAI/Ling-mini-2.0", - "name": "inclusionAI/Ling-mini-2.0", - "display_name": "inclusionAI/Ling-mini-2.0", + "id": "google/gemma-4-26B-A4B-it", + "name": "Gemma 4 26B A4B IT", + "display_name": "Gemma 4 26B A4B IT", "modalities": { "input": [ "text" @@ -114839,8 +115082,8 @@ ] }, "limit": { - "context": 131000, - "output": 131000 + "context": 262144, + "output": 262144 }, "temperature": true, "tool_call": true, @@ -114849,11 +115092,11 @@ }, "attachment": false, "open_weights": false, - "release_date": "2025-09-10", - "last_updated": "2025-11-25", + "release_date": "2026-04-02", + "last_updated": "2026-04-02", "cost": { - "input": 0.07, - "output": 0.28 + "input": 0.12, + "output": 0.4 }, "type": "chat" }, @@ -116213,38 +116456,6 @@ }, "type": "chat" }, - { - "id": "nex-agi/DeepSeek-V3.1-Nex-N1", - "name": "nex-agi/DeepSeek-V3.1-Nex-N1", - "display_name": "nex-agi/DeepSeek-V3.1-Nex-N1", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 131000, - "output": 131000 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "attachment": false, - "open_weights": false, - "release_date": "2025-01-01", - "last_updated": "2025-11-25", - "cost": { - "input": 0.5, - "output": 2 - }, - "type": "chat" - }, { "id": "zai-org/GLM-5V-Turbo", "name": "zai-org/GLM-5V-Turbo", @@ -116698,288 +116909,15 @@ "release_date": "2025-01-20", "last_updated": "2025-11-25", "cost": { - "input": 0.18, - "output": 0.18 - }, - "type": "chat" - }, - { - "id": "deepseek-ai/DeepSeek-V3.1-Terminus", - "name": "deepseek-ai/DeepSeek-V3.1-Terminus", - "display_name": "deepseek-ai/DeepSeek-V3.1-Terminus", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 164000, - "output": 164000 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "attachment": false, - "open_weights": false, - "release_date": "2025-09-29", - "last_updated": "2025-11-25", - "cost": { - "input": 0.27, - "output": 1 - }, - "type": "chat" - }, - { - "id": "deepseek-ai/deepseek-v4-pro", - "name": "DeepSeek V4 Pro", - "display_name": "DeepSeek V4 Pro", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 1000000, - "output": 384000 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } - }, - "attachment": false, - "open_weights": true, - "knowledge": "2025-05", - "release_date": "2026-04-24", - "last_updated": "2026-04-24", - "cost": { - "input": 1.74, - "output": 3.48, - "cache_read": 0.145 - }, - "type": "chat" - }, - { - "id": "deepseek-ai/DeepSeek-V3.1", - "name": "deepseek-ai/DeepSeek-V3.1", - "display_name": "deepseek-ai/DeepSeek-V3.1", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 164000, - "output": 164000 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "attachment": false, - "open_weights": false, - "release_date": "2025-08-25", - "last_updated": "2025-11-25", - "cost": { - "input": 0.27, - "output": 1 - }, - "type": "chat" - }, - { - "id": "deepseek-ai/DeepSeek-V3.2-Exp", - "name": "deepseek-ai/DeepSeek-V3.2-Exp", - "display_name": "deepseek-ai/DeepSeek-V3.2-Exp", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 164000, - "output": 164000 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "attachment": false, - "open_weights": false, - "release_date": "2025-10-10", - "last_updated": "2025-11-25", - "cost": { - "input": 0.27, - "output": 0.41 - }, - "type": "chat" - }, - { - "id": "deepseek-ai/deepseek-vl2", - "name": "deepseek-ai/deepseek-vl2", - "display_name": "deepseek-ai/deepseek-vl2", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 4000, - "output": 4000 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": false - }, - "attachment": true, - "open_weights": false, - "release_date": "2024-12-13", - "last_updated": "2025-11-25", - "cost": { - "input": 0.15, - "output": 0.15 - }, - "type": "chat" - }, - { - "id": "deepseek-ai/DeepSeek-V3.2", - "name": "deepseek-ai/DeepSeek-V3.2", - "display_name": "deepseek-ai/DeepSeek-V3.2", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 164000, - "output": 164000 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "attachment": false, - "open_weights": false, - "release_date": "2025-12-03", - "last_updated": "2025-12-03", - "cost": { - "input": 0.27, - "output": 0.42 - }, - "type": "chat" - }, - { - "id": "deepseek-ai/DeepSeek-V3", - "name": "deepseek-ai/DeepSeek-V3", - "display_name": "deepseek-ai/DeepSeek-V3", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 164000, - "output": 164000 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": false - }, - "attachment": false, - "open_weights": false, - "release_date": "2024-12-26", - "last_updated": "2025-11-25", - "cost": { - "input": 0.25, - "output": 1 - }, - "type": "chat" - }, - { - "id": "deepseek-ai/DeepSeek-R1-Distill-Qwen-14B", - "name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-14B", - "display_name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-14B", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 131000, - "output": 131000 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "attachment": false, - "open_weights": false, - "release_date": "2025-01-20", - "last_updated": "2025-11-25", - "cost": { - "input": 0.1, - "output": 0.1 + "input": 0.18, + "output": 0.18 }, "type": "chat" }, { - "id": "MiniMaxAI/MiniMax-M2.1", - "name": "MiniMaxAI/MiniMax-M2.1", - "display_name": "MiniMaxAI/MiniMax-M2.1", + "id": "deepseek-ai/DeepSeek-V3.1-Terminus", + "name": "deepseek-ai/DeepSeek-V3.1-Terminus", + "display_name": "deepseek-ai/DeepSeek-V3.1-Terminus", "modalities": { "input": [ "text" @@ -116989,13 +116927,187 @@ ] }, "limit": { - "context": 197000, - "output": 131000 + "context": 164000, + "output": 164000 }, "temperature": true, "tool_call": true, "reasoning": { - "supported": true + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": false, + "release_date": "2025-09-29", + "last_updated": "2025-11-25", + "cost": { + "input": 0.27, + "output": 1 + }, + "type": "chat" + }, + { + "id": "deepseek-ai/deepseek-v4-pro", + "name": "DeepSeek V4 Pro", + "display_name": "DeepSeek V4 Pro", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 384000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": false, + "open_weights": true, + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "cost": { + "input": 1.74, + "output": 3.48, + "cache_read": 0.145 + }, + "type": "chat" + }, + { + "id": "deepseek-ai/DeepSeek-V3.1", + "name": "deepseek-ai/DeepSeek-V3.1", + "display_name": "deepseek-ai/DeepSeek-V3.1", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 164000, + "output": 164000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": false, + "release_date": "2025-08-25", + "last_updated": "2025-11-25", + "cost": { + "input": 0.27, + "output": 1 + }, + "type": "chat" + }, + { + "id": "deepseek-ai/DeepSeek-V3.2-Exp", + "name": "deepseek-ai/DeepSeek-V3.2-Exp", + "display_name": "deepseek-ai/DeepSeek-V3.2-Exp", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 164000, + "output": 164000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": false, + "release_date": "2025-10-10", + "last_updated": "2025-11-25", + "cost": { + "input": 0.27, + "output": 0.41 + }, + "type": "chat" + }, + { + "id": "deepseek-ai/deepseek-vl2", + "name": "deepseek-ai/deepseek-vl2", + "display_name": "deepseek-ai/deepseek-vl2", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 4000, + "output": 4000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": true, + "open_weights": false, + "release_date": "2024-12-13", + "last_updated": "2025-11-25", + "cost": { + "input": 0.15, + "output": 0.15 + }, + "type": "chat" + }, + { + "id": "deepseek-ai/DeepSeek-V3.2", + "name": "deepseek-ai/DeepSeek-V3.2", + "display_name": "deepseek-ai/DeepSeek-V3.2", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 164000, + "output": 164000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true }, "extra_capabilities": { "reasoning": { @@ -117004,11 +117116,74 @@ }, "attachment": false, "open_weights": false, - "release_date": "2025-12-23", - "last_updated": "2025-12-23", + "release_date": "2025-12-03", + "last_updated": "2025-12-03", "cost": { - "input": 0.3, - "output": 1.2 + "input": 0.27, + "output": 0.42 + }, + "type": "chat" + }, + { + "id": "deepseek-ai/DeepSeek-V3", + "name": "deepseek-ai/DeepSeek-V3", + "display_name": "deepseek-ai/DeepSeek-V3", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 164000, + "output": 164000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": false, + "release_date": "2024-12-26", + "last_updated": "2025-11-25", + "cost": { + "input": 0.25, + "output": 1 + }, + "type": "chat" + }, + { + "id": "deepseek-ai/DeepSeek-R1-Distill-Qwen-14B", + "name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-14B", + "display_name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-14B", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 131000, + "output": 131000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": false, + "release_date": "2025-01-20", + "last_updated": "2025-11-25", + "cost": { + "input": 0.1, + "output": 0.1 }, "type": "chat" }, @@ -129853,8 +130028,8 @@ }, "attachment": false, "open_weights": true, - "release_date": "2024-02-27", - "last_updated": "2024-02-27", + "release_date": "2024-12-02", + "last_updated": "2024-12-02", "cost": { "input": 0.0375, "output": 0.15 @@ -140077,6 +140252,44 @@ }, "type": "chat" }, + { + "id": "moonshotai/Kimi-K2.7-Code", + "name": "Kimi K2.7 Code", + "display_name": "Kimi K2.7 Code", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-06-14", + "last_updated": "2026-06-14", + "cost": { + "input": 0.95, + "output": 4, + "cache_read": 0.19 + }, + "type": "chat" + }, { "id": "google/gemma-4-31B-it", "name": "Gemma 4 31B Instruct", @@ -186762,6 +186975,45 @@ }, "type": "chat" }, + { + "id": "hf:MiniMaxAI/MiniMax-M3", + "name": "MiniMax-M3", + "display_name": "MiniMax-M3", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 524288, + "output": 65536 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-06-12", + "last_updated": "2026-06-12", + "cost": { + "input": 0.6, + "output": 1.2, + "cache_read": 0.6 + }, + "type": "chat" + }, { "id": "hf:openai/gpt-oss-120b", "name": "GPT OSS 120B", @@ -192398,6 +192650,28 @@ }, "type": "chat" }, + { + "id": "granite4.1-guardian:8b", + "name": "granite4.1-guardian 8b", + "display_name": "granite4.1-guardian 8b", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 16384, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, { "id": "granite4.1:30b", "name": "granite4.1 30b", @@ -194300,6 +194574,94 @@ }, "type": "chat" }, + { + "id": "minicpm-v4.5", + "name": "minicpm-v4.5", + "display_name": "minicpm-v4.5", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 4096, + "output": 2048 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "minicpm-v4.5:8b", + "name": "minicpm-v4.5 8b", + "display_name": "minicpm-v4.5 8b", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 16384, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "minicpm-v4.6", + "name": "minicpm-v4.6", + "display_name": "minicpm-v4.6", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 4096, + "output": 2048 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "minicpm-v4.6:1b", + "name": "minicpm-v4.6 1b", + "display_name": "minicpm-v4.6 1b", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 8192, + "output": 4096 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, { "id": "ministral-3", "name": "ministral-3", @@ -211549,58 +211911,6 @@ }, "type": "chat" }, - { - "id": "glm-5.2", - "name": "GLM-5.2", - "display_name": "GLM-5.2", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 1000000, - "output": 131072 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "default_enabled": true, - "mode": "effort", - "effort": "high", - "effort_options": [ - "high", - "max" - ], - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } - }, - "attachment": false, - "open_weights": false, - "release_date": "2026-06-13", - "last_updated": "2026-06-13", - "cost": { - "input": 0, - "output": 0, - "cache_read": 0, - "cache_write": 0 - }, - "type": "chat" - }, { "id": "glm-5v-turbo", "name": "GLM-5V-Turbo", @@ -212175,9 +212485,9 @@ "type": "chat" }, { - "id": "kimi-k2.7-code", - "name": "Kimi K2.7 Code", - "display_name": "Kimi K2.7 Code", + "id": "kimi-k2.7-code-highspeed", + "name": "Kimi K2.7 Code Highspeed", + "display_name": "Kimi K2.7 Code Highspeed", "modalities": { "input": [ "text", @@ -212348,6 +212658,52 @@ "cache_write": 0.375 }, "type": "chat" + }, + { + "id": "MiniMax-M2.7-highspeed", + "name": "MiniMax-M2.7-highspeed", + "display_name": "MiniMax-M2.7-highspeed", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 204800, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-03-18", + "last_updated": "2026-03-18", + "cost": { + "input": 0.6, + "output": 2.4, + "cache_read": 0.06, + "cache_write": 0.375 + }, + "type": "chat" } ] }, @@ -212601,6 +212957,38 @@ }, "type": "chat" }, + { + "id": "kimi-k2.7-code-highspeed", + "name": "kimi-k2.7-code-highspeed", + "display_name": "kimi-k2.7-code-highspeed", + "modalities": { + "input": [ + "text", + "image", + "video" + ] + }, + "limit": { + "context": 262144, + "output": 262144 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "cost": { + "input": 1.9, + "output": 7.999, + "cache_read": 0.32167 + }, + "type": "chat" + }, { "id": "step-3.7-flash-free", "name": "step-3.7-flash-free", @@ -231967,28 +232355,6 @@ "output": 15 } }, - { - "id": "tts-1-1106", - "name": "tts-1-1106", - "display_name": "tts-1-1106", - "modalities": { - "input": [ - "audio" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 15, - "output": 15 - } - }, { "id": "whisper-1", "name": "whisper-1", @@ -232080,6 +232446,28 @@ "output": 30 } }, + { + "id": "tts-1-hd", + "name": "tts-1-hd", + "display_name": "tts-1-hd", + "modalities": { + "input": [ + "audio" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 30, + "output": 30 + } + }, { "id": "yi-large", "name": "yi-large", @@ -232189,9 +232577,9 @@ "type": "chat" }, { - "id": "tts-1-hd", - "name": "tts-1-hd", - "display_name": "tts-1-hd", + "id": "tts-1-1106", + "name": "tts-1-1106", + "display_name": "tts-1-1106", "modalities": { "input": [ "audio" @@ -232206,8 +232594,8 @@ "supported": false }, "cost": { - "input": 30, - "output": 30 + "input": 15, + "output": 15 } }, { @@ -234148,8 +234536,8 @@ ] }, "limit": { - "context": 1000000, - "output": 65536 + "context": 1048575, + "output": 1048575 }, "tool_call": true, "reasoning": { @@ -240556,7 +240944,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 81920 }, "temperature": true, "tool_call": true, @@ -240592,8 +240980,8 @@ ] }, "limit": { - "context": 262144, - "output": 65536 + "context": 131072, + "output": 131072 }, "temperature": true, "tool_call": true, @@ -248513,71 +248901,6 @@ }, "type": "chat" }, - { - "id": "google/gemma-3-12b-it", - "name": "Google: Gemma 3 12B", - "display_name": "Google: Gemma 3 12B", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 131072, - "output": 8192 - }, - "temperature": true, - "tool_call": false, - "reasoning": { - "supported": false - }, - "attachment": true, - "open_weights": true, - "release_date": "2025-03-13", - "last_updated": "2025-03-13", - "cost": { - "input": 0.05, - "output": 0.1 - }, - "type": "chat" - }, - { - "id": "inclusionai/ling-1t", - "name": "inclusionAI: Ling-1T", - "display_name": "inclusionAI: Ling-1T", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 131072, - "output": 131072 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": false - }, - "attachment": false, - "open_weights": false, - "knowledge": "2025-01-01", - "release_date": "2025-10-09", - "last_updated": "2025-10-09", - "cost": { - "input": 0.56, - "output": 2.24, - "cache_read": 0.11 - }, - "type": "chat" - }, { "id": "inclusionai/ling-2.6-1t", "name": "inclusionAI: Ling-2.6-1T", @@ -248668,45 +248991,6 @@ }, "type": "chat" }, - { - "id": "inclusionai/ring-1t", - "name": "inclusionAI: Ring-1T", - "display_name": "inclusionAI: Ring-1T", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 131072, - "output": 131072 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "attachment": false, - "open_weights": false, - "knowledge": "2025-01-01", - "release_date": "2025-10-12", - "last_updated": "2025-10-12", - "cost": { - "input": 0.56, - "output": 2.24, - "cache_read": 0.11 - }, - "type": "chat" - }, { "id": "inclusionai/ring-2.6-1t", "name": "inclusionAI: Ring-2.6-1T", @@ -249124,7 +249408,7 @@ } }, "attachment": true, - "open_weights": false, + "open_weights": true, "release_date": "2026-05-31", "last_updated": "2026-06-01", "cost": { @@ -249326,6 +249610,55 @@ }, "type": "chat" }, + { + "id": "moonshotai/kimi-k2.7-code-highspeed", + "name": "MoonshotAI: Kimi K2.7 Code HighSpeed", + "display_name": "MoonshotAI: Kimi K2.7 Code HighSpeed", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 262144 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "fixed", + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": true, + "open_weights": true, + "knowledge": "2025-01", + "release_date": "2026-04-21", + "last_updated": "2026-04-21", + "cost": { + "input": 0.95, + "output": 4, + "cache_read": 0.16 + }, + "type": "chat" + }, { "id": "openai/chat-latest", "name": "OpenAI: Chat Latest (GPT-5.5 Instant)", @@ -250899,6 +251232,38 @@ }, "type": "chat" }, + { + "id": "qwen/qwen3-asr-flash", + "name": "Qwen: Qwen3 ASR Flash", + "display_name": "Qwen: Qwen3 ASR Flash", + "modalities": { + "input": [ + "audio" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 53248, + "output": 4096 + }, + "temperature": false, + "tool_call": false, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": false, + "knowledge": "2024-04", + "release_date": "2025-09-08", + "last_updated": "2025-09-08", + "cost": { + "input": 0.032, + "output": 0.032 + }, + "type": "chat" + }, { "id": "qwen/qwen3-14b", "name": "Qwen: Qwen3-14B",