feat(pro): implement all 3 commercial phases
Phase 1 — Cost Intelligence: - budget.rs: per-project token budgets, soft/hard limits, usage logging - router.rs: 3 preset profiles (CostSaver/QualityFirst/Balanced) Phase 2 — Knowledge Base: - memory.rs: persistent agent memory with FTS5, auto-extraction, TTL - symbols.rs: regex-based symbol graph (tree-sitter stub) Phase 3 — Git Integration: - git_context.rs: branch/commit/modified file context injection - branch_policy.rs: session-level branch protection 6 modules, 32 cargo tests, 22+ Tauri plugin commands.
This commit is contained in:
parent
3798bedc4d
commit
191b869b43
7 changed files with 1509 additions and 0 deletions
208
agor-pro/src/branch_policy.rs
Normal file
208
agor-pro/src/branch_policy.rs
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
// SPDX-License-Identifier: LicenseRef-Commercial
|
||||
// Branch Policy Enforcement — block agent sessions on protected branches.
|
||||
|
||||
use rusqlite::params;
|
||||
use serde::Serialize;
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BranchPolicy {
|
||||
pub id: i64,
|
||||
pub pattern: String,
|
||||
pub action: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PolicyDecision {
|
||||
pub allowed: bool,
|
||||
pub branch: String,
|
||||
pub matched_policy: Option<BranchPolicy>,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
fn ensure_tables(conn: &rusqlite::Connection) -> Result<(), String> {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS pro_branch_policies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
pattern TEXT NOT NULL,
|
||||
action TEXT NOT NULL DEFAULT 'block',
|
||||
reason TEXT NOT NULL DEFAULT ''
|
||||
);"
|
||||
).map_err(|e| format!("Failed to create branch_policies table: {e}"))?;
|
||||
|
||||
// Seed default policies if table is empty
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM pro_branch_policies", [], |row| row.get(0)
|
||||
).unwrap_or(0);
|
||||
|
||||
if count == 0 {
|
||||
conn.execute_batch(
|
||||
"INSERT INTO pro_branch_policies (pattern, action, reason) VALUES
|
||||
('main', 'block', 'Protected branch: direct work on main is not allowed'),
|
||||
('master', 'block', 'Protected branch: direct work on master is not allowed'),
|
||||
('release/*', 'block', 'Protected branch: release branches require PRs');"
|
||||
).map_err(|e| format!("Failed to seed default policies: {e}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Simple glob matching: supports `*` at the end of a pattern (e.g., `release/*`).
|
||||
fn glob_match(pattern: &str, value: &str) -> bool {
|
||||
if pattern == value {
|
||||
return true;
|
||||
}
|
||||
if let Some(prefix) = pattern.strip_suffix('*') {
|
||||
return value.starts_with(prefix);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn get_current_branch(project_path: &str) -> Result<String, String> {
|
||||
let output = Command::new("git")
|
||||
.args(["-C", project_path, "branch", "--show-current"])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git: {e}"))?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
} else {
|
||||
Err("Not a git repository or git not available".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn pro_branch_check(project_path: String) -> Result<PolicyDecision, String> {
|
||||
let conn = super::open_sessions_db()?;
|
||||
ensure_tables(&conn)?;
|
||||
|
||||
let branch = get_current_branch(&project_path)?;
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, pattern, action, reason FROM pro_branch_policies"
|
||||
).map_err(|e| format!("Query failed: {e}"))?;
|
||||
|
||||
let policies: Vec<BranchPolicy> = stmt.query_map([], |row| {
|
||||
Ok(BranchPolicy {
|
||||
id: row.get(0)?,
|
||||
pattern: row.get(1)?,
|
||||
action: row.get(2)?,
|
||||
reason: row.get(3)?,
|
||||
})
|
||||
}).map_err(|e| format!("Query failed: {e}"))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| format!("Row read failed: {e}"))?;
|
||||
|
||||
for policy in &policies {
|
||||
if glob_match(&policy.pattern, &branch) {
|
||||
let allowed = policy.action != "block";
|
||||
return Ok(PolicyDecision {
|
||||
allowed,
|
||||
branch: branch.clone(),
|
||||
matched_policy: Some(policy.clone()),
|
||||
reason: policy.reason.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(PolicyDecision {
|
||||
allowed: true,
|
||||
branch,
|
||||
matched_policy: None,
|
||||
reason: "No matching policy".into(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn pro_branch_policy_list() -> Result<Vec<BranchPolicy>, String> {
|
||||
let conn = super::open_sessions_db()?;
|
||||
ensure_tables(&conn)?;
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, pattern, action, reason FROM pro_branch_policies ORDER BY id"
|
||||
).map_err(|e| format!("Query failed: {e}"))?;
|
||||
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(BranchPolicy {
|
||||
id: row.get(0)?,
|
||||
pattern: row.get(1)?,
|
||||
action: row.get(2)?,
|
||||
reason: row.get(3)?,
|
||||
})
|
||||
}).map_err(|e| format!("Query failed: {e}"))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| format!("Row read failed: {e}"))?;
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn pro_branch_policy_add(pattern: String, action: Option<String>, reason: Option<String>) -> Result<i64, String> {
|
||||
let conn = super::open_sessions_db()?;
|
||||
ensure_tables(&conn)?;
|
||||
let act = action.unwrap_or_else(|| "block".into());
|
||||
let rsn = reason.unwrap_or_default();
|
||||
conn.execute(
|
||||
"INSERT INTO pro_branch_policies (pattern, action, reason) VALUES (?1, ?2, ?3)",
|
||||
params![pattern, act, rsn],
|
||||
).map_err(|e| format!("Failed to add policy: {e}"))?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn pro_branch_policy_remove(id: i64) -> Result<(), String> {
|
||||
let conn = super::open_sessions_db()?;
|
||||
ensure_tables(&conn)?;
|
||||
conn.execute("DELETE FROM pro_branch_policies WHERE id = ?1", params![id])
|
||||
.map_err(|e| format!("Failed to remove policy: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_policy_decision_serializes_camel_case() {
|
||||
let d = PolicyDecision {
|
||||
allowed: false,
|
||||
branch: "main".into(),
|
||||
matched_policy: Some(BranchPolicy {
|
||||
id: 1,
|
||||
pattern: "main".into(),
|
||||
action: "block".into(),
|
||||
reason: "Protected".into(),
|
||||
}),
|
||||
reason: "Protected".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&d).unwrap();
|
||||
assert!(json.contains("matchedPolicy"));
|
||||
assert!(json.contains("\"allowed\":false"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_branch_policy_serializes_camel_case() {
|
||||
let p = BranchPolicy {
|
||||
id: 1,
|
||||
pattern: "release/*".into(),
|
||||
action: "block".into(),
|
||||
reason: "No direct commits".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&p).unwrap();
|
||||
assert!(json.contains("\"pattern\":\"release/*\""));
|
||||
assert!(json.contains("\"action\":\"block\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_glob_match() {
|
||||
assert!(glob_match("main", "main"));
|
||||
assert!(!glob_match("main", "main2"));
|
||||
assert!(glob_match("release/*", "release/v1.0"));
|
||||
assert!(glob_match("release/*", "release/hotfix"));
|
||||
assert!(!glob_match("release/*", "feature/test"));
|
||||
assert!(!glob_match("master", "main"));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue