workspace: introduce authority abstraction
This commit is contained in:
parent
5b1b0688bc
commit
e653f8f08b
341
crates/workspace-server/src/authority.rs
Normal file
341
crates/workspace-server/src/authority.rs
Normal file
|
|
@ -0,0 +1,341 @@
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use ticket::{
|
||||||
|
SqliteTicketBackend, TicketBackend, TicketIdOrSlug, TicketListQuery,
|
||||||
|
TicketWorkspaceActionPriority, project_ticket_workspace_item,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::records::{
|
||||||
|
ObjectiveDetail, ObjectiveResourceSummary, ObjectiveSummary, ProjectRecordList, TicketDetail,
|
||||||
|
TicketSummary, summarize_body, truncate_body, validate_project_id,
|
||||||
|
};
|
||||||
|
use crate::store::{ControlPlaneStore, SqliteWorkspaceStore};
|
||||||
|
use crate::{Error, Result};
|
||||||
|
|
||||||
|
const DETAIL_BODY_LIMIT: usize = 64 * 1024;
|
||||||
|
|
||||||
|
/// 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 explicit import
|
||||||
|
/// / migration helpers such as `objective_import`.
|
||||||
|
pub trait WorkspaceAuthority: ObjectiveAuthority + TicketAuthority + MemoryAuthorityMarker {}
|
||||||
|
|
||||||
|
impl<T> WorkspaceAuthority for T where
|
||||||
|
T: ObjectiveAuthority + TicketAuthority + MemoryAuthorityMarker
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait TicketAuthority {
|
||||||
|
fn list_tickets(&self, limit: usize) -> Result<ProjectRecordList<TicketSummary>>;
|
||||||
|
fn ticket(&self, id: &str) -> Result<TicketDetail>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait ObjectiveAuthority {
|
||||||
|
fn list_objectives(&self, limit: usize) -> Result<ProjectRecordList<ObjectiveSummary>>;
|
||||||
|
fn objective(&self, id: &str) -> Result<ObjectiveDetail>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marker for the pending Memory authority split.
|
||||||
|
///
|
||||||
|
/// Memory still routes through the legacy `memory` crate backend in this codebase.
|
||||||
|
/// This marker makes the missing SQL-backed Memory authority explicit so callers
|
||||||
|
/// do not mistake the current Backend API boundary for a completed storage
|
||||||
|
/// authority migration.
|
||||||
|
pub trait MemoryAuthorityMarker {
|
||||||
|
fn memory_authority_status(&self) -> MemoryAuthorityStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum MemoryAuthorityStatus {
|
||||||
|
LegacyFilesystemBackendPendingSqliteAuthority,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SqliteWorkspaceAuthority {
|
||||||
|
workspace_id: String,
|
||||||
|
store: SqliteWorkspaceStore,
|
||||||
|
ticket_backend: SqliteTicketBackend,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqliteWorkspaceAuthority {
|
||||||
|
pub fn new(database_path: impl Into<PathBuf>, workspace_id: impl Into<String>) -> Result<Self> {
|
||||||
|
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::new(database_path, workspace_id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TicketAuthority for SqliteWorkspaceAuthority {
|
||||||
|
fn list_tickets(&self, limit: usize) -> Result<ProjectRecordList<TicketSummary>> {
|
||||||
|
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<TicketDetail> {
|
||||||
|
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);
|
||||||
|
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,
|
||||||
|
risk_flags: ticket.meta.risk_flags,
|
||||||
|
body,
|
||||||
|
body_truncated,
|
||||||
|
event_count: ticket.events.len(),
|
||||||
|
artifact_count: ticket.artifacts.len(),
|
||||||
|
record_source: "sqlite_yoi_ticket".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ObjectiveAuthority for SqliteWorkspaceAuthority {
|
||||||
|
fn list_objectives(&self, limit: usize) -> Result<ProjectRecordList<ObjectiveSummary>> {
|
||||||
|
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::<Vec<_>>();
|
||||||
|
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: "workspace-sqlite".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(ProjectRecordList {
|
||||||
|
items,
|
||||||
|
invalid_records: Vec::new(),
|
||||||
|
record_authority: "workspace-sqlite".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn objective(&self, id: &str) -> Result<ObjectiveDetail> {
|
||||||
|
validate_project_id(id)?;
|
||||||
|
let record = self
|
||||||
|
.store
|
||||||
|
.get_objective(&self.workspace_id, id)?
|
||||||
|
.ok_or_else(|| Error::Store(format!("unknown objective `{id}`")))?;
|
||||||
|
let linked_tickets = self
|
||||||
|
.store
|
||||||
|
.list_objective_ticket_links(&self.workspace_id, &record.objective_id)?
|
||||||
|
.into_iter()
|
||||||
|
.map(|link| link.ticket_id)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
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: "workspace-sqlite".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MemoryAuthorityMarker for SqliteWorkspaceAuthority {
|
||||||
|
fn memory_authority_status(&self) -> MemoryAuthorityStatus {
|
||||||
|
MemoryAuthorityStatus::LegacyFilesystemBackendPendingSqliteAuthority
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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::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::new(&db_path, "workspace-test")
|
||||||
|
.import_from_local_backend(&ticket::LocalTicketBackend::new(
|
||||||
|
dir.path().join(".yoi/tickets"),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
write_objective(dir.path(), "00000000001J3", "Control plane", "active");
|
||||||
|
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();
|
||||||
|
crate::objective_import::import_legacy_objectives_and_memory_staging(
|
||||||
|
dir.path(),
|
||||||
|
"workspace-test",
|
||||||
|
&store,
|
||||||
|
)
|
||||||
|
.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"));
|
||||||
|
assert_eq!(
|
||||||
|
authority.memory_authority_status(),
|
||||||
|
MemoryAuthorityStatus::LegacyFilesystemBackendPendingSqliteAuthority
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
//! Backend authority surfaces rather than Worker-local filesystem access.
|
//! Backend authority surfaces rather than Worker-local filesystem access.
|
||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod authority;
|
||||||
pub mod companion;
|
pub mod companion;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod hosts;
|
pub mod hosts;
|
||||||
|
|
@ -20,15 +21,17 @@ pub mod server;
|
||||||
pub mod skills;
|
pub mod skills;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
|
|
||||||
|
pub use authority::{
|
||||||
|
MemoryAuthorityMarker, MemoryAuthorityStatus, ObjectiveAuthority, SqliteWorkspaceAuthority,
|
||||||
|
TicketAuthority, WorkspaceAuthority,
|
||||||
|
};
|
||||||
pub use config::{
|
pub use config::{
|
||||||
BackendRuntimesConfigFile, ConfigDiff, ResolvedWorkspaceBackendConfig,
|
BackendRuntimesConfigFile, ConfigDiff, ResolvedWorkspaceBackendConfig,
|
||||||
WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH, WORKSPACE_BACKEND_CONFIG_TEMPLATE,
|
WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH, WORKSPACE_BACKEND_CONFIG_TEMPLATE,
|
||||||
WorkspaceBackendConfigFile,
|
WorkspaceBackendConfigFile,
|
||||||
};
|
};
|
||||||
pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity};
|
pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity};
|
||||||
pub use records::{
|
pub use records::{ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary};
|
||||||
LocalProjectRecordReader, ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary,
|
|
||||||
};
|
|
||||||
pub use repositories::{
|
pub use repositories::{
|
||||||
ConfiguredRepository, GitCommitSummary, GitRemoteSummary, GitRepositorySummary,
|
ConfiguredRepository, GitCommitSummary, GitRemoteSummary, GitRepositorySummary,
|
||||||
RepositoryLogRead, RepositoryRegistryReader, RepositorySummary,
|
RepositoryLogRead, RepositoryRegistryReader, RepositorySummary,
|
||||||
|
|
|
||||||
|
|
@ -1,178 +1,10 @@
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use project_record::validate_record_id;
|
use project_record::validate_record_id;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use ticket::{
|
|
||||||
SqliteTicketBackend, TicketBackend, TicketIdOrSlug, TicketListQuery,
|
|
||||||
TicketWorkspaceActionPriority, project_ticket_workspace_item,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::store::{ControlPlaneStore, SqliteWorkspaceStore};
|
|
||||||
use crate::{Error, Result};
|
use crate::{Error, Result};
|
||||||
|
|
||||||
const DETAIL_BODY_LIMIT: usize = 64 * 1024;
|
|
||||||
const SUMMARY_BODY_LIMIT: usize = 240;
|
const SUMMARY_BODY_LIMIT: usize = 240;
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct LocalProjectRecordReader {
|
|
||||||
workspace_id: String,
|
|
||||||
workspace_root: PathBuf,
|
|
||||||
store: SqliteWorkspaceStore,
|
|
||||||
ticket_backend: SqliteTicketBackend,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LocalProjectRecordReader {
|
|
||||||
pub fn new(
|
|
||||||
workspace_root: impl Into<PathBuf>,
|
|
||||||
database_path: impl Into<PathBuf>,
|
|
||||||
workspace_id: impl Into<String>,
|
|
||||||
) -> Result<Self> {
|
|
||||||
let workspace_root = workspace_root.into();
|
|
||||||
let database_path = database_path.into();
|
|
||||||
let workspace_id = workspace_id.into();
|
|
||||||
Ok(Self {
|
|
||||||
workspace_id: workspace_id.clone(),
|
|
||||||
workspace_root,
|
|
||||||
store: SqliteWorkspaceStore::open(&database_path)?,
|
|
||||||
ticket_backend: SqliteTicketBackend::new(database_path, workspace_id),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn workspace_root(&self) -> &Path {
|
|
||||||
self.workspace_root.as_path()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list_tickets(&self, limit: usize) -> Result<ProjectRecordList<TicketSummary>> {
|
|
||||||
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: "local_yoi_project_records".to_string(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ticket(&self, id: &str) -> Result<TicketDetail> {
|
|
||||||
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);
|
|
||||||
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,
|
|
||||||
risk_flags: ticket.meta.risk_flags,
|
|
||||||
body,
|
|
||||||
body_truncated,
|
|
||||||
event_count: ticket.events.len(),
|
|
||||||
artifact_count: ticket.artifacts.len(),
|
|
||||||
record_source: "sqlite_yoi_ticket".to_string(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list_objectives(&self, limit: usize) -> Result<ProjectRecordList<ObjectiveSummary>> {
|
|
||||||
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::<Vec<_>>();
|
|
||||||
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: "workspace-sqlite".to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(ProjectRecordList {
|
|
||||||
items,
|
|
||||||
invalid_records: Vec::new(),
|
|
||||||
record_authority: "workspace-sqlite".to_string(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn objective(&self, id: &str) -> Result<ObjectiveDetail> {
|
|
||||||
validate_project_id(id)?;
|
|
||||||
let record = self
|
|
||||||
.store
|
|
||||||
.get_objective(&self.workspace_id, id)?
|
|
||||||
.ok_or_else(|| Error::Store(format!("unknown objective `{id}`")))?;
|
|
||||||
let linked_tickets = self
|
|
||||||
.store
|
|
||||||
.list_objective_ticket_links(&self.workspace_id, &record.objective_id)?
|
|
||||||
.into_iter()
|
|
||||||
.map(|link| link.ticket_id)
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
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: "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",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct ProjectRecordList<T> {
|
pub struct ProjectRecordList<T> {
|
||||||
pub items: Vec<T>,
|
pub items: Vec<T>,
|
||||||
|
|
@ -250,11 +82,11 @@ pub struct ObjectiveResourceSummary {
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_project_id(id: &str) -> Result<()> {
|
pub(crate) fn validate_project_id(id: &str) -> Result<()> {
|
||||||
validate_record_id(id).map_err(|_| Error::InvalidRecordId(id.to_string()))
|
validate_record_id(id).map_err(|_| Error::InvalidRecordId(id.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn summarize_body(body: &str) -> String {
|
pub(crate) fn summarize_body(body: &str) -> String {
|
||||||
let summary = body
|
let summary = body
|
||||||
.lines()
|
.lines()
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
|
|
@ -268,7 +100,7 @@ fn summarize_body(body: &str) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn truncate_body(body: &str, limit: usize) -> (String, bool) {
|
pub(crate) fn truncate_body(body: &str, limit: usize) -> (String, bool) {
|
||||||
if body.len() <= limit {
|
if body.len() <= limit {
|
||||||
return (body.to_string(), false);
|
return (body.to_string(), false);
|
||||||
}
|
}
|
||||||
|
|
@ -278,119 +110,3 @@ fn truncate_body(body: &str, limit: usize) -> (String, bool) {
|
||||||
}
|
}
|
||||||
(body[..end].to_string(), true)
|
(body[..end].to_string(), true)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use std::fs;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
use crate::store::WorkspaceRecord;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn reads_sqlite_yoi_ticket_and_objective_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::new(&db_path, "workspace-test")
|
|
||||||
.import_from_local_backend(&ticket::LocalTicketBackend::new(
|
|
||||||
dir.path().join(".yoi/tickets"),
|
|
||||||
))
|
|
||||||
.unwrap();
|
|
||||||
write_objective(dir.path(), "00000000001J3", "Control plane", "active");
|
|
||||||
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();
|
|
||||||
crate::objective_import::import_legacy_objectives_and_memory_staging(
|
|
||||||
dir.path(),
|
|
||||||
"workspace-test",
|
|
||||||
&store,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let reader = LocalProjectRecordReader::new(dir.path(), &db_path, "workspace-test").unwrap();
|
|
||||||
let tickets = reader.list_tickets(20).unwrap();
|
|
||||||
assert_eq!(tickets.record_authority, "local_yoi_project_records");
|
|
||||||
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 = reader.ticket("00000000001J2").unwrap();
|
|
||||||
assert!(ticket.body.contains("Ticket body"));
|
|
||||||
|
|
||||||
let objectives = reader.list_objectives(20).unwrap();
|
|
||||||
assert_eq!(objectives.items[0].id, "00000000001J3");
|
|
||||||
assert_eq!(objectives.items[0].linked_tickets, vec!["00000000001J2"]);
|
|
||||||
|
|
||||||
let objective = reader.objective("00000000001J3").unwrap();
|
|
||||||
assert!(objective.body.contains("Objective body"));
|
|
||||||
}
|
|
||||||
#[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 reader = LocalProjectRecordReader::new(dir.path(), &db_path, "workspace-test").unwrap();
|
|
||||||
let tickets = reader.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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ use crate::auth::{
|
||||||
new_user_code, normalize_handle, parse_cookie, resolve_request_actor, rfc3339_after,
|
new_user_code, normalize_handle, parse_cookie, resolve_request_actor, rfc3339_after,
|
||||||
session_set_cookie, token_hash,
|
session_set_cookie, token_hash,
|
||||||
};
|
};
|
||||||
|
use crate::authority::{ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority};
|
||||||
use crate::companion::{
|
use crate::companion::{
|
||||||
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
|
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
|
||||||
CompanionStatusResponse, CompanionTranscriptProjection,
|
CompanionStatusResponse, CompanionTranscriptProjection,
|
||||||
|
|
@ -68,9 +69,7 @@ use crate::profile_settings::{
|
||||||
UpdateWorkspaceMetadataRequest, UpdateWorkspaceProfileRegistryRequest,
|
UpdateWorkspaceMetadataRequest, UpdateWorkspaceProfileRegistryRequest,
|
||||||
UpdateWorkspaceProfileSourceRequest, WriteWorkspaceProfileTreeFileRequest,
|
UpdateWorkspaceProfileSourceRequest, WriteWorkspaceProfileTreeFileRequest,
|
||||||
};
|
};
|
||||||
use crate::records::{
|
use crate::records::{ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary};
|
||||||
LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary,
|
|
||||||
};
|
|
||||||
use crate::repositories::{
|
use crate::repositories::{
|
||||||
ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError,
|
ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError,
|
||||||
RepositoryRegistryReader, RepositorySummary,
|
RepositoryRegistryReader, RepositorySummary,
|
||||||
|
|
@ -229,7 +228,7 @@ impl ServerConfig {
|
||||||
pub struct WorkspaceApi {
|
pub struct WorkspaceApi {
|
||||||
config: ServerConfig,
|
config: ServerConfig,
|
||||||
store: Arc<dyn ControlPlaneStore>,
|
store: Arc<dyn ControlPlaneStore>,
|
||||||
records: LocalProjectRecordReader,
|
authority: SqliteWorkspaceAuthority,
|
||||||
runtime: Arc<RuntimeRegistry>,
|
runtime: Arc<RuntimeRegistry>,
|
||||||
memory_consolidater_worker: Arc<Mutex<Option<(String, String)>>>,
|
memory_consolidater_worker: Arc<Mutex<Option<(String, String)>>>,
|
||||||
companion: Arc<CompanionConsole>,
|
companion: Arc<CompanionConsole>,
|
||||||
|
|
@ -328,8 +327,7 @@ impl WorkspaceApi {
|
||||||
let companion = Arc::new(CompanionConsole::disabled());
|
let companion = Arc::new(CompanionConsole::disabled());
|
||||||
let observation_proxy = BackendObservationProxy::new(config.runtime_event_sources.clone());
|
let observation_proxy = BackendObservationProxy::new(config.runtime_event_sources.clone());
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
records: LocalProjectRecordReader::new(
|
authority: SqliteWorkspaceAuthority::new(
|
||||||
config.workspace_root.clone(),
|
|
||||||
config.database_path.clone(),
|
config.database_path.clone(),
|
||||||
config.workspace_id.clone(),
|
config.workspace_id.clone(),
|
||||||
)?,
|
)?,
|
||||||
|
|
@ -3648,7 +3646,7 @@ async fn list_tickets(
|
||||||
items,
|
items,
|
||||||
invalid_records,
|
invalid_records,
|
||||||
record_authority,
|
record_authority,
|
||||||
} = api.records.list_tickets(limit)?;
|
} = api.authority.list_tickets(limit)?;
|
||||||
Ok(Json(ListResponse {
|
Ok(Json(ListResponse {
|
||||||
workspace_id: api.config.workspace_id,
|
workspace_id: api.config.workspace_id,
|
||||||
limit,
|
limit,
|
||||||
|
|
@ -3662,7 +3660,7 @@ async fn get_ticket(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(id): AxumPath<String>,
|
AxumPath(id): AxumPath<String>,
|
||||||
) -> ApiResult<Json<TicketDetail>> {
|
) -> ApiResult<Json<TicketDetail>> {
|
||||||
Ok(Json(api.records.ticket(&id)?))
|
Ok(Json(api.authority.ticket(&id)?))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_objectives(
|
async fn list_objectives(
|
||||||
|
|
@ -3674,7 +3672,7 @@ async fn list_objectives(
|
||||||
items,
|
items,
|
||||||
invalid_records,
|
invalid_records,
|
||||||
record_authority,
|
record_authority,
|
||||||
} = api.records.list_objectives(limit)?;
|
} = api.authority.list_objectives(limit)?;
|
||||||
Ok(Json(ListResponse {
|
Ok(Json(ListResponse {
|
||||||
workspace_id: api.config.workspace_id,
|
workspace_id: api.config.workspace_id,
|
||||||
limit,
|
limit,
|
||||||
|
|
@ -3688,7 +3686,7 @@ async fn get_objective(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(id): AxumPath<String>,
|
AxumPath(id): AxumPath<String>,
|
||||||
) -> ApiResult<Json<ObjectiveDetail>> {
|
) -> ApiResult<Json<ObjectiveDetail>> {
|
||||||
Ok(Json(api.records.objective(&id)?))
|
Ok(Json(api.authority.objective(&id)?))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_repositories(
|
async fn list_repositories(
|
||||||
|
|
@ -3752,7 +3750,7 @@ async fn repository_tickets(
|
||||||
items,
|
items,
|
||||||
invalid_records,
|
invalid_records,
|
||||||
record_authority,
|
record_authority,
|
||||||
} = api.records.list_tickets(limit)?;
|
} = api.authority.list_tickets(limit)?;
|
||||||
Ok(Json(RepositoryTicketsResponse {
|
Ok(Json(RepositoryTicketsResponse {
|
||||||
workspace_id: api.config.workspace_id,
|
workspace_id: api.config.workspace_id,
|
||||||
repository_id: canonical_repository_id,
|
repository_id: canonical_repository_id,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user