use std::path::PathBuf; use chrono::Utc; use project_record::{allocate_record_id, unix_epoch_millis_now}; use ticket::{ SqliteTicketBackend, TicketBackend, TicketIdOrSlug, TicketListQuery, TicketWorkspaceActionPriority, project_ticket_workspace_item, }; use crate::records::{ ObjectiveDetail, ObjectiveResourceSummary, ObjectiveSummary, ProjectRecordList, TicketDetail, TicketEventDetail, TicketSummary, summarize_body, truncate_body, validate_project_id, }; use crate::store::{ ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord, ObjectiveEventRecord, ObjectiveRecord, ObjectiveTicketLinkRecord, SqliteWorkspaceStore, }; use crate::{Error, Result}; const DETAIL_BODY_LIMIT: usize = 64 * 1024; const TICKET_EVENT_LIMIT: usize = 100; const TICKET_EVENT_BODY_LIMIT: usize = 16 * 1024; const DEFAULT_MEMORY_DOCUMENT_BODY: &str = "# Memory\n\n"; const RECORD_SOURCE_WORKSPACE_SQLITE: &str = "workspace-sqlite"; /// Workspace-scoped runtime authority for project resources. /// /// Normal Backend API handlers should depend on this authority abstraction, not /// on legacy filesystem layouts. Filesystem readers belong in explicitly /// temporary migration/repair tools and tests, not normal runtime authority paths. pub trait WorkspaceAuthority: ObjectiveAuthority + TicketAuthority + MemoryAuthority {} impl WorkspaceAuthority for T where T: ObjectiveAuthority + TicketAuthority + MemoryAuthority {} pub trait TicketAuthority { fn list_tickets(&self, limit: usize) -> Result>; fn ticket(&self, id: &str) -> Result; } pub trait ObjectiveAuthority { fn list_objectives(&self, limit: usize) -> Result>; fn objective(&self, id: &str) -> Result; fn create_objective(&self, input: ObjectiveCreateInput) -> Result; fn edit_objective(&self, id: &str, input: ObjectiveEditInput) -> Result; fn set_objective_state(&self, id: &str, state: &str) -> Result; fn link_objective_ticket(&self, id: &str, ticket_id: &str) -> Result; fn unlink_objective_ticket(&self, id: &str, ticket_id: &str) -> Result; } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ObjectiveCreateInput { pub title: String, pub body_md: String, pub state: String, pub linked_tickets: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct ObjectiveEditInput { pub title: Option, pub old_string: Option, pub new_string: Option, pub replace_all: bool, } pub trait MemoryAuthority { fn ensure_memory_document(&self) -> Result; fn memory_document(&self) -> Result; fn update_memory_document(&self, body_md: &str) -> Result; fn list_memory_staging_records(&self, limit: usize) -> Result>; fn memory_staging_record(&self, candidate_id: &str) -> Result; fn upsert_memory_staging_record( &self, candidate_id: &str, raw_json: &str, source_path: Option<&str>, ) -> Result; fn close_memory_staging_record( &self, candidate_id: &str, action: &str, reason: &str, affected_refs_json: &str, ) -> Result; fn list_memory_staging_resolutions(&self, limit: usize) -> Result>; } #[derive(Debug, Clone, PartialEq, Eq)] pub struct MemoryDocument { pub body_md: String, pub created_at: String, pub updated_at: String, pub record_source: String, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct MemoryStagingEntry { pub candidate_id: String, pub raw_json: String, pub source_path: Option, pub imported_at: String, pub record_source: String, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct MemoryStagingResolution { pub candidate_id: String, pub action: String, pub reason: String, pub affected_refs_json: String, pub staging_raw_json: String, pub source_path: Option, pub imported_at: String, pub resolved_at: String, pub record_source: String, } #[derive(Clone)] pub struct SqliteWorkspaceAuthority { workspace_id: String, store: SqliteWorkspaceStore, ticket_backend: SqliteTicketBackend, } impl SqliteWorkspaceAuthority { pub fn new(database_path: impl Into, workspace_id: impl Into) -> Result { let database_path = database_path.into(); let workspace_id = workspace_id.into(); Ok(Self { workspace_id: workspace_id.clone(), store: SqliteWorkspaceStore::open(&database_path)?, ticket_backend: SqliteTicketBackend::open_verified(database_path, workspace_id)?, }) } fn objective_record(&self, id: &str) -> Result { self.store .get_objective(&self.workspace_id, id)? .ok_or_else(|| unknown_objective_error(id)) } fn objective_detail_from_record(&self, record: ObjectiveRecord) -> Result { let linked_tickets = self .store .list_objective_ticket_links(&self.workspace_id, &record.objective_id)? .into_iter() .map(|link| link.ticket_id) .collect::>(); let resources = self .store .list_objective_resources(&self.workspace_id, &record.objective_id)? .into_iter() .map(|resource| ObjectiveResourceSummary { path: resource.resource_path, media_type: resource.media_type, bytes: resource.body.len(), updated_at: resource.updated_at, }) .collect(); let (body, body_truncated) = truncate_body(&record.body_md, DETAIL_BODY_LIMIT); Ok(ObjectiveDetail { id: record.objective_id, title: record.title, state: record.state, created_at: Some(record.created_at), updated_at: Some(record.updated_at), linked_tickets, resources, body, body_truncated, record_source: RECORD_SOURCE_WORKSPACE_SQLITE.to_string(), }) } fn insert_objective_event( &self, objective_id: &str, kind: &str, body_md: Option<&str>, ) -> Result<()> { let event_id = allocate_record_id( unix_epoch_millis_now().map_err(|err| { invalid_objective_error(format!("failed to read objective event clock: {err}")) })?, |candidate| { self.store .list_objective_events(&self.workspace_id, objective_id) .map(|events| events.iter().any(|event| event.event_id == candidate)) .unwrap_or(true) }, ) .map_err(|err| { invalid_objective_error(format!("failed to allocate objective event id: {err}")) })?; self.store.insert_objective_event(&ObjectiveEventRecord { workspace_id: self.workspace_id.clone(), objective_id: objective_id.to_string(), event_id, kind: kind.to_string(), body_md: body_md.map(str::to_string), created_at: now_rfc3339(), }) } } impl TicketAuthority for SqliteWorkspaceAuthority { fn list_tickets(&self, limit: usize) -> Result> { let mut items = Vec::new(); for item in self.ticket_backend.list(TicketListQuery::all())? { let ticket = self .ticket_backend .show(TicketIdOrSlug::Id(item.id.clone()))?; let projection = project_ticket_workspace_item(&item, &ticket.relations.blockers, None); items.push(TicketSummary { id: item.id, title: item.title, state: item.workflow_state.as_str().to_string(), priority: item.priority, updated_at: item.updated_at, queued_by: item.queued_by, queued_at: item.queued_at, workspace_action_priority: workspace_action_priority_name(projection.priority) .to_string(), record_source: "sqlite_yoi_ticket".to_string(), }); } items.sort_by(|a, b| { b.updated_at .cmp(&a.updated_at) .then_with(|| a.id.cmp(&b.id)) }); items.truncate(limit); Ok(ProjectRecordList { items, invalid_records: Vec::new(), record_authority: "workspace-sqlite".to_string(), }) } fn ticket(&self, id: &str) -> Result { validate_project_id(id)?; let ticket = self .ticket_backend .show(TicketIdOrSlug::Id(id.to_string()))?; let (body, body_truncated) = truncate_body(ticket.document.body.as_str(), DETAIL_BODY_LIMIT); let event_start = ticket.events.len().saturating_sub(TICKET_EVENT_LIMIT); let events = ticket.events[event_start..] .iter() .enumerate() .map(|(index, event)| TicketEventDetail { sequence: event_start + index, kind: event.kind.as_str().to_owned(), author: event.author.clone(), at: event.at.clone(), status: event.status.clone(), from: event.from.clone(), to: event.to.clone(), reason: event.reason.clone(), state_field: event.state_field.clone(), heading: event.heading.clone(), body: (!event.body.as_str().is_empty()) .then(|| truncate_body(event.body.as_str(), TICKET_EVENT_BODY_LIMIT).0), }) .collect(); Ok(TicketDetail { id: ticket.meta.id, title: ticket.meta.title, state: ticket.meta.workflow_state.as_str().to_string(), priority: ticket.meta.priority, created_at: ticket.meta.created_at, updated_at: ticket.meta.updated_at, queued_by: ticket.meta.queued_by, queued_at: ticket.meta.queued_at, assignee: ticket.meta.assignee, repository_id: ticket.meta.repository_id, ref_selector: ticket.meta.ref_selector, risk_flags: ticket.meta.risk_flags, body, body_truncated, event_count: ticket.events.len(), events, artifact_count: ticket.artifacts.len(), artifacts: ticket .artifacts .into_iter() .map(|artifact| artifact.relative_path.display().to_string()) .collect(), relations: ticket.relations.into(), resolution: ticket .resolution .map(|resolution| resolution.as_str().to_string()), record_source: "sqlite_yoi_ticket".to_string(), }) } } impl ObjectiveAuthority for SqliteWorkspaceAuthority { fn list_objectives(&self, limit: usize) -> Result> { let mut items = Vec::new(); for record in self.store.list_objectives(&self.workspace_id, limit)? { let linked_tickets = self .store .list_objective_ticket_links(&self.workspace_id, &record.objective_id)? .into_iter() .map(|link| link.ticket_id) .collect::>(); items.push(ObjectiveSummary { id: record.objective_id, title: record.title, state: record.state, updated_at: Some(record.updated_at), summary: summarize_body(&record.body_md), linked_tickets, record_source: RECORD_SOURCE_WORKSPACE_SQLITE.to_string(), }); } Ok(ProjectRecordList { items, invalid_records: Vec::new(), record_authority: RECORD_SOURCE_WORKSPACE_SQLITE.to_string(), }) } fn objective(&self, id: &str) -> Result { validate_project_id(id)?; let record = self.objective_record(id)?; self.objective_detail_from_record(record) } fn create_objective(&self, input: ObjectiveCreateInput) -> Result { validate_objective_title(&input.title)?; validate_objective_state(&input.state)?; for ticket_id in &input.linked_tickets { validate_project_id(ticket_id)?; } let now = now_rfc3339(); let objective_id = allocate_record_id( unix_epoch_millis_now().map_err(|err| { invalid_objective_error(format!("failed to read objective clock: {err}")) })?, |candidate| { self.store .get_objective(&self.workspace_id, candidate) .map(|record| record.is_some()) .unwrap_or(true) }, ) .map_err(|err| { invalid_objective_error(format!("failed to allocate objective id: {err}")) })?; let record = ObjectiveRecord { workspace_id: self.workspace_id.clone(), objective_id: objective_id.clone(), title: input.title.trim().to_string(), state: input.state.trim().to_string(), body_md: input.body_md, created_at: now.clone(), updated_at: now.clone(), }; self.store.upsert_objective(&record)?; let links = input .linked_tickets .into_iter() .map(|ticket_id| ObjectiveTicketLinkRecord { workspace_id: self.workspace_id.clone(), objective_id: objective_id.clone(), ticket_id, kind: "linked".to_string(), created_at: now.clone(), }) .collect::>(); self.store .replace_objective_ticket_links(&self.workspace_id, &objective_id, &links)?; self.insert_objective_event(&objective_id, "create", Some(&record.body_md))?; self.objective(&objective_id) } fn edit_objective(&self, id: &str, input: ObjectiveEditInput) -> Result { validate_project_id(id)?; let mut record = self.objective_record(id)?; let mut changed = false; if let Some(title) = input.title { validate_objective_title(&title)?; let title = title.trim().to_string(); if title != record.title { record.title = title; changed = true; } } match (input.old_string, input.new_string) { (Some(old_string), Some(new_string)) => { if old_string.is_empty() { return Err(invalid_objective_error("old_string must not be empty")); } let matches = record.body_md.matches(&old_string).count(); if matches == 0 { return Err(invalid_objective_error( "old_string was not found in objective body", )); } if matches > 1 && !input.replace_all { return Err(invalid_objective_error(format!( "old_string matched {matches} times; set replace_all = true or provide a unique string" ))); } record.body_md = if input.replace_all { record.body_md.replace(&old_string, &new_string) } else { record.body_md.replacen(&old_string, &new_string, 1) }; changed = true; } (None, None) => {} _ => { return Err(invalid_objective_error( "old_string and new_string must be provided together", )); } } if !changed { return Err(invalid_objective_error( "objective edit must change title or body", )); } record.updated_at = now_rfc3339(); self.store.upsert_objective(&record)?; self.insert_objective_event(id, "edit", None)?; self.objective(id) } fn set_objective_state(&self, id: &str, state: &str) -> Result { validate_project_id(id)?; validate_objective_state(state)?; let mut record = self.objective_record(id)?; record.state = state.trim().to_string(); record.updated_at = now_rfc3339(); self.store.upsert_objective(&record)?; self.insert_objective_event(id, "state", Some(&record.state))?; self.objective(id) } fn link_objective_ticket(&self, id: &str, ticket_id: &str) -> Result { validate_project_id(id)?; validate_project_id(ticket_id)?; let _record = self.objective_record(id)?; let now = now_rfc3339(); let mut links = self .store .list_objective_ticket_links(&self.workspace_id, id)?; if !links.iter().any(|link| link.ticket_id == ticket_id) { links.push(ObjectiveTicketLinkRecord { workspace_id: self.workspace_id.clone(), objective_id: id.to_string(), ticket_id: ticket_id.to_string(), kind: "linked".to_string(), created_at: now, }); self.store .replace_objective_ticket_links(&self.workspace_id, id, &links)?; self.insert_objective_event(id, "link_ticket", Some(ticket_id))?; } self.objective(id) } fn unlink_objective_ticket(&self, id: &str, ticket_id: &str) -> Result { validate_project_id(id)?; validate_project_id(ticket_id)?; let _record = self.objective_record(id)?; let mut links = self .store .list_objective_ticket_links(&self.workspace_id, id)?; let original_len = links.len(); links.retain(|link| link.ticket_id != ticket_id); if links.len() != original_len { self.store .replace_objective_ticket_links(&self.workspace_id, id, &links)?; self.insert_objective_event(id, "unlink_ticket", Some(ticket_id))?; } self.objective(id) } } impl MemoryAuthority for SqliteWorkspaceAuthority { fn ensure_memory_document(&self) -> Result { let now = now_rfc3339(); self.store .ensure_memory_document(&self.workspace_id, DEFAULT_MEMORY_DOCUMENT_BODY, &now) .map(memory_document_from_record) } fn memory_document(&self) -> Result { self.store .get_memory_document(&self.workspace_id)? .map(memory_document_from_record) .ok_or_else(|| Error::Store("workspace memory document is not initialized".to_string())) } fn update_memory_document(&self, body_md: &str) -> Result { let existing = self.ensure_memory_document()?; let updated_at = now_rfc3339(); let record = MemoryDocumentRecord { workspace_id: self.workspace_id.clone(), body_md: body_md.to_string(), created_at: existing.created_at, updated_at, }; self.store.upsert_memory_document(&record)?; Ok(memory_document_from_record(record)) } fn list_memory_staging_records(&self, limit: usize) -> Result> { self.store .list_memory_staging_records(&self.workspace_id, limit) .map(|records| { records .into_iter() .map(memory_staging_from_record) .collect() }) } fn memory_staging_record(&self, candidate_id: &str) -> Result { validate_memory_candidate_id(candidate_id)?; self.store .get_memory_staging_record(&self.workspace_id, candidate_id)? .map(memory_staging_from_record) .ok_or_else(|| { Error::Store(format!("unknown memory staging candidate '{candidate_id}'")) }) } fn upsert_memory_staging_record( &self, candidate_id: &str, raw_json: &str, source_path: Option<&str>, ) -> Result { validate_memory_candidate_id(candidate_id)?; validate_json_object(raw_json, "raw_json")?; let imported_at = now_rfc3339(); let record = MemoryStagingRecord { workspace_id: self.workspace_id.clone(), candidate_id: candidate_id.to_string(), raw_json: raw_json.to_string(), source_path: source_path.map(str::to_string), imported_at, }; self.store.upsert_memory_staging_record(&record)?; Ok(memory_staging_from_record(record)) } fn close_memory_staging_record( &self, candidate_id: &str, action: &str, reason: &str, affected_refs_json: &str, ) -> Result { validate_memory_candidate_id(candidate_id)?; validate_non_empty(action, "action")?; validate_non_empty(reason, "reason")?; validate_json_array(affected_refs_json, "affected_refs_json")?; let staging = self .store .get_memory_staging_record(&self.workspace_id, candidate_id)? .ok_or_else(|| { Error::Store(format!("unknown memory staging candidate '{candidate_id}'")) })?; let record = MemoryStagingResolutionRecord { workspace_id: self.workspace_id.clone(), candidate_id: staging.candidate_id, action: action.trim().to_string(), reason: reason.trim().to_string(), affected_refs_json: affected_refs_json.to_string(), staging_raw_json: staging.raw_json, source_path: staging.source_path, imported_at: staging.imported_at, resolved_at: now_rfc3339(), }; self.store.insert_memory_staging_resolution(&record)?; self.store .delete_memory_staging_record(&self.workspace_id, candidate_id)?; Ok(memory_resolution_from_record(record)) } fn list_memory_staging_resolutions( &self, limit: usize, ) -> Result> { self.store .list_memory_staging_resolutions(&self.workspace_id, limit) .map(|records| { records .into_iter() .map(memory_resolution_from_record) .collect() }) } } fn now_rfc3339() -> String { Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) } fn validate_memory_candidate_id(candidate_id: &str) -> Result<()> { validate_non_empty(candidate_id, "candidate_id") } fn validate_non_empty(value: &str, field: &str) -> Result<()> { if value.trim().is_empty() { Err(Error::Store(format!("memory {field} must not be empty"))) } else { Ok(()) } } fn validate_objective_title(title: &str) -> Result<()> { if title.trim().is_empty() { Err(invalid_objective_error("objective title must not be empty")) } else { Ok(()) } } fn validate_objective_state(state: &str) -> Result<()> { if state.trim().is_empty() { Err(invalid_objective_error("objective state must not be empty")) } else { Ok(()) } } fn invalid_objective_error(message: impl Into) -> Error { Error::RuntimeOperationFailed { runtime_id: "workspace-authority".to_string(), code: "invalid_objective_request".to_string(), message: message.into(), } } fn unknown_objective_error(id: &str) -> Error { Error::RuntimeOperationFailed { runtime_id: "workspace-authority".to_string(), code: "unknown_objective".to_string(), message: format!("unknown objective `{id}`"), } } fn validate_json_object(raw_json: &str, field: &str) -> Result<()> { let value: serde_json::Value = serde_json::from_str(raw_json) .map_err(|err| Error::Store(format!("memory {field} must be valid JSON: {err}")))?; if value.is_object() { Ok(()) } else { Err(Error::Store(format!( "memory {field} must be a JSON object" ))) } } fn validate_json_array(raw_json: &str, field: &str) -> Result<()> { let value: serde_json::Value = serde_json::from_str(raw_json) .map_err(|err| Error::Store(format!("memory {field} must be valid JSON: {err}")))?; if value.is_array() { Ok(()) } else { Err(Error::Store(format!("memory {field} must be a JSON array"))) } } fn memory_document_from_record(record: MemoryDocumentRecord) -> MemoryDocument { MemoryDocument { body_md: record.body_md, created_at: record.created_at, updated_at: record.updated_at, record_source: RECORD_SOURCE_WORKSPACE_SQLITE.to_string(), } } fn memory_staging_from_record(record: MemoryStagingRecord) -> MemoryStagingEntry { MemoryStagingEntry { candidate_id: record.candidate_id, raw_json: record.raw_json, source_path: record.source_path, imported_at: record.imported_at, record_source: RECORD_SOURCE_WORKSPACE_SQLITE.to_string(), } } fn memory_resolution_from_record(record: MemoryStagingResolutionRecord) -> MemoryStagingResolution { MemoryStagingResolution { candidate_id: record.candidate_id, action: record.action, reason: record.reason, affected_refs_json: record.affected_refs_json, staging_raw_json: record.staging_raw_json, source_path: record.source_path, imported_at: record.imported_at, resolved_at: record.resolved_at, record_source: RECORD_SOURCE_WORKSPACE_SQLITE.to_string(), } } fn workspace_action_priority_name(priority: TicketWorkspaceActionPriority) -> &'static str { match priority { TicketWorkspaceActionPriority::ReadyForQueue => "ready_for_queue", TicketWorkspaceActionPriority::ActiveWork => "active_work", TicketWorkspaceActionPriority::Background => "background", } } #[cfg(test)] mod tests { use std::fs; use std::path::Path; use super::*; use crate::store::{ObjectiveRecord, ObjectiveTicketLinkRecord, WorkspaceRecord}; #[tokio::test] async fn sqlite_workspace_authority_reads_sqlite_records_without_filesystem_authority() { let dir = tempfile::tempdir().unwrap(); write_ticket(dir.path(), "00000000001J2", "Read bridge", "ready"); let db_path = dir.path().join("workspace.db"); SqliteTicketBackend::open(&db_path, "workspace-test") .unwrap() .import_from_local_backend(&ticket::LocalTicketBackend::new( dir.path().join(".yoi/tickets"), )) .unwrap(); let store = SqliteWorkspaceStore::open(&db_path).unwrap(); store .upsert_workspace(&WorkspaceRecord { workspace_id: "workspace-test".to_string(), owner_account_id: None, display_name: "Workspace Test".to_string(), state: "active".to_string(), created_at: "2026-01-01T00:00:00Z".to_string(), updated_at: "2026-01-01T00:00:00Z".to_string(), }) .await .unwrap(); store .upsert_objective(&ObjectiveRecord { workspace_id: "workspace-test".to_string(), objective_id: "00000000001J3".to_string(), title: "Control plane".to_string(), state: "active".to_string(), body_md: "Objective body.\n".to_string(), created_at: "2026-01-01T00:00:00Z".to_string(), updated_at: "2026-01-02T00:00:00Z".to_string(), }) .unwrap(); store .replace_objective_ticket_links( "workspace-test", "00000000001J3", &[ObjectiveTicketLinkRecord { workspace_id: "workspace-test".to_string(), objective_id: "00000000001J3".to_string(), ticket_id: "00000000001J2".to_string(), kind: "linked".to_string(), created_at: "2026-01-01T00:00:00Z".to_string(), }], ) .unwrap(); write_objective( dir.path(), "00000000001J4", "Filesystem-only objective", "active", ); let authority = SqliteWorkspaceAuthority::new(&db_path, "workspace-test").unwrap(); let tickets = authority.list_tickets(20).unwrap(); assert_eq!(tickets.record_authority, "workspace-sqlite"); assert_eq!(tickets.items[0].record_source, "sqlite_yoi_ticket"); assert_eq!(tickets.items[0].id, "00000000001J2"); assert_eq!(tickets.items[0].state, "ready"); assert_eq!( tickets.items[0].workspace_action_priority, "ready_for_queue" ); let ticket = authority.ticket("00000000001J2").unwrap(); assert!(ticket.body.contains("Ticket body")); let objectives = authority.list_objectives(20).unwrap(); assert_eq!(objectives.record_authority, "workspace-sqlite"); assert_eq!(objectives.items.len(), 1); assert_eq!(objectives.items[0].id, "00000000001J3"); assert_eq!(objectives.items[0].linked_tickets, vec!["00000000001J2"]); let objective = authority.objective("00000000001J3").unwrap(); assert!(objective.body.contains("Objective body")); let memory = authority.ensure_memory_document().unwrap(); assert_eq!(memory.body_md, DEFAULT_MEMORY_DOCUMENT_BODY); let updated = authority .update_memory_document("# Memory\n\n- Durable fact.\n") .unwrap(); assert!(updated.body_md.contains("Durable fact")); let staging = authority .upsert_memory_staging_record( "candidate-1", r#"{"claim":"Candidate"}"#, Some("memory/_staging/candidate-1.json"), ) .unwrap(); assert_eq!(staging.record_source, "workspace-sqlite"); assert_eq!(authority.list_memory_staging_records(20).unwrap().len(), 1); let resolution = authority .close_memory_staging_record("candidate-1", "apply", "accepted", r#"["memory"]"#) .unwrap(); assert_eq!(resolution.action, "apply"); assert_eq!(resolution.reason, "accepted"); assert!( authority .list_memory_staging_records(20) .unwrap() .is_empty() ); assert_eq!( authority.list_memory_staging_resolutions(20).unwrap().len(), 1 ); } #[tokio::test] async fn objective_mutations_write_sqlite_records_and_audit_events() { let dir = tempfile::tempdir().unwrap(); let db_path = dir.path().join("workspace.db"); let store = SqliteWorkspaceStore::open(&db_path).unwrap(); store .upsert_workspace(&WorkspaceRecord { workspace_id: "workspace-test".to_string(), owner_account_id: None, display_name: "Workspace Test".to_string(), state: "active".to_string(), created_at: "2026-01-01T00:00:00Z".to_string(), updated_at: "2026-01-01T00:00:00Z".to_string(), }) .await .unwrap(); let authority = SqliteWorkspaceAuthority::new(&db_path, "workspace-test").unwrap(); let created = authority .create_objective(ObjectiveCreateInput { title: "Create Objective".to_string(), body_md: "Alpha body".to_string(), state: "active".to_string(), linked_tickets: vec!["00000000001J2".to_string()], }) .unwrap(); assert_eq!(created.title, "Create Objective"); assert_eq!(created.linked_tickets, vec!["00000000001J2"]); let edited = authority .edit_objective( &created.id, ObjectiveEditInput { title: Some("Edited Objective".to_string()), old_string: Some("Alpha".to_string()), new_string: Some("Beta".to_string()), replace_all: false, }, ) .unwrap(); assert_eq!(edited.title, "Edited Objective"); assert_eq!(edited.body, "Beta body"); let state = authority .set_objective_state(&created.id, "paused") .unwrap(); assert_eq!(state.state, "paused"); assert_eq!( authority .link_objective_ticket(&created.id, "00000000001J3") .unwrap() .linked_tickets, vec!["00000000001J2", "00000000001J3"] ); assert_eq!( authority .unlink_objective_ticket(&created.id, "00000000001J2") .unwrap() .linked_tickets, vec!["00000000001J3"] ); let events = store .list_objective_events("workspace-test", &created.id) .unwrap(); assert_eq!( events .iter() .map(|event| event.kind.as_str()) .collect::>(), vec!["create", "edit", "state", "link_ticket", "unlink_ticket"] ); } #[test] fn does_not_read_legacy_ticket_files_without_sqlite_import() { let dir = tempfile::tempdir().unwrap(); write_ticket(dir.path(), "00000000001J5", "Legacy file", "ready"); let db_path = dir.path().join("workspace.db"); let authority = SqliteWorkspaceAuthority::new(&db_path, "workspace-test").unwrap(); let tickets = authority.list_tickets(20).unwrap(); assert!(tickets.items.is_empty()); } fn write_ticket(root: &Path, id: &str, title: &str, state: &str) { write_ticket_at(&root.join(".yoi/tickets"), id, title, state); } fn write_ticket_at(ticket_root: &Path, id: &str, title: &str, state: &str) { let ticket_dir = ticket_root.join(id); fs::create_dir_all(&ticket_dir).unwrap(); fs::write( ticket_dir.join("item.md"), format!( r#"--- title: "{title}" state: "{state}" created_at: "2026-01-01T00:00:00Z" updated_at: "2026-01-02T00:00:00Z" --- Ticket body. "#, ), ) .unwrap(); fs::write(ticket_dir.join("thread.md"), "").unwrap(); } fn write_objective(root: &Path, id: &str, title: &str, state: &str) { let objective_dir = root.join(".yoi/objectives").join(id); fs::create_dir_all(&objective_dir).unwrap(); fs::write( objective_dir.join("item.md"), format!( r#"--- title: "{title}" state: "{state}" created_at: "2026-01-01T00:00:00Z" updated_at: "2026-01-02T00:00:00Z" linked_tickets: ["00000000001J2"] --- Objective body. "#, ), ) .unwrap(); } }