diff --git a/.yoi/tickets/00001KWGT4WYB/item.md b/.yoi/tickets/00001KWGT4WYB/item.md index 0174c56b..22a78d6a 100644 --- a/.yoi/tickets/00001KWGT4WYB/item.md +++ b/.yoi/tickets/00001KWGT4WYB/item.md @@ -1,9 +1,11 @@ --- title: 'Workspace初期化をinitコマンドに切り出しserveの副作用をなくす' -state: 'planning' +state: 'closed' created_at: '2026-07-02T07:02:02Z' -updated_at: '2026-07-02T08:31:22Z' +updated_at: '2026-07-02T09:20:26Z' assignee: null +queued_by: 'yoi ticket' +queued_at: '2026-07-02T09:03:56Z' --- ## 背景 diff --git a/.yoi/tickets/00001KWGT4WYB/resolution.md b/.yoi/tickets/00001KWGT4WYB/resolution.md new file mode 100644 index 00000000..aecd55ed --- /dev/null +++ b/.yoi/tickets/00001KWGT4WYB/resolution.md @@ -0,0 +1,16 @@ +Implemented explicit Workspace initialization command and removed implicit initialization from serve. + +Delivered: +- `yoi workspace init [--workspace ]`. +- `yoi-workspace-server init [--workspace ]`. +- `init` creates `.yoi/workspace.toml` and `.yoi/workspace-backend.default.toml` only. +- `serve` requires `.yoi/workspace.toml` to already exist and fails with an init diagnostic otherwise. +- `serve` no longer creates workspace identity or default config template. +- Help text and focused tests updated. + +Validation: +- `cargo test -p yoi-workspace-server` +- `cargo test -p yoi` +- `cargo check -p yoi` +- `git diff --check` +- `nix build .#yoi --no-link` diff --git a/.yoi/tickets/00001KWGT4WYB/thread.md b/.yoi/tickets/00001KWGT4WYB/thread.md index 49d461cb..1f3c965d 100644 --- a/.yoi/tickets/00001KWGT4WYB/thread.md +++ b/.yoi/tickets/00001KWGT4WYB/thread.md @@ -38,4 +38,110 @@ Workspace init Ticket に storage / 正本境界を追記した。 - 将来 ProjectRecordBackend / TicketBackend / ObjectiveBackend 相当の provider を差し替える余地を残す。 +--- + + + +## Intake summary + +Marked ready by `yoi ticket state`. + +--- + + + +## State changed + +Marked ready by `yoi ticket state`. + + +--- + + + +## State changed + +Ticket を `yoi ticket` が queued にしました。 + + +--- + + + +## State changed + +State changed to `inprogress`. + + +--- + + + +## Implementation report + +Implemented explicit Workspace init and removed serve-time initialization side effects. + +Changes: +- Added `yoi workspace init [--workspace ]` passthrough to `yoi-workspace-server init`. +- Added `yoi-workspace-server init [--workspace ]`. +- `init` creates only `.yoi/workspace.toml` and `.yoi/workspace-backend.default.toml`. +- `init` does not create `.local` config, SQLite DB, embedded Runtime store, logs, Worker data, or project record layout. +- Added `WorkspaceIdentity::load_required(...)` for serve-time load-only behavior. +- `serve` now requires an initialized workspace and no longer calls `load_or_init` or copies the default config template. +- Uninitialized `serve` fails with a diagnostic telling the user to run `yoi workspace init --workspace `. +- Updated help text for `yoi workspace` and `yoi-workspace-server`. +- Added focused tests for init parsing, init output files, load_required no-create behavior, and yoi CLI passthrough. + +Validation: +- `cargo test -p yoi-workspace-server` +- `cargo test -p yoi` +- `cargo check -p yoi` +- `git diff --check` +- `nix build .#yoi --no-link` +- manual smoke: uninitialized `yoi-workspace-server serve` fails without creating identity. +- manual smoke: `yoi-workspace-server init` creates only workspace identity and default template. + + +--- + + + +## State changed + +State changed to `done`. + + +--- + + + +## State changed + +Ticket を closed にしました。 + + +--- + + + +## 完了 + +Implemented explicit Workspace initialization command and removed implicit initialization from serve. + +Delivered: +- `yoi workspace init [--workspace ]`. +- `yoi-workspace-server init [--workspace ]`. +- `init` creates `.yoi/workspace.toml` and `.yoi/workspace-backend.default.toml` only. +- `serve` requires `.yoi/workspace.toml` to already exist and fails with an init diagnostic otherwise. +- `serve` no longer creates workspace identity or default config template. +- Help text and focused tests updated. + +Validation: +- `cargo test -p yoi-workspace-server` +- `cargo test -p yoi` +- `cargo check -p yoi` +- `git diff --check` +- `nix build .#yoi --no-link` + + --- diff --git a/crates/workspace-server/src/identity.rs b/crates/workspace-server/src/identity.rs index 52acf4bd..d2209646 100644 --- a/crates/workspace-server/src/identity.rs +++ b/crates/workspace-server/src/identity.rs @@ -38,6 +38,21 @@ impl WorkspaceIdentity { }) } + pub fn load_required(workspace_root: impl AsRef) -> Result { + let path = Self::path(workspace_root.as_ref()); + match fs::read_to_string(&path) { + Ok(raw) => Self::parse_str(&raw, &path), + Err(error) if error.kind() == ErrorKind::NotFound => { + Err(Error::WorkspaceIdentity(format!( + "workspace is not initialized at {}; run `yoi workspace init --workspace {}` first", + workspace_root.as_ref().display(), + workspace_root.as_ref().display() + ))) + } + Err(error) => Err(Error::Io(error)), + } + } + pub fn path(workspace_root: impl AsRef) -> PathBuf { workspace_root .as_ref() @@ -192,6 +207,21 @@ mod tests { const FIXED_WORKSPACE_ID: &str = "0192f0e8-4d84-7d6e-a000-000000000001"; const FIXED_CREATED_AT: &str = "2026-06-23T06:43:28Z"; + #[test] + fn load_required_rejects_uninitialized_workspace_without_creating_identity() { + let temp = tempfile::tempdir().unwrap(); + let workspace_root = temp.path().join("uninitialized-workspace"); + fs::create_dir_all(&workspace_root).unwrap(); + + let error = WorkspaceIdentity::load_required(&workspace_root).unwrap_err(); + + assert!( + error.to_string().contains("workspace is not initialized"), + "unexpected error: {error}" + ); + assert!(!WorkspaceIdentity::path(&workspace_root).exists()); + } + #[test] fn missing_identity_file_is_created_with_safe_fields() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/workspace-server/src/main.rs b/crates/workspace-server/src/main.rs index b7538b0f..5247dcb3 100644 --- a/crates/workspace-server/src/main.rs +++ b/crates/workspace-server/src/main.rs @@ -8,6 +8,13 @@ use yoi_workspace_server::{ SqliteWorkspaceStore, WorkspaceBackendConfigFile, WorkspaceIdentity, serve, }; +#[derive(Debug)] +enum Command { + Serve(ServeOptions), + Init(InitOptions), + Help, +} + #[derive(Debug)] struct ServeOptions { workspace: PathBuf, @@ -16,6 +23,11 @@ struct ServeOptions { listen: Option, } +#[derive(Debug)] +struct InitOptions { + workspace: PathBuf, +} + #[derive(Debug)] struct CliError(String); @@ -40,34 +52,57 @@ async fn main() -> ExitCode { async fn run() -> Result<(), Box> { let args = std::env::args().skip(1).collect::>(); - let Some((command, rest)) = args.split_first() else { - print_help(); - return Ok(()); - }; - - match command.as_str() { - "serve" => { - if rest.iter().any(|arg| arg == "--help" || arg == "-h") { - print_serve_help(); - return Ok(()); - } - let options = parse_serve_options(rest)?; - run_serve(options).await?; - Ok(()) - } - "--help" | "-h" => { - print_help(); - Ok(()) - } - other => Err(Box::new(CliError(format!( - "unknown command `{other}`; expected `serve`" - )))), + match parse_command(&args)? { + Command::Serve(options) => run_serve(options).await, + Command::Init(options) => run_init(options), + Command::Help => Ok(()), } } -async fn run_serve(options: ServeOptions) -> Result<(), Box> { +fn parse_command(args: &[String]) -> Result { + let Some((command, rest)) = args.split_first() else { + print_help(); + return Ok(Command::Help); + }; + + match command.as_str() { + "init" => { + if rest.iter().any(|arg| arg == "--help" || arg == "-h") { + print_init_help(); + return Ok(Command::Help); + } + Ok(Command::Init(parse_init_options(rest)?)) + } + "serve" => { + if rest.iter().any(|arg| arg == "--help" || arg == "-h") { + print_serve_help(); + return Ok(Command::Help); + } + Ok(Command::Serve(parse_serve_options(rest)?)) + } + "--help" | "-h" => { + print_help(); + Ok(Command::Help) + } + other => Err(CliError(format!( + "unknown command `{other}`; expected `init` or `serve`" + ))), + } +} + +fn run_init(options: InitOptions) -> Result<(), Box> { let identity = WorkspaceIdentity::load_or_init(&options.workspace)?; WorkspaceBackendConfigFile::ensure_default_template_for_workspace(&options.workspace)?; + eprintln!( + "yoi-workspace-server: initialized workspace `{}` ({})", + options.workspace.display(), + identity.workspace_id + ); + Ok(()) +} + +async fn run_serve(options: ServeOptions) -> Result<(), Box> { + 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)?; if let Some(db) = options.db { @@ -95,6 +130,31 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box Result { + let mut workspace = std::env::current_dir() + .map_err(|error| CliError(format!("failed to read current dir: {error}")))?; + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--workspace" => { + let value = iter + .next() + .ok_or_else(|| CliError("--workspace requires a path".to_string()))?; + workspace = PathBuf::from(value); + } + value if value.starts_with("--workspace=") => { + workspace = PathBuf::from(value_after_equals(arg, "--workspace")?); + } + other => return Err(CliError(format!("unknown init option `{other}`"))), + } + } + + let workspace = workspace + .canonicalize() + .map_err(|error| CliError(format!("failed to canonicalize workspace: {error}")))?; + Ok(InitOptions { workspace }) +} + fn parse_serve_options(args: &[String]) -> Result { let mut workspace = std::env::current_dir() .map_err(|error| CliError(format!("failed to resolve current directory: {error}")))?; @@ -192,12 +252,58 @@ fn parse_listen(value: &str) -> Result { fn print_help() { println!( - "yoi-workspace-server\n\nUsage:\n yoi-workspace-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help" + "yoi-workspace-server\n\nUsage:\n yoi-workspace-server init [OPTIONS]\n yoi-workspace-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help" + ); +} + +fn print_init_help() { + println!( + "yoi-workspace-server init\n\nUsage:\n yoi-workspace-server init [OPTIONS]\n\nDescription:\n Initializes a Workspace identity and copies the default Backend config template. Does not create Backend data stores.\n\nOptions:\n --workspace Workspace root to initialize (defaults to cwd)\n -h, --help Print help" ); } fn print_serve_help() { println!( - "yoi-workspace-server serve\n\nUsage:\n yoi-workspace-server serve [OPTIONS]\n\nOptions:\n --workspace Workspace root containing .yoi project records (defaults to cwd)\n --db SQLite database path (defaults to /.yoi/workspace.db)\n --frontend Static SPA build directory to serve\n --listen Listen address (defaults to 127.0.0.1:8787)\n -h, --help Print help" + "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 Workspace root containing .yoi project records (defaults to cwd)\n --db SQLite database path (legacy dev override)\n --frontend Static SPA build directory to serve (legacy dev override)\n --listen Listen address (legacy dev override; default 127.0.0.1:8787)\n -h, --help Print help" ); } + +#[cfg(test)] +mod tests { + use super::*; + use yoi_workspace_server::{ + WORKSPACE_BACKEND_DEFAULT_CONFIG_RELATIVE_PATH, WORKSPACE_IDENTITY_RELATIVE_PATH, + }; + + #[test] + fn parse_init_defaults_workspace_to_cwd_or_flag() { + let temp = tempfile::tempdir().unwrap(); + let args = vec!["--workspace".to_string(), temp.path().display().to_string()]; + let options = parse_init_options(&args).unwrap(); + assert_eq!(options.workspace, temp.path().canonicalize().unwrap()); + } + + #[test] + fn init_creates_identity_and_default_template_only() { + let temp = tempfile::tempdir().unwrap(); + run_init(InitOptions { + workspace: temp.path().canonicalize().unwrap(), + }) + .unwrap(); + + assert!(temp.path().join(WORKSPACE_IDENTITY_RELATIVE_PATH).exists()); + assert!( + temp.path() + .join(WORKSPACE_BACKEND_DEFAULT_CONFIG_RELATIVE_PATH) + .exists() + ); + assert!( + !temp + .path() + .join(".yoi/workspace-backend.local.toml") + .exists() + ); + assert!(!temp.path().join(".yoi/workspace.db").exists()); + assert!(!temp.path().join(".yoi/embedded-runtime").exists()); + } +} diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index a8c1bb02..3d01f2ea 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -29,7 +29,10 @@ enum Mode { WorkerCleanup(worker_cleanup_cli::WorkerCleanupCli), Ticket(ticket_cli::TicketCli), WorkspaceHelp, - WorkspaceServe(Vec), + WorkspaceServer { + subcommand: String, + args: Vec, + }, WorkerRuntime(Vec), Keys, SetupModel, @@ -78,7 +81,7 @@ async fn main() -> ExitCode { print_workspace_help(); ExitCode::SUCCESS } - Mode::WorkspaceServe(args) => run_workspace_server(args), + Mode::WorkspaceServer { subcommand, args } => run_workspace_server(&subcommand, args), Mode::MemoryLint(options) => match memory_lint::run(&options) { Ok(LintStatus::Clean) => ExitCode::SUCCESS, Ok(LintStatus::Failed) => ExitCode::FAILURE, @@ -607,24 +610,36 @@ fn current_dir() -> Result { fn parse_workspace_args(args: &[String]) -> Result { let Some((subcommand, rest)) = args.split_first() else { return Err(ParseError( - "yoi workspace requires `serve` (try `yoi workspace --help`)".to_string(), + "yoi workspace requires `init` or `serve` (try `yoi workspace --help`)".to_string(), )); }; match subcommand.as_str() { + "init" => { + if rest.iter().any(|arg| arg == "--help" || arg == "-h") { + return Ok(Mode::WorkspaceHelp); + } + Ok(Mode::WorkspaceServer { + subcommand: "init".to_string(), + args: rest.to_vec(), + }) + } "serve" => { if rest.iter().any(|arg| arg == "--help" || arg == "-h") { return Ok(Mode::WorkspaceHelp); } - Ok(Mode::WorkspaceServe(rest.to_vec())) + Ok(Mode::WorkspaceServer { + subcommand: "serve".to_string(), + args: rest.to_vec(), + }) } "--help" | "-h" => Ok(Mode::WorkspaceHelp), other => Err(ParseError(format!( - "unknown yoi workspace subcommand `{other}`" + "unknown yoi workspace subcommand `{other}`; expected `init` or `serve`" ))), } } -fn run_workspace_server(args: Vec) -> ExitCode { +fn run_workspace_server(subcommand: &str, args: Vec) -> ExitCode { let command = match resolve_workspace_server_command() { Ok(command) => command, Err(error) => { @@ -634,7 +649,7 @@ fn run_workspace_server(args: Vec) -> ExitCode { }; let mut child = Command::new(&command); - child.arg("serve"); + child.arg(subcommand); child.args(args); match child.status() { Ok(status) if status.success() => ExitCode::SUCCESS, @@ -999,13 +1014,14 @@ fn parse_session_id(value: &str) -> Result { fn print_help() { println!( - "yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace ] [--all]\n yoi panel [--workspace ]\n yoi keys\n yoi setup-model\n yoi worker [WORKER_OPTIONS]\n yoi worker delete [--force] [--dry-run]\n yoi worker prune --older-than [--force] [--dry-run]\n yoi objective [OPTIONS]\n yoi session analyze --json\n yoi session prune --unreferenced [--older-than ] [--force] [--dry-run]\n yoi ticket [OPTIONS]\n yoi workspace serve [OPTIONS]\n yoi plugin new [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n yoi plugin list [--workspace ] [--profile ] [--json]\n yoi plugin show [--workspace ] [--profile ] [--json]\n yoi mcp list [--workspace ] [--profile ] [--json]\n yoi mcp show [--workspace ] [--profile ] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace ] [--profile ] [--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 Runtime workspace root for default Console/--worker (defaults to cwd)\n --worker Open the Worker Console by name (attach/restore/create)\n --socket Attach a Worker Console to a specific socket with --worker\n --session Resume a specific session segment in the Worker Console\n --profile Select a reusable Profile recipe\n -h, --help Print help\n" + "yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace ] [--all]\n yoi panel [--workspace ]\n yoi keys\n yoi setup-model\n yoi worker [WORKER_OPTIONS]\n yoi worker delete [--force] [--dry-run]\n yoi worker prune --older-than [--force] [--dry-run]\n yoi objective [OPTIONS]\n yoi session analyze --json\n yoi session prune --unreferenced [--older-than ] [--force] [--dry-run]\n yoi ticket [OPTIONS]\n yoi workspace init [OPTIONS] + yoi workspace serve [OPTIONS]\n yoi plugin new [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n yoi plugin list [--workspace ] [--profile ] [--json]\n yoi plugin show [--workspace ] [--profile ] [--json]\n yoi mcp list [--workspace ] [--profile ] [--json]\n yoi mcp show [--workspace ] [--profile ] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace ] [--profile ] [--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 Runtime workspace root for default Console/--worker (defaults to cwd)\n --worker Open the Worker Console by name (attach/restore/create)\n --socket Attach a Worker Console to a specific socket with --worker\n --session Resume a specific session segment in the Worker Console\n --profile Select a reusable Profile recipe\n -h, --help Print help\n" ); } fn print_workspace_help() { println!( - "yoi workspace\n\nUsage:\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\nOptions forwarded to yoi-workspace-server serve:\n --workspace Workspace root containing .yoi project records (defaults to cwd)\n --db SQLite database path (defaults to /.yoi/workspace.db)\n --frontend Static SPA build directory to serve\n --listen Listen address (defaults to 127.0.0.1:8787)\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 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 the default Backend config template\n serve Serve an already initialized Workspace\n\nOptions forwarded to init/serve:\n --workspace Workspace root (defaults to cwd)\n\nLegacy dev options forwarded to serve:\n --db SQLite database path override\n --frontend Static SPA build directory to serve\n --listen Listen address override\n -h, --help Print help\n\nEnvironment:\n YOI_WORKSPACE_SERVER_COMMAND Path to yoi-workspace-server executable override\n" ); } @@ -1209,7 +1225,21 @@ mod tests { #[test] fn parse_workspace_serve_passthrough() { match parse_args_from(["workspace", "serve", "--listen", "127.0.0.1:0"]).unwrap() { - Mode::WorkspaceServe(args) => assert_eq!(args, vec!["--listen", "127.0.0.1:0"]), + Mode::WorkspaceServer { subcommand, args } => { + assert_eq!(subcommand, "serve"); + assert_eq!(args, vec!["--listen", "127.0.0.1:0"]); + } + other => panic!("unexpected mode: {other:?}"), + } + } + + #[test] + fn parse_workspace_init_passthrough() { + match parse_args_from(["workspace", "init", "--workspace", "/tmp/ws"]).unwrap() { + Mode::WorkspaceServer { subcommand, args } => { + assert_eq!(subcommand, "init"); + assert_eq!(args, vec!["--workspace", "/tmp/ws"]); + } other => panic!("unexpected mode: {other:?}"), } }