Extension host functions phase 1: photos analyze_photos runs end to end (#3124)

* Extension tasks run through the host: spacedrive_op, task.begin/end with deadline, manifest grants parsed strictly

JobContext::run executes a #[task] with the retries and timeout the attribute
declares. The host records each attempt in the job log and enforces the
deadline: once it passes, every host operation the task makes fails with
timeout, which is the only way a single-threaded guest can be stopped.

spacedrive_op is the one import every data-returning operation goes through;
the answer is written into memory from the guest's own wasm_alloc. The old
spacedrive_call, its fixed-offset writer and the Wire-method permission model
go; ManifestPermissions is the grant shape photos declares (read_records,
read/write_sidecars, write_tags, write_custom_fields, dispatch_jobs,
use_models) and refuses unknown fields.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Entropy and clock host functions

wasm32-unknown-unknown has neither. spacedrive_random fills guest memory
from the host's RNG and the SDK installs it as getrandom's custom backend,
so Uuid::new_v4 and rand work in a guest built with
--cfg getrandom_backend="custom". spacedrive_now_ms is the host's clock
behind spacedrive_sdk::clock::now(), which replaces Utc::now() in guests.
Neither needs a manifest grant: random ids and the time are not library
data.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Records replace entries in the SDK, read from the library's source stores

The entry world is gone, so the SDK's query and read surface is defined
over source-store records: Record (uuid, source, name, kind, extension,
source-relative path, size, mtime, content uuid), get_record, read_record
and a RecordQuery narrowed by source, directory and extension. The host
answers from the stores of the job's library and reads bytes through the
source's resolved root; the manifest's read_records grant (its glob names
extensions) is checked on every call. A tag filter is refused rather than
ignored until tags have a host side.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Sidecars for extensions, keyed by content uuid and an extension-declared kind

SidecarKind opens: Extension { extension_id, kind } beside the core kinds,
living under extensions/<id>/<kind> in the library's content-addressed
sidecar tree (sd-sidecar-path owns the directory), so an extension's kinds
never collide with the core's or another's. The SDK's sidecar_exists,
save_sidecar and read_sidecar write and read one JSON document per content
and kind; write_sidecars in the manifest grants writing, read_sidecars or
the extension's own write kinds grant reading. save_sidecar drops its
extension id argument, since the host supplies the namespace.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Extension data models: #[model] facets in an extension-owned store

#[model] derives a facet definition from the struct (strings and uuids,
integers, floats, booleans, datetimes; anything else as json) and
#[extension(models = [...])] declares each one to the host in plugin_init.
The host collects them into a DataTypeSchema and opens an sd-store database
under <library>/extensions/<extension id>/ on the first model operation:
the same record and facet shape as a source store, owned by the extension,
which is the store the extension-kinds plan registers as an app-owned
source later. create_model, get_model and update_model key a model by its
own uuid; the _for_content and _by_content forms key it by the content it
describes; query_models lists (filters are refused until a host side
exists). sd-store gains facet_rows, which reads a model's declared fields
back with their types.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* AI inference answers not_available; config.get; photos handles a missing detector

ctx.ai() calls go to the host as ai.infer with the model id, task and raw
input. The host checks the manifest's use_models grant and then, since the
core has no provider for face detection, scene classification, embeddings
or language models, answers not_available naming the category; nothing
builds inference here. analyze_photos handles it: one warning, the photo
keeps no faces sidecar so a later run with a provider picks it up, and the
job completes. config.get serves config.json beside the manifest (or {})
behind ctx.config::<C>(), which the clustering task reads. The photos
extension's DBSCAN and person matching are implemented over the model
store so the job runs to its end instead of a todo!().

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* extension: fixture library and end-to-end acceptance for host functions

The test extension gains a catalog job that reads every record of the
library's sources, digests each file through a task, writes a digest
sidecar and a CatalogEntry model, and skips what a previous run already
digested. The acceptance suite builds a library with twelve JPEG-magic
files and two text files, runs catalog twice, then runs the photos
extension's analyze_photos over the same library and asserts the
not_available path: one warning, no faces sidecar, every task attempt
logged.

Checkpointed guest state now tolerates fields added since the
checkpoint was written (serde(default) on the state structs), and
SourceDb::facet_rows numbers every bind parameter: sqlx binds a bare ?
after a ?N by position, so the external id landed in LIMIT.

Regenerates the TypeScript types for the SidecarKind::Extension variant.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* docs: extension host functions phase 1

The extension README describes spacedrive_op, the operations table and
the model registry in place of permissions.rs and spacedrive_call. The
SDK reference records the Entry to Record rename, the save_sidecar
signature, what has a host side and what does not. The extension READMEs
show the manifest permissions shape and the record, sidecar and model
calls that run today. PROJECT_STATUS gains the phase 1 register row.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* extension: grant checks fail closed; guest identifiers validated

A read_records glob the parser cannot read used to mean no filter for
records.query and no access for records.get, so the manifest is refused
at load unless the glob ends in *.ext or *.{a,b}, and granted_extensions
names nothing for anything else. The loader also refuses an extension id
that is not one path-safe segment, since it becomes a directory under
the library for sidecars and the model store. register_model checks
every field name as an identifier (and refuses record_uuid, the facet
key) because the guest can hand it any JSON. spacedrive_random resolves
the guest slice before allocating the entropy buffer.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
This commit is contained in:
Jamie Pineandcapy-ai-staging[bot] authored and GitHub committed 2026-10-07 21:08:02 +00:00
1 parent f69c1fee89
commit 312dbf39b7
60 files changed
+3170 -1027

No files matched your search

Generated
+2
View File
@@ -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",
+1
View File
@@ -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 <peer> 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 `<data>/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 `<data dir>/extensions/<dir>/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 `<library>/extensions/<id>/data.db`; `models.put/get/list` keyed by uuid or `content:<uuid>`), `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 `<name>` 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. |
+1
View File
@@ -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" }
+39 -5
View File
@@ -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<Self, String> {
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(),
}
}
}
+50 -13
View File
@@ -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
`<extension id>:<job name>`.
- `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 `<library>/extensions/<extension id>/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 `<library>/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:<uuid>` 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.
+170 -212
View File
@@ -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<crate::context::CoreContext>, // Just context, not full Core!
pub api_dispatcher: Arc<crate::infra::api::ApiDispatcher>, // For creating sessions
pub permissions: ExtensionPermissions,
pub memory: Memory,
/// The guest's allocator, for answers. Set once the instance exists.
pub alloc: Option<TypedFunction<i32, i32>>,
pub job_registry: Arc<super::job_registry::ExtensionJobRegistry>,
pub model_registry: Arc<super::model_registry::ExtensionModelRegistry>,
pub current_job: Option<JobBridge>,
}
/// 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<PluginEnv>,
method_ptr: WasmPtr<u8>,
method_len: u32,
library_id_ptr: u32,
payload_ptr: WasmPtr<u8>,
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<PluginEnv>,
@@ -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<u8>,
) -> Result<Uuid, Box<dyn std::error::Error>> {
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::<u8>::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<u8>,
reply: tokio::sync::oneshot::Sender<super::ops::OpResult>,
},
}
/// 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<PluginEnv>,
op_ptr: WasmPtr<u8>,
op_len: u32,
payload_ptr: WasmPtr<u8>,
payload_len: u32,
out_ptr: WasmPtr<u32>,
) -> 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<PluginEnv>, buf_ptr: WasmPtr<u8>, 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<PluginEnv>) -> i64 {
chrono::Utc::now().timestamp_millis()
}
/// Check if job should be interrupted
pub fn host_job_check_interrupt(
mut env: FunctionEnvMut<PluginEnv>,
@@ -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<PluginEnv>,
def_ptr: WasmPtr<u8>,
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
}
}
}
+63 -28
View File
@@ -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<RwLock<HashMap<String, LoadedPlugin>>>,
plugin_dir: PathBuf,
core_context: Arc<CoreContext>,
api_dispatcher: Arc<ApiDispatcher>,
job_registry: Arc<ExtensionJobRegistry>,
model_registry: Arc<ExtensionModelRegistry>,
}
impl PluginManager {
/// Create new plugin manager
pub fn new(
plugin_dir: PathBuf,
core_context: Arc<CoreContext>,
api_dispatcher: Arc<ApiDispatcher>,
) -> 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<ExtensionModelRegistry> {
self.model_registry.clone()
}
/// The directory extensions are installed under: `<data dir>/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<i32, i32> = 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<ExtensionManifest> {
pub async fn get_manifest(&self, plugin_id: &str) -> Option<Arc<ExtensionManifest>> {
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<PathBuf> {
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<Arc<Mutex<PluginRuntime>>> {
self.plugins
+12 -9
View File
@@ -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;
+128
View File
@@ -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 `<library>/extensions/<extension id>/`: 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<String, FieldType>,
}
#[derive(Default)]
pub struct ExtensionModelRegistry {
models: RwLock<HashMap<String, Vec<ModelDefinition>>>,
}
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<DataTypeSchema> {
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<SourceDb> {
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)
}
+633
View File
@@ -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<String>) -> Self {
Self::new("permission_denied", message)
}
pub fn invalid_input(message: impl Into<String>) -> Self {
Self::new("invalid_input", message)
}
pub fn not_available(message: impl Into<String>) -> Self {
Self::new("not_available", message)
}
pub fn failed(message: impl Into<String>) -> Self {
Self::new("failed", message)
}
fn new(code: &'static str, message: impl Into<String>) -> 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<Vec<u8>, OpError>;
/// A task attempt the guest is inside, as `task.begin` declared it.
struct ActiveTask {
name: String,
attempt: u32,
started: Instant,
deadline: Option<Instant>,
}
/// The host side of one running extension job.
pub struct JobOps {
pub extension_id: String,
pub manifest: Arc<ExtensionManifest>,
pub library: Arc<Library>,
/// The directory the extension was installed from, for `config.json`.
plugin_path: std::path::PathBuf,
models: Arc<ExtensionModelRegistry>,
/// The extension's store in this library, opened on the first model
/// operation and closed when the job ends.
store: Option<SourceDb>,
task: Option<ActiveTask>,
}
#[derive(Deserialize)]
struct ModelRef {
model: String,
uuid: Option<Uuid>,
content_uuid: Option<Uuid>,
}
#[derive(Deserialize)]
struct ModelPut {
model: String,
uuid: Option<Uuid>,
content_uuid: Option<Uuid>,
data: serde_json::Value,
}
#[derive(Deserialize)]
struct Inference {
model: String,
task: String,
}
#[derive(Deserialize)]
struct ModelList {
model: String,
limit: Option<usize>,
}
/// 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<String>,
relative_path: String,
size: Option<u64>,
modified_ms: Option<i64>,
content_uuid: Option<Uuid>,
}
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<Uuid>,
scope: Option<String>,
extensions: Option<Vec<String>>,
tag: Option<String>,
limit: Option<usize>,
}
/// 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<String>,
}
impl JobOps {
pub fn new(
extension_id: String,
manifest: Arc<ExtensionManifest>,
library: Arc<Library>,
plugin_path: std::path::PathBuf,
models: Arc<ExtensionModelRegistry>,
) -> 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<Arc<SourceStore>> {
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<SourceStore>, 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<Vec<String>> = 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<std::path::PathBuf, OpError> {
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<Uuid>, content_uuid: Option<Uuid>) -> Result<String, OpError> {
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<T: for<'de> Deserialize<'de>>(payload: &[u8]) -> Result<T, OpError> {
serde_json::from_slice(payload).map_err(|e| OpError::invalid_input(e.to_string()))
}
fn json<T: Serialize>(value: &T) -> OpResult {
serde_json::to_vec(value).map_err(|e| OpError::failed(e.to_string()))
}
-203
View File
@@ -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<String>,
/// Libraries this extension can access ("*" or specific UUIDs)
allowed_libraries: Vec<String>,
/// Rate limiting state
rate_limiter: Arc<RwLock<RateLimiter>>,
/// Resource limits
pub max_memory_mb: usize,
pub max_concurrent_jobs: usize,
}
struct RateLimiter {
requests_per_minute: usize,
recent_requests: Vec<Instant>,
}
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::<Uuid>().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(|&timestamp| 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<Uuid>,
) -> 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()));
}
}
+216 -59
View File
@@ -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<String>,
#[serde(default)]
pub min_core_version: Option<String>,
#[serde(default)]
pub required_features: Vec<String>,
/// 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<ManifestModel>,
/// Configuration schema (JSON Schema)
#[serde(default)]
pub config_schema: Option<serde_json::Value>,
}
/// 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<String>,
/// Libraries this extension can access
/// "*" = all libraries, or specific UUIDs
#[serde(default = "default_all_libraries")]
pub libraries: Vec<String>,
/// Rate limits
pub read_records: Option<ReadRecords>,
#[serde(default)]
pub rate_limits: RateLimits,
/// Network access (for HTTP proxy)
pub read_sidecars: Vec<String>,
#[serde(default)]
pub network_access: Vec<String>,
/// Resource limits
pub write_sidecars: Vec<String>,
#[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<String>,
#[serde(default)]
pub dispatch_jobs: bool,
#[serde(default)]
pub use_models: Vec<UseModel>,
}
fn default_all_libraries() -> Vec<String> {
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<Vec<String>> {
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<Vec<String>> {
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<String>,
}
#[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<String>,
}
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<ExtensionManifest>,
pub loaded_at: DateTime<Utc>,
/// Set by the runtime when a guest call trapped; see `PluginRuntime`.
pub poisoned: Arc<AtomicBool>,
@@ -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::<ManifestPermissions>(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));
}
}
+32 -10
View File
@@ -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::<String>().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),
+1 -5
View File
@@ -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);
+2 -2
View File
@@ -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,
});
}
+353 -76
View File
@@ -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<String>,
}
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<sd_core::library::Library>,
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<sd_core::library::Library>, Vec<sd_store::FsEntry>) {
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<Mutex<String>>);
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for GuestLog {
@@ -167,7 +291,8 @@ impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> 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<Uuid> = 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<PathBuf> {
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
}
+32
View File
@@ -12,6 +12,8 @@ struct ExtensionArgs {
name: String,
version: String,
jobs: Vec<Ident>,
/// Model types whose facets `plugin_init` declares to the host.
models: Vec<Ident>,
// 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::<Token![,]>()?;
}
}
}
// 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,
+61 -3
View File
@@ -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<T>` 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));
}
}
+120 -7
View File
@@ -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<Self> {
let mut args = TaskArgs {
retries: 0,
timeout_ms: 0,
};
while !input.is_empty() {
let key: syn::Ident = input.parse()?;
input.parse::<Token![=]>()?;
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::<Token![,]>()?;
}
}
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<T>")
.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<Self::Output> {
#inner(ctx, args).await
}
}
};
TokenStream::from(expanded)
}
/// `T` out of `TaskResult<T>` or `Result<T>` or `Result<T, E>`.
fn result_ok_type(ty: &Type) -> Option<Type> {
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,
}
}
+4
View File
@@ -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\""]
+85 -133
View File
@@ -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 `<library>/extensions/<id>/`. |
### Modules
@@ -38,9 +55,9 @@ pub type QueryResult<T> = std::result::Result<T, Error>;
pub type TaskResult<T> = std::result::Result<T, Error>;
// 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<Entry>
// Record queries (read_records grant)
fn query_records() -> RecordQuery
async fn get_record(uuid) -> Result<Record>
async fn read_record(uuid) -> Result<Vec<u8>>
// Sidecars (read_sidecars / write_sidecars grants), one JSON document per kind
fn sidecar_exists(content_uuid, kind) -> Result<bool>
async fn read_sidecar<T>(content_uuid, kind) -> Result<T>
async fn write_sidecar<T>(content_uuid, kind, data) -> Result<()>
// Model operations (content-scoped)
async fn create_model_for_content<T>(content_uuid, model) -> Result<()>
@@ -70,29 +93,29 @@ impl VdfsContext {
async fn get_model<T>(uuid) -> Result<T>
fn query_models<T>() -> ModelQuery<T>
// 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<T>(entry_uuid, field, value) -> Result<()>
// Custom fields (no host side yet)
async fn update_custom_field<T>(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<T>() -> 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<Option<Entry>>
async fn collect() -> Result<Vec<Entry>>
fn map<F, T>(f) -> MappedQuery<T>
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<Option<Record>>
async fn collect() -> Result<Vec<Record>>
}
// Model query builder
@@ -125,7 +148,7 @@ impl ModelHandle {
fn prompt_template(template_name: &str) -> PromptBuilder
async fn detect_faces(image_data: &[u8]) -> Result<Vec<FaceDetection>>
async fn classify(image_data: &[u8]) -> Result<Vec<SceneTag>>
async fn ocr_document(entry: &Entry) -> Result<String>
async fn ocr_document(record: &Record) -> Result<String>
async fn embed_text(text: &str) -> Result<Vec<f32>>
}
@@ -244,7 +267,8 @@ impl JobContext {
fn progress(progress: Progress)
async fn check_interrupt() -> Result<()> // Async version
fn sidecar_exists(content_uuid, kind) -> Result<bool>
async fn save_sidecar<T>(content_uuid, kind, extension_id, data) -> Result<()>
async fn save_sidecar<T>(content_uuid, kind, data) -> Result<()>
async fn read_sidecar<T>(content_uuid, kind) -> Result<T>
fn memory() -> MemoryHandle<()>
fn config<C>() -> &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<FaceDetection>,
// ... compiles!
}
#[job]
async fn analyze_photos(ctx: &JobContext, content_uuids: Vec<Uuid>) -> 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<T>(content_uuid: Uuid) -> Result<T> {
// 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.**
+1
View File
@@ -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" }
+2 -2
View File
@@ -49,8 +49,8 @@ pub enum Change {
name: String,
parent: uuid::Uuid,
},
MoveEntry {
entry: Entry,
MoveRecord {
record: Record,
destination: String,
},
}
+23 -10
View File
@@ -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<AiModelId> {
panic!("WASM host call")
Err(Error::Unsupported("register".into()))
}
/// Check if AI model is registered
@@ -91,24 +96,32 @@ impl ModelHandle {
}
}
fn infer<O: DeserializeOwned>(&self, task: &str, input: &[u8]) -> Result<O> {
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<Vec<FaceDetection>> {
panic!("WASM host call")
self.infer("detect_faces", image_data)
}
/// Classify scene in image
pub async fn classify(&self, image_data: &[u8]) -> Result<Vec<SceneTag>> {
panic!("WASM host call")
self.infer("classify", image_data)
}
/// OCR document
pub async fn ocr_document(&self, entry: &Entry) -> Result<String> {
panic!("WASM host call")
pub async fn ocr_document(&self, record: &Record) -> Result<String> {
Err(Error::Unsupported("ocr_document".into()))
}
/// Generate text embedding
pub async fn embed_text(&self, text: &str) -> Result<Vec<f32>> {
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<T: Serialize>(self, context: &T) -> Result<RenderedPrompt> {
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<String> {
panic!("WASM host call - LLM inference")
Err(Error::Unsupported("generate_text".into()))
}
/// Generate JSON from rendered prompt
pub async fn generate_json<T: DeserializeOwned>(self) -> Result<T> {
panic!("WASM host call - structured output")
Err(Error::Unsupported("generate_json".into()))
}
}
+16
View File
@@ -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<Utc> {
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()
}
+17
View File
@@ -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<C: DeserializeOwned + 'static>() -> &'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))
}
+132 -2
View File
@@ -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<Vec<u8>> {
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<I: Serialize, O: DeserializeOwned>(
op_name: &str,
header: &I,
bytes: &[u8],
) -> Result<O> {
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<I: Serialize, O: DeserializeOwned>(op_name: &str, input: &I) -> Result<O> {
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
+41 -14
View File
@@ -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<F, A, R, Fut>(&self, _task: F, _args: A) -> crate::types::Result<R>
/// 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<T: crate::tasks::Task>(
&self,
_task: T,
args: T::Args,
) -> crate::types::Result<T::Output>
where
F: Fn(crate::tasks::TaskContext, A) -> Fut,
Fut: std::future::Future<Output = crate::tasks::TaskResult<R>>,
T::Args: Clone,
{
panic!("Execute task with checkpoint")
crate::tasks::run::<T>(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<bool> {
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<T: serde::Serialize>(
&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<T: serde::de::DeserializeOwned>(
&self,
content_uuid: uuid::Uuid,
kind: &str,
) -> crate::types::Result<T> {
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<C>(&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<C: serde::de::DeserializeOwned + 'static>(&self) -> &C {
crate::config::load()
}
/// Send notification
+12 -21
View File
@@ -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::<Image>().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::*;
+11 -5
View File
@@ -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;
+98 -6
View File
@@ -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<Self::Output>;
}
/// 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<T: Task>(args: T::Args) -> Result<T::Output>
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<C>(&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<C: serde::de::DeserializeOwned + 'static>(&self) -> &C {
crate::config::load()
}
/// Read sidecar data
@@ -30,7 +122,7 @@ impl TaskContext {
content_uuid: Uuid,
kind: &str,
) -> Result<T> {
panic!("WASM host call")
VdfsContext.read_sidecar(content_uuid, kind).await
}
}
+71 -32
View File
@@ -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<T> = std::result::Result<T, Error>;
/// Query result type
pub type QueryResult<T> = std::result::Result<T, Error>;
/// 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<Uuid>,
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<String>,
pub metadata_id: Option<i32>,
pub content_id: Option<i32>,
pub size: i64,
/// Relative to the source root, `/`-separated.
pub relative_path: String,
pub size: Option<u64>,
/// Unix milliseconds.
pub modified_ms: Option<i64>,
/// The identity of the bytes, once the hash job has reached them.
pub content_uuid: Option<Uuid>,
}
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<Uuid> {
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<Vec<u8>> {
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<T: serde::de::DeserializeOwned>(&self, field: &str) -> Result<T> {
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
+189 -68
View File
@@ -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<Entry> {
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<Record> {
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<Vec<u8>> {
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<bool> {
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<T: DeserializeOwned>(
&self,
content_uuid: Uuid,
kind: &str,
) -> Result<T> {
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<T: Serialize>(
&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<T: ExtensionModel>(&self) -> ModelQuery<T> {
ModelQuery {
filtered: false,
limit: None,
_phantom: std::marker::PhantomData,
}
}
/// Get model scoped to content_identity
pub async fn get_model_by_content<T: ExtensionModel>(&self, content_uuid: Uuid) -> Result<T> {
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<T: ExtensionModel>(
&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<T>,
{
panic!("WASM host call")
let current = self.get_model_by_content::<T>(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<T: ExtensionModel>(&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<T: ExtensionModel>(&self, uuid: Uuid) -> Result<T> {
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<T>,
{
panic!("WASM host call")
let current = self.get_model::<T>(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<T: Serialize>(
&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<Uuid>,
scope: Option<String>,
extensions: Option<Vec<String>>,
tag: Option<String>,
limit: Option<usize>,
}
impl EntryQuery {
pub fn in_location(self, path: impl Into<String>) -> 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<T>(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<String>) -> 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<I, S>(mut self, extensions: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
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<T: FileType>(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<Option<Entry>> {
panic!("Execute query")
pub async fn first(self) -> Result<Option<Record>> {
Ok(self.limit(1).collect().await?.into_iter().next())
}
pub async fn collect(self) -> Result<Vec<Entry>> {
panic!("Execute query")
}
pub fn map<F, T>(self, f: F) -> MappedQuery<T>
where
F: Fn(Entry) -> T,
{
panic!("Map results")
pub async fn collect(self) -> Result<Vec<Record>> {
crate::ffi::op_json("records.query", &self)
}
}
/// Mapped query results
pub struct MappedQuery<T> {
_phantom: std::marker::PhantomData<T>,
/// A file type a query can ask for by name.
pub trait FileType {
const EXTENSIONS: &'static [&'static str];
}
impl<T> MappedQuery<T> {
pub async fn collect(self) -> Result<Vec<T>> {
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<T> {
filtered: bool,
limit: Option<usize>,
_phantom: std::marker::PhantomData<T>,
}
impl<T: ExtensionModel> ModelQuery<T> {
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<Option<T>> {
panic!("Execute query")
Ok(self.limit(1).collect().await?.into_iter().next())
}
pub async fn collect(self) -> Result<Vec<T>> {
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 }),
)
}
}
+22
View File
@@ -29,6 +29,12 @@ pub fn kind_directory(kind: &str) -> Option<&'static str> {
}
}
/// Directory for a kind an extension declared: under `extensions/<id>/` 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"));
+74 -1
View File
@@ -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<Vec<serde_json::Value>> {
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<String>)>(&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
+44 -23
View File
@@ -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<CatalogEntry> = 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
+2 -3
View File
@@ -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\""]
+25 -12
View File
@@ -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
+1 -1
View File
@@ -12,7 +12,7 @@
"semantic_search"
],
"permissions": {
"read_entries": {
"read_records": {
"glob": "**/*.{jpg,jpeg,png,heic,heif,raw,cr2,nef,dng,webp}"
},
"read_sidecars": [
Binary file not shown.
@@ -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,
})?,
}],
+4 -4
View File
@@ -21,10 +21,10 @@ impl crate::Photos {
#[on_event(EntryCreated)]
#[filter(".extension().is_image()")]
pub async fn on_new_photo(entry: Entry, ctx: &AgentContext<PhotosMind>) -> AgentResult<()> {
ctx.trace(format!("New photo detected: {}", entry.name()));
pub async fn on_new_photo(record: Record, ctx: &AgentContext<PhotosMind>) -> 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?;
+36 -12
View File
@@ -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<Uuid>,
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,
+59 -5
View File
@@ -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<Uuid>) -
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::<Vec<FaceDetection>>(content_uuid, "faces")
@@ -47,14 +47,14 @@ pub async fn cluster_faces_into_people(ctx: TaskContext, photo_ids: Vec<Uuid>) -
#[task]
pub async fn generate_face_tags(ctx: TaskContext, photo_ids: Vec<Uuid>) -> 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::<Vec<PersonId>>("identified_people") {
for person_id in people {
if let Ok(person) = ctx.vdfs().get_model::<Person>(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<Uuid>) -> 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<PersonId> {
todo!("Implement person matching")
let threshold = ctx
.config::<crate::PhotosConfig>()
.face_clustering_threshold;
let people = ctx.vdfs().query_models::<Person>().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<Vec<f32>> = 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)
}
-1
View File
@@ -1,6 +1,5 @@
use spacedrive_sdk::job;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use spacedrive_sdk::prelude::*;
use spacedrive_sdk::types::JobResult;
+2 -2
View File
@@ -22,7 +22,7 @@ pub async fn identify_places_in_location(
let photos = ctx
.vdfs()
.query_entries()
.query_records()
.in_location(location)
.of_type::<Image>()
.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?;
}
}
+3 -4
View File
@@ -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?;
}
}
+1
View File
@@ -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"],
+4 -4
View File
@@ -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<ExifData>,
@@ -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,
@@ -10,11 +10,12 @@ pub async fn search_scene(
ctx: &QueryContext<PhotosMind>,
scene_type: String,
) -> QueryResult<Vec<Photo>> {
ctx.vdfs()
.query_entries()
let records = ctx
.vdfs()
.query_records()
.with_tag(&format!("#scene:{}", scene_type))
.of_type::<Image>()
.map(|entry| Photo::from_entry(entry))
.collect()
.await
.await?;
Ok(records.into_iter().map(Photo::from_record).collect())
}
@@ -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<Vec<SceneTag>> {
pub async fn classify_scene(ctx: TaskContext, photo: Record) -> TaskResult<Vec<SceneTag>> {
let image_bytes = photo.read().await?;
let classifications = ctx
+1 -1
View File
@@ -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<Vec<FaceDetection>> {
let image_bytes = photo.read().await?;
+94 -4
View File
@@ -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<FaceCluster> {
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::<f32>().sqrt();
let norm_b = b.iter().map(|x| x * x).sum::<f32>().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<PlaceCluster> {
/// The element-wise mean of a set of embeddings; empty when the set is.
pub fn mean_embedding(embeddings: &[Vec<f32>]) -> Vec<f32> {
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<FaceCluster> {
const MIN_POINTS: usize = 2;
let n = faces.len();
let neighbours: Vec<Vec<usize>> = (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::<usize>; n];
let mut clusters: Vec<Vec<usize>> = 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<Vec<f32>> =
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<PlaceCluster> {
todo!("Implement geographic clustering")
}
@@ -22,6 +112,6 @@ pub struct FaceCluster {
}
pub struct PlaceCluster {
pub photos: Vec<Entry>,
pub photos: Vec<Record>,
pub center: GpsCoordinates,
}
+2 -1
View File
@@ -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\""]
+43
View File
@@ -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",
]
+2
View File
@@ -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
+5 -14
View File
@@ -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"
]
}
}
}
+114 -2
View File
@@ -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<u8>,
pub catalogued_at: chrono::DateTime<chrono::Utc>,
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<u8>,
pub record_name: String,
}
#[derive(Serialize, Deserialize, Default)]
#[serde(default)]
pub struct CatalogState {
pub extensions: Vec<String>,
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<Digest> {
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::<CatalogEntry>().collect().await?;
ctx.log(&format!(
"Catalog holds {} entries, {} digested this run, {} skipped",
all.len(),
state.digested,
state.skipped
));
ctx.checkpoint(state)?;
Ok(())
}
Binary file not shown.
+7 -1
View File
@@ -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;