feat: bootstrap workspace web control plane
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "yoi-workspace-server"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
async-trait.workspace = true
|
||||
axum.workspace = true
|
||||
project-record.workspace = true
|
||||
rusqlite.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
serde_yaml.workspace = true
|
||||
thiserror.workspace = true
|
||||
ticket.workspace = true
|
||||
tokio = { workspace = true, features = ["fs", "net", "rt", "sync"] }
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
tower = { workspace = true, features = ["util"] }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
@@ -0,0 +1,35 @@
|
||||
//! Local workspace web control plane backend bootstrap.
|
||||
//!
|
||||
//! This crate deliberately provides backend building blocks and an HTTP router;
|
||||
//! it is not the product CLI facade. Existing `.yoi` Ticket and Objective files
|
||||
//! remain the canonical project records and are read through bounded bridge APIs.
|
||||
|
||||
pub mod records;
|
||||
pub mod server;
|
||||
pub mod store;
|
||||
|
||||
pub use records::{
|
||||
LocalProjectRecordReader, ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary,
|
||||
};
|
||||
pub use server::{AuthConfig, ServerConfig, WorkspaceApi, build_router, serve};
|
||||
pub use store::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord};
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("sqlite error: {0}")]
|
||||
Sqlite(#[from] rusqlite::Error),
|
||||
#[error("ticket error: {0}")]
|
||||
Ticket(#[from] ticket::TicketError),
|
||||
#[error("yaml error: {0}")]
|
||||
Yaml(#[from] serde_yaml::Error),
|
||||
#[error("invalid project record id `{0}`")]
|
||||
InvalidRecordId(String),
|
||||
#[error("record `{0}` is missing frontmatter")]
|
||||
MissingFrontmatter(String),
|
||||
#[error("store error: {0}")]
|
||||
Store(String),
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use project_record::validate_record_id;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ticket::{LocalTicketBackend, TicketFilter, TicketIdOrSlug};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
const DETAIL_BODY_LIMIT: usize = 64 * 1024;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalProjectRecordReader {
|
||||
workspace_root: PathBuf,
|
||||
ticket_backend: LocalTicketBackend,
|
||||
}
|
||||
|
||||
impl LocalProjectRecordReader {
|
||||
pub fn new(workspace_root: impl Into<PathBuf>) -> Self {
|
||||
let workspace_root = workspace_root.into();
|
||||
let ticket_root = workspace_root.join(".yoi/tickets");
|
||||
Self {
|
||||
workspace_root,
|
||||
ticket_backend: LocalTicketBackend::new(ticket_root),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn workspace_root(&self) -> &Path {
|
||||
self.workspace_root.as_path()
|
||||
}
|
||||
|
||||
pub fn list_tickets(&self, limit: usize) -> Result<ProjectRecordList<TicketSummary>> {
|
||||
let partial = self.ticket_backend.list_partial(TicketFilter::all())?;
|
||||
let mut items = partial
|
||||
.tickets
|
||||
.into_iter()
|
||||
.map(|item| 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,
|
||||
record_source: "local_yoi_ticket".to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
items.sort_by(|a, b| {
|
||||
b.updated_at
|
||||
.cmp(&a.updated_at)
|
||||
.then_with(|| a.id.cmp(&b.id))
|
||||
});
|
||||
items.truncate(limit.min(200));
|
||||
Ok(ProjectRecordList {
|
||||
items,
|
||||
invalid_records: partial
|
||||
.invalid_records
|
||||
.into_iter()
|
||||
.map(|record| InvalidProjectRecord {
|
||||
label: record.label,
|
||||
reason: record.reason,
|
||||
})
|
||||
.collect(),
|
||||
record_authority: "local_yoi_project_records".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ticket(&self, id: &str) -> Result<TicketDetail> {
|
||||
validate_project_id(id)?;
|
||||
let partial = self
|
||||
.ticket_backend
|
||||
.show_partial(TicketIdOrSlug::Id(id.to_string()))?;
|
||||
let ticket = partial.ticket;
|
||||
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: "local_yoi_ticket".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_objectives(&self, limit: usize) -> Result<ProjectRecordList<ObjectiveSummary>> {
|
||||
let mut items = Vec::new();
|
||||
let mut invalid_records = Vec::new();
|
||||
let root = self.workspace_root.join(".yoi/objectives");
|
||||
if !root.exists() {
|
||||
return Ok(ProjectRecordList {
|
||||
items,
|
||||
invalid_records,
|
||||
record_authority: "local_yoi_project_records".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
for entry in fs::read_dir(&root)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let id = entry.file_name().to_string_lossy().to_string();
|
||||
match read_objective_summary(&path, &id) {
|
||||
Ok(item) => items.push(item),
|
||||
Err(error) => invalid_records.push(InvalidProjectRecord {
|
||||
label: id,
|
||||
reason: error.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
items.sort_by(|a, b| {
|
||||
b.updated_at
|
||||
.cmp(&a.updated_at)
|
||||
.then_with(|| a.id.cmp(&b.id))
|
||||
});
|
||||
items.truncate(limit.min(200));
|
||||
Ok(ProjectRecordList {
|
||||
items,
|
||||
invalid_records,
|
||||
record_authority: "local_yoi_project_records".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn objective(&self, id: &str) -> Result<ObjectiveDetail> {
|
||||
validate_project_id(id)?;
|
||||
let path = self.workspace_root.join(".yoi/objectives").join(id);
|
||||
let raw = fs::read_to_string(path.join("item.md"))?;
|
||||
let (frontmatter, body) = split_frontmatter(&raw, id)?;
|
||||
let meta: ObjectiveFrontmatter = serde_yaml::from_str(frontmatter)?;
|
||||
let (body, body_truncated) = truncate_body(body, DETAIL_BODY_LIMIT);
|
||||
Ok(ObjectiveDetail {
|
||||
id: id.to_string(),
|
||||
title: meta.title,
|
||||
state: meta.state,
|
||||
created_at: meta.created_at,
|
||||
updated_at: meta.updated_at,
|
||||
linked_tickets: meta.linked_tickets,
|
||||
body,
|
||||
body_truncated,
|
||||
record_source: "local_yoi_objective".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ProjectRecordList<T> {
|
||||
pub items: Vec<T>,
|
||||
pub invalid_records: Vec<InvalidProjectRecord>,
|
||||
pub record_authority: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct InvalidProjectRecord {
|
||||
pub label: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct TicketSummary {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub state: String,
|
||||
pub priority: String,
|
||||
pub updated_at: Option<String>,
|
||||
pub queued_by: Option<String>,
|
||||
pub queued_at: Option<String>,
|
||||
pub record_source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct TicketDetail {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub state: String,
|
||||
pub priority: String,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
pub queued_by: Option<String>,
|
||||
pub queued_at: Option<String>,
|
||||
pub risk_flags: Vec<String>,
|
||||
pub body: String,
|
||||
pub body_truncated: bool,
|
||||
pub event_count: usize,
|
||||
pub artifact_count: usize,
|
||||
pub record_source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ObjectiveSummary {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub state: String,
|
||||
pub updated_at: Option<String>,
|
||||
pub linked_tickets: Vec<String>,
|
||||
pub record_source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ObjectiveDetail {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub state: String,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
pub linked_tickets: Vec<String>,
|
||||
pub body: String,
|
||||
pub body_truncated: bool,
|
||||
pub record_source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ObjectiveFrontmatter {
|
||||
title: String,
|
||||
state: String,
|
||||
#[serde(default)]
|
||||
created_at: Option<String>,
|
||||
#[serde(default)]
|
||||
updated_at: Option<String>,
|
||||
#[serde(default)]
|
||||
linked_tickets: Vec<String>,
|
||||
}
|
||||
|
||||
fn read_objective_summary(path: &Path, id: &str) -> Result<ObjectiveSummary> {
|
||||
validate_project_id(id)?;
|
||||
let raw = fs::read_to_string(path.join("item.md"))?;
|
||||
let (frontmatter, _) = split_frontmatter(&raw, id)?;
|
||||
let meta: ObjectiveFrontmatter = serde_yaml::from_str(frontmatter)?;
|
||||
Ok(ObjectiveSummary {
|
||||
id: id.to_string(),
|
||||
title: meta.title,
|
||||
state: meta.state,
|
||||
updated_at: meta.updated_at,
|
||||
linked_tickets: meta.linked_tickets,
|
||||
record_source: "local_yoi_objective".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn split_frontmatter<'a>(raw: &'a str, label: &str) -> Result<(&'a str, &'a str)> {
|
||||
let rest = raw
|
||||
.strip_prefix("---\n")
|
||||
.ok_or_else(|| Error::MissingFrontmatter(label.to_string()))?;
|
||||
let Some((frontmatter, body)) = rest.split_once("\n---\n") else {
|
||||
return Err(Error::MissingFrontmatter(label.to_string()));
|
||||
};
|
||||
Ok((frontmatter, body))
|
||||
}
|
||||
|
||||
fn validate_project_id(id: &str) -> Result<()> {
|
||||
validate_record_id(id).map_err(|_| Error::InvalidRecordId(id.to_string()))
|
||||
}
|
||||
|
||||
fn truncate_body(body: &str, limit: usize) -> (String, bool) {
|
||||
if body.len() <= limit {
|
||||
return (body.to_string(), false);
|
||||
}
|
||||
let mut end = limit;
|
||||
while !body.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
(body[..end].to_string(), true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_local_yoi_ticket_and_objective_records_without_migration() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_ticket(dir.path(), "00000000001J2", "Read bridge", "ready");
|
||||
write_objective(dir.path(), "00000000001J3", "Control plane", "active");
|
||||
|
||||
let reader = LocalProjectRecordReader::new(dir.path());
|
||||
let tickets = reader.list_tickets(20).unwrap();
|
||||
assert_eq!(tickets.record_authority, "local_yoi_project_records");
|
||||
assert_eq!(tickets.items[0].id, "00000000001J2");
|
||||
assert_eq!(tickets.items[0].state, "ready");
|
||||
|
||||
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"));
|
||||
}
|
||||
|
||||
fn write_ticket(root: &Path, id: &str, title: &str, state: &str) {
|
||||
let ticket_dir = root.join(".yoi/tickets").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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path as AxumPath, State};
|
||||
use axum::http::header::CONTENT_TYPE;
|
||||
use axum::http::{StatusCode, Uri};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use crate::records::{LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail};
|
||||
use crate::store::{ControlPlaneStore, RunSummary, RunnerSummary, WorkspaceRecord};
|
||||
use crate::{Error, Result};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum AuthConfig {
|
||||
/// Local/dev-only mode. If a token is configured by a future entrypoint, it
|
||||
/// is a development guard only and not a production SaaS auth model.
|
||||
LocalDevToken { token_configured: bool },
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ServerConfig {
|
||||
pub workspace_id: String,
|
||||
pub workspace_root: PathBuf,
|
||||
pub static_assets_dir: Option<PathBuf>,
|
||||
pub auth: AuthConfig,
|
||||
pub max_records: usize,
|
||||
}
|
||||
|
||||
impl ServerConfig {
|
||||
pub fn local_dev(workspace_root: impl Into<PathBuf>) -> Self {
|
||||
let workspace_root = workspace_root.into();
|
||||
let display = workspace_root
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("workspace");
|
||||
Self {
|
||||
workspace_id: format!("local:{display}"),
|
||||
workspace_root,
|
||||
static_assets_dir: None,
|
||||
auth: AuthConfig::LocalDevToken {
|
||||
token_configured: false,
|
||||
},
|
||||
max_records: 200,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WorkspaceApi {
|
||||
config: ServerConfig,
|
||||
store: Arc<dyn ControlPlaneStore>,
|
||||
records: LocalProjectRecordReader,
|
||||
}
|
||||
|
||||
impl WorkspaceApi {
|
||||
pub async fn new(config: ServerConfig, store: Arc<dyn ControlPlaneStore>) -> Result<Self> {
|
||||
let display_name = config
|
||||
.workspace_root
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("workspace")
|
||||
.to_string();
|
||||
store
|
||||
.upsert_workspace(&WorkspaceRecord {
|
||||
workspace_id: config.workspace_id.clone(),
|
||||
display_name,
|
||||
local_root: config.workspace_root.clone(),
|
||||
record_authority: "local_yoi_project_records".to_string(),
|
||||
created_at: "1970-01-01T00:00:00Z".to_string(),
|
||||
updated_at: "1970-01-01T00:00:00Z".to_string(),
|
||||
})
|
||||
.await?;
|
||||
Ok(Self {
|
||||
records: LocalProjectRecordReader::new(config.workspace_root.clone()),
|
||||
config,
|
||||
store,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn workspace_id(&self) -> &str {
|
||||
self.config.workspace_id.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_router(api: WorkspaceApi) -> Router {
|
||||
Router::new()
|
||||
.route("/api/workspace", get(get_workspace))
|
||||
.route("/api/tickets", get(list_tickets))
|
||||
.route("/api/tickets/{id}", get(get_ticket))
|
||||
.route("/api/objectives", get(list_objectives))
|
||||
.route("/api/objectives/{id}", get(get_objective))
|
||||
.route("/api/runs", get(list_runs))
|
||||
.route("/api/runners", get(list_runners))
|
||||
.fallback(get(static_or_spa_fallback))
|
||||
.with_state(api)
|
||||
}
|
||||
|
||||
pub async fn serve(
|
||||
config: ServerConfig,
|
||||
store: Arc<dyn ControlPlaneStore>,
|
||||
listener: TcpListener,
|
||||
) -> Result<()> {
|
||||
let api = WorkspaceApi::new(config, store).await?;
|
||||
axum::serve(listener, build_router(api)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct WorkspaceResponse {
|
||||
pub workspace_id: String,
|
||||
pub display_name: String,
|
||||
pub local_root: PathBuf,
|
||||
pub record_authority: String,
|
||||
pub schema_version: i64,
|
||||
pub auth: AuthConfig,
|
||||
pub extension_points: ExtensionPoints,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ExtensionPoints {
|
||||
pub store: String,
|
||||
pub event_stream: ExtensionPointState,
|
||||
pub runner_connection: ExtensionPointState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ExtensionPointState {
|
||||
pub status: String,
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ListResponse<T> {
|
||||
pub workspace_id: String,
|
||||
pub limit: usize,
|
||||
pub items: Vec<T>,
|
||||
pub invalid_records: Vec<crate::records::InvalidProjectRecord>,
|
||||
pub record_authority: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RuntimeListResponse<T> {
|
||||
pub workspace_id: String,
|
||||
pub limit: usize,
|
||||
pub items: Vec<T>,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
async fn get_workspace(State(api): State<WorkspaceApi>) -> ApiResult<Json<WorkspaceResponse>> {
|
||||
let schema_version = api.store.schema_version().await?;
|
||||
let stored = api.store.get_workspace(api.workspace_id()).await?;
|
||||
let display_name = stored
|
||||
.as_ref()
|
||||
.map(|record| record.display_name.clone())
|
||||
.or_else(|| {
|
||||
api.config
|
||||
.workspace_root
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(str::to_string)
|
||||
})
|
||||
.unwrap_or_else(|| "workspace".to_string());
|
||||
Ok(Json(WorkspaceResponse {
|
||||
workspace_id: api.config.workspace_id.clone(),
|
||||
display_name,
|
||||
local_root: api.config.workspace_root.clone(),
|
||||
record_authority: "local_yoi_project_records".to_string(),
|
||||
schema_version,
|
||||
auth: api.config.auth.clone(),
|
||||
extension_points: ExtensionPoints {
|
||||
store: "sqlite".to_string(),
|
||||
event_stream: ExtensionPointState {
|
||||
status: "reserved".to_string(),
|
||||
note: "No event stream is exposed in this bootstrap; route/state seams are reserved.".to_string(),
|
||||
},
|
||||
runner_connection: ExtensionPointState {
|
||||
status: "reserved".to_string(),
|
||||
note: "Runner connections are modeled, but no job dispatch or scheduler is implemented.".to_string(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
async fn list_tickets(
|
||||
State(api): State<WorkspaceApi>,
|
||||
) -> ApiResult<Json<ListResponse<crate::records::TicketSummary>>> {
|
||||
let limit = api.config.max_records.min(200);
|
||||
let ProjectRecordList {
|
||||
items,
|
||||
invalid_records,
|
||||
record_authority,
|
||||
} = api.records.list_tickets(limit)?;
|
||||
Ok(Json(ListResponse {
|
||||
workspace_id: api.config.workspace_id,
|
||||
limit,
|
||||
items,
|
||||
invalid_records,
|
||||
record_authority,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_ticket(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(id): AxumPath<String>,
|
||||
) -> ApiResult<Json<TicketDetail>> {
|
||||
Ok(Json(api.records.ticket(&id)?))
|
||||
}
|
||||
|
||||
async fn list_objectives(
|
||||
State(api): State<WorkspaceApi>,
|
||||
) -> ApiResult<Json<ListResponse<crate::records::ObjectiveSummary>>> {
|
||||
let limit = api.config.max_records.min(200);
|
||||
let ProjectRecordList {
|
||||
items,
|
||||
invalid_records,
|
||||
record_authority,
|
||||
} = api.records.list_objectives(limit)?;
|
||||
Ok(Json(ListResponse {
|
||||
workspace_id: api.config.workspace_id,
|
||||
limit,
|
||||
items,
|
||||
invalid_records,
|
||||
record_authority,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_objective(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(id): AxumPath<String>,
|
||||
) -> ApiResult<Json<ObjectiveDetail>> {
|
||||
Ok(Json(api.records.objective(&id)?))
|
||||
}
|
||||
|
||||
async fn list_runs(
|
||||
State(api): State<WorkspaceApi>,
|
||||
) -> ApiResult<Json<RuntimeListResponse<RunSummary>>> {
|
||||
let limit = api.config.max_records.min(200);
|
||||
let items = api.store.list_runs(api.workspace_id(), limit).await?;
|
||||
Ok(Json(RuntimeListResponse {
|
||||
workspace_id: api.config.workspace_id,
|
||||
limit,
|
||||
items,
|
||||
source: "sqlite_runtime_tables".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn list_runners(
|
||||
State(api): State<WorkspaceApi>,
|
||||
) -> ApiResult<Json<RuntimeListResponse<RunnerSummary>>> {
|
||||
let limit = api.config.max_records.min(200);
|
||||
let items = api.store.list_runners(api.workspace_id(), limit).await?;
|
||||
Ok(Json(RuntimeListResponse {
|
||||
workspace_id: api.config.workspace_id,
|
||||
limit,
|
||||
items,
|
||||
source: "sqlite_runtime_tables".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn static_or_spa_fallback(State(api): State<WorkspaceApi>, uri: Uri) -> Response {
|
||||
if uri.path().starts_with("/api/") || uri.path() == "/api" {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
[(CONTENT_TYPE, "application/json")],
|
||||
Json(serde_json::json!({
|
||||
"error": "not_found",
|
||||
"message": "unknown api route"
|
||||
}))
|
||||
.to_string(),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let Some(static_root) = api.config.static_assets_dir.as_ref() else {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
};
|
||||
|
||||
match read_static_or_index(static_root, uri.path()).await {
|
||||
Ok(StaticAsset {
|
||||
bytes,
|
||||
content_type,
|
||||
}) => (StatusCode::OK, [(CONTENT_TYPE, content_type)], bytes).into_response(),
|
||||
Err(error) => {
|
||||
tracing::debug!(%error, path = %uri.path(), "failed to serve static asset");
|
||||
StatusCode::NOT_FOUND.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct StaticAsset {
|
||||
bytes: Vec<u8>,
|
||||
content_type: &'static str,
|
||||
}
|
||||
|
||||
async fn read_static_or_index(root: &Path, request_path: &str) -> Result<StaticAsset> {
|
||||
let candidate = safe_static_candidate(root, request_path)?;
|
||||
let file = if tokio::fs::metadata(&candidate)
|
||||
.await
|
||||
.map(|m| m.is_file())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
candidate
|
||||
} else {
|
||||
root.join("index.html")
|
||||
};
|
||||
let content_type = content_type_for(&file);
|
||||
let bytes = tokio::fs::read(file).await?;
|
||||
Ok(StaticAsset {
|
||||
bytes,
|
||||
content_type,
|
||||
})
|
||||
}
|
||||
|
||||
fn safe_static_candidate(root: &Path, request_path: &str) -> Result<PathBuf> {
|
||||
let mut path = root.to_path_buf();
|
||||
let clean = request_path.trim_start_matches('/');
|
||||
if clean.is_empty() {
|
||||
path.push("index.html");
|
||||
return Ok(path);
|
||||
}
|
||||
for component in Path::new(clean).components() {
|
||||
match component {
|
||||
Component::Normal(part) => path.push(part),
|
||||
Component::CurDir => {}
|
||||
_ => return Err(Error::Store("static path escape rejected".to_string())),
|
||||
}
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn content_type_for(path: &Path) -> &'static str {
|
||||
match path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or_default()
|
||||
{
|
||||
"css" => "text/css; charset=utf-8",
|
||||
"js" => "text/javascript; charset=utf-8",
|
||||
"json" => "application/json",
|
||||
"svg" => "image/svg+xml",
|
||||
"html" | "" => "text/html; charset=utf-8",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
type ApiResult<T> = std::result::Result<T, ApiError>;
|
||||
|
||||
struct ApiError(Error);
|
||||
|
||||
impl From<Error> for ApiError {
|
||||
fn from(error: Error) -> Self {
|
||||
Self(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = match &self.0 {
|
||||
Error::InvalidRecordId(_) | Error::MissingFrontmatter(_) => StatusCode::NOT_FOUND,
|
||||
Error::Ticket(_) => StatusCode::NOT_FOUND,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
(
|
||||
status,
|
||||
[(CONTENT_TYPE, "application/json")],
|
||||
Json(serde_json::json!({
|
||||
"error": status.canonical_reason().unwrap_or("error"),
|
||||
"message": self.0.to_string(),
|
||||
}))
|
||||
.to_string(),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::Request;
|
||||
use serde_json::Value;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::store::SqliteWorkspaceStore;
|
||||
|
||||
#[tokio::test]
|
||||
async fn serves_bounded_read_apis_and_static_spa_separately() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_ticket(dir.path(), "00000000001J2", "API Ticket", "ready");
|
||||
write_objective(dir.path(), "00000000001J3", "API Objective", "active");
|
||||
let static_dir = dir.path().join("static");
|
||||
std::fs::create_dir_all(static_dir.join("assets")).unwrap();
|
||||
std::fs::write(static_dir.join("index.html"), "<main>Yoi Workspace</main>").unwrap();
|
||||
std::fs::write(static_dir.join("assets/app.js"), "console.log('yoi');").unwrap();
|
||||
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
let mut config = ServerConfig::local_dev(dir.path());
|
||||
config.workspace_id = "local:test".to_string();
|
||||
config.static_assets_dir = Some(static_dir);
|
||||
let api = WorkspaceApi::new(config, Arc::new(store)).await.unwrap();
|
||||
let app = build_router(api);
|
||||
|
||||
let workspace = get_json(app.clone(), "/api/workspace").await;
|
||||
assert_eq!(workspace["workspace_id"], "local:test");
|
||||
assert_eq!(workspace["record_authority"], "local_yoi_project_records");
|
||||
assert_eq!(
|
||||
workspace["extension_points"]["runner_connection"]["status"],
|
||||
"reserved"
|
||||
);
|
||||
|
||||
let tickets = get_json(app.clone(), "/api/tickets").await;
|
||||
assert_eq!(tickets["items"][0]["id"], "00000000001J2");
|
||||
assert_eq!(tickets["items"][0]["state"], "ready");
|
||||
|
||||
let objectives = get_json(app.clone(), "/api/objectives").await;
|
||||
assert_eq!(objectives["items"][0]["id"], "00000000001J3");
|
||||
|
||||
let runners = get_json(app.clone(), "/api/runners").await;
|
||||
assert!(runners["items"].as_array().unwrap().is_empty());
|
||||
|
||||
let static_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/assets/app.js")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(static_response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
static_response.headers().get(CONTENT_TYPE).unwrap(),
|
||||
"text/javascript; charset=utf-8"
|
||||
);
|
||||
|
||||
let spa_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/tickets/00000000001J2")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(spa_response.status(), StatusCode::OK);
|
||||
let bytes = to_bytes(spa_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
String::from_utf8(bytes.to_vec())
|
||||
.unwrap()
|
||||
.contains("Yoi Workspace")
|
||||
);
|
||||
|
||||
let api_miss = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/nope")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(api_miss.status(), StatusCode::NOT_FOUND);
|
||||
let bytes = to_bytes(api_miss.into_body(), usize::MAX).await.unwrap();
|
||||
assert!(
|
||||
!String::from_utf8(bytes.to_vec())
|
||||
.unwrap()
|
||||
.contains("Yoi Workspace")
|
||||
);
|
||||
}
|
||||
|
||||
async fn get_json(app: Router, uri: &str) -> Value {
|
||||
let response = app
|
||||
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK, "{uri}");
|
||||
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
serde_json::from_slice(&bytes).unwrap()
|
||||
}
|
||||
|
||||
fn write_ticket(root: &Path, id: &str, title: &str, state: &str) {
|
||||
let ticket_dir = root.join(".yoi/tickets").join(id);
|
||||
std::fs::create_dir_all(&ticket_dir).unwrap();
|
||||
std::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();
|
||||
std::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);
|
||||
std::fs::create_dir_all(&objective_dir).unwrap();
|
||||
std::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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
const MIGRATIONS: &[Migration] = &[Migration {
|
||||
version: 1,
|
||||
name: "bootstrap workspace control plane",
|
||||
sql: r#"
|
||||
CREATE TABLE IF NOT EXISTS workspaces (
|
||||
workspace_id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
local_root TEXT NOT NULL,
|
||||
record_authority TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS repositories (
|
||||
repository_id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
local_root TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Projection tables are intentionally empty in this bootstrap: `.yoi/tickets`
|
||||
-- and `.yoi/objectives` remain canonical, but the tables reserve a future
|
||||
-- projection/cache seam without migrating authority.
|
||||
CREATE TABLE IF NOT EXISTS ticket_projections (
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
ticket_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS objective_projections (
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
objective_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, objective_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS runners (
|
||||
runner_id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
label TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
last_seen_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS runs (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
subject_kind TEXT NOT NULL,
|
||||
subject_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS artifacts (
|
||||
artifact_id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
run_id TEXT REFERENCES runs(run_id) ON DELETE SET NULL,
|
||||
path TEXT NOT NULL,
|
||||
content_type TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
"#,
|
||||
}];
|
||||
|
||||
struct Migration {
|
||||
version: i64,
|
||||
name: &'static str,
|
||||
sql: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WorkspaceRecord {
|
||||
pub workspace_id: String,
|
||||
pub display_name: String,
|
||||
pub local_root: PathBuf,
|
||||
pub record_authority: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RunSummary {
|
||||
pub run_id: String,
|
||||
pub workspace_id: String,
|
||||
pub subject_kind: String,
|
||||
pub subject_id: String,
|
||||
pub status: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RunnerSummary {
|
||||
pub runner_id: String,
|
||||
pub workspace_id: String,
|
||||
pub label: String,
|
||||
pub status: String,
|
||||
pub last_seen_at: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ControlPlaneStore: Send + Sync {
|
||||
async fn schema_version(&self) -> Result<i64>;
|
||||
async fn upsert_workspace(&self, record: &WorkspaceRecord) -> Result<()>;
|
||||
async fn get_workspace(&self, workspace_id: &str) -> Result<Option<WorkspaceRecord>>;
|
||||
async fn list_runs(&self, workspace_id: &str, limit: usize) -> Result<Vec<RunSummary>>;
|
||||
async fn list_runners(&self, workspace_id: &str, limit: usize) -> Result<Vec<RunnerSummary>>;
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SqliteWorkspaceStore {
|
||||
conn: Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
impl SqliteWorkspaceStore {
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
|
||||
let conn = Connection::open(path)?;
|
||||
Self::from_connection(conn)
|
||||
}
|
||||
|
||||
pub fn in_memory() -> Result<Self> {
|
||||
Self::from_connection(Connection::open_in_memory()?)
|
||||
}
|
||||
|
||||
pub fn from_connection(conn: Connection) -> Result<Self> {
|
||||
configure_sqlite(&conn)?;
|
||||
apply_migrations(&conn)?;
|
||||
Ok(Self {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
})
|
||||
}
|
||||
|
||||
fn with_conn<T>(&self, f: impl FnOnce(&Connection) -> Result<T>) -> Result<T> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|_| Error::Store("sqlite connection lock poisoned".to_string()))?;
|
||||
f(&conn)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
async fn schema_version(&self) -> Result<i64> {
|
||||
self.with_conn(current_schema_version)
|
||||
}
|
||||
|
||||
async fn upsert_workspace(&self, record: &WorkspaceRecord) -> Result<()> {
|
||||
self.with_conn(|conn| {
|
||||
conn.execute(
|
||||
r#"INSERT INTO workspaces (
|
||||
workspace_id, display_name, local_root, record_authority, created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
ON CONFLICT(workspace_id) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
local_root = excluded.local_root,
|
||||
record_authority = excluded.record_authority,
|
||||
updated_at = excluded.updated_at"#,
|
||||
params![
|
||||
record.workspace_id,
|
||||
record.display_name,
|
||||
record.local_root.to_string_lossy(),
|
||||
record.record_authority,
|
||||
record.created_at,
|
||||
record.updated_at,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_workspace(&self, workspace_id: &str) -> Result<Option<WorkspaceRecord>> {
|
||||
self.with_conn(|conn| {
|
||||
conn.query_row(
|
||||
r#"SELECT workspace_id, display_name, local_root, record_authority, created_at, updated_at
|
||||
FROM workspaces WHERE workspace_id = ?1"#,
|
||||
params![workspace_id],
|
||||
|row| {
|
||||
Ok(WorkspaceRecord {
|
||||
workspace_id: row.get(0)?,
|
||||
display_name: row.get(1)?,
|
||||
local_root: PathBuf::from(row.get::<_, String>(2)?),
|
||||
record_authority: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
updated_at: row.get(5)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(Error::from)
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_runs(&self, workspace_id: &str, limit: usize) -> Result<Vec<RunSummary>> {
|
||||
self.with_conn(|conn| {
|
||||
let limit = limit.min(200) as i64;
|
||||
let mut stmt = conn.prepare(
|
||||
r#"SELECT run_id, workspace_id, subject_kind, subject_id, status, created_at, updated_at
|
||||
FROM runs WHERE workspace_id = ?1 ORDER BY updated_at DESC, run_id DESC LIMIT ?2"#,
|
||||
)?;
|
||||
let rows = stmt.query_map(params![workspace_id, limit], |row| {
|
||||
Ok(RunSummary {
|
||||
run_id: row.get(0)?,
|
||||
workspace_id: row.get(1)?,
|
||||
subject_kind: row.get(2)?,
|
||||
subject_id: row.get(3)?,
|
||||
status: row.get(4)?,
|
||||
created_at: row.get(5)?,
|
||||
updated_at: row.get(6)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>().map_err(Error::from)
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_runners(&self, workspace_id: &str, limit: usize) -> Result<Vec<RunnerSummary>> {
|
||||
self.with_conn(|conn| {
|
||||
let limit = limit.min(200) as i64;
|
||||
let mut stmt = conn.prepare(
|
||||
r#"SELECT runner_id, workspace_id, label, status, last_seen_at
|
||||
FROM runners WHERE workspace_id = ?1 ORDER BY runner_id ASC LIMIT ?2"#,
|
||||
)?;
|
||||
let rows = stmt.query_map(params![workspace_id, limit], |row| {
|
||||
Ok(RunnerSummary {
|
||||
runner_id: row.get(0)?,
|
||||
workspace_id: row.get(1)?,
|
||||
label: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
last_seen_at: row.get(4)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.map_err(Error::from)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn configure_sqlite(conn: &Connection) -> Result<()> {
|
||||
conn.busy_timeout(Duration::from_millis(5_000))?;
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
PRAGMA foreign_keys = ON;
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA busy_timeout = 5000;
|
||||
CREATE TABLE IF NOT EXISTS __yoi_schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
"#,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn current_schema_version(conn: &Connection) -> Result<i64> {
|
||||
conn.query_row(
|
||||
"SELECT COALESCE(MAX(version), 0) FROM __yoi_schema_migrations",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
fn apply_migrations(conn: &Connection) -> Result<()> {
|
||||
let current = current_schema_version(conn)?;
|
||||
for migration in MIGRATIONS
|
||||
.iter()
|
||||
.filter(|migration| migration.version > current)
|
||||
{
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute_batch(migration.sql)?;
|
||||
tx.execute(
|
||||
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
|
||||
params![migration.version, migration.name],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn migrates_sqlite_and_preserves_workspace_record() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = dir.path().join("control-plane.sqlite");
|
||||
let store = SqliteWorkspaceStore::open(&db).unwrap();
|
||||
|
||||
assert_eq!(store.schema_version().await.unwrap(), 1);
|
||||
|
||||
let record = WorkspaceRecord {
|
||||
workspace_id: "local-dev".to_string(),
|
||||
display_name: "Yoi Dev".to_string(),
|
||||
local_root: dir.path().to_path_buf(),
|
||||
record_authority: "local_yoi_project_records".to_string(),
|
||||
created_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
updated_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
};
|
||||
store.upsert_workspace(&record).await.unwrap();
|
||||
|
||||
let reopened = SqliteWorkspaceStore::open(&db).unwrap();
|
||||
assert_eq!(reopened.schema_version().await.unwrap(), 1);
|
||||
assert_eq!(
|
||||
reopened.get_workspace("local-dev").await.unwrap(),
|
||||
Some(record)
|
||||
);
|
||||
assert!(
|
||||
reopened
|
||||
.list_runs("local-dev", 20)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
reopened
|
||||
.list_runners("local-dev", 20)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user