migrate: move workspace data to XDG SQLite stores
This commit is contained in:
parent
e975c648c2
commit
19d5396897
|
|
@ -11,7 +11,3 @@ uri = "."
|
|||
display_name = "Yoi"
|
||||
default_selector = "HEAD"
|
||||
|
||||
[[runtimes.remote]]
|
||||
id = "arc"
|
||||
endpoint = "http://127.0.0.1:38800"
|
||||
display_name = "arc"
|
||||
|
|
|
|||
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -4294,6 +4294,7 @@ dependencies = [
|
|||
"fs4",
|
||||
"llm-engine",
|
||||
"project-record",
|
||||
"rusqlite",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
//! | base | 1. `YOI_<KIND>_DIR` | 2. `YOI_HOME` | 3. `XDG_*` | 4. 既定 |
|
||||
//! |---|---|---|---|---|
|
||||
//! | config | `YOI_CONFIG_DIR` | `$YOI_HOME/config` | `$XDG_CONFIG_HOME/yoi` | `$HOME/.config/yoi` |
|
||||
//! | data | `YOI_DATA_DIR` | `$YOI_HOME` | — | `$HOME/.yoi` |
|
||||
//! | data | `YOI_DATA_DIR` | `$YOI_HOME` | `$XDG_DATA_HOME/yoi` | `$HOME/.local/share/yoi` |
|
||||
//! | runtime | `YOI_RUNTIME_DIR` | `$YOI_HOME/run` | `$XDG_RUNTIME_DIR/yoi` | `$HOME/.yoi/run` |
|
||||
//!
|
||||
//! `YOI_HOME=$X` のとき config は `$X/config`、data は `$X` 直下、
|
||||
|
|
@ -42,6 +42,7 @@ pub fn data_dir() -> Option<PathBuf> {
|
|||
resolve_data_dir_from_parts(
|
||||
env_path("YOI_DATA_DIR"),
|
||||
env_path("YOI_HOME"),
|
||||
env_path("XDG_DATA_HOME"),
|
||||
env_path("HOME"),
|
||||
)
|
||||
}
|
||||
|
|
@ -131,6 +132,7 @@ fn resolve_config_dir_from_parts(
|
|||
fn resolve_data_dir_from_parts(
|
||||
yoi_data_dir: Option<PathBuf>,
|
||||
yoi_home: Option<PathBuf>,
|
||||
xdg_data_home: Option<PathBuf>,
|
||||
home: Option<PathBuf>,
|
||||
) -> Option<PathBuf> {
|
||||
if let Some(p) = yoi_data_dir {
|
||||
|
|
@ -139,7 +141,10 @@ fn resolve_data_dir_from_parts(
|
|||
if let Some(p) = yoi_home {
|
||||
return Some(p);
|
||||
}
|
||||
Some(home?.join(".yoi"))
|
||||
if let Some(p) = xdg_data_home {
|
||||
return Some(p.join("yoi"));
|
||||
}
|
||||
Some(home?.join(".local").join("share").join("yoi"))
|
||||
}
|
||||
|
||||
fn resolve_runtime_dir_from_parts(
|
||||
|
|
@ -268,20 +273,35 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn data_dir_default_is_dot_yoi() {
|
||||
fn data_dir_falls_back_to_home_local_share() {
|
||||
assert_eq!(
|
||||
resolve_data_dir_from_parts(None, None, Some(PathBuf::from("/h"))).unwrap(),
|
||||
PathBuf::from("/h/.yoi")
|
||||
resolve_data_dir_from_parts(None, None, None, Some(PathBuf::from("/h"))).unwrap(),
|
||||
PathBuf::from("/h/.local/share/yoi")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_dir_yoi_home_is_data_dir_itself() {
|
||||
fn data_dir_uses_xdg_when_set() {
|
||||
assert_eq!(
|
||||
resolve_data_dir_from_parts(
|
||||
None,
|
||||
None,
|
||||
Some(PathBuf::from("/xdg-data")),
|
||||
Some(PathBuf::from("/h")),
|
||||
)
|
||||
.unwrap(),
|
||||
PathBuf::from("/xdg-data/yoi")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_dir_yoi_home_outranks_xdg() {
|
||||
assert_eq!(
|
||||
resolve_data_dir_from_parts(
|
||||
None,
|
||||
Some(PathBuf::from("/sand")),
|
||||
Some(PathBuf::from("/h"))
|
||||
Some(PathBuf::from("/xdg-data")),
|
||||
Some(PathBuf::from("/h")),
|
||||
)
|
||||
.unwrap(),
|
||||
PathBuf::from("/sand")
|
||||
|
|
@ -294,6 +314,7 @@ mod tests {
|
|||
resolve_data_dir_from_parts(
|
||||
Some(PathBuf::from("/explicit-data")),
|
||||
Some(PathBuf::from("/sand")),
|
||||
Some(PathBuf::from("/xdg-data")),
|
||||
Some(PathBuf::from("/h")),
|
||||
)
|
||||
.unwrap(),
|
||||
|
|
@ -365,7 +386,7 @@ mod tests {
|
|||
#[test]
|
||||
fn returns_none_when_nothing_set() {
|
||||
assert!(resolve_config_dir_from_parts(None, None, None, None).is_none());
|
||||
assert!(resolve_data_dir_from_parts(None, None, None).is_none());
|
||||
assert!(resolve_data_dir_from_parts(None, None, None, None).is_none());
|
||||
assert!(resolve_runtime_dir_from_parts(None, None, None, None).is_none());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ schemars = { workspace = true }
|
|||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
serde_yaml = "0.9.34"
|
||||
rusqlite.workspace = true
|
||||
thiserror.workspace = true
|
||||
tempfile.workspace = true
|
||||
toml = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -219,7 +219,7 @@ fn default_store_dir() -> Result<PathBuf, PickerError> {
|
|||
PickerError::Io(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
"could not resolve sessions directory \
|
||||
(set YOI_HOME, YOI_DATA_DIR, or HOME)",
|
||||
(set YOI_DATA_DIR, YOI_HOME, XDG_DATA_HOME, or HOME)",
|
||||
))
|
||||
})
|
||||
}
|
||||
|
|
@ -231,7 +231,7 @@ fn default_worker_metadata_dir() -> Result<PathBuf, PickerError> {
|
|||
PickerError::Io(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
"could not resolve worker state directory \
|
||||
(set YOI_HOME, YOI_DATA_DIR, or HOME)",
|
||||
(set YOI_DATA_DIR, YOI_HOME, XDG_DATA_HOME, or HOME)",
|
||||
))
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ impl ProfileRuntimeWorkerFactory {
|
|||
.clone()
|
||||
.or_else(paths::sessions_dir)
|
||||
.ok_or_else(|| {
|
||||
"could not resolve sessions directory (set YOI_HOME, YOI_DATA_DIR, or HOME)"
|
||||
"could not resolve sessions directory (set YOI_DATA_DIR, YOI_HOME, XDG_DATA_HOME, or HOME)"
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -449,8 +449,8 @@ async fn run_cli_inner(cli: Cli) -> ExitCode {
|
|||
}
|
||||
};
|
||||
// Initialize persistent store. `paths::sessions_dir()` only
|
||||
// returns None when none of YOI_HOME / YOI_DATA_DIR /
|
||||
// HOME is set — surface that as a hard error to match the
|
||||
// returns None when none of YOI_DATA_DIR / YOI_HOME /
|
||||
// XDG_DATA_HOME / HOME is set — surface that as a hard error to match the
|
||||
// runtime-dir resolution below, rather than silently writing to a
|
||||
// relative path under cwd.
|
||||
let store_dir = match cli.store.clone() {
|
||||
|
|
@ -460,7 +460,7 @@ async fn run_cli_inner(cli: Cli) -> ExitCode {
|
|||
None => {
|
||||
eprintln!(
|
||||
"error: could not resolve sessions directory \
|
||||
(set --store, YOI_HOME, YOI_DATA_DIR, or HOME)"
|
||||
(set --store, YOI_DATA_DIR, YOI_HOME, XDG_DATA_HOME, or HOME)"
|
||||
);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use crate::server::{AuthConfig, ServerConfig};
|
|||
use crate::{Error, Result};
|
||||
|
||||
pub const WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH: &str = ".yoi/workspace-backend.local.toml";
|
||||
pub const BACKEND_RUNTIMES_CONFIG_FILE_NAME: &str = "runtimes.toml";
|
||||
pub const WORKSPACE_BACKEND_CONFIG_TEMPLATE: &str =
|
||||
include_str!("../../../resources/workspace-backend.default.toml");
|
||||
const DEFAULT_LISTEN: &str = "127.0.0.1:8787";
|
||||
|
|
@ -49,6 +50,11 @@ pub struct WorkspaceBackendConfigFile {
|
|||
pub auth: WorkspaceBackendAuthConfig,
|
||||
#[serde(default)]
|
||||
pub repositories: Vec<WorkspaceRepositoryConfigFile>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct BackendRuntimesConfigFile {
|
||||
#[serde(default)]
|
||||
pub runtimes: WorkspaceBackendRuntimesConfig,
|
||||
}
|
||||
|
|
@ -198,6 +204,74 @@ pub struct ResolvedWorkspaceBackendConfig {
|
|||
pub database_path: PathBuf,
|
||||
}
|
||||
|
||||
impl BackendRuntimesConfigFile {
|
||||
pub fn path_for_config_dir(config_dir: impl AsRef<Path>) -> PathBuf {
|
||||
config_dir.as_ref().join(BACKEND_RUNTIMES_CONFIG_FILE_NAME)
|
||||
}
|
||||
|
||||
pub fn default_path() -> Option<PathBuf> {
|
||||
manifest::paths::config_dir().map(Self::path_for_config_dir)
|
||||
}
|
||||
|
||||
pub fn load_default() -> Result<Self> {
|
||||
match Self::default_path() {
|
||||
Some(path) => Self::load_from_path(path),
|
||||
None => Ok(Self::default()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_from_config_dir(config_dir: impl AsRef<Path>) -> Result<Self> {
|
||||
Self::load_from_path(Self::path_for_config_dir(config_dir))
|
||||
}
|
||||
|
||||
pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self> {
|
||||
let path = path.as_ref();
|
||||
match fs::read_to_string(path) {
|
||||
Ok(raw) => Self::parse_str(&raw, path),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
|
||||
Err(error) => Err(Error::Io(error)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write_default(&self) -> Result<PathBuf> {
|
||||
let path = Self::default_path().ok_or_else(|| {
|
||||
Error::Config(
|
||||
"YOI_CONFIG_DIR, YOI_HOME, XDG_CONFIG_HOME, or HOME is required to write Backend runtimes config"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
self.write_to_path(&path)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn write_to_config_dir(&self, config_dir: impl AsRef<Path>) -> Result<()> {
|
||||
self.write_to_path(Self::path_for_config_dir(config_dir))
|
||||
}
|
||||
|
||||
pub fn write_to_path(&self, path: impl AsRef<Path>) -> Result<()> {
|
||||
let path = path.as_ref();
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let raw = toml::to_string_pretty(self).map_err(|error| {
|
||||
Error::Config(format!(
|
||||
"failed to serialize Backend runtimes config: {error}"
|
||||
))
|
||||
})?;
|
||||
fs::write(path, raw)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn parse_str(raw: &str, path: impl AsRef<Path>) -> Result<Self> {
|
||||
toml::from_str(raw).map_err(|error| {
|
||||
Error::Config(format!(
|
||||
"failed to parse Backend runtimes config `{}`: {error}",
|
||||
path.as_ref().display()
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkspaceBackendConfigFile {
|
||||
pub fn path_for_workspace(workspace_root: impl AsRef<Path>) -> PathBuf {
|
||||
workspace_root
|
||||
|
|
@ -276,6 +350,19 @@ impl WorkspaceBackendConfigFile {
|
|||
&self,
|
||||
workspace_root: impl AsRef<Path>,
|
||||
identity: WorkspaceIdentity,
|
||||
) -> Result<ResolvedWorkspaceBackendConfig> {
|
||||
self.resolve_with_runtime_config(
|
||||
workspace_root,
|
||||
identity,
|
||||
&BackendRuntimesConfigFile::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn resolve_with_runtime_config(
|
||||
&self,
|
||||
workspace_root: impl AsRef<Path>,
|
||||
identity: WorkspaceIdentity,
|
||||
runtime_config: &BackendRuntimesConfigFile,
|
||||
) -> Result<ResolvedWorkspaceBackendConfig> {
|
||||
let workspace_root = workspace_root.as_ref();
|
||||
let data_root = self
|
||||
|
|
@ -312,6 +399,7 @@ impl WorkspaceBackendConfigFile {
|
|||
})?;
|
||||
|
||||
let mut server = ServerConfig::local_dev(workspace_root.to_path_buf(), identity);
|
||||
server.database_path = database_path.clone();
|
||||
server.frontend_url = self
|
||||
.server
|
||||
.frontend_url
|
||||
|
|
@ -329,7 +417,7 @@ impl WorkspaceBackendConfigFile {
|
|||
.iter()
|
||||
.map(|repository| resolve_repository(workspace_root, repository))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
server.remote_runtime_sources = self
|
||||
server.remote_runtime_sources = runtime_config
|
||||
.runtimes
|
||||
.remote
|
||||
.iter()
|
||||
|
|
@ -352,7 +440,9 @@ impl WorkspaceBackendConfigFile {
|
|||
|
||||
impl ResolvedWorkspaceBackendConfig {
|
||||
pub fn with_database_path(mut self, path: impl Into<PathBuf>) -> Self {
|
||||
self.database_path = path.into();
|
||||
let path = path.into();
|
||||
self.database_path = path.clone();
|
||||
self.server.database_path = path;
|
||||
self
|
||||
}
|
||||
|
||||
|
|
@ -663,15 +753,80 @@ uri = "https://example.com/org/repo.git"
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn token_value_field_is_not_in_schema() {
|
||||
fn backend_runtimes_config_loads_from_config_dir() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = BackendRuntimesConfigFile {
|
||||
runtimes: WorkspaceBackendRuntimesConfig {
|
||||
remote: vec![RemoteRuntimeConfigFile {
|
||||
id: "arc".to_string(),
|
||||
endpoint: "http://127.0.0.1:38800".to_string(),
|
||||
display_name: Some("arc".to_string()),
|
||||
token_ref: None,
|
||||
}],
|
||||
},
|
||||
};
|
||||
config.write_to_config_dir(dir.path()).unwrap();
|
||||
let loaded = BackendRuntimesConfigFile::load_from_config_dir(dir.path()).unwrap();
|
||||
assert_eq!(loaded, config);
|
||||
assert_eq!(
|
||||
BackendRuntimesConfigFile::path_for_config_dir(dir.path()),
|
||||
dir.path().join("runtimes.toml")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_backend_config_rejects_runtime_entries() {
|
||||
let error = WorkspaceBackendConfigFile::parse_str(
|
||||
r#"
|
||||
[[runtimes.remote]]
|
||||
id = "arc"
|
||||
endpoint = "http://legacy.example.test"
|
||||
display_name = "legacy arc"
|
||||
"#,
|
||||
"test",
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains("unknown field `runtimes`"),
|
||||
"unexpected error: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_runtimes_config_is_the_only_runtime_source() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let workspace_config = WorkspaceBackendConfigFile::parse_str("", "test").unwrap();
|
||||
let runtime_config = BackendRuntimesConfigFile::parse_str(
|
||||
r#"
|
||||
[[runtimes.remote]]
|
||||
id = "arc"
|
||||
endpoint = "http://xdg.example.test"
|
||||
display_name = "xdg arc"
|
||||
"#,
|
||||
"runtimes.toml",
|
||||
)
|
||||
.unwrap();
|
||||
let resolved = workspace_config
|
||||
.resolve_with_runtime_config(dir.path(), identity(), &runtime_config)
|
||||
.unwrap();
|
||||
assert_eq!(resolved.server.remote_runtime_sources.len(), 1);
|
||||
assert_eq!(resolved.server.remote_runtime_sources[0].runtime_id, "arc");
|
||||
assert_eq!(
|
||||
resolved.server.remote_runtime_sources[0].base_url.as_str(),
|
||||
"http://xdg.example.test"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_value_field_is_not_in_runtime_schema() {
|
||||
let error = BackendRuntimesConfigFile::parse_str(
|
||||
r#"
|
||||
[[runtimes.remote]]
|
||||
id = "remote"
|
||||
endpoint = "http://127.0.0.1:8790"
|
||||
token = "secret"
|
||||
"#,
|
||||
"test",
|
||||
"runtimes.toml",
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
|
|
@ -683,17 +838,22 @@ token = "secret"
|
|||
#[test]
|
||||
fn token_ref_fails_closed_until_secret_resolution_exists() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = WorkspaceBackendConfigFile::parse_str(
|
||||
let workspace_config = WorkspaceBackendConfigFile::parse_str("", "test").unwrap();
|
||||
let runtime_config = BackendRuntimesConfigFile::parse_str(
|
||||
r#"
|
||||
[[runtimes.remote]]
|
||||
id = "remote"
|
||||
endpoint = "http://127.0.0.1:8790"
|
||||
token_ref = "local:remote-token"
|
||||
"#,
|
||||
"test",
|
||||
"runtimes.toml",
|
||||
)
|
||||
.unwrap();
|
||||
let error = match config.resolve(dir.path(), identity()) {
|
||||
let error = match workspace_config.resolve_with_runtime_config(
|
||||
dir.path(),
|
||||
identity(),
|
||||
&runtime_config,
|
||||
) {
|
||||
Ok(_) => panic!("token_ref should fail closed until secret resolution exists"),
|
||||
Err(error) => error,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -19,8 +19,9 @@ pub mod skills;
|
|||
pub mod store;
|
||||
|
||||
pub use config::{
|
||||
ConfigDiff, ResolvedWorkspaceBackendConfig, WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH,
|
||||
WORKSPACE_BACKEND_CONFIG_TEMPLATE, WorkspaceBackendConfigFile,
|
||||
BackendRuntimesConfigFile, ConfigDiff, ResolvedWorkspaceBackendConfig,
|
||||
WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH, WORKSPACE_BACKEND_CONFIG_TEMPLATE,
|
||||
WorkspaceBackendConfigFile,
|
||||
};
|
||||
pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity};
|
||||
pub use records::{
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ use std::sync::Arc;
|
|||
|
||||
use tokio::net::TcpListener;
|
||||
use yoi_workspace_server::{
|
||||
SqliteWorkspaceStore, WORKSPACE_BACKEND_CONFIG_TEMPLATE, WorkspaceBackendConfigFile,
|
||||
WorkspaceIdentity, serve,
|
||||
BackendRuntimesConfigFile, SqliteWorkspaceStore, WORKSPACE_BACKEND_CONFIG_TEMPLATE,
|
||||
WorkspaceBackendConfigFile, WorkspaceIdentity, serve,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
|
|
@ -169,7 +169,9 @@ fn run_skills(command: SkillsCommand) -> Result<(), Box<dyn std::error::Error>>
|
|||
async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let identity = WorkspaceIdentity::load_required(&options.workspace)?;
|
||||
let config_file = WorkspaceBackendConfigFile::load_for_workspace(&options.workspace)?;
|
||||
let mut resolved = config_file.resolve(&options.workspace, identity)?;
|
||||
let runtime_config = BackendRuntimesConfigFile::load_default()?;
|
||||
let mut resolved =
|
||||
config_file.resolve_with_runtime_config(&options.workspace, identity, &runtime_config)?;
|
||||
if let Some(db) = options.db {
|
||||
resolved = resolved.with_database_path(db);
|
||||
}
|
||||
|
|
@ -311,8 +313,7 @@ fn parse_init_options(args: &[String]) -> Result<InitOptions, CliError> {
|
|||
}
|
||||
|
||||
fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
|
||||
let mut workspace = std::env::current_dir()
|
||||
.map_err(|error| CliError(format!("failed to resolve current directory: {error}")))?;
|
||||
let mut workspace = None;
|
||||
let mut db = None;
|
||||
let mut frontend = None;
|
||||
let mut listen = None;
|
||||
|
|
@ -326,7 +327,7 @@ fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
|
|||
let value = args
|
||||
.get(index)
|
||||
.ok_or_else(|| CliError("--workspace requires a value".to_string()))?;
|
||||
workspace = PathBuf::from(value);
|
||||
workspace = Some(PathBuf::from(value));
|
||||
}
|
||||
"--db" => {
|
||||
index += 1;
|
||||
|
|
@ -350,7 +351,7 @@ fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
|
|||
listen = Some(parse_listen(value)?);
|
||||
}
|
||||
_ if arg.starts_with("--workspace=") => {
|
||||
workspace = PathBuf::from(value_after_equals(arg, "--workspace")?);
|
||||
workspace = Some(PathBuf::from(value_after_equals(arg, "--workspace")?));
|
||||
}
|
||||
_ if arg.starts_with("--db=") => {
|
||||
db = Some(PathBuf::from(value_after_equals(arg, "--db")?));
|
||||
|
|
@ -373,6 +374,8 @@ fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
|
|||
index += 1;
|
||||
}
|
||||
|
||||
let workspace = workspace
|
||||
.ok_or_else(|| CliError("serve requires --workspace <path>; the current directory is no longer used as an implicit workspace".to_string()))?;
|
||||
let workspace = workspace.canonicalize().map_err(|error| {
|
||||
CliError(format!(
|
||||
"failed to canonicalize workspace `{}`: {error}",
|
||||
|
|
@ -431,7 +434,7 @@ fn print_skills_help() {
|
|||
|
||||
fn print_serve_help() {
|
||||
println!(
|
||||
"yoi-workspace-server serve\n\nUsage:\n yoi-workspace-server serve [OPTIONS]\n\nDescription:\n Serves an already initialized Workspace. Run `yoi workspace init` first.\n\nOptions:\n --workspace <PATH> Workspace root containing .yoi project records (defaults to cwd)\n --db <PATH> SQLite database path (legacy dev override)\n --frontend <PATH> Static SPA build directory to serve (legacy dev override)\n --listen <ADDR> Listen address (legacy dev override; default 127.0.0.1:8787)\n -h, --help Print help"
|
||||
"yoi-workspace-server serve\n\nUsage:\n yoi-workspace-server serve --workspace <PATH> [OPTIONS]\n\nDescription:\n Serves an already initialized Workspace. Run `yoi workspace init --workspace <PATH>` first. Backend serve no longer treats the process current directory as an implicit Workspace.\n\nOptions:\n --workspace <PATH> Workspace root containing .yoi project records (required)\n --db <PATH> SQLite database path (legacy dev override)\n --frontend <PATH> Static SPA build directory to serve (legacy dev override)\n --listen <ADDR> Listen address (legacy dev override; default 127.0.0.1:8787)\n -h, --help Print help"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -451,6 +454,38 @@ mod tests {
|
|||
assert_eq!(options.workspace, temp.path().canonicalize().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_serve_rejects_missing_workspace() {
|
||||
let args = vec!["--listen".to_string(), "127.0.0.1:0".to_string()];
|
||||
let error = parse_serve_options(&args).unwrap_err();
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"serve requires --workspace <path>; the current directory is no longer used as an implicit workspace"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_serve_requires_explicit_workspace() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let args = vec![
|
||||
"--workspace".to_string(),
|
||||
temp.path().display().to_string(),
|
||||
"--listen".to_string(),
|
||||
"127.0.0.1:0".to_string(),
|
||||
];
|
||||
let options = parse_serve_options(&args).unwrap();
|
||||
assert_eq!(options.workspace, temp.path().canonicalize().unwrap());
|
||||
assert_eq!(options.listen.unwrap(), "127.0.0.1:0".parse().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_serve_accepts_equals_workspace() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let args = vec![format!("--workspace={}", temp.path().display())];
|
||||
let options = parse_serve_options(&args).unwrap();
|
||||
assert_eq!(options.workspace, temp.path().canonicalize().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_creates_identity_and_local_config_only() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@ use std::path::{Path, PathBuf};
|
|||
|
||||
use project_record::validate_record_id;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ticket::config::TicketConfig;
|
||||
use ticket::{LocalTicketBackend, TicketIdOrSlug, TicketListQuery};
|
||||
use ticket::{SqliteTicketBackend, TicketBackend, TicketIdOrSlug, TicketListQuery};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
|
|
@ -14,19 +13,19 @@ const SUMMARY_BODY_LIMIT: usize = 240;
|
|||
#[derive(Debug, Clone)]
|
||||
pub struct LocalProjectRecordReader {
|
||||
workspace_root: PathBuf,
|
||||
ticket_backend: LocalTicketBackend,
|
||||
ticket_backend: SqliteTicketBackend,
|
||||
}
|
||||
|
||||
impl LocalProjectRecordReader {
|
||||
pub fn new(workspace_root: impl Into<PathBuf>) -> Result<Self> {
|
||||
pub fn new(
|
||||
workspace_root: impl Into<PathBuf>,
|
||||
database_path: impl Into<PathBuf>,
|
||||
workspace_id: impl Into<String>,
|
||||
) -> Result<Self> {
|
||||
let workspace_root = workspace_root.into();
|
||||
let ticket_config = TicketConfig::load_workspace(&workspace_root)
|
||||
.map_err(|error| Error::Config(format!("load Ticket workspace settings: {error}")))?;
|
||||
let ticket_backend = LocalTicketBackend::new(ticket_config.backend_root().to_path_buf())
|
||||
.with_record_language(ticket_config.ticket_record_language());
|
||||
Ok(Self {
|
||||
workspace_root,
|
||||
ticket_backend,
|
||||
ticket_backend: SqliteTicketBackend::new(database_path, workspace_id),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -35,9 +34,9 @@ impl LocalProjectRecordReader {
|
|||
}
|
||||
|
||||
pub fn list_tickets(&self, limit: usize) -> Result<ProjectRecordList<TicketSummary>> {
|
||||
let partial = self.ticket_backend.list_partial(TicketListQuery::all())?;
|
||||
let mut items = partial
|
||||
.tickets
|
||||
let mut items = self
|
||||
.ticket_backend
|
||||
.list(TicketListQuery::all())?
|
||||
.into_iter()
|
||||
.map(|item| TicketSummary {
|
||||
id: item.id,
|
||||
|
|
@ -47,7 +46,7 @@ impl LocalProjectRecordReader {
|
|||
updated_at: item.updated_at,
|
||||
queued_by: item.queued_by,
|
||||
queued_at: item.queued_at,
|
||||
record_source: "local_yoi_ticket".to_string(),
|
||||
record_source: "sqlite_yoi_ticket".to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
items.sort_by(|a, b| {
|
||||
|
|
@ -58,24 +57,16 @@ impl LocalProjectRecordReader {
|
|||
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(),
|
||||
invalid_records: Vec::new(),
|
||||
record_authority: "local_yoi_project_records".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ticket(&self, id: &str) -> Result<TicketDetail> {
|
||||
validate_project_id(id)?;
|
||||
let partial = self
|
||||
let ticket = self
|
||||
.ticket_backend
|
||||
.show_partial(TicketIdOrSlug::Id(id.to_string()))?;
|
||||
let ticket = partial.ticket;
|
||||
.show(TicketIdOrSlug::Id(id.to_string()))?;
|
||||
let (body, body_truncated) =
|
||||
truncate_body(ticket.document.body.as_str(), DETAIL_BODY_LIMIT);
|
||||
Ok(TicketDetail {
|
||||
|
|
@ -92,7 +83,7 @@ impl LocalProjectRecordReader {
|
|||
body_truncated,
|
||||
event_count: ticket.events.len(),
|
||||
artifact_count: ticket.artifacts.len(),
|
||||
record_source: "local_yoi_ticket".to_string(),
|
||||
record_source: "sqlite_yoi_ticket".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -296,14 +287,21 @@ mod tests {
|
|||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_local_yoi_ticket_and_objective_records_without_migration() {
|
||||
fn reads_sqlite_yoi_ticket_and_objective_records_without_migration() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_ticket(dir.path(), "00000000001J2", "Read bridge", "ready");
|
||||
let db_path = dir.path().join("workspace.db");
|
||||
SqliteTicketBackend::new(&db_path, "workspace-test")
|
||||
.import_from_local_backend(&ticket::LocalTicketBackend::new(
|
||||
dir.path().join(".yoi/tickets"),
|
||||
))
|
||||
.unwrap();
|
||||
write_objective(dir.path(), "00000000001J3", "Control plane", "active");
|
||||
|
||||
let reader = LocalProjectRecordReader::new(dir.path()).unwrap();
|
||||
let reader = LocalProjectRecordReader::new(dir.path(), &db_path, "workspace-test").unwrap();
|
||||
let tickets = reader.list_tickets(20).unwrap();
|
||||
assert_eq!(tickets.record_authority, "local_yoi_project_records");
|
||||
assert_eq!(tickets.items[0].record_source, "sqlite_yoi_ticket");
|
||||
assert_eq!(tickets.items[0].id, "00000000001J2");
|
||||
assert_eq!(tickets.items[0].state, "ready");
|
||||
|
||||
|
|
@ -318,34 +316,16 @@ mod tests {
|
|||
assert!(objective.body.contains("Objective body"));
|
||||
}
|
||||
#[test]
|
||||
fn reads_tickets_from_workspace_settings_backend_root() {
|
||||
fn does_not_read_legacy_ticket_files_without_sqlite_import() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(dir.path().join(".yoi")).unwrap();
|
||||
fs::write(
|
||||
dir.path().join(".yoi/workspace.toml"),
|
||||
r#"
|
||||
[ticket]
|
||||
language = "Japanese"
|
||||
write_ticket(dir.path(), "00000000001J5", "Legacy file", "ready");
|
||||
let db_path = dir.path().join("workspace.db");
|
||||
|
||||
[ticket.backend]
|
||||
provider = "builtin:yoi_local"
|
||||
root = "project-records/tickets"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
write_ticket_at(
|
||||
&dir.path().join("project-records/tickets"),
|
||||
"00000000001J4",
|
||||
"Configured root",
|
||||
"ready",
|
||||
);
|
||||
write_ticket(dir.path(), "00000000001J5", "Default root", "ready");
|
||||
|
||||
let reader = LocalProjectRecordReader::new(dir.path()).unwrap();
|
||||
let reader = LocalProjectRecordReader::new(dir.path(), &db_path, "workspace-test").unwrap();
|
||||
let tickets = reader.list_tickets(20).unwrap();
|
||||
assert_eq!(tickets.items.len(), 1);
|
||||
assert_eq!(tickets.items[0].id, "00000000001J4");
|
||||
assert!(tickets.items.is_empty());
|
||||
}
|
||||
|
||||
fn write_ticket(root: &Path, id: &str, title: &str, state: &str) {
|
||||
write_ticket_at(&root.join(".yoi/tickets"), id, title, state);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ impl RepositoryRegistryReader {
|
|||
kind: repository.provider.clone(),
|
||||
provider: repository.provider.clone(),
|
||||
default_selector: repository.default_selector.clone(),
|
||||
record_authority: "workspace-backend-config".to_string(),
|
||||
record_authority: "workspace-control-plane".to_string(),
|
||||
git,
|
||||
diagnostics,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ use protocol::stream::{decode_method, encode_event};
|
|||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use ticket::{
|
||||
LocalTicketBackend, TicketBackendHttpResponse, TicketBackendOperation,
|
||||
SqliteTicketBackend, TicketBackendHttpResponse, TicketBackendOperation,
|
||||
execute_ticket_backend_operation,
|
||||
};
|
||||
use tokio::net::TcpListener;
|
||||
|
|
@ -45,7 +45,7 @@ use crate::companion::{
|
|||
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
|
||||
CompanionStatusResponse, CompanionTranscriptProjection,
|
||||
};
|
||||
use crate::config::{RemoteRuntimeConfigFile, WorkspaceBackendConfigFile, resolve_remote_runtime};
|
||||
use crate::config::{BackendRuntimesConfigFile, RemoteRuntimeConfigFile, resolve_remote_runtime};
|
||||
use crate::hosts::{
|
||||
ConfigBundleCheckResult, ConfigBundleSyncResult, DiagnosticSeverity, EmbeddedWorkerRuntime,
|
||||
HostSummary, RemoteRuntimeConfig, RemoteWorkerRuntime, RuntimeDiagnostic, RuntimeRegistry,
|
||||
|
|
@ -77,8 +77,8 @@ use crate::resource_broker::BackendResourceBroker;
|
|||
use crate::skills;
|
||||
use crate::store::{
|
||||
AccountRecord, ApiTokenRecord, AuthChallengeRecord, BrowserSessionRecord, ControlPlaneStore,
|
||||
DeviceLoginFlowRecord, PasskeyCredentialRecord, UserRecord, WorkdirRegistryRecord,
|
||||
WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord,
|
||||
DeviceLoginFlowRecord, PasskeyCredentialRecord, RepositoryRecord, UserRecord,
|
||||
WorkdirRegistryRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord,
|
||||
};
|
||||
use crate::{Error, Result};
|
||||
use worker_runtime::catalog::{
|
||||
|
|
@ -120,6 +120,7 @@ pub struct ServerConfig {
|
|||
pub workspace_display_name: String,
|
||||
pub workspace_created_at: String,
|
||||
pub workspace_root: PathBuf,
|
||||
pub database_path: PathBuf,
|
||||
pub frontend_url: String,
|
||||
pub embedded_runtime_store_root: PathBuf,
|
||||
pub static_assets_dir: Option<PathBuf>,
|
||||
|
|
@ -128,6 +129,7 @@ pub struct ServerConfig {
|
|||
pub repositories: Vec<ConfiguredRepository>,
|
||||
pub runtime_event_sources: Vec<RuntimeObservationSourceConfig>,
|
||||
pub remote_runtime_sources: Vec<RemoteRuntimeConfig>,
|
||||
pub runtime_config_path: Option<PathBuf>,
|
||||
pub backend_base_url: Option<String>,
|
||||
}
|
||||
|
||||
|
|
@ -136,11 +138,14 @@ impl ServerConfig {
|
|||
let workspace_root = workspace_root.into();
|
||||
let workspace_id = identity.workspace_id;
|
||||
let embedded_runtime_store_root = Self::default_embedded_runtime_store_root(&workspace_id);
|
||||
let database_path =
|
||||
Self::default_workspace_backend_data_root(&workspace_id).join("workspace.db");
|
||||
Self {
|
||||
workspace_id,
|
||||
workspace_display_name: identity.display_name,
|
||||
workspace_created_at: identity.created_at,
|
||||
workspace_root,
|
||||
database_path,
|
||||
frontend_url: "http://127.0.0.1:5173".to_string(),
|
||||
embedded_runtime_store_root,
|
||||
static_assets_dir: None,
|
||||
|
|
@ -154,6 +159,7 @@ impl ServerConfig {
|
|||
repositories: Vec::new(),
|
||||
runtime_event_sources: Vec::new(),
|
||||
remote_runtime_sources: Vec::new(),
|
||||
runtime_config_path: BackendRuntimesConfigFile::default_path(),
|
||||
backend_base_url: None,
|
||||
}
|
||||
}
|
||||
|
|
@ -246,7 +252,7 @@ impl WorkspaceApi {
|
|||
}
|
||||
|
||||
async fn new_with_execution_backend_and_broker(
|
||||
config: ServerConfig,
|
||||
mut config: ServerConfig,
|
||||
store: Arc<dyn ControlPlaneStore>,
|
||||
execution_backend: Arc<dyn worker_runtime::execution::WorkerExecutionBackend>,
|
||||
resource_broker: BackendResourceBroker,
|
||||
|
|
@ -261,6 +267,8 @@ impl WorkspaceApi {
|
|||
updated_at: config.workspace_created_at.clone(),
|
||||
})
|
||||
.await?;
|
||||
import_configured_repositories(store.as_ref(), &config)?;
|
||||
config.repositories = load_configured_repositories_from_store(store.as_ref(), &config)?;
|
||||
let runtime = RuntimeRegistry::for_workspace(
|
||||
EmbeddedWorkerRuntime::new_fs_store_with_execution_backend(
|
||||
config.workspace_id.clone(),
|
||||
|
|
@ -298,7 +306,11 @@ impl WorkspaceApi {
|
|||
let companion = Arc::new(CompanionConsole::disabled());
|
||||
let observation_proxy = BackendObservationProxy::new(config.runtime_event_sources.clone());
|
||||
Ok(Self {
|
||||
records: LocalProjectRecordReader::new(config.workspace_root.clone())?,
|
||||
records: LocalProjectRecordReader::new(
|
||||
config.workspace_root.clone(),
|
||||
config.database_path.clone(),
|
||||
config.workspace_id.clone(),
|
||||
)?,
|
||||
config,
|
||||
store,
|
||||
runtime,
|
||||
|
|
@ -317,6 +329,76 @@ impl WorkspaceApi {
|
|||
}
|
||||
}
|
||||
|
||||
fn import_configured_repositories(
|
||||
store: &dyn ControlPlaneStore,
|
||||
config: &ServerConfig,
|
||||
) -> Result<()> {
|
||||
if config.repositories.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let now = crate::auth::now_rfc3339();
|
||||
for repository in &config.repositories {
|
||||
store.upsert_repository(&RepositoryRecord {
|
||||
workspace_id: config.workspace_id.clone(),
|
||||
repository_id: repository.id.clone(),
|
||||
name: repository
|
||||
.display_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| repository.id.clone()),
|
||||
kind: repository.provider.clone(),
|
||||
provider: Some(repository.provider.clone()),
|
||||
uri: repository.uri.clone(),
|
||||
default_ref: repository.default_selector.clone(),
|
||||
auth_ref_kind: None,
|
||||
auth_ref_key: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now.clone(),
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_configured_repositories_from_store(
|
||||
store: &dyn ControlPlaneStore,
|
||||
config: &ServerConfig,
|
||||
) -> Result<Vec<ConfiguredRepository>> {
|
||||
store
|
||||
.list_repositories(&config.workspace_id)?
|
||||
.into_iter()
|
||||
.map(|record| configured_repository_from_record(&config.workspace_root, record))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn configured_repository_from_record(
|
||||
workspace_root: &Path,
|
||||
record: RepositoryRecord,
|
||||
) -> Result<ConfiguredRepository> {
|
||||
let provider = record.provider.unwrap_or_else(|| record.kind.clone());
|
||||
if record.uri.contains("://") {
|
||||
return Err(Error::Config(format!(
|
||||
"repository `{}` uses a remote URI, but remote repository materialization is not implemented",
|
||||
record.repository_id
|
||||
)));
|
||||
}
|
||||
let path = resolve_backend_path(workspace_root, Path::new(&record.uri));
|
||||
Ok(ConfiguredRepository {
|
||||
id: record.repository_id,
|
||||
provider,
|
||||
uri: record.uri,
|
||||
path,
|
||||
display_name: Some(record.name),
|
||||
default_selector: record.default_ref,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_backend_path(workspace_root: &Path, path: &Path) -> PathBuf {
|
||||
if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
workspace_root.join(path)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_router(api: WorkspaceApi) -> Router {
|
||||
Router::new()
|
||||
.route("/api/auth/config", get(get_auth_config))
|
||||
|
|
@ -1316,7 +1398,10 @@ async fn scoped_ticket_backend_operation(
|
|||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
let config = ticket::config::TicketConfig::load_workspace(&api.config.workspace_root)
|
||||
.map_err(|error| Error::Config(format!("load Ticket workspace settings: {error}")))?;
|
||||
let backend = LocalTicketBackend::new(config.backend_root().to_path_buf())
|
||||
let backend = SqliteTicketBackend::new(
|
||||
api.config.database_path.clone(),
|
||||
api.config.workspace_id.clone(),
|
||||
)
|
||||
.with_record_language(config.ticket_record_language());
|
||||
let response = match execute_ticket_backend_operation(&backend, operation) {
|
||||
Ok(result) => TicketBackendHttpResponse::Ok { result },
|
||||
|
|
@ -3358,7 +3443,7 @@ async fn list_repositories(
|
|||
Ok(Json(RepositoryListResponse {
|
||||
workspace_id: api.config.workspace_id,
|
||||
items,
|
||||
source: "workspace_backend_config".to_string(),
|
||||
source: "workspace-control-plane".to_string(),
|
||||
diagnostics: repository_diagnostics(diagnostics),
|
||||
}))
|
||||
}
|
||||
|
|
@ -3371,7 +3456,7 @@ async fn repository_detail(
|
|||
Ok(Json(RepositoryDetailResponse {
|
||||
workspace_id: api.config.workspace_id.clone(),
|
||||
item,
|
||||
source: "workspace_backend_config".to_string(),
|
||||
source: "workspace-control-plane".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -3466,10 +3551,10 @@ async fn list_workers(
|
|||
async fn get_runtime_connection_settings(
|
||||
State(api): State<WorkspaceApi>,
|
||||
) -> ApiResult<Json<RuntimeConnectionSettingsResponse>> {
|
||||
let local_config = load_workspace_backend_config_for_settings(&api)?;
|
||||
let runtime_config = load_backend_runtimes_config_for_settings(&api)?;
|
||||
Ok(Json(runtime_connection_settings_response(
|
||||
&api,
|
||||
&local_config,
|
||||
&runtime_config,
|
||||
)))
|
||||
}
|
||||
|
||||
|
|
@ -3478,7 +3563,7 @@ async fn add_remote_runtime_connection(
|
|||
Json(request): Json<AddRemoteRuntimeConnectionRequest>,
|
||||
) -> ApiResult<Json<RuntimeConnectionMutationResponse>> {
|
||||
validate_runtime_connection_request(&request)?;
|
||||
let mut local_config = load_workspace_backend_config_for_settings(&api)?;
|
||||
let mut runtime_config = load_backend_runtimes_config_for_settings(&api)?;
|
||||
let id = request.runtime_id.trim().to_string();
|
||||
if id == EMBEDDED_WORKER_RUNTIME_ID {
|
||||
return Err(settings_bad_request(
|
||||
|
|
@ -3496,7 +3581,7 @@ async fn add_remote_runtime_connection(
|
|||
"remote Runtime token_ref persistence is not supported by this v0 browser settings surface",
|
||||
));
|
||||
}
|
||||
if local_config
|
||||
if runtime_config
|
||||
.runtimes
|
||||
.remote
|
||||
.iter()
|
||||
|
|
@ -3538,12 +3623,12 @@ async fn add_remote_runtime_connection(
|
|||
)
|
||||
.map(|host| host.with_resource_broker(api.resource_broker.clone()))
|
||||
.map_err(|err| err.into_error())?;
|
||||
local_config.runtimes.remote.push(remote_config);
|
||||
write_workspace_backend_config_for_settings(&api, &local_config)?;
|
||||
runtime_config.runtimes.remote.push(remote_config);
|
||||
write_backend_runtimes_config_for_settings(&api, &runtime_config)?;
|
||||
api.runtime.register_or_replace(active_runtime);
|
||||
let mut response = runtime_connection_mutation_response(
|
||||
&api,
|
||||
&local_config,
|
||||
&runtime_config,
|
||||
vec![settings_diagnostic(
|
||||
"runtime_registry_applied",
|
||||
DiagnosticSeverity::Info,
|
||||
|
|
@ -3551,9 +3636,9 @@ async fn add_remote_runtime_connection(
|
|||
)],
|
||||
);
|
||||
response.diagnostics.push(settings_diagnostic(
|
||||
"workspace_backend_config_rewritten",
|
||||
"backend_runtimes_config_rewritten",
|
||||
DiagnosticSeverity::Info,
|
||||
"Local Runtime connection config was rewritten from the typed schema; comments and formatting are not preserved in v0.",
|
||||
"Backend runtimes config was rewritten from the typed schema; comments and formatting are not preserved in v0.",
|
||||
));
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
|
@ -3568,13 +3653,13 @@ async fn delete_remote_runtime_connection(
|
|||
"the embedded Runtime is built in and cannot be deleted from remote Runtime config",
|
||||
));
|
||||
}
|
||||
let mut local_config = load_workspace_backend_config_for_settings(&api)?;
|
||||
let before = local_config.runtimes.remote.len();
|
||||
local_config
|
||||
let mut runtime_config = load_backend_runtimes_config_for_settings(&api)?;
|
||||
let before = runtime_config.runtimes.remote.len();
|
||||
runtime_config
|
||||
.runtimes
|
||||
.remote
|
||||
.retain(|remote| remote.id != runtime_id);
|
||||
if before == local_config.runtimes.remote.len() {
|
||||
if before == runtime_config.runtimes.remote.len() {
|
||||
return Err(Error::UnknownRuntime(runtime_id).into());
|
||||
}
|
||||
match api
|
||||
|
|
@ -3605,10 +3690,10 @@ async fn delete_remote_runtime_connection(
|
|||
));
|
||||
}
|
||||
}
|
||||
write_workspace_backend_config_for_settings(&api, &local_config)?;
|
||||
write_backend_runtimes_config_for_settings(&api, &runtime_config)?;
|
||||
let mut response = runtime_connection_mutation_response(
|
||||
&api,
|
||||
&local_config,
|
||||
&runtime_config,
|
||||
vec![settings_diagnostic(
|
||||
"runtime_registry_applied",
|
||||
DiagnosticSeverity::Info,
|
||||
|
|
@ -3616,9 +3701,9 @@ async fn delete_remote_runtime_connection(
|
|||
)],
|
||||
);
|
||||
response.diagnostics.push(settings_diagnostic(
|
||||
"workspace_backend_config_rewritten",
|
||||
"backend_runtimes_config_rewritten",
|
||||
DiagnosticSeverity::Info,
|
||||
"Local Runtime connection config was rewritten from the typed schema; comments and formatting are not preserved in v0.",
|
||||
"Backend runtimes config was rewritten from the typed schema; comments and formatting are not preserved in v0.",
|
||||
));
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
|
@ -3627,8 +3712,8 @@ async fn test_remote_runtime_connection(
|
|||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(runtime_id): AxumPath<String>,
|
||||
) -> ApiResult<Json<RemoteRuntimeTestResponse>> {
|
||||
let local_config = load_workspace_backend_config_for_settings(&api)?;
|
||||
let remote = local_config
|
||||
let runtime_config = load_backend_runtimes_config_for_settings(&api)?;
|
||||
let remote = runtime_config
|
||||
.runtimes
|
||||
.remote
|
||||
.iter()
|
||||
|
|
@ -4483,27 +4568,37 @@ fn workers_response(api: WorkspaceApi) -> ApiResult<RuntimeListResponse<WorkerSu
|
|||
})
|
||||
}
|
||||
|
||||
fn load_workspace_backend_config_for_settings(
|
||||
fn load_backend_runtimes_config_for_settings(
|
||||
api: &WorkspaceApi,
|
||||
) -> ApiResult<WorkspaceBackendConfigFile> {
|
||||
WorkspaceBackendConfigFile::load_for_workspace(&api.config.workspace_root).map_err(|error| {
|
||||
) -> ApiResult<BackendRuntimesConfigFile> {
|
||||
api.config
|
||||
.runtime_config_path
|
||||
.as_ref()
|
||||
.map(BackendRuntimesConfigFile::load_from_path)
|
||||
.transpose()
|
||||
.map_err(|error| {
|
||||
Error::Config(format!(
|
||||
"failed to read workspace backend local config for Runtime connections: {}",
|
||||
"failed to read Backend runtimes config for Runtime connections: {}",
|
||||
sanitize_backend_error(&error.to_string())
|
||||
))
|
||||
.into()
|
||||
})
|
||||
.map(|config| config.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn write_workspace_backend_config_for_settings(
|
||||
fn write_backend_runtimes_config_for_settings(
|
||||
api: &WorkspaceApi,
|
||||
local_config: &WorkspaceBackendConfigFile,
|
||||
runtime_config: &BackendRuntimesConfigFile,
|
||||
) -> ApiResult<()> {
|
||||
local_config
|
||||
.write_for_workspace(&api.config.workspace_root)
|
||||
.map_err(|error| {
|
||||
let path = api.config.runtime_config_path.as_ref().ok_or_else(|| {
|
||||
Error::Config(
|
||||
"Backend runtimes config path is unavailable; set YOI_CONFIG_DIR, YOI_HOME, XDG_CONFIG_HOME, or HOME"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
runtime_config.write_to_path(path).map_err(|error| {
|
||||
Error::Config(format!(
|
||||
"failed to write workspace backend local config for Runtime connections: {}",
|
||||
"failed to write Backend runtimes config for Runtime connections: {}",
|
||||
sanitize_backend_error(&error.to_string())
|
||||
))
|
||||
.into()
|
||||
|
|
@ -4512,25 +4607,25 @@ fn write_workspace_backend_config_for_settings(
|
|||
|
||||
fn runtime_connection_settings_response(
|
||||
api: &WorkspaceApi,
|
||||
local_config: &WorkspaceBackendConfigFile,
|
||||
runtime_config: &BackendRuntimesConfigFile,
|
||||
) -> RuntimeConnectionSettingsResponse {
|
||||
RuntimeConnectionSettingsResponse {
|
||||
workspace_id: api.config.workspace_id.clone(),
|
||||
embedded: embedded_runtime_connection_summary(api),
|
||||
remotes: remote_runtime_connection_summaries(api, local_config, false),
|
||||
remotes: remote_runtime_connection_summaries(api, runtime_config, false),
|
||||
diagnostics: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_connection_mutation_response(
|
||||
api: &WorkspaceApi,
|
||||
local_config: &WorkspaceBackendConfigFile,
|
||||
runtime_config: &BackendRuntimesConfigFile,
|
||||
diagnostics: Vec<RuntimeDiagnostic>,
|
||||
) -> RuntimeConnectionMutationResponse {
|
||||
RuntimeConnectionMutationResponse {
|
||||
workspace_id: api.config.workspace_id.clone(),
|
||||
restart_required: false,
|
||||
remotes: remote_runtime_connection_summaries(api, local_config, false),
|
||||
remotes: remote_runtime_connection_summaries(api, runtime_config, false),
|
||||
diagnostics,
|
||||
}
|
||||
}
|
||||
|
|
@ -4576,14 +4671,14 @@ fn embedded_runtime_connection_summary(api: &WorkspaceApi) -> RuntimeConnectionS
|
|||
|
||||
fn remote_runtime_connection_summaries(
|
||||
api: &WorkspaceApi,
|
||||
local_config: &WorkspaceBackendConfigFile,
|
||||
runtime_config: &BackendRuntimesConfigFile,
|
||||
restart_required: bool,
|
||||
) -> Vec<RemoteRuntimeConnectionSummary> {
|
||||
let live_runtimes = api
|
||||
.runtime
|
||||
.list_runtimes(api.config.max_records.min(200))
|
||||
.items;
|
||||
local_config
|
||||
runtime_config
|
||||
.runtimes
|
||||
.remote
|
||||
.iter()
|
||||
|
|
@ -6191,6 +6286,7 @@ impl IntoResponse for ApiError {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::WorkspaceBackendRuntimesConfig;
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::Request;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
|
|
@ -6750,6 +6846,7 @@ mod tests {
|
|||
let store_root = workspace_root.join(".test-embedded-runtime-store");
|
||||
let mut config = ServerConfig::local_dev(workspace_root.clone(), test_identity())
|
||||
.with_embedded_runtime_store_root(store_root);
|
||||
config.runtime_config_path = Some(workspace_root.join(".test-config/runtimes.toml"));
|
||||
config.repositories = vec![ConfiguredRepository {
|
||||
id: TEST_REPOSITORY_ID.to_string(),
|
||||
provider: "git".to_string(),
|
||||
|
|
@ -6796,7 +6893,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_backend_endpoint_uses_workspace_settings_backend_root() {
|
||||
async fn ticket_backend_endpoint_uses_workspace_sqlite_backend() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(dir.path().join(".yoi")).unwrap();
|
||||
fs::write(
|
||||
|
|
@ -6809,7 +6906,7 @@ mod tests {
|
|||
let api = test_api(dir.path()).await;
|
||||
|
||||
let Json(response) = scoped_ticket_backend_operation(
|
||||
State(api),
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedWorkspacePath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
}),
|
||||
|
|
@ -6826,12 +6923,13 @@ mod tests {
|
|||
} => ticket_ref,
|
||||
other => panic!("unexpected ticket backend response: {other:?}"),
|
||||
};
|
||||
assert!(api.config.database_path.is_file());
|
||||
assert!(
|
||||
dir.path()
|
||||
!dir.path()
|
||||
.join("server-tickets")
|
||||
.join(&ticket_ref.id)
|
||||
.join("item.md")
|
||||
.is_file()
|
||||
.exists()
|
||||
);
|
||||
assert!(
|
||||
!dir.path()
|
||||
|
|
@ -7423,7 +7521,10 @@ mod tests {
|
|||
let projected = serde_json::to_string(&added).unwrap();
|
||||
assert!(!projected.contains("runtime.example.invalid"));
|
||||
|
||||
let persisted = WorkspaceBackendConfigFile::load_for_workspace(dir.path()).unwrap();
|
||||
let persisted = BackendRuntimesConfigFile::load_from_path(
|
||||
dir.path().join(".test-config/runtimes.toml"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(persisted.runtimes.remote.len(), 1);
|
||||
assert_eq!(persisted.runtimes.remote[0].id, "team-runtime");
|
||||
assert_eq!(
|
||||
|
|
@ -7462,7 +7563,10 @@ mod tests {
|
|||
.iter()
|
||||
.any(|runtime| runtime["runtime_id"] == "team-runtime")
|
||||
);
|
||||
let persisted = WorkspaceBackendConfigFile::load_for_workspace(dir.path()).unwrap();
|
||||
let persisted = BackendRuntimesConfigFile::load_from_path(
|
||||
dir.path().join(".test-config/runtimes.toml"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(persisted.runtimes.remote.is_empty());
|
||||
}
|
||||
|
||||
|
|
@ -7524,7 +7628,10 @@ mod tests {
|
|||
.iter()
|
||||
.any(|diagnostic| { diagnostic["code"] == "remote_runtime_delete_blocked" })
|
||||
);
|
||||
let persisted = WorkspaceBackendConfigFile::load_for_workspace(dir.path()).unwrap();
|
||||
let persisted = BackendRuntimesConfigFile::load_from_path(
|
||||
dir.path().join(".test-config/runtimes.toml"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(persisted.runtimes.remote.len(), 1);
|
||||
}
|
||||
|
||||
|
|
@ -7545,8 +7652,8 @@ mod tests {
|
|||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let endpoint = format!("http://{runtime_addr}");
|
||||
WorkspaceBackendConfigFile {
|
||||
runtimes: crate::config::WorkspaceBackendRuntimesConfig {
|
||||
BackendRuntimesConfigFile {
|
||||
runtimes: WorkspaceBackendRuntimesConfig {
|
||||
remote: vec![RemoteRuntimeConfigFile {
|
||||
id: "probe-runtime".to_string(),
|
||||
endpoint: endpoint.clone(),
|
||||
|
|
@ -7554,9 +7661,8 @@ mod tests {
|
|||
token_ref: None,
|
||||
}],
|
||||
},
|
||||
..WorkspaceBackendConfigFile::default()
|
||||
}
|
||||
.write_for_workspace(dir.path())
|
||||
.write_to_path(dir.path().join(".test-config/runtimes.toml"))
|
||||
.unwrap();
|
||||
let app = test_app(dir.path()).await;
|
||||
|
||||
|
|
@ -7610,8 +7716,8 @@ mod tests {
|
|||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let endpoint = format!("http://{runtime_addr}");
|
||||
WorkspaceBackendConfigFile {
|
||||
runtimes: crate::config::WorkspaceBackendRuntimesConfig {
|
||||
BackendRuntimesConfigFile {
|
||||
runtimes: WorkspaceBackendRuntimesConfig {
|
||||
remote: vec![RemoteRuntimeConfigFile {
|
||||
id: "control-only-runtime".to_string(),
|
||||
display_name: Some("Control-only Runtime".to_string()),
|
||||
|
|
@ -7619,9 +7725,8 @@ mod tests {
|
|||
token_ref: None,
|
||||
}],
|
||||
},
|
||||
..WorkspaceBackendConfigFile::default()
|
||||
}
|
||||
.write_for_workspace(dir.path())
|
||||
.write_to_path(dir.path().join(".test-config/runtimes.toml"))
|
||||
.unwrap();
|
||||
let app = test_app(dir.path()).await;
|
||||
|
||||
|
|
@ -7963,7 +8068,7 @@ mod tests {
|
|||
assert_eq!(repositories["items"][0]["kind"], "git");
|
||||
assert_eq!(
|
||||
repositories["items"][0]["record_authority"],
|
||||
"workspace-backend-config"
|
||||
"workspace-control-plane"
|
||||
);
|
||||
assert!(
|
||||
repositories
|
||||
|
|
|
|||
|
|
@ -82,6 +82,21 @@ pub struct WorkspaceRecord {
|
|||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RepositoryRecord {
|
||||
pub workspace_id: String,
|
||||
pub repository_id: String,
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
pub provider: Option<String>,
|
||||
pub uri: String,
|
||||
pub default_ref: Option<String>,
|
||||
pub auth_ref_kind: Option<String>,
|
||||
pub auth_ref_key: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct AccountRecord {
|
||||
pub account_id: String,
|
||||
|
|
@ -211,6 +226,8 @@ 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>>;
|
||||
fn upsert_repository(&self, record: &RepositoryRecord) -> Result<()>;
|
||||
fn list_repositories(&self, workspace_id: &str) -> Result<Vec<RepositoryRecord>>;
|
||||
|
||||
fn upsert_account(&self, record: &AccountRecord) -> Result<()>;
|
||||
fn get_account(&self, account_id: &str) -> Result<Option<AccountRecord>>;
|
||||
|
|
@ -399,6 +416,56 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||
})
|
||||
}
|
||||
|
||||
fn upsert_repository(&self, record: &RepositoryRecord) -> Result<()> {
|
||||
self.with_conn(|conn| {
|
||||
conn.execute(
|
||||
r#"INSERT INTO repositories (
|
||||
workspace_id, repository_id, name, kind, provider, uri, default_ref,
|
||||
auth_ref_kind, auth_ref_key, created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
|
||||
ON CONFLICT(repository_id) DO UPDATE SET
|
||||
workspace_id = excluded.workspace_id,
|
||||
name = excluded.name,
|
||||
kind = excluded.kind,
|
||||
provider = excluded.provider,
|
||||
uri = excluded.uri,
|
||||
default_ref = excluded.default_ref,
|
||||
auth_ref_kind = excluded.auth_ref_kind,
|
||||
auth_ref_key = excluded.auth_ref_key,
|
||||
updated_at = excluded.updated_at"#,
|
||||
params![
|
||||
record.workspace_id,
|
||||
record.repository_id,
|
||||
record.name,
|
||||
record.kind,
|
||||
record.provider,
|
||||
record.uri,
|
||||
record.default_ref,
|
||||
record.auth_ref_kind,
|
||||
record.auth_ref_key,
|
||||
record.created_at,
|
||||
record.updated_at,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn list_repositories(&self, workspace_id: &str) -> Result<Vec<RepositoryRecord>> {
|
||||
self.with_conn(|conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
r#"SELECT workspace_id, repository_id, name, kind, provider, uri, default_ref,
|
||||
auth_ref_kind, auth_ref_key, created_at, updated_at
|
||||
FROM repositories
|
||||
WHERE workspace_id = ?1
|
||||
ORDER BY repository_id ASC"#,
|
||||
)?;
|
||||
let rows = stmt.query_map(params![workspace_id], read_repository_record)?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(Error::from)
|
||||
})
|
||||
}
|
||||
|
||||
fn upsert_account(&self, record: &AccountRecord) -> Result<()> {
|
||||
self.with_conn(|conn| {
|
||||
conn.execute(
|
||||
|
|
@ -1019,6 +1086,22 @@ fn read_workspace_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<WorkspaceR
|
|||
})
|
||||
}
|
||||
|
||||
fn read_repository_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<RepositoryRecord> {
|
||||
Ok(RepositoryRecord {
|
||||
workspace_id: row.get(0)?,
|
||||
repository_id: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
kind: row.get(3)?,
|
||||
provider: row.get(4)?,
|
||||
uri: row.get(5)?,
|
||||
default_ref: row.get(6)?,
|
||||
auth_ref_kind: row.get(7)?,
|
||||
auth_ref_key: row.get(8)?,
|
||||
created_at: row.get(9)?,
|
||||
updated_at: row.get(10)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn account_select_sql(where_clause: &str) -> String {
|
||||
format!(
|
||||
"SELECT account_id, kind, handle, display_name, created_at, updated_at FROM accounts {where_clause}"
|
||||
|
|
@ -2274,6 +2357,44 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repository_records_round_trip() {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
assert_eq!(store.schema_version().await.unwrap(), 9);
|
||||
let workspace = WorkspaceRecord {
|
||||
workspace_id: "local-dev".to_string(),
|
||||
owner_account_id: None,
|
||||
display_name: "Local Dev".to_string(),
|
||||
state: "active".to_string(),
|
||||
created_at: "1".to_string(),
|
||||
updated_at: "1".to_string(),
|
||||
};
|
||||
store.upsert_workspace(&workspace).await.unwrap();
|
||||
|
||||
let repository = RepositoryRecord {
|
||||
workspace_id: "local-dev".to_string(),
|
||||
repository_id: "main".to_string(),
|
||||
name: "Yoi".to_string(),
|
||||
kind: "git".to_string(),
|
||||
provider: Some("git".to_string()),
|
||||
uri: ".".to_string(),
|
||||
default_ref: Some("HEAD".to_string()),
|
||||
auth_ref_kind: None,
|
||||
auth_ref_key: None,
|
||||
created_at: "2".to_string(),
|
||||
updated_at: "2".to_string(),
|
||||
};
|
||||
store.upsert_repository(&repository).unwrap();
|
||||
assert_eq!(
|
||||
store.list_repositories("local-dev").unwrap(),
|
||||
vec![repository]
|
||||
);
|
||||
assert_eq!(
|
||||
store.list_repositories("other-workspace").unwrap(),
|
||||
Vec::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_workdir_registry_round_trips_and_preserves_pinned_retention() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -1053,6 +1053,12 @@ fn parse_workspace_args(args: &[String]) -> Result<Mode, ParseError> {
|
|||
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
|
||||
return Ok(Mode::WorkspaceHelp);
|
||||
}
|
||||
if !has_workspace_option(rest) {
|
||||
return Err(ParseError(
|
||||
"yoi workspace serve requires --workspace <PATH>; cwd is no longer forwarded as an implicit workspace"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Mode::WorkspaceServer {
|
||||
subcommand: "serve".to_string(),
|
||||
args: rest.to_vec(),
|
||||
|
|
@ -1065,6 +1071,11 @@ fn parse_workspace_args(args: &[String]) -> Result<Mode, ParseError> {
|
|||
}
|
||||
}
|
||||
|
||||
fn has_workspace_option(args: &[String]) -> bool {
|
||||
args.iter()
|
||||
.any(|arg| arg == "--workspace" || arg.starts_with("--workspace="))
|
||||
}
|
||||
|
||||
fn run_workspace_server(subcommand: &str, args: Vec<String>) -> ExitCode {
|
||||
let command = match resolve_workspace_server_command() {
|
||||
Ok(command) => command,
|
||||
|
|
@ -1442,13 +1453,13 @@ fn print_help() {
|
|||
println!(
|
||||
"yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace <PATH>] [--all]\n yoi workers [--workspace <PATH>] [--workspace-id <ID>] [--backend <URL>] [--runtime-id <ID>]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi worker [WORKER_OPTIONS]\n yoi worker delete <NAME> [--force] [--dry-run]\n yoi worker prune --older-than <DURATION> [--force] [--dry-run]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi session prune --unreferenced [--older-than <DURATION>] [--force] [--dry-run]\n yoi ticket <COMMAND> [OPTIONS]\n yoi workspace init [OPTIONS]
|
||||
yoi workspace config <COMMAND> [OPTIONS]
|
||||
yoi workspace serve [OPTIONS]\n yoi plugin new <rust-component-tool|rust-component-service> <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp show <SERVER> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Worker chat/client surface (default, --worker, yoi resume, Backend Runtime target)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace <PATH> Runtime workspace root for default Console/--worker/workers (defaults to cwd)\n --workspace-id <ID> Workspace identity for Backend scoped routes\n --backend <URL> Workspace Backend API URL for Backend Runtime attach/list\n --runtime-id <ID> Backend Runtime identity for attach/list\n --worker <NAME> Open the Worker Console by name (attach/restore/create)\n --socket <PATH> Attach a Worker Console to a specific socket with --worker\n --session <UUID> Resume a specific session segment in the Worker Console\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
|
||||
yoi workspace serve --workspace <PATH> [OPTIONS]\n yoi plugin new <rust-component-tool|rust-component-service> <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp show <SERVER> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Worker chat/client surface (default, --worker, yoi resume, Backend Runtime target)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace <PATH> Runtime workspace root for default Console/--worker/workers (defaults to cwd)\n --workspace-id <ID> Workspace identity for Backend scoped routes\n --backend <URL> Workspace Backend API URL for Backend Runtime attach/list\n --runtime-id <ID> Backend Runtime identity for attach/list\n --worker <NAME> Open the Worker Console by name (attach/restore/create)\n --socket <PATH> Attach a Worker Console to a specific socket with --worker\n --session <UUID> Resume a specific session segment in the Worker Console\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
|
||||
);
|
||||
}
|
||||
|
||||
fn print_workspace_help() {
|
||||
println!(
|
||||
"yoi workspace\n\nUsage:\n yoi workspace init [OPTIONS]\n yoi workspace config <COMMAND> [OPTIONS]\n yoi workspace serve [OPTIONS]\n\nDescription:\n Launches the separate yoi-workspace-server executable. The yoi binary does not link the workspace server crate.\n\nSubcommands:\n init Initialize .yoi/workspace.toml and .yoi/workspace-backend.local.toml\n config default Print the latest packaged Backend config template\n config diff Compare workspace local config with the packaged template\n serve Serve an already initialized Workspace\n\nOptions forwarded to init/config diff/serve:\n --workspace <PATH> Workspace root (defaults to cwd)\n\nLegacy dev options forwarded to serve:\n --db <PATH> SQLite database path override\n --frontend <PATH> Static SPA build directory to serve\n --listen <ADDR> Listen address override\n -h, --help Print help\n\nEnvironment:\n YOI_WORKSPACE_SERVER_COMMAND Path to yoi-workspace-server executable override\n"
|
||||
"yoi workspace\n\nUsage:\n yoi workspace init [OPTIONS]\n yoi workspace config <COMMAND> [OPTIONS]\n yoi workspace serve --workspace <PATH> [OPTIONS]\n\nDescription:\n Launches the separate yoi-workspace-server executable. The yoi binary does not link the workspace server crate. `serve` requires an explicit workspace path and never forwards cwd as an implicit backend workspace.\n\nSubcommands:\n init Initialize .yoi/workspace.toml and .yoi/workspace-backend.local.toml\n config default Print the latest packaged Backend config template\n config diff Compare workspace local config with the packaged template\n serve Serve an explicitly selected Workspace\n\nOptions forwarded to init/config diff:\n --workspace <PATH> Workspace root (defaults to cwd)\n\nOptions forwarded to serve:\n --workspace <PATH> Workspace root (required)\n\nLegacy dev options forwarded to serve:\n --db <PATH> SQLite database path override\n --frontend <PATH> Static SPA build directory to serve\n --listen <ADDR> Listen address override\n -h, --help Print help\n\nEnvironment:\n YOI_WORKSPACE_SERVER_COMMAND Path to yoi-workspace-server executable override\n"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1689,15 +1700,36 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn parse_workspace_serve_passthrough() {
|
||||
match parse_args_from(["workspace", "serve", "--listen", "127.0.0.1:0"]).unwrap() {
|
||||
match parse_args_from([
|
||||
"workspace",
|
||||
"serve",
|
||||
"--workspace",
|
||||
"/tmp/ws",
|
||||
"--listen",
|
||||
"127.0.0.1:0",
|
||||
])
|
||||
.unwrap()
|
||||
{
|
||||
Mode::WorkspaceServer { subcommand, args } => {
|
||||
assert_eq!(subcommand, "serve");
|
||||
assert_eq!(args, vec!["--listen", "127.0.0.1:0"]);
|
||||
assert_eq!(
|
||||
args,
|
||||
vec!["--workspace", "/tmp/ws", "--listen", "127.0.0.1:0"]
|
||||
);
|
||||
}
|
||||
other => panic!("unexpected mode: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_workspace_serve_requires_explicit_workspace() {
|
||||
let err = parse_args_from(["workspace", "serve", "--listen", "127.0.0.1:0"]).unwrap_err();
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"yoi workspace serve requires --workspace <PATH>; cwd is no longer forwarded as an implicit workspace"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_workspace_init_passthrough() {
|
||||
match parse_args_from(["workspace", "init", "--workspace", "/tmp/ws"]).unwrap() {
|
||||
|
|
|
|||
|
|
@ -6,14 +6,14 @@ use std::path::{Path, PathBuf};
|
|||
|
||||
use chrono::{SecondsFormat, Utc};
|
||||
use ticket::config::{
|
||||
DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TICKET_CONFIG_RELATIVE_PATH, TicketConfig,
|
||||
WORKSPACE_SETTINGS_RELATIVE_PATH, ticket_config_scaffold,
|
||||
TICKET_CONFIG_RELATIVE_PATH, TicketConfig, WORKSPACE_SETTINGS_RELATIVE_PATH,
|
||||
ticket_config_scaffold,
|
||||
};
|
||||
use ticket::{
|
||||
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation, TicketBackend,
|
||||
TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug, TicketIntakeSummary, TicketListQuery,
|
||||
TicketListState, TicketRelationKind, TicketReview, TicketReviewResult, TicketSummary,
|
||||
TicketWorkflowState,
|
||||
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation,
|
||||
SqliteTicketBackend, TicketBackend, TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug,
|
||||
TicketIntakeSummary, TicketListQuery, TicketListState, TicketRelationKind, TicketReview,
|
||||
TicketReviewResult, TicketSummary, TicketWorkflowState,
|
||||
};
|
||||
|
||||
const DEFAULT_LIST_LIMIT: usize = 50;
|
||||
|
|
@ -30,6 +30,7 @@ pub enum TicketCli {
|
|||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TicketCommand {
|
||||
Init,
|
||||
ImportLocal,
|
||||
Create(CreateOptions),
|
||||
List(ListOptions),
|
||||
Show { query: String },
|
||||
|
|
@ -179,6 +180,14 @@ pub fn parse_ticket_args(args: &[String]) -> Result<TicketCli, TicketCliError> {
|
|||
}
|
||||
TicketCommand::Init
|
||||
}
|
||||
"import-local" => {
|
||||
if args.len() != 1 {
|
||||
return Err(TicketCliError::new(
|
||||
"ticket import-local takes no arguments",
|
||||
));
|
||||
}
|
||||
TicketCommand::ImportLocal
|
||||
}
|
||||
"create" => TicketCommand::Create(parse_create(&args[1..])?),
|
||||
"list" => TicketCommand::List(parse_list(&args[1..])?),
|
||||
"show" => TicketCommand::Show {
|
||||
|
|
@ -232,19 +241,22 @@ fn run_command(
|
|||
) -> Result<TicketCliOutput, TicketCliError> {
|
||||
match command {
|
||||
TicketCommand::Init => init(workspace),
|
||||
TicketCommand::ImportLocal => import_local(workspace),
|
||||
command => {
|
||||
let backend = backend_for_workspace(workspace)?;
|
||||
match command {
|
||||
TicketCommand::Create(options) => create(&backend, options),
|
||||
TicketCommand::List(options) => list(&backend, options),
|
||||
TicketCommand::Show { query } => show(&backend, query),
|
||||
TicketCommand::Comment(options) => comment(&backend, options),
|
||||
TicketCommand::Review(options) => review(&backend, options),
|
||||
TicketCommand::State(options) => state(&backend, options),
|
||||
TicketCommand::Close(options) => close(&backend, options),
|
||||
TicketCommand::Relation(options) => relation(&backend, options),
|
||||
TicketCommand::Doctor => doctor(&backend),
|
||||
TicketCommand::Init => unreachable!("init handled before backend setup"),
|
||||
TicketCommand::Create(options) => create(backend.as_ref(), options),
|
||||
TicketCommand::List(options) => list(backend.as_ref(), options),
|
||||
TicketCommand::Show { query } => show(backend.as_ref(), query),
|
||||
TicketCommand::Comment(options) => comment(backend.as_ref(), options),
|
||||
TicketCommand::Review(options) => review(backend.as_ref(), options),
|
||||
TicketCommand::State(options) => state(backend.as_ref(), options),
|
||||
TicketCommand::Close(options) => close(backend.as_ref(), options),
|
||||
TicketCommand::Relation(options) => relation(backend.as_ref(), options),
|
||||
TicketCommand::Doctor => doctor(backend.as_ref()),
|
||||
TicketCommand::Init | TicketCommand::ImportLocal => {
|
||||
unreachable!("handled before backend setup")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -262,9 +274,6 @@ fn init(workspace: &Path) -> Result<TicketCliOutput, TicketCliError> {
|
|||
|
||||
let yoi_dir = workspace.join(".yoi");
|
||||
fs::create_dir_all(&yoi_dir)?;
|
||||
let tickets_dir = workspace.join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH);
|
||||
fs::create_dir_all(&tickets_dir)?;
|
||||
fs::write(tickets_dir.join(".gitkeep"), b"")?;
|
||||
|
||||
let settings_path = workspace.join(WORKSPACE_SETTINGS_RELATIVE_PATH);
|
||||
let scaffold = ticket_config_scaffold();
|
||||
|
|
@ -311,8 +320,8 @@ fn init(workspace: &Path) -> Result<TicketCliOutput, TicketCliError> {
|
|||
"updated"
|
||||
};
|
||||
Ok(success(format!(
|
||||
"{verb}\t{}\nensured\t{}\n",
|
||||
WORKSPACE_SETTINGS_RELATIVE_PATH, DEFAULT_TICKET_BACKEND_RELATIVE_PATH
|
||||
"{verb}\t{}\nbackend\tworkspace-sqlite\n",
|
||||
WORKSPACE_SETTINGS_RELATIVE_PATH
|
||||
)))
|
||||
}
|
||||
|
||||
|
|
@ -356,14 +365,92 @@ fn workspace_settings_uuid_v7(workspace: &Path) -> String {
|
|||
)
|
||||
}
|
||||
|
||||
fn backend_for_workspace(workspace: &Path) -> Result<LocalTicketBackend, TicketCliError> {
|
||||
fn backend_for_workspace(workspace: &Path) -> Result<Box<dyn TicketBackend>, TicketCliError> {
|
||||
let config = TicketConfig::load_workspace(workspace)?;
|
||||
Ok(LocalTicketBackend::new(config.backend_root().to_path_buf())
|
||||
.with_record_language(config.ticket_record_language()))
|
||||
let workspace_id = workspace_id_for_workspace(workspace)?;
|
||||
let db_path = workspace_ticket_database_path(workspace, &workspace_id)?;
|
||||
Ok(Box::new(
|
||||
SqliteTicketBackend::new(db_path, workspace_id)
|
||||
.with_record_language(config.ticket_record_language()),
|
||||
))
|
||||
}
|
||||
|
||||
fn import_local(workspace: &Path) -> Result<TicketCliOutput, TicketCliError> {
|
||||
let config = TicketConfig::load_workspace(workspace)?;
|
||||
let local = LocalTicketBackend::new(config.backend_root().to_path_buf())
|
||||
.with_record_language(config.ticket_record_language());
|
||||
let workspace_id = workspace_id_for_workspace(workspace)?;
|
||||
let db_path = workspace_ticket_database_path(workspace, &workspace_id)?;
|
||||
let sqlite = SqliteTicketBackend::new(db_path.clone(), workspace_id)
|
||||
.with_record_language(config.ticket_record_language());
|
||||
sqlite.import_from_local_backend(&local)?;
|
||||
Ok(success(format!(
|
||||
"imported\t{}\nbackend\t{}\n",
|
||||
config.backend_root().display(),
|
||||
db_path.display()
|
||||
)))
|
||||
}
|
||||
|
||||
fn workspace_id_for_workspace(workspace: &Path) -> Result<String, TicketCliError> {
|
||||
let settings_path = workspace.join(WORKSPACE_SETTINGS_RELATIVE_PATH);
|
||||
let raw = fs::read_to_string(&settings_path).map_err(|error| {
|
||||
TicketCliError::new(format!(
|
||||
"failed to read workspace settings {}: {error}",
|
||||
settings_path.display()
|
||||
))
|
||||
})?;
|
||||
let value: toml::Value = toml::from_str(&raw).map_err(|error| {
|
||||
TicketCliError::new(format!(
|
||||
"failed to parse workspace settings {}: {error}",
|
||||
settings_path.display()
|
||||
))
|
||||
})?;
|
||||
value
|
||||
.get("workspace_id")
|
||||
.and_then(toml::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| {
|
||||
TicketCliError::new(format!(
|
||||
"workspace settings {} must contain workspace_id for SQLite Ticket backend",
|
||||
settings_path.display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn workspace_ticket_database_path(
|
||||
workspace: &Path,
|
||||
workspace_id: &str,
|
||||
) -> Result<PathBuf, TicketCliError> {
|
||||
#[cfg(not(test))]
|
||||
let _ = workspace;
|
||||
|
||||
#[cfg(test)]
|
||||
{
|
||||
return Ok(workspace
|
||||
.join(".test-yoi-data")
|
||||
.join("workspace-server")
|
||||
.join(workspace_id)
|
||||
.join("workspace.db"));
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
let data_dir = manifest::paths::data_dir().ok_or_else(|| {
|
||||
TicketCliError::new(
|
||||
"could not resolve Yoi data directory for SQLite Ticket backend (set YOI_DATA_DIR, YOI_HOME, XDG_DATA_HOME, or HOME)",
|
||||
)
|
||||
})?;
|
||||
Ok(data_dir
|
||||
.join("workspace-server")
|
||||
.join(workspace_id)
|
||||
.join("workspace.db"))
|
||||
}
|
||||
}
|
||||
|
||||
fn create(
|
||||
backend: &LocalTicketBackend,
|
||||
backend: &dyn TicketBackend,
|
||||
options: CreateOptions,
|
||||
) -> Result<TicketCliOutput, TicketCliError> {
|
||||
let mut input = NewTicket::new(options.title);
|
||||
|
|
@ -374,7 +461,7 @@ fn create(
|
|||
}
|
||||
|
||||
fn list(
|
||||
backend: &LocalTicketBackend,
|
||||
backend: &dyn TicketBackend,
|
||||
options: ListOptions,
|
||||
) -> Result<TicketCliOutput, TicketCliError> {
|
||||
let filter = match options.state {
|
||||
|
|
@ -410,7 +497,7 @@ fn list(
|
|||
Ok(success(stdout))
|
||||
}
|
||||
|
||||
fn show(backend: &LocalTicketBackend, query: String) -> Result<TicketCliOutput, TicketCliError> {
|
||||
fn show(backend: &dyn TicketBackend, query: String) -> Result<TicketCliOutput, TicketCliError> {
|
||||
let ticket = backend.show(TicketIdOrSlug::Query(query))?;
|
||||
let mut stdout = String::new();
|
||||
stdout.push_str(&format!("# {}\n\n", ticket.meta.title));
|
||||
|
|
@ -545,7 +632,7 @@ fn is_obsolete_ticket_frontmatter_key(key: &str) -> bool {
|
|||
}
|
||||
|
||||
fn comment(
|
||||
backend: &LocalTicketBackend,
|
||||
backend: &dyn TicketBackend,
|
||||
options: CommentOptions,
|
||||
) -> Result<TicketCliOutput, TicketCliError> {
|
||||
let role = options.role.as_str().to_string();
|
||||
|
|
@ -556,7 +643,7 @@ fn comment(
|
|||
}
|
||||
|
||||
fn review(
|
||||
backend: &LocalTicketBackend,
|
||||
backend: &dyn TicketBackend,
|
||||
options: ReviewOptions,
|
||||
) -> Result<TicketCliOutput, TicketCliError> {
|
||||
let result = options.result.as_str().to_string();
|
||||
|
|
@ -573,7 +660,7 @@ fn review(
|
|||
}
|
||||
|
||||
fn state(
|
||||
backend: &LocalTicketBackend,
|
||||
backend: &dyn TicketBackend,
|
||||
options: StateOptions,
|
||||
) -> Result<TicketCliOutput, TicketCliError> {
|
||||
let id = TicketIdOrSlug::Query(options.query.clone());
|
||||
|
|
@ -626,7 +713,7 @@ fn state(
|
|||
}
|
||||
|
||||
fn close(
|
||||
backend: &LocalTicketBackend,
|
||||
backend: &dyn TicketBackend,
|
||||
options: CloseOptions,
|
||||
) -> Result<TicketCliOutput, TicketCliError> {
|
||||
backend.close(
|
||||
|
|
@ -637,7 +724,7 @@ fn close(
|
|||
}
|
||||
|
||||
fn relation(
|
||||
backend: &LocalTicketBackend,
|
||||
backend: &dyn TicketBackend,
|
||||
options: RelationOptions,
|
||||
) -> Result<TicketCliOutput, TicketCliError> {
|
||||
match options.action {
|
||||
|
|
@ -683,7 +770,7 @@ fn relation(
|
|||
}
|
||||
}
|
||||
|
||||
fn doctor(backend: &LocalTicketBackend) -> Result<TicketCliOutput, TicketCliError> {
|
||||
fn doctor(backend: &dyn TicketBackend) -> Result<TicketCliOutput, TicketCliError> {
|
||||
let report = backend.doctor()?;
|
||||
let mut stdout = String::new();
|
||||
if report.is_ok() {
|
||||
|
|
@ -1166,14 +1253,13 @@ fn default_author() -> String {
|
|||
}
|
||||
|
||||
fn help_text() -> &'static str {
|
||||
"yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket create --title <title>\n yoi ticket list [--state active|all|planning|ready|queued|inprogress|done|closed[,..]] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket review <id> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|done|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n `yoi ticket init` writes explicit fixed role profiles and optional [ticket].language into .yoi/workspace.toml.\n Uses workspace Ticket settings from .yoi/workspace.toml [ticket] when present; .yoi/ticket.config.toml is a read-only migration fallback only.\n Supported provider: builtin:yoi_local.\n Without configured Ticket settings, the local backend root is <cwd>/.yoi/tickets.\n"
|
||||
"yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket import-local\n yoi ticket create --title <title>\n yoi ticket list [--state active|all|planning|ready|queued|inprogress|done|closed[,..]] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket review <id> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|done|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n Tickets are stored in the workspace SQLite DB under the Yoi data directory.\n `yoi ticket import-local` imports the legacy .yoi/tickets backend root configured in .yoi/workspace.toml.\n `yoi ticket init` writes explicit fixed role profiles and optional [ticket].language into .yoi/workspace.toml, but does not create .yoi/tickets.\n"
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
use ticket::TicketEventKind;
|
||||
use ticket::config::TicketRole;
|
||||
|
||||
fn args(items: &[&str]) -> Vec<String> {
|
||||
|
|
@ -1181,6 +1267,13 @@ mod tests {
|
|||
}
|
||||
|
||||
fn run(temp: &TempDir, items: &[&str]) -> TicketCliOutput {
|
||||
if items.first().copied() != Some("init")
|
||||
&& !temp.path().join(WORKSPACE_SETTINGS_RELATIVE_PATH).exists()
|
||||
{
|
||||
let init_cli = parse_ticket_args(&args(&["init"])).unwrap();
|
||||
let init = run_in_workspace(init_cli, temp.path()).unwrap();
|
||||
assert_eq!(init.status, TicketCliStatus::Success);
|
||||
}
|
||||
let cli = parse_ticket_args(&args(items)).unwrap();
|
||||
run_in_workspace(cli, temp.path()).unwrap()
|
||||
}
|
||||
|
|
@ -1201,9 +1294,8 @@ mod tests {
|
|||
let initialized = run(&temp, &["init"]);
|
||||
assert_eq!(initialized.status, TicketCliStatus::Success);
|
||||
assert!(initialized.stdout.contains("created\t.yoi/workspace.toml"));
|
||||
assert!(initialized.stdout.contains("ensured\t.yoi/tickets"));
|
||||
assert!(temp.path().join(".yoi/tickets").exists());
|
||||
assert!(temp.path().join(".yoi/tickets/.gitkeep").exists());
|
||||
assert!(initialized.stdout.contains("backend\tworkspace-sqlite"));
|
||||
assert!(!temp.path().join(".yoi/tickets").exists());
|
||||
|
||||
let config = fs::read_to_string(temp.path().join(".yoi/workspace.toml")).unwrap();
|
||||
assert!(config.contains("workspace_id = \""));
|
||||
|
|
@ -1299,21 +1391,8 @@ mod tests {
|
|||
assert_eq!(created.status, TicketCliStatus::Success);
|
||||
assert!(created.stdout.contains("created\t"));
|
||||
let ticket_id = created_id(&created);
|
||||
assert!(temp.path().join(".yoi/tickets").join(&ticket_id).exists());
|
||||
assert!(!temp.path().join(".yoi/tickets").exists());
|
||||
assert!(!temp.path().join("work-items").exists());
|
||||
let created_item = fs::read_to_string(
|
||||
temp.path()
|
||||
.join(".yoi/tickets")
|
||||
.join(&ticket_id)
|
||||
.join("item.md"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(created_item.contains("state:"));
|
||||
assert!(created_item.contains("planning"));
|
||||
assert!(!created_item.contains("legacy_ticket:"));
|
||||
assert!(!created_item.contains("needs_preflight:"));
|
||||
assert!(!created_item.contains("slug:"));
|
||||
assert!(!created_item.contains("workflow_state:"));
|
||||
|
||||
let listed = run(&temp, &["list", "--state", "planning"]);
|
||||
assert!(listed.stdout.contains("state\tid\ttitle"));
|
||||
|
|
@ -1395,29 +1474,21 @@ mod tests {
|
|||
assert_eq!(doctor.status, TicketCliStatus::Success);
|
||||
assert_eq!(doctor.stdout, "doctor: ok\n");
|
||||
|
||||
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
||||
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id.clone())).unwrap();
|
||||
assert!(ticket.resolution.is_some());
|
||||
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Closed);
|
||||
assert!(
|
||||
ticket
|
||||
.events
|
||||
.iter()
|
||||
.any(|event| event.kind == TicketEventKind::ImplementationReport)
|
||||
);
|
||||
assert!(
|
||||
ticket
|
||||
.events
|
||||
.iter()
|
||||
.any(|event| event.kind == TicketEventKind::Review)
|
||||
);
|
||||
let final_show = run(&temp, &["show", &ticket_id]);
|
||||
assert!(final_show.stdout.contains("State: closed"));
|
||||
assert!(final_show.stdout.contains("Done via yoi ticket."));
|
||||
assert!(final_show.stdout.contains("implementation_report"));
|
||||
assert!(final_show.stdout.contains("review"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_cli_show_omits_obsolete_overlay_fields_from_legacy_frontmatter() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let created = run(&temp, &["create", "--title", "Legacy Overlay"]);
|
||||
let ticket_id = created_id(&created);
|
||||
let initialized = run(&temp, &["init"]);
|
||||
assert_eq!(initialized.status, TicketCliStatus::Success);
|
||||
let local = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
||||
let created = local.create(NewTicket::new("Legacy Overlay")).unwrap();
|
||||
let ticket_id = created.id;
|
||||
let item_path = temp
|
||||
.path()
|
||||
.join(".yoi/tickets")
|
||||
|
|
@ -1434,6 +1505,9 @@ mod tests {
|
|||
)
|
||||
.unwrap();
|
||||
|
||||
let imported = run(&temp, &["import-local"]);
|
||||
assert_eq!(imported.status, TicketCliStatus::Success);
|
||||
|
||||
let shown = run(&temp, &["show", &ticket_id]);
|
||||
assert_eq!(shown.status, TicketCliStatus::Success);
|
||||
assert!(shown.stdout.contains("# Legacy Overlay"));
|
||||
|
|
@ -1496,20 +1570,22 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_cli_uses_configured_backend_root() {
|
||||
fn ticket_cli_import_local_uses_configured_backend_root() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
fs::create_dir_all(temp.path().join(".yoi")).unwrap();
|
||||
fs::write(
|
||||
temp.path().join(".yoi/workspace.toml"),
|
||||
"[ticket]\nlanguage = \"Japanese\"\n\n[ticket.backend]\nprovider = \"builtin:yoi_local\"\nroot = \"custom-tickets\"\n",
|
||||
"workspace_id = \"workspace-test\"\n\n[ticket]\nlanguage = \"Japanese\"\n\n[ticket.backend]\nprovider = \"builtin:yoi_local\"\nroot = \"custom-tickets\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let local = LocalTicketBackend::new(temp.path().join("custom-tickets"));
|
||||
let created = local.create(NewTicket::new("Configured Root")).unwrap();
|
||||
|
||||
let created = run(&temp, &["create", "--title", "Configured Root"]);
|
||||
let ticket_id = created_id(&created);
|
||||
|
||||
assert!(temp.path().join("custom-tickets").join(ticket_id).exists());
|
||||
assert!(!temp.path().join("custom-tickets/open").exists());
|
||||
let imported = run(&temp, &["import-local"]);
|
||||
assert_eq!(imported.status, TicketCliStatus::Success);
|
||||
let shown = run(&temp, &["show", &created.id]);
|
||||
assert_eq!(shown.status, TicketCliStatus::Success);
|
||||
assert!(shown.stdout.contains("# Configured Root"));
|
||||
assert!(!temp.path().join("work-items").exists());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -60,11 +60,5 @@ cookie_name = "yoi_workspace_session"
|
|||
# display_name = "Main repository"
|
||||
# default_selector = "HEAD"
|
||||
|
||||
# Remote Runtime sources. Token values must not be written here.
|
||||
# Use token_ref only after secret-ref resolution is implemented for this config.
|
||||
#
|
||||
# [[runtimes.remote]]
|
||||
# id = "example"
|
||||
# endpoint = "http://127.0.0.1:38800"
|
||||
# display_name = "Example Runtime"
|
||||
# token_ref = "local:example-runtime-token"
|
||||
# Runtime registrations live in `$XDG_CONFIG_HOME/yoi/runtimes.toml`
|
||||
# or `YOI_CONFIG_DIR/runtimes.toml`, not in workspace backend config.
|
||||
|
|
|
|||
77
tmp-migrate-home-yoi-data-dir.sh
Normal file
77
tmp-migrate-home-yoi-data-dir.sh
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
# Move the legacy home data root (`$HOME/.yoi`) to the current Yoi data dir.
|
||||
#
|
||||
# IMPORTANT:
|
||||
# This moves the directory that currently contains live sessions, worker
|
||||
# runtime state, workdirs, and this checkout's parent path. Do not run while
|
||||
# Yoi backend/runtime/workers/TUI/web sessions are running.
|
||||
#
|
||||
# Recommended usage after shutdown:
|
||||
# cd /tmp
|
||||
# RUN_YOI_HOME_MIGRATION=1 sh /path/to/tmp-migrate-home-yoi-data-dir.sh
|
||||
#
|
||||
# Destination resolution matches manifest::paths::data_dir():
|
||||
# 1. YOI_DATA_DIR
|
||||
# 2. YOI_HOME
|
||||
# 3. XDG_DATA_HOME/yoi
|
||||
# 4. HOME/.local/share/yoi
|
||||
#
|
||||
# This script intentionally refuses to merge into a non-empty destination. If
|
||||
# the destination already has data, inspect and merge manually.
|
||||
|
||||
if [ "${RUN_YOI_HOME_MIGRATION:-}" != "1" ]; then
|
||||
echo "Refusing to migrate. Set RUN_YOI_HOME_MIGRATION=1 after stopping all Yoi processes." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
HOME_DIR=${HOME:?HOME is required}
|
||||
OLD_DIR=${OLD_YOI_DATA_DIR:-"$HOME_DIR/.yoi"}
|
||||
|
||||
if [ -n "${YOI_DATA_DIR:-}" ]; then
|
||||
NEW_DIR=$YOI_DATA_DIR
|
||||
elif [ -n "${YOI_HOME:-}" ]; then
|
||||
NEW_DIR=$YOI_HOME
|
||||
elif [ -n "${XDG_DATA_HOME:-}" ]; then
|
||||
NEW_DIR=$XDG_DATA_HOME/yoi
|
||||
else
|
||||
NEW_DIR=$HOME_DIR/.local/share/yoi
|
||||
fi
|
||||
|
||||
if [ ! -d "$OLD_DIR" ]; then
|
||||
echo "Legacy data dir does not exist: $OLD_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$NEW_DIR" in
|
||||
"$OLD_DIR"|"$OLD_DIR"/*)
|
||||
echo "Refusing to move into the old data dir or one of its children: $NEW_DIR" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
CURRENT_DIR=$(pwd -P)
|
||||
case "$CURRENT_DIR" in
|
||||
"$OLD_DIR"|"$OLD_DIR"/*)
|
||||
echo "Refusing to run from inside $OLD_DIR. cd outside it first, e.g. cd /tmp." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -e "$NEW_DIR" ]; then
|
||||
if [ -d "$NEW_DIR" ] && [ -z "$(find "$NEW_DIR" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
|
||||
rmdir "$NEW_DIR"
|
||||
else
|
||||
echo "Destination already exists and is not empty: $NEW_DIR" >&2
|
||||
echo "Inspect and merge manually; this script will not overwrite or merge data." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$NEW_DIR")"
|
||||
mv "$OLD_DIR" "$NEW_DIR"
|
||||
|
||||
echo "Moved legacy Yoi data dir:"
|
||||
echo " from: $OLD_DIR"
|
||||
echo " to: $NEW_DIR"
|
||||
88
tmp-migrate-workspace-runtime-and-tickets.sh
Normal file
88
tmp-migrate-workspace-runtime-and-tickets.sh
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
# Migration helper for workspace-local runtime config and legacy Ticket files.
|
||||
#
|
||||
# It performs:
|
||||
# 1. Extract `[[runtimes.remote]]` blocks from `.yoi/workspace-backend.local.toml`.
|
||||
# 2. Write them to the resolved config-dir `runtimes.toml`.
|
||||
# 3. Remove those runtime blocks from `.yoi/workspace-backend.local.toml` so the
|
||||
# updated backend no longer rejects the workspace config.
|
||||
# 4. Run `cargo run --bin yoi -- ticket import-local` to import `.yoi/tickets`
|
||||
# into the workspace SQLite DB.
|
||||
#
|
||||
# It intentionally does NOT delete legacy files. Run
|
||||
# `tmp-remove-legacy-yoi-files.sh` only after repository DB import, ticket import,
|
||||
# and DB location migration have all been verified.
|
||||
#
|
||||
# Preconditions:
|
||||
# - Run from the repository/workspace root.
|
||||
# - Stop Yoi backend/runtime/workers before running.
|
||||
# - Use the updated code in this worktree.
|
||||
#
|
||||
# Run explicitly with:
|
||||
# RUN_YOI_WORKSPACE_RUNTIME_TICKET_MIGRATION=1 sh ./tmp-migrate-workspace-runtime-and-tickets.sh
|
||||
|
||||
if [ "${RUN_YOI_WORKSPACE_RUNTIME_TICKET_MIGRATION:-}" != "1" ]; then
|
||||
echo "Refusing to migrate. Set RUN_YOI_WORKSPACE_RUNTIME_TICKET_MIGRATION=1 after stopping Yoi processes." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f ".yoi/workspace.toml" ]; then
|
||||
echo "Refusing to run outside a Yoi workspace root: .yoi/workspace.toml not found." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CONFIG_FILE=.yoi/workspace-backend.local.toml
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
echo "Workspace backend local config not found: $CONFIG_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "${YOI_CONFIG_DIR:-}" ]; then
|
||||
CONFIG_DIR=$YOI_CONFIG_DIR
|
||||
elif [ -n "${YOI_HOME:-}" ]; then
|
||||
CONFIG_DIR=$YOI_HOME/config
|
||||
elif [ -n "${XDG_CONFIG_HOME:-}" ]; then
|
||||
CONFIG_DIR=$XDG_CONFIG_HOME/yoi
|
||||
else
|
||||
HOME_DIR=${HOME:?HOME is required when YOI_CONFIG_DIR, YOI_HOME, and XDG_CONFIG_HOME are unset}
|
||||
CONFIG_DIR=$HOME_DIR/.config/yoi
|
||||
fi
|
||||
|
||||
RUNTIMES_TOML=$CONFIG_DIR/runtimes.toml
|
||||
RUNTIME_BLOCKS=$(mktemp)
|
||||
STRIPPED_CONFIG=$(mktemp)
|
||||
trap 'rm -f "$RUNTIME_BLOCKS" "$STRIPPED_CONFIG"' EXIT
|
||||
|
||||
awk '
|
||||
/^\[\[runtimes\.remote\]\]/ { in_runtime=1; print; next }
|
||||
in_runtime && /^\[/ && $0 !~ /^\[\[runtimes\.remote\]\]/ { in_runtime=0 }
|
||||
in_runtime { print }
|
||||
' "$CONFIG_FILE" > "$RUNTIME_BLOCKS"
|
||||
|
||||
if [ -s "$RUNTIME_BLOCKS" ]; then
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
if [ -e "$RUNTIMES_TOML" ] && [ -s "$RUNTIMES_TOML" ]; then
|
||||
echo "Refusing to overwrite or merge existing non-empty runtimes config: $RUNTIMES_TOML" >&2
|
||||
echo "Move runtime entries manually or empty the file after inspection." >&2
|
||||
exit 1
|
||||
fi
|
||||
cp "$RUNTIME_BLOCKS" "$RUNTIMES_TOML"
|
||||
echo "Wrote runtime registrations to $RUNTIMES_TOML"
|
||||
|
||||
awk '
|
||||
/^\[\[runtimes\.remote\]\]/ { skip=1; next }
|
||||
skip && /^\[/ && $0 !~ /^\[\[runtimes\.remote\]\]/ { skip=0 }
|
||||
!skip { print }
|
||||
' "$CONFIG_FILE" > "$STRIPPED_CONFIG"
|
||||
cp "$STRIPPED_CONFIG" "$CONFIG_FILE"
|
||||
echo "Removed runtime registrations from $CONFIG_FILE"
|
||||
else
|
||||
echo "No [[runtimes.remote]] blocks found in $CONFIG_FILE; leaving runtimes config unchanged."
|
||||
fi
|
||||
|
||||
cargo run --bin yoi -- ticket import-local
|
||||
|
||||
echo "Imported legacy .yoi/tickets into the workspace SQLite Ticket backend."
|
||||
echo "Next: start the backend once to import repositories, verify data, then run tmp-remove-legacy-yoi-files.sh if safe."
|
||||
43
tmp-remove-legacy-yoi-files.sh
Normal file
43
tmp-remove-legacy-yoi-files.sh
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
# Temporary cleanup script for files that should stop being used after the
|
||||
# workspace DB/runtime-config migration.
|
||||
#
|
||||
# Preconditions before running:
|
||||
# 1. The backend/server process is stopped.
|
||||
# 2. `[[runtimes.remote]]` from `.yoi/workspace-backend.local.toml` has been
|
||||
# copied to the resolved config-dir `runtimes.toml`, e.g.
|
||||
# `$XDG_CONFIG_HOME/yoi/runtimes.toml` when no YOI_CONFIG_DIR/YOI_HOME is set.
|
||||
# 3. Repository records from `.yoi/workspace-backend.local.toml` have been
|
||||
# imported into the workspace control-plane DB.
|
||||
# 4. Legacy `.yoi/tickets` has been imported with `yoi ticket import-local`.
|
||||
# 5. `.yoi/workspace.db` has been copied/moved to the resolved XDG data path.
|
||||
#
|
||||
# This intentionally does NOT remove:
|
||||
# - `.yoi/workspace.toml`
|
||||
# - `.yoi/workflow/**`
|
||||
#
|
||||
# Run explicitly with:
|
||||
# RUN_YOI_LEGACY_CLEANUP=1 sh ./tmp-remove-legacy-yoi-files.sh
|
||||
|
||||
if [ "${RUN_YOI_LEGACY_CLEANUP:-}" != "1" ]; then
|
||||
echo "Refusing to remove files. Set RUN_YOI_LEGACY_CLEANUP=1 after completing migration preconditions." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f ".yoi/workspace.toml" ]; then
|
||||
echo "Refusing to run outside a Yoi workspace root: .yoi/workspace.toml not found." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf \
|
||||
.yoi/tickets
|
||||
|
||||
rm -f \
|
||||
.yoi/workspace-backend.local.toml \
|
||||
.yoi/workspace.db \
|
||||
.yoi/workspace.db-shm \
|
||||
.yoi/workspace.db-wal
|
||||
|
||||
echo "Removed legacy workspace-local backend config, Ticket files, and workspace DB files."
|
||||
Loading…
Reference in New Issue
Block a user