feat: manage workspace backend local config

This commit is contained in:
Keisuke Hirata 2026-07-02 21:02:45 +09:00
parent da631029cb
commit 8e449acd59
No known key found for this signature in database
7 changed files with 300 additions and 67 deletions

View File

@ -2,7 +2,7 @@
title: 'Workspace初期化をinitコマンドに切り出しserveの副作用をなくす' title: 'Workspace初期化をinitコマンドに切り出しserveの副作用をなくす'
state: 'closed' state: 'closed'
created_at: '2026-07-02T07:02:02Z' created_at: '2026-07-02T07:02:02Z'
updated_at: '2026-07-02T09:20:26Z' updated_at: '2026-07-02T11:59:35Z'
assignee: null assignee: null
queued_by: 'yoi ticket' queued_by: 'yoi ticket'
queued_at: '2026-07-02T09:03:56Z' queued_at: '2026-07-02T09:03:56Z'
@ -14,30 +14,32 @@ queued_at: '2026-07-02T09:03:56Z'
- `WorkspaceIdentity::load_or_init(workspace_root)` - `WorkspaceIdentity::load_or_init(workspace_root)`
- `.yoi/workspace.toml` が無ければ作る。 - `.yoi/workspace.toml` が無ければ作る。
- `WorkspaceBackendConfigFile::ensure_default_template_for_workspace(workspace_root)` - `WorkspaceBackendConfigFile::ensure_local_config_for_workspace(workspace_root)`
- `.yoi/workspace-backend.default.toml` が無ければ `resources/workspace-backend.default.toml` からコピーする。 - `.yoi/workspace-backend.local.toml` が無ければ `resources/workspace-backend.default.toml` からコピーする。
1Workspace=1Backend の現状では、Backend の実行 directory / `--workspace` が workspace root になっている。この前提自体はよいが、`serve` が初期化副作用を持つと、間違った cwd で起動しただけで `.yoi/workspace.toml`default config template が作られる。 1Workspace=1Backend の現状では、Backend の実行 directory / `--workspace` が workspace root になっている。この前提自体はよいが、`serve` が初期化副作用を持つと、間違った cwd で起動しただけで `.yoi/workspace.toml`local config が作られる。
Workspace の一回だけ実行されるべき初期化は、明示的な `init` コマンドへ切り出し、`serve` は既に初期化済みの workspace を起動するだけにする。 Workspace の一回だけ実行されるべき初期化は、明示的な `init` コマンドへ切り出し、`serve` は既に初期化済みの workspace を起動するだけにする。
## 目的 ## 目的
- Workspace 初期化を明示コマンドに切り出す。 - Workspace 初期化を明示コマンドに切り出す。
- `serve` 起動時に workspace identity / default config template を作らない。 - `serve` 起動時に workspace identity / local config を作らない。
- `serve` は未初期化 workspace に対して typed diagnostic で失敗する。 - `serve` は未初期化 workspace に対して typed diagnostic で失敗する。
- 1Workspace=1Backend の前提を保ち、workspace root は Backend 起動対象として明示する。 - 1Workspace=1Backend の前提を保ち、workspace root は Backend 起動対象として明示する。
- 初期化で作る record / template と、serve/runtime が生成する data を分ける。 - 初期化で作る record / local config と、serve/runtime が生成する data を分ける。
- 現状の local filesystem 保存を採用しつつ、Workspace / Project record の将来的な provider 可換性を妨げない。 - 現状の local filesystem 保存を採用しつつ、Workspace / Project record の将来的な provider 可換性を妨げない。
## コマンド設計 ## コマンド設計
### Product CLI ### Product CLI
`yoi` 側に workspace init subcommand を追加する。 `yoi` 側に workspace init と config subcommand を追加する。
```text ```text
yoi workspace init [--workspace <PATH>] yoi workspace init [--workspace <PATH>]
yoi workspace config default
yoi workspace config diff [--workspace <PATH>]
yoi workspace serve [OPTIONS] yoi workspace serve [OPTIONS]
``` ```
@ -49,6 +51,8 @@ yoi workspace serve [OPTIONS]
```text ```text
yoi-workspace-server init [--workspace <PATH>] yoi-workspace-server init [--workspace <PATH>]
yoi-workspace-server config default
yoi-workspace-server config diff [--workspace <PATH>]
yoi-workspace-server serve [OPTIONS] yoi-workspace-server serve [OPTIONS]
``` ```
@ -60,7 +64,7 @@ yoi-workspace-server serve [OPTIONS]
```text ```text
.yoi/workspace.toml .yoi/workspace.toml
.yoi/workspace-backend.default.toml .yoi/workspace-backend.local.toml
``` ```
### `.yoi/workspace.toml` ### `.yoi/workspace.toml`
@ -70,17 +74,16 @@ yoi-workspace-server serve [OPTIONS]
- 既存ファイルがある場合は parse/validate し、上書きしない。 - 既存ファイルがある場合は parse/validate し、上書きしない。
- 作成は `create_new` semantics を維持し、race 時は既存 record を読み直す。 - 作成は `create_new` semantics を維持し、race 時は既存 record を読み直す。
### `.yoi/workspace-backend.default.toml` ### `.yoi/workspace-backend.local.toml`
- `resources/workspace-backend.default.toml` からコピーする workspace-local template - `resources/workspace-backend.default.toml` からコピーする workspace-local config
- 既存ファイルがある場合は上書きしない。 - 既存ファイルがある場合は上書きしない。
- `.local` config ではないため、ユーザーが実設定として使う場合は `.yoi/workspace-backend.local.toml` にコピーして編集する。 - packaged default の最新版は `yoi workspace config default` で参照し、local との差分は `yoi workspace config diff` で確認する。
## 初期化で作らないもの ## 初期化で作らないもの
`init` は Backend / Runtime data を作らない。 `init` は Backend / Runtime data を作らない。
- `.yoi/workspace-backend.local.toml`
- control-plane SQLite DB - control-plane SQLite DB
- embedded Runtime fs-store - embedded Runtime fs-store
- logs / pid files - logs / pid files
@ -131,7 +134,7 @@ Ticket / Objective などの project record は、現状では `.yoi/tickets` /
1. workspace root を決める。 1. workspace root を決める。
2. `.yoi/workspace.toml` を load する。 2. `.yoi/workspace.toml` を load する。
3. `.yoi/workspace-backend.default.toml` は作らない。 3. `.yoi/workspace-backend.local.toml` は作らない。
4. `.yoi/workspace-backend.local.toml` があれば読む。無ければ defaults。 4. `.yoi/workspace-backend.local.toml` があれば読む。無ければ defaults。
5. resolved `ServerConfig` で Backend を起動する。 5. resolved `ServerConfig` で Backend を起動する。
@ -143,7 +146,7 @@ Diagnostic 例:
workspace is not initialized at <path>; run `yoi workspace init --workspace <path>` first workspace is not initialized at <path>; run `yoi workspace init --workspace <path>` first
``` ```
`serve``.yoi/workspace-backend.default.toml` が無いだけでは失敗しない方針とする。これは template であり、runtime config の authority ではないため。ただし init 済み workspace で default template が欠けている場合に warning を出すかは実装時に判断する `serve``.yoi/workspace-backend.local.toml` が無いだけでは失敗しない方針とする。config file が欠けている場合は code fallback を使う。ただし `init` は通常 `.local` config を作るため、欠落は診断対象にしてよい
## 内部 API 整理 ## 内部 API 整理
@ -161,7 +164,7 @@ WorkspaceInitialization::load_required(workspace_root) -> WorkspaceIdentity
```rust ```rust
WorkspaceIdentity::init_if_missing(...) WorkspaceIdentity::init_if_missing(...)
WorkspaceIdentity::load_required(...) WorkspaceIdentity::load_required(...)
WorkspaceBackendConfigFile::ensure_default_template_for_workspace(...) WorkspaceBackendConfigFile::ensure_local_config_for_workspace(...)
``` ```
重要なのは、`serve` が `load_or_init` を呼ばないこと。 重要なのは、`serve` が `load_or_init` を呼ばないこと。
@ -171,20 +174,23 @@ WorkspaceBackendConfigFile::ensure_default_template_for_workspace(...)
この Ticket は Workspace Backend config schema の方針を維持し、新規 Backend 設定項目を CLI flag として増やさない。 この Ticket は Workspace Backend config schema の方針を維持し、新規 Backend 設定項目を CLI flag として増やさない。
- `init` に必要なのは `--workspace` だけ。 - `init` に必要なのは `--workspace` だけ。
- `config diff` に必要なのも `--workspace` だけ。
- `serve` の既存 legacy dev flags をこの Ticket で全面削除するかは別判断にする。 - `serve` の既存 legacy dev flags をこの Ticket で全面削除するかは別判断にする。
- ただし `serve` の初期化副作用は必ずなくす。 - ただし `serve` の初期化副作用は必ずなくす。
## 実装要件 ## 実装要件
- `yoi workspace init [--workspace <PATH>]` を追加する。 - `yoi workspace init [--workspace <PATH>]` を追加する。
- `yoi workspace config default` / `yoi workspace config diff [--workspace <PATH>]` を追加する。
- `yoi-workspace-server config default` / `yoi-workspace-server config diff [--workspace <PATH>]` を追加する。
- `yoi-workspace-server init [--workspace <PATH>]` を追加する。 - `yoi-workspace-server init [--workspace <PATH>]` を追加する。
- workspace-server 側の help に init を追加する。 - workspace-server 側の help に init を追加する。
- `WorkspaceIdentity` に load-only path を追加する。 - `WorkspaceIdentity` に load-only path を追加する。
- 既存 `load_or_init` は init command 内部用に残してよいが、serve からは呼ばない。 - 既存 `load_or_init` は init command 内部用に残してよいが、serve からは呼ばない。
- `serve` から `WorkspaceIdentity::load_or_init(...)` を外す。 - `serve` から `WorkspaceIdentity::load_or_init(...)` を外す。
- `serve` から `WorkspaceBackendConfigFile::ensure_default_template_for_workspace(...)` を外す。 - `serve` から `WorkspaceBackendConfigFile::ensure_local_config_for_workspace(...)` を外す。
- 未初期化 workspace の `serve` は typed diagnostic で失敗する。 - 未初期化 workspace の `serve` は typed diagnostic で失敗する。
- `init` は existing workspace identity / default template を上書きしない。 - `init` は existing workspace identity / local config を上書きしない。
- `init` は data root / DB / embedded Runtime store を作らない。 - `init` は data root / DB / embedded Runtime store を作らない。
- `init` は Ticket / Objective など provider-specific project record layout を作らない。 - `init` は Ticket / Objective など provider-specific project record layout を作らない。
- `serve` / Browser-facing API / Runtime create path に workspace-local filesystem path を正本識別子として漏らさない。 - `serve` / Browser-facing API / Runtime create path に workspace-local filesystem path を正本識別子として漏らさない。
@ -192,19 +198,21 @@ WorkspaceBackendConfigFile::ensure_default_template_for_workspace(...)
## 受け入れ条件 ## 受け入れ条件
- `yoi workspace init [--workspace <PATH>]` が使える。 - `yoi workspace init [--workspace <PATH>]` が使える。
- `yoi workspace config default` が packaged template を表示する。
- `yoi workspace config diff [--workspace <PATH>]``.local` config と packaged template を比較する。
- `yoi-workspace-server init [--workspace <PATH>]` が使える。 - `yoi-workspace-server init [--workspace <PATH>]` が使える。
- `init``.yoi/workspace.toml``.yoi/workspace-backend.default.toml` を作る。 - `init``.yoi/workspace.toml``.yoi/workspace-backend.local.toml` を作る。
- `init` が既存 `.yoi/workspace.toml` / `.yoi/workspace-backend.default.toml` を上書きしない。 - `init` が既存 `.yoi/workspace.toml` / `.yoi/workspace-backend.local.toml` を上書きしない。
- `init``.yoi/workspace-backend.local.toml`DB、embedded Runtime fs-store、logs を作らない - `init` が DB、embedded Runtime fs-store、logs を作らない.
- `init` が Ticket / Objective body や provider-specific project record layout を作らない。 - `init` が Ticket / Objective body や provider-specific project record layout を作らない。
- `serve``.yoi/workspace.toml` を新規作成しない。 - `serve``.yoi/workspace.toml` を新規作成しない。
- `serve``.yoi/workspace-backend.default.toml` を新規作成しない。 - `serve``.yoi/workspace-backend.local.toml` を新規作成しない。
- 未初期化 workspace で `serve` すると、`workspace init` を促す diagnostic で失敗する。 - 未初期化 workspace で `serve` すると、`workspace init` を促す diagnostic で失敗する。
- 初期化済み workspace では config file が無くても defaults で `serve` が起動できる。 - 初期化済み workspace では `.local` config が存在し、欠けた key は code fallback で解決される。
- Browser-facing API / Runtime create path / Worker conversation context に `.yoi/workspace.toml`、`.yoi/tickets/...`、data root、DB path、Runtime store path が正本識別子として漏れない。 - Browser-facing API / Runtime create path / Worker conversation context に `.yoi/workspace.toml`、`.yoi/tickets/...`、data root、DB path、Runtime store path が正本識別子として漏れない。
- Project record provider 可換性を妨げないことが code/docs/tests 上で明確になっている。 - Project record provider 可換性を妨げないことが code/docs/tests 上で明確になっている。
- Help text が `workspace init``workspace serve` の責務差を説明している。 - Help text が `workspace init`、`workspace config`、`workspace serve` の責務差を説明している。
- Focused tests が init 作成、init idempotency、serve 未初期化拒否、serve 初期化済み起動、data 非作成を確認する。 - Focused tests が init 作成、init idempotency、serve 未初期化拒否、serve 初期化済み起動、config default/diff、data 非作成を確認する。
- `cargo test -p yoi-workspace-server` が通る。 - `cargo test -p yoi-workspace-server` が通る。
- `cargo test -p yoi` が通る、または CLI parser tests が通る。 - `cargo test -p yoi` が通る、または CLI parser tests が通る。
- `cargo check -p yoi` が通る。 - `cargo check -p yoi` が通る。
@ -213,7 +221,7 @@ WorkspaceBackendConfigFile::ensure_default_template_for_workspace(...)
## 対象外 ## 対象外
- Backend config schema の追加変更。 - Backend config schema の追加変更。ただし packaged template の参照・diff CLI はこの Ticket の correction として扱う。
- frontend Vite config の管理。 - frontend Vite config の管理。
- remote Runtime supervisor。 - remote Runtime supervisor。
- secret store 実装。 - secret store 実装。

View File

@ -144,4 +144,29 @@ Validation:
- `nix build .#yoi --no-link` - `nix build .#yoi --no-link`
---
<!-- event: implementation_report author: hare at: 2026-07-02T11:59:35Z -->
## Implementation report
Adjusted Workspace Backend config handling so no workspace-local `.default.toml` file is created.
Decision:
- `resources/workspace-backend.default.toml` remains the packaged template source.
- `yoi workspace init` copies that template directly to `.yoi/workspace-backend.local.toml` with create-new semantics and never overwrites an existing local config.
- `.yoi/workspace-backend.default.toml` is no longer created.
- `yoi workspace config default` / `yoi-workspace-server config default` print the latest packaged template.
- `yoi workspace config diff` / `yoi-workspace-server config diff` compare the workspace-local config with the packaged template.
- Built-in code fallback remains separate from the generated local config file.
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: `yoi-workspace-server init` creates only `.yoi/workspace.toml` and `.yoi/workspace-backend.local.toml`; `config default` prints the packaged template; `config diff` reports a match for fresh init.
--- ---

View File

@ -10,9 +10,7 @@ use crate::server::{AuthConfig, ServerConfig};
use crate::{Error, Result}; use crate::{Error, Result};
pub const WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH: &str = ".yoi/workspace-backend.local.toml"; pub const WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH: &str = ".yoi/workspace-backend.local.toml";
pub const WORKSPACE_BACKEND_DEFAULT_CONFIG_RELATIVE_PATH: &str = pub const WORKSPACE_BACKEND_CONFIG_TEMPLATE: &str =
".yoi/workspace-backend.default.toml";
pub const WORKSPACE_BACKEND_DEFAULT_CONFIG_TEMPLATE: &str =
include_str!("../../../resources/workspace-backend.default.toml"); include_str!("../../../resources/workspace-backend.default.toml");
const DEFAULT_LISTEN: &str = "127.0.0.1:8787"; const DEFAULT_LISTEN: &str = "127.0.0.1:8787";
const DEFAULT_FRONTEND_URL: &str = "http://127.0.0.1:5173"; const DEFAULT_FRONTEND_URL: &str = "http://127.0.0.1:5173";
@ -78,6 +76,61 @@ pub struct RemoteRuntimeConfigFile {
pub token_ref: Option<String>, pub token_ref: Option<String>,
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigDiff {
pub differs: bool,
pub text: String,
}
impl ConfigDiff {
fn new(default: &str, local: &str) -> Self {
if default == local {
return Self {
differs: false,
text: "workspace backend local config matches the packaged default\n".to_string(),
};
}
let mut text = String::from("--- packaged default\n+++ workspace local\n");
let default_lines = default.lines().collect::<Vec<_>>();
let local_lines = local.lines().collect::<Vec<_>>();
let max = default_lines.len().max(local_lines.len());
for index in 0..max {
match (default_lines.get(index), local_lines.get(index)) {
(Some(left), Some(right)) if left == right => {
text.push(' ');
text.push_str(left);
text.push('\n');
}
(Some(left), Some(right)) => {
text.push('-');
text.push_str(left);
text.push('\n');
text.push('+');
text.push_str(right);
text.push('\n');
}
(Some(left), None) => {
text.push('-');
text.push_str(left);
text.push('\n');
}
(None, Some(right)) => {
text.push('+');
text.push_str(right);
text.push('\n');
}
(None, None) => {}
}
}
Self {
differs: true,
text,
}
}
}
#[derive(Clone)] #[derive(Clone)]
pub struct ResolvedWorkspaceBackendConfig { pub struct ResolvedWorkspaceBackendConfig {
pub server: ServerConfig, pub server: ServerConfig,
@ -92,14 +145,8 @@ impl WorkspaceBackendConfigFile {
.join(WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH) .join(WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH)
} }
pub fn default_template_path_for_workspace(workspace_root: impl AsRef<Path>) -> PathBuf { pub fn ensure_local_config_for_workspace(workspace_root: impl AsRef<Path>) -> Result<()> {
workspace_root let path = Self::path_for_workspace(workspace_root);
.as_ref()
.join(WORKSPACE_BACKEND_DEFAULT_CONFIG_RELATIVE_PATH)
}
pub fn ensure_default_template_for_workspace(workspace_root: impl AsRef<Path>) -> Result<()> {
let path = Self::default_template_path_for_workspace(workspace_root);
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?; fs::create_dir_all(parent)?;
} }
@ -110,7 +157,7 @@ impl WorkspaceBackendConfigFile {
{ {
Ok(mut file) => { Ok(mut file) => {
use std::io::Write; use std::io::Write;
file.write_all(WORKSPACE_BACKEND_DEFAULT_CONFIG_TEMPLATE.as_bytes())?; file.write_all(WORKSPACE_BACKEND_CONFIG_TEMPLATE.as_bytes())?;
file.sync_all()?; file.sync_all()?;
Ok(()) Ok(())
} }
@ -119,6 +166,20 @@ impl WorkspaceBackendConfigFile {
} }
} }
pub fn local_config_diff_for_workspace(workspace_root: impl AsRef<Path>) -> Result<ConfigDiff> {
let workspace_root = workspace_root.as_ref();
let path = Self::path_for_workspace(workspace_root);
match fs::read_to_string(&path) {
Ok(local) => Ok(ConfigDiff::new(WORKSPACE_BACKEND_CONFIG_TEMPLATE, &local)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Err(Error::Config(format!(
"workspace backend local config `{}` does not exist; run `yoi workspace init --workspace {}` first",
path.display(),
workspace_root.display()
))),
Err(error) => Err(Error::Io(error)),
}
}
pub fn load_for_workspace(workspace_root: impl AsRef<Path>) -> Result<Self> { pub fn load_for_workspace(workspace_root: impl AsRef<Path>) -> Result<Self> {
let path = Self::path_for_workspace(workspace_root); let path = Self::path_for_workspace(workspace_root);
match fs::read_to_string(&path) { match fs::read_to_string(&path) {
@ -367,17 +428,39 @@ root = ".local-data"
} }
#[test] #[test]
fn copies_default_template_without_overwriting() { fn copies_local_config_without_overwriting() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
WorkspaceBackendConfigFile::ensure_default_template_for_workspace(dir.path()).unwrap(); WorkspaceBackendConfigFile::ensure_local_config_for_workspace(dir.path()).unwrap();
let path = WorkspaceBackendConfigFile::default_template_path_for_workspace(dir.path()); let path = WorkspaceBackendConfigFile::path_for_workspace(dir.path());
let raw = fs::read_to_string(&path).unwrap(); let raw = fs::read_to_string(&path).unwrap();
assert_eq!(raw, WORKSPACE_BACKEND_DEFAULT_CONFIG_TEMPLATE); assert_eq!(raw, WORKSPACE_BACKEND_CONFIG_TEMPLATE);
WorkspaceBackendConfigFile::parse_str(&raw, &path).unwrap(); WorkspaceBackendConfigFile::parse_str(&raw, &path).unwrap();
fs::write(&path, "# custom template\n").unwrap(); fs::write(&path, "# custom local config\n").unwrap();
WorkspaceBackendConfigFile::ensure_default_template_for_workspace(dir.path()).unwrap(); WorkspaceBackendConfigFile::ensure_local_config_for_workspace(dir.path()).unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "# custom template\n"); assert_eq!(
fs::read_to_string(&path).unwrap(),
"# custom local config\n"
);
}
#[test]
fn local_config_diff_reports_match_and_difference() {
let dir = tempfile::tempdir().unwrap();
WorkspaceBackendConfigFile::ensure_local_config_for_workspace(dir.path()).unwrap();
let matched =
WorkspaceBackendConfigFile::local_config_diff_for_workspace(dir.path()).unwrap();
assert!(!matched.differs);
fs::write(
WorkspaceBackendConfigFile::path_for_workspace(dir.path()),
"[server]\nlisten = \"127.0.0.1:9999\"\n",
)
.unwrap();
let diff = WorkspaceBackendConfigFile::local_config_diff_for_workspace(dir.path()).unwrap();
assert!(diff.differs);
assert!(diff.text.contains("+++ workspace local"));
assert!(diff.text.contains("127.0.0.1:9999"));
} }
#[test] #[test]

View File

@ -15,8 +15,8 @@ pub mod server;
pub mod store; pub mod store;
pub use config::{ pub use config::{
ResolvedWorkspaceBackendConfig, WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH, ConfigDiff, ResolvedWorkspaceBackendConfig, WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH,
WORKSPACE_BACKEND_DEFAULT_CONFIG_RELATIVE_PATH, WorkspaceBackendConfigFile, WORKSPACE_BACKEND_CONFIG_TEMPLATE, WorkspaceBackendConfigFile,
}; };
pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity}; pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity};
pub use records::{ pub use records::{

View File

@ -5,13 +5,16 @@ use std::sync::Arc;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use yoi_workspace_server::{ use yoi_workspace_server::{
SqliteWorkspaceStore, WorkspaceBackendConfigFile, WorkspaceIdentity, serve, SqliteWorkspaceStore, WORKSPACE_BACKEND_CONFIG_TEMPLATE, WorkspaceBackendConfigFile,
WorkspaceIdentity, serve,
}; };
#[derive(Debug)] #[derive(Debug)]
enum Command { enum Command {
Serve(ServeOptions), Serve(ServeOptions),
Init(InitOptions), Init(InitOptions),
ConfigDefault,
ConfigDiff(WorkspacePathOptions),
Help, Help,
} }
@ -28,6 +31,11 @@ struct InitOptions {
workspace: PathBuf, workspace: PathBuf,
} }
#[derive(Debug)]
struct WorkspacePathOptions {
workspace: PathBuf,
}
#[derive(Debug)] #[derive(Debug)]
struct CliError(String); struct CliError(String);
@ -55,6 +63,8 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
match parse_command(&args)? { match parse_command(&args)? {
Command::Serve(options) => run_serve(options).await, Command::Serve(options) => run_serve(options).await,
Command::Init(options) => run_init(options), Command::Init(options) => run_init(options),
Command::ConfigDefault => run_config_default(),
Command::ConfigDiff(options) => run_config_diff(options),
Command::Help => Ok(()), Command::Help => Ok(()),
} }
} }
@ -73,6 +83,7 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
} }
Ok(Command::Init(parse_init_options(rest)?)) Ok(Command::Init(parse_init_options(rest)?))
} }
"config" => parse_config_command(rest),
"serve" => { "serve" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") { if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
print_serve_help(); print_serve_help();
@ -85,14 +96,14 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
Ok(Command::Help) Ok(Command::Help)
} }
other => Err(CliError(format!( other => Err(CliError(format!(
"unknown command `{other}`; expected `init` or `serve`" "unknown command `{other}`; expected `init`, `config`, or `serve`"
))), ))),
} }
} }
fn run_init(options: InitOptions) -> Result<(), Box<dyn std::error::Error>> { fn run_init(options: InitOptions) -> Result<(), Box<dyn std::error::Error>> {
let identity = WorkspaceIdentity::load_or_init(&options.workspace)?; let identity = WorkspaceIdentity::load_or_init(&options.workspace)?;
WorkspaceBackendConfigFile::ensure_default_template_for_workspace(&options.workspace)?; WorkspaceBackendConfigFile::ensure_local_config_for_workspace(&options.workspace)?;
eprintln!( eprintln!(
"yoi-workspace-server: initialized workspace `{}` ({})", "yoi-workspace-server: initialized workspace `{}` ({})",
options.workspace.display(), options.workspace.display(),
@ -101,6 +112,17 @@ fn run_init(options: InitOptions) -> Result<(), Box<dyn std::error::Error>> {
Ok(()) Ok(())
} }
fn run_config_default() -> Result<(), Box<dyn std::error::Error>> {
print!("{WORKSPACE_BACKEND_CONFIG_TEMPLATE}");
Ok(())
}
fn run_config_diff(options: WorkspacePathOptions) -> Result<(), Box<dyn std::error::Error>> {
let diff = WorkspaceBackendConfigFile::local_config_diff_for_workspace(&options.workspace)?;
print!("{}", diff.text);
Ok(())
}
async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Error>> { async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Error>> {
let identity = WorkspaceIdentity::load_required(&options.workspace)?; let identity = WorkspaceIdentity::load_required(&options.workspace)?;
let config_file = WorkspaceBackendConfigFile::load_for_workspace(&options.workspace)?; let config_file = WorkspaceBackendConfigFile::load_for_workspace(&options.workspace)?;
@ -130,6 +152,65 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
Ok(()) Ok(())
} }
fn parse_config_command(args: &[String]) -> Result<Command, CliError> {
let Some((subcommand, rest)) = args.split_first() else {
print_config_help();
return Ok(Command::Help);
};
match subcommand.as_str() {
"default" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
print_config_help();
return Ok(Command::Help);
}
if !rest.is_empty() {
return Err(CliError(
"config default does not accept options".to_string(),
));
}
Ok(Command::ConfigDefault)
}
"diff" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
print_config_help();
return Ok(Command::Help);
}
Ok(Command::ConfigDiff(parse_workspace_path_options(rest)?))
}
"--help" | "-h" => {
print_config_help();
Ok(Command::Help)
}
other => Err(CliError(format!(
"unknown config subcommand `{other}`; expected `default` or `diff`"
))),
}
}
fn parse_workspace_path_options(args: &[String]) -> Result<WorkspacePathOptions, CliError> {
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 workspace option `{other}`"))),
}
}
let workspace = workspace
.canonicalize()
.map_err(|error| CliError(format!("failed to canonicalize workspace: {error}")))?;
Ok(WorkspacePathOptions { workspace })
}
fn parse_init_options(args: &[String]) -> Result<InitOptions, CliError> { fn parse_init_options(args: &[String]) -> Result<InitOptions, CliError> {
let mut workspace = std::env::current_dir() let mut workspace = std::env::current_dir()
.map_err(|error| CliError(format!("failed to read current dir: {error}")))?; .map_err(|error| CliError(format!("failed to read current dir: {error}")))?;
@ -252,13 +333,19 @@ fn parse_listen(value: &str) -> Result<SocketAddr, CliError> {
fn print_help() { fn print_help() {
println!( println!(
"yoi-workspace-server\n\nUsage:\n yoi-workspace-server init [OPTIONS]\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 config <COMMAND> [OPTIONS]\n yoi-workspace-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
); );
} }
fn print_init_help() { fn print_init_help() {
println!( 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 <PATH> Workspace root to initialize (defaults to cwd)\n -h, --help Print help" "yoi-workspace-server init\n\nUsage:\n yoi-workspace-server init [OPTIONS]\n\nDescription:\n Initializes a Workspace identity and copies the packaged Backend config template to .yoi/workspace-backend.local.toml. Does not create Backend data stores.\n\nOptions:\n --workspace <PATH> Workspace root to initialize (defaults to cwd)\n -h, --help Print help"
);
}
fn print_config_help() {
println!(
"yoi-workspace-server config\n\nUsage:\n yoi-workspace-server config default\n yoi-workspace-server config diff [OPTIONS]\n\nDescription:\n Prints the packaged Workspace Backend config template or compares it with the workspace-local config.\n\nOptions for diff:\n --workspace <PATH> Workspace root (defaults to cwd)\n -h, --help Print help"
); );
} }
@ -272,7 +359,8 @@ fn print_serve_help() {
mod tests { mod tests {
use super::*; use super::*;
use yoi_workspace_server::{ use yoi_workspace_server::{
WORKSPACE_BACKEND_DEFAULT_CONFIG_RELATIVE_PATH, WORKSPACE_IDENTITY_RELATIVE_PATH, WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH, WORKSPACE_BACKEND_CONFIG_TEMPLATE,
WORKSPACE_IDENTITY_RELATIVE_PATH,
}; };
#[test] #[test]
@ -284,7 +372,7 @@ mod tests {
} }
#[test] #[test]
fn init_creates_identity_and_default_template_only() { fn init_creates_identity_and_local_config_only() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
run_init(InitOptions { run_init(InitOptions {
workspace: temp.path().canonicalize().unwrap(), workspace: temp.path().canonicalize().unwrap(),
@ -292,15 +380,16 @@ mod tests {
.unwrap(); .unwrap();
assert!(temp.path().join(WORKSPACE_IDENTITY_RELATIVE_PATH).exists()); assert!(temp.path().join(WORKSPACE_IDENTITY_RELATIVE_PATH).exists());
assert!( let local_config_path = temp.path().join(WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH);
temp.path() assert!(local_config_path.exists());
.join(WORKSPACE_BACKEND_DEFAULT_CONFIG_RELATIVE_PATH) assert_eq!(
.exists() std::fs::read_to_string(local_config_path).unwrap(),
WORKSPACE_BACKEND_CONFIG_TEMPLATE
); );
assert!( assert!(
!temp !temp
.path() .path()
.join(".yoi/workspace-backend.local.toml") .join(".yoi/workspace-backend.default.toml")
.exists() .exists()
); );
assert!(!temp.path().join(".yoi/workspace.db").exists()); assert!(!temp.path().join(".yoi/workspace.db").exists());

View File

@ -610,7 +610,8 @@ fn current_dir() -> Result<PathBuf, ParseError> {
fn parse_workspace_args(args: &[String]) -> Result<Mode, ParseError> { fn parse_workspace_args(args: &[String]) -> Result<Mode, ParseError> {
let Some((subcommand, rest)) = args.split_first() else { let Some((subcommand, rest)) = args.split_first() else {
return Err(ParseError( return Err(ParseError(
"yoi workspace requires `init` or `serve` (try `yoi workspace --help`)".to_string(), "yoi workspace requires `init`, `config`, or `serve` (try `yoi workspace --help`)"
.to_string(),
)); ));
}; };
match subcommand.as_str() { match subcommand.as_str() {
@ -623,6 +624,15 @@ fn parse_workspace_args(args: &[String]) -> Result<Mode, ParseError> {
args: rest.to_vec(), args: rest.to_vec(),
}) })
} }
"config" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
return Ok(Mode::WorkspaceHelp);
}
Ok(Mode::WorkspaceServer {
subcommand: "config".to_string(),
args: rest.to_vec(),
})
}
"serve" => { "serve" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") { if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
return Ok(Mode::WorkspaceHelp); return Ok(Mode::WorkspaceHelp);
@ -634,7 +644,7 @@ fn parse_workspace_args(args: &[String]) -> Result<Mode, ParseError> {
} }
"--help" | "-h" => Ok(Mode::WorkspaceHelp), "--help" | "-h" => Ok(Mode::WorkspaceHelp),
other => Err(ParseError(format!( other => Err(ParseError(format!(
"unknown yoi workspace subcommand `{other}`; expected `init` or `serve`" "unknown yoi workspace subcommand `{other}`; expected `init`, `config`, or `serve`"
))), ))),
} }
} }
@ -1015,13 +1025,14 @@ fn parse_session_id(value: &str) -> Result<SegmentId, ParseError> {
fn print_help() { fn print_help() {
println!( println!(
"yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace <PATH>] [--all]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi worker [WORKER_OPTIONS]\n yoi worker delete <NAME> [--force] [--dry-run]\n yoi worker prune --older-than <DURATION> [--force] [--dry-run]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi session prune --unreferenced [--older-than <DURATION>] [--force] [--dry-run]\n yoi ticket <COMMAND> [OPTIONS]\n yoi workspace init [OPTIONS] "yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace <PATH>] [--all]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi worker [WORKER_OPTIONS]\n yoi worker delete <NAME> [--force] [--dry-run]\n yoi worker prune --older-than <DURATION> [--force] [--dry-run]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi session prune --unreferenced [--older-than <DURATION>] [--force] [--dry-run]\n yoi ticket <COMMAND> [OPTIONS]\n yoi workspace init [OPTIONS]
yoi workspace config <COMMAND> [OPTIONS]
yoi workspace serve [OPTIONS]\n yoi plugin new <rust-component-tool|rust-component-service> <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp show <SERVER> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Worker chat/client surface (default, --worker, yoi resume, Backend Runtime target)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace <PATH> Runtime workspace root for default Console/--worker (defaults to cwd)\n --worker <NAME> Open the Worker Console by name (attach/restore/create)\n --socket <PATH> Attach a Worker Console to a specific socket with --worker\n --session <UUID> Resume a specific session segment in the Worker Console\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n" yoi workspace serve [OPTIONS]\n yoi plugin new <rust-component-tool|rust-component-service> <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp show <SERVER> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Worker chat/client surface (default, --worker, yoi resume, Backend Runtime target)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace <PATH> Runtime workspace root for default Console/--worker (defaults to cwd)\n --worker <NAME> Open the Worker Console by name (attach/restore/create)\n --socket <PATH> Attach a Worker Console to a specific socket with --worker\n --session <UUID> Resume a specific session segment in the Worker Console\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
); );
} }
fn print_workspace_help() { fn print_workspace_help() {
println!( println!(
"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 <PATH> Workspace root (defaults to cwd)\n\nLegacy dev options forwarded to serve:\n --db <PATH> SQLite database path override\n --frontend <PATH> Static SPA build directory to serve\n --listen <ADDR> Listen address override\n -h, --help Print help\n\nEnvironment:\n YOI_WORKSPACE_SERVER_COMMAND Path to yoi-workspace-server executable override\n" "yoi workspace\n\nUsage:\n yoi workspace init [OPTIONS]\n yoi workspace config <COMMAND> [OPTIONS]\n yoi workspace serve [OPTIONS]\n\nDescription:\n Launches the separate yoi-workspace-server executable. The yoi binary does not link the workspace server crate.\n\nSubcommands:\n init Initialize .yoi/workspace.toml and .yoi/workspace-backend.local.toml\n config default Print the latest packaged Backend config template\n config diff Compare workspace local config with the packaged template\n serve Serve an already initialized Workspace\n\nOptions forwarded to init/config diff/serve:\n --workspace <PATH> Workspace root (defaults to cwd)\n\nLegacy dev options forwarded to serve:\n --db <PATH> SQLite database path override\n --frontend <PATH> Static SPA build directory to serve\n --listen <ADDR> Listen address override\n -h, --help Print help\n\nEnvironment:\n YOI_WORKSPACE_SERVER_COMMAND Path to yoi-workspace-server executable override\n"
); );
} }
@ -1244,6 +1255,17 @@ mod tests {
} }
} }
#[test]
fn parse_workspace_config_passthrough() {
match parse_args_from(["workspace", "config", "diff", "--workspace", "/tmp/ws"]).unwrap() {
Mode::WorkspaceServer { subcommand, args } => {
assert_eq!(subcommand, "config");
assert_eq!(args, vec!["diff", "--workspace", "/tmp/ws"]);
}
other => panic!("unexpected mode: {other:?}"),
}
}
#[test] #[test]
fn parse_workspace_help() { fn parse_workspace_help() {
assert!(matches!( assert!(matches!(

View File

@ -1,10 +1,16 @@
# Workspace Backend local config template. # Workspace Backend local config template.
# #
# Copied to `.yoi/workspace-backend.default.toml` during workspace init. # `yoi workspace init` copies this packaged template to
# Copy that file to `.yoi/workspace-backend.local.toml` and edit it. # `.yoi/workspace-backend.local.toml` without overwriting an existing file.
# The `.local` file is intentionally git-ignored. # The `.local` file is intentionally git-ignored.
# #
# Omit a key to use the built-in default. TOML has no `null`, so optional # Print the latest packaged template with:
# yoi workspace config default
#
# Compare the local config with the latest packaged template with:
# yoi workspace config diff
#
# Omit a key to use the built-in fallback. TOML has no `null`, so optional
# settings are represented by leaving the key commented out. # settings are represented by leaving the key commented out.
[server] [server]
@ -19,17 +25,17 @@ frontend_url = "http://127.0.0.1:5173"
# static_assets_dir = "web/workspace/dist" # static_assets_dir = "web/workspace/dist"
[data] [data]
# Backend data root override. Leave commented to use the user-data default: # Backend data root override. Leave commented to use the user-data fallback:
# <data_dir>/workspace-server/<workspace_id>/ # <data_dir>/workspace-server/<workspace_id>/
# Relative paths are resolved from the workspace root. # Relative paths are resolved from the workspace root.
# root = ".yoi/workspace-backend.data" # root = ".yoi/workspace-backend.data"
# Explicit control-plane SQLite DB path override. # Explicit control-plane SQLite DB path override.
# If omitted, this defaults to `<data.root>/workspace.db`. # If omitted, this falls back to `<data.root>/workspace.db`.
# workspace_database_path = ".yoi/workspace-backend.data/workspace.db" # workspace_database_path = ".yoi/workspace-backend.data/workspace.db"
# Explicit embedded Runtime fs-store root override. # Explicit embedded Runtime fs-store root override.
# If omitted, this defaults to `<data.root>/embedded-runtime`. # If omitted, this falls back to `<data.root>/embedded-runtime`.
# embedded_runtime_store_root = ".yoi/workspace-backend.data/embedded-runtime" # embedded_runtime_store_root = ".yoi/workspace-backend.data/embedded-runtime"
[limits] [limits]