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
1,812 changes: 1,762 additions & 50 deletions Cargo.lock

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,13 @@ repository = "https://github.com/hartsock/modulex-mcp"
modulex-core = { path = "crates/modulex-core", version = "=0.1.0" }

agent-bridle-core = "0.1.0"
agent-bridle-tool-web = "0.1.0"
anyhow = "1.0"
blake3 = "1.8"
async-trait = "0.1"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
clap = { version = "4", features = ["derive"] }
rusqlite = { version = "0.32", features = ["bundled"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
shell-words = "1.1"
Expand Down
96 changes: 96 additions & 0 deletions crates/modulex-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,49 @@ enum Command {
Steps,
/// Show config location, leash provenance, and tool availability.
Doctor,
/// Manage reminders in the agent state store.
Remind {
#[command(subcommand)]
action: RemindAction,
},
/// Agent state store utilities.
Store {
#[command(subcommand)]
action: StoreAction,
},
}

#[derive(Subcommand)]
enum RemindAction {
/// Register a reminder ("remind me of X").
Add {
/// The reminder text.
text: String,
/// Optional ISO due date (YYYY-MM-DD).
#[arg(long)]
due: Option<String>,
/// Optional recurrence: daily | weekly | monthly.
#[arg(long)]
recur: Option<String>,
},
/// List open reminders.
List,
/// Mark a reminder done by id.
Done {
/// Reminder id (from `remind list`).
id: i64,
},
}

#[derive(Subcommand)]
enum StoreAction {
/// Export the whole store as plain JSON (sovereignty).
Export,
/// Import a previous export (appends).
Import {
/// Path to a JSON export.
file: PathBuf,
},
}

fn load(config_path: Option<&PathBuf>) -> anyhow::Result<(Engine, PathBuf, String)> {
Expand Down Expand Up @@ -176,6 +219,59 @@ async fn run(cli: Cli) -> anyhow::Result<bool> {
}
}
println!("routines: {}", engine.list_routines().len());
match engine.store() {
Some(_) => println!("agent state store: ok"),
None => println!("agent state store: UNAVAILABLE"),
}
Ok(true)
}
Command::Remind { action } => {
let Some(store) = engine.store() else {
anyhow::bail!("agent state store unavailable");
};
let generation = engine.current_generation();
match action {
RemindAction::Add { text, due, recur } => {
let id =
store.reminder_add(&text, due.as_deref(), recur.as_deref(), generation)?;
println!("reminder #{id} registered (after gen {generation})");
}
RemindAction::List => {
let reminders = store.reminders_open()?;
if reminders.is_empty() {
println!("(no open reminders)");
}
for r in reminders {
let due = r.due.map(|d| format!(" due {d}")).unwrap_or_default();
let recur = r
.recurrence
.map(|recurrence| format!(" [{recurrence}]"))
.unwrap_or_default();
println!("#{} {}{due}{recur}", r.id, r.text);
}
}
RemindAction::Done { id } => {
if store.reminder_done(id, generation)? {
println!("reminder #{id} done");
} else {
anyhow::bail!("no open reminder #{id}");
}
}
}
Ok(true)
}
Command::Store { action } => {
let Some(store) = engine.store() else {
anyhow::bail!("agent state store unavailable");
};
match action {
StoreAction::Export => println!("{}", store.export_json()?),
StoreAction::Import { file } => {
let json = std::fs::read_to_string(&file)?;
store.import_json(&json)?;
println!("imported {}", file.display());
}
}
Ok(true)
}
}
Expand Down
7 changes: 7 additions & 0 deletions crates/modulex-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,20 @@ authors.workspace = true
repository.workspace = true

[features]
default = ["web"]
# The url-watch step: leashed fetching via agent-bridle-tool-web (net-axis
# Caveats + SSRF screening). Off → no reqwest/rustls in the build.
web = ["dep:agent-bridle-tool-web", "dep:blake3"]
# Exposes exec::test_support (MockSpawner, gate_with) to downstream crates'
# tests. Dev-dependencies only.
test-support = []

[dependencies]
agent-bridle-core = { workspace = true }
agent-bridle-tool-web = { workspace = true, optional = true }
anyhow = { workspace = true }
blake3 = { workspace = true, optional = true }
rusqlite = { workspace = true }
async-trait = { workspace = true }
chrono = { workspace = true }
serde = { workspace = true }
Expand Down
11 changes: 11 additions & 0 deletions crates/modulex-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,14 @@ pub struct ChoresConfig {
pub path: String,
}

/// Agent state store location (`[store]`).
#[derive(Clone, Debug, Default, Deserialize)]
pub struct StoreConfig {
/// SQLite path; empty = `$MODULEX_STORE` → `~/.modulex/store.db`.
#[serde(default)]
pub path: String,
}

/// A fixed date to count down to.
#[derive(Clone, Debug, Deserialize)]
pub struct DeadlineEntry {
Expand Down Expand Up @@ -218,6 +226,9 @@ pub struct Config {
/// Chores config.
#[serde(default)]
pub chores: ChoresConfig,
/// Agent state store config.
#[serde(default)]
pub store: StoreConfig,
/// Deadlines for `deadline-calc`.
#[serde(default)]
pub deadlines: Vec<DeadlineEntry>,
Expand Down
63 changes: 60 additions & 3 deletions crates/modulex-core/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,17 +88,34 @@ pub struct Engine {
spawner: Arc<dyn Spawner>,
generation: AtomicU64,
reports: Mutex<VecDeque<Report>>,
store: Option<Arc<crate::store::Store>>,
}

impl Engine {
/// An engine over the given config, registry, and grant, spawning real
/// processes.
/// processes. Opens (creating if needed) the agent state store at the
/// configured path; on failure the engine still runs, store-backed
/// steps soft-skip, and a warning goes to stderr.
#[must_use]
pub fn new(config: Config, registry: StepRegistry, granted: Caveats) -> Self {
Self::with_spawner(config, registry, granted, Arc::new(TokioSpawner))
let home = std::env::var_os("HOME").map(std::path::PathBuf::from);
let path = crate::store::Store::resolve_path(
(!config.store.path.is_empty()).then_some(config.store.path.as_str()),
home.as_deref(),
);
let store = match crate::store::Store::open(&path) {
Ok(store) => Some(Arc::new(store)),
Err(e) => {
eprintln!(
"modulex: agent state store unavailable ({e}) — store-backed steps will skip"
);
None
}
};
Self::with_spawner(config, registry, granted, Arc::new(TokioSpawner)).with_store_opt(store)
}

/// As [`Engine::new`] with an injected [`Spawner`] (tests).
/// As [`Engine::new`] with an injected [`Spawner`] and NO store (tests).
#[must_use]
pub fn with_spawner(
config: Config,
Expand All @@ -113,7 +130,38 @@ impl Engine {
spawner,
generation: AtomicU64::new(0),
reports: Mutex::new(VecDeque::new()),
store: None,
}
}

/// Attach an agent state store (builder). Seeds the generation counter
/// from the store's persisted value, so generations stay monotonic
/// across process restarts.
#[must_use]
pub fn with_store(self, store: Arc<crate::store::Store>) -> Self {
self.with_store_opt(Some(store))
}

fn with_store_opt(mut self, store: Option<Arc<crate::store::Store>>) -> Self {
if let Some(store) = &store {
self.generation
.store(store.last_generation(), Ordering::Release);
}
self.store = store;
self
}

/// The agent state store, when available.
#[must_use]
pub fn store(&self) -> Option<&Arc<crate::store::Store>> {
self.store.as_ref()
}

/// The current generation: the identity of the LAST completed (or
/// in-flight) run. Mutation stamps use this — "registered after run N".
#[must_use]
pub fn current_generation(&self) -> u64 {
self.generation.load(Ordering::Acquire)
}

/// The loaded configuration.
Expand Down Expand Up @@ -255,6 +303,13 @@ impl Engine {
}

report.finalize();
// Persist the generation so it stays monotonic across restarts
// (best effort — a read-only disk shouldn't kill the report).
if let Some(store) = &self.store {
if let Err(e) = store.set_last_generation(generation) {
eprintln!("modulex: could not persist generation {generation}: {e}");
}
}
let mut reports = self.reports.lock().expect("report store poisoned");
while reports.len() >= REPORT_RETENTION {
reports.pop_front();
Expand Down Expand Up @@ -290,6 +345,7 @@ impl Engine {
generation,
exec: exec.clone(),
prior: prior.to_vec(),
store: self.store.clone(),
}
}

Expand All @@ -307,6 +363,7 @@ impl Engine {
generation,
exec: exec.clone(),
prior,
store: self.store.clone(),
};
run_with(self.registry.get(&step.step_type).as_deref(), step, &cx).await
}
Expand Down
8 changes: 8 additions & 0 deletions crates/modulex-core/src/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,14 @@ impl ExecGate {
Self { cx, spawner }
}

/// The authorized [`ToolContext`] for this run — read access only.
/// In-proc leashed tools (the url-watch fetcher) consult its `net` axis;
/// the context itself remains unforgeable (minted only by the gate).
#[must_use]
pub fn tool_context(&self) -> &ToolContext {
&self.cx
}

/// Leash-check, spawn, scrub. The ONLY subprocess path in modulex.
///
/// # Errors
Expand Down
2 changes: 2 additions & 0 deletions crates/modulex-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ pub mod registry;
pub mod report;
pub mod step;
pub mod steps;
pub mod store;

pub use caveats::{CaveatsSource, GrantedCaveats};
pub use config::{Config, RoutineSpec, StepSpec};
Expand All @@ -41,6 +42,7 @@ pub use exec::{ExecGate, ExecOutput, ExecRequest, Spawner, TokioSpawner};
pub use registry::StepRegistry;
pub use report::{RepoResult, Report, StepResult};
pub use step::{RunContext, StepHandler};
pub use store::Store;

// Re-export the leash vocabulary so embedders don't need a direct
// agent-bridle-core dependency to construct grants.
Expand Down
3 changes: 3 additions & 0 deletions crates/modulex-core/src/step.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ pub struct RunContext {
/// Results of steps that completed earlier in this run, in config order.
/// Derived steps (e.g. an SLA check over a review-queue step) read these.
pub prior: Vec<StepResult>,
/// The agent state store, when available. Store-backed steps soft-skip
/// without it.
pub store: Option<std::sync::Arc<crate::store::Store>>,
}

/// A step implementation, registered in a [`crate::registry::StepRegistry`]
Expand Down
1 change: 1 addition & 0 deletions crates/modulex-core/src/steps/board.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ mod tests {
generation: 1,
exec: gate_with(&Caveats::top(), Arc::new(MockSpawner::default())),
prior: Vec::new(),
store: None,
}
}

Expand Down
18 changes: 16 additions & 2 deletions crates/modulex-core/src/steps/dates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,21 @@ impl StepHandler for CountdownCalc {
}

async fn run(&self, spec: &StepSpec, cx: &RunContext) -> StepResult {
let countdowns = &cx.config.countdowns;
// Config entries + agent-registered store entries, merged. Store
// failures degrade to config-only (soft).
let mut countdowns = cx.config.countdowns.clone();
if let Some(store) = &cx.store {
if let Ok(stored) = store.countdowns_active() {
countdowns.extend(stored.into_iter().map(|c| crate::config::CountdownEntry {
label: c.label,
start_date: c.start_date,
end_date: c.end_date,
total_work_days: c.total_work_days,
role: String::new(),
display: c.display,
}));
}
}
if countdowns.is_empty() {
return StepResult::ok(&spec.name, &spec.step_type, "No countdowns configured.");
}
Expand All @@ -142,7 +156,7 @@ impl StepHandler for CountdownCalc {
);
}

let output = render_countdowns(countdowns, today());
let output = render_countdowns(&countdowns, today());
StepResult::ok(&spec.name, &spec.step_type, output)
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/modulex-core/src/steps/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ mod tests {
generation: 1,
exec: gate_with(&granted, spawner),
prior: Vec::new(),
store: None,
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/modulex-core/src/steps/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ mod tests {
generation: 1,
exec: gate_with(&granted, spawner.clone()),
prior: Vec::new(),
store: None,
},
spawner,
)
Expand Down
1 change: 1 addition & 0 deletions crates/modulex-core/src/steps/gitlab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ mod tests {
generation: 1,
exec: gate_with(&granted, spawner.clone()),
prior: Vec::new(),
store: None,
},
spawner,
)
Expand Down
Loading
Loading