Commit Graph

229 Commits

Author SHA1 Message Date
Phantom
6ec914cf0f refactor(file-entry): rename trashedAt to deletedAt (#15246)
### What this PR does

Before this PR:

- `file_entry` table used `trashed_at` for the soft-delete timestamp,
diverging from every other soft-deletable table in the schema (`agent`,
`assistant`, `message`, `topic`), which all use `deleted_at`.

After this PR:

- `file_entry.deleted_at` (and BO field `deletedAt`) — naming is
consistent across the entire schema.
- Renamed identifiers:
  - Schema field: `trashedAt` → `deletedAt`
  - SQL column: `trashed_at` → `deleted_at`
  - Index: `fe_trashed_at_idx` → `fe_deleted_at_idx`
  - CHECK constraint: `fe_external_no_trash` → `fe_external_no_delete`
- Updated all source files, tests, and architecture docs (including
`v2-refactor-temp/docs/file-manager/`).
- **Intentionally NOT renamed** (out of scope — these are API surface /
concept names, not the column name): `moveToTrash`, `restoreFromTrash`,
`inTrash` (query flag), `isTrashed`, `batchTrash`, `internalTrash`, and
"Trash" as a concept in comments/docs.

Fixes #

### Why we need it and why it was done in this way

The following tradeoffs were made:

- **Scope discipline**: kept the rename strictly at the
column-identifier layer (4 identifiers). Did not change API names or
concept words — switching the "Trash" concept to "Delete" is a larger
semantic change that deserves its own PR.
- **Migration 0026 contains a manual SQL patch.**
drizzle-orm/drizzle-kit issue
[#3653](https://github.com/drizzle-team/drizzle-orm/issues/3653) causes
the SQLite rebuild-table path to drop the leading `ALTER TABLE … RENAME
COLUMN` statement. The generated `INSERT … SELECT "deleted_at" FROM
file_entry` would fail because the source table still has `trashed_at`.
The migration manually prepends an explicit `ALTER TABLE file_entry
RENAME COLUMN trashed_at TO deleted_at;` before the rebuild. Upstream
fix landed in `drizzle-kit@1.0.0-beta`/`rc` but is not backported to the
`0.31.x` stable line we depend on.
- **Why keeping the manual patch is acceptable**: per `CLAUDE.md` § v2
Refactoring, `migrations/sqlite-drizzle/` is throwaway during v2 — it
will be wiped and regenerated as a single clean initial migration from
the final schemas before release. Mid-development DB drift is explicitly
acceptable, and the manual SQL only needs to survive until that
regeneration.

The following alternatives were considered:

- Selecting `create column` in `drizzle-kit generate` instead of `rename
column`: also produces invalid SQL (same root cause — the rebuild path
puts the new column name in the `SELECT` list regardless of the rename
mapping). Rejected.
- Skipping the `0026` migration entirely and relying on `db:push` / DB
reset during dev: pollutes `_journal.json` divergence and makes the next
schema change confusing. Rejected.
- Upgrading to `drizzle-kit@1.0.0-beta`/`rc` to get the fix: v1 is a
major rewrite with significant breaking changes (alternation engine
rewrite, ORM type system rewrite, migration folder layout change). Out
of scope for this PR. Rejected.

Links to places where the discussion took place: N/A

### Breaking changes

None. Dev-only DB column rename during v2 refactor. No user-visible
behavior change. No public API surface change. v1 data never reaches
this branch except through migrators in `src/main/data/migration/v2/`.

### Special notes for your reviewer

- The single manual edit to drizzle-generated SQL is in
`migrations/sqlite-drizzle/0026_sturdy_aqueduct.sql` — look for the
`MANUAL PATCH` comment block at the top. Without it the migration will
fail to apply.
- "Trash" concept words still appear throughout the file-manager
codebase by design (function names, comments, docs section headings). If
we later want to migrate the whole concept to "Delete", that should be a
follow-up PR.

### Checklist

This checklist is not enforcing, but it's a reminder of items that could
be relevant to every PR.
Approvers are expected to review this list.

- [x] PR: The PR description is expressive enough and will help future
contributors
- [x] Code: [Write code that humans can
understand](https://en.wikiquote.org/wiki/Martin_Fowler#code-for-humans)
and [Keep it simple](https://en.wikipedia.org/wiki/KISS_principle)
- [x] Refactor: You have [left the code cleaner than you found it (Boy
Scout
Rule)](https://learning.oreilly.com/library/view/97-things-every/9780596809515/ch08.html)
- [x] Upgrade: Impact of this change on upgrade flows was considered and
addressed if required
- [ ] Documentation: A [user-guide update](https://docs.cherry-ai.com)
was considered and is present (link) or not required. Check this only
when the PR introduces or changes a user-facing feature or behavior.
- [x] Self-review: I have reviewed my own code (e.g., via
[`/gh-pr-review`](/.claude/skills/gh-pr-review/SKILL.md), `gh pr diff`,
or GitHub UI) before requesting review from others

### Release note

```release-note
NONE
```

---------

Signed-off-by: icarus <eurfelux@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:04:36 +08:00
fullex
5be2215d6d refactor(job): route writes through DbService.withWriteTx
Migrate all JobManager state writes to the new serialized write channel so
they share a single libsql safety boundary, replacing the removed
JobManager.globalDispatchMutex.

Two-form DAO pattern in JobService / JobScheduleService:

- *Tx methods take a DbOrTx and compose inside withWriteTx.
- Non-Tx methods become thin wrappers — callsite shape unchanged so
  consumers do not need to know about withWriteTx.

JobManager changes:

- Delete globalDispatchMutex; dispatch claim now runs inside withWriteTx,
  with queue.mutex preserved as Layer 1 per-queue tick serialization
  (lock acquisition order: Layer 1 first, Layer 0 via withWriteTx).
- All remaining writes (scheduleRetry, finalizeJob, patchMetadata, cancel,
  cancelMany) route through withWriteTx.
- scheduleRetry now degrades to finalizeJob('failed', retryable=true) on
  persistent non-BUSY tx failure. BUSY is already handled by withWriteTx;
  this fallback defends against SQLITE_CORRUPT / FULL / driver bugs that
  would otherwise leave a row stuck in 'running'. finalizeJob's existing
  synthesizeFailedSnapshot still releases the in-memory slot when its own
  tx fails.
- spawnExecute's fire-and-forget IIFE gets a two-stage .catch chain so
  any leaked exception (logger errors, fallback finalize failures) cannot
  surface as UnhandledPromiseRejection.

Stranded `running` rows from any double-failure path are reclaimed by
runStartupRecovery on the next process start.

Updates concurrency-and-locks.md to reflect Layer 0's new home in
DbService. Extends JobManager.integration.test.ts with two regression
tests: scheduleRetry fallback degrade and spawnExecute outer-catch
swallow.
2026-05-21 04:55:05 -07:00
fullex
fd81de8a32 feat(db-service): add withWriteTx for serialized writes (libsql #288)
libsql client-ts upstream issue #288 makes PRAGMA busy_timeout ineffective
for async transactions, so concurrent db.transaction() calls reliably surface
SQLITE_BUSY. Introduce DbService.withWriteTx as a serialized write helper:

- Process-wide FIFO mutex (async-mutex) serializes write transactions.
- libsql client's default BEGIN IMMEDIATE protects against read-then-write
  tx upgrade failures (no override needed at the drizzle layer).
- Single 50ms BUSY retry guards against transient external locks.

Reads do NOT need this — WAL gives readers snapshot isolation that is never
blocked by writers.

Includes unit tests (FIFO ordering, finally release on throw, single BUSY
retry, persistent BUSY rethrow, non-BUSY passthrough) plus a real-libsql
integration test. Updates the DbService test mock with a passthrough
withWriteTx so dependent services do not throw "is not a function" in
tests. Documents the API in database-patterns.md and points
CLAUDE.md / data-api-overview.md at the new pattern.
2026-05-21 04:54:43 -07:00
fullex
4c156c5eea refactor(job): collapse jobs DataApi to read-only and relocate main-only types
- Remove POST /jobs (enqueue) and DELETE /jobs/:id (cancel); Job DataApi
  is GET-only. Business services call JobManager.enqueue/.cancel in main;
  renderer-initiated triggering goes through a dedicated IPC channel.
- Move Trigger / RetryPolicy / CatchUpPolicy / JobScheduleSnapshot /
  Create+Update JobScheduleDto / JobScheduleNameAtomSchema /
  JOB_ERROR_CODES from packages/shared/data/api/schemas/jobs.ts into
  src/main/core/job/{scheduleTypes,errorCodes}.ts. These are
  main-process-only types that never cross IPC.
- shared/jobs.ts retains JobSnapshot, JobProgress, JobError,
  ListJobsQuerySchema, JobSchemas (GET-only) — the actual renderer
  surface useJob/useJobProgress consume via DataApi + cache.
- Update DataApi anti-patterns table and job-and-scheduler docs to
  reference IPC channels for renderer-initiated triggering.

shared/jobs.ts: 316 → 139 lines
handler/jobs.ts: 102 → 43 lines
2026-05-21 02:28:06 -07:00
fullex
72b9105364 refactor(lifecycle): make onAllReady a fire-and-forget supplement
`LifecycleManager.allReady()` was holding `await Promise.allSettled(_doAllReady)`,
making every `onAllReady` body a synchronous bootstrap dependency. This
contradicted the hook's JSDoc ("post-bootstrap supplement, not a critical
initialization gate") and gave any in-hook deferred work an oversize blast
radius — a 60 s wait inside one service stalled `ALL_SERVICES_READY` and
delayed bootstrap completion.

Align the implementation with the JSDoc:
- `allReady()` becomes `void`. It synchronously invokes every initialized
  service's `_doAllReady()`, attaches an async `.catch` that re-emits
  `SERVICE_ERROR`, then emits `ALL_SERVICES_READY` immediately.
- `Application.bootstrap()` drops its `await` on `allReady()`.
- `LifecycleManager` tests adjusted: drop redundant `await`s, rewrite
  `resolves.toBeUndefined()` as `not.toThrow()`, drain microtasks before
  asserting on the now-async `SERVICE_ERROR` emit, and add a test
  exercising the fire-and-forget contract with a never-resolving hook.

`ALL_SERVICES_READY` now fires when hooks are *invoked*, not when they
complete. Docs reflect the contract change: a "Hook vs Event" comparison
in `lifecycle-overview.md`, two new Common Mistakes in
`lifecycle-decision-guide.md`, and an `onAllReady` business-work pattern
template in `lifecycle-usage.md` showing the \`setTimeout\` + signal +
\`onStop\` join model used by JobManager.
2026-05-20 06:14:24 -07:00
fullex
9f8c654907 refactor(job): decouple startup recovery from onAllReady hook
`onAllReady` is a post-bootstrap supplement, but JobManager was holding
a 60 s `await setTimeout` plus four IO steps inside it. The hook became
a 60 s blocking call and left `onStop` without a join point for the
deferred recovery.

Split the two concerns:
- `onAllReady` is now a synchronous `void` that schedules a `setTimeout`
  (cleared via `registerDisposable`) and returns.
- The recovery body moves to a private `runStartupRecoveryFlow` whose
  every IO step re-checks `_isShuttingDown`.
- A new `_recoveryDone: Promise<void> | undefined` lets `onStop` join
  the flow before tearing down dependent resources.
- `_onAllReadyStopRequested` is renamed `_isShuttingDown` to reflect
  its expanded role.
- `detectAndDispatchOverdue` gains a per-loop shutdown check so a slow
  `onMissed` cannot starve `onStop`.

Test fixtures (smoke / schedule / integration) now access `_recoveryDone`
after fake-timer advancement instead of awaiting the lifecycle hook.

Docs synced: new "Startup Recovery" section in
`job-and-scheduler/overview.md`; "Registration timing" + recovery
internals + timeout-sentinel notes in `handler-authoring.md`; `once` /
`interval` trigger lifetime in `scheduler-usage.md`; lock acquisition
order in `concurrency-and-locks.md`. Module READMEs add pointers.
2026-05-20 06:11:23 -07:00
fullex
c335035841 feat(job): resurrect queues on startup recovery
JobManager.onAllReady now walks distinct (queue, type) pairs for all
non-terminal rows and calls ensureQueue() for each. Previously
dispatchAll() iterated an empty this.queues Map on cold start, so rows
reset by runStartupRecovery stayed pending until the next enqueue
incidentally ensured the right queue. This bridges that gap so recovered
jobs dispatch on the same tick recovery finishes.

Additional changes bundled in:
- JobService.getDistinctActiveQueues: SQL groupBy(queue, type) over
  pending/delayed/running, returned shape aligned with ensureQueue input
- Integration tests: retry/singleton cases extended to assert the full
  recovery -> resurrect -> dispatch -> completed chain; new case for
  resurrection of a pre-existing pending row; bootstrapManager drains
  the dispatch chain under fake timers before switching back to real
  timers so handler internal setTimeouts are not cancelled mid-flight
- useJob: add sibling useJobProgress hook (no DataApi fallback; reads
  only the shared cache that JobManager publishes via reportProgress)
- handler-authoring docs: add organization convention (handlers live in
  <module>/tasks/, file naming *JobHandler.ts)
- JobManager.makeError: replace repeated (err as Error & {...})
  assignments with Object.assign for equivalent behaviour in one expr
2026-05-20 01:10:26 -07:00
fullex
5b929d2f87 feat(job): rework schedule lifecycle and startup recovery
Four backbone changes that close known gaps in the schedule subsystem before the next round of business handler work:

* DB-enforced singleton: drop the app-layer Mutex in JobScheduleService and let UNIQUE(type, name) enforce "one singleton per type" via a '' sentinel. rowToSnapshot maps '' back to null so the external snapshot contract stays `string | null`.
* updateJobSchedule public API: writes the DB row and re-arms the in-process cron entry when trigger or enabled changes. Field-presence check avoids JSON.stringify brittleness; the one-turn race is an accepted last-writer-wins limitation.
* Startup recovery gating: move runStartupRecovery + armSchedule from onReady to a new onAllReady behind a 60s delay so business services have a window to register handlers before recovery runs. onStop flips a stop flag for clean teardown; per-step try/catch so one failure does not zero the session.
* Schedule control API tests: cover pause/resume/triggerNow/unregister × by-id/by-name plus updateJobSchedule branch matrix; JobScheduleService gets its own unit suite for singleton/sentinel behavior.

Side effects: add JOB_SCHEDULE_NAME_INVALID code; fix pre-existing arg order on DataApiErrorFactory.conflict at unique handlers (message, then resource); tighten getByTypeAndName signature to (type, name: string); listNamesForType filters the singleton sentinel and returns string[]; sync existing integration/smoke fixtures to await _doAllReady() with fake timers; add "Schedule identity: (type, name) model" section in handler-authoring.md.
2026-05-19 22:49:24 -07:00
fullex
1ab5894dd8 fix(job): state-machine races, error propagation, drizzle JSON codec
Comprehensive Phase 1 review pass — verified each finding against actual
code before fixing. All 9398 existing tests + 35 new tests pass.

Critical races + leaks:
- cancel() pending→running race (claimNextPendingTx WHERE filters
  cancelRequested=false; recovery step 1 extended to cover pending)
- cancel-timeout enforcement after cross-restart dispatch (new
  inFlightExecuted map populated by spawnExecute, decoupled from
  finishedResolvers which only enqueue paths fill)
- cron schedule fire infinite loop on enqueue failure (structured err
  {code, stack} + finally markFired advances nextRun even on failure)
- finalizeJob tx failure: queue slot leak + unresolved handle (synthesize
  failed snapshot, kick dispatch, resolve awaiter)
- patchMetadata in-memory mutation before tx (reorder: await first)

State machine + error propagation:
- @DependsOn declares same-phase only (DbService/CacheService are BeforeReady)
- catchUp computes overdue per trigger kind (interval was silently skipped
  because Scheduler.getNextRun returns null for non-cron)
- triggerJobScheduleNowById writes markFired on non-cron fallback
- recovery sweeps delayed/pending orphans (was running-only — silent leak)
- onReady runs catch-up BEFORE arm (avoids cron protect:true race)
- finalizeJob no-snapshot path: warn + synthetic resolve instead of silent
- DataApi translates JOB_* codes via DataApiErrorFactory.invalidOperation
  (was generic 500 with code buried in message)
- runGC: per-step try/catch (single prune failure no longer aborts sweep)
- JobManager.parseJson warn-logs corrupt rows (mirrors JobService)
- Sentinel JobHandlerTimeoutError + controller.signal.reason classify
  abort/timeout/threw (replaces fragile substring matching)
- Cancel error message sourced from signal.reason (preserves user reason)

Type + schema integrity:
- EnqueueOptions runtime checks for maxAttempts/timeoutMs >= 1
- rowToSnapshot validates trigger/catchUpPolicy/error via zod safeParse
  (schema drift produces typed sentinel + warn, not silent cast)
- JobScheduleService.create: per-type Mutex serializes single-instance
  existence check + insert (SQLite NULL uniqueness cannot enforce)
- JobContext.metadata: Object.freeze (was compile-only Readonly<>)

Drizzle JSON codec (client-side, no SQL schema change):
- 8 JSON columns on jobTable/jobScheduleTable adopt
  text({ mode: 'json' }).$type<>(); drizzle handles stringify/parse
- Deleted 3 duplicate parseJson implementations
- rowToSnapshot / spawnExecute / patchMetadata / setMetadataTx /
  insertSchedule / cancelByIds / cancelManyTx / setTerminalTx /
  setDelayedRetryTx all simplified — no manual stringify/parse
- SQLite layer unaffected (still TEXT); db:migrations:generate confirms
  "No schema changes"

Tests:
- SchedulerService unit suite — 16 tests (was 0 production coverage)
- catchUp pure-function tests — 9 tests covering cron/interval/once
  overdue logic and skip-missed/after-startup policies
- computeBackoff pure-function tests — 7 tests for all 3 backoff kinds
  + maxDelayMs clamping (extracted to runtime/backoff.ts)
- jobRegistry declaration-merging contract test — 3 expectTypeOf assertions
- Shared drainTrailingDispatch hoisted to __tests__/_helpers.ts
- Test teardowns surface _doStop errors (was silent .catch(() => {}))
- Strengthened smoke cancel assertion (retryable + reason in message)

Polish:
- DEFAULT_CANCEL_TIMEOUT_MS extracted as named constant
- "Phase 1" temporal markers removed from long-lived JSDoc
- Singleton recovery: id DESC tiebreaker for same-millisecond createdAt
- triggerJobScheduleNowById JSDoc documents fallback semantics
- armDelayedJob JSDoc cross-references reserved-prefix table
- Doc errors fixed: listJobSchedules JSDoc filter dimensions corrected;
  broken Chinese plan-doc references in scheduler-usage.md inlined as
  English rationale
2026-05-19 07:24:27 -07:00
fullex
2036aaa915 docs(job): add module READMEs, scheduler-usage guide, and public-API JSDoc
Documentation pass that wraps up Phase 1 by surfacing how to find and use
the Job/Scheduler system, plus bringing the public-API JSDoc in line with
the project convention (summary + @param/@returns/@throws tags).

- Add `src/main/core/job/README.md` and `src/main/core/scheduler/README.md`
  as thin pointers into `docs/references/job-and-scheduler/`, matching the
  Lifecycle/Application README style (file structure + Quick Links table,
  no duplicated content).
- Add `docs/references/job-and-scheduler/scheduler-usage.md` documenting
  the decision tree for `SchedulerService` vs `BaseService.registerInterval`
  vs raw `setInterval`, the three judgment questions, common mistakes,
  and reserved id prefixes used internally by JobManager.
- Tighten `overview.md` so the dispatch lock acquisition order matches the
  code: Layer 1 (per-queue) first, then Layer 0 (global libsql tx). Make
  the fixed order explicit to prevent future deadlock-introducing refactors.
- Upgrade public-API JSDoc on JobManager (registerHandler / enqueue / cancel
  / cancelMany / get / list / dispatch + full schedule registry: by-id and
  by-name variants) with @param / @returns / @throws including error codes
  (JOB_UNKNOWN_TYPE / JOB_PAYLOAD_TOO_LARGE / JOB_CANCEL_REASON_TOO_LONG /
  JOB_SCHEDULE_NAME_REQUIRED / JOB_SCHEDULE_NOT_FOUND_BY_NAME /
  JOB_SCHEDULE_NAME_CONFLICT / JOB_SCHEDULE_SINGLETON_EXISTS).
- Upgrade SchedulerService public-API JSDoc on registerSchedule / pause /
  resume / unregister / triggerNow / getNextRun / has with @param /
  @returns; tighten the class-level doc to describe `protect: true`
  semantics and the chained-setTimeout interval implementation.
- Add descriptive block comments to private state-machine transition
  helpers (finalizeJob / scheduleRetry / armSchedule / detectAndDispatch-
  Overdue / runGC / handleFor / publishState / computeBackoff / dispatchAll)
  to document non-obvious design decisions, and inline-document the fixed
  lock acquisition order inside `dispatch` to prevent accidental reversal.

No runtime behavior change. lint / format / docs:check-links all clean.
2026-05-19 05:20:29 -07:00
fullex
344c58eb8e feat(job): introduce JobManager + SchedulerService backbone (Phase 1)
cherry-studio v2 had 6+ ad-hoc queue/scheduler implementations (Knowledge,
FileProcessing, agent SchedulerService, TopicQueue, NotificationQueue,
protocol heartbeats) with no shared registry, inconsistent cancel and
progress semantics, and no cross-restart recovery outside agent_task.
This commit lands the unified replacement: JobManager owns Job lifecycle
and SchedulerService owns time-only scheduling, both reusable independently.

Phase 1 ships the backbone only: jobTable + jobScheduleTable, entity
services, 6-state machine with per-handler recovery (abandon/retry/
singleton), catch-up policy (skip-missed/after-startup), retry backoff,
GC, idempotencyKey dedup, useJob renderer hook, and 4 reference docs.
Four-layer lock model addresses libsql client-ts issue #288 (Layer 0
global dispatch mutex serializes all transactions).

croner@^10 is introduced for cron expressions (zero-dep, Electron-friendly).
Application.SHUTDOWN_TIMEOUT_MS is promoted to public for JobManager reuse.
cron-parser stays until Phase 2 agent task migration.

No business migrations in this commit — Knowledge/FileProcessing/agent
SchedulerService remain untouched and migrate in Phase 2-4 per
docs/references/job-and-scheduler/migration-checklist.md.

Follow-ups for Phase 1 completion: DataApi GET /jobs handler, dummy.echo
smoke test, integration tests, migration feasibility report.
2026-05-19 03:57:18 -07:00
SuYao
e93c8cd943 feat(provider-settings): grouping + mode-based editor follow-up (#15088)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: suyao <sy20010504@gmail.com>
2026-05-18 20:57:26 +08:00
jidan745le
e59c7aa7f7 refactor(provider-settings): migrate to v2 data layer and UI (#14631)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: suyao <sy20010504@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: fullex <106392080+0xfullex@users.noreply.github.com>
Signed-off-by: jidan745le <420511176@qq.com>
Signed-off-by: suyao <sy20010504@gmail.com>
2026-05-14 17:01:33 +08:00
fullex
de1d85d059 docs(data-api): clarify schema file organization rule
The rule that schema files in packages/shared/data/api/schemas/ are
organized by the entity's domain (not URL prefix) was followed in code
but never written down, so readers could reasonably misinfer "the URL
parent decides the file" from routes like /topics/:topicId/messages
living in messages.ts.

Add a Schema File Organization section to api-types.md, with a small
table comparing routes whose URL parent and returned entity disagree,
and a cross-reference note from api-design-guidelines.md so path-design
readers land on the right page.
2026-05-13 06:56:22 -07:00
Phantom
d2c568e349 feat(file): Add schema and foundation for new file module (#13451)
### What this PR does

Adds the **Phase 1a contract surface** for the file module — types, DB
schema, DataApi + File IPC contracts, FileManager skeleton, and
architecture docs.

**Phase 1b.1 (Read Path & Repository), 1b.2 (Write Path & Lifecycle),
1b.3 (Watcher & DanglingCache), and 1b.4 (OrphanSweep &
FileRefCheckerRegistry) are now all landed on top of 1a in this same
PR.** This is the complete Phase 1b runtime — reviewers see the full
read + write + watcher + orphan-sweep picture in one place.

Design, contracts, and decision rationale live in the architecture docs:

-
[`docs/references/file/architecture.md`](docs/references/file/architecture.md)
— module boundaries, type system, IPC/DataApi contracts, layered
architecture, service lifecycle, mutation propagation
-
[`docs/references/file/file-manager-architecture.md`](docs/references/file/file-manager-architecture.md)
— FileManager internals (storage, version detection, atomic writes,
reference cleanup, DirectoryWatcher, orphan sweep, DanglingCache state
machine, key design decisions)

#### Phase 1a deliverables

- Types (`FileEntry` / `FileInfo` / `FileHandle` /
`CanonicalExternalPath` brand)
- DB schema (`file_entry` + `file_ref`) with per-origin CHECK
constraints
- DataApi schemas + stub handlers
- File IPC contract (polymorphic `FileHandle` dispatch;
`batchGetMetadata` included)
- FileManager skeleton + `internal/*` + `ops/*` + `watcher/` +
`DanglingCache` + `versionCache`
- Mutation propagation design (three typed events + prefix-based
queryKey invalidation)

#### Phase 1b.1 deliverables (read-path runtime)

- Shared utilities: `getFileTypeByExt`, `sanitizeFilename`,
`validateFileName` extracted to `@shared/file/types`
- Path utilities: `canonicalizeExternalPath` (NFC + null-byte guard +
trailing-sep strip), `isPathInside`, `isUnderInternalStorage`,
`canWrite`
- FS read primitives: `stat`, `exists`, `read` (text/base64/binary
overloads), `hash` (initial MD5; swapped to xxhash-h64 in 1b.2)
- Metadata utilities: `getFileType(path)`, `isTextFile`, `mimeToExt`
- Repositories: `FileEntryService` + `FileRefService` read methods
(Drizzle-backed, Zod-branded outputs)
- Pure-function modules: `internal/content/{read,hash}`,
`internal/dispatch.ts` (FileHandle dispatcher)
- `toFileInfo(entry)` projection
- `FileManager` class as `BaseService` (`@Injectable('FileManager')`
`@ServicePhase(WhenReady)`); read methods only
- `DanglingCache` + `VersionCache` minimal viable singletons (full impls
in 1b.3 / 1b.2)
- DataApi `/files/*` read handlers fully implemented (entries / single /
ref-counts / refs-by-source)
- 60+ TDD tests (unit + boundary + setupTestDatabase integration)

#### Phase 1b.2 deliverables (write-path runtime)

- **FS atomic primitives** (open to non-file-module consumers per
architecture §5.3): `atomicWriteFile` (tmp + fsync + rename +
fsync(dir)), `atomicWriteIfUnchanged` (re-stat OCC + content-hash
fallback for second-precision mtime), `createAtomicWriteStream`
(Writable wrapper, abort/destroy unlinks tmp)
- **FS general primitives**: `write` (delegates to atomicWriteFile),
`copy` (atomic dest), `move` (rename + EXDEV → copy+unlink fallback),
`remove` (idempotent ENOENT), `mkdir` / `ensureDir` / `removeDir`,
`download` (fetch → atomic stream)
- **Hash swap**: `hash()` migrated MD5 → `xxhash-wasm` h64 streaming;
legacy `md5` dep retained for KnowledgeService loaders
- **VersionCache LRU**: capacity-bounded (default 2000) with
re-insert-on-touch recency
- **Repository mutations**: `FileEntryService.create/update/delete`
(auto UUIDv7 default id, raw DB CHECK errors propagate);
`FileRefService.create/createMany/cleanupBySource/cleanupBySourceBatch`
(`onConflictDoNothing` for batch upsert)
- **internal/entry/**: `create.createInternal` (4 source variants: bytes
/ base64 / path / url) + `create.ensureExternal` (canonicalize + stat +
idempotent upsert + duplicate-suspect peer warn);
`lifecycle.trash/restore/permanentDelete` + batch variants (DB+FS
decoupled, internal best-effort unlink); `rename` (internal DB-only,
external fs.move + canonical externalPath); `copy` (pipes through
createInternal with rollback)
- **internal/content/write.ts**: `write` / `writeIfUnchanged`
(cache-not-trusted re-stat OCC, `StaleVersionError` rewrap from
`PathStaleVersionError`) / `createWriteStream` / `*ByPath` variants
- **internal/system/**: `shell.open` / `shell.showInFolder` (electron
`shell` wrappers); `tempCopy.withTempCopy` (isolated tmp dir; cleanup on
throw)
- **FileManager facade**: every IFileManager mutation method now
delegates to its `internal/*` counterpart (`createInternalEntry` /
`ensureExternalEntry` / `batchCreate*` / `batchEnsure*` / `write` /
`writeIfUnchanged` / `createWriteStream` / `createReadStream` / `trash`
/ `restore` / `permanentDelete` + batch / `rename` / `copy` /
`withTempCopy` / `open` / `showInFolder`); no method throws
notImplemented anymore
- ~60 new TDD tests (each behavior unit = one RED→GREEN→REFACTOR
commit); end-to-end integration scenarios via `setupTestDatabase` cover
atomic-rollback zero-residue, OCC second-precision-mtime
no-false-positive, trash-external CHECK enforcement, full
create→write→read→trash→restore→permanentDelete round-trip, and external
permanentDelete-leaves-user-file-untouched

#### Phase 1b.3 deliverables (watcher + DanglingCache observability)

- **DanglingCache class** (replaces 1a const-literal skeleton):
`byEntryId: Map<entryId, CachedState>` + `pathToEntryIds:
Map<canonicalPath, Set<entryId>>` reverse index, lazy TTL expiration
(default 30 min per architecture §11.2), `forceRecheck` escape hatch,
`Emitter<DanglingStateChangedEvent>` firing only on genuine state
transitions (same-state observations are silent). Injectable `now` /
`statProbe` / `ttlMs` / `fileEntryService` seams for deterministic
tests.
- **createDirectoryWatcher** chokidar v4 wrapper: `add` / `unlink` /
`change` / `ready` / `error` events; built-in OS-junk basename ignores
(`.DS_Store` / `.localized` / `Thumbs.db` / `desktop.ini`); idempotent
`close()`. Factory auto-wires `add` →
`danglingCache.onFsEvent(path,'present')` and `unlink` → `'missing'`.
(Architecture §8.2's richer `onAddDir`/`onUnlinkDir`/`onRename` events
deferred — no consumer needs them in scope.)
- **Reverse-index maintenance from mutation flows**: `ensureExternal`
calls `addEntry` + `onFsEvent('present','ops')` on insert (no-op on
reuse); `permanentDelete(external)` calls `removeEntry`;
`rename(external)` swaps `removeEntry(oldPath) + addEntry(newPath) +
onFsEvent(newPath,'present','ops')`.
- **FileManager surface**: `getDanglingState({id})` (internal →
'present', external → cache check, unknown id → 'unknown');
`batchGetDanglingStates({ids})` (parallel fan-out, unknown ids mapped to
'unknown'); `subscribeDangling({id}, listener)` (in-process per-entry
filter; renderer fan-out via `file-manager-event` IPC channel deferred
to Phase 2).
- **FileManager.onInit**: awaits `danglingCache.initFromDb()` (populates
reverse index from non-trashed external entries; no startup stat probe
per architecture §10.6); registers `File_GetDanglingState` /
`File_BatchGetDanglingStates` IPC handlers via `this.ipcHandle`
(auto-disposed on stop).
- New `IpcChannel` constants: `File_GetDanglingState`,
`File_BatchGetDanglingStates`.
- ~30 new TDD tests across DanglingCache (18 unit) + watcher (6 real-FS)
+ FileManager integration (INT-7..INT-10).

#### Phase 1b.4 deliverables (orphan sweep + FileRefCheckerRegistry)

- **FileRefCheckerRegistry**: `Record<FileRefSourceType,
SourceTypeChecker<...>>` typed registry forces exhaustive coverage at
compile time — adding a new variant to `FileRefSourceType` without a
checker triggers a TS build error. Phase 1 ships `FileRefSourceType =
'temp_session' | 'knowledge_item'`: real DB-backed checker for
`knowledge_item` (Drizzle `inArray` against `knowledge_item`);
`temp_session` checker treats every sourceId as gone (sessions are
in-memory only). `chat_message` / `painting` / `note` are **deliberately
not in the union yet** — each will be added in lockstep (tuple entry in
`allSourceTypes` + `createRefSchema` variant + `SourceTypeChecker`) by
the PR that migrates the owning domain's DB tables to v2. Stray writes
during the migration window fail fast at `FileRefSchema.parse` rather
than being silently persisted under a no-op stub.
- **OrphanRefScanner** (RFC §6.4): `scanOneType(sourceType)` enumerates
distinct `file_ref.sourceId` per type, asks the checker which are alive,
deletes the rest via `cleanupBySourceBatch`. `scanAll()` aggregates
across every registered sourceType. Backed by new
`FileRefService.listDistinctSourceIds` to keep all SQL inside the repo.
- **Report-only orphan-entry pass** (architecture §7.1 default policy is
"preserve"): `scanOrphanEntries` groups active entries with zero
`file_ref` rows by origin. **No deletion** — surfaced via
`getOrphanReport()` for the cleanup-UI consumer. Backed by new
`FileEntryService.findUnreferenced` LEFT JOIN-based query.
- **Startup file sweep** (architecture §10): `runStartupFileSweep`
snapshots `file_entry.id` (active + trashed) into a `Set` via new
`FileEntryService.listAllIds`, walks `{userData}/files/`, plans unlink
for (a) UUID-named files whose id is not in the snapshot and (b)
`*.tmp-<UUID>` atomic-write residue. Applies the `mtime > 5min`
freshness gate (§10.3) — files newer than that are presumed in-flight
and preserved. Plan-then-execute with the `50% / 20-count-floor /
10MB-floor` safety threshold (§10.4); aborts emit
`abortReason='count-fraction'|'byte-fraction'`. Single structured
`orphan-file-sweep` log per run (info / warn / error per outcome,
§10.5).
- **DB-sweep umbrella + observability** (`runDbSweep`): runs scanAll +
scanOrphanEntries, emits one `orphan-sweep` structured record
summarising both passes; failure path returns `outcome='failed'` +
`errorMessage` so callers don't throw on background fire-and-forget.
- **FileManager integration**: `onInit` schedules a fire-and-forget
`runStartupSweeps` that runs the FS-level + DB-level sweeps in parallel;
failures of either are logged but never block ready. `getOrphanReport()`
exposes the most recent `DbSweepReport` (orphan-ref counts already
cleaned + orphan-entry counts preserved) + `lastRunAt` for the cleanup
UI surface.
- ~30 new TDD tests across registry (14 unit) + orphan sweep (16 unit +
integration) + FileManager integration (INT-11/INT-12) + repo
(`findUnreferenced`, `listAllIds`, `listDistinctSourceIds`).

**Out of scope (deferred to Phase 2)**:
- Architecture §7.2 dangling-external auto-cleanup (external + missing +
0-ref + >30d retention) — narrow extension shipping with the cleanup UI.
- Adding `chat_message` / `painting` / `note` as `FileRefSourceType`
variants (tuple entry + schema + checker added together) — gated on each
domain's v2 batch migration.
- Cleanup-UI surface that consumes `getOrphanReport()` — Phase 2
renderer work.

Renderer-side File IPC bridge for write/dangling methods stays deferred
to Phase 2 alongside the consumer-batch migrations. The Phase 1b runtime
is consumable from main-side business services through
`application.get('FileManager')`.

### Why we need it and why it was done in this way

Contract-first concentrates design review in one place; Phase 1b.x then
becomes pure "honor the contracts". Each 1b.x phase keeps strict TDD
(RED → GREEN → REFACTOR per behavior, ~one commit per cycle); each phase
ends with a verification gate (push → CI green) before the next phase
begins.

Core decisions (origin two-state, `FileEntry`/`FileInfo` split, DataApi
SQL-only, external `permanentDelete` DB-only, TTL `DanglingCache`, OCC
trust boundary, atomic write fsync default, etc.) and their rationale
are recorded in `file-manager-architecture.md §12 Key Design Decisions`
— not duplicated here.

### Breaking changes

None — purely additive (read+write paths are new, no existing callers
replaced yet).

### Special notes for your reviewer

- Review focus: contracts (1a) + read-path runtime (1b.1) + write-path
runtime (1b.2) + watcher/DanglingCache (1b.3) + orphan sweep / registry
(1b.4). Phase 1b is now complete on this branch.
- **Phase 1a contract stability policy** (architecture.md top) is
binding — any 1b.x PR that finds a contract mismatch PRs the doc
revision first.
- Deferred (Phase 2): renderer-side File IPC bridge for
write/dangling/orphan methods (alongside consumer migration); cleanup-UI
surface consuming `getOrphanReport()`; architecture §7.2
dangling-external auto-cleanup (>30d retention); adding `chat_message` /
`painting` / `note` as `FileRefSourceType` variants (each adds tuple
entry + schema + checker in lockstep, gated on the owning domain's v2
batch migration); DanglingCache periodic snapshot logger (architecture
§11.8); `listDirectory` ripgrep wrapper; `compressImage`
(KnowledgeService consumer); FileUploadService + `file_upload` table
(Vercel AI SDK Files API).

### Checklist

- [x] PR: The PR description is expressive enough and will help future
contributors
- [x] Code: Write code that humans can understand and Keep it simple
- [x] Refactor: You have left the code cleaner than you found it (Boy
Scout Rule)
- [ ] Upgrade: N/A — purely additive
- [ ] Documentation: Internal architecture docs included
(`docs/references/file/`); no user-facing docs change
- [x] Self-review: I have reviewed my own code before requesting review
from others

### Release note

```release-note
NONE
```

---------

Signed-off-by: icarus <eurfelux@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: fullex <106392080+0xfullex@users.noreply.github.com>
2026-05-13 16:06:10 +08:00
fullex
e1ba04463e docs(data-api): codify registry sub-resource & nullability conventions
- data-api-in-main: add Registry Sub-Resource Endpoints section -
  GET-only for stateless reads, AIP-136 colon notation for derived
  views, registry packages are main-only (bundle waste + merge
  already in main)
- best-practice-layered-preset-pattern: preset-only static fields
  must merge in rowToEntity rather than via parallel endpoint;
  document acceptable exceptions for catalog and specialised
  surfaces
- data-ordering-guide section 2: drop user_provider.isEnabled from
  the Live partition list - the table is whole-table ordered
  (already correct in section 7)
- database-patterns: flag boolean columns without .notNull() as a
  common R3 offender, with concrete wrong/right example
2026-05-10 21:56:51 -07:00
fullex
8d5ecd9a20 refactor(data-services): rename tx-taking methods with Tx suffix
All service methods accepting a Drizzle transaction now follow:
- tx is the first parameter
- method name ends with Tx

Renamed:
- pinService.purgeForEntity / purgeForEntities -> *Tx
- tagService.purgeForEntity / purgeForEntities -> *Tx
- TagService#assertTagsExist (private) -> assertTagsExistTx
- PromptService#assertPromptsExist (private) -> assertPromptsExistTx
- AssistantService#syncRelations (private) -> syncRelationsTx
- AgentService#syncSettingsToSessions (private) -> syncSettingsToSessionsTx

The convention is documented in docs/references/data/data-api-in-main.md
under the new "Transaction Method Naming" section. Behavior is unchanged.
2026-05-09 03:56:49 -07:00
fullex
16f2e1c7a8 feat(lifecycle): add BaseService.registerInterval helper
Wraps setInterval with auto-unref, exception isolation, and cleanup
via the existing registerDisposable channel. Returns a Disposable.

Replaces 5 ad-hoc setInterval patterns (CacheService, PreferenceService,
WindowManager, ProxyManager, FileProcessingTaskService) — unifies
three pre-existing cleanup styles and fixes a missing unref() in
ProxyManager.

WindowManager: warmupGcTimer cleanup moves from onDestroy to onStop
(via registerDisposable) — acceptable for this singleton.

Skipped (YAGNI): registerTimeout, immediate/unref options,
activation-scoped variant. QuickAssistantService keeps bare
setInterval — its lifecycle is finer than activation.
2026-05-08 20:25:04 -07:00
hello_world
3a508c987b refactor(miniapp): migrate to v2 data layer (#14049)
### What this PR does

Migrates the MiniApp feature from v1 (Redux + sidecar
`custom-minapps.json`) to the v2 data architecture (DataApi + Preference
+ Cache), and integrates it into the v2 AppShell tab system.

**Before this PR**
- App lists lived in three Redux arrays (`enabled` / `disabled` /
`pinned`); custom-app logos were stripped before persistence and
recovered at runtime from `{userData}/Data/Files/custom-minapps.json`.
- Settings (`region`, `max_keep_alive`, `open_link_external`,
`show_opened_in_sidebar`) lived in legacy redux/electron-store.
- Runtime keep-alive used a module-level `lru-cache` singleton, mirrored
into v2 cache via `onInsert` / `disposeAfter` (two sources of truth —
already a known race).
- Routes were `/app/minapp/*`; sidebar icon literal was `'minapp'`.
- Sidebar mode used the legacy popup container; top-navbar mode was
non-functional.

**After this PR**
- A single `mini_app` SQLite table owns every row (preset + custom).
Preset rows are seeded by `MiniAppSeeder` from `PRESETS_MINI_APPS` on
every boot; custom rows come in via `POST /mini-apps`. The seeder uses
`setWhere isNotNull(presetMiniappId)` so refreshing preset display
fields can never overwrite a custom row whose `appId` happens to collide
with a preset.
- `MiniAppMigrator` imports v1 Redux state and reads
`custom-minapps.json` (path resolved through
`MigrationPaths.customMiniAppsFile`) to recover stripped logos.
- Settings live under typed Preference keys
(`feature.mini_app.{region,max_keep_alive,open_link_external}`); sidebar
icon literal renamed `'minapp'` → `'mini_app'` with a complex preference
transform that rewrites existing user arrays in-place.
- API: `GET/POST/PATCH/DELETE /mini-apps` + `POST
/mini-apps/order:batch`, Zod-validated, fractional-indexing ordering
scoped by `status` (cross-status batches are rejected with
`VALIDATION_ERROR` per the data-ordering-guide contract). Status
transitions reassign `orderKey` to the tail of the target partition
inside a transaction.
- Renderer hook `useMiniApps` exposes **command-style** writes only:
`updateAppStatus(id, status)` and `setAppStatusBulk([{id, status}])`.
The legacy declarative `updateMiniApps(list)` /
`updateDisabledMiniApps(list)` / `updatePinnedMiniApps(list)` are gone —
they took region-filtered subsets and silently disabled rows the caller
never saw.
- Keep-alive list is stored solely in
`useCache('mini_app.opened_keep_alive')`. Cap eviction respects AppShell
pin status: `useMiniAppPopup` reads pinned mini-app routes from
`useTabs` and skips them in eviction. `MiniAppTabsPool` renders webviews
in a stable `appId`-sorted order so LRU reorders never move `<webview>`
DOM nodes (Electron `<webview>` loses its guest WebContents on
detach/reattach).
- **Unified launch path**: clicking any miniapp (from the launcher grid
or a top tab bar entry) calls `openTab('/app/mini-app/<id>', { title,
icon: app.logo })`. A globally-mounted `<MiniAppTabsPool>` in `AppShell`
keeps a `<webview>` alive per opened app, regardless of sidebar vs
top-navbar layout.
- Settings UI rewritten as a `PageSidePanel` drawer composed of
`MiniAppListPair` (visible / hidden columns with drag-drop) and
`MiniAppDisplaySettings` (region / cache slider). New custom-app form is
a separate `NewMiniAppPanel` drawer.
- Sidebar's running-mini-apps strip removed — opened apps live
exclusively in the top tab bar (per #3198804265). Companion preference
`feature.mini_app.show_opened_in_sidebar` deleted from the schema.

### Why we need it and why it was done in this way

Part of the broader v2 data-layer migration (Redux/Dexie/ElectronStore →
DataApi + Preference + Cache).

**Architecture**
- DataApi for entity rows (preserves user content); Preference for
atomic settings; Cache (Memory tier) for runtime ephemera.
- Layered preset pattern (`best-practice-layered-preset-pattern.md`):
preset and custom rows share the same table, discriminated by
`presetMiniappId`. Seeder refreshes preset display fields on re-run;
custom rows are immutable to the seeder.
- Region filtering is a **view-only** concern (read path); the write
path is command-style and never references region. This eliminated a
class of bugs where editing the visible (filtered) list caused
region-hidden rows to drift.
- AppShell tab pinning is the canonical "keep this loaded" signal. The
keep-alive cap respects it; pinned mini-app tabs never get evicted
regardless of cap. Render-order independence in `MiniAppTabsPool`
ensures LRU touches don't move `<webview>` nodes around.
- Per-app icon resolution: `app.logo` is a `CompoundIcon` id (e.g.
`"Moonshot"`) for presets and a URL for custom apps. UI consumers (tab
bar, sidebar entry, settings list) call `getMiniAppsLogo` to resolve the
id to a `CompoundIcon` before rendering, with `<img>` fallback for URL
strings.
- Per-entity tab icons are cleared on internal navigation, sidebar
reuse, and the top-bar settings button — three call sites that all flip
the active tab's URL now consistently reset `icon: undefined` so a
mini-app logo never sticks onto an unrelated route.

**Tradeoffs**
- `useMiniApps` still exposes `miniapps` (region-filtered
enabled+pinned) and `disabled` (region-filtered). These are display-only
views. Renamed/typed wrappers were considered but deferred — the
refactor to command-style writes already eliminated the bug class that
motivated the rename.
- The `applyReorderedList` integration test for
`reorderMiniAppsByStatus` was dropped — `MockUseDataApiUtils` doesn't
fill the SWR cache that `useReorder.readCurrent` reads. Splice logic is
straightforward and the server-side `applyScopedMoves` test covers the
contract.
- Sidebar primitives in `@cherrystudio/ui`-adjacent layout still accept
`miniAppTabs` / `onMiniAppTabClick` props (defensive defaults — render
nothing without a producer). Removing these from the primitive's API is
a separate refactor not in scope.

### Breaking changes

User-visible changes are auto-migrated by the v2 migration framework —
no manual user action required:
- Sidebar icon literal `'minapp'` → `'mini_app'` (rewritten by the
`sidebar_icons_rename` complex preference transform)
- Preference key rename `feature.minapp.*` → `feature.mini_app.*`
(auto-migrated via `classification.json`)
- Custom-app logos stripped from v1 Redux are recovered from
`custom-minapps.json` during migration

One product-shape change is documented under
`v2-refactor-temp/docs/breaking-changes/`:
- `2026-05-07-miniapp-sidebar-running-list-removed.md` — the sidebar no
longer surfaces opened mini-apps under the mini-app entry. Open apps are
accessed exclusively via the top tab bar; pin a tab to keep its state
across switches.

The legacy v1 preference `showOpenedMinappsInSidebar` is reclassified as
`status: deleted` in the migration pipeline; v1 values are dropped
during v1→v2 migration with no v2 destination.

### Special notes for your reviewer

**Verified end-to-end on a real dev profile**: v1 Redux state +
`custom-minapps.json` → v2 SQLite, including pinned-app cross-group
dedup (a v1 pinned app appears in both `pinned` and `enabled` Redux
arrays; the migrator counts duplicates as skipped so the engine's
`targetCount >= sourceCount - skippedCount` invariant holds — without
this, any user with pinned miniapps was blocked from migrating).

**Drizzle migrations** are throwaway in dev per `CLAUDE.md`.
`migrations/sqlite-drizzle/0020_even_hulk.sql` is the single regenerated
migration; it will be wiped to a clean initial migration before release.

**Review history**: 28 line-comments across multiple formal review
rounds. All resolved. The most consequential fixes:
- `applyScopedMoves` in `MiniAppService.reorder` — rejects cross-status
batches with `VALIDATION_ERROR` instead of silently splitting them.
- `update()` reassigns `orderKey` to a fresh tail in the target
partition on status change.
- Empty-string substitution in migrator mappings is now caught by the
post-transform validity check; bad rows are skipped + warned, never
inserted.
- Migrator validation switched from `limit(5)` sample to full `count(*)
WHERE empty-fields` — bad rows can no longer pass validation by virtue
of being beyond the sample window.
- Keep-alive cap exempts pinned tabs (#3198809321 + the kangfenmao
keepalive review); render order in `MiniAppTabsPool` is `appId`-stable
so LRU touches don't move `<webview>` nodes (this was the root cause of
"switching tabs reloads the webview").

**Out of scope**:
- The remaining `@renderer/store/tabs` import in
`PaintingsRoutePage.tsx` is pre-existing v1 residual (not introduced or
touched by this PR).

### Checklist

- [x] PR: description rewritten to reflect the final architecture +
integration with the AppShell tab system
- [x] Code: command-style writes (`updateAppStatus` /
`setAppStatusBulk`); see `useMiniApps`, `MiniAppService`,
`MiniAppMigrator`, `MiniAppTabsPool`, `useMiniAppPopup` for the main
entry points
- [x] Refactor: ~1500 lines of dead/legacy code removed
(`Tab/TabContainer`, `TabsService`, `MiniAppPopupContainer`,
`TopViewMiniAppContainer`, legacy LRU singleton, `PinnedMiniApps`, dead
`userOverrides` / `MiniAppRegistryService`, unused `Signal.ts`)
- [x] Upgrade: v1 → v2 migration verified end-to-end on a real dev
instance
- [x] Documentation: architecture covered by `docs/references/data/`;
one user-visible behavior change documented in
`v2-refactor-temp/docs/breaking-changes/`
- [x] Self-review: multi-agent review via `/gh-pr-review` (twice); all
28 review comments resolved

### Release note

```release-note
NONE - Internal v2 data refactor. User-facing renames (route, sidebar icon, preference keys) are auto-migrated. The sidebar no longer shows a running-mini-apps strip; opened apps live in the top tab bar.
```

---------

Signed-off-by: suyao <sy20010504@gmail.com>
Signed-off-by: chengcheng84 <hello_world0000@outlook.com>
Co-authored-by: suyao <sy20010504@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: fullex <106392080+0xfullex@users.noreply.github.com>
Co-authored-by: Copilot <copilot@github.com>
2026-05-07 20:45:20 +08:00
槑囿脑袋
434d4a938f refactor(knowledge-data): adjust knowledge v2 data and service (#14719)
Co-authored-by: fullex <0xfullex@gmail.com>
Co-authored-by: fullex <106392080+0xfullex@users.noreply.github.com>
2026-05-01 19:24:48 +08:00
fullex
ee15daeef2 docs(data-api): correct drizzle-kit DEFAULT-change rationale
The "DB defaults are near-permanent" guidance previously claimed
drizzle-kit cannot auto-generate the SQLite table rebuild for DEFAULT
changes. That's incorrect: drizzle-kit emits the full PRAGMA / CREATE
__new_xxx / INSERT SELECT / DROP / RENAME / re-create-indexes sequence
automatically.

Rewrite the supporting argument from "tooling can't do it" to:
- every change forces a full-table rebuild at runtime (schema lock,
  ~2x temporary disk, slow on multi-GB tables);
- DEFAULT changes never touch existing rows;
- legacy NULL backfill must be hand-written into the rebuild's
  INSERT SELECT line via COALESCE - drizzle-kit will not synthesize
  that.

Conclusion (near-permanent) and Safe bias remain unchanged - only the
underlying mechanics are corrected.

Drop the drizzle-orm#2489 reference and the "drizzle-kit generate
--custom" workaround it implied.
2026-04-29 08:36:03 -07:00
fullex
c21eb8139e docs(data-api): elevate "DB DEFAULT is near-permanent" guidance
Codify what was implicit: putting a value into a DB column DEFAULT for the first time costs nothing, but changing it later in SQLite is expensive and asymmetric (no ALTER COLUMN SET DEFAULT; drizzle-kit emits only an explanatory comment without naming the affected column — drizzle-orm#2489). So the first write is effectively the final one, and the placement bias should flip from "DB by default" to "service unless certain".

Verified via drizzle-orm maintainer (Andrii Sherman) Medium article, drizzle-orm GitHub issues #2489 / #5360 / #1313, and Drizzle docs via context7. Empirical: this repo's 7 existing migrations are all ALTER TABLE ADD COLUMN — zero ALTER COLUMN — confirming the team has so far avoided the manual rebuild path organically.

Spec changes in best-practice-default-values-and-nullability.md:
- New section "DB defaults are near-permanent" between DM2 and Quick chooser
- DM2 → DB DEFAULT row note tightened, links to new section
- Quick chooser flipped: "unsure" now → Service ?? (was "try DB first")
- Standard Layered Design: emoji moved from DB DEFAULT to service ?? (product-chosen value)
- Anti-patterns: emoji-mask row's Correct column updated; new row for "speculative DB DEFAULT thinking I can tune later"
- Case Study A's Fix description aligned with new bias
- Related References: drizzle-orm#2489 added

Companion update in database-patterns.md: same precision for the DB .default('X') cell in "Where the default value lives".
2026-04-29 03:35:23 -07:00
fullex
69e9cb6184 docs(data-api): codify default values & nullability rules
Establish team standards for placing default values across the data stack (DB DEFAULT / Drizzle $defaultFn / Zod .default() / service ??) and judging column nullability.

Originating context: PR #14689 fixed a PATCH leakage bug rooted in defaults living in three places at once (DB, Zod Create schema, row mapper) for the assistant entity. The follow-up discussion recovered general principles that other entities (agent, message) also violate; this doc captures them as a reference for future schema/service work.

- New: best-practice-default-values-and-nullability.md — Five rules (R1-R5), decision matrices for nullability and default placement, standard layered design example, anti-patterns table, case studies (assistant, modelId, agent.accessiblePaths)
- api-design-guidelines.md: refine Rule C Update derivation guidance; add Rule E discouraging Zod .default() on entity / Create / Update schemas
- data-api-in-main.md: upgrade row-mapper ?? fallbacks from "needs hand-write" tolerance to anti-pattern; add Write-path defaults section codifying R4
- database-patterns.md: add Column Nullability and Defaults section; add R3 no-fabricated-fallbacks bullet to Row → Entity Mapping
- README.md: index entry under Reference Guides

No code changes. Implementation follow-up will land in separate PRs that apply these rules entity by entity.
2026-04-29 02:49:11 -07:00
fullex
a8c322296e feat(data-api): add bulk purgeForEntities and codify cross-service table access
Mirror the existing single-row purgeForEntity on PinService and TagService
with a purgeForEntities(tx, entityType, ids[]) variant. Empty input is a
no-op; non-empty input collapses N round trips into one and emits a single
aggregated log line with a count, removing the "loop and log per id" smell
when consumers later cascade deletes for many entities of the same type.

Document a Cross-Service Table Access rule in data-api-in-main.md:

- Writes to a foreign table must go through the owner (pass tx into its
  method) — owners are the single source of truth for invariants and the
  emitter of mutation logs; foreign writes split that knowledge.
- Reads that inline-JOIN a foreign table are allowed when JOIN is the
  simpler path (e.g. AssistantService.list pulling per-row tags).

The new bulk methods are intentionally landed without a consumer so that
the "add a bulk method on the owner instead of writing raw SQL from the
caller" path described by the rule is available the moment a cascading
delete appears, rather than nudging the first author to bypass the owner.
2026-04-26 22:45:04 -07:00
fullex
0dc1533aa7 fix(data-api): rebuild useInfiniteQuery and harden pagination guards
Fixes #14593 (mutate type collapses to CursorPaginationResponse, erasing
subtype fields like BranchMessagesResponse.activeNodeId) and #14594
(default flatMap bakes in a "page-load order == display order" assumption
that breaks on branch-walk + column-reverse layouts). Also closes a dual
silent-failure where useInfiniteQuery silently accepted offset paths
(stuck at page 1) and usePaginatedQuery silently accepted cursor paths
(total stays 0).

useInfiniteQuery now exposes raw pages: TResponse[] (no items field),
preserving subtype fields and typing mutate as SWRInfiniteKeyedMutator
correctly. The new useInfiniteFlatItems hook derives the flat list with
explicit reversePages / reverseItems switches, so flattening is no longer
hidden behind an implicit page-load ordering. Path generics on both hooks
are gated via CursorPaginatedPath / OffsetPaginatedPath, built on
InferPaginationMode so the optional nextCursor field cannot collapse
offset paths into the cursor guard structurally.

DEFAULT_SWR_OPTIONS realigned for IPC (not HTTP) semantics: DataApiService
is the single retry decision point (shouldRetryOnError: false), reconnect
revalidation disabled, keepPreviousData enabled to suppress search/
pagination flicker. loadNext drops its isValidating guard (SWR's
dedupingInterval is the right dedup site); usePaginatedQuery
reset-on-query-change uses unstable_serialize to be key-order independent.

useInfiniteQuery had zero consumers when rewritten — the breaking removal
of the items field carries no migration cost. Comprehensive test coverage
for type contracts, flat-items behavior, infinite integration, and
paginated reset; renderer data docs synced.
2026-04-25 07:33:33 -07:00
fullex
c3c6cf6b8d feat(window-manager): add singleton warmup and delayed destroy
Extend the pool warmup state machine to support singleton windows via an
optional `singletonConfig` (eager pre-warm + retentionTime-based close→hide
with delayed destroy). Generalize shared symbols (PoolState→WarmupState,
lastOpenAt→lastActivityAt, pool*→warmup* on non-pool-specific methods) and
fix a half-bug where pool inactivity only counted since last open, not
since last open or close.

Op naming convention: lifecycle-specific ops carry `pool-*` / `singleton-*`
prefixes; ops shared by both (create-idle, release-skip, inactivity-trim,
warmup) are unprefixed so log greps stay precise.

Main is intentionally NOT migrated — its close handler reads tray
preferences, quits the app on Win/Linux, guards on isFullScreen, and
toggles Dock visibility; none of that fits the declarative `retentionTime`
contract. Registry entry now documents this decision inline.
2026-04-24 09:56:45 -07:00
fullex
3dbe907448 refactor(data-api-handlers): extract HandlersFor<> helper
All 14 per-module handler files used the same 7-line mapped type.
Extract it into a single HandlersFor<Schemas> helper in apiTypes and
migrate each file to a one-line annotation (net -119 lines).

The helper is defined as Pick<ApiImplementation, Extract<keyof Schemas,
keyof ApiImplementation>> rather than re-applying ApiHandler<Path, Method>
over generic parameters -- the latter triggers TS2590 (union too complex)
because ApiHandler nests several conditional types that must resolve
symbolically when Path is a free type variable. Indexing the
already-evaluated ApiImplementation sidesteps that.

Also elevate the annotation rule to a dedicated "Handler Type Annotation"
subsection in the data-api-in-main doc and add a type-level regression
matrix under handlers/__tests__.
2026-04-23 08:44:23 -07:00
fullex
f60ef2bfd6 docs(cache): rewrite to match current API and add design invariants
Align cache-overview.md and cache-usage.md with the template-key and
subscribe* APIs (sharedCasual was dropped in 3fbc52e05). Extract the
"adding keys" content into a new cache-schema-guide.md aligned with
preference and boot-config schema guides. Lift non-obvious invariants
(isEqual short-circuit, TTL-with-hooks warning, Persist has no delete,
Main-wins convergence, template placeholder rules) into a first-class
Design Invariants section in the overview.

Fix two code-contradicted claims:
- useCache does not accept a TTL options argument (hook signature is
  (key, initValue?)).
- Persist is renderer-authoritative; Main only relays IPC and does
  not store (CacheService.ts:477-479 is "Reserved, not implemented").

Update peripheral references in the same pass so cross-references stay
coherent: v2-renderer skill, CLAUDE.md, architecture-overview.md,
api-design-guidelines.md (Cache vs DataApi matcher contrast), and the
package READMEs.

Net change: +249 / -579.
2026-04-22 22:56:34 -07:00
fullex
ad2b402c04 feat(cache-service): add main-process subscribe API with template key support
Enables main-process services to react to cache changes without writer-side
wiring — unblocks the web-search/OCR provider rotation use case where new
providers would otherwise require manual hook-ups at every write site.

Also unifies equality across main and renderer on lodash.isEqual (fixing
redundant cross-window broadcasts when Record/Array values are rebuilt on
every write) and extracts template utilities to the shared package so both
processes use one implementation.
2026-04-22 22:04:06 -07:00
fullex
3fbc52e056 refactor(cache-shared): support template keys and drop sharedCasual API
Align SharedCache type system with Memory (UseCache) template support and
remove the sharedCasual escape hatch that existed only because SharedCache
could not type-check dynamic keys:

- Introduce InferSharedCacheValue and expand SharedCacheKey through
  ProcessKey so template schema entries like
  'web_search.provider.last_used_key.${providerId}' match concrete keys
  with precise value types on both Main and Renderer.
- Extend useSharedCache hook with findMatchingSharedCacheSchemaKey /
  getSharedCacheDefaultValue, mirroring useCache's template-aware default
  resolution.
- Remove getSharedCasual / setSharedCasual / hasSharedCasual /
  deleteSharedCasual / hasSharedTTLCasual from the Renderer CacheService
  and all test mocks; Main CacheService never had them.
- Migrate BaseWebSearchProvider and OcrBaseApiClient rotation from
  sharedCasual to type-safe getShared/setShared. Rename keys to conform
  to schema naming rules (ESLint data-schema-key/valid-key):
    web-search-provider:${id}:last_used_key
      -> web_search.provider.last_used_key.${providerId}
    ocr_provider:${id}:last_used_key
      -> ocr.provider.last_used_key.${providerId}
  Old values under legacy key names become orphans after rollout; this
  is acceptable because rotation state is transient and consumers
  reinitialize from keys[0] on a miss.
- Add type-level assertions for SharedCacheKey and InferSharedCacheValue
  in useCache.types.test.ts to lock the contract in CI.
- Update cache-overview.md, cache-usage.md, and tests/__mocks__/README.md
  to describe SharedCache's template support and reflect that casual
  methods now exist only on the Memory tier.
2026-04-22 19:25:54 -07:00
fullex
6d42ab5822 feat(window-manager): add pushInitData for already-open windows
Expose pushInitData(windowId, data) and pushInitDataToType(type, data)
as main-process public methods on WindowManager. Both reuse the existing
initDataStore plus WindowManager_Reused IPC channel, so the renderer's
useWindowInitData hook picks up the new payload in-place without any
remount or DOM churn. This closes the gap between cold-start and reuse
paths: previously, updating an already-visible window required close()
plus open(), which lost interaction state on singletons.

Signatures forbid undefined to keep "push nothing" from silently
no-opping; unlike the reuse path, undefined has no meaningful semantics
here. pushInitDataToType does not filter by visibility — idle pooled
windows still receive the payload so they have the latest data when
taken out of the pool next.
2026-04-22 06:02:23 -07:00
fullex
01986a47b6 docs(data-api): codify Zod schema & DTO conventions
- Add "Zod Schema & DTO Conventions" section to api-design-guidelines.md
  covering the four rules applied across the prior refactor commits:
  A. type (not interface) for XxxSchemas route tables
  B. XxxSchema for Zod constants, XxxDto for TypeScript type names
  C. EntitySchema.pick({...}) whitelist derivation with field atoms
     and z.strictObject (guarded against overposting)
  D. handwritten Zod everywhere; no drizzle-zod; no pure TS interface
     DTOs (responses stay as interface — opposite direction of the
     IPC trust boundary)
- Include a decision rule for when to extract an XXX_MUTABLE_FIELDS
  constant: both Create/Update share the pick set AND fields >= 5
- Sync example code in api-types.md to the new conventions
- Flip the TopicSchemas example in data-api-in-main.md from interface
  to type
2026-04-22 05:15:33 -07:00
fullex
450e35c7af fix(data-ordering): support all DataApi cache shapes in useReorder
Previously the hook hard-coded a `{ items: Array<...> }` cache contract,
so the two resources actually shipping OrderEndpoints — /pins and
/groups — would have thrown `length mismatch` on first drag because
their GET responses are flat arrays (Pin[], Group[]).

Auto-detect flat arrays and `.items`-bearing objects; expose paired
selectItems/updateItems accessors for nested shapes (grouped views,
GraphQL-style connections, envelopes with a different field name).
Half-configured accessors throw at hook construction.

Degradation splits into two tiers: "cache not loaded" (all paths no-op
and warn per call) vs "shape unrecognized" — move/applyBatch let the
PATCH through since id + anchor are self-contained, while
applyReorderedList refuses since blind whole-list reorder without a
client baseline is unsafe. Unrecognized-shape warnings are
de-duplicated per hook instance.

Paginated shapes (OffsetPaginationResponse / CursorPaginationResponse)
now preserve non-items metadata (total, page, nextCursor) on optimistic
overlays.
2026-04-22 02:33:22 -07:00
fullex
168dfffd57 docs(data-ordering): reflect applyScopedMoves and live group/pin partitions
Update the guide to match what's now landed in code:

- §2: drop the `reserved groupId = '__pinned__'` bucket teaching — pin
  is a separate table, not an overloaded scope value. Point readers
  to the schema/service JSDoc for resource-design details.
- §3: add applyScopedMoves to the helper table.
- §3.1 (new): document the scoped-reorder contract (service-side
  scope inference, multi-scope batch rejection, missing-id semantics)
  in one short section.
- §9 "Group Ordering" upgraded from future-extension placeholder to
  spec, trimmed to ordering-relevant points only.
- §10 "Pin Ordering" added, also ordering-only. Polymorphic shape,
  purgeForEntity contract, idempotent pin semantics, and hard-delete
  rationale are deliberately NOT duplicated here — they live in
  pin.ts / PinService.ts JSDoc where the code is.
- Partition-dimensions list and migration checklist updated so
  `group.entityType` / `pin.entityType` show as live rather than
  hypothetical.
2026-04-21 23:28:35 -07:00
fullex
e666470794 refactor(sub-window): rename DetachedWindowManager to SubWindowService
Unify the "window detached from main" concept under the SubWindow name,
pairing it with MainWindow. Rename the service class, WindowType enum
value, HTML entry, renderer directory, React components, logger contexts,
and window-manager docs accordingly.

Scope of rename (noun-only):
- DetachedWindowManager -> SubWindowService
- DetachedWindowState -> SubWindowState
- WindowType.DetachedTab -> WindowType.SubWindow (value 'subWindow')
- detachedWindow.html / detachedWindow/ -> subWindow.html / subWindow/
- DetachedAppShell -> SubWindowAppShell
- DetachedTabApp -> SubWindowApp
- DETACHED_DEFAULT_WIDTH/HEIGHT -> SUB_WINDOW_DEFAULT_WIDTH/HEIGHT
- Logger contexts / sources and all "detached window" prose

Preserved (interaction-specific, not window-type nouns):
- IPC channels Tab_Detach / Tab_Attach / Tab_MoveWindow / Tab_TryAttach /
  Tab_DragEnd and their 'tab:*' literals - these describe the drag-tab
  interaction, not the SubWindow concept
- Component prop isDetached and useTabDrag.detachedCreated state
- The DevTools "detached" and Node spawn "detached" unrelated usages
2026-04-21 19:32:47 -07:00
jidan745le
2c35951356 feat(data): add domain hooks for models/providers and batch model API (#14269)
Co-authored-by: fullex <106392080+0xfullex@users.noreply.github.com>
Co-authored-by: jd <59188306+zhangjiadi225@users.noreply.github.com>
Co-authored-by: SuYao <sy20010504@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: MyPrototypeWhat <daoquqiexing@gmail.com>
2026-04-21 19:45:15 +08:00
fullex
c9abc6e058 docs(data-ordering): add ordering guide and update cross-references 2026-04-21 03:21:05 -07:00
fullex
375478371c refactor(data-schema): tighten createUpdateTimestamps with NOT NULL
Add `.notNull()` to `createdAt` / `updatedAt` in the shared
`createUpdateTimestamps` helper so Drizzle `$inferSelect` produces
`number` instead of the misleading `number | null`. `deletedAt` in
`createUpdateDeleteTimestamps` stays nullable (soft-delete semantics).

Generated migration 0013 rebuilds 26 affected tables via the standard
SQLite table-recreation pattern; FK / CHECK / INDEX constraints are
preserved across rebuild. No backfill is added (project is in the
development phase; null pre-existing rows are accepted as a "wipe DB"
signal rather than engineered around).

Fix upstream in `KnowledgeMappings.toTimestamp` so it returns a
`Date.now()` fallback instead of `undefined` — otherwise future Dexie
-> v2 migrator runs would try to insert undefined into NOT NULL
columns. Three test assertions updated from `undefined` to
`expect.any(Number)`.

Sweep 18 downstream call sites across 9 functions that were carrying a
dead `?? new Date().toISOString()` fallback:

- AssistantService, KnowledgeBaseService, KnowledgeItemService,
  MessageService, TopicService, TranslateHistoryService,
  TranslateLanguageService (the original Pattern A set)
- McpServerService and MiniAppService.rowToMiniApp (reclassified from
  Pattern B: the domain types stay `optional` to accommodate builtin
  literals in the renderer, but `string` assigns legally into
  `string | undefined`, so the switch is safe)

Keep `MiniAppService.builtinToMiniApp` on `timestampToISOOrUndefined`
— its `dbRow?: MiniAppSelect` semantics ("the preference row may not
exist at all") is genuinely optional, not a disguised "nullable column".

Also remove a Pattern C that neither the plan nor the grep audit
caught: `TagService.ensureTagTimestamp` was a self-rolled defense
layer that threw INTERNAL_SERVER_ERROR on null timestamps. The DB
now refuses to produce such rows, so the defense — and the test
named "should surface timestamp anomalies instead of masking them" —
are dead code. Removed both.

Update three docs to reflect the new defaults:

- `services/utils/README.md` — drop the "DB still nullable" table row
  and the predictive paragraph; reframe Pattern B around
  "whole row may not exist"
- `services/utils/rowMappers.ts` JSDoc — same reframing
- `docs/references/data/data-api-in-main.md` — delete the fallback
  code samples and simplify Convention §3
2026-04-21 03:21:05 -07:00
fullex
f8f0e4b19a refactor(data-services): consolidate row-to-entity mapping utilities
Introduce shared `services/utils/rowMappers.ts` exporting three helpers:
`nullsToUndefined` (renamed from the previous `stripNulls`, with a
corrected type signature that preserves `notNull()` columns unchanged),
`timestampToISO` for guaranteed-present timestamps, and
`timestampToISOOrUndefined` for nullable ones.

Migrate 9 services off hand-rolled null/date handling:

- Eliminate the `stripNulls` duplicate in MiniAppService
- Replace 18 repetitive createdAt/updatedAt ternaries with helper calls
- Fix McpServerService misuse where an optional domain field was being
  forced to a synthesized "now" value; restore honest undefined semantics
- Simplify KnowledgeBaseService.rowToKnowledgeBase via the spread idiom,
  bypassing `clean` for the `T | null` embeddingModelId field

Document the paradigm in docs/references/data/data-api-in-main.md with
standard and advanced skeletons, plus an explicit "when NOT to spread"
list covering services with field renaming, custom fallbacks, computed
fields, or sensitive-data sanitization. Per-helper design decisions
(shallow vs. recursive, rejected alternatives) live in
services/utils/README.md.
2026-04-21 03:21:05 -07:00
fullex
686bd15290 feat(data-api): support template paths, function refresh, and /* prefix matching
Extend the renderer data hooks to cover three mutation shapes that
previously had to drop to imperative dataApiService calls:

- Template paths (e.g. `/providers/:providerId`) with a runtime `params`
  option on useQuery / useMutation / useInfiniteQuery / usePaginatedQuery,
  so a single hook instance can operate on ids chosen at call time.
  ParamsForPath derives types directly from the schema's existing
  `params: {...}` declarations (no template-string parsing).
- Function-form `refresh: ({ args, result }) => ConcreteApiPaths[]` for
  invalidation keys that depend on trigger input or server response. Args
  are closure-captured at trigger entry to avoid races between concurrent
  calls.
- Explicit `/*` suffix for path-segment prefix matching on refresh and
  invalidate patterns, preserving the trailing slash so `/providers/*`
  doesn't match siblings like `/providers-archived`.

A single `resolveTemplate` function is the canonical path-replacement
point, so `useQuery('/providers/:id', { params: { id: 'abc' } })` and
`useQuery('/providers/abc')` produce byte-for-byte identical cache keys.

Dev-mode assertions flag invalid `/*` patterns (bare wildcard or missing
trailing slash) and warn on concurrent triggers against the same template
hook instance, which would share SWR mutation state.

Fully backward compatible: existing `refresh: ['/topics']` and
concrete-path hook calls compile and behave identically.
2026-04-20 09:02:57 -07:00
fullex
826a453579 docs(test-mocks): clarify scope boundary and document lifecycle service testing
The mocks README described what was mocked but never stated that
tests/__mocks__/main/ is intentionally limited to cross-cutting
infrastructure. Without that boundary in writing, feature-specific
lifecycle services were getting registered into defaultServiceInstances,
bloating the global default surface without serving any other test.

Adds an explicit Scope section with a pattern-selection table, documents
the canonical local-stub pattern for feature-specific lifecycle services
(vi.mock on @application + MockBaseService stand-in), and a Common
Assertions table covering phase, dependencies, IPC handlers, and
disposables. Adds the missing Main PreferenceService section to match the
other three infrastructure service docs. Links the lifecycle README to
the new testing guidance.
2026-04-20 06:31:55 -07:00
fullex
f533e9259f refactor(window-manager): group behavior runtime setters under wm.behavior
Move runtime overrides (setHideOnBlur / setAlwaysOnTop /
setMacShowInDockByType) off the flat WindowManager API onto a
`wm.behavior` sub-namespace backed by a new BehaviorController class
in behavior.ts. The controller owns the hideOnBlur override map and
the per-type macShowInDock override map; WindowManager keeps only the
Dock commit-state flag and the visibility predicate.

Surfacing the three-layer declarative split (windowOptions / behavior
/ quirks) at the API level lets consumers tell at a glance which
layer a runtime setter belongs to, and gives future behavior setters
a natural home without inflating the flat WindowManager namespace.
`setTitleBarOverlay` and `setInitData` stay on the flat namespace
since they do not target the behavior layer.

Hard-cut migration: 5 production call sites (SelectionService,
MainWindowService) and the window / MainWindowService test suites
updated in place; no deprecation alias kept. Documentation across
the window-manager reference set rewritten to the new API shape.

Drive-by: add missing `setIsPinned` dep to HomeWindow.tsx
`baseFooterProps` useMemo to silence a pre-existing
react-hooks/exhaustive-deps warning surfaced while verifying lint.
2026-04-19 08:10:52 -07:00
fullex
a4007f30bb refactor(python-service): formalize python execution IPC channels
Add Python_ExecutionRequest and Python_ExecutionResponse to the
IpcChannel enum and replace the bare string literals used by
PythonService (main) and PyodideService (renderer) so the push-style
request/response channels are governed the same way as Python_Execute.

Wire values are normalized to the repository's 'namespace:action-dashed'
convention (python:execution-request / python:execution-response) since
both ends are updated in the same change.
2026-04-19 07:34:22 -07:00
fullex
bafed0d0e1 refactor(window-manager): migrate main window & fix macOS Dock semantics
- Rename WindowService -> MainWindowService (git mv, preserves blame/log)
- Register WindowType.Main in windowRegistry (singleton, showMode: manual);
  MainWindowService drives construction via wm.open and retains business
  logic only: IPC handlers, tray-aware close, crash recovery, show/toggle
- 50 call sites renamed (@DependsOn, application.get keys, imports)

WM Dock visibility refactored from visibility-based to existence-based:
- updateDockVisibility predicate drops isVisible/isMinimized check — a
  hidden main window must not remove the Dock icon (Cmd+W semantics)
- Add wm.setMacShowInDockByType(type, value) for tray-mode transitions;
  keyed by type so services can suppress Dock before the first instance
  exists (tray-on-launch path)
- Reduce triggers to window creation, destruction, and type-override
  changes; show/hide/minimize/restore no longer affect Dock state
- dockShouldBeVisible initializes true to match Electron's default

MainWindowService uses setMacShowInDockByType for tray-on-launch, close-
to-tray, and reopen-from-tray paths, replacing manual app.dock?.hide()
calls that raced against WM.

Docs updated across window-manager/{README, overview, platform,
api-reference, migration-guide}. New MainWindowService unit test covers
the close matrix (isQuitting, win/linux tray on/off, mac default, mac
tray on_close, fullscreen edge, Dock override assertions) and crash
recovery (first reload vs second-within-60s forceExit).
2026-04-19 06:52:03 -07:00
fullex
62d39d41f7 refactor(window-manager): split metadata into windowOptions/behavior/quirks
WindowTypeMetadata now declares per-type config across three orthogonal
layers, chosen by what goes wrong if misconfigured:

  - windowOptions: BrowserWindow constructor parameters (Electron-native).
  - behavior: cross-platform declarative WM behavior that the constructor
    cannot express — hideOnBlur, alwaysOnTop level/relativeLevel,
    visibleOnAllWorkspaces options, macShowInDock.
  - quirks: OS-specific monkey-patches around hide/show/close.

`macReapplyAlwaysOnTop` is now a pure boolean — the level it re-applies
reads from `behavior.alwaysOnTop`, eliminating the previous mixing of
hack flag and semantic value.

Field renames in the registry:

  - defaultConfig → windowOptions
  - show → showMode (also gains 'immediate' | 'manual' string variants
    in place of true/false; 'auto' unchanged)
  - showInDock (top-level) → behavior.macShowInDock
  - preload now accepts a plain filename ('index.js' / 'simplest.js'),
    mirroring htmlPath; empty string disables, omit defaults to
    'index.js'. Removes the variant→file mapping switch.
  - mergeWindowConfig → mergeWindowOptions

Two runtime setters added on WindowManager:

  - setHideOnBlur(id, enabled): runtime override on behavior.hideOnBlur.
    Override map is cleared on destroy and on releaseToPool, so pool
    consumers re-applying after open() see a clean default.
  - setAlwaysOnTop(id, enabled): toggles using level/relativeLevel from
    behavior — registry is the single source of truth.

setVisibleOnAllWorkspaces intentionally has no WM setter: its options
differ per call in real usage (SelectionAction's full-screen show
sequence) and WM has no state to maintain.

Types derived from Electron's signatures so they stay in sync with
@types/electron:

  - AlwaysOnTopLevel = NonNullable<Parameters<BW['setAlwaysOnTop']>[1]>
  - VisibleOnAllWorkspacesOptions imported directly

applyWindowQuirks is split into applyWindowBehavior (new, behavior.ts)
+ applyWindowQuirks. Behavior runs first so monkey-patches wrap any
subsequent show/hide rather than the initial setter calls. Behavior
takes a closure for reading the override map, avoiding a reverse
WindowManager dependency.

Consumer changes:

  - SelectionToolbar: declares behavior.hideOnBlur; mouse-key hook
    lifecycle moves from showToolbarAtPosition/hideToolbar call sites
    to window 'show'/'hide' event listeners, so any hide path (WM-
    driven blur included) triggers cleanup symmetrically.
  - SelectionAction: pinActionWindow routes through wm.setAlwaysOnTop
    via getWindowId. Show-sequence setVisibleOnAllWorkspaces calls
    stay direct (true/false options differ per call). Hardcoded
    'floating' level dropped from the show sequence; Electron's
    default is identical.
  - QuickAssistant: removes the inline setVisibleOnAllWorkspaces and
    setAlwaysOnTop calls (now declared in registry behavior). The
    blur handler stays in the service because hideQuickAssistant() is
    a platform-specific business flow (Windows minimize+setOpacity,
    macOS<26 app.hide()) that a generic window.hide() cannot express.

QuickAssistant pin/blur on macOS NSPanel — separate concern, addressed
in the same change because the diagnosis surfaced during refactor:

  Electron's blur tracker can get stuck after (outside-click → intra-
  panel pin/unpin click → outside-click). Cocoa fires
  windowDidResignKey: for the second outside-click but Electron filters
  it — its tracker thinks the window was already blurred (the intra-
  panel click silently re-keyed the panel without firing
  windowDidBecomeKey:). Upstream marked wontfix (electron/electron#3222).

  Workaround: after un-pinning, poll window.isFocused() at 200ms,
  capped at 30s. isFocused() reads Cocoa state directly, bypassing
  the broken Electron tracker. Triggered only when hasBlurredSinceShow
  is true (the failure precondition), so the common open→pin→unpin
  path runs no timer.

  Renderer side: HomeWindow's pin sync moves from useEffect (deferred
  by one render cycle) to a setter wrapper, so IPC fires synchronously
  inside the click handler — closes a small race where a fast outside
  click could see a stale main flag.

Tests: 15 new specs covering behavior layer (declarative + runtime
override, pool recycle reset, level derivation, initial
setVisibleOnAllWorkspaces application, macReapplyAlwaysOnTop reading
from behavior). Existing fixtures mechanically renamed.

Docs: 6 window-manager docs synchronized; README adds sections on
configuration layers, "WM does not know pin", behavior/quirks API,
when to provide a runtime setter, type derivation convention, and
Electron edge cases.
2026-04-19 01:27:26 -07:00
fullex
b40d863b0b refactor(quick-assistant): rename mini window to quick assistant
Unify the feature under its canonical name "Quick Assistant":

- Rename miniWindow.html → quickAssistant.html and
  windows/mini/ → windows/quickAssistant/ (via git mv to preserve history).
  The bounds file miniWindow-state.json is not migrated; users see default
  bounds once after upgrade.
- Collapse half-renamed QuickWindow/MiniWindow identifiers in
  QuickAssistantService and its callers (ShortcutService, TrayService,
  ShortcutService.test) onto QuickAssistant.
- Rename the Inputbar scope literal 'mini-window' → 'quick-assistant'.
  The Redux DEFAULT_TOOL_ORDER_BY_SCOPE keeps the legacy key behind a
  transitional Record<InputbarScope | 'mini-window', ToolOrder> annotation;
  both entries will be collapsed when Redux is removed.
- Rename i18n keys miniwindow.*, mini_window, show_mini_window to
  quickAssistant.*, quick_assistant, show_quick_assistant across all
  locales; synced via i18n:sync.
- Update lingering "mini window" wording in docs and comments.

Legacy 'mini_window' literals intentionally retained in the v2
ShortcutMappings migrator (identifies Redux-persisted old keys) and in
v1 Redux store files (to be removed with Redux).
2026-04-18 10:36:27 -07:00
fullex
e9518e80c1 feat(window-manager): add onWindowCreatedByType/onWindowDestroyedByType
Add thin wrapper methods on WindowManager that subscribe to a specific
WindowType, eliminating the boilerplate `if (managed.type !== X) return`
guard at every consumer call site. The underlying `onWindowCreated` /
`onWindowDestroyed` events remain available for the rare "observe all
windows" use case.

The new variants are additive and non-breaking — same Disposable return
contract, same ManagedWindow callback payload, just with an up-front type
filter baked in.

Documentation updates landing in the same commit:
- window-manager-usage.md: recommend the byType variants as the canonical
  single-type subscription pattern; add a "Callback styles" section covering
  destructuring vs `mw` shorthand for accessing ManagedWindow fields.
- window-manager-api-reference.md: list the byType variants in the Events
  table; update `create`'s rationale to point at byType.
- window-manager-migration-guide.md: Step 3 example uses byType +
  destructuring in the After block.
- docs README and window-manager-overview.md: anti-pattern and feature
  rows point to the byType variants as the consumer-facing primary form.
2026-04-18 09:48:45 -07:00
fullex
d2f629799c docs(window-manager): split README into docs/references/window-manager/
Move the 756-line src/main/core/window/README.md into a dedicated
docs/references/window-manager/ directory organized by concern
(overview, usage, pool mechanics, platform, API reference, migration),
following the docs/references/lifecycle/ pattern. The in-source README
shrinks to a 14-line pointer.

Along the way:

- Reframe onWindowCreated + open()/close() as the canonical consumer
  pattern, and demote create()/destroy() to internal primitives with an
  explicit anti-pattern section for direct-ID attachment at call sites.
- Add a previously-undocumented "Renderer IPC Surface" section covering
  WindowManager_Open/Close/Show/Hide/etc., with target-resolution rules
  (bare sender vs singleton type).
- Make the pool-recycle-does-not-re-fire-onWindowCreated consequence
  explicit in the Event Timing Contract guarantees.
- Update @see links in WindowManager.ts and types.ts to point at the new
  doc locations directly instead of bouncing through the in-source README.
2026-04-18 08:35:17 -07:00
fullex
d5aa32e281 docs(lifecycle): clarify that cross-phase @DependsOn is redundant
Phase ordering already guarantees BeforeReady services (PreferenceService,
DbService, CacheService, DataApiService) are ready before WhenReady starts,
so declaring @DependsOn across phases adds noise and misleads readers about
same-phase coupling. The rule existed in lifecycle-overview but was buried
in a bullet; reinforce it in the docs AI assistants scan first.

- CLAUDE.md: add a bullet under the Lifecycle section stating @DependsOn
  is for same-phase deps only
- lifecycle/README.md: new "Cross-Phase Dependencies Are Automatic"
  callout section and a matching Anti-patterns row
- lifecycle/lifecycle-overview.md: promote the line-87 note to a blockquote
  callout and annotate the Dependency Rules table
- lifecycle/lifecycle-decision-guide.md: add Common Mistake #5 with
  bad/good code examples
2026-04-17 19:27:20 -07:00
fullex
a2adc8fcce feat(data-api): support greedy path params for IDs containing slashes
Extend the DataApi router's `extractPathParams` to recognize `:name*` segments
as greedy captures that consume one or more path segments joined with `/`.
Greedy params may appear as the last segment or in the middle anchored by
trailing static / plain-param segments; at most one greedy is allowed per
pattern. No decoding is performed, consistent with the existing raw-string
policy.

This unblocks routing for composite identifiers whose values contain `/`
(e.g. OpenRouter-style `qwen/qwen3-vl`, Fireworks-style
`accounts/fireworks/models/deepseek-v3p2`) without resorting to URL encoding,
which the project does not use.

- Add 21 unit tests covering plain params, tail greedy, middle greedy,
  multi-greedy rejection, and edge cases.
- Document the feature under "Greedy Path Parameters" in the API design
  guidelines with tail/middle/mixed examples.
2026-04-17 07:29:27 -07:00