feat(v1.1.9): InvokeService trait + InvokeTarget + ScriptRepository::get_by_name

shared/invoke.rs introduces the InvokeService trait + InvokeTarget enum:

  - InvokeTarget::Path(String)  — resolves via the route trie
  - InvokeTarget::Name(String)  — (cx.app_id, name) -> script_id via get_by_name
  - InvokeTarget::Id(ScriptId)  — direct lookup

InvokeService::resolve enforces same-app at the service layer
(InvokeError::CrossApp). InvokeService::enqueue_async writes a v1.1.9
OutboxSourceKind::Invoke row for fire-and-forget composition.

Errors: NotFound, CrossApp, DepthExceeded(u32), Forbidden, Rejected,
Unavailable. NoopInvokeService surfaces all calls as Unavailable for
harnesses that don't wire the service.

ScriptRepository gains get_by_name(app_id, name) backed by the existing
(app_id, name) uniqueness constraint. Postgres impl + in-memory mock +
the picloud crate's PostgresScriptRepoHandle delegate added.

Services::new gains invoke: Arc<dyn InvokeService> positionally after
queue. with_noop_services wires NoopInvokeService. 12 test sites
threaded through. picloud/lib.rs binds NoopInvokeService at this commit
boundary — the real InvokeResolver lands in commit 8.

Deviation from plan (flagged for HANDBACK §7): the plan called for
moving ExecutorClient + ScriptIdentity from orchestrator-core to shared
so the invoke bridge could call a re-entrant variant. Re-evaluated:
the bridge lives in executor-core which can hold Arc<Engine> directly,
making the trait move unnecessary. Engine::execute is sync, returns
ExecResponse, and shares the engine instance + per-call SdkCallCx
exactly as required. AST cache reuse across invokes is a v1.2 optimization
(invokes recompile each call — ms-scale cost, acceptable).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-06 19:44:43 +02:00
parent 6891eda66c
commit 36bf046791
17 changed files with 213 additions and 5 deletions

View File

@@ -24,6 +24,16 @@ pub enum ScriptRepositoryError {
#[async_trait]
pub trait ScriptRepository: Send + Sync {
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError>;
/// v1.1.9. Resolve `(app_id, name) → Script`. Backs `invoke("name", …)`
/// — the SDK lets a caller name a target script by its `scripts.name`
/// instead of plumbing UUIDs. Returns `None` if no script in the app
/// has that name. Names are unique per `(app_id, name)` already
/// (existing migration constraint).
async fn get_by_name(
&self,
app_id: AppId,
name: &str,
) -> Result<Option<Script>, ScriptRepositoryError>;
/// Every script across all apps. Mostly for tests and admin
/// "global" views; the dashboard reaches scripts via `list_for_app`.
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError>;
@@ -145,6 +155,21 @@ impl ScriptRepository for PostgresScriptRepository {
Ok(row.map(Into::into))
}
async fn get_by_name(
&self,
app_id: AppId,
name: &str,
) -> Result<Option<Script>, ScriptRepositoryError> {
let row = sqlx::query_as::<_, ScriptRow>(&format!(
"SELECT {SCRIPT_SELECT_COLS} FROM scripts WHERE app_id = $1 AND name = $2"
))
.bind(app_id.into_inner())
.bind(name)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(Into::into))
}
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, ScriptRow>(&format!(
"SELECT {SCRIPT_SELECT_COLS} FROM scripts ORDER BY name"

View File

@@ -1153,6 +1153,19 @@ mod tests {
) -> Result<Option<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
Ok(self.existing.lock().await.get(&id).cloned())
}
async fn get_by_name(
&self,
app_id: AppId,
name: &str,
) -> Result<Option<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
Ok(self
.existing
.lock()
.await
.values()
.find(|s| s.app_id == app_id && s.name == name)
.cloned())
}
async fn list(
&self,
) -> Result<Vec<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {