Skip to content

refactor(knowledge): restructure into domain services and adopt core/job primitives#16749

Open
eeee0717 wants to merge 37 commits into
mainfrom
refactor/knowledge-structure
Open

refactor(knowledge): restructure into domain services and adopt core/job primitives#16749
eeee0717 wants to merge 37 commits into
mainfrom
refactor/knowledge-structure

Conversation

@eeee0717

@eeee0717 eeee0717 commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Before this PR:

  • KnowledgeService was a ~1,186-line god object; pipeline stages, the vector store, and readers lived under an ad-hoc utils/ plus scattered top-level dirs.
  • The KB workflow re-implemented the core/job primitives that upstream [Feature]: 补齐 core/job(JobManager)的类型化 settled 事件、级联取消与跨 job 等待能力 #16738 has since promoted into core (KeyedMutex, typed settled events, enqueueTx, list filters), and dual-encoded parentJobId.
  • Latent gaps: directory expansion copied file bytes into raw/<prefix>/ before pinning the container's relativePath, so a crash mid-expansion left orphan bytes that a retry could never reclaim (permanent "file already exists" failure loop); KnowledgeBase row invariants were validated in three divergent places; the dead idle item status lingered; index-store facades still carried libsql-era async.

After this PR:

  • StructureKnowledgeService split into base / ingestion / query / concept services (facade down to ~154 lines); utils/ + vectorstore/ + readers/ consolidated under pipeline/; the feature is grouped into domain directories with a README.
  • core/job adoption (KB-side checklist of [Feature]: 补齐 core/job(JobManager)的类型化 settled 事件、级联取消与跨 job 等待能力 #16738) — adopt core KeyedMutex; typed settled events (drop the getJob refetch and the parentJobId double-encoding); push parentId/type[] filters into list() and use the shared ACTIVE_JOB_STATUSES; run deleteItems through enqueueTx (closes a delete-marking/enqueue atomicity gap); change cancellation to loop-until-drained (drops the silent 5,000-job cap).
  • Correctness — directory expansion now pins the container relativePath before any byte copy and reclaims the container prefix on retry, closing the crash-safety gap; KnowledgeBase invariants converge into one shared Zod superRefine plus a new chunk-separator rule.
  • Cleanup — remove the idle status end-to-end; unify addressing (toMaterialRelativePath hoisted into items.ts, processedRelativePath threaded through the job payload); desync the index-store facades now that better-sqlite3 is synchronous; drop dead code.

Related to #16738 — this wires up the KB-side consumer adoption (the maintainer's checklist items 1–7) of the core/job capabilities that were accepted and merged. Item 4 (finished(jobId)/dependsOn) remains deferred upstream, so the issue stays open and this PR does not close it.

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

This branch has two arcs. About 8,700 of the changed lines are pure file relocations (the utils/ + vectorstore/ + readers/pipeline/ moves and the god-object split) with no semantic change; the genuinely edited logic is roughly 1,200 lines. They are kept in one PR because the structural moves are what let the convergence edits land in their final home cleanly.

The following tradeoffs were made:

  • Kept the documented "dumb handler + smart scheduler" invariant (docs/references/knowledge/workflow-architecture.md) and the per-phase job policies (timeout/retry) rather than collapsing the three per-item jobs into one reconcile job.
  • Job types stay internal (no renderer/telemetry consumer), so there is no external contract change.

The following alternatives were considered:

  • Merging the three per-item jobs (prepare-root / index-documents / check-file-processing-result) into one phase-derived reconcile job (P10) — rejected: it fights the documented dumb-handler invariant for near-zero net line savings, and its one real payoff is gated on a core capability that is not landing.
  • Adding finished(jobId) to await a sibling job — rejected: the maintainer deferred it in [Feature]: 补齐 core/job(JobManager)的类型化 settled 事件、级联取消与跨 job 等待能力 #16738 (the correct shape is DB-encoded dependsOn, needs its own RFC), and the polling chain is an intentional durable-timer pattern, not debt.

Links to places where the discussion took place: #16738 (maintainer ruling + KB-side checklist).

Breaking changes

None. Job types are internal with no renderer/telemetry consumer, and there is no external contract or default-value change. The migrations/ diff is a regenerated dev migration (idle-status drop + the P9 schema change) and will be squashed into the single clean initial migration before release.

Special notes for your reviewer

  • Suggested two-pass review: (1) the structural arc — confirm the utils/+vectorstore/+readers/pipeline/ moves and the service extraction are semantics-preserving; (2) the logic arc — the ~1,200 lines of real edits (directory crash-safety, KnowledgeBase invariant convergence, addressing unification, core/job wiring, guard unification).
  • The +3,912 lines under migrations/ are the regenerated dev migration, not meaningful review surface.
  • pnpm test / pnpm lint / pnpm format / pnpm build:check all pass locally.

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.

  • Branch: This PR targets the correct branch — main for active development, v1 for v1 maintenance fixes
  • PR: The PR description is expressive enough and will help future contributors
  • Code: Write code that humans can understand and Keep it simple
  • Refactor: You have left the code cleaner than you found it (Boy Scout Rule)
  • Upgrade: Impact of this change on upgrade flows was considered and addressed if required
  • Documentation: A user-guide update was considered and is present (link) or not required. Check this only when the PR introduces or changes a user-facing feature or behavior.
  • Self-review: I have reviewed my own code (e.g., via /gh-pr-review, gh pr diff, or GitHub UI) before requesting review from others

Release note

NONE

eeee0717 and others added 30 commits July 5, 2026 11:51
Pure moves + import path rewrites, zero logic change. The utils/
grab-bag hid the entire ingestion pipeline; stage directories now sit
at the feature root in pipeline order:

- utils/sources/  -> sources/   (input: directory expansion, url fetch, snapshots)
- utils/indexing/ -> indexing/  (chunk/splitter/embed/rerank/materialFields)
- utils/storage/  -> storage/   (raw/ path allocation)
- utils/cleanup/{subtreePurge,vectorCleanup} -> cleanup/
- utils/cleanup/statusCleanup -> ingestion/ (write-side only consumer)
- utils/addConflicts -> ingestion/
- utils/search -> query/ (read-side ranking helpers)
- utils/items -> items.ts (shared predicates/source probing)

External deep imports updated in KnowledgeMigrator/KnowledgeVectorMigrator
(+ test). vi.mock specifiers in task handler tests updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
Move the base-lifecycle block (~250 lines) out of the facade into
base/KnowledgeBaseAdminService: createBase (with rollback), deleteBase,
restoreBase (+toRestoreRuntimeInput), listBases, hasAnyBase, and the
deleteBase-only cancelAllJobsForBase. Code is moved verbatim; the facade
methods become one-line delegates, so all external call sites and tests
are untouched.

assertBaseCanRunRuntimeOperation becomes a free function in base/guards
so the read side (search/concepts) and the write side can share the
same base-status guard without going through the facade.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
…sorb admission checks

git mv KnowledgeWorkflowService.ts -> ingestion/KnowledgeIngestionService.ts
and align the class, handler params, and test mocks with the new name.

Admission checks sink from the facade into the ingestion service so one
flow's validation + orchestration live in one place:
- addItems absorbs the failed-base guard (base/guards)
- deleteItems absorbs the outermost-root expansion + empty early-return
- reindexItems absorbs the guard, root expansion, and the whole
  assertSubtreesCanReindex source/status precheck

Boot-time recovery (recoverDeletingItems / recoverInterruptedItems)
moves in as well — it re-enqueues write-side jobs. Facade methods become
pure delegates; behavior and call order are unchanged. The barrel now
exports only KnowledgeService (the other exports had no consumers).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
…eService

Move the read side (~700 lines) out of the facade into query/:

- KnowledgeQueryService: search (with visibility filtering, over-fetch
  trim, rerank) + listItemChunks + listRootItems and their private
  helpers, code moved verbatim
- KnowledgeConceptService: the Concept ID-addressed agent tool surface
  (readConcept/grepConcept/deleteConcepts/refreshConcepts/
  getOrganizationTree) together with its types, constants, and the grep
  scanner pure functions; delete/refresh delegate to the ingestion
  service's guarded deleteItems/reindexItems
- visibility.ts / storeOperation.ts: loadVisibleItems, deriveConceptId,
  and runStoreOperation become free functions shared by both classes

KnowledgeService is now a pure facade (~150 lines): lifecycle hooks,
module assembly, and one-line delegates. All public signatures are
unchanged, so external call sites are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
Document the pipeline main axis (sources -> readers -> indexing ->
vectorstore, driven by tasks/), the directory map, the five jobs and
their chaining/recovery semantics, the item status flow, and what the
per-base lock does (and does not) protect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
…root

storage/, base/, and cleanup/ each wrapped only 1-2 files around a
directory that added no grouping value — naming-conventions.md §4.4
promotes to a subdirectory only when a topic actually owns multiple
files, and storage/ (a lone pathStorage.ts) had no precedent elsewhere
in the codebase for a non-bucket single-file topic directory.

- storage/pathStorage.ts -> pathStorage.ts
- base/KnowledgeBaseAdminService.ts -> KnowledgeBaseAdminService.ts
- base/guards.ts -> baseGuards.ts (renamed: 'guards.ts' alone at the
  feature root lost the disambiguating parent directory name)
- cleanup/{subtreePurge,vectorCleanup}.ts -> root

All four are cross-cutting (consumed by ingestion/ and 3+ task
handlers, not owned by a single pipeline stage), matching the existing
items.ts/types.ts root-file precedent. Brings knowledge/ from 11
top-level subdirectories down to 8, closer to the sibling fileProcessing/
(5) and apiGateway/ (4) feature modules. Pure move + import rewrite +
two internal import-depth fixes in the moved files themselves; no
behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
Auto-fixed by eslint/biome across the flattened knowledge/ layout;
no logic changes.

Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
Top level now reads as one facade plus five domains:

- pipeline/ gathers the four stage dirs (sources/readers/indexing/
  vectorstore) under one roof; nothing inside enqueues jobs or mutates
  item status
- base/ owns the per-base domain: KnowledgeBaseAdminService, baseGuards,
  KnowledgeLockManager
- subtreePurge moves into ingestion/ (write-side teardown), vectorCleanup
  into pipeline/vectorstore/ (pure store maintenance)
- types/items.ts merges into items.ts, removing the single-file types/ dir
- pathStorage/items/types stay at the root as shared cross-domain vocabulary

Pure moves + import rewrites, zero behavior change. README directory map
and pipeline diagram updated to match.

Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
…e calls

assertBaseCanRunRuntimeOperation now returns the fetched KnowledgeBase so
search()/listItemChunks() no longer re-fetch it, and search()'s token check
and chunk-access guard are folded into the FTS layer's own tokenizer /
knowledgeItemService.getById instead of duplicating them. listItemChunks()
also drops its now-pointless Promise.all/await wrapper now that
KnowledgeIndexStore's per-material reads are synchronous (better-sqlite3).

Signed-off-by: eeee0717
Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
…esolveConcept

assertBaseCanRunRuntimeOperation already fetches the KnowledgeBase; addItems()
and resolveConcept() immediately re-fetched it by id right after with nothing
but a synchronous check in between, so use the guard's return value instead.

Signed-off-by: eeee0717
Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
markUnscheduledKnowledgeItemsFailed's MarkFailedInput.logContextKey was always
passed as the literal 'scheduleError' by both call sites, so drop the field
and hardcode the key in the two log payloads it built.

Signed-off-by: eeee0717
Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
…ss roots

assertSubtreesCanReindex queried each root's subtree and classified its source
one at a time in a sequential loop. rootItemIds always come from
getOutermostSelectedItemIds, which guarantees the roots are mutually
non-descendant, so a single batched getSubtreeItems(..., rootItemIds) call
covers every root's subtree in one query, and the per-root source
classifications can run concurrently via Promise.all instead of sequential
awaits.

Signed-off-by: eeee0717
Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
…eporter

checkFileProcessingResultJobHandler called ctx.reportProgress(...) directly
with raw payload literals in 8 places, instead of the typed
reportKnowledgeProgress(ctx, ...) helper every other handler already uses. All
payloads already matched KnowledgeProgressDetail, so this is a straight
call-site swap.

Signed-off-by: eeee0717
Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
…tore

better-sqlite3 is a synchronous driver; rebuildMaterial, listMaterialUnits,
getMaterialByRelativePath, readMaterialContent, search, reclaimSpace, close,
and listExistingEmbeddingHashes were still declared async/Promise<T> from the
old libsql-backed store, with zero real awaits in their bodies (only
deleteMaterials keeps async — it genuinely yields via setImmediate for large
batch deletes). Converting them to plain sync calls is behavior-preserving:
await on a non-Promise value is a no-op.

Also collapse listExistingEmbeddingHashes and assertEmbeddingCoverage's
identical batched SELECT ... IN (...) scans into one shared
selectExistingEmbeddingHashes helper parameterized over the SqliteExecutor
(driver vs. transaction) each call site already had.

Test suites updated in lockstep: Vitest's .rejects/.resolves matchers require
an actual Promise, so assertions on the now-synchronous methods switch to
expect(() => call()).toThrow(...) / plain expect(call()).toEqual(...), and the
now-pointless await at every call site is dropped.

Signed-off-by: eeee0717
Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
…ore calls

Follow-on to the KnowledgeIndexStore async→sync conversion: every call site
still awaited rebuildMaterial/listExistingEmbeddingHashes/reclaimSpace/close,
which oxlint's await-thenable rule now flags since the return values are no
longer Promises. Drop the await at each site (indexDocumentsJobHandler,
KnowledgeVectorStoreService.closeStoreInstance — now itself sync,
vectorCleanup.reclaimKnowledgeIndexSpace, KnowledgeVectorMigrator), converting
the wrapping function to sync too where it had no other awaits left.

An unawaited call also changes how a mocked rejection surfaces: a
mockRejectedValue Promise that is no longer awaited becomes an unhandled
rejection instead of a synchronous throw, so a surrounding try/catch written
for the now-sync method never fires. Test mocks for these methods switch from
mockResolvedValue/mockRejectedValue to mockReturnValue/mockImplementation(() =>
{ throw ... }) to match.

Signed-off-by: eeee0717
Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
execute() called this.db.prepare(sql) on every call, recompiling the same SQL
text repeatedly for hot paths like the batched embedding-hash scan. Cache
compiled Statement objects by SQL text; better-sqlite3 transparently
recompiles a cached statement if the underlying schema changes, and
assertOpen() already guards against use after close.

Signed-off-by: eeee0717
Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
- items.ts: delete ContainerKnowledgeItemType (zero references)
- pathStorage.ts: de-export getKnowledgeBaseMetaDir (only used in-file)
- types.ts: de-export KnowledgeWorkflowJobType (only used in-file)
- KnowledgeReader.ts: delete ReadableKnowledgeItem, a duplicate of items.ts's
  IndexableKnowledgeItem; loadKnowledgeItemDocuments now takes the latter
- indexStore/types.ts: delete VectorMatch (zero consumers)

Signed-off-by: eeee0717
Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
chunkItemDocuments was a same-signature pass-through to
chunkKnowledgeDocuments with no added behavior; call chunkKnowledgeDocuments
directly.

Signed-off-by: eeee0717
Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
expandDirectoryNode's `if (node.type !== 'folder') return null` guard was
unreachable: the preceding branch already returns for the 'file' case, so TS
statically knows node.type is 'folder' at this point.

Signed-off-by: eeee0717
Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
…peline/

pipeline/sources/prepare.ts's prepareKnowledgeItem/prepareDirectoryForRuntime/
createDirectoryChildren/createRuntimeItem created knowledge_item rows and
flipped item status directly — violating the feature README's invariant that
pipeline/ code doesn't enqueue jobs or mutate item status. Its only consumer,
prepareRootJobHandler's scanRootItem (already inside withBaseMutationLock),
threaded that through two no-op pass-through params:
onCreatedItem (always `() => {}`) and runMutation (always an identity
`async (task) => await task()`), a leftover from an earlier abstraction layer.

Move the file to tasks/prepareItem.ts (its only consumer already lives in
tasks/) and drop the two dead params, calling knowledgeItemService directly
(better-sqlite3 is synchronous, so no wrapper was ever needed).

Also reuse purgeKnowledgeSubtreeWithinLock (already used by
deleteSubtreeJobHandler) in deletePreviousLeafExpansion instead of a
hand-rolled delete-vectors/best-effort-delete-files/delete-rows sequence — the
two now do the exact same three steps in the same order, just no longer
duplicated. purgeKnowledgeSubtreeWithinLock short-circuits for an empty
subtree without calling deleteItemsByIds, unlike the old code which always
called it (even with []); this is a no-op-equivalent behavior change (an
empty-id-list delete does nothing either way), reflected in
prepareRootJobHandler.test.ts's updated assertion.

Signed-off-by: eeee0717
Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
…duler interface

Job handler factories only need scheduleItem/scheduleIndexing/scheduleFileProcessingCheck,
but took the full KnowledgeIngestionService type. Tests had to hard-cast their
3-method mocks with `as never` at every call site to paper over the mismatch.
Extract the narrow interface and have KnowledgeIngestionService implement it so
handlers depend only on what they use.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
…pansion failure

expandDirectoryOwnerToTree picks a dedup'd pathPrefix from the DB's existing
relativePaths, then copies file bytes into raw/<pathPrefix>/... before any
knowledge_item row references them. A failure partway through (disk full,
permission error, abort) left those bytes orphaned on disk with no DB row and
no reserved name, so the next attempt would reselect the same pathPrefix and
fail again with "Knowledge file already exists". Wrap the expansion in a
try/catch that best-effort removes the whole pathPrefix directory before
rethrowing, so a retry starts from a clean namespace.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
… and job recovery semantics

Several docs still pointed at the pre-v2-refactor layout: knowledge-service.md
referenced a nonexistent src/main/services/knowledge path, a since-renamed
KnowledgeWorkflowService class, and legacy colon-style IPC channel names instead
of the current knowledge.* DataApi routes. workflow-architecture.md repeated the
stale class name and an architecture diagram still labelled FileManager where
knowledge-owned files now go through pathStorage.ts directly.
operation-guards.md and README.md described indexing/reindex job handlers as
recovery:'retry' when they're actually recovery:'abandon' (interrupted items
are parked at failed by recoverInterruptedItems on boot, not silently resumed,
to avoid re-spending embedding cost) — only delete-subtree is 'retry'.
knowledge-technical-design.md's §4.7 still described joining
search_text.rowid = search_text_fts.rowid, the implicit-rowid join #16447's A1
fix deliberately replaced with the stable fts_rowid proxy key.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
…ctiveKnowledgeJobs

Two independent implementations of "cancel in-flight knowledge jobs (+ their
linked file-processing job) in a base's queue" had drifted apart:
KnowledgeBaseAdminService.cancelAllJobsForBase (base-wide, used by deleteBase)
called jobManager.cancel() directly with no timeout handling and no truthy
check on the linked fileProcessingJobId; subtreePurge.cancelActiveKnowledgeSubtreeJobs
(subtree-scoped, used by delete-items/replace-on-add) used cancelJobOrThrow and
did check fileProcessingJobId. Both run outside the base mutation lock (cancel
awaits handler settlement, and the handlers themselves take that lock, so
cancelling while holding it would deadlock).

Introduce cancelActiveKnowledgeJobs(baseId, reason, options) in tasks/utils/cancel.ts
covering both the base-wide and subtree-scoped shapes via an options object
(rootItemIds + excludeJobId + onCancelTimeout: 'throw' | 'proceed'), and switch
all three call sites (deleteBase, deleteSubtreeJobHandler, and
KnowledgeIngestionService's replace-on-add path) onto it. Delete the now-dead
cancelAllJobsForBase/cancelActiveKnowledgeSubtreeJobs plus their now-unused
imports. Also documents KnowledgeItemScheduler's role, missed when that
interface was extracted.

⚠️ Side-fix (not pure refactor): the base-wide path (deleteBase) now also
skips cancelling an empty-string fileProcessingJobId, matching the subtree
path's existing truthy check. Schema allows an empty string in that field in
principle, though normal write paths never produce one, so this is a
narrow-but-real behavior change, not just a rename.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
…solveLiveKnowledgeItem/Subtree

"Is this item/subtree gone (NOT_FOUND) or being deleted (status==='deleting'),
and if so treat the operation as an idempotent no-op" was hand-rolled ~8 times
across prepareRootJobHandler, indexDocumentsJobHandler, checkFileProcessingResultJobHandler,
and reindexSubtreeJobHandler, with the classification logic (catch NOT_FOUND /
check status==='deleting') repeated near-verbatim each time. Extract only that
classification into tasks/utils/liveItem.ts (resolveLiveKnowledgeItem for a
single item, resolveLiveKnowledgeSubtree for a resolved root+descendants set)
and leave each call site's own logging text, progress stage, and control flow
in place — operation-guards.md's convention of each operation keeping its own
explicit flow is preserved; only the duplicated classification is deduped.

checkFileProcessingResultJobHandler.shouldSkipMissingOrDeletingItem keeps its
extra baseId ownership check (an explicit throw, not a skip) outside the
shared helper, since that's specific to this one call site.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
…ItemService.createActive

create() hardcoded status: 'idle' on insert; both production call sites
(prepareItem.ts, KnowledgeIngestionService.addItems) immediately followed it
with updateStatus(id, isContainerKnowledgeItem(item) ? 'preparing' : 'processing')
— the same "container -> preparing, leaf -> processing" rule duplicated twice,
plus an extra write transaction in between that was never observably idle.

Rename create() to createActive() and compute the active status inline from
item.type (mirroring the existing item.type === 'directory' check in
reindexSubtreeJobHandler, rather than importing the feature-layer
isContainerKnowledgeItem into the data layer). Call reconcileContainers after
insert to preserve the container-reactivation side effect that updateStatus's
non-failed branch used to trigger — e.g. adding a live child under an already
completed directory pulls the parent back to preparing/processing. Both call
sites drop their now-redundant updateStatus follow-up call.

Also fixes updateStatus's deleting-guard branch: it returned early inside the
withWriteTx callback, but the method itself still fell through to the normal
"Updated knowledge item status" info log — a blocked update looked identical
to a successful one in the logs. Pull the blocked flag out of the transaction
and log a distinct warn instead.

⚠️ Side-fix (not pure refactor): updateStatus's deleting-guard branch now logs
a warn instead of a misleading "Updated" info line when a status update is
blocked because the item is being deleted. Return value and DB state are
unchanged — this only affects log output.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
…in index-documents

README.md documents that the base mutation lock must only wrap the mutation
step of index-documents, never the network fetch or the embedding API call —
but no test asserted the ordering, only that both eventually happened.
withBaseMutationLock's test double is a plain pass-through (vi.fn(async
(_baseId, task) => await task())), so mock.invocationCallOrder already lets
us assert fetch/embed happen strictly before the lock is acquired; add those
assertions to the two tests that already exercise this path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
…antics

The mock's withWriteTx was a pure pass-through (fn(this.db)), even when
setupTestDatabase() had attached a real better-sqlite3 connection via
MockMainDbServiceUtils.setDb() — so tests never got the rollback-on-throw
semantics that production's this.db.transaction(fn, { behavior: 'immediate' })
provides. README.md also documented the mock's signature as async
((fn: (tx) => Promise<T>) => Promise<T>), which doesn't match the synchronous
production signature.

Delegate to the real db's .transaction() when one is attached (detected via
typeof db.transaction === 'function', which only real better-sqlite3
instances have — hand-written mock db literals don't), falling back to the
plain pass-through otherwise. Update the README's documented signature to
match the synchronous production one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
…s against a real temp-dir store

The 22-test suite mocked all 10 collaborators (KnowledgeIndexStore,
BetterSqlite3Driver, BetterSqlite3VectorIndex, schema.ts, indexMeta.ts,
knowledgeItemService, pathStorage, fs, @logger, @main/core/lifecycle) plus
hand-copied MOCK_SCHEMA_VERSION to mirror schema.ts's real constant — asserting
against the mocks' own call counts rather than the real open/rebuild/evict
logic.

Rewrite against a real temp-dir SQLite store (mkdtempSync + real
openBetterSqlite3IndexDriver, matching the pattern already used by
BetterSqlite3Driver.test.ts), keeping only the app-DB and lifecycle
boundary mocks (knowledgeItemService, pathStorage, @logger,
@main/core/lifecycle). Stale/downgrade-version tests now stamp a real
version via direct SQL (`UPDATE meta SET schema_version = ?`) rather than
ensureIndexMeta, which hardcodes the current constant and can't write an
arbitrary version. Failure-injection tests (evicts-a-failed-open, meta
verification, schema creation) use targeted vi.spyOn on the real modules
instead of blanket vi.mock. The "single-flight" test is retitled to reflect
what getIndexStore's own doc comment already states: the method has no
`await` before caching, so a second concurrent call can't observe a
genuinely in-flight open — there's no async dedup mechanism to test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
…ades, drop idle status

Wires the KB module onto the core/job capabilities the maintainer merged in
response to issue #16738 (KeyedMutex, typed settled events, list() filters),
and clears scaffolding that predated them:

- delete KnowledgeLockManager in favor of the core KeyedMutex it duplicated
- collapse statusCleanup's updateStatus-then-setSubtreeStatus double-write
  into a single setSubtreeStatus call (its CTE already guards deleting rows)
- desync KnowledgeVectorStoreService's getIndexStore/getIndexStoreIfExists
  now that better-sqlite3 makes them synchronous under the hood
- drop the unreachable idle KnowledgeItem status; add a never_indexed error
  code for the migration-mapping branch that used to fall back to it
- swap KNOWLEDGE_ACTIVE_JOB_STATUSES for the shared ACTIVE_JOB_STATUSES
- read onSettled's typed event.input directly instead of re-fetching the job
- drop the redundant input.parentJobId payload field now that ctx.parentId
  carries the same value
- push parentId/type filters into JobManager.list() instead of filtering
  results in JS

This is the first-two-layers slice of a maintainer-invited follow-up
checklist; enqueueTx adoption and the two 5000-job-limit loop-until-drained
fixes are intentionally left for a later pass.

Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
eeee0717 and others added 7 commits July 5, 2026 11:52
…n cancel beyond the page limit

deleteItems previously set items to 'deleting' inside the base lock, then
enqueued the delete-subtree job in a separate step with no rollback on
failure — a failed enqueue left items stuck in 'deleting' forever with no
job to clean them up. Split KnowledgeItemService.setSubtreeStatus into a
Tx variant and run the status update + JobManager.enqueueTx together in
one write transaction, so either both commit or both roll back.

cancelActiveKnowledgeJobs capped its job listing at KNOWLEDGE_ACTIVE_JOB_LIMIT
(5000), silently leaving any excess active jobs uncancelled. Reinterpret the
constant as a page size and loop until a page comes back short, re-querying
from the top each round since cancelling shrinks the active-job set between
rounds and list()'s createdAt-only ordering has no stable offset.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
…one shared schema

create()/update() each hand-rolled their own subset of the cross-field rules
already expressed in KnowledgeBaseSchema's superRefine, so the embedding/dimensions
pairing, no-model-bm25, and chunk-overlap checks were defined 2-3 times with drifting
wording, and no boundary validated the delimiter/separator pairing at all.

Extract the refine callback into refineKnowledgeBaseInvariants, reuse it for a new
KnowledgeBaseWriteSchema covering the pre-write candidate row, and have create()/
update() validate their about-to-be-written row against it via safeParse +
toDataApiError instead of their own DataApiErrorFactory.validation calls. Add the
missing delimiter/separator check, and align update()'s two status-gated rules
(embedding pairing and no-model-bm25) to both fire only when completed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
…read processedRelativePath through job payload

Eliminates a query->pipeline reverse import (toMaterialRelativePath now
lives in the shared items.ts) and removes the fp-check job's absolute-path
rederivation in favor of passing the already-known processedRelativePath
through the job payload, deleting the now-dead toKnowledgeRelativePath/
isPathInsideBase helpers.

Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
…ose crash-safety gap

Directory expansion durably copied file bytes into raw/<pathPrefix>/ before
pinning the container's relativePath or committing any child row. A crash/kill
in that window left orphan bytes that no retry could reclaim: the retry's prefix
selection only counts DB rows (orphan bytes contribute nothing), re-picks the
same prefix, and assertTargetAvailable then hits "Knowledge file already exists"
in a permanent failure loop. The retry purge walks getSubtreeItems, which
excludes the container row, so the container's own raw/<prefix> shell was never
swept.

Design P (pin-first + explicit container-prefix reclaim on retry):
- directory.ts: split the pure prefix chooser (chooseDirectoryPathPrefix) out of
  expandDirectoryOwnerToTree; the expander now takes pathPrefix and returns the
  node array. Drop the stale in-function try/catch cleanup.
- prepareItem.ts: pin updateDirectoryRelativePath BEFORE any byte is copied.
- prepareRootJobHandler.ts: after the descendant purge, removeDir the container's
  own raw/<relativePath> shell (idempotent, same runExclusive lock).

Invariant: the pin strictly precedes the first copy, so any window that holds
orphan bytes has the container row already pinned to those bytes' prefix -> the
retry removeDir deletes a superset of the orphans. happy-path behavior is
unchanged; empty/failed directories now reserve their top-level name (a
failure-path change we accept as more correct).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
…sses the subtree

cancelActiveKnowledgeJobs drained the active-job queue by re-listing a full page
until the raw page length dropped below KNOWLEDGE_ACTIVE_JOB_LIMIT. For a scoped
(subtree) cancel, the subtree filter runs only in memory, so a full page whose
every job misses the scope cancelled nothing, never shrank the active set, and
re-queried the same page forever — hanging delete-subtree/replace-on-add and
busy-spinning a core.

Break when the page comes back short OR when a full page yielded nothing
cancellable this round, so a scoped cancel with no in-scope jobs stops instead
of looping. Base-wide drain behavior is preserved (a full page of matching jobs
still loops until drained). Adds a regression test that returns the same
all-miss full page on every list() call — it hangs on the old code and asserts
a single list() with no cancels on the new.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
…e refactor

This branch dropped the idle item status and the KnowledgeLockManager class
(the per-base lock is now a core KeyedMutex instance created by the
KnowledgeService facade) and moved several helpers/methods, but prose and
comments still referenced the old shapes.

- Remove idle from status lists (knowledge-service/workflow-architecture/
  operation-guards) and code comments; the migrator maps no-uniqueId items to
  failed (never_indexed), so its README now says so instead of idle.
- Replace KnowledgeLockManager references with the concrete per-base mutation
  lock / KeyedMutex.runExclusive wording, drop the deleted withBaseMutationLock
  method reference, and correct the base/-owns-the-lock factual error (the
  facade creates it and shares it).
- Fix stale symbol references: toKnowledgeRelativePath -> toMaterialRelativePath,
  KnowledgeService.recoverInterruptedItems -> KnowledgeIngestionService, and move
  the material-path derivation note from pipeline/indexing/ to items.ts in the
  feature README directory map.
- Correct the feature README query/ role: it is not purely a read side — the
  Concept ID tool surface (KnowledgeConceptService) also exposes kb_manage
  delete/refresh writes that delegate to ingestion/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
…pi error helpers

query/visibility.ts imported isDataApiNotFoundError from tasks/utils/settled.ts,
a query -> tasks reverse dependency that broke the read side's downward-only
layering. The predicate is a fully generic DataApi check
(isDataApiError(e) && e.code === NOT_FOUND) with no task-specific logic, so it
belongs next to its siblings isDataApiError / isRetryableErrorCode in
@shared/data/api/apiErrors.ts rather than in a knowledge task util.

Move it there (verbatim, still a boolean predicate — no speculative type-guard
narrowing) and re-point all five consumers (settled, liveItem, prepareRoot and
indexDocuments handlers, query/visibility) at @shared/data/api. query/ now
depends only downward on @shared, closing the leak.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: eeee0717 <chentao020717Work@outlook.com>
@eeee0717 eeee0717 requested a review from a team July 5, 2026 08:38
@eeee0717 eeee0717 requested a review from 0xfullex as a code owner July 5, 2026 08:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant