merge: workspace local manifest override

This commit is contained in:
Keisuke Hirata 2026-06-01 22:16:59 +09:00
commit 9be37ea123
No known key found for this signature in database
7 changed files with 327 additions and 3 deletions

View File

@ -17,7 +17,8 @@ pub use paths::user_profiles_path;
pub use profile::{ pub use profile::{
ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata, ProfileRegistry, ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata, ProfileRegistry,
ProfileRegistryEntry, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver, ProfileRegistryEntry, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver,
ProfileSelector, ProfileSource, ResolvedProfile, resolve_profile_artifact, ProfileSelector, ProfileSource, ResolvedProfile, WorkspaceOverrideSnapshot,
resolve_profile_artifact,
}; };
pub use protocol::{Permission, ScopeRule}; pub use protocol::{Permission, ScopeRule};
pub use scope::{Scope, ScopeError, SharedScope}; pub use scope::{Scope, ScopeError, SharedScope};

View File

@ -25,6 +25,7 @@ const BUILTIN_DEFAULT_PROFILE_NAME: &str = "default";
const BUILTIN_DEFAULT_PROFILE: &str = include_str!("../../../resources/profiles/default.lua"); const BUILTIN_DEFAULT_PROFILE: &str = include_str!("../../../resources/profiles/default.lua");
const BUILTIN_MODEL_CATALOG: &str = include_str!("../../../resources/models/builtin.toml"); const BUILTIN_MODEL_CATALOG: &str = include_str!("../../../resources/models/builtin.toml");
const DEFAULT_POD_NAME: &str = "yoi"; const DEFAULT_POD_NAME: &str = "yoi";
const WORKSPACE_OVERRIDE_LOCAL_FILENAME: &str = "override.local.toml";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
@ -340,6 +341,19 @@ pub struct ProfileManifestSnapshot {
pub source: ProfileSource, pub source: ProfileSource,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<ProfileMetadata>, pub profile: Option<ProfileMetadata>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_override: Option<WorkspaceOverrideSnapshot>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkspaceOverrideSnapshot {
pub path: PathBuf,
}
#[derive(Debug)]
struct WorkspaceOverrideLayer {
path: PathBuf,
config: PodManifestConfig,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -475,6 +489,7 @@ impl ProfileResolver {
.as_deref() .as_deref()
.unwrap_or_else(|| Path::new(".")), .unwrap_or_else(|| Path::new(".")),
)?; )?;
let workspace_override = load_workspace_override_from(&workspace_base)?;
let lua_value = evaluate_lua_profile(&absolute_path, &profile_dir)?; let lua_value = evaluate_lua_profile(&absolute_path, &profile_dir)?;
let raw_artifact = lua_value.clone(); let raw_artifact = lua_value.clone();
resolve_lua_profile_value( resolve_lua_profile_value(
@ -484,6 +499,7 @@ impl ProfileResolver {
options, options,
lua_value, lua_value,
raw_artifact, raw_artifact,
workspace_override,
) )
} }
@ -499,6 +515,7 @@ impl ProfileResolver {
.as_deref() .as_deref()
.unwrap_or_else(|| Path::new(".")), .unwrap_or_else(|| Path::new(".")),
)?; )?;
let workspace_override = load_workspace_override_from(&workspace_base)?;
let lua_value = evaluate_embedded_lua_profile(label, content)?; let lua_value = evaluate_embedded_lua_profile(label, content)?;
let raw_artifact = lua_value.clone(); let raw_artifact = lua_value.clone();
resolve_lua_profile_value( resolve_lua_profile_value(
@ -508,6 +525,7 @@ impl ProfileResolver {
options, options,
lua_value, lua_value,
raw_artifact, raw_artifact,
workspace_override,
) )
} }
} }
@ -519,6 +537,7 @@ fn resolve_lua_profile_value(
options: ProfileResolveOptions, options: ProfileResolveOptions,
value: serde_json::Value, value: serde_json::Value,
raw_artifact: serde_json::Value, raw_artifact: serde_json::Value,
workspace_override: Option<WorkspaceOverrideLayer>,
) -> Result<ResolvedProfile, ProfileError> { ) -> Result<ResolvedProfile, ProfileError> {
if !workspace_base.is_absolute() { if !workspace_base.is_absolute() {
return Err(ProfileError::InvalidPath { return Err(ProfileError::InvalidPath {
@ -554,11 +573,28 @@ fn resolve_lua_profile_value(
memory: profile.memory, memory: profile.memory,
skills: profile.skills, skills: profile.skills,
}; };
let config = PodManifestConfig::builtin_defaults().merge(config.resolve_paths(profile_dir)); let mut config = PodManifestConfig::builtin_defaults().merge(config.resolve_paths(profile_dir));
let workspace_override_snapshot = if let Some(override_layer) = workspace_override {
let override_base =
override_layer
.path
.parent()
.ok_or_else(|| ProfileError::InvalidPath {
path: override_layer.path.clone(),
message: "workspace override path has no parent directory".into(),
})?;
config = config.merge(override_layer.config.resolve_paths(override_base));
Some(WorkspaceOverrideSnapshot {
path: override_layer.path,
})
} else {
None
};
let mut manifest = PodManifest::try_from(config).map_err(ProfileError::ManifestResolve)?; let mut manifest = PodManifest::try_from(config).map_err(ProfileError::ManifestResolve)?;
manifest.profile = Some(ProfileManifestSnapshot { manifest.profile = Some(ProfileManifestSnapshot {
source: source.clone(), source: source.clone(),
profile: profile_meta.clone(), profile: profile_meta.clone(),
workspace_override: workspace_override_snapshot,
}); });
let manifest_snapshot = let manifest_snapshot =
serde_json::to_value(&manifest).map_err(ProfileError::SnapshotSerialize)?; serde_json::to_value(&manifest).map_err(ProfileError::SnapshotSerialize)?;
@ -687,6 +723,54 @@ fn load_profile_registry_file(
Ok(()) Ok(())
} }
fn load_workspace_override_from(
workspace_base: &Path,
) -> Result<Option<WorkspaceOverrideLayer>, ProfileError> {
find_workspace_override_from(workspace_base)
.map(|path| load_workspace_override_file(&path))
.transpose()
}
fn load_workspace_override_file(path: &Path) -> Result<WorkspaceOverrideLayer, ProfileError> {
let content =
std::fs::read_to_string(path).map_err(|source| ProfileError::WorkspaceOverrideRead {
path: path.to_path_buf(),
source,
})?;
let config = PodManifestConfig::from_toml(&content).map_err(|source| {
ProfileError::WorkspaceOverrideParse {
path: path.to_path_buf(),
source,
}
})?;
if config.pod.name.is_some() {
return Err(ProfileError::InvalidWorkspaceOverride {
path: path.to_path_buf(),
message: "workspace-local manifest overrides cannot set pod.name; Pod identity is a runtime input".into(),
});
}
Ok(WorkspaceOverrideLayer {
path: path.to_path_buf(),
config,
})
}
fn find_workspace_override_from(start: &Path) -> Option<PathBuf> {
let start = start
.canonicalize()
.ok()
.unwrap_or_else(|| start.to_path_buf());
let mut cur: Option<&Path> = Some(start.as_path());
while let Some(dir) = cur {
let candidate = dir.join(".yoi").join(WORKSPACE_OVERRIDE_LOCAL_FILENAME);
if candidate.is_file() {
return Some(candidate);
}
cur = dir.parent();
}
None
}
fn find_project_profiles_from(start: &Path) -> Option<PathBuf> { fn find_project_profiles_from(start: &Path) -> Option<PathBuf> {
let start = start let start = start
.canonicalize() .canonicalize()
@ -1214,6 +1298,7 @@ pub fn resolve_profile_artifact(
ProfileResolveOptions::default(), ProfileResolveOptions::default(),
raw_artifact.clone(), raw_artifact.clone(),
raw_artifact, raw_artifact,
None,
) )
} }
@ -1241,6 +1326,20 @@ pub enum ProfileError {
#[source] #[source]
source: toml::de::Error, source: toml::de::Error,
}, },
#[error("failed to read workspace local manifest override {}: {source}", .path.display())]
WorkspaceOverrideRead {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse workspace local manifest override {}: {source}", .path.display())]
WorkspaceOverrideParse {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("invalid workspace local manifest override {}: {message}", .path.display())]
InvalidWorkspaceOverride { path: PathBuf, message: String },
#[error("no default profile is configured")] #[error("no default profile is configured")]
NoDefaultProfile, NoDefaultProfile,
#[error("profile not found: {selector}")] #[error("profile not found: {selector}")]
@ -1498,6 +1597,125 @@ return profile {
} }
); );
} }
#[test]
fn workspace_local_override_layers_over_profile_defaults() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("project");
let nested = workspace.join("nested");
let yoi_dir = workspace.join(".yoi");
std::fs::create_dir_all(&nested).unwrap();
std::fs::create_dir_all(&yoi_dir).unwrap();
let override_path = yoi_dir.join(WORKSPACE_OVERRIDE_LOCAL_FILENAME);
std::fs::write(
&override_path,
r#"
[pod]
prompt_pack = "prompts.toml"
[worker]
language = "ja"
[session]
record_event_trace = false
"#,
)
.unwrap();
let resolved = ProfileResolver::new()
.with_workspace_base(&nested)
.resolve(&ProfileSelector::Default, ProfileResolveOptions::default())
.unwrap();
assert_eq!(resolved.manifest.pod.name, "yoi");
assert_eq!(resolved.manifest.worker.language, "ja");
assert!(!resolved.manifest.session.record_event_trace);
assert_eq!(
resolved.manifest.pod.prompt_pack.as_deref(),
Some(yoi_dir.join("prompts.toml").as_path())
);
assert_eq!(resolved.manifest.scope.allow[0].target, nested);
assert_eq!(
resolved
.manifest
.profile
.as_ref()
.and_then(|snapshot| snapshot.workspace_override.as_ref())
.map(|snapshot| snapshot.path.as_path()),
Some(override_path.as_path())
);
}
#[test]
fn workspace_local_override_uses_nearest_ancestor() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("project");
let nested = workspace.join("nested");
let child = nested.join("child");
let parent_yoi = workspace.join(".yoi");
let nested_yoi = nested.join(".yoi");
std::fs::create_dir_all(&child).unwrap();
std::fs::create_dir_all(&parent_yoi).unwrap();
std::fs::create_dir_all(&nested_yoi).unwrap();
std::fs::write(
parent_yoi.join(WORKSPACE_OVERRIDE_LOCAL_FILENAME),
r#"
[pod]
prompt_pack = "parent-prompts.toml"
[worker]
language = "parent"
"#,
)
.unwrap();
let nested_override_path = nested_yoi.join(WORKSPACE_OVERRIDE_LOCAL_FILENAME);
std::fs::write(
&nested_override_path,
r#"
[pod]
prompt_pack = "nested-prompts.toml"
[worker]
language = "nested"
"#,
)
.unwrap();
let resolved = ProfileResolver::new()
.with_workspace_base(&child)
.resolve(&ProfileSelector::Default, ProfileResolveOptions::default())
.unwrap();
assert_eq!(resolved.manifest.worker.language, "nested");
assert_eq!(
resolved.manifest.pod.prompt_pack.as_deref(),
Some(nested_yoi.join("nested-prompts.toml").as_path())
);
assert_eq!(
resolved
.manifest
.profile
.as_ref()
.and_then(|snapshot| snapshot.workspace_override.as_ref())
.map(|snapshot| snapshot.path.as_path()),
Some(nested_override_path.as_path())
);
}
#[test]
fn workspace_local_override_rejects_runtime_pod_name() {
let tmp = TempDir::new().unwrap();
let yoi_dir = tmp.path().join(".yoi");
std::fs::create_dir_all(&yoi_dir).unwrap();
std::fs::write(
yoi_dir.join(WORKSPACE_OVERRIDE_LOCAL_FILENAME),
"[pod]\nname = \"not-local\"\n",
)
.unwrap();
let err = ProfileResolver::new()
.with_workspace_base(tmp.path())
.resolve(&ProfileSelector::Default, ProfileResolveOptions::default())
.unwrap_err();
assert!(matches!(err, ProfileError::InvalidWorkspaceOverride { .. }));
assert!(err.to_string().contains("pod.name"));
}
#[test] #[test]
fn unsupported_profile_extension_has_clear_diagnostic() { fn unsupported_profile_extension_has_clear_diagnostic() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();

View File

@ -485,6 +485,51 @@ permission = "write"
assert!(loader.workspace_dir().is_none()); assert!(loader.workspace_dir().is_none());
} }
#[test]
fn manifest_mode_does_not_apply_workspace_local_override() {
let tmp = TempDir::new().unwrap();
let yoi_dir = tmp.path().join(".yoi");
std::fs::create_dir_all(&yoi_dir).unwrap();
write(
&yoi_dir.join("override.local.toml"),
r#"
[pod]
name = "from-local-override"
[worker]
language = "override"
"#,
);
let manifest_path = tmp.path().join("manifest.toml");
write(
&manifest_path,
&format!(
r#"
[pod]
name = "from-single-file"
[model]
scheme = "anthropic"
model_id = "test-model"
[worker]
language = "manifest"
[[scope.allow]]
target = "{}"
permission = "write"
"#,
tmp.path().display()
),
);
let cli = Cli::try_parse_from(["yoi pod", "--manifest", manifest_path.to_str().unwrap()])
.unwrap();
let (manifest, _loader) = resolve_manifest(&cli).unwrap();
assert_eq!(manifest.pod.name, "from-single-file");
assert_eq!(manifest.worker.language, "manifest");
}
#[test] #[test]
fn profile_uses_selected_profile() { fn profile_uses_selected_profile() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();

View File

@ -27,6 +27,8 @@ Source/partial layers may omit fields. Resolved manifests should be explicit eno
`--manifest <path>` exists as an explicit low-level escape hatch. Normal fresh startup should select a Profile through `profiles.toml` / builtin defaults rather than ambient manifest cascades. `--manifest <path>` exists as an explicit low-level escape hatch. Normal fresh startup should select a Profile through `profiles.toml` / builtin defaults rather than ambient manifest cascades.
For normal Profile/default startup, a workspace may add `.yoi/override.local.toml` as a final local manifest layer. Yoi discovers the nearest ancestor `.yoi/override.local.toml` from the workspace base used for profile resolution, resolves relative paths in that file against its containing `.yoi` directory, and applies it after the selected Profile and builtin defaults. This file is intended for machine-local choices such as provider/model, worker language, prompt pack, and permission policy tweaks; it is ignored by git via the repository `*.local.*` rule. It is not applied in explicit `--manifest <path>` mode, and it cannot set `pod.name` because Pod identity remains a runtime input.
## Spawned Pods ## Spawned Pods
`SpawnPod.profile` is optional and resolves through defaults when omitted. The only concrete capability delegation in the tool call is `SpawnPod.scope`, and it must be a subset of the parent's effective scope. `SpawnPod.profile` is optional and resolves through defaults when omitted. The only concrete capability delegation in the tool call is `SpawnPod.scope`, and it must be a subset of the parent's effective scope.

View File

@ -0,0 +1,28 @@
# Implementation report
Implemented workspace-local manifest override support.
## Behavior
- Normal Profile/default resolution now searches upward from the resolver workspace base for the nearest `.yoi/override.local.toml`.
- The override is parsed as a `PodManifestConfig` layer, resolved relative to its parent `.yoi/` directory, and merged after builtin defaults plus the selected Profile but before final `PodManifest` validation and snapshot serialization.
- Resolved profile provenance now records the applied override path in `manifest.profile.workspace_override`.
- Explicit `--manifest <path>` mode remains a single-file escape hatch and does not apply workspace-local overrides.
- Workspace-local overrides are rejected if they set `pod.name`, keeping Pod identity runtime-bound.
## Validation
- `cargo test -p manifest workspace_local_override -- --nocapture`
- `cargo test -p pod manifest_mode_does_not_apply_workspace_local_override -- --nocapture`
- `cargo test -p manifest -p pod`
- `./tickets.sh doctor`
- `git diff --check`
- `nix build .#yoi`
Reviewer follow-up validation:
- `cargo test -p manifest workspace_local_override -- --nocapture`
- `./tickets.sh doctor`
- `git diff --check`
All completed successfully.

View File

@ -7,7 +7,7 @@ kind: task
priority: P2 priority: P2
labels: [profile, manifest, config] labels: [profile, manifest, config]
created_at: 2026-06-01T12:52:40Z created_at: 2026-06-01T12:52:40Z
updated_at: 2026-06-01T12:53:58Z updated_at: 2026-06-01T13:05:58Z
assignee: null assignee: null
legacy_ticket: null legacy_ticket: null
--- ---

View File

@ -50,4 +50,34 @@ Validation:
- Record any skipped validation with rationale. - Record any skipped validation with rationale.
---
<!-- event: implementation_report author: hare at: 2026-06-01T13:05:58Z -->
## Implementation report
# Implementation report
Implemented workspace-local manifest override support.
## Behavior
- Normal Profile/default resolution now searches upward from the resolver workspace base for the nearest `.yoi/override.local.toml`.
- The override is parsed as a `PodManifestConfig` layer, resolved relative to its parent `.yoi/` directory, and merged after builtin defaults plus the selected Profile but before final `PodManifest` validation and snapshot serialization.
- Resolved profile provenance now records the applied override path in `manifest.profile.workspace_override`.
- Explicit `--manifest <path>` mode remains a single-file escape hatch and does not apply workspace-local overrides.
- Workspace-local overrides are rejected if they set `pod.name`, keeping Pod identity runtime-bound.
## Validation
- `cargo test -p manifest workspace_local_override -- --nocapture`
- `cargo test -p pod manifest_mode_does_not_apply_workspace_local_override -- --nocapture`
- `cargo test -p manifest -p pod`
- `./tickets.sh doctor`
- `git diff --check`
- `nix build .#yoi`
All completed successfully.
--- ---