Files
Jamie Pineandcapy-ai-staging[bot] b1a58be78c Extension host functions phase 2: tags, custom fields, jobs.dispatch, photos moments from EXIF (#3144)
* Extension host ops: tags.add and tags.remove over the library's tag ops

An extension with the write_tags grant can tag a record or a content
identity by tag path, creating the tag by name through tags.create when
nothing defines it yet, and remove it again through tags.unapply.
records.query honors a tag filter instead of refusing it.

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

* Extension host ops: records.set_field and records.get_field

Custom fields live in the extension's own store, in a built-in
custom_field model every extension schema carries, keyed by record uuid,
namespace and name. A write needs the namespace in the manifest's
write_custom_fields; a read is the extension's own data. The source
store schema is untouched.

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

* Extension host op: jobs.dispatch queues one of the extension's own jobs

An extension with the dispatch_jobs grant can queue another of its own
jobs by registered name with a starting state. The name resolves under
the caller's extension id, so no extension can start another's job. The
SDK's JobDispatcher goes through it and returns the job id.

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

* Photos: create_moments runs end to end from EXIF, places and scenes do their non-inference parts

A records.exif host op (read_records) answers a record's capture time,
GPS and camera: from the store's image facet when an ingest wrote one,
otherwise parsed from the file through sd-media-metadata, since no
ingest writes the facet yet. create_moments groups photos by time gap
and distance into Moment models, tags each photo Moments/<title> and
sets photos.moment_id, which also keeps a re-run from grouping twice;
titles come from the dates. identify_places clusters by GPS, matches or
creates Place models, tags and sets photos.place_id, and keeps a place
unnamed when the reverse geocode's language model is not available.
analyze_scenes skips with one warning without a classifier and tags
from an existing scene sidecar.

sd-store: a bare string stored in a JSON column reads back as that
string instead of null, which a uuid alias field in a model hit.

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

* WASM acceptance: tags, fields, dispatch and photos moments over EXIF-dated JPEGs

The test extension's tag job tags by name, sets and reads a custom
field, is refused an ungranted namespace and queues a counter job; the
same module under a manifest without the grants fails at the first tag.
A moments fixture of twelve hand-built EXIF JPEGs (dates and GPS) yields
three moments, places from GPS, and the not_available path for scenes.

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

* Docs: extension host functions phase 2 in the SDK reference, the extension README and PROJECT_STATUS

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

* cargo fmt the extension crates

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

* Review fixes: tag-filtered queries start from the tagged records, identify_places skips placed photos

A tag filter now resolves each tagged record through its store and
applies scope and extension there, so it is not bounded by the walk cap,
and a store that cannot list its tagged records is an error rather than
an empty match. identify_places leaves a photo that already carries
photos.place_id alone, so a re-run places only new photos and a place's
photo_count stays the number of photos assigned to it.

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

* Tag-filtered record queries exclude hidden files like the walk does

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-09 00:14:40 +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!