Skip to content
Merged
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
11 changes: 8 additions & 3 deletions src/memory/ingest/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use chrono::Utc;

use crate::memory::chunks::{
self, chunk_markdown, claim_source_ingest_tx, delete_source_ingest, get_chunk_lifecycle_status,
is_source_ingested, set_chunk_lifecycle_status, set_chunk_raw_refs, upsert_chunks,
is_source_ingested, set_chunk_lifecycle_status, set_chunk_raw_refs, upsert_staged_chunks_tx,
with_connection, ChunkerInput, ChunkerOptions, SourceKind, CHUNK_STATUS_PENDING_EXTRACTION,
};
use crate::memory::config::MemoryConfig;
Expand Down Expand Up @@ -146,7 +146,7 @@ async fn persist_score_enqueue(
) -> Result<IngestSummary> {
// 3. Write each chunk body to the content store (atomic write + sha256).
let content_root = chunks::content_root(config);
content::stage_chunks(&content_root, &chunks)
let staged = content::stage_chunks(&content_root, &chunks)
.map_err(|e| anyhow!("stage_chunks failed: {e}"))?;

// 4. Snapshot each chunk's CURRENT lifecycle BEFORE the upsert. A chunk that
Expand All @@ -159,7 +159,12 @@ async fn persist_score_enqueue(
}

// 5. Persist chunk rows (idempotent on deterministic chunk id).
let chunks_written = upsert_chunks(config, &chunks)?;
let chunks_written = with_connection(config, |connection| {
let transaction = connection.unchecked_transaction()?;
let count = upsert_staged_chunks_tx(&transaction, &staged)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Hydrate full bodies after staged upsert

When this switches ingest to upsert_staged_chunks_tx, the SQLite content column is no longer the full chunk; that helper stores only the first 500 chars as a preview. L0 sealing still hydrates leaves via hydrate_leaf_inputs in src/memory/tree/hydrate.rs, which calls get_chunk and feeds chunk.content directly to the summarizer, so any ingested document/chat chunk longer than 500 chars is summarized from a truncated preview; plain ingest_email also has no raw refs for read_chunk_body to recover the skipped email body. Please either hydrate seal inputs from the content store/raw archive or avoid staged previews until those consumers are switched.

Useful? React with 👍 / 👎.

transaction.commit()?;
Ok(count)
})?;

// 5b. Raw-archive-backed bodies: attach refs so a worker can resolve them.
if let Some(refs) = opts.raw_refs.as_ref() {
Expand Down
9 changes: 7 additions & 2 deletions src/memory/ingest/pipeline_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ use tempfile::TempDir;

use super::{ingest_chat, ingest_document, ingest_document_versioned, ingest_email_with_raw_refs};
use crate::memory::chunks::{
count_chunks, get_chunk_lifecycle_status, is_source_ingested, SourceKind,
CHUNK_STATUS_PENDING_EXTRACTION,
count_chunks, get_chunk_content_pointers, get_chunk_lifecycle_status, is_source_ingested,
SourceKind, CHUNK_STATUS_PENDING_EXTRACTION,
};
use crate::memory::chunks::{get_chunk_raw_refs, RawRef};
use crate::memory::config::MemoryConfig;
Expand Down Expand Up @@ -99,6 +99,11 @@ async fn ingest_chat_writes_chunks_and_enqueues_extract_jobs() {
assert!(out.chunks_written >= 1);
assert_eq!(count_chunks(&cfg).unwrap(), out.chunks_written as u64);
assert_eq!(out.chunk_ids.len(), out.chunks_written);
let pointers = get_chunk_content_pointers(&cfg, &out.chunk_ids[0])
.unwrap()
.unwrap();
assert!(!pointers.0.is_empty());
assert!(!pointers.1.is_empty());

// Every scheduled chunk got an extract job and is parked at pending.
assert_eq!(out.extract_jobs_enqueued, out.chunk_ids.len());
Expand Down
11 changes: 7 additions & 4 deletions src/memory/store/kv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,12 +98,15 @@ impl KvStore {
/// characters collapse to `_` so `"team alpha/#1"` and `"team_alpha/_1"`
/// address the same bucket.
fn sanitize_namespace(namespace: &str) -> String {
namespace
let trimmed = namespace.trim();
if trimmed.is_empty() {
return crate::memory::types::GLOBAL_NAMESPACE.to_string();
}
trimmed
.chars()
.map(|c| match c {
c if c.is_whitespace() => '_',
'#' => '_',
other => other,
c if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '/') => c,
_ => '_',
Comment on lines +108 to +109

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Migrate old KV namespace keys

If a workspace already has KV rows in namespaces containing characters that the old sanitizer preserved, such as project.v1 or skill:gmail, this new allowlist rewrites future lookups to project_v1 / skill_gmail while the existing rows remain stored under the original namespace string. Because KvStore::open only installs the schema and does not migrate kv_namespace.namespace, those persisted records become invisible to get_namespace, list_namespace, and records_namespace after upgrade; either preserve backward-compatible lookup or migrate existing namespace keys when changing the normalization.

Useful? React with 👍 / 👎.

})
.collect()
}
Comment on lines +101 to 112

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether kv_namespace rows persist across app restarts/deployments
# and whether any migration/backfill exists for previously-sanitized namespace values.
rg -n "kv_namespace" --type=rust -C3
rg -n "migration|backfill" src/memory/store -C3

Repository: tinyhumansai/tinycortex

Length of output: 161


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant files and inspect namespace handling.
git ls-files 'src/memory/store/*' 'src/memory/types*' | sed -n '1,200p'
printf '\n--- kv.rs outline ---\n'
ast-grep outline src/memory/store/kv.rs --view expanded || true
printf '\n--- kv.rs relevant lines ---\n'
nl -ba src/memory/store/kv.rs | sed -n '1,240p'

printf '\n--- search for namespace persistence / storage backend ---\n'
rg -n "struct .*Namespace|kv_namespace|sanitize_namespace|get_namespace|set_namespace|delete_namespace|list_namespace|records_namespace|GLOBAL_NAMESPACE" src -C 3

Repository: tinyhumansai/tinycortex

Length of output: 2895


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- src/memory/store/kv.rs (selected lines) ---'
sed -n '1,380p' src/memory/store/kv.rs

printf '\n%s\n' '--- src/memory/store/kv_tests.rs ---'
sed -n '1,260p' src/memory/store/kv_tests.rs

printf '\n%s\n' '--- src/memory/store/mod.rs ---'
sed -n '1,220p' src/memory/store/mod.rs

printf '\n%s\n' '--- src/memory/store/store.rs ---'
sed -n '1,220p' src/memory/store/store.rs

Repository: tinyhumansai/tinycortex

Length of output: 26062


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Look for any compatibility layer or migration that rewrites kv_namespace rows.
rg -n "sanitize_namespace|kv_namespace|backfill|migration|compat|legacy namespace|namespace.*old" src -C 3

# Check whether the KV store is documented as durable / file-backed.
rg -n "open\\(|open_in_memory|SQLite-backed|persist|durable|db_path" src/memory/store/kv.rs src/memory/store/mod.rs src/memory/store/store.rs -C 3

Repository: tinyhumansai/tinycortex

Length of output: 50379


Namespace sanitization needs a migration path.

KvStore::open(db_path) persists kv_namespace in SQLite, so this whitelist change remaps any previously stored namespace containing preserved punctuation or non-ASCII characters to a different storage key. Rows written under the old sanitizer will become unreachable from the same namespace input, and there’s no backfill or dual-read fallback here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/memory/store/kv.rs` around lines 101 - 112, Update the namespace
sanitization flow used by KvStore::open so existing SQLite kv_namespace keys
remain readable after the whitelist change. Add a migration/backfill or
dual-read fallback from the legacy sanitized namespace to the new value,
preserving access for namespaces containing previously supported punctuation or
non-ASCII characters while continuing to write using the new sanitizer.

Expand Down
48 changes: 46 additions & 2 deletions src/memory/sync/composio/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,13 +221,24 @@ impl ComposioClient {
let _ = response.bytes().await;
anyhow::bail!("Composio proxy request failed with HTTP {status}");
}
response
let raw: serde_json::Value = response
.json()
.await
.map_err(|error| anyhow::anyhow!("Composio proxy response decode failed: {error}"))
.map_err(|error| anyhow::anyhow!("Composio proxy response decode failed: {error}"))?;
decode_proxy_response(raw)
}
}

fn decode_proxy_response(raw: serde_json::Value) -> anyhow::Result<ExecuteResponse> {
let payload = if raw.get("successful").is_some() {
raw
} else {
raw.get("data").cloned().unwrap_or(raw)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve proxy billing fields when unwrapping

When the proxy uses the backend envelope, this branch replaces the entire response with raw["data"]. If that envelope carries costUsd or markdownFormatted beside data (the flat proxied path already records costUsd, and SyncState::record_action only sees response.cost_usd), every wrapped successful call is returned with the default 0.0 cost and the sync audit/budget under-reports provider spend. Preserve the outer billing fields when unwrapping the provider payload.

Useful? React with 👍 / 👎.

};
serde_json::from_value(payload)
.map_err(|error| anyhow::anyhow!("Composio proxy response decode failed: {error}"))
}

fn retryable_provider_error(error: Option<&str>) -> bool {
error.is_some_and(|error| {
let lower = error.to_ascii_lowercase();
Expand Down Expand Up @@ -259,3 +270,36 @@ async fn decode_response(
.await
.map_err(|error| anyhow::anyhow!("Composio {mode} response decode failed: {error}"))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn proxied_backend_envelope_decodes_provider_response() {
let response = decode_proxy_response(serde_json::json!({
"success": true,
"data": {
"successful": true,
"data": {"messages": [{"messageId": "message-1"}]},
"error": null
}
}))
.unwrap();

assert!(response.successful);
assert_eq!(response.data["messages"][0]["messageId"], "message-1");
}

#[test]
fn flat_proxy_response_remains_supported() {
let response = decode_proxy_response(serde_json::json!({
"successful": true,
"data": {"items": [1]}
}))
.unwrap();

assert!(response.successful);
assert_eq!(response.data["items"], serde_json::json!([1]));
}
}
Comment on lines +273 to +305

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Move tests to client_tests.rs.

New tests are added inline in client.rs via #[cfg(test)] mod tests, but the guideline requires unit tests to live in a per-file sibling module (e.g., client_tests.rs), not mixed into implementation files.

As per path instructions, "Keep tests in per-file <name>_tests.rs siblings, such as store.rs and store_tests.rs, rather than mixing tests into implementation files."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/memory/sync/composio/client.rs` around lines 273 - 305, Move the inline
#[cfg(test)] mod tests containing
proxied_backend_envelope_decodes_provider_response and
flat_proxy_response_remains_supported from client.rs into the sibling
client_tests.rs module. Import the necessary client symbols there and preserve
both test cases and their assertions unchanged.

Source: Path instructions

3 changes: 3 additions & 0 deletions src/memory/sync/composio/gmail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ impl IncrementalSource for GmailSyncPipeline {
fn max_pages(&self) -> usize {
self.max_pages
}
fn stop_on_empty_pending(&self) -> bool {
true
}

fn server_side_depth(&self) -> bool {
true
Expand Down
14 changes: 14 additions & 0 deletions src/memory/sync/composio/orchestrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ pub trait IncrementalSource: Send + Sync {
fn retain_dedup_keys(&self) -> bool {
true
}
fn stop_on_empty_pending(&self) -> bool {
false
}
fn server_side_depth(&self) -> bool {
false
}
Expand Down Expand Up @@ -271,6 +274,7 @@ async fn run_pages(

let fetched = source.extract_page(&response.data, page_token.as_deref());
let mut reached_cursor_boundary = false;
let mut saw_unsynced_item = false;
for raw in fetched.items {
if config
.sync
Expand All @@ -291,6 +295,7 @@ async fn run_pages(
if state.is_synced(&dedup_key) {
continue;
}
saw_unsynced_item = true;
let sort_cursor = source.sort_cursor(&raw);
if sort_cursor
.as_deref()
Expand Down Expand Up @@ -376,6 +381,15 @@ async fn run_pages(
}

page_token = fetched.next;
if source.stop_on_empty_pending() && !saw_unsynced_item {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid stopping before pending pages after capped runs

When Gmail has more results than one capped run can process, more_pending prevents cursor advancement, leaving the next tick to start from the first page again with those items already in synced_ids. If that first page is now all deduped but still has a nextPageToken, this new break exits before following the token, so older unsynced Gmail pages remain permanently unreachable after hitting max_items or max_pages on a prior run.

Useful? React with 👍 / 👎.

tracing::debug!(
toolkit = source.toolkit(),
connection_id,
scope = %scope.label,
"[sync:orchestrator] stopping after all-deduplicated page"
);
break;
}
if reached_cursor_boundary {
break;
}
Expand Down