greentic-aw-runtime 1.2.0-dev.33244367809

Enterprise Agentic Worker runtime — Plan-Act-Observe loop, Redis state, tool dispatch via greentic-ext-runtime
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
//! An HTTP provider that pulls a full [`GraphConfig`] from the
//! greentic-designer-admin agent-graph registry over HTTP, authed with a
//! tenant `gtc_live_*` bearer token.
//!
//! Mirrors [`crate::http_provider::HttpConfigProvider`] exactly — 10s timeout,
//! bearer auth, identical error taxonomy:
//!
//! | HTTP status | `ConfigError` variant |
//! |---|---|
//! | 200 + valid JSON  | `Ok(GraphConfig)` |
//! | 200 + invalid/unparseable body | `Misconfigured` (corrupt doc — do NOT silently fall back) |
//! | 401 / 403 | `Misconfigured` (bad token — operator-actionable, do NOT fall back) |
//! | 404 | `AgentNotFound(graph_id)` |
//! | other (network, 5xx, …) | `Internal` |
//!
//! **Corrupt-doc decision** (mirrors `HttpConfigProvider`): a 200 body that
//! fails `GraphConfig::from_json` — whether due to a JSON parse error, an
//! unsupported `schemaVersion`, or a structural graph validation failure — is
//! classified as `Misconfigured`, not `Internal`. This matches the agent
//! provider, which calls `.json::<AgentConfig>()` and maps *all* decode errors
//! to `Misconfigured`. The intent: a corrupt doc that the admin wrote is an
//! operator configuration problem, not a transient infrastructure failure.
//! Callers (`LayeredGraphProvider`) never fall back past a `Misconfigured`
//! error, so a bad graph document surfaces immediately instead of silently
//! hiding behind stale local pack data.

use crate::error::ConfigError;
use crate::graph::model::GraphConfig;
use crate::tenant::TenantContext;

/// Pulls [`GraphConfig`] from `{base}/api/v1/designer/agent-graphs/{graph_id}`.
///
/// Construct via [`HttpGraphProvider::new`]; call
/// [`HttpGraphProvider::graph_config`] per request. The inherent method
/// (rather than a trait) keeps this struct in `greentic-aw-runtime`, away from
/// the `GraphConfigSource` trait that lives in `runner-host`. The trait adapter
/// is a 5-line `impl GraphConfigSource for HttpGraphProvider` in `runner-host`'s
/// `graph_node.rs`.
pub struct HttpGraphProvider {
    base_url: String,
    token: String,
    client: reqwest::Client,
}

impl HttpGraphProvider {
    /// `base_url` is the admin origin (no trailing slash needed); `token` is a
    /// tenant `gtc_live_*` key.
    ///
    /// Mirrors [`crate::http_provider::HttpConfigProvider::new`] exactly:
    /// 10s per-request timeout, `unwrap_or_default` fallback on client build.
    pub fn new(base_url: impl Into<String>, token: impl Into<String>) -> Self {
        let client = reqwest::Client::builder()
            .timeout(std::time::Duration::from_secs(10))
            .build()
            .unwrap_or_default();
        Self {
            base_url: base_url.into().trim_end_matches('/').to_string(),
            token: token.into(),
            client,
        }
    }

    /// Fetch the graph document for `graph_id` from the admin registry.
    ///
    /// `tenant` is accepted for API symmetry with `ConfigProvider::agent_config`
    /// but not used in the request URL (the bearer token already scopes the
    /// request to the correct tenant, matching the agent-config provider's
    /// behaviour).
    pub async fn graph_config(
        &self,
        _tenant: &TenantContext,
        graph_id: &str,
    ) -> Result<GraphConfig, ConfigError> {
        let url = format!("{}/api/v1/designer/agent-graphs/{graph_id}", self.base_url);
        let resp = self
            .client
            .get(&url)
            .bearer_auth(&self.token)
            .send()
            .await
            .map_err(|e| ConfigError::Internal(format!("graph registry request failed: {e}")))?;

        match resp.status().as_u16() {
            200 => {
                // Read the full body as text, then parse via GraphConfig::from_json.
                // Any failure — invalid JSON, wrong schemaVersion, structural
                // validation error — is `Misconfigured` (mirrors HttpConfigProvider's
                // `.json::<AgentConfig>()` decode-error → Misconfigured mapping).
                let body = resp
                    .text()
                    .await
                    .map_err(|e| ConfigError::Misconfigured(format!("graph config read: {e}")))?;
                GraphConfig::from_json(&body)
                    .map_err(|e| ConfigError::Misconfigured(format!("graph config decode: {e}")))
            }
            404 => Err(ConfigError::AgentNotFound(graph_id.to_string())),
            // Auth failures are operator-actionable misconfig, not a
            // transient fault — surface them (Misconfigured is NOT swallowed
            // by LayeredGraphProvider) rather than masking a bad token behind
            // a local fallback. Mirrors HttpConfigProvider verbatim.
            401 | 403 => Err(ConfigError::Misconfigured(format!(
                "graph registry auth rejected (status {})",
                resp.status().as_u16()
            ))),
            other => Err(ConfigError::Internal(format!(
                "graph registry returned status {other}"
            ))),
        }
    }
}

// ---------------------------------------------------------------------------
// CachingGraphProvider
// ---------------------------------------------------------------------------

/// In-process TTL cache wrapping any `async fn graph_config(…)` provider.
///
/// Mirrors [`crate::config_provider::CachingConfigProvider`] for the graph
/// path: default TTL is 60 seconds (per spec Decision 13). Use
/// [`CachingGraphProvider::with_ttl`] for shorter values in tests.
///
/// The type parameter `P` must expose an inherent `async fn graph_config(…)`
/// matching the signature used by [`HttpGraphProvider`]. Concretely only
/// [`HttpGraphProvider`] needs wrapping today; a generic type parameter avoids
/// boxing and lets the compiler inline the inner call.
pub struct CachingGraphProvider<P> {
    inner: P,
    ttl: std::time::Duration,
    cache:
        tokio::sync::RwLock<std::collections::HashMap<CacheKey, (std::time::Instant, GraphConfig)>>,
}

#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct CacheKey {
    tenant_id: String,
    env_id: String,
    graph_id: String,
}

impl<P: Send + Sync> CachingGraphProvider<P> {
    /// Wrap `inner` with the production 60s TTL.
    pub fn new(inner: P) -> Self {
        Self::with_ttl(inner, std::time::Duration::from_secs(60))
    }

    /// Wrap `inner` with a custom TTL (useful for tests).
    pub fn with_ttl(inner: P, ttl: std::time::Duration) -> Self {
        Self {
            inner,
            ttl,
            cache: tokio::sync::RwLock::new(std::collections::HashMap::new()),
        }
    }
}

impl CachingGraphProvider<HttpGraphProvider> {
    /// Fetch with caching: serve a valid cached entry within the TTL, otherwise
    /// call the inner [`HttpGraphProvider`] and populate the cache on success.
    ///
    /// Only `Ok` responses are cached; errors are always forwarded to the
    /// caller — matching `CachingConfigProvider` behaviour.
    pub async fn graph_config(
        &self,
        tenant: &TenantContext,
        graph_id: &str,
    ) -> Result<GraphConfig, ConfigError> {
        let key = CacheKey {
            tenant_id: tenant.tenant_id.clone(),
            env_id: tenant.env_id.clone(),
            graph_id: graph_id.to_string(),
        };
        {
            let cache = self.cache.read().await;
            if let Some((stored_at, cfg)) = cache.get(&key)
                && stored_at.elapsed() < self.ttl
            {
                return Ok(cfg.clone());
            }
        }
        let fresh = self.inner.graph_config(tenant, graph_id).await?;
        let mut cache = self.cache.write().await;
        cache.insert(key, (std::time::Instant::now(), fresh.clone()));
        Ok(fresh)
    }
}

// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
    use super::*;
    use wiremock::matchers::{header, method, path};
    use wiremock::{Mock, MockServer, ResponseTemplate};

    /// Minimal valid graph JSON — mirrors the triage fixture used elsewhere.
    fn valid_graph_json() -> serde_json::Value {
        serde_json::json!({
            "schemaVersion": 1,
            "entry": "agent",
            "nodes": [
                {"id": "agent", "kind": "agent", "systemPrompt": "You triage.", "model": "gpt-4o-mini", "tools": []},
                {"id": "lookup", "kind": "tool", "toolName": "kb/search"},
                {"id": "router", "kind": "router", "maxIterations": 3},
                {"id": "respond", "kind": "respond"}
            ],
            "edges": [
                {"from": "agent", "to": "lookup"},
                {"from": "lookup", "to": "router"},
                {"from": "router", "to": "agent", "branch": "loop"},
                {"from": "router", "to": "respond", "branch": "resolved"}
            ]
        })
    }

    fn tenant() -> TenantContext {
        TenantContext::new("t", "e")
    }

    // -----------------------------------------------------------------------
    // HttpGraphProvider tests
    // -----------------------------------------------------------------------

    #[tokio::test]
    async fn fetches_and_parses_graph_config() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/api/v1/designer/agent-graphs/triage.graph"))
            .and(header("authorization", "Bearer gtc_live_x"))
            .respond_with(ResponseTemplate::new(200).set_body_json(valid_graph_json()))
            .mount(&server)
            .await;

        let provider = HttpGraphProvider::new(server.uri(), "gtc_live_x");
        let cfg = provider
            .graph_config(&tenant(), "triage.graph")
            .await
            .unwrap();
        assert_eq!(cfg.schema_version, 1);
        assert_eq!(cfg.graph.entry, "agent");
        assert_eq!(cfg.graph.nodes.len(), 4);
    }

    #[tokio::test]
    async fn namespaced_graph_id_with_dot_is_fetched_at_correct_url() {
        // graph_id of form "{worker}.graph" (as registered by store hand-off PR 3)
        // — '.' is valid in graph ids; only ':' is forbidden. The URL must
        // contain the literal dot, not a percent-encoded form.
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/api/v1/designer/agent-graphs/my-worker.graph"))
            .respond_with(ResponseTemplate::new(200).set_body_json(valid_graph_json()))
            .mount(&server)
            .await;

        let provider = HttpGraphProvider::new(server.uri(), "tok");
        let result = provider.graph_config(&tenant(), "my-worker.graph").await;
        assert!(result.is_ok(), "dotted graph_id must resolve: {:?}", result);
    }

    #[tokio::test]
    async fn maps_404_to_agent_not_found() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .respond_with(ResponseTemplate::new(404))
            .mount(&server)
            .await;

        let provider = HttpGraphProvider::new(server.uri(), "gtc_live_x");
        let result = provider.graph_config(&tenant(), "ghost.graph").await;
        assert!(
            matches!(result, Err(ConfigError::AgentNotFound(_))),
            "404 must map to AgentNotFound: {result:?}"
        );
    }

    #[tokio::test]
    async fn maps_401_to_misconfigured() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .respond_with(ResponseTemplate::new(401))
            .mount(&server)
            .await;

        let provider = HttpGraphProvider::new(server.uri(), "gtc_live_bad");
        let result = provider.graph_config(&tenant(), "triage.graph").await;
        assert!(
            matches!(result, Err(ConfigError::Misconfigured(_))),
            "401 must map to Misconfigured: {result:?}"
        );
    }

    #[tokio::test]
    async fn maps_403_to_misconfigured() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .respond_with(ResponseTemplate::new(403))
            .mount(&server)
            .await;

        let provider = HttpGraphProvider::new(server.uri(), "gtc_live_bad");
        let result = provider.graph_config(&tenant(), "triage.graph").await;
        assert!(
            matches!(result, Err(ConfigError::Misconfigured(_))),
            "403 must map to Misconfigured: {result:?}"
        );
    }

    #[tokio::test]
    async fn maps_5xx_to_internal() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .respond_with(ResponseTemplate::new(503))
            .mount(&server)
            .await;

        let provider = HttpGraphProvider::new(server.uri(), "gtc_live_x");
        let result = provider.graph_config(&tenant(), "triage.graph").await;
        assert!(
            matches!(result, Err(ConfigError::Internal(_))),
            "5xx must map to Internal: {result:?}"
        );
    }

    #[tokio::test]
    async fn maps_malformed_json_to_misconfigured() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .respond_with(ResponseTemplate::new(200).set_body_string("{not json"))
            .mount(&server)
            .await;

        let provider = HttpGraphProvider::new(server.uri(), "gtc_live_x");
        let result = provider.graph_config(&tenant(), "triage.graph").await;
        assert!(
            matches!(result, Err(ConfigError::Misconfigured(_))),
            "invalid JSON body must map to Misconfigured: {result:?}"
        );
    }

    #[tokio::test]
    async fn maps_unsupported_schema_version_to_misconfigured() {
        let server = MockServer::start().await;
        let bad_doc = serde_json::json!({
            "schemaVersion": 99,
            "entry": "agent",
            "nodes": [
                {"id": "agent", "kind": "agent", "systemPrompt": "x", "model": "gpt-4o-mini", "tools": []},
                {"id": "respond", "kind": "respond"}
            ],
            "edges": [{"from": "agent", "to": "respond"}]
        });
        Mock::given(method("GET"))
            .respond_with(ResponseTemplate::new(200).set_body_json(bad_doc))
            .mount(&server)
            .await;

        let provider = HttpGraphProvider::new(server.uri(), "gtc_live_x");
        let result = provider.graph_config(&tenant(), "triage.graph").await;
        assert!(
            matches!(result, Err(ConfigError::Misconfigured(_))),
            "unsupported schemaVersion must map to Misconfigured: {result:?}"
        );
    }

    // -----------------------------------------------------------------------
    // CachingGraphProvider tests
    // -----------------------------------------------------------------------

    #[tokio::test]
    async fn caching_provider_hits_inner_once_within_ttl() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .respond_with(ResponseTemplate::new(200).set_body_json(valid_graph_json()))
            .mount(&server)
            .await;

        let provider = CachingGraphProvider::new(HttpGraphProvider::new(server.uri(), "tok"));
        let tc = tenant();

        let _ = provider.graph_config(&tc, "g1").await.unwrap();
        let _ = provider.graph_config(&tc, "g1").await.unwrap();
        let _ = provider.graph_config(&tc, "g1").await.unwrap();

        // wiremock counts requests; only 1 should have reached the mock server.
        assert_eq!(server.received_requests().await.unwrap().len(), 1);
    }

    #[tokio::test]
    async fn caching_provider_expires_after_ttl() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .respond_with(ResponseTemplate::new(200).set_body_json(valid_graph_json()))
            .mount(&server)
            .await;

        let provider = CachingGraphProvider::with_ttl(
            HttpGraphProvider::new(server.uri(), "tok"),
            std::time::Duration::from_millis(50),
        );
        let tc = tenant();

        let _ = provider.graph_config(&tc, "g1").await.unwrap();
        tokio::time::sleep(std::time::Duration::from_millis(80)).await;
        let _ = provider.graph_config(&tc, "g1").await.unwrap();

        assert_eq!(server.received_requests().await.unwrap().len(), 2);
    }

    #[tokio::test]
    async fn caching_provider_does_not_cache_errors() {
        // Errors must always hit the inner provider; a cached error would
        // permanently block a graph_id after a transient failure.
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .respond_with(ResponseTemplate::new(503))
            .mount(&server)
            .await;

        let provider = CachingGraphProvider::new(HttpGraphProvider::new(server.uri(), "tok"));
        let tc = tenant();

        let _ = provider.graph_config(&tc, "g1").await.unwrap_err();
        let _ = provider.graph_config(&tc, "g1").await.unwrap_err();

        // Both calls must have reached the server — errors are not stored.
        assert_eq!(
            server.received_requests().await.unwrap().len(),
            2,
            "errors must not be cached; every error call hits the inner provider"
        );
    }

    #[tokio::test]
    async fn caching_provider_isolates_tenants() {
        // Two tenants with the same graph_id must receive independent cache
        // entries — serving tenant-A's graph to tenant-B would be a data leak.
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .respond_with(ResponseTemplate::new(200).set_body_json(valid_graph_json()))
            .mount(&server)
            .await;

        let provider = CachingGraphProvider::new(HttpGraphProvider::new(server.uri(), "tok"));
        let tc_a = TenantContext::new("tenant-a", "prod");
        let tc_b = TenantContext::new("tenant-b", "prod");

        let _ = provider.graph_config(&tc_a, "g1").await.unwrap();
        let _ = provider.graph_config(&tc_b, "g1").await.unwrap();

        // Each tenant's first request must hit the inner provider independently.
        assert_eq!(
            server.received_requests().await.unwrap().len(),
            2,
            "separate tenants must not share a cache entry for the same graph_id"
        );
    }
}