diff --git a/Cargo.lock b/Cargo.lock index 341e9f287..c3005a455 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11666,6 +11666,7 @@ dependencies = [ "hound", "if-watch", "image", + "indexmap 2.11.4", "indicatif", "int-enum", "inventory", @@ -12988,6 +12989,7 @@ name = "spacedrive-sdk" version = "0.1.0" dependencies = [ "chrono", + "getrandom 0.4.3", "serde", "serde_json", "spacedrive-sdk-macros", diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index 826fed317..a97b29b36 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -211,6 +211,7 @@ Recent landed. | CLI and daemon housekeeping from the live runs | in progress 2026-10-05 (Fable, SPAC-16); one PR against `main`, one commit per item | Fable | Nine small fixes the Mac Studio / MacBook sessions (SPAC-9, 10, 12, 15) turned up. `core.status` defaults its `replication` block so a newer `sd --device status` reads an older daemon (unit test on the pre-#3109 shape); `sd config get/set/show` cover `replication.paused` beside `max_bytes_per_sec` and `--help` lists the keys; a peer syncing a library this device no longer holds gets one WARN per connection and a `SyncMessage::Error` reply so its catch-up fails fast and backs off (a real "leave library" is a separate brief); `core.status` reads paired and connected counts from the network trust registry so `sd status` and `sd network status` agree ("Paired devices"); `sd update` downloads into resumable `/updates/*.part` files with a 60 s idle timeout, verifies sha256 before replacing, appends every attempt with its reason to `logs/update.log`, keeps `update-state.json`, writes `sd-version.txt` beside the binaries, and `sd update status` prints the running build, marker, last attempt and result; the daemon plist asks launchd for 65536 file descriptors; `jobs.list` no longer loads each job's persisted `state` blob (an indexer over millions of files keeps its seen-path set there, which is why a remote `job list` took 45 s); `maximize-build-space` removed from the Blacksmith jobs in `release.yml` and `server.yml`. `main` is the branch of record from 2026-10-05 (fast-forwarded to `sources` at 10dbc18): `nightly.yml` and `core_tests.yml` now trigger on `main`, with `sources` kept in the lists for one release. Next: merge, nightly, re-run the SPAC-15 re-join once the MacBook is on it. | | Library backup and restore | in progress 2026-10-06 (Fable, SPAC-23); one PR against `main` | Fable | Release gate "Database backup and restore pass from release candidates" and candidate validation step 7. `libraries.backup` copies `library.db`, `sync.db`, every owned source store and its `sidecars.db` with `VACUUM INTO` on a fresh connection while the daemon runs, hashes each file with blake3 and writes `manifest.json` (device, build sha, applied migrations by name, each source's store id, revision and schema hash), into a directory or `.tar.zst`; `libraries.backup.verify` rehashes without restoring; `libraries.restore` verifies every hash first, refuses a backup whose library applied a migration this build does not know, stages under the data dir, renames into place (replace mode closes the library, releases open store and sidecar pools so the swapped `data.db` is what the next open reads, and parks the displaced state in `restore-trash/`), and reopens. Replacing a library other devices are members of needs `force`, because the device rows and sync watermarks in it would diverge from what those peers hold. CLI: `sd library backup --to`, `sd library backup verify --from`, `sd library restore --from [--as-new|--replace] [--force]`. `core/tests/library_backup_test.rs` backs up while a watcher writes, restores into a fresh data dir and proves counts, tag assertions, space items and store revisions equal, replaces over a mutated library and proves the mutation gone, and detects a one-byte tamper. Documented in `docs/core/library.mdx` (Backup and restore). Next: run the gate on the MacBook library before the beta candidate. | | Extension loading | in progress 2026-10-07 (Fable, SPAC-28); one PR against `main` | Fable | Product sequence step 5, first half. A daemon built with the `wasm` feature discovers every `/extensions//manifest.json` at startup (before libraries open, so a resumed job finds its extension), instantiates it with wasmer 6 (4.4 stopped linking on Rust 1.89+, wasmer #5610), and keeps the instance for its lifetime. `plugin_init` registers jobs; `extensions.list` reports them and `extensions.run_job` starts one as a `wasm_job` through the normal job manager. The guest's host calls are bridged to the job context: progress, warnings, metrics, an interrupt flag the pause path sets, and checkpoints that reach the checkpoint table before the guest continues, so both a graceful stop (paused row) and a SIGKILL (running row plus checkpoint) resume from the guest's last state on restart; proven live on a Linux daemon and in `core/tests/wasm_extension_test.rs` (acceptance group, with its own `--features wasm` build: carrying the feature across the integration group added minutes to every test binary link, 51 to 78 minutes on the first CI run). The SDK's `#[job]` export now runs the job function (async ones through a poll loop) and `plugin_init` installs a panic hook so a guest panic reaches the host log. Phase B: the photos extension builds to wasm32 without wasm-bindgen imports, loads beside the test extension and registers four jobs; its first job stops at `JobContext::run` (task execution has no host function), and the SDK has no file-kind or preview registration at all, so step 5's second half is a host-function brief (report SPAC-28 lists the gaps: task execution, vdfs entry and sidecar access, AI inference, model registry, tags, entropy, clock, file-kind and preview registration). The `wasm` feature stays opt-in; whether the shipped daemon turns it on is Jamie's call. | +| Extension host functions, phase 1 | in progress 2026-10-07 (Fable, SPAC-30); one PR against `main`, one commit per gap | Fable | Product sequence step 5, second half, in SPAC-28's gap order. Every guest request that returns data now goes through one import, `spacedrive_op(name, payload)`, answered on the job's async side with permission checked against a strict manifest (`read_records` with an optional glob, `read_sidecars`, `write_sidecars` per kind, `use_models` per category; unknown fields are refused at load). Landed: task execution (`#[task]` attempts bracketed by `task.begin`/`task.end` with the SDK's deadline and an attempt line in the job log), entropy and clock (`spacedrive_random` backs `getrandom` so `Uuid::new_v4` works in the guest; `spacedrive_now_ms` backs `clock::now`), records over source stores (`Record` replaces the SDK's `Entry`; `records.get/query/read` resolve through the library's sources and read bytes from the store root; tag and metadata filters are refused), sidecars as one JSON document per extension kind under `SidecarKind::Extension { extension_id, kind }`, models (`#[model]` emits a definition `register_model` collects; the registry builds a `DataTypeSchema` and opens an sd-store db at `/extensions//data.db`; `models.put/get/list` keyed by uuid or `content:`), `config.get` for `config.json` beside the manifest, and `ai.infer`, which checks the `use_models` grant and answers `not_available` because the core has no inference provider. The photos extension handles that with one warning and a skip, so `analyze_photos` completes over a fixture library of twelve JPEGs in `core/tests/wasm_extension_test.rs` (the test extension's new `catalog` job covers records, sidecars, models and tasks in the same suite). Still unbacked: inference itself (so scenes, places and moments have nothing to run on), tags, custom fields, `dispatch_jobs`, agents, actions, queries, and file-kind or preview registration (gap 9, untouched). | | Extension file kinds and previews | design proposed 2026-10-07 (Fable, SPAC-31); nothing built | unowned | Plan: `docs/plans/2026-10-07-extension-kinds-and-previews.md`. Product sequence step 5, second half, and SPAC-28 gap 9. A manifest `kinds` section (name, built-in parent, extensions, magic bytes, preview) folds into a layered `FileTypeRegistry::current()` the plugin manager swaps on load and unload; the content identity phase writes `content.kind` and a new `content.kind_name` (store schema v2), so a file keeps its kind name after the extension unloads and the client falls back to the parent renderer; `ContentRenderer.tsx` becomes a lookup in a renderer registry fed by `extensions.list`, with bundle previews served from `/extension/:id/*` under a plain `mount(el, ctx)` contract. Nothing is stored to migrate: `content.kind` is NULL on every row today and kinds are derived at read time. Five phases K1 to K5, about eight days, two PRs. Jamie decides: kinds stay one level deep under a built-in parent; conflicts resolve by load order and are reported, no picker yet. | | Repository release gates in CI | in progress 2026-10-07 (Fable, SPAC-26); one PR against `main` | Fable | `docs/core/releases.mdx` lists five repository gates; only formatting, clippy, typecheck and tests had jobs. `ci.yml` gains four more on every PR to `main`: Docs (bun 1.3.4, `docs/` frozen lockfile, `types:check` and `next build`), Generated types (`generate_typescript_types` plus `cargo check -p spacedrive` for the Tauri schemas, fails on a diff under `packages/ts-client/src/generated` or `apps/tauri/src-tauri/gen/schemas`, own rust-cache key), Secrets scan (pinned gitleaks over the PR's commits and the tree, allowlist in `.gitleaks.toml` including the AWS docs example key in `cloud_credentials.rs`), and License and notices (LICENSE is Apache-2.0, NOTICE exists and carries the same AGPL-3.0 cut-over date as README). Found and fixed on the way: the docs build failed on an unescaped `` in `cli/library-sync-setup.mdx`, and the committed Tauri schemas predated the updater plugin. The gates in `releases.mdx` now name their job. Next: confirm the four jobs green on the PR and the warm wall time under 10 min. | diff --git a/core/Cargo.toml b/core/Cargo.toml index 1c8b1afcb..02606fa4b 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -124,6 +124,7 @@ sd-task-system = { path = "../crates/task-system" } # The per-source store, one SQLite file per source sd-store = { path = "../crates/store" } +indexmap = { workspace = true, features = ["serde"] } # Archive system - indexes external data sources (emails, notes, etc.) sd-archive = { path = "../crates/archive" } diff --git a/core/src/domain/sidecar.rs b/core/src/domain/sidecar.rs index f5586c74f..accfe7d99 100644 --- a/core/src/domain/sidecar.rs +++ b/core/src/domain/sidecar.rs @@ -9,6 +9,10 @@ use serde::{Deserialize, Serialize}; use specta::Type; use std::fmt; +/// What a sidecar holds. The core kinds are closed; an extension declares +/// its own in its manifest (`write_sidecars`) and they live under the +/// extension's namespace, so two extensions' `faces` never collide and no +/// extension can write a core kind. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Type)] #[serde(rename_all = "snake_case")] pub enum SidecarKind { @@ -19,10 +23,31 @@ pub enum SidecarKind { Ocr, Transcript, GaussianSplat, + Extension { extension_id: String, kind: String }, } impl SidecarKind { - pub fn as_str(&self) -> &'static str { + /// A kind an extension declared. Both parts must be single path + /// segments of `[A-Za-z0-9._-]`, since they become directory names. + pub fn extension(extension_id: &str, kind: &str) -> Result { + for (what, value) in [("extension id", extension_id), ("kind", kind)] { + let safe = !value.is_empty() + && value != "." + && value != ".." + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')); + if !safe { + return Err(format!("Invalid sidecar {what}: {value:?}")); + } + } + Ok(Self::Extension { + extension_id: extension_id.to_string(), + kind: kind.to_string(), + }) + } + + pub fn as_str(&self) -> &str { match self { Self::Thumb => "thumb", Self::Thumbstrip => "thumbstrip", @@ -31,14 +56,23 @@ impl SidecarKind { Self::Ocr => "ocr", Self::Transcript => "transcript", Self::GaussianSplat => "gaussian_splat", + Self::Extension { kind, .. } => kind, } } - pub fn directory(&self) -> &'static str { + /// The directory under a content's sidecar directory, relative to the + /// `sidecars` tree root. + pub fn directory(&self) -> String { // The layout crate owns the mapping so out-of-process readers resolve - // the same directories; every enum variant is a known kind string. - sd_sidecar_path::kind_directory(self.as_str()) - .expect("every SidecarKind maps to a directory") + // the same directories; every core variant is a known kind string. + match self { + Self::Extension { extension_id, kind } => { + sd_sidecar_path::extension_kind_directory(extension_id, kind) + } + core => sd_sidecar_path::kind_directory(core.as_str()) + .expect("every core SidecarKind maps to a directory") + .to_string(), + } } } diff --git a/core/src/infra/extension/README.md b/core/src/infra/extension/README.md index 34aad34b0..a9a8e24ae 100644 --- a/core/src/infra/extension/README.md +++ b/core/src/infra/extension/README.md @@ -13,17 +13,29 @@ reports `supported: false` and `extensions.run_job` is refused. keeps each plugin's store and instance for its lifetime. `PluginRuntime` runs one job export on the calling thread. - `host_functions.rs`: the functions a guest imports from the `spacedrive` - module. `spacedrive_log` and `register_job` serve `plugin_init`; the `job_*` - functions report into the job that is running through a `JobBridge`. + module. `spacedrive_log`, `register_job` and `register_model` serve + `plugin_init`; the `job_*` functions report into the running job through a + `JobBridge`; `spacedrive_random` and `spacedrive_now_ms` give the guest + entropy and wall-clock time; `spacedrive_op` carries every request that + returns data. +- `ops.rs`: the operations behind `spacedrive_op`, named by a string and + carried as bytes. `JobOps` holds the library, the manifest and the open + extension store for the job's duration and answers one request at a time, + because the guest blocks on each. Permission is checked here against the + manifest, never in the guest. - `job_registry.rs`: the jobs extensions registered, keyed `:`. +- `model_registry.rs`: the models each extension declared through + `register_model`. The registry turns them into one `DataTypeSchema` and opens + an sd-store database at `/extensions//data.db` the first + time a job touches a model. - `wasm_job.rs`: the one core job type that runs every extension job. It holds the guest's state as a JSON string and persists it through the normal checkpoint table, so a kill or a pause resumes from the guest's last checkpoint. -- `permissions.rs`, `types.rs`: the manifest and the capability model for - `spacedrive_call`, which routes a Wire method through the operation - registry. The SDK does not import it yet. +- `types.rs`: the manifest. Both the manifest and its `permissions` block + reject unknown fields, so a typo in a grant fails at load instead of + silently granting nothing. ## Running a job @@ -37,18 +49,43 @@ reports `supported: false` and `extensions.run_job` is refused. interrupts the job. Exit code 0 completes, 1 is interrupted, anything else fails. +## Operations + +A guest sends `spacedrive_op(name, payload)` and gets back bytes it allocated +through `wasm_alloc`: JSON for a result, or an `OpError` with a stable `code` +(`not_found`, `permission_denied`, `invalid_input`, `not_available`, `failed`) +that the SDK maps onto its own error type. + +| Operation | Grant | What it does | +| --- | --- | --- | +| `task.begin`, `task.end` | none | Bracket one `#[task]` attempt. The host writes an attempt line to the job log and refuses a task that outlives the SDK's deadline. | +| `records.get`, `records.query` | `read_records` | A record by uuid, or the records of the library's sources filtered by kind and extension. The optional `glob` on the grant is honored for its trailing extension list only (`*.jpg`, `**/*.{jpg,png}`); the directory part is ignored, and a glob of another shape is refused at load. Tag filters are refused. | +| `records.read` | `read_records` | The bytes of a record, read from its source store's root. | +| `sidecars.exists`, `sidecars.read` | `read_sidecars` | One JSON document under `/sidecars/`, keyed by content uuid and `SidecarKind::Extension { extension_id, kind }`. | +| `sidecars.write` | `write_sidecars` (per kind) | Writes that document. | +| `models.put`, `models.get`, `models.list` | none, models are the extension's own | A row in the extension's store, keyed by the model's uuid or `content:` for a content-scoped model. | +| `ai.infer` | `use_models` (per category) | Checks the grant, then answers `not_available`: the core has no inference provider yet. | +| `config.get` | none | `config.json` beside the manifest, or `null`. | + ## What the SDK can call today -`spacedrive_log`, `register_job`, `job_report_progress`, `job_checkpoint`, -`job_check_interrupt`, `job_add_warning`, `job_increment_items`, -`job_increment_bytes`. Everything else in `crates/sdk` (VDFS entries, sidecars, -AI, models, tags, tasks, agents, entropy, clock) has no host function and -panics in the guest; the panic reaches the host log and the job fails. +Everything in the table above, plus `spacedrive_log`, `register_job`, +`register_model`, `spacedrive_random`, `spacedrive_now_ms` and the `job_*` +functions (`job_report_progress`, `job_checkpoint`, `job_check_interrupt`, +`job_add_warning`, `job_increment_items`, `job_increment_bytes`). Tags, custom +fields, dispatching jobs, agents and file-kind or preview registration have no +host side; the SDK returns an error or panics in the guest, and the panic +reaches the host log and fails the job. ## Testing `core/tests/wasm_extension_test.rs` loads the committed fixtures `extensions/test-extension/test_extension.wasm` and -`extensions/photos/photos.wasm`. Rebuild a fixture from its crate with -`cargo build --release` (each crate's `.cargo/config.toml` selects -wasm32-unknown-unknown) and copy the artifact next to its manifest. +`extensions/photos/photos.wasm` against a library with twelve JPEG-magic +files and two text files. The test extension's `catalog` job exercises +records, sidecars, models and tasks; the photos extension's `analyze_photos` +runs end to end and takes the `not_available` path for face detection. +Rebuild a fixture from its crate with `cargo build --release` (each crate's +`.cargo/config.toml` selects wasm32-unknown-unknown) and copy the artifact +next to its manifest. The suite is its own xtask acceptance entry with the +`wasm` feature, because linking wasmer into every test binary is slow. diff --git a/core/src/infra/extension/host_functions.rs b/core/src/infra/extension/host_functions.rs index 1d842dbe4..023651727 100644 --- a/core/src/infra/extension/host_functions.rs +++ b/core/src/infra/extension/host_functions.rs @@ -1,161 +1,31 @@ //! WASM host functions //! -//! This module provides the bridge between WASM extensions and Spacedrive's -//! operation registry. The key function is `host_spacedrive_call()` which routes -//! generic Wire method calls to the existing `execute_json_operation()` function -//! used by daemon RPC. +//! The functions a guest imports from the `spacedrive` module. `spacedrive_log` +//! and `register_job` serve `plugin_init`; the `job_*` functions report into +//! the running job; `spacedrive_op` carries every operation that returns data. +//! +//! An operation is answered on the job's async side: the host function reads +//! the request out of guest memory, sends it through the `JobBridge`, blocks +//! the guest thread until the answer arrives, and writes the answer into +//! memory taken from the guest's own `wasm_alloc`. The guest frees it. One +//! convention for every operation keeps the import table small and the memory +//! handling in one place on each side. use std::sync::Arc; -use uuid::Uuid; -use wasmer::{FunctionEnvMut, Memory, MemoryView, WasmPtr}; - -use crate::{infra::daemon::rpc::RpcServer, Core}; - -use super::permissions::ExtensionPermissions; +use wasmer::{FunctionEnvMut, Memory, MemoryView, TypedFunction, WasmPtr}; /// Environment passed to all host functions pub struct PluginEnv { pub extension_id: String, - pub core_context: Arc, // Just context, not full Core! - pub api_dispatcher: Arc, // For creating sessions - pub permissions: ExtensionPermissions, pub memory: Memory, + /// The guest's allocator, for answers. Set once the instance exists. + pub alloc: Option>, pub job_registry: Arc, + pub model_registry: Arc, pub current_job: Option, } -/// THE MAIN HOST FUNCTION - Generic Wire RPC -/// -/// This is the ONLY function WASM extensions need to call Spacedrive operations. -/// It routes calls to the existing Wire operation registry. -/// -/// # Arguments -/// - `method_ptr`, `method_len`: Wire method string (e.g., "query:ai.ocr") -/// - `library_id_ptr`: 0 for None, or pointer to 16 UUID bytes -/// - `payload_ptr`, `payload_len`: JSON payload string -/// -/// # Returns -/// Pointer to result JSON string in WASM memory (or 0 on error) -pub fn host_spacedrive_call( - mut env: FunctionEnvMut, - method_ptr: WasmPtr, - method_len: u32, - library_id_ptr: u32, - payload_ptr: WasmPtr, - payload_len: u32, -) -> u32 { - let (plugin_env, mut store) = env.data_and_store_mut(); - - // Get memory view from environment - let memory = &plugin_env.memory; - let memory_view = memory.view(&store); - - // 1. Read method string from WASM memory - let method = match read_string_from_wasm(&memory_view, method_ptr, method_len) { - Ok(m) => m, - Err(e) => { - tracing::error!("Failed to read method string: {}", e); - return 0; - } - }; - - // 2. Read library_id (0 = None) - let library_id = if library_id_ptr == 0 { - None - } else { - match read_uuid_from_wasm(&memory_view, WasmPtr::new(library_id_ptr)) { - Ok(uuid) => Some(uuid), - Err(e) => { - tracing::error!("Failed to read library UUID: {}", e); - return 0; - } - } - }; - - // 3. Read payload JSON - let payload_str = match read_string_from_wasm(&memory_view, payload_ptr, payload_len) { - Ok(s) => s, - Err(e) => { - tracing::error!("Failed to read payload: {}", e); - return 0; - } - }; - - let payload_json: serde_json::Value = match serde_json::from_str(&payload_str) { - Ok(json) => json, - Err(e) => { - tracing::error!("Failed to parse payload JSON: {}", e); - return write_error_to_memory(&memory, &mut store, &format!("Invalid JSON: {}", e)); - } - }; - - // 4. Permission check - let auth_result = tokio::runtime::Handle::current() - .block_on(async { plugin_env.permissions.authorize(&method, library_id).await }); - - if let Err(e) = auth_result { - tracing::warn!( - extension = %plugin_env.extension_id, - method = %method, - "Permission denied: {}", - e - ); - return write_error_to_memory(&memory, &mut store, &format!("Permission denied: {}", e)); - } - - tracing::debug!( - extension = %plugin_env.extension_id, - method = %method, - library_id = ?library_id, - "Extension calling operation" - ); - - // 5. Call operation handlers directly (same as execute_json_operation does) - let result = tokio::runtime::Handle::current().block_on(async { - // Create base session - let base_session = match plugin_env.api_dispatcher.create_base_session() { - Ok(s) => s, - Err(e) => return Err(e), - }; - - // Try library queries - if let Some(handler) = crate::infra::wire::registry::LIBRARY_QUERIES.get(method.as_str()) { - let lib_id = library_id.ok_or_else(|| "Library ID required".to_string())?; - let session = base_session.with_library(lib_id); - return handler(plugin_env.core_context.clone(), session, payload_json).await; - } - - // Try core queries - if let Some(handler) = crate::infra::wire::registry::CORE_QUERIES.get(method.as_str()) { - return handler(plugin_env.core_context.clone(), base_session, payload_json).await; - } - - // Try library actions - if let Some(handler) = crate::infra::wire::registry::LIBRARY_ACTIONS.get(method.as_str()) { - let lib_id = library_id.ok_or_else(|| "Library ID required".to_string())?; - let session = base_session.with_library(lib_id); - return handler(plugin_env.core_context.clone(), session, payload_json).await; - } - - // Try core actions - if let Some(handler) = crate::infra::wire::registry::CORE_ACTIONS.get(method.as_str()) { - return handler(plugin_env.core_context.clone(), payload_json).await; - } - - Err(format!("Unknown method: {}", method)) - }); - - // 6. Write result to WASM memory - match result { - Ok(json) => write_json_to_memory(&memory, &mut store, &json), - Err(e) => { - tracing::error!("Operation failed: {}", e); - write_error_to_memory(&memory, &mut store, &e) - } - } -} - /// Optional logging helper for extensions pub fn host_spacedrive_log( mut env: FunctionEnvMut, @@ -201,74 +71,6 @@ fn read_string_from_wasm( String::from_utf8(bytes).map_err(|e| e.into()) } -fn read_uuid_from_wasm( - memory_view: &MemoryView, - ptr: WasmPtr, -) -> Result> { - let bytes = ptr - .slice(memory_view, 16) - .and_then(|slice| slice.read_to_vec()) - .map_err(|e| format!("Failed to read UUID from WASM memory: {:?}", e))?; - - let uuid_bytes: [u8; 16] = bytes - .try_into() - .map_err(|_| "Invalid UUID bytes (expected 16 bytes)")?; - - Ok(Uuid::from_bytes(uuid_bytes)) -} - -fn write_json_to_memory( - memory: &Memory, - store: &mut wasmer::StoreMut, - json: &serde_json::Value, -) -> u32 { - let json_str = match serde_json::to_string(json) { - Ok(s) => s, - Err(e) => { - tracing::error!("Failed to serialize JSON: {}", e); - return 0; // NULL indicates error - } - }; - - let bytes = json_str.as_bytes(); - - // Try to call guest's allocator function - // WASM module must export: fn wasm_alloc(size: i32) -> i32 - let alloc_result = memory - .view(&store) - .data_size() // Just check memory exists for now - .checked_sub(bytes.len() as u64); - - if alloc_result.is_none() { - tracing::error!("Not enough WASM memory for result"); - return 0; - } - - // For now, write to a fixed offset (will implement proper allocator later) - // This is a simplification for testing - production needs guest allocator - let result_offset = 65536u32; // Start at 64KB - - let memory_view = memory.view(&store); - let wasm_ptr = WasmPtr::::new(result_offset); - - if let Ok(slice) = wasm_ptr.slice(&memory_view, bytes.len() as u32) { - if let Err(e) = slice.write_slice(bytes) { - tracing::error!("Failed to write to WASM memory: {:?}", e); - return 0; - } - } else { - tracing::error!("Failed to get WASM memory slice"); - return 0; - } - - result_offset -} - -fn write_error_to_memory(memory: &Memory, store: &mut wasmer::StoreMut, error: &str) -> u32 { - let error_json = serde_json::json!({ "error": error }); - write_json_to_memory(memory, store, &error_json) -} - /// What a running job's host calls report back to its `WasmJob`. /// /// The guest runs on a blocking thread while the job's async side drains these @@ -285,6 +87,12 @@ pub enum JobEvent { Warning(String), Items(u64), Bytes(u64), + /// A request for data, answered by `super::ops` on the async side. + Op { + op: String, + payload: Vec, + reply: tokio::sync::oneshot::Sender, + }, } /// The job a plugin instance is running right now, if any. @@ -376,6 +184,123 @@ pub fn host_job_checkpoint( }) } +/// Run one data-returning operation for the job. +/// +/// Writes `[ptr, len]` of the answer to `out_ptr` and returns 0, or writes a +/// JSON error there and returns 1. Both buffers come from the guest's +/// `wasm_alloc`, which the guest frees after reading. Called outside a job, +/// or with an unreadable request, nothing is written and 2 is returned. +pub fn host_spacedrive_op( + mut env: FunctionEnvMut, + op_ptr: WasmPtr, + op_len: u32, + payload_ptr: WasmPtr, + payload_len: u32, + out_ptr: WasmPtr, +) -> i32 { + let (plugin_env, mut store) = env.data_and_store_mut(); + let memory = plugin_env.memory.clone(); + let memory_view = memory.view(&store); + + let op = match read_string_from_wasm(&memory_view, op_ptr, op_len) { + Ok(op) => op, + Err(e) => { + tracing::error!("Failed to read op name: {}", e); + return 2; + } + }; + let payload = match payload_ptr + .slice(&memory_view, payload_len) + .and_then(|slice| slice.read_to_vec()) + { + Ok(payload) => payload, + Err(e) => { + tracing::error!("Failed to read op payload: {:?}", e); + return 2; + } + }; + let Some(alloc) = plugin_env.alloc.clone() else { + return 2; + }; + + let answer = with_job(plugin_env, None, |job| { + let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); + if job + .events + .send(JobEvent::Op { + op: op.clone(), + payload, + reply: reply_tx, + }) + .is_err() + { + return None; + } + reply_rx.blocking_recv().ok() + }); + let Some(answer) = answer else { + return 2; + }; + + let (status, bytes) = match answer { + Ok(bytes) => (0, bytes), + Err(error) => (1, error.to_json().into_bytes()), + }; + + // The guest frees `len.max(1)` bytes, matching the allocation here. + let len = bytes.len() as u32; + let ptr = match alloc.call(&mut store, len.max(1) as i32) { + Ok(ptr) => ptr as u32, + Err(e) => { + tracing::error!(extension = %plugin_env.extension_id, op, "wasm_alloc failed: {e}"); + return 2; + } + }; + let memory_view = memory.view(&store); + if let Err(e) = memory_view.write(ptr as u64, &bytes) { + tracing::error!(extension = %plugin_env.extension_id, op, "write answer: {e}"); + return 2; + } + if let Err(e) = out_ptr + .slice(&memory_view, 2) + .and_then(|slice| slice.write_slice(&[ptr, len])) + { + tracing::error!(extension = %plugin_env.extension_id, op, "write out pointer: {e:?}"); + return 2; + } + status +} + +/// Fill guest memory with entropy. +/// +/// Every extension gets this without a grant: random ids and the time are +/// not library data. +pub fn host_spacedrive_random(mut env: FunctionEnvMut, buf_ptr: WasmPtr, len: u32) { + use rand::RngCore; + + let (plugin_env, store) = env.data_and_store_mut(); + let view = plugin_env.memory.view(&store); + // Resolve the guest slice before allocating, so a bogus length is an + // error for the guest rather than a host allocation of its choosing. + let slice = match buf_ptr.slice(&view, len) { + Ok(slice) => slice, + Err(e) => { + tracing::error!(extension = %plugin_env.extension_id, "write entropy: {e:?}"); + return; + } + }; + let mut bytes = vec![0u8; len as usize]; + rand::thread_rng().fill_bytes(&mut bytes); + if let Err(e) = slice.write_slice(&bytes) { + tracing::error!(extension = %plugin_env.extension_id, "write entropy: {e:?}"); + } +} + +/// The host's clock, as milliseconds since the Unix epoch. +pub fn host_spacedrive_now_ms(_env: FunctionEnvMut) -> i64 { + chrono::Utc::now().timestamp_millis() +} + /// Check if job should be interrupted pub fn host_job_check_interrupt( mut env: FunctionEnvMut, @@ -495,3 +420,36 @@ pub fn host_register_job( } } } + +/// Declare a data model's facet, from `plugin_init`. +/// +/// The definition is the JSON the SDK's `#[model]` macro derived from the +/// struct. Returns 0 on success, 1 on error. +pub fn host_register_model( + mut env: FunctionEnvMut, + def_ptr: WasmPtr, + def_len: u32, +) -> i32 { + let (plugin_env, store) = env.data_and_store_mut(); + let memory_view = plugin_env.memory.view(&store); + let definition = match read_string_from_wasm(&memory_view, def_ptr, def_len) + .map_err(|e| e.to_string()) + .and_then(|json| serde_json::from_str(&json).map_err(|e| e.to_string())) + { + Ok(definition) => definition, + Err(e) => { + tracing::error!(extension = %plugin_env.extension_id, "Unreadable model definition: {e}"); + return 1; + } + }; + match plugin_env + .model_registry + .register(&plugin_env.extension_id, definition) + { + Ok(()) => 0, + Err(e) => { + tracing::error!(extension = %plugin_env.extension_id, "Failed to register model: {e}"); + 1 + } + } +} diff --git a/core/src/infra/extension/manager.rs b/core/src/infra/extension/manager.rs index c28247225..1d886e3a2 100644 --- a/core/src/infra/extension/manager.rs +++ b/core/src/infra/extension/manager.rs @@ -13,13 +13,9 @@ use thiserror::Error; use tokio::sync::RwLock; use wasmer::{imports, Function, FunctionEnv, Instance, Memory, Module, Store, TypedFunction}; -use crate::{context::CoreContext, infra::api::ApiDispatcher}; - -use super::host_functions::{ - self, host_spacedrive_call, host_spacedrive_log, JobBridge, PluginEnv, -}; +use super::host_functions::{self, host_spacedrive_log, JobBridge, PluginEnv}; use super::job_registry::ExtensionJobRegistry; -use super::permissions::ExtensionPermissions; +use super::model_registry::ExtensionModelRegistry; use super::types::{ExtensionManifest, LoadedPlugin}; #[derive(Error, Debug)] @@ -137,24 +133,18 @@ impl PluginRuntime { pub struct PluginManager { plugins: Arc>>, plugin_dir: PathBuf, - core_context: Arc, - api_dispatcher: Arc, job_registry: Arc, + model_registry: Arc, } impl PluginManager { /// Create new plugin manager - pub fn new( - plugin_dir: PathBuf, - core_context: Arc, - api_dispatcher: Arc, - ) -> Self { + pub fn new(plugin_dir: PathBuf) -> Self { Self { plugins: Arc::new(RwLock::new(HashMap::new())), plugin_dir, - core_context, - api_dispatcher, job_registry: Arc::new(ExtensionJobRegistry::new()), + model_registry: Arc::new(ExtensionModelRegistry::new()), } } @@ -163,6 +153,11 @@ impl PluginManager { self.job_registry.clone() } + /// The data models extensions declared + pub fn model_registry(&self) -> Arc { + self.model_registry.clone() + } + /// The directory extensions are installed under: `/extensions`. pub fn plugin_dir(&self) -> &PathBuf { &self.plugin_dir @@ -231,6 +226,9 @@ impl PluginManager { PluginError::ManifestLoadFailed(format!("Failed to parse manifest: {}", e)) })? }; + manifest + .validate() + .map_err(PluginError::ManifestLoadFailed)?; let plugin_id = manifest.id.clone(); tracing::info!( @@ -251,9 +249,6 @@ impl PluginManager { PluginError::CompilationFailed(format!("Failed to compile WASM: {}", e)) })?; - let permissions = - ExtensionPermissions::from_manifest(manifest.id.clone(), &manifest.permissions); - // Placeholder memory until the instance exists; host functions only run // after it is swapped for the real export. let temp_memory = Memory::new(&mut store, wasmer::MemoryType::new(1, None, false)) @@ -263,11 +258,10 @@ impl PluginManager { let plugin_env = PluginEnv { extension_id: manifest.id.clone(), - core_context: self.core_context.clone(), - api_dispatcher: self.api_dispatcher.clone(), - permissions, memory: temp_memory, + alloc: None, job_registry: self.job_registry.clone(), + model_registry: self.model_registry.clone(), current_job: None, }; @@ -275,11 +269,6 @@ impl PluginManager { let import_object = imports! { "spacedrive" => { - "spacedrive_call" => Function::new_typed_with_env( - &mut store, - &env, - host_spacedrive_call - ), "spacedrive_log" => Function::new_typed_with_env( &mut store, &env, @@ -320,6 +309,26 @@ impl PluginManager { &env, host_functions::host_register_job ), + "register_model" => Function::new_typed_with_env( + &mut store, + &env, + host_functions::host_register_model + ), + "spacedrive_random" => Function::new_typed_with_env( + &mut store, + &env, + host_functions::host_spacedrive_random + ), + "spacedrive_now_ms" => Function::new_typed_with_env( + &mut store, + &env, + host_functions::host_spacedrive_now_ms + ), + "spacedrive_op" => Function::new_typed_with_env( + &mut store, + &env, + host_functions::host_spacedrive_op + ), } }; @@ -331,11 +340,16 @@ impl PluginManager { PluginError::InstantiationFailed(format!("Plugin missing memory export: {}", e)) })?; env.as_mut(&mut store).memory = memory.clone(); + let alloc: TypedFunction = instance + .exports + .get_typed_function(&store, "wasm_alloc") + .map_err(|e| PluginError::BadExport(format!("wasm_alloc: {e}")))?; + env.as_mut(&mut store).alloc = Some(alloc); Ok(LoadedPlugin { id: plugin_id, dir_name: dir_name.to_string(), - manifest, + manifest: Arc::new(manifest), loaded_at: Utc::now(), poisoned: poisoned.clone(), runtime: Arc::new(Mutex::new(PluginRuntime { @@ -361,6 +375,7 @@ impl PluginManager { Ok(init_fn) => { if let Err(e) = init_fn.call(&mut runtime.store, &[]) { self.job_registry.unregister_extension_jobs(&plugin_id); + self.model_registry.unregister_extension(&plugin_id); return Err(PluginError::InstantiationFailed(format!( "plugin_init() failed: {}", e @@ -395,6 +410,7 @@ impl PluginManager { .remove(plugin_id) .ok_or_else(|| PluginError::NotFound(plugin_id.to_string()))?; self.job_registry.unregister_extension_jobs(plugin_id); + self.model_registry.unregister_extension(plugin_id); // A trapped guest cannot be trusted to run cleanup, and a guest still // running a job keeps its instance alive until that job returns; the @@ -427,6 +443,7 @@ impl PluginManager { .ok_or_else(|| PluginError::NotFound(plugin_id.to_string()))?; let fresh = self.instantiate(&dir_name).await?; let previous_jobs = self.job_registry.list_jobs_for_extension(plugin_id); + let previous_schema = self.model_registry.schema_for(plugin_id); let previous = self.unload_plugin(plugin_id).await?; if let Err(e) = self.activate(fresh).await { // The replacement's plugin_init failed; put the old plugin back so @@ -439,6 +456,15 @@ impl PluginManager { job.resumable, ); } + for (name, def) in previous_schema.map(|s| s.models).unwrap_or_default() { + let _ = self.model_registry.register( + plugin_id, + super::model_registry::ModelDefinition { + name, + fields: def.fields, + }, + ); + } self.plugins .write() .await @@ -454,7 +480,7 @@ impl PluginManager { } /// Get plugin manifest - pub async fn get_manifest(&self, plugin_id: &str) -> Option { + pub async fn get_manifest(&self, plugin_id: &str) -> Option> { self.plugins .read() .await @@ -462,6 +488,15 @@ impl PluginManager { .map(|p| p.manifest.clone()) } + /// The directory a loaded plugin was installed from. + pub async fn plugin_path(&self, plugin_id: &str) -> Option { + self.plugins + .read() + .await + .get(plugin_id) + .map(|p| self.plugin_dir.join(&p.dir_name)) + } + /// The runtime of a loaded plugin, for running one of its jobs. pub async fn runtime(&self, plugin_id: &str) -> Option>> { self.plugins diff --git a/core/src/infra/extension/mod.rs b/core/src/infra/extension/mod.rs index f9384482f..58cd8ecd0 100644 --- a/core/src/infra/extension/mod.rs +++ b/core/src/infra/extension/mod.rs @@ -6,16 +6,17 @@ //! //! ## Architecture //! -//! Extensions communicate with Spacedrive Core via a minimal host function API. -//! The key insight: we expose ONE generic `spacedrive_call()` function that routes -//! to the existing Wire operation registry, reusing all daemon RPC infrastructure. +//! Extensions communicate with Spacedrive Core through a small set of host +//! functions. Everything that returns data goes through one of them, +//! `spacedrive_op`, whose operations `ops` answers on the running job's async +//! side with the manifest's grants checked there. //! //! ## Components //! //! - `manager`: Plugin lifecycle management (load, unload, hot-reload) -//! - `host_functions`: WASM host functions (bridge to operation registry) -//! - `permissions`: Capability-based security model -//! - `types`: Shared types and manifest format +//! - `host_functions`: WASM host functions +//! - `ops`: the operations behind `spacedrive_op` +//! - `types`: the manifest, its permission grants, and the loaded plugin #[cfg(feature = "wasm")] mod host_functions; @@ -24,7 +25,9 @@ mod job_registry; #[cfg(feature = "wasm")] mod manager; #[cfg(feature = "wasm")] -mod permissions; +mod model_registry; +#[cfg(feature = "wasm")] +mod ops; #[cfg(feature = "wasm")] mod types; #[cfg(feature = "wasm")] @@ -35,8 +38,8 @@ pub use job_registry::{ExtensionJobRegistration, ExtensionJobRegistry}; #[cfg(feature = "wasm")] pub use manager::PluginManager; #[cfg(feature = "wasm")] -pub use permissions::{ExtensionPermissions, PermissionError}; +pub use model_registry::{ExtensionModelRegistry, ModelDefinition}; #[cfg(feature = "wasm")] -pub use types::{ExtensionManifest, PluginManifest}; +pub use types::{ExtensionManifest, ManifestPermissions, PluginManifest}; #[cfg(feature = "wasm")] pub use wasm_job::WasmJob; diff --git a/core/src/infra/extension/model_registry.rs b/core/src/infra/extension/model_registry.rs new file mode 100644 index 000000000..2284f272a --- /dev/null +++ b/core/src/infra/extension/model_registry.rs @@ -0,0 +1,128 @@ +//! The data models extensions declared, and the store that holds them. +//! +//! `plugin_init` registers each `#[model]` through `register_model`; the +//! registry turns an extension's models into one `DataTypeSchema`, which is +//! what opens the extension's store in a library. The store is an ordinary +//! `sd_store` database under `/extensions//`: the +//! same record and facet shape as a source store, owned by the extension, +//! and the thing the extension-kinds plan registers as an app-owned source +//! when that phase lands. + +use std::collections::HashMap; +use std::path::Path; +use std::sync::RwLock; + +use indexmap::IndexMap; +use sd_store::{DataTypeSchema, FieldType, ModelDef, SourceDb, SourceManager}; +use serde::Deserialize; + +/// A model as the SDK's `#[model]` macro declares it. +#[derive(Debug, Clone, Deserialize)] +pub struct ModelDefinition { + pub name: String, + pub fields: IndexMap, +} + +#[derive(Default)] +pub struct ExtensionModelRegistry { + models: RwLock>>, +} + +impl ExtensionModelRegistry { + pub fn new() -> Self { + Self::default() + } + + /// Accept a model a guest declared. + /// + /// The name becomes a table and each field a column, spliced into DDL + /// and into the facet reader's SQL, and the guest can hand `register_model` + /// any JSON it likes, so every identifier is checked here rather than + /// trusted from the macro. + pub fn register(&self, extension_id: &str, model: ModelDefinition) -> Result<(), String> { + let identifier = |s: &str| { + !s.is_empty() + && !s.starts_with(|c: char| c.is_ascii_digit()) + && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') + }; + if !identifier(&model.name) { + return Err(format!( + "model name {:?} is not a valid table name", + model.name + )); + } + if let Some(field) = model.fields.keys().find(|f| !identifier(f)) { + return Err(format!( + "model {} field {field:?} is not a valid column name", + model.name + )); + } + if model.fields.contains_key("record_uuid") { + return Err(format!( + "model {} field \"record_uuid\" is the facet table's key column", + model.name + )); + } + let mut models = self.models.write().unwrap_or_else(|p| p.into_inner()); + let list = models.entry(extension_id.to_string()).or_default(); + if list.iter().any(|m| m.name == model.name) { + return Err(format!("model {} is already registered", model.name)); + } + tracing::info!(extension = extension_id, model = %model.name, "Registered extension model"); + list.push(model); + Ok(()) + } + + pub fn unregister_extension(&self, extension_id: &str) { + self.models + .write() + .unwrap_or_else(|p| p.into_inner()) + .remove(extension_id); + } + + /// The schema of an extension's store, or `None` when it declared no + /// models and so needs no store. + pub fn schema_for(&self, extension_id: &str) -> Option { + let models = self.models.read().unwrap_or_else(|p| p.into_inner()); + let declared = models.get(extension_id).filter(|m| !m.is_empty())?; + let mut defs = IndexMap::new(); + for model in declared { + defs.insert( + model.name.clone(), + ModelDef { + fields: model.fields.clone(), + relations: Default::default(), + }, + ); + } + Some(DataTypeSchema { + data_type: sd_store::schema::DataTypeMeta { + id: extension_id.to_string(), + name: extension_id.to_string(), + icon: None, + }, + search: sd_store::schema::SearchContract { + primary_model: declared[0].name.clone(), + title: "_derived.title".to_string(), + preview: "_derived.none".to_string(), + subtitle: None, + search_fields: Vec::new(), + date_field: None, + }, + models: defs, + }) + } +} + +/// Open an extension's store in a library, creating it on first use and +/// widening its facets when the extension declared new fields. +pub async fn open_extension_store( + library_path: &Path, + extension_id: &str, + schema: &DataTypeSchema, +) -> anyhow::Result { + let manager = SourceManager::new(library_path.join("extensions")); + manager.create(extension_id, schema).await?; + let (db, _) = manager.open_with_migration(extension_id, schema).await?; + Ok(db) +} diff --git a/core/src/infra/extension/ops.rs b/core/src/infra/extension/ops.rs new file mode 100644 index 000000000..aa0ce875a --- /dev/null +++ b/core/src/infra/extension/ops.rs @@ -0,0 +1,633 @@ +//! The operations a guest asks the host for through `spacedrive_op`. +//! +//! Every operation that returns data to a guest lands here, named by a +//! string and carried as bytes. Requests are answered on the job's async +//! side, one at a time, because the guest blocks on each; so the state an +//! operation needs lives on [`JobOps`] for the job's duration and no +//! operation ever contends with another from the same job. +//! +//! Permission is checked here against the manifest, never in the guest. + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use sd_store::read::Start; +use sd_store::{FsEntry, SourceDb}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::domain::sidecar::{SidecarFormat, SidecarKind, SidecarVariant}; +use crate::infra::job::prelude::JobContext; +use crate::library::Library; +use crate::ops::indexing::store::SourceStore; + +use super::model_registry::{open_extension_store, ExtensionModelRegistry}; +use super::types::ExtensionManifest; + +/// An error a guest can act on. `code` is the stable part: the SDK maps it +/// onto its own error type, and `message` is for the log. +#[derive(Debug, Clone, Serialize)] +pub struct OpError { + pub code: &'static str, + pub message: String, +} + +impl OpError { + pub fn not_found() -> Self { + Self::new("not_found", "not found") + } + + pub fn permission_denied(message: impl Into) -> Self { + Self::new("permission_denied", message) + } + + pub fn invalid_input(message: impl Into) -> Self { + Self::new("invalid_input", message) + } + + pub fn not_available(message: impl Into) -> Self { + Self::new("not_available", message) + } + + pub fn failed(message: impl Into) -> Self { + Self::new("failed", message) + } + + fn new(code: &'static str, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } + + pub fn to_json(&self) -> String { + serde_json::to_string(self) + .unwrap_or_else(|_| format!(r#"{{"code":"{}","message":"unserializable"}}"#, self.code)) + } +} + +pub type OpResult = Result, OpError>; + +/// A task attempt the guest is inside, as `task.begin` declared it. +struct ActiveTask { + name: String, + attempt: u32, + started: Instant, + deadline: Option, +} + +/// The host side of one running extension job. +pub struct JobOps { + pub extension_id: String, + pub manifest: Arc, + pub library: Arc, + /// The directory the extension was installed from, for `config.json`. + plugin_path: std::path::PathBuf, + models: Arc, + /// The extension's store in this library, opened on the first model + /// operation and closed when the job ends. + store: Option, + task: Option, +} + +#[derive(Deserialize)] +struct ModelRef { + model: String, + uuid: Option, + content_uuid: Option, +} + +#[derive(Deserialize)] +struct ModelPut { + model: String, + uuid: Option, + content_uuid: Option, + data: serde_json::Value, +} + +#[derive(Deserialize)] +struct Inference { + model: String, + task: String, +} + +#[derive(Deserialize)] +struct ModelList { + model: String, + limit: Option, +} + +/// A record as the SDK's `Record` deserializes it. +#[derive(Serialize)] +struct RecordOut { + uuid: Uuid, + source_id: Uuid, + name: String, + kind: &'static str, + extension: Option, + relative_path: String, + size: Option, + modified_ms: Option, + content_uuid: Option, +} + +impl RecordOut { + fn new(source_id: Uuid, entry: FsEntry) -> Self { + Self { + uuid: entry.uuid, + source_id, + name: entry.name, + kind: entry.kind.as_str(), + extension: entry.extension.map(|e| e.to_lowercase()), + relative_path: entry.relative_path, + size: entry.size.and_then(|s| u64::try_from(s).ok()), + modified_ms: entry.mtime_ms, + content_uuid: entry.content_uuid, + } + } +} + +#[derive(Deserialize)] +struct RecordRef { + uuid: Uuid, +} + +#[derive(Deserialize)] +struct RecordQuery { + source: Option, + scope: Option, + extensions: Option>, + tag: Option, + limit: Option, +} + +/// The most records one query answers with. +const QUERY_CAP: usize = 10_000; + +#[derive(Deserialize)] +struct SidecarRef { + content_uuid: Uuid, + kind: String, +} + +#[derive(Deserialize)] +struct SidecarWrite { + content_uuid: Uuid, + kind: String, + data: serde_json::Value, +} + +#[derive(Deserialize)] +struct TaskBegin { + name: String, + attempt: u32, + retries: u32, + timeout_ms: u64, +} + +#[derive(Deserialize)] +struct TaskEnd { + name: String, + attempt: u32, + error: Option, +} + +impl JobOps { + pub fn new( + extension_id: String, + manifest: Arc, + library: Arc, + plugin_path: std::path::PathBuf, + models: Arc, + ) -> Self { + Self { + extension_id, + manifest, + library, + plugin_path, + models, + store: None, + task: None, + } + } + + /// Release what the job held open. + pub async fn finish(self) { + if let Some(store) = self.store { + store.pool().close().await; + } + } + + /// Answer one request. + /// + /// Once the active task's deadline has passed, every operation but the + /// one that ends the task fails with `timeout`, which is how a + /// single-threaded guest gets stopped mid-task: it cannot be preempted, + /// but it cannot make progress without the host either. + pub async fn handle(&mut self, op: &str, payload: &[u8], ctx: &JobContext<'_>) -> OpResult { + if op != "task.end" { + if let Some(task) = &self.task { + if task + .deadline + .is_some_and(|deadline| Instant::now() >= deadline) + { + return Err(OpError::new( + "timeout", + format!("task {} ran past its deadline", task.name), + )); + } + } + } + + match op { + "task.begin" => self.task_begin(parse(payload)?, ctx), + "task.end" => self.task_end(parse(payload)?, ctx), + "records.get" => self.record_get(parse(payload)?).await, + "records.read" => self.record_read(parse(payload)?).await, + "records.query" => self.record_query(parse(payload)?).await, + "sidecars.exists" => self.sidecar_exists(parse(payload)?).await, + "sidecars.read" => self.sidecar_read(parse(payload)?).await, + "sidecars.write" => self.sidecar_write(parse(payload)?).await, + "models.put" => self.model_put(parse(payload)?).await, + "models.get" => self.model_get(parse(payload)?).await, + "models.list" => self.model_list(parse(payload)?).await, + "ai.infer" => self.ai_infer(payload), + "config.get" => self.config_get().await, + _ => Err(OpError::new( + "unknown_op", + format!("unknown operation {op}"), + )), + } + } + + fn task_begin(&mut self, begin: TaskBegin, ctx: &JobContext<'_>) -> OpResult { + if let Some(active) = &self.task { + return Err(OpError::invalid_input(format!( + "task {} started while task {} is running", + begin.name, active.name + ))); + } + ctx.log(format!( + "task {} attempt {}/{} started", + begin.name, + begin.attempt + 1, + begin.retries + 1 + )); + let started = Instant::now(); + self.task = Some(ActiveTask { + name: begin.name, + attempt: begin.attempt, + started, + deadline: (begin.timeout_ms > 0) + .then(|| started + Duration::from_millis(begin.timeout_ms)), + }); + json(&serde_json::json!({ "id": begin.attempt })) + } + + fn task_end(&mut self, end: TaskEnd, ctx: &JobContext<'_>) -> OpResult { + let Some(task) = self.task.take() else { + return Err(OpError::invalid_input(format!( + "task {} ended without starting", + end.name + ))); + }; + let elapsed = task.started.elapsed().as_millis(); + match end.error { + None => ctx.log(format!( + "task {} attempt {} finished in {elapsed} ms", + task.name, + task.attempt + 1 + )), + Some(error) => ctx.log(format!( + "task {} attempt {} failed after {elapsed} ms: {error}", + task.name, + task.attempt + 1 + )), + } + json(&serde_json::Value::Null) + } +} + +impl JobOps { + /// The stores of this library's sources, detached ones included: their + /// records are still true even when their bytes are out of reach. + async fn stores(&self) -> Vec> { + let index = self.library.core_context().volume_index(); + let mut stores = Vec::new(); + for source in index.sources_of(self.library.id()) { + if let Some(store) = index.store_for(&source.root).await { + stores.push(store); + } + } + stores + } + + /// The store holding a record, with the entry. + async fn locate(&self, uuid: Uuid) -> Result<(Arc, FsEntry), OpError> { + for store in self.stores().await { + match store.db().entry_by_uuid(uuid).await { + Ok(Some(entry)) => return Ok((store, entry)), + Ok(None) => {} + Err(error) => { + tracing::warn!(source = %store.id(), %error, "record lookup failed") + } + } + } + Err(OpError::not_found()) + } + + fn check_read(&self, entry: &FsEntry) -> Result<(), OpError> { + let extension = entry.extension.as_deref().map(str::to_lowercase); + if self + .manifest + .permissions + .can_read_record(extension.as_deref()) + { + return Ok(()); + } + Err(OpError::permission_denied(format!( + "{} may not read {}", + self.extension_id, entry.relative_path + ))) + } + + async fn record_get(&self, record: RecordRef) -> OpResult { + let (store, entry) = self.locate(record.uuid).await?; + self.check_read(&entry)?; + json(&RecordOut::new(store.id(), entry)) + } + + /// A record's bytes, through the path its source resolves. A detached + /// source answers not found: the record is still known, the bytes are + /// not here. + async fn record_read(&self, record: RecordRef) -> OpResult { + let (store, entry) = self.locate(record.uuid).await?; + self.check_read(&entry)?; + let path = store.root().join(&entry.relative_path); + tokio::fs::read(&path).await.map_err(|e| match e.kind() { + std::io::ErrorKind::NotFound => OpError::not_found(), + _ => OpError::failed(format!("read {}: {e}", path.display())), + }) + } + + /// Files across the library's stores, narrowed by the query and by the + /// grant: a glob grant restricts the extensions a query without its own + /// list gets back. + async fn record_query(&self, query: RecordQuery) -> OpResult { + if query.tag.is_some() { + return Err(OpError::invalid_input( + "record queries cannot filter by tag yet", + )); + } + let permissions = &self.manifest.permissions; + if permissions.read_records.is_none() { + return Err(OpError::permission_denied(format!( + "{} has no read_records grant", + self.extension_id + ))); + } + let extensions: Option> = match query.extensions { + Some(asked) => Some( + asked + .into_iter() + .filter(|e| permissions.can_read_record(Some(e))) + .collect(), + ), + None => permissions.granted_extensions(), + }; + let limit = query.limit.unwrap_or(QUERY_CAP).min(QUERY_CAP); + let scope = query.scope.unwrap_or_default(); + + let mut records = Vec::new(); + for store in self.stores().await { + if query.source.is_some_and(|source| source != store.id()) { + continue; + } + if records.len() >= limit { + break; + } + let entries = sd_store::read::files_beneath( + store.db().pool(), + &scope, + Start::First, + extensions.as_deref(), + false, + limit - records.len(), + ) + .await + .map_err(|e| OpError::failed(e.to_string()))?; + records.extend( + entries + .into_iter() + .map(|entry| RecordOut::new(store.id(), entry)), + ); + } + json(&records) + } +} + +impl JobOps { + /// Where a sidecar of one of this extension's kinds lives: the library's + /// content-addressed sidecar tree, under the extension's namespace. The + /// SDK's sidecars are JSON documents with one variant. + fn sidecar_path( + &self, + content_uuid: Uuid, + kind: &str, + write: bool, + ) -> Result { + let permissions = &self.manifest.permissions; + let granted = if write { + permissions.can_write_sidecar(kind) + } else { + permissions.can_read_sidecar(kind) + }; + if !granted { + return Err(OpError::permission_denied(format!( + "{} may not {} sidecar kind {kind}", + self.extension_id, + if write { "write" } else { "read" } + ))); + } + let kind = + SidecarKind::extension(&self.extension_id, kind).map_err(OpError::invalid_input)?; + Ok(self + .library + .path() + .join("sidecars") + .join(sd_sidecar_path::relative_path( + &content_uuid, + &kind.directory(), + SidecarVariant::new("default").as_str(), + SidecarFormat::Json.extension(), + ))) + } + + async fn sidecar_exists(&self, sidecar: SidecarRef) -> OpResult { + let path = self.sidecar_path(sidecar.content_uuid, &sidecar.kind, false)?; + json(&tokio::fs::try_exists(&path).await.unwrap_or(false)) + } + + async fn sidecar_read(&self, sidecar: SidecarRef) -> OpResult { + let path = self.sidecar_path(sidecar.content_uuid, &sidecar.kind, false)?; + tokio::fs::read(&path).await.map_err(|e| match e.kind() { + std::io::ErrorKind::NotFound => OpError::not_found(), + _ => OpError::failed(format!("read {}: {e}", path.display())), + }) + } + + /// Written whole and renamed into place, so a reader never sees a + /// partial document and a crash leaves either the old one or the new. + async fn sidecar_write(&self, sidecar: SidecarWrite) -> OpResult { + let path = self.sidecar_path(sidecar.content_uuid, &sidecar.kind, true)?; + let bytes = + serde_json::to_vec(&sidecar.data).map_err(|e| OpError::failed(e.to_string()))?; + let io = |e: std::io::Error| OpError::failed(format!("write {}: {e}", path.display())); + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await.map_err(io)?; + } + let tmp = path.with_extension("json.tmp"); + tokio::fs::write(&tmp, &bytes).await.map_err(io)?; + tokio::fs::rename(&tmp, &path).await.map_err(io)?; + json(&serde_json::Value::Null) + } +} + +impl JobOps { + /// The extension's store in this library, opened on first use. + async fn store(&mut self) -> Result<&SourceDb, OpError> { + if self.store.is_none() { + let schema = self.models.schema_for(&self.extension_id).ok_or_else(|| { + OpError::invalid_input(format!( + "{} declares no models; list them in #[extension(models = [...])]", + self.extension_id + )) + })?; + let db = open_extension_store(self.library.path(), &self.extension_id, &schema) + .await + .map_err(|e| OpError::failed(format!("open extension store: {e}")))?; + self.store = Some(db); + } + Ok(self.store.as_ref().expect("opened above")) + } + + /// A standalone model is keyed by its own uuid, a content-scoped one by + /// the content it describes. Both are the record's external id. + fn model_key(uuid: Option, content_uuid: Option) -> Result { + match (uuid, content_uuid) { + (Some(uuid), None) => Ok(uuid.to_string()), + (None, Some(content)) => Ok(format!("content:{content}")), + _ => Err(OpError::invalid_input( + "a model is keyed by exactly one of uuid or content_uuid", + )), + } + } + + async fn model_put(&mut self, put: ModelPut) -> OpResult { + let key = Self::model_key(put.uuid, put.content_uuid)?; + let store = self.store().await?; + store + .upsert(&put.model, &key, &put.data) + .await + .map_err(|e| OpError::failed(e.to_string()))?; + json(&serde_json::Value::Null) + } + + async fn model_get(&mut self, model: ModelRef) -> OpResult { + let key = Self::model_key(model.uuid, model.content_uuid)?; + let store = self.store().await?; + let rows = store + .facet_rows(&model.model, Some(&key), 1) + .await + .map_err(|e| OpError::failed(e.to_string()))?; + match rows.into_iter().next() { + Some(row) => json(&row), + None => Err(OpError::not_found()), + } + } + + async fn model_list(&mut self, list: ModelList) -> OpResult { + let store = self.store().await?; + let rows = store + .facet_rows( + &list.model, + None, + list.limit.unwrap_or(QUERY_CAP).min(QUERY_CAP), + ) + .await + .map_err(|e| OpError::failed(e.to_string()))?; + json(&rows) + } +} + +impl JobOps { + /// Inference, when the core has a provider for the model's category. + /// + /// It has none for the categories photos asks for (face detection, scene + /// classification, embeddings, language models), so a granted request + /// is answered `not_available` with the category named, and the job + /// decides what to do without it. The grant is still checked first: a + /// request for an undeclared category is refused, not deferred. + fn ai_infer(&self, payload: &[u8]) -> OpResult { + let (header, _input) = split_frame(payload)?; + let inference: Inference = parse(header)?; + let category = inference + .model + .split_once(':') + .map(|(category, _)| category) + .unwrap_or(&inference.model); + let granted = self + .manifest + .permissions + .use_models + .iter() + .any(|m| m.category == category); + if !granted { + return Err(OpError::permission_denied(format!( + "{} has no use_models grant for {category}", + self.extension_id + ))); + } + Err(OpError::not_available(format!( + "no {category} provider is installed for {} ({})", + inference.model, inference.task + ))) + } + + /// `config.json` beside the manifest, or `{}`. + async fn config_get(&self) -> OpResult { + match tokio::fs::read(self.plugin_path.join("config.json")).await { + Ok(bytes) => { + let value: serde_json::Value = serde_json::from_slice(&bytes) + .map_err(|e| OpError::failed(format!("config.json: {e}")))?; + json(&value) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => json(&serde_json::json!({})), + Err(e) => Err(OpError::failed(format!("config.json: {e}"))), + } + } +} + +/// A framed payload: a little-endian u32 header length, the JSON header, +/// then raw bytes. +fn split_frame(payload: &[u8]) -> Result<(&[u8], &[u8]), OpError> { + let malformed = || OpError::invalid_input("malformed framed payload"); + let len = payload + .get(..4) + .and_then(|b| <[u8; 4]>::try_from(b).ok()) + .map(u32::from_le_bytes) + .ok_or_else(malformed)? as usize; + let rest = &payload[4..]; + if rest.len() < len { + return Err(malformed()); + } + Ok(rest.split_at(len)) +} + +fn parse Deserialize<'de>>(payload: &[u8]) -> Result { + serde_json::from_slice(payload).map_err(|e| OpError::invalid_input(e.to_string())) +} + +fn json(value: &T) -> OpResult { + serde_json::to_vec(value).map_err(|e| OpError::failed(e.to_string())) +} diff --git a/core/src/infra/extension/permissions.rs b/core/src/infra/extension/permissions.rs deleted file mode 100644 index 5f2f65493..000000000 --- a/core/src/infra/extension/permissions.rs +++ /dev/null @@ -1,203 +0,0 @@ -//! Permission and security system for WASM extensions - -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::sync::Arc; -use std::time::{Duration, Instant}; -use thiserror::Error; -use tokio::sync::RwLock; -use uuid::Uuid; - -use super::types::ManifestPermissions; - -#[derive(Error, Debug)] -pub enum PermissionError { - #[error("Extension not authorized: {0}")] - Unauthorized(String), - - #[error("Method not allowed: {0}")] - MethodNotAllowed(String), - - #[error("Library access denied: {0}")] - LibraryAccessDenied(String), - - #[error("Rate limit exceeded: {0}")] - RateLimitExceeded(String), -} - -/// Runtime permission checker with rate limiting -#[derive(Clone)] -pub struct ExtensionPermissions { - extension_id: String, - - /// Methods this extension can call (prefix matching) - allowed_methods: Vec, - - /// Libraries this extension can access ("*" or specific UUIDs) - allowed_libraries: Vec, - - /// Rate limiting state - rate_limiter: Arc>, - - /// Resource limits - pub max_memory_mb: usize, - pub max_concurrent_jobs: usize, -} - -struct RateLimiter { - requests_per_minute: usize, - recent_requests: Vec, -} - -impl ExtensionPermissions { - /// Create permissions from manifest - pub fn from_manifest(extension_id: String, manifest_perms: &ManifestPermissions) -> Self { - Self { - extension_id, - allowed_methods: manifest_perms.methods.clone(), - allowed_libraries: manifest_perms.libraries.clone(), - rate_limiter: Arc::new(RwLock::new(RateLimiter { - requests_per_minute: manifest_perms.rate_limits.requests_per_minute, - recent_requests: Vec::new(), - })), - max_memory_mb: manifest_perms.max_memory_mb, - max_concurrent_jobs: manifest_perms.rate_limits.concurrent_jobs, - } - } - - /// Check if extension can call this Wire method - pub fn can_call(&self, method: &str) -> bool { - // Check if any allowed prefix matches - self.allowed_methods - .iter() - .any(|prefix| method.starts_with(prefix)) - } - - /// Check if extension can access this library - pub fn can_access_library(&self, library_id: Uuid) -> bool { - // "*" means all libraries - if self.allowed_libraries.iter().any(|id| id == "*") { - return true; - } - - // Check if specific library UUID is allowed - self.allowed_libraries - .iter() - .any(|id| id.parse::().ok() == Some(library_id)) - } - - /// Check rate limit and record request - pub async fn check_rate_limit(&self) -> Result<(), PermissionError> { - let mut limiter = self.rate_limiter.write().await; - - let now = Instant::now(); - let one_minute_ago = now - Duration::from_secs(60); - - // Remove requests older than 1 minute - limiter - .recent_requests - .retain(|×tamp| timestamp > one_minute_ago); - - // Check if under limit - if limiter.recent_requests.len() >= limiter.requests_per_minute { - return Err(PermissionError::RateLimitExceeded(format!( - "Extension {} exceeded {} requests/minute", - self.extension_id, limiter.requests_per_minute - ))); - } - - // Record this request - limiter.recent_requests.push(now); - - Ok(()) - } - - /// Full permission check for a Wire operation - pub async fn authorize( - &self, - method: &str, - library_id: Option, - ) -> Result<(), PermissionError> { - // Check method permission - if !self.can_call(method) { - return Err(PermissionError::MethodNotAllowed(format!( - "Extension {} not allowed to call {}", - self.extension_id, method - ))); - } - - // Check library access if specified - if let Some(lib_id) = library_id { - if !self.can_access_library(lib_id) { - return Err(PermissionError::LibraryAccessDenied(format!( - "Extension {} cannot access library {}", - self.extension_id, lib_id - ))); - } - } - - // Check rate limit - self.check_rate_limit().await?; - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_method_permission() { - let perms = ExtensionPermissions { - extension_id: "test".to_string(), - allowed_methods: vec!["vdfs.".to_string(), "ai.ocr".to_string()], - allowed_libraries: vec!["*".to_string()], - rate_limiter: Arc::new(RwLock::new(RateLimiter { - requests_per_minute: 1000, - recent_requests: Vec::new(), - })), - max_memory_mb: 512, - max_concurrent_jobs: 10, - }; - - assert!(perms.can_call("vdfs.create_entry")); - assert!(perms.can_call("vdfs.write_sidecar")); - assert!(perms.can_call("ai.ocr")); - assert!(!perms.can_call("credentials.delete")); // Not allowed - } - - #[test] - fn test_library_permission() { - let lib_id = Uuid::new_v4(); - - let perms_all = ExtensionPermissions { - extension_id: "test".to_string(), - allowed_methods: vec![], - allowed_libraries: vec!["*".to_string()], - rate_limiter: Arc::new(RwLock::new(RateLimiter { - requests_per_minute: 1000, - recent_requests: Vec::new(), - })), - max_memory_mb: 512, - max_concurrent_jobs: 10, - }; - - assert!(perms_all.can_access_library(lib_id)); - - let perms_specific = ExtensionPermissions { - extension_id: "test".to_string(), - allowed_methods: vec![], - allowed_libraries: vec![lib_id.to_string()], - rate_limiter: Arc::new(RwLock::new(RateLimiter { - requests_per_minute: 1000, - recent_requests: Vec::new(), - })), - max_memory_mb: 512, - max_concurrent_jobs: 10, - }; - - assert!(perms_specific.can_access_library(lib_id)); - assert!(!perms_specific.can_access_library(Uuid::new_v4())); - } -} diff --git a/core/src/infra/extension/types.rs b/core/src/infra/extension/types.rs index f4210f05e..a41ade8d3 100644 --- a/core/src/infra/extension/types.rs +++ b/core/src/infra/extension/types.rs @@ -1,4 +1,4 @@ -//! Types for the WASM plugin system +//! The extension manifest and the loaded-plugin record. use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -6,8 +6,12 @@ use std::path::PathBuf; use std::sync::atomic::AtomicBool; use std::sync::{Arc, Mutex}; -/// Extension manifest (manifest.json) +/// Extension manifest (`manifest.json`). +/// +/// Unknown fields are refused rather than ignored, so a grant the host does +/// not understand fails at load instead of silently granting nothing. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ExtensionManifest { pub id: String, pub name: String, @@ -18,86 +22,172 @@ pub struct ExtensionManifest { pub author: String, #[serde(default)] pub homepage: Option, + #[serde(default)] + pub min_core_version: Option, + #[serde(default)] + pub required_features: Vec, /// WASM file path (relative to manifest) pub wasm_file: PathBuf, - /// Permissions required by this extension + /// What the extension may read and write. A manifest that declares none + /// can still register and run jobs; every host operation that touches + /// library data checks here first. #[serde(default)] pub permissions: ManifestPermissions, + /// AI models the extension wants installed. Declaration only: no host + /// side downloads or serves them yet. + #[serde(default)] + pub models: Vec, + /// Configuration schema (JSON Schema) #[serde(default)] pub config_schema: Option, } -/// Permission declaration in manifest -#[derive(Debug, Clone, Serialize, Deserialize)] +/// Permission declaration in manifest. +/// +/// Sidecar kinds are per extension: `write_sidecars: ["faces"]` grants +/// `faces` under this extension's own namespace, never another's. An +/// extension may always read back the kinds it writes. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ManifestPermissions { - /// Wire methods this extension can call (prefix matching) - /// e.g., ["vdfs.", "ai.ocr", "credentials.store"]. A manifest that - /// declares none can still register and run jobs. + /// Read records (and their bytes) from the library's source stores. #[serde(default)] - pub methods: Vec, - - /// Libraries this extension can access - /// "*" = all libraries, or specific UUIDs - #[serde(default = "default_all_libraries")] - pub libraries: Vec, - - /// Rate limits + pub read_records: Option, #[serde(default)] - pub rate_limits: RateLimits, - - /// Network access (for HTTP proxy) + pub read_sidecars: Vec, #[serde(default)] - pub network_access: Vec, - - /// Resource limits + pub write_sidecars: Vec, #[serde(default)] - pub max_memory_mb: usize, + pub write_tags: bool, + /// Namespaces this extension may write custom fields under. + #[serde(default)] + pub write_custom_fields: Vec, + #[serde(default)] + pub dispatch_jobs: bool, + #[serde(default)] + pub use_models: Vec, } -fn default_all_libraries() -> Vec { - vec!["*".to_string()] +impl ExtensionManifest { + /// What the host relies on beyond the shape serde enforces. + /// + /// The id becomes a directory name under the library (sidecars and the + /// model store), so it has to be one path-safe segment, and a glob the + /// grant parser cannot read would otherwise fail open as "every record". + pub fn validate(&self) -> Result<(), String> { + let id_safe = !self.id.is_empty() + && self.id != "." + && self.id != ".." + && self + .id + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')); + if !id_safe { + return Err(format!( + "extension id {:?} must be one segment of [A-Za-z0-9._-]", + self.id + )); + } + if let Some(glob) = self + .permissions + .read_records + .as_ref() + .and_then(|r| r.glob.as_deref()) + { + if glob_extensions(glob).is_none() { + return Err(format!( + "read_records glob {glob:?} is not understood; only a trailing `*.ext` or `*.{{a,b}}` is honored" + )); + } + } + Ok(()) + } +} + +impl ManifestPermissions { + pub fn can_read_sidecar(&self, kind: &str) -> bool { + self.read_sidecars.iter().any(|k| k == kind) || self.can_write_sidecar(kind) + } + + pub fn can_write_sidecar(&self, kind: &str) -> bool { + self.write_sidecars.iter().any(|k| k == kind) + } + + /// The extensions a glob grant names, lowercase; `None` when the grant is + /// unrestricted or absent. A glob the parser cannot read names nothing, + /// so a query under it answers no records rather than every record. + pub fn granted_extensions(&self) -> Option> { + self.read_records + .as_ref() + .and_then(|r| r.glob.as_deref()) + .map(|glob| glob_extensions(glob).unwrap_or_default()) + } + + /// Whether a record with this extension (lowercase, no dot) is readable. + pub fn can_read_record(&self, extension: Option<&str>) -> bool { + match &self.read_records { + None => false, + Some(ReadRecords { glob: None }) => true, + Some(ReadRecords { glob: Some(glob) }) => { + let Some(extension) = extension else { + return false; + }; + glob_extensions(glob) + .map(|allowed| allowed.iter().any(|e| e == extension)) + .unwrap_or(false) + } + } + } +} + +/// The extensions named by a glob of the form `**/*.{jpg,png}` or `*.jpg`. +/// +/// Only the extension list is honored today; the directory part of the glob +/// is accepted and ignored, since records are addressed by uuid rather than +/// by path when an extension asks for them. +fn glob_extensions(glob: &str) -> Option> { + let tail = glob.rsplit('/').next()?; + let tail = tail.strip_prefix("*.")?; + let list = tail + .strip_prefix('{') + .and_then(|t| t.strip_suffix('}')) + .unwrap_or(tail); + Some( + list.split(',') + .map(|e| e.trim().to_lowercase()) + .filter(|e| !e.is_empty()) + .collect(), + ) +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ReadRecords { + /// A glob over file names; `None` grants every record. + #[serde(default)] + pub glob: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RateLimits { - #[serde(default = "default_requests_per_minute")] - pub requests_per_minute: usize, - - #[serde(default = "default_concurrent_jobs")] - pub concurrent_jobs: usize, +#[serde(deny_unknown_fields)] +pub struct UseModel { + pub category: String, + #[serde(default)] + pub preference: Option, } -fn default_requests_per_minute() -> usize { - 1000 -} - -fn default_concurrent_jobs() -> usize { - 10 -} - -impl Default for RateLimits { - fn default() -> Self { - Self { - requests_per_minute: 1000, - concurrent_jobs: 10, - } - } -} - -impl Default for ManifestPermissions { - fn default() -> Self { - Self { - methods: vec![], - libraries: vec!["*".to_string()], - rate_limits: RateLimits::default(), - network_access: vec![], - max_memory_mb: 512, - } - } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManifestModel { + pub name: String, + pub category: String, + pub source: serde_json::Value, + #[serde(default)] + pub description: String, } /// Loaded plugin instance @@ -106,7 +196,7 @@ pub struct LoadedPlugin { /// Directory under the plugin directory it was loaded from, which the /// manifest id need not match. pub dir_name: String, - pub manifest: ExtensionManifest, + pub manifest: Arc, pub loaded_at: DateTime, /// Set by the runtime when a guest call trapped; see `PluginRuntime`. pub poisoned: Arc, @@ -115,3 +205,70 @@ pub struct LoadedPlugin { /// Alias for consistency with other code pub type PluginManifest = ExtensionManifest; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn photos_manifest_permissions_parse_strictly() { + let manifest: ExtensionManifest = + serde_json::from_str(include_str!("../../../../extensions/photos/manifest.json")) + .unwrap(); + let perms = &manifest.permissions; + assert!(perms.can_read_record(Some("jpg"))); + assert!(!perms.can_read_record(Some("txt"))); + assert!(perms.can_write_sidecar("faces")); + assert!(perms.can_read_sidecar("faces")); + assert!(perms.can_read_sidecar("exif")); + assert!(!perms.can_write_sidecar("exif")); + assert_eq!(manifest.models.len(), 2); + } + + #[test] + fn an_unknown_permission_is_refused() { + let err = + serde_json::from_str::(r#"{"methods": ["query:"]}"#).unwrap_err(); + assert!(err.to_string().contains("unknown field")); + } + + #[test] + fn a_glob_the_parser_cannot_read_grants_nothing() { + let perms = ManifestPermissions { + read_records: Some(ReadRecords { + glob: Some("**/Photos/**".into()), + }), + ..Default::default() + }; + assert_eq!(perms.granted_extensions(), Some(Vec::new())); + assert!(!perms.can_read_record(Some("jpg"))); + } + + #[test] + fn the_loader_refuses_a_path_like_id_and_an_unreadable_glob() { + let mut manifest: ExtensionManifest = serde_json::from_str( + r#"{"id":"ok.ext","name":"x","version":"1","wasm_file":"x.wasm"}"#, + ) + .unwrap(); + assert!(manifest.validate().is_ok()); + manifest.id = "../escape".into(); + assert!(manifest.validate().is_err()); + manifest.id = "ok".into(); + manifest.permissions.read_records = Some(ReadRecords { + glob: Some("*".into()), + }); + assert!(manifest.validate().is_err()); + } + + #[test] + fn a_glob_without_a_list_names_one_extension() { + let perms = ManifestPermissions { + read_records: Some(ReadRecords { + glob: Some("*.PDF".into()), + }), + ..Default::default() + }; + assert!(perms.can_read_record(Some("pdf"))); + assert!(!perms.can_read_record(None)); + } +} diff --git a/core/src/infra/extension/wasm_job.rs b/core/src/infra/extension/wasm_job.rs index 89974d711..799249dbd 100644 --- a/core/src/infra/extension/wasm_job.rs +++ b/core/src/infra/extension/wasm_job.rs @@ -15,6 +15,7 @@ use tokio::sync::mpsc; use crate::infra::job::{error::JobError, prelude::*}; use super::host_functions::{JobBridge, JobEvent}; +use super::ops::JobOps; /// Generic job for executing WASM extension jobs #[derive(Debug, Serialize, Deserialize, Job)] @@ -50,8 +51,11 @@ impl crate::infra::job::traits::DynJob for WasmJob { } impl WasmJob { - async fn handle(&mut self, event: JobEvent, ctx: &JobContext<'_>) { + async fn handle(&mut self, event: JobEvent, ctx: &JobContext<'_>, ops: &mut JobOps) { match event { + JobEvent::Op { op, payload, reply } => { + let _ = reply.send(ops.handle(&op, &payload, ctx).await); + } JobEvent::Progress { fraction, message } => { ctx.progress(Progress::Generic(GenericProgress::new( fraction, @@ -101,14 +105,31 @@ impl JobHandler for WasmJob { .await .map_err(|e| JobError::ExecutionFailed(e.to_string()))?; } - let runtime = plugin_manager - .read() - .await - .runtime(&self.extension_id) - .await - .ok_or_else(|| { + let (runtime, manifest, plugin_path, models) = { + let pm = plugin_manager.read().await; + let not_loaded = || { JobError::ExecutionFailed(format!("Extension '{}' not loaded", self.extension_id)) - })?; + }; + ( + pm.runtime(&self.extension_id) + .await + .ok_or_else(not_loaded)?, + pm.get_manifest(&self.extension_id) + .await + .ok_or_else(not_loaded)?, + pm.plugin_path(&self.extension_id) + .await + .ok_or_else(not_loaded)?, + pm.model_registry(), + ) + }; + let mut ops = JobOps::new( + self.extension_id.clone(), + manifest, + ctx.library_arc(), + plugin_path, + models, + ); // A checkpoint outlives a kill; the job row only outlives a pause. if let Some(saved) = ctx.load_state::().await? { @@ -153,7 +174,7 @@ impl JobHandler for WasmJob { result = &mut guest => break result, event = events_rx.recv() => { if let Some(event) = event { - self.handle(event, &ctx).await; + self.handle(event, &ctx, &mut ops).await; } } _ = interrupt_poll.tick() => { @@ -164,8 +185,9 @@ impl JobHandler for WasmJob { } }; while let Ok(event) = events_rx.try_recv() { - self.handle(event, &ctx).await; + self.handle(event, &ctx, &mut ops).await; } + ops.finish().await; match exit { Ok(Ok(0)) => Ok(JobOutput::Success), diff --git a/core/src/lib.rs b/core/src/lib.rs index 0e52ad81d..8f2185fac 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -186,11 +186,7 @@ impl Core { warn!("Could not create extensions directory: {e}"); } - let mut pm = crate::infra::extension::PluginManager::new( - plugin_dir, - context.clone(), - Arc::new(api_dispatcher.clone()), - ); + let mut pm = crate::infra::extension::PluginManager::new(plugin_dir); let loaded = pm.load_all().await; info!("Loaded {} extension(s): {:?}", loaded.len(), loaded); diff --git a/core/src/ops/extensions/list.rs b/core/src/ops/extensions/list.rs index 568ef8826..b611cbaa2 100644 --- a/core/src/ops/extensions/list.rs +++ b/core/src/ops/extensions/list.rs @@ -82,8 +82,8 @@ impl CoreQuery for ListExtensionsQuery { jobs.sort_by(|a, b| a.name.cmp(&b.name)); extensions.push(ExtensionInfo { id, - name: manifest.name, - version: manifest.version, + name: manifest.name.clone(), + version: manifest.version.clone(), jobs, }); } diff --git a/core/tests/wasm_extension_test.rs b/core/tests/wasm_extension_test.rs index 41ad84dc9..22b1d008d 100644 --- a/core/tests/wasm_extension_test.rs +++ b/core/tests/wasm_extension_test.rs @@ -19,7 +19,10 @@ use sd_core::{ action::LibraryAction, job::{database::checkpoint, types::JobStatus}, }, - ops::extensions::{RunExtensionJobAction, RunExtensionJobInput}, + ops::{ + extensions::{RunExtensionJobAction, RunExtensionJobInput}, + sources::track::{TrackSourceAction, TrackSourceInput}, + }, Core, }; use sea_orm::EntityTrait; @@ -35,18 +38,137 @@ struct CounterState { processed: Vec, } -fn install_test_extension(data_dir: &Path) { +fn install_extension(data_dir: &Path, dir: &str, wasm: &str) { let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .parent() .unwrap() - .join("extensions/test-extension"); - let target = data_dir.join("extensions/test-extension"); + .join("extensions") + .join(dir); + let target = data_dir.join("extensions").join(dir); std::fs::create_dir_all(&target).unwrap(); - for file in ["manifest.json", "test_extension.wasm"] { + for file in ["manifest.json", wasm] { std::fs::copy(source.join(file), target.join(file)).unwrap(); } } +fn install_test_extension(data_dir: &Path) { + install_extension(data_dir, "test-extension", "test_extension.wasm"); +} + +/// Start an extension job and wait for it to end, whichever way. +async fn run_to_end( + core: &Core, + library: &Arc, + job: &str, + state: serde_json::Value, +) -> sd_core::infra::job::JobInfo { + let started = RunExtensionJobAction::from_input(RunExtensionJobInput { + job: job.into(), + state: Some(state), + }) + .unwrap() + .execute(library.clone(), core.context.clone()) + .await + .unwrap(); + let deadline = tokio::time::Instant::now() + Duration::from_secs(60); + loop { + let info = library + .jobs() + .get_job_info(started.job_id) + .await + .unwrap() + .unwrap(); + if matches!( + info.status, + JobStatus::Completed | JobStatus::Failed | JobStatus::Cancelled + ) { + return info; + } + assert!( + tokio::time::Instant::now() < deadline, + "job {job} never ended" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } +} + +/// A library with one tracked source holding a dozen JPEGs (distinct bytes, +/// JPEG magic first) and two text files, hashed through, so every record +/// has a content identity. +async fn fixture_library( + core: &Core, + root: &Path, +) -> (Arc, Vec) { + let library = core + .libraries + .create_library("Photos", None, core.context.clone()) + .await + .unwrap(); + let source_dir = root.join("photos"); + std::fs::create_dir_all(source_dir.join("trip")).unwrap(); + for i in 0..12u8 { + let mut bytes = vec![0xFF, 0xD8, 0xFF, 0xE0, i]; + bytes.extend(std::iter::repeat_n(i, 1000 + i as usize * 7)); + let dir = if i % 2 == 0 { "" } else { "trip/" }; + std::fs::write(source_dir.join(format!("{dir}IMG_{i:04}.JPG")), bytes).unwrap(); + } + std::fs::write(source_dir.join("notes.txt"), b"not a photo").unwrap(); + std::fs::write(source_dir.join("trip/itinerary.txt"), b"day one").unwrap(); + + let tracked = TrackSourceAction::from_input(TrackSourceInput { + path: source_dir, + name: None, + unfiltered: false, + }) + .unwrap() + .execute(library.clone(), core.context.clone()) + .await + .unwrap(); + let store = core + .context + .volume_index() + .store_for(&tracked.root) + .await + .expect("the tracked source has a store"); + let deadline = tokio::time::Instant::now() + Duration::from_secs(60); + loop { + let contents = store.counts().await.map_or(0, |counts| counts.contents); + if contents == 14 { + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "{contents} of 14 files were hashed" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + let mut files = sd_store::read::files_beneath( + store.db().pool(), + "", + sd_store::read::Start::First, + None, + false, + 100, + ) + .await + .unwrap(); + files.sort_by(|a, b| a.name.cmp(&b.name)); + assert_eq!(files.len(), 14); + (library, files) +} + +fn digest_sidecar(library: &sd_core::library::Library, content_uuid: Uuid) -> PathBuf { + library + .path() + .join("sidecars") + .join(sd_sidecar_path::relative_path( + &content_uuid, + &sd_sidecar_path::extension_kind_directory("test-extension", "digest"), + "default", + "json", + )) +} + async fn wait_for_status( library: &sd_core::library::Library, job_id: Uuid, @@ -158,7 +280,9 @@ async fn daemon_discovers_the_extension_and_runs_its_job() { /// A paused extension job survives a daemon restart and continues from the /// state the guest last checkpointed. -/// Collects every log line a guest emits through `spacedrive_log`. +/// Collects every log line a guest emits through `spacedrive_log`, and every +/// line the job context logs on the guest's behalf (task attempts, host +/// operations). struct GuestLog(Arc>); impl tracing_subscriber::Layer for GuestLog { @@ -167,7 +291,8 @@ impl tracing_subscriber::Layer for GuestLog { event: &tracing::Event<'_>, _ctx: tracing_subscriber::layer::Context<'_, S>, ) { - if !event.metadata().target().ends_with("host_functions") { + let target = event.metadata().target(); + if !target.ends_with("host_functions") && !target.ends_with("job::context") { return; } let mut line = String::new(); @@ -292,26 +417,142 @@ async fn extension_job_resumes_after_restart() { core.shutdown().await.unwrap(); } -/// The photos extension (`extensions/photos`, fixture `photos.wasm`) loads -/// beside the test extension and registers its jobs. Its jobs stop at the -/// first SDK call with no host function behind it; the guest's panic reaches -/// the host log and the job fails instead of hanging. +/// The test extension's `catalog` job reads records and their bytes through +/// the library's source stores, writes one sidecar per content and one model +/// per file into the extension's own store, and leaves digested files alone +/// on a second run. #[tokio::test(flavor = "multi_thread")] -async fn photos_extension_loads_and_stops_at_the_first_missing_host_function() { +async fn extension_job_reads_records_and_writes_sidecars_and_models() { let guest_log = guest_log(); let temp_dir = TempDir::new().unwrap(); - install_test_extension(temp_dir.path()); - let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .join("extensions/photos"); - let target = temp_dir.path().join("extensions/photos"); - std::fs::create_dir_all(&target).unwrap(); - for file in ["manifest.json", "photos.wasm"] { - std::fs::copy(source.join(file), target.join(file)).unwrap(); + install_test_extension(temp_dir.path().join("core").as_path()); + let core = Core::new(temp_dir.path().join("core")).await.unwrap(); + let (library, files) = fixture_library(&core, temp_dir.path()).await; + let jpegs: Vec<&sd_store::FsEntry> = files + .iter() + .filter(|f| f.extension.as_deref() == Some("JPG")) + .collect(); + assert_eq!(jpegs.len(), 12); + + guest_log.lock().unwrap().clear(); + let info = run_to_end( + &core, + &library, + "test-extension:catalog", + serde_json::json!({ "extensions": ["jpg"] }), + ) + .await; + assert_eq!( + info.status, + JobStatus::Completed, + "{:?}", + info.error_message + ); + + // One digest sidecar per photo, holding what the guest read + for jpeg in &jpegs { + let content_uuid = jpeg.content_uuid.expect("hashed"); + let path = digest_sidecar(&library, content_uuid); + let digest: serde_json::Value = + serde_json::from_slice(&std::fs::read(&path).unwrap_or_else(|e| { + panic!("digest sidecar for {}: {e} ({})", jpeg.name, path.display()) + })) + .unwrap(); + assert_eq!(digest["size"], jpeg.size.unwrap()); + assert_eq!( + digest["first_bytes"], + serde_json::json!([0xFF, 0xD8, 0xFF, 0xE0]) + ); + assert_eq!(digest["record_name"], jpeg.name); + } + // The text files were outside the query and got no sidecar + for other in files + .iter() + .filter(|f| f.extension.as_deref() == Some("txt")) + { + assert!(!digest_sidecar(&library, other.content_uuid.unwrap()).exists()); } - let core = Core::new(temp_dir.path().to_path_buf()).await.unwrap(); + // One CatalogEntry model per photo in the extension's store, read back + // with its field types: the update after create landed too + let store = sd_store::SourceManager::open_file_read_only( + &library.path().join("extensions/test-extension/data.db"), + ) + .await + .unwrap(); + let mut entries = store.facet_rows("CatalogEntry", None, 100).await.unwrap(); + entries.sort_by_key(|e| e["name"].as_str().unwrap().to_string()); + assert_eq!(entries.len(), 12); + for (entry, jpeg) in entries.iter().zip(&jpegs) { + assert_eq!(entry["name"], jpeg.name); + assert_eq!(entry["record"], jpeg.uuid.to_string()); + assert_eq!(entry["size"], jpeg.size.unwrap()); + assert_eq!(entry["seen_twice"], true); + assert_eq!( + entry["first_bytes"], + serde_json::json!([0xFF, 0xD8, 0xFF, 0xE0]) + ); + Uuid::parse_str(entry["id"].as_str().unwrap()).expect("a v4 uuid from host entropy"); + chrono::DateTime::parse_from_rfc3339(entry["catalogued_at"].as_str().unwrap()) + .expect("a timestamp from the host clock"); + } + let log = guest_log.lock().unwrap().clone(); + assert!( + log.contains("Catalog holds 12 entries, 12 digested this run, 0 skipped"), + "{log}" + ); + + // A second run finds every photo digested and writes nothing new + guest_log.lock().unwrap().clear(); + let info = run_to_end( + &core, + &library, + "test-extension:catalog", + serde_json::json!({ "extensions": ["jpg"] }), + ) + .await; + assert_eq!( + info.status, + JobStatus::Completed, + "{:?}", + info.error_message + ); + let log = guest_log.lock().unwrap().clone(); + assert_eq!(log.matches("already digested").count(), 12, "{log}"); + assert!( + log.contains("Catalog holds 12 entries, 0 digested this run, 12 skipped"), + "{log}" + ); + assert_eq!( + store + .facet_rows("CatalogEntry", None, 100) + .await + .unwrap() + .len(), + 12 + ); + store.pool().close().await; + + core.shutdown().await.unwrap(); +} + +/// The photos extension's `analyze_photos` runs end to end over the fixture: +/// every photo is read through its record, the host answers face detection +/// with not_available, the job warns once and completes with no faces +/// sidecar, so a later run with a detector picks the photos up. A record +/// outside the manifest's read_records glob is refused. +#[tokio::test(flavor = "multi_thread")] +async fn photos_analyze_photos_runs_end_to_end_without_a_detector() { + let guest_log = guest_log(); + let temp_dir = TempDir::new().unwrap(); + install_test_extension(temp_dir.path().join("core").as_path()); + install_extension( + temp_dir.path().join("core").as_path(), + "photos", + "photos.wasm", + ); + + let core = Core::new(temp_dir.path().join("core")).await.unwrap(); let pm = core.plugin_manager.as_ref().expect("plugin manager"); let mut loaded = pm.read().await.list_plugins().await; loaded.sort(); @@ -333,64 +574,100 @@ async fn photos_extension_loads_and_stops_at_the_first_missing_host_function() { "identify_places" ] ); + let models = pm.read().await.model_registry(); + let schema = models + .schema_for("com.spacedrive.photos") + .expect("photos declared its models"); + let mut declared: Vec<&String> = schema.models.keys().collect(); + declared.sort(); + assert_eq!(declared, ["Album", "Moment", "Person", "Photo", "Place"]); - // Twice: the second run lands on a fresh instance, since the first one - // aborted mid-call. - let library = core.libraries.list().await.into_iter().next().unwrap(); - for _ in 0..2 { - guest_log.lock().unwrap().clear(); - let started = RunExtensionJobAction::from_input(RunExtensionJobInput { - job: "com.spacedrive.photos:analyze_photos".into(), - state: None, - }) - .unwrap() - .execute(library.clone(), core.context.clone()) - .await - .unwrap(); + let (library, files) = fixture_library(&core, temp_dir.path()).await; + let photo_ids: Vec = files + .iter() + .filter(|f| f.extension.as_deref() == Some("JPG")) + .map(|f| f.uuid) + .collect(); - let deadline = tokio::time::Instant::now() + Duration::from_secs(30); - let info = loop { - let info = library - .jobs() - .get_job_info(started.job_id) - .await - .unwrap() - .unwrap(); - if info.status == JobStatus::Failed { - break info; - } - assert_ne!( - info.status, - JobStatus::Completed, - "nothing backs this job yet" - ); - assert!( - tokio::time::Instant::now() < deadline, - "the job never ended" - ); - tokio::time::sleep(Duration::from_millis(20)).await; - }; - assert!( - info.error_message - .as_deref() - .unwrap_or("") - .contains("WASM trap"), - "{:?}", - info.error_message - ); - let guest_log = guest_log.lock().unwrap().clone(); - assert!( - guest_log.contains("guest panic:"), - "the guest's panic should reach the host log:\n{guest_log}" - ); - } + guest_log.lock().unwrap().clear(); + let info = run_to_end( + &core, + &library, + "com.spacedrive.photos:analyze_photos", + serde_json::json!({ "photo_ids": photo_ids }), + ) + .await; assert_eq!( - registry - .list_jobs_for_extension("com.spacedrive.photos") - .len(), - 4, - "the reloaded extension registers its jobs again" + info.status, + JobStatus::Completed, + "{:?}", + info.error_message ); + let log = guest_log.lock().unwrap().clone(); + assert!( + log.contains("Job analyze_photos_batch completed successfully"), + "{log}" + ); + assert!(!log.contains("guest panic"), "{log}"); + // No detector, so no faces sidecar anywhere under photos' namespace + let photos_sidecars = walk(&library.path().join("sidecars")) + .into_iter() + .filter(|p| p.to_string_lossy().contains("com.spacedrive.photos")) + .count(); + assert_eq!(photos_sidecars, 0); + + // The host logged each task attempt, and the detector's absence was the + // reason the detection task failed; the policy did not retry a refusal + assert_eq!( + log.matches("task detect_faces_in_photo attempt 1/3 started") + .count(), + 12, + "{log}" + ); + assert!( + log.contains("attempt 1 failed after") + && log.contains("no face_detection provider is installed"), + "{log}" + ); + assert!(!log.contains("attempt 2/3"), "{log}"); + assert!( + log.contains("task cluster_faces_into_people attempt 1/2 started") + && log.contains("task generate_face_tags attempt 1/1 started"), + "{log}" + ); + + // A text file is outside photos' read_records glob + let text = files + .iter() + .find(|f| f.extension.as_deref() == Some("txt")) + .unwrap(); + guest_log.lock().unwrap().clear(); + let info = run_to_end( + &core, + &library, + "com.spacedrive.photos:analyze_photos", + serde_json::json!({ "photo_ids": [text.uuid] }), + ) + .await; + assert_eq!(info.status, JobStatus::Failed); + let log = guest_log.lock().unwrap().clone(); + assert!(log.contains("Permission denied"), "{log}"); core.shutdown().await.unwrap(); } + +fn walk(dir: &Path) -> Vec { + let mut out = Vec::new(); + let Ok(entries) = std::fs::read_dir(dir) else { + return out; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + out.extend(walk(&path)); + } else { + out.push(path); + } + } + out +} diff --git a/crates/sdk-macros/src/extension.rs b/crates/sdk-macros/src/extension.rs index b80a71ac9..4b25cb0a7 100644 --- a/crates/sdk-macros/src/extension.rs +++ b/crates/sdk-macros/src/extension.rs @@ -12,6 +12,8 @@ struct ExtensionArgs { name: String, version: String, jobs: Vec, + /// Model types whose facets `plugin_init` declares to the host. + models: Vec, // We'll ignore other parameters for now (description, permissions, etc.) // They can be used by tooling but don't need codegen } @@ -22,6 +24,7 @@ impl Parse for ExtensionArgs { let mut name = None; let mut version = None; let mut jobs = Vec::new(); + let mut models = Vec::new(); while !input.is_empty() { let ident: Ident = input.parse()?; @@ -50,6 +53,16 @@ impl Parse for ExtensionArgs { } } } + "models" => { + let content; + syn::bracketed!(content in input); + while !content.is_empty() { + models.push(content.parse()?); + if content.peek(Token![,]) { + content.parse::()?; + } + } + } // Ignore other parameters - just skip their values "description" | "min_core_version" => { let _: LitStr = input.parse()?; @@ -95,6 +108,7 @@ impl Parse for ExtensionArgs { name: name.ok_or_else(|| input.error("missing name parameter"))?, version: version.ok_or_else(|| input.error("missing version parameter"))?, jobs, + models, }) } } @@ -127,6 +141,21 @@ pub fn extension_impl(args: TokenStream, input: TokenStream) -> TokenStream { } }); + let model_registrations = args.models.iter().map(|model| { + quote! { + if let Err(e) = ::spacedrive_sdk::ffi::register_model_with_host( + <#model as ::spacedrive_sdk::models::ExtensionModel>::DEFINITION, + ) { + ::spacedrive_sdk::ffi::log_error(&format!( + "Failed to register model {}: {}", + <#model as ::spacedrive_sdk::models::ExtensionModel>::MODEL_TYPE, + e + )); + return 1; + } + } + }); + let expanded = quote! { #input_struct @@ -148,6 +177,9 @@ pub fn extension_impl(args: TokenStream, input: TokenStream) -> TokenStream { // Register all jobs #(#job_registrations)* + // Declare data models, so the host has their facets before a job runs + #(#model_registrations)* + ::spacedrive_sdk::ffi::log_info(&format!( "✓ {} v{} initialized!", #ext_name, diff --git a/crates/sdk-macros/src/model.rs b/crates/sdk-macros/src/model.rs index 1e0f9b718..67e0210d0 100644 --- a/crates/sdk-macros/src/model.rs +++ b/crates/sdk-macros/src/model.rs @@ -1,9 +1,18 @@ //! Model macro implementation - generates ExtensionModel trait impl -//! Handles field attributes: #[entry], #[sidecar], #[custom_field], #[computed], etc. +//! +//! The struct's fields become the model's facet columns in the extension's +//! store. Column types follow the Rust types: strings and uuids are `string`, +//! integers `integer`, floats `float`, bools `boolean`, `DateTime` is +//! `datetime`, `Option` is `T`, and anything else is stored as `json`. +//! +//! The field attributes `#[sidecar]`, `#[sync]`, `#[computed]`, +//! `#[vectorized]`, `#[entry]`, `#[metadata]`, `#[custom_field]` and +//! `#[user_metadata]` are accepted and stripped; nothing acts on them yet, so +//! every field is a column. use proc_macro::TokenStream; use quote::quote; -use syn::{parse_macro_input, Attribute, Data, DeriveInput, Fields}; +use syn::{parse_macro_input, Attribute, Data, DeriveInput, Fields, PathArguments, Type}; pub fn model_impl(_args: TokenStream, input: TokenStream) -> TokenStream { let mut input = parse_macro_input!(input as DeriveInput); @@ -11,6 +20,7 @@ pub fn model_impl(_args: TokenStream, input: TokenStream) -> TokenStream { // Find the id/uuid field before modifying let uuid_field = find_uuid_field(&input); let name = input.ident.clone(); + let definition = definition_json(&input); // Strip known field attributes (they'll be processed later when macros are enhanced) strip_field_attributes(&mut input); @@ -20,6 +30,7 @@ pub fn model_impl(_args: TokenStream, input: TokenStream) -> TokenStream { impl ::spacedrive_sdk::models::ExtensionModel for #name { const MODEL_TYPE: &'static str = stringify!(#name); + const DEFINITION: &'static str = #definition; fn uuid(&self) -> ::spacedrive_sdk::types::Uuid { self.#uuid_field @@ -34,6 +45,54 @@ pub fn model_impl(_args: TokenStream, input: TokenStream) -> TokenStream { TokenStream::from(expanded) } +/// The model definition the host registers: `{"name":..,"fields":{..}}`, +/// with fields in declaration order. +fn definition_json(input: &DeriveInput) -> String { + let mut fields = Vec::new(); + if let Data::Struct(data_struct) = &input.data { + if let Fields::Named(named) = &data_struct.fields { + for field in &named.named { + if let Some(ident) = &field.ident { + fields.push(format!("\"{}\":\"{}\"", ident, field_type_name(&field.ty))); + } + } + } + } + format!( + "{{\"name\":\"{}\",\"fields\":{{{}}}}}", + input.ident, + fields.join(",") + ) +} + +fn field_type_name(ty: &Type) -> &'static str { + let Type::Path(path) = ty else { + return "json"; + }; + let Some(last) = path.path.segments.last() else { + return "json"; + }; + let ident = last.ident.to_string(); + if ident == "Option" { + if let PathArguments::AngleBracketed(generics) = &last.arguments { + if let Some(syn::GenericArgument::Type(inner)) = generics.args.first() { + return field_type_name(inner); + } + } + return "json"; + } + match ident.as_str() { + "String" | "str" | "Uuid" => "string", + "i8" | "i16" | "i32" | "i64" | "isize" | "u8" | "u16" | "u32" | "u64" | "usize" => { + "integer" + } + "f32" | "f64" => "float", + "bool" => "boolean", + "DateTime" | "NaiveDateTime" => "datetime", + _ => "json", + } +} + fn find_uuid_field(input: &DeriveInput) -> syn::Ident { if let Data::Struct(data_struct) = &input.data { if let Fields::Named(fields) = &data_struct.fields { @@ -54,7 +113,6 @@ fn strip_field_attributes(input: &mut DeriveInput) { if let Data::Struct(ref mut data_struct) = input.data { if let Fields::Named(ref mut fields) = data_struct.fields { for field in &mut fields.named { - // Remove known field attributes (for now - will be processed in future) field.attrs.retain(|attr| !is_model_field_attribute(attr)); } } diff --git a/crates/sdk-macros/src/task.rs b/crates/sdk-macros/src/task.rs index 3227f11e8..b181e67e1 100644 --- a/crates/sdk-macros/src/task.rs +++ b/crates/sdk-macros/src/task.rs @@ -1,17 +1,130 @@ //! Task macro implementation +//! +//! `#[task(retries = 2, timeout_ms = 30000)]` on an async function makes a +//! unit struct of the function's name that implements `spacedrive_sdk::Task`, +//! so `ctx.run(detect_faces, photo)` passes the policy along with the code. +//! The body keeps running as a renamed function the trait impl calls. use proc_macro::TokenStream; -use quote::quote; -use syn::{parse_macro_input, ItemFn}; +use quote::{format_ident, quote}; +use syn::{ + parse::{Parse, ParseStream}, + parse_macro_input, FnArg, GenericArgument, ItemFn, Lit, PathArguments, ReturnType, Token, Type, +}; -pub fn task_impl(_args: TokenStream, input: TokenStream) -> TokenStream { - let input = parse_macro_input!(input as ItemFn); +struct TaskArgs { + retries: u32, + timeout_ms: u64, +} + +impl Parse for TaskArgs { + fn parse(input: ParseStream) -> syn::Result { + let mut args = TaskArgs { + retries: 0, + timeout_ms: 0, + }; + while !input.is_empty() { + let key: syn::Ident = input.parse()?; + input.parse::()?; + let value: Lit = input.parse()?; + match (key.to_string().as_str(), value) { + ("retries", Lit::Int(n)) => args.retries = n.base10_parse()?, + ("timeout_ms", Lit::Int(n)) => args.timeout_ms = n.base10_parse()?, + // Capability routing has no host side; the attribute is kept + // for documentation. + ("requires_capability", Lit::Str(_)) => {} + (other, _) => { + return Err(syn::Error::new( + key.span(), + format!("unknown task attribute `{other}`"), + )) + } + } + if input.peek(Token![,]) { + input.parse::()?; + } + } + Ok(args) + } +} + +pub fn task_impl(args: TokenStream, input: TokenStream) -> TokenStream { + let args = parse_macro_input!(args as TaskArgs); + let mut input_fn = parse_macro_input!(input as ItemFn); + + let name = input_fn.sig.ident.clone(); + let name_str = name.to_string(); + let vis = input_fn.vis.clone(); + let docs: Vec<_> = input_fn + .attrs + .iter() + .filter(|attr| attr.path().is_ident("doc")) + .cloned() + .collect(); + let inner = format_ident!("__task_{}", name); + input_fn.sig.ident = inner.clone(); + + let args_type = match input_fn.sig.inputs.iter().nth(1) { + Some(FnArg::Typed(pat)) => (*pat.ty).clone(), + _ => { + return syn::Error::new_spanned( + &input_fn.sig, + "a task takes (ctx: TaskContext, args: A)", + ) + .to_compile_error() + .into() + } + }; + let output_type = match &input_fn.sig.output { + ReturnType::Type(_, ty) => result_ok_type(ty), + ReturnType::Default => None, + }; + let Some(output_type) = output_type else { + return syn::Error::new_spanned(&input_fn.sig.output, "a task returns TaskResult") + .to_compile_error() + .into(); + }; + let retries = args.retries; + let timeout_ms = args.timeout_ms; - // For now, just pass through - // Future: Generate task wrapper with retry logic, timeout, etc. let expanded = quote! { - #input + #input_fn + + #(#docs)* + #[allow(non_camel_case_types)] + #[derive(Clone, Copy)] + #vis struct #name; + + impl ::spacedrive_sdk::tasks::Task for #name { + type Args = #args_type; + type Output = #output_type; + const NAME: &'static str = #name_str; + const RETRIES: u32 = #retries; + const TIMEOUT_MS: u64 = #timeout_ms; + + async fn call( + ctx: ::spacedrive_sdk::tasks::TaskContext, + args: Self::Args, + ) -> ::spacedrive_sdk::tasks::TaskResult { + #inner(ctx, args).await + } + } }; TokenStream::from(expanded) } + +/// `T` out of `TaskResult` or `Result` or `Result`. +fn result_ok_type(ty: &Type) -> Option { + let Type::Path(path) = ty else { + return None; + }; + let last = path.path.segments.last()?; + let PathArguments::AngleBracketed(generics) = &last.arguments else { + return None; + }; + match generics.args.first()? { + GenericArgument::Type(ok) => Some(ok.clone()), + _ => None, + } +} diff --git a/crates/sdk/.cargo/config.toml b/crates/sdk/.cargo/config.toml new file mode 100644 index 000000000..0dc0dbca0 --- /dev/null +++ b/crates/sdk/.cargo/config.toml @@ -0,0 +1,4 @@ +# The SDK supplies getrandom's custom backend from the host; a guest that +# links it builds with the same cfg (see each extension's .cargo/config.toml). +[target.wasm32-unknown-unknown] +rustflags = ["--cfg", "getrandom_backend=\"custom\""] diff --git a/crates/sdk/API_REFERENCE.md b/crates/sdk/API_REFERENCE.md index bf49398db..bd8ff29cf 100644 --- a/crates/sdk/API_REFERENCE.md +++ b/crates/sdk/API_REFERENCE.md @@ -1,13 +1,30 @@ # Spacedrive SDK API Reference -**Status:** Stubs for type-checking (implementations are `todo!()`) -**Purpose:** Full API surface from `docs/sdk/sdk.md` - allows extensions to compile +**Status:** Records, sidecars, models, tasks, entropy, clock and config have a host side behind `spacedrive_op`; inference answers `Error::NotAvailable`; tags, custom fields, agents and actions remain stubs. +**Purpose:** The API surface extensions compile against, and what each part does at runtime. --- ## Overview -The SDK now includes **all APIs** documented in the specification as type-checked stubs. +Every method that returns data from the host goes through one import, +`spacedrive_op(name, payload)`. The host checks the manifest's `permissions` +block and answers JSON, or an error with a stable code the SDK maps onto +`Error` (`NotFound`, `PermissionDenied`, `InvalidInput`, `NotAvailable`, +`OperationFailed`). + +### Renames since the stub phase + +| Was | Now | Why | +|-----|-----|-----| +| `Entry`, `EntryKind` | `Record`, `RecordKind` | The entry table is gone; files are records in a source store. | +| `vdfs().get_entry(uuid)` | `vdfs().get_record(uuid)` | | +| `vdfs().query_entries()` | `vdfs().query_records()` | Filters by source, path, extension and type. `with_tag` and `where_metadata` are refused by the host. | +| new | `vdfs().read_record(uuid)`, `Record::read()` | The bytes of a record, read from its source. | +| `save_sidecar(content_uuid, kind, extension_id, data)` | `save_sidecar(content_uuid, kind, data)` | The host keys the sidecar by the calling extension. | +| `add_tag(metadata_id, tag)` | `add_tag(record_uuid, tag)` | Tags attach to records; no host side yet. | +| new | `spacedrive_sdk::clock::now()` | Wall-clock time; `SystemTime::now` panics on wasm32-unknown-unknown. | +| new | `#[model]` emits a definition the host registers | Models live in an extension-owned store under `/extensions//`. | ### Modules @@ -38,9 +55,9 @@ pub type QueryResult = std::result::Result; pub type TaskResult = std::result::Result; // Core entities -pub struct Entry { id, uuid, name, kind, ... } +pub struct Record { uuid, source, path, name, kind, extension, size, content_uuid, ... } pub struct Tag { id, name, color, icon } -pub enum EntryKind { File, Directory, Symlink, Virtual } +pub enum RecordKind { File, Directory, Symlink } pub enum Priority { Low, Normal, High } pub enum Capability { GPU, CPU } pub enum Progress { Indeterminate, Simple, Complete } @@ -56,9 +73,15 @@ pub struct Pdf; ```rust impl VdfsContext { - // Entry queries - fn query_entries() -> EntryQuery - async fn get_entry(uuid) -> Result + // Record queries (read_records grant) + fn query_records() -> RecordQuery + async fn get_record(uuid) -> Result + async fn read_record(uuid) -> Result> + + // Sidecars (read_sidecars / write_sidecars grants), one JSON document per kind + fn sidecar_exists(content_uuid, kind) -> Result + async fn read_sidecar(content_uuid, kind) -> Result + async fn write_sidecar(content_uuid, kind, data) -> Result<()> // Model operations (content-scoped) async fn create_model_for_content(content_uuid, model) -> Result<()> @@ -70,29 +93,29 @@ impl VdfsContext { async fn get_model(uuid) -> Result fn query_models() -> ModelQuery - // Tagging + // Tagging (no host side yet, returns Error::NotAvailable) async fn add_tag_to_content(content_uuid, tag) -> Result<()> async fn add_tag_to_model(model_uuid, tag) -> Result<()> - async fn add_tag(metadata_id, tag) -> Result<()> + async fn add_tag(record_uuid, tag) -> Result<()> - // Custom fields - async fn update_custom_field(entry_uuid, field, value) -> Result<()> + // Custom fields (no host side yet) + async fn update_custom_field(record_uuid, field, value) -> Result<()> // Permissions fn in_granted_scope(path) -> bool } -// Entry query builder -impl EntryQuery { +// Record query builder +impl RecordQuery { + fn in_source(source_uuid) -> Self fn in_location(path) -> Self + fn with_extensions(extensions) -> Self fn of_type() -> Self - fn where_content_id(content_uuid) -> Self - fn on_this_device() -> Self - fn with_tag(tag) -> Self - fn with_sidecar(kind) -> Self - async fn first() -> Result> - async fn collect() -> Result> - fn map(f) -> MappedQuery + fn with_tag(tag) -> Self // refused by the host today + fn where_metadata(field, predicate) -> Self // refused by the host today + fn limit(n) -> Self + async fn first() -> Result> + async fn collect() -> Result> } // Model query builder @@ -125,7 +148,7 @@ impl ModelHandle { fn prompt_template(template_name: &str) -> PromptBuilder async fn detect_faces(image_data: &[u8]) -> Result> async fn classify(image_data: &[u8]) -> Result> - async fn ocr_document(entry: &Entry) -> Result + async fn ocr_document(record: &Record) -> Result async fn embed_text(text: &str) -> Result> } @@ -244,7 +267,8 @@ impl JobContext { fn progress(progress: Progress) async fn check_interrupt() -> Result<()> // Async version fn sidecar_exists(content_uuid, kind) -> Result - async fn save_sidecar(content_uuid, kind, extension_id, data) -> Result<()> + async fn save_sidecar(content_uuid, kind, data) -> Result<()> + async fn read_sidecar(content_uuid, kind) -> Result fn memory() -> MemoryHandle<()> fn config() -> &C fn notify() -> NotificationBuilder @@ -286,7 +310,7 @@ pub enum Change { UpdateCustomField { entry_id, field, value }, AddTag { target, tag }, CreateDirectory { name, parent }, - MoveEntry { entry, destination }, + MoveRecord { record, destination }, } pub struct ExecutionResult { @@ -354,36 +378,30 @@ All macros are currently pass-through stubs: ## Usage Examples -### Photos Extension (Now Type-Checks!) +### Photos Extension + +`extensions/photos/src/jobs/analyze.rs` is the worked example: it reads each +photo through `get_record` and `Record::read`, runs face detection as a +`#[task]` with a retry policy, handles `Error::NotAvailable` with one warning +and a skip, and writes a `faces` sidecar when a detector answers. ```rust -#[model(scope = "content")] -struct PhotoAnalysis { - detected_faces: Vec, - // ... compiles! -} - -#[job] -async fn analyze_photos(ctx: &JobContext, content_uuids: Vec) -> JobResult<()> { - for content_uuid in content_uuids { - let entry = ctx.vdfs() // Type-checks - .query_entries() - .where_content_id(content_uuid) - .first() - .await?; - - let faces = ctx.ai() // Type-checks - .from_registered("face_detection") - .detect_faces(&image_data) - .await?; - - ctx.vdfs().create_model_for_content(content_uuid, analysis).await?; // Type-checks +#[job(name = "analyze_photos_batch")] +async fn analyze_photos(ctx: &JobContext, state: &mut AnalyzePhotosState) -> JobResult<()> { + for photo_id in &state.photo_ids { + let record = ctx.vdfs().get_record(*photo_id).await?; + let Some(content_uuid) = record.content_uuid() else { continue }; + match ctx.run(detect_faces_in_photo, record).await { + Ok(faces) => ctx.save_sidecar(content_uuid, "faces", &faces).await?, + Err(Error::NotAvailable(_)) => { ctx.add_warning("no face detector"); break } + Err(e) => return Err(e), + } } Ok(()) } ``` -### Test Extension (Still Works!) +### Test Extension ```rust #[job(name = "counter")] @@ -400,98 +418,32 @@ fn test_counter(ctx: &JobContext, state: &mut CounterState) -> Result<()> { | Module | Status | Notes | |--------|--------|-------| -| `ffi.rs` | Implemented | Low-level WASM imports | -| `job_context.rs` | Expanded | New methods added, existing preserved | -| `types.rs` | Expanded | All common types added | -| `vdfs.rs` | Stubs | Type-checks, `todo!()` for host calls | -| `ai.rs` | Stubs | Type-checks, `todo!()` for inference | -| `agent.rs` | Stubs | Type-checks, memory system defined | -| `models.rs` | Stubs | Type-checks, registration stubs | -| `actions.rs` | Stubs | Type-checks, preview/execute defined | -| `tasks.rs` | Stubs | Type-checks, task context defined | -| `query.rs` | Stubs | Type-checks, query context defined | - -**Macros:** All defined as pass-through (no codegen yet) +| `ffi.rs` | Implemented | `spacedrive_log`, `register_job`, `register_model`, `spacedrive_random`, `spacedrive_now_ms`, `spacedrive_op` | +| `job_context.rs` | Implemented | Progress, checkpoints, interrupts, sidecars, `run` for tasks | +| `types.rs` | Implemented | `Record`, `RecordKind`, `Error` with host codes | +| `vdfs.rs` | Partly | Records, sidecars and models work; tags and custom fields return `NotAvailable` | +| `clock.rs` | Implemented | `now()` over `spacedrive_now_ms` | +| `tasks.rs` | Implemented | `#[task]` with retry policy and deadline; the host logs each attempt | +| `models.rs` | Implemented | `#[model]` defines, `#[extension(models = [...])]` registers; rows in the extension's store | +| `ai.rs` | Not available | `infer` checks the `use_models` grant on the host, then returns `Error::NotAvailable` | +| `config.rs` | Implemented | `config.json` beside the manifest | +| `agent.rs` | Stubs | Memory and notifications have no host side | +| `actions.rs` | Stubs | Preview and execute have no host side | +| `query.rs` | Stubs | No host side | --- ## What Works -**Type-checking:** Photos extension compiles and type-checks -**Test extension:** Existing test-extension still works -**API surface:** Complete API from specification available -**Documentation:** IntelliSense/rust-analyzer autocomplete works +**Runtime:** The photos extension's `analyze_photos` runs end to end under the daemon's `wasm` feature; `core/tests/wasm_extension_test.rs` is the acceptance test. +**Randomness:** `getrandom` is backed by `spacedrive_random`, so `Uuid::new_v4()` and `rand` work in the guest (each extension's `.cargo/config.toml` sets `--cfg getrandom_backend="custom"`). ## What Doesn't Work Yet -**Runtime:** All new methods are `todo!()` - will panic if called -**Host functions:** WASM imports not implemented in Core -**Macro codegen:** Macros don't generate code yet -**Memory persistence:** No storage backend - ---- - -## Next Steps for Implementation - -### Phase 1: Core Host Functions - -Implement in `core/src/infra/extension/host_functions.rs`: -```rust -#[no_mangle] -pub extern "C" fn vdfs_query_entries(...) -> u32; -#[no_mangle] -pub extern "C" fn model_create(...) -> u32; -#[no_mangle] -pub extern "C" fn model_get_by_content(...) -> u32; -#[no_mangle] -pub extern "C" fn add_tag_to_content(...) -> u32; -#[no_mangle] -pub extern "C" fn model_register(...) -> u32; -#[no_mangle] -pub extern "C" fn ai_infer(...) -> u32; -``` - -### Phase 2: SDK Implementation - -Replace `todo!()` with actual WASM host calls: -```rust -pub async fn get_model_by_content(content_uuid: Uuid) -> Result { - // Serialize request - let request = ModelRequest { content_uuid, model_type: T::MODEL_TYPE }; - let req_bytes = serde_json::to_vec(&request)?; - - // Call host function - let result_ptr = unsafe { - model_get_by_content(req_bytes.as_ptr(), req_bytes.len()) - }; - - // Deserialize response - let response_bytes = unsafe { read_host_memory(result_ptr) }; - let model: T = serde_json::from_slice(&response_bytes)?; - - Ok(model) -} -``` - -### Phase 3: Macro Code Generation - -Implement real macros: -```rust -#[model(scope = "content")] -struct PhotoAnalysis { ... } - -// Generates: -impl ExtensionModel for PhotoAnalysis { - const MODEL_TYPE: &'static str = "PhotoAnalysis"; - fn uuid(&self) -> Uuid { self.id } -} - -impl PhotoAnalysis { - pub async fn save_for_content(ctx: &VdfsContext, content_uuid: Uuid, self) -> Result<()> { - ctx.create_model_for_content(content_uuid, self).await - } -} -``` +**Inference:** The core has no provider for face detection, scene classification, embeddings or language models, so every `ai().infer` answers `NotAvailable`; photos' scenes, places and moments jobs have nothing to run on. +**Tags, custom fields, dispatching jobs:** no host side. +**Agents, actions, queries:** no host side. +**File kinds and previews:** not registered by extensions. --- @@ -525,7 +477,7 @@ cargo build --target wasm32-unknown-unknown --release ## Breaking Changes -**None!** The existing test-extension API is preserved: +See the renames table in the overview. The original test-extension API is preserved: - `ctx.log()` - `ctx.checkpoint()` - `ctx.check_interrupt()` @@ -537,5 +489,5 @@ New methods are additive only. --- -**The SDK is now complete for type-checking. Extensions can be written and will compile. Runtime implementation is the next phase.** +**Extensions compile and the record, sidecar, model and task paths run. Inference is the next host side to build.** diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml index f8e8da5ad..dc2eafbb9 100644 --- a/crates/sdk/Cargo.toml +++ b/crates/sdk/Cargo.toml @@ -9,6 +9,7 @@ version = "0.1.0" [dependencies] chrono = { version = "0.4", default-features = false, features = ["serde"] } +getrandom = { version = "0.4", default-features = false } serde = { version = "1.0", default-features = false, features = ["alloc", "derive"] } serde_json = { version = "1.0", default-features = false, features = ["alloc"] } spacedrive-sdk-macros = { path = "../sdk-macros" } diff --git a/crates/sdk/src/actions.rs b/crates/sdk/src/actions.rs index ec443641f..d30b378fd 100644 --- a/crates/sdk/src/actions.rs +++ b/crates/sdk/src/actions.rs @@ -49,8 +49,8 @@ pub enum Change { name: String, parent: uuid::Uuid, }, - MoveEntry { - entry: Entry, + MoveRecord { + record: Record, destination: String, }, } diff --git a/crates/sdk/src/ai.rs b/crates/sdk/src/ai.rs index 18ab6db10..055aca2d4 100644 --- a/crates/sdk/src/ai.rs +++ b/crates/sdk/src/ai.rs @@ -3,7 +3,12 @@ //! This handles ML models (face_detection, llm, ocr, embeddings). //! NOT to be confused with data models (Person, Album) - those are in models.rs //! -//! Stubs for type-checking. Implementation will call WASM host functions. +//! Inference goes to the host as `ai.infer`, which checks the manifest's +//! `use_models` grant and hands the input to whatever provider the core has +//! for the model's category. The core has no face detection, scene +//! classification, embedding or language model provider today, so those +//! calls return [`Error::NotAvailable`], which a job handles rather than +//! fails on. use crate::types::*; use serde::{de::DeserializeOwned, Serialize}; @@ -38,7 +43,7 @@ impl AiModelRegistry { name: &str, source: AiModelSource, ) -> Result { - panic!("WASM host call") + Err(Error::Unsupported("register".into())) } /// Check if AI model is registered @@ -91,24 +96,32 @@ impl ModelHandle { } } + fn infer(&self, task: &str, input: &[u8]) -> Result { + crate::ffi::op_framed( + "ai.infer", + &serde_json::json!({ "model": self.model_id, "task": task }), + input, + ) + } + /// Detect faces in image pub async fn detect_faces(&self, image_data: &[u8]) -> Result> { - panic!("WASM host call") + self.infer("detect_faces", image_data) } /// Classify scene in image pub async fn classify(&self, image_data: &[u8]) -> Result> { - panic!("WASM host call") + self.infer("classify", image_data) } /// OCR document - pub async fn ocr_document(&self, entry: &Entry) -> Result { - panic!("WASM host call") + pub async fn ocr_document(&self, record: &Record) -> Result { + Err(Error::Unsupported("ocr_document".into())) } /// Generate text embedding pub async fn embed_text(&self, text: &str) -> Result> { - panic!("WASM host call") + self.infer("embed_text", text.as_bytes()) } } @@ -121,7 +134,7 @@ pub struct PromptBuilder { impl PromptBuilder { /// Render template with context pub fn render_with(self, context: &T) -> Result { - panic!("Render Jinja template") + Err(Error::Unsupported("render_with".into())) } } @@ -134,12 +147,12 @@ pub struct RenderedPrompt { impl RenderedPrompt { /// Generate text from rendered prompt pub async fn generate_text(self) -> Result { - panic!("WASM host call - LLM inference") + Err(Error::Unsupported("generate_text".into())) } /// Generate JSON from rendered prompt pub async fn generate_json(self) -> Result { - panic!("WASM host call - structured output") + Err(Error::Unsupported("generate_json".into())) } } diff --git a/crates/sdk/src/clock.rs b/crates/sdk/src/clock.rs new file mode 100644 index 000000000..afd22e691 --- /dev/null +++ b/crates/sdk/src/clock.rs @@ -0,0 +1,16 @@ +//! The host's clock +//! +//! wasm32-unknown-unknown has no clock, so `Utc::now()` and +//! `SystemTime::now()` trap in a guest. Extensions ask the host instead. + +use chrono::{DateTime, Utc}; + +/// The current time, from the host. +pub fn now() -> DateTime { + DateTime::from_timestamp_millis(crate::ffi::now_ms()).unwrap_or_default() +} + +/// Milliseconds since the Unix epoch, from the host. +pub fn now_ms() -> i64 { + crate::ffi::now_ms() +} diff --git a/crates/sdk/src/config.rs b/crates/sdk/src/config.rs new file mode 100644 index 000000000..18f16f124 --- /dev/null +++ b/crates/sdk/src/config.rs @@ -0,0 +1,17 @@ +//! Extension configuration +//! +//! The host reads `config.json` beside the extension's manifest and answers +//! `config.get` with it, or with `{}` when there is none. + +use serde::de::DeserializeOwned; + +/// Load the configuration once per call and leak it: a config is read a +/// handful of times per job and the guest's heap goes with the job's +/// instance, so a reference is the convenient shape and the leak is bounded. +pub fn load() -> &'static C { + let value: serde_json::Value = crate::ffi::op_json("config.get", &serde_json::json!({})) + .unwrap_or_else(|e| panic!("extension config could not be read from the host: {e}")); + let config: C = serde_json::from_value(value) + .unwrap_or_else(|e| panic!("extension config.json does not match the config type: {e}")); + Box::leak(Box::new(config)) +} diff --git a/crates/sdk/src/ffi.rs b/crates/sdk/src/ffi.rs index d1d186cc7..a69d81989 100644 --- a/crates/sdk/src/ffi.rs +++ b/crates/sdk/src/ffi.rs @@ -1,6 +1,16 @@ //! Low-level FFI bindings to Spacedrive host functions //! //! This module is internal - extension developers should use the high-level API. +//! +//! Every host function that returns data goes through [`op`]: the guest names +//! an operation and hands over a payload, the host writes the result into +//! memory it takes from [`wasm_alloc`] and reports where. One convention for +//! every operation keeps the import table small and the memory handling in +//! one place on each side. + +use serde::{de::DeserializeOwned, Serialize}; + +use crate::types::{Error, Result}; // Import Spacedrive host functions #[link(wasm_import_module = "spacedrive")] @@ -13,6 +23,16 @@ extern "C" { export_fn_len: u32, resumable: u32, ) -> i32; + fn register_model(def_ptr: *const u8, def_len: u32) -> i32; + fn spacedrive_random(buf_ptr: *mut u8, buf_len: u32); + fn spacedrive_now_ms() -> i64; + fn spacedrive_op( + op_ptr: *const u8, + op_len: u32, + payload_ptr: *const u8, + payload_len: u32, + out_ptr: *mut u32, + ) -> i32; } /// Log a message (info level) @@ -63,7 +83,7 @@ pub unsafe extern "C" fn wasm_free(ptr: *mut u8, size: i32) { /// Register a job with the extension system /// /// Called automatically by #[extension] macro during plugin_init() -pub fn register_job_with_host(job_name: &str, export_fn: &str, resumable: bool) -> Result<(), ()> { +pub fn register_job_with_host(job_name: &str, export_fn: &str, resumable: bool) -> Result<()> { let result = unsafe { register_job( job_name.as_ptr(), @@ -77,10 +97,120 @@ pub fn register_job_with_host(job_name: &str, export_fn: &str, resumable: bool) if result == 0 { Ok(()) } else { - Err(()) + Err(Error::HostCall(format!("register_job({job_name})"))) } } +/// Declare a data model's facet to the host, from `plugin_init`. +/// +/// `def_json` is the model definition the `#[model]` macro derived from the +/// struct; the host turns it into a facet table in the extension's store. +pub fn register_model_with_host(def_json: &str) -> Result<()> { + let result = unsafe { register_model(def_json.as_ptr(), def_json.len() as u32) }; + if result == 0 { + Ok(()) + } else { + Err(Error::HostCall("register_model".into())) + } +} + +/// Fill a buffer with entropy from the host. +pub fn random_bytes(buf: &mut [u8]) { + unsafe { spacedrive_random(buf.as_mut_ptr(), buf.len() as u32) } +} + +/// The host's clock, as milliseconds since the Unix epoch. +pub fn now_ms() -> i64 { + unsafe { spacedrive_now_ms() } +} + +/// The entropy source behind `getrandom`, and so behind `Uuid::new_v4` and +/// `rand`, in a guest built with `--cfg getrandom_backend="custom"`. +/// +/// wasm32-unknown-unknown has no entropy of its own; this routes every +/// request to the host. The symbol name is getrandom's contract. +#[no_mangle] +unsafe extern "Rust" fn __getrandom_v03_custom( + dest: *mut u8, + len: usize, +) -> ::core::result::Result<(), getrandom::Error> { + let buf = unsafe { std::slice::from_raw_parts_mut(dest, len) }; + random_bytes(buf); + Ok(()) +} + +/// The shape of an error the host reports from an operation. +#[derive(serde::Deserialize)] +struct HostError { + code: String, + message: String, +} + +/// Run one host operation and return the bytes it answered with. +/// +/// The host allocates the answer through `wasm_alloc` and reports pointer and +/// length through `out`; this copies the bytes into a `Vec` and frees the +/// host's buffer, so callers never touch raw guest memory. A non-zero status +/// means the bytes are a JSON error, mapped onto [`Error`] by its code. +pub fn op(op: &str, payload: &[u8]) -> Result> { + let mut out = [0u32; 2]; + let status = unsafe { + spacedrive_op( + op.as_ptr(), + op.len() as u32, + payload.as_ptr(), + payload.len() as u32, + out.as_mut_ptr(), + ) + }; + let (ptr, len) = (out[0] as *mut u8, out[1] as usize); + let bytes = if ptr.is_null() || len == 0 { + Vec::new() + } else { + let bytes = unsafe { std::slice::from_raw_parts(ptr, len) }.to_vec(); + unsafe { wasm_free(ptr, len.max(1) as i32) }; + bytes + }; + if status == 0 { + return Ok(bytes); + } + let error: HostError = serde_json::from_slice(&bytes) + .map_err(|_| Error::HostCall(format!("{op}: host error {status} was not readable")))?; + Err(match error.code.as_str() { + "not_found" => Error::NotFound, + "permission_denied" => Error::PermissionDenied(error.message), + "invalid_input" => Error::InvalidInput(error.message), + "not_available" => Error::NotAvailable(error.message), + "timeout" => Error::Timeout(error.message), + "interrupted" => Error::Interrupted, + _ => Error::OperationFailed(format!("{op}: {}", error.message)), + }) +} + +/// [`op`] with a JSON header followed by raw bytes, for inputs too large to +/// base64 into JSON: the frame is the header's length as little-endian u32, +/// the header, then the bytes. The answer is JSON. +pub fn op_framed( + op_name: &str, + header: &I, + bytes: &[u8], +) -> Result { + let header = serde_json::to_vec(header).map_err(|e| Error::Serialization(e.to_string()))?; + let mut payload = Vec::with_capacity(4 + header.len() + bytes.len()); + payload.extend_from_slice(&(header.len() as u32).to_le_bytes()); + payload.extend_from_slice(&header); + payload.extend_from_slice(bytes); + let answer = op(op_name, &payload)?; + serde_json::from_slice(&answer).map_err(|e| Error::Deserialization(format!("{op_name}: {e}"))) +} + +/// [`op`] with a JSON request and a JSON answer. +pub fn op_json(op_name: &str, input: &I) -> Result { + let payload = serde_json::to_vec(input).map_err(|e| Error::Serialization(e.to_string()))?; + let bytes = op(op_name, &payload)?; + serde_json::from_slice(&bytes).map_err(|e| Error::Deserialization(format!("{op_name}: {e}"))) +} + /// Drive a future to completion on the guest's single thread. /// /// Every host call returns before the guest continues, so a future built from diff --git a/crates/sdk/src/job_context.rs b/crates/sdk/src/job_context.rs index a91b73431..ed3e744a8 100644 --- a/crates/sdk/src/job_context.rs +++ b/crates/sdk/src/job_context.rs @@ -97,7 +97,7 @@ impl JobContext { /// Check for interruption (async version) pub async fn check_interrupt(&self) -> crate::types::Result<()> { if self.check_interrupt_sync() { - Err(crate::types::Error::OperationFailed("Interrupted".into())) + Err(crate::types::Error::Interrupted) } else { Ok(()) } @@ -144,13 +144,21 @@ impl JobContext { self.ai_models() } - /// Run a task (for job composition) - pub async fn run(&self, _task: F, _args: A) -> crate::types::Result + /// Run a task, retrying and timing out as its `#[task]` attribute says. + /// + /// The host enforces the timeout: once an attempt's deadline passes, every + /// host operation the task makes fails with `Error::Timeout`, and the next + /// attempt starts if the policy allows one. Each attempt is written to the + /// job log. + pub async fn run( + &self, + _task: T, + args: T::Args, + ) -> crate::types::Result where - F: Fn(crate::tasks::TaskContext, A) -> Fut, - Fut: std::future::Future>, + T::Args: Clone, { - panic!("Execute task with checkpoint") + crate::tasks::run::(args).await } /// Report progress @@ -168,24 +176,40 @@ impl JobContext { } } - /// Check if sidecar exists + /// Whether a sidecar of one of this extension's kinds exists for the + /// content. pub fn sidecar_exists( &self, content_uuid: uuid::Uuid, kind: &str, ) -> crate::types::Result { - panic!("WASM host call") + crate::vdfs::VdfsContext.sidecar_exists(content_uuid, kind) } - /// Save sidecar + /// Write a sidecar of one of this extension's kinds, as JSON, keyed by + /// the content it describes. The kind must be in the manifest's + /// `write_sidecars`; the host namespaces it by extension, which is why + /// no extension id is passed here any more. pub async fn save_sidecar( &self, content_uuid: uuid::Uuid, kind: &str, - extension_id: &str, data: &T, ) -> crate::types::Result<()> { - panic!("WASM host call") + crate::vdfs::VdfsContext + .write_sidecar(content_uuid, kind, data) + .await + } + + /// Read back a sidecar this extension may read. + pub async fn read_sidecar( + &self, + content_uuid: uuid::Uuid, + kind: &str, + ) -> crate::types::Result { + crate::vdfs::VdfsContext + .read_sidecar(content_uuid, kind) + .await } /// Access agent memory @@ -193,9 +217,12 @@ impl JobContext { panic!("Access agent memory") } - /// Access extension config - pub fn config(&self) -> &C { - panic!("Access config") + /// The extension's configuration: `config.json` beside its manifest, + /// or `{}` when there is none, so a config type with serde defaults + /// always loads. A config that does not deserialize is a panic, since + /// the job cannot do anything sensible without it. + pub fn config(&self) -> &C { + crate::config::load() } /// Send notification diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs index cfdb29793..6a79c2919 100644 --- a/crates/sdk/src/lib.rs +++ b/crates/sdk/src/lib.rs @@ -4,29 +4,16 @@ //! //! # Example //! -//! This sketches the intended API. `ExtensionContext`, `CreateEntry` and -//! `OcrOptions` do not exist yet, so the example is not compiled. -//! //! ```ignore -//! use spacedrive_sdk::{ExtensionContext, prelude::*}; -//! -//! #[spacedrive_extension] -//! fn init(ctx: &mut ExtensionContext) -> Result<()> { -//! ctx.log("Finance extension starting..."); -//! -//! // Create entry -//! let entry = ctx.vdfs().create_entry(CreateEntry { -//! name: "Receipt: Starbucks".into(), -//! path: "receipts/1.eml".into(), -//! entry_type: "FinancialDocument".into(), -//! })?; -//! -//! // Run OCR -//! let ocr_result = ctx.ai().ocr(&pdf_data, OcrOptions::default())?; -//! -//! // Store sidecar -//! ctx.vdfs().write_sidecar(entry.id, "ocr.txt", ocr_result.text.as_bytes())?; +//! use spacedrive_sdk::prelude::*; //! +//! #[job(name = "sizes")] +//! async fn sizes(ctx: &JobContext, state: &mut SizesState) -> Result<()> { +//! let photos = ctx.vdfs().query_records().of_type::().collect().await?; +//! for photo in photos { +//! let bytes = photo.read().await?; +//! ctx.log(&format!("{} is {} bytes", photo.name(), bytes.len())); +//! } //! Ok(()) //! } //! ``` @@ -36,6 +23,8 @@ pub mod actions; pub mod agent; pub mod ai; +pub mod clock; +pub mod config; pub mod ffi; pub mod job_context; pub mod models; @@ -52,6 +41,7 @@ pub use agent::{ NotificationBuilder, TemporalMemory, TemporalQuery, WorkingMemory, }; pub use ai::*; +pub use clock::now; pub use job_context::JobContext as SdkJobContext; pub use models::*; pub use query::*; @@ -64,6 +54,7 @@ pub mod prelude { pub use crate::actions::*; pub use crate::agent::*; pub use crate::ai::*; + pub use crate::clock::now; pub use crate::job_context::{JobContext, JobResult}; pub use crate::models::*; pub use crate::query::*; diff --git a/crates/sdk/src/models.rs b/crates/sdk/src/models.rs index f5cd7879d..57e47edad 100644 --- a/crates/sdk/src/models.rs +++ b/crates/sdk/src/models.rs @@ -8,16 +8,22 @@ use crate::types::*; use serde::{de::DeserializeOwned, Serialize}; -/// Marker trait for extension-defined data models +/// An extension-defined data model, implemented by `#[model]`. /// -/// Models are stored in the `models` database table and can be: -/// - Content-scoped: Attached to a ContentIdentity (PhotoAnalysis) -/// - Standalone: Independent entities (Person, Album, Place) -/// - Entry-scoped: Tied to a specific path (rare) +/// A model is a record in the extension's own store with a facet table of +/// the struct's fields. It is either standalone, addressed by its own uuid +/// (Person, Album, Place), or content-scoped, addressed by the content it +/// describes (an analysis of a photo). `plugin_init` declares every model +/// listed in `#[extension(models = [...])]` to the host, which creates or +/// widens the facet table before any job runs. pub trait ExtensionModel: Serialize + DeserializeOwned + Send + Sync { /// Model type name (e.g., "Person", "Album") const MODEL_TYPE: &'static str; + /// The facet definition the host registers: the fields and their column + /// types, as JSON. Derived from the struct by `#[model]`. + const DEFINITION: &'static str; + /// Get the model's UUID fn uuid(&self) -> Uuid; diff --git a/crates/sdk/src/tasks.rs b/crates/sdk/src/tasks.rs index 9895e8ad1..a0c100928 100644 --- a/crates/sdk/src/tasks.rs +++ b/crates/sdk/src/tasks.rs @@ -1,14 +1,103 @@ //! Task system for job composition //! -//! Stubs for type-checking. Implementation will call WASM host functions. +//! A task is a unit of work a job runs through [`crate::job_context::JobContext::run`]. +//! The `#[task]` attribute turns an async function into a unit struct that +//! implements [`Task`], carrying the retry and timeout policy the attribute +//! declares. The policy is enforced by the host: `task.begin` hands the host +//! the deadline, and every host operation the task makes after the deadline +//! fails with [`Error::Timeout`], which is the only way a single-threaded +//! guest can be stopped mid-task. Retries are the guest's loop over attempts. + +use serde::{Deserialize, Serialize}; use crate::types::*; use crate::{ai::AiContext, vdfs::VdfsContext}; +/// A unit of work with a retry and timeout policy. +/// +/// Implemented by the `#[task]` attribute; extensions do not implement it by +/// hand. +pub trait Task { + type Args; + type Output; + + /// The function's name, as the job log reports it. + const NAME: &'static str; + /// Attempts after the first one, on a retryable error. + const RETRIES: u32; + /// Deadline per attempt; 0 means none. + const TIMEOUT_MS: u64; + + async fn call(ctx: TaskContext, args: Self::Args) -> TaskResult; +} + +/// What the guest tells the host when a task attempt starts. +#[derive(Serialize)] +pub(crate) struct TaskBegin<'a> { + pub name: &'a str, + pub attempt: u32, + pub retries: u32, + pub timeout_ms: u64, +} + +/// What the guest tells the host when an attempt ends. +#[derive(Serialize)] +pub(crate) struct TaskEnd<'a> { + pub name: &'a str, + pub attempt: u32, + pub error: Option<&'a str>, +} + +#[derive(Deserialize)] +pub(crate) struct TaskHandle { + pub id: u32, +} + +/// Run a task to completion, retrying on retryable errors as its policy says. +pub(crate) async fn run(args: T::Args) -> Result +where + T::Args: Clone, +{ + let mut attempt = 0; + loop { + let handle: TaskHandle = crate::ffi::op_json( + "task.begin", + &TaskBegin { + name: T::NAME, + attempt, + retries: T::RETRIES, + timeout_ms: T::TIMEOUT_MS, + }, + )?; + let result = T::call(TaskContext { task_id: handle.id }, args.clone()).await; + let error = result.as_ref().err().map(|e| e.to_string()); + let _: serde_json::Value = crate::ffi::op_json( + "task.end", + &TaskEnd { + name: T::NAME, + attempt, + error: error.as_deref(), + }, + )?; + match result { + Ok(output) => return Ok(output), + Err(error) if attempt < T::RETRIES && error.is_retryable() => attempt += 1, + Err(error) => return Err(error), + } + } +} + /// Task execution context -pub struct TaskContext; +pub struct TaskContext { + task_id: u32, +} impl TaskContext { + /// The host's handle for this attempt. + pub fn task_id(&self) -> u32 { + self.task_id + } + /// Access VDFS pub fn vdfs(&self) -> VdfsContext { VdfsContext @@ -19,9 +108,12 @@ impl TaskContext { AiContext } - /// Access extension config - pub fn config(&self) -> &C { - panic!("Access config") + /// The extension's configuration: `config.json` beside its manifest, + /// or `{}` when there is none, so a config type with serde defaults + /// always loads. A config that does not deserialize is a panic, since + /// the job cannot do anything sensible without it. + pub fn config(&self) -> &C { + crate::config::load() } /// Read sidecar data @@ -30,7 +122,7 @@ impl TaskContext { content_uuid: Uuid, kind: &str, ) -> Result { - panic!("WASM host call") + VdfsContext.read_sidecar(content_uuid, kind).await } } diff --git a/crates/sdk/src/types.rs b/crates/sdk/src/types.rs index 4dc1d0368..2d333cdf3 100644 --- a/crates/sdk/src/types.rs +++ b/crates/sdk/src/types.rs @@ -32,6 +32,36 @@ pub enum Error { #[error("Not found")] NotFound, + + /// The host has no provider for what was asked: no model of that kind is + /// installed, no tool is present. The call was understood and refused. + #[error("Not available: {0}")] + NotAvailable(String), + + /// The SDK declares this call but no host function backs it yet. + #[error("Unsupported by this host: {0}")] + Unsupported(String), + + /// A task ran past the timeout its `#[task]` attribute declares. + #[error("Timed out: {0}")] + Timeout(String), + + /// The job was asked to pause or cancel. + #[error("Interrupted")] + Interrupted, +} + +impl Error { + /// Whether a task that failed with this error is worth running again. + /// + /// A refusal, a missing provider or an interrupt will come back the same; + /// a timeout or a failed operation might not. + pub fn is_retryable(&self) -> bool { + matches!( + self, + Error::Timeout(_) | Error::OperationFailed(_) | Error::HostCall(_) + ) + } } /// Result type for SDK operations @@ -46,63 +76,72 @@ pub type JobResult = std::result::Result; /// Query result type pub type QueryResult = std::result::Result; -/// Entry in VDFS (file, directory, or virtual) +/// A record in a source store: a file, directory or symlink an ingest saw. +/// +/// This is the SDK's view of `sd_store::Record` with its filesystem facet. +/// Records replace the old `Entry`: a record's identity survives a move, its +/// bytes are identified by `content_uuid` once hashing has reached them, and +/// its path is relative to the source that holds it. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Entry { - pub id: Uuid, - pub uuid: Option, +pub struct Record { + pub uuid: Uuid, + /// The source whose store holds this record. + pub source_id: Uuid, pub name: String, - pub kind: EntryKind, + pub kind: RecordKind, + /// Lowercase, without the dot. pub extension: Option, - pub metadata_id: Option, - pub content_id: Option, - pub size: i64, + /// Relative to the source root, `/`-separated. + pub relative_path: String, + pub size: Option, + /// Unix milliseconds. + pub modified_ms: Option, + /// The identity of the bytes, once the hash job has reached them. + pub content_uuid: Option, } -impl Entry { - /// Get entry UUID +impl Record { + /// The record uuid. pub fn id(&self) -> Uuid { - self.uuid.unwrap_or(self.id) + self.uuid } - /// Get content UUID (for content-scoped operations) + /// The identity of the bytes, for content-scoped sidecars and models. + /// `None` means the file has not been hashed yet. pub fn content_uuid(&self) -> Option { - panic!("WASM host call not implemented") + self.content_uuid } - /// Get metadata ID - pub fn metadata_id(&self) -> i32 { - self.metadata_id.unwrap_or(0) - } - - /// Get entry name pub fn name(&self) -> &str { &self.name } - /// Get entry path - pub fn path(&self) -> String { - panic!("WASM host call not implemented") + /// The source-relative path. + pub fn path(&self) -> &str { + &self.relative_path } - /// Read entry data + /// The record's bytes, read through the source's resolved path. + /// + /// Needs the `read_records` grant for this record's extension; fails + /// with `NotFound` when the source is detached. pub async fn read(&self) -> Result> { - panic!("WASM host call not implemented") + crate::vdfs::VdfsContext.read_record(self.uuid).await } - /// Get custom field from entry's metadata + /// Get custom field from the record's metadata pub fn custom_field(&self, field: &str) -> Result { - panic!("WASM host call not implemented") + Err(Error::Unsupported("custom_field".into())) } } -/// Entry kind +/// What the ingest found at the record's path. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum EntryKind { - File = 0, - Directory = 1, - Symlink = 2, - Virtual = 3, +#[serde(rename_all = "lowercase")] +pub enum RecordKind { + File, + Directory, + Symlink, } /// Tag diff --git a/crates/sdk/src/vdfs.rs b/crates/sdk/src/vdfs.rs index a96d357da..c910e78d7 100644 --- a/crates/sdk/src/vdfs.rs +++ b/crates/sdk/src/vdfs.rs @@ -1,6 +1,9 @@ -//! VDFS operations - queries, models, tags, collections +//! VDFS operations - records, models, tags, collections //! -//! Stubs for type-checking. Implementation will call WASM host functions. +//! Records are the files the library's source stores hold; see +//! [`crate::types::Record`]. Reading them needs the manifest's `read_records` +//! grant. Model and tag operations are stubs until a host function backs +//! them. use crate::types::*; use serde::{de::DeserializeOwned, Serialize}; @@ -9,35 +12,91 @@ use serde::{de::DeserializeOwned, Serialize}; pub struct VdfsContext; impl VdfsContext { - /// Query entries (files/directories) - pub fn query_entries(&self) -> EntryQuery { - EntryQuery::default() + /// Query records (files and directories) across the library's sources. + pub fn query_records(&self) -> RecordQuery { + RecordQuery::default() } - /// Get a specific entry by UUID - pub async fn get_entry(&self, uuid: Uuid) -> Result { - panic!("WASM host call not implemented") + /// One record by uuid, from whichever source store holds it. + pub async fn get_record(&self, uuid: Uuid) -> Result { + crate::ffi::op_json("records.get", &serde_json::json!({ "uuid": uuid })) + } + + /// A record's bytes, through the source's resolved path. + pub async fn read_record(&self, uuid: Uuid) -> Result> { + let payload = serde_json::to_vec(&serde_json::json!({ "uuid": uuid }))?; + crate::ffi::op("records.read", &payload) + } + + /// Whether a sidecar of one of this extension's kinds exists for the + /// content. + pub fn sidecar_exists(&self, content_uuid: Uuid, kind: &str) -> Result { + crate::ffi::op_json( + "sidecars.exists", + &serde_json::json!({ "content_uuid": content_uuid, "kind": kind }), + ) + } + + /// A sidecar this extension may read (`read_sidecars` or its own + /// `write_sidecars` kinds), decoded from JSON. + pub async fn read_sidecar( + &self, + content_uuid: Uuid, + kind: &str, + ) -> Result { + crate::ffi::op_json( + "sidecars.read", + &serde_json::json!({ "content_uuid": content_uuid, "kind": kind }), + ) + } + + /// Write a sidecar of one of this extension's `write_sidecars` kinds, as + /// JSON, replacing any earlier one for the same content. + pub async fn write_sidecar( + &self, + content_uuid: Uuid, + kind: &str, + data: &T, + ) -> Result<()> { + let _: serde_json::Value = crate::ffi::op_json( + "sidecars.write", + &serde_json::json!({ "content_uuid": content_uuid, "kind": kind, "data": data }), + )?; + Ok(()) } /// Query extension models pub fn query_models(&self) -> ModelQuery { ModelQuery { + filtered: false, + limit: None, _phantom: std::marker::PhantomData, } } /// Get model scoped to content_identity pub async fn get_model_by_content(&self, content_uuid: Uuid) -> Result { - panic!("WASM host call") + crate::ffi::op_json( + "models.get", + &serde_json::json!({ "model": T::MODEL_TYPE, "content_uuid": content_uuid }), + ) } - /// Create model scoped to content_identity + /// Create (or replace) the model describing one content identity. pub async fn create_model_for_content( &self, content_uuid: Uuid, model: T, ) -> Result<()> { - panic!("WASM host call") + let _: serde_json::Value = crate::ffi::op_json( + "models.put", + &serde_json::json!({ + "model": T::MODEL_TYPE, + "content_uuid": content_uuid, + "data": model, + }), + )?; + Ok(()) } /// Update model scoped to content @@ -49,17 +108,30 @@ impl VdfsContext { where F: FnOnce(T) -> Result, { - panic!("WASM host call") + let current = self.get_model_by_content::(content_uuid).await?; + self.create_model_for_content(content_uuid, f(current)?) + .await } - /// Create standalone model + /// Create (or replace) a standalone model, keyed by its own uuid. pub async fn create_model(&self, model: T) -> Result<()> { - panic!("WASM host call") + let _: serde_json::Value = crate::ffi::op_json( + "models.put", + &serde_json::json!({ + "model": T::MODEL_TYPE, + "uuid": model.uuid(), + "data": model, + }), + )?; + Ok(()) } /// Get standalone model by UUID pub async fn get_model(&self, uuid: Uuid) -> Result { - panic!("WASM host call") + crate::ffi::op_json( + "models.get", + &serde_json::json!({ "model": T::MODEL_TYPE, "uuid": uuid }), + ) } /// Update standalone model @@ -67,126 +139,175 @@ impl VdfsContext { where F: FnOnce(T) -> Result, { - panic!("WASM host call") + let current = self.get_model::(uuid).await?; + self.create_model(f(current)?).await } /// Add tag to content (all entries with this content get the tag) pub async fn add_tag_to_content(&self, content_uuid: Uuid, tag: &str) -> Result<()> { - panic!("WASM host call") + Err(Error::Unsupported("add_tag_to_content".into())) } /// Add tag to model pub async fn add_tag_to_model(&self, model_uuid: Uuid, tag: &str) -> Result<()> { - panic!("WASM host call") + Err(Error::Unsupported("add_tag_to_model".into())) } - /// Add tag to specific entry - pub async fn add_tag(&self, metadata_id: i32, tag: &str) -> Result<()> { - panic!("WASM host call") + /// Add tag to one record + pub async fn add_tag(&self, record_uuid: Uuid, tag: &str) -> Result<()> { + Err(Error::Unsupported("add_tag".into())) } /// Update custom field in UserMetadata pub async fn update_custom_field( &self, - entry_uuid: Uuid, + record_uuid: Uuid, field: &str, value: T, ) -> Result<()> { - panic!("WASM host call") + Err(Error::Unsupported("update_custom_field".into())) } - /// Check if entry is in user-granted scope + /// Check if a path is in user-granted scope pub fn in_granted_scope(&self, path: &str) -> bool { panic!("WASM host call") } } -/// Entry query builder -#[derive(Default)] -pub struct EntryQuery { - // Query state +/// Record query builder. +/// +/// Filters narrow the records a `collect` returns; the host answers from the +/// source stores of the job's library, newest sources first, in the order +/// each store walks its tree. +#[derive(Default, Serialize)] +pub struct RecordQuery { + source: Option, + scope: Option, + extensions: Option>, + tag: Option, + limit: Option, } -impl EntryQuery { - pub fn in_location(self, path: impl Into) -> Self { - panic!("Build query") +impl RecordQuery { + /// Only records of one source. + pub fn in_source(mut self, source: Uuid) -> Self { + self.source = Some(source); + self } - pub fn of_type(self) -> Self { - panic!("Filter by type") + /// Only records beneath a source-relative directory; "" is the root. + pub fn in_location(mut self, path: impl Into) -> Self { + self.scope = Some(path.into()); + self } - pub fn where_content_id(self, content_uuid: Uuid) -> Self { - panic!("Filter by content") + /// Only files with one of these extensions (case-insensitive). + pub fn with_extensions(mut self, extensions: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.extensions = Some( + extensions + .into_iter() + .map(|e| e.into().to_lowercase()) + .collect(), + ); + self } - pub fn on_this_device(self) -> Self { - panic!("Filter to local entries") + /// Only files whose extension `T` names; see [`Image`] and [`Pdf`]. + pub fn of_type(self) -> Self { + self.with_extensions(T::EXTENSIONS.iter().copied()) } - pub fn with_tag(self, tag: &str) -> Self { - panic!("Filter by tag") + /// Only records carrying this tag. No host side yet: a query with a tag + /// filter is refused rather than answered without it. + pub fn with_tag(mut self, tag: &str) -> Self { + self.tag = Some(tag.to_string()); + self } - pub fn with_sidecar(self, kind: &str) -> Self { - panic!("Filter by sidecar existence") + /// At most this many records; the host caps it at 10,000. + pub fn limit(mut self, limit: usize) -> Self { + self.limit = Some(limit); + self } pub fn where_metadata(self, field: &str, predicate: FieldPredicate) -> Self { panic!("Filter by metadata field") } - pub async fn first(self) -> Result> { - panic!("Execute query") + pub async fn first(self) -> Result> { + Ok(self.limit(1).collect().await?.into_iter().next()) } - pub async fn collect(self) -> Result> { - panic!("Execute query") - } - - pub fn map(self, f: F) -> MappedQuery - where - F: Fn(Entry) -> T, - { - panic!("Map results") + pub async fn collect(self) -> Result> { + crate::ffi::op_json("records.query", &self) } } -/// Mapped query results -pub struct MappedQuery { - _phantom: std::marker::PhantomData, +/// A file type a query can ask for by name. +pub trait FileType { + const EXTENSIONS: &'static [&'static str]; } -impl MappedQuery { - pub async fn collect(self) -> Result> { - panic!("Execute and map") - } +impl FileType for Image { + const EXTENSIONS: &'static [&'static str] = &[ + "jpg", "jpeg", "png", "gif", "webp", "heic", "heif", "tiff", "tif", "bmp", "avif", "raw", + "cr2", "nef", "dng", "arw", + ]; } -/// Model query builder +impl FileType for Pdf { + const EXTENSIONS: &'static [&'static str] = &["pdf"]; +} + +/// Model query builder. +/// +/// Only an unfiltered listing has a host side; a query with a field or +/// semantic filter is refused rather than answered without it. pub struct ModelQuery { + filtered: bool, + limit: Option, _phantom: std::marker::PhantomData, } impl ModelQuery { - pub fn where_field(self, field: &str, predicate: FieldPredicate) -> Self { - panic!("Filter by field") + pub fn where_field(mut self, field: &str, predicate: FieldPredicate) -> Self { + self.filtered = true; + self } - pub fn where_json_field(self, path: &str, predicate: FieldPredicate) -> Self { - panic!("Filter by JSON field") + pub fn where_json_field(mut self, path: &str, predicate: FieldPredicate) -> Self { + self.filtered = true; + self } - pub fn search_semantic(self, field: &str, query: SemanticQuery) -> Self { - panic!("Semantic search") + pub fn search_semantic(mut self, field: &str, query: SemanticQuery) -> Self { + self.filtered = true; + self + } + + pub fn limit(mut self, limit: usize) -> Self { + self.limit = Some(limit); + self } pub async fn first(self) -> Result> { - panic!("Execute query") + Ok(self.limit(1).collect().await?.into_iter().next()) } pub async fn collect(self) -> Result> { - panic!("Execute query") + if self.filtered { + return Err(Error::Unsupported( + "model queries cannot filter yet; list and filter in the guest".into(), + )); + } + crate::ffi::op_json( + "models.list", + &serde_json::json!({ "model": T::MODEL_TYPE, "limit": self.limit }), + ) } } diff --git a/crates/sidecar-path/src/lib.rs b/crates/sidecar-path/src/lib.rs index 833b5bcb3..449fc21b3 100644 --- a/crates/sidecar-path/src/lib.rs +++ b/crates/sidecar-path/src/lib.rs @@ -29,6 +29,12 @@ pub fn kind_directory(kind: &str) -> Option<&'static str> { } } +/// Directory for a kind an extension declared: under `extensions//` so +/// an extension's kinds never collide with the core's or another's. +pub fn extension_kind_directory(extension_id: &str, kind: &str) -> String { + format!("extensions/{extension_id}/{kind}") +} + /// Shard directories for a content UUID: the first two byte-pairs of the /// lowercase hex representation with hyphens removed. pub fn compute_shards(content_uuid: &Uuid) -> (String, String) { @@ -72,6 +78,22 @@ mod tests { ); } + #[test] + fn extension_kinds_sit_under_their_extension() { + let uuid = Uuid::parse_str("abcd1234-5678-90ab-cdef-123456789012").unwrap(); + assert_eq!( + relative_path( + &uuid, + &extension_kind_directory("com.spacedrive.photos", "faces"), + "default", + "json" + ), + PathBuf::from( + "content/ab/cd/abcd1234-5678-90ab-cdef-123456789012/extensions/com.spacedrive.photos/faces/default.json" + ) + ); + } + #[test] fn irregular_plurals_map_correctly() { assert_eq!(kind_directory("proxy"), Some("proxies")); diff --git a/crates/store/src/db.rs b/crates/store/src/db.rs index 447e774b5..da28aba31 100644 --- a/crates/store/src/db.rs +++ b/crates/store/src/db.rs @@ -14,7 +14,7 @@ use crate::content::ContentId; use crate::error::{Error, Result}; use crate::record::{facet_table, ContentIdentity, Record}; use crate::schema::codegen::indexed_search_fields; -use crate::schema::DataTypeSchema; +use crate::schema::{DataTypeSchema, FieldType}; /// `_sync_state` key holding the on-disk root a file-backed source's /// locator paths are relative to. File-backed adapters set it every sync; @@ -904,6 +904,79 @@ impl SourceDb { sql } + /// The declared fields of one model's records as JSON objects, typed back + /// the way they went in: a `json` field is parsed, a `boolean` is a bool, + /// numbers are numbers. `external_id` narrows to one record. Newest first. + /// + /// This is how an extension reads its own models back; the facet row is + /// the model, so nothing outside the declared fields comes out. + pub async fn facet_rows( + &self, + model: &str, + external_id: Option<&str>, + limit: usize, + ) -> Result> { + let model_def = self + .schema + .models + .get(model) + .ok_or_else(|| Error::Other(format!("unknown model: {model}")))?; + let table = facet_table(model); + let mut pairs = String::new(); + for name in model_def.fields.keys() { + if !pairs.is_empty() { + pairs.push_str(", "); + } + let _ = write!(pairs, "'{name}', f.\"{name}\""); + } + let pairs = if pairs.is_empty() { + String::new() + } else { + format!("json_object({pairs})") + }; + let sql = format!( + "SELECT r.uuid, {pairs} FROM record r \ + LEFT JOIN \"{table}\" f ON f.record_uuid = r.uuid \ + WHERE r.type = ?1 AND (?2 IS NULL OR r.external_id = ?2) \ + ORDER BY COALESCE(r.modified_at, r.created_at) DESC, r.rowid DESC \ + LIMIT ?3" + ); + let rows = sqlx::query_as::<_, (Uuid, Option)>(&sql) + .bind(model) + .bind(external_id) + .bind(limit as i64) + .fetch_all(&self.pool) + .await?; + + Ok(rows + .into_iter() + .filter_map(|(_, json)| { + let mut value: serde_json::Value = serde_json::from_str(&json?).ok()?; + let object = value.as_object_mut()?; + for (name, field_type) in &model_def.fields { + let Some(stored) = object.get_mut(name) else { + continue; + }; + match field_type { + FieldType::Json => { + if let Some(text) = stored.as_str() { + *stored = + serde_json::from_str(text).unwrap_or(serde_json::Value::Null); + } + } + FieldType::Boolean => { + if let Some(n) = stored.as_i64() { + *stored = serde_json::Value::Bool(n != 0); + } + } + _ => {} + } + } + Some(value) + }) + .collect()) + } + /// List items of the primary record type, newest first. /// Full record rows for the primary model — every facet field included — /// as JSON objects, newest first. Powers presentation surfaces that need diff --git a/extensions/README.md b/extensions/README.md index 7b22aa34b..fbd207828 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -81,12 +81,23 @@ cp target/wasm32-unknown-unknown/release/my_extension.wasm . "version": "0.1.0", "wasm_file": "my_extension.wasm", "permissions": { - "methods": ["vdfs.*", "ai.*"], - "libraries": ["*"] + "read_records": { "glob": "**/*.{jpg,png}" }, + "read_sidecars": ["exif"], + "write_sidecars": ["digest"], + "use_models": [{ "category": "face_detection", "preference": "local" }] } } ``` +The host rejects a manifest with a field it does not know, so a typo in a +grant fails at load. `read_records` is the only way to see files; its optional +`glob` is honored for its trailing extension list only (`*.jpg` or +`**/*.{jpg,png}`); the directory part is ignored and a glob of another shape +is refused at load. `write_sidecars` names the kinds +the extension may write; `use_models` the inference categories it may ask for. +`write_tags`, `write_custom_fields` and `dispatch_jobs` are declared but have +no host side yet. + ## The Beautiful API ### Before Macros (Manual FFI): @@ -157,8 +168,8 @@ fn email_scan(ctx: &JobContext, state: &mut EmailScanState) -> Result<()> { ctx.add_warning("Non-fatal issue"); // Full SDK access - let entry = ctx.vdfs().create_entry(...)?; - let ocr = ctx.ai().ocr(&pdf, ...)?; + let record = ctx.vdfs().get_record(uuid).await?; + let bytes = record.read().await?; Ok(()) } @@ -167,32 +178,42 @@ fn email_scan(ctx: &JobContext, state: &mut EmailScanState) -> Result<()> { ### VDFS Operations ```rust -// Create entries -let entry = ctx.vdfs().create_entry(CreateEntry { - name: "My File".into(), - path: "path/to/file".into(), - entry_type: "Document".into(), - metadata: None, -})?; +// Records of the library's sources, filtered by extension (read_records grant) +let photos = ctx.vdfs() + .query_records() + .with_extensions(["jpg", "png"]) + .collect() + .await?; -// Write sidecars -ctx.vdfs().write_sidecar(entry.id, "metadata.json", data)?; +// One record and its bytes +let record = ctx.vdfs().get_record(uuid).await?; +let bytes = record.read().await?; -// Read sidecars -let data = ctx.vdfs().read_sidecar(entry.id, "metadata.json")?; +// Sidecars are one JSON document per kind, keyed by content uuid +let content_uuid = record.content_uuid().expect("hashed"); +if !ctx.sidecar_exists(content_uuid, "digest")? { + ctx.save_sidecar(content_uuid, "digest", &digest).await?; +} +let digest: Digest = ctx.read_sidecar(content_uuid, "digest").await?; + +// Models declared with #[model] live in the extension's own store +ctx.vdfs().create_model(entry).await?; +let all: Vec = ctx.vdfs().query_models().collect().await?; ``` +Tags and custom fields have no host side yet and return `Error::NotAvailable`. + ### AI Operations +Inference goes through `ctx.ai().infer(...)` behind the `use_models` grant. +The core has no provider yet, so every request answers `Error::NotAvailable`; +handle it the way `extensions/photos/src/jobs/analyze.rs` does, with one +warning and a skip, so the job completes and a later run with a provider +picks the files up. + ```rust -// OCR -let ocr = ctx.ai().ocr(&pdf_bytes, OcrOptions::default())?; - -// Classification -let result = ctx.ai().classify_text(&text, "Extract data")?; - -// Embeddings -let embedding = ctx.ai().embed("query text")?; +// Written against the API; answers NotAvailable until a provider exists +let faces = ctx.ai().from_registered("face_detection").detect_faces(&bytes).await?; ``` ### Credentials diff --git a/extensions/photos/.cargo/config.toml b/extensions/photos/.cargo/config.toml index 01c5e7c9c..efbd85bab 100644 --- a/extensions/photos/.cargo/config.toml +++ b/extensions/photos/.cargo/config.toml @@ -1,7 +1,6 @@ [build] target = "wasm32-unknown-unknown" -# The guest has no entropy source yet: the SDK exposes no host function for -# it, so a random id fails at runtime instead of failing the build. +# The guest has no entropy of its own; the SDK routes getrandom to the host. [target.wasm32-unknown-unknown] -rustflags = ["--cfg", "getrandom_backend=\"unsupported\""] +rustflags = ["--cfg", "getrandom_backend=\"custom\""] diff --git a/extensions/photos/README.md b/extensions/photos/README.md index 09f1a09f8..f89e95422 100644 --- a/extensions/photos/README.md +++ b/extensions/photos/README.md @@ -116,16 +116,14 @@ User can search "#person:alice" or "photos from beach" └── resnet50.onnx (95MB) .sdlibrary/ - └── sidecars/ - ├── content/{uuid}/ - │ └── extensions/photos/ - │ ├── faces.json # Face detection results - │ ├── scene.json # Scene classification - │ └── aesthetics.json # Quality score - └── extension/photos/ - └── memory/ - ├── history.db # Photo analysis events - └── knowledge.vss # Face/place graph + ├── sidecars/ + │ └── content/{ab}/{cd}/{uuid}/ + │ └── extensions/com.spacedrive.photos/ + │ ├── faces/default.json # Face detection results + │ ├── scene/default.json # Scene classification + │ └── aesthetics/default.json # Quality score + └── extensions/com.spacedrive.photos/ + └── data.db # Person, Place, Moment, Album, Photo rows ``` ## SDK Features Demonstrated @@ -147,9 +145,24 @@ User can search "#person:alice" or "photos from beach" - Tag generation from sidecars - Custom memory query methods -### SDK Features Used (Not Yet Implemented in Core) +### What runs today -Most features here are aspirational - the SDK is still being built. This serves as a comprehensive reference implementation. +Under a daemon built with `--features wasm`, `analyze_photos` runs end to end +over a library: it reads each photo through its record, asks for face +detection, and because the core has no inference provider the host answers +`not_available`; the job warns once, skips, and completes, so a later run with +a detector picks the photos up. `core/tests/wasm_extension_test.rs` proves +this against a fixture library. + +### Not yet backed by the core + +- Inference. There is no provider for face detection, scene classification, + embeddings or language models, so `analyze_scenes`, `identify_places` and + `create_moments` have nothing to run on. +- Tags, custom fields and `dispatch_jobs`. +- Agents, actions and queries. +- Model downloads named in `manifest.json`. +- File-kind and preview registration. ## Capabilities Compared diff --git a/extensions/photos/manifest.json b/extensions/photos/manifest.json index f6985ea0b..347cbb380 100644 --- a/extensions/photos/manifest.json +++ b/extensions/photos/manifest.json @@ -12,7 +12,7 @@ "semantic_search" ], "permissions": { - "read_entries": { + "read_records": { "glob": "**/*.{jpg,jpeg,png,heic,heif,raw,cr2,nef,dng,webp}" }, "read_sidecars": [ diff --git a/extensions/photos/photos.wasm b/extensions/photos/photos.wasm index 7710e2848..cb48e53b1 100755 Binary files a/extensions/photos/photos.wasm and b/extensions/photos/photos.wasm differ diff --git a/extensions/photos/src/actions/create_album.rs b/extensions/photos/src/actions/create_album.rs index eab352787..77692c59b 100644 --- a/extensions/photos/src/actions/create_album.rs +++ b/extensions/photos/src/actions/create_album.rs @@ -1,6 +1,5 @@ use spacedrive_sdk::{action, action_execute}; -use chrono::Utc; use spacedrive_sdk::prelude::*; use uuid::Uuid; @@ -22,7 +21,7 @@ pub async fn create_album( name: name.clone(), photo_ids: photo_ids.clone(), cover_photo_id: photo_ids.first().cloned(), - created_at: Utc::now(), + created_at: spacedrive_sdk::clock::now(), album_type: AlbumType::Manual, })?, }], diff --git a/extensions/photos/src/agent/handlers.rs b/extensions/photos/src/agent/handlers.rs index b60fc0c34..dc3ec52df 100644 --- a/extensions/photos/src/agent/handlers.rs +++ b/extensions/photos/src/agent/handlers.rs @@ -21,10 +21,10 @@ impl crate::Photos { #[on_event(EntryCreated)] #[filter(".extension().is_image()")] - pub async fn on_new_photo(entry: Entry, ctx: &AgentContext) -> AgentResult<()> { - ctx.trace(format!("New photo detected: {}", entry.name())); + pub async fn on_new_photo(record: Record, ctx: &AgentContext) -> AgentResult<()> { + ctx.trace(format!("New photo detected: {}", record.name())); - if !ctx.in_granted_scope(&entry.path()) { + if !ctx.in_granted_scope(record.path()) { ctx.trace("Photo not in granted scope - skipping"); return Ok(()); } @@ -33,7 +33,7 @@ impl crate::Photos { memory .plan .update(|mut plan| { - plan.photos_needing_faces.push(entry.id()); + plan.photos_needing_faces.push(record.id()); Ok(plan) }) .await?; diff --git a/extensions/photos/src/jobs/analyze.rs b/extensions/photos/src/jobs/analyze.rs index e72201492..85438fa12 100644 --- a/extensions/photos/src/jobs/analyze.rs +++ b/extensions/photos/src/jobs/analyze.rs @@ -8,37 +8,61 @@ use crate::jobs::clustering::{cluster_faces_into_people, generate_face_tags}; use crate::tasks::*; #[derive(Serialize, Deserialize, Default)] +#[serde(default)] pub struct AnalyzePhotosState { pub photo_ids: Vec, pub current_index: usize, + /// Photos whose faces sidecar was written by this run. + pub analyzed: usize, + /// Photos skipped because they have no content identity yet or the face + /// detector is not available on this device. + pub skipped: usize, } +/// Detect faces in each photo, cluster them into people, and tag the photos. +/// +/// A photo that already has a faces sidecar is left alone, so the job is +/// safe to re-run over a growing set. When the host has no face detection +/// provider the detection step is skipped with one warning and the photo +/// keeps no sidecar, so a later run with a provider picks it up. #[job(name = "analyze_photos")] pub async fn analyze_photos_batch(ctx: &JobContext, state: &mut AnalyzePhotosState) -> Result<()> { ctx.progress(Progress::indeterminate("Analyzing photos for faces...")); let photo_ids = state.photo_ids.clone(); let total = photo_ids.len(); + let mut detector_missing = false; - for (idx, photo_id) in photo_ids.iter().enumerate() { - let photo = ctx.vdfs().get_entry(*photo_id).await?; + for (idx, photo_id) in photo_ids.iter().enumerate().skip(state.current_index) { + let photo = ctx.vdfs().get_record(*photo_id).await?; if let Some(content_uuid) = photo.content_uuid() { - if ctx.sidecar_exists(content_uuid, "faces")? { - continue; + if !ctx.sidecar_exists(content_uuid, "faces")? { + match ctx.run(detect_faces_in_photo, photo.clone()).await { + Ok(faces) => { + ctx.save_sidecar(content_uuid, "faces", &faces).await?; + state.analyzed += 1; + } + Err(Error::NotAvailable(reason)) => { + if !detector_missing { + ctx.add_warning(&format!("face detection skipped: {reason}")); + detector_missing = true; + } + state.skipped += 1; + } + Err(e) => return Err(e), + } } } else { - continue; - } - - let faces = ctx.run(detect_faces_in_photo, photo.clone()).await?; - - if let Some(content_uuid) = photo.content_uuid() { - ctx.save_sidecar(content_uuid, "faces", "photos", &faces) - .await?; + state.skipped += 1; } + state.current_index = idx + 1; + ctx.increment_items(1); ctx.check_interrupt().await?; + if state.current_index % 10 == 0 { + ctx.checkpoint(state)?; + } ctx.progress(Progress::simple( (idx + 1) as f32 / total as f32, diff --git a/extensions/photos/src/jobs/clustering.rs b/extensions/photos/src/jobs/clustering.rs index d616d4e35..210df26b5 100644 --- a/extensions/photos/src/jobs/clustering.rs +++ b/extensions/photos/src/jobs/clustering.rs @@ -1,4 +1,4 @@ -use spacedrive_sdk::{job, task}; +use spacedrive_sdk::task; use spacedrive_sdk::prelude::*; use spacedrive_sdk::tasks::TaskContext; @@ -12,7 +12,7 @@ pub async fn cluster_faces_into_people(ctx: TaskContext, photo_ids: Vec) - let mut all_faces: Vec<(Uuid, FaceDetection)> = Vec::new(); for photo_id in &photo_ids { - let photo = ctx.vdfs().get_entry(*photo_id).await?; + let photo = ctx.vdfs().get_record(*photo_id).await?; if let Some(content_uuid) = photo.content_uuid() { if let Ok(faces) = ctx .read_sidecar::>(content_uuid, "faces") @@ -47,14 +47,14 @@ pub async fn cluster_faces_into_people(ctx: TaskContext, photo_ids: Vec) - #[task] pub async fn generate_face_tags(ctx: TaskContext, photo_ids: Vec) -> TaskResult<()> { for photo_id in &photo_ids { - let photo = ctx.vdfs().get_entry(*photo_id).await?; + let photo = ctx.vdfs().get_record(*photo_id).await?; if let Ok(people) = photo.custom_field::>("identified_people") { for person_id in people { if let Ok(person) = ctx.vdfs().get_model::(person_id).await { if let Some(name) = person.name { ctx.vdfs() - .add_tag(photo.metadata_id(), &format!("#person:{}", name)) + .add_tag(photo.id(), &format!("#person:{}", name)) .await?; } } @@ -65,6 +65,60 @@ pub async fn generate_face_tags(ctx: TaskContext, photo_ids: Vec) -> TaskR Ok(()) } +/// The person whose representative embedding is nearest the cluster's +/// centroid, when it is within the clustering threshold; otherwise a new, +/// unnamed person built from the cluster. async fn find_or_create_person(ctx: &TaskContext, cluster: &FaceCluster) -> TaskResult { - todo!("Implement person matching") + let threshold = ctx + .config::() + .face_clustering_threshold; + let people = ctx.vdfs().query_models::().collect().await?; + let nearest = people + .iter() + .filter(|person| !person.representative_embedding.is_empty()) + .map(|person| { + ( + person, + cosine_distance( + &person.representative_embedding, + &cluster.centroid_embedding, + ), + ) + }) + .filter(|(_, distance)| *distance <= threshold) + .min_by(|a, b| a.1.total_cmp(&b.1)); + + if let Some((person, _)) = nearest { + let embeddings: Vec> = cluster + .faces + .iter() + .map(|(_, face)| face.embedding.clone()) + .collect(); + let photos = cluster.faces.len(); + ctx.vdfs() + .update_model(person.id, |mut p: Person| { + p.embeddings.extend(embeddings); + p.photo_count += photos; + p.representative_embedding = mean_embedding(&p.embeddings); + Ok(p) + }) + .await?; + return Ok(person.id); + } + + let person = Person { + id: Uuid::new_v4(), + name: None, + thumbnail_photo_id: cluster.faces.first().map(|(photo, _)| *photo), + embeddings: cluster + .faces + .iter() + .map(|(_, face)| face.embedding.clone()) + .collect(), + photo_count: cluster.faces.len(), + representative_embedding: cluster.centroid_embedding.clone(), + }; + let id = person.id; + ctx.vdfs().create_model(person).await?; + Ok(id) } diff --git a/extensions/photos/src/jobs/moments.rs b/extensions/photos/src/jobs/moments.rs index 7cad36b49..000cc2155 100644 --- a/extensions/photos/src/jobs/moments.rs +++ b/extensions/photos/src/jobs/moments.rs @@ -1,6 +1,5 @@ use spacedrive_sdk::job; -use chrono::Utc; use serde::{Deserialize, Serialize}; use spacedrive_sdk::prelude::*; use spacedrive_sdk::types::JobResult; diff --git a/extensions/photos/src/jobs/places.rs b/extensions/photos/src/jobs/places.rs index 5224cd302..b4e739430 100644 --- a/extensions/photos/src/jobs/places.rs +++ b/extensions/photos/src/jobs/places.rs @@ -22,7 +22,7 @@ pub async fn identify_places_in_location( let photos = ctx .vdfs() - .query_entries() + .query_records() .in_location(location) .of_type::() .where_metadata("exif.gps", is_not_null()) @@ -50,7 +50,7 @@ pub async fn identify_places_in_location( .await?; ctx.vdfs() - .add_tag(photo.metadata_id(), &format!("#place:{}", place.name)) + .add_tag(photo.id(), &format!("#place:{}", place.name)) .await?; } } diff --git a/extensions/photos/src/jobs/scenes.rs b/extensions/photos/src/jobs/scenes.rs index d0d52ed61..9fc800966 100644 --- a/extensions/photos/src/jobs/scenes.rs +++ b/extensions/photos/src/jobs/scenes.rs @@ -16,13 +16,12 @@ pub struct AnalyzeScenesState { #[job(name = "analyze_scenes")] pub async fn analyze_scenes(ctx: &JobContext, state: &mut AnalyzeScenesState) -> JobResult<()> { for photo_id in &state.photo_ids { - let photo = ctx.vdfs().get_entry(*photo_id).await?; + let photo = ctx.vdfs().get_record(*photo_id).await?; let scenes = ctx.run(classify_scene, photo.clone()).await?; if let Some(content_uuid) = photo.content_uuid() { - ctx.save_sidecar(content_uuid, "scene", "photos", &scenes) - .await?; + ctx.save_sidecar(content_uuid, "scene", &scenes).await?; } for scene in &scenes { @@ -32,7 +31,7 @@ pub async fn analyze_scenes(ctx: &JobContext, state: &mut AnalyzeScenesState) -> .scene_confidence_threshold { ctx.vdfs() - .add_tag(photo.metadata_id(), &format!("#scene:{}", scene.label)) + .add_tag(photo.id(), &format!("#scene:{}", scene.label)) .await?; } } diff --git a/extensions/photos/src/lib.rs b/extensions/photos/src/lib.rs index babbe0021..e183ee421 100644 --- a/extensions/photos/src/lib.rs +++ b/extensions/photos/src/lib.rs @@ -32,6 +32,7 @@ use spacedrive_sdk::{extension, prelude::*}; name = "Photos", version = "1.0.0", jobs = [analyze_photos_batch, create_moments, identify_places_in_location, analyze_scenes], + models = [Person, Place, Moment, Album, Photo], description = "Advanced photo management with faces, places, and intelligent organization", min_core_version = "2.0.0", required_features = ["exif_extraction", "ai_models"], diff --git a/extensions/photos/src/models/photo.rs b/extensions/photos/src/models/photo.rs index dff2a996a..547cbe807 100644 --- a/extensions/photos/src/models/photo.rs +++ b/extensions/photos/src/models/photo.rs @@ -13,7 +13,7 @@ pub struct Photo { pub id: Uuid, #[entry(filter = "*.{jpg,jpeg,png,heic,heif,raw,cr2,nef,dng}")] - pub file: Entry, + pub file: Record, #[metadata] pub exif: Option, @@ -47,10 +47,10 @@ pub struct Photo { } impl Photo { - pub fn from_entry(entry: Entry) -> Self { + pub fn from_record(record: Record) -> Self { Self { - id: entry.id(), - file: entry.clone(), + id: record.id(), + file: record.clone(), exif: None, detected_faces: None, scene_tags: None, diff --git a/extensions/photos/src/queries/search_scene.rs b/extensions/photos/src/queries/search_scene.rs index d4101d77b..3b10d83a7 100644 --- a/extensions/photos/src/queries/search_scene.rs +++ b/extensions/photos/src/queries/search_scene.rs @@ -10,11 +10,12 @@ pub async fn search_scene( ctx: &QueryContext, scene_type: String, ) -> QueryResult> { - ctx.vdfs() - .query_entries() + let records = ctx + .vdfs() + .query_records() .with_tag(&format!("#scene:{}", scene_type)) .of_type::() - .map(|entry| Photo::from_entry(entry)) .collect() - .await + .await?; + Ok(records.into_iter().map(Photo::from_record).collect()) } diff --git a/extensions/photos/src/tasks/classify_scene.rs b/extensions/photos/src/tasks/classify_scene.rs index 1bc5b8c94..840a93b56 100644 --- a/extensions/photos/src/tasks/classify_scene.rs +++ b/extensions/photos/src/tasks/classify_scene.rs @@ -5,7 +5,7 @@ use spacedrive_sdk::prelude::*; use crate::models::SceneTag; #[task(requires_capability = "gpu_optional")] -pub async fn classify_scene(ctx: TaskContext, photo: Entry) -> TaskResult> { +pub async fn classify_scene(ctx: TaskContext, photo: Record) -> TaskResult> { let image_bytes = photo.read().await?; let classifications = ctx diff --git a/extensions/photos/src/tasks/detect_faces.rs b/extensions/photos/src/tasks/detect_faces.rs index e2c8f277a..4e380fa59 100644 --- a/extensions/photos/src/tasks/detect_faces.rs +++ b/extensions/photos/src/tasks/detect_faces.rs @@ -7,7 +7,7 @@ use crate::models::{BoundingBox, FaceDetection}; #[task(retries = 2, timeout_ms = 30000, requires_capability = "gpu_optional")] pub async fn detect_faces_in_photo( ctx: TaskContext, - photo: Entry, + photo: Record, ) -> TaskResult> { let image_bytes = photo.read().await?; diff --git a/extensions/photos/src/utils/clustering.rs b/extensions/photos/src/utils/clustering.rs index 86d4fd532..798d95de5 100644 --- a/extensions/photos/src/utils/clustering.rs +++ b/extensions/photos/src/utils/clustering.rs @@ -4,11 +4,101 @@ use uuid::Uuid; use crate::agent::PhotoEvent; use crate::models::{FaceDetection, *}; -pub fn dbscan_clustering(faces: &[(Uuid, FaceDetection)], threshold: f32) -> Vec { - todo!("Implement DBSCAN clustering") +/// Cosine distance between two embeddings: 0 for identical directions, 1 +/// for orthogonal ones. Vectors of different lengths or zero length are as +/// far apart as possible. +pub fn cosine_distance(a: &[f32], b: &[f32]) -> f32 { + if a.len() != b.len() || a.is_empty() { + return 1.0; + } + let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum(); + let norm_a = a.iter().map(|x| x * x).sum::().sqrt(); + let norm_b = b.iter().map(|x| x * x).sum::().sqrt(); + if norm_a == 0.0 || norm_b == 0.0 { + return 1.0; + } + 1.0 - dot / (norm_a * norm_b) } -pub fn cluster_by_location(photos: &[Entry], radius_meters: f32) -> Vec { +/// The element-wise mean of a set of embeddings; empty when the set is. +pub fn mean_embedding(embeddings: &[Vec]) -> Vec { + let Some(first) = embeddings.first() else { + return Vec::new(); + }; + let mut mean = vec![0.0f32; first.len()]; + let mut count = 0usize; + for embedding in embeddings.iter().filter(|e| e.len() == first.len()) { + for (m, x) in mean.iter_mut().zip(embedding) { + *m += x; + } + count += 1; + } + for m in &mut mean { + *m /= count as f32; + } + mean +} + +/// DBSCAN over face embeddings with cosine distance, `min_points` of 2: +/// a face with no neighbour within `threshold` is noise and belongs to no +/// person. Quadratic in the number of faces, which is fine for one job's +/// batch and avoids an index the guest has no room for. +pub fn dbscan_clustering(faces: &[(Uuid, FaceDetection)], threshold: f32) -> Vec { + const MIN_POINTS: usize = 2; + let n = faces.len(); + let neighbours: Vec> = (0..n) + .map(|i| { + (0..n) + .filter(|&j| { + j != i + && cosine_distance(&faces[i].1.embedding, &faces[j].1.embedding) + <= threshold + }) + .collect() + }) + .collect(); + + let mut cluster_of = vec![None::; n]; + let mut clusters: Vec> = Vec::new(); + for seed in 0..n { + if cluster_of[seed].is_some() || neighbours[seed].len() + 1 < MIN_POINTS { + continue; + } + let id = clusters.len(); + let mut members = Vec::new(); + let mut frontier = vec![seed]; + cluster_of[seed] = Some(id); + while let Some(i) = frontier.pop() { + members.push(i); + if neighbours[i].len() + 1 < MIN_POINTS { + continue; + } + for &j in &neighbours[i] { + if cluster_of[j].is_none() { + cluster_of[j] = Some(id); + frontier.push(j); + } + } + } + clusters.push(members); + } + + clusters + .into_iter() + .map(|members| { + let faces: Vec<(Uuid, FaceDetection)> = + members.iter().map(|&i| faces[i].clone()).collect(); + let embeddings: Vec> = + faces.iter().map(|(_, f)| f.embedding.clone()).collect(); + FaceCluster { + centroid_embedding: mean_embedding(&embeddings), + faces, + } + }) + .collect() +} + +pub fn cluster_by_location(photos: &[Record], radius_meters: f32) -> Vec { todo!("Implement geographic clustering") } @@ -22,6 +112,6 @@ pub struct FaceCluster { } pub struct PlaceCluster { - pub photos: Vec, + pub photos: Vec, pub center: GpsCoordinates, } diff --git a/extensions/test-extension/.cargo/config.toml b/extensions/test-extension/.cargo/config.toml index 5155be886..4dad2754b 100644 --- a/extensions/test-extension/.cargo/config.toml +++ b/extensions/test-extension/.cargo/config.toml @@ -2,5 +2,6 @@ [build] target = "wasm32-unknown-unknown" +# The guest has no entropy of its own; the SDK routes getrandom to the host. [target.wasm32-unknown-unknown] -rustflags = ["-C", "link-arg=--export-table"] +rustflags = ["-C", "link-arg=--export-table", "--cfg", "getrandom_backend=\"custom\""] diff --git a/extensions/test-extension/Cargo.lock b/extensions/test-extension/Cargo.lock index c10ea93c4..f025e1103 100644 --- a/extensions/test-extension/Cargo.lock +++ b/extensions/test-extension/Cargo.lock @@ -8,6 +8,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + [[package]] name = "chrono" version = "0.4.45" @@ -18,12 +24,29 @@ dependencies = [ "serde", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "itoa" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + [[package]] name = "memchr" version = "2.7.6" @@ -57,6 +80,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "ryu" version = "1.0.20" @@ -111,6 +140,7 @@ name = "spacedrive-sdk" version = "0.1.0" dependencies = [ "chrono", + "getrandom", "serde", "serde_json", "spacedrive-sdk-macros", @@ -142,9 +172,11 @@ dependencies = [ name = "test-extension" version = "0.1.0" dependencies = [ + "chrono", "serde", "serde_json", "spacedrive-sdk", + "uuid", ] [[package]] @@ -179,5 +211,16 @@ version = "1.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" dependencies = [ + "getrandom", "serde_core", + "uuid-rng-internal", +] + +[[package]] +name = "uuid-rng-internal" +version = "1.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41792896a8681503d839b89f31aa4b0d6f752b0054ae86bab412ea09dcb9fc6c" +dependencies = [ + "getrandom", ] diff --git a/extensions/test-extension/Cargo.toml b/extensions/test-extension/Cargo.toml index 1ca732b95..a7600f497 100644 --- a/extensions/test-extension/Cargo.toml +++ b/extensions/test-extension/Cargo.toml @@ -13,6 +13,8 @@ crate-type = ["cdylib"] serde = { version = "1.0", default-features = false, features = ["alloc", "derive"] } serde_json = { version = "1.0", default-features = false, features = ["alloc"] } spacedrive-sdk = { path = "../../crates/sdk" } +chrono = { version = "0.4", default-features = false, features = ["serde"] } +uuid = { version = "1.0", default-features = false, features = ["serde", "v4", "rng-getrandom"] } [profile.release] codegen-units = 1 diff --git a/extensions/test-extension/manifest.json b/extensions/test-extension/manifest.json index 3791fc9c8..b5f548b3a 100644 --- a/extensions/test-extension/manifest.json +++ b/extensions/test-extension/manifest.json @@ -7,18 +7,9 @@ "homepage": "https://spacedrive.com", "wasm_file": "test_extension.wasm", "permissions": { - "methods": [ - "query:", - "action:" - ], - "libraries": [ - "*" - ], - "rate_limits": { - "requests_per_minute": 1000, - "concurrent_jobs": 10 - }, - "network_access": [], - "max_memory_mb": 256 + "read_records": {}, + "write_sidecars": [ + "digest" + ] } -} \ No newline at end of file +} diff --git a/extensions/test-extension/src/lib.rs b/extensions/test-extension/src/lib.rs index 349cc9922..e4761e174 100644 --- a/extensions/test-extension/src/lib.rs +++ b/extensions/test-extension/src/lib.rs @@ -4,7 +4,7 @@ //! extension development by abstracting FFI and state management details. use spacedrive_sdk::prelude::*; -use spacedrive_sdk::{extension, job}; +use spacedrive_sdk::{extension, job, model, task}; // Extension Definition // The #[extension] macro generates plugin_init() and plugin_cleanup(). @@ -14,7 +14,8 @@ use spacedrive_sdk::{extension, job}; id = "test-extension", name = "Test Extension", version = "0.1.0", - jobs = [test_counter], + jobs = [test_counter, catalog_files], + models = [CatalogEntry], )] struct TestExtension; @@ -74,3 +75,114 @@ fn test_counter(ctx: &JobContext, state: &mut CounterState) -> Result<()> { Ok(()) } + +// Catalog Job +// Exercises the data host functions: records, bytes, sidecars, models, the +// clock and entropy. Reads every file of the given extensions in the +// library, writes a `digest` sidecar per content and a CatalogEntry model +// per file, and skips files already digested so a second run is a no-op. + +/// One catalogued file, a standalone model keyed by its own id. +#[derive(Serialize, Deserialize, Clone)] +#[model(version = "1.0.0")] +pub struct CatalogEntry { + pub id: Uuid, + pub record: Uuid, + pub name: String, + pub size: u64, + pub first_bytes: Vec, + pub catalogued_at: chrono::DateTime, + pub seen_twice: bool, +} + +/// What the `digest` sidecar holds for one content identity. +#[derive(Serialize, Deserialize)] +pub struct Digest { + pub size: usize, + pub first_bytes: Vec, + pub record_name: String, +} + +#[derive(Serialize, Deserialize, Default)] +#[serde(default)] +pub struct CatalogState { + pub extensions: Vec, + pub digested: usize, + pub skipped: usize, + pub entries: usize, +} + +/// Read one record's bytes and describe them. The timeout is generous; the +/// retry covers a transient read failure. +#[task(retries = 1, timeout_ms = 10000)] +async fn digest_record(ctx: TaskContext, record: Record) -> TaskResult { + let bytes = record.read().await?; + let _ = ctx.task_id(); + Ok(Digest { + size: bytes.len(), + first_bytes: bytes.iter().take(4).copied().collect(), + record_name: record.name().to_string(), + }) +} + +#[job(name = "catalog")] +async fn catalog_files(ctx: &JobContext, state: &mut CatalogState) -> Result<()> { + let records = ctx + .vdfs() + .query_records() + .with_extensions(state.extensions.iter().cloned()) + .collect() + .await?; + ctx.log(&format!("Cataloguing {} records", records.len())); + + for record in records { + let Some(content_uuid) = record.content_uuid() else { + ctx.add_warning(&format!("{} has no content identity yet", record.name())); + state.skipped += 1; + continue; + }; + if ctx.sidecar_exists(content_uuid, "digest")? { + let existing: Digest = ctx.read_sidecar(content_uuid, "digest").await?; + ctx.log(&format!( + "{} already digested ({} bytes)", + record.name(), + existing.size + )); + state.skipped += 1; + continue; + } + let digest = ctx.run(digest_record, record.clone()).await?; + ctx.save_sidecar(content_uuid, "digest", &digest).await?; + state.digested += 1; + + let entry = CatalogEntry { + id: Uuid::new_v4(), + record: record.id(), + name: record.name().to_string(), + size: digest.size as u64, + first_bytes: digest.first_bytes.clone(), + catalogued_at: spacedrive_sdk::clock::now(), + seen_twice: false, + }; + let id = entry.id; + ctx.vdfs().create_model(entry).await?; + ctx.vdfs() + .update_model(id, |mut e: CatalogEntry| { + e.seen_twice = true; + Ok(e) + }) + .await?; + state.entries += 1; + ctx.increment_items(1); + } + + let all = ctx.vdfs().query_models::().collect().await?; + ctx.log(&format!( + "Catalog holds {} entries, {} digested this run, {} skipped", + all.len(), + state.digested, + state.skipped + )); + ctx.checkpoint(state)?; + Ok(()) +} diff --git a/extensions/test-extension/test_extension.wasm b/extensions/test-extension/test_extension.wasm index 91516cb45..998bb3881 100755 Binary files a/extensions/test-extension/test_extension.wasm and b/extensions/test-extension/test_extension.wasm differ diff --git a/packages/ts-client/src/generated/types.ts b/packages/ts-client/src/generated/types.ts index 1d679db6b..2b7f8c6ec 100644 --- a/packages/ts-client/src/generated/types.ts +++ b/packages/ts-client/src/generated/types.ts @@ -4234,7 +4234,13 @@ version: number; created_at: string; updated_at: string }; */ export type SidecarFormat = "webp" | "mp_4" | "json" | "message_pack" | "text" | "ply"; -export type SidecarKind = "thumb" | "thumbstrip" | "proxy" | "embeddings" | "ocr" | "transcript" | "gaussian_splat"; +/** + * What a sidecar holds. The core kinds are closed; an extension declares + * its own in its manifest (`write_sidecars`) and they live under the + * extension's namespace, so two extensions' `faces` never collide and no + * extension can write a core kind. + */ +export type SidecarKind = "thumb" | "thumbstrip" | "proxy" | "embeddings" | "ocr" | "transcript" | "gaussian_splat" | { extension: { extension_id: string; kind: string } }; export type SidecarVariant = string;