mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-11-02 21:35:12 -05:00
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:
1 parent
f69c1fee89
commit
312dbf39b7
60 files changed
+3170
-1027
No files matched your search
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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(|×tamp| timestamp > one_minute_ago);
|
||||
|
||||
// Check if under limit
|
||||
if limiter.recent_requests.len() >= limiter.requests_per_minute {
|
||||
return Err(PermissionError::RateLimitExceeded(format!(
|
||||
"Extension {} exceeded {} requests/minute",
|
||||
self.extension_id, limiter.requests_per_minute
|
||||
)));
|
||||
}
|
||||
|
||||
// Record this request
|
||||
limiter.recent_requests.push(now);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Full permission check for a Wire operation
|
||||
pub async fn authorize(
|
||||
&self,
|
||||
method: &str,
|
||||
library_id: Option<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()));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.**
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -49,8 +49,8 @@ pub enum Change {
|
||||
name: String,
|
||||
parent: uuid::Uuid,
|
||||
},
|
||||
MoveEntry {
|
||||
entry: Entry,
|
||||
MoveRecord {
|
||||
record: Record,
|
||||
destination: String,
|
||||
},
|
||||
}
|
||||
|
||||
+23
-10
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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::*;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 }),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
})?,
|
||||
}],
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,6 +1,5 @@
|
||||
use spacedrive_sdk::job;
|
||||
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use spacedrive_sdk::prelude::*;
|
||||
use spacedrive_sdk::types::JobResult;
|
||||
|
||||
@@ -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?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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,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\""]
|
||||
Generated
+43
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user