15 KiB
レビュー: 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:
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):
- 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:
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 で渡す形に書き換える:
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:
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)
レビュー後の追加作業として、指摘 #2(builtin 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_providerdead 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_bytesbuiltin_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(テスト名)は解消済み。受け入れ可否には 変化なし(元々「無条件で受け入れ可」)。