Skip to content

feat(sdk): add semantic search by file helper - #469

Open
you06 wants to merge 500 commits into
mem9-ai:mainfrom
you06:feat/semantic-search-by-file-sdk
Open

you06 wants to merge 500 commits into
mem9-ai:mainfrom
you06:feat/semantic-search-by-file-sdk

Conversation

@you06

@you06 you06 commented May 27, 2026 •

Copy link
Copy Markdown
Contributor

SDK: search file by file

Summary by CodeRabbit

  • New Features

    • Added enriched file metadata functionality across all SDKs (JavaScript, Kotlin, Python, Rust, Swift) through new statMetadata / stat_metadata method, returning comprehensive file information including semantic classification, tags, content type, and standard metadata fields.
  • Documentation

    • Updated README files for all SDKs documenting the new enriched metadata operation.

Review Change Stack

JaySon-Huang and others added 30 commits April 7, 2026 13:43
Unify server, local, test, and e2e configuration under the documented DRIVE9_* namespace so README examples work without DAT9_* fallbacks. Rename the test DSN to DRIVE9_TEST_MYSQL_DSN and move the local helper script to the documented path.
Align DRIVE9 env vars and server naming
Add algorithm-aware S3 interface: CreateMultipartUpload and
PresignUploadPart now accept a ChecksumAlgo parameter (SHA256 or
CRC32C). v1 upload chain (initiate, resume, client upload) uses
CRC32C for faster hardware-accelerated integrity checks. v2 and
patch paths remain on SHA256 — no protocol change there.

Scope reduction: this PR only changes v1. v2/patch stay as-is.
Deploy-time action: abort all in-flight v1 uploads before deploying,
since existing uploads were created with SHA256 and cannot be
completed with CRC32C checksums.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
…i#134)

* fix: v2 multipart upload should not declare ChecksumAlgorithm (mem9-ai#133)

InitiateUploadV2 was creating S3 multipart uploads with
ChecksumAlgorithm=SHA256, but the v2 client never sends per-part
checksum headers (ChecksumContract.Required=false). S3 rejects
parts that lack the declared checksum header.

Fix: use ChecksumAlgoNone for v2 initiate so S3 does not require
per-part checksums. v1 (CRC32C) and patch (SHA256) are unchanged.
When mem9-ai#114 adds inline per-part checksums to the v2 client, the
algorithm declaration can be re-enabled.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: normalize v2 multipart ETags on complete

* fix: reject unknown multipart checksum algorithms

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: JaySon-Huang <tshent@qq.com>
…vider-design

docs: add tidbcloud-native provider design
Move provider, schema, and token files into dedicated sub-packages:

- pkg/tenant/db9/: DB9 provisioner + PostgreSQL DDL
- pkg/tenant/tidbzero/: TiDB Zero provisioner
- pkg/tenant/starter/: TiDB Cloud Starter provisioner + test
- pkg/tenant/schema/: schema init, TiDB embedding mode detection/validation
- pkg/tenant/token/: JWT token issue/parse/verify, Claims type, helpers

Root tenant package retains: provider constants, Provisioner interface,
Pool (LRU), and shared test infrastructure.

All external consumers (pkg/server, cmd/drive9-server-local) updated.
Rename local var 'token' to 'apiToken' in server.go to avoid shadowing
the new token sub-package import.
refactor(tenant): reorganize into sub-packages by concern
)

* client: reuse presigned url for parallel downloads

* cli: gate benchmark download summary output

* client: reuse multipart upload buffers

* cli: log download summary via cli logger

* logger: centralize cli log path helpers

* cli: expose build git hash in version output

* client: document parallel download follow-ups

* client: guard upload buffer pool ownership

* client: fallback large download when redirect is absent
…fetch, TTL (mem9-ai#136)

* feat: FUSE performance overhaul — streaming upload, lazy preload, prefetch read, TTL + invalidation

Four core optimizations to improve FUSE mount performance:

1. **Streaming Multipart Upload**: Parts are uploaded in the background as
   they fill (8MB each) via StreamUploader + client.StreamWriter. close()
   only waits for the last partial part + CompleteMultipartUpload, reducing
   close latency from ~1.5s to ~200ms for large files.

2. **Lazy Preload**: Opening existing large files no longer reads the entire
   file into memory. Only Stat() is called at open time; parts are loaded
   on demand via ReadStreamRange when actually written to. Removes the
   256MB EFBIG hard limit.

3. **Sequential Read Prefetch**: Detects sequential read patterns and
   prefetches upcoming data blocks with an adaptive window (256KB → 16MB).
   Reduces HTTP round-trips for large sequential reads (e.g. cat, cp).

4. **TTL 60s + Active Kernel Cache Invalidation**: Raised kernel attr/entry
   TTL from 1s to 60s. All mutation points (Create, Mkdir, Unlink, Rmdir,
   Rename, SetAttr, flushHandle) now call EntryNotify/InodeNotify to
   invalidate stale kernel cache entries immediately.

Also: MaxReadAhead raised from 128KB to 8MB, e2e tests updated for CRC32C
checksums (from earlier SHA-256 → CRC32C switch).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review comments and CI lint errors

CI fixes:
- dat9fs_test.go: check w.Write return values (errcheck)
- stream.go: remove redundant nil check before len() (staticcheck S1009)

PR review feedback:
- Prefetcher: fix misleading docstring — it is fully self-synchronized
  via p.mu, callers do NOT need to hold FileHandle.mu. Document lock
  ordering (FileHandle.mu → Prefetcher.mu) and forward-sequential
  eviction assumption.
- WriteBuffer: replace origSize() magic value (1<<63-1) with explicit
  remoteSize field. ensurePart() now only calls LoadPart for parts
  whose start offset < remoteSize, giving clear semantics for which
  parts exist remotely vs are new.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review critical issues — race conditions, goroutine leaks, error handling

- StreamWriter: move inflight.Add(1) inside mutex to fix race with
  Complete().Wait(); add aborted state and guard all state transitions
- StreamUploader: SubmitPart now returns error instead of silently
  dropping; track submitted parts to prevent duplicate uploads when
  OnPartReady fires on rewrites of full parts
- Prefetcher: add Close() with context cancellation to prevent
  goroutine leaks on file handle Release
- WriteBuffer: add notifiedParts tracking to prevent duplicate
  OnPartReady callbacks; add ReadAt method for efficient partial reads
  without materializing the entire sparse buffer
- dat9fs: Release now aborts streamer on flush failure and closes
  prefetcher; Read path uses ReadAt instead of Bytes()
- Fix inaccurate comments: remove v1 fallback claim, fix PartData
  and ensurePart docs, clarify Prefetcher lifecycle
- Add tests: OnPartReady dedup, ReadAt, Prefetcher.Close,
  StreamUploader dedup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: defer streaming upload to flush time to fix FUSE write correctness

The previous design eagerly uploaded parts during Write() via OnPartReady
callbacks. This had two critical correctness bugs:

1. StreamUploader initiated v2 multipart with stale totalSize when the
   first 8MB part filled. The backend persists a fixed PartsTotal, so
   later parts beyond the initial plan couldn't be presigned.

2. OnPartReady fired only once per part, and submittedParts blocked
   re-submission. If a process filled part 1, triggered upload, then
   revisited part 1 (e.g. patching headers/checksums), the updated
   bytes were never re-sent — stale content was silently persisted.

Root cause: FUSE allows arbitrary writes to any offset until close().
Eagerly uploading parts during Write() is fundamentally incompatible
with these semantics because (a) the final file size is unknown until
close, and (b) any part can be rewritten before close.

Fix: Remove the entire OnPartReady mechanism. StreamUploader.UploadAll()
is now called only at flush/close time with the final file size and all
part data, initiating the v2 upload and uploading all parts in parallel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: sequential write streaming — upload parts during Write() for large files

For sequential append workloads (cp, dd, ffmpeg), parts are now uploaded
as they fill during Write() and memory is evicted immediately after upload.
This reduces memory from O(file_size) to ~24MB constant, enabling files
up to 10GB (previously capped at 64MB).

Key changes:
- WriteBuffer: appendCursor tracking, OnPartFull callback, EvictPart
- StreamUploader: SubmitPart + FinishStreaming for streaming mode
- dat9fs: wire streaming in Create/Open, Path 1a in flushHandle
- Read: fall through to server for evicted part ranges
- SetAttr: ResetSequentialState after ftruncate
- Existing file bufMax raised to max(size*2, 1GB) with lazy load

Bug fixes found during self-review:
- SubmitPart errors now logged instead of silently ignored
- Exact partSize-multiple files no longer double-upload last part
- ReadAt no longer returns zeros for evicted (streamed) parts
- ftruncate resets appendCursor to avoid false back-write detection
- DirtyStreamedParts simplified to avoid redundant map lookup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review — race conditions, stale reads, negative cache, abort context

Fixes from PR mem9-ai#136 review comments:

1. StreamWriter: add `closing` flag to seal writer before Wait() in
   Complete/Abort, preventing late WritePart from racing with shutdown.

2. Mkdir/Create: add notifyEntry() to invalidate kernel negative entry
   cache, preventing stale ENOENT after creating new files/dirs.

3. Read path: lazily load unloaded parts before serving ReadAt for dirty
   handles, fixing mixed dirty/clean reads returning zeros for unloaded
   parts of lazily-loaded files.

4. Prefetcher: verify block identity before cache deletion to prevent
   removing a replacement block inserted between unlock/relock.

5. StreamUploader: use context.WithTimeout(Background, 30s) for Abort
   calls in error paths, preventing orphaned multipart uploads when the
   original context is already canceled.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: remove agent-knowledge from drive9

Moved to the dedicated agent-knowledge repo at
github.com/qiffang/agent-knowledge.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review critical issues — race conditions, goroutine leaks, error handling

1. Fix flushHandle deadlock: SubmitPart's onDone callback acquires fh.mu,
   but FinishStreaming (called holding fh.mu) waits on inflightWg which
   blocks until onDone completes → deadlock. Fix: release fh.mu before
   FinishStreaming/UploadAll network calls, re-acquire after.

2. Fix Release deadlock: Abort() also calls inflightWg.Wait(), same
   deadlock pattern. Fix: call Abort() without holding fh.mu.

3. Fix evicted-part reads on new files: for new files mid-stream
   (remoteSize == 0), the multipart upload hasn't been completed yet,
   so ReadStreamRange would fail with ENOENT. Return EIO instead of
   falling through to server read.

4. Fix prefetch inflight leak: error paths in startPrefetch goroutine
   left p.inflight[offset] set permanently, preventing retries on
   transient failures. Fix: use single deferred cleanup that always
   clears inflight, regardless of success/error.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
…em9-ai#144)

* feat(fuse): add StatFs, xattr stubs, mtime propagation, SetAttr mtime, and write debounce

Obsidian and other macOS apps cannot write through FUSE because several
critical POSIX operations are missing. This commit adds:

- StatFs: report 1 TiB virtual capacity so apps see free space
- Xattr stubs: GetXAttr/ListXAttr/SetXAttr/RemoveXAttr for Finder/Spotlight
- Mtime propagation: server → client → FUSE via X-Dat9-Mtime header and
  JSON mtime field in directory listings
- SetAttr mtime: handle FATTR_MTIME and FATTR_MTIME_NOW locally
- Write debounce: per-path coalescing for small-file flush operations with
  configurable --flush-debounce flag (default 2s)
- Fix critical data loss bug in debounce→Release path where ClearDirty()
  was called synchronously, causing Release to find no dirty data after
  cancelling the debounce timer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review comments — DNS fallback, debounce races, FlushDebounce=0

1. Gate DNS fallback on *net.DNSError only (not all dial errors) to avoid
   leaking internal hostnames to public resolvers on non-DNS failures.

2. Guard debounce callback's ClearDirty() with DirtySeq comparison to
   prevent data loss when writes occur between snapshot and callback.

3. Fix race conditions in debounce tests by using atomic types and
   channels instead of unsynchronized shared variables.

4. Fix race on `uploaded` variable in regression test by using a channel.

5. Allow FlushDebounce=0 to truly disable debouncing. Use negative
   sentinel (-1) for "unset/use default" in setDefaults().

6. Add --flush-debounce to README mount options and add code fence lang.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
…t timeouts (mem9-ai#155)

The FUSE mount succeeded but all subsequent operations (ls, cd, rm) hung
because mount.go called server.Serve() directly instead of the required
go-fuse initialization sequence: go server.Serve() → server.WaitMount()
→ server.Wait(). Without WaitMount(), the pollHack never ran, which could
cause macOS to deadlock on _OP_POLL requests.

Additionally, all FUSE operations now use context-aware HTTP calls with
30-second timeouts (wired through the FUSE cancel channel), preventing
slow/dead servers from blocking the FUSE event loop indefinitely. Added
7 context-aware client methods (StatCtx, ListCtx, ReadCtx, WriteCtx,
DeleteCtx, RenameCtx, MkdirCtx) to support cancellation.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
…-ai#148)

* Add issue template

Signed-off-by: JaySon-Huang <tshent@qq.com>

* datastore: add semantic task lease renew

* server: refactor semantic worker task outcomes

* server: renew semantic task leases

* cmd: use fixed default semantic lease

* server: document lease renewal API and stabilize lease tests

Clarify public and tricky semantic lease-renewal behavior with targeted comments, and harden lease worker tests against timing races so the expected renew/lease-loss contract is validated reliably.

Made-with: Cursor

* docs: relocate issue templates and update AGENTS guidance

Move GitHub issue templates to `.github/ISSUE_TEMPLATE/` and document in AGENTS.md that new issues should follow those templates.

Made-with: Cursor

* docs: update bug template to request drive9 version

Replace TiDB-specific version guidance in the bug report template with a drive9-specific prompt and command example.

Made-with: Cursor

* server: harden semantic task lease shutdown

* datastore: make semantic renew errors explicit

* docs: align local env and enhancement template

* address comment

Signed-off-by: JaySon-Huang <tshent@qq.com>

---------

Signed-off-by: JaySon-Huang <tshent@qq.com>
…-ai#156)

* fix(fuse): async kernel notifications to prevent macOS deadlock + regression tests

notifyEntry/notifyInode called server.EntryNotify/InodeNotify synchronously
inside FUSE handlers (Create, Mkdir, Unlink, Rmdir, Rename, flushHandle).
On macOS, the kernel processes invalidations in-band, triggering new FUSE
operations (Lookup, GetAttr) back to userspace. When all go-fuse worker
threads are occupied, this causes a deadlock — manifesting as `echo > file`
hanging indefinitely after a successful mount.

Changes:
- Make notifyEntry/notifyInode dispatch via `go func()` (async)
- Add timeout to LoadPart callback to prevent indefinite blocking under fh.mu
- Add 7 regression tests covering the full Create→Write→Flush→Release
  lifecycle, concurrent operations, mutation handler timeouts, parallel
  lock ordering, and server failure scenarios

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(fuse): address PR review — shutdown-aware notifications, full mutation coverage

1. Add notifyWg sync.WaitGroup to Dat9FS to track inflight async
   notification goroutines. FlushAll now drains them before returning.

2. Rewrite TestNotifyEntry_NonBlocking to use a non-nil gofuse.Server
   (via Init) so the test actually exercises the async goroutine path
   instead of short-circuiting through the nil guard.

3. Add Rmdir and Rename to TestMutationHandlers_CompleteWithinTimeout
   for complete coverage of all notification-emitting mutation handlers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* add failpoint test runner infrastructure

Introduce a dedicated failpoint test entrypoint so instrumented tests can run without affecting the default go test path.

Made-with: Cursor

* add datastore failpoint lease boundary tests

Inject a failpoint-aware lease clock inside semantic task ownership checks so lease expiry cases can be tested deterministically without sleep-based timing windows.

Made-with: Cursor

* refactor worker finalize for failpoint boundary checks

Move semantic task terminal decisions to a single finalize path so ownership can be rechecked and failpoint-tested deterministically before ack or retry.

Made-with: Cursor

* document failpoint test boundaries

Clarify the failpoint test entrypoint and the lease-sensitive ownership checks so the new failpoint paths stay readable during maintenance and review.

Made-with: Cursor

* migrate worker lease timing tests to failpoints

Replace the flaky semantic worker renew, lease-loss, and panic timing checks with deterministic failpoint-driven coverage so worker lease transitions can be validated without race-prone sleeps.

Made-with: Cursor

* document failpoint test workflow

Capture the failpoint test entrypoints, concurrency caveats, and authoring guidelines in AGENTS.md and README.md so contributors can run and extend the suite without corrupting normal test runs.

Made-with: Cursor

* add image extract writeback failpoint tests

Inject deterministic writeback failures around image text update and embed-task bridging so backend image extraction can verify transactional rollback behavior without timing-sensitive mocks.

Made-with: Cursor

* add failpoint tests to code CI

Run the failpoint suite after the normal test pass in CI and install failpoint-ctl on the runner so the deterministic fault-injection coverage stays enforced on PRs.

Made-with: Cursor

* remove stale semantic worker test helpers

Drop the worker test helpers that became unused after the failpoint test migration so golangci-lint stays clean without changing runtime behavior.

Made-with: Cursor

* Fix build

Signed-off-by: JaySon-Huang <tshent@qq.com>

* fix semantic worker ownership-check fallbacks

Treat finalize ownership-check read failures as transient errors instead of lease loss, tighten the failpoint tests to query the exact task under test, and document that lint must not overlap with failpoint instrumentation.

Made-with: Cursor

* Refine semantic worker failpoint review follow-ups.

Align the worker ownership check with failpoint-controlled lease time, remove an unused renew-stop failpoint hook, and document the accepted command_exists shell-injection tradeoff in the failpoint runner.

Made-with: Cursor

* Harden failpoint runner cleanup and tool lookup.

Resolve failpoint-ctl using GOBIN-aware path selection and surface disable failures so the runner cannot silently leave the repository instrumented after a failpoint test run.

Made-with: Cursor

---------

Signed-off-by: JaySon-Huang <tshent@qq.com>
qiffang and others added 22 commits May 19, 2026 17:46
Consolidate Drive9 CLI token/context UX per task mem9-ai#11 Plan B.

- Add ctx list type filters and scoped shortcut
- Add ctx rm as local-only context cleanup with explicit safety warnings
- Keep token issue/revoke as server-side capability lifecycle
- Hide/deprecate token list/forget aliases
- Add regression coverage for English-only help and stale deprecated-command guidance

Reviewed in Slock: primary LGTM by @adversary-1, GTM spot-check by @Gtm-1, final review by @Dev-1. CI green; using admin merge because GitHub app/auth maps agent approvals to the PR author account.
…#452)

* cli: support display tenant_id in drive9 ctx for tracing log

Signed-off-by: JaySon-Huang <tshent@qq.com>

* docs: clarify decode_drive9_config.py is for legacy CLI versions

Prefer drive9 ctx show/ls when tenant_id is already displayed.

---------

Signed-off-by: JaySon-Huang <tshent@qq.com>
* server: rotate semantic tenant scans

* server: drop semantic_worker_observe info log

Keep observeOnce metrics-only so periodic observation does not emit noisy logs.

* server: log tenant scan wrap rounds

Document nextTenantScanPage behavior and emit semantic_worker_tenant_scan_wrapped
with raw and included tenant counts when pagination wraps.

* test: use ResetMetaDB for tenant scan rotation test

CodeRabbit suggested testmysql.ResetDB, but meta store tests need control-plane
table cleanup; add ResetMetaDB and use it in the rotation test.

* meta: add index for active tenant keyset scans

Add tenants (status, created_at, id) index for ListTenantsByStatusAfter and
cover it with schema-spec and missing-index repair tests.

* test: use Errorf for tenant index schema assertions

Keep the missing-index setup as Fatal but report createSQL, diff, and repair
plan mismatches as non-fatal errors.
* ci: publish agent archive to drive9

* fix archive publisher review findings

* fix archive manifest validation

* Simplify Drive9 archive workflow

* Remove Drive9 archive schedule

* fix drive9 archive review findings
* Fix CLI context server precedence

* Fix mixed-context fs routing

* Fix recursive fs context resolution

* Address context server review feedback
* fix cli help success path

* fix ctx rm special-name removal
* feat: add symlink support

Add backend/server/client/FUSE symlink handling, expose drive9 fs symlink, and cover symlink behavior in unit and smoke tests.

* fix: address symlink review feedback

* fix: harden symlink review follow-up
* Add latest archive index

* Prevent stale latest archive index
* fix(fuse): keep local writes visible during reopens

* fix(fuse): harden local reopen visibility

* fix(fuse): keep readdirplus entry metadata
* fix(fuse): preserve pending create modes

* fix(fuse): honor chmod commit failures

* fix(fuse): avoid stale default-mode lockfiles

* fix(fuse): retry writeback chmod without reupload

* fix(fuse): acknowledge pending chmod retry error

* fix(fuse): keep writeback data pending on upload failure

* fix(fuse): retry release deferred chmod visibility

* test(fuse): prove release chmod retry updates mode

* fix(fuse): guard deferred chmod generations
* feat: add coding agent mount policy

* fix: harden coding agent policy scaffold

* fix: align coding agent policy scaffold

* feat: route coding-agent local-only paths

* perf: avoid repeated local policy path canonicalization

* fix: clean up local policy helpers

---------

Co-authored-by: mornyx <mornyx.z@gmail.com>
* fix(drive9-rs): make Semaphore permit own its lifetime

StreamWriter::write_part() moved the permit acquired from `self.sem` into a
`tokio::spawn`'d task, but `Semaphore::acquire()` returns a permit borrowed
from `&self.sem`, so the spawned future would have to outlive `'static` with
a non-`'static` borrow. Recent stable rustc enforces this.

Switch `sem` to `Arc<Semaphore>` and use `acquire_owned()` so the permit can
travel into the spawned task safely. Behaviour (concurrency limit) is
unchanged; only the lifetime annotation moves.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

feat(drive9-rs): add rustls-tls feature for mobile builds

drive9-rs depended on reqwest with default features, which forces native-tls.
Android NDK and iOS targets don't have a system OpenSSL, so the mobile wrapper
crate (clients/drive9-mobile-core) needs a way to opt into rustls instead
without breaking the existing desktop story.

Gate the TLS backend:
- `default = ["native-tls"]` keeps current desktop behaviour (reqwest's
  default-tls / native-tls).
- New `rustls-tls` feature switches to reqwest's rustls-tls.

Both feature combinations build cleanly; existing tests still pass under
default features.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

feat(clients): add drive9-mobile-core UniFFI wrapper crate

Phase-1 mobile wrapper around drive9-rs. Stays intentionally small so the FFI
surface is auditable and easy to keep stable across Kotlin and Swift.

Exposed surface:
- Drive9MobileClient::new(base_url, api_key) — owns its own multi-thread
  Tokio runtime via Arc<Runtime>; share a single client across the app.
- write / read / list / stat / delete (path-based FS API).
- write() takes an optional expected_revision so conditional writes
  round-trip server_revision through the error path.
- Drive9Exception single variant with { code, status_code, detail,
  server_revision }. The field is named `detail` rather than `message`
  because UniFFI generates `Throwable.message` for Kotlin error types and
  the two collide on a field named `message`.

Phase 2 (stream/patch, vault, copy/rename/mkdir, search, SQL) intentionally
not exposed yet; bake the basic FFI shape first.

The crate depends on drive9 with default-features = false + rustls-tls so
mobile cross-compiles don't need a system OpenSSL.

Comes with cargo smoke tests against mockito covering happy path, list,
stat, delete, 409 conflict with server_revision, and 4xx error mapping —
exercising the same exported types Kotlin / Swift consumers see.

scripts/regenerate-bindings.sh rebuilds the cdylib and refreshes the
generated Kotlin / Swift sources under the sibling client crates.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

feat(clients): add drive9-kotlin facade and JVM smoke tests

Kotlin (JVM / Android) consumer of drive9-mobile-core UniFFI bindings.

Layout:
- com.drive9.mobile.Drive9Client — hand-written idiomatic facade that wraps
  the generated bindings in suspend functions dispatched on Dispatchers.IO,
  re-exports Drive9FileInfo / Drive9StatResult as plain data classes, and
  forwards Drive9Exception via typealias.
- uniffi.drive9_mobile_core.* — UniFFI-generated bindings; regenerate via
  clients/drive9-mobile-core/scripts/regenerate-bindings.sh.

Tests use the JDK's HttpServer to drive the FFI surface end-to-end:
JVM → JNA → libdrive9_mobile_core.so → drive9-rs. Covered:
write/read roundtrip, list, stat with revision, delete, 409 conflict with
server_revision preserved, and a 4xx error producing code="http_status".

The Linux x86-64 .so under lib/src/main/resources is git-ignored; the
regeneration script repopulates it for local Gradle runs. Production
Android builds should ship the per-ABI .so via cargo-ndk in jniLibs/.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

feat(clients): add drive9-swift facade and SwiftPM smoke tests

Swift consumer of drive9-mobile-core UniFFI bindings as a Swift Package.

Layout:
- Drive9Mobile.Drive9Client — hand-written facade exposing async throws
  methods that dispatch via Task.detached so a long-blocking FFI call
  doesn't tie up the Swift concurrency cooperative pool.
- Drive9Mobile/Drive9MobileGenerated.swift — UniFFI-generated Swift;
  regenerate via clients/drive9-mobile-core/scripts/regenerate-bindings.sh.
- drive9_mobile_coreFFI/ — system-library target wrapping the generated
  C header. The modulemap is rewritten by the regeneration script so it
  builds on Linux as well as Darwin (the upstream modulemap declared
  Darwin-only `use` clauses).

Package.swift wires the Drive9Mobile target to link against the libdrive9
native library; SwiftPM does not invoke cargo, so callers set
LD_LIBRARY_PATH (Linux) / DYLD_LIBRARY_PATH (macOS) at
clients/drive9-mobile-core/target/release. iOS / macOS shipping builds
should bundle the matching universal binary through the host project's
Xcode pipeline instead.

Tests use an in-process MockHTTPServer (BSD sockets) so the FFI smoke
suite mirrors the Kotlin one: write/read roundtrip, list, stat, delete,
409 conflict with server_revision preserved, and a 4xx error producing
code="http_status".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

fix(drive9-rs): preserve reqwest defaults under native-tls feature

review feedback (Kaltsit):
- `default = ["native-tls"]` together with `default-features = false` on the
  reqwest dependency dropped reqwest's other default features (`charset`,
  `http2`, `system-proxy`). That silently regressed HTTP/2, charset
  decoding, and system-proxy support on default desktop builds, contrary
  to the "default desktop behaviour unchanged" goal.

Re-state the desktop feature set explicitly via the `native-tls` feature:
default-tls + charset + http2 + system-proxy. `rustls-tls` keeps
charset + http2 but drops system-proxy on purpose (reqwest's system-proxy
implementation targets desktop OS network settings; on Android / iOS the
platform handles proxying differently).

Also: drive9-kotlin README now explicitly tells callers to run
`drive9-mobile-core/scripts/regenerate-bindings.sh` before `gradle test`,
since the .so it depends on is gitignored.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

feat(drive9-mobile-core): add Phase 2A APIs (FS ext + search + sql)

Extends the wrapper with the next batch of low-risk synchronous HTTP API
calls so Kotlin / Swift consumers don't have to drop down to raw HTTP for
common operations.

Added:
- copy(src_path, dst_path)
- rename(old_path, new_path)
- mkdir(path)
- grep(query, path_prefix, limit) -> [Drive9SearchResult]
- find(path_prefix, params: HashMap<String, String>) -> [Drive9SearchResult]
  — params are forwarded verbatim to drive9-rs; URL encoding and query
  string assembly stay there.
- sql(query) -> [String] — each row is a JSON-encoded string. The wrapper
  intentionally does not interpret column names or types; consumers parse
  with their preferred JSON library so an arbitrary serde_json::Value
  never crosses FFI.
- Drive9SearchResult { path, name, size_bytes, score: Option<f64> } record.

Stream / patch / vault remain deferred (Phase 2B / Phase 3).

Smoke tests cover: copy/rename/mkdir succeed with the expected request
headers, grep parses score (both Some and None), find passes
HashMap<String,String> through under non-deterministic ordering plus the
empty-params case, sql returns rows as JSON strings without schema
interpretation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

feat(drive9-kotlin): expose Phase 2A APIs in facade and tests

- Drive9Client gains suspend methods: copy, rename, mkdir, grep, find, sql.
- New Drive9SearchResult data class re-exported through the facade so
  callers don't need to import the generated uniffi package.
- Regenerated bindings under uniffi/drive9_mobile_core/ pick up the new
  Drive9MobileClient methods and the Drive9SearchResult record.

Smoke tests added: copyRenameMkdirSucceed, grepReturnsSearchResults,
findForwardsParams (asserts query pieces independently — HashMap order is
not deterministic), sqlReturnsJsonStrings (does not assume JSON key order).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

feat(drive9-swift): expose Phase 2A APIs in facade and tests

- Drive9Client gains async throws methods: copy, rename, mkdir, grep,
  find, sql.
- Regenerated bindings expose Drive9MobileClient and Drive9SearchResult to
  Swift. Callers depend on the Drive9Mobile module facade rather than the
  generated symbols directly.
- MockHTTPServer now exposes routeAnyQuery(method, pathOnly) and the
  Request struct carries pathOnly + query, so the find test can match on
  path and inspect query pieces independently without depending on Swift
  Dictionary iteration order.

Smoke tests added: testCopyRenameMkdirSucceed, testGrepReturnsSearchResults,
testFindForwardsParams, testSqlReturnsJsonStrings. All 10 tests pass on
Linux Swift 6.0 against the same libdrive9 build.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

feat(drive9-mobile-core): Phase 2B-1 (download_file + patch_file_parts)

Per review-narrowed Phase 2B-1 scope:

Drive9CancelToken — UniFFI object (AtomicBool + tokio::sync::Notify).
- cancel() is idempotent.
- Internal wait() future closes the race between cancel() and a future that
  subscribes after the flag was set: register Notify::notified() interest
  BEFORE re-checking the flag.

Drive9ProgressListener — UniFFI foreign callback trait.
- on_progress(transferred, total). total = 0 means "unknown".

download_file(remote_path, local_path, progress?, cancel?)
- Stats the remote path to learn total; falls back to 0 (unknown) when stat
  fails so the rest of the transfer still works.
- Streams via Client::read_stream into a tokio::fs::File in 64KB chunks.
- Progress is precise: emitted after each chunk lands on disk; bytes
  transferred always equal bytes already written.
- Cancellation is checked at chunk boundaries AND wrapped around the
  read_stream future and per-chunk read futures via tokio::select!, so a
  cancel during an in-flight chunk read terminates the underlying
  connection.
- On any failure (cancel, network, write) the partial local file is
  removed so callers don't mistake it for the full file. Success leaves
  the file in place.

patch_file_parts(local_path, remote_path, dirty_parts, new_size,
                 part_size?, expected_revision?)
- Path-based wrapper over Client::patch_file. The Rust closure seeks into
  the local file for the requested part and returns exactly part.size
  bytes from the server's plan. Short reads (local file does not match
  declared new_size) produce a clear `code = "other"` error instead of
  uploading short data.
- Pre-request validation rejects new_size < 0, part_size <= 0, and any
  dirty_parts entry < 1 (all surface as `code = "other"`).
- No cancel / progress in this iteration; documented limitation.

Upload + a cancel/progress-aware upload path are deferred to Phase 2B-2;
that depends on drive9-rs gaining a callback / cancel hook in
write_stream_conditional so cancellation can call abort_upload_v2
explicitly instead of relying on drop semantics.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

feat(drive9-kotlin): expose Phase 2B-1 (downloadFile + patchFileParts)

- Drive9Client gains downloadFile and patchFileParts suspend methods.
- Drive9CancelToken and Drive9ProgressListener re-exported through the
  facade as typealiases so consumers don't import from the generated
  uniffi package directly.
- Regenerated UniFFI bindings carry the new types and methods.

Tests:
- downloadFileRoundtripWithProgress: confirms bytes-on-disk match,
  progress is monotonic, start=(0,total) and end=(total,total).
- downloadFileCancellationDeletesPartialFile: pre-cancelled token causes
  `code = "cancelled"` and the temp file does not exist after the call.
- patchFilePartsValidatesInputs: covers new_size, part_size, dirty_parts
  validation paths.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

feat(drive9-swift): expose Phase 2B-1 (downloadFile + patchFileParts)

- Drive9Client gains async throws downloadFile / patchFileParts.
- Drive9CancelToken / Drive9ProgressListener are surfaced via the
  generated bindings; callers implement Drive9ProgressListener as a Swift
  class.
- Regenerated UniFFI bindings carry the new symbols.

Tests:
- testDownloadFileRoundtripWithProgress: bytes match the source body;
  progress sequence is non-decreasing with start/finish endpoints.
- testDownloadFileCancellationDeletesPartialFile: pre-cancelled token
  yields `code = "cancelled"` and the target path is absent afterwards.
- testPatchFilePartsValidatesInputs: covers new_size, part_size,
  dirty_parts validation.

Test infrastructure: RecordingProgressListener is @unchecked Sendable
because the Rust runtime calls onProgress from a Tokio worker thread;
internal storage is guarded by NSLock.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

fix(drive9-mobile-core): Phase 2B-1 review fixes

Two blocking issues from Kaltsit's review of Phase 2B-1:

1. download_file no longer touches a pre-existing destination file.

   Previously download_file called `File::create(local_path)` (which truncates
   any pre-existing user file) and on failure removed it, losing the user's
   original content.

   Now it writes to a sibling temp file `.{name}.drive9-tmp-{nonce}` inside
   the parent directory of `local_path`. On success the temp is renamed
   onto the destination (atomic on Unix when both paths share a
   filesystem). On any failure only the temp file is removed; a
   pre-existing destination is left bit-for-bit untouched.

2. patch_file_parts now uses the caller-supplied global part_size for
   file offsets.

   Previously the closure used the per-part `size` from the upload plan
   as both the offset multiplier and the read length. For the final
   short part (part_size 100, last part size 50) that produced offset
   (part_num-1) * 50 instead of (part_num-1) * 100, reading the wrong
   bytes from the local file.

   `part_size` is now a required parameter (Int64, not Optional). The
   closure captures it for offset arithmetic and uses the upload plan's
   per-part size only for the read length and short-read check. The
   server still receives `part_size` so its plan uses the same chunking.

New regression coverage:
- patch_file_parts_uses_global_part_size_for_offset: local file 150
  bytes ('a'*100 ++ 'b'*50), dirty_parts=[2], server plan size 50;
  assert the PUT receives the second-half 'b' bytes.
- download_file_preserves_preexisting_destination_on_failure: write
  "do not overwrite me" to dest, pre-cancel, expect cancelled error
  AND dest unchanged AND no leftover temp file in the parent directory.

Also cleanup: drop the unused `Ordering` import flagged in review.

Kotlin / Swift facades follow the new patch_file_parts signature
(partSize is now a plain Long / Int64 instead of Long? / Int64?), and
the cancel tests now use a non-existent destination path so the new
"preserve pre-existing" semantics don't conflict with the original
"no leftover" check.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

feat(drive9-rs): upload stream hooks for progress + cancel

Adds a hook-aware upload entry point so callers can drive cancellation
that cleans up server-side multipart state and receive progress reports
tied to bytes actually persisted on the server. Existing
write_stream / write_stream_conditional paths are untouched.

New public surface in `drive9::transfer`:

```
pub trait UploadProgress: Send + Sync {
    fn on_progress(&self, transferred: u64, total: u64);
}

pub trait CancelSignal: Send + Sync {
    fn is_cancelled(&self) -> bool;
}

impl Client {
    pub async fn write_stream_with_hooks(
        &self,
        path: &str,
        reader: Box<dyn SeekableReader>,
        size: i64,
        expected_revision: i64,
        progress: Option<Arc<dyn UploadProgress>>,
        cancel: Option<Arc<dyn CancelSignal>>,
    ) -> Result<(), Drive9Error>;
}
```

Plus `Drive9Error::Cancelled` so cancel has a stable code rather than
being conflated with `Drive9Error::Other("cancelled")`.

Behaviour:
- Cancel observed before initiate -> Drive9Error::Cancelled, no HTTP.
- Multipart cancel observed mid-upload -> already-running PUTs are
  allowed to drain, subsequent parts are short-circuited with
  Drive9Error::Cancelled, then abort_upload_v2(upload_id) runs and
  the call returns Drive9Error::Cancelled (regardless of whether
  some parts returned per-task errors while draining).
- Small file cancel observed pre-PUT or mid-PUT: races the PUT
  future via tokio::select! against a small poll loop on the cancel
  flag; cancel drops the PUT future.
- Progress is reported only after a part PUT actually succeeds.
  Cancelled or failed uploads never emit a "(total, total)" event.
- Small file path emits (0, total) before the PUT and (total, total)
  only on success, mirroring download progress semantics.

Trait scope: declared upload-only via docstring. download / patch
do not share a transfer framework with this; if either grows real
progress later we'll revisit whether to unify or keep separate.

Test coverage in tests/test_hooks.rs:
- small_file_emits_start_and_finish_progress
- small_file_cancel_before_put_returns_cancelled_without_request
- multipart_cancel_before_initiate_skips_all_requests
- multipart_cancel_between_parts_calls_abort_and_returns_cancelled
- existing_write_stream_path_unchanged

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

feat(drive9-mobile-core): upload_file using write_stream_with_hooks

Drive9MobileClient::upload_file(local_path, remote_path,
                                expected_revision?, progress?, cancel?)
streams a local file to the server via the new hook-aware
drive9-rs API, so cancellation cleans up server-side multipart
state and progress events match real on-server byte counts (instead
of the ProgressReader-counts hack that v1 rejected at design time).

- Drive9CancelToken now also implements drive9::transfer::CancelSignal
  via the same AtomicBool the uniffi-exposed `is_cancelled()` reads.
- ProgressAdapter is a private struct that bridges
  Arc<dyn Drive9ProgressListener> (foreign-implemented via UniFFI) into
  Arc<dyn drive9::transfer::UploadProgress>; the foreign-only trait
  does not leak into drive9-rs.
- Drive9Error::Cancelled maps to Drive9Exception::Drive9 with
  code = "cancelled", status_code = None, server_revision = None.

Smoke tests cover small-file roundtrip + progress emission,
pre-cancel resulting in `code = "cancelled"` with zero PUTs, and
conditional-write conflict (preserves server_revision).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

feat(drive9-kotlin,drive9-swift): expose uploadFile facades

Both facades gain an idiomatic uploadFile method built on the new
drive9-mobile-core::upload_file:
- Kotlin: suspend fun uploadFile(localPath, remotePath, expectedRevision?,
  progress?, token?) on Dispatchers.IO.
- Swift: async throws uploadFile(localPath:, remotePath:, expectedRevision:,
  progress:, cancel:) dispatched via Task.detached.

Smoke tests added on both sides:
- Small-file roundtrip: PUT body matches local file; progress sequence
  is exactly [(0, total), (total, total)].
- Pre-cancel: returns Drive9Exception with `code = "cancelled"` and the
  server records zero PUT hits.

Swift test helpers: ReceivedBody and HitCounter shared-state wrappers
guarded by NSLock, marked `@unchecked Sendable` because MockHTTPServer
handlers run off the Swift concurrency cooperative pool.

Regenerated UniFFI bindings pick up uploadFile and the
Drive9Error::Cancelled mapping.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

fix(drive9-rs): write_stream_with_hooks falls back to v1 upload

Phase 2B-2 review (Kaltsit): the hooks path called the v2 multipart
upload unconditionally and never fell back to v1 when the server
returned 404 on /v2/uploads/initiate. write_stream_conditional does
that fallback, so the hooks path regressed against the no-hooks path
on backends that don't expose v2 upload — mobile upload_file would
fail outright on those servers.

This commit:

- Adds write_stream_v1_with_hooks + upload_parts_v1_with_hooks, which
  mirror the v1 internals plus cancel/progress integration:
  - Cancel observed before checksums / initiate / each part task is
    started returns Drive9Error::Cancelled.
  - Progress is reported only after a part PUT actually succeeds; the
    initial (0, total) event fires once, right after v1 initiate.
  - There is no v1 server abort endpoint; the docstring documents
    cancel as best-effort on v1 (already-issued PUTs may run to
    completion). The v2 path remains the recommended route when the
    server supports it.
- write_stream_with_hooks now matches on Drive9Error::Other("v2 upload
  API not available") just like write_stream_conditional and falls
  through to write_stream_v1_with_hooks. Progress emitted by the
  failed v2 attempt is impossible: v2 only emits (0, total) AFTER
  initiate_upload_v2 succeeds, so a 404 here means no progress event
  fires before the v1 path emits its own (0, total).

New tests in tests/test_hooks.rs:
- multipart_falls_back_to_v1_when_v2_not_available: v2 initiate 404,
  v1 initiate returns a 2-part plan, both PUTs and the v1 complete
  call fire exactly once, progress reaches (total, total).
- v1_fallback_respects_cancel_before_parts: v2 404 + cancel triggered
  while v1 initiate is in flight; no PUT is issued.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

fix(drive9-rs): late cancel must not flip a completed upload to Cancelled

Phase 2B-2 review (Kaltsit): write_stream_v2_with_hooks and v1
counterpart re-checked the cancel flag after every part task joined.
That created a real race: if the last part PUT completed, the
progress callback fired (total, total), and the user's listener (or
unrelated code) then called cancel.cancel(), the outer recheck saw
true and aborted the upload + returned Cancelled. The caller would
see both the completion-progress event AND a Cancelled error,
violating the "cancelled / failed uploads never emit a (total,
total) event" semantic.

Establish a single cutoff: cancellation only escalates to a
Drive9Error::Cancelled outcome when a part task itself observed
cancel and returned through that path (i.e. parts_result is
Err(Drive9Error::Cancelled)). When parts_result is Ok, all parts
genuinely uploaded successfully; commit the upload regardless of
whether the cancel flag flipped afterwards.

- v2: match parts_result and only abort+Cancelled on
  Err(Drive9Error::Cancelled); other errors still abort+propagate;
  Ok continues to complete_upload_v2.
- v1: same pattern, but v1 has no server abort endpoint so the
  cancel branch just returns Cancelled.

Regression test added:
- multipart_late_cancel_after_final_progress_does_not_flip_to_cancelled:
  progress listener triggers cancel.cancel() when it observes
  transferred == total. The upload must still call /complete and
  return Ok, and /abort must NOT be called.

Existing multipart_cancel_after_initiate test (renamed from
"between_parts") is restructured to trigger cancel from the
presign-batch handler so all part tasks deterministically observe
cancel before their pre-PUT check. This was previously a flaky race
that depended on parallel-task scheduling once parts started; the
new design makes it deterministic.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

feat(drive9-mobile-core): Phase 3 vault read-only consumer API

Drive9MobileClient gains two read-only vault methods:

- vault_list_readable_secrets() -> Vec<String>
- vault_read_secret_field(name: String, field: String) -> String

Scope is intentionally narrow:
- Admin operations (create / update / delete secret, issue / revoke
  token, query audit) stay off the mobile FFI for now. Token issuance
  happens elsewhere (backend); the resulting scoped token is what the
  mobile client carries as `api_key`.
- read_vault_secret(name) -> dict is not exposed because
  serde_json::Map does not cross UniFFI cleanly; field-by-field reads
  are sufficient and make caller intent explicit.

Implementation is a thin pass-through: drive9-rs handles URL encoding
and the wrapper does NOT inspect, parse, or re-encode the field value.
A field that stores `{"k":1}` is returned to the caller as the literal
string `{"k":1}`.

Authorization failures and missing secrets surface through the
existing Drive9Exception::Drive9 channel with `code = "http_status"`
and the corresponding HTTP status_code (401 / 403 / 404). No
vault-specific error code is added; foreign callers keep one branch
for HTTP-shaped errors.

Smoke tests cover: happy path list + field read, JSON-looking string
passthrough, 401 unauthorized, 403 forbidden, 404 missing secret, and
URL special-character encoding (delegated to drive9-rs).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

feat(drive9-kotlin,drive9-swift): expose vault read API in facades

Both facades gain idiomatic vault methods built on the new
drive9-mobile-core read API:
- Kotlin: suspend fun vaultListReadableSecrets() / vaultReadSecretField
  on Dispatchers.IO.
- Swift: async throws vaultListReadableSecrets / vaultReadSecretField
  dispatched via Task.detached.

Tests on both sides cover the same three pillars:
- Happy path list returns the expected names.
- A JSON-looking field value (`{"k":1,"nested":{"flag":true}}`) is
  returned verbatim — wrapper does not parse, normalize whitespace, or
  re-encode.
- 401 unauthorized surfaces as `code = "http_status"`, statusCode = 401,
  detail = "token expired"; no vault-specific error path needed.

Regenerated UniFFI bindings pick up the two new mobile-core methods.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

fix(drive9-rs): close the StreamWriter write_part queued-vs-close race

Phase 4A review (Kaltsit): StreamWriter::write_part incremented
`state.inflight` and then awaited a semaphore permit before spawning
the actual PUT task. A concurrent `complete()` / `abort()` could
flip `state.closing` / `aborted` / `completed` while the write was
queued at the permit; once the permit was granted there was no
re-check, so the spawned task still issued a PUT against a stream
that had already been closed out from under it. With more concurrent
callers (the upcoming mobile FFI Drive9StreamUpload), this race
becomes much more reachable.

This commit re-checks the StreamState under lock right after acquiring
the permit:
- if a sibling task already recorded an error -> decrement inflight,
  drop the permit, return Drive9Error::Other("background upload
  error: ...");
- if aborted / completed / closing -> decrement inflight, drop the
  permit, return a state-specific Drive9Error::Other so callers can
  distinguish which transition happened ("aborted while write_part
  was queued" / "completed while ..." / "closing while ...");
- otherwise proceed to spawn the PUT as before.

The fine-grained messages were a review constraint — a single generic
"closed" message would have made test debugging and Kotlin/Swift error
handling harder.

New tests in tests/test_stream_writer.rs:
- write_part_queued_at_permit_aborts_without_uploading: queues
  UPLOAD_MAX_CONCURRENCY + 1 part writes against handlers that sleep
  200ms, calls abort() while the 17th sits on the semaphore, and
  asserts the 17th never PUTs (mock expect_at_most equals the
  concurrency limit), abort_upload_v2 hits exactly once, and
  subsequent write_part / complete reject with state-specific
  messages.
- abort_is_idempotent: second abort() returns Ok without re-hitting
  /v2/uploads/{id}/abort.

Existing test_client.rs and test_hooks.rs suites still pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

feat(drive9-mobile-core): Phase 4A Drive9StreamUpload object

Adds a UniFFI Object wrapping drive9::StreamWriter so foreign code
can stream a multipart upload incrementally:

```
let upload = client.new_stream_upload(
    "/remote.bin", total_size, expected_revision?,
);
upload.write_part(1, chunk_1)?;
upload.write_part(2, chunk_2)?;
upload.complete(3, chunk_3)?;  // or upload.abort()?
```

State machine (enforced in the wrapper, in addition to whatever
drive9::StreamWriter reports):

- Active: write_part / complete / abort accepted.
- Completed: terminal after complete() Ok; all calls reject.
- Aborted: terminal after abort() Ok; further write/complete reject;
  abort itself stays idempotent.
- Errored: any write_part or complete that surfaces an error
  transitions here; write_part / complete reject; abort() is still
  permitted so callers can clean up server-side multipart state.

Backpressure comes for free from the underlying StreamWriter's
internal semaphore: the 17th concurrent write_part blocks until a
permit is released, and the queued-vs-close race that the new
StreamWriter::write_part recheck addresses applies to this object too.

Rust smoke tests cover:
- stream_upload_happy_path_two_parts: write + complete + reject after
  complete.
- stream_upload_abort_after_write_calls_server_abort: abort hits
  /v2/uploads/{id}/abort once, second abort is a no-op, write_part
  after abort rejects.
- stream_upload_part_error_transitions_to_errored: failed part PUT
  surfaces in the next write_part, complete also rejects, abort
  remains callable for cleanup.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

feat(drive9-kotlin,drive9-swift): expose Drive9StreamUpload facade

Both facades expose the Phase 4A streaming upload object:
- Kotlin: suspend fun newStreamUpload(remotePath, totalSize,
  expectedRevision?) returning a Drive9StreamUpload typealias of the
  generated binding. writePart / complete / abort are sync methods
  on the returned object — they finish quickly because the underlying
  Tokio runtime handles HTTP work; idiomatic Flow wrapping is left
  to Phase 4B.
- Swift: async throws newStreamUpload returning the generated
  Drive9StreamUpload. Same shape.

Tests (Kotlin / Swift mirror each other):
- streamUploadHappyPathTwoParts: write part 1, complete with part 2;
  both PUTs fire (path-based counter), /complete fires once; write
  after complete rejects with code = "other" and detail containing
  "completed".
- streamUploadAbortIsIdempotentAndRejectsFurtherWrites: write +
  abort + abort again; only one /abort PUT; subsequent write rejects
  with detail containing "aborted".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

fix(drive9-rs,drive9-mobile-core): Phase 4A review fixes

Two blocking issues from Kaltsit's review of Phase 4A:

1. Drive9StreamUpload no longer poisons the upload on parameter
   errors.

   Previously `observe_result` transitioned wrapper state to
   `Errored` on any `Err` returned by drive9-rs::StreamWriter, but
   that includes caller-side parameter validation: `part_num < 1`,
   duplicate part numbers, and `part_num > total_parts`. Those are
   recoverable caller bugs, not upload failures. Calling
   `write_part(0, ...)` and then trying a legitimate
   `write_part(1, ...) + complete(...)` used to be rejected as
   "errored state" — wrong.

   `observe_result` now checks `is_parameter_error()` (substring
   match against the known drive9-rs message patterns) and only
   transitions to `Errored` for non-parameter errors (background
   upload failures, init failures, queued-vs-close state
   transitions). The match list is localized in one private helper
   and documented to track drive9-rs's `StreamWriter::write_part`
   error message set.

2. drive9-rs::StreamWriter::complete now supports the one-shot path.

   Previously `complete(final_part_num, final_part_data)` required
   `state.started` (set by a prior `write_part`), so the natural
   single-part flow `new_stream_writer + complete(1, data)` failed
   with "stream writer was never started". This contradicted the
   Phase 4A facade docs that say `complete` accepts final-part data.

   complete() now initiates the v2 upload inside its own setup phase
   when `!started && !final_part_data.is_empty()`. If both
   `!started` and `final_part_data` is empty, the call still rejects
   with a more explicit "never started (call write_part first or
   pass final_part_data)" message.

New regression coverage:

- clients/drive9-rs/tests/test_stream_writer.rs:
  - complete_alone_with_final_data_initiates_and_finishes: skip
    write_part entirely, complete(1, data) must initiate, upload,
    and complete with exactly one initiate/presign/PUT/complete hit.
  - complete_alone_without_data_still_errors_never_started: empty
    final_part_data + no prior write_part still rejects, and
    initiate is NOT issued.

- clients/drive9-mobile-core/tests/smoke.rs:
  - stream_upload_parameter_error_keeps_upload_active: write_part(0)
    fails with "part number must be" detail; subsequent write_part(1)
    + complete(...) still succeed.
  - stream_upload_complete_alone_works_for_one_part_stream: facade
    sees one-shot complete(1, data) succeed.

- Kotlin and Swift smoke each gain a mirror pair
  (streamUploadParameterErrorKeepsUploadActive,
  streamUploadCompleteAloneWorksForOnePartStream /
  testStreamUploadParameterErrorKeepsUploadActive,
  testStreamUploadCompleteAloneWorksForOnePartStream).

Regression: all suites green.
- cargo test drive9-rs:   20/20 (was 18; +2 in test_stream_writer)
- cargo test mobile-core: 36/36 (was 34; +2 stream cases)
- gradle test kotlin:     23/23 (was 21)
- swift test swift:       23/23 (was 21)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

feat(drive9-mobile-core): Phase 4B Drive9StreamDownload object

Companion to Phase 4A's upload object: a UniFFI Object that pulls
bytes off a `drive9::Client::read_stream` one chunk at a time.

API:
- Drive9MobileClient::new_stream_download(remote_path, cancel?)
  -> Arc<Drive9StreamDownload>
- Drive9StreamDownload::read_chunk()
  -> Result<Option<Vec<u8>>, Drive9Exception>
- Drive9StreamDownload::close_stream()  (idempotent, distinct name
  so it doesn't collide with the auto-generated AutoCloseable.close)

State machine: Open -> EndOfStream (Ok(None)) or Open -> Errored(msg)
or Open -> Closed (explicit close_stream).

Mid-read cancel: the read_chunk implementation runs the underlying
async read inside `tokio::select!` against `Drive9CancelToken::wait()`,
so calling `cancel()` on the token wakes the SAME blocking read_chunk
call and surfaces `code = "cancelled"`. Subsequent read_chunk calls
replay the same cancelled error. close_stream does NOT promise to
interrupt an in-flight read; it sets state to Closed and drops the
reader, but if read_chunk is currently parked on a network read, that
read will complete (or time out) before the resource is freed.

The reader is taken out of the internal Mutex during a chunk read so
close_stream can flip state from another thread without blocking. On
read completion, the wrapper re-checks state and only puts the reader
back if still Open.

Tests in tests/smoke.rs:
- stream_download_happy_path_multiple_chunks: read multiple 64KB
  chunks until EOF; subsequent read_chunk returns None.
- stream_download_close_blocks_further_reads: explicit close_stream
  is idempotent and rejects the next read with detail = "closed".
- stream_download_replays_error: a 4xx at constructor time surfaces
  as Drive9Exception with `code = "http_status"`.
- stream_download_cancel_during_read_returns_cancelled: uses a raw
  TcpListener that sends headers + one chunk, then hangs.  A second
  read_chunk parks; cancel() from another thread must wake the SAME
  call within ~100ms (asserted < 500ms) with `code = "cancelled"`;
  the next read_chunk replays cancelled. Mockito wasn't usable here
  because it buffers the full body before the client starts reading.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

feat(drive9-kotlin,drive9-swift): Phase 4B Flow / AsyncSequence wrappers

Kotlin:
- Drive9Client.downloadFlow(remotePath, cancel?) -> Flow<ByteArray>
  built with `flow {}`. Each emit suspends the producer until the
  collector resumes (genuine backpressure); the finally block calls
  closeStream so a cancelled coroutine releases the underlying
  socket. For mid-read abort the caller passes a Drive9CancelToken.
- Drive9Client.uploadFlow(remotePath, totalSize, chunks, ...) -> Unit
  wraps Drive9StreamUpload: each emitted chunk becomes one
  writePart(N) (1-indexed in emission order), and the upload
  finalizes via `complete(lastPartNum, empty)`. Zero-chunk source
  triggers abort + Drive9Exception(code="other", detail="no chunks").
  Collector throw / coroutine cancel: abort, then re-throw.

Swift:
- Drive9DownloadAsyncSequence is a custom pull-based AsyncSequence
  whose AsyncIterator.next() calls exactly one Drive9StreamDownload
  .readChunk per iteration (off the Swift concurrency pool via
  Task.detached). The iterator's deinit also calls closeStream as a
  safety net for early-break loops. We deliberately don't use
  AsyncThrowingStream(bufferingNewest: 1) — that drops chunks on
  pressure, which would silently corrupt file content.
- Drive9Client.uploadStream(remotePath, totalSize, source:, ...)
  takes any `AsyncSequence` where Element == Data and follows the
  same per-chunk writePart + complete(lastPartNum, empty) protocol
  as Kotlin's uploadFlow.

Public typealias additions:
- Drive9StreamDownload (Kotlin) -> generated UniFFI type
- Drive9StreamDownload available in Swift through the existing
  Drive9Mobile module surface.

Tests:
- downloadFlowEmitsChunksUntilEof / testDownloadStreamEmitsChunksUntilEof
- uploadFlowEachChunkWritesOnePartCompleteEmpty /
  testUploadStreamEachChunkWritesOnePart
- uploadFlowZeroChunkSourceAbortsAndErrors /
  testUploadStreamZeroChunkSourceAbortsAndErrors

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

fix(drive9-mobile-core,drive9-kotlin,drive9-swift): Phase 4B review fixes

Three blocking issues from Kaltsit's review of Phase 4B:

1. Concurrent read_chunk on a Drive9StreamDownload now marks the
   object terminal Errored, matching the documented contract.

   Before: a second thread calling read_chunk while the first one
   held the reader saw a "reader not available" error but the state
   stayed Open, so a third read could succeed once the first put
   the reader back. That contradicted the docstring ("on error,
   object transitions to terminal Errored and subsequent calls
   re-surface the same error").

   The None-reader branch now distinguishes the legitimate cases
   (concurrent close_stream, prior EndOfStream / Closed /
   Errored) from concurrent misuse: only the latter — state is
   still Open with no reader to be found — flips to
   `DownloadState::Errored("concurrent read_chunk is not
   supported")`, and the same message is replayed by subsequent
   read_chunk and by the in-flight read when it finishes and
   re-checks state.

   New regression: stream_download_concurrent_read_chunk_marks_terminal_error
   spawns one thread that parks on a hung TcpListener while the
   main thread fires a second read_chunk; the second call returns
   the concurrent error, the third call replays it, and the
   parked thread is unblocked via Drive9CancelToken at cleanup.

2. Kotlin uploadFlow no longer double-aborts.

   The previous version called upload.abort() inside the
   `partNum == 0` branch and then threw a Drive9Exception, which
   the outer `catch (e: Throwable)` re-caught and called abort
   again. The second invocation was wrapped in
   `runCatching { withContext(Dispatchers.IO) { ... } }` — the
   runCatching block isn't a suspend lambda, so calling
   `withContext` inside is at best a typecheck-corner-case and
   at worst won't compile under stricter Kotlin versions.

   Replaced with a `suspend fun abortQuietly()` nested helper that
   carries a single `aborted` flag. Zero-chunk → flag → throw →
   outer catch sees flag=true → skip. Source-throws-mid-stream →
   no flag yet → catch fires abort once → flag set → flag stays
   set against double-finalization.

3. Swift uploadStream no longer double-aborts.

   Same shape as Kotlin: the zero-chunk branch used
   `try? upload.abort()` then `throw`, and the outer do/catch
   re-fired `try? upload.abort()`. Phase 4A's idempotent
   server-side abort hid the wire-level effect, but the wrapper
   shouldn't be calling abort twice in the first place. Inline
   nested `func abortQuietly()` with `var aborted` capture
   closes both paths.

Test assertion updates:
- Kotlin uploadFlowZeroChunkSourceAbortsAndErrors now asserts
  abortCalls == 0 on the wire (drive9-rs StreamWriter.abort
  short-circuits when !started, so any wrapper-level call here is
  invisible to the server — a non-zero count would indicate the
  upload was unexpectedly initiated or the abort short-circuit
  regressed).
- Swift testUploadStreamZeroChunkSourceAbortsAndErrors gains the
  same abortCalls.get() == 0 assertion plus a new mock for the
  /v2/uploads/{id}/abort endpoint.

Regression: all suites green.
- cargo test drive9-rs:   20/20
- cargo test mobile-core: 41/41 (was 40; +1 concurrent-read test)
- gradle test kotlin:     26/26
- swift test swift:       26/26

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

feat(drive9-rs): StreamWriter::initiate + part_size / total_parts accessors

Phase 4C: foreign Flow / AsyncSequence wrappers need to know the
server-chosen part_size before they shape a caller's byte stream,
but the existing StreamWriter only exposed that via the private
init_locked path triggered inside write_part. Adds:

- pub async fn initiate(&self) -> Result<(), Drive9Error>
  Idempotent. Triggers v2 initiate if state is fresh; otherwise
  returns Ok (already started) or a state-specific error
  (closing/aborted/completed/background-errored).
- pub async fn part_size(&self) -> Result<i64, Drive9Error>
  Calls initiate() then returns the cached plan.part_size.
- pub async fn total_parts(&self) -> Result<i32, Drive9Error>
  Same shape as part_size.

upload_id is intentionally NOT exposed: mobile callers should not
need it for the Phase 4C ergonomic wrappers, and surfacing it
invites misuse (e.g., racing direct part PUTs against the writer's
internal state).

Tests added in tests/test_stream_writer.rs:
- initiate_and_accessors_hit_initiate_exactly_once: mockito
  expect(1) on /v2/uploads/initiate covers all three entry points
  (initiate × 2, part_size × 2, total_parts × 1).
- accessors_reject_after_terminal_state: after explicit abort,
  part_size() rejects with a state-aware error.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

feat(drive9-mobile-core): Drive9StreamUpload.part_size / total_parts

Surfaces the Phase 4C drive9-rs accessors through the UniFFI Object
so foreign callers can ask the server for the upload's chunking
plan up front. State machine: Active is OK; Completed/Aborted/
Errored reject. Initiate failures (e.g. server returns
"v2 upload API not available") flip the wrapper to Errored via a
new private observe_initiate_error helper — the existing
observe_result is intentionally restricted to parameter-vs-
background classification on write_part, which doesn't apply
here.

Test in tests/smoke.rs:
- stream_upload_part_size_total_parts_initiate_once asserts
  /v2/uploads/initiate is called exactly once even when
  part_size() / total_parts() are interleaved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

feat(drive9-kotlin,drive9-swift): Phase 4C upload auto re-chunking

Phase 4C: uploadFlow / uploadStream now accept arbitrarily-sized
caller chunks and split them to the server-chosen part_size
inside the facade. Caller contract goes from "you must emit
parts at exactly server's part_size" (Phase 4B) to "emit any
byte chunks you want; we'll cut them for you".

Implementation outline (both languages):
1. Call upload.partSize() — forces /v2/uploads/initiate and
   returns the cached value.
2. Maintain a running buffer. For each input chunk, append +
   drain a while-loop of partSize-sized slices, writePart()ing
   each. Buffer never carries a full part across iterations.
3. After the source finishes:
   - non-empty leftover -> complete(nextPart, leftover);
     drive9-rs uploads it as the final part inside complete().
   - exactly partSize-aligned -> complete(lastPart, empty);
     no additional PUT.

Kotlin: ByteArray concat + copyOfRange slicing (allocates per
slice; acceptable for v1). Swift: Data append + removeSubrange.

Single-abort flag retained from Phase 4B. zero-chunk source now
hits /abort on the wire exactly once because partSize() already
initiated; the wrapper's `aborted` flag prevents the outer catch
from re-firing it.

Tests updated and added on both sides:
- AutoRechunksLargeSingleChunk: 350-byte chunk + partSize=100
  -> PUT 1/2/3 of 100 bytes + PUT 4 of 50 bytes via complete.
- AutoRechunksManySmallChunks: 9×30-byte chunks + partSize=100
  -> PUT 1/2 of 100 bytes + PUT 3 of 70 bytes via complete.
- ExactAlignmentCompletesWithEmptyFinalData: 4×50-byte chunks +
  partSize=100 -> exactly 2 PUTs, NO spurious 3rd.
- ZeroChunkSourceAbortsAndErrors: updated to require abort=1 on
  the wire (Phase 4B's "abort=0" assertion no longer applies
  since 4C initiates eagerly).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

fix(drive9-mobile-core): pass --no-format to uniffi-bindgen

By default uniffi-bindgen invokes ktlint / swift-format on the
generated files. On this host swift-format hangs on the large
UniFFI-generated Swift file (200KB+, heavy use of operator
overloading, generics, and conditional compilation) — observed
runaway swift-format processes pinned at 100% CPU for over 24 hours
each, parented to past regenerate-bindings.sh invocations.

Pass --no-format to both invocations of uniffi-bindgen so the
script just writes the generated files and exits. The generated
code already compiles cleanly on both languages, and the cosmetic
formatting doesn't matter because the file is regenerated, not
edited by hand.

This also removes the noisy "Warning: Unable to auto-format ...
swift-format not found" messages that printed on hosts where
swift-format isn't installed at all.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

ci: add sdk test workflow

fix: address mobile sdk review comments

feat: rewrite mobile SDKs with native HTTP

chore: clean up native mobile SDK CI and docs

fix: finish native mobile SDK cleanup

fix: make native mobile SDK tests pass

chore: revert drive9-rs changes from native sdk PR

ci: drop rust sdk from native mobile workflow

feat: port mobile sdks to native drive9 parity

fix: complete kotlin native parity gaps

docs: note kotlin patch transport fallback

fix: declare swift sdk platform requirements

chore: raise swift sdk ios target to 21

fix: set swift sdk ios target to 17

fix: restore rust sdk CI coverage

* fix: decode chunked kotlin patch responses

* fix: make swift mock socket type portable
@coderabbitai

coderabbitai Bot commented May 27, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Five language SDKs (JavaScript, Kotlin, Python, Rust, Swift) gain a new statMetadata(path) method that fetches enriched file metadata via GET /v1/fs/{path}?stat=1. Each SDK defines a StatMetadataResult type, implements the client method to parse and normalize the response, exports the type, and documents the feature in its README.

Changes

Enriched File Metadata API Across SDKs

Layer / File(s) Summary
StatMetadataResult type definitions
clients/drive9-js/src/models.ts, clients/drive9-kotlin/lib/src/main/kotlin/com/drive9/mobile/Drive9.kt, clients/drive9-py/drive9/models.py, clients/drive9-rs/src/models.rs, clients/drive9-swift/Sources/Drive9Mobile/Drive9.swift
New StatMetadataResult interface/struct/dataclass added consistently across all five SDKs with fields for size, directory flag, resource ID, revision, modification time, content type, semantic_text, and tags.
Client method implementations
clients/drive9-js/src/client.ts, clients/drive9-kotlin/lib/src/main/kotlin/com/drive9/mobile/Drive9.kt, clients/drive9-py/drive9/client.py, clients/drive9-rs/src/client.rs, clients/drive9-swift/Sources/Drive9Mobile/Drive9.swift
statMetadata(path) methods added to all five clients. Each method issues GET /v1/fs/{path}?stat=1, applies language-specific error handling, parses the JSON response, normalizes missing fields with defaults, and returns the typed result.
Type exports and re-exports
clients/drive9-js/src/index.ts, clients/drive9-py/drive9/__init__.py
StatMetadataResult added to public module exports for JavaScript and Python SDKs.
README documentation
clients/drive9-js/README.md, clients/drive9-kotlin/README.md, clients/drive9-py/README.md, clients/drive9-rs/README.md, clients/drive9-swift/README.md
Consistent updates to all README files adding statMetadata to supported operations tables and documenting the enriched metadata fields and underlying GET /v1/fs/{path}?stat=1 endpoint.
Test cleanup
clients/drive9-js/tests/client.test.ts
Whitespace normalization inserted within test suite.

🎯 2 (Simple) | ⏱️ ~12 minutes

Possibly Related PRs

  • mem9-ai/drive9#352: Adds resource_id field to the server-side enriched stat response; this PR implements client-side parsing and export of that enriched metadata across all SDK languages.

Suggested Reviewers

  • qiffang
  • JaySon-Huang

🐰 Five SDKs, one feature, enriched so bright,
StatMetadata hops in with semantic delight!
Types, clients, and docs all aligned,
A multi-language hop, perfectly designed! 🐇✨

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'add semantic search by file helper' is partially related to the changeset. The PR primarily adds a statMetadata() method across multiple SDK clients (JS, Kotlin, Python, Rust, Swift), but the title focuses on 'semantic search by file' which is only one aspect of the enriched metadata being returned (the semanticText field). Consider a more descriptive title that captures the main change, such as 'feat(sdk): add statMetadata() for enriched file metadata' or 'feat(sdk): add enriched stat operation across SDKs'.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@you06
you06 force-pushed the feat/semantic-search-by-file-sdk branch from d4a4169 to 14b2af7 Compare May 27, 2026 07:01

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d4a416984f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread pkg/client/client.go Outdated
}
semanticText := strings.TrimSpace(meta.SemanticText)
if semanticText != "" {
result, resultErr = c.Grep(semanticText, searchPrefix, limit)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve context for the final grep request

When SearchByFileCtx reaches the grep step after semantic text is available, it calls c.Grep, which constructs its request without the caller's ctx; the client also has no default HTTP timeout. In contexts with a deadline/cancellation, a slow or hung grep request can therefore block past the caller's cancellation instead of returning promptly, defeating the purpose of the Ctx variant. Use a context-aware grep request here or add a GrepCtx helper.

Useful? React with 👍 / 👎.

Comment thread pkg/client/client.go Outdated
if err != nil {
return nil, err
}
if err := c.WriteCtxConditional(ctx, tmpPath, data, 0); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use multipart-capable upload for query files

For local query files larger than the server's inline threshold, this simple PUT path fails before semantic extraction: handleWrite rejects large-file PUTs that do not include multipart checksums with missing X-Dat9-Part-Checksums. That makes SearchByFile unusable for common audio/image query files (for example the documented query.wav) unless they happen to be below the inline cutoff; use the existing stream/multipart upload path with expectedRevision=0 instead of WriteCtxConditional here.

Useful? React with 👍 / 👎.

Comment thread pkg/client/client.go Outdated
case <-ctx.Done():
resultErr = ctx.Err()
break
case <-time.After(pollInterval):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cap polling sleep at the remaining timeout

When callers pass a pollInterval larger than the remaining timeout, this sleeps for the full interval before checking the deadline again, so SearchByFileCtx can block well past the requested timeout (for example timeout=1s, pollInterval=1m waits about a minute). The helper advertises timeout as the bound for waiting on semantic_text; use a timer for the smaller of the poll interval and remaining time.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (1)
clients/drive9-py/drive9/models.py (1)

26-37: 💤 Low value

Consider aligning mtime handling with StatResult and improving tags type annotation.

Two minor observations:

  1. StatResult.mtime is Optional[datetime] while StatMetadataResult.mtime is Optional[int] (Unix seconds). This inconsistency may confuse users switching between the two.

  2. tags: Optional[dict] could be more precisely typed as Optional[Dict[str, str]] for better IDE support.

These are not blocking issues since the docstring clarifies the raw response format.

💡 Optional type refinement
+from typing import Dict, Optional
+
 `@dataclass`
 class StatMetadataResult:
     """Represents enriched file metadata from GET ?stat=1."""
     size: int
     is_dir: bool
     resource_id: str
     revision: int
     mtime: Optional[int] = None
     content_type: str = ""
     semantic_text: str = ""
-    tags: Optional[dict] = None
+    tags: Optional[Dict[str, str]] = None
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@clients/drive9-py/drive9/models.py` around lines 26 - 37,
StatMetadataResult's mtime and tags types should be aligned with StatResult for
consistency: change StatMetadataResult.mtime from Optional[int] to
Optional[datetime] (or normalize both to the same representation) and tighten
tags from Optional[dict] to Optional[Dict[str, str]]; update imports to include
typing.Dict and datetime as needed and ensure any serialization/deserialization
in methods that populate StatMetadataResult convert the raw Unix-second mtime to
a datetime if you choose datetime.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@clients/drive9-js/README.md`:
- Around line 70-75: Update the README entry for client.searchByFile to include
an options parameter and document timeout/poll controls: mention that
searchByFile(localPath, tmpPrefix, searchPrefix, limit?, options?) accepts an
optional options object (e.g., { timeoutMs, pollIntervalMs, maxPollAttempts,
abortSignal }) which controls how long to wait for semantic_text from
statMetadata() and how frequently to poll; list default values, describe
behavior on timeout (throws or returns empty), and update the table row and the
following paragraph to reference these option fields and their interaction with
semantic_text polling and temporary file cleanup.

In `@clients/drive9-py/tests/test_client.py`:
- Around line 201-205: The test registers DELETE mocks via
responses.add_callback (using the regex
f"{BASE_URL}/v1/fs/tmp/query/[0-9a-f]+\\.txt") but never asserts those mocks
were invoked; add an assertion after the operation that should trigger cleanup
to verify the DELETE occurred. Locate the responses.add_callback registration(s)
and then assert either responses.assert_call_count for DELETE (or check
responses.calls) contains at least one request whose method is DELETE and whose
URL matches the same regex (or the BASE_URL/.../tmp/query/[0-9a-f]+\.txt
pattern); do the same for the second registration referenced at 219-222 so the
test fails if remote temp-file cleanup wasn't performed.

In `@clients/drive9-rs/src/client.rs`:
- Around line 434-440: The current unique_id() uses SystemTime nanoseconds which
can collide; replace it to generate a UUIDv4 instead: add the uuid crate to
Cargo.toml (uuid = { version = "1", features = ["v4"] }) and update the
unique_id() function to return Uuid::new_v4().to_string() (remove the
SystemTime/duration logic). Ensure you import uuid::Uuid at the top of the
module and remove unused SystemTime/UNIX_EPOCH imports if no longer used.

In `@clients/drive9-rs/src/models.rs`:
- Around line 21-32: The struct StatMetadataResult can fail deserialization when
the server omits certain fields; update the struct by adding #[serde(default)]
on the potentially-missing fields (e.g., resource_id, content_type,
semantic_text, tags) so they fall back to their Rust Default values during
Deserialize; locate the StatMetadataResult definition and add #[serde(default)]
above each of those fields (tags will default to an empty HashMap, strings to
empty String) to match the behavior of the other SDKs.

In `@pkg/client/client.go`:
- Around line 1189-1201: Replace the manual string manipulations in joinRemote
with the canonical path utilities: import pkg/pathutil, use its join/normalize
functions (e.g., pathutil.Join + pathutil.Normalize or the equivalent
pathutil.JoinRemote API) to combine prefix and name, then validate/enforce
drive9 constraints (no "..", no backslashes, NFC, leading slash) via the
pathutil normalization/validation calls and return that normalized path; remove
the hand-rolled TrimRight/HasPrefix logic in the joinRemote function and rely on
pathutil to produce a safe, normalized remote path.
- Line 1163: The cleanup call currently reuses the caller's ctx for
c.DeleteCtx(ctx, tmpPath) so if the caller cancels the deletion is skipped;
change this to use a dedicated cleanup context (e.g., ctxCleanup :=
context.Background() or context.WithTimeout(context.Background(), 5*time.Second)
with defer cancel()) and call c.DeleteCtx(ctxCleanup, tmpPath) to ensure
best-effort removal of tmpPath even when the original ctx is canceled; update
any surrounding code to create and cancel the cleanup context properly.
- Line 1137: SearchByFileCtx is calling the non-cancellable c.Grep(semanticText,
searchPrefix, limit); update the call chain to propagate ctx by either adding a
context-aware Grep method (e.g., Client.GrepCtx(ctx, semanticText, searchPrefix,
limit)) or by changing the existing Grep signature to accept ctx, and then
replace the call in SearchByFileCtx to use the ctx-aware variant; inside the
new/updated Grep/GrepCtx implementation, build the HTTP request with
http.NewRequestWithContext(ctx, ...) (instead of http.NewRequest) so
cancellations/deadlines are honored during the grep HTTP request.

---

Nitpick comments:
In `@clients/drive9-py/drive9/models.py`:
- Around line 26-37: StatMetadataResult's mtime and tags types should be aligned
with StatResult for consistency: change StatMetadataResult.mtime from
Optional[int] to Optional[datetime] (or normalize both to the same
representation) and tighten tags from Optional[dict] to Optional[Dict[str,
str]]; update imports to include typing.Dict and datetime as needed and ensure
any serialization/deserialization in methods that populate StatMetadataResult
convert the raw Unix-second mtime to a datetime if you choose datetime.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6a8ee730-cf22-48aa-93e3-908574928574

📥 Commits

Reviewing files that changed from the base of the PR and between 69a20e6 and d4a4169.

📒 Files selected for processing (19)
  • clients/drive9-js/README.md
  • clients/drive9-js/src/client.ts
  • clients/drive9-js/src/index.ts
  • clients/drive9-js/src/models.ts
  • clients/drive9-js/tests/client.test.ts
  • clients/drive9-kotlin/README.md
  • clients/drive9-kotlin/lib/src/main/kotlin/com/drive9/mobile/Drive9.kt
  • clients/drive9-py/README.md
  • clients/drive9-py/drive9/__init__.py
  • clients/drive9-py/drive9/client.py
  • clients/drive9-py/drive9/models.py
  • clients/drive9-py/tests/test_client.py
  • clients/drive9-rs/README.md
  • clients/drive9-rs/src/client.rs
  • clients/drive9-rs/src/models.rs
  • clients/drive9-rs/tests/test_client.rs
  • clients/drive9-swift/README.md
  • clients/drive9-swift/Sources/Drive9Mobile/Drive9.swift
  • pkg/client/client.go

Comment thread clients/drive9-js/README.md Outdated
Comment on lines +70 to +75
| Search by local file | `client.searchByFile(localPath, tmpPrefix, searchPrefix, limit?)` |

`searchByFile` uploads a local query file to a unique temporary Drive9 path,
waits for `semantic_text` from `statMetadata()`, greps with that text, and
best-effort deletes the temporary file. It applies to any file type the Drive9
backend can parse into `semantic_text`; unsupported MIME types may time out.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Document timeout/poll options in the searchByFile method signature.

The table omits optional timeout controls while the paragraph discusses timeout behavior, which makes the API harder to discover.

Suggested README signature update
-| Search by local file | `client.searchByFile(localPath, tmpPrefix, searchPrefix, limit?)` |
+| Search by local file | `client.searchByFile(localPath, tmpPrefix, searchPrefix, limit?, timeout?, pollInterval?)` |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@clients/drive9-js/README.md` around lines 70 - 75, Update the README entry
for client.searchByFile to include an options parameter and document
timeout/poll controls: mention that searchByFile(localPath, tmpPrefix,
searchPrefix, limit?, options?) accepts an optional options object (e.g., {
timeoutMs, pollIntervalMs, maxPollAttempts, abortSignal }) which controls how
long to wait for semantic_text from statMetadata() and how frequently to poll;
list default values, describe behavior on timeout (throws or returns empty), and
update the table row and the following paragraph to reference these option
fields and their interaction with semantic_text polling and temporary file
cleanup.

Comment thread clients/drive9-py/tests/test_client.py Outdated
Comment on lines +201 to +205
responses.add_callback(
responses.DELETE,
re.compile(f"{BASE_URL}/v1/fs/tmp/query/[0-9a-f]+\\.txt"),
callback=lambda req: (200, {}, ""),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Assert that temporary remote cleanup actually happened.

The test registers a DELETE mock but never asserts it was called, so cleanup regressions could pass undetected.

Proposed test assertion update
 def test_search_by_file(client):
     calls = []
     tmp_paths = []
+    deleted = False
@@
-    responses.add_callback(
-        responses.DELETE,
-        re.compile(f"{BASE_URL}/v1/fs/tmp/query/[0-9a-f]+\\.txt"),
-        callback=lambda req: (200, {}, ""),
-    )
+    def delete_callback(req):
+        nonlocal deleted
+        deleted = True
+        return (200, {}, "")
+
+    responses.add_callback(
+        responses.DELETE,
+        re.compile(f"{BASE_URL}/v1/fs/tmp/query/[0-9a-f]+\\.txt"),
+        callback=delete_callback,
+    )
@@
     assert len(results) == 1
     assert results[0].path == "/data/hit.txt"
     assert calls
+    assert deleted

Also applies to: 219-222

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@clients/drive9-py/tests/test_client.py` around lines 201 - 205, The test
registers DELETE mocks via responses.add_callback (using the regex
f"{BASE_URL}/v1/fs/tmp/query/[0-9a-f]+\\.txt") but never asserts those mocks
were invoked; add an assertion after the operation that should trigger cleanup
to verify the DELETE occurred. Locate the responses.add_callback registration(s)
and then assert either responses.assert_call_count for DELETE (or check
responses.calls) contains at least one request whose method is DELETE and whose
URL matches the same regex (or the BASE_URL/.../tmp/query/[0-9a-f]+\.txt
pattern); do the same for the second registration referenced at 219-222 so the
test fails if remote temp-file cleanup wasn't performed.

Comment thread clients/drive9-rs/src/client.rs Outdated
Comment on lines +434 to +440
fn unique_id() -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or_default();
format!("{:x}", nanos)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if uuid crate is already a dependency
rg -l "^uuid" --glob "Cargo.toml" clients/drive9-rs/
cat clients/drive9-rs/Cargo.toml | grep -A5 "\[dependencies\]"

Repository: mem9-ai/drive9

Length of output: 274


Replace timestamp-based unique_id() with a random UUID to avoid collisions

clients/drive9-rs/src/client.rs’s unique_id() derives IDs from SystemTime nanoseconds (format!("{:x}", nanos)), which can collide if multiple calls happen within the same clock resolution/tick. Switching to UUID v4 removes this collision risk. Also, clients/drive9-rs/Cargo.toml currently has no uuid dependency.

Proposed fix using uuid crate
uuid = { version = "1", features = ["v4"] }
-fn unique_id() -> String {
-    let nanos = SystemTime::now()
-        .duration_since(UNIX_EPOCH)
-        .map(|d| d.as_nanos())
-        .unwrap_or_default();
-    format!("{:x}", nanos)
+fn unique_id() -> String {
+    uuid::Uuid::new_v4().simple().to_string()
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn unique_id() -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or_default();
format!("{:x}", nanos)
}
fn unique_id() -> String {
uuid::Uuid::new_v4().simple().to_string()
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@clients/drive9-rs/src/client.rs` around lines 434 - 440, The current
unique_id() uses SystemTime nanoseconds which can collide; replace it to
generate a UUIDv4 instead: add the uuid crate to Cargo.toml (uuid = { version =
"1", features = ["v4"] }) and update the unique_id() function to return
Uuid::new_v4().to_string() (remove the SystemTime/duration logic). Ensure you
import uuid::Uuid at the top of the module and remove unused
SystemTime/UNIX_EPOCH imports if no longer used.

Comment on lines +21 to +32
#[derive(Debug, Clone, Deserialize)]
pub struct StatMetadataResult {
pub size: i64,
#[serde(rename = "isdir")]
pub is_dir: bool,
pub resource_id: String,
pub revision: i64,
pub mtime: Option<i64>,
pub content_type: String,
pub semantic_text: String,
pub tags: std::collections::HashMap<String, String>,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add #[serde(default)] to handle potentially missing fields gracefully.

If the server omits fields like resource_id, content_type, semantic_text, or tags, deserialization will fail. Other SDKs (JS, Kotlin, Swift) provide defaults for missing fields. Adding #[serde(default)] ensures consistent behavior.

🛡️ Proposed fix to add default handling
-#[derive(Debug, Clone, Deserialize)]
+#[derive(Debug, Clone, Deserialize, Default)]
 pub struct StatMetadataResult {
+    #[serde(default)]
     pub size: i64,
     #[serde(rename = "isdir")]
+    #[serde(default)]
     pub is_dir: bool,
+    #[serde(default)]
     pub resource_id: String,
+    #[serde(default)]
     pub revision: i64,
     pub mtime: Option<i64>,
+    #[serde(default)]
     pub content_type: String,
+    #[serde(default)]
     pub semantic_text: String,
+    #[serde(default)]
     pub tags: std::collections::HashMap<String, String>,
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#[derive(Debug, Clone, Deserialize)]
pub struct StatMetadataResult {
pub size: i64,
#[serde(rename = "isdir")]
pub is_dir: bool,
pub resource_id: String,
pub revision: i64,
pub mtime: Option<i64>,
pub content_type: String,
pub semantic_text: String,
pub tags: std::collections::HashMap<String, String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct StatMetadataResult {
#[serde(default)]
pub size: i64,
#[serde(rename = "isdir")]
#[serde(default)]
pub is_dir: bool,
#[serde(default)]
pub resource_id: String,
#[serde(default)]
pub revision: i64,
pub mtime: Option<i64>,
#[serde(default)]
pub content_type: String,
#[serde(default)]
pub semantic_text: String,
#[serde(default)]
pub tags: std::collections::HashMap<String, String>,
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@clients/drive9-rs/src/models.rs` around lines 21 - 32, The struct
StatMetadataResult can fail deserialization when the server omits certain
fields; update the struct by adding #[serde(default)] on the potentially-missing
fields (e.g., resource_id, content_type, semantic_text, tags) so they fall back
to their Rust Default values during Deserialize; locate the StatMetadataResult
definition and add #[serde(default)] above each of those fields (tags will
default to an empty HashMap, strings to empty String) to match the behavior of
the other SDKs.

Comment thread pkg/client/client.go Outdated
}
semanticText := strings.TrimSpace(meta.SemanticText)
if semanticText != "" {
result, resultErr = c.Grep(semanticText, searchPrefix, limit)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether a context-aware Grep variant exists and where Grep is called.
rg -nP --type=go -C2 '\bfunc\s*\(c \*Client\)\s*GrepCtx\s*\('
rg -nP --type=go -C2 '\bfunc\s*\(c \*Client\)\s*Grep\s*\('
rg -nP --type=go -C2 '\bSearchByFileCtx\s*\(|\bGrep\s*\('

Repository: mem9-ai/drive9

Length of output: 5244


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show Grep and SearchByFileCtx implementations around the reported line
sed -n '1060,1160p' pkg/client/client.go | cat -n

# Show whether any GrepCtx exists anywhere else
rg -n --type=go '\bGrepCtx\b' pkg/client || true
rg -n --type=go '\bfunc\s*\(c \*Client\)\s*GrepCtx\s*\(' -S . || true

# Show Grep callsites where SearchByFileCtx is used (context propagation)
rg -n --type=go 'SearchByFileCtx\(' -S pkg cmd . || true

Repository: mem9-ai/drive9

Length of output: 4390


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find Client.do implementation
rg -n --type=go 'func\s*\(c \*Client\)\s*do\s*\(' pkg/client/client.go
rg -n --type=go '\.do\(req\)' pkg/client/client.go

# Print a focused window around Client.do and any related request creation helpers
sed -n '1,260p' pkg/client/client.go | cat -n
# Narrow further around the do() function once we know its location
# (fallback: print around likely area where do is defined)
sed -n '260,520p' pkg/client/client.go | cat -n

Repository: mem9-ai/drive9

Length of output: 23791


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Repo-wide search for any context-aware grep variant
rg -n --type=go '\bGrepCtx\b' . || true
rg -n --type=go 'func\s*\(c\s*\*Client\)\s*Grep\w*Ctx\(' . || true

# Also confirm the grep request in Grep() uses http.NewRequest (no context)
rg -n --type=go 'func\s*\(c \*Client\)\s*Grep\(' pkg/client/client.go
rg -n --type=go 'http\.NewRequest\(' pkg/client/client.go

Repository: mem9-ai/drive9

Length of output: 435


Propagate ctx into the grep request path.

SearchByFileCtx is context-aware, but it calls c.Grep(...) (non-ctx) at pkg/client/client.go:1137, so cancellation/deadlines won’t apply to the grep HTTP request. Client.Grep also constructs the request with http.NewRequest (no context), making it inherently non-cancellable.

result, resultErr = c.Grep(semanticText, searchPrefix, limit)

Add a context-aware grep method (e.g., GrepCtx) and use it from SearchByFileCtx, or update the existing Grep to accept/use ctx (per “Pass context through call chains”).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/client/client.go` at line 1137, SearchByFileCtx is calling the
non-cancellable c.Grep(semanticText, searchPrefix, limit); update the call chain
to propagate ctx by either adding a context-aware Grep method (e.g.,
Client.GrepCtx(ctx, semanticText, searchPrefix, limit)) or by changing the
existing Grep signature to accept ctx, and then replace the call in
SearchByFileCtx to use the ctx-aware variant; inside the new/updated
Grep/GrepCtx implementation, build the HTTP request with
http.NewRequestWithContext(ctx, ...) (instead of http.NewRequest) so
cancellations/deadlines are honored during the grep HTTP request.

Comment thread pkg/client/client.go Outdated
break
}
}
_ = c.DeleteCtx(ctx, tmpPath)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use a cleanup context independent of caller cancellation.

Line 1163 reuses ctx for delete. If caller cancels/times out, cleanup is skipped and temp files are left behind; this undermines the helper’s best-effort cleanup contract.

Proposed fix
-	_ = c.DeleteCtx(ctx, tmpPath)
+	cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+	_ = c.DeleteCtx(cleanupCtx, tmpPath)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
_ = c.DeleteCtx(ctx, tmpPath)
cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = c.DeleteCtx(cleanupCtx, tmpPath)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/client/client.go` at line 1163, The cleanup call currently reuses the
caller's ctx for c.DeleteCtx(ctx, tmpPath) so if the caller cancels the deletion
is skipped; change this to use a dedicated cleanup context (e.g., ctxCleanup :=
context.Background() or context.WithTimeout(context.Background(), 5*time.Second)
with defer cancel()) and call c.DeleteCtx(ctxCleanup, tmpPath) to ensure
best-effort removal of tmpPath even when the original ctx is canceled; update
any surrounding code to create and cancel the cleanup context properly.

Comment thread pkg/client/client.go Outdated
Comment on lines +1189 to +1201
func joinRemote(prefix, name string) string {
base := strings.TrimRight(prefix, "/")
if base == "" {
base = "/"
}
if !strings.HasPrefix(base, "/") {
base = "/" + base
}
if base == "/" {
return "/" + name
}
return base + "/" + name
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Replace raw path string manipulation with pkg/pathutil.

joinRemote manually builds remote paths and does not enforce normalization constraints (.., backslashes, NFC), which is explicitly prohibited for drive9 paths.

As per coding guidelines, "Use pkg/pathutil for all path normalization; never manipulate raw path strings directly."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/client/client.go` around lines 1189 - 1201, Replace the manual string
manipulations in joinRemote with the canonical path utilities: import
pkg/pathutil, use its join/normalize functions (e.g., pathutil.Join +
pathutil.Normalize or the equivalent pathutil.JoinRemote API) to combine prefix
and name, then validate/enforce drive9 constraints (no "..", no backslashes,
NFC, leading slash) via the pathutil normalization/validation calls and return
that normalized path; remove the hand-rolled TrimRight/HasPrefix logic in the
joinRemote function and rely on pathutil to produce a safe, normalized remote
path.

Add `statMetadata` (Swift / Kotlin / JS) and `stat_metadata` (Python /
Rust) to the mobile Drive9 SDKs. The helper wraps the existing
`GET /v1/fs/{path}?stat=1` endpoint and returns enriched metadata -
size, isDir, resourceId, revision, mtime, contentType, semanticText,
and tags. The Go client already had `StatMetadata`.

This is a smaller PR than the original `searchByFile` proposal. The
upload-and-poll `searchByFile` / `search_by_file` helper has been
dropped because the audio search demo no longer needs it: query-side
speech-to-text now runs on-device and calls the existing `grep`
endpoint directly. If a future feature really needs a server-side
"upload a file, wait for semantic_text, then search" primitive, it
can land as a separate PR.
@you06
you06 force-pushed the feat/semantic-search-by-file-sdk branch from 14b2af7 to 6d2e8e8 Compare May 28, 2026 05:11

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
clients/drive9-py/drive9/client.py (1)

198-212: ⚡ Quick win

Consider converting mtime to datetime for consistency with stat().

The existing stat() method (lines 181-196) converts the mtime header to a datetime object. For consistency, stat_metadata() should also convert the epoch-seconds mtime field to datetime:

from datetime import timezone

mtime = None
mt = data.get("mtime")
if mt is not None:
    try:
        mtime = datetime.fromtimestamp(int(mt), tz=timezone.utc)
    except (ValueError, OSError):
        pass

This would align with the TypeScript SDK (line 163 in client.ts converts to Date) and ensure a consistent API surface across methods in the Python client.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@clients/drive9-py/drive9/client.py` around lines 198 - 212, The stat_metadata
method currently returns mtime raw from the response; change it to mirror stat()
by converting the epoch-seconds mtime to a timezone-aware datetime in UTC (use
datetime.fromtimestamp(..., tz=timezone.utc)), handling missing or invalid
values by leaving mtime as None and catching ValueError/OSError; update imports
to include datetime and timezone and set the StatMetadataResult mtime field to
the converted datetime (or None) in stat_metadata.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@clients/drive9-py/drive9/client.py`:
- Around line 198-212: The stat_metadata method currently returns mtime raw from
the response; change it to mirror stat() by converting the epoch-seconds mtime
to a timezone-aware datetime in UTC (use datetime.fromtimestamp(...,
tz=timezone.utc)), handling missing or invalid values by leaving mtime as None
and catching ValueError/OSError; update imports to include datetime and timezone
and set the StatMetadataResult mtime field to the converted datetime (or None)
in stat_metadata.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 84482b33-a7e2-467a-96d6-ca22365a1d3e

📥 Commits

Reviewing files that changed from the base of the PR and between d4a4169 and 6d2e8e8.

📒 Files selected for processing (16)
  • clients/drive9-js/README.md
  • clients/drive9-js/src/client.ts
  • clients/drive9-js/src/index.ts
  • clients/drive9-js/src/models.ts
  • clients/drive9-js/tests/client.test.ts
  • clients/drive9-kotlin/README.md
  • clients/drive9-kotlin/lib/src/main/kotlin/com/drive9/mobile/Drive9.kt
  • clients/drive9-py/README.md
  • clients/drive9-py/drive9/__init__.py
  • clients/drive9-py/drive9/client.py
  • clients/drive9-py/drive9/models.py
  • clients/drive9-rs/README.md
  • clients/drive9-rs/src/client.rs
  • clients/drive9-rs/src/models.rs
  • clients/drive9-swift/README.md
  • clients/drive9-swift/Sources/Drive9Mobile/Drive9.swift
✅ Files skipped from review due to trivial changes (7)
  • clients/drive9-js/src/index.ts
  • clients/drive9-swift/README.md
  • clients/drive9-rs/README.md
  • clients/drive9-kotlin/README.md
  • clients/drive9-py/README.md
  • clients/drive9-py/drive9/init.py
  • clients/drive9-js/tests/client.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • clients/drive9-js/src/models.ts
  • clients/drive9-rs/src/models.rs
  • clients/drive9-py/drive9/models.py
  • clients/drive9-kotlin/lib/src/main/kotlin/com/drive9/mobile/Drive9.kt

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.

7 participants