Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 81 additions & 22 deletions crates/temper-server/src/state/dispatch/effects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -426,32 +426,91 @@ impl crate::state::ServerState {
.send(crate::state::ObserveRefreshHint::Agents);
}

/// Build the trajectory entry webhook dispatch renders templates from.
fn trajectory_entry_for_webhooks(
&self,
ctx: &PostDispatchContext<'_>,
response: &EntityResponse,
) -> TrajectoryEntry {
TrajectoryEntry {
timestamp: sim_now().to_rfc3339(),
tenant: ctx.tenant.to_string(),
entity_type: ctx.entity_type.to_string(),
entity_id: ctx.entity_id.to_string(),
action: ctx.action.to_string(),
success: response.success,
from_status: response.state.events.back().map(|e| e.from_status.clone()),
to_status: Some(response.state.status.clone()),
error: response.error.clone(),
agent_id: ctx.agent_ctx.agent_id.clone(),
session_id: ctx.agent_ctx.session_id.clone(),
authz_denied: None,
denied_resource: None,
denied_module: None,
source: Some(TrajectorySource::Entity),
spec_governed: None,
agent_type: ctx.agent_ctx.agent_type.clone(),
request_body: None,
intent: ctx.agent_ctx.intent.clone(),
matched_policy_ids: None,
}
}

/// Fire webhooks for the trajectory entry (non-blocking).
pub(crate) fn fire_webhooks(&self, ctx: &PostDispatchContext<'_>, response: &EntityResponse) {
// ARN-227: spec-declared webhook integrations fire here, matching the
// action name or any custom effect it produced — a superset of the
// wasm path's trigger semantics (wasm fires only on custom effects).
// They execute regardless of whether a webhooks.toml dispatcher is
// configured.
let spec_webhooks: Vec<temper_spec::automaton::Integration> = {
// Poison-tolerant read: this is a fire-and-forget side path, and
// a poisoned registry lock must not panic the dispatch response.
let registry = self
.registry
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner());
registry
.get_spec(ctx.tenant, ctx.entity_type)
.map(|spec| {
spec.integrations
.iter()
.filter(|ig| {
ig.integration_type == "webhook"
&& (ig.trigger == ctx.action
|| response.custom_effects.contains(&ig.trigger))
})
.cloned()
.collect()
})
.unwrap_or_default()
};
if !spec_webhooks.is_empty() {
let entry = self.trajectory_entry_for_webhooks(ctx, response);
for integration in &spec_webhooks {
// The same secret-template resolution the wasm and adapter
// integration paths apply (ADR-0046 `{secret:key}` headers).
let resolved_config = match self.secrets_vault.as_ref() {
Some(vault) => crate::secrets::template::resolve_secret_templates(
&integration.config,
vault,
&ctx.tenant.to_string(),
),
None => integration.config.clone(),
};
crate::webhooks::WebhookDispatcher::dispatch_spec_integration(
&self.spec_webhook_client,
integration,
&resolved_config,
&entry,
&response.state.fields,
);
}
}

if let Some(ref dispatcher) = self.webhook_dispatcher {
let dispatcher = Arc::clone(dispatcher);
let entry = TrajectoryEntry {
timestamp: sim_now().to_rfc3339(),
tenant: ctx.tenant.to_string(),
entity_type: ctx.entity_type.to_string(),
entity_id: ctx.entity_id.to_string(),
action: ctx.action.to_string(),
success: response.success,
from_status: response.state.events.back().map(|e| e.from_status.clone()),
to_status: Some(response.state.status.clone()),
error: response.error.clone(),
agent_id: ctx.agent_ctx.agent_id.clone(),
session_id: ctx.agent_ctx.session_id.clone(),
authz_denied: None,
denied_resource: None,
denied_module: None,
source: Some(TrajectorySource::Entity),
spec_governed: None,
agent_type: ctx.agent_ctx.agent_type.clone(),
request_body: None,
intent: ctx.agent_ctx.intent.clone(),
matched_policy_ids: None,
};
let entry = self.trajectory_entry_for_webhooks(ctx, response);
let from_status = entry.from_status.as_deref().unwrap_or("unknown");
let to_status = entry.to_status.as_deref().unwrap_or("unknown");
let outcome = if entry.success { "succeeded" } else { "failed" };
Expand Down
6 changes: 6 additions & 0 deletions crates/temper-server/src/state/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,10 @@ pub struct ServerState {
pub reaction_dispatcher: Arc<RwLock<Option<Arc<ReactionDispatcher>>>>,
/// Optional webhook dispatcher for external system notifications.
pub webhook_dispatcher: Option<Arc<WebhookDispatcher>>,
/// Shared HTTP client for spec-declared webhook integrations (ARN-227).
/// Always present — spec integrations fire regardless of whether a
/// `webhooks.toml` dispatcher is configured.
pub(crate) spec_webhook_client: reqwest::Client,
/// Native adapter integration registry (`type = "adapter"` dispatch path).
pub adapter_registry: Arc<AdapterRegistry>,
/// WASM module registry: maps (tenant, module_name) → sha256_hash.
Expand Down Expand Up @@ -692,6 +696,7 @@ impl ServerState {
pg_record_store: None,
reaction_dispatcher: Arc::new(RwLock::new(None)),
webhook_dispatcher: None,
spec_webhook_client: reqwest::Client::new(),
adapter_registry: Arc::new(AdapterRegistry::with_builtins()),
wasm_module_registry: Arc::new(RwLock::new(WasmModuleRegistry::new())),
wasm_engine: Arc::new(WasmEngine::default()),
Expand Down Expand Up @@ -940,6 +945,7 @@ impl ServerState {
pg_record_store: None,
reaction_dispatcher: Arc::new(RwLock::new(None)),
webhook_dispatcher: None,
spec_webhook_client: reqwest::Client::new(),
adapter_registry: Arc::new(AdapterRegistry::with_builtins()),
wasm_module_registry: Arc::new(RwLock::new(WasmModuleRegistry::new())),
wasm_engine: Arc::new(WasmEngine::default()),
Expand Down
153 changes: 153 additions & 0 deletions crates/temper-server/src/webhooks/dispatcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

use std::collections::BTreeMap;

use temper_spec::automaton::Integration;

use crate::state::TrajectoryEntry;

/// Configuration for a single webhook endpoint.
Expand Down Expand Up @@ -154,6 +156,135 @@ impl WebhookDispatcher {
}
}

/// Fire a spec-declared webhook integration (ARN-227).
///
/// `[[integration]]` blocks with `type = "webhook"` — including the
/// records the parser synthesizes from `[[action.triggers]]` webhook
/// blocks (ADR-0046) — are executed here after their trigger action
/// commits. The config contract matches what the parser writes:
/// - `url` (required; a missing url is a warn — the L0 gap of accepting
/// it is noted in ADR-0164),
/// - `method` (default POST),
/// - `body_template` / `payload_template`: the request body, with
/// `${...}` trajectory variables and `${field}` entity-field
/// placeholders expanded (default body: a JSON object of the
/// transition),
/// - `header.{Name}` keys: sent as HTTP header `Name`. The caller
/// resolves `{secret:key}` templates in config values BEFORE calling
/// (the same `resolve_secret_templates` pass the wasm/adapter paths
/// use); a header value still carrying an unresolved `{secret:`
/// template is dropped with a warning rather than leaked to the
/// remote host.
/// - Any other config key is ignored (debug-logged), never sent.
///
/// Fire-and-forget with the same never-block-the-action semantics as
/// `webhooks.toml` dispatch; fires only for successful actions.
pub fn dispatch_spec_integration(
client: &reqwest::Client,
integration: &Integration,
resolved_config: &BTreeMap<String, String>,
entry: &TrajectoryEntry,
entity_fields: &serde_json::Value,
) {
if !entry.success {
return;
}
let Some(url) = resolved_config.get("url").cloned() else {
tracing::warn!(
integration = %integration.name,
trigger = %integration.trigger,
"webhook integration has no url; nothing to call"
);
return;
};

let method = resolved_config
.get("method")
.map(|m| m.to_ascii_uppercase())
.unwrap_or_else(|| "POST".to_string());
let method =
reqwest::Method::from_bytes(method.as_bytes()).unwrap_or(reqwest::Method::POST);

let template = resolved_config
.get("body_template")
.or_else(|| resolved_config.get("payload_template"));
let payload = match template {
Some(template) => {
let expanded = expand_template(template, entry);
expand_entity_fields(&expanded, entity_fields)
}
None => serde_json::json!({
"tenant": entry.tenant,
"entity_type": entry.entity_type,
"entity_id": entry.entity_id,
"action": entry.action,
"from_status": entry.from_status,
"to_status": entry.to_status,
"integration": integration.name,
})
.to_string(),
};

let mut headers: Vec<(String, String)> = Vec::new();
for (key, value) in resolved_config {
if matches!(
key.as_str(),
"url" | "method" | "payload_template" | "body_template"
) {
continue;
}
if let Some(header_name) = key.strip_prefix("header.") {
if value.contains("{secret:") {
tracing::warn!(
integration = %integration.name,
header = %header_name,
"webhook header value carries an unresolved secret template; header dropped"
);
continue;
}
headers.push((header_name.to_string(), value.clone()));
} else {
tracing::debug!(
integration = %integration.name,
key = %key,
"unknown webhook integration config key ignored"
);
}
}

// Default Content-Type only when the integration did not declare its
// own — RequestBuilder::header APPENDS, so setting it unconditionally
// would send two Content-Type headers when a `header.Content-Type`
// config key exists.
let has_content_type = headers
.iter()
.any(|(k, _)| k.eq_ignore_ascii_case("content-type"));

let client = client.clone();
let name = integration.name.clone();
tokio::spawn(async move {
// determinism-ok: fire-and-forget webhook side-effect; no simulation-visible state touched
let mut builder = client.request(method, &url).body(payload);
if !has_content_type {
builder = builder.header("Content-Type", "application/json");
}
for (k, v) in &headers {
builder = builder.header(k.as_str(), v.as_str());
}
match builder.send().await {
Ok(resp) if resp.status().is_success() => {
tracing::debug!(integration = %name, url = %url, status = %resp.status(), "spec webhook integration dispatched");
}
Ok(resp) => {
tracing::warn!(integration = %name, url = %url, status = %resp.status(), "spec webhook integration returned non-success status");
}
Err(e) => {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
tracing::warn!(integration = %name, url = %url, error = %e, "spec webhook integration dispatch failed");
}
}
});
}

/// Returns `true` if the config should fire for the given trajectory entry.
fn matches(&self, config: &WebhookConfig, entry: &TrajectoryEntry) -> bool {
if config.on_success_only && !entry.success {
Expand Down Expand Up @@ -196,6 +327,28 @@ fn expand_env_vars(s: &str) -> String {
result
}

/// Expand `${field}` placeholders from the entity's post-action fields
/// (ADR-0046 `body_template` contract). Unknown placeholders are left
/// verbatim; non-string values render as JSON.
fn expand_entity_fields(template: &str, fields: &serde_json::Value) -> String {
let Some(map) = fields.as_object() else {
return template.to_string();
};
let mut result = template.to_string();
for (key, value) in map {
let placeholder = format!("${{{key}}}");
if !result.contains(&placeholder) {
continue;
}
let rendered = match value {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
result = result.replace(&placeholder, &rendered);
}
result
}

/// Expand `${variable}` placeholders using fields from a [`TrajectoryEntry`].
fn expand_template(template: &str, entry: &TrajectoryEntry) -> String {
template
Expand Down
Loading
Loading