From 9eb7a001da011926489cbe09f3105953e273afcf Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sun, 16 Aug 2026 09:22:46 -0700 Subject: [PATCH] Move template rendering and themes onto PluginHost (#559) --- crates-tauri/yaak-app-client/src/commands.rs | 12 --- crates-tauri/yaak-app-client/src/lib.rs | 75 +-------------- crates-tauri/yaak-app-client/src/render.rs | 31 ++---- crates-tauri/yaak-app-client/src/rpc_ext.rs | 55 +++++++++-- crates/yaak-commands/src/host.rs | 31 +++++- crates/yaak-commands/src/lib.rs | 2 + crates/yaak-commands/src/render.rs | 30 ++++++ crates/yaak-commands/src/templates.rs | 67 +++++++++++++ crates/yaak-commands/tests/test_host.rs | 99 +++++++++++++++++++- 9 files changed, 280 insertions(+), 122 deletions(-) delete mode 100644 crates-tauri/yaak-app-client/src/commands.rs create mode 100644 crates/yaak-commands/src/render.rs create mode 100644 crates/yaak-commands/src/templates.rs diff --git a/crates-tauri/yaak-app-client/src/commands.rs b/crates-tauri/yaak-app-client/src/commands.rs deleted file mode 100644 index f8b9dbc2..00000000 --- a/crates-tauri/yaak-app-client/src/commands.rs +++ /dev/null @@ -1,12 +0,0 @@ -use crate::PluginContextExt; -use crate::error::Result; -use tauri::{Runtime, State, WebviewWindow}; -use yaak_plugins::events::GetThemesResponse; -use yaak_plugins::manager::PluginManager; - -pub(crate) async fn cmd_get_themes( - window: WebviewWindow, - plugin_manager: State<'_, PluginManager>, -) -> Result> { - Ok(plugin_manager.get_themes(&window.plugin_context()).await?) -} diff --git a/crates-tauri/yaak-app-client/src/lib.rs b/crates-tauri/yaak-app-client/src/lib.rs index 8408cc42..14136893 100644 --- a/crates-tauri/yaak-app-client/src/lib.rs +++ b/crates-tauri/yaak-app-client/src/lib.rs @@ -48,7 +48,6 @@ use yaak_plugins::events::{ CallWorkspaceActionRequest, Color, FilterResponse, GetFolderActionsResponse, GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse, - GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, InternalEvent, InternalEventPayload, JsonPrimitive, PluginContext, RenderPurpose, ShowToastRequest, }; @@ -58,10 +57,9 @@ use yaak_rpc_schema::{AppMetaData, EphemeralHttpResponse}; use yaak_sse::sse::ServerSentEvent; use yaak_tauri_utils::window::WorkspaceWindowTrait; use yaak_templates::strip_json_comments::strip_json_comments; -use yaak_templates::{RenderErrorBehavior, RenderOptions, Tokens, transform_args}; +use yaak_templates::{RenderErrorBehavior, RenderOptions}; use yaak_tls::find_client_certificate; -mod commands; mod encoding; mod error; mod feedback; @@ -220,56 +218,6 @@ async fn detect_cli_version_for_binary(program: &str) -> Option { Some(parts.next().unwrap_or(line).to_string()) } -async fn cmd_template_tokens_to_string( - window: WebviewWindow, - app_handle: AppHandle, - tokens: Tokens, -) -> YaakResult { - let plugin_manager = Arc::new((*app_handle.state::()).clone()); - let encryption_manager = Arc::new((*app_handle.state::()).clone()); - let cb = PluginTemplateCallback::new( - plugin_manager, - encryption_manager, - &PluginContext::new(Some(window.label().to_string()), window.workspace_id()), - RenderPurpose::Preview, - ); - let new_tokens = transform_args(tokens, &cb)?; - Ok(new_tokens.to_string()) -} - -async fn cmd_render_template( - window: WebviewWindow, - app_handle: AppHandle, - template: &str, - workspace_id: &str, - environment_id: Option<&str>, - purpose: Option, - ignore_error: Option, -) -> YaakResult { - let environment_chain = - app_handle.db().resolve_environments(workspace_id, None, environment_id)?; - let plugin_manager = Arc::new((*app_handle.state::()).clone()); - let encryption_manager = Arc::new((*app_handle.state::()).clone()); - let result = render_template( - template, - environment_chain, - &PluginTemplateCallback::new( - plugin_manager, - encryption_manager, - &PluginContext::new(Some(window.label().to_string()), window.workspace_id()), - purpose.unwrap_or(RenderPurpose::Preview), - ), - &RenderOptions { - error_behavior: match ignore_error { - Some(true) => RenderErrorBehavior::ReturnEmpty, - _ => RenderErrorBehavior::Throw, - }, - }, - ) - .await?; - Ok(result) -} - async fn cmd_send_feedback( app_handle: AppHandle, feature: String, @@ -1160,27 +1108,6 @@ async fn cmd_grpc_request_actions( Ok(plugin_manager.get_grpc_request_actions(&window.plugin_context()).await?) } -async fn cmd_template_function_summaries( - window: WebviewWindow, - plugin_manager: State<'_, PluginManager>, -) -> YaakResult> { - let results = plugin_manager.get_template_function_summaries(&window.plugin_context()).await?; - Ok(results) -} - -async fn cmd_template_function_config( - window: WebviewWindow, - plugin_manager: State<'_, PluginManager>, - function_name: &str, - values: HashMap, - model: AnyModel, - _environment_id: Option<&str>, -) -> YaakResult { - Ok(plugin_manager - .get_template_function_config(&window.plugin_context(), function_name, values, model.id()) - .await?) -} - async fn cmd_get_http_authentication_summaries( window: WebviewWindow, plugin_manager: State<'_, PluginManager>, diff --git a/crates-tauri/yaak-app-client/src/render.rs b/crates-tauri/yaak-app-client/src/render.rs index a6abe0d3..788b6767 100644 --- a/crates-tauri/yaak-app-client/src/render.rs +++ b/crates-tauri/yaak-app-client/src/render.rs @@ -1,25 +1,8 @@ -use serde_json::Value; +//! One import path for rendering, wherever the pieces actually live. +//! +//! The request renderers are engine code; the template renderers moved to +//! `yaak-commands` when the template commands did. Callers in this crate do not +//! need to track which is which. + pub use yaak::render::{render_grpc_request, render_http_request}; -use yaak_models::models::Environment; -use yaak_models::render::make_vars_hashmap; -use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw}; - -pub async fn render_template( - template: &str, - environment_chain: Vec, - cb: &T, - opt: &RenderOptions, -) -> yaak_templates::error::Result { - let vars = &make_vars_hashmap(environment_chain); - parse_and_render(template, vars, cb, &opt).await -} - -pub async fn render_json_value( - value: Value, - environment_chain: Vec, - cb: &T, - opt: &RenderOptions, -) -> yaak_templates::error::Result { - let vars = &make_vars_hashmap(environment_chain); - render_json_value_raw(value, vars, cb, opt).await -} +pub use yaak_commands::render::{render_json_value, render_template}; diff --git a/crates-tauri/yaak-app-client/src/rpc_ext.rs b/crates-tauri/yaak-app-client/src/rpc_ext.rs index 7913c2c6..9b70d105 100644 --- a/crates-tauri/yaak-app-client/src/rpc_ext.rs +++ b/crates-tauri/yaak-app-client/src/rpc_ext.rs @@ -22,6 +22,7 @@ use crate::updates::YaakUpdater; use log::warn; use serde::Serialize; use tauri::{Manager, Runtime, State, WebviewWindow}; +use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex; use yaak_commands::{Host, PluginHost}; @@ -41,7 +42,7 @@ use yaak_models::models::{ use yaak_models::query_manager::QueryManager; use yaak_models::util::BatchUpsertResult; use yaak_plugins::events::{ - FilterResponse, GetFolderActionsResponse, GetGrpcRequestActionsResponse, + FilterResponse, JsonPrimitive, RenderPurpose, GetFolderActionsResponse, GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse, GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse, GetWebsocketRequestActionsResponse, @@ -50,11 +51,13 @@ use yaak_plugins::events::{ use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse}; use yaak_plugins::manager::PluginManager; use yaak_plugins::native_template_functions::encrypt_secure_template_function; +use yaak_plugins::template_callback::PluginTemplateCallback; use yaak_plugins::plugin_meta::PluginMetadata; use yaak_rpc::RpcRouter; use yaak_rpc_schema::*; use yaak_sse::sse::ServerSentEvent; use yaak_sync::sync::SyncOp; +use yaak_templates::TemplateCallback; use yaak_tauri_utils::window::WorkspaceWindowTrait; use yaak_ws::WebsocketManager; @@ -122,6 +125,42 @@ impl PluginHost for ClientCtx { self.window.state::().resolve_plugins_for_runtime_from_db(plugins).await } + fn template_callback(&self, purpose: RenderPurpose) -> impl TemplateCallback { + PluginTemplateCallback::new( + Arc::new((*self.window.state::()).clone()), + Arc::new(self.encryption_manager().clone()), + &self.plugin_context(), + purpose, + ) + } + + async fn template_function_summaries( + &self, + ) -> yaak_commands::Result> { + Ok(self + .window + .state::() + .get_template_function_summaries(&self.plugin_context()) + .await?) + } + + async fn template_function_config( + &self, + function_name: &str, + values: HashMap, + model_id: &str, + ) -> yaak_commands::Result { + Ok(self + .window + .state::() + .get_template_function_config(&self.plugin_context(), function_name, values, model_id) + .await?) + } + + async fn themes(&self) -> yaak_commands::Result> { + Ok(self.window.state::().get_themes(&self.plugin_context()).await?) + } + async fn encrypt_secure_template(&self, template: &str) -> yaak_commands::Result { let plugin_manager = Arc::new((*self.window.state::()).clone()); let encryption_manager = Arc::new(self.encryption_manager().clone()); @@ -227,11 +266,11 @@ async fn cmd_metadata(ctx: ClientCtx, _req: CmdMetadataReq) -> Re } async fn cmd_template_tokens_to_string(ctx: ClientCtx, req: CmdTemplateTokensToStringReq) -> Result { - Ok(crate::cmd_template_tokens_to_string(ctx.window.clone(), ctx.window.app_handle().clone(), req.tokens).await?) + Ok(yaak_commands::templates::cmd_template_tokens_to_string(ctx, req).await?) } async fn cmd_render_template(ctx: ClientCtx, req: CmdRenderTemplateReq) -> Result { - Ok(crate::cmd_render_template(ctx.window.clone(), ctx.window.app_handle().clone(), &req.template, &req.workspace_id, req.environment_id.as_deref(), req.purpose, req.ignore_error).await?) + Ok(yaak_commands::templates::cmd_render_template(ctx, req).await?) } async fn cmd_send_feedback(ctx: ClientCtx, req: CmdSendFeedbackReq) -> Result<()> { @@ -326,12 +365,12 @@ async fn cmd_grpc_request_actions(ctx: ClientCtx, _req: CmdGrpcRe Ok(crate::cmd_grpc_request_actions(ctx.window.clone(), ctx.window.app_handle().state::()).await?) } -async fn cmd_template_function_summaries(ctx: ClientCtx, _req: CmdTemplateFunctionSummariesReq) -> Result> { - Ok(crate::cmd_template_function_summaries(ctx.window.clone(), ctx.window.app_handle().state::()).await?) +async fn cmd_template_function_summaries(ctx: ClientCtx, req: CmdTemplateFunctionSummariesReq) -> Result> { + Ok(yaak_commands::templates::cmd_template_function_summaries(ctx, req).await?) } async fn cmd_template_function_config(ctx: ClientCtx, req: CmdTemplateFunctionConfigReq) -> Result { - Ok(crate::cmd_template_function_config(ctx.window.clone(), ctx.window.app_handle().state::(), &req.function_name, req.values, req.model, req.environment_id.as_deref()).await?) + Ok(yaak_commands::templates::cmd_template_function_config(ctx, req).await?) } async fn cmd_get_http_authentication_summaries(ctx: ClientCtx, _req: CmdGetHttpAuthenticationSummariesReq) -> Result> { @@ -418,8 +457,8 @@ async fn cmd_secure_template(ctx: ClientCtx, req: CmdSecureTempla Ok(yaak_commands::encryption::cmd_secure_template(ctx, req).await?) } -async fn cmd_get_themes(ctx: ClientCtx, _req: CmdGetThemesReq) -> Result> { - Ok(crate::commands::cmd_get_themes(ctx.window.clone(), ctx.window.app_handle().state::()).await?) +async fn cmd_get_themes(ctx: ClientCtx, req: CmdGetThemesReq) -> Result> { + Ok(yaak_commands::templates::cmd_get_themes(ctx, req).await?) } async fn cmd_enable_encryption(ctx: ClientCtx, req: CmdEnableEncryptionReq) -> Result<()> { diff --git a/crates/yaak-commands/src/host.rs b/crates/yaak-commands/src/host.rs index ef525542..cc92e914 100644 --- a/crates/yaak-commands/src/host.rs +++ b/crates/yaak-commands/src/host.rs @@ -13,6 +13,7 @@ //! is anything only a desktop can do — open a native window, run the updater, //! show a native dialog — those handlers stay with the desktop. +use std::collections::HashMap; use std::future::Future; use yaak_core::WorkspaceContext; use yaak_crypto::manager::EncryptionManager; @@ -21,8 +22,12 @@ use yaak_models::client_db::ClientDb; use yaak_models::models::Plugin; use yaak_models::query_manager::QueryManager; use yaak_models::util::UpdateSource; -use yaak_plugins::events::PluginContext; +use yaak_plugins::events::{ + GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse, + JsonPrimitive, PluginContext, RenderPurpose, +}; use yaak_plugins::plugin_meta::PluginMetadata; +use yaak_templates::TemplateCallback; /// Only `Clone` is required here. `Send`/`Sync`/`'static` are deliberately /// *not*: a browser host is single-threaded and its connection pool is an @@ -106,6 +111,30 @@ pub trait PluginHost: Host { /// loaded. A host without a runtime can return them untouched. fn resolve_plugins(&self, plugins: Vec) -> impl Future>; + /// The template functions this host can run, as a callback the renderer + /// drives. This is the *only* thing the plugin runtime uniquely provides to + /// a render — the variables come from the environment chain, which is an + /// ordinary database read — so handing back the callback keeps the rest of + /// rendering shared instead of pushing whole commands behind this trait. + fn template_callback(&self, purpose: RenderPurpose) -> impl TemplateCallback; + + /// Every template function the installed plugins expose, for the + /// autocomplete menu. + fn template_function_summaries( + &self, + ) -> impl Future>>; + + /// The form a template function wants to show for the given values. + fn template_function_config( + &self, + function_name: &str, + values: HashMap, + model_id: &str, + ) -> impl Future>; + + /// Themes contributed by plugins. + fn themes(&self) -> impl Future>>; + /// Re-encrypt the `secure(...)` values in a template. /// /// Whole operation rather than its pieces because the encryption is only diff --git a/crates/yaak-commands/src/lib.rs b/crates/yaak-commands/src/lib.rs index d9a99583..f7e03229 100644 --- a/crates/yaak-commands/src/lib.rs +++ b/crates/yaak-commands/src/lib.rs @@ -17,7 +17,9 @@ pub mod error; pub mod host; pub mod models; pub mod plugins; +pub mod render; pub mod responses; +pub mod templates; pub use error::{Error, Result}; pub use host::{Host, PluginHost}; diff --git a/crates/yaak-commands/src/render.rs b/crates/yaak-commands/src/render.rs new file mode 100644 index 00000000..3e6d794f --- /dev/null +++ b/crates/yaak-commands/src/render.rs @@ -0,0 +1,30 @@ +//! Rendering a template against an environment chain. +//! +//! The variables come from the chain, the functions come from the host's +//! template callback. Neither of these knows which host it is running under — +//! that is the whole point of taking the callback as a parameter. + +use serde_json::Value; +use yaak_models::models::Environment; +use yaak_models::render::make_vars_hashmap; +use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw}; + +pub async fn render_template( + template: &str, + environment_chain: Vec, + cb: &T, + opt: &RenderOptions, +) -> yaak_templates::error::Result { + let vars = &make_vars_hashmap(environment_chain); + parse_and_render(template, vars, cb, opt).await +} + +pub async fn render_json_value( + value: Value, + environment_chain: Vec, + cb: &T, + opt: &RenderOptions, +) -> yaak_templates::error::Result { + let vars = &make_vars_hashmap(environment_chain); + render_json_value_raw(value, vars, cb, opt).await +} diff --git a/crates/yaak-commands/src/templates.rs b/crates/yaak-commands/src/templates.rs new file mode 100644 index 00000000..d16628c5 --- /dev/null +++ b/crates/yaak-commands/src/templates.rs @@ -0,0 +1,67 @@ +//! Templates, the functions plugins put in them, and themes. +//! +//! Everything here needs the plugin runtime, but only for the one thing it +//! uniquely provides: running a template function. Resolving the environment +//! chain and deciding what a render should do about errors are ordinary work +//! and stay here, where every host gets them the same. + +use crate::error::Result; +use crate::host::PluginHost; +use crate::render::render_template; +use yaak_plugins::events::{ + GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse, + RenderPurpose, +}; +use yaak_rpc_schema::*; +use yaak_templates::{RenderErrorBehavior, RenderOptions, transform_args}; + +pub async fn cmd_render_template( + host: H, + req: CmdRenderTemplateReq, +) -> Result { + let environment_chain = + host.db().resolve_environments(&req.workspace_id, None, req.environment_id.as_deref())?; + let cb = host.template_callback(req.purpose.unwrap_or(RenderPurpose::Preview)); + let options = RenderOptions { + // A preview that throws would show the user an error where they expect + // to see the value so far, so callers rendering *into the UI* ask for + // empties instead. + error_behavior: match req.ignore_error { + Some(true) => RenderErrorBehavior::ReturnEmpty, + _ => RenderErrorBehavior::Throw, + }, + }; + Ok(render_template(&req.template, environment_chain, &cb, &options).await?) +} + +/// Render only the *arguments* of a template's function calls, leaving the +/// calls themselves intact. This is what turns a parsed template back into +/// something displayable without evaluating it. +pub async fn cmd_template_tokens_to_string( + host: H, + req: CmdTemplateTokensToStringReq, +) -> Result { + let cb = host.template_callback(RenderPurpose::Preview); + Ok(transform_args(req.tokens, &cb)?.to_string()) +} + +pub async fn cmd_template_function_summaries( + host: H, + _req: CmdTemplateFunctionSummariesReq, +) -> Result> { + host.template_function_summaries().await +} + +pub async fn cmd_template_function_config( + host: H, + req: CmdTemplateFunctionConfigReq, +) -> Result { + host.template_function_config(&req.function_name, req.values, &req.model.id()).await +} + +pub async fn cmd_get_themes( + host: H, + _req: CmdGetThemesReq, +) -> Result> { + host.themes().await +} diff --git a/crates/yaak-commands/tests/test_host.rs b/crates/yaak-commands/tests/test_host.rs index 41bc5618..8b37ee41 100644 --- a/crates/yaak-commands/tests/test_host.rs +++ b/crates/yaak-commands/tests/test_host.rs @@ -8,6 +8,7 @@ //! `PluginHost` too, without one, which is only possible because that trait //! names operations rather than handing back a manager. +use std::collections::HashMap; use std::rc::Rc; use std::sync::{Arc, Mutex}; use tempfile::TempDir; @@ -15,18 +16,24 @@ use yaak_commands::models::{ cmd_default_headers, cmd_get_workspace_meta, models_delete, models_upsert, models_workspace_models, }; +use yaak_commands::templates::cmd_render_template; use yaak_commands::{Host, PluginHost}; use yaak_core::WorkspaceContext; use yaak_crypto::manager::EncryptionManager; use yaak_models::blob_manager::BlobManager; -use yaak_models::models::{AnyModel, Plugin, Workspace}; +use yaak_models::models::{AnyModel, Environment, EnvironmentVariable, Plugin, Workspace}; use yaak_models::query_manager::QueryManager; use yaak_models::util::{ModelPayload, UpdateSource}; +use yaak_plugins::events::{ + GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse, + JsonPrimitive, RenderPurpose, +}; use yaak_plugins::plugin_meta::PluginMetadata; use yaak_rpc_schema::{ - CmdDefaultHeadersReq, CmdGetWorkspaceMetaReq, ModelsDeleteReq, ModelsUpsertReq, - ModelsWorkspaceModelsReq, + CmdDefaultHeadersReq, CmdGetWorkspaceMetaReq, CmdRenderTemplateReq, ModelsDeleteReq, + ModelsUpsertReq, ModelsWorkspaceModelsReq, }; +use yaak_templates::TemplateCallback; #[derive(Clone)] struct TestHost { @@ -183,6 +190,32 @@ impl Host for SingleThreadedHost { } } +/// A template callback with no plugins behind it: variables still resolve, +/// function calls have nothing to run them. A browser host would put a Worker +/// round-trip where this returns an error. +struct NoTemplateFunctions; + +impl TemplateCallback for NoTemplateFunctions { + async fn run( + &self, + fn_name: &str, + _args: HashMap, + ) -> yaak_templates::error::Result { + Err(yaak_templates::error::Error::RenderError(format!( + "no plugin runtime to run {fn_name}()" + ))) + } + + fn transform_arg( + &self, + _fn_name: &str, + _arg_name: &str, + arg_value: &str, + ) -> yaak_templates::error::Result { + Ok(arg_value.to_string()) + } +} + /// Answering plugin questions with no plugin runtime behind them. A browser /// host would put a `postMessage` round-trip to its Worker where these return /// constants; the shape of the trait is what makes either possible. @@ -204,6 +237,29 @@ impl PluginHost for SingleThreadedHost { async fn encrypt_secure_template(&self, _template: &str) -> yaak_commands::Result { Err(yaak_commands::Error::Generic("no plugin runtime on this host".into())) } + + fn template_callback(&self, _purpose: RenderPurpose) -> impl TemplateCallback { + NoTemplateFunctions + } + + async fn template_function_summaries( + &self, + ) -> yaak_commands::Result> { + Ok(Vec::new()) + } + + async fn template_function_config( + &self, + function_name: &str, + _values: HashMap, + _model_id: &str, + ) -> yaak_commands::Result { + Err(yaak_commands::Error::Generic(format!("no plugin provides {function_name}()"))) + } + + async fn themes(&self) -> yaak_commands::Result> { + Ok(Vec::new()) + } } #[tokio::test] @@ -227,6 +283,43 @@ async fn a_single_threaded_host_can_implement_the_trait() { .expect("workspace models"); assert!(json.contains(&id), "the workspace should be in its own bootstrap payload"); + // Rendering, on a host whose template callback has no plugins behind it. + // Resolving the environment chain is a database read and the render is + // shared code; only the callback came from the host. Rendering a real + // variable is what proves the chain was resolved rather than skipped. + let environment = host + .db() + .upsert_environment( + &Environment { + workspace_id: id.clone(), + name: "Test env".to_string(), + base: true, + variables: vec![EnvironmentVariable { + enabled: true, + name: "greeting".to_string(), + value: "hello".to_string(), + id: None, + }], + ..Default::default() + }, + &host.update_source(), + ) + .expect("seed environment"); + + let rendered = cmd_render_template( + host.clone(), + CmdRenderTemplateReq { + template: "${[ greeting ]} world".to_string(), + workspace_id: id.clone(), + environment_id: Some(environment.id.clone()), + purpose: None, + ignore_error: None, + }, + ) + .await + .expect("render"); + assert_eq!(rendered, "hello world", "the environment chain should have been resolved"); + // The delete path too, since it is the one that used to reach for a // blocking thread this host does not have. let workspace = host.db().get_workspace(&id).expect("get workspace");