Skip to content
Closed
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
6 changes: 4 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@ members = [
"tests/e2e",

# Automaton crates
# Note: Type aliases added but compilation errors remain - needs refactoring
# "crate/satellites/sinex-analytics-automaton",
# "crate/satellites/sinex-search-automaton",
Comment on lines +28 to +30

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider linking this TODO comment to a concrete tracking issue.

The “compilation errors remain” note is useful, but adding an issue/ADR reference here would prevent this from becoming stale and make re-enablement criteria explicit.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Cargo.toml` around lines 28 - 30, Update the inline TODO comment "Type
aliases added but compilation errors remain" to reference a concrete tracking
item (issue number or ADR URL) and a brief re-enablement criterion; find the
comment text in Cargo.toml (the lines containing "Type aliases added but
compilation errors remain" and the commented crate entries) and replace it with
a TODO that includes the issue/ADR identifier (e.g., GH-1234 or a full ADR link)
plus a short note on what must be resolved before uncommenting the crates (e.g.,
specific compilation errors or tests to pass).


# Keep experimental automata disabled until stabilized
# "crate/satellites/sinex-content-automaton",
# "crate/satellites/sinex-analytics-automaton",
# "crate/satellites/sinex-pkm-automaton",
# "crate/satellites/sinex-search-automaton",

# Test modules are integration tests within the main workspace
# "test/*", # Removed - tests are integration tests, not separate workspace members
Expand Down
25 changes: 13 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,14 @@ The promise is threefold: to restore **agency** by placing you in control of you

Sinex is a "sentient archive" implemented as a satellite constellation architecture – independent services orchestrated by NixOS/systemd that comprehensively capture, intelligently process, and powerfully query personal digital experiences.

**Current Core Flow (implemented in code)**: Satellites → ingestd (gRPC over Unix socket) → Postgres (`core.events`) → NATS JetStream fanout → Automata → Gateway (JSON‑RPC) → CLI
**Current Core Flow (implemented in code)**: Satellites → NATS JetStream → ingestd (consumer/archiver) → Postgres (`core.events`) + JetStream confirmations → Automata → Gateway (JSON‑RPC) → CLI

Note: A NATS‑native ingestion refactor (satellites publish directly to JetStream; ingestd acts as archiver/consumer) is planned. See `docs/plan_v3.txt`. Until that refactor lands, ingestion uses gRPC to ingestd as above.
Satellites publish events directly to NATS JetStream (`events.raw.*` subjects); ingestd consumes from JetStream, validates, persists to Postgres, and publishes confirmations back to JetStream for consumption by automata and other processors.

- **Satellites**: Independent services that capture events (filesystem, terminals, window managers, system events)
- **ingestd**: Central ingestion daemon that receives events via gRPC and stores data atomically; also fans out persisted events via NATS JetStream
- **NATS JetStream**: Durable message bus for event ingestion, distribution, and confirmation delivery
- **ingestd**: Consumes events from JetStream, validates, archives to Postgres, and publishes confirmations
- **Event Substrate**: PostgreSQL + TimescaleDB with ULID keys, stores immutable events
- **NATS JetStream**: Real-time event distribution and durable buffering
- **Automata**: Stream processors that transform raw events into canonical representations
- **Query Interface**: Python CLI (`exo.py`) for exploring captured events

Expand All @@ -59,10 +59,10 @@ Note: A NATS‑native ingestion refactor (satellites publish directly to JetStre
- **Dual-Mode Operation**: Satellites support both sensor (real-time) and scanner (batch) modes
- **Immutable Event Storage**: All events preserved with full fidelity
- **ULID Primary Keys**: Time-ordered, globally unique identifiers
- **Ingestion via gRPC (current)**: Satellites submit to ingestd over gRPC; ingestd validates/persists and fans out via NATS JetStream
- **NATS‑native ingest (planned)**: Per `docs/plan_v3.txt`, satellites will publish directly to JetStream; ingestd becomes an archiver/consumer
- **NATS JetStream Ingestion**: Satellites publish directly to JetStream; ingestd acts as consumer/archiver
- **Idempotency & Confirmation**: Message deduplication via NATS-Msg-Id headers; confirmation delivery for provisionals
- **Schema Validation**: JSON Schema validation for event payloads
- **Git-Annex Integration**: Large file storage for blobs
- **Git-Annex Integration**: Large file storage for blobs (source material slices)
- **NixOS Module**: First-class NixOS deployment support

### Satellite Architecture Details
Expand Down Expand Up @@ -92,21 +92,22 @@ All satellites support two operational modes:

### System Components Progress

- ✅ **Satellite Architecture**: Independent satellite services operational, unified SDK in progress
- ✅ **Satellite Architecture**: Independent satellite services operational, unified SDK complete
- ✅ **Data Substrate**: PostgreSQL + TimescaleDB with ULID keys; `core.events` operational
- 🚧 **Message Bus**: Converging on NATS JetStream for ingestion and distribution (see docs/plan_v3.txt)
- ✅ **Message Bus**: NATS JetStream ingestion and distribution fully implemented
- 🚧 **Event Sources**: Filesystem, Terminal, Desktop, System — expanding coverage
- 🚧 **Automaton Ecosystem**: Deterministic processors; more in progress
- 🚧 **Gateway & CLI**: Operational; iterative improvements
- 🔨 **AI/LLM Integration**: Early framework and schema; integration ongoing

### ✅ Implemented (Working Code)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fix markdown heading spacing to satisfy lint.

There should be a blank line before this heading to comply with MD022.

📝 Suggested markdown fix
 - 🔨 **AI/LLM Integration**: Early framework and schema; integration ongoing
+
 ### ✅ Implemented (Working Code)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
### ✅ Implemented (Working Code)
- 🔨 **AI/LLM Integration**: Early framework and schema; integration ongoing
### ✅ Implemented (Working Code)
🧰 Tools
🪛 markdownlint-cli2 (0.22.0)

[warning] 103-103: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` at line 103, Add a blank line immediately before the markdown
heading "### ✅ Implemented (Working Code)" to satisfy MD022 (ensure there is a
separating empty line above the heading).

- **Core Infrastructure**: Event storage, ULID keys, TimescaleDB integration
- **Satellite Architecture**: Independent satellites publish to NATS JetStream
- **Satellite Architecture**: Independent satellites publish to NATS JetStream with idempotency headers
- **Event Source Satellites**: Filesystem, Terminal (Kitty/Atuin/recordings), Desktop (clipboard/Hyprland), System (D-Bus/systemd/udev)
- **Dual-Mode Support**: All satellites support sensor (real-time) and scanner (batch) modes
- **Processing Pipeline**: Satellites → NATS JetStream → ingestd (archiver) → Automata
- **Storage**: Git-annex blob storage, JSON schema validation
- **Processing Pipeline**: Satellites → JetStream (`events.raw.*`) → ingestd (consumer/archiver) → Postgres + JetStream confirmations → Automata
- **Confirmation Flow**: Provisional events receive confirmations with canonical IDs after persistence
- **Storage**: Git-annex blob storage (source material slices), JSON schema validation
- **Deployment**: NixOS module with systemd satellite services
- **Testing**: Comprehensive test suite including satellite integration tests

Expand Down
275 changes: 275 additions & 0 deletions crate/core/sinex-ingestd/tests/jetstream_e2e_integration_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
//! End-to-end JetStream integration test
//!
//! This test demonstrates the complete JetStream event flow as specified in docs/way.md:
//! 1. Satellite publishes provisional event to JetStream (events.raw)
//! 2. ingestd consumes event, persists to database
//! 3. ingestd publishes confirmation (events.confirmations)
//! 4. Automaton consumes confirmed event
//!
//! This validates Phase 1 (Event Backbone) and Phase 2 (Confirmation-Aware Consumption).

use async_nats::jetstream;
use serde_json::json;
use sinex_core::types::Ulid;
use sinex_core::DbPoolExt;
use sinex_ingestd::validator::EventValidator;
use sinex_ingestd::JetStreamConsumer;
use sinex_satellite_sdk::{
AutomatonEventHandler, JetStreamEventConsumer, JetStreamEventConsumerConfig, NatsPublisher,
ProcessingModel,
};
use sinex_test_utils::{sinex_test, TestContext};
use std::sync::Arc;
use std::time::Duration;
use tracing::info;

#[sinex_test]
async fn test_jetstream_e2e_event_flow() -> color_eyre::Result<()> {
info!("🚀 Starting E2E JetStream test");

// Setup test infrastructure
let ctx = TestContext::new().await?.with_nats().await?;
let nats_client = ctx.nats_client();
let pool = ctx.pool.clone();
let env = ctx.env();

// Create JetStream context
let js = jetstream::new(nats_client.clone());

// Bootstrap events_raw stream
let events_raw_stream = env.nats_subject("events_raw");
js.get_or_create_stream(jetstream::stream::Config {
name: events_raw_stream.clone(),
subjects: vec![env.nats_subject("events.raw.>")],
retention: jetstream::stream::RetentionPolicy::Limits,
max_messages: 10_000,
storage: jetstream::stream::StorageType::File,
..Default::default()
})
.await?;

// Bootstrap events_confirmations stream
let confirmations_stream = env.nats_subject("events_confirmations");
js.get_or_create_stream(jetstream::stream::Config {
name: confirmations_stream.clone(),
subjects: vec![env.nats_subject("events.confirmations.>")],
retention: jetstream::stream::RetentionPolicy::Limits,
max_messages: 10_000,
storage: jetstream::stream::StorageType::File,
..Default::default()
})
.await?;

Comment on lines +39 to +62

Copilot AI Apr 9, 2026

Copy link

Choose a reason for hiding this comment

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

The test bootstraps events_raw / events_confirmations streams with configs that differ from ingestd’s own JetStreamConsumer::bootstrap_streams (e.g., confirmations stream compaction via max_messages_per_subject = 1, retention/max_age, etc.). This can make the e2e test less representative and can prevent ingestd from applying its intended stream settings once the streams already exist. Prefer letting ingestd bootstrap the streams, or align these get_or_create_stream configs with ingestd’s bootstrap config.

Suggested change
// Bootstrap events_raw stream
let events_raw_stream = env.nats_subject("events_raw");
js.get_or_create_stream(jetstream::stream::Config {
name: events_raw_stream.clone(),
subjects: vec![env.nats_subject("events.raw.>")],
retention: jetstream::stream::RetentionPolicy::Limits,
max_messages: 10_000,
storage: jetstream::stream::StorageType::File,
..Default::default()
})
.await?;
// Bootstrap events_confirmations stream
let confirmations_stream = env.nats_subject("events_confirmations");
js.get_or_create_stream(jetstream::stream::Config {
name: confirmations_stream.clone(),
subjects: vec![env.nats_subject("events.confirmations.>")],
retention: jetstream::stream::RetentionPolicy::Limits,
max_messages: 10_000,
storage: jetstream::stream::StorageType::File,
..Default::default()
})
.await?;
// Do not pre-create the ingestd-owned streams here.
// Let ingestd bootstrap them so this e2e test exercises the same
// stream configuration that production uses.

Copilot uses AI. Check for mistakes.
info!("✅ JetStream streams created");

// Step 1: Start ingestd consumer
let validator = EventValidator::new(false); // Validation disabled for test
let ingestd_consumer =
JetStreamConsumer::new(nats_client.clone(), pool.clone(), Arc::new(validator));
let ingestd_handle = tokio::spawn(async move { ingestd_consumer.run().await });

// Wait for ingestd to initialize
tokio::time::sleep(Duration::from_secs(1)).await;
info!("✅ ingestd JetStream consumer started");

// Step 2: Start automaton consumer
let automaton_handler = Arc::new(AutomatonEventHandler::new());
let automaton_config = JetStreamEventConsumerConfig {
processing_model: ProcessingModel::StatelessWorker,
batch_size: 100,
confirmation_timeout: Duration::from_secs(30),
consumer_name: "test-automaton".to_string(),
enable_provisional_processing: false,
};
let automaton_consumer = JetStreamEventConsumer::new(
nats_client.clone(),
env.clone(),
automaton_config,
automaton_handler.clone(),
None,
);
let automaton_handle = tokio::spawn(async move { automaton_consumer.run().await });

// Wait for automaton to initialize
tokio::time::sleep(Duration::from_secs(1)).await;
info!("✅ Automaton JetStream consumer started");

// Step 3: Publish event using NatsPublisher (satellite simulation)
let publisher = NatsPublisher::new(nats_client.clone());

let test_event = ctx
.create_test_event(
"test-satellite",
"test.event",
json!({
"message": "E2E JetStream test event",
"timestamp": chrono::Utc::now().to_rfc3339(),
}),
)
.await?;

let event_id = test_event
.id
.as_ref()
.expect("Event should have ID")
.clone();

// Publish to JetStream using the SDK publisher
publisher
.publish(&test_event)
.await
.map_err(|e| color_eyre::eyre::eyre!("Publish failed: {}", e))?;
info!(event_id = %event_id, "✅ Event published to JetStream via NatsPublisher");

// Step 4: Wait for event to flow through the pipeline
// Event flow: JetStream → ingestd → DB → confirmation → automaton
let mut event_persisted = false;
let mut confirmation_received = false;

for attempt in 0..30 {
// Check if event persisted to database
if !event_persisted {
if let Some(event_from_db) = pool.events().get_by_id(event_id).await? {
info!(
attempt,
event_id = %event_id,
"✅ Event persisted to database"
);
assert_eq!(event_from_db.source.as_str(), "test-satellite");
assert_eq!(event_from_db.event_type.as_str(), "test.event");
event_persisted = true;
}
}

// Check if automaton received confirmation
if !confirmation_received {
let processed_ids = automaton_handler.processed_event_ids().await;
if processed_ids.contains(&event_id.as_ulid()) {
info!(
attempt,
event_id = %event_id,
"✅ Automaton received confirmed event"
);
confirmation_received = true;
}
}

// Both conditions met
if event_persisted && confirmation_received {
break;
}

tokio::time::sleep(Duration::from_millis(500)).await;
}

// Verify the complete flow succeeded
assert!(
event_persisted,
"Event should be persisted to database by ingestd"
);
assert!(
confirmation_received,
"Automaton should receive confirmed event"
);

// Verify automaton processed exactly one event
assert_eq!(
automaton_handler.processed_count().await,
1,
"Automaton should have processed exactly one event"
);

info!("🎉 E2E JetStream test PASSED");
info!(" ✓ Satellite → JetStream (events.raw)");
info!(" ✓ ingestd → Database persistence");
info!(" ✓ ingestd → JetStream (events.confirmations)");
info!(" ✓ Automaton → Confirmed event consumption");

// Cleanup
drop(ingestd_handle);
drop(automaton_handle);

Comment on lines +188 to +191

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Dropping task handles does not cancel spawned tasks.

drop(ingestd_handle) and drop(automaton_handle) simply release the JoinHandle without aborting the underlying tasks. The tasks will continue running until the test process exits.

🧹 Proposed cleanup
     // Cleanup
-    drop(ingestd_handle);
-    drop(automaton_handle);
+    ingestd_handle.abort();
+    automaton_handle.abort();
+    // Optionally wait for cleanup
+    let _ = ingestd_handle.await;
+    let _ = automaton_handle.await;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Cleanup
drop(ingestd_handle);
drop(automaton_handle);
// Cleanup
ingestd_handle.abort();
automaton_handle.abort();
// Optionally wait for cleanup
let _ = ingestd_handle.await;
let _ = automaton_handle.await;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crate/core/sinex-ingestd/tests/jetstream_e2e_integration_test.rs` around
lines 188 - 191, The current cleanup just drops the JoinHandles (ingestd_handle
and automaton_handle) which doesn't stop the spawned tasks; call abort() on both
JoinHandle values (ingestd_handle.abort() and automaton_handle.abort()) and then
await their completions (await the handles) to ensure the tasks are cancelled
and any panics are observed before the test exits; update the cleanup section
that currently drops ingestd_handle and automaton_handle to explicitly abort and
await them instead.

Ok(())
}

#[sinex_test]
async fn test_jetstream_idempotency() -> color_eyre::Result<()> {
info!("🚀 Starting JetStream idempotency test");

let ctx = TestContext::new().await?.with_nats().await?;
let nats_client = ctx.nats_client();
let pool = ctx.pool.clone();
let env = ctx.env();

// Create JetStream context and bootstrap streams
let js = jetstream::new(nats_client.clone());
let events_raw_stream = env.nats_subject("events_raw");
js.get_or_create_stream(jetstream::stream::Config {
name: events_raw_stream.clone(),
subjects: vec![env.nats_subject("events.raw.>")],
retention: jetstream::stream::RetentionPolicy::Limits,
max_messages: 10_000,
storage: jetstream::stream::StorageType::File,
..Default::default()
})
.await?;

// Start ingestd
let validator = EventValidator::new(false);
let ingestd_consumer =
JetStreamConsumer::new(nats_client.clone(), pool.clone(), Arc::new(validator));
let _ingestd_handle = tokio::spawn(async move { ingestd_consumer.run().await });
tokio::time::sleep(Duration::from_secs(1)).await;
Comment on lines +221 to +222

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Same task cleanup issue in idempotency test.

The _ingestd_handle is not aborted on completion.

🧹 Add cleanup at end of test
     info!("🎉 Idempotency test PASSED - 3 publishes → 1 database row");

+    _ingestd_handle.abort();
+
     Ok(())
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crate/core/sinex-ingestd/tests/jetstream_e2e_integration_test.rs` around
lines 221 - 222, The spawned task handle _ingestd_handle created from
tokio::spawn(async move { ingestd_consumer.run().await }) is never cleaned up at
the end of the test; ensure the background consumer is stopped by either
awaiting the handle to finish or aborting it when the test completes (e.g., call
_ingestd_handle.abort() or await _ingestd_handle with a timeout and then abort
if still running) so ingestd_consumer.run() is not left running after the test.


// Publish same event 3 times with same event_id (idempotency header)
let publisher = NatsPublisher::new(nats_client.clone());
let test_event = ctx
.create_test_event(
"idempotency-test",
"test.duplicate",
json!({"test": "idempotency"}),
)
.await?;

let event_id = test_event
.id
.as_ref()
.expect("Event should have ID")
.clone();

// Publish 3 times
for i in 1..=3 {
publisher
.publish(&test_event)
.await
.map_err(|e| color_eyre::eyre::eyre!("Publish failed: {}", e))?;
info!(iteration = i, event_id = %event_id, "Published event");
}

// Wait for processing
tokio::time::sleep(Duration::from_secs(2)).await;

// Verify only ONE event in database
let events = pool
.events()
.get_by_source(
&sinex_core::EventSource::new("idempotency-test".to_string()),
Some(100),
None,
)
.await?;

assert_eq!(
events.len(),
1,
"Should have exactly 1 event despite 3 publishes (idempotency)"
);
assert_eq!(
events[0].id.as_ref().expect("Event should have ID"),
&event_id
);

info!("🎉 Idempotency test PASSED - 3 publishes → 1 database row");

Ok(())
}
Loading
Loading