mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(composio): incremental sync with per-item persistence for Gmail and Notion (#519)
* feat(composio): implement incremental sync and state management for Gmail and Notion providers - Enhanced the Gmail and Notion providers to support incremental synchronization with per-item persistence, improving data handling efficiency. - Introduced a new `SyncState` module to manage persistent sync state, including cursor tracking, synced IDs, and daily request budget management. - Updated sync logic to load state from a KV store, check daily budget limits, and handle paginated API requests, ensuring robust data retrieval and deduplication. - Refactored existing sync methods to utilize the new state management, enhancing overall reliability and performance of the providers. - Improved documentation for the sync process and state management, clarifying the operational flow and usage of the new features. * refactor(composio): improve error handling and logging in Gmail and Notion providers - Enhanced error handling in the Gmail provider to format error messages more clearly during email fetching. - Streamlined debug logging in both Gmail and Notion providers to improve readability by consolidating multiline statements into single lines. - Refactored the `extract_page_title` function in the Notion provider for better clarity in property extraction logic. - Overall, these changes aim to enhance maintainability and improve the clarity of error reporting and logging across the providers.
This commit is contained in:
@@ -1,42 +1,60 @@
|
||||
//! Gmail provider — native Rust counterpart to the QuickJS gmail skill.
|
||||
//! Gmail provider — incremental sync with per-item persistence.
|
||||
//!
|
||||
//! Mirrors the high-level shape of the JS skill in
|
||||
//! `tinyhumansai/openhuman-skills/skills/gmail/index.js`:
|
||||
//! On each sync pass:
|
||||
//!
|
||||
//! * On connection / periodic tick → fetch the user profile
|
||||
//! (`GMAIL_GET_PROFILE`) and a window of recent message metadata
|
||||
//! (`GMAIL_FETCH_EMAILS`).
|
||||
//! * Persist a JSON snapshot of the result into the global memory
|
||||
//! layer under namespace `composio-gmail` so the agent loop can
|
||||
//! surface it via `recall_memory`.
|
||||
//! * On `GMAIL_NEW_GMAIL_MESSAGE` triggers → run an incremental
|
||||
//! sync so newly arrived mail makes it into memory promptly.
|
||||
//! 1. Load persistent [`SyncState`] from the KV store.
|
||||
//! 2. Check the daily request budget — bail early if exhausted.
|
||||
//! 3. Fetch a page of recent messages via `GMAIL_FETCH_EMAILS`, adding
|
||||
//! a date filter when a cursor exists so only newer mail is returned.
|
||||
//! 4. Deduplicate against `synced_ids` in the state.
|
||||
//! 5. Persist each **new** message as its own memory document (not a
|
||||
//! single giant snapshot) so agent recall can find individual emails.
|
||||
//! 6. Paginate (up to budget) until no more results or all items in the
|
||||
//! page are already synced.
|
||||
//! 7. Advance the cursor and save state.
|
||||
//!
|
||||
//! All upstream API access goes through
|
||||
//! [`super::ProviderContext::client`] which proxies to the openhuman
|
||||
//! backend's `/agent-integrations/composio/execute` endpoint. This
|
||||
//! provider never holds raw OAuth tokens or hits Composio directly.
|
||||
//! Daily budget (`DEFAULT_DAILY_REQUEST_LIMIT`, default 500) caps the
|
||||
//! number of `execute_tool` calls per calendar day, preventing runaway
|
||||
//! API usage during large initial backfills.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::sync_state::{extract_item_id, persist_single_item, SyncState};
|
||||
use super::{
|
||||
pick_str, ComposioProvider, ProviderContext, ProviderUserProfile, SyncOutcome, SyncReason,
|
||||
};
|
||||
|
||||
/// Composio action slugs used by this provider. Hoisted to constants so
|
||||
/// they're easy to grep + adjust if Composio renames them upstream.
|
||||
const ACTION_GET_PROFILE: &str = "GMAIL_GET_PROFILE";
|
||||
const ACTION_FETCH_EMAILS: &str = "GMAIL_FETCH_EMAILS";
|
||||
|
||||
/// Default page size for the periodic email pull. Kept conservative —
|
||||
/// the goal is "freshness for the agent", not a full archive backfill.
|
||||
const FETCH_EMAILS_LIMIT: u32 = 25;
|
||||
/// Page size per API call. Kept moderate so each call is fast and we
|
||||
/// get frequent checkpoints for the daily budget.
|
||||
const PAGE_SIZE: u32 = 25;
|
||||
|
||||
/// Memory namespace prefix used when persisting sync snapshots. Mirrors
|
||||
/// the `skill-{id}` convention in [`crate::openhuman::memory::store::client`]
|
||||
/// so namespace listings stay coherent across composio + js skills.
|
||||
const MEMORY_NAMESPACE: &str = "composio-gmail";
|
||||
/// Larger page size for the very first sync after OAuth so the user
|
||||
/// gets a meaningful initial snapshot.
|
||||
const INITIAL_PAGE_SIZE: u32 = 50;
|
||||
|
||||
/// Maximum pages to fetch in a single sync pass (guards against infinite
|
||||
/// pagination loops). Combined with PAGE_SIZE this yields at most
|
||||
/// 500 items per sync pass, well within the daily budget.
|
||||
const MAX_PAGES_PER_SYNC: u32 = 20;
|
||||
|
||||
/// Paths to try when extracting a message's unique ID from the Composio
|
||||
/// response envelope.
|
||||
const MESSAGE_ID_PATHS: &[&str] = &["id", "data.id", "messageId", "data.messageId"];
|
||||
|
||||
/// Paths for extracting the internal date (epoch millis or date string)
|
||||
/// used as the sync cursor.
|
||||
const MESSAGE_DATE_PATHS: &[&str] = &[
|
||||
"internalDate",
|
||||
"data.internalDate",
|
||||
"date",
|
||||
"data.date",
|
||||
"receivedAt",
|
||||
"data.receivedAt",
|
||||
];
|
||||
|
||||
pub struct GmailProvider;
|
||||
|
||||
@@ -59,8 +77,6 @@ impl ComposioProvider for GmailProvider {
|
||||
}
|
||||
|
||||
fn sync_interval_secs(&self) -> Option<u64> {
|
||||
// 15 minutes — matches the default `syncIntervalMinutes` the
|
||||
// QuickJS gmail skill uses.
|
||||
Some(15 * 60)
|
||||
}
|
||||
|
||||
@@ -88,11 +104,6 @@ impl ComposioProvider for GmailProvider {
|
||||
}
|
||||
|
||||
let data = &resp.data;
|
||||
// Composio wraps results in `{ data: { ... }, successful: bool }`
|
||||
// and the upstream Gmail API returns `{ emailAddress, messagesTotal,
|
||||
// threadsTotal, historyId }`. We dig through both `data` and the
|
||||
// raw root because backend wrappers occasionally collapse the
|
||||
// outer envelope.
|
||||
let email = pick_str(
|
||||
data,
|
||||
&[
|
||||
@@ -124,10 +135,6 @@ impl ComposioProvider for GmailProvider {
|
||||
avatar_url: None,
|
||||
extras: data.clone(),
|
||||
};
|
||||
// PII discipline: never log the actual email address. We log
|
||||
// only non-PII indicators (presence of an email, the domain
|
||||
// portion if any) so the trace is still useful for debugging
|
||||
// missing-profile cases without leaking the user's identity.
|
||||
let has_email = profile.email.is_some();
|
||||
let email_domain = profile
|
||||
.email
|
||||
@@ -145,67 +152,233 @@ impl ComposioProvider for GmailProvider {
|
||||
|
||||
async fn sync(&self, ctx: &ProviderContext, reason: SyncReason) -> Result<SyncOutcome, String> {
|
||||
let started_at_ms = now_ms();
|
||||
let connection_id = ctx
|
||||
.connection_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
|
||||
tracing::info!(
|
||||
connection_id = ?ctx.connection_id,
|
||||
connection_id = %connection_id,
|
||||
reason = reason.as_str(),
|
||||
"[composio:gmail] sync starting"
|
||||
"[composio:gmail] incremental sync starting"
|
||||
);
|
||||
|
||||
// For initial syncs, we ask for a slightly larger window so the
|
||||
// first impression of the user's inbox is meaningful. Periodic
|
||||
// ticks stay small.
|
||||
let limit = match reason {
|
||||
SyncReason::ConnectionCreated => FETCH_EMAILS_LIMIT * 2,
|
||||
_ => FETCH_EMAILS_LIMIT,
|
||||
// ── Step 1: load persistent sync state ──────────────────────
|
||||
let Some(memory) = ctx.memory_client() else {
|
||||
return Err("[composio:gmail] memory client not ready".to_string());
|
||||
};
|
||||
let args = json!({
|
||||
"max_results": limit,
|
||||
"query": "in:inbox -in:spam -in:trash",
|
||||
});
|
||||
let mut state = SyncState::load(&memory, "gmail", &connection_id).await?;
|
||||
|
||||
let resp = ctx
|
||||
.client
|
||||
.execute_tool(ACTION_FETCH_EMAILS, Some(args))
|
||||
.await
|
||||
.map_err(|e| format!("[composio:gmail] {ACTION_FETCH_EMAILS} failed: {e:#}"))?;
|
||||
|
||||
if !resp.successful {
|
||||
let err = resp
|
||||
.error
|
||||
.clone()
|
||||
.unwrap_or_else(|| "provider reported failure".to_string());
|
||||
return Err(format!("[composio:gmail] {ACTION_FETCH_EMAILS}: {err}"));
|
||||
// ── Step 2: check daily budget ──────────────────────────────
|
||||
if state.budget_exhausted() {
|
||||
tracing::info!(
|
||||
connection_id = %connection_id,
|
||||
"[composio:gmail] daily request budget exhausted, skipping sync"
|
||||
);
|
||||
return Ok(SyncOutcome {
|
||||
toolkit: "gmail".to_string(),
|
||||
connection_id: Some(connection_id),
|
||||
reason: reason.as_str().to_string(),
|
||||
items_ingested: 0,
|
||||
started_at_ms,
|
||||
finished_at_ms: now_ms(),
|
||||
summary: "gmail sync skipped: daily budget exhausted".to_string(),
|
||||
details: json!({ "budget_exhausted": true }),
|
||||
});
|
||||
}
|
||||
|
||||
let messages = extract_messages(&resp.data);
|
||||
let items_ingested = persist_messages(ctx, &messages).await;
|
||||
let finished_at_ms = now_ms();
|
||||
// ── Step 3: paginated incremental fetch ─────────────────────
|
||||
let page_size = match reason {
|
||||
SyncReason::ConnectionCreated => INITIAL_PAGE_SIZE,
|
||||
_ => PAGE_SIZE,
|
||||
};
|
||||
|
||||
let mut total_fetched: usize = 0;
|
||||
let mut total_persisted: usize = 0;
|
||||
let mut newest_date: Option<String> = None;
|
||||
let mut page_token: Option<String> = None;
|
||||
|
||||
for page_num in 0..MAX_PAGES_PER_SYNC {
|
||||
if state.budget_exhausted() {
|
||||
tracing::info!(
|
||||
page = page_num,
|
||||
"[composio:gmail] budget exhausted mid-sync, stopping pagination"
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Build the Gmail query. If we have a cursor (date of last
|
||||
// synced message), add `after:YYYY/MM/DD` so the API only
|
||||
// returns newer mail.
|
||||
let mut query = "in:inbox -in:spam -in:trash".to_string();
|
||||
if let Some(ref cursor) = state.cursor {
|
||||
if let Some(date_filter) = cursor_to_gmail_after_filter(cursor) {
|
||||
query.push_str(&format!(" after:{date_filter}"));
|
||||
tracing::debug!(
|
||||
page = page_num,
|
||||
filter = %date_filter,
|
||||
"[composio:gmail] using date filter from cursor"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mut args = json!({
|
||||
"max_results": page_size,
|
||||
"query": query,
|
||||
});
|
||||
if let Some(ref token) = page_token {
|
||||
args["page_token"] = json!(token);
|
||||
}
|
||||
|
||||
let resp = ctx
|
||||
.client
|
||||
.execute_tool(ACTION_FETCH_EMAILS, Some(args))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
format!("[composio:gmail] {ACTION_FETCH_EMAILS} page {page_num}: {e:#}")
|
||||
})?;
|
||||
|
||||
state.record_requests(1);
|
||||
|
||||
if !resp.successful {
|
||||
let err = resp
|
||||
.error
|
||||
.clone()
|
||||
.unwrap_or_else(|| "provider reported failure".to_string());
|
||||
// Save state so budget accounting isn't lost.
|
||||
let _ = state.save(&memory).await;
|
||||
return Err(format!(
|
||||
"[composio:gmail] {ACTION_FETCH_EMAILS} page {page_num}: {err}"
|
||||
));
|
||||
}
|
||||
|
||||
let messages = extract_messages(&resp.data);
|
||||
total_fetched += messages.len();
|
||||
|
||||
if messages.is_empty() {
|
||||
tracing::debug!(
|
||||
page = page_num,
|
||||
"[composio:gmail] empty page, stopping pagination"
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Step 4: deduplicate and persist per-item ────────────
|
||||
let mut all_already_synced = true;
|
||||
for msg in &messages {
|
||||
let Some(msg_id) = extract_item_id(msg, MESSAGE_ID_PATHS) else {
|
||||
tracing::debug!("[composio:gmail] message missing ID, skipping");
|
||||
continue;
|
||||
};
|
||||
|
||||
// Track the newest date we've seen for cursor advancement.
|
||||
if let Some(date_val) = extract_item_id(msg, MESSAGE_DATE_PATHS) {
|
||||
if newest_date
|
||||
.as_ref()
|
||||
.map_or(true, |existing| date_val > *existing)
|
||||
{
|
||||
newest_date = Some(date_val);
|
||||
}
|
||||
}
|
||||
|
||||
if state.is_synced(&msg_id) {
|
||||
continue;
|
||||
}
|
||||
all_already_synced = false;
|
||||
|
||||
// Build a human-readable title for this email document.
|
||||
let subject = pick_str(
|
||||
msg,
|
||||
&[
|
||||
"subject",
|
||||
"data.subject",
|
||||
"payload.headers.Subject",
|
||||
"snippet",
|
||||
],
|
||||
)
|
||||
.unwrap_or_else(|| format!("Email {msg_id}"));
|
||||
let doc_id = format!("composio-gmail-msg-{msg_id}");
|
||||
let title = format!("Gmail: {subject}");
|
||||
|
||||
match persist_single_item(
|
||||
&memory,
|
||||
"gmail",
|
||||
&doc_id,
|
||||
&title,
|
||||
msg,
|
||||
"gmail",
|
||||
ctx.connection_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
state.mark_synced(&msg_id);
|
||||
total_persisted += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
msg_id = %msg_id,
|
||||
error = %e,
|
||||
"[composio:gmail] failed to persist message (continuing)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If every message in this page was already synced, there's
|
||||
// nothing new beyond this point — stop paginating.
|
||||
if all_already_synced {
|
||||
tracing::debug!(
|
||||
page = page_num,
|
||||
"[composio:gmail] all items in page already synced, stopping"
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for next page token.
|
||||
page_token = extract_page_token(&resp.data);
|
||||
if page_token.is_none() {
|
||||
tracing::debug!(page = page_num, "[composio:gmail] no next page token, done");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 5: advance cursor and save state ───────────────────
|
||||
if let Some(new_cursor) = newest_date {
|
||||
state.advance_cursor(&new_cursor);
|
||||
}
|
||||
state.save(&memory).await?;
|
||||
|
||||
let finished_at_ms = now_ms();
|
||||
let summary = format!(
|
||||
"gmail sync ({reason}): fetched {fetched} message(s), persisted {persisted}",
|
||||
"gmail sync ({reason}): fetched {total_fetched}, persisted {total_persisted} new, \
|
||||
budget remaining {remaining}",
|
||||
reason = reason.as_str(),
|
||||
fetched = messages.len(),
|
||||
persisted = items_ingested,
|
||||
remaining = state.budget_remaining(),
|
||||
);
|
||||
tracing::info!(
|
||||
connection_id = ?ctx.connection_id,
|
||||
connection_id = %connection_id,
|
||||
elapsed_ms = finished_at_ms.saturating_sub(started_at_ms),
|
||||
fetched = messages.len(),
|
||||
persisted = items_ingested,
|
||||
"[composio:gmail] sync complete"
|
||||
total_fetched,
|
||||
total_persisted,
|
||||
budget_remaining = state.budget_remaining(),
|
||||
"[composio:gmail] incremental sync complete"
|
||||
);
|
||||
|
||||
Ok(SyncOutcome {
|
||||
toolkit: "gmail".to_string(),
|
||||
connection_id: ctx.connection_id.clone(),
|
||||
connection_id: Some(connection_id),
|
||||
reason: reason.as_str().to_string(),
|
||||
items_ingested,
|
||||
items_ingested: total_persisted,
|
||||
started_at_ms,
|
||||
finished_at_ms,
|
||||
summary,
|
||||
details: json!({
|
||||
"messages_fetched": messages.len(),
|
||||
"limit": limit,
|
||||
"messages_fetched": total_fetched,
|
||||
"messages_persisted": total_persisted,
|
||||
"budget_remaining": state.budget_remaining(),
|
||||
"cursor": state.cursor,
|
||||
"synced_ids_total": state.synced_ids.len(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -222,14 +395,9 @@ impl ComposioProvider for GmailProvider {
|
||||
"[composio:gmail] on_trigger"
|
||||
);
|
||||
|
||||
// Only react to message-arrival triggers — other gmail triggers
|
||||
// (label changes, etc.) don't justify a full sync round-trip.
|
||||
if trigger.eq_ignore_ascii_case("GMAIL_NEW_GMAIL_MESSAGE")
|
||||
|| trigger.eq_ignore_ascii_case("GMAIL_NEW_MESSAGE")
|
||||
{
|
||||
// Best-effort incremental pull. Errors here are logged but
|
||||
// not propagated — the trigger subscriber doesn't have a
|
||||
// user-facing error surface to forward into.
|
||||
if let Err(e) = self.sync(ctx, SyncReason::Manual).await {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
@@ -243,10 +411,7 @@ impl ComposioProvider for GmailProvider {
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/// Walk the Composio response envelope and pull out a list of message
|
||||
/// objects. Composio is inconsistent about whether the array lives at
|
||||
/// `data.messages`, `messages`, or `data.data.messages`, so we try a
|
||||
/// handful of common shapes before giving up.
|
||||
/// Walk the Composio response envelope and pull out message objects.
|
||||
fn extract_messages(data: &Value) -> Vec<Value> {
|
||||
let candidates = [
|
||||
data.pointer("/data/messages"),
|
||||
@@ -263,64 +428,43 @@ fn extract_messages(data: &Value) -> Vec<Value> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Persist a sync snapshot into the global memory store under the
|
||||
/// `composio-gmail` namespace. Returns the number of items recorded
|
||||
/// (currently always one document — the snapshot, not per-message
|
||||
/// rows). Per-message ingestion can come later if/when we add an
|
||||
/// agent surface that benefits from it.
|
||||
async fn persist_messages(ctx: &ProviderContext, messages: &[Value]) -> usize {
|
||||
let Some(client) = ctx.memory_client() else {
|
||||
tracing::debug!("[composio:gmail] memory client not ready, skipping persist");
|
||||
return 0;
|
||||
};
|
||||
if messages.is_empty() {
|
||||
return 0;
|
||||
/// Try to extract a pagination token from the API response.
|
||||
fn extract_page_token(data: &Value) -> Option<String> {
|
||||
let candidates = [
|
||||
data.pointer("/data/nextPageToken"),
|
||||
data.pointer("/nextPageToken"),
|
||||
data.pointer("/data/data/nextPageToken"),
|
||||
];
|
||||
for cand in candidates.into_iter().flatten() {
|
||||
if let Some(s) = cand.as_str() {
|
||||
let trimmed = s.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
let connection_label = ctx
|
||||
.connection_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
let title = format!("gmail sync — {connection_label}");
|
||||
let snapshot = json!({
|
||||
"toolkit": "gmail",
|
||||
"connection_id": ctx.connection_id,
|
||||
"messages": messages,
|
||||
"synced_at_ms": now_ms(),
|
||||
});
|
||||
let content = serde_json::to_string_pretty(&snapshot).unwrap_or_else(|_| "{}".to_string());
|
||||
|
||||
if let Err(e) = client
|
||||
.store_skill_sync(
|
||||
// The store_skill_sync helper namespaces as `skill-{id}`,
|
||||
// so we pass `gmail` here and rely on the standard prefix.
|
||||
// The composio domain reads from `skill-gmail` namespaces
|
||||
// through the same memory store as the JS gmail skill —
|
||||
// intentional, so the agent's `recall_memory` sees both.
|
||||
MEMORY_NAMESPACE.trim_start_matches("composio-"),
|
||||
&connection_label,
|
||||
&title,
|
||||
&content,
|
||||
Some("composio-sync".to_string()),
|
||||
Some(json!({
|
||||
"toolkit": "gmail",
|
||||
"connection_id": ctx.connection_id,
|
||||
"source": "composio-provider",
|
||||
})),
|
||||
Some("medium".to_string()),
|
||||
None,
|
||||
None,
|
||||
Some(format!("composio-gmail-{connection_label}")),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
"[composio:gmail] persist snapshot failed (non-fatal)"
|
||||
);
|
||||
return 0;
|
||||
/// Convert a cursor value (epoch millis or date string) into a Gmail
|
||||
/// `after:YYYY/MM/DD` filter component. Returns `None` if the cursor
|
||||
/// cannot be parsed.
|
||||
fn cursor_to_gmail_after_filter(cursor: &str) -> Option<String> {
|
||||
// Try parsing as epoch millis first (Gmail's internalDate).
|
||||
if let Ok(millis) = cursor.parse::<i64>() {
|
||||
let secs = millis / 1000;
|
||||
if let Some(dt) = chrono::DateTime::from_timestamp(secs, 0) {
|
||||
return Some(dt.format("%Y/%m/%d").to_string());
|
||||
}
|
||||
}
|
||||
1
|
||||
// Try parsing as an ISO date/datetime.
|
||||
if let Ok(dt) = chrono::NaiveDate::parse_from_str(cursor, "%Y-%m-%d") {
|
||||
return Some(dt.format("%Y/%m/%d").to_string());
|
||||
}
|
||||
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(cursor) {
|
||||
return Some(dt.format("%Y/%m/%d").to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
@@ -356,6 +500,48 @@ mod tests {
|
||||
assert_eq!(extract_messages(&v).len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_page_token_finds_nested() {
|
||||
let v = json!({ "data": { "nextPageToken": "tok123" } });
|
||||
assert_eq!(extract_page_token(&v), Some("tok123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_page_token_none_when_missing() {
|
||||
let v = json!({ "data": {} });
|
||||
assert_eq!(extract_page_token(&v), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_to_filter_from_epoch_millis() {
|
||||
// 2026-04-01 00:00:00 UTC in millis
|
||||
let millis = "1774915200000";
|
||||
let filter = cursor_to_gmail_after_filter(millis);
|
||||
assert!(filter.is_some());
|
||||
// Should produce a YYYY/MM/DD date.
|
||||
let f = filter.unwrap();
|
||||
assert!(f.contains('/'), "Expected date with slashes, got {f}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_to_filter_from_iso_date() {
|
||||
assert_eq!(
|
||||
cursor_to_gmail_after_filter("2026-03-15"),
|
||||
Some("2026/03/15".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_to_filter_from_rfc3339() {
|
||||
let f = cursor_to_gmail_after_filter("2026-03-15T12:00:00Z");
|
||||
assert_eq!(f, Some("2026/03/15".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_to_filter_returns_none_for_garbage() {
|
||||
assert_eq!(cursor_to_gmail_after_filter("not-a-date"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_metadata_is_stable() {
|
||||
let p = GmailProvider::new();
|
||||
|
||||
@@ -43,6 +43,7 @@ use super::client::{build_composio_client, ComposioClient};
|
||||
pub mod gmail;
|
||||
pub mod notion;
|
||||
pub mod registry;
|
||||
pub mod sync_state;
|
||||
|
||||
pub use registry::{
|
||||
all_providers, get_provider, init_default_providers, register_provider, ProviderArc,
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
//! Notion provider — native Rust counterpart to the QuickJS notion skill.
|
||||
//! Notion provider — incremental sync with per-item persistence.
|
||||
//!
|
||||
//! Behaves like [`super::gmail::GmailProvider`] but for Notion: pulls
|
||||
//! the connected user's "about" record + a window of recent pages on
|
||||
//! sync, persists snapshots into the global memory store, and reacts
|
||||
//! to Notion triggers (typically `NOTION_PAGE_*` events) by re-running
|
||||
//! the incremental sync.
|
||||
//! On each sync pass:
|
||||
//!
|
||||
//! Notion's Composio shape is intentionally squishy in this provider:
|
||||
//! the upstream `users/me` and search endpoints have stable fields
|
||||
//! (`name`, `person.email`, `results[]`), but Composio occasionally
|
||||
//! re-wraps them. We use [`super::pick_str`] for tolerant extraction
|
||||
//! so a minor backend change does not break the provider.
|
||||
//! 1. Load persistent [`SyncState`] from the KV store.
|
||||
//! 2. Check the daily request budget — bail early if exhausted.
|
||||
//! 3. Fetch a page of recently edited pages via `NOTION_FETCH_DATA`,
|
||||
//! sorted by `last_edited_time` descending. When a cursor exists
|
||||
//! we can stop as soon as we see pages older than the cursor.
|
||||
//! 4. Deduplicate against `synced_ids` in the state. Pages that have
|
||||
//! been *edited* since their last sync are re-persisted (the cursor
|
||||
//! is based on `last_edited_time`, so an edited page appears again).
|
||||
//! 5. Persist each **new or updated** page as its own memory document.
|
||||
//! 6. Paginate (up to budget) until no more results or all items in the
|
||||
//! page are older than the cursor.
|
||||
//! 7. Advance the cursor and save state.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::sync_state::{extract_item_id, persist_single_item, SyncState};
|
||||
use super::{
|
||||
pick_str, ComposioProvider, ProviderContext, ProviderUserProfile, SyncOutcome, SyncReason,
|
||||
};
|
||||
@@ -22,7 +26,25 @@ use super::{
|
||||
const ACTION_GET_ABOUT_ME: &str = "NOTION_GET_ABOUT_ME";
|
||||
const ACTION_FETCH_DATA: &str = "NOTION_FETCH_DATA";
|
||||
|
||||
const FETCH_LIMIT: u32 = 25;
|
||||
/// Page size per API call.
|
||||
const PAGE_SIZE: u32 = 25;
|
||||
|
||||
/// Larger page size for initial sync after OAuth.
|
||||
const INITIAL_PAGE_SIZE: u32 = 50;
|
||||
|
||||
/// Maximum pages per sync pass.
|
||||
const MAX_PAGES_PER_SYNC: u32 = 20;
|
||||
|
||||
/// Paths for extracting a page's unique ID.
|
||||
const PAGE_ID_PATHS: &[&str] = &["id", "data.id", "pageId", "data.pageId"];
|
||||
|
||||
/// Paths for extracting the `last_edited_time` used as sync cursor.
|
||||
const PAGE_EDITED_PATHS: &[&str] = &[
|
||||
"last_edited_time",
|
||||
"data.last_edited_time",
|
||||
"lastEditedTime",
|
||||
"data.lastEditedTime",
|
||||
];
|
||||
|
||||
pub struct NotionProvider;
|
||||
|
||||
@@ -45,8 +67,6 @@ impl ComposioProvider for NotionProvider {
|
||||
}
|
||||
|
||||
fn sync_interval_secs(&self) -> Option<u64> {
|
||||
// 30 minutes — Notion content changes less frequently than
|
||||
// email, no need for the gmail cadence.
|
||||
Some(30 * 60)
|
||||
}
|
||||
|
||||
@@ -114,76 +134,227 @@ impl ComposioProvider for NotionProvider {
|
||||
|
||||
async fn sync(&self, ctx: &ProviderContext, reason: SyncReason) -> Result<SyncOutcome, String> {
|
||||
let started_at_ms = now_ms();
|
||||
let connection_id = ctx
|
||||
.connection_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
|
||||
tracing::info!(
|
||||
connection_id = ?ctx.connection_id,
|
||||
connection_id = %connection_id,
|
||||
reason = reason.as_str(),
|
||||
"[composio:notion] sync starting"
|
||||
"[composio:notion] incremental sync starting"
|
||||
);
|
||||
|
||||
let limit = match reason {
|
||||
SyncReason::ConnectionCreated => FETCH_LIMIT * 2,
|
||||
_ => FETCH_LIMIT,
|
||||
// ── Step 1: load persistent sync state ──────────────────────
|
||||
let Some(memory) = ctx.memory_client() else {
|
||||
return Err("[composio:notion] memory client not ready".to_string());
|
||||
};
|
||||
// NOTION_FETCH_DATA is a generic search/list action. We
|
||||
// intentionally restrict to `object: page` and sort by
|
||||
// `last_edited_time` descending so the sync pulls the most
|
||||
// recently touched pages — that's what the agent's recall
|
||||
// path benefits from most. Databases are skipped here on
|
||||
// purpose: most users have far more pages than databases,
|
||||
// and including databases would silently bloat the snapshot
|
||||
// size for everyone. If we ever want to surface databases
|
||||
// we should do it as a separate, opt-in fetch.
|
||||
let args = json!({
|
||||
"page_size": limit,
|
||||
"filter": { "value": "page", "property": "object" },
|
||||
"sort": { "direction": "descending", "timestamp": "last_edited_time" }
|
||||
});
|
||||
let mut state = SyncState::load(&memory, "notion", &connection_id).await?;
|
||||
|
||||
let resp = ctx
|
||||
.client
|
||||
.execute_tool(ACTION_FETCH_DATA, Some(args))
|
||||
.await
|
||||
.map_err(|e| format!("[composio:notion] {ACTION_FETCH_DATA} failed: {e:#}"))?;
|
||||
|
||||
if !resp.successful {
|
||||
let err = resp
|
||||
.error
|
||||
.clone()
|
||||
.unwrap_or_else(|| "provider reported failure".to_string());
|
||||
return Err(format!("[composio:notion] {ACTION_FETCH_DATA}: {err}"));
|
||||
// ── Step 2: check daily budget ──────────────────────────────
|
||||
if state.budget_exhausted() {
|
||||
tracing::info!(
|
||||
connection_id = %connection_id,
|
||||
"[composio:notion] daily request budget exhausted, skipping sync"
|
||||
);
|
||||
return Ok(SyncOutcome {
|
||||
toolkit: "notion".to_string(),
|
||||
connection_id: Some(connection_id),
|
||||
reason: reason.as_str().to_string(),
|
||||
items_ingested: 0,
|
||||
started_at_ms,
|
||||
finished_at_ms: now_ms(),
|
||||
summary: "notion sync skipped: daily budget exhausted".to_string(),
|
||||
details: json!({ "budget_exhausted": true }),
|
||||
});
|
||||
}
|
||||
|
||||
let results = extract_results(&resp.data);
|
||||
let items_ingested = persist_snapshot(ctx, &results)
|
||||
.await
|
||||
.map_err(|e| format!("[composio:notion] persist_snapshot failed: {e}"))?;
|
||||
let finished_at_ms = now_ms();
|
||||
// ── Step 3: paginated incremental fetch ─────────────────────
|
||||
let page_size = match reason {
|
||||
SyncReason::ConnectionCreated => INITIAL_PAGE_SIZE,
|
||||
_ => PAGE_SIZE,
|
||||
};
|
||||
|
||||
let mut total_fetched: usize = 0;
|
||||
let mut total_persisted: usize = 0;
|
||||
let mut newest_edited_time: Option<String> = None;
|
||||
let mut notion_cursor: Option<String> = None;
|
||||
|
||||
for page_num in 0..MAX_PAGES_PER_SYNC {
|
||||
if state.budget_exhausted() {
|
||||
tracing::info!(
|
||||
page = page_num,
|
||||
"[composio:notion] budget exhausted mid-sync, stopping pagination"
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
let mut args = json!({
|
||||
"page_size": page_size,
|
||||
"filter": { "value": "page", "property": "object" },
|
||||
"sort": { "direction": "descending", "timestamp": "last_edited_time" }
|
||||
});
|
||||
if let Some(ref cursor) = notion_cursor {
|
||||
args["start_cursor"] = json!(cursor);
|
||||
}
|
||||
|
||||
let resp = ctx
|
||||
.client
|
||||
.execute_tool(ACTION_FETCH_DATA, Some(args))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
format!("[composio:notion] {ACTION_FETCH_DATA} page {page_num}: {e:#}")
|
||||
})?;
|
||||
|
||||
state.record_requests(1);
|
||||
|
||||
if !resp.successful {
|
||||
let err = resp
|
||||
.error
|
||||
.clone()
|
||||
.unwrap_or_else(|| "provider reported failure".to_string());
|
||||
let _ = state.save(&memory).await;
|
||||
return Err(format!(
|
||||
"[composio:notion] {ACTION_FETCH_DATA} page {page_num}: {err}"
|
||||
));
|
||||
}
|
||||
|
||||
let results = extract_results(&resp.data);
|
||||
total_fetched += results.len();
|
||||
|
||||
if results.is_empty() {
|
||||
tracing::debug!(
|
||||
page = page_num,
|
||||
"[composio:notion] empty page, stopping pagination"
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Step 4: deduplicate and persist per-item ────────────
|
||||
let mut hit_cursor_boundary = false;
|
||||
for page in &results {
|
||||
let Some(page_id) = extract_item_id(page, PAGE_ID_PATHS) else {
|
||||
tracing::debug!("[composio:notion] page missing ID, skipping");
|
||||
continue;
|
||||
};
|
||||
|
||||
let edited_time = extract_item_id(page, PAGE_EDITED_PATHS);
|
||||
|
||||
// Track the newest edited time for cursor advancement.
|
||||
if let Some(ref et) = edited_time {
|
||||
if newest_edited_time
|
||||
.as_ref()
|
||||
.map_or(true, |existing| et > existing)
|
||||
{
|
||||
newest_edited_time = Some(et.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// For Notion, a page can be *edited* after we last synced
|
||||
// it. We use a composite key of page_id + edited_time to
|
||||
// detect this: if the page_id is in synced_ids but the
|
||||
// edited_time is newer than the cursor, we re-sync it.
|
||||
let sync_key = match &edited_time {
|
||||
Some(et) => format!("{page_id}@{et}"),
|
||||
None => page_id.clone(),
|
||||
};
|
||||
|
||||
// If the page's edited time is older than our cursor,
|
||||
// we've caught up — everything beyond is already synced.
|
||||
if let (Some(ref cursor), Some(ref et)) = (&state.cursor, &edited_time) {
|
||||
if et <= cursor && state.is_synced(&sync_key) {
|
||||
hit_cursor_boundary = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if state.is_synced(&sync_key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build a title from the page's properties.
|
||||
let title_text =
|
||||
extract_page_title(page).unwrap_or_else(|| format!("Notion page {page_id}"));
|
||||
let doc_id = format!("composio-notion-page-{page_id}");
|
||||
let title = format!("Notion: {title_text}");
|
||||
|
||||
match persist_single_item(
|
||||
&memory,
|
||||
"notion",
|
||||
&doc_id,
|
||||
&title,
|
||||
page,
|
||||
"notion",
|
||||
ctx.connection_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
state.mark_synced(&sync_key);
|
||||
total_persisted += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
page_id = %page_id,
|
||||
error = %e,
|
||||
"[composio:notion] failed to persist page (continuing)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hit_cursor_boundary {
|
||||
tracing::debug!(
|
||||
page = page_num,
|
||||
"[composio:notion] reached cursor boundary, stopping"
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for next page cursor from Notion API.
|
||||
notion_cursor = extract_notion_cursor(&resp.data);
|
||||
if notion_cursor.is_none() {
|
||||
tracing::debug!(page = page_num, "[composio:notion] no next cursor, done");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 5: advance cursor and save state ───────────────────
|
||||
if let Some(new_cursor) = newest_edited_time {
|
||||
state.advance_cursor(&new_cursor);
|
||||
}
|
||||
state.save(&memory).await?;
|
||||
|
||||
let finished_at_ms = now_ms();
|
||||
let summary = format!(
|
||||
"notion sync ({reason}): fetched {fetched} item(s), persisted {persisted}",
|
||||
"notion sync ({reason}): fetched {total_fetched}, persisted {total_persisted} new, \
|
||||
budget remaining {remaining}",
|
||||
reason = reason.as_str(),
|
||||
fetched = results.len(),
|
||||
persisted = items_ingested,
|
||||
remaining = state.budget_remaining(),
|
||||
);
|
||||
tracing::info!(
|
||||
connection_id = ?ctx.connection_id,
|
||||
connection_id = %connection_id,
|
||||
elapsed_ms = finished_at_ms.saturating_sub(started_at_ms),
|
||||
fetched = results.len(),
|
||||
persisted = items_ingested,
|
||||
"[composio:notion] sync complete"
|
||||
total_fetched,
|
||||
total_persisted,
|
||||
budget_remaining = state.budget_remaining(),
|
||||
"[composio:notion] incremental sync complete"
|
||||
);
|
||||
|
||||
Ok(SyncOutcome {
|
||||
toolkit: "notion".to_string(),
|
||||
connection_id: ctx.connection_id.clone(),
|
||||
connection_id: Some(connection_id),
|
||||
reason: reason.as_str().to_string(),
|
||||
items_ingested,
|
||||
items_ingested: total_persisted,
|
||||
started_at_ms,
|
||||
finished_at_ms,
|
||||
summary,
|
||||
details: json!({
|
||||
"results_fetched": results.len(),
|
||||
"page_size": limit,
|
||||
"results_fetched": total_fetched,
|
||||
"results_persisted": total_persisted,
|
||||
"budget_remaining": state.budget_remaining(),
|
||||
"cursor": state.cursor,
|
||||
"synced_ids_total": state.synced_ids.len(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -199,10 +370,6 @@ impl ComposioProvider for NotionProvider {
|
||||
trigger = %trigger,
|
||||
"[composio:notion] on_trigger"
|
||||
);
|
||||
// Notion triggers all imply "something in the workspace
|
||||
// changed", so any of them should kick a fresh incremental
|
||||
// sync. Best-effort: we don't propagate errors out of the
|
||||
// trigger path.
|
||||
if let Err(e) = self.sync(ctx, SyncReason::Manual).await {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
@@ -215,6 +382,7 @@ impl ComposioProvider for NotionProvider {
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/// Walk the Composio response envelope for Notion page results.
|
||||
fn extract_results(data: &Value) -> Vec<Value> {
|
||||
let candidates = [
|
||||
data.pointer("/data/results"),
|
||||
@@ -231,48 +399,56 @@ fn extract_results(data: &Value) -> Vec<Value> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
async fn persist_snapshot(ctx: &ProviderContext, results: &[Value]) -> Result<usize, String> {
|
||||
let Some(client) = ctx.memory_client() else {
|
||||
tracing::debug!("[composio:notion] memory client not ready, skipping persist");
|
||||
return Ok(0);
|
||||
};
|
||||
if results.is_empty() {
|
||||
return Ok(0);
|
||||
/// Extract the Notion pagination cursor (for `start_cursor` on the
|
||||
/// next request).
|
||||
fn extract_notion_cursor(data: &Value) -> Option<String> {
|
||||
let candidates = [
|
||||
data.pointer("/data/next_cursor"),
|
||||
data.pointer("/next_cursor"),
|
||||
data.pointer("/data/data/next_cursor"),
|
||||
];
|
||||
for cand in candidates.into_iter().flatten() {
|
||||
if let Some(s) = cand.as_str() {
|
||||
let trimmed = s.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Try to extract a human-readable title from a Notion page object.
|
||||
///
|
||||
/// Notion pages store the title in `properties.title` or
|
||||
/// `properties.Name.title[0].plain_text`. We try several shapes.
|
||||
fn extract_page_title(page: &Value) -> Option<String> {
|
||||
// Try the common `properties.title.title[0].plain_text` shape.
|
||||
let props = page
|
||||
.get("properties")
|
||||
.or_else(|| page.get("data")?.get("properties"));
|
||||
if let Some(props) = props {
|
||||
// Walk all properties looking for a "title" type field.
|
||||
if let Some(obj) = props.as_object() {
|
||||
for (_key, val) in obj {
|
||||
if val.get("type").and_then(Value::as_str) == Some("title") {
|
||||
if let Some(arr) = val.get("title").and_then(Value::as_array) {
|
||||
let text: String = arr
|
||||
.iter()
|
||||
.filter_map(|t| t.get("plain_text").and_then(Value::as_str))
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
if !text.is_empty() {
|
||||
return Some(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let connection_label = ctx
|
||||
.connection_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
let title = format!("notion sync — {connection_label}");
|
||||
let snapshot = json!({
|
||||
"toolkit": "notion",
|
||||
"connection_id": ctx.connection_id,
|
||||
"results": results,
|
||||
"synced_at_ms": now_ms(),
|
||||
});
|
||||
let content = serde_json::to_string_pretty(&snapshot).unwrap_or_else(|_| "{}".to_string());
|
||||
|
||||
client
|
||||
.store_skill_sync(
|
||||
"notion",
|
||||
&connection_label,
|
||||
&title,
|
||||
&content,
|
||||
Some("composio-sync".to_string()),
|
||||
Some(json!({
|
||||
"toolkit": "notion",
|
||||
"connection_id": ctx.connection_id,
|
||||
"source": "composio-provider",
|
||||
})),
|
||||
Some("medium".to_string()),
|
||||
None,
|
||||
None,
|
||||
Some(format!("composio-notion-{connection_label}")),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("store_skill_sync: {e}"))?;
|
||||
Ok(1)
|
||||
// Fallback: top-level "title" field (some Composio shapes).
|
||||
pick_str(page, &["title", "data.title", "name", "data.name"])
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
@@ -297,6 +473,50 @@ mod tests {
|
||||
assert_eq!(extract_results(&v3).len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_notion_cursor_finds_nested() {
|
||||
let v = json!({ "data": { "next_cursor": "abc123" } });
|
||||
assert_eq!(extract_notion_cursor(&v), Some("abc123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_notion_cursor_none_when_missing() {
|
||||
let v = json!({ "data": { "has_more": false } });
|
||||
assert_eq!(extract_notion_cursor(&v), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_page_title_from_properties() {
|
||||
let page = json!({
|
||||
"id": "page-1",
|
||||
"properties": {
|
||||
"Name": {
|
||||
"type": "title",
|
||||
"title": [
|
||||
{ "plain_text": "My " },
|
||||
{ "plain_text": "Page Title" }
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
assert_eq!(extract_page_title(&page), Some("My Page Title".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_page_title_fallback_to_top_level() {
|
||||
let page = json!({ "title": "Fallback Title" });
|
||||
assert_eq!(
|
||||
extract_page_title(&page),
|
||||
Some("Fallback Title".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_page_title_returns_none_when_missing() {
|
||||
let page = json!({ "id": "p1" });
|
||||
assert_eq!(extract_page_title(&page), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_metadata_is_stable() {
|
||||
let p = NotionProvider::new();
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
//! Persistent sync state for Composio providers.
|
||||
//!
|
||||
//! Each `(toolkit, connection_id)` pair gets its own [`SyncState`] persisted
|
||||
//! in the local KV store. The state tracks:
|
||||
//!
|
||||
//! * **Cursor** — a provider-specific watermark (e.g. a timestamp or page
|
||||
//! token) so the next sync can skip items already seen.
|
||||
//! * **Synced IDs** — a set of item identifiers that have been written to
|
||||
//! memory. Items in this set are skipped even if they appear again in
|
||||
//! an API response (deduplication).
|
||||
//! * **Daily request budget** — a rolling counter keyed by calendar date
|
||||
//! (`YYYY-MM-DD`) that caps the number of `execute_tool` calls a
|
||||
//! provider makes per day. Resets automatically when the date rolls
|
||||
//! over.
|
||||
//!
|
||||
//! All persistence goes through [`crate::openhuman::memory::MemoryClient`]'s
|
||||
//! KV surface (`kv_set` / `kv_get` under a dedicated namespace), so the
|
||||
//! state survives process restarts without any extra file management.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::openhuman::memory::MemoryClientRef;
|
||||
|
||||
/// Maximum API requests a single provider connection may make per calendar
|
||||
/// day. This covers the initial backfill case where there are thousands of
|
||||
/// unsynced items — after this many requests the provider yields and
|
||||
/// continues on the next day.
|
||||
pub const DEFAULT_DAILY_REQUEST_LIMIT: u32 = 500;
|
||||
|
||||
/// KV namespace under which all sync state keys live. Separate from the
|
||||
/// memory document namespaces (`skill-gmail`, etc.) to avoid collisions.
|
||||
const KV_NAMESPACE: &str = "composio-sync-state";
|
||||
|
||||
/// Persistent sync state for one `(toolkit, connection_id)` pair.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SyncState {
|
||||
/// Toolkit slug, e.g. `"gmail"`.
|
||||
pub toolkit: String,
|
||||
/// Connection id, e.g. `"conn_abc123"`.
|
||||
pub connection_id: String,
|
||||
|
||||
/// Provider-specific cursor. For Gmail this is the internal-date
|
||||
/// (epoch millis) of the newest synced message; for Notion it is the
|
||||
/// `last_edited_time` ISO string of the most recently synced page.
|
||||
/// `None` means "never synced — start from scratch".
|
||||
#[serde(default)]
|
||||
pub cursor: Option<String>,
|
||||
|
||||
/// Set of item IDs that have already been persisted to memory.
|
||||
/// Used for deduplication: if an item appears in an API response
|
||||
/// but its ID is in this set, skip it.
|
||||
#[serde(default)]
|
||||
pub synced_ids: HashSet<String>,
|
||||
|
||||
/// Rolling daily request budget.
|
||||
#[serde(default)]
|
||||
pub daily_budget: DailyBudget,
|
||||
}
|
||||
|
||||
/// Tracks the number of API requests made on a given calendar day.
|
||||
/// Automatically resets when the date rolls over.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DailyBudget {
|
||||
/// Calendar date in `YYYY-MM-DD` format.
|
||||
pub date: String,
|
||||
/// Number of `execute_tool` requests made so far today.
|
||||
pub requests_used: u32,
|
||||
/// Maximum requests allowed per day.
|
||||
pub limit: u32,
|
||||
}
|
||||
|
||||
impl Default for DailyBudget {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
date: today_str(),
|
||||
requests_used: 0,
|
||||
limit: DEFAULT_DAILY_REQUEST_LIMIT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DailyBudget {
|
||||
/// Remaining requests available today. If the stored date is stale
|
||||
/// (a previous day), this returns the full limit because the budget
|
||||
/// will be reset on the next [`Self::record_request`] call.
|
||||
pub fn remaining(&self) -> u32 {
|
||||
if self.date != today_str() {
|
||||
return self.limit;
|
||||
}
|
||||
self.limit.saturating_sub(self.requests_used)
|
||||
}
|
||||
|
||||
/// Returns `true` if the daily budget is exhausted for today.
|
||||
pub fn is_exhausted(&self) -> bool {
|
||||
self.remaining() == 0
|
||||
}
|
||||
|
||||
/// Record `n` API requests. If the date has rolled over, resets the
|
||||
/// counter before adding.
|
||||
pub fn record_requests(&mut self, n: u32) {
|
||||
let today = today_str();
|
||||
if self.date != today {
|
||||
self.date = today;
|
||||
self.requests_used = 0;
|
||||
}
|
||||
self.requests_used = self.requests_used.saturating_add(n);
|
||||
}
|
||||
|
||||
/// Record a single API request.
|
||||
pub fn record_request(&mut self) {
|
||||
self.record_requests(1);
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncState {
|
||||
/// Create a fresh state for a new connection (never synced).
|
||||
pub fn new(toolkit: impl Into<String>, connection_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
toolkit: toolkit.into(),
|
||||
connection_id: connection_id.into(),
|
||||
cursor: None,
|
||||
synced_ids: HashSet::new(),
|
||||
daily_budget: DailyBudget::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the daily request budget is exhausted.
|
||||
pub fn budget_exhausted(&self) -> bool {
|
||||
self.daily_budget.is_exhausted()
|
||||
}
|
||||
|
||||
/// Remaining API requests for today.
|
||||
pub fn budget_remaining(&self) -> u32 {
|
||||
self.daily_budget.remaining()
|
||||
}
|
||||
|
||||
/// Record API requests made.
|
||||
pub fn record_requests(&mut self, n: u32) {
|
||||
self.daily_budget.record_requests(n);
|
||||
}
|
||||
|
||||
/// Check if an item ID has already been synced.
|
||||
pub fn is_synced(&self, item_id: &str) -> bool {
|
||||
self.synced_ids.contains(item_id)
|
||||
}
|
||||
|
||||
/// Mark an item ID as synced.
|
||||
pub fn mark_synced(&mut self, item_id: impl Into<String>) {
|
||||
self.synced_ids.insert(item_id.into());
|
||||
}
|
||||
|
||||
/// Update the cursor to a new watermark value.
|
||||
pub fn advance_cursor(&mut self, cursor: impl Into<String>) {
|
||||
self.cursor = Some(cursor.into());
|
||||
}
|
||||
|
||||
/// KV key for this state. Deterministic so load + save are symmetric.
|
||||
fn kv_key(&self) -> String {
|
||||
format!("{}:{}", self.toolkit, self.connection_id)
|
||||
}
|
||||
|
||||
/// Load sync state from the KV store, or return a fresh default if
|
||||
/// none exists.
|
||||
pub async fn load(
|
||||
memory: &MemoryClientRef,
|
||||
toolkit: &str,
|
||||
connection_id: &str,
|
||||
) -> Result<Self, String> {
|
||||
let key = format!("{toolkit}:{connection_id}");
|
||||
match memory.kv_get(Some(KV_NAMESPACE), &key).await? {
|
||||
Some(value) => {
|
||||
let mut state: SyncState = serde_json::from_value(value)
|
||||
.map_err(|e| format!("[sync_state] deserialize failed for {key}: {e}"))?;
|
||||
// Ensure budget rolls over if date changed.
|
||||
if state.daily_budget.date != today_str() {
|
||||
tracing::debug!(
|
||||
toolkit,
|
||||
connection_id,
|
||||
old_date = %state.daily_budget.date,
|
||||
"[sync_state] daily budget rolled over"
|
||||
);
|
||||
state.daily_budget.date = today_str();
|
||||
state.daily_budget.requests_used = 0;
|
||||
}
|
||||
tracing::debug!(
|
||||
toolkit,
|
||||
connection_id,
|
||||
cursor = ?state.cursor,
|
||||
synced_ids_count = state.synced_ids.len(),
|
||||
budget_remaining = state.budget_remaining(),
|
||||
"[sync_state] loaded"
|
||||
);
|
||||
Ok(state)
|
||||
}
|
||||
None => {
|
||||
tracing::debug!(
|
||||
toolkit,
|
||||
connection_id,
|
||||
"[sync_state] no existing state, starting fresh"
|
||||
);
|
||||
Ok(Self::new(toolkit, connection_id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the current state to the KV store.
|
||||
pub async fn save(&self, memory: &MemoryClientRef) -> Result<(), String> {
|
||||
let key = self.kv_key();
|
||||
let value = serde_json::to_value(self)
|
||||
.map_err(|e| format!("[sync_state] serialize failed: {e}"))?;
|
||||
memory.kv_set(Some(KV_NAMESPACE), &key, &value).await?;
|
||||
tracing::debug!(
|
||||
toolkit = %self.toolkit,
|
||||
connection_id = %self.connection_id,
|
||||
cursor = ?self.cursor,
|
||||
synced_ids_count = self.synced_ids.len(),
|
||||
budget_used = self.daily_budget.requests_used,
|
||||
"[sync_state] saved"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Today's date as `YYYY-MM-DD` in UTC.
|
||||
fn today_str() -> String {
|
||||
Utc::now().format("%Y-%m-%d").to_string()
|
||||
}
|
||||
|
||||
/// Extract an ID string from a JSON value, trying multiple candidate paths.
|
||||
/// Returns the first non-empty string found.
|
||||
pub fn extract_item_id(item: &serde_json::Value, paths: &[&str]) -> Option<String> {
|
||||
for path in paths {
|
||||
let mut cur = item;
|
||||
let mut ok = true;
|
||||
for segment in path.split('.') {
|
||||
match cur.get(segment) {
|
||||
Some(next) => cur = next,
|
||||
None => {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
continue;
|
||||
}
|
||||
if let Some(s) = cur.as_str() {
|
||||
let trimmed = s.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Helper to persist a single item as its own memory document.
|
||||
///
|
||||
/// Each item is stored under the provider's memory namespace with a
|
||||
/// deterministic `document_id` so repeated syncs upsert rather than
|
||||
/// duplicate. Returns the document ID on success.
|
||||
pub async fn persist_single_item(
|
||||
memory: &MemoryClientRef,
|
||||
namespace_skill_id: &str,
|
||||
document_id: &str,
|
||||
title: &str,
|
||||
item: &serde_json::Value,
|
||||
toolkit: &str,
|
||||
connection_id: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let content = serde_json::to_string_pretty(item).unwrap_or_else(|_| "{}".to_string());
|
||||
memory
|
||||
.store_skill_sync(
|
||||
namespace_skill_id,
|
||||
connection_id.unwrap_or("default"),
|
||||
title,
|
||||
&content,
|
||||
Some("composio-sync".to_string()),
|
||||
Some(json!({
|
||||
"toolkit": toolkit,
|
||||
"connection_id": connection_id,
|
||||
"source": "composio-provider-incremental",
|
||||
})),
|
||||
Some("medium".to_string()),
|
||||
None,
|
||||
None,
|
||||
Some(document_id.to_string()),
|
||||
)
|
||||
.await?;
|
||||
Ok(document_id.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn daily_budget_defaults_to_full() {
|
||||
let b = DailyBudget::default();
|
||||
assert_eq!(b.remaining(), DEFAULT_DAILY_REQUEST_LIMIT);
|
||||
assert!(!b.is_exhausted());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daily_budget_tracks_requests() {
|
||||
let mut b = DailyBudget::default();
|
||||
b.record_requests(100);
|
||||
assert_eq!(b.remaining(), DEFAULT_DAILY_REQUEST_LIMIT - 100);
|
||||
assert!(!b.is_exhausted());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daily_budget_exhaustion() {
|
||||
let mut b = DailyBudget::default();
|
||||
b.record_requests(DEFAULT_DAILY_REQUEST_LIMIT);
|
||||
assert_eq!(b.remaining(), 0);
|
||||
assert!(b.is_exhausted());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daily_budget_saturates_on_overflow() {
|
||||
let mut b = DailyBudget::default();
|
||||
b.record_requests(DEFAULT_DAILY_REQUEST_LIMIT + 100);
|
||||
assert_eq!(b.remaining(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daily_budget_resets_on_date_change() {
|
||||
let mut b = DailyBudget {
|
||||
date: "2025-01-01".to_string(),
|
||||
requests_used: 499,
|
||||
limit: DEFAULT_DAILY_REQUEST_LIMIT,
|
||||
};
|
||||
// Calling remaining() when date is stale returns full limit.
|
||||
assert_eq!(b.remaining(), DEFAULT_DAILY_REQUEST_LIMIT);
|
||||
// Recording a request resets the counter.
|
||||
b.record_request();
|
||||
assert_eq!(b.date, today_str());
|
||||
assert_eq!(b.requests_used, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_state_deduplication() {
|
||||
let mut state = SyncState::new("gmail", "conn_1");
|
||||
assert!(!state.is_synced("msg_abc"));
|
||||
state.mark_synced("msg_abc");
|
||||
assert!(state.is_synced("msg_abc"));
|
||||
assert!(!state.is_synced("msg_xyz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_state_cursor_advancement() {
|
||||
let mut state = SyncState::new("notion", "conn_2");
|
||||
assert!(state.cursor.is_none());
|
||||
state.advance_cursor("2026-04-01T00:00:00Z");
|
||||
assert_eq!(state.cursor.as_deref(), Some("2026-04-01T00:00:00Z"));
|
||||
state.advance_cursor("2026-04-10T00:00:00Z");
|
||||
assert_eq!(state.cursor.as_deref(), Some("2026-04-10T00:00:00Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_state_serialization_roundtrip() {
|
||||
let mut state = SyncState::new("gmail", "conn_test");
|
||||
state.advance_cursor("12345");
|
||||
state.mark_synced("item_a");
|
||||
state.mark_synced("item_b");
|
||||
state.daily_budget.record_requests(42);
|
||||
|
||||
let json = serde_json::to_value(&state).unwrap();
|
||||
let restored: SyncState = serde_json::from_value(json).unwrap();
|
||||
|
||||
assert_eq!(restored.toolkit, "gmail");
|
||||
assert_eq!(restored.connection_id, "conn_test");
|
||||
assert_eq!(restored.cursor.as_deref(), Some("12345"));
|
||||
assert!(restored.synced_ids.contains("item_a"));
|
||||
assert!(restored.synced_ids.contains("item_b"));
|
||||
assert_eq!(restored.synced_ids.len(), 2);
|
||||
assert_eq!(restored.daily_budget.requests_used, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_item_id_walks_paths() {
|
||||
let item = serde_json::json!({
|
||||
"id": "top_level",
|
||||
"data": { "id": "nested" }
|
||||
});
|
||||
assert_eq!(
|
||||
extract_item_id(&item, &["data.id", "id"]),
|
||||
Some("nested".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
extract_item_id(&item, &["missing", "id"]),
|
||||
Some("top_level".to_string())
|
||||
);
|
||||
assert_eq!(extract_item_id(&item, &["nope"]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kv_key_is_deterministic() {
|
||||
let s1 = SyncState::new("gmail", "conn_x");
|
||||
let s2 = SyncState::new("gmail", "conn_x");
|
||||
assert_eq!(s1.kv_key(), s2.kv_key());
|
||||
assert_eq!(s1.kv_key(), "gmail:conn_x");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user