refactor: remove repository-local yoi authority
This commit is contained in:
Generated
-12
@@ -2565,18 +2565,11 @@ dependencies = [
|
|||||||
"agen",
|
"agen",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
"libc",
|
|
||||||
"lint-common",
|
"lint-common",
|
||||||
"manifest",
|
|
||||||
"schemars",
|
"schemars",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml",
|
|
||||||
"sha2 0.11.0",
|
|
||||||
"tempfile",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4731,18 +4724,15 @@ dependencies = [
|
|||||||
"agen",
|
"agen",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
"fs4",
|
|
||||||
"project-record",
|
"project-record",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"schemars",
|
"schemars",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml",
|
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6261,12 +6251,10 @@ dependencies = [
|
|||||||
"client",
|
"client",
|
||||||
"futures",
|
"futures",
|
||||||
"manifest",
|
"manifest",
|
||||||
"memory",
|
|
||||||
"project-record",
|
"project-record",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml",
|
|
||||||
"server-api",
|
"server-api",
|
||||||
"session-analytics",
|
"session-analytics",
|
||||||
"session-store",
|
"session-store",
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ Key docs:
|
|||||||
|
|
||||||
## 5. Development
|
## 5. Development
|
||||||
|
|
||||||
This repository dogfoods Yoi to develop Yoi. Work is tracked through `.yoi/tickets/` and `yoi ticket ...`; git history plus Ticket files are the authoritative project record.
|
This repository dogfoods Yoi to develop Yoi. Work is tracked through Workspace Server-backed Tickets and `yoi ticket ...`; Git history plus append-only Ticket and Merge Request evidence are the authoritative project record. Repository-local `.yoi/tickets` trees are ignored legacy input.
|
||||||
|
|
||||||
Common checks:
|
Common checks:
|
||||||
|
|
||||||
|
|||||||
@@ -7,19 +7,12 @@ license.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
libc = { workspace = true }
|
|
||||||
lint-common = { workspace = true }
|
lint-common = { workspace = true }
|
||||||
agen = { workspace = true }
|
agen = { workspace = true }
|
||||||
manifest = { workspace = true }
|
|
||||||
schemars = { workspace = true }
|
schemars = { workspace = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
sha2 = { workspace = true }
|
|
||||||
serde_yaml = "0.9.34"
|
|
||||||
thiserror = { workspace = true }
|
|
||||||
tracing = { workspace = true }
|
|
||||||
uuid = { workspace = true, features = ["v7", "serde"] }
|
uuid = { workspace = true, features = ["v7", "serde"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = { workspace = true }
|
|
||||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||||
|
|||||||
+9
-17
@@ -2,28 +2,20 @@
|
|||||||
|
|
||||||
## Role
|
## 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
|
## 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
|
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`.
|
||||||
- memory lint subcommand backend behavior
|
|
||||||
- staging and consolidation file mechanics
|
|
||||||
- audit log observation writes
|
|
||||||
- generated memory prompt support where it belongs below the CLI
|
|
||||||
|
|
||||||
Does not own:
|
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.
|
||||||
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
## See also
|
## 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
|
//! Runtime Workers construct these DTOs and submit them through the Memory
|
||||||
//! `.yoi/memory/_logs/current.log`. It is intentionally separate from
|
//! backend operation boundary. Repository-local audit logs are not an authority.
|
||||||
//! `_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};
|
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::workspace::WorkspaceLayout;
|
|
||||||
|
|
||||||
fn is_zero_usize(value: &usize) -> bool {
|
fn is_zero_usize(value: &usize) -> bool {
|
||||||
*value == 0
|
*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
|
//! Runtime Workers serialize these DTOs to the Workspace API. Persistence and
|
||||||
//! Workspace authority but no direct local filesystem authority. The local
|
//! validation are owned by the Workspace authority; this crate deliberately has
|
||||||
//! executor intentionally lives in the memory crate so the workspace backend and
|
//! no repository-local filesystem executor.
|
||||||
//! future non-HTTP hosts share validation and path semantics.
|
|
||||||
|
|
||||||
use std::fs;
|
|
||||||
use std::io;
|
|
||||||
use std::io::Write;
|
|
||||||
|
|
||||||
use chrono::Utc;
|
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::audit::{AuditEvent, append_audit_event};
|
use crate::audit::AuditEvent;
|
||||||
use crate::consolidate::list_staging_entries_snapshot;
|
use crate::extract::{ExtractedCandidate, ExtractedPayload, StagingEvidence};
|
||||||
use crate::extract::{
|
|
||||||
ExtractedCandidate, ExtractedPayload, StagingEvidence, write_staging, write_staging_candidate,
|
|
||||||
};
|
|
||||||
use crate::schema::{SourceEvidenceRef, SourceRef};
|
use crate::schema::{SourceEvidenceRef, SourceRef};
|
||||||
use crate::workspace::WorkspaceLayout;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "operation", rename_all = "snake_case")]
|
#[serde(tag = "operation", rename_all = "snake_case")]
|
||||||
@@ -166,19 +155,6 @@ pub struct MemoryConsolidationOutput {
|
|||||||
pub total_bytes: u64,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct MemoryBackendAckOutput {
|
pub struct MemoryBackendAckOutput {
|
||||||
pub summary: String,
|
pub summary: String,
|
||||||
@@ -189,345 +165,3 @@ pub struct MemoryStagingWriteOutput {
|
|||||||
pub staging_count: usize,
|
pub staging_count: usize,
|
||||||
pub staging_ids: Vec<String>,
|
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 と、その
|
//! Worker-side extraction produces typed candidates that are submitted to the
|
||||||
//! 出力を `<workspace>/.yoi/memory/_staging/<id>.json` に書き出す
|
//! Workspace backend. Staging persistence is owned by that backend; this module
|
||||||
//! ヘルパーを提供する。Worker 側はこのモジュールから:
|
//! does not write repository-local files.
|
||||||
//!
|
|
||||||
//! - [`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 を推論させない。
|
|
||||||
|
|
||||||
mod input;
|
mod input;
|
||||||
mod payload;
|
mod payload;
|
||||||
mod pointer;
|
mod pointer;
|
||||||
mod staging;
|
|
||||||
mod tool;
|
mod tool;
|
||||||
|
|
||||||
pub use input::build_extract_input;
|
pub use input::build_extract_input;
|
||||||
@@ -28,7 +15,6 @@ pub use payload::{
|
|||||||
StagingRecord,
|
StagingRecord,
|
||||||
};
|
};
|
||||||
pub use pointer::{ExtractPointerPayload, fold_pointer};
|
pub use pointer::{ExtractPointerPayload, fold_pointer};
|
||||||
pub use staging::{StagingWriteResult, write_staging, write_staging_candidate};
|
|
||||||
pub use tool::{ExtractWorkerContext, write_extracted_tool};
|
pub use tool::{ExtractWorkerContext, write_extracted_tool};
|
||||||
|
|
||||||
/// session-store `LogEntry::Extension` で使う domain 名。
|
/// 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
|
//! Normal Memory persistence is owned by the Workspace control plane. This crate
|
||||||
//! operations. Generic filesystem tools must not touch local memory paths; Worker
|
//! intentionally contains no repository-local `.yoi` layout, discovery, reader,
|
||||||
//! is responsible for denying them at the Scope level when memory is enabled.
|
//! writer, or fallback implementation.
|
||||||
|
|
||||||
pub mod audit;
|
pub mod audit;
|
||||||
pub mod backend;
|
pub mod backend;
|
||||||
pub mod consolidate;
|
|
||||||
pub mod error;
|
|
||||||
pub mod extract;
|
pub mod extract;
|
||||||
pub mod linter;
|
|
||||||
pub mod resident;
|
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
pub mod scope;
|
|
||||||
pub mod usage;
|
|
||||||
pub mod workspace;
|
|
||||||
|
|
||||||
pub use error::{LintError, LintWarning, MemoryError};
|
|
||||||
pub use extract::ExtractPointerPayload;
|
pub use extract::ExtractPointerPayload;
|
||||||
pub use lint_common::{RecordLintError, Slug, is_valid_slug};
|
pub use lint_common::{RecordLintError, Slug, is_valid_slug};
|
||||||
pub use linter::{LintReport, Linter};
|
|
||||||
pub use resident::collect_resident_summary;
|
#[cfg(test)]
|
||||||
pub use scope::deny_write_rules;
|
mod authority_tests {
|
||||||
pub use usage::{
|
#[test]
|
||||||
UsageEvent, UsageEventKind, UsageRecordSnapshot, UsageReport, UsageReportRecord, UsageSource,
|
fn production_source_has_no_repository_local_memory_provider() {
|
||||||
append_resident_exposure_event, append_usage_event, append_use_event, build_usage_report,
|
let production = [
|
||||||
snapshot_record_from_bytes, snapshot_record_from_layout,
|
include_str!("lib.rs")
|
||||||
};
|
.split_once("#[cfg(test)]\nmod authority_tests")
|
||||||
pub use workspace::WorkspaceLayout;
|
.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.
|
//! Common frontmatter helpers and shared types.
|
||||||
|
|
||||||
|
use lint_common::RecordLintError;
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::error::LintError;
|
|
||||||
|
|
||||||
pub use lint_common::Frontmatter;
|
pub use lint_common::Frontmatter;
|
||||||
|
|
||||||
/// Reference to a session-store entry range. Stored in `sources` /
|
/// 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)`.
|
/// Split a markdown document into `(yaml_frontmatter, body)`.
|
||||||
pub fn split_frontmatter(content: &str) -> Result<(&str, &str), LintError> {
|
pub fn split_frontmatter(content: &str) -> Result<(&str, &str), RecordLintError> {
|
||||||
lint_common::split_frontmatter(content).map_err(Into::into)
|
lint_common::split_frontmatter(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use lint_common::RecordLintError;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn splits_simple() {
|
fn splits_simple() {
|
||||||
@@ -158,19 +156,13 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn no_leading_delim_errors() {
|
fn no_leading_delim_errors() {
|
||||||
let err = split_frontmatter("hello").unwrap_err();
|
let err = split_frontmatter("hello").unwrap_err();
|
||||||
assert!(matches!(
|
assert!(matches!(err, RecordLintError::MissingFrontmatter));
|
||||||
err,
|
|
||||||
LintError::Record(RecordLintError::MissingFrontmatter)
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_closing_delim_errors() {
|
fn no_closing_delim_errors() {
|
||||||
let err = split_frontmatter("---\nfoo: 1\nno close\n").unwrap_err();
|
let err = split_frontmatter("---\nfoo: 1\nno close\n").unwrap_err();
|
||||||
assert!(matches!(
|
assert!(matches!(err, RecordLintError::MalformedFrontmatter(_)));
|
||||||
err,
|
|
||||||
LintError::Record(RecordLintError::MalformedFrontmatter(_))
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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
|
//! These structures and pure parsing helpers are shared by Workspace-backed
|
||||||
//! deserializes the YAML between the leading `---` markers into the
|
//! validation. They do not select or traverse a repository-local Memory tree.
|
||||||
//! kind-appropriate struct; field-level errors are surfaced as
|
|
||||||
//! [`LintError::MissingField`] / [`LintError::InvalidField`].
|
|
||||||
|
|
||||||
mod common;
|
mod common;
|
||||||
mod decision;
|
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,17 +8,13 @@ license.workspace = true
|
|||||||
project-record = { workspace = true }
|
project-record = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
fs4 = { workspace = true, features = ["sync"] }
|
|
||||||
agen = { workspace = true }
|
agen = { workspace = true }
|
||||||
schemars = { workspace = true }
|
schemars = { workspace = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
serde_yaml = "0.9.34"
|
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
rusqlite.workspace = true
|
rusqlite.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tempfile.workspace = true
|
|
||||||
toml = { workspace = true }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
|||||||
+27
-1432
File diff suppressed because it is too large
Load Diff
+59
-3651
File diff suppressed because it is too large
Load Diff
+24
-41
@@ -12,13 +12,12 @@ use serde::{Deserialize, Serialize};
|
|||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
AcceptedOrchestrationPlan, LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord,
|
AcceptedOrchestrationPlan, MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent,
|
||||||
NewTicket, NewTicketEvent, NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord,
|
NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult,
|
||||||
Result as TicketResult, Ticket, TicketBackend, TicketBodyReplacement, TicketDoctorDiagnostic,
|
Ticket, TicketBackend, TicketBodyReplacement, TicketDoctorDiagnostic, TicketDoctorReport,
|
||||||
TicketDoctorReport, TicketDoctorSeverity, TicketError, TicketEventKind, TicketIdOrSlug,
|
TicketDoctorSeverity, TicketError, TicketEventKind, TicketIdOrSlug, TicketIntakeSummary,
|
||||||
TicketIntakeSummary, TicketListState, TicketMarkReady, TicketRef, TicketRelation,
|
TicketListState, TicketMarkReady, TicketRef, TicketRelation, TicketRelationKind,
|
||||||
TicketRelationKind, TicketRelationView, TicketStateChange, TicketSummary, TicketWorkflowState,
|
TicketRelationView, TicketStateChange, TicketSummary, TicketWorkflowState, default_author,
|
||||||
default_author,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_LIST_LIMIT: usize = 50;
|
const DEFAULT_LIST_LIMIT: usize = 50;
|
||||||
@@ -119,16 +118,15 @@ pub const TICKET_MUTATING_TOOL_NAMES: [&str; 14] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const CREATE_DESCRIPTION: &str = "Create a Ticket through the configured typed Ticket backend. \
|
const CREATE_DESCRIPTION: &str = "Create a Ticket through the configured typed Ticket backend. \
|
||||||
Inputs mirror the Ticket `item.md` fields; `title` is required, `body` is Markdown, and the \
|
`title` is required, `body` is Markdown, and the backend assigns the id.";
|
||||||
backend assigns the id and writes the local Ticket file layout under the configured backend root.";
|
|
||||||
const EDIT_ITEM_DESCRIPTION: &str = "Edit a Ticket item through the configured typed Ticket backend. \
|
const EDIT_ITEM_DESCRIPTION: &str = "Edit a Ticket item through the configured typed Ticket backend. \
|
||||||
This updates the current item title/body and appends an audited item_edit thread event. Intended for \
|
This updates the current item title/body and appends an audited item_edit event. Intended for \
|
||||||
User/Companion authoring surfaces, not Orchestrator implementation control.";
|
User/Companion authoring surfaces, not Orchestrator implementation control.";
|
||||||
const LIST_DESCRIPTION: &str = "Query Tickets from the configured typed Ticket backend as a bounded \
|
const LIST_DESCRIPTION: &str = "Query Tickets from the configured typed Ticket backend as a bounded \
|
||||||
overview. The local backend supports workflow-state selection; Workspace-backed Workers replace this \
|
overview. Workspace-backed Workers replace this definition with the richer authoritative \
|
||||||
definition with the richer authoritative text/event/evidence/relation/Objective/time/attention query.";
|
text/event/evidence/relation/Objective/time/attention query.";
|
||||||
const SHOW_DESCRIPTION: &str = "Show one Ticket by id or exact query through the configured typed \
|
const SHOW_DESCRIPTION: &str = "Show one Ticket by id or exact query through the configured typed \
|
||||||
Ticket backend. Output includes bounded Markdown body, recent thread events, resolution, and artifact \
|
Ticket backend. Output includes bounded Markdown body, recent events, resolution, and artifact \
|
||||||
metadata; Workspace-backed Workers replace this definition with the richer authoritative evidence projection.";
|
metadata; Workspace-backed Workers replace this definition with the richer authoritative evidence projection.";
|
||||||
const COMMENT_DESCRIPTION: &str = "Append a typed Ticket comment event. `body` is Markdown.";
|
const COMMENT_DESCRIPTION: &str = "Append a typed Ticket comment event. `body` is Markdown.";
|
||||||
const PLAN_DESCRIPTION: &str = "Append a typed Ticket plan event. `body` is Markdown.";
|
const PLAN_DESCRIPTION: &str = "Append a typed Ticket plan event. `body` is Markdown.";
|
||||||
@@ -149,8 +147,7 @@ Ticket backend with a bounded `state_changed` event. Treat `queued -> inprogress
|
|||||||
as the implementation acceptance step: implementation side effects should happen only after that \
|
as the implementation acceptance step: implementation side effects should happen only after that \
|
||||||
transition is accepted and recorded. Orchestrator may return `ready` or `queued` Tickets to `planning` only with a concrete missing decision/information reason.";
|
transition is accepted and recorded. Orchestrator may return `ready` or `queued` Tickets to `planning` only with a concrete missing decision/information reason.";
|
||||||
const CLOSE_DESCRIPTION: &str = "Close a Ticket with a Markdown resolution through the typed Ticket \
|
const CLOSE_DESCRIPTION: &str = "Close a Ticket with a Markdown resolution through the typed Ticket \
|
||||||
backend. The backend sets `state: closed`, writes resolution.md, updates item.md, and appends \
|
backend. The backend sets the Ticket state to closed and appends a close event.";
|
||||||
a close event.";
|
|
||||||
const RELATION_RECORD_DESCRIPTION: &str = "Record a forward typed Ticket-to-Ticket relation as durable \
|
const RELATION_RECORD_DESCRIPTION: &str = "Record a forward typed Ticket-to-Ticket relation as durable \
|
||||||
project-level metadata. Supported kinds are depends_on, blocks, related, supersedes, and duplicate_of; \
|
project-level metadata. Supported kinds are depends_on, blocks, related, supersedes, and duplicate_of; \
|
||||||
inverse views are derived, not stored.";
|
inverse views are derived, not stored.";
|
||||||
@@ -215,7 +212,7 @@ pub fn ticket_tool_description(name: &str, record_language: Option<&str>) -> Str
|
|||||||
/// Backend object used by the LLM-facing Ticket tools.
|
/// Backend object used by the LLM-facing Ticket tools.
|
||||||
///
|
///
|
||||||
/// Tool execution is intentionally parameterized by this wrapper rather than by
|
/// Tool execution is intentionally parameterized by this wrapper rather than by
|
||||||
/// `LocalTicketBackend` so Worker hosts can supply an API-backed implementation
|
/// `TicketBackend` so Worker hosts can supply an API-backed implementation
|
||||||
/// without changing the model-visible tool surface.
|
/// without changing the model-visible tool surface.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct TicketToolBackend {
|
pub struct TicketToolBackend {
|
||||||
@@ -247,8 +244,8 @@ impl TicketToolBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<LocalTicketBackend> for TicketToolBackend {
|
impl From<crate::SqliteTicketBackend> for TicketToolBackend {
|
||||||
fn from(backend: LocalTicketBackend) -> Self {
|
fn from(backend: crate::SqliteTicketBackend) -> Self {
|
||||||
let record_language = backend.record_language().map(str::to_string);
|
let record_language = backend.record_language().map(str::to_string);
|
||||||
Self::new(backend).with_record_language(record_language.as_deref())
|
Self::new(backend).with_record_language(record_language.as_deref())
|
||||||
}
|
}
|
||||||
@@ -1950,9 +1947,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn backend(temp: &TempDir) -> LocalTicketBackend {
|
fn backend(temp: &TempDir) -> crate::SqliteTicketBackend {
|
||||||
LocalTicketBackend::new(temp.path().join("tickets"))
|
sqlite_backend(temp)
|
||||||
.with_target_authority(Arc::new(TestTargetAuthority))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sqlite_backend(temp: &TempDir) -> crate::SqliteTicketBackend {
|
fn sqlite_backend(temp: &TempDir) -> crate::SqliteTicketBackend {
|
||||||
@@ -2323,7 +2319,6 @@ mod tests {
|
|||||||
let close = tool_by_name(backend.clone(), "TicketClose");
|
let close = tool_by_name(backend.clone(), "TicketClose");
|
||||||
let body_secret = "ITEM_BODY_SECRET_DO_NOT_LIST";
|
let body_secret = "ITEM_BODY_SECRET_DO_NOT_LIST";
|
||||||
let thread_secret = "THREAD_SECRET_DO_NOT_LIST";
|
let thread_secret = "THREAD_SECRET_DO_NOT_LIST";
|
||||||
let artifact_secret = "ARTIFACT_SECRET_DO_NOT_LIST";
|
|
||||||
let resolution_secret = "RESOLUTION_SECRET_DO_NOT_LIST";
|
let resolution_secret = "RESOLUTION_SECRET_DO_NOT_LIST";
|
||||||
let mut ticket = NewTicket::new("Leak Probe");
|
let mut ticket = NewTicket::new("Leak Probe");
|
||||||
ticket.body = MarkdownText::new(format!("Item body {body_secret}"));
|
ticket.body = MarkdownText::new(format!("Item body {body_secret}"));
|
||||||
@@ -2335,15 +2330,6 @@ mod tests {
|
|||||||
NewTicketEvent::new(TicketEventKind::Comment, format!("Thread {thread_secret}")),
|
NewTicketEvent::new(TicketEventKind::Comment, format!("Thread {thread_secret}")),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
std::fs::write(
|
|
||||||
temp.path()
|
|
||||||
.join("tickets")
|
|
||||||
.join(&created.id)
|
|
||||||
.join("artifacts")
|
|
||||||
.join("secret.txt"),
|
|
||||||
artifact_secret,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
close
|
close
|
||||||
.execute(
|
.execute(
|
||||||
&json!({
|
&json!({
|
||||||
@@ -2364,12 +2350,7 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let listed_content = listed.content.unwrap();
|
let listed_content = listed.content.unwrap();
|
||||||
for secret in [
|
for secret in [body_secret, thread_secret, resolution_secret] {
|
||||||
body_secret,
|
|
||||||
thread_secret,
|
|
||||||
artifact_secret,
|
|
||||||
resolution_secret,
|
|
||||||
] {
|
|
||||||
assert!(!listed_content.contains(secret));
|
assert!(!listed_content.contains(secret));
|
||||||
}
|
}
|
||||||
let listed_json: Value = serde_json::from_str(&listed_content).unwrap();
|
let listed_json: Value = serde_json::from_str(&listed_content).unwrap();
|
||||||
@@ -2584,7 +2565,7 @@ mod tests {
|
|||||||
closed
|
closed
|
||||||
.events
|
.events
|
||||||
.iter()
|
.iter()
|
||||||
.any(|event| event.kind == TicketEventKind::StateChanged)
|
.any(|event| event.kind == TicketEventKind::Close)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2889,7 +2870,7 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
|
||||||
assert!(error.to_string().contains("state changed concurrently"));
|
assert!(matches!(error, ToolError::ExecutionFailed(_)));
|
||||||
let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap();
|
let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap();
|
||||||
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Planning);
|
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Planning);
|
||||||
assert!(!record.events.iter().any(|event| {
|
assert!(!record.events.iter().any(|event| {
|
||||||
@@ -3068,7 +3049,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn ticket_create_uses_opaque_id_under_backend_root() {
|
async fn ticket_create_uses_opaque_id_without_repository_local_writes() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
let backend = backend(&temp);
|
let backend = backend(&temp);
|
||||||
let create = tool_by_name(backend.clone(), "TicketCreate");
|
let create = tool_by_name(backend.clone(), "TicketCreate");
|
||||||
@@ -3083,7 +3064,9 @@ mod tests {
|
|||||||
let id = value["id"].as_str().unwrap();
|
let id = value["id"].as_str().unwrap();
|
||||||
assert!(!id.contains("escape"));
|
assert!(!id.contains("escape"));
|
||||||
assert!(!temp.path().join("escape").exists());
|
assert!(!temp.path().join("escape").exists());
|
||||||
assert!(temp.path().join("tickets").join(id).is_dir());
|
assert!(!temp.path().join(".yoi").exists());
|
||||||
|
assert!(!temp.path().join("tickets").exists());
|
||||||
|
assert!(temp.path().join("tickets.db").is_file());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
backend.list(crate::TicketListQuery::all()).unwrap().len(),
|
backend.list(crate::TicketListQuery::all()).unwrap().len(),
|
||||||
1
|
1
|
||||||
|
|||||||
@@ -1493,15 +1493,6 @@ pub struct WorkerRuntimeExecutionBackend<F = ProfileRuntimeWorkerFactory> {
|
|||||||
spawn_restore_timeout: Duration,
|
spawn_restore_timeout: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkerRuntimeExecutionBackend<ProfileRuntimeWorkerFactory> {
|
|
||||||
pub fn from_workspace(workspace_root: impl Into<PathBuf>) -> Result<Self, String> {
|
|
||||||
let workspace_root = workspace_root.into();
|
|
||||||
let factory = ProfileRuntimeWorkerFactory::new(&workspace_root)
|
|
||||||
.with_runtime_store_dir(workspace_root.join(".yoi/runtime-store"));
|
|
||||||
Self::new(factory)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<F> WorkerRuntimeExecutionBackend<F>
|
impl<F> WorkerRuntimeExecutionBackend<F>
|
||||||
where
|
where
|
||||||
F: RuntimeWorkerFactory,
|
F: RuntimeWorkerFactory,
|
||||||
@@ -2957,6 +2948,20 @@ mod tests {
|
|||||||
use manifest::{Scope, WorkerManifest};
|
use manifest::{Scope, WorkerManifest};
|
||||||
use session_store::{LogEntry, WorkerMetadataStore};
|
use session_store::{LogEntry, WorkerMetadataStore};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn production_source_has_no_repository_derived_runtime_store() {
|
||||||
|
let production = include_str!("worker_backend.rs")
|
||||||
|
.split_once("#[cfg(test)]\nmod tests")
|
||||||
|
.map(|(production, _)| production)
|
||||||
|
.expect("worker backend test module marker");
|
||||||
|
for forbidden in ["from_workspace", ".yoi/runtime-store"] {
|
||||||
|
assert!(
|
||||||
|
!production.contains(forbidden),
|
||||||
|
"repository-derived Runtime store returned through {forbidden}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn restored_router_uses_workspace_proxy_for_logical_only_attachment() {
|
fn restored_router_uses_workspace_proxy_for_logical_only_attachment() {
|
||||||
let root = tempfile::tempdir().unwrap();
|
let root = tempfile::tempdir().unwrap();
|
||||||
@@ -4593,6 +4598,10 @@ mod tests {
|
|||||||
}];
|
}];
|
||||||
|
|
||||||
let detail = runtime.create_worker(request).unwrap();
|
let detail = runtime.create_worker(request).unwrap();
|
||||||
|
assert!(
|
||||||
|
!repo.path().join(".yoi").exists(),
|
||||||
|
"Worker launch must not create repository-local authority"
|
||||||
|
);
|
||||||
runtime
|
runtime
|
||||||
.send_input(&detail.worker_ref, WorkerInput::user("inspect tools"))
|
.send_input(&detail.worker_ref, WorkerInput::user("inspect tools"))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -4631,6 +4640,10 @@ mod tests {
|
|||||||
runtime.stop_worker(&detail.worker_ref, None).unwrap();
|
runtime.stop_worker(&detail.worker_ref, None).unwrap();
|
||||||
let restored_without_workdir = runtime.restore_worker(&detail.worker_ref).unwrap();
|
let restored_without_workdir = runtime.restore_worker(&detail.worker_ref).unwrap();
|
||||||
assert!(restored_without_workdir.workdir_attachments.is_empty());
|
assert!(restored_without_workdir.workdir_attachments.is_empty());
|
||||||
|
assert!(
|
||||||
|
!repo.path().join(".yoi").exists(),
|
||||||
|
"Worker restore must not create repository-local authority"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1287,15 +1287,10 @@ where
|
|||||||
"ticket tools require Backend Workspace API authority",
|
"ticket tools require Backend Workspace API authority",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let ticket_backend = crate::feature::builtin::ticket::TicketFeatureBackend::WorkspaceClient(
|
feature_registry.add_module(crate::feature::builtin::ticket::ticket_tools_feature(
|
||||||
workspace_client,
|
workspace_client,
|
||||||
);
|
|
||||||
feature_registry.add_module(
|
|
||||||
crate::feature::builtin::ticket::ticket_tools_feature_with_backend(
|
|
||||||
ticket_backend,
|
|
||||||
ticket_access,
|
ticket_access,
|
||||||
),
|
));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if feature_config.merge_request.any() {
|
if feature_config.merge_request.any() {
|
||||||
let workspace_client = worker.workspace_client_handle();
|
let workspace_client = worker.workspace_client_handle();
|
||||||
|
|||||||
@@ -832,6 +832,20 @@ permission = "write"
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clean_workspace_manifest_resolution_does_not_create_repository_local_yoi() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let workspace = tmp.path().join("runtime-workspace");
|
||||||
|
std::fs::create_dir(&workspace).unwrap();
|
||||||
|
let cli = Cli::try_parse_from(["yoi worker", "--workspace", workspace.to_str().unwrap()])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (manifest, _loader) = resolve_manifest(&cli).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(manifest.worker.name, "runtime-workspace");
|
||||||
|
assert!(!workspace.join(".yoi").exists());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn normal_startup_uses_builtin_defaults_without_local_profile_discovery() {
|
fn normal_startup_uses_builtin_defaults_without_local_profile_discovery() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -21,10 +21,7 @@ pub mod worker_observation;
|
|||||||
pub mod workspace_worker_discovery;
|
pub mod workspace_worker_discovery;
|
||||||
|
|
||||||
pub use task::{TaskFeature, task_tools_feature};
|
pub use task::{TaskFeature, task_tools_feature};
|
||||||
pub use ticket::{
|
pub use ticket::{TicketFeature, TicketFeatureAccess, ticket_tools_feature};
|
||||||
TicketFeature, TicketFeatureAccess, ticket_tools_feature, ticket_tools_feature_with_access,
|
|
||||||
ticket_tools_feature_with_backend,
|
|
||||||
};
|
|
||||||
pub use worker_observation::{
|
pub use worker_observation::{
|
||||||
CompositeWorkerObservationProvider, WorkerObservationError, WorkerObservationFeature,
|
CompositeWorkerObservationProvider, WorkerObservationError, WorkerObservationFeature,
|
||||||
WorkerObservationProvider, WorkerObservationSubject, WorkerObservationSubjectRef,
|
WorkerObservationProvider, WorkerObservationSubject, WorkerObservationSubjectRef,
|
||||||
|
|||||||
@@ -1,34 +1,28 @@
|
|||||||
//! Built-in Ticket feature adapter.
|
//! Built-in Ticket feature adapter.
|
||||||
//!
|
//!
|
||||||
//! The ticket crate owns Ticket domain logic and Tool implementations. This
|
//! The ticket crate owns Ticket domain logic and Tool implementations. This
|
||||||
//! module only resolves the local backend root, declares the built-in feature,
|
//! module binds an authority-scoped Workspace client, declares the built-in
|
||||||
//! and contributes those tools through the normal feature registry path.
|
//! feature, and contributes those tools through the normal registry path.
|
||||||
|
|
||||||
use std::{
|
use std::sync::Arc;
|
||||||
path::{Path, PathBuf},
|
|
||||||
sync::Arc,
|
|
||||||
};
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use ticket::{
|
use ticket::{
|
||||||
LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent,
|
MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation,
|
||||||
NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult,
|
OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult, Ticket, TicketBackend,
|
||||||
Ticket, TicketBackend, TicketBackendOperation, TicketBackendOperationResult,
|
TicketBackendOperation, TicketBackendOperationResult, TicketDoctorReport, TicketError,
|
||||||
TicketDoctorReport, TicketError, TicketIdOrSlug, TicketIntakeSummary, TicketListQuery,
|
TicketIdOrSlug, TicketIntakeSummary, TicketListQuery, TicketRef, TicketRelation,
|
||||||
TicketRef, TicketRelation, TicketRelationKind, TicketRelationView, TicketStateChange,
|
TicketRelationKind, TicketRelationView, TicketStateChange, TicketSummary, TicketWorkflowState,
|
||||||
TicketSummary, TicketWorkflowState,
|
|
||||||
config::{DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TicketConfig},
|
|
||||||
tool::{TICKET_TOOL_NAMES, TicketToolBackend, ticket_tool_description, ticket_tools},
|
tool::{TICKET_TOOL_NAMES, TicketToolBackend, ticket_tool_description, ticket_tools},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::feature::{
|
use crate::feature::{
|
||||||
FeatureDescriptor, FeatureDiagnostic, FeatureInstallContext, FeatureInstallError,
|
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureInstructionContribution,
|
||||||
FeatureInstructionContribution, FeatureInstructionDeclaration, FeatureInstructionId,
|
FeatureInstructionDeclaration, FeatureInstructionId, FeatureModule, ServiceDeclaration,
|
||||||
FeatureModule, ServiceDeclaration, ServiceId, ToolContribution, ToolDeclaration,
|
ServiceId, ToolContribution, ToolDeclaration, ToolDefinition,
|
||||||
ToolDefinition,
|
|
||||||
};
|
};
|
||||||
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
||||||
use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||||
@@ -278,8 +272,8 @@ fn workspace_ticket_read_definition(
|
|||||||
|
|
||||||
const FEATURE_ID: &str = "ticket";
|
const FEATURE_ID: &str = "ticket";
|
||||||
const FEATURE_NAME: &str = "Ticket tools";
|
const FEATURE_NAME: &str = "Ticket tools";
|
||||||
const FEATURE_DESCRIPTION: &str = "Typed local Ticket work-item operations over a bounded backend root. \
|
const FEATURE_DESCRIPTION: &str =
|
||||||
The tools operate through the ticket crate backend and do not grant generic filesystem write scope.";
|
"Typed Ticket operations through the authoritative Workspace API.";
|
||||||
const TICKET_WORKFLOW_INSTRUCTION_ID: &str = "ticket.workflow";
|
const TICKET_WORKFLOW_INSTRUCTION_ID: &str = "ticket.workflow";
|
||||||
const TICKET_WORKFLOW_PROMPT_REF: &str = "common.tickets";
|
const TICKET_WORKFLOW_PROMPT_REF: &str = "common.tickets";
|
||||||
pub const TICKET_SERVICE_ID: &str = "ticket.authority";
|
pub const TICKET_SERVICE_ID: &str = "ticket.authority";
|
||||||
@@ -302,10 +296,6 @@ fn is_canonical_ticket_resource_key(resource_key: &str) -> bool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
struct BackendTicketService {
|
|
||||||
backend: TicketToolBackend,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct WorkspaceTicketService {
|
struct WorkspaceTicketService {
|
||||||
backend: WorkspaceHttpTicketBackend,
|
backend: WorkspaceHttpTicketBackend,
|
||||||
}
|
}
|
||||||
@@ -323,12 +313,6 @@ fn ticket_handoff_from_record(ticket: Ticket) -> Result<TicketHandoff, TicketErr
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TicketService for BackendTicketService {
|
|
||||||
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
|
||||||
ticket_handoff_from_record(self.backend.show(ticket_ref.into())?)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TicketService for WorkspaceTicketService {
|
impl TicketService for WorkspaceTicketService {
|
||||||
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
||||||
ticket_handoff_from_record(self.backend.show_unprojected(ticket_ref)?)
|
ticket_handoff_from_record(self.backend.show_unprojected(ticket_ref)?)
|
||||||
@@ -478,98 +462,20 @@ const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[
|
|||||||
"TicketOrchestrationPlanQuery",
|
"TicketOrchestrationPlanQuery",
|
||||||
];
|
];
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub enum TicketFeatureBackend {
|
|
||||||
Local { root: PathBuf },
|
|
||||||
WorkspaceClient(Arc<dyn WorkspaceClient>),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<PathBuf> for TicketFeatureBackend {
|
|
||||||
fn from(root: PathBuf) -> Self {
|
|
||||||
Self::Local { root }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&Path> for TicketFeatureBackend {
|
|
||||||
fn from(root: &Path) -> Self {
|
|
||||||
Self::Local {
|
|
||||||
root: root.to_path_buf(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&PathBuf> for TicketFeatureBackend {
|
|
||||||
fn from(root: &PathBuf) -> Self {
|
|
||||||
Self::Local { root: root.clone() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct TicketFeature {
|
pub struct TicketFeature {
|
||||||
backend: TicketFeatureBackend,
|
workspace_client: Arc<dyn WorkspaceClient>,
|
||||||
record_language: Option<String>,
|
|
||||||
config_error: Option<String>,
|
|
||||||
access: TicketFeatureAccess,
|
access: TicketFeatureAccess,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TicketFeature {
|
impl TicketFeature {
|
||||||
pub fn new(backend_root: impl Into<PathBuf>) -> Self {
|
pub fn new(workspace_client: Arc<dyn WorkspaceClient>, access: TicketFeatureAccess) -> Self {
|
||||||
Self::new_with_access(backend_root, TicketFeatureAccess::workspace_authoring())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn new_with_access(backend_root: impl Into<PathBuf>, access: TicketFeatureAccess) -> Self {
|
|
||||||
Self::with_backend(
|
|
||||||
TicketFeatureBackend::Local {
|
|
||||||
root: backend_root.into(),
|
|
||||||
},
|
|
||||||
access,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_backend(backend: TicketFeatureBackend, access: TicketFeatureAccess) -> Self {
|
|
||||||
Self {
|
Self {
|
||||||
backend,
|
workspace_client,
|
||||||
record_language: None,
|
|
||||||
config_error: None,
|
|
||||||
access,
|
access,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn for_workspace(workspace: impl AsRef<Path>) -> Self {
|
|
||||||
Self::for_workspace_with_access(workspace, TicketFeatureAccess::workspace_authoring())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn for_workspace_with_access(
|
|
||||||
workspace: impl AsRef<Path>,
|
|
||||||
access: TicketFeatureAccess,
|
|
||||||
) -> Self {
|
|
||||||
let workspace = workspace.as_ref();
|
|
||||||
match TicketConfig::load_workspace(workspace) {
|
|
||||||
Ok(config) => {
|
|
||||||
let backend_root = config.backend_root().to_path_buf();
|
|
||||||
let record_language = config.ticket_record_language().map(str::to_string);
|
|
||||||
let mut feature = Self::new_with_access(backend_root, access);
|
|
||||||
feature.record_language = record_language;
|
|
||||||
feature
|
|
||||||
}
|
|
||||||
Err(error) => Self {
|
|
||||||
backend: TicketFeatureBackend::Local {
|
|
||||||
root: workspace.join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH),
|
|
||||||
},
|
|
||||||
record_language: None,
|
|
||||||
config_error: Some(error.to_string()),
|
|
||||||
access,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn backend_root(&self) -> Option<&Path> {
|
|
||||||
match &self.backend {
|
|
||||||
TicketFeatureBackend::Local { root } => Some(root),
|
|
||||||
TicketFeatureBackend::WorkspaceClient(_) => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn access(&self) -> TicketFeatureAccess {
|
pub fn access(&self) -> TicketFeatureAccess {
|
||||||
self.access
|
self.access
|
||||||
}
|
}
|
||||||
@@ -578,46 +484,12 @@ impl TicketFeature {
|
|||||||
self.access.tool_names()
|
self.access.tool_names()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn usable_backend_root(&self) -> Result<PathBuf, String> {
|
fn workspace_client(&self) -> Arc<dyn WorkspaceClient> {
|
||||||
let Some(root) = self.backend_root() else {
|
self.workspace_client.clone()
|
||||||
return Err("ticket backend is not local filesystem backed".to_string());
|
|
||||||
};
|
|
||||||
let root = root
|
|
||||||
.canonicalize()
|
|
||||||
.map_err(|error| format!("ticket backend root is not usable: {error}"))?;
|
|
||||||
if !root.is_dir() {
|
|
||||||
return Err("ticket backend root is not a directory".to_string());
|
|
||||||
}
|
|
||||||
Ok(root)
|
|
||||||
}
|
|
||||||
fn tool_backend(&self, context: &mut FeatureInstallContext<'_>) -> Option<TicketToolBackend> {
|
|
||||||
match &self.backend {
|
|
||||||
TicketFeatureBackend::Local { root: _ } => {
|
|
||||||
let usable_root = match self.usable_backend_root() {
|
|
||||||
Ok(root) => root,
|
|
||||||
Err(reason) => {
|
|
||||||
context
|
|
||||||
.diagnostics()
|
|
||||||
.push(FeatureDiagnostic::warning(format!(
|
|
||||||
"Ticket tools not registered: {reason}; root={} ",
|
|
||||||
self.backend_root()
|
|
||||||
.map(|root| root.display().to_string())
|
|
||||||
.unwrap_or_else(|| "<non-local>".to_string())
|
|
||||||
)));
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Some(
|
|
||||||
LocalTicketBackend::new(usable_root)
|
|
||||||
.with_record_language(self.record_language.as_deref())
|
|
||||||
.into(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
TicketFeatureBackend::WorkspaceClient(client) => Some(
|
|
||||||
TicketToolBackend::new(WorkspaceHttpTicketBackend::new(client.clone()))
|
|
||||||
.with_record_language(self.record_language.as_deref()),
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn tool_backend(&self) -> TicketToolBackend {
|
||||||
|
TicketToolBackend::new(WorkspaceHttpTicketBackend::new(self.workspace_client()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,32 +507,18 @@ impl FeatureModule for TicketFeature {
|
|||||||
for name in enabled_tool_names {
|
for name in enabled_tool_names {
|
||||||
descriptor = descriptor.with_tool(ToolDeclaration::new(
|
descriptor = descriptor.with_tool(ToolDeclaration::new(
|
||||||
name,
|
name,
|
||||||
ticket_tool_description(name, self.record_language.as_deref()),
|
ticket_tool_description(name, None),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
descriptor
|
descriptor
|
||||||
}
|
}
|
||||||
|
|
||||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||||
if let Some(error) = &self.config_error {
|
let backend = self.tool_backend();
|
||||||
context
|
let workspace_client = self.workspace_client();
|
||||||
.diagnostics()
|
let ticket_service: Arc<dyn TicketService> = Arc::new(WorkspaceTicketService {
|
||||||
.push(FeatureDiagnostic::warning(format!(
|
backend: WorkspaceHttpTicketBackend::new(workspace_client.clone()),
|
||||||
"Ticket tools not registered: {error}"
|
});
|
||||||
)));
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let Some(backend) = self.tool_backend(context) else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
let ticket_service: Arc<dyn TicketService> = match &self.backend {
|
|
||||||
TicketFeatureBackend::WorkspaceClient(client) => Arc::new(WorkspaceTicketService {
|
|
||||||
backend: WorkspaceHttpTicketBackend::new(client.clone()),
|
|
||||||
}),
|
|
||||||
TicketFeatureBackend::Local { .. } => Arc::new(BackendTicketService {
|
|
||||||
backend: backend.clone(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
context.services().provide(
|
context.services().provide(
|
||||||
ServiceDeclaration::new(
|
ServiceDeclaration::new(
|
||||||
ServiceId::builtin(TICKET_SERVICE_ID),
|
ServiceId::builtin(TICKET_SERVICE_ID),
|
||||||
@@ -675,10 +533,6 @@ impl FeatureModule for TicketFeature {
|
|||||||
ticket_workflow_instruction(),
|
ticket_workflow_instruction(),
|
||||||
))?;
|
))?;
|
||||||
let allowed_tool_names = self.enabled_tool_names();
|
let allowed_tool_names = self.enabled_tool_names();
|
||||||
let workspace_client = match &self.backend {
|
|
||||||
TicketFeatureBackend::WorkspaceClient(client) => Some(client.clone()),
|
|
||||||
TicketFeatureBackend::Local { .. } => None,
|
|
||||||
};
|
|
||||||
let mut tools = context.tools();
|
let mut tools = context.tools();
|
||||||
for definition in ticket_tools(backend) {
|
for definition in ticket_tools(backend) {
|
||||||
let (meta, _) = definition();
|
let (meta, _) = definition();
|
||||||
@@ -689,13 +543,15 @@ impl FeatureModule for TicketFeature {
|
|||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let definition = match (name.as_str(), workspace_client.as_ref()) {
|
let definition = match name.as_str() {
|
||||||
("QueryTicket", Some(client)) => {
|
"QueryTicket" => workspace_ticket_read_definition(
|
||||||
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)
|
workspace_client.clone(),
|
||||||
}
|
WorkspaceTicketReadKind::Query,
|
||||||
("ShowTicket", Some(client)) => {
|
),
|
||||||
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Show)
|
"ShowTicket" => workspace_ticket_read_definition(
|
||||||
}
|
workspace_client.clone(),
|
||||||
|
WorkspaceTicketReadKind::Show,
|
||||||
|
),
|
||||||
_ => definition,
|
_ => definition,
|
||||||
};
|
};
|
||||||
tools.register(ToolContribution::new(name, definition))?;
|
tools.register(ToolContribution::new(name, definition))?;
|
||||||
@@ -1371,22 +1227,11 @@ impl TicketBackend for WorkspaceHttpTicketBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn ticket_tools_feature(workspace: impl AsRef<Path>) -> TicketFeature {
|
pub fn ticket_tools_feature(
|
||||||
TicketFeature::for_workspace(workspace)
|
workspace_client: Arc<dyn WorkspaceClient>,
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ticket_tools_feature_with_access(
|
|
||||||
workspace: impl AsRef<Path>,
|
|
||||||
access: TicketFeatureAccess,
|
access: TicketFeatureAccess,
|
||||||
) -> TicketFeature {
|
) -> TicketFeature {
|
||||||
TicketFeature::for_workspace_with_access(workspace, access)
|
TicketFeature::new(workspace_client, access)
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ticket_tools_feature_with_backend(
|
|
||||||
backend: impl Into<TicketFeatureBackend>,
|
|
||||||
access: TicketFeatureAccess,
|
|
||||||
) -> TicketFeature {
|
|
||||||
TicketFeature::with_backend(backend.into(), access)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1397,29 +1242,31 @@ mod tests {
|
|||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::net::TcpListener;
|
use std::net::TcpListener;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
fn make_ticket_root(root: &Path) {
|
#[test]
|
||||||
std::fs::create_dir_all(root).unwrap();
|
fn production_source_has_no_local_ticket_feature_backend() {
|
||||||
|
let production = include_str!("ticket.rs")
|
||||||
|
.split_once("#[cfg(test)]\nmod tests")
|
||||||
|
.map(|(production, _)| production)
|
||||||
|
.expect("Ticket feature test module marker");
|
||||||
|
for forbidden in [
|
||||||
|
"TicketFeatureBackend",
|
||||||
|
"LocalTicketBackend",
|
||||||
|
"for_workspace",
|
||||||
|
".yoi/tickets",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!production.contains(forbidden),
|
||||||
|
"repository-local Ticket feature returned through {forbidden}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_ticket_config(workspace: &Path, content: &str) {
|
fn workspace_feature(access: TicketFeatureAccess) -> TicketFeature {
|
||||||
let yoi_dir = workspace.join(".yoi");
|
let client: Arc<dyn WorkspaceClient> = Arc::new(
|
||||||
std::fs::create_dir_all(&yoi_dir).unwrap();
|
crate::worker::TestWorkspaceHttpClient::new("workspace", "http://backend"),
|
||||||
std::fs::write(yoi_dir.join("workspace.toml"), content).unwrap();
|
);
|
||||||
}
|
ticket_tools_feature(client, access)
|
||||||
|
|
||||||
fn pending_tool_description(
|
|
||||||
pending_tools: &[agen::tool::ToolDefinition],
|
|
||||||
name: &str,
|
|
||||||
) -> String {
|
|
||||||
pending_tools
|
|
||||||
.iter()
|
|
||||||
.find_map(|definition| {
|
|
||||||
let (meta, _) = definition();
|
|
||||||
(meta.name == name).then_some(meta.description)
|
|
||||||
})
|
|
||||||
.expect("tool exists")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1482,8 +1329,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn descriptor_declares_ticket_tools() {
|
fn descriptor_declares_ticket_tools() {
|
||||||
let temp = TempDir::new().unwrap();
|
let feature = workspace_feature(TicketFeatureAccess::workspace_authoring());
|
||||||
let feature = ticket_tools_feature(temp.path());
|
|
||||||
let descriptor = feature.descriptor();
|
let descriptor = feature.descriptor();
|
||||||
assert_eq!(descriptor.id.to_string(), "builtin:ticket");
|
assert_eq!(descriptor.id.to_string(), "builtin:ticket");
|
||||||
assert_eq!(descriptor.runtime, FeatureRuntimeKind::Builtin);
|
assert_eq!(descriptor.runtime, FeatureRuntimeKind::Builtin);
|
||||||
@@ -1500,9 +1346,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn read_only_descriptor_declares_only_state_tools() {
|
fn read_only_descriptor_declares_only_state_tools() {
|
||||||
let temp = TempDir::new().unwrap();
|
let feature = workspace_feature(TicketFeatureAccess::read_only());
|
||||||
let feature =
|
|
||||||
ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::read_only());
|
|
||||||
let descriptor = feature.descriptor();
|
let descriptor = feature.descriptor();
|
||||||
assert_eq!(feature.access(), TicketFeatureAccess::read_only());
|
assert_eq!(feature.access(), TicketFeatureAccess::read_only());
|
||||||
assert_eq!(descriptor.tools.len(), READ_ONLY_TOOL_NAMES.len());
|
assert_eq!(descriptor.tools.len(), READ_ONLY_TOOL_NAMES.len());
|
||||||
@@ -1518,9 +1362,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workflow_descriptor_declares_workflow_tools() {
|
fn workflow_descriptor_declares_workflow_tools() {
|
||||||
let temp = TempDir::new().unwrap();
|
let feature = workspace_feature(TicketFeatureAccess::workflow());
|
||||||
let feature =
|
|
||||||
ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::workflow());
|
|
||||||
let descriptor = feature.descriptor();
|
let descriptor = feature.descriptor();
|
||||||
assert_eq!(feature.access(), TicketFeatureAccess::workflow());
|
assert_eq!(feature.access(), TicketFeatureAccess::workflow());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1535,12 +1377,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn additive_ticket_capabilities_expose_expected_tool_surfaces() {
|
fn additive_ticket_capabilities_expose_expected_tool_surfaces() {
|
||||||
let temp = TempDir::new().unwrap();
|
let workspace_authoring = workspace_feature(TicketFeatureAccess::workspace_authoring());
|
||||||
|
|
||||||
let workspace_authoring = ticket_tools_feature_with_access(
|
|
||||||
temp.path(),
|
|
||||||
TicketFeatureAccess::workspace_authoring(),
|
|
||||||
);
|
|
||||||
let workspace_descriptor = workspace_authoring.descriptor();
|
let workspace_descriptor = workspace_authoring.descriptor();
|
||||||
let workspace_tools = workspace_descriptor
|
let workspace_tools = workspace_descriptor
|
||||||
.tools
|
.tools
|
||||||
@@ -1552,8 +1389,7 @@ mod tests {
|
|||||||
assert!(workspace_tools.contains(&"TicketQueue"));
|
assert!(workspace_tools.contains(&"TicketQueue"));
|
||||||
assert!(!workspace_tools.contains(&"TicketWorkflowState"));
|
assert!(!workspace_tools.contains(&"TicketWorkflowState"));
|
||||||
|
|
||||||
let orchestration =
|
let orchestration = workspace_feature(TicketFeatureAccess::workflow());
|
||||||
ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::workflow());
|
|
||||||
let orchestration_descriptor = orchestration.descriptor();
|
let orchestration_descriptor = orchestration.descriptor();
|
||||||
let orchestration_tools = orchestration_descriptor
|
let orchestration_tools = orchestration_descriptor
|
||||||
.tools
|
.tools
|
||||||
@@ -1567,8 +1403,7 @@ mod tests {
|
|||||||
assert!(!orchestration_tools.contains(&"TicketEditItem"));
|
assert!(!orchestration_tools.contains(&"TicketEditItem"));
|
||||||
assert!(!orchestration_tools.contains(&"TicketQueue"));
|
assert!(!orchestration_tools.contains(&"TicketQueue"));
|
||||||
|
|
||||||
let work_report =
|
let work_report = workspace_feature(TicketFeatureAccess::work_report());
|
||||||
ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::work_report());
|
|
||||||
let work_report_descriptor = work_report.descriptor();
|
let work_report_descriptor = work_report.descriptor();
|
||||||
let work_report_tools = work_report_descriptor
|
let work_report_tools = work_report_descriptor
|
||||||
.tools
|
.tools
|
||||||
@@ -1578,7 +1413,7 @@ mod tests {
|
|||||||
assert!(work_report_tools.contains(&"TicketComment"));
|
assert!(work_report_tools.contains(&"TicketComment"));
|
||||||
assert!(!work_report_tools.contains(&"TicketWorkflowState"));
|
assert!(!work_report_tools.contains(&"TicketWorkflowState"));
|
||||||
|
|
||||||
let review = ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::review());
|
let review = workspace_feature(TicketFeatureAccess::review());
|
||||||
let review_descriptor = review.descriptor();
|
let review_descriptor = review.descriptor();
|
||||||
let review_tools = review_descriptor
|
let review_tools = review_descriptor
|
||||||
.tools
|
.tools
|
||||||
@@ -1590,15 +1425,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn read_only_installation_does_not_expose_mutating_tools() {
|
fn read_only_installation_does_not_expose_mutating_tools() {
|
||||||
let temp = TempDir::new().unwrap();
|
|
||||||
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
|
|
||||||
let mut pending_tools = Vec::new();
|
let mut pending_tools = Vec::new();
|
||||||
let mut hooks = HookRegistryBuilder::default();
|
let mut hooks = HookRegistryBuilder::default();
|
||||||
let report = FeatureRegistryBuilder::new()
|
let report = FeatureRegistryBuilder::new()
|
||||||
.with_module(ticket_tools_feature_with_access(
|
.with_module(workspace_feature(TicketFeatureAccess::read_only()))
|
||||||
temp.path(),
|
|
||||||
TicketFeatureAccess::read_only(),
|
|
||||||
))
|
|
||||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
.install_into_pending(&mut pending_tools, &mut hooks);
|
||||||
|
|
||||||
assert_eq!(pending_tools.len(), READ_ONLY_TOOL_NAMES.len());
|
assert_eq!(pending_tools.len(), READ_ONLY_TOOL_NAMES.len());
|
||||||
@@ -1619,54 +1449,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn read_only_companion_style_context_exposes_ticket_language_guidance() {
|
|
||||||
let temp = TempDir::new().unwrap();
|
|
||||||
write_ticket_config(
|
|
||||||
temp.path(),
|
|
||||||
r#"
|
|
||||||
[ticket]
|
|
||||||
language = "Japanese"
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
|
|
||||||
let feature =
|
|
||||||
ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::read_only());
|
|
||||||
let descriptor = feature.descriptor();
|
|
||||||
let descriptor_description = descriptor
|
|
||||||
.tools
|
|
||||||
.iter()
|
|
||||||
.find(|tool| tool.name == "ShowTicket")
|
|
||||||
.expect("ShowTicket declared")
|
|
||||||
.description
|
|
||||||
.clone();
|
|
||||||
assert!(descriptor_description.contains("Ticket record language: Japanese"));
|
|
||||||
|
|
||||||
let mut pending_tools = Vec::new();
|
|
||||||
let mut hooks = HookRegistryBuilder::default();
|
|
||||||
let report = FeatureRegistryBuilder::new()
|
|
||||||
.with_module(feature)
|
|
||||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
|
||||||
|
|
||||||
assert_eq!(pending_tools.len(), READ_ONLY_TOOL_NAMES.len());
|
|
||||||
assert_eq!(report.reports[0].installed_tools, READ_ONLY_TOOL_NAMES);
|
|
||||||
let description = pending_tool_description(&pending_tools, "ShowTicket");
|
|
||||||
assert!(description.contains("Ticket record language: Japanese"));
|
|
||||||
assert!(description.contains("distinct from worker.language"));
|
|
||||||
assert!(description.contains("Preserve protocol literals"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workspace_authoring_installation_exposes_authoring_tools() {
|
fn workspace_authoring_installation_exposes_authoring_tools() {
|
||||||
let temp = TempDir::new().unwrap();
|
|
||||||
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
|
|
||||||
let mut pending_tools = Vec::new();
|
let mut pending_tools = Vec::new();
|
||||||
let mut hooks = HookRegistryBuilder::default();
|
let mut hooks = HookRegistryBuilder::default();
|
||||||
let report = FeatureRegistryBuilder::new()
|
let report = FeatureRegistryBuilder::new()
|
||||||
.with_module(ticket_tools_feature_with_access(
|
.with_module(workspace_feature(TicketFeatureAccess::workspace_authoring()))
|
||||||
temp.path(),
|
|
||||||
TicketFeatureAccess::workspace_authoring(),
|
|
||||||
))
|
|
||||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
.install_into_pending(&mut pending_tools, &mut hooks);
|
||||||
|
|
||||||
assert_eq!(pending_tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len());
|
assert_eq!(pending_tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len());
|
||||||
@@ -1690,169 +1478,28 @@ language = "Japanese"
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workspace_authoring_context_exposes_ticket_language_guidance() {
|
fn workspace_only_ticket_feature_preserves_unrelated_stale_repository_trees() {
|
||||||
let temp = TempDir::new().unwrap();
|
let ancestor = tempfile::tempdir().unwrap();
|
||||||
write_ticket_config(
|
let repository = ancestor.path().join("repository");
|
||||||
temp.path(),
|
std::fs::create_dir_all(repository.join(".yoi/tickets/broken")).unwrap();
|
||||||
r#"
|
std::fs::create_dir_all(ancestor.path().join(".yoi")).unwrap();
|
||||||
[ticket]
|
std::fs::write(
|
||||||
language = "Japanese"
|
ancestor.path().join(".yoi/workspace.toml"),
|
||||||
"#,
|
"not valid toml",
|
||||||
);
|
)
|
||||||
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
|
.unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
repository.join(".yoi/tickets/broken/item.md"),
|
||||||
|
"not a Ticket",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// TicketFeature accepts only an injected Workspace client and access policy;
|
||||||
|
// there is deliberately no repository/cwd path to pass to this installation.
|
||||||
let mut pending_tools = Vec::new();
|
let mut pending_tools = Vec::new();
|
||||||
let mut hooks = HookRegistryBuilder::default();
|
let mut hooks = HookRegistryBuilder::default();
|
||||||
let report = FeatureRegistryBuilder::new()
|
let report = FeatureRegistryBuilder::new()
|
||||||
.with_module(ticket_tools_feature_with_access(
|
.with_module(workspace_feature(TicketFeatureAccess::workspace_authoring()))
|
||||||
temp.path(),
|
|
||||||
TicketFeatureAccess::workspace_authoring(),
|
|
||||||
))
|
|
||||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
|
||||||
|
|
||||||
assert_eq!(pending_tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len());
|
|
||||||
assert_eq!(
|
|
||||||
report.reports[0].installed_tools,
|
|
||||||
WORKSPACE_AUTHORING_TOOL_NAMES
|
|
||||||
);
|
|
||||||
let description = pending_tool_description(&pending_tools, "TicketComment");
|
|
||||||
assert!(description.contains("Ticket record language: Japanese"));
|
|
||||||
assert!(description.contains("durable Ticket record and Ticket tool body text"));
|
|
||||||
assert!(description.contains("distinct from worker.language"));
|
|
||||||
assert!(description.contains("memory.language"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn installs_ticket_tools_when_default_root_is_usable() {
|
|
||||||
let temp = TempDir::new().unwrap();
|
|
||||||
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
|
|
||||||
let mut pending_tools = Vec::new();
|
|
||||||
let mut hooks = HookRegistryBuilder::default();
|
|
||||||
let report = FeatureRegistryBuilder::new()
|
|
||||||
.with_module(ticket_tools_feature(temp.path()))
|
|
||||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
|
||||||
|
|
||||||
assert_eq!(pending_tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len());
|
|
||||||
assert_eq!(report.reports.len(), 1);
|
|
||||||
assert!(report.reports[0].installed);
|
|
||||||
assert_eq!(
|
|
||||||
report.reports[0].installed_tools,
|
|
||||||
WORKSPACE_AUTHORING_TOOL_NAMES
|
|
||||||
);
|
|
||||||
assert!(report.reports[0].skipped.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn installs_ticket_tools_with_configured_backend_root() {
|
|
||||||
let temp = TempDir::new().unwrap();
|
|
||||||
write_ticket_config(
|
|
||||||
temp.path(),
|
|
||||||
r#"
|
|
||||||
[ticket.backend]
|
|
||||||
provider = "builtin:yoi_local"
|
|
||||||
root = "tickets"
|
|
||||||
|
|
||||||
[ticket.roles.coder]
|
|
||||||
profile = "project:coder"
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
make_ticket_root(&temp.path().join("tickets"));
|
|
||||||
|
|
||||||
let feature = ticket_tools_feature(temp.path());
|
|
||||||
assert_eq!(
|
|
||||||
feature.backend_root(),
|
|
||||||
Some(temp.path().join("tickets").as_path())
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut pending_tools = Vec::new();
|
|
||||||
let mut hooks = HookRegistryBuilder::default();
|
|
||||||
let report = FeatureRegistryBuilder::new()
|
|
||||||
.with_module(feature)
|
|
||||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
|
||||||
|
|
||||||
assert_eq!(pending_tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len());
|
|
||||||
assert!(report.reports[0].diagnostics.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn malformed_ticket_config_fails_closed() {
|
|
||||||
let temp = TempDir::new().unwrap();
|
|
||||||
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
|
|
||||||
write_ticket_config(
|
|
||||||
temp.path(),
|
|
||||||
r#"
|
|
||||||
[ticket.roles.operator]
|
|
||||||
profile = "inherit"
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
let mut pending_tools = Vec::new();
|
|
||||||
let mut hooks = HookRegistryBuilder::default();
|
|
||||||
let report = FeatureRegistryBuilder::new()
|
|
||||||
.with_module(ticket_tools_feature(temp.path()))
|
|
||||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
|
||||||
|
|
||||||
assert!(pending_tools.is_empty());
|
|
||||||
assert!(report.reports[0].installed_tools.is_empty());
|
|
||||||
assert_eq!(report.reports[0].diagnostics.len(), 1);
|
|
||||||
let message = &report.reports[0].diagnostics[0].message;
|
|
||||||
assert!(message.contains("Ticket tools not registered"));
|
|
||||||
assert!(message.contains("unsupported Ticket role `operator`"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unsupported_ticket_backend_provider_fails_closed() {
|
|
||||||
let temp = TempDir::new().unwrap();
|
|
||||||
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
|
|
||||||
write_ticket_config(
|
|
||||||
temp.path(),
|
|
||||||
r#"
|
|
||||||
[ticket.backend]
|
|
||||||
provider = "github"
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
let mut pending_tools = Vec::new();
|
|
||||||
let mut hooks = HookRegistryBuilder::default();
|
|
||||||
let report = FeatureRegistryBuilder::new()
|
|
||||||
.with_module(ticket_tools_feature(temp.path()))
|
|
||||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
|
||||||
|
|
||||||
assert!(pending_tools.is_empty());
|
|
||||||
assert!(report.reports[0].installed_tools.is_empty());
|
|
||||||
assert_eq!(report.reports[0].diagnostics.len(), 1);
|
|
||||||
let message = &report.reports[0].diagnostics[0].message;
|
|
||||||
assert!(message.contains("Ticket tools not registered"));
|
|
||||||
assert!(message.contains("unsupported Ticket backend provider `github`"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn does_not_register_ticket_tools_when_root_is_missing() {
|
|
||||||
let temp = TempDir::new().unwrap();
|
|
||||||
let mut pending_tools = Vec::new();
|
|
||||||
let mut hooks = HookRegistryBuilder::default();
|
|
||||||
let report = FeatureRegistryBuilder::new()
|
|
||||||
.with_module(ticket_tools_feature(temp.path()))
|
|
||||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
|
||||||
|
|
||||||
assert!(pending_tools.is_empty());
|
|
||||||
assert_eq!(report.reports.len(), 1);
|
|
||||||
assert!(report.reports[0].installed);
|
|
||||||
assert!(report.reports[0].installed_tools.is_empty());
|
|
||||||
assert_eq!(report.reports[0].diagnostics.len(), 1);
|
|
||||||
assert!(
|
|
||||||
report.reports[0].diagnostics[0]
|
|
||||||
.message
|
|
||||||
.contains("Ticket tools not registered")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn registers_ticket_tools_for_flat_backend_root() {
|
|
||||||
let temp = TempDir::new().unwrap();
|
|
||||||
let root = temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH);
|
|
||||||
std::fs::create_dir_all(&root).unwrap();
|
|
||||||
let mut pending_tools = Vec::new();
|
|
||||||
let mut hooks = HookRegistryBuilder::default();
|
|
||||||
let report = FeatureRegistryBuilder::new()
|
|
||||||
.with_module(ticket_tools_feature(temp.path()))
|
|
||||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
.install_into_pending(&mut pending_tools, &mut hooks);
|
||||||
|
|
||||||
assert_eq!(pending_tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len());
|
assert_eq!(pending_tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len());
|
||||||
@@ -1861,9 +1508,14 @@ provider = "github"
|
|||||||
WORKSPACE_AUTHORING_TOOL_NAMES
|
WORKSPACE_AUTHORING_TOOL_NAMES
|
||||||
);
|
);
|
||||||
assert!(report.reports[0].diagnostics.is_empty());
|
assert!(report.reports[0].diagnostics.is_empty());
|
||||||
assert!(!root.join("open").exists());
|
assert_eq!(
|
||||||
assert!(!root.join("pending").exists());
|
std::fs::read_to_string(ancestor.path().join(".yoi/workspace.toml")).unwrap(),
|
||||||
assert!(!root.join("closed").exists());
|
"not valid toml"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(repository.join(".yoi/tickets/broken/item.md")).unwrap(),
|
||||||
|
"not a Ticket"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
@@ -2057,10 +1709,12 @@ provider = "github"
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workspace_ticket_service_preserves_internal_identity_for_handoff() {
|
fn workspace_ticket_service_preserves_internal_identity_for_handoff() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let local = LocalTicketBackend::new(temp.path().join("tickets"));
|
let sqlite =
|
||||||
let created = local.create(NewTicket::new("Ticket handoff")).unwrap();
|
ticket::SqliteTicketBackend::open(temp.path().join("tickets.db"), "workspace-test")
|
||||||
let mut ticket = local.show(TicketIdOrSlug::Id(created.id.clone())).unwrap();
|
.unwrap();
|
||||||
|
let created = sqlite.create(NewTicket::new("Ticket handoff")).unwrap();
|
||||||
|
let mut ticket = sqlite.show(TicketIdOrSlug::Id(created.id.clone())).unwrap();
|
||||||
ticket.meta.resource_key = Some("T-548".to_string());
|
ticket.meta.resource_key = Some("T-548".to_string());
|
||||||
ticket.meta.workflow_state = TicketWorkflowState::Queued;
|
ticket.meta.workflow_state = TicketWorkflowState::Queued;
|
||||||
|
|
||||||
|
|||||||
@@ -3051,11 +3051,47 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
SqliteTicketBackend::open(&db_path, "workspace-test")
|
// Seed the active control-plane authority directly. The malformed legacy
|
||||||
|
// tree above is retained only as ignored regression input.
|
||||||
|
SqliteTicketBackend::open_verified(&db_path, "workspace-test").unwrap();
|
||||||
|
rusqlite::Connection::open(&db_path)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.import_from_local_backend(&ticket::LocalTicketBackend::new(
|
.execute_batch(
|
||||||
dir.path().join(".yoi/tickets"),
|
r#"
|
||||||
))
|
INSERT INTO typed_tickets (
|
||||||
|
workspace_id, ticket_id, slug, title, status, kind, priority, body,
|
||||||
|
created_at, updated_at, workflow_state, workflow_state_explicit
|
||||||
|
) VALUES
|
||||||
|
('workspace-test', '00000000001J2', '00000000001J2', 'Read bridge', 'open', 'ticket', 'P2',
|
||||||
|
'Ticket body. Deep Ticket marker. This body is intentionally long enough for bounded projection tests.',
|
||||||
|
'2026-01-01T00:00:00Z', '2026-01-03T00:00:00Z', 'ready', 1),
|
||||||
|
('workspace-test', '00000000001J5', '00000000001J5', 'Second ticket', 'open', 'ticket', 'P2',
|
||||||
|
'Ticket body', '2026-01-01T00:00:00Z', '2026-01-02T00:00:00Z', 'queued', 1),
|
||||||
|
('workspace-test', '00000000001J6', '00000000001J6', 'Third ticket', 'open', 'ticket', 'P2',
|
||||||
|
'Ticket body', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 'planning', 1);
|
||||||
|
|
||||||
|
INSERT INTO typed_ticket_events (
|
||||||
|
workspace_id, ticket_id, event_index, kind, author, at, heading, body
|
||||||
|
) VALUES
|
||||||
|
('workspace-test', '00000000001J2', 0, 'create', 'test', '2026-01-01T00:00:00Z', 'Created', 'created'),
|
||||||
|
('workspace-test', '00000000001J5', 0, 'create', 'test', '2026-01-01T00:00:00Z', 'Created', 'created'),
|
||||||
|
('workspace-test', '00000000001J6', 0, 'create', 'test', '2026-01-01T00:00:00Z', 'Created', 'created');
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
rusqlite::Connection::open(&db_path)
|
||||||
|
.unwrap()
|
||||||
|
.execute(
|
||||||
|
"UPDATE typed_tickets SET body = ?3 WHERE workspace_id = ?1 AND ticket_id = ?2",
|
||||||
|
rusqlite::params![
|
||||||
|
"workspace-test",
|
||||||
|
"00000000001J2",
|
||||||
|
format!(
|
||||||
|
"Ticket body.\n{}\nDeep Ticket marker.\n",
|
||||||
|
"x".repeat(70_000)
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
rusqlite::Connection::open(&db_path)
|
rusqlite::Connection::open(&db_path)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ project-record = { workspace = true }
|
|||||||
reqwest = { version = "0.13", default-features = false, features = ["json", "native-tls"] }
|
reqwest = { version = "0.13", default-features = false, features = ["json", "native-tls"] }
|
||||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
client = { workspace = true }
|
client = { workspace = true }
|
||||||
memory = { workspace = true }
|
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
manifest = { workspace = true }
|
manifest = { workspace = true }
|
||||||
worker = { workspace = true }
|
worker = { workspace = true }
|
||||||
@@ -26,7 +25,6 @@ ticket = { workspace = true }
|
|||||||
tui = { workspace = true }
|
tui = { workspace = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
serde_yaml = "0.9.34"
|
|
||||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "signal"] }
|
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "signal"] }
|
||||||
toml = { workspace = true }
|
toml = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Role
|
## Role
|
||||||
|
|
||||||
`yoi` is the product CLI facade. It owns the installed binary name, top-level argument parsing, normal TUI launch, and product subcommands such as `yoi worker` and `yoi memory lint`.
|
`yoi` is the product CLI facade. It owns the installed binary name, top-level argument parsing, normal TUI launch, and product subcommands such as `yoi worker` and `yoi ticket`.
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ pub(crate) enum CliCommand {
|
|||||||
Objective,
|
Objective,
|
||||||
Plugin,
|
Plugin,
|
||||||
Mcp,
|
Mcp,
|
||||||
MemoryLint,
|
|
||||||
Session,
|
Session,
|
||||||
Login,
|
Login,
|
||||||
}
|
}
|
||||||
@@ -46,7 +45,6 @@ impl CliCommand {
|
|||||||
CliCommand::Objective => "yoi objective",
|
CliCommand::Objective => "yoi objective",
|
||||||
CliCommand::Plugin => "yoi plugin",
|
CliCommand::Plugin => "yoi plugin",
|
||||||
CliCommand::Mcp => "yoi mcp",
|
CliCommand::Mcp => "yoi mcp",
|
||||||
CliCommand::MemoryLint => "yoi memory lint",
|
|
||||||
CliCommand::Session => "yoi session",
|
CliCommand::Session => "yoi session",
|
||||||
CliCommand::Login => "yoi login",
|
CliCommand::Login => "yoi login",
|
||||||
}
|
}
|
||||||
@@ -67,7 +65,6 @@ impl CliCommand {
|
|||||||
| CliCommand::WorkerCleanup
|
| CliCommand::WorkerCleanup
|
||||||
| CliCommand::Plugin
|
| CliCommand::Plugin
|
||||||
| CliCommand::Mcp
|
| CliCommand::Mcp
|
||||||
| CliCommand::MemoryLint
|
|
||||||
| CliCommand::Session => CliConnectionRequirement::LocalOnly,
|
| CliCommand::Session => CliConnectionRequirement::LocalOnly,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -319,7 +316,6 @@ mod tests {
|
|||||||
CliCommand::WorkerCleanup,
|
CliCommand::WorkerCleanup,
|
||||||
CliCommand::Plugin,
|
CliCommand::Plugin,
|
||||||
CliCommand::Mcp,
|
CliCommand::Mcp,
|
||||||
CliCommand::MemoryLint,
|
|
||||||
CliCommand::Session,
|
CliCommand::Session,
|
||||||
] {
|
] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
+30
-94
@@ -1,6 +1,5 @@
|
|||||||
mod cli_connection;
|
mod cli_connection;
|
||||||
mod mcp_cli;
|
mod mcp_cli;
|
||||||
mod memory_lint;
|
|
||||||
mod objective_cli;
|
mod objective_cli;
|
||||||
mod plugin_cli;
|
mod plugin_cli;
|
||||||
mod session_cli;
|
mod session_cli;
|
||||||
@@ -23,7 +22,6 @@ use cli_connection::{
|
|||||||
resolve_local_cli_connection,
|
resolve_local_cli_connection,
|
||||||
};
|
};
|
||||||
use client::{BackendAuthTarget, Target, TargetKind, start_device_login, wait_for_device_login};
|
use client::{BackendAuthTarget, Target, TargetKind, start_device_login, wait_for_device_login};
|
||||||
use memory_lint::{LintCliOptions, LintStatus};
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tui::{LaunchMode, LaunchOptions};
|
use tui::{LaunchMode, LaunchOptions};
|
||||||
use workspace_bootstrap::{
|
use workspace_bootstrap::{
|
||||||
@@ -35,8 +33,6 @@ enum Mode {
|
|||||||
Help,
|
Help,
|
||||||
ResumeHelp,
|
ResumeHelp,
|
||||||
WorkersHelp,
|
WorkersHelp,
|
||||||
MemoryLintHelp,
|
|
||||||
MemoryLint(LintCliOptions),
|
|
||||||
Mcp(mcp_cli::McpCliCommand),
|
Mcp(mcp_cli::McpCliCommand),
|
||||||
Plugin(plugin_cli::PluginCliCommand),
|
Plugin(plugin_cli::PluginCliCommand),
|
||||||
Objective {
|
Objective {
|
||||||
@@ -109,10 +105,6 @@ async fn run(mode: Mode) -> ExitCode {
|
|||||||
print_workers_help();
|
print_workers_help();
|
||||||
ExitCode::SUCCESS
|
ExitCode::SUCCESS
|
||||||
}
|
}
|
||||||
Mode::MemoryLintHelp => {
|
|
||||||
print_memory_lint_help();
|
|
||||||
ExitCode::SUCCESS
|
|
||||||
}
|
|
||||||
Mode::Login {
|
Mode::Login {
|
||||||
backend_url,
|
backend_url,
|
||||||
no_wait,
|
no_wait,
|
||||||
@@ -147,14 +139,6 @@ async fn run(mode: Mode) -> ExitCode {
|
|||||||
ExitCode::FAILURE
|
ExitCode::FAILURE
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Mode::MemoryLint(options) => match memory_lint::run(&options) {
|
|
||||||
Ok(LintStatus::Clean) => ExitCode::SUCCESS,
|
|
||||||
Ok(LintStatus::Failed) => ExitCode::FAILURE,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("yoi memory lint: {e}");
|
|
||||||
ExitCode::FAILURE
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Mode::Mcp(command) => match mcp_cli::run(command) {
|
Mode::Mcp(command) => match mcp_cli::run(command) {
|
||||||
Ok(()) => ExitCode::SUCCESS,
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -603,22 +587,6 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
|
|||||||
resolve_local_cli_connection(connection_resolver, CliCommand::SetupModel)?;
|
resolve_local_cli_connection(connection_resolver, CliCommand::SetupModel)?;
|
||||||
return Ok(Mode::SetupModel);
|
return Ok(Mode::SetupModel);
|
||||||
}
|
}
|
||||||
"memory" if args.get(1).map(String::as_str) == Some("lint") => {
|
|
||||||
let _target =
|
|
||||||
resolve_local_cli_connection(connection_resolver, CliCommand::MemoryLint)?;
|
|
||||||
let lint_args = &args[2..];
|
|
||||||
if lint_args.iter().any(|arg| arg == "--help" || arg == "-h") {
|
|
||||||
return Ok(Mode::MemoryLintHelp);
|
|
||||||
}
|
|
||||||
let options =
|
|
||||||
memory_lint::parse_lint_args(lint_args).map_err(|e| ParseError(e.to_string()))?;
|
|
||||||
return Ok(Mode::MemoryLint(options));
|
|
||||||
}
|
|
||||||
"memory" => {
|
|
||||||
return Err(ParseError(
|
|
||||||
"yoi memory requires the `lint` subcommand".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
other if !other.starts_with('-') => {
|
other if !other.starts_with('-') => {
|
||||||
return Err(ParseError(format!("unknown command `{other}`")));
|
return Err(ParseError(format!("unknown command `{other}`")));
|
||||||
}
|
}
|
||||||
@@ -1921,7 +1889,6 @@ Host commands:
|
|||||||
objective <COMMAND> Manage Objectives through a Backend target
|
objective <COMMAND> Manage Objectives through a Backend target
|
||||||
plugin <COMMAND> Author/check/pack explicit Plugin packages
|
plugin <COMMAND> Author/check/pack explicit Plugin packages
|
||||||
mcp <COMMAND> Inspect configured MCP servers
|
mcp <COMMAND> Inspect configured MCP servers
|
||||||
memory lint Lint local memory files
|
|
||||||
session <COMMAND> Inspect/prune Standalone session logs
|
session <COMMAND> Inspect/prune Standalone session logs
|
||||||
|
|
||||||
Standalone binaries:
|
Standalone binaries:
|
||||||
@@ -1985,12 +1952,6 @@ fn print_resume_help() {
|
|||||||
println!("{RESUME_HELP}");
|
println!("{RESUME_HELP}");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_memory_lint_help() {
|
|
||||||
println!(
|
|
||||||
"yoi memory lint\n\nUsage:\n yoi memory lint [OPTIONS]\n\nOptions:\n --workspace <PATH> Workspace root to lint (defaults to cwd)\n --json Emit a JSON report\n --warnings-as-errors Return failure when warnings are present\n -h, --help Print help\n"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -2621,9 +2582,36 @@ backend = "shared"
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_memory_without_lint_is_usage_error() {
|
fn removed_memory_command_is_unknown_without_touching_legacy_trees() {
|
||||||
let err = parse_args_from(["memory"]).unwrap_err();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
assert_eq!(err.to_string(), "yoi memory requires the `lint` subcommand");
|
let legacy = temp.path().join(".yoi/memory");
|
||||||
|
std::fs::create_dir_all(&legacy).unwrap();
|
||||||
|
let malformed = legacy.join("summary.md");
|
||||||
|
std::fs::write(&malformed, "not: [valid").unwrap();
|
||||||
|
|
||||||
|
let workspace = temp.path().to_string_lossy().into_owned();
|
||||||
|
for args in [
|
||||||
|
vec!["memory"],
|
||||||
|
vec!["memory", "lint"],
|
||||||
|
vec!["memory", "lint", "--help"],
|
||||||
|
vec!["memory", "lint", "--workspace", workspace.as_str()],
|
||||||
|
] {
|
||||||
|
let err = parse_args_from(args).unwrap_err();
|
||||||
|
assert_eq!(err.to_string(), "unknown command `memory`");
|
||||||
|
}
|
||||||
|
assert_eq!(std::fs::read_to_string(malformed).unwrap(), "not: [valid");
|
||||||
|
assert_eq!(std::fs::read_dir(&legacy).unwrap().count(), 1);
|
||||||
|
|
||||||
|
let production = include_str!("main.rs")
|
||||||
|
.split_once("#[cfg(test)]\nmod tests")
|
||||||
|
.map(|(production, _)| production)
|
||||||
|
.expect("CLI test module marker");
|
||||||
|
for forbidden in ["MemoryLint", "memory_lint", ".yoi/memory"] {
|
||||||
|
assert!(
|
||||||
|
!production.contains(forbidden),
|
||||||
|
"repository-local Memory CLI returned through {forbidden}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -3035,27 +3023,6 @@ backend = "shared"
|
|||||||
assert_eq!(err.to_string(), "yoi setup-model does not accept arguments");
|
assert_eq!(err.to_string(), "yoi setup-model does not accept arguments");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_memory_lint_mode() {
|
|
||||||
match parse_args_from([
|
|
||||||
"memory",
|
|
||||||
"lint",
|
|
||||||
"--workspace",
|
|
||||||
"/tmp/ws",
|
|
||||||
"--json",
|
|
||||||
"--warnings-as-errors",
|
|
||||||
])
|
|
||||||
.unwrap()
|
|
||||||
{
|
|
||||||
Mode::MemoryLint(options) => {
|
|
||||||
assert_eq!(options.workspace, Some(PathBuf::from("/tmp/ws")));
|
|
||||||
assert!(options.json);
|
|
||||||
assert!(options.warnings_as_errors);
|
|
||||||
}
|
|
||||||
_ => panic!("expected MemoryLint mode"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn plugin_cli_rejects_ambient_catalog_commands_and_options() {
|
fn plugin_cli_rejects_ambient_catalog_commands_and_options() {
|
||||||
for args in [
|
for args in [
|
||||||
@@ -3131,30 +3098,6 @@ backend = "shared"
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_memory_lint_rejects_usage_errors() {
|
|
||||||
let err = parse_args_from(["memory", "lint", "--workspace"]).unwrap_err();
|
|
||||||
assert_eq!(err.to_string(), "--workspace requires a value");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_memory_lint_workspace_equals() {
|
|
||||||
match parse_args_from(["memory", "lint", "--workspace=/tmp/ws"]).unwrap() {
|
|
||||||
Mode::MemoryLint(options) => {
|
|
||||||
assert_eq!(options.workspace, Some(PathBuf::from("/tmp/ws")));
|
|
||||||
assert!(!options.json);
|
|
||||||
assert!(!options.warnings_as_errors);
|
|
||||||
}
|
|
||||||
_ => panic!("expected MemoryLint mode"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn memory_lint_with_other_second_word_is_usage_error() {
|
|
||||||
let err = parse_args_from(["memory", "other"]).unwrap_err();
|
|
||||||
assert_eq!(err.to_string(), "yoi memory requires the `lint` subcommand");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_rejects_legacy_resume_flags() {
|
fn parse_rejects_legacy_resume_flags() {
|
||||||
let cases = [
|
let cases = [
|
||||||
@@ -3287,13 +3230,6 @@ backend = "shared"
|
|||||||
assert!(!RESUME_HELP.contains("local workspace"));
|
assert!(!RESUME_HELP.contains("local workspace"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_memory_lint_help() {
|
|
||||||
match parse_args_from(["memory", "lint", "--help"]).unwrap() {
|
|
||||||
Mode::MemoryLintHelp => {}
|
|
||||||
_ => panic!("expected MemoryLintHelp mode"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_backend_panel_uses_backend_dashboard_only() {
|
fn parse_backend_panel_uses_backend_dashboard_only() {
|
||||||
match parse_args_from([
|
match parse_args_from([
|
||||||
|
|||||||
@@ -1,507 +0,0 @@
|
|||||||
use std::fmt;
|
|
||||||
use std::io::{self, Write};
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use memory::linter::WriteMode;
|
|
||||||
use memory::{Linter, WorkspaceLayout};
|
|
||||||
use serde::Serialize;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct LintCliOptions {
|
|
||||||
pub workspace: Option<PathBuf>,
|
|
||||||
pub json: bool,
|
|
||||||
pub warnings_as_errors: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LintCliOptions {
|
|
||||||
fn workspace_root(&self) -> Result<PathBuf, LintCliError> {
|
|
||||||
let cwd = std::env::current_dir().map_err(LintCliError::CurrentDir)?;
|
|
||||||
Ok(match &self.workspace {
|
|
||||||
Some(path) if path.is_absolute() => path.clone(),
|
|
||||||
Some(path) => cwd.join(path),
|
|
||||||
None => cwd,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum UsageError {
|
|
||||||
MissingValue(&'static str),
|
|
||||||
UnknownArgument(String),
|
|
||||||
UnexpectedArgument(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for UsageError {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::MissingValue(flag) => write!(f, "{flag} requires a value"),
|
|
||||||
Self::UnknownArgument(arg) => write!(f, "unknown memory lint argument: {arg}"),
|
|
||||||
Self::UnexpectedArgument(arg) => write!(f, "unexpected memory lint argument: {arg}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_lint_args(args: &[String]) -> Result<LintCliOptions, UsageError> {
|
|
||||||
let mut options = LintCliOptions {
|
|
||||||
workspace: None,
|
|
||||||
json: false,
|
|
||||||
warnings_as_errors: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut i = 0;
|
|
||||||
while i < args.len() {
|
|
||||||
match args[i].as_str() {
|
|
||||||
"--json" => {
|
|
||||||
options.json = true;
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
"--warnings-as-errors" => {
|
|
||||||
options.warnings_as_errors = true;
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
"--workspace" => {
|
|
||||||
let raw = args
|
|
||||||
.get(i + 1)
|
|
||||||
.ok_or(UsageError::MissingValue("--workspace"))?;
|
|
||||||
if raw.starts_with('-') {
|
|
||||||
return Err(UsageError::MissingValue("--workspace"));
|
|
||||||
}
|
|
||||||
options.workspace = Some(PathBuf::from(raw));
|
|
||||||
i += 2;
|
|
||||||
}
|
|
||||||
arg if arg.starts_with("--workspace=") => {
|
|
||||||
let value = arg.trim_start_matches("--workspace=");
|
|
||||||
if value.is_empty() {
|
|
||||||
return Err(UsageError::MissingValue("--workspace"));
|
|
||||||
}
|
|
||||||
options.workspace = Some(PathBuf::from(value));
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
arg if arg.starts_with('-') => {
|
|
||||||
return Err(UsageError::UnknownArgument(arg.to_string()));
|
|
||||||
}
|
|
||||||
arg => return Err(UsageError::UnexpectedArgument(arg.to_string())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(options)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum LintStatus {
|
|
||||||
Clean,
|
|
||||||
Failed,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum LintCliError {
|
|
||||||
CurrentDir(io::Error),
|
|
||||||
Io { path: PathBuf, source: io::Error },
|
|
||||||
Output(io::Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for LintCliError {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::CurrentDir(source) => write!(f, "failed to resolve current directory: {source}"),
|
|
||||||
Self::Io { path, source } => write!(f, "io error at {}: {source}", path.display()),
|
|
||||||
Self::Output(source) => write!(f, "failed to write lint output: {source}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for LintCliError {
|
|
||||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
|
||||||
match self {
|
|
||||||
Self::CurrentDir(source) | Self::Io { source, .. } | Self::Output(source) => {
|
|
||||||
Some(source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run(options: &LintCliOptions) -> Result<LintStatus, LintCliError> {
|
|
||||||
let stdout = io::stdout();
|
|
||||||
run_with_writer(options, stdout.lock())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run_with_writer<W: Write>(
|
|
||||||
options: &LintCliOptions,
|
|
||||||
mut writer: W,
|
|
||||||
) -> Result<LintStatus, LintCliError> {
|
|
||||||
let workspace = options.workspace_root()?;
|
|
||||||
let report = lint_workspace(&workspace)?;
|
|
||||||
|
|
||||||
if options.json {
|
|
||||||
write_json_report(&mut writer, &report)?;
|
|
||||||
} else {
|
|
||||||
write_human_report(&mut writer, &report)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
if report.counts.errors > 0 || (options.warnings_as_errors && report.counts.warnings > 0) {
|
|
||||||
Ok(LintStatus::Failed)
|
|
||||||
} else {
|
|
||||||
Ok(LintStatus::Clean)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct WorkspaceLintReport {
|
|
||||||
pub workspace: String,
|
|
||||||
pub files: Vec<FileLintReport>,
|
|
||||||
pub errors: Vec<LintDiagnostic>,
|
|
||||||
pub warnings: Vec<LintDiagnostic>,
|
|
||||||
pub counts: LintCounts,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct FileLintReport {
|
|
||||||
pub path: String,
|
|
||||||
pub errors: Vec<String>,
|
|
||||||
pub warnings: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct LintDiagnostic {
|
|
||||||
pub path: String,
|
|
||||||
pub message: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default, Serialize)]
|
|
||||||
pub struct LintCounts {
|
|
||||||
pub files: usize,
|
|
||||||
pub errors: usize,
|
|
||||||
pub warnings: usize,
|
|
||||||
pub failing_files: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn lint_workspace(workspace: &Path) -> Result<WorkspaceLintReport, LintCliError> {
|
|
||||||
let layout = WorkspaceLayout::new(workspace.to_path_buf());
|
|
||||||
let linter = Linter::new(layout.clone());
|
|
||||||
let mut paths = collect_record_paths(&layout)?;
|
|
||||||
paths.sort();
|
|
||||||
|
|
||||||
let mut files = Vec::with_capacity(paths.len());
|
|
||||||
for path in paths {
|
|
||||||
let content = std::fs::read_to_string(&path).map_err(|source| LintCliError::Io {
|
|
||||||
path: path.clone(),
|
|
||||||
source,
|
|
||||||
})?;
|
|
||||||
let lint = linter.lint(&path, &content, WriteMode::Update);
|
|
||||||
files.push(FileLintReport {
|
|
||||||
path: display_path(workspace, &path),
|
|
||||||
errors: lint.errors.iter().map(ToString::to_string).collect(),
|
|
||||||
warnings: lint.warnings.iter().map(ToString::to_string).collect(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let counts = count_files(&files);
|
|
||||||
let errors = diagnostics_for(&files, DiagnosticKind::Error);
|
|
||||||
let warnings = diagnostics_for(&files, DiagnosticKind::Warning);
|
|
||||||
Ok(WorkspaceLintReport {
|
|
||||||
workspace: workspace.display().to_string(),
|
|
||||||
files,
|
|
||||||
errors,
|
|
||||||
warnings,
|
|
||||||
counts,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn collect_record_paths(layout: &WorkspaceLayout) -> Result<Vec<PathBuf>, LintCliError> {
|
|
||||||
let mut paths = Vec::new();
|
|
||||||
|
|
||||||
let summary = layout.summary_path();
|
|
||||||
if is_file(&summary)? {
|
|
||||||
paths.push(summary);
|
|
||||||
}
|
|
||||||
|
|
||||||
collect_md_files(&layout.decisions_dir(), &mut paths)?;
|
|
||||||
collect_md_files(&layout.requests_dir(), &mut paths)?;
|
|
||||||
|
|
||||||
Ok(paths)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_file(path: &Path) -> Result<bool, LintCliError> {
|
|
||||||
match std::fs::metadata(path) {
|
|
||||||
Ok(meta) => Ok(meta.is_file()),
|
|
||||||
Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(false),
|
|
||||||
Err(source) => Err(LintCliError::Io {
|
|
||||||
path: path.to_path_buf(),
|
|
||||||
source,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn collect_md_files(dir: &Path, paths: &mut Vec<PathBuf>) -> Result<(), LintCliError> {
|
|
||||||
let entries = match std::fs::read_dir(dir) {
|
|
||||||
Ok(entries) => entries,
|
|
||||||
Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(()),
|
|
||||||
Err(source) => {
|
|
||||||
return Err(LintCliError::Io {
|
|
||||||
path: dir.to_path_buf(),
|
|
||||||
source,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for entry in entries {
|
|
||||||
let entry = entry.map_err(|source| LintCliError::Io {
|
|
||||||
path: dir.to_path_buf(),
|
|
||||||
source,
|
|
||||||
})?;
|
|
||||||
let path = entry.path();
|
|
||||||
let file_type = entry.file_type().map_err(|source| LintCliError::Io {
|
|
||||||
path: path.clone(),
|
|
||||||
source,
|
|
||||||
})?;
|
|
||||||
if file_type.is_file() && path.extension().and_then(|ext| ext.to_str()) == Some("md") {
|
|
||||||
paths.push(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn diagnostics_for(files: &[FileLintReport], kind: DiagnosticKind) -> Vec<LintDiagnostic> {
|
|
||||||
files
|
|
||||||
.iter()
|
|
||||||
.flat_map(|file| {
|
|
||||||
let messages = match kind {
|
|
||||||
DiagnosticKind::Error => &file.errors,
|
|
||||||
DiagnosticKind::Warning => &file.warnings,
|
|
||||||
};
|
|
||||||
messages.iter().map(|message| LintDiagnostic {
|
|
||||||
path: file.path.clone(),
|
|
||||||
message: message.clone(),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
enum DiagnosticKind {
|
|
||||||
Error,
|
|
||||||
Warning,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn count_files(files: &[FileLintReport]) -> LintCounts {
|
|
||||||
files.iter().fold(
|
|
||||||
LintCounts {
|
|
||||||
files: files.len(),
|
|
||||||
..LintCounts::default()
|
|
||||||
},
|
|
||||||
|mut counts, file| {
|
|
||||||
counts.errors += file.errors.len();
|
|
||||||
counts.warnings += file.warnings.len();
|
|
||||||
if !file.errors.is_empty() {
|
|
||||||
counts.failing_files += 1;
|
|
||||||
}
|
|
||||||
counts
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn display_path(workspace: &Path, path: &Path) -> String {
|
|
||||||
path.strip_prefix(workspace)
|
|
||||||
.unwrap_or(path)
|
|
||||||
.display()
|
|
||||||
.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_human_report<W: Write>(
|
|
||||||
writer: &mut W,
|
|
||||||
report: &WorkspaceLintReport,
|
|
||||||
) -> Result<(), LintCliError> {
|
|
||||||
writeln!(writer, "Workspace: {}", report.workspace).map_err(LintCliError::Output)?;
|
|
||||||
for file in &report.files {
|
|
||||||
let status = if file.errors.is_empty() && file.warnings.is_empty() {
|
|
||||||
"ok"
|
|
||||||
} else if file.errors.is_empty() {
|
|
||||||
"warning"
|
|
||||||
} else {
|
|
||||||
"error"
|
|
||||||
};
|
|
||||||
writeln!(writer, "{status}: {}", file.path).map_err(LintCliError::Output)?;
|
|
||||||
for error in &file.errors {
|
|
||||||
writeln!(writer, " error: {error}").map_err(LintCliError::Output)?;
|
|
||||||
}
|
|
||||||
for warning in &file.warnings {
|
|
||||||
writeln!(writer, " warning: {warning}").map_err(LintCliError::Output)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writeln!(
|
|
||||||
writer,
|
|
||||||
"Summary: files={} errors={} warnings={} failing_files={}",
|
|
||||||
report.counts.files,
|
|
||||||
report.counts.errors,
|
|
||||||
report.counts.warnings,
|
|
||||||
report.counts.failing_files
|
|
||||||
)
|
|
||||||
.map_err(LintCliError::Output)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_json_report<W: Write>(
|
|
||||||
writer: &mut W,
|
|
||||||
report: &WorkspaceLintReport,
|
|
||||||
) -> Result<(), LintCliError> {
|
|
||||||
serde_json::to_writer_pretty(&mut *writer, report)
|
|
||||||
.map_err(|source| LintCliError::Output(io::Error::other(source)))?;
|
|
||||||
writeln!(writer).map_err(LintCliError::Output)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use serde_json::Value;
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
fn write(path: &Path, content: &str) {
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
std::fs::create_dir_all(parent).unwrap();
|
|
||||||
}
|
|
||||||
std::fs::write(path, content).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn valid_summary() -> &'static str {
|
|
||||||
"---\nupdated_at: 2026-05-31T00:00:00Z\n---\nsummary body\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn valid_request() -> &'static str {
|
|
||||||
"---\ncreated_at: 2026-05-31T00:00:00Z\nupdated_at: 2026-05-31T00:00:00Z\nsources: []\n---\nrequest body\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn warning_request() -> String {
|
|
||||||
format!(
|
|
||||||
"---\ncreated_at: 2026-05-31T00:00:00Z\nupdated_at: 2026-05-31T00:00:00Z\nsources:\n - segment_id: seg\n range: [0, 1]\n---\n{}\n",
|
|
||||||
"x".repeat(1500)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_lint_options() {
|
|
||||||
let args = vec![
|
|
||||||
"--workspace".to_string(),
|
|
||||||
"/tmp/ws".to_string(),
|
|
||||||
"--json".to_string(),
|
|
||||||
"--warnings-as-errors".to_string(),
|
|
||||||
];
|
|
||||||
let parsed = parse_lint_args(&args).unwrap();
|
|
||||||
assert_eq!(parsed.workspace, Some(PathBuf::from("/tmp/ws")));
|
|
||||||
assert!(parsed.json);
|
|
||||||
assert!(parsed.warnings_as_errors);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_lint_usage_errors() {
|
|
||||||
assert_eq!(
|
|
||||||
parse_lint_args(&["--workspace".to_string()]).unwrap_err(),
|
|
||||||
UsageError::MissingValue("--workspace")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parse_lint_args(&["--workspace".to_string(), "--json".to_string()]).unwrap_err(),
|
|
||||||
UsageError::MissingValue("--workspace")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parse_lint_args(&["--bogus".to_string()]).unwrap_err(),
|
|
||||||
UsageError::UnknownArgument("--bogus".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parse_lint_args(&["extra".to_string()]).unwrap_err(),
|
|
||||||
UsageError::UnexpectedArgument("extra".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lints_only_workspace_memory_records() {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let root = dir.path();
|
|
||||||
write(&root.join(".yoi/memory/summary.md"), valid_summary());
|
|
||||||
write(
|
|
||||||
&root.join(".yoi/memory/requests/request-one.md"),
|
|
||||||
valid_request(),
|
|
||||||
);
|
|
||||||
write(
|
|
||||||
&root.join(".yoi/memory/_logs/ignored.md"),
|
|
||||||
"not frontmatter",
|
|
||||||
);
|
|
||||||
|
|
||||||
let report = lint_workspace(root).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
report
|
|
||||||
.files
|
|
||||||
.iter()
|
|
||||||
.map(|file| file.path.as_str())
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
vec![
|
|
||||||
".yoi/memory/requests/request-one.md",
|
|
||||||
".yoi/memory/summary.md",
|
|
||||||
]
|
|
||||||
);
|
|
||||||
assert_eq!(report.counts.files, 2);
|
|
||||||
assert_eq!(report.counts.errors, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn invalid_records_count_as_lint_failures() {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let root = dir.path();
|
|
||||||
write(&root.join(".yoi/memory/summary.md"), "missing frontmatter");
|
|
||||||
|
|
||||||
let report = lint_workspace(root).unwrap();
|
|
||||||
assert_eq!(report.counts.files, 1);
|
|
||||||
assert_eq!(report.counts.errors, 1);
|
|
||||||
assert_eq!(report.counts.failing_files, 1);
|
|
||||||
assert!(report.files[0].errors[0].contains("frontmatter"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn warnings_as_errors_changes_status_without_changing_report() {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let root = dir.path();
|
|
||||||
write(
|
|
||||||
&root.join(".yoi/memory/requests/large-record.md"),
|
|
||||||
&warning_request(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut output = Vec::new();
|
|
||||||
let status = run_with_writer(
|
|
||||||
&LintCliOptions {
|
|
||||||
workspace: Some(root.to_path_buf()),
|
|
||||||
json: false,
|
|
||||||
warnings_as_errors: true,
|
|
||||||
},
|
|
||||||
&mut output,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(status, LintStatus::Failed);
|
|
||||||
let text = String::from_utf8(output).unwrap();
|
|
||||||
assert!(text.contains("warnings=1"));
|
|
||||||
assert!(text.contains("errors=0"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn json_output_is_machine_readable() {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let root = dir.path();
|
|
||||||
write(&root.join(".yoi/memory/summary.md"), valid_summary());
|
|
||||||
|
|
||||||
let mut output = Vec::new();
|
|
||||||
let status = run_with_writer(
|
|
||||||
&LintCliOptions {
|
|
||||||
workspace: Some(root.to_path_buf()),
|
|
||||||
json: true,
|
|
||||||
warnings_as_errors: false,
|
|
||||||
},
|
|
||||||
&mut output,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(status, LintStatus::Clean);
|
|
||||||
let parsed: Value = serde_json::from_slice(&output).unwrap();
|
|
||||||
assert_eq!(parsed["workspace"], root.display().to_string());
|
|
||||||
assert_eq!(parsed["counts"]["files"], 1);
|
|
||||||
assert_eq!(parsed["files"][0]["path"], ".yoi/memory/summary.md");
|
|
||||||
assert!(parsed["files"][0]["errors"].as_array().unwrap().is_empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
# Generated memory records
|
# Workspace Memory authority
|
||||||
|
|
||||||
Yoi keeps generated memory under `.yoi/memory/` for durable, low-volume context:
|
Generated Memory is durable Workspace context owned by the Workspace Server control plane. Runtime Workers query and mutate it through typed Workspace API operations; they do not read or write repository-local paths.
|
||||||
|
|
||||||
- `summary.md` is optional resident context.
|
The active Memory document and staging/audit lifecycle are stored by the Server-owned backend. Worker-side `memory` code contains only shared schema, extraction, audit, and transport types needed to construct those operations.
|
||||||
- `decisions/*.md` capture durable decisions and rationale.
|
|
||||||
- `requests/*.md` capture durable user preferences or standing requests.
|
|
||||||
|
|
||||||
Memory records are not workspace record authority for exact implementation state. Use tickets, objectives, repository files, git history, and session logs for exact current facts.
|
Repository or ancestor `.yoi/memory`, `.yoi/knowledge`, and malformed marker trees are ignored. There is no cwd/ancestor discovery, dual read, startup import, or automatic migration. The removed `yoi memory lint` command is intentionally not a compatibility surface; invoking `yoi memory ...` is an unknown command.
|
||||||
|
|
||||||
## Historical Knowledge records
|
Memory remains supporting context rather than implementation authority. Use Tickets, Objectives, repository files, Git history, and append-only session records for exact current facts.
|
||||||
|
|
||||||
Older workspaces may contain `.yoi/knowledge/`. Knowledge is no longer an active supported feature or workspace record authority. Current memory tooling ignores it; archive or inspect those files manually if needed.
|
## Upgrade guidance
|
||||||
|
|
||||||
Future Agent Skills are intentionally separate and are not implemented by this design note.
|
Old repository-local Memory data is not imported automatically. Before upgrading from a version that used `.yoi/memory`, explicitly export any data that must be retained with that old version and import it into the Workspace-backed product surface available to the deployment. Stale files may be archived or deleted after verification.
|
||||||
|
|
||||||
|
## Preserved filesystem boundaries
|
||||||
|
|
||||||
|
This removal does not affect repository checkouts, Git metadata, Runtime-owned Workdirs/session logs/artifacts, Server data directories, or XDG client/server configuration. It removes only ambient repository-local Memory authority.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ This decision records the layout policy for Ticket `00001KTTB479X`.
|
|||||||
|
|
||||||
## Roots
|
## Roots
|
||||||
|
|
||||||
- `role_workspace_root`: the launched role runtime workspace root and Ticket backend context.
|
- `role_workspace_root`: the launched role Runtime's explicit execution/workdir root. Ticket authority remains the Workspace Server control-plane database reached through the Workspace API.
|
||||||
- `original_workspace_root`: the repository root from which implementation worktrees should be created.
|
- `original_workspace_root`: the repository root from which implementation worktrees should be created.
|
||||||
- `implementation_worktree_root`: `<original_workspace_root>/.worktree`.
|
- `implementation_worktree_root`: `<original_workspace_root>/.worktree`.
|
||||||
- `merge_target_workspace_root`: the workspace/branch where merge-completion validates and merges.
|
- `merge_target_workspace_root`: the workspace/branch where merge-completion validates and merges.
|
||||||
@@ -15,9 +15,9 @@ When these roots differ, implementation worktrees must be created under `impleme
|
|||||||
|
|
||||||
## Policy
|
## Policy
|
||||||
|
|
||||||
- The filesystem Ticket backend remains authoritative for project records; this design does not introduce a Git-external Ticket store.
|
- Workspace Server Ticket records remain authoritative; none of these filesystem roots selects or hosts a Ticket backend.
|
||||||
- Main-workspace draft Tickets are not an implicit Orchestrator queue. Queue acceptance still requires explicit Ticket state and routing checks.
|
- Main-workspace draft Tickets are not an implicit Orchestrator queue. Queue acceptance still requires explicit Ticket state and routing checks.
|
||||||
- Implementation branches should not include unrelated Orchestrator Ticket churn. Orchestration progress stays in the Orchestrator workspace/Ticket backend; child implementation worktrees contain implementation diffs and branch-local artifacts only when needed.
|
- Implementation branches should not include unrelated Orchestrator bookkeeping. Orchestration progress stays in the Workspace Server Ticket/event record; child implementation worktrees contain implementation diffs and branch-local artifacts only when needed.
|
||||||
- Prompt and workflow guidance must treat merge-ready dossier as a checkpoint. If reviewer approval, clean/safe target workspace, and standing/user merge authority are present, Orchestrator should continue through merge, validation, Ticket close, and cleanup instead of stopping solely because a dossier exists.
|
- Prompt and workflow guidance must treat merge-ready dossier as a checkpoint. If reviewer approval, clean/safe target workspace, and standing/user merge authority are present, Orchestrator should continue through merge, validation, Ticket close, and cleanup instead of stopping solely because a dossier exists.
|
||||||
|
|
||||||
## Implemented in this branch
|
## Implemented in this branch
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ That rule shapes the crate split. The runtime can restart, attach, compact, or d
|
|||||||
|
|
||||||
## Core layers
|
## Core layers
|
||||||
|
|
||||||
- `yoi` owns the product CLI and top-level command shape. It is the façade that wires profile selection, memory linting, and normal TUI launch.
|
- `yoi` owns the product CLI and top-level command shape. It is the façade that wires connection selection, product commands, and normal TUI launch.
|
||||||
- `worker` turns a `Engine` into a named runtime entity with scope, session persistence, protocol handling, tools, and Worker metadata integration.
|
- `worker` turns a `Engine` into a named runtime entity with scope, session persistence, protocol handling, tools, and Worker metadata integration.
|
||||||
- `agen` owns model-facing turns: history append, retries, continuation, pruning/compaction mechanics, tool loops, and provider-independent callbacks.
|
- `agen` owns model-facing turns: history append, retries, continuation, pruning/compaction mechanics, tool loops, and provider-independent callbacks.
|
||||||
- `session-store` owns replayable append-only conversation/session logs.
|
- `session-store` owns replayable append-only conversation/session logs.
|
||||||
@@ -16,7 +16,7 @@ That rule shapes the crate split. The runtime can restart, attach, compact, or d
|
|||||||
- `client` contains reusable Backend Workspace/Runtime clients plus the shared `Target` boundary. Its normal targets are exactly Standalone and Backend; it is not a subprocess launcher or repository-local product backend.
|
- `client` contains reusable Backend Workspace/Runtime clients plus the shared `Target` boundary. Its normal targets are exactly Standalone and Backend; it is not a subprocess launcher or repository-local product backend.
|
||||||
- `manifest` resolves Profiles, Manifests, model/provider references, scopes, prompts, and tool permission policy into a runtime contract.
|
- `manifest` resolves Profiles, Manifests, model/provider references, scopes, prompts, and tool permission policy into a runtime contract.
|
||||||
- `tools` implements built-in tools with bounded output and policy-aware execution.
|
- `tools` implements built-in tools with bounded output and policy-aware execution.
|
||||||
- `memory` owns generated memory summary/decision/request records, linting, staging, and audit observations.
|
- `memory` owns shared Memory domain, extraction, audit-event, and Workspace API transport types. Persistence and lifecycle execution belong to the Workspace Server control plane.
|
||||||
- `workspace-server` is the local Workspace control-plane seam. It can project Tickets, Workers, lifecycle, usage, and orchestration events, but browser/API operations must stay on opaque backend identities instead of raw local paths, sockets, Worker names, or session files.
|
- `workspace-server` is the local Workspace control-plane seam. It can project Tickets, Workers, lifecycle, usage, and orchestration events, but browser/API operations must stay on opaque backend identities instead of raw local paths, sockets, Worker names, or session files.
|
||||||
- `tui` is a UI over either one in-process Standalone session or Backend Workspace/Runtime Worker authority; it should not invent a local Worker catalog or durable product state.
|
- `tui` is a UI over either one in-process Standalone session or Backend Workspace/Runtime Worker authority; it should not invent a local Worker catalog or durable product state.
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
# Tickets and development workflow
|
# Tickets and development workflow
|
||||||
|
|
||||||
Yoi project work is tracked through Tickets. For normal use, interact with Tickets through `yoi panel`, Ticket tools, the `yoi ticket ...` CLI, and typed role surfaces. Git history plus Ticket files remain the authoritative state-transition record behind those interfaces.
|
Yoi project work is tracked through Tickets. For normal use, interact with Tickets through `yoi panel`, Ticket tools, the `yoi ticket ...` CLI, and typed role surfaces. Workspace Server control-plane records plus the append-only Ticket event and Merge Request evidence streams are authoritative.
|
||||||
|
|
||||||
The current local backend stores each Ticket in the flat `.yoi/tickets/<ticket-id>/` layout. The directory name is the canonical opaque Ticket id: a fixed-width Crockford base32 Unix epoch millisecond timestamp. Slugs and frontmatter `id`/`slug` fields are not current-state authority. That storage detail matters for maintainers and backend compatibility, but it is not the primary user-facing workflow.
|
Ticket and Objective records are stored in the Workspace Server database. Repository-local `.yoi/tickets`, `.yoi/objectives`, and config files are ignored legacy input, not a debugging or compatibility backend.
|
||||||
|
|
||||||
Do not treat ad-hoc chat summaries, memory records, or Worker notifications as the final source of project state. Notifications are hints to inspect concrete state, not proof of completion.
|
Do not treat ad-hoc chat summaries, memory records, or Worker notifications as the final source of project state. Notifications are hints to inspect concrete state, not proof of completion.
|
||||||
|
|
||||||
## Concepts
|
## Concepts
|
||||||
|
|
||||||
- `Ticket`: durable project/orchestration record. It contains requirements, decisions, plans, implementation reports, reviews, artifacts, and resolution history.
|
- `Ticket`: durable project/orchestration record. It contains requirements, decisions, plans, implementation reports, reviews, artifacts, and resolution history.
|
||||||
- `Objective`: first-class medium-term goal record. It stores goal, motivation/background, strategy/design direction, success criteria/exit conditions, decision context, current Objective lifecycle, and canonical Ticket links under `.yoi/objectives/<objective-id>/item.md`. Objective context is judgment/background context; it is not implementation authority and does not replace reading each Ticket body/thread/artifacts.
|
- `Objective`: first-class medium-term goal record stored by the Workspace Server. It stores goal, motivation/background, strategy/design direction, success criteria/exit conditions, decision context, current Objective lifecycle, and canonical Ticket links. Objective context is judgment/background context; it is not implementation authority and does not replace reading each Ticket body, thread, and evidence.
|
||||||
- `Task`: session-local progress tracking inside a Worker. It is not the project record.
|
- `Task`: session-local progress tracking inside a Worker. It is not the project record.
|
||||||
- `Assignment`: a concrete delegation from an Orchestrator to a coder/reviewer Worker or task-specific helper Worker.
|
- `Assignment`: a concrete delegation from an Orchestrator to a coder/reviewer Worker or task-specific helper Worker.
|
||||||
- `IntentPacket`: the short implementation/review contract derived from a Ticket and handed to an Assignment.
|
- `IntentPacket`: the short implementation/review contract derived from a Ticket and handed to an Assignment.
|
||||||
- `LocalTicketBackend`: the current `.yoi/tickets/` markdown/thread/artifacts storage backend.
|
- `Ticket backend`: the Workspace Server control-plane database exposed through typed Workspace APIs.
|
||||||
- `Ticket relation`: durable project-level Ticket-to-Ticket metadata stored as forward canonical-id relations (`depends_on`, `blocks`, `related`, `supersedes`, `duplicate_of`). Inverse views such as `blocked_by` are derived, not stored.
|
- `Ticket relation`: durable project-level Ticket-to-Ticket metadata stored as forward canonical-id relations (`depends_on`, `blocks`, `related`, `supersedes`, `duplicate_of`). Inverse views such as `blocked_by` are derived, not stored.
|
||||||
|
|
||||||
A Ticket may represent a feature, bug, cleanup, design decision, investigation, workflow change, release task, or orchestration task. The common requirement is that the Ticket is a concrete work item that can be implemented, reviewed, validated, and closed on its own terms.
|
A Ticket may represent a feature, bug, cleanup, design decision, investigation, workflow change, release task, or orchestration task. The common requirement is that the Ticket is a concrete work item that can be implemented, reviewed, validated, and closed on its own terms.
|
||||||
@@ -27,7 +27,7 @@ Use the highest-level interface that matches the work:
|
|||||||
- Inside Workers, use typed Ticket tools for Ticket records and typed Merge Request tools for immutable implementation/review/completion evidence.
|
- Inside Workers, use typed Ticket tools for Ticket records and typed Merge Request tools for immutable implementation/review/completion evidence.
|
||||||
- For multi-step work, follow the typed Ticket role surfaces and recorded Ticket lifecycle gates.
|
- For multi-step work, follow the typed Ticket role surfaces and recorded Ticket lifecycle gates.
|
||||||
|
|
||||||
Maintainers can inspect the local `.yoi/tickets/` files directly when debugging storage, but normal user instructions should go through `yoi panel`, Ticket tools, or `yoi ticket ...`.
|
Maintainers inspect Ticket state through the authenticated Workspace API, Server administration surfaces, and database diagnostics. Repository-local Ticket files are not storage authority.
|
||||||
|
|
||||||
## Ticket tools inside Workers
|
## Ticket tools inside Workers
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ Workers with the Ticket and operation-specific Merge Request built-in features c
|
|||||||
|
|
||||||
Profile-visible Ticket catalogs are intentionally smaller than the former broad read catalog: Workspace authoring exposes 9 tools instead of 13, workflow exposes 10 instead of 12, and review exposes only `QueryTicket` plus `ShowTicket` (2 instead of 6). The `QueryTicket` schema is regression-guarded below 8 KiB while consolidating relation/evidence/attention discovery; diagnostics are not projected into normal profiles, while specialized orchestration-plan commands remain visible only to workflow roles that need their distinct semantics.
|
Profile-visible Ticket catalogs are intentionally smaller than the former broad read catalog: Workspace authoring exposes 9 tools instead of 13, workflow exposes 10 instead of 12, and review exposes only `QueryTicket` plus `ShowTicket` (2 instead of 6). The `QueryTicket` schema is regression-guarded below 8 KiB while consolidating relation/evidence/attention discovery; diagnostics are not projected into normal profiles, while specialized orchestration-plan commands remain visible only to workflow roles that need their distinct semantics.
|
||||||
|
|
||||||
These tools operate through the typed Ticket backend. They are not arbitrary filesystem write permission to `.yoi/tickets/`.
|
These tools operate through the typed Workspace Ticket API. They do not grant arbitrary filesystem access and never select repository-local Ticket storage.
|
||||||
|
|
||||||
Relation tools are for non-hierarchical project metadata only. Use canonical opaque Ticket ids, store forward relations only, and keep runtime execution planning (capacity, ordering decisions, do-not-parallelize notes, Worker/session/worktree ownership) in OrchestrationPlan or session-local records instead of relation metadata. Unresolved `depends_on` and incoming unresolved `blocks` are queue/acceptance blockers; `related` is not blocking, and `supersedes` / `duplicate_of` are diagnostics rather than automatic lifecycle transitions.
|
Relation tools are for non-hierarchical project metadata only. Use canonical opaque Ticket ids, store forward relations only, and keep runtime execution planning (capacity, ordering decisions, do-not-parallelize notes, Worker/session/worktree ownership) in OrchestrationPlan or session-local records instead of relation metadata. Unresolved `depends_on` and incoming unresolved `blocks` are queue/acceptance blockers; `related` is not blocking, and `supersedes` / `duplicate_of` are diagnostics rather than automatic lifecycle transitions.
|
||||||
|
|
||||||
@@ -60,51 +60,11 @@ Do not bypass Ticket lifecycle gates just because Ticket tools are available. Ti
|
|||||||
|
|
||||||
## Objective records
|
## Objective records
|
||||||
|
|
||||||
Objectives are lightweight medium-term project records, not Tickets, Ticket relations, OrchestrationPlan execution records, or Worker/session claims. Use them when a goal spans several concrete Tickets and the durable motivation, design direction, success criteria, or decision context would otherwise be repeated or lost.
|
Objectives are Workspace Server control-plane records, not files in a checkout. Use typed Objective tools or `yoi objective ...`; both resolve the selected Backend/Workspace through the shared client target and call the Workspace API.
|
||||||
|
|
||||||
The local Objective surface stores records under:
|
Objective-to-Ticket links are context links only: they are not dependency, blocking, ordering, ownership, or scheduling relations. Objective lifecycle does not drive Ticket state or authorize implementation. A role reading Objective context must still inspect each Ticket and its current Merge Request evidence.
|
||||||
|
|
||||||
```text
|
Repository-local `.yoi/objectives` trees are ignored. There is no automatic import or cwd/ancestor fallback.
|
||||||
.yoi/objectives/<objective-id>
|
|
||||||
item.md
|
|
||||||
```
|
|
||||||
|
|
||||||
`<objective-id>` is the canonical opaque path-derived id: a fixed-width Crockford base32 Unix epoch millisecond timestamp. Do not treat Objective titles or slug words as link authority.
|
|
||||||
|
|
||||||
`item.md` uses YAML frontmatter plus Markdown body:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
---
|
|
||||||
title: "Improve orchestration evidence"
|
|
||||||
state: "active" # active|paused|done|archived
|
|
||||||
created_at: "2026-06-09T00:00:00Z"
|
|
||||||
updated_at: "2026-06-09T00:00:00Z"
|
|
||||||
linked_tickets: ["00001KTKMS0VG"]
|
|
||||||
---
|
|
||||||
```
|
|
||||||
|
|
||||||
The Markdown body should include these sections:
|
|
||||||
|
|
||||||
- `## Goal`
|
|
||||||
- `## Motivation / background`
|
|
||||||
- `## Strategy / design direction`
|
|
||||||
- `## Success criteria / exit conditions`
|
|
||||||
- `## Decision context`
|
|
||||||
|
|
||||||
Linked Tickets must be canonical opaque Ticket ids that exist in the configured Ticket backend root. Objective-to-Ticket links are context links only: they are not dependency, blocking, ordering, ownership, or scheduling relations. Use typed Ticket relations for Ticket-to-Ticket dependency/blocking/related metadata, OrchestrationPlan records for routing/execution plans, and Worker/session claims for runtime ownership hints.
|
|
||||||
|
|
||||||
Objective lifecycle is only Objective lifecycle. `active`, `paused`, `done`, and `archived` do not drive Ticket `state`, do not authorize implementation, and do not close linked Tickets. A role reading Objective context must still inspect each Ticket body, thread, artifacts, explicit Ticket relations, and OrchestrationPlan records before acting.
|
|
||||||
|
|
||||||
The maintainer CLI is:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
yoi objective create --title "..." [--ticket <ticket-id> ...]
|
|
||||||
yoi objective list [--state active|paused|done|archived|all]
|
|
||||||
yoi objective show <objective-id>
|
|
||||||
yoi objective doctor
|
|
||||||
```
|
|
||||||
|
|
||||||
The first version intentionally does not implement roadmap scheduling, milestones, OKRs, graph solving, Objective-mandatory Ticket creation, Objective thread/artifact history, or broad panel UX. Future UX can surface Objective context around Tickets as long as it remains background context and never substitutes for reading the Ticket.
|
|
||||||
|
|
||||||
## Ticket configuration
|
## Ticket configuration
|
||||||
|
|
||||||
@@ -268,40 +228,15 @@ A useful Ticket states:
|
|||||||
- reviews;
|
- reviews;
|
||||||
- final resolution when closed.
|
- final resolution when closed.
|
||||||
|
|
||||||
Keep long research dumps out of the item body. Put necessary artifacts under the Ticket's `artifacts/` directory and summarize the conclusion in the thread.
|
Keep long research dumps out of the item body. Attach necessary bounded artifacts through the Ticket evidence surface and summarize the conclusion in the thread.
|
||||||
|
|
||||||
Do not store secrets, credentials, private prompt contents, or raw logs containing secrets in Ticket bodies, thread entries, artifacts, diagnostics, or model-visible prompts.
|
Do not store secrets, credentials, private prompt contents, or raw logs containing secrets in Ticket bodies, thread entries, artifacts, diagnostics, or model-visible prompts.
|
||||||
|
|
||||||
## Backend/maintainer CLI: `yoi ticket`
|
## Backend CLI: `yoi ticket`
|
||||||
|
|
||||||
The product CLI exposes the typed Ticket backend for repository maintenance and validation. It operates on the configured `.yoi/tickets/` storage and is the preferred command-line surface when editing Tickets outside a Worker.
|
The product CLI resolves an explicit or configured Backend target and performs Ticket operations through the Workspace API. It does not open a repository-local backend. Use command help for the current typed command surface.
|
||||||
|
|
||||||
```sh
|
Legacy `ticket init` and `ticket import-local` workflows are unsupported. No automatic migration reads old `.yoi/tickets` trees; export data with an older version before upgrading if it must be retained, then import it through an explicit supported Workspace surface.
|
||||||
yoi ticket create --title "..." [--priority P2]
|
|
||||||
yoi ticket list [--state planning|ready|queued|inprogress|done|closed|all] [--limit n]
|
|
||||||
yoi ticket show <id>
|
|
||||||
yoi ticket comment <id> [--role comment|plan|decision|implementation_report] [--file path]
|
|
||||||
yoi ticket review <id> --approve|--request-changes [--file path]
|
|
||||||
yoi ticket state <id> <planning|ready|queued|inprogress|done>
|
|
||||||
yoi ticket close <id> [--resolution text|--file path]
|
|
||||||
yoi ticket doctor
|
|
||||||
```
|
|
||||||
|
|
||||||
`yoi ticket list` is a capped overview/selection command. It should remain readable for humans and safe for model context: use it to find a canonical id, then use `yoi ticket show <id>` before routing, closing, planning, or implementation decisions.
|
|
||||||
|
|
||||||
`yoi ticket state` records current lifecycle transitions among active states. Closing must use `yoi ticket close` so the backend writes the required `resolution.md` and passes `yoi ticket doctor`; `done` and `closed` remain distinct states.
|
|
||||||
|
|
||||||
The current LocalTicketBackend stores records under:
|
|
||||||
|
|
||||||
```text
|
|
||||||
.yoi/tickets/<ticket-id>
|
|
||||||
item.md
|
|
||||||
thread.md
|
|
||||||
artifacts/
|
|
||||||
resolution.md # closed Tickets only
|
|
||||||
```
|
|
||||||
|
|
||||||
Backend integrations must preserve this format until an explicit migration changes it. `thread.md` is an append-only typed event log: existing events such as `create`, `comment`, `plan`, `decision`, `implementation_report`, `review`, `state_changed`, and `close` remain valid, while `state_changed` records durable transition metadata (`from`, `to`, `reason`, optional `field`, plus `author` and `at`) and `intake_summary` records the bounded Intake outcome body. Thread events are audit history, not current-state authority; current state belongs in `item.md` frontmatter or the owning backend record. The repository-root `work-items/` path is no longer a live mutable backend; do not recreate it for Ticket records. Human users should prefer `yoi panel`, Ticket tools, or `yoi ticket ...` when working directly with repository records.
|
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Repository-local `.yoi` authority removal
|
||||||
|
|
||||||
|
This release removes the remaining production readers, writers, discovery helpers, fallbacks, and import paths that treated a repository-local `.yoi` tree as product authority.
|
||||||
|
|
||||||
|
Removed surfaces include:
|
||||||
|
|
||||||
|
- Ticket filesystem config loading, `LocalTicketBackend`, and local-to-SQLite import helpers;
|
||||||
|
- Worker Ticket feature constructors backed by local paths;
|
||||||
|
- Memory repository layout/discovery, local resident/query/staging/audit/usage executors, and `yoi memory lint`;
|
||||||
|
- the Runtime helper that derived its store from `<workspace>/.yoi/runtime-store`.
|
||||||
|
|
||||||
|
Workspace identity, metadata, Ticket, Objective, Memory, and launch configuration remain owned by the Workspace Server control-plane database and typed Workspace APIs. Client routing remains in XDG client configuration. Runtime-owned Workdirs, session logs, artifacts, Server data directories, repository/Git files, explicit Plugin package files, and the legacy home-directory secret-store boundary are unchanged.
|
||||||
|
|
||||||
|
There is no automatic migration or compatibility fallback. Before upgrading from a version that stored active Ticket or Memory data in a repository `.yoi` tree, explicitly export data with the old version and import it through a supported Workspace-backed surface. Stale or malformed repository/ancestor `.yoi` trees are ignored by normal operation and may be archived or removed after verification.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# T-452 final repository-local `.yoi` authority audit
|
||||||
|
|
||||||
|
Date: 2026-09-22
|
||||||
|
Objective: O-12
|
||||||
|
Ticket: T-452
|
||||||
|
|
||||||
|
## Baseline and method
|
||||||
|
|
||||||
|
The implementation branch was created from `develop` at `043cb7bf22bd59d1d553e3f5a7c86c20502fad30` after confirming that ref was the then-current provider-resolved `origin/develop`. This audit follows normal entrypoints through constructors and typed backends to their I/O boundary. A raw `.yoi` string search was used only to enumerate candidates; strings in comments, negative regression fixtures, explicit artifacts, and preserved storage boundaries are not treated as authority by themselves.
|
||||||
|
|
||||||
|
The final pre-review pass must refresh from `develop` after T-629 is integrated. T-629 owns conversion of the existing panel E2E setup; this change neither restores nor duplicates that legacy fixture.
|
||||||
|
|
||||||
|
## Reachability and disposition
|
||||||
|
|
||||||
|
| Surface | Entry point and pre-change path | Disposition |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Ticket config | `TicketConfig::load_workspace` read `<workspace>/.yoi/workspace.toml`, fell back to `.yoi/ticket.config.toml`, and defaulted to `.yoi/tickets`. | Removed the filesystem loader, backend path/provider types, fallback parser, and path resolution. `ticket::config` now contains only filesystem-independent fixed-role domain types. |
|
||||||
|
| Ticket provider | Public `LocalTicketBackend` performed Ticket tree reads/writes. `SqliteTicketBackend::import_from_local_backend` converted that tree into SQLite. | Removed both production providers/helpers and their filesystem-only tests. SQLite fixtures now seed `typed_tickets`/events directly. Deliberately stale Ticket/Objective trees remain only as ignored-input regressions. |
|
||||||
|
| Worker Ticket tools | `TicketFeatureBackend::Local`, `TicketFeature::for_workspace[_with_access]`, and local constructors could install filesystem-backed tools even though the normal controller already selected a Workspace client. | Removed the local variant and constructors. The feature now accepts only an injected `Arc<dyn WorkspaceClient>` and constructs `WorkspaceHttpTicketBackend`; there is no provider-selection enum or filesystem path parameter. |
|
||||||
|
| Memory CLI | `yoi memory lint` -> `memory_lint::run` -> cwd/`--workspace` -> `WorkspaceLayout` -> filesystem linter. | Removed the mode, parser/help branch, module, and CLI dependency. Old invocations fail as unknown before target/cwd resolution and do not read or mutate malformed legacy input. No replacement Memory UX or compatibility shim was added. |
|
||||||
|
| Memory provider | `WorkspaceLayout::resolve` searched cwd/ancestors and supplied `.yoi/memory` paths to query/resident, staging, consolidation, audit, usage, scope, and lint implementations. `execute_memory_backend_operation` dispatched operations to that layout. | Removed layout/discovery, local executor, resident/scope/usage writers, extraction/consolidation staging files, filesystem audit implementation, and path-scanning linter modules. Kept Backend operation DTOs, extraction payload/tool contracts, audit event DTOs, provenance/frontmatter schema helpers, and lint-common slug types because they are filesystem-independent shared domain/API types. Workspace Server SQLite authority remains the Memory persistence executor. |
|
||||||
|
| Runtime store | Public `WorkerRuntimeExecutionBackend::from_workspace` derived `<workspace>/.yoi/runtime-store`. | Removed. Runtime callers continue to pass explicit Runtime-owned store/config paths; no repository fallback was introduced. |
|
||||||
|
| Workspace/client/bootstrap | Normal `yoi init`, client target resolution, Workspace catalog/metadata, Server bootstrap, and repository mapping were already cut over by T-468. | Preserved. Existing root/ancestor stale-marker and clean-init tests continue to assert Server DB/API plus XDG client routing, with no repository write. |
|
||||||
|
| Profile/Skill/Prompt | Workspace projections were already sourced from the Server virtual config tree; direct Worker startup uses explicit manifest/builtin authority. | Preserved. Existing malformed local profile/prompt/skill fixtures are negative tests. A clean direct Worker-startup regression now verifies that manifest resolution does not create `.yoi`. |
|
||||||
|
| Plugin | T-603 already removed ambient `.yoi/plugins` discovery. | Preserved the source assertions and explicit `.yoi-plugin` offline package operations. No install/runtime fallback was added. |
|
||||||
|
| Objective | Worker Objective tools already require Workspace API authority; Workspace Server stores Objective rows in SQLite. | Preserved. Filesystem-only Objective fixtures are ignored-input regressions, not import setup. |
|
||||||
|
| Web/TUI | Both consume Workspace HTTP/client models and do not construct repository Ticket/Memory backends. | Preserved. Their checks validate the current typed API/routing surfaces; T-629 separately owns the panel E2E fixture modernization. |
|
||||||
|
|
||||||
|
## Regression evidence added or strengthened
|
||||||
|
|
||||||
|
- Source assertions reject the concrete production provider/constructor chain rather than banning every `.yoi` string: Ticket config/provider/import, Worker local Ticket feature, Memory layout/executor/staging entrypoints (including every compiled extraction implementation module), Runtime `from_workspace`, and CLI `memory_lint` mode.
|
||||||
|
- `TicketFeature` has no repository/cwd input: installation accepts only an authority-scoped Workspace client and access policy. A regression verifies that separately present malformed repository-root Ticket input and malformed ancestor Workspace input remain byte-for-byte unchanged while the Workspace tool surface installs.
|
||||||
|
- The removed Memory CLI is unknown with bare, `lint`, help, and legacy `--workspace` forms; a malformed `.yoi/memory` fixture remains byte-for-byte unchanged.
|
||||||
|
- Direct Worker manifest resolution against a clean explicit workspace does not create `.yoi`. A Runtime integration regression additionally creates a Worker from a clean Git repository, executes a turn, stops/restores the Worker, and verifies that launch and restore never create repository-local `.yoi` authority.
|
||||||
|
- Ticket tool mutation uses a direct SQLite backend and asserts `tickets.db` is written while `.yoi`/`tickets` are not.
|
||||||
|
- Workspace Server authority seeds SQLite Ticket/Objectives directly, proves filesystem-only records are ignored, and proves legacy Memory staging cannot enter the Backend consolidation backlog.
|
||||||
|
|
||||||
|
## Remaining `.yoi` classifications
|
||||||
|
|
||||||
|
The post-change source inventory contains the following intentional classes:
|
||||||
|
|
||||||
|
1. **Negative regression input or assertions:** stale/malformed Workspace, Ticket, Objective, Memory, Profile, Skill, Prompt, and Plugin trees; assertions that clean operation creates no `.yoi`; leak-prevention fixtures.
|
||||||
|
2. **Preserved runtime/server boundaries:** Runtime-owned Workdirs and run artifacts, explicit Server data/config directories, session-store compatibility for the legacy home-directory secret/session boundary, and generated ignore files that prevent execution artifacts from entering Git.
|
||||||
|
3. **Explicit artifacts:** repository/Git files selected by the user and `.yoi-plugin` package paths passed directly to offline `new`/`check`/`pack` commands.
|
||||||
|
4. **Documentation/history:** current guidance describing removal and historical reports retained as historical evidence.
|
||||||
|
|
||||||
|
None of these classes performs cwd/ancestor authority discovery, automatic import, dual read, or repository-local fallback. The repository checkout and Git metadata remain ordinary filesystem inputs; removing filesystem use in general is outside T-452.
|
||||||
|
|
||||||
|
## Upgrade contract
|
||||||
|
|
||||||
|
There is no automatic migration, startup import, compatibility reader, or delete pass. Deployments that still need legacy repository-local Ticket or Memory data must export it with the old version before upgrading and import it through a supported Workspace-backed surface. Stale trees are ignored and can be archived or removed only after operator verification.
|
||||||
Reference in New Issue
Block a user