プロジェクトManifestの相対基準の修正

This commit is contained in:
Keisuke Hirata 2026-04-19 08:03:59 +09:00
parent 88e29d7bbe
commit 4ec8f63482
4 changed files with 141 additions and 25 deletions

View File

@ -14,12 +14,13 @@
//!
//! Path resolution happens **before** merge. Each layer is resolved
//! against its own base directory so that a relative `target = "."`
//! in the project manifest means "project directory" regardless of
//! how the user or overlay layers lay out their own paths:
//! in the project manifest means the project root regardless of how
//! the user or overlay layers lay out their own paths:
//!
//! - user manifest: base = the directory holding the manifest file
//! - project manifest: base = the directory holding the manifest file
//! (i.e. `<project>/.insomnia/`)
//! - project manifest: base = the **project root** (the parent of
//! `.insomnia/`, not `.insomnia/` itself) so that natural project
//! manifests with `target = "."` cover the whole workspace
//! - overlay: base = the process's `current_dir()` at the time the
//! overlay is installed, since an inline TOML string has no file
//! location of its own
@ -119,9 +120,7 @@ impl PodFactory {
source,
})?;
if let Some(path) = find_project_manifest(&cwd) {
let base = manifest_base(&path)?;
self.project = Some((read_config_file(&path)?, base.clone()));
self.project_prompts_dir = Some(base.join("prompts"));
self.install_project_manifest(&path)?;
}
Ok(self)
}
@ -133,13 +132,27 @@ impl PodFactory {
start: impl AsRef<Path>,
) -> Result<Self, FactoryError> {
if let Some(path) = find_project_manifest(start.as_ref()) {
let base = manifest_base(&path)?;
self.project = Some((read_config_file(&path)?, base.clone()));
self.project_prompts_dir = Some(base.join("prompts"));
self.install_project_manifest(&path)?;
}
Ok(self)
}
/// Shared setup for `with_project_manifest_auto` / `_from`: record
/// the manifest's project root as the base for relative-path
/// resolution (the parent of `.insomnia/`, not `.insomnia/` itself)
/// so `target = "."` in a project manifest means the project root.
/// `prompts/` still lives inside `.insomnia/`.
fn install_project_manifest(&mut self, path: &Path) -> Result<(), FactoryError> {
let insomnia_dir = manifest_base(path)?;
let project_root = insomnia_dir
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| insomnia_dir.clone());
self.project = Some((read_config_file(path)?, project_root));
self.project_prompts_dir = Some(insomnia_dir.join("prompts"));
Ok(())
}
/// Install a programmatic overlay parsed from a TOML string. Any
/// relative paths in the overlay are resolved against the process's
/// current working directory at the time of this call — an inline
@ -535,11 +548,12 @@ permission = "write"
}
#[test]
fn project_manifest_relative_paths_resolve_against_insomnia_dir() {
// per ticket: base is the directory holding the manifest —
// `.insomnia/` for a project manifest. `target = "."` inside
// a project manifest therefore points at `.insomnia/`, not at
// the project root.
fn project_manifest_relative_paths_resolve_against_project_root() {
// `.insomnia/manifest.toml` is the marker for the project, but
// the intuitive base for its relative paths is the project
// root (the parent of `.insomnia/`) — `target = "."` in a
// project manifest should cover the whole workspace, not the
// `.insomnia/` subdir.
let tmp = TempDir::new().unwrap();
let root = tmp.path().canonicalize().unwrap();
let insomnia_dir = root.join(".insomnia");
@ -558,6 +572,10 @@ model = "m"
[[scope.allow]]
target = "."
permission = "read"
[[scope.allow]]
target = "src"
permission = "write"
"#,
);
@ -566,7 +584,8 @@ permission = "read"
.unwrap()
.resolve()
.unwrap();
assert_eq!(manifest.scope.allow[0].target, insomnia_dir);
assert_eq!(manifest.scope.allow[0].target, root);
assert_eq!(manifest.scope.allow[1].target, root.join("src"));
}
#[test]

View File

@ -1,5 +1,7 @@
# Manifest のパス解決: cwd ベース + manifest ファイル相対
レビュー中: [manifest-path-resolution.review.md](manifest-path-resolution.review.md)
## 背景
現状 manifest 内のパス(`pod.pwd` / `provider.api_key_file` / `scope.allow.target` / `scope.deny.target` / `compaction.provider.api_key_file`)は全て絶対必須で、相対パスは `ResolveError::RelativePath` で弾かれる。
@ -15,9 +17,9 @@ cargo / pyproject / npm などに倣い「相対パスは manifest ファイル
## 新しい解決規則
- `pod.pwd` フィールドは削除。Pod の作業ディレクトリ = プロセスの cwd
- 相対パス**manifest ファイルがあるディレクトリ** を基準に解決
- user manifest (`~/.config/insomnia/manifest.toml`) の相対 = そのディレクトリ基準
- project manifest (`<project>/.insomnia/manifest.toml`) の相対 = そのディレクトリ基準
- 相対パスの基準は層ごとに決める
- user manifest (`~/.config/insomnia/manifest.toml`) の相対 = そのファイルの親ディレクトリ
- project manifest (`<project>/.insomnia/manifest.toml`) の相対 = **プロジェクトルート**`.insomnia/` の親)。`target = "."` がワークスペース全体を指すように
- overlayインライン TOML、ファイル位置なしの相対パスは **プロセスの cwd** 基準
- builtin 層には manifest を埋め込んでいないので対象外

View File

@ -0,0 +1,64 @@
# Review: manifest-path-resolution
実装コミット `aed46e6`(マニフェスト解決の相対パス化)に対するレビュー。`cargo build` clean、`cargo test --workspace` 全 pass。
## 総評
チケット要件(`pod.pwd` 削除、相対パス = manifest ファイル基準、overlay は cwd 基準、マージ前絶対化)を忠実に実装。テストもカスケード各層・不変条件違反・両 manifest レイヤの相対解決まで網羅。ビルド・テスト通過。
指摘 1 の UX 判断だけ合意したら完了可。
## 完了条件の対応
| 要件 | 状態 | 確認箇所 |
|---|---|---|
| `pod.pwd` 削除 | ✅ | `PodMeta``PodMetaConfig` から削除 |
| Pod pwd = cwd | ✅ | `pod.rs:current_pwd()` = `current_dir().canonicalize()` |
| 相対 = manifest ファイル基準 | ✅ | `PodManifestConfig::resolve_paths(base)` + `factory::manifest_base()` |
| overlay は cwd 基準 | ✅ | `factory::resolve_and_merge_overlay``current_dir()` を base |
| マージ前に絶対化 | ✅ | `factory::resolve()` で各層 `.resolve_paths(&base)` → merge |
| `ensure_absolute` は不変条件チェックのみ | ✅ | `TryFrom` に残存、cascade 層で通れば通るだけ |
| `--pwd` 廃止 | ✅ | CLI から削除 |
| `api_key_file = "keys/anthropic"` 動作 | ✅ | `resolve_paths_joins_relative_api_key_file` テスト |
| `scope.allow target = "."` 動作 | ✅ | `project_manifest_relative_paths_resolve_against_insomnia_dir` テスト |
## 指摘と判断
### 1. project manifest の base が `.insomnia/` であることの UX要判断
**状況**: 実装は `<project>/.insomnia/manifest.toml``target = "."` と書いた場合、**`<project>/.insomnia/`** を指す。チケットの「manifest ファイルのあるディレクトリ基準」を忠実に実装した結果。
```rust
// factory.rs:538 テスト
// project manifest 内 target = "." が .insomnia/ ディレクトリに解決される
assert_eq!(manifest.scope.allow[0].target, insomnia_dir);
```
**懸念**: ユーザが「project manifest で `target = "."`」と書いたら自然には「プロジェクトルート」を意図する。`.insomnia/` 下を scope の対象にしたい運用は稀。
**選択肢**:
- **(a)** このまま。規則の一貫性(全層 "manifest と同じ dir")を優先し、ドキュメントで「`target = ".."` でプロジェクトルート」と案内
- **(b)** project manifest のみ base を親 `<project>/` にする。user manifest と挙動が揃わなくなる例外ルール
- **(c)** `.` 表記を使わず「絶対パス or `..` 表記」を案内のみ
**判断**: **(a) を推奨**。user manifest (`$XDG_CONFIG_HOME/insomnia/`) で `keys/anthropic``$XDG_CONFIG_HOME/insomnia/keys/anthropic` を指すのと同じ規則で、シンプル。ただし project で違和感が強いので README / docs で**典型例のテンプレ**を提示する必要あり(例: `target = ".."` で project root
この判断を合意できれば完了条件を満たす。(b) を選ぶなら `factory.rs:manifest_base` から派生した project 専用の親ディレクトリ計算に差し替える小改修が必要1 関数程度)。
### 2. `pod.pwd` を書いた古い manifest の警告が埋もれる
**状況**: `from_toml_accepts_unknown_field` テストで確認されている通り、`pod.pwd = "/obsolete"` は `serde_ignored` の warn でスキップされる。`tracing_subscriber` が WARN 有効になっていないと出ない。
**判断**: 範囲外だが、移行ユーザが「設定したのに効かない」と混乱するリスクあり。README / CHANGELOG にマイ採用しないション注記を入れたい(本チケットに含めるか、別 issue を切るかは任意)。本チケットの完了条件には影響しない。
### 3. 軽微
- `PodManifestConfig::resolve_paths``debug_assert!(base.is_absolute())` は release で落ちないが、現状の呼び出し側(`manifest_base` / `current_dir`)が絶対を保証するので許容
- `spawn_pod.rs` で overlay TOML から `pod.pwd` を消し、`Command::current_dir(spawner_pwd)` で子に cwd を伝える構造へ正しく移行
- `Scope::from_config` の signature 変更(`base` 引数削除)が全呼出箇所に反映されている
- 不変条件違反(`ResolveError::RelativePath` / `ScopeError::RelativeTarget`)が両層でチェックされていて、万一 cascade 解決漏れがあっても catch される二重防衛になっている
## 完了に向けた作業
- 指摘 1 について (a) で合意 → このままマージ
- ドキュメントでの「`target = ".."` で project root」ガイドは別タスク化可本チケット範囲外扱い

View File

@ -59,14 +59,28 @@ pub enum PodEvent {
| タイミング | variant |
|---|---|
| `RunEnd { result: Finished }` 発行時 | `TurnEnded` |
| `Event::Error` 発行時 | `Errored` |
| Worker 実行エラー発生時(後述) | `Errored` |
| shutdown シーケンスcontroller loop 終了直前) | `ShutDown` |
| `SpawnPod` tool 成功直後 | `ScopeSubDelegated` |
`Errored` の対象は **`run_with_cancel_support` 内で Worker 実行が失敗した `Event::Error`** に限定する。`AlreadyRunning` / `NotPaused` / `NotRunning` のような method 拒否応答はクライアント向けの一時的なフィードバックであり、親に通知すべき子のライフサイクルイベントではない。実装時は発火点を worker エラー経路に絞ること。
送信は非同期 spawn で発射し、await しない。接続失敗はログのみで続行(親が落ちていても子は生きる)。
親 socket のアドレスは spawn 時に `--callback <PATH>` で受け取って保持している(既存)。
### 又貸しの親連鎖
`ScopeSubDelegated` は**直接の親にのみ送る**。曾孫が現れた場合:
1. 孫 (C) が曾孫 (D) を `SpawnPod` する
2. C は自分の親 (B) に `ScopeSubDelegated { parent_pod: C, sub_pod: D, ... }` を送る
3. B は D を自分の `spawned_pods.json` に追加し、さらに B の親 (A) へ `ScopeSubDelegated { parent_pod: B, sub_pod: D, ... }` を再発射する
これを再帰的に繰り返すことで、`scope.lock` の `delegated_from` チェーンと一致した形で root まで D の存在が登録される。各 Pod は「自分の直接の子+紹介された孫(= 更に下の Pod も含む)」を把握し、孫より下の階層は子経由で管理される。
再発射は `ScopeSubDelegated` 受信時の system 処理の一部として行う(後述の表に反映)。
### 親(受信側)
親 Controller の main loop に `Method::PodEvent` ハンドラを追加:
@ -89,10 +103,20 @@ variant 別の (1) の中身:
| `TurnEnded` | なし |
| `Errored` | なしLLM に判断させる) |
| `ShutDown` | `spawned_registry.remove(pod_name)`、scope lock を flock して該当 allocation を `release_pod` で解放 |
| `ScopeSubDelegated` | `spawned_registry.add(SpawnedPodRecord { sub_pod, sub_socket, scope, ... })`(孫を直接把握する。親が子を経由せず孫を管理することで、子が死んでも孫の scope 管理が維持される |
| `ScopeSubDelegated` | `spawned_registry.add(SpawnedPodRecord { sub_pod, sub_socket, scope, ... })` で孫を追加。続けて自分の親(いれば)へ `ScopeSubDelegated { parent_pod: self, sub_pod, ... }` を再発射(親連鎖参照 |
(2) の `render_event` は一箇所に集約し、`format!("Pod `{pod_name}` finished a turn.")` のような短い human-readable 文字列を返す。
### Controller の配線変更
現状 `PodController::spawn` 内で `SpawnedPodRegistry` は tool 登録のためだけに生成され、main loop の `method_rx` 処理ループには渡っていない。`Method::PodEvent` ハンドラを追加するには:
- `spawned_registry: Arc<SpawnedPodRegistry>` を main loop に `clone` で持ち込む
- `scope_lock::default_lock_path()` を event 処理時に呼ぶ(毎回 open するので保持は不要)
- 親 callback socket自分の親、トップ Pod では `None`)を `Pod::from_manifest_spawned` 経由で既に保持している値から再利用(再発射で使う)
`apply_event_side_effects` は Controller の main loop から呼ぶ非同期関数として定義する。
### 失敗時のフォールバック
- 子 → 親の PodEvent 送信が失敗しても諦める(再試行しない)
@ -102,16 +126,23 @@ variant 別の (1) の中身:
## 設計で決めること
- **送信の接続タイムアウト**: `SpawnPod` / pod-comm-tools と揃える5 秒想定)
- **同時多発イベントの順序保証**: `TurnEnded` 直後に `ShutDown` が起きた場合、親側で順序を保証する必要があるか。現状は fire-and-forget で並列送信されうる
- **`ScopeSubDelegated` の親連鎖**: 孫がさらに曾孫を spawn したとき、曾孫の `ScopeSubDelegated` は誰に送る?(子に送り、子が親に転送? or 最上位の root まで届ける? or 直接の親だけで十分?
### 決定事項
- **順序保証は求めず、ハンドラを冪等・遅延到着に強くする**: fire-and-forget の unix socket 接続は順序を保証しない。`TurnEnded` 直後に `ShutDown` が届いても、逆順で到着しても親側で成立するように `apply_event_side_effects` を設計する。具体的には:
- `ShutDown` 受信時、すでに registry から削除済みでもエラーにしない(`release_pod` の `UnknownPod` を swallow する既存挙動を踏襲)
- `TurnEnded``ShutDown` より後に届いても、該当 Pod が既に registry にいなければ render だけして終えるLLM 向け通知は出る、system 処理は no-op
- `ScopeSubDelegated` で孫が既に registry にいたら上書きせず no-op`DuplicatePodName` を swallow
- **`ScopeSubDelegated` の親連鎖は直接の親のみ + 再発射**: 上記「又貸しの親連鎖」セクション参照。曾孫以上は再帰的に再発射で root まで届く
## 完了条件
- `Method::PodEvent(PodEvent)``protocol` crate に追加され、serde round-trip テストが通る
- 子の Controller が 4 種の variant を適切なタイミングで親 socket に送信する
- 子の Controller が 4 種の variant を適切なタイミングで親 socket に送信する。`Errored` は Worker 実行エラーにのみ限定されることを確認
- 親の Controller が variant ごとの system 処理を実行し、レンダリングした文字列を LLM 通知バッファに流す
- `ScopeSubDelegated` 受信後、孫 Pod が親の `spawned_pods.json` に現れる
- `ScopeSubDelegated` 受信後、孫 Pod が親の `spawned_pods.json` に現れ、親が更に上位親を持つ場合は上位へ再発射され
- `ShutDown` 受信後、該当 Pod が親の registry から消え、scope lock からも解放される
- イベント到着順が入れ替わっても副作用が安全(冪等)であることを単体テストで確認
- 送信失敗しても子プロセスが続行する
- 各 variant の送受信を検証する単体テスト