diff --git a/Cargo.lock b/Cargo.lock index 0e9528999..01722f689 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", @@ -6286,8 +6274,10 @@ name = "yoi-e2e" version = "0.0.0" dependencies = [ "libc", + "reqwest", "serde", "serde_json", + "sha2 0.11.0", "tempfile", ] diff --git a/README.md b/README.md index 82b65d891..36e716c4c 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Typical flow: 3. Use explicit tools and scoped delegation for multi-agent work. 4. Record project work through Tickets, workflow files, and git history. -Runtime surfaces use `yoi`, `.yoi`, `~/.yoi`, and `YOI_*`. +Client and execution surfaces use the `yoi` binaries, XDG client/server configuration and data directories, Runtime-owned Workdirs/session artifacts, Server data directories, `YOI_*`, and the preserved legacy home secret-store boundary under `~/.yoi`. Repository-local `.yoi` trees are ignored legacy input, not runtime authority. ## 4. Documentation @@ -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/client/src/workspace_product.rs b/crates/client/src/workspace_product.rs index f69c61927..f87e62dab 100644 --- a/crates/client/src/workspace_product.rs +++ b/crates/client/src/workspace_product.rs @@ -1,6 +1,6 @@ use reqwest::Method; -use serde::Serialize; use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; use server_api::{ BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse, CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse, @@ -22,6 +22,21 @@ use crate::{BackendApiClient, BackendWorkspaceClientError}; const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000; +#[derive(Debug, Deserialize)] +struct BackendObjectiveListResponse { + workspace_id: String, + limit: usize, + items: Vec, + invalid_records: Vec, + record_authority: String, +} + +#[derive(Debug, Deserialize)] +struct BackendInvalidProjectRecord { + label: String, + reason: String, +} + /// Workspace-scoped Backend client for Ticket and Objective product state. /// /// Construction requires both the selected Backend URL and Workspace identity. @@ -177,7 +192,23 @@ impl BackendWorkspaceProductClient { &self, limit: usize, ) -> Result, BackendWorkspaceClientError> { - self.get_json(&format!("/objectives?limit={limit}")) + let response: BackendObjectiveListResponse = + self.get_json(&format!("/objectives?limit={limit}"))?; + Ok(ListResponse { + workspace_id: response.workspace_id, + limit: response.limit, + items: response.items, + source: response.record_authority, + diagnostics: response + .invalid_records + .into_iter() + .map(|invalid| server_api::Diagnostic { + code: "invalid_objective_record".to_string(), + severity: server_api::DiagnosticSeverity::Warning, + message: format!("{}: {}", invalid.label, invalid.reason), + }) + .collect(), + }) } pub fn show_objective(&self, id: &str) -> Result { @@ -794,7 +825,7 @@ mod tests { #[test] fn objective_list_uses_workspace_scoped_backend_route() { - let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"source":"sqlite","diagnostics":[]}"#; + let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"invalid_records":[{"label":"broken.md","reason":"invalid frontmatter"}],"record_authority":"sqlite"}"#; let (base_url, request, handle) = one_response_server("200 OK", body); let client = BackendWorkspaceProductClient::new_with_access_token( base_url, @@ -806,6 +837,13 @@ mod tests { let response = client.list_objectives(1_000).unwrap(); assert!(response.items.is_empty()); + assert_eq!(response.source, "sqlite"); + assert_eq!(response.diagnostics.len(), 1); + assert_eq!(response.diagnostics[0].code, "invalid_objective_record"); + assert_eq!( + response.diagnostics[0].message, + "broken.md: invalid frontmatter" + ); let request = request.recv().unwrap(); assert!(request.starts_with("GET /api/w/workspace-a/objectives?limit=1000 ")); assert!(request.contains("authorization: Bearer test-backend-token\r\n")); 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/tui/src/backend_dashboard.rs b/crates/tui/src/backend_dashboard.rs index 7fcdc36cb..fc8a88ae5 100644 --- a/crates/tui/src/backend_dashboard.rs +++ b/crates/tui/src/backend_dashboard.rs @@ -2,7 +2,7 @@ use std::io; use std::time::Duration; use client::{BackendWorkspaceProductClient, ObjectiveSummary}; -use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; use ratatui::{ Frame, Terminal, backend::CrosstermBackend, @@ -79,8 +79,26 @@ async fn run_loop( app: &mut BackendDashboard, client: BackendWorkspaceProductClient, ) -> Result<(), Box> { + let mut first_frame = true; + let mut initial_snapshot_pending = false; loop { terminal.draw(|frame| draw(frame, app))?; + if first_frame { + first_frame = false; + #[cfg(feature = "e2e-test")] + { + crate::e2e_observer::emit( + "backend_dashboard", + "panel_ready", + serde_json::json!({ "workspace_id": app.workspace_id }), + ); + initial_snapshot_pending = true; + } + } else if initial_snapshot_pending { + #[cfg(feature = "e2e-test")] + emit_e2e_snapshot(app, "backend_dashboard_content_ready"); + initial_snapshot_pending = false; + } if !event::poll(Duration::from_millis(100))? { continue; } @@ -91,21 +109,50 @@ async fn run_loop( continue; } match key.code { - KeyCode::Char('q') | KeyCode::Esc => return Ok(()), + KeyCode::Char('q') | KeyCode::Esc => { + #[cfg(feature = "e2e-test")] + crate::e2e_observer::emit( + "backend_dashboard", + "quit_requested", + serde_json::json!({}), + ); + return Ok(()); + } + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + #[cfg(feature = "e2e-test")] + crate::e2e_observer::emit( + "backend_dashboard", + "quit_requested", + serde_json::json!({}), + ); + return Ok(()); + } KeyCode::Tab | KeyCode::BackTab => { app.focus = match app.focus { Focus::Tickets => Focus::Objectives, Focus::Objectives => Focus::Tickets, }; + #[cfg(feature = "e2e-test")] + emit_e2e_snapshot(app, "backend_dashboard_selection_changed"); + } + KeyCode::Down | KeyCode::Char('j') => { + app.select_next(); + #[cfg(feature = "e2e-test")] + emit_e2e_snapshot(app, "backend_dashboard_selection_changed"); + } + KeyCode::Up | KeyCode::Char('k') => { + app.select_previous(); + #[cfg(feature = "e2e-test")] + emit_e2e_snapshot(app, "backend_dashboard_selection_changed"); } - KeyCode::Down | KeyCode::Char('j') => app.select_next(), - KeyCode::Up | KeyCode::Char('k') => app.select_previous(), KeyCode::Char('r') => match load(&client).await { Ok((tickets, objectives)) => { app.tickets = tickets; app.objectives = objectives; app.clamp_selection(); app.status = "Reloaded from Backend authority".to_string(); + #[cfg(feature = "e2e-test")] + emit_e2e_snapshot(app, "backend_dashboard_reloaded"); } Err(error) => app.status = format!("Backend reload failed: {error}"), }, @@ -144,6 +191,51 @@ async fn run_loop( } } +#[cfg(feature = "e2e-test")] +fn emit_e2e_snapshot(app: &BackendDashboard, event: &'static str) { + let focus = match app.focus { + Focus::Tickets => "tickets", + Focus::Objectives => "objectives", + }; + let tickets = app + .tickets + .iter() + .map(|ticket| { + serde_json::json!({ + "id": ticket.id, + "resource_key": ticket.resource_key, + "title": ticket.title, + "workflow_state": ticket.workflow_state.as_str(), + }) + }) + .collect::>(); + let objectives = app + .objectives + .iter() + .map(|objective| { + serde_json::json!({ + "id": objective.id, + "resource_key": objective.resource_key, + "title": objective.title, + "state": objective.state, + }) + }) + .collect::>(); + crate::e2e_observer::emit( + "backend_dashboard", + event, + serde_json::json!({ + "workspace_id": app.workspace_id, + "focus": focus, + "selected_ticket_id": app.selected_ticket_id(), + "selected_objective_id": app.objectives.get(app.selected_objective).map(|objective| &objective.id), + "tickets": tickets, + "objectives": objectives, + "status": app.status, + }), + ); +} + impl BackendDashboard { fn select_next(&mut self) { match self.focus { diff --git a/crates/tui/src/console/mod.rs b/crates/tui/src/console/mod.rs index fa5a2b2fa..93ca9251a 100644 --- a/crates/tui/src/console/mod.rs +++ b/crates/tui/src/console/mod.rs @@ -623,6 +623,16 @@ fn read_terminal_events(stop: Arc, tx: mpsc::UnboundedSender Result<(), Box> { + let mut terminal = enter_fullscreen()?; + let result = run_e2e_rewind_fixture(&mut terminal, worker_name).await; + let leave_result = leave_fullscreen(&mut terminal); + result?; + leave_result?; + Ok(()) +} + #[cfg(feature = "e2e-test")] async fn run_e2e_rewind_fixture( terminal: &mut ConsoleTerminal, @@ -637,6 +647,8 @@ async fn run_e2e_rewind_fixture( entries: Vec::new(), }, state: WorkerStatus::Idle.into(), + in_flight: protocol::InFlightSnapshot::default(), + internal_workers: Vec::new(), greeting: Greeting { worker_name: worker_name.clone(), cwd: workspace_root.display().to_string(), diff --git a/crates/tui/src/e2e_observer.rs b/crates/tui/src/e2e_observer.rs index 77eceb609..91a81a8e2 100644 --- a/crates/tui/src/e2e_observer.rs +++ b/crates/tui/src/e2e_observer.rs @@ -2,12 +2,11 @@ use std::fs::{File, OpenOptions}; use std::io::Write; use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::{SystemTime, UNIX_EPOCH}; use serde::Serialize; const EVENT_PATH_ENV: &str = "YOI_TUI_TEST_EVENTS"; -const HOLD_BACKGROUND_TASK_ENV: &str = "YOI_TUI_TEST_HOLD_BACKGROUND_TASK"; static EVENT_WRITER: OnceLock>> = OnceLock::new(); @@ -44,25 +43,6 @@ where } } -pub(crate) async fn hold_background_task_if_requested(task: &'static str) { - let requested = std::env::var(HOLD_BACKGROUND_TASK_ENV).unwrap_or_default(); - if !requested - .split(',') - .map(str::trim) - .any(|requested| requested == task) - { - return; - } - emit( - "panel", - "background_task_hold_started", - serde_json::json!({ "task": task }), - ); - loop { - tokio::time::sleep(Duration::from_millis(25)).await; - } -} - fn open_event_writer() -> Option> { let path = std::env::var_os(EVENT_PATH_ENV).map(PathBuf::from)?; if let Some(parent) = path.parent() { diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 437e554b2..c9c48bd8e 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -137,7 +137,51 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { } let mut terminal_mode = TerminalModeGuard::new(); - let result = match mode { + #[cfg(feature = "e2e-test")] + let result = if std::env::var_os("YOI_TUI_TEST_REWIND_FIXTURE").is_some() { + let worker_name = match &mode { + LaunchMode::Spawn { worker_name, .. } => worker_name + .clone() + .unwrap_or_else(|| "e2e-rewind".to_string()), + _ => "e2e-rewind".to_string(), + }; + console::run_e2e_rewind(worker_name).await + } else { + launch_mode(target.as_ref(), mode, &workspace_root).await + }; + #[cfg(not(feature = "e2e-test"))] + let result = launch_mode(target.as_ref(), mode, &workspace_root).await; + + // Always restore the terminal first so any pending eprintln below + // shows up cleanly in scrollback rather than inside an active + // alternate-screen buffer. + #[cfg(feature = "e2e-test")] + e2e_observer::emit("tui", "terminal_cleanup_started", serde_json::json!({})); + let _ = terminal_mode.restore(); + #[cfg(feature = "e2e-test")] + e2e_observer::emit("tui", "terminal_cleanup_finished", serde_json::json!({})); + + match result { + Ok(()) => { + #[cfg(feature = "e2e-test")] + e2e_observer::emit("tui", "exit", serde_json::json!({ "status": "success" })); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("yoi: {e}"); + #[cfg(feature = "e2e-test")] + e2e_observer::emit("tui", "exit", serde_json::json!({ "status": "failure" })); + ExitCode::FAILURE + } + } +} + +async fn launch_mode( + target: &dyn Target, + mode: LaunchMode, + workspace_root: &std::path::Path, +) -> Result<(), Box> { + match mode { LaunchMode::Spawn { worker_name, profile, @@ -145,7 +189,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { Ok(Some(selection)) => match target.spawn_worker() { Ok(spawn) => { console::run_standalone( - workspace_root.clone(), + workspace_root.to_path_buf(), spawn.state_dir, Some(selection.worker_name), Some(selection.profile), @@ -158,7 +202,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { Err(error) => Err(Box::new(error) as Box), }, LaunchMode::StandaloneResume { include_all } => { - match standalone_picker::pick(target.as_ref(), include_all) { + match standalone_picker::pick(target, include_all) { Ok(Some(intent)) => console::run_standalone_restore(intent).await, Ok(None) => Ok(()), Err(error) => Err(Box::new(error) as Box), @@ -195,28 +239,5 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { } Err(e) => Err(Box::new(e) as Box), }, - }; - - // Always restore the terminal first so any pending eprintln below - // shows up cleanly in scrollback rather than inside an active - // alternate-screen buffer. - #[cfg(feature = "e2e-test")] - e2e_observer::emit("tui", "terminal_cleanup_started", serde_json::json!({})); - let _ = terminal_mode.restore(); - #[cfg(feature = "e2e-test")] - e2e_observer::emit("tui", "terminal_cleanup_finished", serde_json::json!({})); - - match result { - Ok(()) => { - #[cfg(feature = "e2e-test")] - e2e_observer::emit("tui", "exit", serde_json::json!({ "status": "success" })); - ExitCode::SUCCESS - } - Err(e) => { - eprintln!("yoi: {e}"); - #[cfg(feature = "e2e-test")] - e2e_observer::emit("tui", "exit", serde_json::json!({ "status": "failure" })); - ExitCode::FAILURE - } } } diff --git a/crates/worker-runtime/Cargo.toml b/crates/worker-runtime/Cargo.toml index e07dde7ef..3f4a327c3 100644 --- a/crates/worker-runtime/Cargo.toml +++ b/crates/worker-runtime/Cargo.toml @@ -41,7 +41,7 @@ reqwest = { version = "0.13", optional = true, default-features = false, feature ring.workspace = true tar.workspace = true thiserror = { workspace = true } -tokio = { workspace = true, features = ["net", "process", "rt", "sync", "time"] } +tokio = { workspace = true, features = ["net", "process", "rt", "signal", "sync", "time"] } tracing.workspace = true tracing-subscriber.workspace = true toml.workspace = true diff --git a/crates/worker-runtime/src/fs_store.rs b/crates/worker-runtime/src/fs_store.rs index ffb129bfa..b97101201 100644 --- a/crates/worker-runtime/src/fs_store.rs +++ b/crates/worker-runtime/src/fs_store.rs @@ -123,8 +123,8 @@ impl FsRuntimeStore { })?; let owner_lock = acquire_runtime_store_owner_lock(&root)?; recover_runtime_store_migration(&root)?; - let existed = root.exists(); - if existed && !root.is_dir() { + let root_existed = root.exists(); + if root_existed && !root.is_dir() { return Err(RuntimeError::StoreCorrupt { operation: "open runtime store", path: root, @@ -132,17 +132,22 @@ impl FsRuntimeStore { }); } - if !existed { + if !root_existed { fs::create_dir_all(&root).map_err(|source| RuntimeError::StoreIo { operation: "create runtime store root", path: root.clone(), source, })?; } + let snapshot_exists = root.join(RUNTIME_FILE).is_file(); + if root_existed && !snapshot_exists { + validate_fresh_runtime_store_layout(&root)?; + } - fs::create_dir_all(root.join(WORKERS_DIR)).map_err(|source| RuntimeError::StoreIo { + let workers_dir = root.join(WORKERS_DIR); + fs::create_dir_all(&workers_dir).map_err(|source| RuntimeError::StoreIo { operation: "create runtime store", - path: root.join(WORKERS_DIR), + path: workers_dir, source, })?; let legacy_events = root.join("events.jsonl"); @@ -158,14 +163,14 @@ impl FsRuntimeStore { } } - if existed { + if snapshot_exists { migrate_runtime_store(&root, runtime_id)?; } let store = Self { root, _owner_lock: Some(owner_lock), }; - let state = if existed { + let state = if snapshot_exists { Some(store.load_runtime_state()?) } else { None @@ -343,6 +348,38 @@ impl FsRuntimeStore { } } +fn validate_fresh_runtime_store_layout(root: &Path) -> Result<(), RuntimeError> { + let entries = fs::read_dir(root).map_err(|source| RuntimeError::StoreIo { + operation: "inspect uninitialized runtime store", + path: root.to_path_buf(), + source, + })?; + for entry in entries { + let entry = entry.map_err(|source| RuntimeError::StoreIo { + operation: "inspect uninitialized runtime store", + path: root.to_path_buf(), + source, + })?; + let file_type = entry.file_type().map_err(|source| RuntimeError::StoreIo { + operation: "inspect uninitialized runtime store entry", + path: entry.path(), + source, + })?; + if entry.file_name() == "auth.toml" && file_type.is_file() { + continue; + } + return Err(RuntimeError::StoreCorrupt { + operation: "open runtime store", + path: root.to_path_buf(), + message: format!( + "unexpected persistence `{}` exists without a Runtime snapshot", + entry.file_name().to_string_lossy() + ), + }); + } + Ok(()) +} + fn acquire_runtime_store_owner_lock( root: &Path, ) -> Result, RuntimeError> { @@ -2137,6 +2174,72 @@ mod tests { document["schema_version"].as_u64().unwrap() } + #[test] + fn auth_initialized_directory_without_snapshot_is_a_fresh_store() { + let parent = tempfile::tempdir().unwrap(); + let root = parent.path().join("runtime"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("auth.toml"), b"fixture auth material").unwrap(); + + let opened = FsRuntimeStore::open_or_create(root.clone(), "runtime-test").unwrap(); + + assert!(opened.state.is_none()); + assert!(root.join(WORKERS_DIR).is_dir()); + assert_eq!( + fs::read(root.join("auth.toml")).unwrap(), + b"fixture auth material" + ); + } + + #[test] + fn snapshotless_directory_rejects_non_auth_persistence() { + for unexpected in ["events.jsonl", "unknown.json"] { + let parent = tempfile::tempdir().unwrap(); + let root = parent.path().join("runtime"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("auth.toml"), b"fixture auth material").unwrap(); + fs::write(root.join(unexpected), b"must remain").unwrap(); + + let error = FsRuntimeStore::open_or_create(root.clone(), "runtime-test").unwrap_err(); + + assert!(matches!( + error, + RuntimeError::StoreCorrupt { path, .. } if path == root + )); + assert_eq!(fs::read(root.join(unexpected)).unwrap(), b"must remain"); + assert!(!root.join(WORKERS_DIR).exists()); + } + } + + #[test] + fn orphan_archive_without_runtime_snapshot_is_rejected() { + let parent = tempfile::tempdir().unwrap(); + let root = parent.path().join("runtime"); + fs::create_dir_all(root.join(ORPHANED_WORKERS_DIR).join("worker-a")).unwrap(); + + let error = FsRuntimeStore::open_or_create(root.clone(), "runtime-test").unwrap_err(); + + assert!(matches!( + error, + RuntimeError::StoreCorrupt { path, .. } if path == root + )); + assert!(root.join(ORPHANED_WORKERS_DIR).join("worker-a").is_dir()); + } + + #[test] + fn worker_records_without_runtime_snapshot_are_rejected() { + let parent = tempfile::tempdir().unwrap(); + let root = parent.path().join("runtime"); + fs::create_dir_all(root.join(WORKERS_DIR).join("orphan")).unwrap(); + + let error = FsRuntimeStore::open_or_create(root.clone(), "runtime-test").unwrap_err(); + + assert!(matches!( + error, + RuntimeError::StoreCorrupt { path, .. } if path == root + )); + } + #[test] fn runtime_store_owner_lock_child_probe() { let Some(root) = std::env::var_os("YOI_TEST_RUNTIME_STORE_LOCK_ROOT") else { diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index 5462a09f5..1ffdae919 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -139,6 +139,22 @@ pub enum RuntimeHttpStoreSelection { }, } +pub async fn serve_runtime_http_with_shutdown( + runtime: Runtime, + listener: TcpListener, + local_token: Option, + shutdown: F, +) -> Result<(), RuntimeHttpServerError> +where + F: std::future::Future + Send + 'static, +{ + let local_token = local_token.ok_or(RuntimeHttpServerError::AuthRequired)?; + axum::serve(listener, runtime_http_router(runtime, local_token)) + .with_graceful_shutdown(shutdown) + .await?; + Ok(()) +} + /// Serve an existing Runtime on a pre-bound listener. pub async fn serve_runtime_http( runtime: Runtime, @@ -150,6 +166,25 @@ pub async fn serve_runtime_http( Ok(()) } +pub async fn serve_runtime_http_with_workspace_auth_shutdown( + runtime: Runtime, + listener: TcpListener, + local_token: Option, + workspace_auth: WorkspaceRuntimeHttpAuth, + shutdown: F, +) -> Result<(), RuntimeHttpServerError> +where + F: std::future::Future + Send + 'static, +{ + axum::serve( + listener, + runtime_http_router_with_optional_auth(runtime, local_token, Some(workspace_auth)), + ) + .with_graceful_shutdown(shutdown) + .await?; + Ok(()) +} + pub async fn serve_runtime_http_with_workspace_auth( runtime: Runtime, listener: TcpListener, diff --git a/crates/worker-runtime/src/main.rs b/crates/worker-runtime/src/main.rs index 835efde8b..c09b0f149 100644 --- a/crates/worker-runtime/src/main.rs +++ b/crates/worker-runtime/src/main.rs @@ -75,6 +75,23 @@ fn init_serve_tracing() { .try_init(); } +async fn shutdown_signal() { + #[cfg(unix)] + { + let mut terminate = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("install SIGTERM handler"); + tokio::select! { + _ = tokio::signal::ctrl_c() => {}, + _ = terminate.recv() => {}, + } + } + #[cfg(not(unix))] + { + let _ = tokio::signal::ctrl_c().await; + } +} + fn run() -> Result<(), ProcessError> { let args = env::args().skip(1).collect::>(); if matches!(args.first().map(String::as_str), Some("migrate")) { @@ -104,11 +121,12 @@ fn run() -> Result<(), ProcessError> { "yoi-runtime listening on {local_addr}; intended client is a trusted backend/proxy, not a browser" ); let server = if let Some(workspace_auth) = workspace_http_auth { - worker_runtime::http_server::serve_runtime_http_with_workspace_auth( + worker_runtime::http_server::serve_runtime_http_with_workspace_auth_shutdown( worker_runtime, listener, config.http.local_token, workspace_auth, + shutdown_signal(), ) .await } else { @@ -117,10 +135,11 @@ fn run() -> Result<(), ProcessError> { "Runtime HTTP server requires Workspace issuer auth or --local-token".to_owned(), ) })?; - worker_runtime::http_server::serve_runtime_http( + worker_runtime::http_server::serve_runtime_http_with_shutdown( worker_runtime, listener, Some(local_token), + shutdown_signal(), ) .await }; diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index 157cb6a78..de9cf6e53 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(); @@ -4630,6 +4635,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(); @@ -4668,6 +4677,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 ebd3c17b7..a48c5f59a 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/worker/src/fs_view.rs b/crates/worker/src/fs_view.rs index cca540b0b..e9230c00b 100644 --- a/crates/worker/src/fs_view.rs +++ b/crates/worker/src/fs_view.rs @@ -467,7 +467,7 @@ mod tests { #[cfg(unix)] #[tokio::test] - async fn resolve_file_ref_directory_listing_marks_readable_symlink_entries() { + async fn resolve_file_ref_directory_listing_resolves_symlinks_under_default_scope() { use std::os::unix::fs::symlink; let dir = TempDir::new().unwrap(); @@ -478,7 +478,8 @@ mod tests { let item = view.resolve_file_ref("docs", 4096).await.unwrap(); let text = system_text(&item); - assert!(text.contains("link.txt@")); + assert!(text.contains("docs/link.txt"), "{text}"); + assert!(!text.contains("link.txt@"), "{text}"); } #[tokio::test] diff --git a/crates/workspace-server/Cargo.toml b/crates/workspace-server/Cargo.toml index 39b41e9c4..5b7330acc 100644 --- a/crates/workspace-server/Cargo.toml +++ b/crates/workspace-server/Cargo.toml @@ -38,7 +38,7 @@ thiserror.workspace = true ticket.workspace = true memory.workspace = true merge-request.workspace = true -tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync", "time"] } +tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] } tower.workspace = true tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] } worker.workspace = true 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/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index a1ebc50e5..efab8ecbb 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -48,6 +48,7 @@ pub use repositories::{ConfiguredRepository, RepositoryLogRead, RepositoryRegist pub use server::{ AuthConfig, ServerConfig, WorkspaceApi, WorkspaceServerApi, build_router, build_workspace_server_router, serve, serve_workspace_catalog, + serve_workspace_catalog_with_shutdown, }; pub use server_api::{ GitCommitSummary, GitRemoteSummary, GitRepositorySummary, RepositorySummary, diff --git a/crates/workspace-server/src/main.rs b/crates/workspace-server/src/main.rs index 08a3d9c36..b712941e8 100644 --- a/crates/workspace-server/src/main.rs +++ b/crates/workspace-server/src/main.rs @@ -9,18 +9,19 @@ use yoi_workspace_server::hosts::{ EMBEDDED_RUNTIME_ID, RemoteRuntimeConfig, is_loopback_runtime_origin, }; use yoi_workspace_server::store::{ - SqliteWorkspaceStore, WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding, - WorkspaceRuntimeBindingState, + AccountRecord, ApiTokenRecord, SqliteWorkspaceStore, UserRecord, + WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding, WorkspaceRuntimeBindingState, }; use yoi_workspace_server::{ ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, ServerHostConfigFile, - WorkspaceRecord, serve_workspace_catalog, + WorkspaceRecord, serve_workspace_catalog_with_shutdown, }; #[derive(Debug)] enum Command { Serve(ServeOptions), Migrate(MigrateOptions), + BootstrapAuth(BootstrapAuthOptions), Skills(SkillsCommand), Help, } @@ -38,6 +39,13 @@ struct MigrateOptions { help: bool, } +#[derive(Debug)] +struct BootstrapAuthOptions { + handle: String, + display_name: Option, + label: Option, +} + #[derive(Debug)] struct SkillWorkspaceOptions { workspace_id: String, @@ -77,6 +85,7 @@ async fn run() -> Result<(), Box> { match parse_command(&args)? { Command::Serve(options) => run_serve(options).await, Command::Migrate(options) => run_migrate(options), + Command::BootstrapAuth(options) => run_bootstrap_auth(options), Command::Skills(command) => run_skills(command), Command::Help => Ok(()), } @@ -90,6 +99,7 @@ fn parse_command(args: &[String]) -> Result { match command.as_str() { "migrate" => parse_migrate_options(rest).map(Command::Migrate), + "bootstrap-auth" => parse_bootstrap_auth_options(rest).map(Command::BootstrapAuth), "skills" => parse_skills_command(rest), "serve" => { if rest.iter().any(|arg| arg == "--help" || arg == "-h") { @@ -103,7 +113,7 @@ fn parse_command(args: &[String]) -> Result { Ok(Command::Help) } other => Err(CliError(format!( - "unknown command `{other}`; expected `migrate`, `skills`, or `serve`" + "unknown command `{other}`; expected `bootstrap-auth`, `migrate`, `skills`, or `serve`" ))), } } @@ -214,6 +224,131 @@ fn run_migrate(options: MigrateOptions) -> Result<(), Box Ok(()) } +fn run_bootstrap_auth(options: BootstrapAuthOptions) -> Result<(), Box> { + let database_path = ServerConfig::default_server_database_path(); + if let Some(parent) = database_path.parent() { + std::fs::create_dir_all(parent)?; + } + let store = SqliteWorkspaceStore::open(&database_path)?; + if !store.list_workspaces()?.is_empty() { + return Err(Box::new(CliError( + "bootstrap-auth is available only before the first Workspace is created".to_string(), + ))); + } + + let handle = yoi_workspace_server::auth::normalize_handle(&options.handle)?; + let display_name = options + .display_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(&handle) + .to_string(); + let now = yoi_workspace_server::auth::now_rfc3339(); + let user = match store.any_user()? { + Some(user) if user.handle == handle => user, + Some(_) => { + return Err(Box::new(CliError( + "bootstrap-auth refuses to replace an existing bootstrap user".to_string(), + ))); + } + None => { + let account_id = yoi_workspace_server::auth::new_id("acct-user"); + store.upsert_account(&AccountRecord { + account_id: account_id.clone(), + kind: "user".to_string(), + handle: handle.clone(), + display_name: display_name.clone(), + created_at: now.clone(), + updated_at: now.clone(), + })?; + let user = UserRecord { + user_id: yoi_workspace_server::auth::new_id("user"), + account_id, + handle, + display_name, + created_at: now.clone(), + updated_at: now.clone(), + }; + store.upsert_user(&user)?; + user + } + }; + + let access_token = yoi_workspace_server::auth::mint_secret("yoi_api"); + store.create_api_token(&ApiTokenRecord { + token_id: yoi_workspace_server::auth::new_id("api-token"), + token_hash: yoi_workspace_server::auth::token_hash(&access_token), + user_id: user.user_id.clone(), + label: options + .label + .unwrap_or_else(|| "initial host setup".to_string()), + created_at: now, + expires_at: None, + revoked_at: None, + last_used_at: None, + })?; + println!( + "{}", + serde_json::to_string(&serde_json::json!({ + "access_token": access_token, + "token_type": "bearer", + "user": { + "user_id": user.user_id, + "account_id": user.account_id, + "handle": user.handle, + "display_name": user.display_name, + } + }))? + ); + Ok(()) +} + +fn parse_bootstrap_auth_options(args: &[String]) -> Result { + let mut handle = None; + let mut display_name = None; + let mut label = None; + let mut index = 0; + while index < args.len() { + let arg = &args[index]; + let (field, target) = match arg.as_str() { + "--handle" => ("--handle", &mut handle), + "--display-name" => ("--display-name", &mut display_name), + "--label" => ("--label", &mut label), + _ if arg.starts_with("--handle=") => { + handle = Some(value_after_equals(arg, "--handle")?.to_string()); + index += 1; + continue; + } + _ if arg.starts_with("--display-name=") => { + display_name = Some(value_after_equals(arg, "--display-name")?.to_string()); + index += 1; + continue; + } + _ if arg.starts_with("--label=") => { + label = Some(value_after_equals(arg, "--label")?.to_string()); + index += 1; + continue; + } + other => return Err(CliError(format!("unknown bootstrap-auth option `{other}`"))), + }; + index += 1; + let value = args + .get(index) + .ok_or_else(|| CliError(format!("{field} requires a value")))?; + if target.replace(value.clone()).is_some() { + return Err(CliError(format!("{field} must be provided only once"))); + } + index += 1; + } + let handle = handle.ok_or_else(|| CliError("bootstrap-auth requires --handle".to_string()))?; + Ok(BootstrapAuthOptions { + handle, + display_name, + label, + }) +} + fn init_serve_tracing() { let filter = tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); @@ -226,6 +361,23 @@ fn init_serve_tracing() { .try_init(); } +async fn shutdown_signal() { + #[cfg(unix)] + { + let mut terminate = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("install SIGTERM handler"); + tokio::select! { + _ = tokio::signal::ctrl_c() => {}, + _ = terminate.recv() => {}, + } + } + #[cfg(not(unix))] + { + let _ = tokio::signal::ctrl_c().await; + } +} + async fn run_serve(options: ServeOptions) -> Result<(), Box> { init_serve_tracing(); let database_path = ServerConfig::default_server_database_path(); @@ -284,7 +436,8 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box Result { fn print_help() { println!( - "yoi-server\n\nUsage:\n yoi-server migrate [--dry-run] [--database ]\n yoi-server skills [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help" + "yoi-server\n\nUsage:\n yoi-server bootstrap-auth --handle [--display-name ] [--label