merge: workspace web control plane
This commit is contained in:
commit
3e03e53627
148
Cargo.lock
generated
148
Cargo.lock
generated
|
|
@ -196,6 +196,58 @@ dependencies = [
|
||||||
"fs_extra",
|
"fs_extra",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
||||||
|
dependencies = [
|
||||||
|
"axum-core",
|
||||||
|
"bytes",
|
||||||
|
"form_urlencoded",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"itoa",
|
||||||
|
"matchit",
|
||||||
|
"memchr",
|
||||||
|
"mime",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"serde_core",
|
||||||
|
"serde_json",
|
||||||
|
"serde_path_to_error",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tower",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum-core"
|
||||||
|
version = "0.5.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"mime",
|
||||||
|
"pin-project-lite",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
|
|
@ -1039,6 +1091,18 @@ dependencies = [
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fallible-iterator"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fallible-streaming-iterator"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fancy-regex"
|
name = "fancy-regex"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
|
|
@ -1429,6 +1493,15 @@ dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashlink"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown 0.15.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
|
|
@ -1968,6 +2041,17 @@ dependencies = [
|
||||||
"redox_syscall 0.7.4",
|
"redox_syscall 0.7.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libsqlite3-sys"
|
||||||
|
version = "0.35.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "line-clipping"
|
name = "line-clipping"
|
||||||
version = "0.3.7"
|
version = "0.3.7"
|
||||||
|
|
@ -2201,6 +2285,12 @@ dependencies = [
|
||||||
"regex-automata",
|
"regex-automata",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matchit"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mcp"
|
name = "mcp"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
@ -3376,6 +3466,20 @@ dependencies = [
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rusqlite"
|
||||||
|
version = "0.37.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.0",
|
||||||
|
"fallible-iterator",
|
||||||
|
"fallible-streaming-iterator",
|
||||||
|
"hashlink",
|
||||||
|
"libsqlite3-sys",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-demangle"
|
name = "rustc-demangle"
|
||||||
version = "0.1.27"
|
version = "0.1.27"
|
||||||
|
|
@ -3692,6 +3796,17 @@ dependencies = [
|
||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_path_to_error"
|
||||||
|
version = "0.1.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_spanned"
|
name = "serde_spanned"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
|
|
@ -3701,6 +3816,18 @@ dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_urlencoded"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||||
|
dependencies = [
|
||||||
|
"form_urlencoded",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_yaml"
|
name = "serde_yaml"
|
||||||
version = "0.9.34+deprecated"
|
version = "0.9.34+deprecated"
|
||||||
|
|
@ -4419,6 +4546,7 @@ dependencies = [
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -4457,6 +4585,7 @@ version = "0.1.44"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"log",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tracing-attributes",
|
"tracing-attributes",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
|
|
@ -5839,6 +5968,25 @@ dependencies = [
|
||||||
"wit-bindgen",
|
"wit-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yoi-workspace-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"axum",
|
||||||
|
"project-record",
|
||||||
|
"rusqlite",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_yaml",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"ticket",
|
||||||
|
"tokio",
|
||||||
|
"tower",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ members = [
|
||||||
"crates/ticket",
|
"crates/ticket",
|
||||||
"crates/project-record",
|
"crates/project-record",
|
||||||
"crates/workflow",
|
"crates/workflow",
|
||||||
|
"crates/workspace-server",
|
||||||
"tests/e2e",
|
"tests/e2e",
|
||||||
]
|
]
|
||||||
default-members = [
|
default-members = [
|
||||||
|
|
@ -50,6 +51,7 @@ default-members = [
|
||||||
"crates/ticket",
|
"crates/ticket",
|
||||||
"crates/project-record",
|
"crates/project-record",
|
||||||
"crates/workflow",
|
"crates/workflow",
|
||||||
|
"crates/workspace-server",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|
@ -80,21 +82,26 @@ session-store = { path = "crates/session-store" }
|
||||||
secrets = { path = "crates/secrets" }
|
secrets = { path = "crates/secrets" }
|
||||||
tools = { path = "crates/tools" }
|
tools = { path = "crates/tools" }
|
||||||
tui = { path = "crates/tui" }
|
tui = { path = "crates/tui" }
|
||||||
|
yoi-workspace-server = { path = "crates/workspace-server" }
|
||||||
|
|
||||||
# External
|
# External
|
||||||
# Note: `reqwest` and `chrono` are not aggregated here because some crates
|
# Note: `reqwest` and `chrono` are not aggregated here because some crates
|
||||||
# need `default-features = false`, which workspace inheritance cannot override.
|
# need `default-features = false`, which workspace inheritance cannot override.
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
axum = "0.8"
|
||||||
fs4 = "0.13"
|
fs4 = "0.13"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
schemars = "1.2"
|
schemars = "1.2"
|
||||||
serde = "1.0"
|
serde = "1.0"
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
serde_yaml = "0.9.34"
|
||||||
|
rusqlite = { version = "0.37", features = ["bundled"] }
|
||||||
sha2 = "0.11"
|
sha2 = "0.11"
|
||||||
tempfile = "3.27"
|
tempfile = "3.27"
|
||||||
thiserror = "2.0"
|
thiserror = "2.0"
|
||||||
tokio = "1.52"
|
tokio = "1.52"
|
||||||
|
tower = "0.5"
|
||||||
toml = "1.1"
|
toml = "1.1"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
uuid = "1.23"
|
uuid = "1.23"
|
||||||
|
|
|
||||||
24
crates/workspace-server/Cargo.toml
Normal file
24
crates/workspace-server/Cargo.toml
Normal file
|
|
@ -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"] }
|
||||||
35
crates/workspace-server/src/lib.rs
Normal file
35
crates/workspace-server/src/lib.rs
Normal file
|
|
@ -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),
|
||||||
|
}
|
||||||
341
crates/workspace-server/src/records.rs
Normal file
341
crates/workspace-server/src/records.rs
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
530
crates/workspace-server/src/server.rs
Normal file
530
crates/workspace-server/src/server.rs
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
341
crates/workspace-server/src/store.rs
Normal file
341
crates/workspace-server/src/store.rs
Normal file
|
|
@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,9 @@ let
|
||||||
|| isExcludedTree ".worktree"
|
|| isExcludedTree ".worktree"
|
||||||
|| isExcludedTree "work-items"
|
|| isExcludedTree "work-items"
|
||||||
|| isExcludedTree "docs/report"
|
|| isExcludedTree "docs/report"
|
||||||
|
|| isExcludedTree "web/workspace/node_modules"
|
||||||
|
|| isExcludedTree "web/workspace/.svelte-kit"
|
||||||
|
|| isExcludedTree "web/workspace/build"
|
||||||
);
|
);
|
||||||
in
|
in
|
||||||
rustPlatform.buildRustPackage rec {
|
rustPlatform.buildRustPackage rec {
|
||||||
|
|
@ -40,7 +43,7 @@ rustPlatform.buildRustPackage rec {
|
||||||
filter = sourceFilter;
|
filter = sourceFilter;
|
||||||
};
|
};
|
||||||
|
|
||||||
cargoHash = "sha256-GUqhvq+JhJokk1R4VVeVz5cZe/6oSrVMyKjcltZEWqE=";
|
cargoHash = "sha256-RER/UXd74C2VhPHAeF36u6ruNBg0oLnR4YeQ/zLag88=";
|
||||||
|
|
||||||
depsExtraArgs = {
|
depsExtraArgs = {
|
||||||
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
||||||
|
|
|
||||||
5
web/workspace/.gitignore
vendored
Normal file
5
web/workspace/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules
|
||||||
|
.svelte-kit
|
||||||
|
build
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
21
web/workspace/README.md
Normal file
21
web/workspace/README.md
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Workspace web SPA
|
||||||
|
|
||||||
|
This is the static SvelteKit shell for the local Yoi Workspace control plane.
|
||||||
|
It is intentionally a read-only UI bootstrap: `.yoi/tickets` and
|
||||||
|
`.yoi/objectives` remain canonical, and the Rust backend owns all business/API
|
||||||
|
semantics.
|
||||||
|
|
||||||
|
Package manager: npm with `package-lock.json` committed.
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
npm run check
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Build output is `web/workspace/build/` and is not checked in. Point the Rust
|
||||||
|
backend `ServerConfig.static_assets_dir` at that directory (or another static
|
||||||
|
asset directory) to serve the SPA. `node_modules/`, `.svelte-kit/`, and `build/`
|
||||||
|
are generated local state and must remain ignored/excluded from package sources.
|
||||||
1673
web/workspace/package-lock.json
generated
Normal file
1673
web/workspace/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
web/workspace/package.json
Normal file
21
web/workspace/package.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"name": "@yoi/workspace-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-static": "^3.0.9",
|
||||||
|
"@sveltejs/kit": "^2.49.4",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||||
|
"svelte": "^5.45.6",
|
||||||
|
"svelte-check": "^4.3.4",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^7.2.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
web/workspace/src/app.html
Normal file
11
web/workspace/src/app.html
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2
web/workspace/src/routes/+layout.ts
Normal file
2
web/workspace/src/routes/+layout.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export const ssr = false;
|
||||||
|
export const prerender = true;
|
||||||
176
web/workspace/src/routes/+page.svelte
Normal file
176
web/workspace/src/routes/+page.svelte
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
<script lang="ts">
|
||||||
|
type WorkspaceResponse = {
|
||||||
|
workspace_id: string;
|
||||||
|
display_name: string;
|
||||||
|
record_authority: string;
|
||||||
|
extension_points: {
|
||||||
|
event_stream: { status: string; note: string };
|
||||||
|
runner_connection: { status: string; note: string };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const endpoints = [
|
||||||
|
{ label: 'Workspace', path: '/api/workspace' },
|
||||||
|
{ label: 'Tickets', path: '/api/tickets' },
|
||||||
|
{ label: 'Objectives', path: '/api/objectives' },
|
||||||
|
{ label: 'Runs', path: '/api/runs' },
|
||||||
|
{ label: 'Runners', path: '/api/runners' }
|
||||||
|
];
|
||||||
|
|
||||||
|
let workspace = $state<WorkspaceResponse | null>(null);
|
||||||
|
let loadError = $state<string | null>(null);
|
||||||
|
|
||||||
|
async function loadWorkspace() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/workspace');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`GET /api/workspace failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
workspace = await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
loadError = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void loadWorkspace();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Yoi Workspace Control Plane</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Local single-workspace Yoi control plane bootstrap"
|
||||||
|
/>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main class="shell">
|
||||||
|
<section class="hero">
|
||||||
|
<p class="eyebrow">Local / single-workspace bootstrap</p>
|
||||||
|
<h1>Yoi Workspace Control Plane</h1>
|
||||||
|
<p>
|
||||||
|
Static SPA shell for reading canonical <code>.yoi</code> project records
|
||||||
|
through bounded backend APIs. Ticket and Objective lifecycle authority stays
|
||||||
|
in the existing local record workflow.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Workspace</h2>
|
||||||
|
{#if workspace}
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>ID</dt>
|
||||||
|
<dd>{workspace.workspace_id}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Name</dt>
|
||||||
|
<dd>{workspace.display_name}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Record authority</dt>
|
||||||
|
<dd>{workspace.record_authority}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{:else if loadError}
|
||||||
|
<p class="error">{loadError}</p>
|
||||||
|
{:else}
|
||||||
|
<p>Waiting for <code>/api/workspace</code>…</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Read API surface</h2>
|
||||||
|
<ul>
|
||||||
|
{#each endpoints as endpoint}
|
||||||
|
<li><code>{endpoint.path}</code> — {endpoint.label}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Reserved seams</h2>
|
||||||
|
<p>
|
||||||
|
Event streams and runner connections are represented as extension-point
|
||||||
|
state in the backend response, but no scheduler, write API, or hosted
|
||||||
|
multi-tenant behavior is implemented in this slice.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(body) {
|
||||||
|
margin: 0;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-family:
|
||||||
|
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
width: min(980px, calc(100vw - 32px));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 48px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
color: #38bdf8;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: clamp(2.5rem, 8vw, 5rem);
|
||||||
|
line-height: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
color: #bae6fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(15, 23, 42, 0.75);
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
dl {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
dt {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
18
web/workspace/svelte.config.js
Normal file
18
web/workspace/svelte.config.js
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import adapter from '@sveltejs/adapter-static';
|
||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
kit: {
|
||||||
|
adapter: adapter({
|
||||||
|
pages: 'build',
|
||||||
|
assets: 'build',
|
||||||
|
fallback: 'index.html',
|
||||||
|
precompress: false,
|
||||||
|
strict: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
14
web/workspace/tsconfig.json
Normal file
14
web/workspace/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
web/workspace/vite.config.ts
Normal file
6
web/workspace/vite.config.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [sveltekit()]
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user