yoi/crates/memory/src/workspace.rs

387 lines
12 KiB
Rust

//! Workspace-level path layout for the memory subsystem.
//!
//! `WorkspaceLayout` carries the workspace root (typically the Pod's
//! pwd). All insomnia-managed content lives under the conventional
//! `<root>/.insomnia/` subdirectory — the same place that holds
//! `manifest.toml` and `prompts/`. The trees inside it:
//!
//! - `<root>/.insomnia/workflow/<slug>.md`
//! - `<root>/.insomnia/knowledge/<slug>.md`
//! - `<root>/.insomnia/memory/summary.md`
//! - `<root>/.insomnia/memory/decisions/<slug>.md`
//! - `<root>/.insomnia/memory/requests/<slug>.md`
//! - `<root>/.insomnia/memory/_staging/<id>.json`
//! - `<root>/.insomnia/memory/_logs/current.log` (append-only audit log)
//!
//! `memory/` is reserved for session-derived / generated state;
//! Workflows are human-managed and live one level up under
//! `.insomnia/workflow/`.
//!
//! Configuring `[memory]` with an empty body is therefore sufficient
//! for any workspace that already uses the `.insomnia/` convention; no
//! `workspace_root` override is needed.
use std::path::{Path, PathBuf};
use crate::Slug;
use crate::error::LintError;
#[cfg(test)]
use lint_common::RecordLintError;
const INSOMNIA_DIR: &str = ".insomnia";
const MEMORY_DIR: &str = "memory";
const KNOWLEDGE_DIR: &str = "knowledge";
const WORKFLOW_DIR: &str = "workflow";
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,
Workflow,
Knowledge,
}
impl RecordKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Summary => "summary",
Self::Decision => "decision",
Self::Request => "request",
Self::Workflow => "workflow",
Self::Knowledge => "knowledge",
}
}
}
/// 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 a `MemoryConfig`, falling back to
/// `default_root` (typically the Pod's pwd) when the manifest does
/// not pin `workspace_root` explicitly. Single source of truth for
/// the `workspace_root.unwrap_or(pwd)` convention used across the
/// codebase (controller wiring, scope-deny build, system-prompt
/// resident-injection).
pub fn resolve(cfg: &manifest::MemoryConfig, default_root: &Path) -> Self {
let root = cfg
.workspace_root
.clone()
.unwrap_or_else(|| default_root.to_path_buf());
Self::new(root)
}
pub fn root(&self) -> &Path {
&self.root
}
/// `<root>/.insomnia/`. The base of every other memory path.
pub fn insomnia_dir(&self) -> PathBuf {
self.root.join(INSOMNIA_DIR)
}
pub fn memory_dir(&self) -> PathBuf {
self.insomnia_dir().join(MEMORY_DIR)
}
pub fn knowledge_dir(&self) -> PathBuf {
self.insomnia_dir().join(KNOWLEDGE_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)
}
/// Workflow directory: `<root>/.insomnia/workflow/`.
pub fn workflow_dir(&self) -> PathBuf {
self.insomnia_dir().join(WORKFLOW_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 .insomnia/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"))
}
pub fn workflow_path(&self, slug: &Slug) -> PathBuf {
self.workflow_dir().join(format!("{slug}.md"))
}
pub fn knowledge_path(&self, slug: &Slug) -> PathBuf {
self.knowledge_dir().join(format!("{slug}.md"))
}
/// Classify a path under the memory tree. Returns `None` if the
/// path is not under `.insomnia/memory/` or `.insomnia/knowledge/`
/// 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. `.insomnia/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 knowledge = self.knowledge_dir();
if let Ok(rel) = path.strip_prefix(&knowledge) {
return Ok(Some(classify_kinded_md(rel, RecordKind::Knowledge, path)?));
}
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 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;
fn layout() -> WorkspaceLayout {
WorkspaceLayout::new(PathBuf::from("/ws"))
}
#[test]
fn classifies_summary() {
let cp = layout()
.classify(&PathBuf::from("/ws/.insomnia/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/.insomnia/memory/decisions/foo-bar.md"))
.unwrap()
.unwrap();
assert_eq!(cp.kind, RecordKind::Decision);
assert_eq!(cp.slug.unwrap().as_str(), "foo-bar");
}
#[test]
fn classifies_knowledge() {
let cp = layout()
.classify(&PathBuf::from("/ws/.insomnia/knowledge/x.md"))
.unwrap()
.unwrap();
assert_eq!(cp.kind, RecordKind::Knowledge);
}
#[test]
fn workflow_under_memory_is_invalid_path() {
let err = layout()
.classify(&PathBuf::from("/ws/.insomnia/memory/workflow/wf.md"))
.unwrap_err();
assert!(matches!(err, LintError::InvalidPath(_)));
}
#[test]
fn staging_returns_none() {
assert!(
layout()
.classify(&PathBuf::from("/ws/.insomnia/memory/_staging/abc.json"))
.unwrap()
.is_none()
);
}
#[test]
fn usage_tree_is_opaque_to_classifier() {
let cp = layout()
.classify(&PathBuf::from("/ws/.insomnia/memory/_usage/events.jsonl"))
.unwrap();
assert!(cp.is_none());
}
#[test]
fn logs_tree_is_opaque_to_classifier() {
let cp = layout()
.classify(&PathBuf::from("/ws/.insomnia/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/.insomnia/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/.insomnia/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/.insomnia/memory/something/foo.md"))
.unwrap_err();
assert!(matches!(err, LintError::InvalidPath(_)));
}
#[test]
fn resolve_uses_workspace_root_when_set() {
let cfg = manifest::MemoryConfig {
workspace_root: Some(PathBuf::from("/explicit")),
..Default::default()
};
let layout = WorkspaceLayout::resolve(&cfg, Path::new("/fallback"));
assert_eq!(layout.root(), Path::new("/explicit"));
}
#[test]
fn resolve_falls_back_to_default_when_workspace_root_missing() {
let cfg = manifest::MemoryConfig::default();
let layout = WorkspaceLayout::resolve(&cfg, Path::new("/fallback"));
assert_eq!(layout.root(), Path::new("/fallback"));
}
}