refactor: remove repository-local yoi authority
This commit is contained in:
@@ -7,19 +7,12 @@ license.workspace = true
|
||||
[dependencies]
|
||||
async-trait = { workspace = true }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
libc = { workspace = true }
|
||||
lint-common = { workspace = true }
|
||||
agen = { workspace = true }
|
||||
manifest = { workspace = true }
|
||||
schemars = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
serde_yaml = "0.9.34"
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
uuid = { workspace = true, features = ["v7", "serde"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
|
||||
+9
-17
@@ -2,28 +2,20 @@
|
||||
|
||||
## Role
|
||||
|
||||
`memory` owns generated memory, records, staging/consolidation mechanics, linting, and audit observations.
|
||||
`memory` owns the shared types used by Workspace-backed Memory operations:
|
||||
|
||||
- Memory API request/response DTOs
|
||||
- extraction candidate and evidence schemas
|
||||
- audit event schemas
|
||||
- pure Memory document frontmatter schemas and parsing helpers
|
||||
|
||||
## Boundaries
|
||||
|
||||
Owns:
|
||||
The crate does **not** own persistence. Workspace Memory records, staging candidates, and audit observations are stored by the Workspace Server control plane and accessed by Workers through typed API operations.
|
||||
|
||||
- memory/record parsing and validation
|
||||
- memory lint subcommand backend behavior
|
||||
- staging and consolidation file mechanics
|
||||
- audit log observation writes
|
||||
- generated memory prompt support where it belongs below the CLI
|
||||
The crate intentionally contains no repository-local layout, cwd/ancestor discovery, resident-file reader, staging writer, audit/usage log writer, generic filesystem scope helper, or local backend executor. The product CLI does not expose `yoi memory lint`.
|
||||
|
||||
Does not own:
|
||||
|
||||
- authoritative project records (`.yoi/tickets/`, git history)
|
||||
- normal Worker turn orchestration (`agen`)
|
||||
- product CLI command shape (`yoi`)
|
||||
- curated workflow definitions (`workflow`)
|
||||
|
||||
## Design notes
|
||||
|
||||
Memory is useful context, not authority. It should preserve small durable preferences and rationale without duplicating tickets, docs, or implementation reports.
|
||||
Memory is useful context, not implementation authority. Tickets, Objectives, repository files, Git history, and append-only session records remain the exact evidence surfaces for implementation state.
|
||||
|
||||
## See also
|
||||
|
||||
|
||||
+3
-214
@@ -1,26 +1,12 @@
|
||||
//! Append-only JSONL audit log for memory workers and tools.
|
||||
//! Typed Memory audit events sent to the Workspace authority.
|
||||
//!
|
||||
//! The log is evidence-only observability data under
|
||||
//! `.yoi/memory/_logs/current.log`. It is intentionally separate from
|
||||
//! `_staging` and `_usage`, and consolidation never consumes it. Operators can
|
||||
//! follow the latest stream with:
|
||||
//!
|
||||
//! ```text
|
||||
//! tail -f .yoi/memory/_logs/current.log
|
||||
//! ```
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
//! Runtime Workers construct these DTOs and submit them through the Memory
|
||||
//! backend operation boundary. Repository-local audit logs are not an authority.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::workspace::WorkspaceLayout;
|
||||
|
||||
fn is_zero_usize(value: &usize) -> bool {
|
||||
*value == 0
|
||||
}
|
||||
@@ -260,200 +246,3 @@ impl AuditEvent {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RecordSnapshot {
|
||||
pub kind: String,
|
||||
pub slug: String,
|
||||
pub path: PathBuf,
|
||||
pub hash: String,
|
||||
}
|
||||
|
||||
/// Append one audit event to `.yoi/memory/_logs/current.log`.
|
||||
pub fn append_audit_event(layout: &WorkspaceLayout, event: &AuditEvent) -> io::Result<()> {
|
||||
let path = layout.audit_current_log_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let line = serde_json::to_string(event)
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
|
||||
let mut file = OpenOptions::new().create(true).append(true).open(path)?;
|
||||
writeln!(file, "{line}")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn append_worker_lifecycle(
|
||||
layout: &WorkspaceLayout,
|
||||
audit: WorkerLifecycleAudit,
|
||||
) -> io::Result<()> {
|
||||
append_audit_event(
|
||||
layout,
|
||||
&AuditEvent::new(AuditPayload::WorkerLifecycle(audit)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn append_record_operation(
|
||||
layout: &WorkspaceLayout,
|
||||
audit: RecordOperationAudit,
|
||||
) -> io::Result<()> {
|
||||
append_audit_event(
|
||||
layout,
|
||||
&AuditEvent::new(AuditPayload::RecordOperation(audit)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn append_record_usage(layout: &WorkspaceLayout, audit: RecordUsageAudit) -> io::Result<()> {
|
||||
append_audit_event(layout, &AuditEvent::new(AuditPayload::RecordUsage(audit)))
|
||||
}
|
||||
|
||||
pub fn file_hash(path: &Path) -> io::Result<Option<String>> {
|
||||
match fs::read(path) {
|
||||
Ok(bytes) => Ok(Some(hash_bytes(&bytes))),
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hash_bytes(bytes: &[u8]) -> String {
|
||||
let digest = Sha256::digest(bytes);
|
||||
let mut out = String::with_capacity("sha256:".len() + digest.len() * 2);
|
||||
out.push_str("sha256:");
|
||||
for byte in digest {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(&mut out, "{byte:02x}");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn snapshot_records(layout: &WorkspaceLayout) -> BTreeMap<String, RecordSnapshot> {
|
||||
let mut out = BTreeMap::new();
|
||||
snapshot_one(&mut out, "summary", "summary", layout.summary_path());
|
||||
snapshot_dir(&mut out, "decision", layout.decisions_dir());
|
||||
snapshot_dir(&mut out, "request", layout.requests_dir());
|
||||
out
|
||||
}
|
||||
|
||||
pub fn operation_counts_from_snapshots(
|
||||
before: &BTreeMap<String, RecordSnapshot>,
|
||||
after: &BTreeMap<String, RecordSnapshot>,
|
||||
) -> OperationCounts {
|
||||
let mut counts = OperationCounts::default();
|
||||
for (key, after_record) in after {
|
||||
match before.get(key) {
|
||||
None => counts.write += 1,
|
||||
Some(before_record) if before_record.hash != after_record.hash => counts.edit += 1,
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
for key in before.keys() {
|
||||
if !after.contains_key(key) {
|
||||
counts.delete += 1;
|
||||
}
|
||||
}
|
||||
counts
|
||||
}
|
||||
|
||||
fn snapshot_dir(out: &mut BTreeMap<String, RecordSnapshot>, kind: &str, dir: PathBuf) {
|
||||
let entries = match fs::read_dir(dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(slug) = name.strip_suffix(".md").map(str::to_string) else {
|
||||
continue;
|
||||
};
|
||||
snapshot_one(out, kind, &slug, path);
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_one(out: &mut BTreeMap<String, RecordSnapshot>, kind: &str, slug: &str, path: PathBuf) {
|
||||
if !path.is_file() {
|
||||
return;
|
||||
}
|
||||
let Ok(Some(hash)) = file_hash(&path) else {
|
||||
return;
|
||||
};
|
||||
out.insert(
|
||||
format!("{kind}/{slug}"),
|
||||
RecordSnapshot {
|
||||
kind: kind.to_string(),
|
||||
slug: slug.to_string(),
|
||||
path,
|
||||
hash,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (TempDir, WorkspaceLayout) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
|
||||
(dir, layout)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appends_jsonl_to_current_log() {
|
||||
let (_dir, layout) = setup();
|
||||
let run_id = Uuid::now_v7();
|
||||
append_worker_lifecycle(
|
||||
&layout,
|
||||
WorkerLifecycleAudit {
|
||||
run_id,
|
||||
worker: AuditWorker::MemoryExtract,
|
||||
status: WorkerLifecycleStatus::Started,
|
||||
trigger: AuditTrigger::TokenThreshold,
|
||||
reason: "tokens_threshold_reached".to_string(),
|
||||
memory_settings: None,
|
||||
model: None,
|
||||
usage: None,
|
||||
extract: None,
|
||||
consolidation: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let text = fs::read_to_string(layout.audit_current_log_path()).unwrap();
|
||||
let value: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
|
||||
assert_eq!(value["event"], "worker_lifecycle");
|
||||
assert_eq!(value["worker"], "memory_extract");
|
||||
assert_eq!(value["status"], "started");
|
||||
assert_eq!(value["run_id"], run_id.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_created_edited_deleted_records() {
|
||||
let (dir, layout) = setup();
|
||||
let decision_dir = dir.path().join(".yoi/memory/decisions");
|
||||
fs::create_dir_all(&decision_dir).unwrap();
|
||||
fs::write(decision_dir.join("a.md"), "old").unwrap();
|
||||
fs::write(decision_dir.join("gone.md"), "old").unwrap();
|
||||
let before = snapshot_records(&layout);
|
||||
|
||||
fs::write(decision_dir.join("a.md"), "new").unwrap();
|
||||
fs::remove_file(decision_dir.join("gone.md")).unwrap();
|
||||
fs::write(decision_dir.join("created.md"), "new").unwrap();
|
||||
let after = snapshot_records(&layout);
|
||||
|
||||
let counts = operation_counts_from_snapshots(&before, &after);
|
||||
assert_eq!(counts.write, 1);
|
||||
assert_eq!(counts.edit, 1);
|
||||
assert_eq!(counts.delete, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_has_sha256_prefix() {
|
||||
assert_eq!(hash_bytes(b"abc").len(), "sha256:".len() + 64);
|
||||
assert!(hash_bytes(b"abc").starts_with("sha256:"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,15 @@
|
||||
//! Workspace-backend operations for Memory tools.
|
||||
//! Typed transport boundary for Workspace-backed Memory operations.
|
||||
//!
|
||||
//! These types are the typed HTTP boundary used by runtime workers that have
|
||||
//! Workspace authority but no direct local filesystem authority. The local
|
||||
//! executor intentionally lives in the memory crate so the workspace backend and
|
||||
//! future non-HTTP hosts share validation and path semantics.
|
||||
//! Runtime Workers serialize these DTOs to the Workspace API. Persistence and
|
||||
//! validation are owned by the Workspace authority; this crate deliberately has
|
||||
//! no repository-local filesystem executor.
|
||||
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::io::Write;
|
||||
|
||||
use chrono::Utc;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::audit::{AuditEvent, append_audit_event};
|
||||
use crate::consolidate::list_staging_entries_snapshot;
|
||||
use crate::extract::{
|
||||
ExtractedCandidate, ExtractedPayload, StagingEvidence, write_staging, write_staging_candidate,
|
||||
};
|
||||
use crate::audit::AuditEvent;
|
||||
use crate::extract::{ExtractedCandidate, ExtractedPayload, StagingEvidence};
|
||||
use crate::schema::{SourceEvidenceRef, SourceRef};
|
||||
use crate::workspace::WorkspaceLayout;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "operation", rename_all = "snake_case")]
|
||||
@@ -166,19 +155,6 @@ pub struct MemoryConsolidationOutput {
|
||||
pub total_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct MemoryStagingCloseDispositionRecord {
|
||||
schema_version: u32,
|
||||
candidate_id: String,
|
||||
staging_path: String,
|
||||
recorded_at: String,
|
||||
action: MemoryStagingCloseAction,
|
||||
reason: String,
|
||||
affected_memory: Vec<MemoryStagingAffectedMemory>,
|
||||
}
|
||||
|
||||
const STAGING_RESOLUTIONS_FILE: &str = "_resolutions.jsonl";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MemoryBackendAckOutput {
|
||||
pub summary: String,
|
||||
@@ -189,345 +165,3 @@ pub struct MemoryStagingWriteOutput {
|
||||
pub staging_count: usize,
|
||||
pub staging_ids: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn execute_memory_backend_operation(
|
||||
layout: &WorkspaceLayout,
|
||||
operation: MemoryBackendOperation,
|
||||
) -> io::Result<MemoryBackendOperationResult> {
|
||||
match operation {
|
||||
MemoryBackendOperation::Query(operation) => Ok(MemoryBackendOperationResult::ToolOutput(
|
||||
execute_query(layout, operation),
|
||||
)),
|
||||
MemoryBackendOperation::ReadDocument(_operation) => Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"Memory document operations require WorkspaceAuthority-backed executor",
|
||||
)),
|
||||
MemoryBackendOperation::UpdateDocument(_operation) => Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"Memory document operations require WorkspaceAuthority-backed executor",
|
||||
)),
|
||||
MemoryBackendOperation::ResidentSummary(_operation) => Ok(
|
||||
MemoryBackendOperationResult::ToolOutput(execute_resident_summary(layout)),
|
||||
),
|
||||
MemoryBackendOperation::AppendAudit(operation) => {
|
||||
append_audit_event(layout, &operation.event)?;
|
||||
Ok(MemoryBackendOperationResult::Acknowledged(
|
||||
MemoryBackendAckOutput {
|
||||
summary: "memory audit event appended".to_string(),
|
||||
},
|
||||
))
|
||||
}
|
||||
MemoryBackendOperation::StageCandidate(operation) => {
|
||||
let written = write_staging_candidate(
|
||||
layout,
|
||||
operation.source,
|
||||
&operation.extract_run_id,
|
||||
operation.candidate,
|
||||
operation.evidence,
|
||||
operation.source_refs,
|
||||
)?;
|
||||
Ok(MemoryBackendOperationResult::StagingWritten(
|
||||
MemoryStagingWriteOutput {
|
||||
staging_count: 1,
|
||||
staging_ids: vec![written.id.to_string()],
|
||||
},
|
||||
))
|
||||
}
|
||||
MemoryBackendOperation::StageExtracted(operation) => {
|
||||
let written = write_staging(layout, operation.source, operation.payload)?;
|
||||
Ok(MemoryBackendOperationResult::StagingWritten(
|
||||
MemoryStagingWriteOutput {
|
||||
staging_count: written.len(),
|
||||
staging_ids: written.iter().map(|item| item.id.to_string()).collect(),
|
||||
},
|
||||
))
|
||||
}
|
||||
MemoryBackendOperation::StagingList(operation) => {
|
||||
execute_staging_list(layout, operation).map(MemoryBackendOperationResult::ToolOutput)
|
||||
}
|
||||
MemoryBackendOperation::StagingRead(operation) => {
|
||||
execute_staging_read(layout, operation).map(MemoryBackendOperationResult::ToolOutput)
|
||||
}
|
||||
MemoryBackendOperation::StagingClose(operation) => {
|
||||
execute_staging_close(layout, operation).map(MemoryBackendOperationResult::ToolOutput)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_resident_summary(layout: &WorkspaceLayout) -> MemoryToolOutput {
|
||||
match crate::collect_resident_summary(layout) {
|
||||
Some(summary) => MemoryToolOutput {
|
||||
summary: "resident memory summary collected".to_string(),
|
||||
content: Some(summary),
|
||||
},
|
||||
None => MemoryToolOutput {
|
||||
summary: "resident memory summary unavailable".to_string(),
|
||||
content: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_query(layout: &WorkspaceLayout, operation: MemoryQueryOperation) -> MemoryToolOutput {
|
||||
let Some(document) = crate::collect_resident_summary(layout) else {
|
||||
let suffix = operation
|
||||
.query
|
||||
.as_deref()
|
||||
.map(|query| format!(" matching {query:?}"))
|
||||
.unwrap_or_default();
|
||||
return MemoryToolOutput {
|
||||
summary: format!("No memory document content found{suffix}"),
|
||||
content: None,
|
||||
};
|
||||
};
|
||||
if !matches_query(&document, operation.query.as_deref()) {
|
||||
let suffix = operation
|
||||
.query
|
||||
.as_deref()
|
||||
.map(|query| format!(" matching {query:?}"))
|
||||
.unwrap_or_default();
|
||||
return MemoryToolOutput {
|
||||
summary: format!("No memory document content found{suffix}"),
|
||||
content: None,
|
||||
};
|
||||
}
|
||||
tool_output_from_string(excerpt(&document, operation.query.as_deref()))
|
||||
}
|
||||
|
||||
fn execute_staging_list(
|
||||
layout: &WorkspaceLayout,
|
||||
operation: MemoryStagingListOperation,
|
||||
) -> io::Result<MemoryToolOutput> {
|
||||
let limit = operation.limit.unwrap_or(20).min(100);
|
||||
let snapshot = list_staging_entries_snapshot(layout);
|
||||
let total = snapshot.entries.len();
|
||||
let invalid_count = snapshot.invalid_count;
|
||||
let records = snapshot
|
||||
.entries
|
||||
.into_iter()
|
||||
.take(limit)
|
||||
.map(|entry| {
|
||||
serde_json::json!({
|
||||
"candidate_id": entry.id.to_string(),
|
||||
"bytes": entry.bytes,
|
||||
"path": entry.path.display().to_string(),
|
||||
"source": entry.record.source,
|
||||
"kind": entry.record.kind,
|
||||
"claim": entry.record.claim,
|
||||
"why_useful": entry.record.why_useful,
|
||||
"staleness": entry.record.staleness,
|
||||
"evidence_count": entry.record.evidence.len(),
|
||||
"source_ref_count": entry.record.source_refs.len(),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(MemoryToolOutput {
|
||||
summary: format!(
|
||||
"Listed {} of {total} staging candidate(s); invalid_count={invalid_count}",
|
||||
records.len()
|
||||
),
|
||||
content: Some(serde_json::to_string_pretty(&records).map_err(io::Error::other)?),
|
||||
})
|
||||
}
|
||||
|
||||
fn execute_staging_read(
|
||||
layout: &WorkspaceLayout,
|
||||
operation: MemoryStagingReadOperation,
|
||||
) -> io::Result<MemoryToolOutput> {
|
||||
let entry = find_staging_entry(layout, &operation.candidate_id)?;
|
||||
Ok(MemoryToolOutput {
|
||||
summary: format!("Read staging candidate {}", entry.id),
|
||||
content: Some(serde_json::to_string_pretty(&entry.record).map_err(io::Error::other)?),
|
||||
})
|
||||
}
|
||||
|
||||
fn execute_staging_close(
|
||||
layout: &WorkspaceLayout,
|
||||
operation: MemoryStagingCloseOperation,
|
||||
) -> io::Result<MemoryToolOutput> {
|
||||
if operation.reason.trim().is_empty() {
|
||||
return Err(invalid_input("reason is required"));
|
||||
}
|
||||
let entry = find_staging_entry(layout, &operation.candidate_id)?;
|
||||
let disposition = MemoryStagingCloseDispositionRecord {
|
||||
schema_version: 1,
|
||||
candidate_id: entry.id.to_string(),
|
||||
staging_path: entry.path.display().to_string(),
|
||||
recorded_at: Utc::now().to_rfc3339(),
|
||||
action: operation.action,
|
||||
reason: operation.reason,
|
||||
affected_memory: operation.affected_memory,
|
||||
};
|
||||
let resolutions_path = layout.memory_dir().join(STAGING_RESOLUTIONS_FILE);
|
||||
if let Some(parent) = resolutions_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut line = serde_json::to_string(&disposition).map_err(io::Error::other)?;
|
||||
line.push('\n');
|
||||
fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&resolutions_path)?
|
||||
.write_all(line.as_bytes())?;
|
||||
fs::remove_file(&entry.path)?;
|
||||
Ok(MemoryToolOutput {
|
||||
summary: format!("Closed staging candidate {}", entry.id),
|
||||
content: Some(serde_json::to_string_pretty(&disposition).map_err(io::Error::other)?),
|
||||
})
|
||||
}
|
||||
|
||||
fn find_staging_entry(
|
||||
layout: &WorkspaceLayout,
|
||||
candidate_id: &str,
|
||||
) -> io::Result<crate::consolidate::StagingEntry> {
|
||||
let candidate_id = Uuid::parse_str(candidate_id)
|
||||
.map_err(|err| invalid_input(format!("invalid candidate_id: {err}")))?;
|
||||
list_staging_entries_snapshot(layout)
|
||||
.entries
|
||||
.into_iter()
|
||||
.find(|entry| entry.id == candidate_id)
|
||||
.ok_or_else(|| invalid_input(format!("staging candidate not found: {candidate_id}")))
|
||||
}
|
||||
|
||||
fn matches_query(content: &str, query: Option<&str>) -> bool {
|
||||
match query.map(str::trim).filter(|query| !query.is_empty()) {
|
||||
Some(query) => content.to_lowercase().contains(&query.to_lowercase()),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn excerpt(content: &str, query: Option<&str>) -> String {
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let Some(query) = query.map(str::trim).filter(|query| !query.is_empty()) else {
|
||||
return lines
|
||||
.iter()
|
||||
.take(20)
|
||||
.copied()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
};
|
||||
let query_lower = query.to_lowercase();
|
||||
let hit = lines
|
||||
.iter()
|
||||
.position(|line| line.to_lowercase().contains(&query_lower))
|
||||
.unwrap_or(0);
|
||||
let start = hit.saturating_sub(2);
|
||||
let end = (hit + 3).min(lines.len());
|
||||
lines[start..end].join("\n")
|
||||
}
|
||||
|
||||
fn tool_output_from_string(value: String) -> MemoryToolOutput {
|
||||
const SUMMARY_THRESHOLD: usize = 200;
|
||||
if value.len() <= SUMMARY_THRESHOLD {
|
||||
MemoryToolOutput {
|
||||
summary: value,
|
||||
content: None,
|
||||
}
|
||||
} else {
|
||||
let lines = value.lines().count();
|
||||
let first_line: String = value
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.chars()
|
||||
.take(80)
|
||||
.collect();
|
||||
MemoryToolOutput {
|
||||
summary: format!("{lines} lines | {first_line}…"),
|
||||
content: Some(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_input(message: impl Into<String>) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidInput, message.into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::extract::{CandidateKind, ExtractedCandidate};
|
||||
|
||||
#[test]
|
||||
fn consolidation_operation_rejects_caller_owned_thresholds() {
|
||||
let error =
|
||||
serde_json::from_value::<MemoryConsolidateStagingOperation>(serde_json::json!({
|
||||
"force": false,
|
||||
"threshold_files": 1,
|
||||
}))
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("threshold_files"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staging_list_read_close_records_reason_and_deletes_candidate() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let layout = WorkspaceLayout::resolve(temp.path());
|
||||
let source = SourceRef {
|
||||
segment_id: "segment-1".into(),
|
||||
range: [0, 1],
|
||||
};
|
||||
let payload = ExtractedPayload {
|
||||
candidates: vec![ExtractedCandidate {
|
||||
kind: CandidateKind::Preference,
|
||||
claim: "User prefers short reviews".into(),
|
||||
why_useful: "Review style preference".into(),
|
||||
staleness: None,
|
||||
evidence_ids: Vec::new(),
|
||||
}],
|
||||
};
|
||||
let result = execute_memory_backend_operation(
|
||||
&layout,
|
||||
MemoryBackendOperation::StageExtracted(MemoryStageExtractedOperation {
|
||||
source,
|
||||
payload,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let candidate_id = match result {
|
||||
MemoryBackendOperationResult::StagingWritten(output) => output.staging_ids[0].clone(),
|
||||
_ => panic!("expected staging write output"),
|
||||
};
|
||||
|
||||
let list = execute_memory_backend_operation(
|
||||
&layout,
|
||||
MemoryBackendOperation::StagingList(MemoryStagingListOperation { limit: Some(10) }),
|
||||
)
|
||||
.unwrap();
|
||||
let MemoryBackendOperationResult::ToolOutput(list) = list else {
|
||||
panic!("expected list tool output")
|
||||
};
|
||||
assert!(list.content.unwrap().contains(&candidate_id));
|
||||
|
||||
let read = execute_memory_backend_operation(
|
||||
&layout,
|
||||
MemoryBackendOperation::StagingRead(MemoryStagingReadOperation {
|
||||
candidate_id: candidate_id.clone(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let MemoryBackendOperationResult::ToolOutput(read) = read else {
|
||||
panic!("expected read tool output")
|
||||
};
|
||||
assert!(read.content.unwrap().contains("short reviews"));
|
||||
|
||||
execute_memory_backend_operation(
|
||||
&layout,
|
||||
MemoryBackendOperation::StagingClose(MemoryStagingCloseOperation {
|
||||
candidate_id: candidate_id.clone(),
|
||||
action: MemoryStagingCloseAction::Applied,
|
||||
reason: "Merged into the Memory document.".into(),
|
||||
affected_memory: vec![MemoryStagingAffectedMemory {
|
||||
operation: MemoryStagingAffectedMemoryOperation::Edit,
|
||||
}],
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let snapshot = list_staging_entries_snapshot(&layout);
|
||||
assert!(snapshot.entries.is_empty());
|
||||
let resolutions =
|
||||
fs::read_to_string(layout.memory_dir().join(STAGING_RESOLUTIONS_FILE)).unwrap();
|
||||
assert!(resolutions.contains(&candidate_id));
|
||||
assert!(resolutions.contains("Merged into the Memory document."));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
//! Memory staging queue helpers.
|
||||
//!
|
||||
//! Staging candidates are consumed by the backend-managed memory-consolidation
|
||||
//! Worker through MemoryStaging tools. This module only exposes bounded staging
|
||||
//! listing/read support; consolidation decisions are recorded by the backend close
|
||||
//! operation before a staging file is deleted.
|
||||
|
||||
mod staging;
|
||||
|
||||
pub use staging::{
|
||||
StagingEntriesSnapshot, StagingEntry, list_staging_entries, list_staging_entries_snapshot,
|
||||
};
|
||||
@@ -1,210 +0,0 @@
|
||||
//! `_staging/*.json` を列挙して [`StagingRecord`] に展開する読み込みヘルパー。
|
||||
//!
|
||||
//! consolidation 起動時のスナップショット(consumed ID list 確定)と、整理 step
|
||||
//! が終わった後の cleanup の双方で使う。`.consolidation.lock` のような
|
||||
//! 占有ファイルは UUIDv7 として parse できないので自然に除外される。
|
||||
//!
|
||||
//! [`StagingRecord`] のスキーマは extract が書き出す側 (`crate::extract`)
|
||||
//! と単一の真実源 — ここでは読み出す側だけを担当する。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::extract::StagingRecord;
|
||||
use crate::workspace::WorkspaceLayout;
|
||||
|
||||
/// staging に積まれている 1 件分のエントリ。`id` は UUIDv7 で、ファイル名
|
||||
/// `<id>.json` を逆引きしたもの。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StagingEntry {
|
||||
pub id: Uuid,
|
||||
pub path: PathBuf,
|
||||
pub record: StagingRecord,
|
||||
/// このファイルのバイト長。Backendのconsolidation閾値判定に使用する。
|
||||
pub bytes: u64,
|
||||
}
|
||||
|
||||
/// staging directory の検査結果。`entries` は current schema として読めた
|
||||
/// staging のみで、`invalid_count` は `.json` だが staging として採用できなかった
|
||||
/// ファイル数。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct StagingEntriesSnapshot {
|
||||
pub entries: Vec<StagingEntry>,
|
||||
pub invalid_count: usize,
|
||||
}
|
||||
|
||||
/// `<staging_dir>/*.json` を読んで UUIDv7 順に並べた [`StagingEntry`]
|
||||
/// 配列を返す。staging_dir が存在しなければ空配列。読めないファイルや
|
||||
/// JSON parse 失敗は `tracing::warn!` してスキップ(壊れた個別ファイルが
|
||||
/// consolidation 全体を止めないように)。
|
||||
pub fn list_staging_entries(layout: &WorkspaceLayout) -> Vec<StagingEntry> {
|
||||
list_staging_entries_snapshot(layout).entries
|
||||
}
|
||||
|
||||
/// `<staging_dir>/*.json` を読んで valid staging と invalid staging 件数を返す。
|
||||
/// invalid は自動 migration / 削除 / archive せず、観測可能にするための件数だけを
|
||||
/// 呼び出し側へ渡す。
|
||||
pub fn list_staging_entries_snapshot(layout: &WorkspaceLayout) -> StagingEntriesSnapshot {
|
||||
let dir = layout.staging_dir();
|
||||
let entries = match std::fs::read_dir(&dir) {
|
||||
Ok(it) => it,
|
||||
Err(_) => return StagingEntriesSnapshot::default(),
|
||||
};
|
||||
|
||||
let mut out: Vec<StagingEntry> = Vec::new();
|
||||
let mut invalid_count = 0;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
|
||||
if ext != "json" {
|
||||
continue;
|
||||
}
|
||||
let stem = match path.file_stem().and_then(|s| s.to_str()) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
invalid_count += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let id = match Uuid::parse_str(stem) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
invalid_count += 1;
|
||||
tracing::warn!(path = %path.display(), error = %e, "failed to parse staging entry id");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let bytes = match std::fs::metadata(&path) {
|
||||
Ok(m) => m.len(),
|
||||
Err(_) => 0,
|
||||
};
|
||||
let raw = match std::fs::read_to_string(&path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
invalid_count += 1;
|
||||
tracing::warn!(path = %path.display(), error = %e, "failed to read staging entry");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let record = match serde_json::from_str::<StagingRecord>(&raw) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
invalid_count += 1;
|
||||
tracing::warn!(path = %path.display(), error = %e, "failed to parse staging entry");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
out.push(StagingEntry {
|
||||
id,
|
||||
path,
|
||||
record,
|
||||
bytes,
|
||||
});
|
||||
}
|
||||
out.sort_by_key(|e| e.id);
|
||||
StagingEntriesSnapshot {
|
||||
entries: out,
|
||||
invalid_count,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::extract::{CandidateKind, ExtractedCandidate, ExtractedPayload, write_staging};
|
||||
use crate::schema::SourceRef;
|
||||
|
||||
fn candidate_payload(claim: &str) -> ExtractedPayload {
|
||||
ExtractedPayload {
|
||||
candidates: vec![ExtractedCandidate {
|
||||
kind: CandidateKind::Lesson,
|
||||
claim: claim.into(),
|
||||
why_useful: "useful for later consolidation".into(),
|
||||
staleness: None,
|
||||
evidence_ids: Vec::new(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn source(segment_id: &str, range: [u64; 2]) -> SourceRef {
|
||||
SourceRef {
|
||||
segment_id: segment_id.into(),
|
||||
range,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_in_uuidv7_order() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let layout = WorkspaceLayout::new(tmp.path().to_path_buf());
|
||||
|
||||
let id1 = write_staging(&layout, source("s", [0, 1]), candidate_payload("one"))
|
||||
.unwrap()
|
||||
.remove(0)
|
||||
.id;
|
||||
let id2 = write_staging(&layout, source("s", [2, 3]), candidate_payload("two"))
|
||||
.unwrap()
|
||||
.remove(0)
|
||||
.id;
|
||||
let id3 = write_staging(&layout, source("s", [4, 5]), candidate_payload("three"))
|
||||
.unwrap()
|
||||
.remove(0)
|
||||
.id;
|
||||
|
||||
let entries = list_staging_entries(&layout);
|
||||
let ids: Vec<Uuid> = entries.iter().map(|e| e.id).collect();
|
||||
assert_eq!(ids, vec![id1, id2, id3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_lock_file_and_counts_invalid_json() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let layout = WorkspaceLayout::new(tmp.path().to_path_buf());
|
||||
let _id = write_staging(&layout, source("s", [0, 1]), candidate_payload("kept"))
|
||||
.unwrap()
|
||||
.remove(0)
|
||||
.id;
|
||||
|
||||
// Drop a non-UUID json file, an unparsable UUID-named json file, an
|
||||
// old batch-schema UUID-named json file, and a bare lock file alongside.
|
||||
// Lock files are not `.json`; invalid `.json` files are surfaced
|
||||
// separately instead of being mistaken for an empty staging directory.
|
||||
std::fs::write(layout.staging_dir().join("not-a-uuid.json"), "{}").unwrap();
|
||||
let bad_id = Uuid::now_v7();
|
||||
std::fs::write(layout.staging_dir().join(format!("{bad_id}.json")), "{").unwrap();
|
||||
let old_schema_id = Uuid::now_v7();
|
||||
std::fs::write(
|
||||
layout.staging_dir().join(format!("{old_schema_id}.json")),
|
||||
serde_json::json!({
|
||||
"source": {
|
||||
"session_id": "legacy-session",
|
||||
"range": [0, 1]
|
||||
},
|
||||
"requests": []
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(layout.staging_dir().join(".consolidation.lock"), "{}").unwrap();
|
||||
|
||||
let entries = list_staging_entries(&layout);
|
||||
assert_eq!(entries.len(), 1);
|
||||
|
||||
let snapshot = list_staging_entries_snapshot(&layout);
|
||||
assert_eq!(snapshot.entries.len(), 1);
|
||||
assert_eq!(snapshot.invalid_count, 3);
|
||||
assert_eq!(snapshot.entries[0].record.claim, "kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_dir_returns_empty() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let layout = WorkspaceLayout::new(tmp.path().to_path_buf());
|
||||
// No staging dir at all.
|
||||
assert!(list_staging_entries(&layout).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
//! Errors raised by the memory subsystem.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use lint_common::RecordLintError;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Top-level error for memory operations that don't fit the lint flow.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum MemoryError {
|
||||
#[error("path is not under the memory tree: {}", .0.display())]
|
||||
OutsideMemoryTree(PathBuf),
|
||||
#[error("path is not absolute: {}", .0.display())]
|
||||
RelativePath(PathBuf),
|
||||
#[error("io error at {}: {source}", .path.display())]
|
||||
Io {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
}
|
||||
|
||||
impl MemoryError {
|
||||
pub fn io(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
|
||||
Self::Io {
|
||||
path: path.into(),
|
||||
source,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single Linter violation. Multiple are aggregated in a [`LintReport`].
|
||||
///
|
||||
/// `Display` produces a one-line message used directly in the `ToolError`
|
||||
/// payload returned to the LLM.
|
||||
#[derive(Debug, Clone, Error, PartialEq, Eq)]
|
||||
pub enum LintError {
|
||||
#[error("path is not a valid memory record location: {}", .0.display())]
|
||||
InvalidPath(PathBuf),
|
||||
|
||||
#[error("path is for a different record kind than expected at this location: {}", .0.display())]
|
||||
WrongRecordKind(PathBuf),
|
||||
|
||||
#[error(transparent)]
|
||||
Record(#[from] RecordLintError),
|
||||
|
||||
#[error("missing required frontmatter field: `{0}`")]
|
||||
MissingField(&'static str),
|
||||
|
||||
#[error("invalid value for `{field}`: {message}")]
|
||||
InvalidField {
|
||||
field: &'static str,
|
||||
message: String,
|
||||
},
|
||||
|
||||
#[error("Decisions `status` must be one of open|resolved|replaced (got `{0}`)")]
|
||||
InvalidStatus(String),
|
||||
|
||||
#[error("body exceeds the size limit for this record kind: {actual} chars > {limit}")]
|
||||
BodyTooLong { actual: usize, limit: usize },
|
||||
|
||||
#[error("slug `{0}` already exists; use the edit tool instead of creating a new record")]
|
||||
SlugAlreadyExists(String),
|
||||
|
||||
#[error("`{field}` references unknown {kind} slug `{slug}`")]
|
||||
UnknownReference {
|
||||
field: &'static str,
|
||||
kind: &'static str,
|
||||
slug: String,
|
||||
},
|
||||
|
||||
#[error("`replaced_by` chain forms a cycle: {chain}")]
|
||||
ReplacedByCycle { chain: String },
|
||||
|
||||
#[error("`replaced_by` must point to a different slug than the record itself")]
|
||||
ReplacedBySelf,
|
||||
}
|
||||
|
||||
/// A single Linter warning (non-blocking).
|
||||
///
|
||||
/// Warnings ride along in the `ToolOutput.summary` so the agent can act
|
||||
/// on them when convenient; they never abort the write.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LintWarning {
|
||||
/// Single-source record exceeds the importance/size threshold.
|
||||
LowImportanceLargeRecord { chars: usize },
|
||||
/// `sources` array has grown past the soft cap.
|
||||
SourcesOverflow { count: usize },
|
||||
/// Multiple slugs in the same kind are within Levenshtein distance 2.
|
||||
SimilarSlugs(Vec<String>),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LintWarning {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::LowImportanceLargeRecord { chars } => write!(
|
||||
f,
|
||||
"record is large ({chars} chars) but only has 1 source — consider splitting or trimming"
|
||||
),
|
||||
Self::SourcesOverflow { count } => write!(
|
||||
f,
|
||||
"`sources` has {count} entries — consider keeping only the most recent and relying on git log for the rest"
|
||||
),
|
||||
Self::SimilarSlugs(slugs) => {
|
||||
write!(f, "similar slugs detected (consider merging): ")?;
|
||||
for (i, s) in slugs.iter().enumerate() {
|
||||
if i > 0 {
|
||||
write!(f, ", ")?;
|
||||
}
|
||||
write!(f, "{s}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,12 @@
|
||||
//! extract: memory candidate extraction.
|
||||
//! Memory candidate extraction domain types and Worker tool boundary.
|
||||
//!
|
||||
//! 通常 Worker の post-run hook で発火する disposable Engine と、その
|
||||
//! 出力を `<workspace>/.yoi/memory/_staging/<id>.json` に書き出す
|
||||
//! ヘルパーを提供する。Worker 側はこのモジュールから:
|
||||
//!
|
||||
//! - [`build_extract_input`] を sub-Engine の最初の user 入力に
|
||||
//! - [`write_extracted_tool`] を唯一のツールとして
|
||||
//! - [`write_staging`] で受け取った JSON を staging に書き出し
|
||||
//!
|
||||
//! の順で組み立てる。system prompt は Worker の `PromptCatalog`
|
||||
//! (`WorkerPrompt::MemoryExtractSystem`) で管理される。pointer 永続化
|
||||
//! (session-store の `LogEntry::Extension`、domain `"memory.extract"`)は
|
||||
//! Worker 側が責務を持つ。
|
||||
//!
|
||||
//! 出力 JSON の wrap は [`write_staging`] が `source: { segment_id, range }`
|
||||
//! を機械付与する形で担当し、LLM には source を推論させない。
|
||||
//! Worker-side extraction produces typed candidates that are submitted to the
|
||||
//! Workspace backend. Staging persistence is owned by that backend; this module
|
||||
//! does not write repository-local files.
|
||||
|
||||
mod input;
|
||||
mod payload;
|
||||
mod pointer;
|
||||
mod staging;
|
||||
mod tool;
|
||||
|
||||
pub use input::build_extract_input;
|
||||
@@ -28,7 +15,6 @@ pub use payload::{
|
||||
StagingRecord,
|
||||
};
|
||||
pub use pointer::{ExtractPointerPayload, fold_pointer};
|
||||
pub use staging::{StagingWriteResult, write_staging, write_staging_candidate};
|
||||
pub use tool::{ExtractWorkerContext, write_extracted_tool};
|
||||
|
||||
/// session-store `LogEntry::Extension` で使う domain 名。
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
//! extract staging writer.
|
||||
//!
|
||||
//! Staging is flat: one file is one candidate and one consolidation decision
|
||||
//! unit. The transitional extract tool still submits `ExtractedPayload` with
|
||||
//! `candidates[]`; this writer expands it into one [`StagingRecord`] per
|
||||
//! candidate.
|
||||
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::extract::payload::{
|
||||
ExtractedCandidate, ExtractedPayload, StagingEvidence, StagingRecord,
|
||||
};
|
||||
use crate::schema::{SourceEvidenceRef, SourceRef};
|
||||
use crate::workspace::WorkspaceLayout;
|
||||
|
||||
/// Filesystem result for a single staged candidate.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StagingWriteResult {
|
||||
pub id: Uuid,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
/// Write one flat staging JSON file per extracted candidate.
|
||||
///
|
||||
/// Returns an empty vector when `payload` has no candidates.
|
||||
pub fn write_staging(
|
||||
layout: &WorkspaceLayout,
|
||||
source: SourceRef,
|
||||
payload: ExtractedPayload,
|
||||
) -> io::Result<Vec<StagingWriteResult>> {
|
||||
if payload.candidates.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let extract_run_id = Uuid::now_v7().to_string();
|
||||
let mut written = Vec::with_capacity(payload.candidates.len());
|
||||
|
||||
for candidate in payload.candidates {
|
||||
written.push(write_staging_candidate(
|
||||
layout,
|
||||
source.clone(),
|
||||
&extract_run_id,
|
||||
candidate,
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
)?);
|
||||
}
|
||||
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
pub fn write_staging_candidate(
|
||||
layout: &WorkspaceLayout,
|
||||
source: SourceRef,
|
||||
extract_run_id: &str,
|
||||
candidate: ExtractedCandidate,
|
||||
evidence: Vec<StagingEvidence>,
|
||||
source_refs: Vec<SourceEvidenceRef>,
|
||||
) -> io::Result<StagingWriteResult> {
|
||||
let dir = layout.staging_dir();
|
||||
fs::create_dir_all(&dir)?;
|
||||
let id = Uuid::now_v7();
|
||||
let record = StagingRecord::from_candidate(
|
||||
id.to_string(),
|
||||
extract_run_id.to_string(),
|
||||
source,
|
||||
candidate,
|
||||
evidence,
|
||||
source_refs,
|
||||
);
|
||||
let path = dir.join(format!("{}.json", id));
|
||||
let bytes = serde_json::to_vec_pretty(&record).map_err(io::Error::other)?;
|
||||
fs::write(&path, bytes)?;
|
||||
Ok(StagingWriteResult { id, path })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use crate::extract::payload::{CandidateKind, ExtractedCandidate};
|
||||
|
||||
fn layout() -> WorkspaceLayout {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// leak tempdir for the duration of the test process; sufficient for unit tests
|
||||
let path = dir.keep();
|
||||
WorkspaceLayout::new(path)
|
||||
}
|
||||
|
||||
fn source() -> SourceRef {
|
||||
SourceRef {
|
||||
segment_id: "segment-1".into(),
|
||||
range: [1, 3],
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate(kind: CandidateKind, claim: &str) -> ExtractedCandidate {
|
||||
ExtractedCandidate {
|
||||
kind,
|
||||
claim: claim.into(),
|
||||
why_useful: "useful for future consolidation".into(),
|
||||
staleness: None,
|
||||
evidence_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_one_file_per_candidate() {
|
||||
let layout = layout();
|
||||
let payload = ExtractedPayload {
|
||||
candidates: vec![
|
||||
candidate(CandidateKind::Preference, "Prefer implementation tickets"),
|
||||
candidate(CandidateKind::Decision, "Use flat staging records"),
|
||||
],
|
||||
};
|
||||
let results = write_staging(&layout, source(), payload).unwrap();
|
||||
assert_eq!(results.len(), 2);
|
||||
assert_ne!(results[0].id, results[1].id);
|
||||
|
||||
let first = fs::read_to_string(&results[0].path).unwrap();
|
||||
let second = fs::read_to_string(&results[1].path).unwrap();
|
||||
let first_record: StagingRecord = serde_json::from_str(&first).unwrap();
|
||||
let second_record: StagingRecord = serde_json::from_str(&second).unwrap();
|
||||
|
||||
assert_eq!(first_record.kind, CandidateKind::Preference);
|
||||
assert_eq!(second_record.kind, CandidateKind::Decision);
|
||||
assert_eq!(first_record.extract_run_id, second_record.extract_run_id);
|
||||
assert_eq!(first_record.source.segment_id, "segment-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_payload_writes_nothing() {
|
||||
let layout = layout();
|
||||
let results = write_staging(&layout, source(), ExtractedPayload::default()).unwrap();
|
||||
assert!(results.is_empty());
|
||||
assert!(!layout.staging_dir().exists());
|
||||
}
|
||||
}
|
||||
+38
-21
@@ -1,30 +1,47 @@
|
||||
//! Memory subsystem support shared by Worker and Workspace authority paths.
|
||||
//! Shared Memory domain and Workspace API transport types.
|
||||
//!
|
||||
//! Normal runtime Memory access is mediated through Workspace-backed backend
|
||||
//! operations. Generic filesystem tools must not touch local memory paths; Worker
|
||||
//! is responsible for denying them at the Scope level when memory is enabled.
|
||||
//! Normal Memory persistence is owned by the Workspace control plane. This crate
|
||||
//! intentionally contains no repository-local `.yoi` layout, discovery, reader,
|
||||
//! writer, or fallback implementation.
|
||||
|
||||
pub mod audit;
|
||||
pub mod backend;
|
||||
pub mod consolidate;
|
||||
pub mod error;
|
||||
pub mod extract;
|
||||
pub mod linter;
|
||||
pub mod resident;
|
||||
pub mod schema;
|
||||
pub mod scope;
|
||||
pub mod usage;
|
||||
pub mod workspace;
|
||||
|
||||
pub use error::{LintError, LintWarning, MemoryError};
|
||||
pub use extract::ExtractPointerPayload;
|
||||
pub use lint_common::{RecordLintError, Slug, is_valid_slug};
|
||||
pub use linter::{LintReport, Linter};
|
||||
pub use resident::collect_resident_summary;
|
||||
pub use scope::deny_write_rules;
|
||||
pub use usage::{
|
||||
UsageEvent, UsageEventKind, UsageRecordSnapshot, UsageReport, UsageReportRecord, UsageSource,
|
||||
append_resident_exposure_event, append_usage_event, append_use_event, build_usage_report,
|
||||
snapshot_record_from_bytes, snapshot_record_from_layout,
|
||||
};
|
||||
pub use workspace::WorkspaceLayout;
|
||||
|
||||
#[cfg(test)]
|
||||
mod authority_tests {
|
||||
#[test]
|
||||
fn production_source_has_no_repository_local_memory_provider() {
|
||||
let production = [
|
||||
include_str!("lib.rs")
|
||||
.split_once("#[cfg(test)]\nmod authority_tests")
|
||||
.map(|(production, _)| production)
|
||||
.expect("Memory authority test module marker"),
|
||||
include_str!("backend.rs"),
|
||||
include_str!("audit.rs"),
|
||||
include_str!("extract/mod.rs"),
|
||||
include_str!("extract/input.rs"),
|
||||
include_str!("extract/payload.rs"),
|
||||
include_str!("extract/pointer.rs"),
|
||||
include_str!("extract/tool.rs"),
|
||||
]
|
||||
.join("\n");
|
||||
for forbidden in [
|
||||
"WorkspaceLayout",
|
||||
"execute_memory_backend_operation",
|
||||
"collect_resident_summary",
|
||||
"deny_write_rules",
|
||||
"write_staging_candidate",
|
||||
".yoi/memory",
|
||||
] {
|
||||
assert!(
|
||||
!production.contains(forbidden),
|
||||
"repository-local Memory provider returned through {forbidden}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
//! Walks `<workspace>/memory/{decisions,requests}/` to collect
|
||||
//! the slug set the linter needs for reference-integrity and
|
||||
//! same-slug-duplication checks.
|
||||
//!
|
||||
//! No caching: each lint call walks fresh. Tree size is expected to
|
||||
//! stay small (hundreds of files, not thousands).
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::Slug;
|
||||
use crate::schema::{DecisionFrontmatter, RequestFrontmatter, split_frontmatter};
|
||||
use crate::workspace::{RecordKind, WorkspaceLayout};
|
||||
|
||||
/// Snapshot of every record currently on disk under the workspace.
|
||||
///
|
||||
/// Carries enough metadata to answer:
|
||||
/// - "does slug X of kind K exist?" (same-slug duplication, reference checks)
|
||||
/// - "what is X's `replaced_by`?" (cycle detection)
|
||||
/// - "what other slugs of kind K exist?" (similar-slug warning)
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct ExistingRecords {
|
||||
decisions: HashMap<Slug, DecisionMeta>,
|
||||
requests: HashSet<Slug>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DecisionMeta {
|
||||
pub replaced_by: Option<Slug>,
|
||||
}
|
||||
|
||||
impl ExistingRecords {
|
||||
pub fn contains(&self, kind: RecordKind, slug: &Slug) -> bool {
|
||||
match kind {
|
||||
RecordKind::Decision => self.decisions.contains_key(slug),
|
||||
RecordKind::Request => self.requests.contains(slug),
|
||||
RecordKind::Summary => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decision(&self, slug: &Slug) -> Option<&DecisionMeta> {
|
||||
self.decisions.get(slug)
|
||||
}
|
||||
|
||||
pub fn slugs(&self, kind: RecordKind) -> Vec<&Slug> {
|
||||
match kind {
|
||||
RecordKind::Decision => self.decisions.keys().collect(),
|
||||
RecordKind::Request => self.requests.iter().collect(),
|
||||
RecordKind::Summary => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk the workspace and collect every record.
|
||||
pub fn scan_existing(layout: &WorkspaceLayout) -> io::Result<ExistingRecords> {
|
||||
let mut out = ExistingRecords::default();
|
||||
|
||||
scan_dir(&layout.decisions_dir(), |path, slug| {
|
||||
let meta = read_decision_meta(path);
|
||||
out.decisions.insert(slug, meta);
|
||||
})?;
|
||||
scan_dir(&layout.requests_dir(), |path, slug| {
|
||||
// Parse to validate but discard contents — only slug existence
|
||||
// matters for reference checks. Parse failure is silently
|
||||
// ignored: existing record corruption isn't this write's
|
||||
// responsibility to fix.
|
||||
let _ = parse_silent::<RequestFrontmatter>(path);
|
||||
out.requests.insert(slug);
|
||||
})?;
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn scan_dir<F>(dir: &Path, mut visit: F) -> io::Result<()>
|
||||
where
|
||||
F: FnMut(&Path, Slug),
|
||||
{
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let stem = match path.file_stem().and_then(|s| s.to_str()) {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
};
|
||||
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
|
||||
if ext != "md" {
|
||||
continue;
|
||||
}
|
||||
if let Ok(slug) = Slug::parse(stem) {
|
||||
visit(&path, slug);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_decision_meta(path: &Path) -> DecisionMeta {
|
||||
match parse_silent::<DecisionFrontmatter>(path) {
|
||||
Some(fm) => DecisionMeta {
|
||||
replaced_by: fm.replaced_by,
|
||||
},
|
||||
None => DecisionMeta { replaced_by: None },
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_silent<F>(path: &Path) -> Option<F>
|
||||
where
|
||||
F: serde::de::DeserializeOwned,
|
||||
{
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
let (yaml, _) = split_frontmatter(&content).ok()?;
|
||||
serde_yaml::from_str::<F>(yaml).ok()
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
//! YAML frontmatter parsing helpers shared by every kind.
|
||||
|
||||
use lint_common::RecordLintError;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::error::LintError;
|
||||
|
||||
/// Strict YAML deserialization that maps serde errors into the linter's
|
||||
/// `MissingField` / `InvalidField` / `MalformedFrontmatter` taxonomy
|
||||
/// when possible.
|
||||
pub fn deserialize_strict<F: DeserializeOwned>(yaml: &str) -> Result<F, LintError> {
|
||||
serde_yaml::from_str::<F>(yaml).map_err(map_serde_error)
|
||||
}
|
||||
|
||||
fn map_serde_error(err: serde_yaml::Error) -> LintError {
|
||||
let msg = err.to_string();
|
||||
|
||||
// `missing field \`X\`` is the exact pattern serde uses for missing
|
||||
// required fields. Hoist into the typed variant so the LLM sees a
|
||||
// crisp message it can act on.
|
||||
if let Some(field) = parse_missing_field(&msg) {
|
||||
return LintError::MissingField(field);
|
||||
}
|
||||
if let Some((field, message)) = parse_invalid_status(&msg) {
|
||||
if field == "status" {
|
||||
return LintError::InvalidStatus(message);
|
||||
}
|
||||
return LintError::InvalidField { field, message };
|
||||
}
|
||||
LintError::Record(RecordLintError::MalformedFrontmatter(msg))
|
||||
}
|
||||
|
||||
fn parse_missing_field(msg: &str) -> Option<&'static str> {
|
||||
let needle = "missing field `";
|
||||
let start = msg.find(needle)? + needle.len();
|
||||
let end = msg[start..].find('`')? + start;
|
||||
let field_name = &msg[start..end];
|
||||
static FIELDS: &[&str] = &[
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"sources",
|
||||
"status",
|
||||
"kind",
|
||||
"description",
|
||||
"requires",
|
||||
];
|
||||
FIELDS.iter().copied().find(|n| *n == field_name)
|
||||
}
|
||||
|
||||
fn parse_invalid_status(msg: &str) -> Option<(&'static str, String)> {
|
||||
// serde renders enum failures as: "unknown variant `Foo`, expected one of ..."
|
||||
// We can't reliably attribute it to a specific field from the message
|
||||
// alone, so we conservatively label it as `status` only when the
|
||||
// message mentions one of the DecisionStatus variants in the
|
||||
// expected set.
|
||||
if msg.contains("unknown variant") && msg.contains("`open`") {
|
||||
let needle = "unknown variant `";
|
||||
let start = msg.find(needle)? + needle.len();
|
||||
let end = msg[start..].find('`')? + start;
|
||||
let bad = msg[start..end].to_string();
|
||||
return Some(("status", bad));
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -1,447 +0,0 @@
|
||||
//! Pre-write Linter for the memory subsystem.
|
||||
//!
|
||||
//! The linter is pure: given a [`WorkspaceLayout`], a target path, and
|
||||
//! the proposed file content (raw bytes), it returns a [`LintReport`]
|
||||
//! aggregating every applicable rule violation. The memory tool calls
|
||||
//! this *before* committing to fs and surfaces a non-empty `errors`
|
||||
//! collection back to the LLM as `ToolError::InvalidArgument`.
|
||||
//!
|
||||
//! Reference-integrity checks (`replaced_by` / `requires` existence,
|
||||
//! cycle detection) walk the whole `memory/` tree
|
||||
//! each call. No caching; the trees are expected to be small.
|
||||
|
||||
mod existing;
|
||||
mod frontmatter;
|
||||
mod references;
|
||||
mod size;
|
||||
mod warnings;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use lint_common::RecordLintError;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::error::{LintError, LintWarning};
|
||||
use crate::schema::{
|
||||
DecisionFrontmatter, RequestFrontmatter, SummaryFrontmatter, split_frontmatter,
|
||||
};
|
||||
use crate::workspace::{ClassifiedPath, RecordKind, WorkspaceLayout};
|
||||
|
||||
pub use existing::{ExistingRecords, scan_existing};
|
||||
|
||||
/// Aggregated linter result. `errors` empty ⇒ write proceeds.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct LintReport {
|
||||
pub errors: Vec<LintError>,
|
||||
pub warnings: Vec<LintWarning>,
|
||||
}
|
||||
|
||||
impl LintReport {
|
||||
pub fn has_errors(&self) -> bool {
|
||||
!self.errors.is_empty()
|
||||
}
|
||||
|
||||
pub fn extend_errors(&mut self, more: impl IntoIterator<Item = LintError>) {
|
||||
self.errors.extend(more);
|
||||
}
|
||||
|
||||
pub fn push_error(&mut self, err: LintError) {
|
||||
self.errors.push(err);
|
||||
}
|
||||
|
||||
pub fn push_warning(&mut self, w: LintWarning) {
|
||||
self.warnings.push(w);
|
||||
}
|
||||
}
|
||||
|
||||
/// Operation context: is this a brand-new file or an update of an
|
||||
/// existing one? Affects same-slug duplication check.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WriteMode {
|
||||
Create,
|
||||
Update,
|
||||
}
|
||||
|
||||
/// Stateless entry point holding the workspace layout. Cheap to clone.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Linter {
|
||||
layout: WorkspaceLayout,
|
||||
}
|
||||
|
||||
impl Linter {
|
||||
pub fn new(layout: WorkspaceLayout) -> Self {
|
||||
Self { layout }
|
||||
}
|
||||
|
||||
pub fn layout(&self) -> &WorkspaceLayout {
|
||||
&self.layout
|
||||
}
|
||||
|
||||
/// Lint a proposed write to `path` with the given UTF-8 `content`.
|
||||
///
|
||||
/// `mode` tells the linter whether the path already existed at the
|
||||
/// moment of write — Create triggers same-slug duplication checks,
|
||||
/// Update doesn't.
|
||||
pub fn lint(&self, path: &Path, content: &str, mode: WriteMode) -> LintReport {
|
||||
let mut report = LintReport::default();
|
||||
|
||||
// 1. Path classification.
|
||||
let classified = match self.layout.classify(path) {
|
||||
Ok(Some(cp)) => cp,
|
||||
Ok(None) => {
|
||||
report.push_error(LintError::InvalidPath(path.to_path_buf()));
|
||||
return report;
|
||||
}
|
||||
Err(e) => {
|
||||
report.push_error(e);
|
||||
return report;
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Frontmatter parse + kind-specific structural checks +
|
||||
// size limits. Reference-integrity needs the existing
|
||||
// record set, fetched once below.
|
||||
let existing = match existing::scan_existing(&self.layout) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
report.push_error(LintError::Record(RecordLintError::MalformedFrontmatter(
|
||||
format!("failed to scan existing records: {e}"),
|
||||
)));
|
||||
return report;
|
||||
}
|
||||
};
|
||||
|
||||
// Same-slug check on Create.
|
||||
if mode == WriteMode::Create {
|
||||
if let Some(slug) = &classified.slug {
|
||||
if existing.contains(classified.kind, slug) {
|
||||
report.push_error(LintError::SlugAlreadyExists(slug.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Similar-slug clustering warning. Skipped for Summary (no slug).
|
||||
if let Some(slug) = &classified.slug {
|
||||
warnings::check_similar_slugs(slug, classified.kind, &existing, &mut report);
|
||||
}
|
||||
|
||||
// Frontmatter parse dispatch by kind.
|
||||
match classified.kind {
|
||||
RecordKind::Decision => {
|
||||
self.check_decision(content, &classified, &existing, &mut report);
|
||||
}
|
||||
RecordKind::Request => {
|
||||
self.check_request(content, &classified, &mut report);
|
||||
}
|
||||
RecordKind::Summary => {
|
||||
self.check_kind::<SummaryFrontmatter>(content, &classified, &mut report);
|
||||
}
|
||||
}
|
||||
|
||||
report
|
||||
}
|
||||
|
||||
fn check_kind<F>(&self, content: &str, cp: &ClassifiedPath, report: &mut LintReport)
|
||||
where
|
||||
F: DeserializeOwned + crate::schema::Frontmatter,
|
||||
{
|
||||
let parsed = match parse_frontmatter::<F>(content) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
report.push_error(e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let body = parsed.body;
|
||||
size::check_body::<F>(body, report);
|
||||
warnings::check_warnings_kindless(cp, body, report);
|
||||
let _ = parsed.frontmatter; // discarded after structural checks
|
||||
}
|
||||
|
||||
fn check_request(&self, content: &str, _cp: &ClassifiedPath, report: &mut LintReport) {
|
||||
let parsed = match parse_frontmatter::<RequestFrontmatter>(content) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
report.push_error(e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
size::check_body::<RequestFrontmatter>(parsed.body, report);
|
||||
warnings::check_warnings_with_sources(
|
||||
parsed.body,
|
||||
parsed.frontmatter.sources.len(),
|
||||
report,
|
||||
);
|
||||
}
|
||||
|
||||
fn check_decision(
|
||||
&self,
|
||||
content: &str,
|
||||
cp: &ClassifiedPath,
|
||||
existing: &ExistingRecords,
|
||||
report: &mut LintReport,
|
||||
) {
|
||||
let parsed = match parse_frontmatter::<DecisionFrontmatter>(content) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
report.push_error(e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let fm = parsed.frontmatter;
|
||||
size::check_body::<DecisionFrontmatter>(parsed.body, report);
|
||||
|
||||
// replaced_by structural rules.
|
||||
if let Some(target) = &fm.replaced_by {
|
||||
if let Some(self_slug) = &cp.slug {
|
||||
if target == self_slug {
|
||||
report.push_error(LintError::ReplacedBySelf);
|
||||
}
|
||||
}
|
||||
references::check_replaced_by(cp.slug.as_ref(), target, existing, report);
|
||||
}
|
||||
|
||||
warnings::check_warnings_with_sources(parsed.body, fm.sources.len(), report);
|
||||
}
|
||||
}
|
||||
|
||||
struct Parsed<'a, F> {
|
||||
frontmatter: F,
|
||||
body: &'a str,
|
||||
}
|
||||
|
||||
fn parse_frontmatter<F: DeserializeOwned>(content: &str) -> Result<Parsed<'_, F>, LintError> {
|
||||
let (yaml, body) = split_frontmatter(content)?;
|
||||
let fm = frontmatter::deserialize_strict::<F>(yaml)?;
|
||||
Ok(Parsed {
|
||||
frontmatter: fm,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write(p: &std::path::Path, content: &str) {
|
||||
if let Some(parent) = p.parent() {
|
||||
std::fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
std::fs::write(p, content).unwrap();
|
||||
}
|
||||
|
||||
fn iso_now() -> String {
|
||||
Utc::now().to_rfc3339()
|
||||
}
|
||||
|
||||
fn workspace() -> (TempDir, Linter) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
|
||||
let linter = Linter::new(layout);
|
||||
(dir, linter)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outside_memory_tree_rejected() {
|
||||
let (dir, linter) = workspace();
|
||||
let path = dir.path().join("src/main.rs");
|
||||
let report = linter.lint(&path, "ignored", WriteMode::Create);
|
||||
assert!(
|
||||
report
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| matches!(e, LintError::InvalidPath(_)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_with_unknown_replaced_by_errors() {
|
||||
let (dir, linter) = workspace();
|
||||
let path = dir.path().join(".yoi/memory/decisions/foo.md");
|
||||
let content = format!(
|
||||
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: replaced\nreplaced_by: ghost\n---\nbody\n",
|
||||
now = iso_now()
|
||||
);
|
||||
let report = linter.lint(&path, &content, WriteMode::Create);
|
||||
assert!(
|
||||
report
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| matches!(e, LintError::UnknownReference { .. }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_replaced_by_self_errors() {
|
||||
let (dir, linter) = workspace();
|
||||
let path = dir.path().join(".yoi/memory/decisions/foo.md");
|
||||
let content = format!(
|
||||
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: replaced\nreplaced_by: foo\n---\nbody\n",
|
||||
now = iso_now()
|
||||
);
|
||||
let report = linter.lint(&path, &content, WriteMode::Update);
|
||||
assert!(
|
||||
report
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| matches!(e, LintError::ReplacedBySelf))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_replaced_by_existing_ok() {
|
||||
let (dir, linter) = workspace();
|
||||
// Pre-create the target.
|
||||
let target = dir.path().join(".yoi/memory/decisions/bar.md");
|
||||
write(
|
||||
&target,
|
||||
&format!(
|
||||
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: open\n---\nbar body\n",
|
||||
now = iso_now()
|
||||
),
|
||||
);
|
||||
let path = dir.path().join(".yoi/memory/decisions/foo.md");
|
||||
let content = format!(
|
||||
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: replaced\nreplaced_by: bar\n---\nbody\n",
|
||||
now = iso_now()
|
||||
);
|
||||
let report = linter.lint(&path, &content, WriteMode::Create);
|
||||
assert!(!report.has_errors(), "got errors: {:?}", report.errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_required_field_errors() {
|
||||
let (dir, linter) = workspace();
|
||||
let path = dir.path().join(".yoi/memory/decisions/foo.md");
|
||||
// Missing `status`.
|
||||
let content = format!(
|
||||
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\n---\nbody\n",
|
||||
now = iso_now()
|
||||
);
|
||||
let report = linter.lint(&path, &content, WriteMode::Create);
|
||||
assert!(report.errors.iter().any(|e| matches!(
|
||||
e,
|
||||
LintError::MissingField(_)
|
||||
| LintError::Record(RecordLintError::MalformedFrontmatter(_))
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_path_accepted() {
|
||||
let (dir, linter) = workspace();
|
||||
let path = dir.path().join(".yoi/memory/summary.md");
|
||||
let content = format!(
|
||||
"---\nupdated_at: {now}\n---\nsummary body\n",
|
||||
now = iso_now()
|
||||
);
|
||||
let report = linter.lint(&path, &content, WriteMode::Update);
|
||||
assert!(!report.has_errors(), "got errors: {:?}", report.errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_when_existing_errors() {
|
||||
let (dir, linter) = workspace();
|
||||
let path = dir.path().join(".yoi/memory/decisions/foo.md");
|
||||
write(
|
||||
&path,
|
||||
&format!(
|
||||
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: open\n---\nold\n",
|
||||
now = iso_now()
|
||||
),
|
||||
);
|
||||
let content = format!(
|
||||
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: open\n---\nnew\n",
|
||||
now = iso_now()
|
||||
);
|
||||
let report = linter.lint(&path, &content, WriteMode::Create);
|
||||
assert!(
|
||||
report
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| matches!(e, LintError::SlugAlreadyExists(_)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn similar_slugs_warns_on_cluster() {
|
||||
let (dir, linter) = workspace();
|
||||
// Two existing decisions within Levenshtein 2 of `db-pool`:
|
||||
// `db-pol` (1 deletion), `db-pools` (1 insertion).
|
||||
for slug in ["db-pol", "db-pools"] {
|
||||
write(
|
||||
&dir.path().join(format!(".yoi/memory/decisions/{slug}.md")),
|
||||
&format!(
|
||||
"---\ncreated_at: {n}\nupdated_at: {n}\nsources: []\nstatus: open\n---\n",
|
||||
n = iso_now()
|
||||
),
|
||||
);
|
||||
}
|
||||
let path = dir.path().join(".yoi/memory/decisions/db-pool.md");
|
||||
let content = format!(
|
||||
"---\ncreated_at: {n}\nupdated_at: {n}\nsources: []\nstatus: open\n---\nbody\n",
|
||||
n = iso_now()
|
||||
);
|
||||
let report = linter.lint(&path, &content, WriteMode::Create);
|
||||
let warned = report
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|w| matches!(w, LintWarning::SimilarSlugs(slugs) if slugs.len() >= 3));
|
||||
assert!(
|
||||
warned,
|
||||
"expected SimilarSlugs warning, got {:?}",
|
||||
report.warnings
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn similar_slugs_silent_when_distant() {
|
||||
let (dir, linter) = workspace();
|
||||
for slug in ["alpha", "bravo"] {
|
||||
write(
|
||||
&dir.path().join(format!(".yoi/memory/decisions/{slug}.md")),
|
||||
&format!(
|
||||
"---\ncreated_at: {n}\nupdated_at: {n}\nsources: []\nstatus: open\n---\n",
|
||||
n = iso_now()
|
||||
),
|
||||
);
|
||||
}
|
||||
let path = dir.path().join(".yoi/memory/decisions/charlie.md");
|
||||
let content = format!(
|
||||
"---\ncreated_at: {n}\nupdated_at: {n}\nsources: []\nstatus: open\n---\n",
|
||||
n = iso_now()
|
||||
);
|
||||
let report = linter.lint(&path, &content, WriteMode::Create);
|
||||
assert!(
|
||||
!report
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|w| matches!(w, LintWarning::SimilarSlugs(_))),
|
||||
"unexpected SimilarSlugs warning: {:?}",
|
||||
report.warnings
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_size_limit_errors() {
|
||||
let (dir, linter) = workspace();
|
||||
let path = dir.path().join(".yoi/memory/decisions/foo.md");
|
||||
let big_body = "x".repeat(8001);
|
||||
let content = format!(
|
||||
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: open\n---\n{body}",
|
||||
now = iso_now(),
|
||||
body = big_body
|
||||
);
|
||||
let report = linter.lint(&path, &content, WriteMode::Create);
|
||||
assert!(
|
||||
report
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| matches!(e, LintError::BodyTooLong { .. }))
|
||||
);
|
||||
// Sanity: ensure path was treated as PathBuf consistently.
|
||||
let _ = PathBuf::from(path);
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
//! Reference-integrity checks: `replaced_by` existence + cycle detection.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::Slug;
|
||||
use crate::error::LintError;
|
||||
use crate::linter::ExistingRecords;
|
||||
use crate::linter::LintReport;
|
||||
use crate::workspace::RecordKind;
|
||||
|
||||
/// Validate a Decision's `replaced_by` against the existing record set.
|
||||
///
|
||||
/// `self_slug` is the slug of the record currently being written (None
|
||||
/// only when the path was malformed and we shouldn't even reach here).
|
||||
pub fn check_replaced_by(
|
||||
self_slug: Option<&Slug>,
|
||||
target: &Slug,
|
||||
existing: &ExistingRecords,
|
||||
report: &mut LintReport,
|
||||
) {
|
||||
// Existence: target must already be a Decision on disk.
|
||||
if !existing.contains(RecordKind::Decision, target) {
|
||||
report.push_error(LintError::UnknownReference {
|
||||
field: "replaced_by",
|
||||
kind: "decision",
|
||||
slug: target.to_string(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Cycle: walk the chain target → target.replaced_by → ... and
|
||||
// ensure we never revisit `self_slug` or any node twice.
|
||||
let mut visited = HashSet::new();
|
||||
if let Some(s) = self_slug {
|
||||
visited.insert(s.clone());
|
||||
}
|
||||
let mut cursor = Some(target.clone());
|
||||
let mut chain: Vec<String> = Vec::new();
|
||||
while let Some(node) = cursor {
|
||||
if !visited.insert(node.clone()) {
|
||||
chain.push(node.to_string());
|
||||
report.push_error(LintError::ReplacedByCycle {
|
||||
chain: chain.join(" -> "),
|
||||
});
|
||||
return;
|
||||
}
|
||||
chain.push(node.to_string());
|
||||
cursor = existing.decision(&node).and_then(|m| m.replaced_by.clone());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
// Smoke test: cycle detection terminates on a 2-node loop where the
|
||||
// existing tree already contains A↔B and the new write would close
|
||||
// the loop. A direct unit test against `check_replaced_by` is
|
||||
// exercised by linter::tests; here we just guard the loop bound.
|
||||
#[test]
|
||||
fn empty_chain_terminates() {
|
||||
let mut report = LintReport::default();
|
||||
let existing = ExistingRecords::default();
|
||||
let target = Slug::parse("foo").unwrap();
|
||||
check_replaced_by(None, &target, &existing, &mut report);
|
||||
assert_eq!(report.errors.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
//! Body size limit checks.
|
||||
|
||||
use crate::error::LintError;
|
||||
use crate::linter::LintReport;
|
||||
use crate::schema::Frontmatter;
|
||||
|
||||
pub fn check_body<F: Frontmatter>(body: &str, report: &mut LintReport) {
|
||||
let chars = body.chars().count();
|
||||
if chars > F::BODY_LIMIT {
|
||||
report.push_error(LintError::BodyTooLong {
|
||||
actual: chars,
|
||||
limit: F::BODY_LIMIT,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
//! Soft warnings: low-importance large records, sources accumulation.
|
||||
//!
|
||||
//! Similar-slug warnings need the existing record set and are
|
||||
//! integrated into the main linter pass when implemented; this file
|
||||
//! covers per-write checks that only need the proposed content.
|
||||
|
||||
use crate::Slug;
|
||||
use crate::error::LintWarning;
|
||||
use crate::linter::LintReport;
|
||||
use crate::linter::existing::ExistingRecords;
|
||||
use crate::workspace::{ClassifiedPath, RecordKind};
|
||||
|
||||
const LARGE_BODY_THRESHOLD: usize = 1500;
|
||||
const SOURCES_OVERFLOW_THRESHOLD: usize = 10;
|
||||
const SIMILAR_SLUG_DISTANCE: usize = 2;
|
||||
/// Cluster size (including the new slug) at which the similar-slug
|
||||
/// warning fires. 3 follows `docs/plan/memory.md` §Linter (`類似 slug
|
||||
/// 乱立`) — two existing close neighbours plus the new write.
|
||||
const SIMILAR_SLUG_CLUSTER_MIN: usize = 3;
|
||||
|
||||
/// For kinds that don't carry a `sources` array (Summary), emit only
|
||||
/// the body-size warning.
|
||||
pub fn check_warnings_kindless(_cp: &ClassifiedPath, body: &str, _report: &mut LintReport) {
|
||||
let _ = body;
|
||||
// Summary intentionally has no warning band — the per-record
|
||||
// size:importance heuristic doesn't apply to a single rolling file.
|
||||
}
|
||||
|
||||
/// For kinds with `sources`, consult
|
||||
/// both the body length and the sources count.
|
||||
pub fn check_warnings_with_sources(body: &str, source_count: usize, report: &mut LintReport) {
|
||||
let chars = body.chars().count();
|
||||
if source_count <= 1 && chars >= LARGE_BODY_THRESHOLD {
|
||||
report.push_warning(LintWarning::LowImportanceLargeRecord { chars });
|
||||
}
|
||||
if source_count > SOURCES_OVERFLOW_THRESHOLD {
|
||||
report.push_warning(LintWarning::SourcesOverflow {
|
||||
count: source_count,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit a `SimilarSlugs` warning when the proposed slug joins a cluster
|
||||
/// of `SIMILAR_SLUG_CLUSTER_MIN` or more slugs in the same kind that
|
||||
/// are pairwise within `SIMILAR_SLUG_DISTANCE` Levenshtein steps of the
|
||||
/// new one. The reported list includes the new slug, sorted to keep
|
||||
/// the warning text deterministic.
|
||||
pub fn check_similar_slugs(
|
||||
new_slug: &Slug,
|
||||
kind: RecordKind,
|
||||
existing: &ExistingRecords,
|
||||
report: &mut LintReport,
|
||||
) {
|
||||
let mut neighbours: Vec<String> = existing
|
||||
.slugs(kind)
|
||||
.into_iter()
|
||||
.filter(|s| *s != new_slug)
|
||||
.filter(|s| levenshtein(new_slug.as_str(), s.as_str()) <= SIMILAR_SLUG_DISTANCE)
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
if neighbours.len() + 1 < SIMILAR_SLUG_CLUSTER_MIN {
|
||||
return;
|
||||
}
|
||||
neighbours.push(new_slug.to_string());
|
||||
neighbours.sort();
|
||||
report.push_warning(LintWarning::SimilarSlugs(neighbours));
|
||||
}
|
||||
|
||||
/// Iterative two-row Levenshtein distance over chars.
|
||||
fn levenshtein(a: &str, b: &str) -> usize {
|
||||
let a: Vec<char> = a.chars().collect();
|
||||
let b: Vec<char> = b.chars().collect();
|
||||
if a.is_empty() {
|
||||
return b.len();
|
||||
}
|
||||
if b.is_empty() {
|
||||
return a.len();
|
||||
}
|
||||
let mut prev: Vec<usize> = (0..=b.len()).collect();
|
||||
let mut curr: Vec<usize> = vec![0; b.len() + 1];
|
||||
for (i, ca) in a.iter().enumerate() {
|
||||
curr[0] = i + 1;
|
||||
for (j, cb) in b.iter().enumerate() {
|
||||
let cost = if ca == cb { 0 } else { 1 };
|
||||
curr[j + 1] = (curr[j] + 1).min(prev[j + 1] + 1).min(prev[j] + cost);
|
||||
}
|
||||
std::mem::swap(&mut prev, &mut curr);
|
||||
}
|
||||
prev[b.len()]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn levenshtein_basics() {
|
||||
assert_eq!(levenshtein("", ""), 0);
|
||||
assert_eq!(levenshtein("a", ""), 1);
|
||||
assert_eq!(levenshtein("", "ab"), 2);
|
||||
assert_eq!(levenshtein("kitten", "sitting"), 3);
|
||||
assert_eq!(levenshtein("foo", "foo"), 0);
|
||||
assert_eq!(levenshtein("abc", "abd"), 1);
|
||||
assert_eq!(levenshtein("abcd", "acbd"), 2);
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
//! Workspace memory resident-enumeration helpers.
|
||||
//!
|
||||
//! Surfaces used by the Worker system-prompt assembler:
|
||||
//!
|
||||
//! - [`collect_resident_summary`] — the body of
|
||||
//! `<workspace>/.yoi/memory/summary.md` when it parses as a summary
|
||||
//! record and has non-empty body.
|
||||
//!
|
||||
//! Files that fail to read or parse are skipped silently — the Linter
|
||||
//! enforces shape on write, so a malformed file here means external
|
||||
//! tampering and we'd rather degrade than panic.
|
||||
|
||||
use crate::schema::{SummaryFrontmatter, split_frontmatter};
|
||||
use crate::workspace::WorkspaceLayout;
|
||||
|
||||
/// Read `<workspace>/.yoi/memory/summary.md` for resident prompt
|
||||
/// injection. Returns only the markdown body (frontmatter stripped), and
|
||||
/// degrades to `None` for missing, unreadable, malformed, or empty records.
|
||||
pub fn collect_resident_summary(layout: &WorkspaceLayout) -> Option<String> {
|
||||
let raw = std::fs::read_to_string(layout.summary_path()).ok()?;
|
||||
let (yaml, body) = split_frontmatter(&raw).ok()?;
|
||||
let _fm: SummaryFrontmatter = serde_yaml::from_str(yaml).ok()?;
|
||||
let body = body.trim_matches(&['\n', '\r'][..]);
|
||||
if body.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(body.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn now() -> String {
|
||||
Utc::now().to_rfc3339()
|
||||
}
|
||||
|
||||
fn write_summary(dir: &Path, body: &str) {
|
||||
let path = dir.join(".yoi/memory/summary.md");
|
||||
let content = format!("---\nupdated_at: {n}\n---\n{body}", n = now());
|
||||
std::fs::write(path, content).unwrap();
|
||||
}
|
||||
|
||||
fn setup() -> (TempDir, WorkspaceLayout) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join(".yoi/memory")).unwrap();
|
||||
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
|
||||
(dir, layout)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_summary_returns_none() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
|
||||
assert!(collect_resident_summary(&layout).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_returns_body_without_frontmatter() {
|
||||
let (dir, layout) = setup();
|
||||
write_summary(dir.path(), "remember this\n");
|
||||
|
||||
let got = collect_resident_summary(&layout).unwrap();
|
||||
assert_eq!(got, "remember this");
|
||||
assert!(!got.contains("updated_at"));
|
||||
assert!(!got.contains("---"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_summary_returns_none() {
|
||||
let (dir, layout) = setup();
|
||||
std::fs::write(
|
||||
dir.path().join(".yoi/memory/summary.md"),
|
||||
"---\nthis is not yaml: : :\n---\nbody\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(collect_resident_summary(&layout).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_summary_body_returns_none() {
|
||||
let (dir, layout) = setup();
|
||||
write_summary(dir.path(), " \n");
|
||||
assert!(collect_resident_summary(&layout).is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
//! Common frontmatter helpers and shared types.
|
||||
|
||||
use lint_common::RecordLintError;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::LintError;
|
||||
|
||||
pub use lint_common::Frontmatter;
|
||||
|
||||
/// Reference to a session-store entry range. Stored in `sources` /
|
||||
@@ -138,14 +137,13 @@ pub struct SourceEvidenceRef {
|
||||
}
|
||||
|
||||
/// Split a markdown document into `(yaml_frontmatter, body)`.
|
||||
pub fn split_frontmatter(content: &str) -> Result<(&str, &str), LintError> {
|
||||
lint_common::split_frontmatter(content).map_err(Into::into)
|
||||
pub fn split_frontmatter(content: &str) -> Result<(&str, &str), RecordLintError> {
|
||||
lint_common::split_frontmatter(content)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lint_common::RecordLintError;
|
||||
|
||||
#[test]
|
||||
fn splits_simple() {
|
||||
@@ -158,19 +156,13 @@ mod tests {
|
||||
#[test]
|
||||
fn no_leading_delim_errors() {
|
||||
let err = split_frontmatter("hello").unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
LintError::Record(RecordLintError::MissingFrontmatter)
|
||||
));
|
||||
assert!(matches!(err, RecordLintError::MissingFrontmatter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_closing_delim_errors() {
|
||||
let err = split_frontmatter("---\nfoo: 1\nno close\n").unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
LintError::Record(RecordLintError::MalformedFrontmatter(_))
|
||||
));
|
||||
assert!(matches!(err, RecordLintError::MalformedFrontmatter(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
//! Frontmatter schemas for memory records.
|
||||
//! Typed frontmatter schemas for Memory documents.
|
||||
//!
|
||||
//! Each record kind has its own typed `*Frontmatter` struct. The linter
|
||||
//! deserializes the YAML between the leading `---` markers into the
|
||||
//! kind-appropriate struct; field-level errors are surfaced as
|
||||
//! [`LintError::MissingField`] / [`LintError::InvalidField`].
|
||||
//! These structures and pure parsing helpers are shared by Workspace-backed
|
||||
//! validation. They do not select or traverse a repository-local Memory tree.
|
||||
|
||||
mod common;
|
||||
mod decision;
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
//! Helpers for constructing `ScopeRule` entries that exclude the
|
||||
//! memory tree from the generic CRUD tools' write surface.
|
||||
//!
|
||||
//! Worker is expected to call [`deny_write_rules`] when memory is enabled
|
||||
//! and append the result to the manifest's `scope.deny` list before
|
||||
//! constructing the [`Scope`] passed to the local WorkdirSession provider. The
|
||||
//! memory tools themselves bypass generic WorkdirSession filesystem operations and
|
||||
//! write directly under the workspace root, so this deny does not affect them.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use manifest::{Permission, ScopeRule};
|
||||
|
||||
use crate::workspace::WorkspaceLayout;
|
||||
|
||||
/// Build a deny rule that strips Write permission from `<workspace>/.yoi/memory/`.
|
||||
pub fn deny_write_rules(layout: &WorkspaceLayout) -> Vec<ScopeRule> {
|
||||
vec![deny_write(layout.memory_dir().as_path())]
|
||||
}
|
||||
|
||||
fn deny_write(target: &Path) -> ScopeRule {
|
||||
ScopeRule {
|
||||
target: target.to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn deny_targets_memory() {
|
||||
let layout = WorkspaceLayout::new(PathBuf::from("/ws"));
|
||||
let rules = deny_write_rules(&layout);
|
||||
assert_eq!(rules.len(), 1);
|
||||
assert_eq!(rules[0].target, PathBuf::from("/ws/.yoi/memory"));
|
||||
assert_eq!(rules[0].permission, Permission::Write);
|
||||
assert!(rules[0].recursive);
|
||||
}
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
//! Workspace-local usage event log for memory records.
|
||||
//!
|
||||
//! The log is append-only JSONL under the workspace's `.yoi/` tree. It is
|
||||
//! intentionally evidence-only: aggregation reports explicit context reads and
|
||||
//! resident exposure cost telemetry, but it does not classify records as
|
||||
//! tidy-protected records.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{self, BufRead, Write};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::workspace::{RecordKind, WorkspaceLayout};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum UsageEventKind {
|
||||
Use,
|
||||
ResidentExposure,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub enum UsageSource {
|
||||
MemoryRead,
|
||||
ResidentInjection,
|
||||
}
|
||||
|
||||
impl UsageSource {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::MemoryRead => "MemoryRead",
|
||||
Self::ResidentInjection => "ResidentInjection",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UsageRecordSnapshot {
|
||||
pub kind: String,
|
||||
pub slug: String,
|
||||
pub file_bytes: u64,
|
||||
pub file_tokens_estimate: u64,
|
||||
}
|
||||
|
||||
impl UsageRecordSnapshot {
|
||||
pub fn from_bytes(kind: RecordKind, slug: impl Into<String>, bytes: &[u8]) -> Self {
|
||||
Self {
|
||||
kind: kind.as_str().to_string(),
|
||||
slug: slug.into(),
|
||||
file_bytes: bytes.len() as u64,
|
||||
file_tokens_estimate: estimate_tokens(bytes.len()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UsageEvent {
|
||||
pub id: Uuid,
|
||||
pub occurred_at: DateTime<Utc>,
|
||||
pub segment_id: String,
|
||||
pub event: UsageEventKind,
|
||||
pub source: UsageSource,
|
||||
pub records: Vec<UsageRecordSnapshot>,
|
||||
}
|
||||
|
||||
impl UsageEvent {
|
||||
pub fn new(
|
||||
segment_id: impl Into<String>,
|
||||
event: UsageEventKind,
|
||||
source: UsageSource,
|
||||
records: Vec<UsageRecordSnapshot>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: Uuid::now_v7(),
|
||||
occurred_at: Utc::now(),
|
||||
segment_id: segment_id.into(),
|
||||
event,
|
||||
source,
|
||||
records,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UsageReport {
|
||||
pub records: Vec<UsageReportRecord>,
|
||||
}
|
||||
|
||||
impl UsageReport {
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
records: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.records.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UsageReportRecord {
|
||||
pub kind: String,
|
||||
pub slug: String,
|
||||
pub use_count: u64,
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
pub source_breakdown: BTreeMap<String, u64>,
|
||||
pub resident_exposure_count: u64,
|
||||
pub estimated_tokens_per_injection: u64,
|
||||
pub estimated_total_resident_exposure_tokens: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Accumulator {
|
||||
kind: String,
|
||||
slug: String,
|
||||
use_count: u64,
|
||||
last_used_at: Option<DateTime<Utc>>,
|
||||
source_breakdown: BTreeMap<String, u64>,
|
||||
resident_exposure_count: u64,
|
||||
last_resident_tokens: u64,
|
||||
}
|
||||
|
||||
/// Append one usage event to the workspace-local JSONL log.
|
||||
pub fn append_usage_event(layout: &WorkspaceLayout, event: &UsageEvent) -> io::Result<()> {
|
||||
let path = layout.usage_events_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let line = serde_json::to_string(event)
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
|
||||
let mut file = OpenOptions::new().create(true).append(true).open(path)?;
|
||||
writeln!(file, "{line}")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Convenience for a successful explicit record read.
|
||||
pub fn append_use_event(
|
||||
layout: &WorkspaceLayout,
|
||||
segment_id: impl Into<String>,
|
||||
source: UsageSource,
|
||||
records: Vec<UsageRecordSnapshot>,
|
||||
) -> io::Result<()> {
|
||||
if records.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
append_usage_event(
|
||||
layout,
|
||||
&UsageEvent::new(segment_id, UsageEventKind::Use, source, records),
|
||||
)
|
||||
}
|
||||
|
||||
/// Convenience for resident model-invocation exposure cost telemetry.
|
||||
pub fn append_resident_exposure_event(
|
||||
layout: &WorkspaceLayout,
|
||||
segment_id: impl Into<String>,
|
||||
records: Vec<UsageRecordSnapshot>,
|
||||
) -> io::Result<()> {
|
||||
if records.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
append_usage_event(
|
||||
layout,
|
||||
&UsageEvent::new(
|
||||
segment_id,
|
||||
UsageEventKind::ResidentExposure,
|
||||
UsageSource::ResidentInjection,
|
||||
records,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Read a record from the workspace and build the snapshot stored in usage
|
||||
/// events. `slug` should be `"summary"` for [`RecordKind::Summary`].
|
||||
pub fn snapshot_record_from_layout(
|
||||
layout: &WorkspaceLayout,
|
||||
kind: RecordKind,
|
||||
slug: &str,
|
||||
) -> io::Result<UsageRecordSnapshot> {
|
||||
let path = record_path(layout, kind, slug)?;
|
||||
let bytes = fs::read(path)?;
|
||||
Ok(UsageRecordSnapshot::from_bytes(
|
||||
kind,
|
||||
slug.to_string(),
|
||||
&bytes,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn snapshot_record_from_bytes(
|
||||
kind: RecordKind,
|
||||
slug: impl Into<String>,
|
||||
bytes: &[u8],
|
||||
) -> UsageRecordSnapshot {
|
||||
UsageRecordSnapshot::from_bytes(kind, slug, bytes)
|
||||
}
|
||||
|
||||
fn record_path(
|
||||
layout: &WorkspaceLayout,
|
||||
kind: RecordKind,
|
||||
slug: &str,
|
||||
) -> io::Result<std::path::PathBuf> {
|
||||
match kind {
|
||||
RecordKind::Summary => Ok(layout.summary_path()),
|
||||
RecordKind::Decision => {
|
||||
let slug = crate::Slug::parse(slug.to_string()).map_err(invalid_slug_error)?;
|
||||
Ok(layout.decision_path(&slug))
|
||||
}
|
||||
RecordKind::Request => {
|
||||
let slug = crate::Slug::parse(slug.to_string()).map_err(invalid_slug_error)?;
|
||||
Ok(layout.request_path(&slug))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_slug_error(err: lint_common::RecordLintError) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidInput, err)
|
||||
}
|
||||
|
||||
/// Aggregate the append-only usage log into per-record evidence.
|
||||
pub fn build_usage_report(layout: &WorkspaceLayout) -> io::Result<UsageReport> {
|
||||
let path = layout.usage_events_path();
|
||||
if !path.exists() {
|
||||
return Ok(UsageReport::empty());
|
||||
}
|
||||
|
||||
let file = fs::File::open(path)?;
|
||||
let reader = io::BufReader::new(file);
|
||||
let mut acc: HashMap<(String, String), Accumulator> = HashMap::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = line?;
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let event: UsageEvent = match serde_json::from_str(&line) {
|
||||
Ok(event) => event,
|
||||
Err(err) => {
|
||||
tracing::warn!(error = %err, "Skipping malformed memory usage event log line");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
for record in event.records {
|
||||
let key = (record.kind.clone(), record.slug.clone());
|
||||
let entry = acc.entry(key).or_insert_with(|| Accumulator {
|
||||
kind: record.kind.clone(),
|
||||
slug: record.slug.clone(),
|
||||
..Accumulator::default()
|
||||
});
|
||||
match event.event {
|
||||
UsageEventKind::Use => {
|
||||
entry.use_count += 1;
|
||||
let source = event.source.as_str().to_string();
|
||||
*entry.source_breakdown.entry(source).or_insert(0) += 1;
|
||||
entry.last_used_at = Some(
|
||||
entry
|
||||
.last_used_at
|
||||
.map(|prev| prev.max(event.occurred_at))
|
||||
.unwrap_or(event.occurred_at),
|
||||
);
|
||||
}
|
||||
UsageEventKind::ResidentExposure => {
|
||||
entry.resident_exposure_count += 1;
|
||||
entry.last_resident_tokens = record.file_tokens_estimate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut records: Vec<UsageReportRecord> = acc
|
||||
.into_values()
|
||||
.map(|a| UsageReportRecord {
|
||||
kind: a.kind,
|
||||
slug: a.slug,
|
||||
use_count: a.use_count,
|
||||
last_used_at: a.last_used_at,
|
||||
source_breakdown: a.source_breakdown,
|
||||
resident_exposure_count: a.resident_exposure_count,
|
||||
estimated_tokens_per_injection: a.last_resident_tokens,
|
||||
estimated_total_resident_exposure_tokens: a
|
||||
.last_resident_tokens
|
||||
.saturating_mul(a.resident_exposure_count),
|
||||
})
|
||||
.collect();
|
||||
records.sort_by(|a, b| (&a.kind, &a.slug).cmp(&(&b.kind, &b.slug)));
|
||||
Ok(UsageReport { records })
|
||||
}
|
||||
|
||||
fn estimate_tokens(bytes: usize) -> u64 {
|
||||
(bytes as u64).div_ceil(4)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (TempDir, WorkspaceLayout) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
|
||||
(dir, layout)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregates_use_and_resident_exposure_separately() {
|
||||
let (_dir, layout) = setup();
|
||||
let decision = snapshot_record_from_bytes(RecordKind::Decision, "alpha", b"abcd");
|
||||
let request = snapshot_record_from_bytes(RecordKind::Request, "policy", b"abcdefgh");
|
||||
|
||||
append_use_event(
|
||||
&layout,
|
||||
"session-a",
|
||||
UsageSource::MemoryRead,
|
||||
vec![decision.clone()],
|
||||
)
|
||||
.unwrap();
|
||||
append_use_event(
|
||||
&layout,
|
||||
"session-a",
|
||||
UsageSource::MemoryRead,
|
||||
vec![request.clone()],
|
||||
)
|
||||
.unwrap();
|
||||
append_use_event(
|
||||
&layout,
|
||||
"session-b",
|
||||
UsageSource::MemoryRead,
|
||||
vec![request.clone()],
|
||||
)
|
||||
.unwrap();
|
||||
append_resident_exposure_event(&layout, "session-b", vec![request]).unwrap();
|
||||
|
||||
let report = build_usage_report(&layout).unwrap();
|
||||
let decision = report
|
||||
.records
|
||||
.iter()
|
||||
.find(|r| r.kind == "decision" && r.slug == "alpha")
|
||||
.unwrap();
|
||||
assert_eq!(decision.use_count, 1);
|
||||
assert_eq!(decision.source_breakdown["MemoryRead"], 1);
|
||||
assert_eq!(decision.resident_exposure_count, 0);
|
||||
assert!(decision.last_used_at.is_some());
|
||||
|
||||
let request = report
|
||||
.records
|
||||
.iter()
|
||||
.find(|r| r.kind == "request" && r.slug == "policy")
|
||||
.unwrap();
|
||||
assert_eq!(request.use_count, 2);
|
||||
assert_eq!(request.source_breakdown["MemoryRead"], 2);
|
||||
assert_eq!(request.resident_exposure_count, 1);
|
||||
assert_eq!(request.estimated_tokens_per_injection, 2);
|
||||
assert_eq!(request.estimated_total_resident_exposure_tokens, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resident_only_record_does_not_increment_use_count() {
|
||||
let (_dir, layout) = setup();
|
||||
let snapshot = snapshot_record_from_bytes(RecordKind::Decision, "policy", b"abcdefgh");
|
||||
append_resident_exposure_event(&layout, "session", vec![snapshot]).unwrap();
|
||||
|
||||
let report = build_usage_report(&layout).unwrap();
|
||||
let record = &report.records[0];
|
||||
assert_eq!(record.use_count, 0);
|
||||
assert!(record.last_used_at.is_none());
|
||||
assert!(record.source_breakdown.is_empty());
|
||||
assert_eq!(record.resident_exposure_count, 1);
|
||||
}
|
||||
}
|
||||
@@ -1,363 +0,0 @@
|
||||
//! Workspace-level path layout for the memory subsystem.
|
||||
//!
|
||||
//! `WorkspaceLayout` carries the root used by the memory subsystem.
|
||||
//! All yoi-managed memory content lives under the conventional
|
||||
//! `<root>/.yoi/` subdirectory — alongside workspace project records
|
||||
//! generated durable memory. The trees inside it:
|
||||
//!
|
||||
//! - `<root>/.yoi/memory/summary.md`
|
||||
//! - `<root>/.yoi/memory/decisions/<slug>.md`
|
||||
//! - `<root>/.yoi/memory/requests/<slug>.md`
|
||||
//! - `<root>/.yoi/memory/_staging/<id>.json`
|
||||
//! - `<root>/.yoi/memory/_logs/current.log` (append-only audit log)
|
||||
//!
|
||||
//! `memory/` is reserved for session-derived / generated state;
|
||||
//! `memory.workspace_root` pins this root explicitly. Without an explicit
|
||||
//! root, resolution searches upward from the Worker pwd for a `.yoi/memory`
|
||||
//! marker; `.yoi` project records alone are not a memory marker.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::Slug;
|
||||
use crate::error::LintError;
|
||||
#[cfg(test)]
|
||||
use lint_common::RecordLintError;
|
||||
|
||||
const YOI_DIR: &str = ".yoi";
|
||||
const MEMORY_DIR: &str = "memory";
|
||||
const SUMMARY_FILE: &str = "summary.md";
|
||||
const DECISIONS_DIR: &str = "decisions";
|
||||
const REQUESTS_DIR: &str = "requests";
|
||||
const STAGING_DIR: &str = "_staging";
|
||||
const USAGE_DIR: &str = "_usage";
|
||||
const LOGS_DIR: &str = "_logs";
|
||||
const USAGE_EVENTS_FILE: &str = "events.jsonl";
|
||||
const AUDIT_CURRENT_LOG_FILE: &str = "current.log";
|
||||
|
||||
/// What kind of record a path under the memory tree represents.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RecordKind {
|
||||
Summary,
|
||||
Decision,
|
||||
Request,
|
||||
}
|
||||
|
||||
impl RecordKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Summary => "summary",
|
||||
Self::Decision => "decision",
|
||||
Self::Request => "request",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A path classified into a kind and (where applicable) a slug.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClassifiedPath {
|
||||
pub kind: RecordKind,
|
||||
pub slug: Option<Slug>,
|
||||
}
|
||||
|
||||
/// Workspace-rooted layout. Cheap to clone.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorkspaceLayout {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl WorkspaceLayout {
|
||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||
Self { root: root.into() }
|
||||
}
|
||||
|
||||
/// Resolve a layout from the nearest Memory marker.
|
||||
///
|
||||
/// Resolution searches `default_root` and its ancestors for the nearest
|
||||
/// `.yoi/memory` directory. This legacy local-storage helper owns its path
|
||||
/// policy directly; resolved Worker Manifests do not carry storage paths.
|
||||
pub fn resolve(default_root: &Path) -> Self {
|
||||
let root =
|
||||
find_memory_marker_root(default_root).unwrap_or_else(|| default_root.to_path_buf());
|
||||
Self::new(root)
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
/// `<root>/.yoi/`. The base of every other memory path.
|
||||
pub fn yoi_dir(&self) -> PathBuf {
|
||||
self.root.join(YOI_DIR)
|
||||
}
|
||||
|
||||
pub fn memory_dir(&self) -> PathBuf {
|
||||
self.yoi_dir().join(MEMORY_DIR)
|
||||
}
|
||||
|
||||
pub fn summary_path(&self) -> PathBuf {
|
||||
self.memory_dir().join(SUMMARY_FILE)
|
||||
}
|
||||
|
||||
pub fn decisions_dir(&self) -> PathBuf {
|
||||
self.memory_dir().join(DECISIONS_DIR)
|
||||
}
|
||||
|
||||
pub fn requests_dir(&self) -> PathBuf {
|
||||
self.memory_dir().join(REQUESTS_DIR)
|
||||
}
|
||||
|
||||
pub fn staging_dir(&self) -> PathBuf {
|
||||
self.memory_dir().join(STAGING_DIR)
|
||||
}
|
||||
|
||||
pub fn usage_dir(&self) -> PathBuf {
|
||||
self.memory_dir().join(USAGE_DIR)
|
||||
}
|
||||
|
||||
pub fn usage_events_path(&self) -> PathBuf {
|
||||
self.usage_dir().join(USAGE_EVENTS_FILE)
|
||||
}
|
||||
|
||||
pub fn audit_logs_dir(&self) -> PathBuf {
|
||||
self.memory_dir().join(LOGS_DIR)
|
||||
}
|
||||
|
||||
/// Tail-friendly latest memory audit log path.
|
||||
///
|
||||
/// Operators can inspect live memory worker and tool events with:
|
||||
/// `tail -f .yoi/memory/_logs/current.log`.
|
||||
pub fn audit_current_log_path(&self) -> PathBuf {
|
||||
self.audit_logs_dir().join(AUDIT_CURRENT_LOG_FILE)
|
||||
}
|
||||
|
||||
pub fn decision_path(&self, slug: &Slug) -> PathBuf {
|
||||
self.decisions_dir().join(format!("{slug}.md"))
|
||||
}
|
||||
|
||||
pub fn request_path(&self, slug: &Slug) -> PathBuf {
|
||||
self.requests_dir().join(format!("{slug}.md"))
|
||||
}
|
||||
|
||||
/// Classify a path under the memory tree. Returns `None` if the
|
||||
/// path is not under `.yoi/memory/`
|
||||
/// of this workspace, or if it lives in
|
||||
/// `_staging/` / `_usage/` / `_logs/` (opaque subsystem-owned trees).
|
||||
///
|
||||
/// On a conventional path that's *almost* a record but malformed
|
||||
/// (e.g. `.yoi/memory/decisions/Foo.md` with an invalid slug),
|
||||
/// returns `Err(LintError::Record(InvalidSlug) | InvalidPath)` so the caller
|
||||
/// can surface it as a write violation.
|
||||
pub fn classify(&self, path: &Path) -> Result<Option<ClassifiedPath>, LintError> {
|
||||
let memory = self.memory_dir();
|
||||
|
||||
let rel = match path.strip_prefix(&memory) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
|
||||
let mut comps = rel.components();
|
||||
let first = match comps.next() {
|
||||
Some(c) => c.as_os_str(),
|
||||
None => return Err(LintError::InvalidPath(path.to_path_buf())),
|
||||
};
|
||||
|
||||
if first == SUMMARY_FILE {
|
||||
if comps.next().is_some() {
|
||||
return Err(LintError::InvalidPath(path.to_path_buf()));
|
||||
}
|
||||
return Ok(Some(ClassifiedPath {
|
||||
kind: RecordKind::Summary,
|
||||
slug: None,
|
||||
}));
|
||||
}
|
||||
if first == STAGING_DIR || first == USAGE_DIR || first == LOGS_DIR {
|
||||
// Linter opts out of subsystem-owned opaque trees.
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let kind = if first == DECISIONS_DIR {
|
||||
RecordKind::Decision
|
||||
} else if first == REQUESTS_DIR {
|
||||
RecordKind::Request
|
||||
} else {
|
||||
return Err(LintError::InvalidPath(path.to_path_buf()));
|
||||
};
|
||||
|
||||
let rest: PathBuf = comps.collect();
|
||||
let cp = classify_kinded_md(&rest, kind, path)?;
|
||||
Ok(Some(cp))
|
||||
}
|
||||
}
|
||||
|
||||
fn find_memory_marker_root(default_root: &Path) -> Option<PathBuf> {
|
||||
default_root
|
||||
.ancestors()
|
||||
.find(|ancestor| ancestor.join(YOI_DIR).join(MEMORY_DIR).is_dir())
|
||||
.map(Path::to_path_buf)
|
||||
}
|
||||
|
||||
fn classify_kinded_md(
|
||||
rel: &Path,
|
||||
kind: RecordKind,
|
||||
full_path: &Path,
|
||||
) -> Result<ClassifiedPath, LintError> {
|
||||
let mut comps = rel.components();
|
||||
let first = match comps.next() {
|
||||
Some(c) => c,
|
||||
None => return Err(LintError::InvalidPath(full_path.to_path_buf())),
|
||||
};
|
||||
if comps.next().is_some() {
|
||||
// Subdirectories under the record kind aren't allowed.
|
||||
return Err(LintError::InvalidPath(full_path.to_path_buf()));
|
||||
}
|
||||
let name = first.as_os_str();
|
||||
let s = name
|
||||
.to_str()
|
||||
.ok_or_else(|| LintError::InvalidPath(full_path.to_path_buf()))?;
|
||||
let stem = s
|
||||
.strip_suffix(".md")
|
||||
.ok_or_else(|| LintError::InvalidPath(full_path.to_path_buf()))?;
|
||||
let slug = Slug::parse(stem)?;
|
||||
Ok(ClassifiedPath {
|
||||
kind,
|
||||
slug: Some(slug),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn layout() -> WorkspaceLayout {
|
||||
WorkspaceLayout::new(PathBuf::from("/ws"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_summary() {
|
||||
let cp = layout()
|
||||
.classify(&PathBuf::from("/ws/.yoi/memory/summary.md"))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(cp.kind, RecordKind::Summary);
|
||||
assert!(cp.slug.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_decision_with_slug() {
|
||||
let cp = layout()
|
||||
.classify(&PathBuf::from("/ws/.yoi/memory/decisions/foo-bar.md"))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(cp.kind, RecordKind::Decision);
|
||||
assert_eq!(cp.slug.unwrap().as_str(), "foo-bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staging_tree_is_opaque_to_classifier() {
|
||||
assert!(
|
||||
layout()
|
||||
.classify(&PathBuf::from("/ws/.yoi/memory/_staging/abc.json"))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_tree_is_opaque_to_classifier() {
|
||||
let cp = layout()
|
||||
.classify(&PathBuf::from("/ws/.yoi/memory/_usage/events.jsonl"))
|
||||
.unwrap();
|
||||
assert!(cp.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logs_tree_is_opaque_to_classifier() {
|
||||
let cp = layout()
|
||||
.classify(&PathBuf::from("/ws/.yoi/memory/_logs/current.log"))
|
||||
.unwrap();
|
||||
assert!(cp.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outside_returns_none() {
|
||||
assert!(
|
||||
layout()
|
||||
.classify(&PathBuf::from("/elsewhere/file.md"))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
layout()
|
||||
.classify(&PathBuf::from("/ws/src/main.rs"))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_slug_rejected() {
|
||||
let err = layout()
|
||||
.classify(&PathBuf::from("/ws/.yoi/memory/decisions/Foo.md"))
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
LintError::Record(RecordLintError::InvalidSlug(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_under_record_dir_rejected() {
|
||||
let err = layout()
|
||||
.classify(&PathBuf::from("/ws/.yoi/memory/decisions/sub/foo.md"))
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, LintError::InvalidPath(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_top_level_dir_rejected() {
|
||||
let err = layout()
|
||||
.classify(&PathBuf::from("/ws/.yoi/memory/something/foo.md"))
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, LintError::InvalidPath(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_selects_nearest_ancestor_memory_marker_when_workspace_root_missing() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let workspace = tmp.path().join("workspace");
|
||||
let child = workspace.join(".worktree/child");
|
||||
std::fs::create_dir_all(workspace.join(".yoi/memory")).unwrap();
|
||||
std::fs::create_dir_all(&child).unwrap();
|
||||
|
||||
let layout = WorkspaceLayout::resolve(&child);
|
||||
assert_eq!(layout.root(), workspace.as_path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_ignores_child_project_records_without_memory_marker() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let workspace = tmp.path().join("workspace");
|
||||
let child = workspace.join(".worktree/child");
|
||||
std::fs::create_dir_all(workspace.join(".yoi/memory")).unwrap();
|
||||
std::fs::create_dir_all(child.join(".yoi/tickets")).unwrap();
|
||||
|
||||
let layout = WorkspaceLayout::resolve(&child);
|
||||
assert_eq!(layout.root(), workspace.as_path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yoi_project_records_alone_do_not_define_memory_marker_root() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let workspace = tmp.path().join("workspace");
|
||||
let child = workspace.join("child");
|
||||
std::fs::create_dir_all(workspace.join(".yoi/tickets")).unwrap();
|
||||
std::fs::create_dir_all(&child).unwrap();
|
||||
|
||||
assert_eq!(find_memory_marker_root(&child), None);
|
||||
|
||||
let layout = WorkspaceLayout::resolve(&child);
|
||||
assert_eq!(layout.root(), child.as_path());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user