From aea51caeb448b4d1ea8efea3967d4a7df3b1bca9 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 14 Sep 2026 19:24:44 +0900 Subject: [PATCH] fix: resolve workspaces through backend repositories --- Cargo.lock | 1 + crates/client/src/backend_workspace.rs | 63 +++- crates/client/src/lib.rs | 4 +- crates/client/tests/backend_workspace.rs | 17 +- crates/tui/src/backend_workspace_picker.rs | 14 +- crates/workspace-server/src/store.rs | 13 +- crates/yoi/Cargo.toml | 1 + crates/yoi/src/cli_connection.rs | 23 ++ crates/yoi/src/main.rs | 268 +++++++++++++- crates/yoi/src/workspace_bootstrap.rs | 409 +++++++++++++++++++++ docs/design/standalone-agent-host.md | 4 +- docs/development/work-items.md | 2 +- 12 files changed, 775 insertions(+), 44 deletions(-) create mode 100644 crates/yoi/src/workspace_bootstrap.rs diff --git a/Cargo.lock b/Cargo.lock index 17c9eb51..0d64bfb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6822,6 +6822,7 @@ dependencies = [ "tokio", "toml", "tui", + "uuid", "worker", "workspace-api", ] diff --git a/crates/client/src/backend_workspace.rs b/crates/client/src/backend_workspace.rs index 244025ca..149dc04e 100644 --- a/crates/client/src/backend_workspace.rs +++ b/crates/client/src/backend_workspace.rs @@ -3,20 +3,19 @@ use reqwest::Method; use serde::{Deserialize, Serialize}; use std::fmt; use workspace_api::{ - WorkspaceCatalogListResponse, WorkspaceCreateResponse, WorkspaceRepositoryRecord, - WorkspaceSummary, + RepositoryListResponse, RepositorySummary, WorkspaceCatalogListResponse, + WorkspaceCreateResponse, WorkspaceSummary, }; const DEFAULT_WORKSPACE_LIMIT: usize = 200; pub type BackendWorkspace = WorkspaceSummary; pub type CreateBackendWorkspaceResponse = WorkspaceCreateResponse; -pub type CreateBackendWorkspaceRepositoryRecord = WorkspaceRepositoryRecord; #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct CreateBackendWorkspaceRequest { - pub operation_key: String, + pub operation_id: String, pub display_name: String, pub repository: CreateBackendWorkspaceRepository, } @@ -24,8 +23,8 @@ pub struct CreateBackendWorkspaceRequest { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct CreateBackendWorkspaceRepository { - pub uri: String, - pub display_name: Option, + pub repository_key: String, + pub source: String, pub default_ref: Option, } @@ -73,6 +72,48 @@ impl From for BackendWorkspaceClientError { } } +pub fn list_backend_workspaces_blocking( + target: &BackendWorkspaceCatalogTarget, +) -> Result, 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::()?.0) +} + +pub fn list_backend_workspace_repositories_blocking( + target: &BackendWorkspaceCatalogTarget, + workspace_id: &str, +) -> Result, 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::()?; + 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( target: &BackendWorkspaceCatalogTarget, ) -> Result, BackendWorkspaceClientError> { @@ -144,19 +185,19 @@ mod tests { } #[test] - fn create_request_keeps_operation_key_for_exact_retry() { + fn create_request_keeps_operation_id_for_exact_retry() { let request = CreateBackendWorkspaceRequest { - operation_key: "workspace-create-1".to_string(), + operation_id: "workspace-create-1".to_string(), display_name: "Alpha".to_string(), repository: CreateBackendWorkspaceRepository { - uri: "/srv/repos/alpha".to_string(), - display_name: Some("Main".to_string()), + repository_key: "main".to_string(), + source: "/srv/repos/alpha".to_string(), default_ref: Some("develop".to_string()), }, }; let retry = request.clone(); - assert_eq!(retry.operation_key, "workspace-create-1"); + assert_eq!(retry.operation_id, "workspace-create-1"); assert_eq!(retry, request); } } diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index a1051bbe..538b1493 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -34,7 +34,9 @@ pub use backend_runtime::{ pub use backend_workspace::{ BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError, 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 target::{ diff --git a/crates/client/tests/backend_workspace.rs b/crates/client/tests/backend_workspace.rs index 64772bd2..e7a9a122 100644 --- a/crates/client/tests/backend_workspace.rs +++ b/crates/client/tests/backend_workspace.rs @@ -4,19 +4,26 @@ use client::{ }; #[test] -fn workspace_creation_request_preserves_operation_key_for_retry() { +fn workspace_creation_request_preserves_operation_id_for_retry() { let request = CreateBackendWorkspaceRequest { - operation_key: "workspace-create-1".to_string(), + operation_id: "workspace-create-1".to_string(), display_name: "Alpha".to_string(), repository: CreateBackendWorkspaceRepository { - uri: "/srv/repos/alpha".to_string(), - display_name: Some("Main".to_string()), + repository_key: "main".to_string(), + source: "/srv/repos/alpha".to_string(), default_ref: Some("develop".to_string()), }, }; 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] diff --git a/crates/tui/src/backend_workspace_picker.rs b/crates/tui/src/backend_workspace_picker.rs index e64cc71f..0db2356f 100644 --- a/crates/tui/src/backend_workspace_picker.rs +++ b/crates/tui/src/backend_workspace_picker.rs @@ -189,7 +189,7 @@ fn prompt_create_request_inner() -> PickerResult PickerResult, ) -> Result, 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)] @@ -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( diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 03446437..9ccc5284 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -6,6 +6,7 @@ mod plugin_cli; mod session_cli; mod ticket_cli; mod worker_cleanup_cli; +mod workspace_bootstrap; use std::collections::BTreeMap; use std::fmt; @@ -24,6 +25,9 @@ use client::{BackendAuthTarget, Target, TargetKind, start_device_login, wait_for use memory_lint::{LintCliOptions, LintStatus}; use serde::Deserialize; use tui::{LaunchMode, LaunchOptions}; +use workspace_bootstrap::{ + InitOptions, discover_repository_root, run_init, select_backend_workspace_for_repository, +}; #[derive(Debug)] enum Mode { @@ -48,6 +52,7 @@ enum Mode { backend_url: String, no_wait: bool, }, + Init(InitOptions), WorkerRuntime(Vec), Keys, SetupModel, @@ -107,6 +112,19 @@ async fn main() -> ExitCode { 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) { Ok(LintStatus::Clean) => ExitCode::SUCCESS, Ok(LintStatus::Failed) => ExitCode::FAILURE, @@ -220,7 +238,7 @@ fn resolve_tui_target( connection_resolver: &R, command: CliCommand, selection: &TargetSelection, - _workspace_root: &Path, + workspace_root: &Path, ) -> Result, ParseError> { if selection.explicit_local { return resolve_connection_aware_cli_connection( @@ -232,12 +250,29 @@ fn resolve_tui_target( ); } - if selection.backend_url.is_none() - && let Ok(target) = - resolve_connection_aware_cli_connection(connection_resolver, command, false, None, None) - && target.kind() == TargetKind::Standalone - { - return Ok(target); + if selection.workspace_id.is_none() { + if selection.backend_url.is_none() + && let Ok(target) = resolve_connection_aware_cli_connection( + connection_resolver, + command, + false, + None, + None, + ) + && target.kind() == TargetKind::Standalone + { + 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( @@ -435,6 +470,22 @@ fn parse_args_slice_with_connection_resolver( .map_err(|error| ParseError(error.to_string()))?; 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" => { let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Plugin)?; let plugin_cli = parse_plugin_args(&args[1..])?; @@ -552,6 +603,68 @@ fn parse_args_slice_with_connection_resolver( parse_console_options(args, &target_selection, connection_resolver) } +fn parse_init_args( + args: &[String], + explicit_backend_url: Option, +) -> Result { + 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( args: &[String], target_selection: &TargetSelection, @@ -1046,7 +1159,7 @@ fn current_dir() -> Result { .map_err(|e| ParseError(format!("failed to resolve current directory: {e}"))) } -#[derive(Debug, Default)] +#[derive(Debug, Default, PartialEq, Eq)] struct ClientConfigFile { default_backend: Option, default_connection: ClientDefaultConnection, @@ -1064,7 +1177,7 @@ struct ClientConfigOverlay { workspaces: BTreeMap, } -#[derive(Debug, Default)] +#[derive(Debug, Default, PartialEq, Eq)] struct ClientBackendConfig { url: Option, } @@ -1074,7 +1187,7 @@ struct ClientBackendConfigOverlay { url: Option, } -#[derive(Debug, Default)] +#[derive(Debug, Default, PartialEq, Eq)] struct ClientWorkspaceConfig { backend: Option, } @@ -1663,6 +1776,7 @@ Usage: yoi [TARGET] workers [-r|--stopped] [--runtime-id ] yoi [TARGET] resume [--all] [--runtime-id ] yoi --backend [--workspace-id ] panel + yoi [--backend ] init --display-name --repository-key [--repository ] [--default-ref ] yoi [--backend ] login [--no-wait] yoi [OPTIONS] @@ -1688,6 +1802,7 @@ Console options: --worker-id Backend Worker id; requires --runtime-id Host commands: + yoi init Register the current Git repository as a new Backend Workspace. keys Manage local model/API keys setup-model Configure a local model provider worker [WORKER_OPTIONS] Run the direct Worker process entrypoint @@ -1722,7 +1837,7 @@ Authority: Options: --backend Use this Workspace Backend --workspace-id Scope Backend routes to a Workspace id - --workspace Resolve Backend Workspace identity from this repository root + --workspace Match this Git repository against Server DB Repository records -r, --stopped List stopped Backend Workers --runtime-id Restrict the Backend Worker picker to a Runtime id -h, --help Print help @@ -1764,6 +1879,7 @@ mod tests { use super::*; use crate::cli_connection::CliConnectionInput; use client::{BackendTarget, StandaloneTarget, Target, TargetKind, WorkerListRequest}; + use std::process::Command; struct FixedCliConnectionResolver { backend_url: &'static str, @@ -1812,6 +1928,42 @@ mod tests { 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, ParseError> { + Ok(Box::new(BackendTarget::new( + "http://offline.example", + None::, + ))) + } + + 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] @@ -2054,7 +2206,15 @@ backend = "shared" #[test] 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 { target, @@ -2411,7 +2571,71 @@ backend = "shared" } #[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(); std::fs::create_dir_all(workspace.path().join(".yoi")).unwrap(); std::fs::write( @@ -2433,8 +2657,11 @@ backend = "shared" assert_eq!(target.kind(), TargetKind::Backend); assert_eq!( - target.resolve().unwrap_err().to_string(), - "invalid Backend target: workspace selection is required for Backend product-state operations", + target.resolve().unwrap(), + 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] fn parse_panel_rejects_removed_host_local_restore_path() { - let err = - parse_args_from(["--backend", "http://127.0.0.1:8787", "panel", "-r"]).unwrap_err(); + let err = parse_args_from([ + "--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")); } } diff --git a/crates/yoi/src/workspace_bootstrap.rs b/crates/yoi/src/workspace_bootstrap.rs new file mode 100644 index 00000000..f62266b0 --- /dev/null +++ b/crates/yoi/src/workspace_bootstrap.rs @@ -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, +} + +pub(crate) async fn run_init( + options: InitOptions, +) -> Result { + 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 { + 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 { + Ok(discover_open_repository(path)?.root) +} + +fn select_workspace_from_repository_catalog( + repository: &OpenRepositoryIdentity, + catalog: &[( + workspace_api::WorkspaceSummary, + Vec, + )], +) -> Result { + let matches = catalog + .iter() + .filter(|(_, repositories)| { + repositories + .iter() + .any(|candidate| repository.matches(&candidate.source)) + }) + .map(|(workspace, _)| workspace) + .collect::>(); + + 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::>() + .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, +} + +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 { + 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(path: &Path, args: [&str; N]) -> Result { + 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::(&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()); + } +} diff --git a/docs/design/standalone-agent-host.md b/docs/design/standalone-agent-host.md index c8ed3e7f..e8ac94f2 100644 --- a/docs/design/standalone-agent-host.md +++ b/docs/design/standalone-agent-host.md @@ -31,7 +31,9 @@ worker ## 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 --repository-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 は探索しない。 - `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 起動経路からは呼び出さない。 diff --git a/docs/development/work-items.md b/docs/development/work-items.md index 7e95cdbb..1e8df5b3 100644 --- a/docs/development/work-items.md +++ b/docs/development/work-items.md @@ -108,7 +108,7 @@ The first version intentionally does not implement roadmap scheduling, milestone ## 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 --repository-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.