yoi/tickets/pod-factory.review.md

223 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# レビュー: Pod Factory
対象差分: `crates/manifest/src/{lib,config}.rs`, `crates/pod/src/{lib,pod,main,system_prompt,factory,prompt_loader}.rs`, `crates/pod/examples/{pod_cli,pod_protocol}.rs`, `crates/provider/src/lib.rs`, `resources/prompts/`, `docs/pod-factory.md`(いずれも未コミット)
## 要件達成状況
| 要件 | 状態 |
|---|---|
| カスケード基盤ユーザー・プロジェクト・overlay の層マージ) | ✅ `manifest::PodManifestConfig` が部分形として実装。`PodFactory` が 4 層default/user/project/overlayを順にマージ |
| 解決後の型は `PodManifest` のまま | ✅ `TryFrom<PodManifestConfig> for PodManifest` が検証付き変換 |
| 各層の manifest.toml スキーマは同じ | ✅ partial 型群 (`PodMetaConfig` / `ProviderConfigPartial` / `WorkerManifestConfig` / ...) が本物と同構造 |
| ユーザーパスは XDG | ✅ `user_manifest_path``XDG_CONFIG_HOME``$HOME/.config` の順 |
| プロジェクトルートは `.insomnia/` 最近接 | ✅ `find_project_manifest` が cwd から上方向に walk |
| scope は union マージ | ✅ `merge_scope` が allow/deny を `extend` |
| マップは key-wise マージ | ✅ `ToolOutputLimitsPartial::merge` |
| スカラー / Option は upper 優位 | ✅ `upper.x.or(self.x)` パターンで実装 |
| 未知フィールドは warn のみ | ✅ `serde_ignored::deserialize` + `tracing::warn!` |
| 型ミスマッチは hard error | ✅ toml パースで通常通り失敗 |
| パスフィールドは絶対パスのみ | ✅ `ensure_absolute``pod.pwd` / `provider.api_key_file` / `scope.*.target` を検証 |
| `Pod::from_manifest(manifest, store, loader)` 二段 API | ✅ + `from_manifest_toml` 便利関数。旧 path 受けは廃止 |
| `manifest_dir` 引数の消滅 | ✅ `Pod` 構造体から削除、`provider::build_client` からも消滅 |
| プロンプト3層ローダ | ✅ `PromptLoader` が project/user/builtin の順で解決 |
| ビルトインは `resources/prompts/` + `include_dir!` | ✅ `pod/src/prompt_loader.rs:17` |
| プロンプト include の親変数伝搬 | ✅ minijinja デフォルト挙動、`include_resolves_builtin_prompt` テストで確認 |
| CLI の `--user-manifest` / `--project` / `--overlay` / `--pwd` | ✅ `pod/src/main.rs` で実装。引数無しでも XDG + cwd 自動解決で動く |
| 引数無しでの最小構成動作 | ✅ CLI デフォルトが auto 系メソッドにつながる |
| ドキュメント (`docs/pod-factory.md`) | ✅ カスケード層・マージ規則・CLI・プログラマティック API・ビルトイン一覧を網羅 |
**すべての要件を達成**。さらに当初のチケットに無かった点として CLI docs の整理、`examples/` の追随更新、provider の `~` 展開廃止(絶対パス徹底)までカバーしている。
## アーキテクチャ統合
### クレート境界
- **`manifest/src/config.rs`** — `PodManifestConfig` を manifest crate 側に置いたのは**正しい判断**。manifest crate は純データ型と検証ロジック (`Scope::from_config`, `TryFrom`) の置き場で I/O を持たない、という既存方針をそのまま継承している
- **`crates/pod/src/factory.rs`** / **`prompt_loader.rs`** — I/Oファイル読み、XDG 解決、`include_dir!`)は pod crate 側に集約。新規 crate を作らず、既存レイヤに素直に乗せている。前回私が推した統合方針どおり
- **`PodFactory::resolve()` が `(PodManifest, PromptLoader)` を返す** — factory は「manifest + どこからプロンプトを引くか」を一体で管理し、Pod は渡された loader を素直に使うだけ。責務の分離が綺麗
- **`Pod::manifest_dir` の完全削除 + `resolve_pwd` の絶対パス強制** — 「パスの正規化は cascade 層の専売事項」という単一ソースの原則が守られている。`provider::build_client` からも `manifest_dir` 引数が消え、`~` 展開も消え、relative rejection だけ残った。下流が大幅にシンプルになった
### 副作用的な改善
- `Pod::from_manifest_toml``PromptLoader::builtins_only()` を暗黙に使う → テスト・examples が「単層 TOML で Pod 起動」を一行で書けるようになった
- `examples/pod_cli.rs``pod_protocol.rs` が絶対パス化と新 API に追随済み。壊れ残りなし
## 指摘事項
### 1. 🟢 `with_overlay_toml` / `with_overlay_config` の重ね合わせが「同じ層にマージ」
`factory.rs:123-139`:
```rust
pub fn with_overlay_toml(mut self, toml: &str) -> Result<Self, FactoryError> {
let config = PodManifestConfig::from_toml(toml).map_err(FactoryError::OverlayParse)?;
self.overlay = Some(match self.overlay {
Some(existing) => existing.merge(config),
None => config,
});
Ok(self)
}
```
`with_overlay_*` を複数回呼ぶと、独立したレイヤにはならず**1 つの overlay スロットに逐次マージ**される(後勝ち)。これは CLI + 1 回のプログラマティック注入には十分だが、「複数の独立した overlay を priority order 付きで積みたい」ニーズには応えない。
**判断**: 現状の要件範囲内では問題なし。将来 preset や環境変数ベースの overlay を追加する場合に再検討。
**細かい付随点**: `factory.rs` のテスト `cascade_overlay_overrides_project_overrides_user` は名前が「overlay が project を override し、project が user を override する」と読めるが、実際には**全部 overlay スロットに積まれている**user/project スロットは未使用)。テスト内コメントで断り書きはあるが、名前だけ見ると誤解されやすい。`cascade_priority_layer_ordering` のほうが本来のレイヤ順序を検証しているので、前者は `overlay_stacking_merges_in_place` などに改名するとより正確。
### 2. 🟢 "builtin defaults" 層の実体がゼロ
チケット方針:
> ビルトインのデフォルト: コードに焼き込んだ基本値(現在 `PodManifest` 各フィールドの `#[serde(default)]` や `Default` 実装に散っているものを集約)
実装方針(`factory.rs` module doc:
> 1. **Builtin defaults** — in-code defaults, currently empty. Upper layers provide everything; `TryFrom<PodManifestConfig>` fills in per-field defaults
実際の builtin layer は `PodManifestConfig::default()`(全部 Noneで、デフォルト値の適用は `TryFrom` 内の `.unwrap_or(ToolOutputLimits::default())` のように散在している。つまり**「散らばってる defaults を builtin layer に集約する」という元の目的は達成されていない**。
**判断**: 実運用上の挙動は同じ(ユーザーから見れば `PodManifest` の各フィールドが既定値を持つことに変わりなし)。チケットの表現 vs 実装の厳密な乖離だけで、受け入れ可否には影響しない。将来 defaults の一覧を可視化したくなった時点で builtin layer を実体化する余地を残しておけば OK。
### 3. 🟢 CLI `--pwd` の overlay 注入が文字列フォーマット経由
`main.rs:64-76`:
```rust
parts.push(format!(
"[pod]\npwd = \"{}\"\n",
absolute.display().to_string().replace('\\', "\\\\")
));
```
生成した TOML 断片を `with_overlay_toml` に渡している。`\` は escape しているが `"` は escape していないため、`"` を含むパスLinux では理論上あり得る)で壊れる。また `--overlay` 側と `--pwd` 側を join するときに両者の構文が衝突しないかも微妙。
**推奨**: 文字列を作らず、`PodManifestConfig` を直接構築して `with_overlay_config` で渡す形に書き換える:
```rust
if let Some(pwd) = cli.pwd.as_ref() {
let absolute = std::fs::canonicalize(pwd).unwrap_or_else(|_| pwd.clone());
factory = factory.with_overlay_config(PodManifestConfig {
pod: PodMetaConfig { pwd: Some(absolute), ..Default::default() },
..Default::default()
});
}
if let Some(overlay) = cli.overlay.as_deref() {
factory = factory.with_overlay_toml(overlay)?;
}
```
型を経由するので escape 問題が消え、`--pwd` と `--overlay` の干渉も無くなる。
**判断**: 現実には問題が起きる可能性は極めて低いが、型経由のほうが筋が良い。**任意**。
### 4. 🟢 `resolve_provider` に dead code
`manifest/src/config.rs:218-237`:
```rust
fn resolve_provider(
cfg: ProviderConfigPartial,
field_prefix: &'static str, // ← 使われていない
kind_field: &'static str,
...
) -> Result<ProviderConfig, ResolveError> {
let _ = field_prefix; // ← 明示的に捨てられている
...
}
```
`field_prefix` を取っているが関数内で使っていない(`let _ =` で捨てている)。将来エラーメッセージで `"missing field: {prefix}.kind"` のようにしたい意図だったと推察されるが、現在の `ResolveError::MissingField` は静的文字列を直接受けているので不要。
**判断**: 不要なので削除推奨。**任意**実害なし、lint が効けば dead_code 警告が出るかも)。
### 5. 🟢 `~` 展開廃止は breaking change
`provider` から `~/.config/insomnia/keys/anthropic` のような `~` 始まりパスの展開処理が消えた。既存の手書き manifest にこの形式が書かれていると resolve で `RelativePath` エラーになる。
**判断**: チケットで「絶対パスのみ」と決めた結果であり、意図通り。`docs/pod-factory.md` でも「パスの絶対性」として明記されている。受け入れ可。
### 6. 🟢 `find_project_manifest` の canonicalize 失敗時フォールバック
`factory.rs:197-208``start.canonicalize()` が失敗したら raw path で walk を続ける。`.insomnia` 名前の検出は path 比較だけなので動作するが、shell が与えた相対パスが絶対化されないまま `dir.parent()` を続けると仕様上の最上位で止まる可能性。
**判断**: 実害小。canonicalize が失敗するケースは cwd が無効等で、そもそも Pod 起動前の別エラーに出るはず。**不問**。
## テスト
- `manifest/src/config.rs`: 14 ケースresolve 成功 / 必須欠落 / 相対パス3種 / scalar merge / scope union / per_tool keywise / option struct / type mismatch / unknown field / partial layer / end-to-end cascade
- `pod/src/factory.rs`: 7 ケースoverlay only / 模擬レイヤ順 / 実レイヤ順 / walk-up / 無プロジェクト / loader 連携 / 必須欠落)
- `pod/src/prompt_loader.rs`: 6 ケースbuiltin 存在 / サブディレクトリ / 未知 / user override / project override / fallthrough
- `pod/src/system_prompt.rs`: 2 ケースinclude 成功 / 未知 prompt
- `provider/src/lib.rs`: 既存テストを新シグネチャに追随 + 新規 relative rejection
**合計 30 ケース超**、各層の検証が丁寧。特に `resolve_produces_loader_with_project_prompts_dir` は factory + prompt_loader + system_prompt の**3 層を貫く end-to-end テスト**で、この変更でもっとも壊れやすい配線を lock-in している点が優秀。
## 結論
**無条件で受け入れ可**。要件は全項目達成、アーキテクチャ統合も筋が良く、テスト被覆も厚い。指摘はすべて任意修正または nit レベルで、受け入れ可否に影響しない。
任意修正として推せるのは:
- **指摘 3**CLI `--pwd` の型経由化)が最も筋が良い改善。余力があれば
- **指摘 4**dead codeは数行で済む clean-up
- **指摘 1 のテスト名**`cascade_overlay_overrides_project_overrides_user` → `overlay_stacking_merges_in_place` 等)も小さな改善
上記はいずれも受け入れ後の別タスクとしても良い。
---
## フォローアップ差分 (2026-04-16)
レビュー後の追加作業として、指摘 #2builtin defaults の実体ゼロ)を解消し、
**デフォルト値のメンテナンス性向上**を目的とした集約リファクタを入れた。
### 変更内容
- **`crates/manifest/src/defaults.rs` 新設**: 全 manifest デフォルト値を
`pub const` で宣言する単一の真実ソース
- `TOOL_OUTPUT_MAX_BYTES`, `PRUNE_PROTECTED_TURNS`,
`PRUNE_MIN_SAVINGS`, `COMPACT_RETAINED_TURNS`
- **`crates/manifest/src/lib.rs`**: 既存の `default_*` fn 群を constants を
返すだけの 1 行に縮小。`ToolOutputLimits::default()` /
`CompactionConfig::default()` の serde `#[default = "..."]` 経路もすべて
constants に収束
- **`crates/manifest/src/config.rs`**:
- `PodManifestConfig::builtin_defaults()` を追加cascade の最下層として使う
構築メソッド、constants 直接参照)
- `TryFrom<PodManifestConfig> for PodManifest``ToolOutputLimits::default()`
/ `CompactionConfig::default()` 経由をやめ、constants を直接 `unwrap_or`
する belt-and-suspenders 形
- 指摘 #4`resolve_provider` dead param (`field_prefix`) を削除
- **`crates/pod/src/factory.rs`**:
- `PodFactory::resolve` の base layer を `PodManifestConfig::default()` から
`PodManifestConfig::builtin_defaults()` に切替。これで "builtin layer" が
実体を持つ cascade 層として機能
- 指摘 #1 のテスト名 `cascade_overlay_overrides_project_overrides_user`
`overlay_stacking_merges_in_place` にリネーム
- **`crates/manifest/src/config.rs` テスト追加**:
- `builtin_defaults_populates_tool_output_max_bytes`
- `builtin_defaults_merged_into_minimal_resolves_with_defaults`
### 効果
- デフォルト値を変えるときの編集箇所が**1 ファイル 1 行**`defaults.rs`)に。
従来は `default_*` fn、`Default::default()` impl、`TryFrom` フォールバックの
3 経路にそれぞれ値が書かれていたが、すべて `defaults::X` を参照する形に収束
- チケット本文で当初意図されていた「builtin layer がデフォルト値を保持する」
概念が `builtin_defaults()` + factory の base 層で実体化
- `TryFrom` の fallback は残すので、`builtin_defaults()` を経由しない直接構築
テスト等でも同じ既定値が保証されるbelt-and-suspenders
### テスト
全 ワークスペース テスト通過manifest 45 / pod 71 を含む)。新規 2 ケース
`builtin_defaults` の挙動と constants の同一性を lock-in。
### 未処理
- **指摘 3**CLI `--pwd` の型経由化)は未対応。現在の文字列 format 経由でも
escape 問題が実際に起きる可能性は極めて低いため保留。必要になった時点で別
フォローアップとする
この差分の後、指摘 #2 / #4 / #1(テスト名)は解消済み。受け入れ可否には
変化なし(元々「無条件で受け入れ可」)。