From 5fb3cb20cdeae3c850ddff3a0d15f32b4e8298d7 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 22 Sep 2026 22:52:41 +0900 Subject: [PATCH] refactor: remove repository-local yoi authority --- Cargo.lock | 12 - README.md | 2 +- crates/memory/Cargo.toml | 7 - crates/memory/README.md | 26 +- crates/memory/src/audit.rs | 217 +- crates/memory/src/backend.rs | 378 +- crates/memory/src/consolidate/mod.rs | 12 - crates/memory/src/consolidate/staging.rs | 210 - crates/memory/src/error.rs | 116 - crates/memory/src/extract/mod.rs | 22 +- crates/memory/src/extract/staging.rs | 142 - crates/memory/src/lib.rs | 59 +- crates/memory/src/linter/existing.rs | 121 - crates/memory/src/linter/frontmatter.rs | 64 - crates/memory/src/linter/mod.rs | 447 -- crates/memory/src/linter/references.rs | 67 - crates/memory/src/linter/size.rs | 15 - crates/memory/src/linter/warnings.rs | 106 - crates/memory/src/resident.rs | 91 - crates/memory/src/schema/common.rs | 18 +- crates/memory/src/schema/mod.rs | 8 +- crates/memory/src/scope.rs | 44 - crates/memory/src/usage.rs | 371 -- crates/memory/src/workspace.rs | 363 -- crates/ticket/Cargo.toml | 4 - crates/ticket/src/config.rs | 1457 +------ crates/ticket/src/lib.rs | 3712 +---------------- crates/ticket/src/tool.rs | 65 +- crates/worker-runtime/src/worker_backend.rs | 31 +- crates/worker/src/controller.rs | 11 +- crates/worker/src/entrypoint.rs | 14 + crates/worker/src/feature/builtin.rs | 5 +- crates/worker/src/feature/builtin/ticket.rs | 552 +-- crates/workspace-server/src/authority.rs | 44 +- crates/yoi/Cargo.toml | 2 - crates/yoi/README.md | 2 +- crates/yoi/src/cli_connection.rs | 4 - crates/yoi/src/main.rs | 124 +- crates/yoi/src/memory_lint.rs | 507 --- docs/design/memory-knowledge.md | 20 +- docs/design/orchestrator-worktree-layout.md | 6 +- docs/design/overview.md | 4 +- docs/development/work-items.md | 91 +- .../repository-local-yoi-removal.md | 14 + ...ository-local-yoi-final-authority-audit.md | 51 + 45 files changed, 487 insertions(+), 9151 deletions(-) delete mode 100644 crates/memory/src/consolidate/mod.rs delete mode 100644 crates/memory/src/consolidate/staging.rs delete mode 100644 crates/memory/src/error.rs delete mode 100644 crates/memory/src/extract/staging.rs delete mode 100644 crates/memory/src/linter/existing.rs delete mode 100644 crates/memory/src/linter/frontmatter.rs delete mode 100644 crates/memory/src/linter/mod.rs delete mode 100644 crates/memory/src/linter/references.rs delete mode 100644 crates/memory/src/linter/size.rs delete mode 100644 crates/memory/src/linter/warnings.rs delete mode 100644 crates/memory/src/resident.rs delete mode 100644 crates/memory/src/scope.rs delete mode 100644 crates/memory/src/usage.rs delete mode 100644 crates/memory/src/workspace.rs delete mode 100644 crates/yoi/src/memory_lint.rs create mode 100644 docs/release-notes/repository-local-yoi-removal.md create mode 100644 docs/report/2026-09-22-repository-local-yoi-final-authority-audit.md diff --git a/Cargo.lock b/Cargo.lock index 0e9528999..eb5651f6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2565,18 +2565,11 @@ dependencies = [ "agen", "async-trait", "chrono", - "libc", "lint-common", - "manifest", "schemars", "serde", "serde_json", - "serde_yaml", - "sha2 0.11.0", - "tempfile", - "thiserror 2.0.18", "tokio", - "tracing", "uuid", ] @@ -4731,18 +4724,15 @@ dependencies = [ "agen", "async-trait", "chrono", - "fs4", "project-record", "rusqlite", "schemars", "serde", "serde_json", - "serde_yaml", "sha2 0.11.0", "tempfile", "thiserror 2.0.18", "tokio", - "toml", ] [[package]] @@ -6261,12 +6251,10 @@ dependencies = [ "client", "futures", "manifest", - "memory", "project-record", "reqwest", "serde", "serde_json", - "serde_yaml", "server-api", "session-analytics", "session-store", diff --git a/README.md b/README.md index 82b65d891..91c078e64 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ Key docs: ## 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: diff --git a/crates/memory/Cargo.toml b/crates/memory/Cargo.toml index 36d4e95ea..e569266c0 100644 --- a/crates/memory/Cargo.toml +++ b/crates/memory/Cargo.toml @@ -7,19 +7,12 @@ license.workspace = true [dependencies] async-trait = { workspace = true } chrono = { version = "0.4", features = ["serde"] } -libc = { workspace = true } lint-common = { workspace = true } agen = { workspace = true } -manifest = { workspace = true } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } -sha2 = { workspace = true } -serde_yaml = "0.9.34" -thiserror = { workspace = true } -tracing = { workspace = true } uuid = { workspace = true, features = ["v7", "serde"] } [dev-dependencies] -tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/crates/memory/README.md b/crates/memory/README.md index 889829135..ef11f2ec1 100644 --- a/crates/memory/README.md +++ b/crates/memory/README.md @@ -2,28 +2,20 @@ ## Role -`memory` owns generated memory, records, staging/consolidation mechanics, linting, and audit observations. +`memory` owns the shared types used by Workspace-backed Memory operations: + +- Memory API request/response DTOs +- extraction candidate and evidence schemas +- audit event schemas +- pure Memory document frontmatter schemas and parsing helpers ## Boundaries -Owns: +The crate does **not** own persistence. Workspace Memory records, staging candidates, and audit observations are stored by the Workspace Server control plane and accessed by Workers through typed API operations. -- memory/record parsing and validation -- memory lint subcommand backend behavior -- staging and consolidation file mechanics -- audit log observation writes -- generated memory prompt support where it belongs below the CLI +The crate intentionally contains no repository-local layout, cwd/ancestor discovery, resident-file reader, staging writer, audit/usage log writer, generic filesystem scope helper, or local backend executor. The product CLI does not expose `yoi memory lint`. -Does not own: - -- authoritative project records (`.yoi/tickets/`, git history) -- normal Worker turn orchestration (`agen`) -- product CLI command shape (`yoi`) -- curated workflow definitions (`workflow`) - -## Design notes - -Memory is useful context, not authority. It should preserve small durable preferences and rationale without duplicating tickets, docs, or implementation reports. +Memory is useful context, not implementation authority. Tickets, Objectives, repository files, Git history, and append-only session records remain the exact evidence surfaces for implementation state. ## See also diff --git a/crates/memory/src/audit.rs b/crates/memory/src/audit.rs index c27a79905..bd3b1887e 100644 --- a/crates/memory/src/audit.rs +++ b/crates/memory/src/audit.rs @@ -1,26 +1,12 @@ -//! Append-only JSONL audit log for memory workers and tools. +//! Typed Memory audit events sent to the Workspace authority. //! -//! The log is evidence-only observability data under -//! `.yoi/memory/_logs/current.log`. It is intentionally separate from -//! `_staging` and `_usage`, and consolidation never consumes it. Operators can -//! follow the latest stream with: -//! -//! ```text -//! tail -f .yoi/memory/_logs/current.log -//! ``` - -use std::collections::BTreeMap; -use std::fs::{self, OpenOptions}; -use std::io::{self, Write}; -use std::path::{Path, PathBuf}; +//! Runtime Workers construct these DTOs and submit them through the Memory +//! backend operation boundary. Repository-local audit logs are not an authority. use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; use uuid::Uuid; -use crate::workspace::WorkspaceLayout; - fn is_zero_usize(value: &usize) -> bool { *value == 0 } @@ -260,200 +246,3 @@ impl AuditEvent { } } } - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RecordSnapshot { - pub kind: String, - pub slug: String, - pub path: PathBuf, - pub hash: String, -} - -/// Append one audit event to `.yoi/memory/_logs/current.log`. -pub fn append_audit_event(layout: &WorkspaceLayout, event: &AuditEvent) -> io::Result<()> { - let path = layout.audit_current_log_path(); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - let line = serde_json::to_string(event) - .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; - let mut file = OpenOptions::new().create(true).append(true).open(path)?; - writeln!(file, "{line}")?; - Ok(()) -} - -pub fn append_worker_lifecycle( - layout: &WorkspaceLayout, - audit: WorkerLifecycleAudit, -) -> io::Result<()> { - append_audit_event( - layout, - &AuditEvent::new(AuditPayload::WorkerLifecycle(audit)), - ) -} - -pub fn append_record_operation( - layout: &WorkspaceLayout, - audit: RecordOperationAudit, -) -> io::Result<()> { - append_audit_event( - layout, - &AuditEvent::new(AuditPayload::RecordOperation(audit)), - ) -} - -pub fn append_record_usage(layout: &WorkspaceLayout, audit: RecordUsageAudit) -> io::Result<()> { - append_audit_event(layout, &AuditEvent::new(AuditPayload::RecordUsage(audit))) -} - -pub fn file_hash(path: &Path) -> io::Result> { - 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 { - 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, - after: &BTreeMap, -) -> 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, 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, 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:")); - } -} diff --git a/crates/memory/src/backend.rs b/crates/memory/src/backend.rs index 66a5b73a5..32d987e57 100644 --- a/crates/memory/src/backend.rs +++ b/crates/memory/src/backend.rs @@ -1,26 +1,15 @@ -//! Workspace-backend operations for Memory tools. +//! Typed transport boundary for Workspace-backed Memory operations. //! -//! These types are the typed HTTP boundary used by runtime workers that have -//! Workspace authority but no direct local filesystem authority. The local -//! executor intentionally lives in the memory crate so the workspace backend and -//! future non-HTTP hosts share validation and path semantics. +//! Runtime Workers serialize these DTOs to the Workspace API. Persistence and +//! validation are owned by the Workspace authority; this crate deliberately has +//! no repository-local filesystem executor. -use std::fs; -use std::io; -use std::io::Write; - -use chrono::Utc; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use uuid::Uuid; -use crate::audit::{AuditEvent, append_audit_event}; -use crate::consolidate::list_staging_entries_snapshot; -use crate::extract::{ - ExtractedCandidate, ExtractedPayload, StagingEvidence, write_staging, write_staging_candidate, -}; +use crate::audit::AuditEvent; +use crate::extract::{ExtractedCandidate, ExtractedPayload, StagingEvidence}; use crate::schema::{SourceEvidenceRef, SourceRef}; -use crate::workspace::WorkspaceLayout; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "operation", rename_all = "snake_case")] @@ -166,19 +155,6 @@ pub struct MemoryConsolidationOutput { pub total_bytes: u64, } -#[derive(Debug, Clone, Serialize, Deserialize)] -struct MemoryStagingCloseDispositionRecord { - schema_version: u32, - candidate_id: String, - staging_path: String, - recorded_at: String, - action: MemoryStagingCloseAction, - reason: String, - affected_memory: Vec, -} - -const STAGING_RESOLUTIONS_FILE: &str = "_resolutions.jsonl"; - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MemoryBackendAckOutput { pub summary: String, @@ -189,345 +165,3 @@ pub struct MemoryStagingWriteOutput { pub staging_count: usize, pub staging_ids: Vec, } - -pub fn execute_memory_backend_operation( - layout: &WorkspaceLayout, - operation: MemoryBackendOperation, -) -> io::Result { - 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 { - 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::>(); - 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 { - 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 { - 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 { - 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::>() - .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) -> 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::(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.")); - } -} diff --git a/crates/memory/src/consolidate/mod.rs b/crates/memory/src/consolidate/mod.rs deleted file mode 100644 index bd6580331..000000000 --- a/crates/memory/src/consolidate/mod.rs +++ /dev/null @@ -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, -}; diff --git a/crates/memory/src/consolidate/staging.rs b/crates/memory/src/consolidate/staging.rs deleted file mode 100644 index 6449d63a7..000000000 --- a/crates/memory/src/consolidate/staging.rs +++ /dev/null @@ -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 で、ファイル名 -/// `.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, - pub invalid_count: usize, -} - -/// `/*.json` を読んで UUIDv7 順に並べた [`StagingEntry`] -/// 配列を返す。staging_dir が存在しなければ空配列。読めないファイルや -/// JSON parse 失敗は `tracing::warn!` してスキップ(壊れた個別ファイルが -/// consolidation 全体を止めないように)。 -pub fn list_staging_entries(layout: &WorkspaceLayout) -> Vec { - list_staging_entries_snapshot(layout).entries -} - -/// `/*.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 = 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::(&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 = 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()); - } -} diff --git a/crates/memory/src/error.rs b/crates/memory/src/error.rs deleted file mode 100644 index 735866e70..000000000 --- a/crates/memory/src/error.rs +++ /dev/null @@ -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, 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), -} - -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(()) - } - } - } -} diff --git a/crates/memory/src/extract/mod.rs b/crates/memory/src/extract/mod.rs index a320dbc9e..2b934804f 100644 --- a/crates/memory/src/extract/mod.rs +++ b/crates/memory/src/extract/mod.rs @@ -1,25 +1,12 @@ -//! extract: memory candidate extraction. +//! Memory candidate extraction domain types and Worker tool boundary. //! -//! 通常 Worker の post-run hook で発火する disposable Engine と、その -//! 出力を `/.yoi/memory/_staging/.json` に書き出す -//! ヘルパーを提供する。Worker 側はこのモジュールから: -//! -//! - [`build_extract_input`] を sub-Engine の最初の user 入力に -//! - [`write_extracted_tool`] を唯一のツールとして -//! - [`write_staging`] で受け取った JSON を staging に書き出し -//! -//! の順で組み立てる。system prompt は Worker の `PromptCatalog` -//! (`WorkerPrompt::MemoryExtractSystem`) で管理される。pointer 永続化 -//! (session-store の `LogEntry::Extension`、domain `"memory.extract"`)は -//! Worker 側が責務を持つ。 -//! -//! 出力 JSON の wrap は [`write_staging`] が `source: { segment_id, range }` -//! を機械付与する形で担当し、LLM には source を推論させない。 +//! Worker-side extraction produces typed candidates that are submitted to the +//! Workspace backend. Staging persistence is owned by that backend; this module +//! does not write repository-local files. mod input; mod payload; mod pointer; -mod staging; mod tool; pub use input::build_extract_input; @@ -28,7 +15,6 @@ pub use payload::{ StagingRecord, }; pub use pointer::{ExtractPointerPayload, fold_pointer}; -pub use staging::{StagingWriteResult, write_staging, write_staging_candidate}; pub use tool::{ExtractWorkerContext, write_extracted_tool}; /// session-store `LogEntry::Extension` で使う domain 名。 diff --git a/crates/memory/src/extract/staging.rs b/crates/memory/src/extract/staging.rs deleted file mode 100644 index 8c8a6c1f1..000000000 --- a/crates/memory/src/extract/staging.rs +++ /dev/null @@ -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> { - 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, - source_refs: Vec, -) -> io::Result { - 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()); - } -} diff --git a/crates/memory/src/lib.rs b/crates/memory/src/lib.rs index 44dac0130..4afa340aa 100644 --- a/crates/memory/src/lib.rs +++ b/crates/memory/src/lib.rs @@ -1,30 +1,47 @@ -//! Memory subsystem support shared by Worker and Workspace authority paths. +//! Shared Memory domain and Workspace API transport types. //! -//! Normal runtime Memory access is mediated through Workspace-backed backend -//! operations. Generic filesystem tools must not touch local memory paths; Worker -//! is responsible for denying them at the Scope level when memory is enabled. +//! Normal Memory persistence is owned by the Workspace control plane. This crate +//! intentionally contains no repository-local `.yoi` layout, discovery, reader, +//! writer, or fallback implementation. pub mod audit; pub mod backend; -pub mod consolidate; -pub mod error; pub mod extract; -pub mod linter; -pub mod resident; pub mod schema; -pub mod scope; -pub mod usage; -pub mod workspace; -pub use error::{LintError, LintWarning, MemoryError}; pub use extract::ExtractPointerPayload; pub use lint_common::{RecordLintError, Slug, is_valid_slug}; -pub use linter::{LintReport, Linter}; -pub use resident::collect_resident_summary; -pub use scope::deny_write_rules; -pub use usage::{ - UsageEvent, UsageEventKind, UsageRecordSnapshot, UsageReport, UsageReportRecord, UsageSource, - append_resident_exposure_event, append_usage_event, append_use_event, build_usage_report, - snapshot_record_from_bytes, snapshot_record_from_layout, -}; -pub use workspace::WorkspaceLayout; + +#[cfg(test)] +mod authority_tests { + #[test] + fn production_source_has_no_repository_local_memory_provider() { + let production = [ + include_str!("lib.rs") + .split_once("#[cfg(test)]\nmod authority_tests") + .map(|(production, _)| production) + .expect("Memory authority test module marker"), + include_str!("backend.rs"), + include_str!("audit.rs"), + include_str!("extract/mod.rs"), + include_str!("extract/input.rs"), + include_str!("extract/payload.rs"), + include_str!("extract/pointer.rs"), + include_str!("extract/tool.rs"), + ] + .join("\n"); + for forbidden in [ + "WorkspaceLayout", + "execute_memory_backend_operation", + "collect_resident_summary", + "deny_write_rules", + "write_staging_candidate", + ".yoi/memory", + ] { + assert!( + !production.contains(forbidden), + "repository-local Memory provider returned through {forbidden}" + ); + } + } +} diff --git a/crates/memory/src/linter/existing.rs b/crates/memory/src/linter/existing.rs deleted file mode 100644 index ecab37364..000000000 --- a/crates/memory/src/linter/existing.rs +++ /dev/null @@ -1,121 +0,0 @@ -//! Walks `/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, - requests: HashSet, -} - -#[derive(Debug, Clone)] -pub struct DecisionMeta { - pub replaced_by: Option, -} - -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 { - 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::(path); - out.requests.insert(slug); - })?; - - Ok(out) -} - -fn scan_dir(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::(path) { - Some(fm) => DecisionMeta { - replaced_by: fm.replaced_by, - }, - None => DecisionMeta { replaced_by: None }, - } -} - -fn parse_silent(path: &Path) -> Option -where - F: serde::de::DeserializeOwned, -{ - let content = std::fs::read_to_string(path).ok()?; - let (yaml, _) = split_frontmatter(&content).ok()?; - serde_yaml::from_str::(yaml).ok() -} diff --git a/crates/memory/src/linter/frontmatter.rs b/crates/memory/src/linter/frontmatter.rs deleted file mode 100644 index 60bf912c0..000000000 --- a/crates/memory/src/linter/frontmatter.rs +++ /dev/null @@ -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(yaml: &str) -> Result { - serde_yaml::from_str::(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 -} diff --git a/crates/memory/src/linter/mod.rs b/crates/memory/src/linter/mod.rs deleted file mode 100644 index e823f1d65..000000000 --- a/crates/memory/src/linter/mod.rs +++ /dev/null @@ -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, - pub warnings: Vec, -} - -impl LintReport { - pub fn has_errors(&self) -> bool { - !self.errors.is_empty() - } - - pub fn extend_errors(&mut self, more: impl IntoIterator) { - 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::(content, &classified, &mut report); - } - } - - report - } - - fn check_kind(&self, content: &str, cp: &ClassifiedPath, report: &mut LintReport) - where - F: DeserializeOwned + crate::schema::Frontmatter, - { - let parsed = match parse_frontmatter::(content) { - Ok(p) => p, - Err(e) => { - report.push_error(e); - return; - } - }; - let body = parsed.body; - size::check_body::(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::(content) { - Ok(p) => p, - Err(e) => { - report.push_error(e); - return; - } - }; - size::check_body::(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::(content) { - Ok(p) => p, - Err(e) => { - report.push_error(e); - return; - } - }; - let fm = parsed.frontmatter; - size::check_body::(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(content: &str) -> Result, LintError> { - let (yaml, body) = split_frontmatter(content)?; - let fm = frontmatter::deserialize_strict::(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); - } -} diff --git a/crates/memory/src/linter/references.rs b/crates/memory/src/linter/references.rs deleted file mode 100644 index e6d116c62..000000000 --- a/crates/memory/src/linter/references.rs +++ /dev/null @@ -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 = 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); - } -} diff --git a/crates/memory/src/linter/size.rs b/crates/memory/src/linter/size.rs deleted file mode 100644 index d1ccf7f15..000000000 --- a/crates/memory/src/linter/size.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! Body size limit checks. - -use crate::error::LintError; -use crate::linter::LintReport; -use crate::schema::Frontmatter; - -pub fn check_body(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, - }); - } -} diff --git a/crates/memory/src/linter/warnings.rs b/crates/memory/src/linter/warnings.rs deleted file mode 100644 index 3ded46f84..000000000 --- a/crates/memory/src/linter/warnings.rs +++ /dev/null @@ -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 = 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 = a.chars().collect(); - let b: Vec = b.chars().collect(); - if a.is_empty() { - return b.len(); - } - if b.is_empty() { - return a.len(); - } - let mut prev: Vec = (0..=b.len()).collect(); - let mut curr: Vec = 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); - } -} diff --git a/crates/memory/src/resident.rs b/crates/memory/src/resident.rs deleted file mode 100644 index 70a25116c..000000000 --- a/crates/memory/src/resident.rs +++ /dev/null @@ -1,91 +0,0 @@ -//! Workspace memory resident-enumeration helpers. -//! -//! Surfaces used by the Worker system-prompt assembler: -//! -//! - [`collect_resident_summary`] — the body of -//! `/.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 `/.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 { - 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()); - } -} diff --git a/crates/memory/src/schema/common.rs b/crates/memory/src/schema/common.rs index 65f520ef6..5c0ec3cc8 100644 --- a/crates/memory/src/schema/common.rs +++ b/crates/memory/src/schema/common.rs @@ -1,10 +1,9 @@ //! Common frontmatter helpers and shared types. +use lint_common::RecordLintError; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::error::LintError; - pub use lint_common::Frontmatter; /// Reference to a session-store entry range. Stored in `sources` / @@ -138,14 +137,13 @@ pub struct SourceEvidenceRef { } /// Split a markdown document into `(yaml_frontmatter, body)`. -pub fn split_frontmatter(content: &str) -> Result<(&str, &str), LintError> { - lint_common::split_frontmatter(content).map_err(Into::into) +pub fn split_frontmatter(content: &str) -> Result<(&str, &str), RecordLintError> { + lint_common::split_frontmatter(content) } #[cfg(test)] mod tests { use super::*; - use lint_common::RecordLintError; #[test] fn splits_simple() { @@ -158,19 +156,13 @@ mod tests { #[test] fn no_leading_delim_errors() { let err = split_frontmatter("hello").unwrap_err(); - assert!(matches!( - err, - LintError::Record(RecordLintError::MissingFrontmatter) - )); + assert!(matches!(err, RecordLintError::MissingFrontmatter)); } #[test] fn no_closing_delim_errors() { let err = split_frontmatter("---\nfoo: 1\nno close\n").unwrap_err(); - assert!(matches!( - err, - LintError::Record(RecordLintError::MalformedFrontmatter(_)) - )); + assert!(matches!(err, RecordLintError::MalformedFrontmatter(_))); } #[test] diff --git a/crates/memory/src/schema/mod.rs b/crates/memory/src/schema/mod.rs index b2e998334..34b75ca8d 100644 --- a/crates/memory/src/schema/mod.rs +++ b/crates/memory/src/schema/mod.rs @@ -1,9 +1,7 @@ -//! Frontmatter schemas for memory records. +//! Typed frontmatter schemas for Memory documents. //! -//! Each record kind has its own typed `*Frontmatter` struct. The linter -//! deserializes the YAML between the leading `---` markers into the -//! kind-appropriate struct; field-level errors are surfaced as -//! [`LintError::MissingField`] / [`LintError::InvalidField`]. +//! These structures and pure parsing helpers are shared by Workspace-backed +//! validation. They do not select or traverse a repository-local Memory tree. mod common; mod decision; diff --git a/crates/memory/src/scope.rs b/crates/memory/src/scope.rs deleted file mode 100644 index cec6dd45a..000000000 --- a/crates/memory/src/scope.rs +++ /dev/null @@ -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 `/.yoi/memory/`. -pub fn deny_write_rules(layout: &WorkspaceLayout) -> Vec { - 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); - } -} diff --git a/crates/memory/src/usage.rs b/crates/memory/src/usage.rs deleted file mode 100644 index dcf81168d..000000000 --- a/crates/memory/src/usage.rs +++ /dev/null @@ -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, 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, - pub segment_id: String, - pub event: UsageEventKind, - pub source: UsageSource, - pub records: Vec, -} - -impl UsageEvent { - pub fn new( - segment_id: impl Into, - event: UsageEventKind, - source: UsageSource, - records: Vec, - ) -> 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, -} - -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>, - pub source_breakdown: BTreeMap, - 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>, - source_breakdown: BTreeMap, - 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, - source: UsageSource, - records: Vec, -) -> 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, - records: Vec, -) -> 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 { - 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, - bytes: &[u8], -) -> UsageRecordSnapshot { - UsageRecordSnapshot::from_bytes(kind, slug, bytes) -} - -fn record_path( - layout: &WorkspaceLayout, - kind: RecordKind, - slug: &str, -) -> io::Result { - 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 { - 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 = 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); - } -} diff --git a/crates/memory/src/workspace.rs b/crates/memory/src/workspace.rs deleted file mode 100644 index 3daa21b86..000000000 --- a/crates/memory/src/workspace.rs +++ /dev/null @@ -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 -//! `/.yoi/` subdirectory — alongside workspace project records -//! generated durable memory. The trees inside it: -//! -//! - `/.yoi/memory/summary.md` -//! - `/.yoi/memory/decisions/.md` -//! - `/.yoi/memory/requests/.md` -//! - `/.yoi/memory/_staging/.json` -//! - `/.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, -} - -/// Workspace-rooted layout. Cheap to clone. -#[derive(Debug, Clone)] -pub struct WorkspaceLayout { - root: PathBuf, -} - -impl WorkspaceLayout { - pub fn new(root: impl Into) -> 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 - } - - /// `/.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, 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 { - 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 { - 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()); - } -} diff --git a/crates/ticket/Cargo.toml b/crates/ticket/Cargo.toml index 4d72af57f..1098deeef 100644 --- a/crates/ticket/Cargo.toml +++ b/crates/ticket/Cargo.toml @@ -8,17 +8,13 @@ license.workspace = true project-record = { workspace = true } async-trait = { workspace = true } chrono = { version = "0.4", default-features = false, features = ["clock"] } -fs4 = { workspace = true, features = ["sync"] } agen = { workspace = true } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } -serde_yaml = "0.9.34" sha2.workspace = true rusqlite.workspace = true thiserror.workspace = true -tempfile.workspace = true -toml = { workspace = true } [dev-dependencies] tempfile.workspace = true diff --git a/crates/ticket/src/config.rs b/crates/ticket/src/config.rs index 3d16b9bb7..9c35fc684 100644 --- a/crates/ticket/src/config.rs +++ b/crates/ticket/src/config.rs @@ -1,396 +1,12 @@ -//! Workspace-local Ticket orchestration configuration. +//! Filesystem-independent Ticket role domain types. //! -//! Durable Ticket policy lives under the `[ticket]` table in tracked -//! `.yoi/workspace.toml` workspace settings. The legacy -//! `.yoi/ticket.config.toml` file is only a read-only migration fallback when -//! workspace settings do not contain any Ticket policy. The config intentionally -//! stores lightweight string references for Profile selectors, launch prompts, -//! and workflows so this crate remains independent from `worker` and `manifest` -//! runtime resolution. +//! Ticket policy and role launch configuration are projected by the Workspace +//! control plane. This module deliberately contains no repository-local config +//! loader, path fallback, or storage backend selection. -use std::collections::{BTreeMap, BTreeSet}; use std::fmt; -use std::fs; -use std::path::{Component, Path, PathBuf}; use serde::{Deserialize, Serialize}; -use thiserror::Error; - -pub const WORKSPACE_SETTINGS_RELATIVE_PATH: &str = ".yoi/workspace.toml"; -/// Legacy Ticket config path. This is kept as a narrow read-only migration fallback only. -pub const TICKET_CONFIG_RELATIVE_PATH: &str = ".yoi/ticket.config.toml"; -/// Workspace-relative default root for the built-in local Ticket backend. -pub const DEFAULT_TICKET_BACKEND_RELATIVE_PATH: &str = ".yoi/tickets"; -const DEFAULT_ORCHESTRATION_BRANCH: &str = "orchestration"; -const DEFAULT_ORCHESTRATION_WORKTREE_DIR: &str = ".worktree"; -const DEFAULT_ORCHESTRATION_WORKTREE_NAME: &str = "orchestration"; - -/// Return the explicit Workspace settings Ticket policy scaffold written by `yoi ticket init`. -/// -/// The scaffold is a valid `.yoi/workspace.toml` fragment rooted at `[ticket]`. -/// It intentionally configures every fixed Ticket role with a concrete profile -/// so strict role launch planning can validate the config without runtime -/// fallback. -pub fn ticket_config_scaffold() -> String { - let mut out = String::from( - "[ticket]\n# Optional durable Ticket record language. When unset, generated Ticket text keeps current defaults.\n# language = \"Japanese\"\n", - ); - out.push_str("\n[ticket.backend]\n"); - out.push_str(&format!( - "provider = \"{}\"\n", - TicketBackendProvider::BuiltinYoiLocal.as_str() - )); - out.push_str(&format!( - "root = \"{}\"\n", - DEFAULT_TICKET_BACKEND_RELATIVE_PATH - )); - out.push_str( - "\n# Optional Panel Orchestrator worktree settings. When unset, Panel uses branch `orchestration` at `.worktree/orchestration`.\n# [ticket.orchestration]\n# branch = \"orchestration\"\n# worktree_dir = \".worktree\"\n# worktree_name = \"orchestration\"\n", - ); - for role in TicketRole::ALL { - out.push_str(&format!( - "\n[ticket.roles.{role}]\nprofile = \"{}\"\n", - role.default_profile(), - )); - } - out -} - -#[derive(Debug, Error)] -pub enum TicketConfigError { - #[error("failed to read Ticket config {}: {source}", .path.display())] - Read { - path: PathBuf, - #[source] - source: std::io::Error, - }, - #[error("failed to parse Ticket config {}: {source}", .path.display())] - Parse { - path: PathBuf, - #[source] - source: toml::de::Error, - }, - #[error("invalid Ticket config {}: {message}", .path.display())] - Invalid { path: PathBuf, message: String }, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TicketConfig { - pub backend: TicketBackendConfig, - pub ticket: TicketRecordConfig, - pub orchestration: TicketOrchestrationConfig, - pub roles: TicketRoleProfiles, -} - -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct TicketOrchestrationConfig { - pub branch: Option, - pub worktree_dir: Option, - pub worktree_name: Option, -} - -impl TicketOrchestrationConfig { - pub fn branch_name(&self) -> Option<&str> { - self.branch.as_ref().map(GitBranchName::as_str) - } - - pub fn effective_branch_name(&self) -> &str { - self.branch_name().unwrap_or(DEFAULT_ORCHESTRATION_BRANCH) - } - - pub fn worktree_dir(&self) -> &Path { - self.worktree_dir - .as_deref() - .unwrap_or_else(|| Path::new(DEFAULT_ORCHESTRATION_WORKTREE_DIR)) - } - - pub fn worktree_name(&self) -> &Path { - self.worktree_name - .as_deref() - .unwrap_or_else(|| Path::new(DEFAULT_ORCHESTRATION_WORKTREE_NAME)) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] -pub struct GitBranchName(String); - -impl GitBranchName { - pub fn new(value: impl Into) -> Result { - let value = value.into(); - let trimmed = value.trim(); - if trimmed != value { - return Err("git branch name must not have leading or trailing whitespace".to_string()); - } - validate_git_branch_name_value(trimmed)?; - Ok(Self(trimmed.to_string())) - } - - pub fn as_str(&self) -> &str { - self.0.as_str() - } -} - -impl<'de> Deserialize<'de> for GitBranchName { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let value = String::deserialize(deserializer)?; - Self::new(value).map_err(serde::de::Error::custom) - } -} - -impl fmt::Display for GitBranchName { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.as_str()) - } -} - -fn validate_git_branch_name_value(value: &str) -> Result<(), String> { - if value.is_empty() { - return Err("git branch name must not be empty".to_string()); - } - if value == "@" { - return Err("git branch name must not be `@`".to_string()); - } - if value.starts_with('-') { - return Err("git branch name must not start with `-`".to_string()); - } - if value.starts_with("refs/") { - return Err("git branch name must be a short branch name, not a full ref".to_string()); - } - if value.starts_with('/') || value.ends_with('/') || value.contains("//") { - return Err("git branch name must not contain empty path components".to_string()); - } - if value.contains("..") { - return Err("git branch name must not contain `..`".to_string()); - } - if value.contains("@{") { - return Err("git branch name must not contain `@{`".to_string()); - } - if value.ends_with('.') { - return Err("git branch name must not end with `.`".to_string()); - } - - for component in value.split('/') { - if component.starts_with('.') { - return Err("git branch name components must not start with `.`".to_string()); - } - if component.ends_with(".lock") { - return Err("git branch name components must not end with `.lock`".to_string()); - } - } - - for ch in value.chars() { - if ch.is_control() || matches!(ch, ' ' | '~' | '^' | ':' | '?' | '*' | '[' | '\\') { - return Err(format!( - "git branch name contains unsupported character `{}`", - ch.escape_default() - )); - } - } - - Ok(()) -} - -fn validate_orchestration_relative_path(path: &Path, label: &str) -> Result<(), String> { - if path.as_os_str().is_empty() { - return Err(format!("{label} must not be empty")); - } - if path.is_absolute() { - return Err(format!("{label} must be workspace-relative")); - } - for component in path.components() { - match component { - Component::Normal(_) => {} - Component::CurDir | Component::ParentDir => { - return Err(format!("{label} must not contain `.` or `..` components")); - } - Component::RootDir | Component::Prefix(_) => { - return Err(format!("{label} must be workspace-relative")); - } - } - } - Ok(()) -} - -impl TicketConfig { - pub fn default_for_workspace(workspace_root: impl AsRef) -> Self { - let workspace_root = workspace_root.as_ref(); - Self { - backend: TicketBackendConfig::default_for_workspace(workspace_root), - ticket: TicketRecordConfig::default(), - orchestration: TicketOrchestrationConfig::default(), - roles: TicketRoleProfiles::default(), - } - } - - pub fn load_workspace(workspace_root: impl AsRef) -> Result { - let workspace_root = workspace_root.as_ref(); - let workspace_settings_path = workspace_root.join(WORKSPACE_SETTINGS_RELATIVE_PATH); - match fs::read_to_string(&workspace_settings_path) { - Ok(content) => { - if let Some(config) = Self::from_workspace_settings_toml( - workspace_root, - &workspace_settings_path, - &content, - )? { - return Ok(config); - } - } - Err(source) if source.kind() == std::io::ErrorKind::NotFound => {} - Err(source) => { - return Err(TicketConfigError::Read { - path: workspace_settings_path, - source, - }); - } - } - - // Narrow read-only migration fallback for pre-workspace-settings projects. - // As soon as `.yoi/workspace.toml` contains any `[ticket]` table, the - // workspace settings authority wins and this legacy file is ignored. - let legacy_path = workspace_root.join(TICKET_CONFIG_RELATIVE_PATH); - let legacy_content = match fs::read_to_string(&legacy_path) { - Ok(content) => content, - Err(source) if source.kind() == std::io::ErrorKind::NotFound => { - return Ok(Self::default_for_workspace(workspace_root)); - } - Err(source) => { - return Err(TicketConfigError::Read { - path: legacy_path, - source, - }); - } - }; - Self::from_toml(workspace_root, &legacy_path, &legacy_content) - } - - pub fn from_workspace_settings_toml( - workspace_root: impl AsRef, - path: impl AsRef, - content: &str, - ) -> Result, TicketConfigError> { - let workspace_root = workspace_root.as_ref(); - let path = path.as_ref(); - let value: toml::Value = - toml::from_str(content).map_err(|source| TicketConfigError::Parse { - path: path.to_path_buf(), - source, - })?; - let Some(ticket_value) = value.get("ticket").cloned() else { - return Ok(None); - }; - let raw: RawWorkspaceTicketConfig = - ticket_value - .try_into() - .map_err(|source| TicketConfigError::Parse { - path: path.to_path_buf(), - source, - })?; - raw.resolve(workspace_root, path).map(Some) - } - - pub fn workspace_settings_has_ticket_config( - path: impl AsRef, - content: &str, - ) -> Result { - let path = path.as_ref(); - let value: toml::Value = - toml::from_str(content).map_err(|source| TicketConfigError::Parse { - path: path.to_path_buf(), - source, - })?; - Ok(value.get("ticket").is_some()) - } - - pub fn from_toml( - workspace_root: impl AsRef, - path: impl AsRef, - content: &str, - ) -> Result { - let workspace_root = workspace_root.as_ref(); - let path = path.as_ref(); - let raw: RawTicketConfig = - toml::from_str(content).map_err(|source| TicketConfigError::Parse { - path: path.to_path_buf(), - source, - })?; - raw.resolve(workspace_root, path) - } - - pub fn backend_root(&self) -> &Path { - self.backend.root.as_path() - } - - pub fn ticket_record_language(&self) -> Option<&str> { - self.ticket - .language - .as_ref() - .map(TicketRecordLanguage::as_str) - } - - pub fn role(&self, role: TicketRole) -> &TicketRoleConfig { - self.roles.get(role) - } - - pub fn role_launch_config( - &self, - role: TicketRole, - ) -> Result<&TicketRoleConfig, TicketRoleLaunchConfigError> { - self.roles.launch_config(role) - } - - pub fn profile_for(&self, role: TicketRole) -> &ProfileSelectorRef { - &self.role(role).profile - } - - pub fn launch_prompt_for(&self, role: TicketRole) -> Option<&PromptRef> { - self.role(role).launch_prompt.as_ref() - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TicketBackendConfig { - pub provider: TicketBackendProvider, - pub root: PathBuf, -} - -impl TicketBackendConfig { - pub fn default_for_workspace(workspace_root: &Path) -> Self { - Self { - provider: TicketBackendProvider::BuiltinYoiLocal, - root: workspace_root.join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum TicketBackendProvider { - #[serde(rename = "builtin:yoi_local")] - BuiltinYoiLocal, -} - -impl TicketBackendProvider { - pub fn as_str(self) -> &'static str { - match self { - Self::BuiltinYoiLocal => "builtin:yoi_local", - } - } - - pub fn parse(value: &str) -> Option { - match value { - "builtin:yoi_local" => Some(Self::BuiltinYoiLocal), - _ => None, - } - } -} - -impl fmt::Display for TicketBackendProvider { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.as_str()) - } -} #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -443,1062 +59,41 @@ impl TicketRole { } impl fmt::Display for TicketRole { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.as_str()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TicketRecordConfig { - pub language: Option, -} - -impl Default for TicketRecordConfig { - fn default() -> Self { - Self { language: None } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TicketRecordLanguage(String); - -impl TicketRecordLanguage { - pub fn new(language: impl Into) -> Result { - let language = normalized_non_empty(language, "ticket record language")?; - Ok(Self(language)) - } - - pub fn as_str(&self) -> &str { - self.0.as_str() - } -} - -impl<'de> Deserialize<'de> for TicketRecordLanguage { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let value = String::deserialize(deserializer)?; - TicketRecordLanguage::new(value).map_err(serde::de::Error::custom) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TicketRoleProfiles { - inner: BTreeMap, - configured_roles: BTreeSet, - profile_configured_roles: BTreeSet, -} - -impl TicketRoleProfiles { - pub fn get(&self, role: TicketRole) -> &TicketRoleConfig { - self.inner - .get(&role) - .expect("TicketRoleProfiles always contains all fixed roles") - } - - pub fn role_is_configured(&self, role: TicketRole) -> bool { - self.configured_roles.contains(&role) - } - - pub fn profile_is_configured(&self, role: TicketRole) -> bool { - self.profile_configured_roles.contains(&role) - } - - pub fn launch_config( - &self, - role: TicketRole, - ) -> Result<&TicketRoleConfig, TicketRoleLaunchConfigError> { - if !self.role_is_configured(role) { - return Err(TicketRoleLaunchConfigError::MissingRoleTable { role }); - } - if !self.profile_is_configured(role) { - return Err(TicketRoleLaunchConfigError::MissingProfile { role }); - } - let config = self.get(role); - if config.profile.as_str() == "inherit" { - return Err(TicketRoleLaunchConfigError::InheritProfile { role }); - } - Ok(config) - } - - pub fn iter(&self) -> impl Iterator { - TicketRole::ALL - .into_iter() - .map(|role| (role, self.get(role))) - } -} - -impl Default for TicketRoleProfiles { - fn default() -> Self { - let inner = TicketRole::ALL - .into_iter() - .map(|role| (role, TicketRoleConfig::default_for_role(role))) - .collect(); - Self { - inner, - configured_roles: BTreeSet::new(), - profile_configured_roles: BTreeSet::new(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Error)] -pub enum TicketRoleLaunchConfigError { - #[error( - "Ticket role `{role}` is not launch-configured; add `[ticket.roles.{role}]` with the role builtin profile or another executable concrete profile selector" - )] - MissingRoleTable { role: TicketRole }, - #[error( - "Ticket role `{role}` has no launch profile; set `[ticket.roles.{role}].profile` to the role builtin profile or another executable concrete profile selector" - )] - MissingProfile { role: TicketRole }, - #[error( - "Ticket role `{role}` uses `profile = \"inherit\"`; top-level Ticket role launch requires an explicit executable profile selector such as the role builtin profile or a project/user profile" - )] - InheritProfile { role: TicketRole }, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TicketRoleConfig { - pub profile: ProfileSelectorRef, - pub launch_prompt: Option, -} - -impl TicketRoleConfig { - pub fn default_for_role(_role: TicketRole) -> Self { - Self { - profile: ProfileSelectorRef::inherit(), - launch_prompt: None, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] -pub struct ProfileSelectorRef(String); - -impl ProfileSelectorRef { - pub fn new(value: impl Into) -> Result { - let value = normalized_non_empty(value, "profile selector")?; - if value.starts_with("path:") - || value.starts_with('.') - || value.contains('/') - || value.ends_with(".dcdl") - || value.ends_with(".json") - || value.ends_with(".toml") - || value.ends_with(".nix") - { - return Err("profile selector must be `inherit`, `default`, a source-qualified registry selector, or an unqualified registry selector; path selectors are not supported".to_string()); - } - if let Some((source, name)) = value.split_once(':') { - if !matches!(source, "builtin" | "user" | "project") { - return Err( - "profile selector source must be one of `builtin`, `user`, or `project`" - .to_string(), - ); - } - if name.trim().is_empty() { - return Err("profile selector registry name must not be empty".to_string()); - } - } - Ok(Self(value)) - } - - pub fn inherit() -> Self { - Self("inherit".to_string()) - } - - pub fn as_str(&self) -> &str { - self.0.as_str() - } -} - -impl<'de> Deserialize<'de> for ProfileSelectorRef { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let value = String::deserialize(deserializer)?; - Self::new(value).map_err(serde::de::Error::custom) - } -} - -impl fmt::Display for ProfileSelectorRef { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.as_str()) - } -} - -impl AsRef for ProfileSelectorRef { - fn as_ref(&self) -> &str { - self.as_str() - } -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] -pub struct PromptRef(String); - -impl PromptRef { - pub fn new(value: impl Into) -> Result { - normalized_non_empty(value, "launch prompt ref").map(Self) - } - - pub fn as_str(&self) -> &str { - self.0.as_str() - } -} - -impl<'de> Deserialize<'de> for PromptRef { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let value = String::deserialize(deserializer)?; - Self::new(value).map_err(serde::de::Error::custom) - } -} - -impl fmt::Display for PromptRef { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.as_str()) - } -} - -impl AsRef for PromptRef { - fn as_ref(&self) -> &str { - self.as_str() - } -} - -fn normalized_non_empty(value: impl Into, label: &str) -> Result { - let value = value.into(); - let trimmed = value.trim(); - if trimmed.is_empty() { - Err(format!("{label} must not be empty")) - } else { - Ok(trimmed.to_string()) - } -} - -#[derive(Debug, Default, Deserialize)] -#[serde(deny_unknown_fields)] -struct RawTicketConfig { - #[serde(default)] - backend: RawBackendConfig, - #[serde(default)] - ticket: RawTicketRecordConfig, - #[serde(default)] - orchestration: RawTicketOrchestrationConfig, - #[serde(default)] - roles: BTreeMap, -} - -#[derive(Debug, Default, Deserialize)] -#[serde(deny_unknown_fields)] -struct RawWorkspaceTicketConfig { - #[serde(default)] - backend: RawBackendConfig, - #[serde(default)] - language: Option, - #[serde(default)] - orchestration: RawTicketOrchestrationConfig, - #[serde(default)] - roles: BTreeMap, -} - -#[derive(Debug, Default, Deserialize)] -#[serde(deny_unknown_fields)] -struct RawTicketOrchestrationConfig { - #[serde(default)] - branch: Option, - #[serde(default)] - worktree_dir: Option, - #[serde(default)] - worktree_name: Option, -} - -impl RawTicketOrchestrationConfig { - fn resolve(self) -> Result { - if let Some(path) = &self.worktree_dir { - validate_orchestration_relative_path(path, "orchestration.worktree_dir")?; - } - if let Some(path) = &self.worktree_name { - validate_orchestration_relative_path(path, "orchestration.worktree_name")?; - } - Ok(TicketOrchestrationConfig { - branch: self.branch, - worktree_dir: self.worktree_dir, - worktree_name: self.worktree_name, - }) - } -} - -#[derive(Debug, Default, Deserialize)] -#[serde(deny_unknown_fields)] -struct RawTicketRecordConfig { - #[serde(default)] - language: Option, -} - -impl RawTicketRecordConfig { - fn resolve(self) -> TicketRecordConfig { - TicketRecordConfig { - language: self.language, - } - } -} - -impl RawTicketConfig { - fn resolve( - self, - workspace_root: &Path, - path: &Path, - ) -> Result { - resolve_ticket_config_parts( - self.backend, - self.ticket.resolve(), - self.orchestration, - self.roles, - workspace_root, - path, - ) - } -} - -impl RawWorkspaceTicketConfig { - fn resolve( - self, - workspace_root: &Path, - path: &Path, - ) -> Result { - resolve_ticket_config_parts( - self.backend, - TicketRecordConfig { - language: self.language, - }, - self.orchestration, - self.roles, - workspace_root, - path, - ) - } -} - -fn resolve_ticket_config_parts( - backend: RawBackendConfig, - ticket: TicketRecordConfig, - orchestration: RawTicketOrchestrationConfig, - raw_roles: BTreeMap, - workspace_root: &Path, - path: &Path, -) -> Result { - let mut roles = TicketRoleProfiles::default(); - for (name, raw_role) in raw_roles { - let role = TicketRole::parse(&name).ok_or_else(|| TicketConfigError::Invalid { - path: path.to_path_buf(), - message: format!( - "unsupported Ticket role `{name}`; supported fixed roles: {}", - TicketRole::supported_names().join(", ") - ), - })?; - let profile_configured = raw_role.profile.is_some(); - roles.inner.insert(role, raw_role.resolve(role)); - roles.configured_roles.insert(role); - if profile_configured { - roles.profile_configured_roles.insert(role); - } - } - Ok(TicketConfig { - backend: backend - .resolve(workspace_root) - .map_err(|message| TicketConfigError::Invalid { - path: path.to_path_buf(), - message, - })?, - ticket, - orchestration: orchestration - .resolve() - .map_err(|message| TicketConfigError::Invalid { - path: path.to_path_buf(), - message, - })?, - roles, - }) -} - -#[derive(Debug, Default, Deserialize)] -#[serde(deny_unknown_fields)] -struct RawBackendConfig { - #[serde(default)] - provider: Option, - #[serde(default)] - kind: Option, - #[serde(default)] - root: Option, -} - -impl RawBackendConfig { - fn resolve(self, workspace_root: &Path) -> Result { - let provider = match (self.provider, self.kind) { - (Some(provider), None) => TicketBackendProvider::parse(&provider).ok_or_else(|| { - format!( - "unsupported Ticket backend provider `{provider}`; supported provider: `builtin:yoi_local`" - ) - })?, - (None, Some(kind)) if kind == "local" => TicketBackendProvider::BuiltinYoiLocal, - (None, Some(kind)) => { - return Err(format!( - "unsupported legacy Ticket backend kind `{kind}`; use provider = \"builtin:yoi_local\"" - )); - } - (None, None) => TicketBackendProvider::BuiltinYoiLocal, - (Some(_), Some(_)) => { - return Err( - "backend.provider and legacy backend.kind are mutually exclusive; use provider = \"builtin:yoi_local\"" - .to_string(), - ); - } - }; - let root = self - .root - .unwrap_or_else(|| PathBuf::from(DEFAULT_TICKET_BACKEND_RELATIVE_PATH)); - Ok(TicketBackendConfig { - provider, - root: join_if_relative(workspace_root, &root), - }) - } -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct RawTicketRoleConfig { - #[serde(default)] - profile: Option, - #[serde(default)] - launch_prompt: Option, -} - -impl RawTicketRoleConfig { - fn resolve(self, _role: TicketRole) -> TicketRoleConfig { - TicketRoleConfig { - profile: self.profile.unwrap_or_else(ProfileSelectorRef::inherit), - launch_prompt: self.launch_prompt, - } - } -} - -fn join_if_relative(base: &Path, path: &Path) -> PathBuf { - if path.is_absolute() { - path.to_path_buf() - } else { - base.join(path) + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) } } #[cfg(test)] mod tests { use super::*; - use tempfile::TempDir; - - fn write_config(workspace: &Path, content: &str) { - let dir = workspace.join(".yoi"); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("ticket.config.toml"), content).unwrap(); - } - - fn write_workspace_settings(workspace: &Path, content: &str) { - let dir = workspace.join(".yoi"); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("workspace.toml"), content).unwrap(); - } #[test] - fn missing_config_returns_documented_defaults() { - let temp = TempDir::new().unwrap(); - let config = TicketConfig::load_workspace(temp.path()).unwrap(); - - assert_eq!( - config.backend.provider, - TicketBackendProvider::BuiltinYoiLocal - ); - assert_eq!( - config.backend.root, - temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH) - ); - assert_eq!(config.ticket_record_language(), None); - assert_eq!(config.orchestration.branch_name(), None); - assert_eq!( - config.orchestration.effective_branch_name(), - "orchestration" - ); - assert_eq!(config.orchestration.worktree_dir(), Path::new(".worktree")); - assert_eq!( - config.orchestration.worktree_name(), - Path::new("orchestration") - ); - for role in TicketRole::ALL { - let role_config = config.role(role); - assert_eq!(role_config.profile.as_str(), "inherit"); - assert!(role_config.launch_prompt.is_none()); + fn production_config_has_no_repository_local_authority() { + let production = include_str!("config.rs") + .split_once("#[cfg(test)]\nmod tests") + .map(|(production, _)| production) + .expect("config test module marker"); + for forbidden in [ + "load_workspace", + ".yoi/", + "TicketBackendConfig", + "std::fs", + "PathBuf", + ] { + assert!( + !production.contains(forbidden), + "repository-local Ticket config authority returned through {forbidden}" + ); } } #[test] - fn workspace_settings_take_precedence_over_legacy_ticket_config() { - let temp = TempDir::new().unwrap(); - write_workspace_settings( - temp.path(), - r#" -[ticket] -language = "Japanese" - -[ticket.backend] -provider = "builtin:yoi_local" -root = "workspace-tickets" -"#, - ); - write_config( - temp.path(), - r#" -[backend] -provider = "builtin:yoi_local" -root = "legacy-tickets" - -[ticket] -language = "English" -"#, - ); - - let config = TicketConfig::load_workspace(temp.path()).unwrap(); - assert_eq!(config.backend.root, temp.path().join("workspace-tickets")); - assert_eq!(config.ticket_record_language(), Some("Japanese")); - } - - #[test] - fn legacy_ticket_config_is_read_only_migration_fallback() { - let temp = TempDir::new().unwrap(); - write_workspace_settings( - temp.path(), - r#" -workspace_id = "00000000-0000-7000-8000-000000000000" -display_name = "legacy-fallback" -"#, - ); - write_config( - temp.path(), - r#" -[backend] -provider = "builtin:yoi_local" -root = "legacy-tickets" - -[ticket] -language = "Japanese" -"#, - ); - - let config = TicketConfig::load_workspace(temp.path()).unwrap(); - assert_eq!(config.backend.root, temp.path().join("legacy-tickets")); - assert_eq!(config.ticket_record_language(), Some("Japanese")); - } - - #[test] - fn empty_workspace_ticket_table_uses_workspace_defaults_and_ignores_legacy() { - let temp = TempDir::new().unwrap(); - write_workspace_settings( - temp.path(), - r#" -[ticket] -"#, - ); - write_config( - temp.path(), - r#" -[backend] -provider = "builtin:yoi_local" -root = "legacy-tickets" - -[ticket] -language = "Japanese" -"#, - ); - - let config = TicketConfig::load_workspace(temp.path()).unwrap(); - assert_eq!(config.backend.root, temp.path().join(".yoi/tickets")); - assert_eq!(config.ticket_record_language(), None); - } - - #[test] - fn full_config_parses_fixed_role_refs() { - let temp = TempDir::new().unwrap(); - write_workspace_settings( - temp.path(), - r#" -[ticket] -language = "Japanese" - -[ticket.backend] -provider = "builtin:yoi_local" -root = "custom-tickets" - -[ticket.orchestration] -branch = "orchestration/custom-panel" -worktree_dir = "custom-worktrees" -worktree_name = "custom-orchestrator" - -[ticket.roles.intake] -profile = "project:intake" -launch_prompt = "ticket.intake.launch" - -[ticket.roles.orchestrator] -profile = "project:orchestrator" -launch_prompt = "ticket.orchestrator.launch" - -[ticket.roles.coder] -profile = "inherit" -launch_prompt = "ticket.coder.launch" - -[ticket.roles.reviewer] -profile = "project:reviewer" -launch_prompt = "ticket.reviewer.launch" -"#, - ); - - let config = TicketConfig::load_workspace(temp.path()).unwrap(); - assert_eq!( - config.backend.provider, - TicketBackendProvider::BuiltinYoiLocal - ); - assert_eq!(config.backend.root, temp.path().join("custom-tickets")); - assert_eq!(config.ticket_record_language(), Some("Japanese")); - assert_eq!( - config.orchestration.branch_name(), - Some("orchestration/custom-panel") - ); - assert_eq!( - config.orchestration.effective_branch_name(), - "orchestration/custom-panel" - ); - assert_eq!( - config.orchestration.worktree_dir(), - Path::new("custom-worktrees") - ); - assert_eq!( - config.orchestration.worktree_name(), - Path::new("custom-orchestrator") - ); - assert_eq!( - config.profile_for(TicketRole::Intake).as_str(), - "project:intake" - ); - assert_eq!( - config - .launch_prompt_for(TicketRole::Reviewer) - .unwrap() - .as_str(), - "ticket.reviewer.launch" - ); - } - - #[test] - fn scaffold_config_includes_backend_and_all_fixed_roles() { - let temp = TempDir::new().unwrap(); - let scaffold = ticket_config_scaffold(); - - assert!(scaffold.contains("[ticket]\n")); - assert!(scaffold.contains("[ticket.backend]\n")); - assert!(scaffold.contains("provider = \"builtin:yoi_local\"")); - assert!(scaffold.contains("root = \".yoi/tickets\"")); - assert!(scaffold.contains("# language = \"Japanese\"")); - assert!(scaffold.contains( - "# [ticket.orchestration]\n# branch = \"orchestration\"\n# worktree_dir = \".worktree\"\n# worktree_name = \"orchestration\"" - )); + fn fixed_roles_round_trip_without_filesystem_configuration() { for role in TicketRole::ALL { - assert!(scaffold.contains(&format!("[ticket.roles.{role}]"))); - assert!(scaffold.contains(&format!( - "[ticket.roles.{role}]\nprofile = \"{}\"", - role.default_profile() - ))); + assert_eq!(TicketRole::parse(role.as_str()), Some(role)); + assert!(role.default_profile().starts_with("builtin:")); } - assert!(!scaffold.contains("[ticket.roles.investigator]")); - - let config = TicketConfig::from_workspace_settings_toml( - temp.path(), - temp.path().join(WORKSPACE_SETTINGS_RELATIVE_PATH), - &scaffold, - ) - .unwrap() - .unwrap(); - assert_eq!(config.backend_root(), temp.path().join(".yoi/tickets")); - assert_eq!(config.orchestration.branch_name(), None); - assert_eq!( - config.orchestration.effective_branch_name(), - "orchestration" - ); - assert_eq!(config.orchestration.worktree_dir(), Path::new(".worktree")); - assert_eq!( - config.orchestration.worktree_name(), - Path::new("orchestration") - ); - for role in TicketRole::ALL { - let role_config = config.role_launch_config(role).unwrap(); - assert_eq!(role_config.profile.as_str(), role.default_profile()); - } - } - - #[test] - fn partial_role_config_keeps_role_defaults() { - let temp = TempDir::new().unwrap(); - write_config( - temp.path(), - r#" -[roles.coder] -profile = "project:coder" -"#, - ); - - let config = TicketConfig::load_workspace(temp.path()).unwrap(); - let coder = config.role(TicketRole::Coder); - assert_eq!(coder.profile.as_str(), "project:coder"); - assert!(coder.launch_prompt.is_none()); - assert_eq!(config.profile_for(TicketRole::Reviewer).as_str(), "inherit"); - } - - #[test] - fn backend_only_config_is_not_role_launch_ready() { - let temp = TempDir::new().unwrap(); - write_config( - temp.path(), - r#" -[backend] -provider = "builtin:yoi_local" -root = ".yoi/tickets" -"#, - ); - - let config = TicketConfig::load_workspace(temp.path()).unwrap(); - assert_eq!(config.backend.root, temp.path().join(".yoi/tickets")); - assert_eq!( - config.role_launch_config(TicketRole::Intake).unwrap_err(), - TicketRoleLaunchConfigError::MissingRoleTable { - role: TicketRole::Intake - } - ); - } - - #[test] - fn partial_role_config_only_marks_configured_roles_launch_ready() { - let temp = TempDir::new().unwrap(); - write_config( - temp.path(), - r#" -[roles.intake] -profile = "builtin:companion" -"#, - ); - - let config = TicketConfig::load_workspace(temp.path()).unwrap(); - assert_eq!( - config - .role_launch_config(TicketRole::Intake) - .unwrap() - .profile - .as_str(), - "builtin:companion" - ); - assert_eq!( - config - .role_launch_config(TicketRole::Orchestrator) - .unwrap_err(), - TicketRoleLaunchConfigError::MissingRoleTable { - role: TicketRole::Orchestrator - } - ); - } - - #[test] - fn orchestration_branch_config_is_validated_as_git_branch_name() { - let temp = TempDir::new().unwrap(); - write_config( - temp.path(), - r#" -[orchestration] -branch = "orchestration/panel:bad" -"#, - ); - - let error = TicketConfig::load_workspace(temp.path()).unwrap_err(); - assert!(error.to_string().contains("git branch name")); - assert!(error.to_string().contains("unsupported character")); - } - - #[test] - fn orchestration_branch_rejects_full_refs_and_dash_prefixes() { - assert!(GitBranchName::new("refs/heads/orchestration/panel").is_err()); - assert!(GitBranchName::new("-orchestration-panel").is_err()); - assert_eq!( - GitBranchName::new("orchestration/panel").unwrap().as_str(), - "orchestration/panel" - ); - } - - #[test] - fn role_table_without_profile_is_not_role_launch_ready() { - let temp = TempDir::new().unwrap(); - write_config( - temp.path(), - r#" -[roles.orchestrator] -"#, - ); - - let config = TicketConfig::load_workspace(temp.path()).unwrap(); - assert_eq!( - config - .role_launch_config(TicketRole::Orchestrator) - .unwrap_err(), - TicketRoleLaunchConfigError::MissingProfile { - role: TicketRole::Orchestrator - } - ); - } - - #[test] - fn inherit_profile_is_not_role_launch_ready() { - let temp = TempDir::new().unwrap(); - write_config( - temp.path(), - r#" -[roles.intake] -profile = "inherit" -"#, - ); - - let config = TicketConfig::load_workspace(temp.path()).unwrap(); - assert_eq!( - config.role_launch_config(TicketRole::Intake).unwrap_err(), - TicketRoleLaunchConfigError::InheritProfile { - role: TicketRole::Intake - } - ); - } - - #[test] - fn unknown_roles_are_rejected() { - let temp = TempDir::new().unwrap(); - write_config( - temp.path(), - r#" -[roles.operator] -profile = "inherit" -"#, - ); - - let error = TicketConfig::load_workspace(temp.path()).unwrap_err(); - assert!( - error - .to_string() - .contains("unsupported Ticket role `operator`") - ); - assert!( - error - .to_string() - .contains("intake, orchestrator, coder, reviewer") - ); - } - - #[test] - fn stale_investigator_role_config_is_rejected() { - let temp = TempDir::new().unwrap(); - write_config( - temp.path(), - r#" -[roles.investigator] -profile = "builtin:companion" -"#, - ); - - let error = TicketConfig::load_workspace(temp.path()).unwrap_err(); - assert!( - error - .to_string() - .contains("unsupported Ticket role `investigator`") - ); - assert!( - error - .to_string() - .contains("intake, orchestrator, coder, reviewer") - ); - } - - #[test] - fn unknown_fields_are_rejected() { - let temp = TempDir::new().unwrap(); - write_config( - temp.path(), - r#" -[roles.coder] -profile = "inherit" -system_instruction = "unsupported" -"#, - ); - - let error = TicketConfig::load_workspace(temp.path()).unwrap_err(); - assert!(error.to_string().contains("unknown field")); - assert!(error.to_string().contains("system_instruction")); - } - - #[test] - fn legacy_backend_kind_local_is_transitional_alias() { - let temp = TempDir::new().unwrap(); - write_config( - temp.path(), - r#" -[backend] -kind = "local" -root = "legacy-tickets" -"#, - ); - - let config = TicketConfig::load_workspace(temp.path()).unwrap(); - assert_eq!( - config.backend.provider, - TicketBackendProvider::BuiltinYoiLocal - ); - assert_eq!(config.backend_root(), temp.path().join("legacy-tickets")); - } - - #[test] - fn rejects_empty_ticket_record_language() { - let temp = TempDir::new().unwrap(); - write_config( - temp.path(), - r#" -[backend] -provider = "builtin:yoi_local" -root = ".yoi/tickets" - -[ticket] -language = " " -"#, - ); - - let error = TicketConfig::load_workspace(temp.path()).unwrap_err(); - assert!( - error - .to_string() - .contains("ticket record language must not be empty") - ); - } - - #[test] - fn unsupported_backend_provider_is_rejected() { - let temp = TempDir::new().unwrap(); - write_config( - temp.path(), - r#" -[backend] -provider = "github" -"#, - ); - - let error = TicketConfig::load_workspace(temp.path()).unwrap_err(); - assert!( - error - .to_string() - .contains("unsupported Ticket backend provider `github`") - ); - assert!(error.to_string().contains("builtin:yoi_local")); - } - - #[test] - fn unsupported_legacy_backend_kind_is_rejected() { - let temp = TempDir::new().unwrap(); - write_config( - temp.path(), - r#" -[backend] -kind = "github" -"#, - ); - - let error = TicketConfig::load_workspace(temp.path()).unwrap_err(); - assert!( - error - .to_string() - .contains("unsupported legacy Ticket backend kind `github`") - ); - } - - #[test] - fn backend_provider_and_legacy_kind_are_mutually_exclusive() { - let temp = TempDir::new().unwrap(); - write_config( - temp.path(), - r#" -[backend] -provider = "builtin:yoi_local" -kind = "local" -"#, - ); - - let error = TicketConfig::load_workspace(temp.path()).unwrap_err(); - assert!( - error - .to_string() - .contains("backend.provider and legacy backend.kind are mutually exclusive") - ); - } - - #[test] - fn relative_backend_root_resolves_against_workspace() { - let temp = TempDir::new().unwrap(); - write_config( - temp.path(), - r#" -[backend] -root = "nested/tickets" -"#, - ); - - let config = TicketConfig::load_workspace(temp.path()).unwrap(); - assert_eq!(config.backend_root(), temp.path().join("nested/tickets")); - } - - #[test] - fn nix_profile_selector_refs_are_rejected() { - let temp = TempDir::new().unwrap(); - write_config( - temp.path(), - r#" -[roles.coder] -profile = "legacy.nix" -"#, - ); - - let error = TicketConfig::load_workspace(temp.path()).unwrap_err(); - assert!( - error - .to_string() - .contains("path selectors are not supported") - ); - } - - #[test] - fn malformed_refs_are_reported() { - let temp = TempDir::new().unwrap(); - write_config( - temp.path(), - r#" -[roles.coder] -profile = "./coder.toml" -"#, - ); - - let error = TicketConfig::load_workspace(temp.path()).unwrap_err(); - assert!( - error - .to_string() - .contains("path selectors are not supported") - ); + assert_eq!(TicketRole::parse("operator"), None); } } diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index ff366a1c9..56531a027 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -1,24 +1,22 @@ -//! Ticket domain types and the local `.yoi/tickets/` file backend. +//! Ticket domain types and SQLite Workspace authority backend. //! -//! The public domain name is **Ticket**. `LocalTicketBackend` preserves the -//! repository's current flat `.yoi/tickets//` layout and the -//! event/thread format while exposing typed Rust operations. +//! Repository-local Ticket trees are not an authority. Runtime Workers access +//! Tickets through the Workspace API, whose control-plane implementation uses +//! the SQLite backend defined here. use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fmt; -use std::fs::{self, File, OpenOptions}; -use std::io::{self, Write}; -use std::path::{Component, Path, PathBuf}; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; use std::sync::Arc; #[cfg(test)] use std::sync::atomic::{AtomicUsize, Ordering}; use chrono::Utc; -use fs4::fs_std::FileExt; -use project_record::{allocate_record_id, unix_epoch_millis_now, validate_record_id}; +use project_record::{allocate_record_id, unix_epoch_millis_now}; use rusqlite::{Connection, OptionalExtension, params}; use serde::{Deserialize, Serialize}; -use serde_yaml::{Mapping as YamlMapping, Value as YamlValue}; use sha2::{Digest, Sha256}; use thiserror::Error; @@ -28,19 +26,11 @@ pub mod tool; pub use sqlite_schema::{migrate_sqlite_ticket_schema, verify_sqlite_ticket_schema}; -const REQUIRED_FIELDS: [&str; 4] = ["title", "state", "created_at", "updated_at"]; const MAX_STATE_CHANGE_REASON_BYTES: usize = 1024; const MAX_INTAKE_SUMMARY_BODY_BYTES: usize = 16 * 1024; -const ORCHESTRATION_PLAN_ARTIFACT: &str = "orchestration-plan.jsonl"; -const TICKET_RELATIONS_ARTIFACT: &str = "relations.json"; const MAX_ORCHESTRATION_PLAN_TEXT_BYTES: usize = 16 * 1024; const MAX_ORCHESTRATION_PLAN_FIELD_BYTES: usize = 1024; -const MAX_TICKET_RELATION_NOTE_BYTES: usize = 16 * 1024; -const MAX_TICKET_RELATION_FIELD_BYTES: usize = 1024; -const DEFAULT_TICKET_BODY: &str = - "## Background\n\nCreated by LocalTicketBackend.\n\n## Acceptance criteria\n\n- TBD\n"; -const JAPANESE_TICKET_BODY: &str = - "## 背景\n\nLocalTicketBackend によって作成されました。\n\n## 受け入れ条件\n\n- 未定\n"; +const DEFAULT_TICKET_BODY: &str = "## Background\n\n## Acceptance criteria\n\n- TBD\n"; fn normalized_record_language(language: &str) -> Option { let language = language.trim(); @@ -1001,14 +991,6 @@ pub struct TicketRelation { pub at: String, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct TicketRelationArtifact { - version: u32, - #[serde(default)] - relations: Vec, -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DerivedTicketRelation { pub source_ticket: String, @@ -1669,24 +1651,6 @@ pub struct SqliteTicketListPage { pub next: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct TicketInvalidRecord { - pub label: String, - pub reason: String, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct TicketPartialList { - pub tickets: Vec, - pub invalid_records: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct TicketPartial { - pub ticket: Ticket, - pub invalid_records: Vec, -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TicketDocument { pub body: MarkdownText, @@ -2010,511 +1974,6 @@ where }) } -#[derive(Clone)] -pub struct LocalTicketBackend { - root: PathBuf, - record_language: Option, - target_authority: Option>, -} - -impl fmt::Debug for LocalTicketBackend { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("LocalTicketBackend") - .field("root", &self.root) - .field("record_language", &self.record_language) - .field( - "target_authority", - &self.target_authority.as_ref().map(|_| "configured"), - ) - .finish() - } -} - -impl LocalTicketBackend { - pub fn new(root: impl Into) -> Self { - Self { - root: root.into(), - record_language: None, - target_authority: None, - } - } - - pub fn with_record_language(mut self, language: Option<&str>) -> Self { - self.record_language = language.and_then(normalized_record_language); - self - } - - pub fn with_target_authority(mut self, authority: Arc) -> Self { - self.target_authority = Some(authority); - self - } - - pub fn record_language(&self) -> Option<&str> { - self.record_language.as_deref() - } - - pub fn root(&self) -> &Path { - self.root.as_path() - } - - pub fn default_intake_ready_state_change_body(&self, from: &str) -> String { - if is_japanese_record_language(self.record_language()) { - format!("Ticket planning が完了しました。state {from} -> ready。\n") - } else { - format!("Ticket planning complete; state {from} -> ready.\n") - } - } - - pub fn list_partial(&self, filter: TicketListQuery) -> Result { - let mut output = TicketPartialList::default(); - let mut invalid_seen = BTreeSet::new(); - for dir in self.iter_ticket_dirs(TicketListQuery::all())? { - let item = dir.join("item.md"); - if !item.exists() { - continue; - } - match read_item_file(&item) - .and_then(|parsed| ticket_meta_for_dir(&dir, parsed.frontmatter)) - { - Ok(meta) => { - if !filter.matches_state(meta.workflow_state) { - continue; - } - output.tickets.push(ticket_summary_from_meta(meta)); - } - Err(error) => push_invalid_ticket_record( - &mut output.invalid_records, - &mut invalid_seen, - &dir, - &error, - ), - } - } - Ok(output) - } - - pub fn show_partial(&self, id: TicketIdOrSlug) -> Result { - let dir = self.find_ticket_dir(&id)?; - let mut invalid_records = Vec::new(); - let mut invalid_seen = BTreeSet::new(); - let ticket = - self.ticket_from_dir_tolerant(&dir, &mut invalid_records, &mut invalid_seen)?; - Ok(TicketPartial { - ticket, - invalid_records, - }) - } - - fn generated_heading(&self, default: &'static str, japanese: &'static str) -> &'static str { - if is_japanese_record_language(self.record_language()) { - japanese - } else { - default - } - } - - fn generated_default_body(&self) -> &'static str { - if is_japanese_record_language(self.record_language()) { - JAPANESE_TICKET_BODY - } else { - DEFAULT_TICKET_BODY - } - } - - fn created_event_body(&self) -> &'static str { - if is_japanese_record_language(self.record_language()) { - "LocalTicketBackend によって作成されました。" - } else { - "Created by LocalTicketBackend create." - } - } - - fn queued_ready_body(&self, queued_by: &str) -> String { - if is_japanese_record_language(self.record_language()) { - format!("Ticket を `{queued_by}` が queued にしました。\n") - } else { - "Ticket queued for Orchestrator routing.\n".to_string() - } - } - - fn closed_workflow_state_body(&self) -> &'static str { - if is_japanese_record_language(self.record_language()) { - "Ticket を closed にしました。\n" - } else { - "Ticket closed.\n" - } - } - - fn ensure_backend_dirs(&self) -> Result<()> { - fs::create_dir_all(&self.root).map_err(|e| io_err(&self.root, e)) - } - - fn ticket_dir(&self, id: &str) -> Result { - ensure_safe_component(id)?; - let dir = self.root.join(id); - ensure_child_of(&self.root, &dir)?; - Ok(dir) - } - - fn acquire_lock(&self) -> Result { - fs::create_dir_all(&self.root).map_err(|e| io_err(&self.root, e))?; - let path = self.root.join(".ticket-backend.lock"); - let file = OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .open(&path) - .map_err(|e| io_err(&path, e))?; - match FileExt::try_lock_exclusive(&file) { - Ok(true) => Ok(BackendLock { file }), - Ok(false) => Err(TicketError::Locked { path }), - Err(e) if e.kind() == io::ErrorKind::WouldBlock => Err(TicketError::Locked { path }), - Err(e) => Err(io_err(path, e)), - } - } - - fn iter_ticket_dirs(&self, filter: TicketListQuery) -> Result> { - let mut dirs = Vec::new(); - if !self.root.exists() { - return Ok(dirs); - } - let entries = fs::read_dir(&self.root).map_err(|e| io_err(&self.root, e))?; - for entry in entries { - let entry = entry.map_err(|e| io_err(&self.root, e))?; - let path = entry.path(); - let name = entry.file_name(); - let Some(name) = name.to_str() else { - continue; - }; - if !path.is_dir() || name.starts_with('.') { - continue; - } - let item = path.join("item.md"); - if !item.is_file() { - continue; - } - if !matches!(filter.state, TicketStateSelector::All) { - let parsed = read_item_file(&item)?; - let meta = ticket_meta_for_dir(&path, parsed.frontmatter)?; - if !filter.matches_state(meta.workflow_state) { - continue; - } - } - dirs.push(path); - } - dirs.sort(); - Ok(dirs) - } - - fn find_ticket_dir(&self, query: &TicketIdOrSlug) -> Result { - let query = query.as_query(); - let dir = self.ticket_dir(query)?; - if dir.join("item.md").is_file() { - Ok(dir) - } else { - Err(TicketError::NotFound(query.to_string())) - } - } - - fn ticket_from_dir(&self, dir: &Path) -> Result { - self.ticket_from_dir_with_relations(dir, |backend, meta| { - backend.relation_view_for_meta(meta) - }) - } - - fn ticket_from_dir_tolerant( - &self, - dir: &Path, - invalid_records: &mut Vec, - invalid_seen: &mut BTreeSet, - ) -> Result { - self.ticket_from_dir_with_relations(dir, |backend, meta| { - backend.relation_view_for_meta_tolerant(meta, invalid_records, invalid_seen) - }) - } - - fn ticket_from_dir_with_relations( - &self, - dir: &Path, - relation_view: impl FnOnce(&Self, &TicketMeta) -> Result, - ) -> Result { - let item_path = dir.join("item.md"); - let parsed = read_item_file(&item_path)?; - let meta = ticket_meta_for_dir(dir, parsed.frontmatter.clone())?; - let document = TicketDocument { - body: MarkdownText::new(parsed.body), - raw_frontmatter: parsed.frontmatter.raw, - }; - let thread_path = dir.join("thread.md"); - let events = if thread_path.exists() { - parse_thread(&thread_path)? - } else { - Vec::new() - }; - let artifacts = collect_artifacts(&dir.join("artifacts"))?; - let relations = relation_view(self, &meta)?; - let resolution_path = dir.join("resolution.md"); - let resolution = if resolution_path.exists() { - Some(MarkdownText::new( - fs::read_to_string(&resolution_path).map_err(|e| io_err(&resolution_path, e))?, - )) - } else { - None - }; - Ok(Ticket { - meta, - document, - events, - artifacts, - relations, - resolution, - }) - } - - fn ticket_workflow_state_from_dir(&self, dir: &Path) -> Result { - let item = dir.join("item.md"); - let parsed = read_item_file(&item)?; - let meta = ticket_meta_for_dir(dir, parsed.frontmatter)?; - Ok(meta.workflow_state) - } - - fn apply_workflow_state_change( - &self, - dir: &Path, - expected_from: TicketWorkflowState, - to: TicketWorkflowState, - change: TicketStateChange, - extra_updates: &[(&str, &str)], - ) -> Result<()> { - validate_state_change(&change)?; - if change.from.as_str() != expected_from.as_str() || change.to.as_str() != to.as_str() { - return Err(TicketError::Conflict(format!( - "workflow_state change payload mismatch: expected {} -> {}, got {} -> {}", - expected_from.as_str(), - to.as_str(), - change.from, - change.to - ))); - } - let item = dir.join("item.md"); - let current = self.ticket_workflow_state_from_dir(dir)?; - if current != expected_from { - return Err(TicketError::Conflict(format!( - "state changed concurrently: expected `{}`, found `{}`", - expected_from.as_str(), - current.as_str() - ))); - } - self.append_state_changed_event(dir, &change, Some("state"))?; - let mut updates = vec![("state", to.as_str())]; - updates.extend_from_slice(extra_updates); - self.set_frontmatter_fields(&item, &updates) - } - - fn append_thread_event( - &self, - dir: &Path, - event: &str, - heading: &str, - author: &str, - status: Option<&str>, - attrs: &[(&str, &str)], - body: &MarkdownText, - ) -> Result<()> { - let at = now_utc(); - let mut event_attrs = vec![("event", event), ("author", author), ("at", at.as_str())]; - if let Some(status) = status { - event_attrs.push(("status", status)); - } - event_attrs.extend_from_slice(attrs); - let comment = render_event_comment(&event_attrs)?; - let entry = format!("\n{comment}\n\n## {heading}\n\n{}\n\n---\n", body.as_str()); - - let thread = dir.join("thread.md"); - if !thread.exists() { - File::create(&thread).map_err(|e| io_err(&thread, e))?; - } - let mut file = OpenOptions::new() - .append(true) - .open(&thread) - .map_err(|e| io_err(&thread, e))?; - file.write_all(entry.as_bytes()) - .map_err(|e| io_err(&thread, e))?; - file.sync_data().map_err(|e| io_err(&thread, e))?; - self.set_frontmatter_fields(&dir.join("item.md"), &[("updated_at", at.as_str())]) - } - - fn append_state_changed_event( - &self, - dir: &Path, - change: &TicketStateChange, - state_field: Option<&str>, - ) -> Result<()> { - self.append_state_changed_event_with_attributes(dir, change, state_field, &[]) - } - - fn append_state_changed_event_with_attributes( - &self, - dir: &Path, - change: &TicketStateChange, - state_field: Option<&str>, - extra_attributes: &[(&str, &str)], - ) -> Result<()> { - validate_state_change(change)?; - let author = change.author.clone().unwrap_or_else(default_author); - let mut attrs = vec![ - ("from", change.from.as_str()), - ("to", change.to.as_str()), - ("reason", change.reason.as_str()), - ]; - if let Some(state_field) = state_field { - attrs.push(("field", state_field)); - } - attrs.extend_from_slice(extra_attributes); - self.append_thread_event( - dir, - TicketEventKind::StateChanged.as_str(), - &TicketEventKind::StateChanged.heading(), - &author, - None, - &attrs, - &change.body, - ) - } - - fn append_intake_summary_event(&self, dir: &Path, summary: &TicketIntakeSummary) -> Result<()> { - validate_intake_summary(summary)?; - let author = summary.author.clone().unwrap_or_else(default_author); - self.append_thread_event( - dir, - TicketEventKind::IntakeSummary.as_str(), - &TicketEventKind::IntakeSummary.heading(), - &author, - None, - &[], - &summary.body, - ) - } - - fn set_frontmatter_fields(&self, item: &Path, updates: &[(&str, &str)]) -> Result<()> { - let content = fs::read_to_string(item).map_err(|e| io_err(item, e))?; - let updated = replace_frontmatter_fields(&content, updates).map_err(|message| { - TicketError::Parse { - path: item.to_path_buf(), - message, - } - })?; - atomic_write(item, updated.as_bytes()) - } - - fn orchestration_plan_path(&self, dir: &Path) -> PathBuf { - dir.join("artifacts").join(ORCHESTRATION_PLAN_ARTIFACT) - } - - fn ticket_relations_path(&self, dir: &Path) -> PathBuf { - dir.join("artifacts").join(TICKET_RELATIONS_ARTIFACT) - } - - fn read_ticket_relations_for_dir(&self, dir: &Path) -> Result> { - let item = dir.join("item.md"); - let meta = ticket_meta_for_dir(dir, read_item_file(&item)?.frontmatter)?; - let path = self.ticket_relations_path(dir); - read_ticket_relations_artifact(&path, Some(&meta)) - } - - fn all_ticket_relation_records(&self) -> Result> { - let mut relations = Vec::new(); - for dir in self.iter_ticket_dirs(TicketListQuery::all())? { - relations.extend(self.read_ticket_relations_for_dir(&dir)?); - } - sort_ticket_relations(&mut relations); - Ok(relations) - } - - fn all_ticket_relation_records_tolerant( - &self, - invalid_records: &mut Vec, - invalid_seen: &mut BTreeSet, - ) -> Result> { - let mut relations = Vec::new(); - for dir in self.iter_ticket_dirs(TicketListQuery::all())? { - match self.read_ticket_relations_for_dir(&dir) { - Ok(records) => relations.extend(records), - Err(error) => { - push_invalid_ticket_record(invalid_records, invalid_seen, &dir, &error) - } - } - } - sort_ticket_relations(&mut relations); - Ok(relations) - } - - fn relation_view_for_meta(&self, meta: &TicketMeta) -> Result { - let states = self.ticket_state_index()?; - let all = self.all_ticket_relation_records()?; - Ok(relation_view_from_records(meta, &all, &states)) - } - - fn relation_view_for_meta_tolerant( - &self, - meta: &TicketMeta, - invalid_records: &mut Vec, - invalid_seen: &mut BTreeSet, - ) -> Result { - let states = self.ticket_state_index_tolerant(invalid_records, invalid_seen)?; - let all = self.all_ticket_relation_records_tolerant(invalid_records, invalid_seen)?; - Ok(relation_view_from_records(meta, &all, &states)) - } - - fn ticket_state_index(&self) -> Result> { - let mut states = HashMap::new(); - for dir in self.iter_ticket_dirs(TicketListQuery::all())? { - let item = dir.join("item.md"); - let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?; - states.insert(meta.id, meta.workflow_state); - } - Ok(states) - } - - fn ticket_state_index_tolerant( - &self, - invalid_records: &mut Vec, - invalid_seen: &mut BTreeSet, - ) -> Result> { - let mut states = HashMap::new(); - for dir in self.iter_ticket_dirs(TicketListQuery::all())? { - let item = dir.join("item.md"); - match read_item_file(&item) - .and_then(|parsed| ticket_meta_for_dir(&dir, parsed.frontmatter)) - { - Ok(meta) => { - states.insert(meta.id, meta.workflow_state); - } - Err(error) => { - push_invalid_ticket_record(invalid_records, invalid_seen, &dir, &error) - } - } - } - Ok(states) - } - - fn relation_blockers_for_meta(&self, meta: &TicketMeta) -> Result> { - Ok(self.relation_view_for_meta(meta)?.blockers) - } - - fn read_orchestration_plan_records_for_dir( - &self, - dir: &Path, - ) -> Result> { - let item = dir.join("item.md"); - let meta = ticket_meta_for_dir(dir, read_item_file(&item)?.frontmatter)?; - let path = self.orchestration_plan_path(dir); - read_orchestration_plan_artifact(&path, Some(&meta)) - } -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct SqliteTicketMutationEvent { pub workspace_id: String, @@ -2849,20 +2308,6 @@ impl SqliteTicketBackend { .collect() } - pub fn import_from_local_backend(&self, local: &LocalTicketBackend) -> Result<()> { - let conn = self.open_connection()?; - conn.execute_batch("BEGIN IMMEDIATE").map_err(sqlite_err)?; - let result = (|| { - for summary in local.list(TicketListQuery::all())? { - let ticket = local.show(TicketIdOrSlug::Id(summary.id.clone()))?; - self.store_ticket(&conn, &ticket)?; - self.import_artifact_content(&conn, local.root(), &ticket)?; - } - Ok(()) - })(); - finish_sqlite_transaction(&conn, result) - } - fn connect(&self) -> Result { if let Some(parent) = self.db_path.parent() { fs::create_dir_all(parent).map_err(|error| io_err(parent, error))?; @@ -3055,15 +2500,6 @@ impl SqliteTicketBackend { Ok(()) } - fn store_ticket(&self, conn: &Connection, ticket: &Ticket) -> Result<()> { - conn.execute( - "DELETE FROM typed_tickets WHERE workspace_id = ?1 AND ticket_id = ?2", - params![self.workspace_id, ticket.meta.id], - ) - .map_err(sqlite_err)?; - self.insert_ticket(conn, ticket) - } - fn insert_ticket(&self, conn: &Connection, ticket: &Ticket) -> Result<()> { conn.execute(r#"INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, body, created_at, updated_at, assignee, readiness, workflow_state, workflow_state_explicit, queued_by, queued_at, resolution, repository_id, ref_selector) @@ -3116,26 +2552,6 @@ impl SqliteTicketBackend { } Ok(()) } - - fn import_artifact_content( - &self, - conn: &Connection, - local_root: &Path, - ticket: &Ticket, - ) -> Result<()> { - for artifact in &ticket.artifacts { - validate_artifact_relative_path(&artifact.relative_path)?; - let path = local_root - .join(&ticket.meta.id) - .join("artifacts") - .join(&artifact.relative_path); - let content = fs::read(&path).map_err(|error| io_err(&path, error))?; - conn.execute("INSERT OR REPLACE INTO typed_ticket_artifacts (workspace_id, ticket_id, relative_path, content) VALUES (?1, ?2, ?3, ?4)", - params![self.workspace_id, ticket.meta.id, artifact.relative_path.to_string_lossy(), content]).map_err(sqlite_err)?; - } - Ok(()) - } - fn ticket_meta_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { let state_raw: String = row.get(12)?; Ok(TicketMeta { @@ -3420,25 +2836,6 @@ fn finish_sqlite_transaction(conn: &Connection, result: Result) -> Result< } } -fn validate_artifact_relative_path(path: &Path) -> Result<()> { - if path.as_os_str().is_empty() || path.is_absolute() { - return Err(TicketError::PathEscapesRoot { - path: path.to_path_buf(), - }); - } - for component in path.components() { - match component { - Component::Normal(_) => {} - _ => { - return Err(TicketError::PathEscapesRoot { - path: path.to_path_buf(), - }); - } - } - } - Ok(()) -} - impl TicketBackend for SqliteTicketBackend { fn default_intake_ready_state_change_body(&self, from: &str) -> String { if is_japanese_record_language(self.record_language()) { @@ -4160,1292 +3557,6 @@ impl TicketBackend for SqliteTicketBackend { } } -impl TicketBackend for LocalTicketBackend { - fn default_intake_ready_state_change_body(&self, from: &str) -> String { - self.default_intake_ready_state_change_body(from) - } - - fn list(&self, filter: TicketListQuery) -> Result> { - let mut tickets = Vec::new(); - for dir in self.iter_ticket_dirs(filter)? { - let item = dir.join("item.md"); - if !item.exists() { - continue; - } - let parsed = read_item_file(&item)?; - let meta = ticket_meta_for_dir(&dir, parsed.frontmatter)?; - tickets.push(ticket_summary_from_meta(meta)); - } - Ok(tickets) - } - - fn show(&self, id: TicketIdOrSlug) -> Result { - let dir = self.find_ticket_dir(&id)?; - self.ticket_from_dir(&dir) - } - - fn create(&self, input: NewTicket) -> Result { - let _lock = self.acquire_lock()?; - self.ensure_backend_dirs()?; - if input.title.trim().is_empty() { - return Err(TicketError::Conflict( - "ticket title must not be empty".to_string(), - )); - } - validate_ticket_target( - input.repository_id.as_deref(), - input.ref_selector.as_deref(), - )?; - let base_millis = unix_epoch_millis_now().map_err(|err| { - TicketError::Conflict(format!("failed to read ticket id timestamp: {err}")) - })?; - let id = allocate_record_id(base_millis, |candidate| match self.ticket_dir(candidate) { - Ok(dir) => dir.exists(), - Err(_) => true, - }) - .map_err(|err| { - TicketError::Conflict(format!("failed to allocate unique ticket id: {err}")) - })?; - let dir = self.ticket_dir(&id)?; - let created = now_utc(); - let author = input - .author - .unwrap_or_else(|| "LocalTicketBackend".to_string()); - let create_comment = render_event_comment(&[ - ("event", TicketEventKind::Create.as_str()), - ("author", &author), - ("at", &created), - ])?; - - fs::create_dir_all(dir.join("artifacts")).map_err(|e| io_err(&dir, e))?; - atomic_write(&dir.join("artifacts/.gitkeep"), b"")?; - let mut fields = Vec::new(); - fields.push(( - "title".to_string(), - format_yaml_string_scalar(input.title.as_str()), - )); - fields.push(( - "state".to_string(), - format_yaml_string_scalar( - input - .workflow_state - .unwrap_or(TicketWorkflowState::Planning) - .as_str(), - ), - )); - fields.push(( - "created_at".to_string(), - format_yaml_string_scalar(&created), - )); - fields.push(( - "updated_at".to_string(), - format_yaml_string_scalar(&created), - )); - fields.push(( - "assignee".to_string(), - yaml_string_or_null(input.assignee.as_deref()), - )); - if let Some(readiness) = input.readiness { - fields.push(( - "readiness".to_string(), - format_yaml_string_scalar(readiness.as_str()), - )); - } - if !input.risk_flags.is_empty() { - fields.push(("risk_flags".to_string(), labels_yaml(&input.risk_flags))); - } - if let Some(queued_by) = input.queued_by { - fields.push(( - "queued_by".to_string(), - format_yaml_string_scalar(queued_by.as_str()), - )); - } - if let Some(queued_at) = input.queued_at { - fields.push(( - "queued_at".to_string(), - format_yaml_string_scalar(queued_at.as_str()), - )); - } - if let Some(repository_id) = input.repository_id { - fields.push(( - "repository_id".to_string(), - format_yaml_string_scalar(repository_id.as_str()), - )); - } - if let Some(ref_selector) = input.ref_selector { - fields.push(( - "ref_selector".to_string(), - format_yaml_string_scalar(ref_selector.as_str()), - )); - } - let item_body = if input.body.as_str() == DEFAULT_TICKET_BODY { - self.generated_default_body() - } else { - input.body.as_str() - }; - let item = serialize_item(&fields, item_body); - atomic_write(&dir.join("item.md"), item.as_bytes())?; - let thread = format!( - "{create_comment}\n\n## {}\n\n{}\n\n---\n", - self.generated_heading("Created", "作成"), - self.created_event_body() - ); - atomic_write(&dir.join("thread.md"), thread.as_bytes())?; - Ok(TicketRef { - id: id.clone(), - resource_key: None, - slug: id, - status: TicketStatus::Open, - }) - } - - fn edit_item(&self, id: TicketIdOrSlug, edit: TicketItemEdit) -> Result { - edit.validate_body_edit_request()?; - if !edit.has_changes() { - return Err(TicketError::Conflict( - "TicketEditItem requires at least one of title, body, or body_replacement" - .to_string(), - )); - } - if let Some(title) = edit.title.as_deref() { - validate_required_event_value("title", title)?; - } - if let Some(author) = edit.author.as_deref() { - validate_required_event_value("author", author)?; - } - let _lock = self.acquire_lock()?; - let dir = self.find_ticket_dir(&id)?; - let item = dir.join("item.md"); - if edit.target.is_some() { - let current = self.ticket_workflow_state_from_dir(&dir)?; - if current != TicketWorkflowState::Planning { - return Err(TicketError::Conflict(format!( - "ticket implementation target is locked after planning (current state: {})", - current.as_str() - ))); - } - } - let mut content = fs::read_to_string(&item).map_err(|e| io_err(&item, e))?; - let mut body_edit_audit = TicketBodyEditAudit::None; - let mut updates = Vec::new(); - if let Some(title) = edit.title.as_deref() { - updates.push(("title", title)); - } - if !updates.is_empty() { - content = replace_frontmatter_fields(&content, &updates).map_err(|message| { - TicketError::Parse { - path: item.clone(), - message, - } - })?; - } - if let Some(target) = edit.target.as_ref() { - match target { - TicketTargetEdit::Set { - repository_id, - ref_selector, - } => { - content = replace_frontmatter_fields( - &content, - &[("repository_id", repository_id.as_str())], - ) - .map_err(|message| TicketError::Parse { - path: item.clone(), - message, - })?; - if let Some(ref_selector) = ref_selector { - content = replace_frontmatter_fields( - &content, - &[("ref_selector", ref_selector.as_str())], - ) - .map_err(|message| TicketError::Parse { - path: item.clone(), - message, - })?; - } else { - content = remove_frontmatter_fields(&content, &["ref_selector"]).map_err( - |message| TicketError::Parse { - path: item.clone(), - message, - }, - )?; - } - } - TicketTargetEdit::Clear => { - content = - remove_frontmatter_fields(&content, &["repository_id", "ref_selector"]) - .map_err(|message| TicketError::Parse { - path: item.clone(), - message, - })?; - } - } - } - if let Some(body) = edit.body.as_ref() { - content = replace_item_body(&content, body.as_str()).map_err(|message| { - TicketError::Parse { - path: item.clone(), - message, - } - })?; - body_edit_audit = TicketBodyEditAudit::WholeBody; - } - if let Some(replacement) = edit.body_replacement.as_ref() { - let parsed = parse_item(&content).map_err(|message| TicketError::Parse { - path: item.clone(), - message, - })?; - let outcome = replacement.apply(parsed.body.as_str())?; - content = replace_item_body(&content, outcome.body.as_str()).map_err(|message| { - TicketError::Parse { - path: item.clone(), - message, - } - })?; - body_edit_audit = TicketBodyEditAudit::Partial { - replacement_count: outcome.replacement_count, - }; - } - atomic_write(&item, content.as_bytes())?; - - let author = edit.author.unwrap_or_else(default_author); - let mut changes = Vec::new(); - if edit.title.is_some() { - changes.push("title"); - } - if !matches!(body_edit_audit, TicketBodyEditAudit::None) { - changes.push("body"); - } - if edit.target.is_some() { - changes.push("target"); - } - let mut attrs = Vec::new(); - attrs.push(("changes", changes.join(","))); - let body = match body_edit_audit { - TicketBodyEditAudit::None => { - MarkdownText::new(format!("Ticket item updated: {}.", changes.join(", "))) - } - TicketBodyEditAudit::WholeBody => { - attrs.push(("body_edit", "whole".to_string())); - MarkdownText::new(format!( - "Ticket item updated: {}. Body was replaced as a whole.", - changes.join(", ") - )) - } - TicketBodyEditAudit::Partial { replacement_count } => { - attrs.push(("body_edit", "partial".to_string())); - attrs.push(("replacement_count", replacement_count.to_string())); - MarkdownText::new(format!( - "Ticket item updated: {}. Body replacement applied to {replacement_count} occurrence(s).", - changes.join(", ") - )) - } - }; - self.append_thread_event( - &dir, - "item_edit", - self.generated_heading("Item updated", "項目更新"), - &author, - None, - &attrs - .iter() - .map(|(key, value)| (*key, value.as_str())) - .collect::>(), - &body, - )?; - self.ticket_from_dir(&dir) - } - - fn dependency_check(&self, id: TicketIdOrSlug) -> Result { - let requested_dir = self.find_ticket_dir(&id)?; - let requested_ticket = ticket_id_from_dir(&requested_dir)?; - let mut states = HashMap::new(); - for dir in self.iter_ticket_dirs(TicketListQuery::all())? { - let item = dir.join("item.md"); - let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?; - states.insert(meta.id, meta.workflow_state); - } - let relations = self.all_ticket_relation_records()?; - let blockers = transitive_dependency_blockers(&requested_ticket, &states, &relations)?; - let ticket = self.ticket_from_dir(&requested_dir)?; - let summary = ticket_summary_from_meta(ticket.meta); - let mut projection = project_ticket_workspace_item(&summary, &blockers, None); - let queue_tickets = if summary.workflow_state == TicketWorkflowState::Ready { - match dependency_queue_plan(&requested_ticket, &states, &relations) { - Ok(queue_tickets) => { - let target_error = queue_tickets.iter().find_map(|candidate| { - self.find_ticket_dir(&TicketIdOrSlug::Id(candidate.clone())) - .and_then(|dir| self.ticket_from_dir(&dir)) - .and_then(|ticket| { - resolve_ready_target( - self.target_authority.as_ref(), - "local", - &ticket, - ) - }) - .err() - }); - if let Some(error) = target_error { - projection.queue_guard = TicketQueueGuard { - can_queue_for_orchestrator: false, - reason: Some("Queue dependency target validation failed".to_string()), - blocked_reason: Some(error.to_string()), - }; - Vec::new() - } else { - queue_tickets - } - } - Err(error) => { - projection.queue_guard = TicketQueueGuard { - can_queue_for_orchestrator: false, - reason: Some("Queue dependency validation failed".to_string()), - blocked_reason: Some(error.to_string()), - }; - Vec::new() - } - } - } else { - Vec::new() - }; - Ok(TicketDependencyCheck { - ticket: summary, - blockers, - queue_guard: projection.queue_guard, - queue_tickets, - recommended_action: projection - .next_action - .unwrap_or(TicketWorkspaceNextAction::WaitForOrchestrator), - }) - } - - fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> Result<()> { - let _lock = self.acquire_lock()?; - let dir = self.find_ticket_dir(&id)?; - let author = event.author.unwrap_or_else(default_author); - self.append_thread_event( - &dir, - event.kind.as_str(), - &event.kind.heading(), - &author, - None, - &[], - &event.body, - ) - } - - fn add_state_changed(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()> { - let _lock = self.acquire_lock()?; - let dir = self.find_ticket_dir(&id)?; - self.append_state_changed_event(&dir, &change, None) - } - - fn add_intake_summary(&self, id: TicketIdOrSlug, summary: TicketIntakeSummary) -> Result<()> { - let _lock = self.acquire_lock()?; - let dir = self.find_ticket_dir(&id)?; - self.append_intake_summary_event(&dir, &summary) - } - - fn set_state_field( - &self, - id: TicketIdOrSlug, - field: &str, - change: TicketStateChange, - ) -> Result<()> { - validate_state_field_name(field)?; - if field == "state" || field == "workflow_state" || field == "status" { - return Err(TicketError::Conflict( - "ticket lifecycle state transitions must use dedicated lifecycle APIs".to_string(), - )); - } - let _lock = self.acquire_lock()?; - let dir = self.find_ticket_dir(&id)?; - let item = dir.join("item.md"); - let parsed = read_item_file(&item)?; - let current = parsed - .frontmatter - .get(field) - .map(String::as_str) - .unwrap_or(""); - if current != change.from.as_str() { - return Err(TicketError::Conflict(format!( - "state field `{field}` changed concurrently: expected `{}`, found `{current}`", - change.from - ))); - } - self.append_state_changed_event(&dir, &change, Some(field))?; - self.set_frontmatter_fields(&item, &[(field, change.to.as_str())]) - } - - fn set_workflow_state(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()> { - let from = TicketWorkflowState::parse(&change.from).ok_or_else(|| { - TicketError::Conflict(format!( - "invalid workflow_state transition source: {}", - change.from - )) - })?; - let to = TicketWorkflowState::parse(&change.to).ok_or_else(|| { - TicketError::Conflict(format!( - "invalid workflow_state transition target: {}", - change.to - )) - })?; - validate_generic_state_change(from, to)?; - let _lock = self.acquire_lock()?; - let dir = self.find_ticket_dir(&id)?; - if from == TicketWorkflowState::Queued && to == TicketWorkflowState::InProgress { - let item = dir.join("item.md"); - let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?; - let blockers = self.relation_blockers_for_meta(&meta)?; - if !blockers.is_empty() { - return Err(TicketError::Conflict(format!( - "ticket {} has unresolved blocking relation(s): {}", - meta.id, - format_relation_blockers(&blockers) - ))); - } - } - self.apply_workflow_state_change(&dir, from, to, change, &[]) - } - - fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> Result { - validate_required_event_value("operation_key", &request.operation_key)?; - let _lock = self.acquire_lock()?; - let dir = self.find_ticket_dir(&id)?; - let ticket = self.ticket_from_dir(&dir)?; - if validate_mark_ready_replay(&ticket, &request)? { - return Ok(ticket); - } - let target = resolve_ready_target(self.target_authority.as_ref(), "local", &ticket)?; - let fingerprint = mark_ready_fingerprint(&ticket, &request, &target); - if ticket.meta.workflow_state != TicketWorkflowState::Planning { - return Err(TicketError::StaleWorkflowState { - expected: TicketWorkflowState::Planning.as_str().to_owned(), - actual: ticket.meta.workflow_state.as_str().to_owned(), - }); - } - let reason = request - .reason - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or("implementation target validated"); - let mut change = TicketStateChange::new( - TicketWorkflowState::Planning.as_str(), - TicketWorkflowState::Ready.as_str(), - reason, - MarkdownText::new(format!( - "Implementation target `{}` at selector `{}` was validated and the Ticket was marked ready.", - target.repository_id, target.ref_selector - )), - ); - change.author = request.author.clone().or_else(|| Some(default_author())); - if let Some(mut summary) = request.intake_summary { - summary.author = request.author.clone().or(summary.author); - self.append_intake_summary_event(&dir, &summary)?; - } - self.append_state_changed_event_with_attributes( - &dir, - &change, - Some("state"), - &[ - ("operation_key", request.operation_key.as_str()), - ("request_fingerprint", fingerprint.as_str()), - ("repository_id", target.repository_id.as_str()), - ("ref_selector", target.ref_selector.as_str()), - ], - )?; - self.set_frontmatter_fields( - &dir.join("item.md"), - &[ - ("state", TicketWorkflowState::Ready.as_str()), - ("repository_id", target.repository_id.as_str()), - ("ref_selector", target.ref_selector.as_str()), - ], - )?; - self.ticket_from_dir(&dir) - } - - fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result { - validate_required_event_value("queued_by", queued_by)?; - let _lock = self.acquire_lock()?; - let requested_dir = self.find_ticket_dir(&id)?; - let requested_ticket = ticket_id_from_dir(&requested_dir)?; - let mut states = HashMap::new(); - for dir in self.iter_ticket_dirs(TicketListQuery::all())? { - let item = dir.join("item.md"); - let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?; - states.insert(meta.id, meta.workflow_state); - } - let relations = self.all_ticket_relation_records()?; - let queued_tickets = dependency_queue_plan(&requested_ticket, &states, &relations)?; - - let mut planned = Vec::with_capacity(queued_tickets.len()); - for ticket_id in &queued_tickets { - let dir = self.find_ticket_dir(&TicketIdOrSlug::Id(ticket_id.clone()))?; - let ticket = self.ticket_from_dir(&dir)?; - let target = resolve_ready_target(self.target_authority.as_ref(), "local", &ticket)?; - planned.push((dir, target)); - } - - let snapshots = planned - .iter() - .map(|(dir, _)| { - let item_path = dir.join("item.md"); - let thread_path = dir.join("thread.md"); - Ok(( - item_path.clone(), - fs::read(&item_path).map_err(|error| io_err(&item_path, error))?, - thread_path.clone(), - if thread_path.exists() { - Some(fs::read(&thread_path).map_err(|error| io_err(&thread_path, error))?) - } else { - None - }, - )) - }) - .collect::>>()?; - - for (dir, target) in planned { - let at = now_utc(); - let mut change = TicketStateChange::new( - TicketWorkflowState::Ready.as_str(), - TicketWorkflowState::Queued.as_str(), - "queued", - self.queued_ready_body(queued_by), - ); - change.author = Some(queued_by.to_string()); - if let Err(error) = self.apply_workflow_state_change( - &dir, - TicketWorkflowState::Ready, - TicketWorkflowState::Queued, - change, - &[ - ("queued_by", queued_by), - ("queued_at", at.as_str()), - ("repository_id", target.repository_id.as_str()), - ("ref_selector", target.ref_selector.as_str()), - ("queue_root_ticket", requested_ticket.as_str()), - ], - ) { - for (item_path, item_before, thread_path, thread_before) in &snapshots { - if fs::read(item_path).ok().as_deref() != Some(item_before.as_slice()) { - fs::write(item_path, item_before) - .map_err(|rollback| io_err(item_path, rollback))?; - } - match thread_before { - Some(thread_before) - if fs::read(thread_path).ok().as_deref() - != Some(thread_before.as_slice()) => - { - fs::write(thread_path, thread_before) - .map_err(|rollback| io_err(thread_path, rollback))?; - } - None if thread_path.exists() => { - fs::remove_file(thread_path) - .map_err(|rollback| io_err(thread_path, rollback))?; - } - _ => {} - } - } - return Err(error); - } - } - - Ok(TicketQueueOutcome { - requested_ticket, - queued_tickets, - }) - } - - fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()> { - let _lock = self.acquire_lock()?; - self.ensure_backend_dirs()?; - let dir = self.find_ticket_dir(&id)?; - let at = now_utc(); - let current_workflow_state = self.ticket_workflow_state_from_dir(&dir)?; - if current_workflow_state != TicketWorkflowState::Closed { - let mut change = TicketStateChange::new( - current_workflow_state.as_str(), - TicketWorkflowState::Closed.as_str(), - "closed", - self.closed_workflow_state_body(), - ); - change.author = Some(default_author()); - self.append_state_changed_event(&dir, &change, Some("state"))?; - } - self.set_frontmatter_fields( - &dir.join("item.md"), - &[ - ("state", TicketWorkflowState::Closed.as_str()), - ("updated_at", &at), - ], - )?; - atomic_write(&dir.join("resolution.md"), resolution.as_str().as_bytes())?; - let author = default_author(); - self.append_thread_event( - &dir, - "close", - self.generated_heading("Closed", "完了"), - &author, - Some("closed"), - &[], - &resolution, - ) - } - - fn add_ticket_relation( - &self, - id: TicketIdOrSlug, - relation: NewTicketRelation, - ) -> Result { - validate_new_ticket_relation(&relation)?; - let _lock = self.acquire_lock()?; - self.ensure_backend_dirs()?; - let dir = self.find_ticket_dir(&id)?; - let item = dir.join("item.md"); - let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?; - if relation.target.trim() == meta.id { - return Err(TicketError::Conflict(format!( - "ticket relation cannot target itself: {}", - meta.id - ))); - } - let target_id = relation.target.trim().to_string(); - let target_dir = self.ticket_dir(&target_id)?; - if !target_dir.join("item.md").is_file() { - return Err(TicketError::NotFound(target_id)); - } - let artifacts = dir.join("artifacts"); - fs::create_dir_all(&artifacts).map_err(|e| io_err(&artifacts, e))?; - let path = self.ticket_relations_path(&dir); - ensure_child_of(&artifacts, &path)?; - let mut relations = read_ticket_relations_artifact(&path, Some(&meta))?; - if relations - .iter() - .any(|existing| existing.kind == relation.kind && existing.target == target_id) - { - return Err(TicketError::Conflict(format!( - "ticket relation already exists: {} {} {}", - meta.id, relation.kind, target_id - ))); - } - let at = now_utc(); - let output = TicketRelation { - ticket_id: meta.id.clone(), - kind: relation.kind, - target: target_id, - note: relation - .note - .map(trim_owned) - .filter(|note| !note.is_empty()), - author: relation - .author - .map(trim_owned) - .unwrap_or_else(default_author), - at: at.clone(), - }; - validate_ticket_relation(&output, Some(&meta))?; - relations.push(output.clone()); - relations.sort_by(|a, b| a.kind.cmp(&b.kind).then_with(|| a.target.cmp(&b.target))); - write_ticket_relations_artifact(&path, &relations)?; - self.set_frontmatter_fields(&item, &[("updated_at", &at)])?; - Ok(output) - } - - fn remove_ticket_relation( - &self, - id: TicketIdOrSlug, - kind: TicketRelationKind, - target: TicketIdOrSlug, - ) -> Result { - let _lock = self.acquire_lock()?; - self.ensure_backend_dirs()?; - let dir = self.find_ticket_dir(&id)?; - let item = dir.join("item.md"); - let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?; - let target_id = match target { - TicketIdOrSlug::Id(value) => value, - other => ticket_id_from_dir(&self.find_ticket_dir(&other)?)?, - }; - let path = self.ticket_relations_path(&dir); - let mut relations = read_ticket_relations_artifact(&path, Some(&meta))?; - let Some(index) = relations - .iter() - .position(|relation| relation.kind == kind && relation.target == target_id) - else { - return Err(TicketError::NotFound(format!( - "relation {} {} {}", - meta.id, kind, target_id - ))); - }; - let removed = relations.remove(index); - write_ticket_relations_artifact(&path, &relations)?; - let at = now_utc(); - self.set_frontmatter_fields(&item, &[("updated_at", &at)])?; - Ok(removed) - } - - fn query_ticket_relations( - &self, - ticket: Option, - kind: Option, - ) -> Result> { - let mut relations = Vec::new(); - if let Some(ticket) = ticket { - let dir = self.find_ticket_dir(&ticket)?; - let source_id = ticket_id_from_dir(&dir)?; - relations.extend(self.read_ticket_relations_for_dir(&dir)?); - relations.extend( - self.all_ticket_relation_records()? - .into_iter() - .filter(|relation| relation.target == source_id), - ); - } else { - relations.extend(self.all_ticket_relation_records()?); - } - if let Some(kind) = kind { - relations.retain(|relation| relation.kind == kind); - } - relations.sort_by(|a, b| { - a.ticket_id - .cmp(&b.ticket_id) - .then_with(|| a.kind.cmp(&b.kind)) - .then_with(|| a.target.cmp(&b.target)) - .then_with(|| a.at.cmp(&b.at)) - }); - relations.dedup_by(|a, b| { - a.ticket_id == b.ticket_id && a.kind == b.kind && a.target == b.target && a.at == b.at - }); - Ok(relations) - } - - fn relation_view(&self, id: TicketIdOrSlug) -> Result { - let dir = self.find_ticket_dir(&id)?; - let item = dir.join("item.md"); - let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?; - self.relation_view_for_meta(&meta) - } - - fn add_orchestration_plan_record( - &self, - id: TicketIdOrSlug, - record: NewOrchestrationPlanRecord, - ) -> Result { - validate_new_orchestration_plan_record(&record)?; - let _lock = self.acquire_lock()?; - self.ensure_backend_dirs()?; - let dir = self.find_ticket_dir(&id)?; - let item = dir.join("item.md"); - let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?; - let artifacts = dir.join("artifacts"); - fs::create_dir_all(&artifacts).map_err(|e| io_err(&artifacts, e))?; - let path = self.orchestration_plan_path(&dir); - ensure_child_of(&artifacts, &path)?; - let line_count = if path.exists() { - fs::read_to_string(&path) - .map_err(|e| io_err(&path, e))? - .lines() - .filter(|line| !line.trim().is_empty()) - .count() - } else { - 0 - }; - let at = now_utc(); - let output = OrchestrationPlanRecord { - id: format!("orch-plan-{}-{}", compact_now_utc(), line_count + 1), - ticket_id: meta.id.clone(), - kind: record.kind, - related_ticket: record.related_ticket.map(trim_owned), - note: record.note.map(trim_owned), - accepted_plan: record.accepted_plan.map(trim_accepted_orchestration_plan), - author: record.author.map(trim_owned).unwrap_or_else(default_author), - at: at.clone(), - }; - validate_orchestration_plan_record(&output, Some(&meta))?; - let serialized = serde_json::to_string(&output).map_err(|e| { - TicketError::Conflict(format!( - "failed to serialize orchestration plan record: {e}" - )) - })?; - let mut file = fs::OpenOptions::new() - .create(true) - .append(true) - .open(&path) - .map_err(|e| io_err(&path, e))?; - writeln!(file, "{serialized}").map_err(|e| io_err(&path, e))?; - file.sync_all().map_err(|e| io_err(&path, e))?; - self.set_frontmatter_fields(&item, &[("updated_at", &at)])?; - Ok(output) - } - - fn query_orchestration_plan_records( - &self, - ticket: Option, - kind: Option, - ) -> Result> { - let mut records = Vec::new(); - if let Some(ticket) = ticket { - let dir = self.find_ticket_dir(&ticket)?; - records.extend(self.read_orchestration_plan_records_for_dir(&dir)?); - } else { - for dir in self.iter_ticket_dirs(TicketListQuery::all())? { - records.extend(self.read_orchestration_plan_records_for_dir(&dir)?); - } - } - if let Some(kind) = kind { - records.retain(|record| record.kind == kind); - } - records.sort_by(|a, b| a.at.cmp(&b.at).then_with(|| a.id.cmp(&b.id))); - Ok(records) - } - - fn doctor(&self) -> Result { - let mut report = TicketDoctorReport::default(); - - let mut ids: HashMap = HashMap::new(); - let mut duplicate_ids: BTreeSet = BTreeSet::new(); - let mut state_index: HashMap = HashMap::new(); - let mut relation_records: Vec = Vec::new(); - - for legacy_bucket in ["open", "pending", "closed"] { - let legacy_dir = self.root.join(legacy_bucket); - if legacy_dir.is_dir() { - report.push_error( - format!("legacy ticket bucket remains: {}", legacy_dir.display()), - Some(legacy_dir), - ); - } - } - - for dir in self.iter_ticket_dirs(TicketListQuery::all())? { - let ticket_id = match ticket_id_from_dir(&dir) { - Ok(id) => id, - Err(err) => { - report.push_error(err.to_string(), Some(dir.clone())); - continue; - } - }; - if ids.insert(ticket_id.clone(), dir.clone()).is_some() { - duplicate_ids.insert(ticket_id.clone()); - } - let item = dir.join("item.md"); - let thread = dir.join("thread.md"); - let artifacts = dir.join("artifacts"); - if !thread.is_file() { - report.push_error( - format!("missing thread.md: {}", dir.display()), - Some(thread.clone()), - ); - } - if !artifacts.is_dir() { - report.push_error( - format!("missing artifacts/: {}", dir.display()), - Some(artifacts.clone()), - ); - } - let parsed = match read_item_file(&item) { - Ok(parsed) => parsed, - Err(TicketError::Parse { message, .. }) => { - report.push_error(message, Some(item.clone())); - continue; - } - Err(e) => return Err(e), - }; - for field in REQUIRED_FIELDS { - if parsed - .frontmatter - .get(field) - .is_none_or(|value| value.is_empty()) - { - report.push_error( - format!("missing required field '{field}': {}", item.display()), - Some(item.clone()), - ); - } - } - for obsolete in [ - "id", - "slug", - "status", - "workflow_state", - "kind", - "labels", - "action_required", - "attention_required", - ] { - if parsed.frontmatter.get(obsolete).is_some() { - report.push_error( - format!( - "obsolete current frontmatter field '{obsolete}': {}", - item.display() - ), - Some(item.clone()), - ); - } - } - match parsed.frontmatter.get("state").map(String::as_str) { - Some(value) if TicketWorkflowState::parse(value).is_none() => report.push_error( - format!("invalid state '{value}': {}", item.display()), - Some(item.clone()), - ), - _ => {} - } - if let Ok(meta) = ticket_meta_for_dir(&dir, parsed.frontmatter.clone()) { - state_index.insert(meta.id.clone(), meta.workflow_state); - } - if parsed.frontmatter.get("state").map(String::as_str) == Some("closed") - && !dir.join("resolution.md").is_file() - { - report.push_warning( - format!("closed ticket missing resolution.md: {}", dir.display()), - Some(dir.join("resolution.md")), - ); - } - if thread.exists() { - doctor_thread_events(&thread, &mut report)?; - } - if artifacts.exists() { - doctor_artifacts(&artifacts, &mut report)?; - let meta = ticket_meta_for_dir(&dir, parsed.frontmatter.clone())?; - doctor_ticket_relations_artifact( - &artifacts.join(TICKET_RELATIONS_ARTIFACT), - &meta, - &mut report, - &mut relation_records, - )?; - doctor_orchestration_plan_artifact( - &artifacts.join(ORCHESTRATION_PLAN_ARTIFACT), - &meta, - &mut report, - )?; - } - } - doctor_ticket_relation_references(&relation_records, &ids, &state_index, &mut report); - doctor_ticket_relation_cycles(&relation_records, &state_index, &mut report); - - for duplicate in duplicate_ids { - report.push_error(format!("duplicate id: {duplicate}"), None); - } - - let todo = self - .root - .parent() - .unwrap_or_else(|| Path::new(".")) - .join("TODO.md"); - if todo.is_file() { - let content = fs::read_to_string(&todo).map_err(|e| io_err(&todo, e))?; - if content.contains("tickets/") - && (content.contains(".md") || content.contains(".review.md")) - { - report.push_error("TODO.md still references legacy tickets/*.md", Some(todo)); - } - } - let legacy_dir = self - .root - .parent() - .unwrap_or_else(|| Path::new(".")) - .join("tickets"); - if legacy_dir.is_dir() { - for entry in fs::read_dir(&legacy_dir).map_err(|e| io_err(&legacy_dir, e))? { - let entry = entry.map_err(|e| io_err(&legacy_dir, e))?; - let path = entry.path(); - if path.extension().and_then(|ext| ext.to_str()) == Some("md") { - report.push_error( - format!("legacy ticket file remains: {}", path.display()), - Some(path), - ); - } - } - } - Ok(report) - } -} - -struct BackendLock { - file: File, -} - -impl Drop for BackendLock { - fn drop(&mut self) { - let _ = FileExt::unlock(&self.file); - } -} - -#[derive(Debug, Clone)] -struct ParsedItem { - frontmatter: TicketItemFrontmatter, - body: String, -} - -#[derive(Debug, Clone, Default)] -#[allow(dead_code)] -struct TicketItemFrontmatter { - id: Option, - slug: Option, - title: Option, - status: Option, - kind: Option, - priority: Option, - labels: Vec, - created_at: Option, - updated_at: Option, - assignee: Option, - readiness: Option, - risk_flags: Vec, - workflow_state: Option, - workflow_state_explicit: bool, - state: Option, - state_explicit: bool, - queued_by: Option, - queued_at: Option, - repository_id: Option, - ref_selector: Option, - raw: BTreeMap, -} - -impl TicketItemFrontmatter { - fn get(&self, key: &str) -> Option<&String> { - self.raw.get(key) - } -} - -fn read_item_file(path: &Path) -> Result { - let content = fs::read_to_string(path).map_err(|e| io_err(path, e))?; - parse_item(&content).map_err(|message| TicketError::Parse { - path: path.to_path_buf(), - message, - }) -} - -fn parse_item(content: &str) -> std::result::Result { - let mut lines = content.lines(); - let Some(first) = lines.next() else { - return Err("item.md is empty".to_string()); - }; - if first != "---" { - return Err("item.md missing frontmatter opener".to_string()); - } - let mut found_close = false; - let mut frontmatter_lines = Vec::new(); - let mut body = String::new(); - for line in &mut lines { - if line == "---" { - found_close = true; - break; - } - frontmatter_lines.push(line); - } - if !found_close { - return Err("item.md missing frontmatter closer".to_string()); - } - let rest: Vec<&str> = lines.collect(); - if !rest.is_empty() { - body.push_str(&rest.join("\n")); - if content.ends_with('\n') { - body.push('\n'); - } - } - let frontmatter = parse_ticket_frontmatter(&frontmatter_lines.join("\n"))?; - Ok(ParsedItem { frontmatter, body }) -} - -fn parse_ticket_frontmatter(content: &str) -> std::result::Result { - let value: YamlValue = - serde_yaml::from_str(content).map_err(|err| format!("invalid YAML frontmatter: {err}"))?; - let mapping = match value { - YamlValue::Mapping(mapping) => mapping, - YamlValue::Null => YamlMapping::new(), - other => { - return Err(format!( - "frontmatter must be a YAML mapping, found {}", - yaml_kind(&other) - )); - } - }; - - let mut raw = BTreeMap::new(); - for (key, value) in &mapping { - let YamlValue::String(key) = key else { - return Err("frontmatter keys must be strings".to_string()); - }; - raw.insert(key.clone(), raw_frontmatter_value(value)?); - } - - let workflow_state_explicit = mapping.contains_key(YamlValue::String("workflow_state".into())); - let workflow_state_value = yaml_string(&mapping, "workflow_state")?; - let workflow_state = match workflow_state_value.as_deref() { - Some(value) => Some(TicketWorkflowState::parse(value).ok_or_else(|| { - format!("invalid workflow_state '{value}': expected planning, ready, queued, inprogress, done, or closed") - })?), - None => None, - }; - let state_explicit = mapping.contains_key(YamlValue::String("state".into())); - let state_value = yaml_string(&mapping, "state")?; - let state = match state_value.as_deref() { - Some(value) => Some(TicketWorkflowState::parse(value).ok_or_else(|| { - format!("invalid state '{value}': expected planning, ready, queued, inprogress, done, or closed") - })?), - None => None, - }; - - Ok(TicketItemFrontmatter { - id: yaml_string(&mapping, "id")?, - slug: yaml_string(&mapping, "slug")?, - title: yaml_string(&mapping, "title")?, - status: yaml_string(&mapping, "status")?, - kind: yaml_string(&mapping, "kind")?, - priority: yaml_string(&mapping, "priority")?, - labels: yaml_string_list(&mapping, "labels")?, - created_at: yaml_string(&mapping, "created_at")?, - updated_at: yaml_string(&mapping, "updated_at")?, - assignee: yaml_string(&mapping, "assignee")?, - readiness: yaml_string(&mapping, "readiness")?, - risk_flags: yaml_string_list(&mapping, "risk_flags")?, - workflow_state, - workflow_state_explicit, - state, - state_explicit, - queued_by: yaml_string(&mapping, "queued_by")?, - queued_at: yaml_string(&mapping, "queued_at")?, - repository_id: yaml_string(&mapping, "repository_id")?, - ref_selector: yaml_string(&mapping, "ref_selector")?, - raw, - }) -} - -fn yaml_key(key: &str) -> YamlValue { - YamlValue::String(key.to_string()) -} - -fn yaml_get<'a>(mapping: &'a YamlMapping, key: &str) -> Option<&'a YamlValue> { - mapping.get(yaml_key(key)) -} - -fn yaml_string(mapping: &YamlMapping, key: &str) -> std::result::Result, String> { - match yaml_get(mapping, key) { - Some(YamlValue::Null) | None => Ok(None), - Some(YamlValue::String(value)) => Ok(Some(value.clone())), - Some(value) => Err(format!( - "frontmatter field `{key}` must be a YAML string or null, found {}", - yaml_kind(value) - )), - } -} - -fn yaml_string_list(mapping: &YamlMapping, key: &str) -> std::result::Result, String> { - match yaml_get(mapping, key) { - Some(YamlValue::Null) | None => Ok(Vec::new()), - Some(YamlValue::Sequence(values)) => values - .iter() - .enumerate() - .map(|(idx, value)| match value { - YamlValue::String(value) => Ok(value.clone()), - other => Err(format!( - "frontmatter field `{key}` item {idx} must be a YAML string, found {}", - yaml_kind(other) - )), - }) - .collect(), - Some(value) => Err(format!( - "frontmatter field `{key}` must be a YAML sequence or null, found {}", - yaml_kind(value) - )), - } -} - -fn raw_frontmatter_value(value: &YamlValue) -> std::result::Result { - match value { - YamlValue::Null => Ok("null".to_string()), - YamlValue::Bool(value) => Ok(value.to_string()), - YamlValue::Number(value) => Ok(value.to_string()), - YamlValue::String(value) => Ok(value.clone()), - YamlValue::Sequence(values) => values - .iter() - .map(|value| match value { - YamlValue::String(value) => Ok(format_yaml_string_scalar(value)), - other => Err(format!( - "frontmatter sequence values must be strings, found {}", - yaml_kind(other) - )), - }) - .collect::, _>>() - .map(|values| format!("[{}]", values.join(", "))), - YamlValue::Mapping(_) => Err("frontmatter nested mappings are not supported".to_string()), - YamlValue::Tagged(tagged) => raw_frontmatter_value(&tagged.value), - } -} - -fn yaml_kind(value: &YamlValue) -> &'static str { - match value { - YamlValue::Null => "null", - YamlValue::Bool(_) => "boolean", - YamlValue::Number(_) => "number", - YamlValue::String(_) => "string", - YamlValue::Sequence(_) => "sequence", - YamlValue::Mapping(_) => "mapping", - YamlValue::Tagged(_) => "tagged value", - } -} - -fn ticket_id_from_dir(dir: &Path) -> Result { - let Some(name) = dir.file_name().and_then(|name| name.to_str()) else { - return Err(TicketError::Conflict(format!( - "ticket directory has no UTF-8 id: {}", - dir.display() - ))); - }; - ensure_safe_component(name)?; - validate_record_id(name).map_err(|err| { - TicketError::InvalidPathComponent(format!("{name} is not a canonical record id: {err}")) - })?; - Ok(name.to_string()) -} - -fn ticket_meta_for_dir(dir: &Path, frontmatter: TicketItemFrontmatter) -> Result { - Ok(ticket_meta(frontmatter, ticket_id_from_dir(dir)?)) -} - -fn ticket_meta(frontmatter: TicketItemFrontmatter, id: String) -> TicketMeta { - let workflow_state = frontmatter - .state - .or(frontmatter.workflow_state) - .or_else(|| { - frontmatter - .status - .as_deref() - .map(ExtensibleTicketStatus::from) - .map(|status| TicketWorkflowState::default_for_status(&status)) - }) - .unwrap_or(TicketWorkflowState::Planning); - let status = match workflow_state { - TicketWorkflowState::Closed => ExtensibleTicketStatus::Closed, - _ => ExtensibleTicketStatus::Open, - }; - TicketMeta { - id: id.clone(), - resource_key: None, - slug: id, - title: frontmatter.title.unwrap_or_default(), - status, - kind: String::new(), - priority: frontmatter.priority.unwrap_or_default(), - labels: Vec::new(), - created_at: frontmatter.created_at, - updated_at: frontmatter.updated_at, - assignee: frontmatter.assignee, - readiness: frontmatter.readiness, - risk_flags: frontmatter.risk_flags, - workflow_state, - workflow_state_explicit: frontmatter.state_explicit, - queued_by: frontmatter.queued_by, - queued_at: frontmatter.queued_at, - repository_id: frontmatter.repository_id, - ref_selector: frontmatter.ref_selector, - raw: frontmatter.raw, - } -} - fn ticket_summary_from_meta(meta: TicketMeta) -> TicketSummary { TicketSummary { id: meta.id, @@ -5475,57 +3586,6 @@ fn sort_ticket_relations(relations: &mut [TicketRelation]) { }); } -fn invalid_ticket_record_label(dir: &Path) -> String { - dir.file_name() - .and_then(|name| name.to_str()) - .filter(|name| validate_record_id(name).is_ok()) - .map(ToOwned::to_owned) - .unwrap_or_else(|| "invalid ticket record".to_string()) -} - -fn invalid_ticket_record_reason(error: &TicketError) -> &'static str { - match error { - TicketError::Io { .. } => "could not read ticket record", - TicketError::Parse { .. } => "invalid ticket record schema", - TicketError::InvalidPathComponent(_) | TicketError::PathEscapesRoot { .. } => { - "invalid ticket record identity" - } - TicketError::Locked { .. } => "ticket backend is locked", - TicketError::Sqlite(_) => "could not read ticket record", - TicketError::NotFound(_) => "ticket record is missing", - TicketError::Ambiguous { .. } - | TicketError::Conflict(_) - | TicketError::MissingTargetRepository - | TicketError::UnknownTargetRepository(_) - | TicketError::MissingTargetSelector(_) - | TicketError::InvalidTargetSelector { .. } - | TicketError::TargetAuthorityUnavailable - | TicketError::StaleWorkflowState { .. } - | TicketError::InvalidWorkflowTransition { .. } - | TicketError::BlockingRelations(_) - | TicketError::OperationFingerprintMismatch { .. } => "invalid ticket record metadata", - } -} - -fn push_invalid_ticket_record( - invalid_records: &mut Vec, - invalid_seen: &mut BTreeSet, - dir: &Path, - error: &TicketError, -) { - let label = invalid_ticket_record_label(dir); - if invalid_seen.insert(label.clone()) { - invalid_records.push(TicketInvalidRecord { - label, - reason: invalid_ticket_record_reason(error).to_string(), - }); - } -} - -fn trim_owned(value: String) -> String { - value.trim().to_string() -} - fn relation_inverse_kind(kind: TicketRelationKind) -> &'static str { match kind { TicketRelationKind::DependsOn => "dependency_of", @@ -5955,171 +4015,6 @@ fn format_relation_blockers(blockers: &[TicketRelationBlocker]) -> String { .join(", ") } -fn validate_relation_optional_text( - label: &str, - value: Option<&str>, - max_bytes: usize, -) -> Result<()> { - if let Some(value) = value { - if value.as_bytes().len() > max_bytes { - return Err(TicketError::Conflict(format!( - "ticket relation {label} exceeds {max_bytes} bytes" - ))); - } - if value.contains('\0') { - return Err(TicketError::Conflict(format!( - "ticket relation {label} must not contain NUL bytes" - ))); - } - } - Ok(()) -} - -fn validate_relation_optional_single_line( - label: &str, - value: Option<&str>, - max_bytes: usize, -) -> Result<()> { - validate_relation_optional_text(label, value, max_bytes)?; - if let Some(value) = value { - if value.contains('\n') || value.contains('\r') { - return Err(TicketError::Conflict(format!( - "ticket relation {label} must be a single line" - ))); - } - } - Ok(()) -} - -fn validate_new_ticket_relation(relation: &NewTicketRelation) -> Result<()> { - let target = relation.target.trim(); - validate_relation_optional_single_line( - "target", - Some(target), - MAX_TICKET_RELATION_FIELD_BYTES, - )?; - if target.is_empty() { - return Err(TicketError::Conflict( - "ticket relation target must not be empty".to_string(), - )); - } - validate_relation_optional_text( - "note", - relation.note.as_deref(), - MAX_TICKET_RELATION_NOTE_BYTES, - )?; - validate_relation_optional_single_line( - "author", - relation.author.as_deref(), - MAX_TICKET_RELATION_FIELD_BYTES, - ) -} - -fn validate_ticket_relation(relation: &TicketRelation, meta: Option<&TicketMeta>) -> Result<()> { - validate_relation_optional_single_line( - "ticket_id", - Some(&relation.ticket_id), - MAX_TICKET_RELATION_FIELD_BYTES, - )?; - validate_relation_optional_single_line( - "target", - Some(&relation.target), - MAX_TICKET_RELATION_FIELD_BYTES, - )?; - validate_relation_optional_text( - "note", - relation.note.as_deref(), - MAX_TICKET_RELATION_NOTE_BYTES, - )?; - validate_relation_optional_single_line( - "author", - Some(&relation.author), - MAX_TICKET_RELATION_FIELD_BYTES, - )?; - validate_relation_optional_single_line( - "at", - Some(&relation.at), - MAX_TICKET_RELATION_FIELD_BYTES, - )?; - if let Some(meta) = meta { - if relation.ticket_id != meta.id { - return Err(TicketError::Conflict(format!( - "ticket relation targets {} but artifact belongs to {}", - relation.ticket_id, meta.id - ))); - } - } - if relation.ticket_id == relation.target { - return Err(TicketError::Conflict(format!( - "ticket relation cannot target itself: {}", - relation.ticket_id - ))); - } - Ok(()) -} - -fn read_ticket_relations_artifact( - path: &Path, - meta: Option<&TicketMeta>, -) -> Result> { - if !path.exists() { - return Ok(Vec::new()); - } - let content = fs::read_to_string(path).map_err(|e| io_err(path, e))?; - let artifact: TicketRelationArtifact = - serde_json::from_str(&content).map_err(|e| TicketError::Parse { - path: path.to_path_buf(), - message: format!("invalid ticket relations artifact: {e}"), - })?; - if artifact.version != 1 { - return Err(TicketError::Parse { - path: path.to_path_buf(), - message: format!( - "unsupported ticket relations artifact version {}", - artifact.version - ), - }); - } - let mut seen = BTreeSet::new(); - for relation in &artifact.relations { - validate_ticket_relation(relation, meta).map_err(|err| TicketError::Parse { - path: path.to_path_buf(), - message: format!("invalid ticket relation: {err}"), - })?; - if !seen.insert((relation.kind, relation.target.clone())) { - return Err(TicketError::Parse { - path: path.to_path_buf(), - message: format!( - "duplicate ticket relation {} {}", - relation.kind, relation.target - ), - }); - } - } - Ok(artifact.relations) -} - -fn write_ticket_relations_artifact(path: &Path, relations: &[TicketRelation]) -> Result<()> { - let artifact = TicketRelationArtifact { - version: 1, - relations: relations.to_vec(), - }; - let content = serde_json::to_string_pretty(&artifact).map_err(|e| TicketError::Parse { - path: path.to_path_buf(), - message: format!("failed to serialize ticket relations artifact: {e}"), - })? + "\n"; - fs::write(path, content).map_err(|e| io_err(path, e)) -} - -fn trim_accepted_orchestration_plan(plan: AcceptedOrchestrationPlan) -> AcceptedOrchestrationPlan { - AcceptedOrchestrationPlan { - summary: plan.summary.trim().to_string(), - branch: plan.branch.map(trim_owned), - worktree: plan.worktree.map(trim_owned), - role_plan: plan.role_plan.map(trim_owned), - } -} - fn validate_plan_required_text(label: &str, value: &str, max_bytes: usize) -> Result<()> { let trimmed = value.trim(); if trimmed.is_empty() { @@ -6284,222 +4179,6 @@ fn validate_orchestration_plan_record( Ok(()) } -fn read_orchestration_plan_artifact( - path: &Path, - meta: Option<&TicketMeta>, -) -> Result> { - if !path.exists() { - return Ok(Vec::new()); - } - let content = fs::read_to_string(path).map_err(|e| io_err(path, e))?; - let mut records = Vec::new(); - for (idx, line) in content.lines().enumerate() { - let line = line.trim(); - if line.is_empty() { - continue; - } - let record: OrchestrationPlanRecord = - serde_json::from_str(line).map_err(|e| TicketError::Parse { - path: path.to_path_buf(), - message: format!("invalid orchestration plan record on line {}: {e}", idx + 1), - })?; - validate_orchestration_plan_record(&record, meta).map_err(|err| TicketError::Parse { - path: path.to_path_buf(), - message: format!( - "invalid orchestration plan record on line {}: {err}", - idx + 1 - ), - })?; - records.push(record); - } - Ok(records) -} - -fn format_yaml_string_scalar(value: &str) -> String { - let mut out = String::from("'"); - for ch in value.chars() { - if ch == '\'' { - out.push_str("''"); - } else { - out.push(ch); - } - } - out.push('\''); - out -} - -fn yaml_string_or_null(value: Option<&str>) -> String { - value - .map(format_yaml_string_scalar) - .unwrap_or_else(|| "null".to_string()) -} - -fn labels_yaml(labels: &[String]) -> String { - if labels.is_empty() { - return "[]".to_string(); - } - format!( - "[{}]", - labels - .iter() - .map(|label| label.trim()) - .filter(|label| !label.is_empty()) - .map(format_yaml_string_scalar) - .collect::>() - .join(", ") - ) -} - -fn serialize_item(fields: &[(String, String)], body: &str) -> String { - let mut out = String::from("---\n"); - for (key, value) in fields { - out.push_str(key); - out.push_str(": "); - out.push_str(value); - out.push('\n'); - } - out.push_str("---\n\n"); - out.push_str(body); - if !out.ends_with('\n') { - out.push('\n'); - } - out -} - -fn replace_frontmatter_fields( - content: &str, - updates: &[(&str, &str)], -) -> std::result::Result { - let mut lines: Vec = content.lines().map(ToOwned::to_owned).collect(); - if lines.first().map(String::as_str) != Some("---") { - return Err("item.md missing frontmatter opener".to_string()); - } - let Some(end) = lines - .iter() - .enumerate() - .skip(1) - .find_map(|(idx, line)| (line == "---").then_some(idx)) - else { - return Err("item.md missing frontmatter closer".to_string()); - }; - let mut seen = BTreeSet::new(); - for line in lines.iter_mut().take(end).skip(1) { - if let Some((key, _)) = line.split_once(':') { - let key = key.trim().to_string(); - if let Some((_, value)) = updates.iter().find(|(update_key, _)| *update_key == key) { - *line = format!("{key}: {}", format_yaml_string_scalar(value)); - seen.insert(key); - } - } - } - let mut insert_at = end; - for (key, value) in updates { - if !seen.contains(*key) { - lines.insert( - insert_at, - format!("{key}: {}", format_yaml_string_scalar(value)), - ); - insert_at += 1; - } - } - let mut out = lines.join("\n"); - if content.ends_with('\n') { - out.push('\n'); - } - Ok(out) -} - -fn remove_frontmatter_fields( - content: &str, - fields: &[&str], -) -> std::result::Result { - let mut lines: Vec = content.lines().map(ToOwned::to_owned).collect(); - if lines.first().map(String::as_str) != Some("---") { - return Err("item.md missing frontmatter opener".to_string()); - } - let Some(end) = lines - .iter() - .enumerate() - .skip(1) - .find_map(|(idx, line)| (line == "---").then_some(idx)) - else { - return Err("item.md missing frontmatter closer".to_string()); - }; - for index in (1..end).rev() { - let should_remove = lines[index] - .split_once(':') - .is_some_and(|(key, _)| fields.contains(&key.trim())); - if should_remove { - lines.remove(index); - } - } - let mut out = lines.join("\n"); - if content.ends_with('\n') { - out.push('\n'); - } - Ok(out) -} - -fn replace_item_body(content: &str, body: &str) -> std::result::Result { - let mut lines = content.lines(); - if lines.next() != Some("---") { - return Err("item.md missing frontmatter opener".to_string()); - } - let mut frontmatter = vec!["---".to_string()]; - let mut found_close = false; - for line in lines.by_ref() { - frontmatter.push(line.to_string()); - if line == "---" { - found_close = true; - break; - } - } - if !found_close { - return Err("item.md missing frontmatter closer".to_string()); - } - let mut out = frontmatter.join("\n"); - out.push_str("\n"); - out.push_str(body); - if !out.ends_with('\n') { - out.push('\n'); - } - Ok(out) -} - -fn render_event_comment(attrs: &[(&str, &str)]) -> Result { - let mut out = String::from(""); - Ok(out) -} - -fn format_event_attr_value(value: &str) -> String { - if !value.is_empty() - && !value.chars().any(char::is_whitespace) - && !value.contains('"') - && !value.contains('\\') - && !value.contains("-->") - { - return value.to_string(); - } - let mut out = String::from("\""); - for ch in value.chars() { - match ch { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - _ => out.push(ch), - } - } - out.push('"'); - out -} - fn validate_event_attr(key: &str, value: &str) -> Result<()> { if key.trim().is_empty() || key.chars().any(char::is_whitespace) || key.contains(':') { return Err(TicketError::Conflict(format!( @@ -6561,544 +4240,10 @@ fn validate_intake_summary(summary: &TicketIntakeSummary) -> Result<()> { Ok(()) } -fn validate_state_field_name(field: &str) -> Result<()> { - if field.trim().is_empty() - || field.chars().any(char::is_whitespace) - || field.contains(':') - || field.contains("--") - { - return Err(TicketError::Conflict(format!( - "state field name is invalid: {field:?}" - ))); - } - Ok(()) -} - -fn parse_thread(path: &Path) -> Result> { - let content = fs::read_to_string(path).map_err(|e| io_err(path, e))?; - let mut events = Vec::new(); - let lines: Vec<&str> = content.lines().collect(); - let mut idx = 0; - while idx < lines.len() { - let line = lines[idx].trim(); - if let Some(comment) = line - .strip_prefix("")) - { - let mut attrs = parse_event_comment(comment); - let legacy_review = attrs.get("event").is_some_and(|value| value == "review"); - let kind = attrs - .get("event") - .map(|value| TicketEventKind::from(value.as_str())) - .unwrap_or_else(|| TicketEventKind::Other(String::new())); - idx += 1; - while idx < lines.len() && lines[idx].trim().is_empty() { - idx += 1; - } - let mut heading = None; - if idx < lines.len() { - if let Some(stripped) = lines[idx].strip_prefix("## ") { - heading = Some(stripped.to_string()); - idx += 1; - } - } - while idx < lines.len() && lines[idx].trim().is_empty() { - idx += 1; - } - let mut body_lines = Vec::new(); - while idx < lines.len() { - if lines[idx].trim() == "---" { - idx += 1; - break; - } - body_lines.push(lines[idx]); - idx += 1; - } - let mut body = body_lines.join("\n"); - while body.ends_with('\n') { - body.pop(); - } - if legacy_review { - heading = Some("Legacy review (non-authoritative)".to_string()); - attrs.remove("status"); - attrs.remove("result"); - attrs.insert("event".to_string(), "comment".to_string()); - attrs.insert("legacy_event_kind".to_string(), "review".to_string()); - } - events.push(TicketEvent { - kind, - author: attrs.get("author").cloned(), - at: attrs.get("at").cloned(), - status: if legacy_review { - None - } else { - attrs.get("status").cloned() - }, - from: attrs.get("from").cloned(), - to: attrs.get("to").cloned(), - reason: attrs.get("reason").cloned(), - state_field: attrs.get("field").cloned(), - heading, - body: MarkdownText::new(body), - references: Vec::new(), - attributes: attrs, - }); - } else { - idx += 1; - } - } - Ok(events) -} - -fn parse_event_comment(comment: &str) -> BTreeMap { - let mut attrs = BTreeMap::new(); - let mut chars = comment.char_indices().peekable(); - while let Some((_, ch)) = chars.peek().copied() { - if ch.is_whitespace() { - chars.next(); - continue; - } - let start = chars.peek().map(|(idx, _)| *idx).unwrap_or(comment.len()); - while let Some((_, ch)) = chars.peek().copied() { - if ch == ':' || ch.is_whitespace() { - break; - } - chars.next(); - } - let end = chars.peek().map(|(idx, _)| *idx).unwrap_or(comment.len()); - if chars.peek().map(|(_, ch)| *ch) != Some(':') { - while let Some((_, ch)) = chars.peek().copied() { - if ch.is_whitespace() { - break; - } - chars.next(); - } - continue; - } - chars.next(); - while let Some((_, ch)) = chars.peek().copied() { - if ch.is_whitespace() { - chars.next(); - } else { - break; - } - } - let value = if chars.peek().map(|(_, ch)| *ch) == Some('"') { - chars.next(); - let mut value = String::new(); - let mut escaped = false; - for (_, ch) in chars.by_ref() { - if escaped { - value.push(ch); - escaped = false; - } else if ch == '\\' { - escaped = true; - } else if ch == '"' { - break; - } else { - value.push(ch); - } - } - value - } else { - let value_start = chars.peek().map(|(idx, _)| *idx).unwrap_or(comment.len()); - while let Some((_, ch)) = chars.peek().copied() { - if ch.is_whitespace() { - break; - } - chars.next(); - } - let value_end = chars.peek().map(|(idx, _)| *idx).unwrap_or(comment.len()); - comment[value_start..value_end].to_string() - }; - let key = &comment[start..end]; - if !key.is_empty() { - attrs.insert(key.to_string(), value); - } - } - attrs -} - -fn doctor_thread_events(path: &Path, report: &mut TicketDoctorReport) -> Result<()> { - let content = fs::read_to_string(path).map_err(|e| io_err(path, e))?; - let mut intake_summary_lines = Vec::new(); - for (line_no, line) in content.lines().enumerate() { - let trimmed = line.trim(); - if trimmed.starts_with("") { - report.push_error( - format!( - "malformed thread event comment at {}:{}", - path.display(), - line_no + 1 - ), - Some(path.to_path_buf()), - ); - } - if let Some(comment) = trimmed - .strip_prefix("")) - { - let attrs = parse_event_comment(comment); - let Some(event) = attrs.get("event").map(String::as_str) else { - continue; - }; - if attrs - .get("at") - .map_or(true, |value| value.trim().is_empty()) - { - report.push_error( - format!( - "thread event missing at: {}:{}", - path.display(), - line_no + 1 - ), - Some(path.to_path_buf()), - ); - } - match event { - "review" => match attrs.get("status").map(String::as_str) { - Some("approve" | "request_changes") => {} - _ => report.push_warning( - format!( - "legacy review event missing valid status at {}:{}", - path.display(), - line_no + 1 - ), - Some(path.to_path_buf()), - ), - }, - "state_changed" => { - for key in ["from", "to", "reason", "author"] { - if attrs.get(key).map_or(true, |value| value.trim().is_empty()) { - report.push_error( - format!( - "state_changed event missing {key}: {}:{}", - path.display(), - line_no + 1 - ), - Some(path.to_path_buf()), - ); - } - } - } - "intake_summary" => { - if attrs - .get("author") - .map_or(true, |value| value.trim().is_empty()) - { - report.push_error( - format!( - "intake_summary event missing author: {}:{}", - path.display(), - line_no + 1 - ), - Some(path.to_path_buf()), - ); - } - intake_summary_lines.push(line_no + 1); - } - _ => {} - } - } - } - if !intake_summary_lines.is_empty() { - let summaries = parse_thread(path)? - .into_iter() - .filter(|event| event.kind == TicketEventKind::IntakeSummary); - for (idx, event) in summaries.enumerate() { - if event.body.as_str().trim().is_empty() { - let line = intake_summary_lines.get(idx).copied().unwrap_or_default(); - report.push_error( - format!( - "intake_summary event missing body at {}:{}", - path.display(), - line - ), - Some(path.to_path_buf()), - ); - } - } - } - Ok(()) -} - -fn collect_artifacts(dir: &Path) -> Result> { - let mut artifacts = Vec::new(); - if !dir.exists() { - return Ok(artifacts); - } - collect_artifacts_inner(dir, dir, &mut artifacts)?; - artifacts.sort_by(|a, b| a.relative_path.cmp(&b.relative_path)); - Ok(artifacts) -} - -fn collect_artifacts_inner( - root: &Path, - dir: &Path, - artifacts: &mut Vec, -) -> Result<()> { - for entry in fs::read_dir(dir).map_err(|e| io_err(dir, e))? { - let entry = entry.map_err(|e| io_err(dir, e))?; - let path = entry.path(); - if path.is_dir() { - collect_artifacts_inner(root, &path, artifacts)?; - } else if path.file_name().and_then(|name| name.to_str()) != Some(".gitkeep") { - let relative_path = path - .strip_prefix(root) - .map_err(|_| TicketError::PathEscapesRoot { path: path.clone() })? - .to_path_buf(); - artifacts.push(TicketArtifactRef { relative_path }); - } - } - Ok(()) -} - -fn doctor_ticket_relations_artifact( - path: &Path, - meta: &TicketMeta, - report: &mut TicketDoctorReport, - relation_records: &mut Vec, -) -> Result<()> { - match read_ticket_relations_artifact(path, Some(meta)) { - Ok(relations) => { - relation_records.extend(relations); - Ok(()) - } - Err(TicketError::Parse { message, .. }) => { - report.push_error(message, Some(path.to_path_buf())); - Ok(()) - } - Err(TicketError::Conflict(message)) => { - report.push_error(message, Some(path.to_path_buf())); - Ok(()) - } - Err(err) => Err(err), - } -} - -fn doctor_ticket_relation_references( - relations: &[TicketRelation], - ticket_dirs: &HashMap, - _states: &HashMap, - report: &mut TicketDoctorReport, -) { - for relation in relations { - let path = ticket_dirs - .get(&relation.ticket_id) - .map(|dir| dir.join("artifacts").join(TICKET_RELATIONS_ARTIFACT)); - if relation.ticket_id == relation.target { - report.push_error( - format!( - "ticket relation cannot target itself: {} {} {}", - relation.ticket_id, relation.kind, relation.target - ), - path.clone(), - ); - } - if !ticket_dirs.contains_key(&relation.target) { - report.push_error( - format!( - "ticket relation has dangling target: {} {} {}", - relation.ticket_id, relation.kind, relation.target - ), - path, - ); - } - } -} - -fn doctor_ticket_relation_cycles( - relations: &[TicketRelation], - states: &HashMap, - report: &mut TicketDoctorReport, -) { - let mut graph: BTreeMap> = BTreeMap::new(); - for relation in relations { - if !matches!( - relation.kind, - TicketRelationKind::DependsOn | TicketRelationKind::Blocks - ) { - continue; - } - if !states.contains_key(&relation.ticket_id) || !states.contains_key(&relation.target) { - continue; - } - let (waiter, blocker) = match relation.kind { - TicketRelationKind::DependsOn => (&relation.ticket_id, &relation.target), - TicketRelationKind::Blocks => (&relation.target, &relation.ticket_id), - _ => unreachable!(), - }; - graph - .entry(waiter.clone()) - .or_default() - .push(blocker.clone()); - } - let mut reported = BTreeSet::new(); - for start in graph.keys() { - let mut path = Vec::new(); - detect_relation_cycle(start, start, &graph, &mut path, &mut reported, report); - if reported.len() >= 32 { - report.push_warning( - "ticket relation cycle diagnostics truncated after 32 cycles".to_string(), - None, - ); - break; - } - } -} - -fn detect_relation_cycle( - start: &str, - current: &str, - graph: &BTreeMap>, - path: &mut Vec, - reported: &mut BTreeSet, - report: &mut TicketDoctorReport, -) { - if path.len() > 64 { - return; - } - path.push(current.to_string()); - if let Some(nexts) = graph.get(current) { - for next in nexts { - if next == start { - let mut cycle = path.clone(); - cycle.push(start.to_string()); - let key = canonical_cycle_key(&cycle); - if reported.insert(key) { - report.push_error( - format!( - "ticket relation dependency/blocking cycle: {}", - cycle.join(" -> ") - ), - None, - ); - } - continue; - } - if path.iter().any(|value| value == next) { - continue; - } - detect_relation_cycle(start, next, graph, path, reported, report); - if reported.len() >= 32 { - break; - } - } - } - path.pop(); -} - -fn canonical_cycle_key(cycle: &[String]) -> String { - if cycle.len() <= 1 { - return String::new(); - } - let nodes = &cycle[..cycle.len() - 1]; - let Some((idx, _)) = nodes.iter().enumerate().min_by(|(_, a), (_, b)| a.cmp(b)) else { - return String::new(); - }; - let mut ordered = Vec::new(); - for offset in 0..nodes.len() { - ordered.push(nodes[(idx + offset) % nodes.len()].clone()); - } - ordered.join(" -> ") -} - -fn doctor_orchestration_plan_artifact( - path: &Path, - meta: &TicketMeta, - report: &mut TicketDoctorReport, -) -> Result<()> { - match read_orchestration_plan_artifact(path, Some(meta)) { - Ok(_) => Ok(()), - Err(TicketError::Parse { message, .. }) => { - report.push_error(message, Some(path.to_path_buf())); - Ok(()) - } - Err(TicketError::Conflict(message)) => { - report.push_error(message, Some(path.to_path_buf())); - Ok(()) - } - Err(err) => Err(err), - } -} - -fn doctor_artifacts(dir: &Path, report: &mut TicketDoctorReport) -> Result<()> { - for entry in fs::read_dir(dir).map_err(|e| io_err(dir, e))? { - let entry = entry.map_err(|e| io_err(dir, e))?; - let path = entry.path(); - if path.is_dir() { - doctor_artifacts(&path, report)?; - } else if path - .components() - .any(|component| matches!(component, Component::ParentDir)) - { - report.push_error( - format!("artifact path escapes artifacts/: {}", path.display()), - Some(path), - ); - } - } - Ok(()) -} - -fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> { - let parent = path.parent().ok_or_else(|| TicketError::PathEscapesRoot { - path: path.to_path_buf(), - })?; - fs::create_dir_all(parent).map_err(|e| io_err(parent, e))?; - let file_name = path - .file_name() - .and_then(|name| name.to_str()) - .ok_or_else(|| TicketError::InvalidPathComponent(path.display().to_string()))?; - let tmp = parent.join(format!(".{file_name}.tmp.{}", std::process::id())); - { - let mut file = OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(&tmp) - .map_err(|e| io_err(&tmp, e))?; - file.write_all(bytes).map_err(|e| io_err(&tmp, e))?; - file.sync_data().map_err(|e| io_err(&tmp, e))?; - } - fs::rename(&tmp, path).map_err(|e| io_err(path, e))?; - Ok(()) -} - -fn ensure_child_of(root: &Path, path: &Path) -> Result<()> { - let root = root.components().collect::>(); - let path_components = path.components().collect::>(); - if path_components.starts_with(&root) { - Ok(()) - } else { - Err(TicketError::PathEscapesRoot { - path: path.to_path_buf(), - }) - } -} - -fn ensure_safe_component(value: &str) -> Result<()> { - let invalid = value.is_empty() - || value == "." - || value == ".." - || value.contains('/') - || value.contains('\\') - || value.contains('\0'); - if invalid { - Err(TicketError::InvalidPathComponent(value.to_string())) - } else { - Ok(()) - } -} - fn now_utc() -> String { Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string() } -fn compact_now_utc() -> String { - Utc::now().format("%Y%m%d-%H%M%S").to_string() -} - fn default_author() -> String { std::env::var("USER").unwrap_or_else(|_| "unknown".to_string()) } @@ -7108,6 +4253,27 @@ mod tests { use super::*; use tempfile::TempDir; + #[test] + fn production_source_has_no_repository_local_ticket_provider() { + let production = include_str!("lib.rs") + .split_once("#[cfg(test)]\nmod tests") + .map(|(production, _)| production) + .expect("Ticket test module marker"); + for forbidden in [ + "LocalTicketBackend", + "import_from_local_backend", + ".yoi/tickets", + "TicketConfig::load_workspace", + "ticket.config.toml", + "workspace.toml", + ] { + assert!( + !production.contains(forbidden), + "repository-local Ticket provider returned through {forbidden}" + ); + } + } + #[derive(Debug)] struct TestTargetAuthority; @@ -7131,8 +4297,9 @@ mod tests { } } - fn backend(dir: &TempDir) -> LocalTicketBackend { - LocalTicketBackend::new(dir.path().join("tickets")) + fn backend(dir: &TempDir) -> SqliteTicketBackend { + SqliteTicketBackend::open(dir.path().join("tickets.db"), "workspace-test") + .unwrap() .with_target_authority(Arc::new(TestTargetAuthority)) } @@ -7600,76 +4767,6 @@ mod tests { )); } - #[test] - fn parses_item_frontmatter_and_optional_fields() { - let item = r#"--- -title: Example -state: ready -priority: P1 -created_at: 2026-06-05T00:00:00Z -updated_at: 2026-06-05T00:00:00Z -assignee: null -readiness: implementation-ready -risk_flags: [low, local] -queued_by: workspace-panel -queued_at: 2026-06-05T00:01:00Z ---- - -## Body -"#; - let parsed = parse_item(item).unwrap(); - let meta = ticket_meta(parsed.frontmatter, "0000000000001".to_string()); - assert_eq!(meta.id, "0000000000001"); - assert_eq!(meta.slug, "0000000000001"); - assert!(meta.labels.is_empty()); - assert_eq!(meta.readiness.as_deref(), Some("implementation-ready")); - assert_eq!(meta.risk_flags, vec!["low", "local"]); - assert_eq!(meta.workflow_state, TicketWorkflowState::Ready); - assert!(meta.workflow_state_explicit); - assert_eq!(meta.queued_by.as_deref(), Some("workspace-panel")); - assert_eq!(meta.queued_at.as_deref(), Some("2026-06-05T00:01:00Z")); - } - - #[test] - fn yaml_frontmatter_preserves_typed_nulls_lists_and_quoted_strings() { - let frontmatter = parse_ticket_frontmatter( - r#"risk_flags: [low, local] -assignee: ~ -readiness: "~" -state: planning -"#, - ) - .unwrap(); - let meta = ticket_meta(frontmatter, "0000000000001".to_string()); - assert!(meta.labels.is_empty()); - assert_eq!(meta.risk_flags, vec!["low", "local"]); - assert_eq!(meta.assignee, None); - assert_eq!(meta.readiness.as_deref(), Some("~")); - assert_eq!(meta.workflow_state, TicketWorkflowState::Planning); - assert!(meta.workflow_state_explicit); - } - - #[test] - fn yaml_frontmatter_rejects_legacy_raw_string_fallbacks() { - let labels_error = parse_ticket_frontmatter("labels: ticket").unwrap_err(); - assert!( - labels_error.contains("must be a YAML sequence"), - "{labels_error}" - ); - - let state_error = parse_ticket_frontmatter("state: almost").unwrap_err(); - assert!(state_error.contains("invalid state"), "{state_error}"); - - let intake_error = parse_ticket_frontmatter("state: intake").unwrap_err(); - assert!(intake_error.contains("invalid state"), "{intake_error}"); - } - - #[test] - fn yaml_frontmatter_rejects_invalid_yaml() { - let err = parse_ticket_frontmatter("labels: [ticket").unwrap_err(); - assert!(err.contains("invalid YAML frontmatter"), "{err}"); - } - #[test] fn list_query_defaults_to_active_and_supports_all_or_explicit_states() { let tmp = TempDir::new().unwrap(); @@ -7721,58 +4818,6 @@ state: planning assert!(!explicit_ids.contains(&ready.id.as_str())); } - #[test] - fn create_writes_local_ticket_layout() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let mut input = NewTicket::new("Example Ticket"); - input.labels = vec!["ticket".into(), "backend".into()]; - let ticket = backend.create(input).unwrap(); - let dir = tmp.path().join("tickets").join(&ticket.id); - assert!(dir.join("item.md").exists()); - assert!(dir.join("thread.md").exists()); - assert!(dir.join("artifacts/.gitkeep").exists()); - assert!(!ticket.id.contains("example")); - assert_eq!(ticket.id.len(), project_record::RECORD_ID_WIDTH); - validate_record_id(&ticket.id).unwrap(); - assert_eq!(ticket.slug, ticket.id); - let item = fs::read_to_string(dir.join("item.md")).unwrap(); - assert!( - item.contains("state: planning") - || item.contains("state: \"planning\"") - || item.contains("state: 'planning'") - ); - for obsolete in [ - "id:", - "slug:", - "status:", - "workflow_state:", - "kind:", - "labels:", - "action_required:", - "attention_required:", - ] { - assert!( - !item.contains(obsolete), - "obsolete field {obsolete} in {item}" - ); - } - assert!(!item.contains("legacy_ticket:")); - assert!(!item.contains("needs_preflight:")); - let record = backend.show(TicketIdOrSlug::Id(ticket.id.clone())).unwrap(); - assert_eq!(record.meta.workflow_state, TicketWorkflowState::Planning); - assert!(record.meta.workflow_state_explicit); - let report = backend.doctor().unwrap(); - assert!(report.is_ok(), "{:?}", report.diagnostics); - } - - #[test] - fn local_backend_persists_and_edits_ticket_target() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - assert_ticket_target_edit_semantics(&backend); - } - #[test] fn sqlite_dependency_check_blocks_transitive_planning_dependency() { let temp = TempDir::new().unwrap(); @@ -7865,30 +4910,6 @@ state: planning sqlite.queue_ready(TicketIdOrSlug::Id(sqlite_ticket.id), "test"), Err(TicketError::TargetAuthorityUnavailable) )); - - let local_temp = TempDir::new().unwrap(); - let local = LocalTicketBackend::new(local_temp.path()); - let mut local_input = NewTicket::new("Local ready Ticket"); - local_input.workflow_state = Some(TicketWorkflowState::Ready); - local_input.repository_id = Some("main".to_string()); - local_input.ref_selector = Some("develop".to_string()); - let local_ticket = local.create(local_input).unwrap(); - let local_check = local - .dependency_check(TicketIdOrSlug::Id(local_ticket.id.clone())) - .unwrap(); - assert!(!local_check.queue_guard.can_queue_for_orchestrator); - assert!( - local_check - .queue_guard - .blocked_reason - .as_deref() - .unwrap_or_default() - .contains("target authority is unavailable") - ); - assert!(matches!( - local.queue_ready(TicketIdOrSlug::Id(local_ticket.id), "test"), - Err(TicketError::TargetAuthorityUnavailable) - )); } #[test] @@ -8219,13 +5240,6 @@ state: planning assert_ticket_target_edit_semantics(&backend); } - #[test] - fn local_backend_removes_ticket_relations() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - assert_ticket_relation_removal_semantics(&backend); - } - #[test] fn sqlite_backend_removes_ticket_relations() { let tmp = TempDir::new().unwrap(); @@ -8234,13 +5248,6 @@ state: planning assert_ticket_relation_removal_semantics(&backend); } - #[test] - fn local_backend_edit_item_supports_partial_body_replacement() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - assert_partial_body_replacement_semantics(&backend); - } - #[test] fn sqlite_backend_edit_item_supports_partial_body_replacement() { let tmp = TempDir::new().unwrap(); @@ -8469,9 +5476,9 @@ state: planning .find("pub fn list_workspace_projection") .expect("projection method"); let end = source[start..] - .find("pub fn import_from_local_backend") + .find(" fn connect(&self)") .map(|offset| start + offset) - .expect("following method"); + .expect("following SQLite method"); let projection_source = &source[start..end]; assert_eq!(projection_source.matches("self.with_read(").count(), 2); assert_eq!(projection_source.matches(".prepare(").count(), 2); @@ -8526,322 +5533,40 @@ state: planning ); } - #[test] - fn sqlite_backend_imports_legacy_local_layout_explicitly() { - let tmp = TempDir::new().unwrap(); - let local = backend(&tmp); - let created = local.create(NewTicket::new("Legacy Ticket")).unwrap(); - let db = - SqliteTicketBackend::open(tmp.path().join("workspace.db"), "workspace-test").unwrap(); - db.import_from_local_backend(&local).unwrap(); - - let ticket = db.show(TicketIdOrSlug::Id(created.id.clone())).unwrap(); - assert_eq!(ticket.meta.id, created.id); - assert_eq!(ticket.meta.title, "Legacy Ticket"); - - fs::remove_dir_all(local.root()).unwrap(); - let ticket = db.show(TicketIdOrSlug::Id(created.id)).unwrap(); - assert_eq!(ticket.meta.title, "Legacy Ticket"); - } - - #[test] - fn partial_list_and_show_keep_valid_tickets_when_peer_record_is_invalid() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let mut ready = NewTicket::new("Ready Valid"); - ready.workflow_state = Some(TicketWorkflowState::Ready); - let valid = backend.create(ready).unwrap(); - let invalid = backend - .create(NewTicket::new("Invalid Secret Title")) - .unwrap(); - fs::write( - backend.root().join(&invalid.id).join("item.md"), - "---\ntitle: Invalid Secret Title\nstate: super-secret-invalid\n---\nbody\n", - ) - .unwrap(); - - assert!(backend.list(TicketListQuery::all()).is_err()); - - let partial = backend.list_partial(TicketListQuery::all()).unwrap(); - assert_eq!(partial.tickets.len(), 1); - assert_eq!(partial.tickets[0].id, valid.id); - assert_eq!(partial.invalid_records.len(), 1); - assert_eq!(partial.invalid_records[0].label, invalid.id); - assert_eq!( - partial.invalid_records[0].reason, - "invalid ticket record schema" - ); - assert!( - !partial.invalid_records[0] - .reason - .contains("super-secret-invalid") - ); - - let detail = backend - .show_partial(TicketIdOrSlug::Id(valid.id.clone())) - .unwrap(); - assert_eq!(detail.ticket.meta.title, "Ready Valid"); - assert_eq!(detail.invalid_records.len(), 1); - assert_eq!(detail.invalid_records[0].label, invalid.id); - } - #[test] fn create_uses_configured_japanese_record_language_for_generated_defaults() { let tmp = TempDir::new().unwrap(); - let backend = LocalTicketBackend::new(tmp.path().join("tickets")) + let backend = SqliteTicketBackend::open(tmp.path().join("tickets.db"), "workspace-test") + .unwrap() .with_record_language(Some("Japanese")); let created = backend.create(NewTicket::new("日本語レコード")).unwrap(); - let dir = backend.root().join(created.id.as_str()); - let item = fs::read_to_string(dir.join("item.md")).unwrap(); - let thread = fs::read_to_string(dir.join("thread.md")).unwrap(); + let ticket = backend.show(TicketIdOrSlug::Id(created.id)).unwrap(); - assert!(item.contains("## 背景")); - assert!(item.contains("LocalTicketBackend によって作成されました。")); - assert!(thread.contains("## 作成")); - assert!(thread.contains("LocalTicketBackend によって作成されました。")); - } - - #[test] - fn create_round_trips_numeric_looking_string_frontmatter_values() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let mut input = NewTicket::new("123"); - input.risk_flags = vec!["1".into(), "42".into()]; - input.assignee = Some("42".into()); - let ticket = backend.create(input).unwrap(); - - let record = backend.show(TicketIdOrSlug::Id(ticket.id.clone())).unwrap(); - assert_eq!(record.meta.title, "123"); - assert!(record.meta.labels.is_empty()); - assert_eq!(record.meta.risk_flags, vec!["1", "42"]); - assert_eq!(record.meta.assignee.as_deref(), Some("42")); - - let item = fs::read_to_string(tmp.path().join("tickets").join(&ticket.id).join("item.md")) - .unwrap(); - assert!(item.contains("title: '123'"), "{item}"); - assert!(!item.contains("labels:"), "{item}"); - assert!(item.contains("risk_flags: ['1', '42']"), "{item}"); - assert!(item.contains("assignee: '42'"), "{item}"); - assert!(!item.contains("attention_required:"), "{item}"); - assert!(!item.contains("action_required:"), "{item}"); - - let report = backend.doctor().unwrap(); - assert!(report.is_ok(), "{:?}", report.diagnostics); - } - - #[test] - fn add_event_status_and_close_preserve_local_layout() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let mut input = NewTicket::new("Flow Ticket"); - input.repository_id = Some("main".to_owned()); - let ticket = backend.create(input).unwrap(); - backend - .add_event( - TicketIdOrSlug::Id(ticket.id.clone()), - NewTicketEvent::new(TicketEventKind::Plan, "Implementation plan."), - ) - .unwrap(); - backend - .mark_ready( - TicketIdOrSlug::Id(ticket.id.clone()), - TicketMarkReady { - operation_key: "test-flow-ready".to_owned(), - reason: Some("ready_for_queue".to_owned()), - author: Some("test".to_owned()), - intake_summary: None, - }, - ) - .unwrap(); - let current_item = tmp.path().join("tickets").join(&ticket.id).join("item.md"); - assert!(current_item.exists()); - backend - .close( - TicketIdOrSlug::Id(ticket.id.clone()), - MarkdownText::new("Done.\n"), - ) - .unwrap(); - let closed_dir = tmp.path().join("tickets").join(&ticket.id); - assert!(closed_dir.join("resolution.md").exists()); - let thread = fs::read_to_string(closed_dir.join("thread.md")).unwrap(); - assert!(thread.contains("author".into()); - - assert!(matches!( - backend.create(input), - Err(TicketError::Conflict(_)) - )); - let ticket_dirs = fs::read_dir(tmp.path().join("tickets")) - .unwrap() - .filter(|entry| entry.as_ref().is_ok_and(|entry| entry.path().is_dir())) - .count(); - assert_eq!(ticket_dirs, 0); - } - - #[test] - fn state_changed_and_intake_summary_events_round_trip() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let ticket = backend - .create(NewTicket::new("Typed Thread Ticket")) - .unwrap(); - let mut change = TicketStateChange::new( - "requirements-sync", - "implementation-ready", - "requirements approved", - "Planning sync finished; implementation can begin.", - ); - change.author = Some("orchestrator".into()); - backend - .add_state_changed(TicketIdOrSlug::Id(ticket.id.clone()), change) - .unwrap(); - let mut summary = TicketIntakeSummary::new("## Accepted intent\n\nImplement typed events."); - summary.author = Some("intake".into()); - backend - .add_intake_summary(TicketIdOrSlug::Id(ticket.id.clone()), summary) - .unwrap(); - - let record = backend.show(TicketIdOrSlug::Id(ticket.id.clone())).unwrap(); - let state_event = record - .events - .iter() - .find(|event| event.kind == TicketEventKind::StateChanged) - .unwrap(); - assert_eq!(state_event.from.as_deref(), Some("requirements-sync")); - assert_eq!(state_event.to.as_deref(), Some("implementation-ready")); - assert_eq!(state_event.reason.as_deref(), Some("requirements approved")); - assert_eq!(state_event.author.as_deref(), Some("orchestrator")); - assert_eq!( - state_event.attributes.get("reason").map(String::as_str), - Some("requirements approved") - ); + assert!(ticket.document.body.as_str().contains("## 背景")); assert!( - record - .events - .iter() - .any(|event| event.kind == TicketEventKind::IntakeSummary - && event.body.as_str().contains("Accepted intent")) + ticket.events[0] + .body + .as_str() + .contains("Ticket が作成されました。") ); - let thread = fs::read_to_string( - tmp.path() - .join("tickets") - .join(&ticket.id) - .join("thread.md"), - ) - .unwrap(); - assert!(thread.contains("event: state_changed")); - assert!(thread.contains("reason: \"requirements approved\"")); - assert!(thread.contains("event: intake_summary")); - let report = backend.doctor().unwrap(); - assert!(report.is_ok(), "{:?}", report.diagnostics); - } - - #[test] - fn set_state_field_updates_frontmatter_and_appends_transition() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let ticket = backend - .create(NewTicket::new("State Field Ticket")) - .unwrap(); - let item = tmp.path().join("tickets").join(&ticket.id).join("item.md"); - backend - .set_frontmatter_fields(&item, &[("readiness", "requirements-sync")]) - .unwrap(); - - let mut change = TicketStateChange::new( - "requirements-sync", - "implementation-ready", - "requirements accepted", - "Implementation is authorized.", - ); - change.author = Some("orchestrator".into()); - backend - .set_state_field(TicketIdOrSlug::Id(ticket.id.clone()), "readiness", change) - .unwrap(); - - let record = backend.show(TicketIdOrSlug::Id(ticket.id.clone())).unwrap(); - assert_eq!( - record.meta.readiness.as_deref(), - Some("implementation-ready") - ); - let event = record - .events - .iter() - .find(|event| event.kind == TicketEventKind::StateChanged) - .unwrap(); - assert_eq!(event.state_field.as_deref(), Some("readiness")); - let stale = TicketStateChange::new( - "requirements-sync", - "done", - "stale update", - "This must be rejected.", - ); - assert!(matches!( - backend.set_state_field(TicketIdOrSlug::Id(ticket.id), "readiness", stale), - Err(TicketError::Conflict(_)) - )); } #[test] fn state_defaults_and_queue_transition_round_trip() { let tmp = TempDir::new().unwrap(); let backend = backend(&tmp); - let missing_meta = ticket_meta( - parse_ticket_frontmatter("title: Missing State").expect("missing state parses"), - "0000000000001".to_string(), - ); - assert_eq!(missing_meta.workflow_state, TicketWorkflowState::Planning); - assert!(!missing_meta.workflow_state_explicit); + let planning = backend.create(NewTicket::new("Default planning")).unwrap(); + let planning = backend.show(TicketIdOrSlug::Id(planning.id)).unwrap(); + assert_eq!(planning.meta.workflow_state, TicketWorkflowState::Planning); + assert!(planning.meta.workflow_state_explicit); - let closed_meta = ticket_meta( - parse_ticket_frontmatter("state: closed").expect("closed state parses"), - "0000000000002".to_string(), - ); - assert_eq!(closed_meta.workflow_state, TicketWorkflowState::Closed); - assert!(closed_meta.workflow_state_explicit); + let mut closed_input = NewTicket::new("Closed state"); + closed_input.workflow_state = Some(TicketWorkflowState::Closed); + let closed = backend.create(closed_input).unwrap(); + let closed = backend.show(TicketIdOrSlug::Id(closed.id)).unwrap(); + assert_eq!(closed.meta.workflow_state, TicketWorkflowState::Closed); + assert!(closed.meta.workflow_state_explicit); let mut ready_input = NewTicket::new("Ready Workflow"); ready_input.workflow_state = Some(TicketWorkflowState::Ready); @@ -8889,83 +5614,6 @@ state: planning ); } - #[cfg(unix)] - #[test] - fn local_queue_rolls_back_ready_dependency_when_later_write_fails() { - use std::os::unix::fs::PermissionsExt; - - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let mut dependency_input = NewTicket::new("Ready dependency"); - dependency_input.workflow_state = Some(TicketWorkflowState::Ready); - dependency_input.repository_id = Some("main".to_string()); - dependency_input.ref_selector = Some("develop".to_string()); - let dependency = backend.create(dependency_input).unwrap(); - let mut root_input = NewTicket::new("Ready root"); - root_input.workflow_state = Some(TicketWorkflowState::Ready); - root_input.repository_id = Some("main".to_string()); - root_input.ref_selector = Some("develop".to_string()); - let root = backend.create(root_input).unwrap(); - backend - .add_ticket_relation( - TicketIdOrSlug::Id(root.id.clone()), - NewTicketRelation { - kind: TicketRelationKind::DependsOn, - target: dependency.id.clone(), - note: None, - author: None, - }, - ) - .unwrap(); - - let root_dir = backend - .find_ticket_dir(&TicketIdOrSlug::Id(root.id.clone())) - .unwrap(); - let root_item = root_dir.join("item.md"); - fs::set_permissions(&root_dir, fs::Permissions::from_mode(0o555)).unwrap(); - fs::set_permissions(&root_item, fs::Permissions::from_mode(0o444)).unwrap(); - let result = backend.queue_ready(TicketIdOrSlug::Id(root.id.clone()), "test"); - fs::set_permissions(&root_dir, fs::Permissions::from_mode(0o755)).unwrap(); - fs::set_permissions(&root_item, fs::Permissions::from_mode(0o644)).unwrap(); - - assert!(result.is_err()); - for ticket_id in [dependency.id, root.id] { - let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); - assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Ready); - assert!( - !ticket - .events - .iter() - .any(|event| event.to.as_deref() == Some("queued")), - "Ticket {} retained queued events: {:?}", - ticket.meta.id, - ticket.events - ); - } - } - - #[test] - fn state_cannot_be_changed_through_generic_field_api() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let ticket = backend - .create(NewTicket::new("Generic Workflow Bypass")) - .unwrap(); - let change = TicketStateChange::new( - "planning", - "done", - "bypass", - "Generic field API must not mutate state.", - ); - - assert!(matches!( - backend.set_state_field(TicketIdOrSlug::Id(ticket.id.clone()), "state", change), - Err(TicketError::Conflict(_)) - )); - let record = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap(); - assert_eq!(record.meta.workflow_state, TicketWorkflowState::Planning); - } - #[test] fn mark_ready_resolves_target_and_is_idempotent() { let tmp = TempDir::new().unwrap(); @@ -9032,123 +5680,12 @@ state: planning let record = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap(); assert_eq!(record.meta.status, ExtensibleTicketStatus::Closed); assert_eq!(record.meta.workflow_state, TicketWorkflowState::Closed); - assert!(record.events.iter().any(|event| { - event.kind == TicketEventKind::StateChanged - && event.state_field.as_deref() == Some("state") - && event.to.as_deref() == Some("closed") - })); - } - - #[test] - fn doctor_reports_invalid_state() { - let tmp = TempDir::new().unwrap(); - let root = tmp.path().join("tickets"); - fs::create_dir_all(root.join("0000000000001/artifacts")).unwrap(); - fs::write( - root.join("0000000000001/item.md"), - "---\ntitle: Bad\nstate: almost\ncreated_at: x\nupdated_at: x\n---\n", - ) - .unwrap(); - fs::write(root.join("0000000000001/thread.md"), "").unwrap(); - - let report = LocalTicketBackend::new(&root).doctor().unwrap(); - let messages = report - .diagnostics - .iter() - .map(|d| d.message.as_str()) - .collect::>() - .join("\n"); - assert!(!report.is_ok()); - assert!(messages.contains("invalid state"), "{messages}"); - } - - #[test] - fn doctor_validates_typed_thread_event_attributes() { - let tmp = TempDir::new().unwrap(); - let root = tmp.path().join("tickets"); - fs::create_dir_all(root.join("0000000000001/artifacts")).unwrap(); - fs::write( - root.join("0000000000001/item.md"), - "---\ntitle: Bad\nstate: planning\ncreated_at: x\nupdated_at: x\n---\n", - ) - .unwrap(); - fs::write( - root.join("0000000000001/thread.md"), - "\n\n## State changed\n\n---\n\n\n\n## Intake summary\n\n---\n", - ) - .unwrap(); - let report = LocalTicketBackend::new(&root).doctor().unwrap(); - let messages = report - .diagnostics - .iter() - .map(|d| d.message.as_str()) - .collect::>() - .join("\n"); - assert!(!report.is_ok()); - assert!(messages.contains("state_changed event missing to")); - assert!(messages.contains("state_changed event missing reason")); - assert!(messages.contains("intake_summary event missing body")); - } - - #[test] - fn doctor_reports_core_consistency_errors() { - let tmp = TempDir::new().unwrap(); - let root = tmp.path().join("tickets"); - fs::create_dir_all(root.join("open/legacy/artifacts")).unwrap(); - fs::write( - root.join("open/legacy/item.md"), - "---\ntitle: Legacy\nstate: planning\ncreated_at: x\nupdated_at: x\n---\n", - ) - .unwrap(); - fs::write(root.join("open/legacy/thread.md"), "").unwrap(); - fs::create_dir_all(root.join("0000000000001/artifacts")).unwrap(); - fs::write( - root.join("0000000000001/item.md"), - "---\nid: old\nslug: old\ntitle: Bad\nstatus: pending\nworkflow_state: ready\nkind: task\nlabels: []\naction_required: human\nattention_required: true\ncreated_at: x\nupdated_at: x\n---\n", - ) - .unwrap(); - fs::write( - root.join("0000000000001/thread.md"), - "\n", - ) - .unwrap(); - let report = LocalTicketBackend::new(&root).doctor().unwrap(); - let messages = report - .diagnostics - .iter() - .map(|d| d.message.as_str()) - .collect::>() - .join("\n"); - assert!(!report.is_ok()); - assert!(messages.contains("legacy ticket bucket remains")); - assert!(messages.contains("obsolete current frontmatter field 'id'")); - assert!(messages.contains("obsolete current frontmatter field 'slug'")); - assert!(messages.contains("obsolete current frontmatter field 'status'")); - assert!(messages.contains("obsolete current frontmatter field 'workflow_state'")); - assert!(messages.contains("obsolete current frontmatter field 'kind'")); - assert!(messages.contains("obsolete current frontmatter field 'labels'")); - assert!(messages.contains("obsolete current frontmatter field 'action_required'")); - assert!(messages.contains("obsolete current frontmatter field 'attention_required'")); - assert!(messages.contains("review event missing valid status")); - } - - #[test] - fn lock_conflict_is_reported() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - fs::create_dir_all(backend.root()).unwrap(); - let lock_path = backend.root().join(".ticket-backend.lock"); - let file = OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .open(&lock_path) - .unwrap(); - FileExt::lock_exclusive(&file).unwrap(); - let err = backend.create(NewTicket::new("Locked")).unwrap_err(); - FileExt::unlock(&file).unwrap(); - assert!(matches!(err, TicketError::Locked { .. })); + assert!( + record + .events + .iter() + .any(|event| event.kind == TicketEventKind::Close) + ); } #[test] @@ -9326,110 +5863,6 @@ state: planning ); } - #[test] - fn doctor_validates_ticket_relations() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let first = backend.create(NewTicket::new("First")).unwrap(); - let second = backend.create(NewTicket::new("Second")).unwrap(); - backend - .add_ticket_relation( - TicketIdOrSlug::Id(first.id.clone()), - NewTicketRelation { - kind: TicketRelationKind::DependsOn, - target: second.id.clone(), - note: None, - author: Some("test".to_string()), - }, - ) - .unwrap(); - backend - .add_ticket_relation( - TicketIdOrSlug::Id(second.id.clone()), - NewTicketRelation { - kind: TicketRelationKind::DependsOn, - target: first.id.clone(), - note: None, - author: Some("test".to_string()), - }, - ) - .unwrap(); - - let report = backend.doctor().unwrap(); - assert!( - report - .diagnostics - .iter() - .any(|diagnostic| diagnostic.message.contains("cycle")), - "{:?}", - report.diagnostics - ); - - let artifacts = tmp.path().join("tickets").join(&first.id).join("artifacts"); - fs::write( - artifacts.join(TICKET_RELATIONS_ARTIFACT), - format!( - r#"{{ - "version": 1, - "relations": [ - {{"ticket_id":"{}","kind":"related","target":"{}","author":"test","at":"2026-06-09T00:00:00Z"}} - ] -}} -"#, - first.id, first.id - ), - ) - .unwrap(); - let report = backend.doctor().unwrap(); - assert!(report.diagnostics.iter().any(|diagnostic| { - diagnostic - .message - .contains("ticket relation cannot target itself") - })); - - fs::write( - artifacts.join(TICKET_RELATIONS_ARTIFACT), - format!( - r#"{{ - "version": 1, - "relations": [ - {{"ticket_id":"{}","kind":"related","target":"missing-ticket","author":"test","at":"2026-06-09T00:00:01Z"}} - ] -}} -"#, - first.id - ), - ) - .unwrap(); - let report = backend.doctor().unwrap(); - assert!(report.diagnostics.iter().any(|diagnostic| { - diagnostic - .message - .contains("ticket relation has dangling target") - })); - - fs::write( - artifacts.join(TICKET_RELATIONS_ARTIFACT), - format!( - r#"{{ - "version": 1, - "relations": [ - {{"ticket_id":"{}","kind":"parent","target":"{}","author":"test","at":"2026-06-09T00:00:00Z"}} - ] -}} -"#, - first.id, second.id - ), - ) - .unwrap(); - let report = backend.doctor().unwrap(); - assert!(report.diagnostics.iter().any(|diagnostic| { - diagnostic - .message - .contains("invalid ticket relations artifact") - })); - } - #[test] fn orchestration_plan_records_persist_and_query_by_ticket_and_kind() { let temp = TempDir::new().unwrap(); @@ -9493,21 +5926,11 @@ state: planning before_records[0].related_ticket.as_deref(), Some(second.id.as_str()) ); - - let path = temp - .path() - .join("tickets") - .join(&first.id) - .join("artifacts") - .join(ORCHESTRATION_PLAN_ARTIFACT); - assert!(path.is_file()); - let content = fs::read_to_string(path).unwrap(); - assert_eq!(content.lines().count(), 2); assert_eq!(backend.doctor().unwrap().error_count(), 0); } #[test] - fn orchestration_plan_validation_rejects_missing_related_ticket_and_bad_artifacts() { + fn orchestration_plan_validation_rejects_missing_related_ticket() { let temp = TempDir::new().unwrap(); let backend = backend(&temp); let ticket = backend @@ -9527,20 +5950,5 @@ state: planning ) .unwrap_err(); assert!(err.to_string().contains("requires related_ticket")); - - let artifact = temp - .path() - .join("tickets") - .join(&ticket.id) - .join("artifacts") - .join(ORCHESTRATION_PLAN_ARTIFACT); - fs::write(&artifact, "{not json}\n").unwrap(); - let report = backend.doctor().unwrap(); - assert!(report.error_count() > 0); - assert!(report.diagnostics.iter().any(|diagnostic| { - diagnostic - .message - .contains("invalid orchestration plan record") - })); } } diff --git a/crates/ticket/src/tool.rs b/crates/ticket/src/tool.rs index 2b853ae28..5acbec2ee 100644 --- a/crates/ticket/src/tool.rs +++ b/crates/ticket/src/tool.rs @@ -12,13 +12,12 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use crate::{ - AcceptedOrchestrationPlan, LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord, - NewTicket, NewTicketEvent, NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, - Result as TicketResult, Ticket, TicketBackend, TicketBodyReplacement, TicketDoctorDiagnostic, - TicketDoctorReport, TicketDoctorSeverity, TicketError, TicketEventKind, TicketIdOrSlug, - TicketIntakeSummary, TicketListState, TicketMarkReady, TicketRef, TicketRelation, - TicketRelationKind, TicketRelationView, TicketStateChange, TicketSummary, TicketWorkflowState, - default_author, + AcceptedOrchestrationPlan, MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, + NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult, + Ticket, TicketBackend, TicketBodyReplacement, TicketDoctorDiagnostic, TicketDoctorReport, + TicketDoctorSeverity, TicketError, TicketEventKind, TicketIdOrSlug, TicketIntakeSummary, + TicketListState, TicketMarkReady, TicketRef, TicketRelation, TicketRelationKind, + TicketRelationView, TicketStateChange, TicketSummary, TicketWorkflowState, default_author, }; 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. \ -Inputs mirror the Ticket `item.md` fields; `title` is required, `body` is Markdown, and the \ -backend assigns the id and writes the local Ticket file layout under the configured backend root."; +`title` is required, `body` is Markdown, and the backend assigns the id."; 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."; 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 \ -definition with the richer authoritative text/event/evidence/relation/Objective/time/attention query."; +overview. Workspace-backed Workers replace this definition with the richer authoritative \ +text/event/evidence/relation/Objective/time/attention query."; 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."; 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."; @@ -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 \ 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 \ -backend. The backend sets `state: closed`, writes resolution.md, updates item.md, and appends \ -a close event."; +backend. The backend sets the Ticket state to closed and appends a close event."; 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; \ 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. /// /// 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. #[derive(Clone)] pub struct TicketToolBackend { @@ -247,8 +244,8 @@ impl TicketToolBackend { } } -impl From for TicketToolBackend { - fn from(backend: LocalTicketBackend) -> Self { +impl From for TicketToolBackend { + fn from(backend: crate::SqliteTicketBackend) -> Self { let record_language = backend.record_language().map(str::to_string); Self::new(backend).with_record_language(record_language.as_deref()) } @@ -1950,9 +1947,8 @@ mod tests { } } - fn backend(temp: &TempDir) -> LocalTicketBackend { - LocalTicketBackend::new(temp.path().join("tickets")) - .with_target_authority(Arc::new(TestTargetAuthority)) + fn backend(temp: &TempDir) -> crate::SqliteTicketBackend { + sqlite_backend(temp) } fn sqlite_backend(temp: &TempDir) -> crate::SqliteTicketBackend { @@ -2323,7 +2319,6 @@ mod tests { let close = tool_by_name(backend.clone(), "TicketClose"); let body_secret = "ITEM_BODY_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 mut ticket = NewTicket::new("Leak Probe"); ticket.body = MarkdownText::new(format!("Item body {body_secret}")); @@ -2335,15 +2330,6 @@ mod tests { NewTicketEvent::new(TicketEventKind::Comment, format!("Thread {thread_secret}")), ) .unwrap(); - std::fs::write( - temp.path() - .join("tickets") - .join(&created.id) - .join("artifacts") - .join("secret.txt"), - artifact_secret, - ) - .unwrap(); close .execute( &json!({ @@ -2364,12 +2350,7 @@ mod tests { .await .unwrap(); let listed_content = listed.content.unwrap(); - for secret in [ - body_secret, - thread_secret, - artifact_secret, - resolution_secret, - ] { + for secret in [body_secret, thread_secret, resolution_secret] { assert!(!listed_content.contains(secret)); } let listed_json: Value = serde_json::from_str(&listed_content).unwrap(); @@ -2584,7 +2565,7 @@ mod tests { closed .events .iter() - .any(|event| event.kind == TicketEventKind::StateChanged) + .any(|event| event.kind == TicketEventKind::Close) ); } @@ -2889,7 +2870,7 @@ mod tests { .await .unwrap_err(); - assert!(error.to_string().contains("state changed concurrently")); + assert!(matches!(error, ToolError::ExecutionFailed(_))); let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap(); assert_eq!(record.meta.workflow_state, TicketWorkflowState::Planning); assert!(!record.events.iter().any(|event| { @@ -3068,7 +3049,7 @@ mod tests { } #[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 backend = backend(&temp); let create = tool_by_name(backend.clone(), "TicketCreate"); @@ -3083,7 +3064,9 @@ mod tests { let id = value["id"].as_str().unwrap(); assert!(!id.contains("escape")); 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!( backend.list(crate::TicketListQuery::all()).unwrap().len(), 1 diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index 0cd3ad5de..69483ae82 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -1493,15 +1493,6 @@ pub struct WorkerRuntimeExecutionBackend { spawn_restore_timeout: Duration, } -impl WorkerRuntimeExecutionBackend { - pub fn from_workspace(workspace_root: impl Into) -> Result { - 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 WorkerRuntimeExecutionBackend where F: RuntimeWorkerFactory, @@ -2957,6 +2948,20 @@ mod tests { use manifest::{Scope, WorkerManifest}; 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] fn restored_router_uses_workspace_proxy_for_logical_only_attachment() { let root = tempfile::tempdir().unwrap(); @@ -4593,6 +4598,10 @@ mod tests { }]; let detail = runtime.create_worker(request).unwrap(); + assert!( + !repo.path().join(".yoi").exists(), + "Worker launch must not create repository-local authority" + ); runtime .send_input(&detail.worker_ref, WorkerInput::user("inspect tools")) .unwrap(); @@ -4631,6 +4640,10 @@ mod tests { runtime.stop_worker(&detail.worker_ref, None).unwrap(); let restored_without_workdir = runtime.restore_worker(&detail.worker_ref).unwrap(); assert!(restored_without_workdir.workdir_attachments.is_empty()); + assert!( + !repo.path().join(".yoi").exists(), + "Worker restore must not create repository-local authority" + ); } #[test] diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index babae8e21..ade0580ba 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -1287,15 +1287,10 @@ where "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, - ); - 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() { let workspace_client = worker.workspace_client_handle(); diff --git a/crates/worker/src/entrypoint.rs b/crates/worker/src/entrypoint.rs index 6270caf33..960a48a19 100644 --- a/crates/worker/src/entrypoint.rs +++ b/crates/worker/src/entrypoint.rs @@ -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] fn normal_startup_uses_builtin_defaults_without_local_profile_discovery() { let tmp = TempDir::new().unwrap(); diff --git a/crates/worker/src/feature/builtin.rs b/crates/worker/src/feature/builtin.rs index 407e20ddc..800a906db 100644 --- a/crates/worker/src/feature/builtin.rs +++ b/crates/worker/src/feature/builtin.rs @@ -21,10 +21,7 @@ pub mod worker_observation; pub mod workspace_worker_discovery; pub use task::{TaskFeature, task_tools_feature}; -pub use ticket::{ - TicketFeature, TicketFeatureAccess, ticket_tools_feature, ticket_tools_feature_with_access, - ticket_tools_feature_with_backend, -}; +pub use ticket::{TicketFeature, TicketFeatureAccess, ticket_tools_feature}; pub use worker_observation::{ CompositeWorkerObservationProvider, WorkerObservationError, WorkerObservationFeature, WorkerObservationProvider, WorkerObservationSubject, WorkerObservationSubjectRef, diff --git a/crates/worker/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index 65ad9749d..73841be2c 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -1,34 +1,28 @@ //! Built-in Ticket feature adapter. //! //! The ticket crate owns Ticket domain logic and Tool implementations. This -//! module only resolves the local backend root, declares the built-in feature, -//! and contributes those tools through the normal feature registry path. +//! module binds an authority-scoped Workspace client, declares the built-in +//! feature, and contributes those tools through the normal registry path. -use std::{ - path::{Path, PathBuf}, - sync::Arc, -}; +use std::sync::Arc; use async_trait::async_trait; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use ticket::{ - LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, - NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult, - Ticket, TicketBackend, TicketBackendOperation, TicketBackendOperationResult, - TicketDoctorReport, TicketError, TicketIdOrSlug, TicketIntakeSummary, TicketListQuery, - TicketRef, TicketRelation, TicketRelationKind, TicketRelationView, TicketStateChange, - TicketSummary, TicketWorkflowState, - config::{DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TicketConfig}, + MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation, + OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult, Ticket, TicketBackend, + TicketBackendOperation, TicketBackendOperationResult, TicketDoctorReport, TicketError, + TicketIdOrSlug, TicketIntakeSummary, TicketListQuery, TicketRef, TicketRelation, + TicketRelationKind, TicketRelationView, TicketStateChange, TicketSummary, TicketWorkflowState, tool::{TICKET_TOOL_NAMES, TicketToolBackend, ticket_tool_description, ticket_tools}, }; use crate::feature::{ - FeatureDescriptor, FeatureDiagnostic, FeatureInstallContext, FeatureInstallError, - FeatureInstructionContribution, FeatureInstructionDeclaration, FeatureInstructionId, - FeatureModule, ServiceDeclaration, ServiceId, ToolContribution, ToolDeclaration, - ToolDefinition, + FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureInstructionContribution, + FeatureInstructionDeclaration, FeatureInstructionId, FeatureModule, ServiceDeclaration, + ServiceId, ToolContribution, ToolDeclaration, ToolDefinition, }; use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod}; use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput}; @@ -278,8 +272,8 @@ fn workspace_ticket_read_definition( const FEATURE_ID: &str = "ticket"; const FEATURE_NAME: &str = "Ticket tools"; -const FEATURE_DESCRIPTION: &str = "Typed local Ticket work-item operations over a bounded backend root. \ -The tools operate through the ticket crate backend and do not grant generic filesystem write scope."; +const FEATURE_DESCRIPTION: &str = + "Typed Ticket operations through the authoritative Workspace API."; const TICKET_WORKFLOW_INSTRUCTION_ID: &str = "ticket.workflow"; const TICKET_WORKFLOW_PROMPT_REF: &str = "common.tickets"; 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 { backend: WorkspaceHttpTicketBackend, } @@ -323,12 +313,6 @@ fn ticket_handoff_from_record(ticket: Ticket) -> Result Result { - ticket_handoff_from_record(self.backend.show(ticket_ref.into())?) - } -} - impl TicketService for WorkspaceTicketService { fn ticket_handoff(&self, ticket_ref: &str) -> Result { ticket_handoff_from_record(self.backend.show_unprojected(ticket_ref)?) @@ -478,98 +462,20 @@ const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[ "TicketOrchestrationPlanQuery", ]; -#[derive(Clone, Debug)] -pub enum TicketFeatureBackend { - Local { root: PathBuf }, - WorkspaceClient(Arc), -} - -impl From 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)] pub struct TicketFeature { - backend: TicketFeatureBackend, - record_language: Option, - config_error: Option, + workspace_client: Arc, access: TicketFeatureAccess, } impl TicketFeature { - pub fn new(backend_root: impl Into) -> Self { - Self::new_with_access(backend_root, TicketFeatureAccess::workspace_authoring()) - } - - pub fn new_with_access(backend_root: impl Into, access: TicketFeatureAccess) -> Self { - Self::with_backend( - TicketFeatureBackend::Local { - root: backend_root.into(), - }, - access, - ) - } - - pub fn with_backend(backend: TicketFeatureBackend, access: TicketFeatureAccess) -> Self { + pub fn new(workspace_client: Arc, access: TicketFeatureAccess) -> Self { Self { - backend, - record_language: None, - config_error: None, + workspace_client, access, } } - pub fn for_workspace(workspace: impl AsRef) -> Self { - Self::for_workspace_with_access(workspace, TicketFeatureAccess::workspace_authoring()) - } - - pub fn for_workspace_with_access( - workspace: impl AsRef, - 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 { self.access } @@ -578,46 +484,12 @@ impl TicketFeature { self.access.tool_names() } - fn usable_backend_root(&self) -> Result { - let Some(root) = self.backend_root() else { - 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 workspace_client(&self) -> Arc { + self.workspace_client.clone() } - fn tool_backend(&self, context: &mut FeatureInstallContext<'_>) -> Option { - 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(|| "".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 { descriptor = descriptor.with_tool(ToolDeclaration::new( name, - ticket_tool_description(name, self.record_language.as_deref()), + ticket_tool_description(name, None), )); } descriptor } fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { - if let Some(error) = &self.config_error { - context - .diagnostics() - .push(FeatureDiagnostic::warning(format!( - "Ticket tools not registered: {error}" - ))); - return Ok(()); - } - let Some(backend) = self.tool_backend(context) else { - return Ok(()); - }; - let ticket_service: Arc = match &self.backend { - TicketFeatureBackend::WorkspaceClient(client) => Arc::new(WorkspaceTicketService { - backend: WorkspaceHttpTicketBackend::new(client.clone()), - }), - TicketFeatureBackend::Local { .. } => Arc::new(BackendTicketService { - backend: backend.clone(), - }), - }; + let backend = self.tool_backend(); + let workspace_client = self.workspace_client(); + let ticket_service: Arc = Arc::new(WorkspaceTicketService { + backend: WorkspaceHttpTicketBackend::new(workspace_client.clone()), + }); context.services().provide( ServiceDeclaration::new( ServiceId::builtin(TICKET_SERVICE_ID), @@ -675,10 +533,6 @@ impl FeatureModule for TicketFeature { ticket_workflow_instruction(), ))?; 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(); for definition in ticket_tools(backend) { let (meta, _) = definition(); @@ -689,13 +543,15 @@ impl FeatureModule for TicketFeature { { continue; } - let definition = match (name.as_str(), workspace_client.as_ref()) { - ("QueryTicket", Some(client)) => { - workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query) - } - ("ShowTicket", Some(client)) => { - workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Show) - } + let definition = match name.as_str() { + "QueryTicket" => workspace_ticket_read_definition( + workspace_client.clone(), + WorkspaceTicketReadKind::Query, + ), + "ShowTicket" => workspace_ticket_read_definition( + workspace_client.clone(), + WorkspaceTicketReadKind::Show, + ), _ => definition, }; tools.register(ToolContribution::new(name, definition))?; @@ -1371,22 +1227,11 @@ impl TicketBackend for WorkspaceHttpTicketBackend { } } -pub fn ticket_tools_feature(workspace: impl AsRef) -> TicketFeature { - TicketFeature::for_workspace(workspace) -} - -pub fn ticket_tools_feature_with_access( - workspace: impl AsRef, +pub fn ticket_tools_feature( + workspace_client: Arc, access: TicketFeatureAccess, ) -> TicketFeature { - TicketFeature::for_workspace_with_access(workspace, access) -} - -pub fn ticket_tools_feature_with_backend( - backend: impl Into, - access: TicketFeatureAccess, -) -> TicketFeature { - TicketFeature::with_backend(backend.into(), access) + TicketFeature::new(workspace_client, access) } #[cfg(test)] @@ -1397,29 +1242,31 @@ mod tests { use std::io::{Read, Write}; use std::net::TcpListener; use std::thread; - use tempfile::TempDir; - fn make_ticket_root(root: &Path) { - std::fs::create_dir_all(root).unwrap(); + #[test] + 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) { - let yoi_dir = workspace.join(".yoi"); - std::fs::create_dir_all(&yoi_dir).unwrap(); - std::fs::write(yoi_dir.join("workspace.toml"), content).unwrap(); - } - - 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") + fn workspace_feature(access: TicketFeatureAccess) -> TicketFeature { + let client: Arc = Arc::new( + crate::worker::TestWorkspaceHttpClient::new("workspace", "http://backend"), + ); + ticket_tools_feature(client, access) } #[test] @@ -1482,8 +1329,7 @@ mod tests { #[test] fn descriptor_declares_ticket_tools() { - let temp = TempDir::new().unwrap(); - let feature = ticket_tools_feature(temp.path()); + let feature = workspace_feature(TicketFeatureAccess::workspace_authoring()); let descriptor = feature.descriptor(); assert_eq!(descriptor.id.to_string(), "builtin:ticket"); assert_eq!(descriptor.runtime, FeatureRuntimeKind::Builtin); @@ -1500,9 +1346,7 @@ mod tests { #[test] fn read_only_descriptor_declares_only_state_tools() { - let temp = TempDir::new().unwrap(); - let feature = - ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::read_only()); + let feature = workspace_feature(TicketFeatureAccess::read_only()); let descriptor = feature.descriptor(); assert_eq!(feature.access(), TicketFeatureAccess::read_only()); assert_eq!(descriptor.tools.len(), READ_ONLY_TOOL_NAMES.len()); @@ -1518,9 +1362,7 @@ mod tests { #[test] fn workflow_descriptor_declares_workflow_tools() { - let temp = TempDir::new().unwrap(); - let feature = - ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::workflow()); + let feature = workspace_feature(TicketFeatureAccess::workflow()); let descriptor = feature.descriptor(); assert_eq!(feature.access(), TicketFeatureAccess::workflow()); assert_eq!( @@ -1535,12 +1377,7 @@ mod tests { #[test] fn additive_ticket_capabilities_expose_expected_tool_surfaces() { - let temp = TempDir::new().unwrap(); - - let workspace_authoring = ticket_tools_feature_with_access( - temp.path(), - TicketFeatureAccess::workspace_authoring(), - ); + let workspace_authoring = workspace_feature(TicketFeatureAccess::workspace_authoring()); let workspace_descriptor = workspace_authoring.descriptor(); let workspace_tools = workspace_descriptor .tools @@ -1552,8 +1389,7 @@ mod tests { assert!(workspace_tools.contains(&"TicketQueue")); assert!(!workspace_tools.contains(&"TicketWorkflowState")); - let orchestration = - ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::workflow()); + let orchestration = workspace_feature(TicketFeatureAccess::workflow()); let orchestration_descriptor = orchestration.descriptor(); let orchestration_tools = orchestration_descriptor .tools @@ -1567,8 +1403,7 @@ mod tests { assert!(!orchestration_tools.contains(&"TicketEditItem")); assert!(!orchestration_tools.contains(&"TicketQueue")); - let work_report = - ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::work_report()); + let work_report = workspace_feature(TicketFeatureAccess::work_report()); let work_report_descriptor = work_report.descriptor(); let work_report_tools = work_report_descriptor .tools @@ -1578,7 +1413,7 @@ mod tests { assert!(work_report_tools.contains(&"TicketComment")); 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_tools = review_descriptor .tools @@ -1590,15 +1425,10 @@ mod tests { #[test] 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 hooks = HookRegistryBuilder::default(); let report = FeatureRegistryBuilder::new() - .with_module(ticket_tools_feature_with_access( - temp.path(), - TicketFeatureAccess::read_only(), - )) + .with_module(workspace_feature(TicketFeatureAccess::read_only())) .install_into_pending(&mut pending_tools, &mut hooks); 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] 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 hooks = HookRegistryBuilder::default(); let report = FeatureRegistryBuilder::new() - .with_module(ticket_tools_feature_with_access( - temp.path(), - TicketFeatureAccess::workspace_authoring(), - )) + .with_module(workspace_feature(TicketFeatureAccess::workspace_authoring())) .install_into_pending(&mut pending_tools, &mut hooks); assert_eq!(pending_tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len()); @@ -1690,169 +1478,28 @@ language = "Japanese" } #[test] - fn workspace_authoring_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)); + fn workspace_only_ticket_feature_preserves_unrelated_stale_repository_trees() { + let ancestor = tempfile::tempdir().unwrap(); + let repository = ancestor.path().join("repository"); + std::fs::create_dir_all(repository.join(".yoi/tickets/broken")).unwrap(); + std::fs::create_dir_all(ancestor.path().join(".yoi")).unwrap(); + std::fs::write( + ancestor.path().join(".yoi/workspace.toml"), + "not valid toml", + ) + .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 hooks = HookRegistryBuilder::default(); let report = FeatureRegistryBuilder::new() - .with_module(ticket_tools_feature_with_access( - 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())) + .with_module(workspace_feature(TicketFeatureAccess::workspace_authoring())) .install_into_pending(&mut pending_tools, &mut hooks); assert_eq!(pending_tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len()); @@ -1861,9 +1508,14 @@ provider = "github" WORKSPACE_AUTHORING_TOOL_NAMES ); assert!(report.reports[0].diagnostics.is_empty()); - assert!(!root.join("open").exists()); - assert!(!root.join("pending").exists()); - assert!(!root.join("closed").exists()); + assert_eq!( + std::fs::read_to_string(ancestor.path().join(".yoi/workspace.toml")).unwrap(), + "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")] @@ -2057,10 +1709,12 @@ provider = "github" #[test] fn workspace_ticket_service_preserves_internal_identity_for_handoff() { - let temp = TempDir::new().unwrap(); - let local = LocalTicketBackend::new(temp.path().join("tickets")); - let created = local.create(NewTicket::new("Ticket handoff")).unwrap(); - let mut ticket = local.show(TicketIdOrSlug::Id(created.id.clone())).unwrap(); + let temp = tempfile::tempdir().unwrap(); + let sqlite = + ticket::SqliteTicketBackend::open(temp.path().join("tickets.db"), "workspace-test") + .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.workflow_state = TicketWorkflowState::Queued; diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index 931569588..5069fe9dc 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -3051,11 +3051,47 @@ mod tests { }) .await .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() - .import_from_local_backend(&ticket::LocalTicketBackend::new( - dir.path().join(".yoi/tickets"), - )) + .execute_batch( + 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(); rusqlite::Connection::open(&db_path) .unwrap() diff --git a/crates/yoi/Cargo.toml b/crates/yoi/Cargo.toml index 77877261c..86dbdf81a 100644 --- a/crates/yoi/Cargo.toml +++ b/crates/yoi/Cargo.toml @@ -13,7 +13,6 @@ project-record = { workspace = true } reqwest = { version = "0.13", default-features = false, features = ["json", "native-tls"] } chrono = { version = "0.4", default-features = false, features = ["clock"] } client = { workspace = true } -memory = { workspace = true } futures = { workspace = true } manifest = { workspace = true } worker = { workspace = true } @@ -26,7 +25,6 @@ ticket = { workspace = true } tui = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } -serde_yaml = "0.9.34" tokio = { workspace = true, features = ["rt-multi-thread", "macros", "signal"] } toml = { workspace = true } uuid = { workspace = true } diff --git a/crates/yoi/README.md b/crates/yoi/README.md index 68053350f..397c6ee6e 100644 --- a/crates/yoi/README.md +++ b/crates/yoi/README.md @@ -2,7 +2,7 @@ ## 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 diff --git a/crates/yoi/src/cli_connection.rs b/crates/yoi/src/cli_connection.rs index edc71ea53..0958ea16a 100644 --- a/crates/yoi/src/cli_connection.rs +++ b/crates/yoi/src/cli_connection.rs @@ -26,7 +26,6 @@ pub(crate) enum CliCommand { Objective, Plugin, Mcp, - MemoryLint, Session, Login, } @@ -46,7 +45,6 @@ impl CliCommand { CliCommand::Objective => "yoi objective", CliCommand::Plugin => "yoi plugin", CliCommand::Mcp => "yoi mcp", - CliCommand::MemoryLint => "yoi memory lint", CliCommand::Session => "yoi session", CliCommand::Login => "yoi login", } @@ -67,7 +65,6 @@ impl CliCommand { | CliCommand::WorkerCleanup | CliCommand::Plugin | CliCommand::Mcp - | CliCommand::MemoryLint | CliCommand::Session => CliConnectionRequirement::LocalOnly, } } @@ -319,7 +316,6 @@ mod tests { CliCommand::WorkerCleanup, CliCommand::Plugin, CliCommand::Mcp, - CliCommand::MemoryLint, CliCommand::Session, ] { assert_eq!( diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 6cc675518..b7e4e888e 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -1,6 +1,5 @@ mod cli_connection; mod mcp_cli; -mod memory_lint; mod objective_cli; mod plugin_cli; mod session_cli; @@ -23,7 +22,6 @@ use cli_connection::{ resolve_local_cli_connection, }; use client::{BackendAuthTarget, Target, TargetKind, start_device_login, wait_for_device_login}; -use memory_lint::{LintCliOptions, LintStatus}; use serde::Deserialize; use tui::{LaunchMode, LaunchOptions}; use workspace_bootstrap::{ @@ -35,8 +33,6 @@ enum Mode { Help, ResumeHelp, WorkersHelp, - MemoryLintHelp, - MemoryLint(LintCliOptions), Mcp(mcp_cli::McpCliCommand), Plugin(plugin_cli::PluginCliCommand), Objective { @@ -109,10 +105,6 @@ async fn run(mode: Mode) -> ExitCode { print_workers_help(); ExitCode::SUCCESS } - Mode::MemoryLintHelp => { - print_memory_lint_help(); - ExitCode::SUCCESS - } Mode::Login { backend_url, no_wait, @@ -147,14 +139,6 @@ async fn run(mode: Mode) -> ExitCode { 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) { Ok(()) => ExitCode::SUCCESS, Err(e) => { @@ -603,22 +587,6 @@ fn parse_args_slice_with_connection_resolver( resolve_local_cli_connection(connection_resolver, CliCommand::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('-') => { return Err(ParseError(format!("unknown command `{other}`"))); } @@ -1921,7 +1889,6 @@ Host commands: objective Manage Objectives through a Backend target plugin Author/check/pack explicit Plugin packages mcp Inspect configured MCP servers - memory lint Lint local memory files session Inspect/prune Standalone session logs Standalone binaries: @@ -1985,12 +1952,6 @@ fn print_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 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)] mod tests { use super::*; @@ -2621,9 +2582,36 @@ backend = "shared" } #[test] - fn parse_memory_without_lint_is_usage_error() { - let err = parse_args_from(["memory"]).unwrap_err(); - assert_eq!(err.to_string(), "yoi memory requires the `lint` subcommand"); + fn removed_memory_command_is_unknown_without_touching_legacy_trees() { + let temp = tempfile::tempdir().unwrap(); + 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] @@ -3035,27 +3023,6 @@ backend = "shared" 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] fn plugin_cli_rejects_ambient_catalog_commands_and_options() { 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] fn parse_rejects_legacy_resume_flags() { let cases = [ @@ -3287,13 +3230,6 @@ backend = "shared" 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] fn parse_backend_panel_uses_backend_dashboard_only() { match parse_args_from([ diff --git a/crates/yoi/src/memory_lint.rs b/crates/yoi/src/memory_lint.rs deleted file mode 100644 index e7d23f41d..000000000 --- a/crates/yoi/src/memory_lint.rs +++ /dev/null @@ -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, - pub json: bool, - pub warnings_as_errors: bool, -} - -impl LintCliOptions { - fn workspace_root(&self) -> Result { - 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 { - 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 { - let stdout = io::stdout(); - run_with_writer(options, stdout.lock()) -} - -pub fn run_with_writer( - options: &LintCliOptions, - mut writer: W, -) -> Result { - 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, - pub errors: Vec, - pub warnings: Vec, - pub counts: LintCounts, -} - -#[derive(Debug, Clone, Serialize)] -pub struct FileLintReport { - pub path: String, - pub errors: Vec, - pub warnings: Vec, -} - -#[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 { - 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, 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 { - 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) -> 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 { - 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( - 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( - 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![ - ".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()); - } -} diff --git a/docs/design/memory-knowledge.md b/docs/design/memory-knowledge.md index 629c2b99c..fbf2726d4 100644 --- a/docs/design/memory-knowledge.md +++ b/docs/design/memory-knowledge.md @@ -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. -- `decisions/*.md` capture durable decisions and rationale. -- `requests/*.md` capture durable user preferences or standing requests. +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. -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. diff --git a/docs/design/orchestrator-worktree-layout.md b/docs/design/orchestrator-worktree-layout.md index f56de4d8f..ce6f533a4 100644 --- a/docs/design/orchestrator-worktree-layout.md +++ b/docs/design/orchestrator-worktree-layout.md @@ -6,7 +6,7 @@ This decision records the layout policy for Ticket `00001KTTB479X`. ## 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. - `implementation_worktree_root`: `/.worktree`. - `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 -- 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. -- 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. ## Implemented in this branch diff --git a/docs/design/overview.md b/docs/design/overview.md index e51b2f166..cb54d15f9 100644 --- a/docs/design/overview.md +++ b/docs/design/overview.md @@ -6,7 +6,7 @@ That rule shapes the crate split. The runtime can restart, attach, compact, or d ## 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. - `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. @@ -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. - `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. -- `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. - `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. diff --git a/docs/development/work-items.md b/docs/development/work-items.md index 1e8df5b36..e774aac0a 100644 --- a/docs/development/work-items.md +++ b/docs/development/work-items.md @@ -1,19 +1,19 @@ # 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//` 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. ## Concepts - `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//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. - `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. -- `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. 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. - 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 @@ -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. -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. @@ -60,51 +60,11 @@ Do not bypass Ticket lifecycle gates just because Ticket tools are available. Ti ## 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 -.yoi/objectives/ - item.md -``` - -`` 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 ...] -yoi objective list [--state active|paused|done|archived|all] -yoi objective show -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. +Repository-local `.yoi/objectives` trees are ignored. There is no automatic import or cwd/ancestor fallback. ## Ticket configuration @@ -268,40 +228,15 @@ A useful Ticket states: - reviews; - 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. -## 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 -yoi ticket create --title "..." [--priority P2] -yoi ticket list [--state planning|ready|queued|inprogress|done|closed|all] [--limit n] -yoi ticket show -yoi ticket comment [--role comment|plan|decision|implementation_report] [--file path] -yoi ticket review --approve|--request-changes [--file path] -yoi ticket state -yoi ticket close [--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 ` 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/ - 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. +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. ## Validation diff --git a/docs/release-notes/repository-local-yoi-removal.md b/docs/release-notes/repository-local-yoi-removal.md new file mode 100644 index 000000000..672ccd5c6 --- /dev/null +++ b/docs/release-notes/repository-local-yoi-removal.md @@ -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 `/.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. diff --git a/docs/report/2026-09-22-repository-local-yoi-final-authority-audit.md b/docs/report/2026-09-22-repository-local-yoi-final-authority-audit.md new file mode 100644 index 000000000..a606c9397 --- /dev/null +++ b/docs/report/2026-09-22-repository-local-yoi-final-authority-audit.md @@ -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 `/.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` 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 `/.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.