memory: add audit log events
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
//! `MemoryDelete` tool for removing memory / knowledge records with audit logging.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::audit::{AuditStatus, RecordOperationAudit, append_record_operation, file_hash};
|
||||
use crate::tool::MemoryToolKind;
|
||||
use crate::workspace::WorkspaceLayout;
|
||||
|
||||
const DESCRIPTION: &str = "Delete an existing memory or knowledge record selected by `kind` + `slug`. \
|
||||
For `summary` omit `slug`; for the others `slug` is required. The delete is audited and cannot target \
|
||||
workflow or staging/log files.";
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct DeleteParams {
|
||||
/// Kind of record to delete.
|
||||
kind: MemoryToolKind,
|
||||
/// Slug. Required for everything except `summary`; forbidden for `summary`.
|
||||
#[serde(default)]
|
||||
slug: Option<String>,
|
||||
}
|
||||
|
||||
struct MemoryDeleteTool {
|
||||
layout: WorkspaceLayout,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for MemoryDeleteTool {
|
||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
let params: DeleteParams = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid MemoryDelete input: {e}")))?;
|
||||
let path = params
|
||||
.kind
|
||||
.resolve_path(&self.layout, params.slug.as_deref())?;
|
||||
let kind = params.kind.to_string();
|
||||
let slug = audit_slug(¶ms.kind, params.slug.as_deref());
|
||||
let before_hash = file_hash(&path).ok().flatten();
|
||||
if before_hash.is_none() {
|
||||
let reason = format!("record not found: {}", path.display());
|
||||
let _ = append_record_operation(
|
||||
&self.layout,
|
||||
RecordOperationAudit {
|
||||
op: "delete".to_string(),
|
||||
status: AuditStatus::Failed,
|
||||
kind,
|
||||
slug,
|
||||
path: path.display().to_string(),
|
||||
before_hash,
|
||||
after_hash: None,
|
||||
reason: Some(reason.clone()),
|
||||
},
|
||||
);
|
||||
return Err(ToolError::ExecutionFailed(reason));
|
||||
}
|
||||
|
||||
if let Err(err) = std::fs::remove_file(&path) {
|
||||
let reason = format!("failed to delete {}: {err}", path.display());
|
||||
let _ = append_record_operation(
|
||||
&self.layout,
|
||||
RecordOperationAudit {
|
||||
op: "delete".to_string(),
|
||||
status: AuditStatus::Failed,
|
||||
kind,
|
||||
slug,
|
||||
path: path.display().to_string(),
|
||||
before_hash,
|
||||
after_hash: None,
|
||||
reason: Some(reason.clone()),
|
||||
},
|
||||
);
|
||||
return Err(ToolError::ExecutionFailed(reason));
|
||||
}
|
||||
|
||||
let _ = append_record_operation(
|
||||
&self.layout,
|
||||
RecordOperationAudit {
|
||||
op: "delete".to_string(),
|
||||
status: AuditStatus::Success,
|
||||
kind,
|
||||
slug,
|
||||
path: path.display().to_string(),
|
||||
before_hash,
|
||||
after_hash: None,
|
||||
reason: None,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(ToolOutput {
|
||||
summary: format!("Deleted {}", path.display()),
|
||||
content: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_tool(layout: WorkspaceLayout) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(DeleteParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("MemoryDelete")
|
||||
.description(DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(MemoryDeleteTool {
|
||||
layout: layout.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
fn audit_slug(kind: &MemoryToolKind, slug: Option<&str>) -> String {
|
||||
match kind {
|
||||
MemoryToolKind::Summary => "summary".to_string(),
|
||||
_ => slug.unwrap_or("<missing>").to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_removes_file_and_audits() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
|
||||
std::fs::create_dir_all(layout.decisions_dir()).unwrap();
|
||||
let path = layout.decisions_dir().join("obsolete.md");
|
||||
let now = Utc::now().to_rfc3339();
|
||||
std::fs::write(
|
||||
&path,
|
||||
format!(
|
||||
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: open\n---\nold"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (_, tool) = delete_tool(layout.clone())();
|
||||
let out = tool
|
||||
.execute(r#"{"kind":"decision","slug":"obsolete"}"#)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(out.summary.contains("Deleted"));
|
||||
assert!(!path.exists());
|
||||
let log = std::fs::read_to_string(layout.audit_current_log_path()).unwrap();
|
||||
assert!(log.contains(r#""event":"record_operation""#));
|
||||
assert!(log.contains(r#""op":"delete""#));
|
||||
assert!(log.contains(r#""status":"success""#));
|
||||
}
|
||||
}
|
||||
+140
-20
@@ -12,6 +12,9 @@ use async_trait::async_trait;
|
||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::audit::{
|
||||
AuditStatus, RecordOperationAudit, append_record_operation, file_hash, hash_bytes,
|
||||
};
|
||||
use crate::linter::{LintReport, Linter, WriteMode};
|
||||
use crate::tool::MemoryToolKind;
|
||||
use crate::workspace::WorkspaceLayout;
|
||||
@@ -62,30 +65,94 @@ impl Tool for EditTool {
|
||||
let path = params
|
||||
.kind
|
||||
.resolve_path(&self.layout, params.slug.as_deref())?;
|
||||
let kind = params.kind.to_string();
|
||||
let slug = audit_slug(¶ms.kind, params.slug.as_deref());
|
||||
|
||||
let current_bytes = std::fs::read(&path).map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => ToolError::ExecutionFailed(format!(
|
||||
"record not found (use MemoryWrite to create): {}",
|
||||
path.display()
|
||||
)),
|
||||
_ => ToolError::ExecutionFailed(format!("read failed at {}: {e}", path.display())),
|
||||
})?;
|
||||
let current_text = std::str::from_utf8(¤t_bytes).map_err(|_| {
|
||||
ToolError::InvalidArgument(format!("file is not valid UTF-8: {}", path.display()))
|
||||
})?;
|
||||
let current_bytes = match std::fs::read(&path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
let reason = match e.kind() {
|
||||
std::io::ErrorKind::NotFound => format!(
|
||||
"record not found (use MemoryWrite to create): {}",
|
||||
path.display()
|
||||
),
|
||||
_ => format!("read failed at {}: {e}", path.display()),
|
||||
};
|
||||
let _ = append_record_operation(
|
||||
&self.layout,
|
||||
RecordOperationAudit {
|
||||
op: "edit".to_string(),
|
||||
status: AuditStatus::Failed,
|
||||
kind,
|
||||
slug,
|
||||
path: path.display().to_string(),
|
||||
before_hash: None,
|
||||
after_hash: None,
|
||||
reason: Some(reason.clone()),
|
||||
},
|
||||
);
|
||||
return Err(ToolError::ExecutionFailed(reason));
|
||||
}
|
||||
};
|
||||
let before_hash = Some(hash_bytes(¤t_bytes));
|
||||
let current_text = match std::str::from_utf8(¤t_bytes) {
|
||||
Ok(text) => text,
|
||||
Err(_) => {
|
||||
let reason = format!("file is not valid UTF-8: {}", path.display());
|
||||
let _ = append_record_operation(
|
||||
&self.layout,
|
||||
RecordOperationAudit {
|
||||
op: "edit".to_string(),
|
||||
status: AuditStatus::Failed,
|
||||
kind,
|
||||
slug,
|
||||
path: path.display().to_string(),
|
||||
before_hash,
|
||||
after_hash: None,
|
||||
reason: Some(reason.clone()),
|
||||
},
|
||||
);
|
||||
return Err(ToolError::InvalidArgument(reason));
|
||||
}
|
||||
};
|
||||
|
||||
let count = current_text.matches(¶ms.old_string).count();
|
||||
if count == 0 {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"old_string not found in {}",
|
||||
path.display()
|
||||
)));
|
||||
let reason = format!("old_string not found in {}", path.display());
|
||||
let _ = append_record_operation(
|
||||
&self.layout,
|
||||
RecordOperationAudit {
|
||||
op: "edit".to_string(),
|
||||
status: AuditStatus::Failed,
|
||||
kind,
|
||||
slug,
|
||||
path: path.display().to_string(),
|
||||
before_hash,
|
||||
after_hash: None,
|
||||
reason: Some(reason.clone()),
|
||||
},
|
||||
);
|
||||
return Err(ToolError::InvalidArgument(reason));
|
||||
}
|
||||
if !params.replace_all && count > 1 {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
let reason = format!(
|
||||
"old_string occurs {count} times in {}; pass replace_all: true or narrow the snippet",
|
||||
path.display()
|
||||
)));
|
||||
);
|
||||
let _ = append_record_operation(
|
||||
&self.layout,
|
||||
RecordOperationAudit {
|
||||
op: "edit".to_string(),
|
||||
status: AuditStatus::Failed,
|
||||
kind,
|
||||
slug,
|
||||
path: path.display().to_string(),
|
||||
before_hash,
|
||||
after_hash: None,
|
||||
reason: Some(reason.clone()),
|
||||
},
|
||||
);
|
||||
return Err(ToolError::InvalidArgument(reason));
|
||||
}
|
||||
|
||||
let new_text = if params.replace_all {
|
||||
@@ -97,12 +164,58 @@ impl Tool for EditTool {
|
||||
|
||||
let report = self.linter.lint(&path, &new_text, WriteMode::Update);
|
||||
if report.has_errors() {
|
||||
return Err(ToolError::InvalidArgument(format_report(&report)));
|
||||
let reason = format_report(&report);
|
||||
let _ = append_record_operation(
|
||||
&self.layout,
|
||||
RecordOperationAudit {
|
||||
op: "edit".to_string(),
|
||||
status: AuditStatus::Failed,
|
||||
kind,
|
||||
slug,
|
||||
path: path.display().to_string(),
|
||||
before_hash,
|
||||
after_hash: None,
|
||||
reason: Some(reason.clone()),
|
||||
},
|
||||
);
|
||||
return Err(ToolError::InvalidArgument(reason));
|
||||
}
|
||||
|
||||
std::fs::write(&path, new_text.as_bytes()).map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!("failed to write {}: {e}", path.display()))
|
||||
})?;
|
||||
if let Err(e) = std::fs::write(&path, new_text.as_bytes()) {
|
||||
let reason = format!("failed to write {}: {e}", path.display());
|
||||
let _ = append_record_operation(
|
||||
&self.layout,
|
||||
RecordOperationAudit {
|
||||
op: "edit".to_string(),
|
||||
status: AuditStatus::Failed,
|
||||
kind,
|
||||
slug,
|
||||
path: path.display().to_string(),
|
||||
before_hash,
|
||||
after_hash: None,
|
||||
reason: Some(reason.clone()),
|
||||
},
|
||||
);
|
||||
return Err(ToolError::ExecutionFailed(reason));
|
||||
}
|
||||
let after_hash = file_hash(&path).ok().flatten();
|
||||
let _ = append_record_operation(
|
||||
&self.layout,
|
||||
RecordOperationAudit {
|
||||
op: "edit".to_string(),
|
||||
status: AuditStatus::Success,
|
||||
kind,
|
||||
slug,
|
||||
path: path.display().to_string(),
|
||||
before_hash,
|
||||
after_hash,
|
||||
reason: if report.warnings.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(format!("{} warning(s)", report.warnings.len()))
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let summary = format!(
|
||||
"Edited {} ({} replacement{}){}",
|
||||
@@ -118,6 +231,13 @@ impl Tool for EditTool {
|
||||
}
|
||||
}
|
||||
|
||||
fn audit_slug(kind: &MemoryToolKind, slug: Option<&str>) -> String {
|
||||
match kind {
|
||||
MemoryToolKind::Summary => "summary".to_string(),
|
||||
_ => slug.unwrap_or("<missing>").to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_report(report: &LintReport) -> String {
|
||||
use std::fmt::Write as _;
|
||||
let mut buf = String::from("memory linter rejected the edit:");
|
||||
|
||||
@@ -5,11 +5,14 @@
|
||||
//! to know the on-disk layout — Search returns `{slug, kind, ...}` and
|
||||
//! that pair feeds straight into Read / Edit.
|
||||
|
||||
mod delete;
|
||||
mod edit;
|
||||
mod query;
|
||||
mod read;
|
||||
mod write;
|
||||
|
||||
pub use delete::delete_tool;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use llm_worker::tool::ToolError;
|
||||
@@ -34,6 +37,17 @@ pub enum MemoryToolKind {
|
||||
Knowledge,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MemoryToolKind {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::Summary => "summary",
|
||||
Self::Decision => "decision",
|
||||
Self::Request => "request",
|
||||
Self::Knowledge => "knowledge",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl MemoryToolKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
//! enumerate what records exist without knowing what's inside them.
|
||||
//!
|
||||
//! - `MemoryQuery` walks `.insomnia/memory/{summary.md,decisions/,
|
||||
//! requests/}`. `.insomnia/workflow/` and `.insomnia/memory/_staging/`
|
||||
//! are excluded by construction.
|
||||
//! requests/}`. `.insomnia/workflow/`, `.insomnia/memory/_staging/`,
|
||||
//! `.insomnia/memory/_usage/`, and `.insomnia/memory/_logs/` are excluded
|
||||
//! by construction.
|
||||
//! - `KnowledgeQuery` walks `.insomnia/knowledge/*.md` and supports a
|
||||
//! `kind` filter against the Knowledge frontmatter's `kind` field.
|
||||
//!
|
||||
@@ -23,6 +24,7 @@ use async_trait::async_trait;
|
||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::audit::{AuditStatus, RecordUsageAudit, append_record_usage};
|
||||
use crate::schema::{KnowledgeFrontmatter, split_frontmatter};
|
||||
use crate::workspace::WorkspaceLayout;
|
||||
|
||||
@@ -128,7 +130,25 @@ impl Tool for MemoryQueryTool {
|
||||
let params: MemoryQueryParams = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid MemoryQuery input: {e}")))?;
|
||||
let needle = match params.query.as_deref() {
|
||||
Some(q) => Some(validate_query(q)?),
|
||||
Some(q) => match validate_query(q) {
|
||||
Ok(q) => Some(q),
|
||||
Err(err) => {
|
||||
let _ = append_record_usage(
|
||||
&self.layout,
|
||||
RecordUsageAudit {
|
||||
op: "query".to_string(),
|
||||
status: AuditStatus::Failed,
|
||||
kind: "memory".to_string(),
|
||||
slug: None,
|
||||
path: None,
|
||||
query: params.query.clone(),
|
||||
result_count: None,
|
||||
reason: Some(err.to_string()),
|
||||
},
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
@@ -194,6 +214,23 @@ impl Tool for MemoryQueryTool {
|
||||
Some(q) => format!("{} hit(s) for {q:?}", records.len()),
|
||||
None => format!("{} record(s)", records.len()),
|
||||
};
|
||||
let _ = append_record_usage(
|
||||
&self.layout,
|
||||
RecordUsageAudit {
|
||||
op: "query".to_string(),
|
||||
status: AuditStatus::Success,
|
||||
kind: "memory".to_string(),
|
||||
slug: None,
|
||||
path: None,
|
||||
query: params.query.clone(),
|
||||
result_count: Some(records.len()),
|
||||
reason: if records.len() >= limit {
|
||||
Some("result_limit_reached".to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
},
|
||||
);
|
||||
Ok(ToolOutput {
|
||||
summary,
|
||||
content: Some(body),
|
||||
@@ -208,7 +245,25 @@ impl Tool for KnowledgeQueryTool {
|
||||
ToolError::InvalidArgument(format!("invalid KnowledgeQuery input: {e}"))
|
||||
})?;
|
||||
let needle = match params.query.as_deref() {
|
||||
Some(q) => Some(validate_query(q)?),
|
||||
Some(q) => match validate_query(q) {
|
||||
Ok(q) => Some(q),
|
||||
Err(err) => {
|
||||
let _ = append_record_usage(
|
||||
&self.layout,
|
||||
RecordUsageAudit {
|
||||
op: "query".to_string(),
|
||||
status: AuditStatus::Failed,
|
||||
kind: "knowledge".to_string(),
|
||||
slug: None,
|
||||
path: None,
|
||||
query: params.query.clone(),
|
||||
result_count: None,
|
||||
reason: Some(err.to_string()),
|
||||
},
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let kind_filter = params.kind.as_deref();
|
||||
@@ -272,6 +327,23 @@ impl Tool for KnowledgeQueryTool {
|
||||
Some(q) => format!("{} hit(s) for {q:?}", records.len()),
|
||||
None => format!("{} record(s)", records.len()),
|
||||
};
|
||||
let _ = append_record_usage(
|
||||
&self.layout,
|
||||
RecordUsageAudit {
|
||||
op: "query".to_string(),
|
||||
status: AuditStatus::Success,
|
||||
kind: "knowledge".to_string(),
|
||||
slug: None,
|
||||
path: None,
|
||||
query: params.query.clone(),
|
||||
result_count: Some(records.len()),
|
||||
reason: if records.len() >= limit {
|
||||
Some("result_limit_reached".to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
},
|
||||
);
|
||||
Ok(ToolOutput {
|
||||
summary,
|
||||
content: Some(body),
|
||||
|
||||
@@ -11,6 +11,7 @@ use async_trait::async_trait;
|
||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::audit::{AuditStatus, RecordUsageAudit, append_record_usage};
|
||||
use crate::tool::MemoryToolKind;
|
||||
use crate::usage::{self, UsageSource};
|
||||
use crate::workspace::WorkspaceLayout;
|
||||
@@ -51,13 +52,32 @@ impl Tool for ReadTool {
|
||||
let path = params
|
||||
.kind
|
||||
.resolve_path(&self.layout, params.slug.as_deref())?;
|
||||
let kind = params.kind.to_string();
|
||||
let slug = audit_slug(¶ms.kind, params.slug.as_deref());
|
||||
|
||||
let bytes = std::fs::read(&path).map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => {
|
||||
ToolError::ExecutionFailed(format!("record not found: {}", path.display()))
|
||||
let bytes = match std::fs::read(&path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
let reason = match e.kind() {
|
||||
std::io::ErrorKind::NotFound => format!("record not found: {}", path.display()),
|
||||
_ => format!("read failed at {}: {e}", path.display()),
|
||||
};
|
||||
let _ = append_record_usage(
|
||||
&self.layout,
|
||||
RecordUsageAudit {
|
||||
op: "read".to_string(),
|
||||
status: AuditStatus::Failed,
|
||||
kind,
|
||||
slug: Some(slug),
|
||||
path: Some(path.display().to_string()),
|
||||
query: None,
|
||||
result_count: None,
|
||||
reason: Some(reason.clone()),
|
||||
},
|
||||
);
|
||||
return Err(ToolError::ExecutionFailed(reason));
|
||||
}
|
||||
_ => ToolError::ExecutionFailed(format!("read failed at {}: {e}", path.display())),
|
||||
})?;
|
||||
};
|
||||
|
||||
let text = String::from_utf8_lossy(&bytes).into_owned();
|
||||
if let Some(segment_id) = self.usage_session_id.as_deref() {
|
||||
@@ -97,6 +117,24 @@ impl Tool for ReadTool {
|
||||
)
|
||||
};
|
||||
|
||||
let _ = append_record_usage(
|
||||
&self.layout,
|
||||
RecordUsageAudit {
|
||||
op: "read".to_string(),
|
||||
status: AuditStatus::Success,
|
||||
kind,
|
||||
slug: Some(slug),
|
||||
path: Some(path.display().to_string()),
|
||||
query: None,
|
||||
result_count: Some(rendered.line_count),
|
||||
reason: if rendered.truncated {
|
||||
Some("truncated".to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
Ok(ToolOutput {
|
||||
summary,
|
||||
content: Some(rendered.body),
|
||||
@@ -104,6 +142,13 @@ impl Tool for ReadTool {
|
||||
}
|
||||
}
|
||||
|
||||
fn audit_slug(kind: &MemoryToolKind, slug: Option<&str>) -> String {
|
||||
match kind {
|
||||
MemoryToolKind::Summary => "summary".to_string(),
|
||||
_ => slug.unwrap_or("<missing>").to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
struct Rendered {
|
||||
body: String,
|
||||
line_count: usize,
|
||||
|
||||
@@ -12,6 +12,9 @@ use async_trait::async_trait;
|
||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::audit::{
|
||||
AuditStatus, RecordOperationAudit, append_record_operation, file_hash, hash_bytes,
|
||||
};
|
||||
use crate::linter::{LintReport, Linter, WriteMode};
|
||||
use crate::tool::MemoryToolKind;
|
||||
use crate::workspace::WorkspaceLayout;
|
||||
@@ -46,8 +49,11 @@ impl Tool for WriteTool {
|
||||
let path = params
|
||||
.kind
|
||||
.resolve_path(&self.layout, params.slug.as_deref())?;
|
||||
let kind = params.kind.to_string();
|
||||
let slug = audit_slug(¶ms.kind, params.slug.as_deref());
|
||||
|
||||
let already_exists = path.exists();
|
||||
let before_hash = file_hash(&path).ok().flatten();
|
||||
let already_exists = before_hash.is_some();
|
||||
let mode = if already_exists {
|
||||
WriteMode::Update
|
||||
} else {
|
||||
@@ -56,20 +62,77 @@ impl Tool for WriteTool {
|
||||
|
||||
let report = self.linter.lint(&path, ¶ms.content, mode);
|
||||
if report.has_errors() {
|
||||
return Err(ToolError::InvalidArgument(format_report(&report)));
|
||||
let reason = format_report(&report);
|
||||
let _ = append_record_operation(
|
||||
&self.layout,
|
||||
RecordOperationAudit {
|
||||
op: "write".to_string(),
|
||||
status: AuditStatus::Failed,
|
||||
kind,
|
||||
slug,
|
||||
path: path.display().to_string(),
|
||||
before_hash,
|
||||
after_hash: None,
|
||||
reason: Some(reason.clone()),
|
||||
},
|
||||
);
|
||||
return Err(ToolError::InvalidArgument(reason));
|
||||
}
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"failed to create directory {}: {e}",
|
||||
parent.display()
|
||||
))
|
||||
})?;
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
let reason = format!("failed to create directory {}: {e}", parent.display());
|
||||
let _ = append_record_operation(
|
||||
&self.layout,
|
||||
RecordOperationAudit {
|
||||
op: "write".to_string(),
|
||||
status: AuditStatus::Failed,
|
||||
kind,
|
||||
slug,
|
||||
path: path.display().to_string(),
|
||||
before_hash,
|
||||
after_hash: None,
|
||||
reason: Some(reason.clone()),
|
||||
},
|
||||
);
|
||||
return Err(ToolError::ExecutionFailed(reason));
|
||||
}
|
||||
}
|
||||
std::fs::write(&path, params.content.as_bytes()).map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!("failed to write {}: {e}", path.display()))
|
||||
})?;
|
||||
if let Err(e) = std::fs::write(&path, params.content.as_bytes()) {
|
||||
let reason = format!("failed to write {}: {e}", path.display());
|
||||
let _ = append_record_operation(
|
||||
&self.layout,
|
||||
RecordOperationAudit {
|
||||
op: "write".to_string(),
|
||||
status: AuditStatus::Failed,
|
||||
kind,
|
||||
slug,
|
||||
path: path.display().to_string(),
|
||||
before_hash,
|
||||
after_hash: None,
|
||||
reason: Some(reason.clone()),
|
||||
},
|
||||
);
|
||||
return Err(ToolError::ExecutionFailed(reason));
|
||||
}
|
||||
let after_hash = Some(hash_bytes(params.content.as_bytes()));
|
||||
let _ = append_record_operation(
|
||||
&self.layout,
|
||||
RecordOperationAudit {
|
||||
op: "write".to_string(),
|
||||
status: AuditStatus::Success,
|
||||
kind,
|
||||
slug,
|
||||
path: path.display().to_string(),
|
||||
before_hash,
|
||||
after_hash,
|
||||
reason: if report.warnings.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(format!("{} warning(s)", report.warnings.len()))
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let summary = format!(
|
||||
"{} {}{}",
|
||||
@@ -88,6 +151,13 @@ impl Tool for WriteTool {
|
||||
}
|
||||
}
|
||||
|
||||
fn audit_slug(kind: &MemoryToolKind, slug: Option<&str>) -> String {
|
||||
match kind {
|
||||
MemoryToolKind::Summary => "summary".to_string(),
|
||||
_ => slug.unwrap_or("<missing>").to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_report(report: &LintReport) -> String {
|
||||
use std::fmt::Write as _;
|
||||
let mut buf = String::from("memory linter rejected the write:");
|
||||
|
||||
Reference in New Issue
Block a user