Files
PiCloud/crates/shared/src/services.rs
MechaCat02 5a630e1d9f feat(workflows): M5 — SDK workflow::start + admin run API + pic CLI
The user-facing surface for the durable DAG engine. A workflow run can now be
started three ways, all resolving the workflow by name in the caller's app
(`cx.app_id` / the resolved app — never a passed-in arg, the isolation
boundary), seeding a run the orchestrator advances.

SDK seam (shared):
- `WorkflowService` trait + `NoopWorkflowService` + `WorkflowError` in
  `shared::workflow` (mirrors `InvokeService`); added to `Services` as a
  noop-defaulted `with_workflow` builder (all `Services::new` call sites
  unchanged).
- `WorkflowServiceImpl` (manager-core): resolves + seeds a run; authenticated
  callers gated on `AppInvoke` (anonymous skips, script-as-gate), the same gate
  `invoke()` uses.
- `executor-core::sdk::workflow` — the Rhai bridge `workflow::start(name, input)`
  / `workflow::start(name)`, registered in `register_all`. Returns the run id.

Admin API (manager-core `workflows_api`, mounted in the binary):
- `GET  /apps/{app}/workflows`              — definitions (AppRead)
- `POST /apps/{app}/workflows/{name}/runs`  — start a run (AppInvoke)
- `GET  /apps/{app}/workflows/{name}/runs`  — run history (AppRead)
- `GET  /apps/{app}/workflow-runs/{run_id}` — one run + its steps (AppRead)
  `app_id` scopes every query; a foreign run 404s.

CLI (`pic workflows`): `ls` · `run <name> [--input JSON]` · `runs <name>` ·
`run-status <run_id>` — all `--app`-scoped; run definitions stay declarative
(`[[workflows]]` → `pic apply`).

Also: `list_runs_for_workflow` repo reader.

Tests: `workflow_service_start_seeds_a_run` (DB-gated — the SDK/API-shared
entry) + `workflow_run_executes_end_to_end` CLI journey (apply → `pic workflows
run` → poll `run-status` → succeeded, through the real binary + orchestrator).
fmt + clippy -D warnings clean, 449 manager-core lib tests, 14 orchestrator DB
tests, 18 executor-core lib tests, 3 workflow CLI journeys green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 17:48:38 +02:00

281 lines
12 KiB
Rust

//! `Services` — bundle of stateful SDK service handles plumbed from the
//! host binary into every Rhai execution.
//!
//! Constructed once at startup in the picloud binary; cloned (cheap —
//! every field is an `Arc`) into the per-call sdk bridge so script
//! invocations don't need to re-resolve dependencies. The bundle is
//! handed to `executor-core::sdk::register_all` alongside an
//! `SdkCallCx` to wire each `::` namespace.
//!
//! v1.1.0 shipped this empty; v1.1.1 added the first two service fields
//! (`kv`, `dead_letters`) plus the `events` emitter that bound services
//! use to publish events into the triggers outbox. v1.1.3 adds the
//! `modules` field — the `ModuleSource` consulted by the per-call
//! `PicloudModuleResolver` to load `import`ed module scripts.
//!
//! `#[non_exhaustive]` so adding fields is a non-breaking change for
//! consumers that only *pattern-match* a `&Services`; only crates that
//! *construct* a `Services` (the picloud binary and tests) update.
use std::sync::Arc;
use crate::{
DeadLetterService, DocsService, EmailService, FilesService, GroupDocsService,
GroupFilesService, GroupKvService, GroupPubsubService, GroupQueueService, HttpService,
InvokeService, KvService, ModuleSource, NoopDeadLetterService, NoopDocsService,
NoopEmailService, NoopEventEmitter, NoopFilesService, NoopGroupDocsService,
NoopGroupFilesService, NoopGroupKvService, NoopGroupPubsubService, NoopGroupQueueService,
NoopHttpService, NoopInvokeService, NoopKvService, NoopModuleSource, NoopPubsubService,
NoopQueueService, NoopSecretsService, NoopUsersService, NoopVarsService, NoopWorkflowService,
PubsubService, QueueService, SecretsService, ServiceEventEmitter, UsersService, VarsService,
WorkflowService,
};
/// SDK service bundle. See module docs for the lifecycle and the v1.1.x
/// expansion plan.
#[non_exhaustive]
pub struct Services {
/// KV store (v1.1.1). Backed by Postgres in the picloud binary;
/// in-memory in tests.
pub kv: Arc<dyn KvService>,
/// Document store (v1.1.2). Backed by Postgres in the picloud
/// binary; in-memory in tests.
pub docs: Arc<dyn DocsService>,
/// Dead-letter management (v1.1.1). Scripts get
/// `dead_letters::replay(id)` and `dead_letters::resolve(id, reason)`.
pub dead_letters: Arc<dyn DeadLetterService>,
/// Event emitter for the triggers outbox. Mutating service methods
/// (`KvService::set/delete`, `DocsService::create/update/delete`,
/// future `files::*`, etc.) call `events.emit(cx, event)` after
/// the write succeeds. The outbox-backed impl in
/// `manager-core::outbox_event_emitter` replaces v1.1.0's
/// `NoopEventEmitter`.
pub events: Arc<dyn ServiceEventEmitter>,
/// Module source (v1.1.3). The `PicloudModuleResolver` consults
/// this to load `kind = 'module'` scripts that other scripts
/// `import`. Backed by Postgres in the picloud binary; in-memory
/// fakes in resolver tests.
pub modules: Arc<dyn ModuleSource>,
/// Outbound HTTP (v1.1.4). Scripts get `http::{get,post,…}`.
/// Backed by a reqwest client with the SSRF deny-list resolver in
/// the picloud binary; `NoopHttpService` in tests that don't make
/// network calls.
pub http: Arc<dyn HttpService>,
/// Filesystem-backed blob storage (v1.1.5). Scripts get
/// `files::collection(name).{create,head,get,update,delete,list}`.
/// Backed by a Postgres-metadata + on-disk-bytes repo in the
/// picloud binary; `NoopFilesService` in tests that don't touch
/// files.
pub files: Arc<dyn FilesService>,
/// Durable pub/sub (v1.1.5). Scripts get
/// `pubsub::publish_durable(topic, message)`. Backed by a
/// publish-time outbox fan-out in the picloud binary;
/// `NoopPubsubService` in tests that don't publish.
pub pubsub: Arc<dyn PubsubService>,
/// Encrypted per-app secrets (v1.1.7). Scripts get
/// `secrets::{get,set,delete,list}(name)`. Backed by an
/// AES-256-GCM-at-rest Postgres repo in the picloud binary;
/// `NoopSecretsService` in tests that don't touch secrets.
pub secrets: Arc<dyn SecretsService>,
/// Outbound email (v1.1.7). Scripts get `email::{send,send_html}`.
/// Backed by an SMTP relay (lettre) in the picloud binary;
/// `NoopEmailService` (always `NotConfigured`) in tests that don't
/// send mail.
pub email: Arc<dyn EmailService>,
/// Per-app data-plane user management (v1.1.8). Scripts get the
/// full `users::*` namespace (CRUD, login/verify/logout, email
/// verification, password reset, invitations, string-tagged
/// roles). Backed by Postgres-stored Argon2id hashes + SHA-256
/// session tokens in the picloud binary; `NoopUsersService` in
/// tests that don't exercise users.
pub users: Arc<dyn UsersService>,
/// Durable per-app named queues (v1.1.9). Scripts get
/// `queue::{enqueue,depth,depth_pending}`. Backed by Postgres in
/// the picloud binary; `NoopQueueService` in tests that don't
/// touch queues. Consumers register via `queue:receive` triggers
/// (one per `(app_id, queue_name)`).
pub queue: Arc<dyn QueueService>,
/// Same-app function composition (v1.1.9). Backs `invoke()` (sync)
/// and `invoke_async()` (fire-and-forget through the outbox).
/// Cross-app invokes are rejected at the service entry point.
pub invoke: Arc<dyn InvokeService>,
/// Group-inherited, env-scoped config (Phase 3). Scripts get
/// read-only `vars::{get,all}`; values resolve down the group tree
/// (§3). Backed by `config_resolver` over Postgres in the picloud
/// binary; `NoopVarsService` in tests that don't read config.
pub vars: Arc<dyn VarsService>,
/// Shared cross-app group collections (§11.6). Scripts get
/// `kv::shared_collection(name)` — read/write KV scoped to the nearest ancestor
/// group that declares the collection shared. Backed by Postgres in the
/// picloud binary (wired via [`Services::with_group_kv`]); defaults to
/// `NoopGroupKvService` so test bundles that don't exercise sharing build
/// unchanged.
pub group_kv: Arc<dyn GroupKvService>,
/// Shared cross-app group DOCS collections (§11.6). Scripts get
/// `docs::shared_collection(name)`. Wired via [`Services::with_group_docs`];
/// defaults to `NoopGroupDocsService`.
pub group_docs: Arc<dyn GroupDocsService>,
/// Shared cross-app group FILES collections (§11.6). Scripts get
/// `files::shared_collection(name)`. Wired via [`Services::with_group_files`];
/// defaults to `NoopGroupFilesService`.
pub group_files: Arc<dyn GroupFilesService>,
/// Shared cross-app group TOPICS (§11.6 D2). Scripts get
/// `pubsub::shared_topic(name)` — publish to a group-scoped topic watched by
/// `shared = true` group pubsub triggers. Wired via
/// [`Services::with_group_pubsub`]; defaults to `NoopGroupPubsubService`.
pub group_pubsub: Arc<dyn GroupPubsubService>,
/// Shared cross-app group QUEUES (§11.6 D3). Scripts get
/// `queue::shared_collection(name)` — enqueue into a group-keyed store
/// drained by competing per-descendant consumers. Wired via
/// [`Services::with_group_queue`]; defaults to `NoopGroupQueueService`.
pub group_queue: Arc<dyn GroupQueueService>,
/// v1.2 Workflows (M5). Scripts get `workflow::start(name, input)` — start a
/// run of a named workflow in the caller's app. Wired via
/// [`Services::with_workflow`]; defaults to `NoopWorkflowService`.
pub workflow: Arc<dyn WorkflowService>,
}
impl Services {
/// Construct a bundle from already-constructed `Arc<dyn …>` handles.
/// The picloud binary's `main` wires this up after the DB pool is
/// open; tests build it from in-memory fakes.
#[must_use]
#[allow(clippy::too_many_arguments)] // one Arc per stateful service; a builder would just move the noise
pub fn new(
kv: Arc<dyn KvService>,
docs: Arc<dyn DocsService>,
dead_letters: Arc<dyn DeadLetterService>,
events: Arc<dyn ServiceEventEmitter>,
modules: Arc<dyn ModuleSource>,
http: Arc<dyn HttpService>,
files: Arc<dyn FilesService>,
pubsub: Arc<dyn PubsubService>,
secrets: Arc<dyn SecretsService>,
email: Arc<dyn EmailService>,
users: Arc<dyn UsersService>,
queue: Arc<dyn QueueService>,
invoke: Arc<dyn InvokeService>,
vars: Arc<dyn VarsService>,
) -> Self {
Self {
kv,
docs,
dead_letters,
events,
modules,
http,
files,
pubsub,
secrets,
email,
users,
queue,
invoke,
vars,
// §11.6 group collections default to noop; the picloud binary opts
// in via `with_group_kv`. Keeps the ~14 `Services::new` call sites
// (mostly tests) unchanged — a field addition, not a param.
group_kv: Arc::new(NoopGroupKvService),
group_docs: Arc::new(NoopGroupDocsService),
group_files: Arc::new(NoopGroupFilesService),
group_pubsub: Arc::new(NoopGroupPubsubService),
group_queue: Arc::new(NoopGroupQueueService),
workflow: Arc::new(NoopWorkflowService),
}
}
/// Set the v1.2 Workflows service (picloud binary wires the Postgres-backed
/// impl; tests leave the noop default).
#[must_use]
pub fn with_workflow(mut self, workflow: Arc<dyn WorkflowService>) -> Self {
self.workflow = workflow;
self
}
/// Set the §11.6 shared group-KV service (the picloud binary wires the
/// Postgres-backed impl; tests leave the noop default).
#[must_use]
pub fn with_group_kv(mut self, group_kv: Arc<dyn GroupKvService>) -> Self {
self.group_kv = group_kv;
self
}
/// Set the §11.6 shared group-DOCS service (picloud binary; tests leave noop).
#[must_use]
pub fn with_group_docs(mut self, group_docs: Arc<dyn GroupDocsService>) -> Self {
self.group_docs = group_docs;
self
}
/// Set the §11.6 shared group-FILES service (picloud binary; tests leave noop).
#[must_use]
pub fn with_group_files(mut self, group_files: Arc<dyn GroupFilesService>) -> Self {
self.group_files = group_files;
self
}
/// Set the §11.6 D2 shared group-TOPIC service (picloud binary; tests leave noop).
#[must_use]
pub fn with_group_pubsub(mut self, group_pubsub: Arc<dyn GroupPubsubService>) -> Self {
self.group_pubsub = group_pubsub;
self
}
/// Set the §11.6 D3 shared group-QUEUE service (picloud binary; tests leave noop).
#[must_use]
pub fn with_group_queue(mut self, group_queue: Arc<dyn GroupQueueService>) -> Self {
self.group_queue = group_queue;
self
}
/// All-noop bundle for tests that build an `Engine` but don't
/// exercise the stateful services. Returns the same shape as
/// `Services::new` so callers can't accidentally rely on a stub
/// silently doing the right thing — every call into a noop
/// service surfaces an explicit error.
#[must_use]
pub fn with_noop_services() -> Self {
Self::new(
Arc::new(NoopKvService),
Arc::new(NoopDocsService),
Arc::new(NoopDeadLetterService),
Arc::new(NoopEventEmitter),
Arc::new(NoopModuleSource),
Arc::new(NoopHttpService),
Arc::new(NoopFilesService),
Arc::new(NoopPubsubService),
Arc::new(NoopSecretsService),
Arc::new(NoopEmailService),
Arc::new(NoopUsersService),
Arc::new(NoopQueueService),
Arc::new(NoopInvokeService),
Arc::new(NoopVarsService),
)
}
}
impl Default for Services {
fn default() -> Self {
Self::with_noop_services()
}
}