Files
spacedrive/extensions
Jamie Pineandcapy-ai-staging[bot] 312dbf39b7 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>
2026-10-07 21:08:02 +00:00
..

Spacedrive Official Extensions

This directory contains the extension SDK and official extensions for Spacedrive.

Structure

extensions/
├── spacedrive-sdk/          # Core SDK library
├── spacedrive-sdk-macros/   # Proc macros for beautiful API
├── test-extension/          # Example extension with beautiful API
└── finance/                 # (Future) First revenue-generating extension

Quick Start

1. Install WASM Target

rustup target add wasm32-unknown-unknown

2. Create Extension

cargo new --lib my-extension
cd my-extension

Cargo.toml:

[lib]
crate-type = ["cdylib"]

[dependencies]
spacedrive-sdk = { path = "../spacedrive-sdk" }
serde = { version = "1.0", features = ["derive"] }

src/lib.rs:

use spacedrive_sdk::prelude::*;
use spacedrive_sdk::{extension, job};

#[extension(
    id = "my-extension",
    name = "My Extension",
    version = "0.1.0"
)]
struct MyExtension;

#[derive(Serialize, Deserialize, Default)]
pub struct MyJobState {
    pub counter: u32,
}

#[job]
fn my_job(ctx: &JobContext, state: &mut MyJobState) -> Result<()> {
    ctx.log("Job starting!");

    state.counter += 1;
    ctx.report_progress(1.0, "Done!");

    Ok(())
}

3. Build

cargo build --target wasm32-unknown-unknown --release
cp target/wasm32-unknown-unknown/release/my_extension.wasm .

4. Create manifest.json

{
  "id": "my-extension",
  "name": "My Extension",
  "version": "0.1.0",
  "wasm_file": "my_extension.wasm",
  "permissions": {
    "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):

#[no_mangle]
pub extern "C" fn execute_my_job(
    ctx_ptr: u32, ctx_len: u32,
    state_ptr: u32, state_len: u32
) -> i32 {
    let ctx_json = unsafe { /* 30 lines of pointer manipulation */ };
    let mut state = /* 40 lines of deserialization */;
    // ... business logic buried in boilerplate ...
}

180+ lines, lots of unsafe

After Macros (Beautiful):

#[job]
fn my_job(ctx: &JobContext, state: &mut MyJobState) -> Result<()> {
    // Just write business logic!
    ctx.log("Working...");
    state.counter += 1;
    Ok(())
}

60-80 lines, zero unsafe, pure logic

API Reference

Extension Container

#[extension(
    id = "finance",
    name = "Spacedrive Finance",
    version = "0.1.0"
)]
struct Finance;

Generates:

  • plugin_init() export
  • plugin_cleanup() export
  • Metadata for manifest generation

Job Definition

#[job]
fn email_scan(ctx: &JobContext, state: &mut EmailScanState) -> Result<()> {
    // Progress reporting
    ctx.report_progress(0.5, "Half done");

    // Checkpointing
    ctx.checkpoint(state)?;

    // Interruption handling
    if ctx.check_interrupt() {
        return Err(Error::OperationFailed("Interrupted".into()));
    }

    // Metrics
    ctx.increment_items(1);
    ctx.increment_bytes(1000);

    // Warnings
    ctx.add_warning("Non-fatal issue");

    // Full SDK access
    let record = ctx.vdfs().get_record(uuid).await?;
    let bytes = record.read().await?;

    Ok(())
}

VDFS Operations

// Records of the library's sources, filtered by extension (read_records grant)
let photos = ctx.vdfs()
    .query_records()
    .with_extensions(["jpg", "png"])
    .collect()
    .await?;

// One record and its bytes
let record = ctx.vdfs().get_record(uuid).await?;
let bytes = record.read().await?;

// 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.

// Written against the API; answers NotAvailable until a provider exists
let faces = ctx.ai().from_registered("face_detection").detect_faces(&bytes).await?;

Credentials

// Store OAuth
ctx.credentials().store("gmail", Credential::oauth2(
    access_token,
    Some(refresh_token),
    3600,
    vec!["https://www.googleapis.com/auth/gmail.readonly".into()]
))?;

// Get (auto-refreshes)
let cred = ctx.credentials().get("gmail")?;

Examples

See extensions/test-extension/ for a complete working example.

Building

All extensions:

cd extensions/test-extension
cargo build --target wasm32-unknown-unknown --release

Documentation


Extension development is now beautiful, safe, and productive. Start building!