fix: resolve workspaces through backend repositories
This commit is contained in:
Generated
+1
@@ -6822,6 +6822,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
"tui",
|
"tui",
|
||||||
|
"uuid",
|
||||||
"worker",
|
"worker",
|
||||||
"workspace-api",
|
"workspace-api",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,20 +3,19 @@ use reqwest::Method;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use workspace_api::{
|
use workspace_api::{
|
||||||
WorkspaceCatalogListResponse, WorkspaceCreateResponse, WorkspaceRepositoryRecord,
|
RepositoryListResponse, RepositorySummary, WorkspaceCatalogListResponse,
|
||||||
WorkspaceSummary,
|
WorkspaceCreateResponse, WorkspaceSummary,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_WORKSPACE_LIMIT: usize = 200;
|
const DEFAULT_WORKSPACE_LIMIT: usize = 200;
|
||||||
|
|
||||||
pub type BackendWorkspace = WorkspaceSummary;
|
pub type BackendWorkspace = WorkspaceSummary;
|
||||||
pub type CreateBackendWorkspaceResponse = WorkspaceCreateResponse;
|
pub type CreateBackendWorkspaceResponse = WorkspaceCreateResponse;
|
||||||
pub type CreateBackendWorkspaceRepositoryRecord = WorkspaceRepositoryRecord;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct CreateBackendWorkspaceRequest {
|
pub struct CreateBackendWorkspaceRequest {
|
||||||
pub operation_key: String,
|
pub operation_id: String,
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
pub repository: CreateBackendWorkspaceRepository,
|
pub repository: CreateBackendWorkspaceRepository,
|
||||||
}
|
}
|
||||||
@@ -24,8 +23,8 @@ pub struct CreateBackendWorkspaceRequest {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct CreateBackendWorkspaceRepository {
|
pub struct CreateBackendWorkspaceRepository {
|
||||||
pub uri: String,
|
pub repository_key: String,
|
||||||
pub display_name: Option<String>,
|
pub source: String,
|
||||||
pub default_ref: Option<String>,
|
pub default_ref: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +72,48 @@ impl From<reqwest::Error> for BackendWorkspaceClientError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn list_backend_workspaces_blocking(
|
||||||
|
target: &BackendWorkspaceCatalogTarget,
|
||||||
|
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
|
||||||
|
let client = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||||
|
let response = client
|
||||||
|
.blocking_request(
|
||||||
|
Method::GET,
|
||||||
|
&format!("/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}"),
|
||||||
|
)?
|
||||||
|
.send()?;
|
||||||
|
client.check_status(response.status())?;
|
||||||
|
Ok(response.json::<WorkspaceCatalogListResponse>()?.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_backend_workspace_repositories_blocking(
|
||||||
|
target: &BackendWorkspaceCatalogTarget,
|
||||||
|
workspace_id: &str,
|
||||||
|
) -> Result<Vec<RepositorySummary>, BackendWorkspaceClientError> {
|
||||||
|
if workspace_id.is_empty()
|
||||||
|
|| workspace_id.len() > 200
|
||||||
|
|| !workspace_id
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
|
||||||
|
{
|
||||||
|
return Err(BackendWorkspaceClientError::InvalidTarget(
|
||||||
|
"Workspace id returned by Backend is invalid".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let client = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||||
|
let response = client
|
||||||
|
.blocking_request(Method::GET, &format!("/api/w/{workspace_id}/repositories"))?
|
||||||
|
.send()?;
|
||||||
|
client.check_status(response.status())?;
|
||||||
|
let response = response.json::<RepositoryListResponse>()?;
|
||||||
|
if response.workspace_id != workspace_id {
|
||||||
|
return Err(BackendWorkspaceClientError::InvalidTarget(
|
||||||
|
"Repository catalog response does not match the requested Workspace".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(response.items)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn list_backend_workspaces(
|
pub async fn list_backend_workspaces(
|
||||||
target: &BackendWorkspaceCatalogTarget,
|
target: &BackendWorkspaceCatalogTarget,
|
||||||
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
|
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
|
||||||
@@ -144,19 +185,19 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_request_keeps_operation_key_for_exact_retry() {
|
fn create_request_keeps_operation_id_for_exact_retry() {
|
||||||
let request = CreateBackendWorkspaceRequest {
|
let request = CreateBackendWorkspaceRequest {
|
||||||
operation_key: "workspace-create-1".to_string(),
|
operation_id: "workspace-create-1".to_string(),
|
||||||
display_name: "Alpha".to_string(),
|
display_name: "Alpha".to_string(),
|
||||||
repository: CreateBackendWorkspaceRepository {
|
repository: CreateBackendWorkspaceRepository {
|
||||||
uri: "/srv/repos/alpha".to_string(),
|
repository_key: "main".to_string(),
|
||||||
display_name: Some("Main".to_string()),
|
source: "/srv/repos/alpha".to_string(),
|
||||||
default_ref: Some("develop".to_string()),
|
default_ref: Some("develop".to_string()),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let retry = request.clone();
|
let retry = request.clone();
|
||||||
assert_eq!(retry.operation_key, "workspace-create-1");
|
assert_eq!(retry.operation_id, "workspace-create-1");
|
||||||
assert_eq!(retry, request);
|
assert_eq!(retry, request);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ pub use backend_runtime::{
|
|||||||
pub use backend_workspace::{
|
pub use backend_workspace::{
|
||||||
BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError,
|
BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError,
|
||||||
CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest,
|
CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest,
|
||||||
CreateBackendWorkspaceResponse, create_backend_workspace, list_backend_workspaces,
|
CreateBackendWorkspaceResponse, create_backend_workspace,
|
||||||
|
list_backend_workspace_repositories_blocking, list_backend_workspaces,
|
||||||
|
list_backend_workspaces_blocking,
|
||||||
};
|
};
|
||||||
pub use client::{Client, ClientError};
|
pub use client::{Client, ClientError};
|
||||||
pub use target::{
|
pub use target::{
|
||||||
|
|||||||
@@ -4,19 +4,26 @@ use client::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workspace_creation_request_preserves_operation_key_for_retry() {
|
fn workspace_creation_request_preserves_operation_id_for_retry() {
|
||||||
let request = CreateBackendWorkspaceRequest {
|
let request = CreateBackendWorkspaceRequest {
|
||||||
operation_key: "workspace-create-1".to_string(),
|
operation_id: "workspace-create-1".to_string(),
|
||||||
display_name: "Alpha".to_string(),
|
display_name: "Alpha".to_string(),
|
||||||
repository: CreateBackendWorkspaceRepository {
|
repository: CreateBackendWorkspaceRepository {
|
||||||
uri: "/srv/repos/alpha".to_string(),
|
repository_key: "main".to_string(),
|
||||||
display_name: Some("Main".to_string()),
|
source: "/srv/repos/alpha".to_string(),
|
||||||
default_ref: Some("develop".to_string()),
|
default_ref: Some("develop".to_string()),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(request.clone(), request);
|
assert_eq!(request.clone(), request);
|
||||||
assert_eq!(request.operation_key, "workspace-create-1");
|
assert_eq!(request.operation_id, "workspace-create-1");
|
||||||
|
let json = serde_json::to_value(&request).unwrap();
|
||||||
|
assert_eq!(json["operation_id"], "workspace-create-1");
|
||||||
|
assert_eq!(json["repository"]["repository_key"], "main");
|
||||||
|
assert_eq!(json["repository"]["source"], "/srv/repos/alpha");
|
||||||
|
assert!(json.get("operation_key").is_none());
|
||||||
|
assert!(json["repository"].get("display_name").is_none());
|
||||||
|
assert!(json["repository"].get("uri").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ fn prompt_create_request_inner() -> PickerResult<Option<CreateBackendWorkspaceRe
|
|||||||
println!("Repository path/URI is required.");
|
println!("Repository path/URI is required.");
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let repository_name = prompt_line("Repository display name [Main]: ")?;
|
let repository_key = prompt_line("Repository key [main]: ")?;
|
||||||
let default_ref = prompt_line("Default ref [repository default]: ")?;
|
let default_ref = prompt_line("Default ref [repository default]: ")?;
|
||||||
let operation_key = format!(
|
let operation_key = format!(
|
||||||
"tui-workspace-create-{}-{}",
|
"tui-workspace-create-{}-{}",
|
||||||
@@ -200,15 +200,15 @@ fn prompt_create_request_inner() -> PickerResult<Option<CreateBackendWorkspaceRe
|
|||||||
.as_nanos()
|
.as_nanos()
|
||||||
);
|
);
|
||||||
Ok(Some(CreateBackendWorkspaceRequest {
|
Ok(Some(CreateBackendWorkspaceRequest {
|
||||||
operation_key,
|
operation_id: operation_key,
|
||||||
display_name,
|
display_name,
|
||||||
repository: CreateBackendWorkspaceRepository {
|
repository: CreateBackendWorkspaceRepository {
|
||||||
uri,
|
source: uri,
|
||||||
display_name: Some(if repository_name.is_empty() {
|
repository_key: if repository_key.is_empty() {
|
||||||
"Main".to_string()
|
"main".to_string()
|
||||||
} else {
|
} else {
|
||||||
repository_name
|
repository_key
|
||||||
}),
|
},
|
||||||
default_ref: (!default_ref.is_empty()).then_some(default_ref),
|
default_ref: (!default_ref.is_empty()).then_some(default_ref),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -12405,7 +12405,8 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn workspace_display_name_update_is_revision_guarded_and_preserves_identity() {
|
async fn workspace_display_name_update_is_revision_guarded_and_preserves_identity() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let store = SqliteWorkspaceStore::open(dir.path().join("server.db")).unwrap();
|
let database_path = dir.path().join("server.db");
|
||||||
|
let store = SqliteWorkspaceStore::open(&database_path).unwrap();
|
||||||
let record = WorkspaceRecord {
|
let record = WorkspaceRecord {
|
||||||
workspace_id: "workspace-a".to_string(),
|
workspace_id: "workspace-a".to_string(),
|
||||||
owner_account_id: "owner-account".to_string(),
|
owner_account_id: "owner-account".to_string(),
|
||||||
@@ -12443,6 +12444,16 @@ mod tests {
|
|||||||
.unwrap(),
|
.unwrap(),
|
||||||
updated
|
updated
|
||||||
);
|
);
|
||||||
|
drop(store);
|
||||||
|
let reopened = SqliteWorkspaceStore::open(database_path).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
reopened
|
||||||
|
.get_workspace(&record.workspace_id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap(),
|
||||||
|
updated
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ serde_json = { workspace = true }
|
|||||||
serde_yaml = "0.9.34"
|
serde_yaml = "0.9.34"
|
||||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||||
toml = { workspace = true }
|
toml = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
use client::{BackendTarget, StandaloneTarget, Target, TargetKind};
|
use client::{BackendTarget, StandaloneTarget, Target, TargetKind};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
@@ -103,6 +105,16 @@ pub(crate) trait CliConnectionResolver {
|
|||||||
command: CliCommand,
|
command: CliCommand,
|
||||||
input: CliConnectionInput<'_>,
|
input: CliConnectionInput<'_>,
|
||||||
) -> Result<Box<dyn Target>, ParseError>;
|
) -> Result<Box<dyn Target>, ParseError>;
|
||||||
|
|
||||||
|
fn select_workspace_for_repository(
|
||||||
|
&self,
|
||||||
|
_explicit_backend_url: Option<&str>,
|
||||||
|
_repository_path: &Path,
|
||||||
|
) -> Result<(String, String), ParseError> {
|
||||||
|
Err(ParseError(
|
||||||
|
"repository-based Workspace selection is unavailable".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone, Copy)]
|
#[derive(Debug, Default, Clone, Copy)]
|
||||||
@@ -177,6 +189,17 @@ impl CliConnectionResolver for ClientConfigCliConnectionResolver {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn select_workspace_for_repository(
|
||||||
|
&self,
|
||||||
|
explicit_backend_url: Option<&str>,
|
||||||
|
repository_path: &Path,
|
||||||
|
) -> Result<(String, String), ParseError> {
|
||||||
|
let base_url = resolve_backend_url(explicit_backend_url.map(str::to_string), None)?;
|
||||||
|
let workspace_id =
|
||||||
|
super::select_backend_workspace_for_repository(&base_url, repository_path)?;
|
||||||
|
Ok((base_url, workspace_id))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn resolve_local_cli_connection<R: CliConnectionResolver + ?Sized>(
|
pub(crate) fn resolve_local_cli_connection<R: CliConnectionResolver + ?Sized>(
|
||||||
|
|||||||
+247
-13
@@ -6,6 +6,7 @@ mod plugin_cli;
|
|||||||
mod session_cli;
|
mod session_cli;
|
||||||
mod ticket_cli;
|
mod ticket_cli;
|
||||||
mod worker_cleanup_cli;
|
mod worker_cleanup_cli;
|
||||||
|
mod workspace_bootstrap;
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
@@ -24,6 +25,9 @@ use client::{BackendAuthTarget, Target, TargetKind, start_device_login, wait_for
|
|||||||
use memory_lint::{LintCliOptions, LintStatus};
|
use memory_lint::{LintCliOptions, LintStatus};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tui::{LaunchMode, LaunchOptions};
|
use tui::{LaunchMode, LaunchOptions};
|
||||||
|
use workspace_bootstrap::{
|
||||||
|
InitOptions, discover_repository_root, run_init, select_backend_workspace_for_repository,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
enum Mode {
|
enum Mode {
|
||||||
@@ -48,6 +52,7 @@ enum Mode {
|
|||||||
backend_url: String,
|
backend_url: String,
|
||||||
no_wait: bool,
|
no_wait: bool,
|
||||||
},
|
},
|
||||||
|
Init(InitOptions),
|
||||||
WorkerRuntime(Vec<String>),
|
WorkerRuntime(Vec<String>),
|
||||||
Keys,
|
Keys,
|
||||||
SetupModel,
|
SetupModel,
|
||||||
@@ -107,6 +112,19 @@ async fn main() -> ExitCode {
|
|||||||
ExitCode::FAILURE
|
ExitCode::FAILURE
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
Mode::Init(options) => match run_init(options).await {
|
||||||
|
Ok(workspace) => {
|
||||||
|
println!(
|
||||||
|
"Initialized Workspace '{}' for repository '{}'",
|
||||||
|
workspace.workspace.display_name, workspace.repository.repository_key
|
||||||
|
);
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("yoi init: {error}");
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
},
|
||||||
Mode::MemoryLint(options) => match memory_lint::run(&options) {
|
Mode::MemoryLint(options) => match memory_lint::run(&options) {
|
||||||
Ok(LintStatus::Clean) => ExitCode::SUCCESS,
|
Ok(LintStatus::Clean) => ExitCode::SUCCESS,
|
||||||
Ok(LintStatus::Failed) => ExitCode::FAILURE,
|
Ok(LintStatus::Failed) => ExitCode::FAILURE,
|
||||||
@@ -220,7 +238,7 @@ fn resolve_tui_target<R: CliConnectionResolver + ?Sized>(
|
|||||||
connection_resolver: &R,
|
connection_resolver: &R,
|
||||||
command: CliCommand,
|
command: CliCommand,
|
||||||
selection: &TargetSelection,
|
selection: &TargetSelection,
|
||||||
_workspace_root: &Path,
|
workspace_root: &Path,
|
||||||
) -> Result<Box<dyn Target>, ParseError> {
|
) -> Result<Box<dyn Target>, ParseError> {
|
||||||
if selection.explicit_local {
|
if selection.explicit_local {
|
||||||
return resolve_connection_aware_cli_connection(
|
return resolve_connection_aware_cli_connection(
|
||||||
@@ -232,14 +250,31 @@ fn resolve_tui_target<R: CliConnectionResolver + ?Sized>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if selection.workspace_id.is_none() {
|
||||||
if selection.backend_url.is_none()
|
if selection.backend_url.is_none()
|
||||||
&& let Ok(target) =
|
&& let Ok(target) = resolve_connection_aware_cli_connection(
|
||||||
resolve_connection_aware_cli_connection(connection_resolver, command, false, None, None)
|
connection_resolver,
|
||||||
|
command,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
&& target.kind() == TargetKind::Standalone
|
&& target.kind() == TargetKind::Standalone
|
||||||
{
|
{
|
||||||
return Ok(target);
|
return Ok(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let (base_url, workspace_id) = connection_resolver
|
||||||
|
.select_workspace_for_repository(selection.backend_url.as_deref(), workspace_root)?;
|
||||||
|
return resolve_connection_aware_cli_connection(
|
||||||
|
connection_resolver,
|
||||||
|
command,
|
||||||
|
false,
|
||||||
|
Some(base_url),
|
||||||
|
Some(&workspace_id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
resolve_connection_aware_cli_connection(
|
resolve_connection_aware_cli_connection(
|
||||||
connection_resolver,
|
connection_resolver,
|
||||||
command,
|
command,
|
||||||
@@ -435,6 +470,22 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
|
|||||||
.map_err(|error| ParseError(error.to_string()))?;
|
.map_err(|error| ParseError(error.to_string()))?;
|
||||||
return Ok(Mode::Ticket { cli, target });
|
return Ok(Mode::Ticket { cli, target });
|
||||||
}
|
}
|
||||||
|
"init" => {
|
||||||
|
if target_selection.explicit_local {
|
||||||
|
return Err(ParseError(
|
||||||
|
"yoi init requires a Backend target and cannot use --local".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if target_selection.workspace_id.is_some() {
|
||||||
|
return Err(ParseError(
|
||||||
|
"yoi init creates a Workspace and does not accept --workspace-id".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return Ok(Mode::Init(parse_init_args(
|
||||||
|
&args[1..],
|
||||||
|
target_selection.backend_url,
|
||||||
|
)?));
|
||||||
|
}
|
||||||
"plugin" => {
|
"plugin" => {
|
||||||
let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Plugin)?;
|
let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Plugin)?;
|
||||||
let plugin_cli = parse_plugin_args(&args[1..])?;
|
let plugin_cli = parse_plugin_args(&args[1..])?;
|
||||||
@@ -552,6 +603,68 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
|
|||||||
parse_console_options(args, &target_selection, connection_resolver)
|
parse_console_options(args, &target_selection, connection_resolver)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_init_args(
|
||||||
|
args: &[String],
|
||||||
|
explicit_backend_url: Option<String>,
|
||||||
|
) -> Result<InitOptions, ParseError> {
|
||||||
|
let mut display_name = None;
|
||||||
|
let mut repository_key = None;
|
||||||
|
let mut repository_root = current_dir()?;
|
||||||
|
let mut default_ref = None;
|
||||||
|
let mut index = 0;
|
||||||
|
while index < args.len() {
|
||||||
|
let option = args[index].as_str();
|
||||||
|
let (slot, label) = match option {
|
||||||
|
"--display-name" => (&mut display_name, "--display-name"),
|
||||||
|
"--repository-key" => (&mut repository_key, "--repository-key"),
|
||||||
|
"--repository" => {
|
||||||
|
let value = required_option_value(args, index, "--repository")?;
|
||||||
|
repository_root = PathBuf::from(value);
|
||||||
|
index += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
"--default-ref" => (&mut default_ref, "--default-ref"),
|
||||||
|
"--help" | "-h" => {
|
||||||
|
return Err(ParseError(
|
||||||
|
"usage: yoi [--backend URL] init --display-name NAME --repository-key KEY [--repository PATH] [--default-ref REF]"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
unknown => {
|
||||||
|
return Err(ParseError(format!("unknown yoi init option `{unknown}`")));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if slot.is_some() {
|
||||||
|
return Err(ParseError(format!("{label} may only be provided once")));
|
||||||
|
}
|
||||||
|
*slot = Some(required_option_value(args, index, label)?.to_string());
|
||||||
|
index += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
let repository_root = discover_repository_root(&repository_root)?;
|
||||||
|
Ok(InitOptions {
|
||||||
|
backend_url: resolve_backend_url(explicit_backend_url, None)?,
|
||||||
|
display_name: display_name
|
||||||
|
.ok_or_else(|| ParseError("yoi init requires --display-name NAME".to_string()))?,
|
||||||
|
repository_key: repository_key
|
||||||
|
.ok_or_else(|| ParseError("yoi init requires --repository-key KEY".to_string()))?,
|
||||||
|
repository_root,
|
||||||
|
default_ref,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required_option_value<'a>(
|
||||||
|
args: &'a [String],
|
||||||
|
index: usize,
|
||||||
|
option: &str,
|
||||||
|
) -> Result<&'a str, ParseError> {
|
||||||
|
let value = args
|
||||||
|
.get(index + 1)
|
||||||
|
.filter(|value| !value.is_empty() && !value.starts_with('-'))
|
||||||
|
.ok_or_else(|| ParseError(format!("{option} requires a value")))?;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_console_options<R: CliConnectionResolver + ?Sized>(
|
fn parse_console_options<R: CliConnectionResolver + ?Sized>(
|
||||||
args: &[String],
|
args: &[String],
|
||||||
target_selection: &TargetSelection,
|
target_selection: &TargetSelection,
|
||||||
@@ -1046,7 +1159,7 @@ fn current_dir() -> Result<PathBuf, ParseError> {
|
|||||||
.map_err(|e| ParseError(format!("failed to resolve current directory: {e}")))
|
.map_err(|e| ParseError(format!("failed to resolve current directory: {e}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default, PartialEq, Eq)]
|
||||||
struct ClientConfigFile {
|
struct ClientConfigFile {
|
||||||
default_backend: Option<String>,
|
default_backend: Option<String>,
|
||||||
default_connection: ClientDefaultConnection,
|
default_connection: ClientDefaultConnection,
|
||||||
@@ -1064,7 +1177,7 @@ struct ClientConfigOverlay {
|
|||||||
workspaces: BTreeMap<String, ClientWorkspaceConfigOverlay>,
|
workspaces: BTreeMap<String, ClientWorkspaceConfigOverlay>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default, PartialEq, Eq)]
|
||||||
struct ClientBackendConfig {
|
struct ClientBackendConfig {
|
||||||
url: Option<String>,
|
url: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -1074,7 +1187,7 @@ struct ClientBackendConfigOverlay {
|
|||||||
url: Option<String>,
|
url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default, PartialEq, Eq)]
|
||||||
struct ClientWorkspaceConfig {
|
struct ClientWorkspaceConfig {
|
||||||
backend: Option<String>,
|
backend: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -1663,6 +1776,7 @@ Usage:
|
|||||||
yoi [TARGET] workers [-r|--stopped] [--runtime-id <ID>]
|
yoi [TARGET] workers [-r|--stopped] [--runtime-id <ID>]
|
||||||
yoi [TARGET] resume [--all] [--runtime-id <ID>]
|
yoi [TARGET] resume [--all] [--runtime-id <ID>]
|
||||||
yoi --backend <URL> [--workspace-id <ID>] panel
|
yoi --backend <URL> [--workspace-id <ID>] panel
|
||||||
|
yoi [--backend <URL>] init --display-name <NAME> --repository-key <KEY> [--repository <PATH>] [--default-ref <REF>]
|
||||||
yoi [--backend <URL>] login [--no-wait]
|
yoi [--backend <URL>] login [--no-wait]
|
||||||
yoi <HOST_COMMAND> [OPTIONS]
|
yoi <HOST_COMMAND> [OPTIONS]
|
||||||
|
|
||||||
@@ -1688,6 +1802,7 @@ Console options:
|
|||||||
--worker-id <ID> Backend Worker id; requires --runtime-id
|
--worker-id <ID> Backend Worker id; requires --runtime-id
|
||||||
|
|
||||||
Host commands:
|
Host commands:
|
||||||
|
yoi init Register the current Git repository as a new Backend Workspace.
|
||||||
keys Manage local model/API keys
|
keys Manage local model/API keys
|
||||||
setup-model Configure a local model provider
|
setup-model Configure a local model provider
|
||||||
worker [WORKER_OPTIONS] Run the direct Worker process entrypoint
|
worker [WORKER_OPTIONS] Run the direct Worker process entrypoint
|
||||||
@@ -1722,7 +1837,7 @@ Authority:
|
|||||||
Options:
|
Options:
|
||||||
--backend <URL> Use this Workspace Backend
|
--backend <URL> Use this Workspace Backend
|
||||||
--workspace-id <ID> Scope Backend routes to a Workspace id
|
--workspace-id <ID> Scope Backend routes to a Workspace id
|
||||||
--workspace <PATH> Resolve Backend Workspace identity from this repository root
|
--workspace <PATH> Match this Git repository against Server DB Repository records
|
||||||
-r, --stopped List stopped Backend Workers
|
-r, --stopped List stopped Backend Workers
|
||||||
--runtime-id <ID> Restrict the Backend Worker picker to a Runtime id
|
--runtime-id <ID> Restrict the Backend Worker picker to a Runtime id
|
||||||
-h, --help Print help
|
-h, --help Print help
|
||||||
@@ -1764,6 +1879,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::cli_connection::CliConnectionInput;
|
use crate::cli_connection::CliConnectionInput;
|
||||||
use client::{BackendTarget, StandaloneTarget, Target, TargetKind, WorkerListRequest};
|
use client::{BackendTarget, StandaloneTarget, Target, TargetKind, WorkerListRequest};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
struct FixedCliConnectionResolver {
|
struct FixedCliConnectionResolver {
|
||||||
backend_url: &'static str,
|
backend_url: &'static str,
|
||||||
@@ -1812,6 +1928,42 @@ mod tests {
|
|||||||
workspace_id.map(str::to_string),
|
workspace_id.map(str::to_string),
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn select_workspace_for_repository(
|
||||||
|
&self,
|
||||||
|
_explicit_backend_url: Option<&str>,
|
||||||
|
_repository_path: &Path,
|
||||||
|
) -> Result<(String, String), ParseError> {
|
||||||
|
Ok((
|
||||||
|
self.backend_url.to_string(),
|
||||||
|
"workspace-from-backend".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OfflineBackendCliConnectionResolver;
|
||||||
|
|
||||||
|
impl CliConnectionResolver for OfflineBackendCliConnectionResolver {
|
||||||
|
fn resolve_connection(
|
||||||
|
&self,
|
||||||
|
_command: CliCommand,
|
||||||
|
_input: cli_connection::CliConnectionInput<'_>,
|
||||||
|
) -> Result<Box<dyn Target>, ParseError> {
|
||||||
|
Ok(Box::new(BackendTarget::new(
|
||||||
|
"http://offline.example",
|
||||||
|
None::<String>,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_workspace_for_repository(
|
||||||
|
&self,
|
||||||
|
_explicit_backend_url: Option<&str>,
|
||||||
|
_repository_path: &Path,
|
||||||
|
) -> Result<(String, String), ParseError> {
|
||||||
|
Err(ParseError(
|
||||||
|
"failed to query Backend Workspace catalog: Backend is offline".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2054,7 +2206,15 @@ backend = "shared"
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_backend_runtime_picker_target_mode() {
|
fn parse_backend_runtime_picker_target_mode() {
|
||||||
match parse_args_from(["--backend", "http://127.0.0.1:8787", "--runtime-id", "r"]).unwrap()
|
match parse_args_from([
|
||||||
|
"--backend",
|
||||||
|
"http://127.0.0.1:8787",
|
||||||
|
"--workspace-id",
|
||||||
|
"workspace-a",
|
||||||
|
"--runtime-id",
|
||||||
|
"r",
|
||||||
|
])
|
||||||
|
.unwrap()
|
||||||
{
|
{
|
||||||
Mode::Tui {
|
Mode::Tui {
|
||||||
target,
|
target,
|
||||||
@@ -2411,7 +2571,71 @@ backend = "shared"
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn default_backend_target_does_not_read_repository_workspace_identity() {
|
fn backend_workspace_selection_reports_offline_without_local_fallback() {
|
||||||
|
let workspace = tempfile::tempdir().unwrap();
|
||||||
|
fs::create_dir_all(workspace.path().join(".yoi")).unwrap();
|
||||||
|
fs::write(
|
||||||
|
workspace.path().join(".yoi/workspace.toml"),
|
||||||
|
"workspace_id = \"stale-local-workspace\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let error = resolve_tui_target(
|
||||||
|
&OfflineBackendCliConnectionResolver,
|
||||||
|
CliCommand::Ticket,
|
||||||
|
&TargetSelection::default(),
|
||||||
|
workspace.path(),
|
||||||
|
)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
assert!(error.contains("Backend is offline"));
|
||||||
|
assert!(!error.contains("stale-local-workspace"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn init_parsing_uses_git_root_without_writing_repository_local_identity() {
|
||||||
|
let repository = tempfile::tempdir().unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["init", "-q"])
|
||||||
|
.current_dir(repository.path())
|
||||||
|
.status()
|
||||||
|
.unwrap();
|
||||||
|
let resolver = FixedCliConnectionResolver {
|
||||||
|
backend_url: "http://unused.example",
|
||||||
|
};
|
||||||
|
let args = vec![
|
||||||
|
"--backend".to_string(),
|
||||||
|
"http://backend.example".to_string(),
|
||||||
|
"init".to_string(),
|
||||||
|
"--display-name".to_string(),
|
||||||
|
"Workspace A".to_string(),
|
||||||
|
"--repository-key".to_string(),
|
||||||
|
"main".to_string(),
|
||||||
|
"--repository".to_string(),
|
||||||
|
repository.path().display().to_string(),
|
||||||
|
"--default-ref".to_string(),
|
||||||
|
"develop".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mode = parse_args_slice_with_connection_resolver(&args, &resolver).unwrap();
|
||||||
|
|
||||||
|
let Mode::Init(options) = mode else {
|
||||||
|
panic!("expected init mode");
|
||||||
|
};
|
||||||
|
assert_eq!(options.backend_url, "http://backend.example");
|
||||||
|
assert_eq!(options.display_name, "Workspace A");
|
||||||
|
assert_eq!(options.repository_key, "main");
|
||||||
|
assert_eq!(options.default_ref.as_deref(), Some("develop"));
|
||||||
|
assert_eq!(
|
||||||
|
options.repository_root,
|
||||||
|
fs::canonicalize(repository.path()).unwrap()
|
||||||
|
);
|
||||||
|
assert!(!repository.path().join(".yoi/workspace.toml").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_backend_target_selects_workspace_from_backend_not_repository_file() {
|
||||||
let workspace = tempfile::tempdir().unwrap();
|
let workspace = tempfile::tempdir().unwrap();
|
||||||
std::fs::create_dir_all(workspace.path().join(".yoi")).unwrap();
|
std::fs::create_dir_all(workspace.path().join(".yoi")).unwrap();
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
@@ -2433,8 +2657,11 @@ backend = "shared"
|
|||||||
|
|
||||||
assert_eq!(target.kind(), TargetKind::Backend);
|
assert_eq!(target.kind(), TargetKind::Backend);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
target.resolve().unwrap_err().to_string(),
|
target.resolve().unwrap(),
|
||||||
"invalid Backend target: workspace selection is required for Backend product-state operations",
|
client::ResolvedTarget::Backend {
|
||||||
|
base_url: "http://default-backend.example".to_string(),
|
||||||
|
workspace_id: "workspace-from-backend".to_string(),
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2878,8 +3105,15 @@ backend = "shared"
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_panel_rejects_removed_host_local_restore_path() {
|
fn parse_panel_rejects_removed_host_local_restore_path() {
|
||||||
let err =
|
let err = parse_args_from([
|
||||||
parse_args_from(["--backend", "http://127.0.0.1:8787", "panel", "-r"]).unwrap_err();
|
"--backend",
|
||||||
|
"http://127.0.0.1:8787",
|
||||||
|
"--workspace-id",
|
||||||
|
"workspace-a",
|
||||||
|
"panel",
|
||||||
|
"-r",
|
||||||
|
])
|
||||||
|
.unwrap_err();
|
||||||
assert!(err.to_string().contains("removed host-local Worker path"));
|
assert!(err.to_string().contains("removed host-local Worker path"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use client::{
|
||||||
|
BackendWorkspaceCatalogTarget, CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest,
|
||||||
|
create_backend_workspace, list_backend_workspace_repositories_blocking,
|
||||||
|
list_backend_workspaces_blocking,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{ParseError, client_global_config_path};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) struct InitOptions {
|
||||||
|
pub(crate) backend_url: String,
|
||||||
|
pub(crate) display_name: String,
|
||||||
|
pub(crate) repository_key: String,
|
||||||
|
pub(crate) repository_root: PathBuf,
|
||||||
|
pub(crate) default_ref: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn run_init(
|
||||||
|
options: InitOptions,
|
||||||
|
) -> Result<workspace_api::WorkspaceCreateResponse, ParseError> {
|
||||||
|
let repository_uri = options
|
||||||
|
.repository_root
|
||||||
|
.to_str()
|
||||||
|
.ok_or_else(|| ParseError("the repository root is not valid UTF-8".to_string()))?;
|
||||||
|
let target = BackendWorkspaceCatalogTarget {
|
||||||
|
base_url: options.backend_url.clone(),
|
||||||
|
};
|
||||||
|
let response = create_backend_workspace(
|
||||||
|
&target,
|
||||||
|
&CreateBackendWorkspaceRequest {
|
||||||
|
operation_id: uuid::Uuid::now_v7().to_string(),
|
||||||
|
display_name: options.display_name,
|
||||||
|
repository: CreateBackendWorkspaceRepository {
|
||||||
|
repository_key: options.repository_key,
|
||||||
|
source: repository_uri.to_string(),
|
||||||
|
default_ref: options.default_ref,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| ParseError(format!("Backend rejected Workspace creation: {error}")))?;
|
||||||
|
record_workspace_backend_routing(&response.workspace.workspace_id, &options.backend_url)?;
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn select_backend_workspace_for_repository(
|
||||||
|
base_url: &str,
|
||||||
|
repository_path: &Path,
|
||||||
|
) -> Result<String, ParseError> {
|
||||||
|
let repository = discover_open_repository(repository_path)?;
|
||||||
|
let target = BackendWorkspaceCatalogTarget {
|
||||||
|
base_url: base_url.to_string(),
|
||||||
|
};
|
||||||
|
let workspaces = list_backend_workspaces_blocking(&target).map_err(|error| {
|
||||||
|
ParseError(format!(
|
||||||
|
"failed to query Backend Workspace catalog: {error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let mut catalog = Vec::with_capacity(workspaces.len());
|
||||||
|
for workspace in workspaces {
|
||||||
|
let repositories =
|
||||||
|
list_backend_workspace_repositories_blocking(&target, &workspace.workspace_id)
|
||||||
|
.map_err(|error| {
|
||||||
|
ParseError(format!(
|
||||||
|
"failed to query Repository catalog for Workspace '{}': {error}",
|
||||||
|
workspace.display_name
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
catalog.push((workspace, repositories));
|
||||||
|
}
|
||||||
|
select_workspace_from_repository_catalog(&repository, &catalog)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn discover_repository_root(path: &Path) -> Result<PathBuf, ParseError> {
|
||||||
|
Ok(discover_open_repository(path)?.root)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_workspace_from_repository_catalog(
|
||||||
|
repository: &OpenRepositoryIdentity,
|
||||||
|
catalog: &[(
|
||||||
|
workspace_api::WorkspaceSummary,
|
||||||
|
Vec<workspace_api::RepositorySummary>,
|
||||||
|
)],
|
||||||
|
) -> Result<String, ParseError> {
|
||||||
|
let matches = catalog
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, repositories)| {
|
||||||
|
repositories
|
||||||
|
.iter()
|
||||||
|
.any(|candidate| repository.matches(&candidate.source))
|
||||||
|
})
|
||||||
|
.map(|(workspace, _)| workspace)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
match matches.as_slice() {
|
||||||
|
[workspace] => Ok(workspace.workspace_id.clone()),
|
||||||
|
[] => Err(ParseError(
|
||||||
|
"the current Git repository is not registered in any accessible Workspace; run `yoi init` or pass `--workspace-id` explicitly"
|
||||||
|
.to_string(),
|
||||||
|
)),
|
||||||
|
matches => {
|
||||||
|
let names = matches
|
||||||
|
.iter()
|
||||||
|
.map(|workspace| workspace.display_name.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
Err(ParseError(format!(
|
||||||
|
"the current Git repository matches multiple accessible Workspaces ({names}); pass `--workspace-id` explicitly"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct OpenRepositoryIdentity {
|
||||||
|
root: PathBuf,
|
||||||
|
remote_uris: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpenRepositoryIdentity {
|
||||||
|
fn matches(&self, source: &workspace_api::RepositorySource) -> bool {
|
||||||
|
match source.kind {
|
||||||
|
workspace_api::RepositorySourceKind::LocalPath => fs::canonicalize(&source.uri)
|
||||||
|
.ok()
|
||||||
|
.is_some_and(|path| path == self.root),
|
||||||
|
workspace_api::RepositorySourceKind::File => source
|
||||||
|
.uri
|
||||||
|
.strip_prefix("file://")
|
||||||
|
.and_then(|path| fs::canonicalize(path).ok())
|
||||||
|
.is_some_and(|path| path == self.root),
|
||||||
|
workspace_api::RepositorySourceKind::Ssh
|
||||||
|
| workspace_api::RepositorySourceKind::Https => self
|
||||||
|
.remote_uris
|
||||||
|
.iter()
|
||||||
|
.any(|uri| normalize_git_uri(uri) == normalize_git_uri(&source.uri)),
|
||||||
|
workspace_api::RepositorySourceKind::Invalid => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discover_open_repository(path: &Path) -> Result<OpenRepositoryIdentity, ParseError> {
|
||||||
|
let root = git_stdout(path, ["rev-parse", "--show-toplevel"])?;
|
||||||
|
let root = fs::canonicalize(root.trim()).map_err(|error| {
|
||||||
|
ParseError(format!(
|
||||||
|
"failed to resolve current Git repository root: {error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let remote_uris = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&root)
|
||||||
|
.args(["remote", "get-url", "--all", "origin"])
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.filter(|output| output.status.success())
|
||||||
|
.map(|output| {
|
||||||
|
String::from_utf8_lossy(&output.stdout)
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|line| !line.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
Ok(OpenRepositoryIdentity { root, remote_uris })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git_stdout<const N: usize>(path: &Path, args: [&str; N]) -> Result<String, ParseError> {
|
||||||
|
let output = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(path)
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.map_err(|error| ParseError(format!("failed to execute Git: {error}")))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(ParseError(
|
||||||
|
"the current directory is not inside a readable Git repository".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
String::from_utf8(output.stdout)
|
||||||
|
.map_err(|_| ParseError("Git returned a non-UTF-8 repository path".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_git_uri(uri: &str) -> String {
|
||||||
|
let uri = uri.trim().trim_end_matches('/');
|
||||||
|
uri.strip_suffix(".git").unwrap_or(uri).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_workspace_backend_routing(
|
||||||
|
workspace_id: &str,
|
||||||
|
backend_url: &str,
|
||||||
|
) -> Result<(), ParseError> {
|
||||||
|
let path = client_global_config_path().ok_or_else(|| {
|
||||||
|
ParseError("unable to resolve the global client configuration directory".to_string())
|
||||||
|
})?;
|
||||||
|
record_workspace_backend_routing_at(&path, workspace_id, backend_url)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_workspace_backend_routing_at(
|
||||||
|
path: &Path,
|
||||||
|
workspace_id: &str,
|
||||||
|
backend_url: &str,
|
||||||
|
) -> Result<(), ParseError> {
|
||||||
|
let mut config = match fs::read_to_string(path) {
|
||||||
|
Ok(raw) => toml::from_str::<toml::Value>(&raw).map_err(|error| {
|
||||||
|
ParseError(format!(
|
||||||
|
"failed to parse global client config {}: {error}",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
})?,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
toml::Value::Table(toml::map::Map::new())
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
return Err(ParseError(format!(
|
||||||
|
"failed to read global client config {}: {error}",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let table = config
|
||||||
|
.as_table_mut()
|
||||||
|
.ok_or_else(|| ParseError("global client config must be a TOML table".to_string()))?;
|
||||||
|
let backends = table
|
||||||
|
.entry("backends")
|
||||||
|
.or_insert_with(|| toml::Value::Table(toml::map::Map::new()))
|
||||||
|
.as_table_mut()
|
||||||
|
.ok_or_else(|| ParseError("global client config `backends` must be a table".to_string()))?;
|
||||||
|
let existing_name = backends.iter().find_map(|(name, value)| {
|
||||||
|
value
|
||||||
|
.get("url")
|
||||||
|
.and_then(toml::Value::as_str)
|
||||||
|
.filter(|url| *url == backend_url)
|
||||||
|
.map(|_| name.clone())
|
||||||
|
});
|
||||||
|
let backend_name = existing_name.unwrap_or_else(|| {
|
||||||
|
let mut candidate = "init".to_string();
|
||||||
|
let mut suffix = 2_u32;
|
||||||
|
while backends.contains_key(&candidate) {
|
||||||
|
candidate = format!("init-{suffix}");
|
||||||
|
suffix += 1;
|
||||||
|
}
|
||||||
|
backends.insert(
|
||||||
|
candidate.clone(),
|
||||||
|
toml::Value::Table(toml::map::Map::from_iter([(
|
||||||
|
"url".to_string(),
|
||||||
|
toml::Value::String(backend_url.to_string()),
|
||||||
|
)])),
|
||||||
|
);
|
||||||
|
candidate
|
||||||
|
});
|
||||||
|
table
|
||||||
|
.entry("default_connection")
|
||||||
|
.or_insert_with(|| toml::Value::String("backend".to_string()));
|
||||||
|
table
|
||||||
|
.entry("default_backend")
|
||||||
|
.or_insert_with(|| toml::Value::String(backend_name.clone()));
|
||||||
|
let workspaces = table
|
||||||
|
.entry("workspaces")
|
||||||
|
.or_insert_with(|| toml::Value::Table(toml::map::Map::new()))
|
||||||
|
.as_table_mut()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ParseError("global client config `workspaces` must be a table".to_string())
|
||||||
|
})?;
|
||||||
|
workspaces.insert(
|
||||||
|
workspace_id.to_string(),
|
||||||
|
toml::Value::Table(toml::map::Map::from_iter([(
|
||||||
|
"backend".to_string(),
|
||||||
|
toml::Value::String(backend_name),
|
||||||
|
)])),
|
||||||
|
);
|
||||||
|
|
||||||
|
let parent = path.parent().ok_or_else(|| {
|
||||||
|
ParseError("global client config path has no parent directory".to_string())
|
||||||
|
})?;
|
||||||
|
fs::create_dir_all(parent).map_err(|error| {
|
||||||
|
ParseError(format!(
|
||||||
|
"failed to create global client config directory {}: {error}",
|
||||||
|
parent.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let encoded = toml::to_string_pretty(&config)
|
||||||
|
.map_err(|error| ParseError(format!("failed to encode global client config: {error}")))?;
|
||||||
|
let temporary = path.with_extension(format!("toml.tmp-{}", uuid::Uuid::now_v7()));
|
||||||
|
fs::write(&temporary, encoded).map_err(|error| {
|
||||||
|
ParseError(format!(
|
||||||
|
"failed to write global client config {}: {error}",
|
||||||
|
temporary.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
fs::rename(&temporary, path).map_err(|error| {
|
||||||
|
let _ = fs::remove_file(&temporary);
|
||||||
|
ParseError(format!(
|
||||||
|
"failed to publish global client config {}: {error}",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{ClientDefaultConnection, read_client_config_from_global_path};
|
||||||
|
|
||||||
|
fn workspace_summary(id: &str, name: &str) -> workspace_api::WorkspaceSummary {
|
||||||
|
workspace_api::WorkspaceSummary {
|
||||||
|
workspace_id: id.to_string(),
|
||||||
|
owner_account_id: "owner-account".to_string(),
|
||||||
|
display_name: name.to_string(),
|
||||||
|
state: "active".to_string(),
|
||||||
|
created_at: "2026-01-01T00:00:00Z".to_string(),
|
||||||
|
updated_at: "2026-01-01T00:00:00Z".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn repository_summary(
|
||||||
|
source: workspace_api::RepositorySource,
|
||||||
|
) -> workspace_api::RepositorySummary {
|
||||||
|
workspace_api::RepositorySummary {
|
||||||
|
repository_key: "main".to_string(),
|
||||||
|
kind: "git".to_string(),
|
||||||
|
provider: "builtin:git".to_string(),
|
||||||
|
source,
|
||||||
|
source_revision: 1,
|
||||||
|
source_fingerprint: "fingerprint".to_string(),
|
||||||
|
observed_status: workspace_api::RepositoryObservedStatus::Unverified,
|
||||||
|
observed_at: None,
|
||||||
|
default_selector: Some("develop".to_string()),
|
||||||
|
record_authority: "server_db".to_string(),
|
||||||
|
git: None,
|
||||||
|
diagnostics: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repository_catalog_selection_handles_single_zero_and_multiple_matches() {
|
||||||
|
let repository = tempfile::tempdir().unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["init", "-q"])
|
||||||
|
.current_dir(repository.path())
|
||||||
|
.status()
|
||||||
|
.unwrap();
|
||||||
|
let identity = discover_open_repository(repository.path()).unwrap();
|
||||||
|
let source = workspace_api::RepositorySource {
|
||||||
|
kind: workspace_api::RepositorySourceKind::LocalPath,
|
||||||
|
uri: identity.root.display().to_string(),
|
||||||
|
};
|
||||||
|
let matching_repository = repository_summary(source);
|
||||||
|
let workspace_a = workspace_summary("workspace-a", "Workspace A");
|
||||||
|
let workspace_b = workspace_summary("workspace-b", "Workspace B");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
select_workspace_from_repository_catalog(
|
||||||
|
&identity,
|
||||||
|
&[(workspace_a.clone(), vec![matching_repository.clone()])],
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
"workspace-a"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
select_workspace_from_repository_catalog(&identity, &[])
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("not registered in any accessible Workspace")
|
||||||
|
);
|
||||||
|
let multiple = select_workspace_from_repository_catalog(
|
||||||
|
&identity,
|
||||||
|
&[
|
||||||
|
(workspace_a, vec![matching_repository.clone()]),
|
||||||
|
(workspace_b, vec![matching_repository]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert!(multiple.contains("matches multiple accessible Workspaces"));
|
||||||
|
assert!(multiple.contains("--workspace-id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn init_routing_config_survives_reload_without_repository_local_state() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let path = temp.path().join("client.toml");
|
||||||
|
|
||||||
|
record_workspace_backend_routing_at(&path, "workspace-a", "http://backend.example")
|
||||||
|
.unwrap();
|
||||||
|
let first = read_client_config_from_global_path(Some(&path))
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let second = read_client_config_from_global_path(Some(&path))
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(first.default_connection, ClientDefaultConnection::Backend);
|
||||||
|
assert_eq!(first.default_backend.as_deref(), Some("init"));
|
||||||
|
assert_eq!(
|
||||||
|
first
|
||||||
|
.workspaces
|
||||||
|
.get("workspace-a")
|
||||||
|
.and_then(|entry| entry.backend.as_deref()),
|
||||||
|
Some("init")
|
||||||
|
);
|
||||||
|
assert_eq!(second, first);
|
||||||
|
assert!(!temp.path().join(".yoi/workspace.toml").exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,9 @@ worker
|
|||||||
|
|
||||||
## CLI / TUI routing
|
## CLI / TUI routing
|
||||||
|
|
||||||
- `yoi` の connection-aware command は `TargetKind::Standalone | Backend` の二択で dispatch する。`--local` は Standalone を、明示的な CLI selector または `$XDG_CONFIG_HOME/yoi/client.toml` の `default_backend` は Backend を選ぶ。repository-local `.yoi/workspace.toml` / `.yoi/client.config.toml` は connection や Workspace selection の authority ではなく、Backend operation の Workspace は明示的な Workspace selector または Server DB catalog から選択する。
|
- `yoi` の connection-aware command は `TargetKind::Standalone | Backend` の二択で dispatch する。`--local` は Standalone を、明示的な CLI selector、または `$XDG_CONFIG_HOME/yoi/client.toml` の `default_connection = "backend"` と `default_backend` は Backend を選ぶ。repository-local `.yoi/workspace.toml` / `.yoi/client.config.toml` は connection や Workspace selection の authority ではない。
|
||||||
|
- `yoi init --display-name <NAME> --repository-key <KEY>` は現在の Git repository root を Server DB に登録し、`$XDG_CONFIG_HOME/yoi/client.toml` に Backend routing のみを保存する。repository-local `.yoi/workspace.toml` は作成しない。
|
||||||
|
- Backend workflow で Workspace selector が省略された場合、client は現在の Git repository の canonical local source または `origin` URI を Server DB の Repository catalog と照合する。exactly one の Workspace だけを選択し、zero/multiple match や Backend offline は明示的に失敗して repository-local file へ fallback しない。
|
||||||
- Standalone の通常起動は `StandaloneHost`、restore は専用 `StandaloneStore` の session picker を使う。Workspace Worker list、PID、Unix socket、subprocess は探索しない。
|
- Standalone の通常起動は `StandaloneHost`、restore は専用 `StandaloneStore` の session picker を使う。Workspace Worker list、PID、Unix socket、subprocess は探索しない。
|
||||||
- `workers`、Backend Worker restore、Workspace panel、Ticket、Objective は Backend authority を要求する。Standalone から repository-local filesystem backend へ fallback しない。
|
- `workers`、Backend Worker restore、Workspace panel、Ticket、Objective は Backend authority を要求する。Standalone から repository-local filesystem backend へ fallback しない。
|
||||||
- `yoi worker` は Runtime や明示的な process-owned integration が使う direct Worker entrypoint として残るが、通常の `yoi` / TUI 起動経路からは呼び出さない。
|
- `yoi worker` は Runtime や明示的な process-owned integration が使う direct Worker entrypoint として残るが、通常の `yoi` / TUI 起動経路からは呼び出さない。
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ The first version intentionally does not implement roadmap scheduling, milestone
|
|||||||
|
|
||||||
## Ticket configuration
|
## Ticket configuration
|
||||||
|
|
||||||
Workspace Ticket data and workflow authority live in the Workspace Server's SQLite control-plane store. Repository-local `.yoi/workspace.toml` and `.yoi/ticket.config.toml` are not Ticket, Workspace identity, Backend connection, or role-launch authority.
|
Workspace Ticket data and workflow authority live in the Workspace Server's SQLite control-plane store. Repository-local `.yoi/workspace.toml` and `.yoi/ticket.config.toml` are not Ticket, Workspace identity, Backend connection, or role-launch authority. `yoi init --display-name <NAME> --repository-key <KEY>` registers the current Git repository through the Backend API and writes only global client routing under `$XDG_CONFIG_HOME/yoi/client.toml`.
|
||||||
|
|
||||||
Fixed Ticket workflow roles are `intake`, `orchestrator`, `coder`, and `reviewer`. The Server resolves the selected Profile and launch material from the active Workspace configuration authority, and Runtime receives the resulting immutable launch snapshot. A repository checkout may still contain ordinary project files, but neither the client nor Runtime may infer Workspace identity, Backend routing, role Profile, or Ticket storage from repository-local `.yoi` files.
|
Fixed Ticket workflow roles are `intake`, `orchestrator`, `coder`, and `reviewer`. The Server resolves the selected Profile and launch material from the active Workspace configuration authority, and Runtime receives the resulting immutable launch snapshot. A repository checkout may still contain ordinary project files, but neither the client nor Runtime may infer Workspace identity, Backend routing, role Profile, or Ticket storage from repository-local `.yoi` files.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user