Skip to main content

embassy_github/
suggestor.rs

1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4//! Formation-callable surface — [`GithubLookupSuggestor`] reads
5//! [`GithubRequest`] facts from `ContextKey::Seeds` and proposes
6//! typed [`GithubOrganizationPayload`] facts to `ContextKey::Hypotheses`.
7//!
8//! Same shape as every other embassy Suggestor: the kernel payload
9//! flattens the provider-side [`embassy_pack::Observation`] into
10//! audit-relevant fields, so `ProposedFact`'s `PartialEq` requirement
11//! is satisfied without forcing every `Observation<T>` content to derive
12//! it.
13
14use std::sync::Arc;
15
16use async_trait::async_trait;
17use converge_pack::{
18    AgentEffect, Context, ContextKey, ExecutionIdentity, FactPayload, ProposedFact,
19    ProvenanceSource, Suggestor,
20};
21use serde::{Deserialize, Serialize};
22
23use crate::provenance::GITHUB_PROVENANCE;
24use crate::provider::{GithubProvider, GithubRequest};
25use crate::types::Organization;
26
27/// Typed fact payload — one [`Organization`] per fact. Flattens the
28/// provider-side Observation into kernel-relevant fields so the
29/// `ProposedFact` `PartialEq` requirement holds without committing
30/// embassy-pack to deriving `PartialEq` on every `Observation<T>`.
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32#[serde(deny_unknown_fields)]
33pub struct GithubOrganizationPayload {
34    pub organization: Organization,
35    /// Joins back to `Observation::request_hash` for audit replay.
36    pub request_hash: String,
37    pub vendor: String,
38    pub latency_ms: u64,
39    pub execution_identity: ExecutionIdentity,
40}
41
42impl FactPayload for GithubOrganizationPayload {
43    const FAMILY: &'static str = "embassy.github.organization";
44    const VERSION: u16 = 1;
45}
46
47pub struct GithubLookupSuggestor<P: GithubProvider + 'static> {
48    provider: Arc<P>,
49}
50
51impl<P: GithubProvider + 'static> GithubLookupSuggestor<P> {
52    pub fn new(provider: Arc<P>) -> Self {
53        Self { provider }
54    }
55}
56
57#[async_trait]
58impl<P: GithubProvider + 'static> Suggestor for GithubLookupSuggestor<P> {
59    fn name(&self) -> &'static str {
60        "GithubLookupSuggestor"
61    }
62
63    fn dependencies(&self) -> &[ContextKey] {
64        &[ContextKey::Seeds]
65    }
66
67    fn provenance(&self) -> &'static str {
68        GITHUB_PROVENANCE.as_str()
69    }
70
71    fn accepts(&self, ctx: &dyn Context) -> bool {
72        ctx.get(ContextKey::Seeds)
73            .iter()
74            .any(|fact| fact.payload::<GithubRequest>().is_some())
75    }
76
77    async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
78        let mut proposals = Vec::new();
79
80        for seed in ctx.get(ContextKey::Seeds) {
81            let Some(request) = seed.payload::<GithubRequest>() else {
82                continue;
83            };
84
85            let response = match self
86                .provider
87                .fetch(request, &embassy_pack::CallContext::default())
88                .await
89            {
90                Ok(resp) => resp,
91                Err(err) => {
92                    tracing::warn!(
93                        seed = %seed.id(),
94                        provider = self.provider.name(),
95                        error = %err,
96                        "github fetch failed; skipping seed"
97                    );
98                    continue;
99                }
100            };
101
102            for (idx, observation) in response.records.into_iter().enumerate() {
103                let runtime_config = ExecutionIdentity::runtime_config_from_typed(request);
104                let execution_identity = ExecutionIdentity::non_native(
105                    env!("CARGO_PKG_NAME"),
106                    env!("CARGO_PKG_VERSION"),
107                    self.provider.name().to_string(),
108                    runtime_config,
109                );
110
111                let payload_value = GithubOrganizationPayload {
112                    organization: observation.content,
113                    request_hash: observation.request_hash,
114                    vendor: observation.vendor,
115                    latency_ms: observation.latency_ms,
116                    execution_identity,
117                };
118
119                proposals.push(
120                    ProposedFact::new(
121                        ContextKey::Hypotheses,
122                        format!("github:{}:{idx}", seed.id()),
123                        payload_value,
124                        GITHUB_PROVENANCE.as_str(),
125                    )
126                    .with_confidence(0.95),
127                );
128            }
129        }
130
131        if proposals.is_empty() {
132            AgentEffect::empty()
133        } else {
134            AgentEffect::with_proposals(proposals)
135        }
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use crate::provider::StubGithubProvider;
143
144    #[test]
145    fn suggestor_declares_seeds_dependency() {
146        // Intent: the engine wakes Suggestors by dirty-dependency
147        // intersection. If this list ever drops Seeds, the Suggestor
148        // stops firing on incoming requests — and would silently miss
149        // every one. Pin the contract.
150        let s = GithubLookupSuggestor::new(Arc::new(StubGithubProvider));
151        assert_eq!(s.dependencies(), &[ContextKey::Seeds]);
152    }
153
154    #[test]
155    fn suggestor_provenance_is_canonical() {
156        // Intent: every fact this Suggestor emits must be tagged
157        // with the canonical port provenance string so audit log
158        // searches scoped to that string hit every record.
159        let s = GithubLookupSuggestor::new(Arc::new(StubGithubProvider));
160        assert_eq!(s.provenance(), GITHUB_PROVENANCE.as_str());
161    }
162
163    #[test]
164    fn payload_family_and_version_are_stable() {
165        // Intent: payload (family, version) is the cross-version
166        // contract for any consumer filtering typed payloads.
167        // Changing either is a payload-schema break — pin the values
168        // so the change shows up in code review.
169        assert_eq!(
170            GithubOrganizationPayload::FAMILY,
171            "embassy.github.organization"
172        );
173        assert_eq!(GithubOrganizationPayload::VERSION, 1);
174    }
175}