Compare commits
34 Commits
1d64c50cf2
...
aba7809fb9
| Author | SHA1 | Date | |
|---|---|---|---|
| aba7809fb9 | |||
| dda9910c30 | |||
| 123aa5b30b | |||
| 4df68978aa | |||
| 20ac0c96a5 | |||
| 625730cb0a | |||
| ffe1785262 | |||
| 2242987804 | |||
| 4773be702a | |||
| 31620257cd | |||
| 43cff6d410 | |||
| 3f67c83d43 | |||
| 513f55415f | |||
| 3b60708cff | |||
| c9627a13d4 | |||
| b9d8bb392e | |||
| d77a86d550 | |||
| ba9e924c89 | |||
| 45c94a6fbe | |||
| ccb8f96118 | |||
| 8db3ff5de7 | |||
| d5d50a3214 | |||
| ee7147b355 | |||
| a17cd47bdd | |||
| 0269b91f0a | |||
| 06c778a725 | |||
| 9e99764927 | |||
| 2fa496300a | |||
| c8996de94d | |||
| c9a175af54 | |||
| 5de31a9be2 | |||
| a25982b045 | |||
| b47364fb46 | |||
| feec991676 |
|
|
@ -6,8 +6,25 @@ allow = [
|
|||
[session]
|
||||
record_event_trace = true
|
||||
|
||||
[worker]
|
||||
reasoning = "high"
|
||||
|
||||
[model]
|
||||
ref = "codex-oauth/gpt-5.5"
|
||||
|
||||
[compaction]
|
||||
compact_threshold = 200000
|
||||
compact_request_threshold = 240000
|
||||
compact_worker_max_input_tokens = 100000
|
||||
|
||||
[memory]
|
||||
extract_threshold = 50000
|
||||
|
||||
consolidation_threshold_files = 5
|
||||
consolidation_threshold_bytes = 50000
|
||||
|
||||
[web]
|
||||
enabled = true
|
||||
[web.search]
|
||||
provider = "brave"
|
||||
api_key_env = "BRAVE_SEARCH_API_KEY"
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ requires: []
|
|||
|
||||
insomnia を AI maintainer として運用するための半自動 loop。TODO / tickets から「今進められそうな作業」を選ぶだけでなく、課題の発見、設計判断の切り分け、次に人間へ戻すべき問いの整理までを扱う。
|
||||
|
||||
これは unattended 自動開発ではない。実装の並列委譲は `multi-agent-workflow`、worktree の機械的作成は `worktree-workflow` に任せる。本 Workflow はその前段として、何を進めるべきか、何をまだ決めるべきかを整理する。
|
||||
これは unattended 自動開発ではない。実装の並列委譲は `multi-agent-workflow`、worktree の機械的作成は `worktree-workflow` に任せる。本 Workflow はその前段として、何を進めるべきか、何をまだ決めるべきか、下位 orchestrator にどの intent packet を渡すべきかを整理する。
|
||||
|
||||
参照:
|
||||
|
||||
|
|
@ -24,6 +24,7 @@ AI maintainer の目的は、コードを書くこと自体ではなく、プロ
|
|||
- TODO / tickets / docs / git history を読んで現在地を把握する。
|
||||
- 実装可能な ticket と、方針決定が必要な ticket を分ける。
|
||||
- 小さく実装できる候補を提案する。
|
||||
- 複数 ticket からなる作業群は、下位 orchestrator に任せる単位として整理する。
|
||||
- 設計相談が必要な論点を人間に戻す。
|
||||
- 運用上の問題や繰り返し発生する詰まりを report / ticket / workflow 改訂候補として整理する。
|
||||
|
||||
|
|
@ -65,7 +66,7 @@ TODO と ticket の不整合を見つけたら、勝手に修正せず、まず
|
|||
- 大きな設計判断が不要。
|
||||
- scope を狭く切れる。
|
||||
|
||||
この場合は、人間に候補として提示する。人間が実行を許可したら `$user/multi-agent-workflow` に進む。
|
||||
この場合は、人間に候補として提示する。人間が実行を許可したら `$user/multi-agent-workflow` に進む。複数 ticket や連続した作業群では、最上位 Pod が直接 coder を抱えず、下位 orchestrator に intent packet を渡して coder / reviewer sibling loop を管理させる。
|
||||
|
||||
### B. 方針決定が必要
|
||||
|
||||
|
|
@ -89,6 +90,7 @@ TODO と ticket の不整合を見つけたら、勝手に修正せず、まず
|
|||
|
||||
- 同じ tool 問題が繰り返し出る。
|
||||
- Workflow の指示が曖昧で実装 Pod が迷った。
|
||||
- coder / reviewer / orchestrator の責務が混ざり、親 Pod が細かい code review に戻ってしまった。
|
||||
- AI が過剰に Task tool を使うなど、運用上の癖が出た。
|
||||
- 通知や Pod completion tracking など、開発基盤の不足が観測された。
|
||||
|
||||
|
|
@ -114,9 +116,12 @@ TODO と ticket の不整合を見つけたら、勝手に修正せず、まず
|
|||
- 「次に進めるなら X」を1つ推奨する。
|
||||
- 理由を短く述べる。
|
||||
- 実装委譲する場合の scope / test 方針を添える。
|
||||
- 複数 ticket の作業群なら、下位 orchestrator に任せる単位として提示する。
|
||||
|
||||
5. 実行への接続
|
||||
- 人間が「進めて」と言ったら `$user/multi-agent-workflow` に接続する。
|
||||
- 単発 ticket か、下位 orchestrator に任せる ticket 群かを明示する。
|
||||
- 下位 orchestrator に渡す intent / requirements / invariants / non-goals / escalation 条件を短くまとめる。
|
||||
- worktree 作成は `$user/worktree-workflow` に従う。
|
||||
|
||||
## エスカレーション基準
|
||||
|
|
@ -129,7 +134,7 @@ TODO と ticket の不整合を見つけたら、勝手に修正せず、まず
|
|||
- 新 ticket の作成、既存 ticket の大幅変更、ticket 完了削除について合意がない。
|
||||
- test 不能、再現不能、または作業範囲外の不具合に遭遇した。
|
||||
- WorkItem / Thread / Lease / maintainer state など、まだ設計中の概念が必要になる。
|
||||
|
||||
- 下位 orchestrator に委譲するには intent / invariant / escalation 条件が曖昧すぎる。
|
||||
|
||||
## まだ固定しないもの
|
||||
|
||||
|
|
|
|||
|
|
@ -1,75 +1,159 @@
|
|||
---
|
||||
description: worktree と子 Pod を使って複数 ticket の実装・レビュー・修正・完了処理を並列に進める orchestration フロー
|
||||
description: worktree と sibling の coder / reviewer Pod を使い、下位 orchestrator が複数 ticket の実装・外部レビュー・修正・完了準備を管理する orchestration フロー
|
||||
model_invokation: true
|
||||
user_invocable: true
|
||||
requires: []
|
||||
---
|
||||
# Multi-agent Worktree Workflow
|
||||
|
||||
insomnia を insomnia で開発する際の、worktree + 実装 Pod + 親 Pod review の標準フロー。これは **実装を並列に進めるためのフロー** であり、worktree の機械的作成手順は `$user/worktree-workflow`、ticket 候補選定や方針探索の半自動 loop は `$user/auto-maintain` に分ける。
|
||||
insomnia を insomnia で開発する際の、worktree + coder Pod + 外部 reviewer Pod + orchestrator Pod の標準フロー。これは **最上位 Pod が細かい code review を抱えず、下位 orchestrator が実装と外部レビューの loop を完了状態まで運ぶためのフロー** である。
|
||||
|
||||
worktree の機械的作成手順は `$user/worktree-workflow`、ticket 候補選定や方針探索の半自動 loop は `$user/auto-maintain` に分ける。
|
||||
|
||||
## 目的
|
||||
|
||||
- 実装差分を ticket ごとの child worktree に隔離する。
|
||||
- 実装 Pod に narrow write scope を渡して並列実装させる。
|
||||
- 親 Pod が diff / test / ticket 要件を review し、必要なら修正依頼する。
|
||||
- approve 後に merge / ticket 完了処理 / main workspace での再検証を行う。
|
||||
- coder Pod に narrow write scope を渡して実装させる。
|
||||
- reviewer Pod を coder の子ではなく **同じ orchestrator 配下の sibling** として立て、外部レビューを行わせる。
|
||||
- orchestrator は coder / reviewer のやり取り、修正 loop、validation、merge-ready dossier 作成に責任を持つ。
|
||||
- 最上位 orchestrator は、コードを直接理解し切ることではなく、委譲した intent / 要件 / invariant に沿って下位 orchestrator が完了まで運んだかを acceptance する。
|
||||
|
||||
## 階層モデル
|
||||
|
||||
基本形は以下。
|
||||
|
||||
```text
|
||||
最上位 orchestrator Pod
|
||||
- 人間との会話相手
|
||||
- intent / 要件 / invariant / escalation 条件を定義
|
||||
- 複数の作業群を並列管理
|
||||
- final merge / ticket close / main workspace validation を行う
|
||||
- 原則として line-by-line code review を主業務にしない
|
||||
|
||||
下位 orchestrator Pod(area / epic / ticket-group coordinator)
|
||||
- 連続した複数 ticket または大きめの ticket 群を完了状態まで運ぶ
|
||||
- worktree / branch / coder / reviewer / validation / 修正 loop を管理する
|
||||
- coder と reviewer を sibling として扱う
|
||||
- 親には merge-ready dossier と残論点だけを返す
|
||||
|
||||
coder Pod
|
||||
- 指定 worktree / branch に実装する
|
||||
- ticket 外判断や設計衝突は orchestrator に戻す
|
||||
- reviewer に直接反論・修正依頼を完結させず、orchestrator に報告する
|
||||
|
||||
reviewer Pod
|
||||
- 原則 read-only
|
||||
- ticket / intent packet / diff / validation 結果を読む
|
||||
- 実際のコード変更が概念的に何を変えたかを説明する
|
||||
- intent / 要件 / invariant に反する blocker を分類して返す
|
||||
```
|
||||
|
||||
一段だけで足りる小さい ticket では、最上位 orchestrator が直接 coder / reviewer sibling を扱ってよい。複数 ticket や設計境界をまたぐ作業では、最上位の下に下位 orchestrator を挟む。
|
||||
|
||||
## 開始条件
|
||||
|
||||
以下が揃っている時に使う。
|
||||
|
||||
- 対象 ticket が決まっている。
|
||||
- 対象 ticket または ticket 群が決まっている。
|
||||
- ticket の背景・要件・完了条件から実装方針が概ね導ける。
|
||||
- worktree 作成と git 書き込み操作について、人間の許可がある。
|
||||
- main workspace の unrelated dirty changes を把握している。
|
||||
- 下位 orchestrator に渡す intent / invariant / non-goals / escalation 条件を短く書ける。
|
||||
|
||||
設計方針が複数自然に導ける場合、protocol / scope / permission / history persistence に触れる場合、ticket 自体の再定義が必要な場合は、実装委譲前に人間へ戻す。
|
||||
設計方針が複数自然に導ける場合、protocol / scope / permission / history persistence に触れる場合、ticket 自体の再定義が必要な場合は、実装委譲前に人間へ戻す。ただし下位 orchestrator に探索だけを委譲することはできる。
|
||||
|
||||
## 親 Pod / orchestrator の責務
|
||||
## Intent packet
|
||||
|
||||
階層化すると人間の意図が劣化しやすい。下位 orchestrator や coder / reviewer へは、自然文の依頼だけでなく intent packet を渡す。
|
||||
|
||||
標準形:
|
||||
|
||||
```text
|
||||
Intent:
|
||||
- 何を実現するか。
|
||||
|
||||
Requirements:
|
||||
- 完了時に満たすべき observable な要件。
|
||||
|
||||
Invariants:
|
||||
- 壊してはいけない設計境界。
|
||||
- 残してはいけない旧概念や互換層。
|
||||
|
||||
Non-goals:
|
||||
- 今回やらないこと。
|
||||
|
||||
Escalate if:
|
||||
- 親へ戻すべき判断条件。
|
||||
|
||||
Validation:
|
||||
- 実行すべき format / build / test / doctor。
|
||||
```
|
||||
|
||||
reviewer には coder の実装方針ではなく、この intent packet と diff を中心に読ませる。
|
||||
|
||||
## orchestrator の責務
|
||||
|
||||
下位 orchestrator を挟まない場合は、以下を最上位 orchestrator が行う。下位 orchestrator を挟む場合は、最上位は intent packet を渡し、以下の実務を下位に委譲する。
|
||||
|
||||
1. 状態確認
|
||||
- `git status --short --branch`
|
||||
- 対象 ticket
|
||||
- 対象 ticket / ticket 群
|
||||
- 関連 TODO / docs / 既存 worktree
|
||||
|
||||
2. worktree 作成
|
||||
- `$user/worktree-workflow` に従い `./.worktree/<task-name>` を作る。
|
||||
- `.insomnia` を sparse checkout で除外する。
|
||||
|
||||
3. 実装 Pod spawn
|
||||
3. coder Pod spawn
|
||||
- read scope: main workspace 全体。
|
||||
- write scope: child worktree、または必要最小 directory。
|
||||
- task には以下を明示する。
|
||||
- child worktree path / branch
|
||||
- 対象 ticket path
|
||||
- intent packet
|
||||
- Bash は必ず child worktree に `cd` すること
|
||||
- main workspace の `TODO.md` / `tickets/` / `docs/report/` / `.insomnia` は編集しないこと
|
||||
- 範囲外事項
|
||||
- 実行すべき build / test / format
|
||||
- 完了報告項目
|
||||
|
||||
4. 監督
|
||||
4. coder 完了確認
|
||||
- `ReadPodOutput` で報告を読む。
|
||||
- 通知が来ない場合でも、worktree の `git status` / `git diff` / test で完了状態を確認する。
|
||||
- 必要なら `SendToPod` で修正依頼する。
|
||||
- coder が止まった場合、worktree 状態を見て再 spawn / rollback / 親 escalation を判断する。
|
||||
|
||||
5. review
|
||||
- ticket の背景・要件・完了条件・範囲外に照らして diff を確認する。
|
||||
- build / test / `git diff --check` を確認する。
|
||||
- 必要なら reviewer Pod を read-only で立てる。
|
||||
5. reviewer Pod spawn
|
||||
- reviewer は coder の子ではなく orchestrator 配下の sibling として立てる。
|
||||
- 原則 read-only scope にする。
|
||||
- reviewer に渡すもの:
|
||||
- ticket / intent packet
|
||||
- branch / commit / diff の読み方
|
||||
- 実行済み validation
|
||||
- blocker / non-blocker / acceptable の分類基準
|
||||
- reviewer の主目的は「コードの詳細を親の代わりに説明し、intent / invariant 違反を見つけること」。
|
||||
|
||||
6. merge / lifecycle
|
||||
- approve 後に main workspace へ merge する。
|
||||
- `TODO.md` から該当行を削除し、`tickets/foo.md` を削除して完了 commit を作る。
|
||||
6. review → 修正 loop
|
||||
- reviewer finding を orchestrator が読む。
|
||||
- blocker は coder に修正依頼する。
|
||||
- reviewer の指摘を却下する場合は、orchestrator が理由を dossier に残す。
|
||||
- 修正後は focused validation を実行し、必要なら reviewer に再確認させる。
|
||||
- reviewer の blocker が未解決のまま親に提出しない。
|
||||
|
||||
7. merge-ready dossier 作成
|
||||
- 親がコードを直接理解しなくても判断できるよう、変更の概念的説明と evidence をまとめる。
|
||||
|
||||
8. merge / lifecycle
|
||||
- 最上位 orchestrator または人間の許可を持つ orchestrator が main workspace へ merge する。
|
||||
- `TODO.md` から該当行を削除し、ticket を完了処理して commit する。
|
||||
- main workspace で必要な test / `cargo check --workspace` / `cargo fmt --check` を再実行する。
|
||||
|
||||
## 実装 Pod の責務
|
||||
## coder Pod の責務
|
||||
|
||||
- child worktree 内でのみ実装する。
|
||||
- main workspace の管理ファイルを書かない。
|
||||
- intent / requirements / invariants / non-goals を読んでから実装する。
|
||||
- 指定された build / test / format を実行する。
|
||||
- ticket 要件外の設計変更、依存関係追加、scope / permission / history persistence / prompt context 加工原則に触れる変更が必要なら止めて報告する。
|
||||
- ticket 要件外の設計変更、依存関係追加、scope / permission / history persistence / prompt context 加工原則に触れる変更が必要なら止めて orchestrator に報告する。
|
||||
- 完了時に以下を報告する。
|
||||
- worktree path / branch
|
||||
- commit hash(commit した場合)
|
||||
|
|
@ -79,34 +163,54 @@ insomnia を insomnia で開発する際の、worktree + 実装 Pod + 親 Pod re
|
|||
- 未解決事項
|
||||
- review に回せるか
|
||||
|
||||
## 実装 Pod の commit 方針
|
||||
## reviewer Pod の責務
|
||||
|
||||
実装 Pod には child worktree 内での commit を許可してよい。
|
||||
reviewer は coder の subordinate ではない。orchestrator 配下の sibling として、実装 diff を外部から読む。
|
||||
|
||||
- 原則 read-only で作業する。
|
||||
- ticket / intent packet / diff / validation 結果を読む。
|
||||
- 実際のコード変更が概念的に何を変えたかを説明する。
|
||||
- 親や上位 orchestrator が line-by-line diff を読まずに判断できるよう、以下を整理する。
|
||||
- 変更の概念モデル
|
||||
- intent / requirements との対応
|
||||
- invariant 違反の有無
|
||||
- 旧概念・禁止語彙・不要な互換層の残存
|
||||
- validation の妥当性
|
||||
- blocker / non-blocker / follow-up
|
||||
- reviewer は直接 merge しない。
|
||||
- reviewer は coder に直接作業指示を出さず、orchestrator に finding を返す。
|
||||
|
||||
## commit 方針
|
||||
|
||||
coder Pod には child worktree 内での commit を許可してよい。
|
||||
|
||||
- commit は ticket 内で意味のある粒度にする。
|
||||
- 例: `feat: ...`、`fix: ...`、`test: ...`、`docs: ...`
|
||||
- 実装 Pod は merge / push / branch deletion / worktree remove をしない。
|
||||
- 実装 Pod は `TODO.md` / `tickets/` の完了処理 commit をしない。
|
||||
- 親 Pod は review 時に commit 粒度も確認する。
|
||||
- coder Pod は merge / push / branch deletion / worktree remove をしない。
|
||||
- coder Pod は `TODO.md` / ticket の完了処理 commit をしない。
|
||||
- orchestrator は review 時に commit 粒度も確認する。
|
||||
- 必要な修正は、原則追加 commit として積む。履歴改変や squash は人間の明示指示がある時だけ行う。
|
||||
|
||||
## Review → 修正 → 完了の標準形
|
||||
|
||||
### Approve
|
||||
|
||||
1. 実装 Pod を停止し、scope を回収する。
|
||||
2. 親 Pod が main workspace で `git merge --no-ff <branch>` する。
|
||||
3. 親 Pod が `TODO.md` と `tickets/foo.md` を完了処理して commit する。
|
||||
4. main workspace で検証コマンドを再実行する。
|
||||
5. 変更内容・commit・検証結果・残 dirty changes を報告する。
|
||||
1. coder Pod / reviewer Pod を停止し、scope を回収する。
|
||||
2. orchestrator が merge-ready dossier を確認する。
|
||||
3. 最上位 orchestrator が必要最小限の spot check を行う。
|
||||
4. main workspace で `git merge --no-ff <branch>` する。
|
||||
5. `TODO.md` と ticket を完了処理して commit する。
|
||||
6. main workspace で検証コマンドを再実行する。
|
||||
7. 変更内容・commit・検証結果・残 dirty changes を報告する。
|
||||
|
||||
### Request changes
|
||||
|
||||
1. blocking finding をファイル / 行 / 理由 / 修正方針つきで整理する。
|
||||
2. 実装 Pod が生きていれば `SendToPod` で修正依頼する。
|
||||
3. 停止済みなら、同じ worktree / branch / scope で再 spawn するか、親 Pod が最小修正する。
|
||||
1. reviewer finding または orchestrator finding を blocker / non-blocker に分ける。
|
||||
2. blocker はファイル / 行 / 理由 / 修正方針つきで coder に戻す。
|
||||
3. coder が停止済みなら、同じ worktree / branch / scope で再 spawn する。
|
||||
4. 修正後に focused test と必要な broader test を再実行する。
|
||||
5. 再 review する。
|
||||
5. 必要なら reviewer に再確認させる。
|
||||
6. blocker が解消したら dossier を更新する。
|
||||
|
||||
### Non-blocking comments
|
||||
|
||||
|
|
@ -118,28 +222,75 @@ insomnia を insomnia で開発する際の、worktree + 実装 Pod + 親 Pod re
|
|||
|
||||
- 1 ticket = 1 worktree = 1 branch を基本にする。
|
||||
- 複数 Pod に同じ write scope を渡さない。
|
||||
- parent は child の write scope 配下を直接編集しない。
|
||||
- parent / orchestrator は coder の write scope 配下を直接編集しない。
|
||||
- reviewer は read-only を基本にする。review artifact を書かせる場合は ticket artifacts など限定 scope にする。
|
||||
- 依存関係がある ticket は、土台 branch を merge してから次 worktree を切る。
|
||||
- parallel に走らせた Pod の完了通知は取りこぼしうるため、`ReadPodOutput` と worktree 状態で確認する。
|
||||
|
||||
## 完了報告の標準形
|
||||
## merge-ready dossier の標準形
|
||||
|
||||
```text
|
||||
完了:
|
||||
- ticket: <path>
|
||||
Status:
|
||||
- completed / blocked / needs parent decision
|
||||
|
||||
Scope:
|
||||
- ticket(s): <path>
|
||||
- branch: <name>
|
||||
- commits:
|
||||
- <hash> <subject>
|
||||
- 変更概要: ...
|
||||
- 検証:
|
||||
- cargo fmt --check
|
||||
- cargo check --workspace
|
||||
- cargo test ...
|
||||
- review: approve / approve with comments / request changes
|
||||
- 未解決事項: ...
|
||||
- 残 dirty changes: ...
|
||||
|
||||
Intent check:
|
||||
- invariant 1: ok / violated / not applicable, evidence ...
|
||||
- invariant 2: ok / violated / not applicable, evidence ...
|
||||
|
||||
Implementation summary:
|
||||
- 変更の概念的説明: ...
|
||||
- 主要変更ファイル: ...
|
||||
- compatibility / migration: ...
|
||||
|
||||
Coder loop:
|
||||
- coder pod: <name>
|
||||
- produced commits: ...
|
||||
- unresolved coder notes: ...
|
||||
|
||||
External review:
|
||||
- reviewer pod: <name>
|
||||
- blockers found: ...
|
||||
- blockers fixed: ...
|
||||
- rejected findings and reasons: ...
|
||||
- non-blocking follow-ups: ...
|
||||
|
||||
Validation:
|
||||
- cargo fmt --check
|
||||
- cargo check --workspace
|
||||
- cargo test ...
|
||||
- ./tickets.sh doctor
|
||||
|
||||
Parent decision needed:
|
||||
- none / specific question
|
||||
|
||||
Residual risk:
|
||||
- ...
|
||||
|
||||
Dirty state:
|
||||
- ...
|
||||
```
|
||||
|
||||
## 最上位 orchestrator の acceptance
|
||||
|
||||
最上位 orchestrator は、下位 orchestrator の成果を code review するのではなく acceptance する。
|
||||
|
||||
確認するもの:
|
||||
|
||||
- intent packet が保持されているか。
|
||||
- reviewer が coder と独立した sibling として機能したか。
|
||||
- blocker が未解決のまま握りつぶされていないか。
|
||||
- 変更の概念的説明が要件と対応しているか。
|
||||
- validation が ticket のリスクに対して十分か。
|
||||
- escalation すべき判断を下位が勝手に決めていないか。
|
||||
|
||||
必要なら spot check するが、常態的な line-by-line review に戻らない。
|
||||
|
||||
## この Workflow で扱わないもの
|
||||
|
||||
以下は `$user/auto-maintain` または別の設計相談で扱う。
|
||||
|
|
@ -148,3 +299,4 @@ insomnia を insomnia で開発する際の、worktree + 実装 Pod + 親 Pod re
|
|||
- 新規 ticket 作成判断。
|
||||
- QA feedback / AI feedback を ticket / report / workflow に落とす判断。
|
||||
- 長期 maintainer loop / WorkItemStore / LeaseStore の設計。
|
||||
- reviewer Pod の品質評価を機械的に採点する仕組み。
|
||||
|
|
|
|||
|
|
@ -1,26 +1,28 @@
|
|||
---
|
||||
description: insomnia プロジェクトで child git worktree を作成・管理するための機械的手順。実装 Pod に作らせず、親 Pod が main workspace で実行する。
|
||||
model_invokation: false
|
||||
description: insomnia プロジェクトで child git worktree を作成・管理するための機械的手順。coder Pod に作らせず、orchestrator Pod が main workspace で実行する。
|
||||
model_invokation: true
|
||||
user_invocable: true
|
||||
requires: []
|
||||
---
|
||||
# Worktree Workflow
|
||||
|
||||
insomnia プロジェクトで実装差分を main workspace から分離するため、`./.worktree/<task-name>` に child git worktree を作る。これは **worktree の扱い方だけ** を定める Workflow であり、ticket 選定、実装委譲、review、merge の運用は `$user/multi-agent-workflow` 側で扱う。
|
||||
insomnia プロジェクトで実装差分を main workspace から分離するため、`./.worktree/<task-name>` に child git worktree を作る。これは **worktree の扱い方だけ** を定める Workflow であり、ticket 選定、coder / reviewer sibling の起動、外部レビュー、merge の運用は `$user/multi-agent-workflow` 側で扱う。
|
||||
|
||||
insomnia では Pod の write scope が排他的に委譲されるため、child worktree に `.insomnia` を置かない。main workspace は orchestration / ticket / docs / memory / workflow 管理の場所として残し、child worktree はコード差分専用の作業面として扱う。
|
||||
|
||||
## 適用範囲
|
||||
|
||||
この Workflow は親 Pod / orchestrator が main workspace で実行する。
|
||||
この Workflow は親 Pod / 下位 orchestrator が main workspace で実行する。
|
||||
|
||||
- 実装 Pod にこの Workflow を渡して worktree を作らせない。
|
||||
- 実装 Pod は、親 Pod が作成済みの child worktree を受け取り、その中で実装・build・test・報告を行う。
|
||||
- coder Pod にこの Workflow を渡して worktree を作らせない。
|
||||
- coder Pod は、orchestrator が作成済みの child worktree を受け取り、その中で実装・build・test・報告を行う。
|
||||
- reviewer Pod は、coder Pod の子ではなく orchestrator 配下の sibling として、原則 read-only で main workspace と child worktree を読む。
|
||||
- ticket 作成、TODO 更新、review artifact、docs/report は main workspace 側で扱う。
|
||||
|
||||
## 原則
|
||||
|
||||
- 1 ticket / 1 実装 task につき 1 worktree を作る。
|
||||
- 複数 ticket を下位 orchestrator に任せる場合も、実装差分は ticket / bounded task ごとに worktree を分ける。
|
||||
- worktree path は `./.worktree/<task-name>`。
|
||||
- branch 名は原則 `<task-name>` と同じ kebab-case。
|
||||
- child worktree には `.insomnia` を出さない。
|
||||
|
|
@ -36,6 +38,7 @@ insomnia では Pod の write scope が排他的に委譲されるため、child
|
|||
3. `git worktree add` を実行してよい許可があるか。
|
||||
4. main workspace に混ぜてはいけない未保存差分がないか。
|
||||
5. 同名 branch / worktree が既に存在しないか。
|
||||
6. coder / reviewer を sibling として扱う orchestrator が誰か明確か。
|
||||
|
||||
同名 branch がある場合は、既存 branch を使うか、人間に確認する。`git worktree add -b` で上書きしない。
|
||||
|
||||
|
|
@ -62,18 +65,27 @@ test ! -e .worktree/<task-name>/.insomnia
|
|||
|
||||
失敗した場合は、worktree / branch / lock の状態を確認し、勝手に cleanup せず人間へ報告する。
|
||||
|
||||
## 子 Pod へ渡す scope
|
||||
## Pod へ渡す scope
|
||||
|
||||
子 Pod を使う場合、子 Pod の cwd は main workspace のままになる。必ず作業対象が child worktree であることを明示し、Bash 実行時は毎回 `cd <repo>/.worktree/<task-name> && ...` させる。
|
||||
Pod を使う場合、Pod の cwd は main workspace のままになる。必ず作業対象が child worktree であることを明示し、Bash 実行時は毎回 `cd <repo>/.worktree/<task-name> && ...` させる。
|
||||
|
||||
推奨 scope:
|
||||
coder Pod 推奨 scope:
|
||||
|
||||
```text
|
||||
read: <repo>
|
||||
write: <repo>/.worktree/<task-name>
|
||||
```
|
||||
|
||||
より狭く切れる場合は、write scope を変更対象 crate / directory まで狭めてよい。ただし build / test に必要な生成物を書けることを確認する。
|
||||
reviewer Pod 推奨 scope:
|
||||
|
||||
```text
|
||||
read: <repo>
|
||||
read: <repo>/.worktree/<task-name> # main workspace の read に含まれるなら別指定不要
|
||||
```
|
||||
|
||||
reviewer は原則 write scope を持たない。review artifact を書かせる必要がある場合だけ、ticket artifacts など限定 directory を write scope として渡す。
|
||||
|
||||
より狭く切れる場合は、coder の write scope を変更対象 crate / directory まで狭めてよい。ただし build / test に必要な生成物を書けることを確認する。
|
||||
|
||||
## child worktree 内の禁止事項
|
||||
|
||||
|
|
@ -86,13 +98,22 @@ write: <repo>/.worktree/<task-name>
|
|||
|
||||
worktree 作成 Workflow としては、完了時に merge しない。merge、ticket 完了、TODO 削除は `$user/multi-agent-workflow` または人間の明示指示で行う。
|
||||
|
||||
実装 Pod へ渡す完了報告項目の標準形:
|
||||
coder Pod へ渡す完了報告項目の標準形:
|
||||
|
||||
- worktree path
|
||||
- branch 名
|
||||
- commit hash(実装 Pod に commit を許可した場合)
|
||||
- commit hash(coder Pod に commit を許可した場合)
|
||||
- 変更ファイル
|
||||
- 実装概要
|
||||
- 実行した build / test / format
|
||||
- 未解決事項
|
||||
- review に回せるか
|
||||
|
||||
reviewer Pod へ渡す完了報告項目の標準形:
|
||||
|
||||
- 読んだ ticket / intent packet / diff
|
||||
- 実際のコード変更の概念的説明
|
||||
- intent / requirements / invariant との対応
|
||||
- blocker / non-blocker / follow-up
|
||||
- validation の妥当性
|
||||
- 親または上位 orchestrator が判断すべき残論点
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
全体設計が概ね固まり、随所の細かい仕様を詰めながら実装を進めている。
|
||||
すでにシステムのドッグフーディングに成功しており、改善・機能追加のフェーズになっている。
|
||||
随所の細かい仕様を詰めながら実装を進めている。
|
||||
|
||||
## このシステムに置ける設計要旨
|
||||
|
||||
- プロンプトはすべて resources/promptsに集約している。管理効率の工場と同時に、ユーザーがオーバーライドする形式でもある。
|
||||
- プロンプトはすべて resources/promptsに集約している。管理効率の向上と同時に、ユーザーがオーバーライドする形式でもある。
|
||||
- E2E(実プロセスをスポーンさせてのテスト)は未設計。
|
||||
- 変更量を最小にするために設計を歪めたり、設計問題に対して不必要な後方互換性を作らない。長期的なメンテナンスと型安全性を追求すること。
|
||||
|
||||
|
|
@ -28,7 +29,7 @@ Podの状態から純粋に再現可能で、且つ揮発性の無い操作で
|
|||
|
||||
## Git操作
|
||||
|
||||
workflowで明示されない限り、読み取り以外の操作は控えること。
|
||||
明示的に指示されない限り、読み取り以外の操作は控えること。
|
||||
基本はworktree上の一時的なブランチでコミットを重ね、メインブランチに取り込む運用をしている。
|
||||
コミットメッセージは適当に`<prefix>: *簡潔な1行*`で書いている。
|
||||
|
||||
|
|
|
|||
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -334,6 +334,7 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"manifest",
|
||||
"protocol",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
|
@ -1732,6 +1733,7 @@ dependencies = [
|
|||
"protocol",
|
||||
"serde",
|
||||
"serde_ignored",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"toml",
|
||||
|
|
|
|||
|
|
@ -7,5 +7,6 @@ license.workspace = true
|
|||
[dependencies]
|
||||
protocol = { workspace = true }
|
||||
manifest = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync", "time", "process", "fs"] }
|
||||
uuid = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
//! `insomnia-pod` バイナリをサブプロセスとして立ち上げ、`INSOMNIA-READY` を待つ
|
||||
//! ハンドシェイク。
|
||||
//!
|
||||
//! - 親プロセス (TUI / GUI / E2E) は overlay TOML を組み立ててこの関数に
|
||||
//! 渡す。pod はそれを受けて socket を bind し、stderr に
|
||||
//! - 親プロセス (TUI / GUI / E2E) は profile/default/typed restore flags を
|
||||
//! 指定してこの関数に渡す。pod はそれを受けて socket を bind し、stderr に
|
||||
//! `INSOMNIA-READY\t<name>\t<socket>` を吐く。
|
||||
//! - 待機中の stderr 行は `progress` コールバック越しに呼び出し側へ流す。
|
||||
//! UI の進捗表示や E2E のログ収集はここで賄う。
|
||||
|
|
@ -27,8 +27,12 @@ pub struct SpawnConfig {
|
|||
/// (`manifest::paths::pod_runtime_dir`) の解決と、ready 行に乗る
|
||||
/// 名前との突き合わせに使う。
|
||||
pub pod_name: String,
|
||||
/// `--overlay` で pod に渡す TOML 文字列。
|
||||
pub overlay_toml: String,
|
||||
/// Optional Nix profile selector. When present the child is launched with
|
||||
/// `--profile`; the Pod name is supplied through `--profile-pod-name` so
|
||||
/// profile evaluation stays separate from `--pod` restore semantics.
|
||||
pub profile: Option<String>,
|
||||
/// Optional session-scope snapshot used when restoring by session id.
|
||||
pub resume_scope: Option<manifest::ScopeConfig>,
|
||||
/// pod の current_dir。
|
||||
pub cwd: PathBuf,
|
||||
/// `Some(id)` のとき `--session <id>` を付与し、当該セッションから
|
||||
|
|
@ -107,18 +111,33 @@ where
|
|||
|
||||
let mut command = Command::new(&pod_bin);
|
||||
command
|
||||
.arg("--overlay")
|
||||
.arg(&config.overlay_toml)
|
||||
.current_dir(&config.cwd)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::from(stderr_file))
|
||||
.process_group(0);
|
||||
if config.resume_by_pod_name {
|
||||
if let Some(profile) = &config.profile {
|
||||
command
|
||||
.arg("--profile")
|
||||
.arg(profile)
|
||||
.arg("--profile-pod-name")
|
||||
.arg(&config.pod_name);
|
||||
}
|
||||
if config.resume_by_pod_name && config.profile.is_none() {
|
||||
command.arg("--pod").arg(&config.pod_name);
|
||||
}
|
||||
if let Some(id) = config.resume_from {
|
||||
command.arg("--session").arg(id.to_string());
|
||||
command
|
||||
.arg("--session")
|
||||
.arg(id.to_string())
|
||||
.arg("--session-pod-name")
|
||||
.arg(&config.pod_name);
|
||||
if let Some(scope) = &config.resume_scope {
|
||||
let scope_json = serde_json::to_string(scope).map_err(|e| {
|
||||
SpawnError::PodLaunchFailed(io::Error::new(io::ErrorKind::InvalidInput, e))
|
||||
})?;
|
||||
command.arg("--resume-scope-json").arg(scope_json);
|
||||
}
|
||||
}
|
||||
let mut child = command.spawn().map_err(SpawnError::PodLaunchFailed)?;
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ arc-swap = "1"
|
|||
llm-worker = { workspace = true }
|
||||
protocol = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
serde_ignored = "0.1.14"
|
||||
thiserror = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -676,6 +676,7 @@ impl TryFrom<PodManifestConfig> for PodManifest {
|
|||
web: cfg.web,
|
||||
memory: cfg.memory,
|
||||
skills: cfg.skills,
|
||||
profile: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ mod config;
|
|||
pub mod defaults;
|
||||
mod model;
|
||||
pub mod paths;
|
||||
mod profile;
|
||||
mod scope;
|
||||
|
||||
pub use cascade::{LayerLoadError, find_project_manifest_from, load_layer};
|
||||
|
|
@ -15,6 +16,12 @@ pub use model::{
|
|||
};
|
||||
pub use paths::{
|
||||
user_manifest_path, user_manifest_path_from_env, user_manifest_path_with_env_override,
|
||||
user_profiles_path,
|
||||
};
|
||||
pub use profile::{
|
||||
NixProfileResolver, ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata,
|
||||
ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource, ProfileSelector, ProfileSource,
|
||||
ResolvedProfile, resolve_profile_artifact,
|
||||
};
|
||||
pub use protocol::{Permission, ScopeRule};
|
||||
pub use scope::{Scope, ScopeError, SharedScope};
|
||||
|
|
@ -66,6 +73,11 @@ pub struct PodManifest {
|
|||
/// there is no implicit `$config_dir/skills/` or builtin probe.
|
||||
#[serde(default)]
|
||||
pub skills: Option<SkillsConfig>,
|
||||
/// Optional profile provenance for manifests produced by a Nix profile.
|
||||
/// Stored only after profile resolution so Pod restore can prefer the
|
||||
/// validated snapshot over ambient manifest cascade state.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub profile: Option<profile::ProfileManifestSnapshot>,
|
||||
}
|
||||
|
||||
/// External Agent Skills (`SKILL.md`) ingest configuration. Skills are
|
||||
|
|
|
|||
|
|
@ -117,6 +117,14 @@ pub enum AuthRef {
|
|||
/// ChatGPT OAuth(`~/.codex/auth.json`)。実装は `llm-auth-codex-oauth` チケット
|
||||
#[serde(rename = "codex_oauth")]
|
||||
CodexOAuth,
|
||||
/// Typed secret-store reference. The profile resolver preserves this
|
||||
/// reference verbatim; secret-store lookup/decryption is intentionally a
|
||||
/// later consumer-boundary concern.
|
||||
#[serde(rename = "secret_ref")]
|
||||
SecretRef {
|
||||
#[serde(rename = "ref")]
|
||||
ref_: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl SchemeKind {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@ use std::path::PathBuf;
|
|||
/// auto-discovered user manifest path.
|
||||
pub const USER_MANIFEST_ENV: &str = "INSOMNIA_USER_MANIFEST";
|
||||
|
||||
/// Environment variable that points at installed project resources.
|
||||
pub const RESOURCE_DIR_ENV: &str = "INSOMNIA_RESOURCE_DIR";
|
||||
|
||||
/// 設定ディレクトリ。`manifest.toml`, `providers.toml`, `models.toml`,
|
||||
/// `prompts/` などが置かれる。
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
|
|
@ -109,11 +112,45 @@ pub fn user_manifest_path_with_env_override() -> Option<PathBuf> {
|
|||
user_manifest_path_from_env(std::env::var_os(USER_MANIFEST_ENV)).or_else(user_manifest_path)
|
||||
}
|
||||
|
||||
/// `<config_dir>/profiles.toml` — user profile registry/default configuration.
|
||||
///
|
||||
/// This is application/profile selection configuration, not a Pod manifest
|
||||
/// layer. It deliberately ignores [`USER_MANIFEST_ENV`].
|
||||
pub fn user_profiles_path() -> Option<PathBuf> {
|
||||
Some(config_dir()?.join("profiles.toml"))
|
||||
}
|
||||
|
||||
/// `<config_dir>/prompts/` — user prompts ライブラリ。
|
||||
pub fn user_prompts_dir() -> Option<PathBuf> {
|
||||
Some(config_dir()?.join("prompts"))
|
||||
}
|
||||
|
||||
/// Root resource directory used for bundled prompts/Nix support files.
|
||||
pub fn resource_dir() -> Option<PathBuf> {
|
||||
if let Some(p) = env_path(RESOURCE_DIR_ENV) {
|
||||
return Some(p);
|
||||
}
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(prefix) = exe.parent().and_then(|bin| bin.parent()) {
|
||||
let installed = prefix.join("share").join("insomnia").join("resources");
|
||||
if installed.exists() {
|
||||
return Some(installed);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.join("resources"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Bundled profile registry directory. Missing directories are treated as an
|
||||
/// empty builtin registry by discovery.
|
||||
pub fn builtin_profiles_dir() -> Option<PathBuf> {
|
||||
Some(resource_dir()?.join("nix").join("profiles"))
|
||||
}
|
||||
|
||||
/// `<config_dir>/prompts.toml` — user prompt pack。
|
||||
pub fn user_pack_file() -> Option<PathBuf> {
|
||||
Some(config_dir()?.join("prompts.toml"))
|
||||
|
|
@ -192,6 +229,7 @@ mod tests {
|
|||
"INSOMNIA_DATA_DIR",
|
||||
"INSOMNIA_RUNTIME_DIR",
|
||||
"INSOMNIA_USER_MANIFEST",
|
||||
"INSOMNIA_RESOURCE_DIR",
|
||||
"INSOMNIA_HOME",
|
||||
"XDG_CONFIG_HOME",
|
||||
"XDG_RUNTIME_DIR",
|
||||
|
|
@ -355,6 +393,10 @@ mod tests {
|
|||
user_manifest_path().unwrap(),
|
||||
PathBuf::from("/sand/config/manifest.toml")
|
||||
);
|
||||
assert_eq!(
|
||||
user_profiles_path().unwrap(),
|
||||
PathBuf::from("/sand/config/profiles.toml")
|
||||
);
|
||||
assert_eq!(
|
||||
user_prompts_dir().unwrap(),
|
||||
PathBuf::from("/sand/config/prompts")
|
||||
|
|
|
|||
1217
crates/manifest/src/profile.rs
Normal file
1217
crates/manifest/src/profile.rs
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -806,6 +806,7 @@ mod tests {
|
|||
child("child-stale", &stale_socket),
|
||||
child("child-pending", &pending_socket),
|
||||
],
|
||||
resolved_manifest_snapshot: None,
|
||||
};
|
||||
store.write(&parent).unwrap();
|
||||
store
|
||||
|
|
@ -816,6 +817,7 @@ mod tests {
|
|||
active_child_segment,
|
||||
)),
|
||||
spawned_children: Vec::new(),
|
||||
resolved_manifest_snapshot: None,
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
|
|
@ -826,6 +828,7 @@ mod tests {
|
|||
active_child_segment,
|
||||
)),
|
||||
spawned_children: Vec::new(),
|
||||
resolved_manifest_snapshot: None,
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
|
|
@ -833,6 +836,7 @@ mod tests {
|
|||
pod_name: "child-pending".into(),
|
||||
active: Some(PodActiveSegmentRef::pending_segment(pending_session_id)),
|
||||
spawned_children: Vec::new(),
|
||||
resolved_manifest_snapshot: None,
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
|
|
@ -843,6 +847,7 @@ mod tests {
|
|||
new_segment_id(),
|
||||
)),
|
||||
spawned_children: Vec::new(),
|
||||
resolved_manifest_snapshot: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use std::io;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use protocol::stream::{JsonLineReader, JsonLineWriter};
|
||||
|
|
@ -57,6 +58,16 @@ impl Drop for SocketServer {
|
|||
}
|
||||
}
|
||||
|
||||
fn is_peer_disconnect_read_error(error: &io::Error) -> bool {
|
||||
matches!(
|
||||
error.kind(),
|
||||
ErrorKind::ConnectionReset
|
||||
| ErrorKind::ConnectionAborted
|
||||
| ErrorKind::BrokenPipe
|
||||
| ErrorKind::UnexpectedEof
|
||||
)
|
||||
}
|
||||
|
||||
async fn handle_connection(stream: tokio::net::UnixStream, handle: PodHandle) {
|
||||
let (reader, writer) = stream.into_split();
|
||||
let mut reader = JsonLineReader::new(reader);
|
||||
|
|
@ -206,14 +217,48 @@ async fn handle_connection(stream: tokio::net::UnixStream, handle: PodHandle) {
|
|||
let _ = handle.send(method).await;
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(e) if is_peer_disconnect_read_error(&e) => break,
|
||||
Err(e) => {
|
||||
let _ = handle.send_event(Event::Error {
|
||||
code: protocol::ErrorCode::Internal,
|
||||
message: format!("invalid method: {e}"),
|
||||
});
|
||||
if writer
|
||||
.write(&Event::Error {
|
||||
code: protocol::ErrorCode::InvalidRequest,
|
||||
message: format!("invalid method: {e}"),
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn peer_disconnect_read_errors_are_connection_close() {
|
||||
for kind in [
|
||||
ErrorKind::ConnectionReset,
|
||||
ErrorKind::ConnectionAborted,
|
||||
ErrorKind::BrokenPipe,
|
||||
ErrorKind::UnexpectedEof,
|
||||
] {
|
||||
let error = io::Error::new(kind, "peer disconnected");
|
||||
assert!(
|
||||
is_peer_disconnect_read_error(&error),
|
||||
"{kind:?} should be treated as a normal peer disconnect"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_data_is_not_peer_disconnect() {
|
||||
let error = io::Error::new(ErrorKind::InvalidData, "malformed method");
|
||||
assert!(!is_peer_disconnect_read_error(&error));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +1,63 @@
|
|||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::ExitCode;
|
||||
|
||||
use clap::Parser;
|
||||
use manifest::{PodManifest, PodManifestConfig, paths};
|
||||
use pod::{Pod, PodController, PodFactory, PromptLoader};
|
||||
use manifest::{
|
||||
NixProfileResolver, PodManifest, PodManifestConfig, ProfileSelector, ScopeConfig, paths,
|
||||
};
|
||||
use pod::{Pod, PodController, PromptLoader};
|
||||
use session_store::{FsStore, PodMetadataStore, SegmentId, Store};
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "insomnia-pod",
|
||||
about = "Spawn a Pod process from manifest layers or a single manifest file"
|
||||
about = "Spawn a Pod process from a Nix profile or a single manifest file"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Manifest TOML to use directly, without loading user, project, or
|
||||
/// overlay layers.
|
||||
#[arg(long, value_name = "PATH", conflicts_with_all = ["project", "overlay"])]
|
||||
/// Nix profile to evaluate. Accepts an explicit path, `path:<path>`, a
|
||||
/// discovered profile name, `default`, or a source-qualified name such as
|
||||
/// `project:coder`.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "PROFILE",
|
||||
conflicts_with_all = ["manifest", "project", "pod", "session", "adopt"]
|
||||
)]
|
||||
profile: Option<String>,
|
||||
|
||||
/// Pod name override for a freshly-created profile Pod. This does not use
|
||||
/// `--pod` restore semantics, so it must not attach/restore existing Pod
|
||||
/// state by re-evaluating the profile source.
|
||||
#[arg(long, value_name = "NAME", requires = "profile", conflicts_with_all = ["pod", "session", "adopt"])]
|
||||
profile_pod_name: Option<String>,
|
||||
|
||||
/// Manifest TOML to use directly as a one-file compatibility/debug input.
|
||||
/// This bypasses profile discovery but still applies builtin defaults and
|
||||
/// the same required-field validation boundary.
|
||||
#[arg(long, value_name = "PATH", conflicts_with_all = ["project"])]
|
||||
manifest: Option<PathBuf>,
|
||||
|
||||
/// Start the project-manifest walk from this directory. When
|
||||
/// omitted, the factory walks up from the current working
|
||||
/// directory looking for `.insomnia/manifest.toml`.
|
||||
/// Deprecated manifest-cascade project root flag. Ambient project/user
|
||||
/// manifest discovery has been removed; configure/select a profile instead.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
project: Option<PathBuf>,
|
||||
|
||||
/// Inline TOML string applied as the highest-priority overlay
|
||||
/// layer. Example: `--overlay 'pod.name = "dbg"'`.
|
||||
#[arg(long, value_name = "TOML")]
|
||||
overlay: Option<String>,
|
||||
/// Internal typed pod-name override for session restore launched by the TUI.
|
||||
#[arg(long, value_name = "NAME", requires = "session", hide = true)]
|
||||
session_pod_name: Option<String>,
|
||||
|
||||
/// Internal typed scope snapshot for session restore launched by the TUI.
|
||||
#[arg(long, value_name = "JSON", requires = "session", hide = true)]
|
||||
resume_scope_json: Option<String>,
|
||||
|
||||
/// Internal resolved manifest config for delegated child Pod spawning.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "JSON",
|
||||
requires = "adopt",
|
||||
conflicts_with_all = ["profile", "manifest", "project", "pod", "session"],
|
||||
hide = true
|
||||
)]
|
||||
spawn_config_json: Option<String>,
|
||||
|
||||
/// Directory for session persistence. Defaults to
|
||||
/// `<data_dir>/sessions/` (see `manifest::paths`).
|
||||
|
|
@ -66,29 +96,75 @@ struct Cli {
|
|||
}
|
||||
|
||||
fn resolve_manifest(cli: &Cli) -> Result<(PodManifest, PromptLoader), String> {
|
||||
resolve_manifest_with_user_manifest_env(cli, std::env::var_os(paths::USER_MANIFEST_ENV))
|
||||
resolve_manifest_with_profile_loader(cli, load_profile)
|
||||
}
|
||||
|
||||
fn resolve_manifest_with_user_manifest_env(
|
||||
fn resolve_manifest_with_profile_loader<F>(
|
||||
cli: &Cli,
|
||||
user_manifest_env: Option<OsString>,
|
||||
) -> Result<(PodManifest, PromptLoader), String> {
|
||||
let user_manifest = paths::user_manifest_path_from_env(user_manifest_env);
|
||||
|
||||
if let Some(path) = &cli.manifest {
|
||||
if user_manifest.is_some() {
|
||||
return Err(format!(
|
||||
"--manifest cannot be used when {} is set",
|
||||
paths::USER_MANIFEST_ENV
|
||||
));
|
||||
load_profile_fn: F,
|
||||
) -> Result<(PodManifest, PromptLoader), String>
|
||||
where
|
||||
F: FnOnce(&ProfileSelector, Option<&str>) -> Result<(PodManifest, PromptLoader), String>,
|
||||
{
|
||||
let mut manifest_and_loader = if let Some(config_json) = cli.spawn_config_json.as_deref() {
|
||||
load_spawn_config_json(config_json)?
|
||||
} else if let Some(profile) = &cli.profile {
|
||||
let selector = ProfileSelector::parse_cli(profile);
|
||||
load_profile_fn(&selector, cli.profile_pod_name.as_deref())?
|
||||
} else if let Some(path) = &cli.manifest {
|
||||
load_single_manifest(path, cli.pod.as_deref())?
|
||||
} else {
|
||||
if cli.project.is_some() {
|
||||
return Err(
|
||||
"--project is no longer supported; normal startup uses profile discovery/default, \
|
||||
and --manifest <PATH> is the only one-file manifest mode"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
return load_single_manifest(path, cli.pod.as_deref());
|
||||
}
|
||||
let selector = ProfileSelector::Default;
|
||||
load_profile_fn(&selector, cli.pod.as_deref())?
|
||||
};
|
||||
|
||||
let factory = build_factory_with_user_manifest_path(cli, user_manifest)?;
|
||||
factory
|
||||
.resolve()
|
||||
.map_err(|e| format!("failed to resolve manifest cascade: {e}"))
|
||||
apply_session_restore_overrides(&mut manifest_and_loader.0, cli)?;
|
||||
Ok(manifest_and_loader)
|
||||
}
|
||||
|
||||
fn apply_session_restore_overrides(manifest: &mut PodManifest, cli: &Cli) -> Result<(), String> {
|
||||
if let Some(pod_name) = cli.session_pod_name.as_deref() {
|
||||
manifest.pod.name = pod_name.to_string();
|
||||
}
|
||||
if let Some(scope_json) = cli.resume_scope_json.as_deref() {
|
||||
manifest.scope = serde_json::from_str::<ScopeConfig>(scope_json)
|
||||
.map_err(|e| format!("failed to parse --resume-scope-json: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_spawn_config_json(config_json: &str) -> Result<(PodManifest, PromptLoader), String> {
|
||||
let config = serde_json::from_str::<PodManifestConfig>(config_json)
|
||||
.map_err(|e| format!("failed to parse --spawn-config-json: {e}"))?;
|
||||
let manifest = PodManifest::try_from(PodManifestConfig::builtin_defaults().merge(config))
|
||||
.map_err(|e| format!("failed to resolve --spawn-config-json: {e}"))?;
|
||||
Ok((manifest, PromptLoader::builtins_only()))
|
||||
}
|
||||
|
||||
fn load_profile(
|
||||
selector: &ProfileSelector,
|
||||
pod_name_override: Option<&str>,
|
||||
) -> Result<(PodManifest, PromptLoader), String> {
|
||||
let cwd = std::env::current_dir()
|
||||
.map_err(|e| format!("failed to resolve current directory for profile: {e}"))?;
|
||||
let resolver = NixProfileResolver::new().with_workspace_base(cwd);
|
||||
let mut resolved = resolver.resolve(selector).map_err(|e| {
|
||||
format!(
|
||||
"failed to resolve profile {}: {e}",
|
||||
selector.display_label()
|
||||
)
|
||||
})?;
|
||||
if let Some(pod_name) = pod_name_override {
|
||||
resolved.manifest.pod.name = pod_name.to_string();
|
||||
}
|
||||
Ok((resolved.manifest, PromptLoader::builtins_only()))
|
||||
}
|
||||
|
||||
fn load_single_manifest(
|
||||
|
|
@ -97,78 +173,32 @@ fn load_single_manifest(
|
|||
) -> Result<(PodManifest, PromptLoader), String> {
|
||||
let toml = std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("failed to read manifest {}: {e}", path.display()))?;
|
||||
let manifest = match pod_name_override {
|
||||
Some(pod_name) => match PodManifest::from_toml(&toml) {
|
||||
Ok(mut manifest) => {
|
||||
manifest.pod.name = pod_name.to_string();
|
||||
manifest
|
||||
}
|
||||
Err(_) => {
|
||||
let base = PodManifestConfig::from_toml(&toml)
|
||||
.map_err(|e| format!("failed to parse manifest {}: {e}", path.display()))?;
|
||||
let overlay = PodManifestConfig::from_toml(&pod_name_overlay_toml(pod_name))
|
||||
.expect("pod name overlay TOML is generated");
|
||||
PodManifest::try_from(base.merge(overlay)).map_err(|e| {
|
||||
format!(
|
||||
"failed to resolve manifest {} with --pod: {e}",
|
||||
path.display()
|
||||
)
|
||||
})?
|
||||
}
|
||||
},
|
||||
None => PodManifest::from_toml(&toml)
|
||||
.map_err(|e| format!("failed to parse manifest {}: {e}", path.display()))?,
|
||||
let absolute_path = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.map_err(|e| format!("failed to resolve current directory: {e}"))?
|
||||
.join(path)
|
||||
};
|
||||
let base_dir = absolute_path.parent().ok_or_else(|| {
|
||||
format!(
|
||||
"manifest path {} has no parent directory",
|
||||
absolute_path.display()
|
||||
)
|
||||
})?;
|
||||
let mut config = PodManifestConfig::builtin_defaults().merge(
|
||||
PodManifestConfig::from_toml(&toml)
|
||||
.map_err(|e| format!("failed to parse manifest {}: {e}", path.display()))?
|
||||
.resolve_paths(base_dir),
|
||||
);
|
||||
if let Some(pod_name) = pod_name_override {
|
||||
config.pod.name = Some(pod_name.to_string());
|
||||
}
|
||||
let manifest = PodManifest::try_from(config)
|
||||
.map_err(|e| format!("failed to resolve manifest {}: {e}", path.display()))?;
|
||||
Ok((manifest, PromptLoader::builtins_only()))
|
||||
}
|
||||
|
||||
fn pod_name_overlay_toml(pod_name: &str) -> String {
|
||||
let mut pod = toml::value::Table::new();
|
||||
pod.insert("name".into(), toml::Value::String(pod_name.to_string()));
|
||||
let mut root = toml::value::Table::new();
|
||||
root.insert("pod".into(), toml::Value::Table(pod));
|
||||
toml::to_string(&toml::Value::Table(root)).expect("pod name overlay serialisation cannot fail")
|
||||
}
|
||||
|
||||
fn build_factory_with_user_manifest_path(
|
||||
cli: &Cli,
|
||||
user_manifest: Option<PathBuf>,
|
||||
) -> Result<PodFactory, String> {
|
||||
let mut factory = PodFactory::new();
|
||||
|
||||
factory = match user_manifest {
|
||||
Some(path) => factory
|
||||
.with_user_manifest(path)
|
||||
.map_err(|e| format!("failed to load user manifest: {e}"))?,
|
||||
None => factory
|
||||
.with_user_manifest_auto()
|
||||
.map_err(|e| format!("failed to auto-load user manifest: {e}"))?,
|
||||
};
|
||||
|
||||
factory = match &cli.project {
|
||||
Some(path) => factory
|
||||
.with_project_manifest_from(path)
|
||||
.map_err(|e| format!("failed to load project manifest: {e}"))?,
|
||||
None => factory
|
||||
.with_project_manifest_auto()
|
||||
.map_err(|e| format!("failed to auto-load project manifest: {e}"))?,
|
||||
};
|
||||
|
||||
if let Some(overlay) = cli.overlay.as_deref() {
|
||||
factory = factory
|
||||
.with_overlay_toml(overlay)
|
||||
.map_err(|e| format!("failed to parse overlay TOML: {e}"))?;
|
||||
}
|
||||
|
||||
if let Some(pod_name) = cli.pod.as_deref() {
|
||||
factory = factory
|
||||
.with_overlay_toml(&pod_name_overlay_toml(pod_name))
|
||||
.map_err(|e| format!("failed to apply --pod overlay: {e}"))?;
|
||||
}
|
||||
|
||||
Ok(factory)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> ExitCode {
|
||||
let cli = Cli::parse();
|
||||
|
|
@ -370,7 +400,7 @@ permission = "write"
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_conflicts_with_project_and_overlay() {
|
||||
fn manifest_conflicts_with_project() {
|
||||
let project_err = Cli::try_parse_from([
|
||||
"insomnia-pod",
|
||||
"--manifest",
|
||||
|
|
@ -380,43 +410,23 @@ permission = "write"
|
|||
])
|
||||
.unwrap_err();
|
||||
assert_eq!(project_err.kind(), clap::error::ErrorKind::ArgumentConflict);
|
||||
|
||||
let overlay_err = Cli::try_parse_from([
|
||||
"insomnia-pod",
|
||||
"--manifest",
|
||||
"manifest.toml",
|
||||
"--overlay",
|
||||
"pod.name = 'x'",
|
||||
])
|
||||
.unwrap_err();
|
||||
assert_eq!(overlay_err.kind(), clap::error::ErrorKind::ArgumentConflict);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_conflicts_with_user_manifest_env_when_env_is_non_empty() {
|
||||
fn overlay_flag_is_not_accepted() {
|
||||
let err = Cli::try_parse_from(["insomnia-pod", "--overlay", "pod.name = 'x'"]).unwrap_err();
|
||||
assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_loads_single_file_without_user_or_workspace_prompt_loader() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let manifest = tmp.path().join("manifest.toml");
|
||||
write(&manifest, &manifest_toml("single", tmp.path()));
|
||||
let cli = Cli::try_parse_from(["insomnia-pod", "--manifest", manifest.to_str().unwrap()])
|
||||
.unwrap();
|
||||
|
||||
let err = resolve_manifest_with_user_manifest_env(&cli, Some(OsString::from("user.toml")))
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.contains("--manifest cannot be used"));
|
||||
assert!(err.contains(paths::USER_MANIFEST_ENV));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_allows_empty_user_manifest_env() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let manifest = tmp.path().join("manifest.toml");
|
||||
write(&manifest, &manifest_toml("single", tmp.path()));
|
||||
let cli = Cli::try_parse_from(["insomnia-pod", "--manifest", manifest.to_str().unwrap()])
|
||||
.unwrap();
|
||||
|
||||
let (manifest, loader) =
|
||||
resolve_manifest_with_user_manifest_env(&cli, Some(OsString::new())).unwrap();
|
||||
let (manifest, loader) = resolve_manifest(&cli).unwrap();
|
||||
|
||||
assert_eq!(manifest.pod.name, "single");
|
||||
assert!(loader.user_dir().is_none());
|
||||
|
|
@ -424,26 +434,105 @@ permission = "write"
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn user_manifest_env_overrides_auto_user_manifest_path() {
|
||||
fn profile_uses_selected_profile() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let user_manifest = tmp.path().join("custom-user.toml");
|
||||
write(&user_manifest, &manifest_toml("from-env", tmp.path()));
|
||||
let no_project_root = tmp.path().join("no-project");
|
||||
std::fs::create_dir_all(&no_project_root).unwrap();
|
||||
let profile = tmp.path().join("profile.nix");
|
||||
let cli = Cli::try_parse_from([
|
||||
"insomnia-pod",
|
||||
"--project",
|
||||
no_project_root.to_str().unwrap(),
|
||||
"--profile",
|
||||
profile.to_str().unwrap(),
|
||||
"--profile-pod-name",
|
||||
"from-profile-name",
|
||||
])
|
||||
.unwrap();
|
||||
let mut called = false;
|
||||
|
||||
let (manifest, _loader) = resolve_manifest_with_user_manifest_env(
|
||||
&cli,
|
||||
Some(user_manifest.as_os_str().to_os_string()),
|
||||
)
|
||||
let (manifest, loader) =
|
||||
resolve_manifest_with_profile_loader(&cli, |selector, pod_name| {
|
||||
called = true;
|
||||
assert_eq!(selector, &ProfileSelector::path(profile.clone()));
|
||||
assert_eq!(pod_name, Some("from-profile-name"));
|
||||
let mut manifest =
|
||||
PodManifest::from_toml(&manifest_toml("from-profile", tmp.path())).unwrap();
|
||||
if let Some(pod_name) = pod_name {
|
||||
manifest.pod.name = pod_name.to_string();
|
||||
}
|
||||
Ok((manifest, PromptLoader::builtins_only()))
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(called);
|
||||
assert_eq!(manifest.pod.name, "from-profile-name");
|
||||
assert!(loader.user_dir().is_none());
|
||||
assert!(loader.workspace_dir().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_accepts_source_qualified_discovered_name() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cli = Cli::try_parse_from([
|
||||
"insomnia-pod",
|
||||
"--profile",
|
||||
"project:coder",
|
||||
"--profile-pod-name",
|
||||
"from-profile-name",
|
||||
])
|
||||
.unwrap();
|
||||
let mut called = false;
|
||||
|
||||
assert_eq!(manifest.pod.name, "from-env");
|
||||
let (manifest, _loader) =
|
||||
resolve_manifest_with_profile_loader(&cli, |selector, pod_name| {
|
||||
called = true;
|
||||
assert_eq!(
|
||||
selector,
|
||||
&ProfileSelector::source_named(
|
||||
manifest::ProfileRegistrySource::Project,
|
||||
"coder"
|
||||
)
|
||||
);
|
||||
let mut manifest =
|
||||
PodManifest::from_toml(&manifest_toml("from-profile", tmp.path())).unwrap();
|
||||
if let Some(pod_name) = pod_name {
|
||||
manifest.pod.name = pod_name.to_string();
|
||||
}
|
||||
Ok((manifest, PromptLoader::builtins_only()))
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(called);
|
||||
assert_eq!(manifest.pod.name, "from-profile-name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_startup_uses_default_profile() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cli = Cli::try_parse_from(["insomnia-pod"]).unwrap();
|
||||
let mut called = false;
|
||||
|
||||
let (manifest, _loader) =
|
||||
resolve_manifest_with_profile_loader(&cli, |selector, pod_name| {
|
||||
called = true;
|
||||
assert_eq!(selector, &ProfileSelector::Default);
|
||||
assert_eq!(pod_name, None);
|
||||
let manifest =
|
||||
PodManifest::from_toml(&manifest_toml("from-default-profile", tmp.path()))
|
||||
.unwrap();
|
||||
Ok((manifest, PromptLoader::builtins_only()))
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(called);
|
||||
assert_eq!(manifest.pod.name, "from-default-profile");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_flag_no_longer_enables_ambient_manifest_cascade() {
|
||||
let cli = Cli::try_parse_from(["insomnia-pod", "--project", "."]).unwrap();
|
||||
let err = resolve_manifest_with_profile_loader(&cli, |_, _| {
|
||||
panic!("default profile loader must not run when deprecated --project is present")
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(err.contains("--project is no longer supported"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -469,7 +558,7 @@ permission = "write"
|
|||
])
|
||||
.unwrap();
|
||||
|
||||
let (manifest, _loader) = resolve_manifest_with_user_manifest_env(&cli, None).unwrap();
|
||||
let (manifest, _loader) = resolve_manifest(&cli).unwrap();
|
||||
|
||||
assert_eq!(manifest.pod.name, "from-flag");
|
||||
}
|
||||
|
|
@ -480,7 +569,17 @@ permission = "write"
|
|||
let manifest = tmp.path().join("manifest.toml");
|
||||
write(
|
||||
&manifest,
|
||||
&manifest_toml("unused", tmp.path()).replace("name = \"unused\"\n", ""),
|
||||
r#"
|
||||
[pod]
|
||||
|
||||
[model]
|
||||
scheme = "anthropic"
|
||||
model_id = "test-model"
|
||||
|
||||
[[scope.allow]]
|
||||
target = "."
|
||||
permission = "write"
|
||||
"#,
|
||||
);
|
||||
let cli = Cli::try_parse_from([
|
||||
"insomnia-pod",
|
||||
|
|
@ -491,9 +590,74 @@ permission = "write"
|
|||
])
|
||||
.unwrap();
|
||||
|
||||
let (manifest, _loader) = resolve_manifest_with_user_manifest_env(&cli, None).unwrap();
|
||||
let (manifest, _loader) = resolve_manifest(&cli).unwrap();
|
||||
|
||||
assert_eq!(manifest.pod.name, "from-flag");
|
||||
assert_eq!(manifest.scope.allow[0].target, tmp.path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pod_flag_with_no_manifest_creates_from_default_profile_with_typed_name() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cli = Cli::try_parse_from(["insomnia-pod", "--pod", "agent"]).unwrap();
|
||||
let mut called = false;
|
||||
|
||||
let (manifest, _loader) =
|
||||
resolve_manifest_with_profile_loader(&cli, |selector, pod_name| {
|
||||
called = true;
|
||||
assert_eq!(selector, &ProfileSelector::Default);
|
||||
assert_eq!(pod_name, Some("agent"));
|
||||
let mut manifest =
|
||||
PodManifest::from_toml(&manifest_toml("from-default-profile", tmp.path()))
|
||||
.unwrap();
|
||||
if let Some(pod_name) = pod_name {
|
||||
manifest.pod.name = pod_name.to_string();
|
||||
}
|
||||
Ok((manifest, PromptLoader::builtins_only()))
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(called);
|
||||
assert_eq!(manifest.pod.name, "agent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_conflicts_with_manifest_and_restore_modes() {
|
||||
let segment_id = session_store::new_segment_id().to_string();
|
||||
for args in [
|
||||
vec!["insomnia-pod", "--profile", "p.nix", "--manifest", "m.toml"],
|
||||
vec!["insomnia-pod", "--profile", "p.nix", "--pod", "agent"],
|
||||
vec![
|
||||
"insomnia-pod",
|
||||
"--profile",
|
||||
"p.nix",
|
||||
"--session",
|
||||
&segment_id,
|
||||
],
|
||||
] {
|
||||
let err = Cli::try_parse_from(args).unwrap_err();
|
||||
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_pod_name_requires_profile() {
|
||||
let err = Cli::try_parse_from(["insomnia-pod", "--profile-pod-name", "agent"]).unwrap_err();
|
||||
assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_pod_name_is_not_restore_pod_flag() {
|
||||
let cli = Cli::try_parse_from([
|
||||
"insomnia-pod",
|
||||
"--profile",
|
||||
"p.nix",
|
||||
"--profile-pod-name",
|
||||
"agent",
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(cli.profile_pod_name.as_deref(), Some("agent"));
|
||||
assert!(cli.pod.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -510,7 +674,7 @@ permission = "write"
|
|||
])
|
||||
.unwrap();
|
||||
|
||||
let (manifest, loader) = resolve_manifest_with_user_manifest_env(&cli, None).unwrap();
|
||||
let (manifest, loader) = resolve_manifest(&cli).unwrap();
|
||||
|
||||
assert_eq!(manifest.pod.name, "single-file");
|
||||
assert!(loader.user_dir().is_none());
|
||||
|
|
|
|||
|
|
@ -917,27 +917,31 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
})
|
||||
}
|
||||
|
||||
fn pod_metadata(&self, active: Option<PodActiveSegmentRef>) -> PodMetadata {
|
||||
let mut metadata = PodMetadata::new(self.manifest.pod.name.clone(), active);
|
||||
if self.manifest.profile.is_some() {
|
||||
metadata.resolved_manifest_snapshot = serde_json::to_value(&self.manifest).ok();
|
||||
}
|
||||
metadata
|
||||
}
|
||||
|
||||
fn write_pod_metadata_pending(&self) -> Result<(), StoreError> {
|
||||
let Some(writer) = &self.pod_metadata_writer else {
|
||||
return Ok(());
|
||||
};
|
||||
writer(PodMetadata::new(
|
||||
self.manifest.pod.name.clone(),
|
||||
Some(PodActiveSegmentRef::pending_segment(self.session_id())),
|
||||
))
|
||||
writer(self.pod_metadata(Some(PodActiveSegmentRef::pending_segment(
|
||||
self.session_id(),
|
||||
))))
|
||||
}
|
||||
|
||||
fn write_pod_metadata_active(&self, loc: SegmentLocation) -> Result<(), StoreError> {
|
||||
let Some(writer) = &self.pod_metadata_writer else {
|
||||
return Ok(());
|
||||
};
|
||||
writer(PodMetadata::new(
|
||||
self.manifest.pod.name.clone(),
|
||||
Some(PodActiveSegmentRef::active_segment(
|
||||
loc.session_id,
|
||||
loc.segment_id,
|
||||
)),
|
||||
))
|
||||
writer(self.pod_metadata(Some(PodActiveSegmentRef::active_segment(
|
||||
loc.session_id,
|
||||
loc.segment_id,
|
||||
))))
|
||||
}
|
||||
|
||||
/// Enable name-keyed Pod metadata write-through for Pods built through
|
||||
|
|
@ -3945,6 +3949,15 @@ where
|
|||
pod_name: pod_name.to_string(),
|
||||
session_id: active.session_id,
|
||||
})?;
|
||||
let manifest = match metadata.resolved_manifest_snapshot {
|
||||
Some(snapshot) => serde_json::from_value(snapshot).map_err(|source| {
|
||||
PodError::PodMetadataManifestSnapshot {
|
||||
pod_name: pod_name.to_string(),
|
||||
source,
|
||||
}
|
||||
})?,
|
||||
None => manifest,
|
||||
};
|
||||
Self::restore_from_manifest(active.session_id, segment_id, manifest, store, loader).await
|
||||
}
|
||||
|
||||
|
|
@ -4618,6 +4631,13 @@ pub enum PodError {
|
|||
pod_name: String,
|
||||
session_id: SessionId,
|
||||
},
|
||||
|
||||
#[error("pod metadata for {pod_name} contains an invalid resolved manifest snapshot: {source}")]
|
||||
PodMetadataManifestSnapshot {
|
||||
pod_name: String,
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
}
|
||||
|
||||
/// Bundle of resources that every high-level Pod constructor needs:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! `SpawnPod` tool — launch a new Pod process as a child of this one.
|
||||
//!
|
||||
//! Wires pod-registry delegation, overlay-TOML construction, subprocess
|
||||
//! Wires pod-registry delegation, child manifest-config construction, subprocess
|
||||
//! launch, and socket handoff into a single `Tool` implementation. When
|
||||
//! the LLM calls `SpawnPod`, a fresh `insomnia-pod` binary is exec'd in its own
|
||||
//! process group, the pod-registry is updated atomically, and the child's
|
||||
|
|
@ -116,8 +116,8 @@ pub struct SpawnPodTool {
|
|||
/// no-op.
|
||||
parent_socket: Option<PathBuf>,
|
||||
/// Spawner's resolved provider config — copied into every spawned
|
||||
/// Pod's overlay TOML so the child does not need its own provider
|
||||
/// configuration in the manifest cascade. Per-spawn override is
|
||||
/// Pod's internal manifest config so the child does not need its own provider
|
||||
/// configuration. Per-spawn override is
|
||||
/// out of scope here (see `tickets/spawn-inherit-provider.md`).
|
||||
spawner_model: ModelManifest,
|
||||
/// Spawner's runtime scope. After a successful spawn, the
|
||||
|
|
@ -208,7 +208,7 @@ impl Tool for SpawnPodTool {
|
|||
// it back — even if later steps (Method::Run delivery, record
|
||||
// write) fail, the child is running and will release its own
|
||||
// entry on exit.
|
||||
let overlay_toml = match build_overlay_toml(
|
||||
let spawn_config_json = match build_spawn_config_json(
|
||||
&input.name,
|
||||
&instruction,
|
||||
&scope_allow,
|
||||
|
|
@ -218,13 +218,13 @@ impl Tool for SpawnPodTool {
|
|||
Err(e) => {
|
||||
self.release_reservation(&lock_path, &input.name);
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"overlay serialisation: {e}"
|
||||
"spawn config serialisation: {e}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let start_outcome = self
|
||||
.exec_child(&input.name, &overlay_toml, &predicted_socket)
|
||||
.exec_child(&input.name, &spawn_config_json, &predicted_socket)
|
||||
.await;
|
||||
if let Err(e) = start_outcome {
|
||||
self.release_reservation(&lock_path, &input.name);
|
||||
|
|
@ -300,7 +300,7 @@ impl SpawnPodTool {
|
|||
async fn exec_child(
|
||||
&self,
|
||||
pod_name: &str,
|
||||
overlay_toml: &str,
|
||||
spawn_config_json: &str,
|
||||
predicted_socket: &Path,
|
||||
) -> Result<(), ToolError> {
|
||||
let pod_command =
|
||||
|
|
@ -329,8 +329,8 @@ impl SpawnPodTool {
|
|||
cmd.arg("--adopt")
|
||||
.arg("--callback")
|
||||
.arg(&self.callback_socket)
|
||||
.arg("--overlay")
|
||||
.arg(overlay_toml)
|
||||
.arg("--spawn-config-json")
|
||||
.arg(spawn_config_json)
|
||||
.current_dir(&self.spawner_pwd)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
|
|
@ -382,20 +382,21 @@ fn parse_scope(rules: &[ScopeRuleInput]) -> Result<Vec<ScopeRule>, ToolError> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// Serialise the overlay TOML that gets handed to the child `insomnia-pod`
|
||||
/// binary via `--overlay`. `PodManifestConfig`'s `Serialize` impl is
|
||||
/// the single source of truth for the on-disk manifest format.
|
||||
/// Serialise the internal manifest config that gets handed to the child
|
||||
/// `insomnia-pod` binary via the hidden `--spawn-config-json` flag.
|
||||
/// `PodManifestConfig`'s `Serialize` impl is the single source of truth for the
|
||||
/// internal handoff shape.
|
||||
///
|
||||
/// The child's working directory is set separately via
|
||||
/// `Command::current_dir` (see [`SpawnPodTool::exec_child`]) — it is
|
||||
/// not part of the manifest.
|
||||
fn build_overlay_toml(
|
||||
fn build_spawn_config_json(
|
||||
name: &str,
|
||||
instruction: &str,
|
||||
scope_allow: &[ScopeRule],
|
||||
model: &ModelManifest,
|
||||
) -> Result<String, toml::ser::Error> {
|
||||
let overlay = PodManifestConfig {
|
||||
) -> Result<String, serde_json::Error> {
|
||||
let config = PodManifestConfig {
|
||||
pod: PodMetaConfig {
|
||||
name: Some(name.to_string()),
|
||||
prompt_pack: None,
|
||||
|
|
@ -411,7 +412,7 @@ fn build_overlay_toml(
|
|||
},
|
||||
..Default::default()
|
||||
};
|
||||
toml::to_string(&overlay)
|
||||
serde_json::to_string(&config)
|
||||
}
|
||||
|
||||
/// Tail of the spawned child's `stderr.log` to splice into a startup
|
||||
|
|
@ -524,7 +525,7 @@ mod tests {
|
|||
use manifest::{AuthRef, SchemeKind};
|
||||
|
||||
#[test]
|
||||
fn overlay_inherits_inline_spawner_model() {
|
||||
fn spawn_config_inherits_inline_spawner_model() {
|
||||
let model = ModelManifest {
|
||||
scheme: Some(SchemeKind::Anthropic),
|
||||
base_url: Some("https://example.test".into()),
|
||||
|
|
@ -536,8 +537,9 @@ mod tests {
|
|||
..Default::default()
|
||||
};
|
||||
|
||||
let toml_str = build_overlay_toml("child", "$insomnia/default", &[], &model).unwrap();
|
||||
let parsed = PodManifestConfig::from_toml(&toml_str).unwrap();
|
||||
let config_json =
|
||||
build_spawn_config_json("child", "$insomnia/default", &[], &model).unwrap();
|
||||
let parsed: PodManifestConfig = serde_json::from_str(&config_json).unwrap();
|
||||
|
||||
assert_eq!(parsed.model.scheme, Some(SchemeKind::Anthropic));
|
||||
assert_eq!(parsed.model.model_id.as_deref(), Some("claude-sonnet-4"));
|
||||
|
|
@ -553,13 +555,14 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_inherits_ref_spawner_model() {
|
||||
fn spawn_config_inherits_ref_spawner_model() {
|
||||
let model = ModelManifest {
|
||||
ref_: Some("anthropic/claude-sonnet-4-6".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let toml_str = build_overlay_toml("child", "$insomnia/default", &[], &model).unwrap();
|
||||
let parsed = PodManifestConfig::from_toml(&toml_str).unwrap();
|
||||
let config_json =
|
||||
build_spawn_config_json("child", "$insomnia/default", &[], &model).unwrap();
|
||||
let parsed: PodManifestConfig = serde_json::from_str(&config_json).unwrap();
|
||||
assert_eq!(
|
||||
parsed.model.ref_.as_deref(),
|
||||
Some("anthropic/claude-sonnet-4-6")
|
||||
|
|
|
|||
|
|
@ -1100,45 +1100,128 @@ async fn socket_pod_event_turn_ended_while_idle_auto_starts_turn() {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn socket_invalid_method_returns_error() {
|
||||
async fn socket_error_after_method_line(
|
||||
handle: &PodHandle,
|
||||
line: &[u8],
|
||||
) -> (pod::ErrorCode, String) {
|
||||
use protocol::stream::JsonLineReader;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::net::UnixStream;
|
||||
|
||||
let sock_path = handle.runtime_dir.socket_path();
|
||||
let stream = UnixStream::connect(&sock_path).await.unwrap();
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
let mut reader = JsonLineReader::new(reader);
|
||||
|
||||
writer.write_all(line).await.unwrap();
|
||||
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1);
|
||||
loop {
|
||||
tokio::select! {
|
||||
event = reader.next::<Event>() => {
|
||||
match event {
|
||||
Ok(Some(Event::Error { code, message })) => return (code, message),
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => panic!("socket closed before invalid-method error"),
|
||||
Err(e) => panic!("socket read failed before invalid-method error: {e}"),
|
||||
}
|
||||
}
|
||||
_ = tokio::time::sleep_until(deadline) => {
|
||||
panic!("timed out waiting for invalid-method error")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn socket_schema_invalid_method_returns_error() {
|
||||
let client = MockClient::new(simple_text_events());
|
||||
let pod = make_pod(client).await;
|
||||
let handle = spawn_controller(pod).await;
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
|
||||
let (code, message) = socket_error_after_method_line(&handle, b"{\"bad\":\"json\"}\n").await;
|
||||
|
||||
assert_eq!(code, pod::ErrorCode::InvalidRequest);
|
||||
assert!(
|
||||
message.contains("invalid method"),
|
||||
"expected invalid-method diagnostic, got: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn socket_malformed_method_returns_error() {
|
||||
let client = MockClient::new(simple_text_events());
|
||||
let pod = make_pod(client).await;
|
||||
let handle = spawn_controller(pod).await;
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
|
||||
let (code, message) = socket_error_after_method_line(&handle, b"{not-json}\n").await;
|
||||
|
||||
assert_eq!(code, pod::ErrorCode::InvalidRequest);
|
||||
assert!(
|
||||
message.contains("invalid method"),
|
||||
"expected invalid-method diagnostic, got: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn socket_peer_close_without_method_does_not_broadcast_error() {
|
||||
use protocol::stream::JsonLineReader;
|
||||
use tokio::net::UnixStream;
|
||||
|
||||
let client = MockClient::new(simple_text_events());
|
||||
let pod = make_pod(client).await;
|
||||
let handle = spawn_controller(pod).await;
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
|
||||
let mut broadcast_rx = handle.subscribe();
|
||||
let sock_path = handle.runtime_dir.socket_path();
|
||||
let stream = UnixStream::connect(&sock_path).await.unwrap();
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
let (reader, writer) = stream.into_split();
|
||||
let mut reader = JsonLineReader::new(reader);
|
||||
|
||||
// Send garbage
|
||||
writer.write_all(b"{\"bad\":\"json\"}\n").await.unwrap();
|
||||
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1);
|
||||
let mut saw_error = false;
|
||||
loop {
|
||||
tokio::select! {
|
||||
event = reader.next::<Event>() => {
|
||||
match event {
|
||||
Ok(Some(Event::Error { .. })) => {
|
||||
saw_error = true;
|
||||
break;
|
||||
Ok(Some(Event::Snapshot { .. })) => break,
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => panic!("socket closed before connect-time snapshot"),
|
||||
Err(e) => panic!("socket read failed before connect-time snapshot: {e}"),
|
||||
}
|
||||
}
|
||||
_ = tokio::time::sleep_until(deadline) => {
|
||||
panic!("timed out waiting for connect-time snapshot")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drop(writer);
|
||||
drop(reader);
|
||||
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(200);
|
||||
loop {
|
||||
tokio::select! {
|
||||
event = broadcast_rx.recv() => {
|
||||
match event {
|
||||
Ok(Event::Error { code, message }) => {
|
||||
panic!("peer close without Method broadcast error {code:?}: {message}")
|
||||
}
|
||||
Ok(None) | Err(_) => break,
|
||||
_ => {}
|
||||
Ok(_) => {}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
panic!("broadcast receiver lagged while checking peer close: {n}")
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
_ = tokio::time::sleep_until(deadline) => break,
|
||||
}
|
||||
}
|
||||
|
||||
assert!(saw_error, "should see error for invalid method");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -90,6 +90,9 @@ fn resolve_auth(scheme: SchemeKind, auth: &AuthRef) -> Result<ResolvedAuth, Prov
|
|||
.map_err(|e| ProviderError::Config(e.to_string()))?;
|
||||
Ok(ResolvedAuth::Custom(Arc::new(provider)))
|
||||
}
|
||||
AuthRef::SecretRef { ref_ } => Err(ProviderError::Config(format!(
|
||||
"secret store references are not implemented yet: {ref_}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ pub struct PodMetadata {
|
|||
pub active: Option<PodActiveSegmentRef>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub spawned_children: Vec<PodSpawnedChild>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub resolved_manifest_snapshot: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl PodMetadata {
|
||||
|
|
@ -76,6 +78,7 @@ impl PodMetadata {
|
|||
pod_name: pod_name.into(),
|
||||
active,
|
||||
spawned_children: Vec::new(),
|
||||
resolved_manifest_snapshot: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -117,3 +120,31 @@ pub(crate) fn validate_pod_name(pod_name: &str) -> Result<(), StoreError> {
|
|||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pod_metadata_manifest_snapshot_roundtrips() {
|
||||
let mut metadata = PodMetadata::new(
|
||||
"profile-pod",
|
||||
Some(PodActiveSegmentRef::pending_segment(crate::new_session_id())),
|
||||
);
|
||||
metadata.resolved_manifest_snapshot = Some(serde_json::json!({
|
||||
"pod": { "name": "profile-pod" },
|
||||
"profile": {
|
||||
"source": { "kind": "path", "path": "/profiles/coder.nix" }
|
||||
}
|
||||
}));
|
||||
|
||||
let json = serde_json::to_string(&metadata).unwrap();
|
||||
let restored: PodMetadata = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(restored, metadata);
|
||||
assert_eq!(
|
||||
restored.resolved_manifest_snapshot.as_ref().unwrap()["profile"]["source"]["kind"],
|
||||
"path"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,9 @@ fn resolve_socket(pod_name: &str, override_path: Option<PathBuf>) -> PathBuf {
|
|||
|
||||
#[derive(Debug)]
|
||||
enum Mode {
|
||||
Spawn,
|
||||
Spawn {
|
||||
profile: Option<String>,
|
||||
},
|
||||
/// `insomnia <name>` / `insomnia --pod <name>`: attach to a live Pod by name if
|
||||
/// possible; otherwise launch `insomnia-pod --pod <name>` so the pod process
|
||||
/// resumes from name-keyed state or creates a fresh same-name Pod.
|
||||
|
|
@ -111,6 +113,7 @@ where
|
|||
let mut multi = false;
|
||||
let mut session: Option<SegmentId> = None;
|
||||
let mut pod: Option<String> = None;
|
||||
let mut profile: Option<String> = None;
|
||||
let mut socket_override: Option<PathBuf> = None;
|
||||
let mut socket_seen = false;
|
||||
let mut positional: Option<String> = None;
|
||||
|
|
@ -141,6 +144,13 @@ where
|
|||
pod = Some(raw.clone());
|
||||
i += 2;
|
||||
}
|
||||
"--profile" => {
|
||||
let raw = args
|
||||
.get(i + 1)
|
||||
.ok_or(ParseError::MissingValue("--profile"))?;
|
||||
profile = Some(raw.clone());
|
||||
i += 2;
|
||||
}
|
||||
"--socket" => {
|
||||
socket_seen = true;
|
||||
let raw = args
|
||||
|
|
@ -187,6 +197,11 @@ where
|
|||
"--multi and --socket are mutually exclusive",
|
||||
));
|
||||
}
|
||||
if profile.is_some() {
|
||||
return Err(ParseError::Conflict(
|
||||
"--multi and --profile are mutually exclusive",
|
||||
));
|
||||
}
|
||||
return Ok(Mode::Multi);
|
||||
}
|
||||
|
||||
|
|
@ -205,6 +220,13 @@ where
|
|||
"--pod and --resume are mutually exclusive",
|
||||
));
|
||||
}
|
||||
if profile.is_some()
|
||||
&& (resume || session.is_some() || pod.is_some() || positional.is_some() || socket_seen)
|
||||
{
|
||||
return Err(ParseError::Conflict(
|
||||
"--profile can only be used for fresh spawn",
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(pod_name) = pod {
|
||||
return Ok(Mode::PodName {
|
||||
|
|
@ -224,7 +246,7 @@ where
|
|||
socket_override,
|
||||
});
|
||||
}
|
||||
Ok(Mode::Spawn)
|
||||
Ok(Mode::Spawn { profile })
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
|
|
@ -248,13 +270,13 @@ async fn main() -> ExitCode {
|
|||
}
|
||||
|
||||
let result = match mode {
|
||||
Mode::Spawn => run_spawn(None).await,
|
||||
Mode::Spawn { profile } => run_spawn(None, profile).await,
|
||||
Mode::PodName {
|
||||
pod_name,
|
||||
socket_override,
|
||||
} => run_pod_name(pod_name, socket_override).await,
|
||||
Mode::Resume => run_resume().await,
|
||||
Mode::ResumeWithSession(id) => run_spawn(Some(id)).await,
|
||||
Mode::ResumeWithSession(id) => run_spawn(Some(id), None).await,
|
||||
Mode::Multi => run_multi().await,
|
||||
};
|
||||
|
||||
|
|
@ -449,8 +471,11 @@ fn is_recoverable_multi_open_error(error: &(dyn std::error::Error + 'static)) ->
|
|||
error.is::<spawn::SpawnError>() || error.is::<NestedOpenCancelled>()
|
||||
}
|
||||
|
||||
async fn run_spawn(resume_from: Option<SegmentId>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let ready = match spawn::run(resume_from).await? {
|
||||
async fn run_spawn(
|
||||
resume_from: Option<SegmentId>,
|
||||
profile: Option<String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let ready = match spawn::run(resume_from, profile).await? {
|
||||
SpawnOutcome::Ready(r) => r,
|
||||
SpawnOutcome::Cancelled => return Ok(()),
|
||||
};
|
||||
|
|
@ -1154,6 +1179,62 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_profile_spawn_mode() {
|
||||
match parse_args_from(["--profile", "/profiles/coder.nix"]).unwrap() {
|
||||
Mode::Spawn { profile } => {
|
||||
assert_eq!(profile, Some("/profiles/coder.nix".to_string()));
|
||||
}
|
||||
_ => panic!("expected Spawn mode"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_profile_rejects_resume_attach_modes() {
|
||||
let segment_id = session_store::new_segment_id().to_string();
|
||||
let cases = [
|
||||
(
|
||||
vec![
|
||||
"--profile".to_string(),
|
||||
"p.nix".to_string(),
|
||||
"--resume".to_string(),
|
||||
],
|
||||
"--profile can only be used for fresh spawn",
|
||||
),
|
||||
(
|
||||
vec![
|
||||
"--profile".to_string(),
|
||||
"p.nix".to_string(),
|
||||
"--session".to_string(),
|
||||
segment_id,
|
||||
],
|
||||
"--profile can only be used for fresh spawn",
|
||||
),
|
||||
(
|
||||
vec![
|
||||
"--profile".to_string(),
|
||||
"p.nix".to_string(),
|
||||
"--socket".to_string(),
|
||||
"/tmp/insomnia/sock".to_string(),
|
||||
],
|
||||
"--profile can only be used for fresh spawn",
|
||||
),
|
||||
(
|
||||
vec![
|
||||
"--profile".to_string(),
|
||||
"p.nix".to_string(),
|
||||
"agent".to_string(),
|
||||
],
|
||||
"--profile can only be used for fresh spawn",
|
||||
),
|
||||
];
|
||||
|
||||
for (args, message) in cases {
|
||||
let err = parse_args_from(args).unwrap_err();
|
||||
assert_eq!(err.to_string(), message);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_multi_mode() {
|
||||
match parse_args_from(["--multi"]).unwrap() {
|
||||
|
|
|
|||
|
|
@ -1,28 +1,23 @@
|
|||
//! Inline-viewport "spawn Pod and attach" UX.
|
||||
//!
|
||||
//! Rendered at the user's current cursor position when `insomnia` is invoked
|
||||
//! with no positional argument. Walks the cwd for a `.insomnia/manifest.toml`
|
||||
//! to seed defaults, prompts for the Pod's name, and on confirmation
|
||||
//! launches the `insomnia-pod` binary as an independent process with a freshly built
|
||||
//! overlay (name + cwd scope when no project manifest exists). Once
|
||||
//! the process reports its socket via the `INSOMNIA-READY` stderr line,
|
||||
//! the dialog hands control back so main can switch the terminal to
|
||||
//! alternate-screen mode.
|
||||
//! with no positional argument. Discovers `.insomnia/profiles.toml` profile
|
||||
//! choices plus bundled profiles, defaults to the builtin profile, prompts for
|
||||
//! the Pod's name, and on confirmation launches the `insomnia-pod` binary as an
|
||||
//! independent process. Once the process reports its socket via the
|
||||
//! `INSOMNIA-READY` stderr line, the dialog hands control back so main can
|
||||
//! switch the terminal to alternate-screen mode.
|
||||
//!
|
||||
//! The viewport's last frame stays in the terminal's scrollback so the
|
||||
//! user has a record of what was spawned (or why a spawn failed).
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use client::{SpawnConfig, spawn_pod};
|
||||
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use manifest::{
|
||||
PodManifestConfig, ScopeConfig, find_project_manifest_from, load_layer, user_manifest_path,
|
||||
user_manifest_path_from_env,
|
||||
};
|
||||
use manifest::{ProfileDiscovery, ScopeConfig};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::layout::{Constraint, Layout};
|
||||
|
|
@ -91,12 +86,24 @@ type InlineTerminal = Terminal<CrosstermBackend<io::Stdout>>;
|
|||
/// Source session for a resume run. `None` = fresh spawn (current
|
||||
/// behaviour); `Some(id)` swaps the dialog into "Resume Pod" mode and
|
||||
/// passes `--session <id>` to the spawned `insomnia-pod` child.
|
||||
pub async fn run(resume_from: Option<SegmentId>) -> Result<SpawnOutcome, SpawnError> {
|
||||
pub async fn run(
|
||||
resume_from: Option<SegmentId>,
|
||||
profile: Option<String>,
|
||||
) -> Result<SpawnOutcome, SpawnError> {
|
||||
let defaults = load_spawn_defaults()?;
|
||||
let mut profile_choices = if resume_from.is_some() {
|
||||
Vec::new()
|
||||
} else {
|
||||
defaults.profile_choices
|
||||
};
|
||||
let profile_index = initial_profile_index(
|
||||
&mut profile_choices,
|
||||
profile.as_deref(),
|
||||
defaults.default_profile_index,
|
||||
);
|
||||
|
||||
let mut form = Form {
|
||||
cwd: defaults.cwd.clone(),
|
||||
cascade_has_scope: defaults.cascade_has_scope,
|
||||
scope_origin: defaults.scope_origin,
|
||||
name_cursor: defaults.default_name.chars().count(),
|
||||
name: defaults.default_name,
|
||||
|
|
@ -105,6 +112,8 @@ pub async fn run(resume_from: Option<SegmentId>) -> Result<SpawnOutcome, SpawnEr
|
|||
resume_from,
|
||||
resume_by_pod_name: false,
|
||||
resume_scope: None,
|
||||
profile_choices,
|
||||
profile_index,
|
||||
};
|
||||
|
||||
let mut terminal = make_inline_terminal()?;
|
||||
|
|
@ -135,13 +144,14 @@ pub async fn run(resume_from: Option<SegmentId>) -> Result<SpawnOutcome, SpawnEr
|
|||
Some(Action::Right) => form.move_right(),
|
||||
Some(Action::Home) => form.name_cursor = 0,
|
||||
Some(Action::End) => form.name_cursor = form.name.chars().count(),
|
||||
Some(Action::ProfileNext) => form.cycle_profile_next(),
|
||||
Some(Action::ProfilePrev) => form.cycle_profile_prev(),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(id) = form.resume_from {
|
||||
form.resume_scope = Some(load_resume_scope(id).await?);
|
||||
}
|
||||
let overlay_toml = build_overlay_toml(&form);
|
||||
|
||||
// Phase 2: launch pod and wait for ready line. Drop the cursor
|
||||
// out of the name field — subsequent frames are passive status
|
||||
|
|
@ -151,7 +161,7 @@ pub async fn run(resume_from: Option<SegmentId>) -> Result<SpawnOutcome, SpawnEr
|
|||
form.message = Some(("starting pod...".to_string(), MessageKind::Progress));
|
||||
terminal.draw(|f| draw_form(f, &form))?;
|
||||
|
||||
match wait_for_ready(&mut terminal, &mut form, &overlay_toml).await {
|
||||
match wait_for_ready(&mut terminal, &mut form).await {
|
||||
Ok(ready) => {
|
||||
form.message = Some((
|
||||
format!("ready: {} attaching...", ready.pod_name),
|
||||
|
|
@ -172,15 +182,14 @@ pub async fn run(resume_from: Option<SegmentId>) -> Result<SpawnOutcome, SpawnEr
|
|||
|
||||
/// Launch `insomnia-pod --pod <name>` without opening the name dialog. The child Pod
|
||||
/// resolves persisted Pod metadata if present, or creates a fresh same-name Pod
|
||||
/// with the usual TUI cwd-scope fallback.
|
||||
/// from the default profile.
|
||||
pub async fn run_pod_name(pod_name: String) -> Result<SpawnOutcome, SpawnError> {
|
||||
let defaults = load_spawn_defaults()?;
|
||||
let mut form = form_for_pod_name(pod_name, defaults);
|
||||
let overlay_toml = build_overlay_toml(&form);
|
||||
let mut terminal = make_inline_terminal()?;
|
||||
terminal.draw(|f| draw_form(f, &form))?;
|
||||
|
||||
match wait_for_ready(&mut terminal, &mut form, &overlay_toml).await {
|
||||
match wait_for_ready(&mut terminal, &mut form).await {
|
||||
Ok(ready) => {
|
||||
form.message = Some((
|
||||
format!("ready: {} attaching...", ready.pod_name),
|
||||
|
|
@ -201,50 +210,22 @@ pub async fn run_pod_name(pod_name: String) -> Result<SpawnOutcome, SpawnError>
|
|||
|
||||
struct SpawnDefaults {
|
||||
cwd: PathBuf,
|
||||
cascade_has_scope: bool,
|
||||
scope_origin: ScopeOrigin,
|
||||
default_name: String,
|
||||
default_profile_index: usize,
|
||||
profile_choices: Vec<ProfileChoice>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ProfileChoice {
|
||||
selector: Option<String>,
|
||||
label: String,
|
||||
is_default: bool,
|
||||
}
|
||||
|
||||
fn load_spawn_defaults() -> Result<SpawnDefaults, SpawnError> {
|
||||
let cwd = std::env::current_dir().map_err(SpawnError::Io)?;
|
||||
|
||||
// Run the same merge pod itself uses, then read what's missing off the
|
||||
// result. We only look at `scope.allow` here — `pod.name` is an
|
||||
// instance-level identifier and is supplied by the dialog or `--pod`.
|
||||
// TUI must pre-read the same user manifest path that the pod CLI will use,
|
||||
// including a non-empty INSOMNIA_USER_MANIFEST override; empty values fall
|
||||
// back to the auto-discovered path.
|
||||
let user_layer = user_manifest_path_for_spawn(
|
||||
std::env::var_os(manifest::paths::USER_MANIFEST_ENV),
|
||||
user_manifest_path(),
|
||||
)
|
||||
.filter(|p| p.is_file())
|
||||
.and_then(|p| load_layer(&p).ok());
|
||||
let project_layer = find_project_manifest_from(&cwd).and_then(|p| load_layer(&p).ok());
|
||||
|
||||
let mut cascade = PodManifestConfig::builtin_defaults();
|
||||
for layer in [user_layer.as_ref(), project_layer.as_ref()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
cascade = cascade.merge(layer.clone());
|
||||
}
|
||||
let cascade_has_scope = !cascade.scope.allow.is_empty();
|
||||
|
||||
let scope_origin = match (
|
||||
project_layer
|
||||
.as_ref()
|
||||
.is_some_and(|l| !l.scope.allow.is_empty()),
|
||||
user_layer
|
||||
.as_ref()
|
||||
.is_some_and(|l| !l.scope.allow.is_empty()),
|
||||
) {
|
||||
(true, _) => ScopeOrigin::FromProject,
|
||||
(false, true) => ScopeOrigin::FromUser,
|
||||
(false, false) => ScopeOrigin::CwdDefault,
|
||||
};
|
||||
|
||||
let default_name = cwd
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
|
|
@ -252,25 +233,71 @@ fn load_spawn_defaults() -> Result<SpawnDefaults, SpawnError> {
|
|||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "pod".to_string());
|
||||
|
||||
let (profile_choices, default_profile_index) = profile_choices_for_cwd(&cwd);
|
||||
|
||||
Ok(SpawnDefaults {
|
||||
cwd,
|
||||
cascade_has_scope,
|
||||
scope_origin,
|
||||
scope_origin: ScopeOrigin::FromProfile,
|
||||
default_name,
|
||||
default_profile_index,
|
||||
profile_choices,
|
||||
})
|
||||
}
|
||||
|
||||
fn user_manifest_path_for_spawn(
|
||||
env_value: Option<OsString>,
|
||||
default_user_manifest: Option<PathBuf>,
|
||||
) -> Option<PathBuf> {
|
||||
user_manifest_path_from_env(env_value).or(default_user_manifest)
|
||||
fn profile_choices_for_cwd(cwd: &Path) -> (Vec<ProfileChoice>, usize) {
|
||||
let Ok(registry) = ProfileDiscovery::for_cwd(cwd).discover() else {
|
||||
return (Vec::new(), 0);
|
||||
};
|
||||
|
||||
let mut choices = Vec::new();
|
||||
for entry in registry.entries() {
|
||||
let mut label = entry.qualified_name();
|
||||
if entry.is_default {
|
||||
label.push_str(" (default)");
|
||||
}
|
||||
if let Some(description) = entry.description.as_deref() {
|
||||
label.push_str(" — ");
|
||||
label.push_str(description);
|
||||
}
|
||||
choices.push(ProfileChoice {
|
||||
selector: Some(entry.qualified_name()),
|
||||
label,
|
||||
is_default: entry.is_default,
|
||||
});
|
||||
}
|
||||
|
||||
let default_index = choices
|
||||
.iter()
|
||||
.position(|choice| choice.is_default)
|
||||
.unwrap_or(0);
|
||||
(choices, default_index)
|
||||
}
|
||||
|
||||
fn initial_profile_index(
|
||||
choices: &mut Vec<ProfileChoice>,
|
||||
explicit_profile: Option<&str>,
|
||||
default_index: usize,
|
||||
) -> usize {
|
||||
let Some(selector) = explicit_profile else {
|
||||
return default_index.min(choices.len().saturating_sub(1));
|
||||
};
|
||||
if let Some(index) = choices
|
||||
.iter()
|
||||
.position(|choice| choice.selector.as_deref() == Some(selector))
|
||||
{
|
||||
return index;
|
||||
}
|
||||
choices.push(ProfileChoice {
|
||||
selector: Some(selector.to_string()),
|
||||
label: selector.to_string(),
|
||||
is_default: false,
|
||||
});
|
||||
choices.len() - 1
|
||||
}
|
||||
|
||||
fn form_for_pod_name(pod_name: String, defaults: SpawnDefaults) -> Form {
|
||||
Form {
|
||||
cwd: defaults.cwd,
|
||||
cascade_has_scope: defaults.cascade_has_scope,
|
||||
scope_origin: defaults.scope_origin,
|
||||
name_cursor: pod_name.chars().count(),
|
||||
name: pod_name,
|
||||
|
|
@ -279,6 +306,8 @@ fn form_for_pod_name(pod_name: String, defaults: SpawnDefaults) -> Form {
|
|||
resume_from: None,
|
||||
resume_by_pod_name: true,
|
||||
resume_scope: None,
|
||||
profile_choices: Vec::new(),
|
||||
profile_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -302,6 +331,8 @@ enum Action {
|
|||
Right,
|
||||
Home,
|
||||
End,
|
||||
ProfileNext,
|
||||
ProfilePrev,
|
||||
}
|
||||
|
||||
fn poll_event() -> io::Result<Option<Action>> {
|
||||
|
|
@ -322,6 +353,8 @@ fn poll_event() -> io::Result<Option<Action>> {
|
|||
KeyCode::Delete => Some(Action::Delete),
|
||||
KeyCode::Left => Some(Action::Left),
|
||||
KeyCode::Right => Some(Action::Right),
|
||||
KeyCode::Up | KeyCode::BackTab => Some(Action::ProfilePrev),
|
||||
KeyCode::Down | KeyCode::Tab => Some(Action::ProfileNext),
|
||||
KeyCode::Home => Some(Action::Home),
|
||||
KeyCode::End => Some(Action::End),
|
||||
KeyCode::Char(c) if !ctrl && is_safe_name_char(c) => Some(Action::Char(c)),
|
||||
|
|
@ -346,14 +379,12 @@ fn sanitise_default_name(s: &str) -> String {
|
|||
async fn wait_for_ready(
|
||||
terminal: &mut InlineTerminal,
|
||||
form: &mut Form,
|
||||
overlay_toml: &str,
|
||||
) -> Result<SpawnReady, SpawnError> {
|
||||
let cwd = std::env::current_dir().map_err(SpawnError::Io)?;
|
||||
|
||||
let config = SpawnConfig {
|
||||
pod_name: form.name.clone(),
|
||||
overlay_toml: overlay_toml.to_string(),
|
||||
cwd,
|
||||
profile: form.selected_profile_selector(),
|
||||
resume_scope: form.resume_scope.clone(),
|
||||
cwd: form.cwd.clone(),
|
||||
resume_from: form.resume_from,
|
||||
resume_by_pod_name: form.resume_by_pod_name,
|
||||
};
|
||||
|
|
@ -368,36 +399,6 @@ async fn wait_for_ready(
|
|||
})
|
||||
}
|
||||
|
||||
fn build_overlay_toml(form: &Form) -> String {
|
||||
let mut root = toml::value::Table::new();
|
||||
|
||||
let mut pod = toml::value::Table::new();
|
||||
pod.insert("name".into(), toml::Value::String(form.name.clone()));
|
||||
root.insert("pod".into(), toml::Value::Table(pod));
|
||||
|
||||
if let Some(scope_config) = form.resume_scope.as_ref() {
|
||||
root.insert(
|
||||
"scope".into(),
|
||||
toml::Value::try_from(scope_config).expect("scope serialisation cannot fail"),
|
||||
);
|
||||
} else if !form.cascade_has_scope {
|
||||
let mut rule = toml::value::Table::new();
|
||||
rule.insert(
|
||||
"target".into(),
|
||||
toml::Value::String(form.cwd.display().to_string()),
|
||||
);
|
||||
rule.insert("permission".into(), toml::Value::String("write".into()));
|
||||
let mut scope = toml::value::Table::new();
|
||||
scope.insert(
|
||||
"allow".into(),
|
||||
toml::Value::Array(vec![toml::Value::Table(rule)]),
|
||||
);
|
||||
root.insert("scope".into(), toml::Value::Table(scope));
|
||||
}
|
||||
|
||||
toml::to_string(&toml::Value::Table(root)).expect("overlay serialisation cannot fail")
|
||||
}
|
||||
|
||||
async fn load_resume_scope(segment_id: SegmentId) -> Result<ScopeConfig, SpawnError> {
|
||||
let store_dir = manifest::paths::sessions_dir().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
|
|
@ -425,17 +426,11 @@ enum MessageKind {
|
|||
}
|
||||
|
||||
enum ScopeOrigin {
|
||||
FromUser,
|
||||
FromProject,
|
||||
CwdDefault,
|
||||
FromProfile,
|
||||
}
|
||||
|
||||
struct Form {
|
||||
cwd: PathBuf,
|
||||
/// True when at least one cascade layer (user or project manifest)
|
||||
/// already declares `scope.allow`. Drives whether the overlay
|
||||
/// should add a cwd-write rule.
|
||||
cascade_has_scope: bool,
|
||||
/// Display label for the scope row in the dialog.
|
||||
scope_origin: ScopeOrigin,
|
||||
name: String,
|
||||
|
|
@ -459,9 +454,14 @@ struct Form {
|
|||
/// resolves name-keyed state before falling back to fresh creation.
|
||||
resume_by_pod_name: bool,
|
||||
/// Scope snapshot recovered from the source session log. Set only for
|
||||
/// resume runs, and serialized into the overlay instead of cwd-default
|
||||
/// scope so resume does not silently broaden access.
|
||||
/// resume runs and passed through a typed internal restore flag so resume
|
||||
/// does not silently broaden access.
|
||||
resume_scope: Option<ScopeConfig>,
|
||||
/// Optional Nix profile choices passed to `insomnia-pod --profile` for
|
||||
/// fresh spawns. This is not used for resume/attach flows because those must
|
||||
/// restore Pod state rather than re-evaluate a profile source.
|
||||
profile_choices: Vec<ProfileChoice>,
|
||||
profile_index: usize,
|
||||
}
|
||||
|
||||
impl Form {
|
||||
|
|
@ -504,6 +504,37 @@ impl Form {
|
|||
}
|
||||
}
|
||||
|
||||
fn selected_profile(&self) -> Option<&ProfileChoice> {
|
||||
self.profile_choices
|
||||
.get(self.profile_index)
|
||||
.filter(|choice| choice.selector.is_some())
|
||||
}
|
||||
|
||||
fn selected_profile_selector(&self) -> Option<String> {
|
||||
self.selected_profile()
|
||||
.and_then(|choice| choice.selector.clone())
|
||||
}
|
||||
|
||||
fn cycle_profile_next(&mut self) {
|
||||
if self.profile_choices.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.profile_index = (self.profile_index + 1) % self.profile_choices.len();
|
||||
self.message = None;
|
||||
}
|
||||
|
||||
fn cycle_profile_prev(&mut self) {
|
||||
if self.profile_choices.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.profile_index = if self.profile_index == 0 {
|
||||
self.profile_choices.len() - 1
|
||||
} else {
|
||||
self.profile_index - 1
|
||||
};
|
||||
self.message = None;
|
||||
}
|
||||
|
||||
fn char_offset_to_byte(&self, char_off: usize) -> usize {
|
||||
self.name
|
||||
.char_indices()
|
||||
|
|
@ -518,7 +549,7 @@ fn draw_form(f: &mut Frame<'_>, form: &Form) {
|
|||
let layout = Layout::vertical([
|
||||
Constraint::Length(1), // title
|
||||
Constraint::Length(1), // name field
|
||||
Constraint::Length(1), // context (manifest or scope default)
|
||||
Constraint::Length(1), // context (profile or scope default)
|
||||
Constraint::Length(1), // hint
|
||||
Constraint::Length(1), // message
|
||||
Constraint::Length(1), // spacer
|
||||
|
|
@ -573,37 +604,43 @@ fn name_line(form: &Form) -> Line<'_> {
|
|||
}
|
||||
|
||||
fn context_line(form: &Form) -> Line<'_> {
|
||||
match form.scope_origin {
|
||||
ScopeOrigin::FromProject => Line::from(vec![
|
||||
if let Some(profile) = form.profile_choices.get(form.profile_index) {
|
||||
return Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled("scope: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled("from project manifest", Style::default().fg(Color::Green)),
|
||||
]),
|
||||
ScopeOrigin::FromUser => Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled("scope: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled("from user manifest", Style::default().fg(Color::Green)),
|
||||
]),
|
||||
ScopeOrigin::CwdDefault => Line::from(vec![
|
||||
Span::styled("profile: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(profile.label.as_str(), Style::default().fg(Color::Green)),
|
||||
Span::styled(
|
||||
" (tab/down to change)",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
if form.resume_scope.is_some() {
|
||||
return Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled("scope: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(
|
||||
form.cwd.display().to_string(),
|
||||
Style::default().fg(Color::Yellow),
|
||||
"from restored session snapshot",
|
||||
Style::default().fg(Color::Green),
|
||||
),
|
||||
Span::styled(" (write, default)", Style::default().fg(Color::DarkGray)),
|
||||
]);
|
||||
}
|
||||
|
||||
match form.scope_origin {
|
||||
ScopeOrigin::FromProfile => Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled("scope: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled("from selected profile", Style::default().fg(Color::Green)),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
fn hint_line() -> Line<'static> {
|
||||
Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled("[enter]", Style::default().fg(Color::Green)),
|
||||
Span::raw(" spawn "),
|
||||
Span::styled("[esc]", Style::default().fg(Color::Yellow)),
|
||||
Span::raw(" cancel"),
|
||||
])
|
||||
Line::from(vec![Span::styled(
|
||||
" enter spawn · tab/down next profile · shift-tab/up prev · esc cancel",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)])
|
||||
}
|
||||
|
||||
fn message_line(form: &Form) -> Line<'_> {
|
||||
|
|
@ -623,15 +660,10 @@ fn message_line(form: &Form) -> Line<'_> {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn form(name: &str, cascade_has_scope: bool) -> Form {
|
||||
fn form(name: &str) -> Form {
|
||||
Form {
|
||||
cwd: PathBuf::from("/work/example"),
|
||||
cascade_has_scope,
|
||||
scope_origin: if cascade_has_scope {
|
||||
ScopeOrigin::FromProject
|
||||
} else {
|
||||
ScopeOrigin::CwdDefault
|
||||
},
|
||||
scope_origin: ScopeOrigin::FromProfile,
|
||||
name: name.to_string(),
|
||||
name_cursor: name.chars().count(),
|
||||
message: None,
|
||||
|
|
@ -639,6 +671,8 @@ mod tests {
|
|||
resume_from: None,
|
||||
resume_by_pod_name: false,
|
||||
resume_scope: None,
|
||||
profile_choices: Vec::new(),
|
||||
profile_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -646,9 +680,10 @@ mod tests {
|
|||
fn pod_name_form_restores_or_creates_by_pod_name() {
|
||||
let defaults = SpawnDefaults {
|
||||
cwd: PathBuf::from("/work/example"),
|
||||
cascade_has_scope: true,
|
||||
scope_origin: ScopeOrigin::FromProject,
|
||||
scope_origin: ScopeOrigin::FromProfile,
|
||||
default_name: "ignored".to_string(),
|
||||
default_profile_index: 0,
|
||||
profile_choices: Vec::new(),
|
||||
};
|
||||
let f = form_for_pod_name("agent".to_string(), defaults);
|
||||
|
||||
|
|
@ -665,29 +700,8 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_adds_scope_default_when_cascade_lacks_scope() {
|
||||
let f = form("agent-1", false);
|
||||
let toml_str = build_overlay_toml(&f);
|
||||
let parsed: toml::Value = toml::from_str(&toml_str).unwrap();
|
||||
assert_eq!(parsed["pod"]["name"].as_str(), Some("agent-1"));
|
||||
let allow = parsed["scope"]["allow"].as_array().unwrap();
|
||||
assert_eq!(allow.len(), 1);
|
||||
assert_eq!(allow[0]["target"].as_str(), Some("/work/example"));
|
||||
assert_eq!(allow[0]["permission"].as_str(), Some("write"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_omits_scope_when_cascade_already_has_one() {
|
||||
let f = form("agent-2", true);
|
||||
let toml_str = build_overlay_toml(&f);
|
||||
let parsed: toml::Value = toml::from_str(&toml_str).unwrap();
|
||||
assert_eq!(parsed["pod"]["name"].as_str(), Some("agent-2"));
|
||||
assert!(parsed.get("scope").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_uses_resume_scope_snapshot() {
|
||||
let mut f = form("agent-r", false);
|
||||
fn resume_scope_snapshot_stays_on_form_for_typed_restore_flag() {
|
||||
let mut f = form("agent-r");
|
||||
f.resume_from = Some(session_store::new_segment_id());
|
||||
f.resume_scope = Some(ScopeConfig {
|
||||
allow: vec![manifest::ScopeRule {
|
||||
|
|
@ -701,57 +715,111 @@ mod tests {
|
|||
recursive: true,
|
||||
}],
|
||||
});
|
||||
let toml_str = build_overlay_toml(&f);
|
||||
let parsed: toml::Value = toml::from_str(&toml_str).unwrap();
|
||||
assert_eq!(parsed["pod"]["name"].as_str(), Some("agent-r"));
|
||||
assert_eq!(parsed["scope"]["allow"].as_array().unwrap().len(), 1);
|
||||
let deny = parsed["scope"]["deny"].as_array().unwrap();
|
||||
assert_eq!(deny[0]["target"].as_str(), Some("/work/example/child"));
|
||||
|
||||
let scope = f.resume_scope.as_ref().unwrap();
|
||||
assert_eq!(scope.allow[0].target, PathBuf::from("/work/example"));
|
||||
assert_eq!(scope.deny[0].target, PathBuf::from("/work/example/child"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_merge_detects_scope_from_any_layer() {
|
||||
let user = PodManifestConfig::from_toml(
|
||||
fn profile_choices_use_project_registry_default() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let project = temp.path().join("project");
|
||||
let insomnia = project.join(".insomnia");
|
||||
std::fs::create_dir_all(&insomnia).unwrap();
|
||||
std::fs::write(
|
||||
insomnia.join("profiles.toml"),
|
||||
r#"
|
||||
[[scope.allow]]
|
||||
target = "/from-user"
|
||||
permission = "write"
|
||||
default = "coder"
|
||||
[profile]
|
||||
coder = "profiles/coder.nix"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut cascade = PodManifestConfig::builtin_defaults();
|
||||
cascade = cascade.merge(user);
|
||||
assert!(!cascade.scope.allow.is_empty());
|
||||
|
||||
let empty_cascade = PodManifestConfig::builtin_defaults();
|
||||
assert!(empty_cascade.scope.allow.is_empty());
|
||||
let (choices, default_index) = profile_choices_for_cwd(&project);
|
||||
assert_eq!(default_index, 1);
|
||||
let selected = &choices[default_index];
|
||||
assert_eq!(selected.selector.as_deref(), Some("project:coder"));
|
||||
assert_eq!(selected.label, "project:coder (default)");
|
||||
assert!(selected.is_default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_manifest_path_for_spawn_prefers_non_empty_env_override() {
|
||||
fn profile_choices_include_builtin_and_project_default_marker() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let project = temp.path().join("project");
|
||||
let insomnia = project.join(".insomnia");
|
||||
std::fs::create_dir_all(&insomnia).unwrap();
|
||||
std::fs::write(
|
||||
insomnia.join("profiles.toml"),
|
||||
r#"
|
||||
default = "coder"
|
||||
[profile.coder]
|
||||
path = "profiles/coder.nix"
|
||||
description = "Project coder"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (choices, default_index) = profile_choices_for_cwd(&project);
|
||||
assert_eq!(choices[0].selector.as_deref(), Some("builtin:default"));
|
||||
assert_eq!(choices[0].label, "builtin:default");
|
||||
assert_eq!(default_index, 1);
|
||||
assert_eq!(choices[1].selector.as_deref(), Some("project:coder"));
|
||||
assert_eq!(choices[1].label, "project:coder (default) — Project coder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_cycle_selects_only_discovered_profiles() {
|
||||
let mut form = form("coder");
|
||||
form.profile_choices = vec![
|
||||
ProfileChoice {
|
||||
selector: Some("project:coder".to_string()),
|
||||
label: "project:coder (default)".to_string(),
|
||||
is_default: true,
|
||||
},
|
||||
ProfileChoice {
|
||||
selector: Some("user:reviewer".to_string()),
|
||||
label: "user:reviewer".to_string(),
|
||||
is_default: false,
|
||||
},
|
||||
];
|
||||
form.profile_index = 0;
|
||||
|
||||
assert_eq!(
|
||||
user_manifest_path_for_spawn(
|
||||
Some(OsString::from("/tmp/override.toml")),
|
||||
Some(PathBuf::from("/default/manifest.toml")),
|
||||
),
|
||||
Some(PathBuf::from("/tmp/override.toml")),
|
||||
form.selected_profile_selector().as_deref(),
|
||||
Some("project:coder")
|
||||
);
|
||||
form.cycle_profile_next();
|
||||
assert_eq!(
|
||||
form.selected_profile_selector().as_deref(),
|
||||
Some("user:reviewer")
|
||||
);
|
||||
form.cycle_profile_next();
|
||||
assert_eq!(
|
||||
form.selected_profile_selector().as_deref(),
|
||||
Some("project:coder")
|
||||
);
|
||||
form.cycle_profile_prev();
|
||||
assert_eq!(
|
||||
form.selected_profile_selector().as_deref(),
|
||||
Some("user:reviewer")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_manifest_path_for_spawn_treats_empty_env_as_unset() {
|
||||
assert_eq!(
|
||||
user_manifest_path_for_spawn(
|
||||
Some(OsString::from("")),
|
||||
Some(PathBuf::from("/default/manifest.toml")),
|
||||
),
|
||||
Some(PathBuf::from("/default/manifest.toml")),
|
||||
);
|
||||
fn initial_profile_index_adds_explicit_selector_not_in_discovery_list() {
|
||||
let mut choices = Vec::new();
|
||||
let selected = initial_profile_index(&mut choices, Some("coder"), 0);
|
||||
assert_eq!(selected, 0);
|
||||
assert_eq!(choices[0].selector.as_deref(), Some("coder"));
|
||||
assert_eq!(choices[0].label, "coder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_input_handles_insert_backspace_and_cursor() {
|
||||
let mut f = form("", false);
|
||||
let mut f = form("");
|
||||
for c in "abc".chars() {
|
||||
f.insert_char(c);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,16 +89,13 @@ permission = "write"
|
|||
|
||||
`[model]` は `ref = "<provider>/<model_id>"` でプロバイダ / モデルカタログを引く短縮形と、`scheme` / `model_id` / `auth` を直書きする inline 形式の両方を受ける。カタログは `resources/{providers,models}/builtin.toml` を builtin、`<config_dir>/{providers,models}.toml` を user override として解決する(`<config_dir>` の解決ルールは `manifest::paths` 参照)。詳細は `docs/pod-factory.md` と `crates/provider/README.md`。
|
||||
|
||||
### PodFactory: カスケード設定
|
||||
### Manifest / profile 入力
|
||||
|
||||
マニフェストを手書きせず、4 層のカスケードで `PodManifest` を組み立てる:
|
||||
通常の Pod 起動は Nix profile discovery/default から `PodManifest` を生成する。bundled `builtin:default` が fallback default で、user/project `profiles.toml` は profile registry と default selection だけを担う。user/project `manifest.toml` の ambient cascade は通常起動では使わない。
|
||||
|
||||
1. **ビルトインデフォルト** — `manifest::defaults` の定数値
|
||||
2. **ユーザー manifest** — `<config_dir>/manifest.toml`(`manifest::paths` で解決)
|
||||
3. **プロジェクト manifest** — `.insomnia/manifest.toml`(cwd から上方向に探索)
|
||||
4. **プログラマティック overlay** — CLI / GUI / spawn 時のインライン指定
|
||||
`insomnia-pod --manifest <PATH>` は explicit one-file compatibility/debug input で、指定 TOML 1 枚だけに builtin defaults を merge し、`PodManifestConfig -> PodManifest` の required validation を通す。
|
||||
|
||||
マージ規則: スカラーは上層が置換、Map はキー単位マージ、`scope.allow` / `scope.deny` は union。全パスは絶対パスのみ。
|
||||
`PodFactory` の user/project/overlay API は低レベル構成部品として残るが、CLI の通常起動 path では generic TOML overlay を公開しない。
|
||||
|
||||
### Instruction とプロンプト資産
|
||||
|
||||
|
|
|
|||
98
docs/manifest-profiles.md
Normal file
98
docs/manifest-profiles.md
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# Manifest profiles
|
||||
|
||||
Manifest profiles are the human-authored Nix entrypoint for generating an Insomnia runtime manifest. The Rust side evaluates a selected profile with `nix eval --json --file <path>`, deserializes the resulting JSON artifact, and validates it through the existing `PodManifest` pipeline.
|
||||
|
||||
This keeps composition/import/common logic in Nix. Insomnia does not add an implicit profile cascade or merge TOML profile layers into the selected runtime manifest.
|
||||
|
||||
## Minimal profile
|
||||
|
||||
```nix
|
||||
let
|
||||
insomnia = import ./resources/nix/profile-lib.nix {};
|
||||
in
|
||||
insomnia.mkProfile {
|
||||
name = "coder";
|
||||
description = "Example coding Pod";
|
||||
manifest = insomnia.mkManifest {
|
||||
pod.name = "coder";
|
||||
model = {
|
||||
scheme = "anthropic";
|
||||
model_id = "claude-sonnet-4-20250514";
|
||||
auth = insomnia.secrets.ref "llm.anthropic.default";
|
||||
};
|
||||
scope.allow = [
|
||||
{ target = "."; permission = "write"; }
|
||||
];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Run an explicit path with:
|
||||
|
||||
```sh
|
||||
insomnia-pod --profile ./coder.nix
|
||||
# or through the TUI fresh-spawn dialog
|
||||
insomnia --profile ./coder.nix
|
||||
```
|
||||
|
||||
`--profile` accepts an explicit path, `path:<path>`, a discovered profile name, `default`, or a source-qualified name such as `project:coder`, `user:coder`, or `builtin:coder`. Path-like values containing `/`, starting with `.`, or ending in `.nix` preserve the original explicit-path behavior.
|
||||
|
||||
`--profile` conflicts with `insomnia-pod --manifest` and with restore/session/adopt modes. Use `--profile-pod-name <name>` when a launcher needs a creation-time Pod name override without invoking `--pod` restore semantics. Profile evaluation is a creation-time path; Pod resume restores saved Pod state/resolved snapshots rather than re-evaluating the Nix source.
|
||||
|
||||
## Profile discovery
|
||||
|
||||
Profile discovery is separate from runtime manifest merging. User/project `profiles.toml` files may declare profile registry metadata, but those files are application/project UX configuration and are not merged into the Nix profile artifact.
|
||||
|
||||
Example project config at `.insomnia/profiles.toml`:
|
||||
|
||||
```toml
|
||||
default = "coder"
|
||||
|
||||
[profile]
|
||||
coder = "profiles/coder.nix"
|
||||
reviewer = "profiles/reviewer.nix"
|
||||
```
|
||||
|
||||
Table entries can carry descriptions:
|
||||
|
||||
```toml
|
||||
[profile.coder]
|
||||
path = "profiles/coder.nix"
|
||||
description = "Project coding assistant"
|
||||
```
|
||||
|
||||
Relative registry paths are resolved against the `profiles.toml` file that declares them. Discovery checks bundled builtin profiles, then the user registry at `<config_dir>/profiles.toml`, then the nearest project registry at `.insomnia/profiles.toml`. The bundled `builtin:default` profile is the fallback default when no user/project registry declares another default. Later defaults override earlier defaults, so a project default wins over a user default, and either wins over the builtin default. Unqualified defaults resolve within the declaring source by default. Unqualified ambiguous names fail closed:
|
||||
|
||||
```sh
|
||||
insomnia --profile coder # fails if both user:coder and project:coder exist
|
||||
insomnia --profile project:coder # source-qualified selection
|
||||
insomnia --profile default # selected registry default
|
||||
```
|
||||
|
||||
The fresh-spawn TUI also uses discovery. The new Pod dialog defaults to the selected registry default, normally `builtin:default` unless a user/project registry overrides it. `Tab`/`Down` cycles forward through discovered profiles and `Shift-Tab`/`Up` cycles backward; there is no ambient manifest-cascade opt-out. Passing `insomnia --profile <selector>` opens the same new Pod dialog with that selector selected and leaves Pod-name editing unchanged.
|
||||
|
||||
## One-file manifests
|
||||
|
||||
`insomnia-pod --manifest <PATH>` remains as an explicit compatibility/debug path. It reads exactly that TOML file, resolves relative paths against the file's parent directory, merges builtin defaults, and validates through the same `PodManifestConfig -> PodManifest` boundary as profile artifacts. It does not load user or project `manifest.toml` files and conflicts with `--profile`.
|
||||
|
||||
Ambient user/project `manifest.toml` cascade startup has been removed. Normal fresh spawns use profile discovery/default selection, with `profiles.toml` acting only as a profile registry/default selector.
|
||||
|
||||
## Artifact contract
|
||||
|
||||
A profile should evaluate to one of:
|
||||
|
||||
- `{ profile = { format = "insomnia.nix-profile.v1"; ... }; manifest = { ... }; }`
|
||||
- `{ profile = { format = "insomnia.nix-profile.v1"; ... }; config = { ... }; }`
|
||||
- a raw manifest/config object for debug/test paths.
|
||||
|
||||
The resolved artifact is deserialized into the same `PodManifestConfig -> PodManifest` boundary used by direct one-file manifests, so builtin defaults and required-field validation stay shared. Explicit profile paths and user/project registry profile artifacts resolve relative manifest paths against the profile file's directory. Builtin profile artifacts resolve manifest-relative paths against the launch workspace/current directory so the bundled default can grant `scope.allow target = "."` for the workspace rather than for `resources/nix/profiles`.
|
||||
|
||||
Profile and one-file manifest CLI paths currently use builtin prompt assets only. `$insomnia/...` instruction refs work; `$user/...` and `$workspace/...` prompt refs need a future explicit prompt-loader source design instead of reviving ambient manifest discovery.
|
||||
|
||||
Secret values must stay as typed references. `resources/nix/profile-lib.nix` emits secret references as JSON like:
|
||||
|
||||
```json
|
||||
{ "kind": "secret_ref", "ref": "llm.anthropic.default" }
|
||||
```
|
||||
|
||||
The encrypted secret store is intentionally not implemented by this profile foundation; attempting to use a `secret_ref` as a live provider credential currently fails with a clear diagnostic at provider construction time.
|
||||
|
|
@ -3,17 +3,17 @@
|
|||
# ============================================================================
|
||||
# Pod の宣言的設定 (`PodManifest` / `PodManifestConfig`)。
|
||||
#
|
||||
# カスケード層は下から順に
|
||||
# 1. builtin defaults (`manifest::defaults`)
|
||||
# 2. user manifest (`<config_dir>/manifest.toml`)
|
||||
# 3. project manifest (cwd から上方向に探す `.insomnia/manifest.toml`)
|
||||
# 4. programmatic overlay (呼び出し側が差し込む)
|
||||
# 上の層が同名フィールドを上書き、scope rule と skills.directories は
|
||||
# 累積マージ、tool_output.per_tool は key 単位でマージ。
|
||||
# このファイル形式は低レベル runtime manifest。通常起動は profile discovery/default
|
||||
# (`profiles.toml` と bundled builtin profile) から manifest を生成する。
|
||||
# `insomnia-pod --manifest <path>` の one-file compatibility/debug mode では、
|
||||
# 指定した TOML 1 枚に builtin defaults を merge し、required validation を行う。
|
||||
# user/project `manifest.toml` を暗黙に merge する通常起動 cascade は使わない。
|
||||
#
|
||||
# パス解決: 相対パスは「その層の manifest ファイルが置かれているディレクトリ」
|
||||
# を base に絶対パスへ解決される (overlay 層は cwd)。マージは絶対化済みの
|
||||
# 値同士で行われる。
|
||||
# `PodManifestConfig` の merge 規則: 上の層が同名フィールドを上書き、scope rule と
|
||||
# skills.directories は累積マージ、tool_output.per_tool は key 単位でマージ。
|
||||
#
|
||||
# パス解決: `--manifest <path>` では相対パスはその manifest ファイルの親ディレクトリ
|
||||
# を base に絶対パスへ解決される。profile artifact でも同じ validation 境界を通る。
|
||||
#
|
||||
# 凡例:
|
||||
# - 必須 … 値が無いと resolve エラー
|
||||
|
|
|
|||
|
|
@ -43,11 +43,11 @@ The Nix package does not put user configuration, sessions, sockets, or other mut
|
|||
|
||||
| Purpose | Override | `INSOMNIA_HOME` fallback | XDG / default fallback |
|
||||
| --- | --- | --- | --- |
|
||||
| User config (`manifest.toml`, prompt overrides, model/provider overrides) | `INSOMNIA_CONFIG_DIR` | `$INSOMNIA_HOME/config` | `$XDG_CONFIG_HOME/insomnia`, then `$HOME/.config/insomnia` |
|
||||
| User config (`profiles.toml`, prompt overrides, model/provider overrides) | `INSOMNIA_CONFIG_DIR` | `$INSOMNIA_HOME/config` | `$XDG_CONFIG_HOME/insomnia`, then `$HOME/.config/insomnia` |
|
||||
| Persistent data (`sessions/`, Pod metadata) | `INSOMNIA_DATA_DIR` | `$INSOMNIA_HOME` | `$HOME/.insomnia` |
|
||||
| Runtime state (sockets, lock files, live registry) | `INSOMNIA_RUNTIME_DIR` | `$INSOMNIA_HOME/run` | `$XDG_RUNTIME_DIR/insomnia`, then `$HOME/.insomnia/run` |
|
||||
|
||||
`INSOMNIA_USER_MANIFEST=<path>` can still be used to select an explicit user manifest for the Pod CLI cascade path. Project manifests are still discovered from `.insomnia/manifest.toml` under the current workspace unless a CLI mode documents otherwise.
|
||||
Normal fresh startup is profile-based. The package ships a builtin default profile, user/project `profiles.toml` files may select or define profiles, and `insomnia-pod --manifest <PATH>` remains a one-file compatibility/debug input. `INSOMNIA_USER_MANIFEST` and ambient `.insomnia/manifest.toml` discovery are not part of normal Pod/TUI startup.
|
||||
|
||||
## Validation
|
||||
|
||||
|
|
|
|||
|
|
@ -192,8 +192,8 @@ host_a (spawner) host_b (remote)
|
|||
Pod A (pod binary + ssh のみ)
|
||||
│
|
||||
├── ssh: session データを転送 ────────→ ファイル書き込み
|
||||
├── ssh: overlay TOML を転送 ─────────→ ファイル書き込み
|
||||
├── ssh: `insomnia-pod --overlay ... &` ───────→ Pod プロセス起動、socket 作成
|
||||
├── ssh: profile / one-file manifest 入力を転送 ─→ 必要ならファイル書き込み
|
||||
├── ssh: `insomnia-pod --profile ... &` ───────→ Pod プロセス起動、socket 作成
|
||||
├── ssh -L: socket を tunnel ─────────→ Pod B の unix socket
|
||||
│
|
||||
└── localhost:tunnel に接続 ──────────→ Method::Run / Event stream
|
||||
|
|
@ -203,14 +203,14 @@ host_a (spawner) host_b (remote)
|
|||
### コマンドイメージ
|
||||
|
||||
```bash
|
||||
# 1. session + overlay を転送
|
||||
# 1. session + profile/manifest input を転送
|
||||
ssh insomnia@host-b "mkdir -p ~/workspaces/task-123/store"
|
||||
tar cz session/ | ssh insomnia@host-b "tar xz -C ~/workspaces/task-123/store"
|
||||
echo "$OVERLAY" | ssh insomnia@host-b "cat > ~/workspaces/task-123/overlay.toml"
|
||||
scp profile.nix insomnia@host-b:~/workspaces/task-123/profile.nix
|
||||
|
||||
# 2. Pod を起動(detach)
|
||||
ssh insomnia@host-b "insomnia-pod --store ~/workspaces/task-123/store \
|
||||
--overlay ~/workspaces/task-123/overlay.toml &"
|
||||
--profile ~/workspaces/task-123/profile.nix &"
|
||||
|
||||
# 3. socket を tunnel で引っ張る
|
||||
ssh -L /tmp/pod-b.sock:/run/insomnia/task-123/pod.sock insomnia@host-b
|
||||
|
|
|
|||
|
|
@ -337,42 +337,33 @@ import-map 形式のプレフィックスで指定する:
|
|||
|
||||
## `insomnia-pod` CLI
|
||||
|
||||
`insomnia-pod` は通常、builtin default → user manifest → project manifest → overlay の cascade で manifest を解決して起動する。
|
||||
`insomnia-pod` の通常起動は profile discovery/default から runtime manifest を作る。user/project `manifest.toml` の ambient cascade は通常起動では使わない。
|
||||
|
||||
```
|
||||
insomnia-pod [--project <path>] [--overlay <toml>] [-s/--store <path>] [--session <uuid>]
|
||||
insomnia-pod [--profile <selector>] [--profile-pod-name <name>] [-s/--store <path>] [--session <uuid>]
|
||||
```
|
||||
|
||||
| フラグ | 説明 |
|
||||
|---|---|
|
||||
| `--project <path>` | プロジェクト manifest 探索の起点。省略時は cwd から上方向に `.insomnia/manifest.toml` を探索 |
|
||||
| `--overlay <toml>` | 最上層の overlay を inline TOML 文字列で渡す(例: `--overlay 'worker.instruction = "$user/foo"'`) |
|
||||
| `--profile <selector>` | builtin/user/project profile registry から Nix profile を選択。省略時は registry default(通常は `builtin:default`) |
|
||||
| `--profile-pod-name <name>` | profile 由来 manifest の `pod.name` を fresh spawn 用に上書き |
|
||||
| `-s, --store <path>` | セッション永続化ディレクトリ(デフォルト: `<data_dir>/sessions/`、`manifest::paths` で解決) |
|
||||
| `--session <uuid>` | 既存 session id から Pod を復元し、同じ jsonl に後続 turn を追記する |
|
||||
|
||||
user manifest は CLI フラグではなく、以下の規則で解決する。
|
||||
|
||||
| 入力 | 挙動 |
|
||||
|---|---|
|
||||
| `INSOMNIA_USER_MANIFEST=<path>` | 指定 path を user manifest として読む。ファイル不在や parse error は起動エラー |
|
||||
| `INSOMNIA_USER_MANIFEST=` | 空文字列は未指定扱い |
|
||||
| env 未指定 | `manifest::paths::user_manifest_path()` で自動探索し、存在すれば読む |
|
||||
|
||||
単一ファイルだけで起動したい場合は cascade を使わず、`--manifest` を指定する。
|
||||
単一ファイルだけで起動したい場合は `--manifest` を指定する。
|
||||
|
||||
```
|
||||
insomnia-pod --manifest <path> [-s/--store <path>] [--session <uuid>]
|
||||
```
|
||||
|
||||
`--manifest` は指定 TOML 1 枚だけを `PodManifest::from_toml` で読み、user / project / overlay layer は一切読まない。したがって `--project`、`--overlay`、非空の `INSOMNIA_USER_MANIFEST` とは併用不可。
|
||||
`--manifest` は指定 TOML 1 枚だけを読み、builtin defaults を merge したうえで `PodManifestConfig -> PodManifest` の required validation を通す。user / project manifest layer は読まない。`--profile`、`--project` とは併用不可。
|
||||
|
||||
spawn 子 Pod 用の内部フラグとして `--adopt` と `--callback <path>` がある。これらは `SpawnPod` が scope allocation と親 callback socket を引き継がせるために使うもので、通常の手動起動では使わない。
|
||||
|
||||
Pod の作業ディレクトリは `insomnia-pod` 起動時の cwd が直接使われる。別ディレクトリで
|
||||
動かしたい場合は `cd <path> && insomnia-pod ...` のように外側で `cd` してから起動する。
|
||||
|
||||
引数無しで起動すると、cwd + `manifest::paths` の自動解決だけで動く最小構成になる
|
||||
(overlay 無し、プロジェクトに `.insomnia/manifest.toml` があればそれを使う)。
|
||||
引数無しで起動すると、profile registry default(通常は bundled `builtin:default`)で起動する。
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
67
resources/nix/profile-lib.nix
Normal file
67
resources/nix/profile-lib.nix
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# Insomnia Nix profile helpers.
|
||||
#
|
||||
# A profile file can use:
|
||||
#
|
||||
# let insomnia = import ./path/to/profile-lib.nix {};
|
||||
# in insomnia.mkProfile {
|
||||
# name = "coder";
|
||||
# manifest = insomnia.mkManifest { ... };
|
||||
# }
|
||||
#
|
||||
# The output is consumed by `insomnia-pod --profile <path>` via
|
||||
# `nix eval --json --file <path>`.
|
||||
|
||||
{ }:
|
||||
|
||||
let
|
||||
profileFormat = "insomnia.nix-profile.v1";
|
||||
|
||||
optional = name: value:
|
||||
if value == null then {} else { ${name} = value; };
|
||||
|
||||
secretRef = ref: {
|
||||
kind = "secret_ref";
|
||||
inherit ref;
|
||||
};
|
||||
|
||||
mkManifest = manifest: manifest;
|
||||
|
||||
mkProfile =
|
||||
{ name ? null
|
||||
, description ? null
|
||||
, manifest ? null
|
||||
, config ? null
|
||||
, ...
|
||||
}@args:
|
||||
let
|
||||
resolvedManifest =
|
||||
if manifest != null then manifest
|
||||
else if config != null then config
|
||||
else removeAttrs args [ "name" "description" "manifest" "config" ];
|
||||
in
|
||||
{
|
||||
profile = ({ format = profileFormat; }
|
||||
// optional "name" name
|
||||
// optional "description" description);
|
||||
manifest = resolvedManifest;
|
||||
};
|
||||
|
||||
semanticPresets = {
|
||||
# Skeleton for users to extend in their own Nix. Rust does not attach any
|
||||
# hidden semantic meaning to these helpers; they only generate manifest JSON.
|
||||
codingAssistant = { modelId ? "claude-sonnet-4-20250514", authRef ? null }:
|
||||
{
|
||||
model = {
|
||||
scheme = "anthropic";
|
||||
model_id = modelId;
|
||||
} // (if authRef == null then {} else { auth = secretRef authRef; });
|
||||
};
|
||||
};
|
||||
|
||||
in
|
||||
{
|
||||
inherit profileFormat mkProfile mkManifest semanticPresets;
|
||||
secrets = {
|
||||
ref = secretRef;
|
||||
};
|
||||
}
|
||||
40
resources/nix/profiles/default.nix
Normal file
40
resources/nix/profiles/default.nix
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
let
|
||||
insomnia = import ../profile-lib.nix {};
|
||||
in
|
||||
insomnia.mkProfile {
|
||||
name = "default";
|
||||
description = "Bundled default Insomnia coding profile";
|
||||
manifest = insomnia.mkManifest {
|
||||
pod.name = "insomnia";
|
||||
|
||||
scope.allow = [
|
||||
{ target = "."; permission = "write"; recursive = true; }
|
||||
];
|
||||
|
||||
session.record_event_trace = true;
|
||||
|
||||
worker.reasoning = "high";
|
||||
|
||||
model.ref = "codex-oauth/gpt-5.5";
|
||||
|
||||
compaction = {
|
||||
threshold = 200000;
|
||||
request_threshold = 240000;
|
||||
worker_context_max_tokens = 100000;
|
||||
};
|
||||
|
||||
memory = {
|
||||
extract_threshold = 50000;
|
||||
consolidation_threshold_files = 5;
|
||||
consolidation_threshold_bytes = 50000;
|
||||
};
|
||||
|
||||
web = {
|
||||
enabled = true;
|
||||
search = {
|
||||
provider = "brave";
|
||||
api_key_env = "BRAVE_SEARCH_API_KEY";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -2,12 +2,12 @@
|
|||
id: 20260527-000022-manifest-profiles
|
||||
slug: manifest-profiles
|
||||
title: Nix profile entrypoints that resolve to portable Pod manifests
|
||||
status: open
|
||||
status: closed
|
||||
kind: feature
|
||||
priority: P2
|
||||
labels: [manifest, profiles, nix, tui]
|
||||
created_at: 2026-05-27T00:00:22Z
|
||||
updated_at: 2026-05-29T15:55:00Z
|
||||
updated_at: 2026-05-29T17:45:59Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
---
|
||||
id: 20260527-000022-manifest-profiles
|
||||
slug: manifest-profiles
|
||||
title: Nix profile entrypoints that resolve to portable Pod manifests
|
||||
status: closed
|
||||
kind: feature
|
||||
priority: P2
|
||||
labels: [manifest, profiles, nix, tui]
|
||||
created_at: 2026-05-27T00:00:22Z
|
||||
updated_at: 2026-05-29T17:45:59Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Migration reference
|
||||
|
||||
- legacy_ticket: null
|
||||
- migrated_from: TODO.md / tickets directory migration on 2026-05-27
|
||||
|
||||
# Nix profile entrypoints that resolve to portable Pod manifests
|
||||
|
||||
## Background
|
||||
|
||||
This work item was migrated from an unfinished TODO.md entry:
|
||||
|
||||
> 事前定義したManifestをProfile的に扱い、Orchestrator/Coder/Researcherで別々のモデル/設定を使わせる運用ができるようにする
|
||||
|
||||
The current manifest cascade is good at configuration defaults by location: built-in defaults, user manifest, workspace manifest, and explicit overlays. That is less suitable for operational role selection. Users want to choose between profiles such as Orchestrator, Coder, Researcher, Reviewer, or cheap/fast variants, and they want those profiles to be portable as a pure artifact rather than assembled implicitly from several ambient layers.
|
||||
|
||||
Another problem is authoring ergonomics. The current manifest exposes many low-level numeric parameters that require implementation-specific intuition, such as compaction thresholds, pruning protection sizes, memory thresholds, and feature-specific token limits. Profiles should let users express high-level intent and reusable presets while the resolver produces the precise runtime manifest.
|
||||
|
||||
## Related work
|
||||
|
||||
- `work-items/open/20260529-145355-manifest-profile-encrypted-secrets/item.md`: profiles should integrate with explicit encrypted secret references so API keys/tokens are not limited to process environment variables.
|
||||
|
||||
## Design direction
|
||||
|
||||
Use Nix as the default human-authored profile format. A profile is a Nix expression that produces the final Pod manifest/configuration artifact through an Insomnia-provided `mkProfile` / `mkManifest` style library.
|
||||
|
||||
The profile itself is the source of truth. Commonality, imports, role presets, and any cascade-like behavior should be expressed in Nix by the profile author instead of being implemented as an additional ambient manifest cascade in Insomnia.
|
||||
|
||||
The runtime boundary should be:
|
||||
|
||||
```text
|
||||
selected Nix profile + explicit startup inputs
|
||||
=> deterministic resolved manifest/config snapshot
|
||||
=> Pod runtime
|
||||
```
|
||||
|
||||
Do not introduce a three-layer authoring model where Nix generates TOML profiles that then merge into TOML manifests. That would make manifest/profile/Nix ownership unclear and hard to operate. Rust should consume the resolved artifact, ideally as a typed JSON/config representation, and preserve a snapshot for Pod restore.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Add a Nix-based profile entrypoint as the default path for new Pod creation.
|
||||
- Provide an Insomnia Nix library with `mkProfile` / `mkManifest` helpers.
|
||||
- The helper should produce a pure resolved manifest/config artifact that Rust can deserialize and validate.
|
||||
- Profile authors may use Nix imports/functions to share common settings, implement their own cascade, or build role presets.
|
||||
- Treat the resolved manifest/config as the runtime contract.
|
||||
- Persist the selected profile identity/source and the resolved snapshot in Pod/session metadata.
|
||||
- Pod resume should prefer the saved resolved snapshot, not silently re-evaluate the Nix profile.
|
||||
- Re-evaluating a profile for an existing Pod must be explicit because it may change model, tools, permissions, or thresholds.
|
||||
- Move role-oriented authoring into profiles.
|
||||
- Support profiles for roles such as Orchestrator, Coder, Researcher, Reviewer, and cost/performance variants.
|
||||
- Profiles should be able to select model/provider settings, prompts, tools, permissions, memory behavior, web/search behavior, workflows, skills, and context/compaction strategy.
|
||||
- Prefer semantic presets in the Nix library for values that are difficult to tune by raw numbers, e.g. context budget, compaction behavior, retention, autonomy, and tool policy.
|
||||
- Keep raw low-level numeric overrides available as an advanced escape hatch, not the primary user-facing interface.
|
||||
- Shrink ambient cascade to discovery/default selection rather than runtime config merging.
|
||||
- User/project configuration may provide profile registries, aliases, defaults, and UI preferences.
|
||||
- User/project configuration should not be required as intermediate runtime override layers for model IDs, compaction thresholds, or other behavior controlled by the selected profile.
|
||||
- Existing TOML manifest cascade can remain as compatibility/debug/test infrastructure, but it should not be the main profile design.
|
||||
- Add profile discovery and selection UX.
|
||||
- New Pod creation UI should show a selectable profile field such as `profile: coder (default)`.
|
||||
- The profile picker should list built-in/user/project/explicit profiles with enough source/default information to avoid ambiguity.
|
||||
- CLI/TUI should support explicit profile selection by name/source and by path/flakeref where appropriate.
|
||||
- Ambiguous profile names should fail closed or require source-qualified selection rather than being implicitly merged.
|
||||
- Keep secrets as references, not plaintext values.
|
||||
- Nix profiles may refer to credentials using typed secret references, e.g. `secrets.ref "brave.search.default"`.
|
||||
- Nix evaluation output, resolved config serialization, diagnostics, session logs, and model context must not contain plaintext secrets.
|
||||
- Secret dereferencing/decryption happens in Rust at the consumer boundary.
|
||||
- Define compatibility and fallback behavior.
|
||||
- `--manifest` / TOML manifest loading may continue to work for compatibility, tests, fixtures, and low-level debugging.
|
||||
- If Nix is unavailable, diagnostics should clearly say that profile resolution requires Nix and point to the manifest/resolved-config fallback path.
|
||||
- Existing manifest behavior should not be broken until the Nix profile path is implemented and documented.
|
||||
|
||||
## Open design points
|
||||
|
||||
- Exact Nix entrypoint shape:
|
||||
- flake output names, e.g. `insomniaProfiles.<name>` / `profiles.<name>`
|
||||
- path-based profiles, e.g. `.insomnia/profiles/coder/profile.nix`
|
||||
- whether both are supported initially
|
||||
- Exact Rust-facing artifact:
|
||||
- JSON resolved config vs TOML manifest snapshot vs a new typed `ResolvedPodConfig`
|
||||
- whether `PodManifest` remains the final runtime type or becomes the legacy/compatibility representation
|
||||
- Profile registry/default storage:
|
||||
- where user-level profile aliases live
|
||||
- where project-level defaults live
|
||||
- how built-in profiles are exposed
|
||||
- How much Nix support is external-command based initially vs embedded/library-integrated later.
|
||||
- How profile summaries are generated for the new Pod UI without exposing low-level internals or secrets.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- A Nix profile can be selected when creating a new Pod and resolves to the complete runtime manifest/config for that Pod.
|
||||
- Insomnia provides a documented `mkProfile` / `mkManifest` Nix helper for producing a valid resolved profile artifact.
|
||||
- Profile authors can share common settings and implement cascade-like composition in Nix without relying on ambient user/project manifest merging.
|
||||
- New Pod UI includes profile selection and displays the effective default, e.g. `profile: coder (default)`.
|
||||
- CLI/TUI profile selection supports at least one explicit path/flakeref flow and one discovered-name/default flow.
|
||||
- Resolved profile artifacts are validated with clear diagnostics before Pod creation.
|
||||
- Pod/session metadata persists the selected profile identity/source and the resolved snapshot.
|
||||
- Pod resume uses the persisted resolved snapshot unless the user explicitly asks to reload/re-resolve the profile.
|
||||
- Secret references are preserved as references through Nix evaluation and resolved config; plaintext secrets are not written to config snapshots, logs, diagnostics, or model context.
|
||||
- Existing TOML manifest path remains available as a compatibility/debug/test path during the migration.
|
||||
- Documentation explains the new profile model, why ambient cascade is no longer the primary runtime config mechanism, and how users should structure reusable Nix profiles.
|
||||
- Focused tests cover Nix profile resolution, validation errors, profile default/source selection, ambiguity handling, snapshot persistence, and no-plaintext secret serialization paths.
|
||||
- `cargo fmt --check`
|
||||
- Relevant manifest/profile/pod/tui tests pass.
|
||||
250
work-items/closed/20260527-000022-manifest-profiles/thread.md
Normal file
250
work-items/closed/20260527-000022-manifest-profiles/thread.md
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
<!-- event: migration author: tickets.sh-migration at: 2026-05-27T00:00:22Z -->
|
||||
|
||||
## Migrated
|
||||
|
||||
Migrated from TODO.md entry without a legacy ticket file. No legacy review file was present at migration time.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: plan author: hare at: 2026-05-29T16:09:27Z -->
|
||||
|
||||
## Plan
|
||||
|
||||
Implementation will proceed through a child orchestrator Pod in a dedicated worktree as an experiment in nested Pod delegation.
|
||||
|
||||
Initial implementation target:
|
||||
|
||||
- Introduce Nix profile resolution as a new manifest source before the existing manifest cascade.
|
||||
- Start with explicit path-based profiles; discovered-name/default selection and rich TUI picker can be staged after the core resolver if necessary.
|
||||
- Provide a minimal bundled Nix helper that can produce a typed resolved manifest/config artifact.
|
||||
- Keep existing TOML manifest loading as compatibility/debug/test infrastructure.
|
||||
- Persist enough profile identity and resolved snapshot data for future restore semantics; do not silently re-evaluate profiles on resume.
|
||||
- Secret values must remain references only; plaintext secrets are out of scope for the profile resolver.
|
||||
|
||||
The child orchestrator may split implementation among sub-Pods, but final merge/close remains parent-side.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: review author: hare at: 2026-05-29T16:52:47Z status: approve -->
|
||||
|
||||
## Review: approve
|
||||
|
||||
Reviewed the nested Pod implementation from branch `work/nix-manifest-profiles`.
|
||||
|
||||
Result: approved after blocking fix.
|
||||
|
||||
Findings:
|
||||
|
||||
- Initial review found one blocking issue: `--profile` rejected non-empty `INSOMNIA_USER_MANIFEST`. That contradicted the profile design because a selected Nix profile is a standalone runtime config artifact and ambient user/project manifest cascade should not affect it.
|
||||
- The child orchestrator fixed this in commit `c9a175a fix: ignore user manifest for profiles` and added `profile_ignores_non_empty_user_manifest_env` without invoking real Nix.
|
||||
- Profile foundation is intentionally a vertical slice: explicit path-based Nix profile resolution, minimal Nix helper, CLI/TUI spawn entrypoints, resolved snapshot metadata, and restore-from-snapshot behavior. Rich discovery/default picker remains future work.
|
||||
|
||||
Validation run by reviewer:
|
||||
|
||||
- `cargo fmt --check`
|
||||
- `cargo test -p manifest profile -- --nocapture`
|
||||
- `cargo test -p pod --bin insomnia-pod profile -- --nocapture`
|
||||
- `cargo check -p session-store -p manifest -p pod -p client -p tui -p provider`
|
||||
- `cargo check -p pod -p tui`
|
||||
- `git diff --check`
|
||||
- Manual `nix eval --json --file` smoke check for `resources/nix/profile-lib.nix`
|
||||
|
||||
Non-blocking follow-up candidates:
|
||||
|
||||
- Hide or narrow `ResolvedProfile::raw_artifact` if future call sites might log/persist accidental raw Nix output.
|
||||
- Add a timeout around `nix eval` so profile startup cannot hang indefinitely.
|
||||
- Validate direct `client::SpawnConfig` construction that combines `profile_path` with `resume_from`; TUI currently avoids it.
|
||||
- Build richer profile discovery/default selection and the full TUI profile picker.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: plan author: hare at: 2026-05-29T16:59:41Z -->
|
||||
|
||||
## Plan
|
||||
|
||||
Continue implementation beyond the merged Nix profile foundation. The previous nested delegation stopped at a reviewable vertical slice; this phase should keep iterating until the work item acceptance criteria are materially closer to completion or a real blocker is found.
|
||||
|
||||
Phase 2 target:
|
||||
|
||||
- Add profile discovery/default selection instead of requiring only explicit `--profile <path>`.
|
||||
- Support discovered-name selection with source disambiguation where needed, e.g. builtin/user/project/path semantics.
|
||||
- Add or substantially advance the new Pod profile selection UX so a default can be displayed as `profile: coder (default)` and changed before spawn.
|
||||
- Preserve the core design: selected Nix profile resolves to a standalone manifest/config artifact; ambient user/project manifests do not merge into it.
|
||||
- Keep Pod resume using resolved snapshots rather than silent Nix re-evaluation.
|
||||
- Keep secrets as references only.
|
||||
|
||||
The delegated orchestrator should not return merely because one slice is reviewable. It should internally review acceptance criteria, delegate sub-Pods as needed, and continue with the next reachable slice unless it hits a concrete design or technical blocker. Parent-side merge/close remains reserved for this parent Pod.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: review author: hare at: 2026-05-29T17:44:47Z status: approve -->
|
||||
|
||||
## Review: approve
|
||||
|
||||
Reviewed phase 2 implementation from branch `work/nix-manifest-profiles-phase2`.
|
||||
|
||||
Result: approved after two parent-review fixes.
|
||||
|
||||
Implemented scope:
|
||||
|
||||
- Profile registry/discovery for builtin/user/project sources.
|
||||
- `[profiles]` metadata in user/project manifests for discovery/default/alias only; it is not merged into the selected runtime manifest.
|
||||
- `--profile` selector parsing for explicit paths, `path:<path>`, discovered names, `default`, and source-qualified names such as `project:coder`.
|
||||
- Ambiguous unqualified discovered names fail closed.
|
||||
- TUI fresh-spawn UI now shows a selectable `profile:` row, uses discovered choices, marks defaults, and includes `manifest cascade` as opt-out.
|
||||
- SpawnConfig passes selected profiles to `insomnia-pod --profile`; resume/attach paths do not re-evaluate profiles.
|
||||
- Docs and focused tests updated.
|
||||
|
||||
Parent review findings fixed by child orchestrator:
|
||||
|
||||
1. Unqualified alias targets initially resolved globally. Fixed so aliases declared in a source resolve unqualified targets within that declaring source by default.
|
||||
2. Defaults pointing at aliases initially did not mark the resolved target entry as default, causing TUI to fall back to `manifest cascade`. Fixed by resolving the default through `select_named()` before setting `is_default` flags.
|
||||
|
||||
Validation run by parent reviewer:
|
||||
|
||||
- `cargo fmt --check`
|
||||
- `cargo check`
|
||||
- `cargo test -p manifest profile -- --nocapture`
|
||||
- `cargo test -p tui spawn -- --nocapture`
|
||||
- `cargo test -p pod profile -- --nocapture`
|
||||
- `cargo test -p client spawn -- --nocapture`
|
||||
- `git diff --check`
|
||||
|
||||
All passed. Full `cargo test` was run by the child orchestrator and failed only in the unrelated existing/flaky `llm-worker` parallel timing test class.
|
||||
|
||||
Remaining polish/follow-up candidates, not blockers for this work item:
|
||||
|
||||
- A richer popup-style profile picker instead of inline cycling.
|
||||
- Actual bundled builtin profile files once default builtin semantics are decided.
|
||||
- `nix eval` timeout/robustness follow-up.
|
||||
- Encrypted secret store integration remains tracked by the related encrypted-secrets work item.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: close author: hare at: 2026-05-29T17:45:59Z status: closed -->
|
||||
|
||||
## Closed
|
||||
|
||||
---
|
||||
id: 20260527-000022-manifest-profiles
|
||||
slug: manifest-profiles
|
||||
title: Nix profile entrypoints that resolve to portable Pod manifests
|
||||
status: closed
|
||||
kind: feature
|
||||
priority: P2
|
||||
labels: [manifest, profiles, nix, tui]
|
||||
created_at: 2026-05-27T00:00:22Z
|
||||
updated_at: 2026-05-29T17:45:59Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Migration reference
|
||||
|
||||
- legacy_ticket: null
|
||||
- migrated_from: TODO.md / tickets directory migration on 2026-05-27
|
||||
|
||||
# Nix profile entrypoints that resolve to portable Pod manifests
|
||||
|
||||
## Background
|
||||
|
||||
This work item was migrated from an unfinished TODO.md entry:
|
||||
|
||||
> 事前定義したManifestをProfile的に扱い、Orchestrator/Coder/Researcherで別々のモデル/設定を使わせる運用ができるようにする
|
||||
|
||||
The current manifest cascade is good at configuration defaults by location: built-in defaults, user manifest, workspace manifest, and explicit overlays. That is less suitable for operational role selection. Users want to choose between profiles such as Orchestrator, Coder, Researcher, Reviewer, or cheap/fast variants, and they want those profiles to be portable as a pure artifact rather than assembled implicitly from several ambient layers.
|
||||
|
||||
Another problem is authoring ergonomics. The current manifest exposes many low-level numeric parameters that require implementation-specific intuition, such as compaction thresholds, pruning protection sizes, memory thresholds, and feature-specific token limits. Profiles should let users express high-level intent and reusable presets while the resolver produces the precise runtime manifest.
|
||||
|
||||
## Related work
|
||||
|
||||
- `work-items/open/20260529-145355-manifest-profile-encrypted-secrets/item.md`: profiles should integrate with explicit encrypted secret references so API keys/tokens are not limited to process environment variables.
|
||||
|
||||
## Design direction
|
||||
|
||||
Use Nix as the default human-authored profile format. A profile is a Nix expression that produces the final Pod manifest/configuration artifact through an Insomnia-provided `mkProfile` / `mkManifest` style library.
|
||||
|
||||
The profile itself is the source of truth. Commonality, imports, role presets, and any cascade-like behavior should be expressed in Nix by the profile author instead of being implemented as an additional ambient manifest cascade in Insomnia.
|
||||
|
||||
The runtime boundary should be:
|
||||
|
||||
```text
|
||||
selected Nix profile + explicit startup inputs
|
||||
=> deterministic resolved manifest/config snapshot
|
||||
=> Pod runtime
|
||||
```
|
||||
|
||||
Do not introduce a three-layer authoring model where Nix generates TOML profiles that then merge into TOML manifests. That would make manifest/profile/Nix ownership unclear and hard to operate. Rust should consume the resolved artifact, ideally as a typed JSON/config representation, and preserve a snapshot for Pod restore.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Add a Nix-based profile entrypoint as the default path for new Pod creation.
|
||||
- Provide an Insomnia Nix library with `mkProfile` / `mkManifest` helpers.
|
||||
- The helper should produce a pure resolved manifest/config artifact that Rust can deserialize and validate.
|
||||
- Profile authors may use Nix imports/functions to share common settings, implement their own cascade, or build role presets.
|
||||
- Treat the resolved manifest/config as the runtime contract.
|
||||
- Persist the selected profile identity/source and the resolved snapshot in Pod/session metadata.
|
||||
- Pod resume should prefer the saved resolved snapshot, not silently re-evaluate the Nix profile.
|
||||
- Re-evaluating a profile for an existing Pod must be explicit because it may change model, tools, permissions, or thresholds.
|
||||
- Move role-oriented authoring into profiles.
|
||||
- Support profiles for roles such as Orchestrator, Coder, Researcher, Reviewer, and cost/performance variants.
|
||||
- Profiles should be able to select model/provider settings, prompts, tools, permissions, memory behavior, web/search behavior, workflows, skills, and context/compaction strategy.
|
||||
- Prefer semantic presets in the Nix library for values that are difficult to tune by raw numbers, e.g. context budget, compaction behavior, retention, autonomy, and tool policy.
|
||||
- Keep raw low-level numeric overrides available as an advanced escape hatch, not the primary user-facing interface.
|
||||
- Shrink ambient cascade to discovery/default selection rather than runtime config merging.
|
||||
- User/project configuration may provide profile registries, aliases, defaults, and UI preferences.
|
||||
- User/project configuration should not be required as intermediate runtime override layers for model IDs, compaction thresholds, or other behavior controlled by the selected profile.
|
||||
- Existing TOML manifest cascade can remain as compatibility/debug/test infrastructure, but it should not be the main profile design.
|
||||
- Add profile discovery and selection UX.
|
||||
- New Pod creation UI should show a selectable profile field such as `profile: coder (default)`.
|
||||
- The profile picker should list built-in/user/project/explicit profiles with enough source/default information to avoid ambiguity.
|
||||
- CLI/TUI should support explicit profile selection by name/source and by path/flakeref where appropriate.
|
||||
- Ambiguous profile names should fail closed or require source-qualified selection rather than being implicitly merged.
|
||||
- Keep secrets as references, not plaintext values.
|
||||
- Nix profiles may refer to credentials using typed secret references, e.g. `secrets.ref "brave.search.default"`.
|
||||
- Nix evaluation output, resolved config serialization, diagnostics, session logs, and model context must not contain plaintext secrets.
|
||||
- Secret dereferencing/decryption happens in Rust at the consumer boundary.
|
||||
- Define compatibility and fallback behavior.
|
||||
- `--manifest` / TOML manifest loading may continue to work for compatibility, tests, fixtures, and low-level debugging.
|
||||
- If Nix is unavailable, diagnostics should clearly say that profile resolution requires Nix and point to the manifest/resolved-config fallback path.
|
||||
- Existing manifest behavior should not be broken until the Nix profile path is implemented and documented.
|
||||
|
||||
## Open design points
|
||||
|
||||
- Exact Nix entrypoint shape:
|
||||
- flake output names, e.g. `insomniaProfiles.<name>` / `profiles.<name>`
|
||||
- path-based profiles, e.g. `.insomnia/profiles/coder/profile.nix`
|
||||
- whether both are supported initially
|
||||
- Exact Rust-facing artifact:
|
||||
- JSON resolved config vs TOML manifest snapshot vs a new typed `ResolvedPodConfig`
|
||||
- whether `PodManifest` remains the final runtime type or becomes the legacy/compatibility representation
|
||||
- Profile registry/default storage:
|
||||
- where user-level profile aliases live
|
||||
- where project-level defaults live
|
||||
- how built-in profiles are exposed
|
||||
- How much Nix support is external-command based initially vs embedded/library-integrated later.
|
||||
- How profile summaries are generated for the new Pod UI without exposing low-level internals or secrets.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- A Nix profile can be selected when creating a new Pod and resolves to the complete runtime manifest/config for that Pod.
|
||||
- Insomnia provides a documented `mkProfile` / `mkManifest` Nix helper for producing a valid resolved profile artifact.
|
||||
- Profile authors can share common settings and implement cascade-like composition in Nix without relying on ambient user/project manifest merging.
|
||||
- New Pod UI includes profile selection and displays the effective default, e.g. `profile: coder (default)`.
|
||||
- CLI/TUI profile selection supports at least one explicit path/flakeref flow and one discovered-name/default flow.
|
||||
- Resolved profile artifacts are validated with clear diagnostics before Pod creation.
|
||||
- Pod/session metadata persists the selected profile identity/source and the resolved snapshot.
|
||||
- Pod resume uses the persisted resolved snapshot unless the user explicitly asks to reload/re-resolve the profile.
|
||||
- Secret references are preserved as references through Nix evaluation and resolved config; plaintext secrets are not written to config snapshots, logs, diagnostics, or model context.
|
||||
- Existing TOML manifest path remains available as a compatibility/debug/test path during the migration.
|
||||
- Documentation explains the new profile model, why ambient cascade is no longer the primary runtime config mechanism, and how users should structure reusable Nix profiles.
|
||||
- Focused tests cover Nix profile resolution, validation errors, profile default/source selection, ambiguity handling, snapshot persistence, and no-plaintext secret serialization paths.
|
||||
- `cargo fmt --check`
|
||||
- Relevant manifest/profile/pod/tui tests pass.
|
||||
|
||||
|
||||
---
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
---
|
||||
id: 20260529-171326-pod-socket-peer-disconnect-noise
|
||||
slug: pod-socket-peer-disconnect-noise
|
||||
title: Treat Pod socket peer disconnects as normal connection close
|
||||
status: closed
|
||||
kind: bug
|
||||
priority: P1
|
||||
labels: [pod, ipc, tui, noise]
|
||||
created_at: 2026-05-29T17:13:26Z
|
||||
updated_at: 2026-05-29T17:26:50Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
The active `insomnia` TUI session frequently shows notices like:
|
||||
|
||||
```text
|
||||
[notice error] pod: [Internal] invalid method: Connection reset by peer (os error 104)
|
||||
```
|
||||
|
||||
The message is misleading. The Pod IPC server reports every `reader.next::<Method>()` error as an `Event::Error` with `invalid method: ...`. A peer disconnect/reset from a short-lived socket client or status probe is a normal connection lifecycle event, not an invalid method sent by a client.
|
||||
|
||||
This becomes noisy during orchestration, multi-Pod polling, attach/picker refreshes, and diagnostic tooling because many clients connect briefly to read initial `Alert` / `Snapshot` traffic and then close. Depending on timing, the server can observe that close as `ConnectionReset`, `ConnectionAborted`, `BrokenPipe`, or EOF-like errors. Those must not be broadcast as user-visible Pod errors.
|
||||
|
||||
Likely code path:
|
||||
|
||||
- `crates/pod/src/ipc/server.rs`: `handle_connection`, `method = reader.next::<Method>()`
|
||||
- `crates/protocol/src/stream.rs`: JSON line reader returns I/O errors
|
||||
- TUI displays broadcast `Event::Error` through notice/error surfaces
|
||||
|
||||
## Requirements
|
||||
|
||||
- Classify normal peer disconnects while reading client `Method` values as connection close, not invalid method.
|
||||
- At minimum handle `ConnectionReset`, `ConnectionAborted`, `BrokenPipe`, and EOF-like cases appropriately.
|
||||
- The handler should break/return for those cases without broadcasting `Event::Error`.
|
||||
- Preserve diagnostics for genuinely invalid client input.
|
||||
- Malformed JSON or schema-invalid `Method` payloads should still produce a clear error.
|
||||
- Prefer sending the protocol error to the offending connection only if the current IPC shape makes that reasonable; do not unnecessarily broadcast connection-local parse errors to unrelated TUI clients.
|
||||
- Keep normal socket behavior intact.
|
||||
- Initial `Alert` and `Snapshot` delivery must still work.
|
||||
- Long-lived TUI attach clients must still receive live events.
|
||||
- Short-lived probe clients may connect, read enough state, and drop without generating user-visible errors.
|
||||
- Avoid hiding real server failures.
|
||||
- Only expected peer disconnect/read-close errors should be silenced.
|
||||
- Unexpected I/O or parse errors should remain observable through logs or explicit error events as appropriate.
|
||||
- Add focused tests.
|
||||
- A client that connects and resets/closes without sending a Method should not create an `Event::Error` / notice.
|
||||
- A malformed Method line should still be treated as invalid input.
|
||||
- Existing controller/IPC tests should continue to pass.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- The `[Internal] invalid method: Connection reset by peer` notice no longer appears when short-lived Pod socket clients/probes disconnect.
|
||||
- Normal disconnect/reset/abort/broken-pipe while reading a Method closes only that connection and is not broadcast to all clients.
|
||||
- Invalid JSON or schema-invalid Method input still produces a clear diagnostic.
|
||||
- Tests cover peer disconnect handling and invalid-method handling.
|
||||
- `cargo fmt --check`
|
||||
- Relevant pod/protocol/tui IPC tests pass.
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
---
|
||||
id: 20260529-171326-pod-socket-peer-disconnect-noise
|
||||
slug: pod-socket-peer-disconnect-noise
|
||||
title: Treat Pod socket peer disconnects as normal connection close
|
||||
status: closed
|
||||
kind: bug
|
||||
priority: P1
|
||||
labels: [pod, ipc, tui, noise]
|
||||
created_at: 2026-05-29T17:13:26Z
|
||||
updated_at: 2026-05-29T17:26:50Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
The active `insomnia` TUI session frequently shows notices like:
|
||||
|
||||
```text
|
||||
[notice error] pod: [Internal] invalid method: Connection reset by peer (os error 104)
|
||||
```
|
||||
|
||||
The message is misleading. The Pod IPC server reports every `reader.next::<Method>()` error as an `Event::Error` with `invalid method: ...`. A peer disconnect/reset from a short-lived socket client or status probe is a normal connection lifecycle event, not an invalid method sent by a client.
|
||||
|
||||
This becomes noisy during orchestration, multi-Pod polling, attach/picker refreshes, and diagnostic tooling because many clients connect briefly to read initial `Alert` / `Snapshot` traffic and then close. Depending on timing, the server can observe that close as `ConnectionReset`, `ConnectionAborted`, `BrokenPipe`, or EOF-like errors. Those must not be broadcast as user-visible Pod errors.
|
||||
|
||||
Likely code path:
|
||||
|
||||
- `crates/pod/src/ipc/server.rs`: `handle_connection`, `method = reader.next::<Method>()`
|
||||
- `crates/protocol/src/stream.rs`: JSON line reader returns I/O errors
|
||||
- TUI displays broadcast `Event::Error` through notice/error surfaces
|
||||
|
||||
## Requirements
|
||||
|
||||
- Classify normal peer disconnects while reading client `Method` values as connection close, not invalid method.
|
||||
- At minimum handle `ConnectionReset`, `ConnectionAborted`, `BrokenPipe`, and EOF-like cases appropriately.
|
||||
- The handler should break/return for those cases without broadcasting `Event::Error`.
|
||||
- Preserve diagnostics for genuinely invalid client input.
|
||||
- Malformed JSON or schema-invalid `Method` payloads should still produce a clear error.
|
||||
- Prefer sending the protocol error to the offending connection only if the current IPC shape makes that reasonable; do not unnecessarily broadcast connection-local parse errors to unrelated TUI clients.
|
||||
- Keep normal socket behavior intact.
|
||||
- Initial `Alert` and `Snapshot` delivery must still work.
|
||||
- Long-lived TUI attach clients must still receive live events.
|
||||
- Short-lived probe clients may connect, read enough state, and drop without generating user-visible errors.
|
||||
- Avoid hiding real server failures.
|
||||
- Only expected peer disconnect/read-close errors should be silenced.
|
||||
- Unexpected I/O or parse errors should remain observable through logs or explicit error events as appropriate.
|
||||
- Add focused tests.
|
||||
- A client that connects and resets/closes without sending a Method should not create an `Event::Error` / notice.
|
||||
- A malformed Method line should still be treated as invalid input.
|
||||
- Existing controller/IPC tests should continue to pass.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- The `[Internal] invalid method: Connection reset by peer` notice no longer appears when short-lived Pod socket clients/probes disconnect.
|
||||
- Normal disconnect/reset/abort/broken-pipe while reading a Method closes only that connection and is not broadcast to all clients.
|
||||
- Invalid JSON or schema-invalid Method input still produces a clear diagnostic.
|
||||
- Tests cover peer disconnect handling and invalid-method handling.
|
||||
- `cargo fmt --check`
|
||||
- Relevant pod/protocol/tui IPC tests pass.
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
<!-- event: create author: tickets.sh at: 2026-05-29T17:13:26Z -->
|
||||
|
||||
## Created
|
||||
|
||||
Created by tickets.sh create.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: review author: hare at: 2026-05-29T17:26:19Z status: approve -->
|
||||
|
||||
## Review: approve
|
||||
|
||||
Reviewed implementation commit `d5d50a3 fix: suppress pod socket peer disconnect noise` from branch `work/pod-socket-peer-disconnect-noise`.
|
||||
|
||||
Result: approved.
|
||||
|
||||
The implementation correctly separates normal peer disconnect read errors from invalid method payloads in `crates/pod/src/ipc/server.rs`:
|
||||
|
||||
- `ConnectionReset`, `ConnectionAborted`, `BrokenPipe`, and `UnexpectedEof` now close only the affected connection.
|
||||
- Those peer disconnects no longer broadcast `Event::Error` to unrelated clients.
|
||||
- malformed/schema-invalid Method lines still return a clear `InvalidRequest` error to the offending connection.
|
||||
|
||||
Focused tests cover both paths:
|
||||
|
||||
- `peer_disconnect_read_errors_are_connection_close`
|
||||
- `invalid_data_is_not_peer_disconnect`
|
||||
- `socket_schema_invalid_method_returns_error`
|
||||
- `socket_malformed_method_returns_error`
|
||||
- `socket_peer_close_without_method_does_not_broadcast_error`
|
||||
|
||||
Validation reported by implementer:
|
||||
|
||||
- `cargo fmt --check` passed
|
||||
- `cargo test -p pod ipc::server -- --nocapture` passed
|
||||
- `cargo test -p pod --test controller_test socket_ -- --nocapture` passed
|
||||
|
||||
Full `cargo test -p pod --test controller_test -- --nocapture` still has unrelated empty-turn rollback failures; the new socket tests passed within that run and the failing area is unrelated to this IPC disconnect change.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: close author: hare at: 2026-05-29T17:26:50Z status: closed -->
|
||||
|
||||
## Closed
|
||||
|
||||
---
|
||||
id: 20260529-171326-pod-socket-peer-disconnect-noise
|
||||
slug: pod-socket-peer-disconnect-noise
|
||||
title: Treat Pod socket peer disconnects as normal connection close
|
||||
status: closed
|
||||
kind: bug
|
||||
priority: P1
|
||||
labels: [pod, ipc, tui, noise]
|
||||
created_at: 2026-05-29T17:13:26Z
|
||||
updated_at: 2026-05-29T17:26:50Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
The active `insomnia` TUI session frequently shows notices like:
|
||||
|
||||
```text
|
||||
[notice error] pod: [Internal] invalid method: Connection reset by peer (os error 104)
|
||||
```
|
||||
|
||||
The message is misleading. The Pod IPC server reports every `reader.next::<Method>()` error as an `Event::Error` with `invalid method: ...`. A peer disconnect/reset from a short-lived socket client or status probe is a normal connection lifecycle event, not an invalid method sent by a client.
|
||||
|
||||
This becomes noisy during orchestration, multi-Pod polling, attach/picker refreshes, and diagnostic tooling because many clients connect briefly to read initial `Alert` / `Snapshot` traffic and then close. Depending on timing, the server can observe that close as `ConnectionReset`, `ConnectionAborted`, `BrokenPipe`, or EOF-like errors. Those must not be broadcast as user-visible Pod errors.
|
||||
|
||||
Likely code path:
|
||||
|
||||
- `crates/pod/src/ipc/server.rs`: `handle_connection`, `method = reader.next::<Method>()`
|
||||
- `crates/protocol/src/stream.rs`: JSON line reader returns I/O errors
|
||||
- TUI displays broadcast `Event::Error` through notice/error surfaces
|
||||
|
||||
## Requirements
|
||||
|
||||
- Classify normal peer disconnects while reading client `Method` values as connection close, not invalid method.
|
||||
- At minimum handle `ConnectionReset`, `ConnectionAborted`, `BrokenPipe`, and EOF-like cases appropriately.
|
||||
- The handler should break/return for those cases without broadcasting `Event::Error`.
|
||||
- Preserve diagnostics for genuinely invalid client input.
|
||||
- Malformed JSON or schema-invalid `Method` payloads should still produce a clear error.
|
||||
- Prefer sending the protocol error to the offending connection only if the current IPC shape makes that reasonable; do not unnecessarily broadcast connection-local parse errors to unrelated TUI clients.
|
||||
- Keep normal socket behavior intact.
|
||||
- Initial `Alert` and `Snapshot` delivery must still work.
|
||||
- Long-lived TUI attach clients must still receive live events.
|
||||
- Short-lived probe clients may connect, read enough state, and drop without generating user-visible errors.
|
||||
- Avoid hiding real server failures.
|
||||
- Only expected peer disconnect/read-close errors should be silenced.
|
||||
- Unexpected I/O or parse errors should remain observable through logs or explicit error events as appropriate.
|
||||
- Add focused tests.
|
||||
- A client that connects and resets/closes without sending a Method should not create an `Event::Error` / notice.
|
||||
- A malformed Method line should still be treated as invalid input.
|
||||
- Existing controller/IPC tests should continue to pass.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- The `[Internal] invalid method: Connection reset by peer` notice no longer appears when short-lived Pod socket clients/probes disconnect.
|
||||
- Normal disconnect/reset/abort/broken-pipe while reading a Method closes only that connection and is not broadcast to all clients.
|
||||
- Invalid JSON or schema-invalid Method input still produces a clear diagnostic.
|
||||
- Tests cover peer disconnect handling and invalid-method handling.
|
||||
- `cargo fmt --check`
|
||||
- Relevant pod/protocol/tui IPC tests pass.
|
||||
|
||||
|
||||
---
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
---
|
||||
id: 20260529-180257-profile-registry-config-file
|
||||
slug: profile-registry-config-file
|
||||
title: Move profile registry out of manifest files
|
||||
status: closed
|
||||
kind: bug
|
||||
priority: P1
|
||||
labels: [manifest, profiles, config]
|
||||
created_at: 2026-05-29T18:02:57Z
|
||||
updated_at: 2026-05-29T18:11:10Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
The initial Nix manifest profiles implementation made profile discovery/default/alias metadata available from `[profiles]` sections inside `manifest.toml`. That is a design error.
|
||||
|
||||
A manifest is the Pod runtime configuration contract: model, scope, tools, memory, web, permissions, prompts, and other settings that are already selected for a Pod. Profile registry/default/alias metadata is application/project UX configuration used before a Pod manifest exists. Putting registry metadata inside `manifest.toml` makes the manifest both the runtime config and the profile selector config, contradicting the profile design.
|
||||
|
||||
This was caught immediately after merge, before compatibility needs exist. Do not keep backward compatibility for `[profiles]` in manifest files.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Move profile registry/default/alias configuration out of manifest files.
|
||||
- User registry file: `<config_dir>/profiles.toml`.
|
||||
- Project registry file: closest `.insomnia/profiles.toml` found by walking up from the cwd.
|
||||
- Builtin profile discovery can remain under bundled resources.
|
||||
- Remove manifest-file profile registry parsing.
|
||||
- `manifest.toml` must not be the source for profile registry/default/alias data.
|
||||
- `INSOMNIA_USER_MANIFEST` must not affect profile registry discovery.
|
||||
- `PodManifestConfig` / `PodManifest` must remain runtime config only.
|
||||
- Use a top-level `profiles.toml` schema rather than nesting under `[profiles]`.
|
||||
- Example:
|
||||
```toml
|
||||
default = "coder"
|
||||
|
||||
[profile]
|
||||
coder = "profiles/coder.nix"
|
||||
|
||||
[alias]
|
||||
work = "coder"
|
||||
```
|
||||
- The existing table form for entries may remain useful:
|
||||
```toml
|
||||
[profile.coder]
|
||||
path = "profiles/coder.nix"
|
||||
description = "Project coder"
|
||||
```
|
||||
- Keep existing profile selection semantics.
|
||||
- `default`, unqualified names, source-qualified names, aliases, ambiguity handling, and default markers should continue to work.
|
||||
- Unqualified alias/default targets remain source-local by default.
|
||||
- TUI fresh-spawn profile row and manifest-cascade opt-out should continue to work.
|
||||
- Update docs and tests.
|
||||
- Documentation must describe `profiles.toml`, not `[profiles]` in `manifest.toml`.
|
||||
- Tests should create `.insomnia/profiles.toml` / user `profiles.toml`, not `.insomnia/manifest.toml`, for registry behavior.
|
||||
- Add coverage proving `[profiles]` in `manifest.toml` is ignored for discovery.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- User profile registry is discovered from `<config_dir>/profiles.toml`.
|
||||
- Project profile registry is discovered from closest `.insomnia/profiles.toml`.
|
||||
- `[profiles]` in user/project `manifest.toml` is not read for profile registry/default/alias discovery.
|
||||
- `INSOMNIA_USER_MANIFEST` does not change profile registry discovery.
|
||||
- Existing CLI/TUI selector behavior still works with `profiles.toml`.
|
||||
- Docs no longer instruct users to put profile registry metadata in `manifest.toml`.
|
||||
- Focused manifest/tui/pod/client profile tests pass.
|
||||
- `cargo fmt --check`
|
||||
- Relevant checks pass.
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
---
|
||||
id: 20260529-180257-profile-registry-config-file
|
||||
slug: profile-registry-config-file
|
||||
title: Move profile registry out of manifest files
|
||||
status: closed
|
||||
kind: bug
|
||||
priority: P1
|
||||
labels: [manifest, profiles, config]
|
||||
created_at: 2026-05-29T18:02:57Z
|
||||
updated_at: 2026-05-29T18:11:09Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
The initial Nix manifest profiles implementation made profile discovery/default/alias metadata available from `[profiles]` sections inside `manifest.toml`. That is a design error.
|
||||
|
||||
A manifest is the Pod runtime configuration contract: model, scope, tools, memory, web, permissions, prompts, and other settings that are already selected for a Pod. Profile registry/default/alias metadata is application/project UX configuration used before a Pod manifest exists. Putting registry metadata inside `manifest.toml` makes the manifest both the runtime config and the profile selector config, contradicting the profile design.
|
||||
|
||||
This was caught immediately after merge, before compatibility needs exist. Do not keep backward compatibility for `[profiles]` in manifest files.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Move profile registry/default/alias configuration out of manifest files.
|
||||
- User registry file: `<config_dir>/profiles.toml`.
|
||||
- Project registry file: closest `.insomnia/profiles.toml` found by walking up from the cwd.
|
||||
- Builtin profile discovery can remain under bundled resources.
|
||||
- Remove manifest-file profile registry parsing.
|
||||
- `manifest.toml` must not be the source for profile registry/default/alias data.
|
||||
- `INSOMNIA_USER_MANIFEST` must not affect profile registry discovery.
|
||||
- `PodManifestConfig` / `PodManifest` must remain runtime config only.
|
||||
- Use a top-level `profiles.toml` schema rather than nesting under `[profiles]`.
|
||||
- Example:
|
||||
```toml
|
||||
default = "coder"
|
||||
|
||||
[profile]
|
||||
coder = "profiles/coder.nix"
|
||||
|
||||
[alias]
|
||||
work = "coder"
|
||||
```
|
||||
- The existing table form for entries may remain useful:
|
||||
```toml
|
||||
[profile.coder]
|
||||
path = "profiles/coder.nix"
|
||||
description = "Project coder"
|
||||
```
|
||||
- Keep existing profile selection semantics.
|
||||
- `default`, unqualified names, source-qualified names, aliases, ambiguity handling, and default markers should continue to work.
|
||||
- Unqualified alias/default targets remain source-local by default.
|
||||
- TUI fresh-spawn profile row and manifest-cascade opt-out should continue to work.
|
||||
- Update docs and tests.
|
||||
- Documentation must describe `profiles.toml`, not `[profiles]` in `manifest.toml`.
|
||||
- Tests should create `.insomnia/profiles.toml` / user `profiles.toml`, not `.insomnia/manifest.toml`, for registry behavior.
|
||||
- Add coverage proving `[profiles]` in `manifest.toml` is ignored for discovery.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- User profile registry is discovered from `<config_dir>/profiles.toml`.
|
||||
- Project profile registry is discovered from closest `.insomnia/profiles.toml`.
|
||||
- `[profiles]` in user/project `manifest.toml` is not read for profile registry/default/alias discovery.
|
||||
- `INSOMNIA_USER_MANIFEST` does not change profile registry discovery.
|
||||
- Existing CLI/TUI selector behavior still works with `profiles.toml`.
|
||||
- Docs no longer instruct users to put profile registry metadata in `manifest.toml`.
|
||||
- Focused manifest/tui/pod/client profile tests pass.
|
||||
- `cargo fmt --check`
|
||||
- Relevant checks pass.
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
<!-- event: create author: tickets.sh at: 2026-05-29T18:02:57Z -->
|
||||
|
||||
## Created
|
||||
|
||||
Created by tickets.sh create.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: close author: hare at: 2026-05-29T18:11:10Z status: closed -->
|
||||
|
||||
## Closed
|
||||
|
||||
---
|
||||
id: 20260529-180257-profile-registry-config-file
|
||||
slug: profile-registry-config-file
|
||||
title: Move profile registry out of manifest files
|
||||
status: closed
|
||||
kind: bug
|
||||
priority: P1
|
||||
labels: [manifest, profiles, config]
|
||||
created_at: 2026-05-29T18:02:57Z
|
||||
updated_at: 2026-05-29T18:11:09Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
The initial Nix manifest profiles implementation made profile discovery/default/alias metadata available from `[profiles]` sections inside `manifest.toml`. That is a design error.
|
||||
|
||||
A manifest is the Pod runtime configuration contract: model, scope, tools, memory, web, permissions, prompts, and other settings that are already selected for a Pod. Profile registry/default/alias metadata is application/project UX configuration used before a Pod manifest exists. Putting registry metadata inside `manifest.toml` makes the manifest both the runtime config and the profile selector config, contradicting the profile design.
|
||||
|
||||
This was caught immediately after merge, before compatibility needs exist. Do not keep backward compatibility for `[profiles]` in manifest files.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Move profile registry/default/alias configuration out of manifest files.
|
||||
- User registry file: `<config_dir>/profiles.toml`.
|
||||
- Project registry file: closest `.insomnia/profiles.toml` found by walking up from the cwd.
|
||||
- Builtin profile discovery can remain under bundled resources.
|
||||
- Remove manifest-file profile registry parsing.
|
||||
- `manifest.toml` must not be the source for profile registry/default/alias data.
|
||||
- `INSOMNIA_USER_MANIFEST` must not affect profile registry discovery.
|
||||
- `PodManifestConfig` / `PodManifest` must remain runtime config only.
|
||||
- Use a top-level `profiles.toml` schema rather than nesting under `[profiles]`.
|
||||
- Example:
|
||||
```toml
|
||||
default = "coder"
|
||||
|
||||
[profile]
|
||||
coder = "profiles/coder.nix"
|
||||
|
||||
[alias]
|
||||
work = "coder"
|
||||
```
|
||||
- The existing table form for entries may remain useful:
|
||||
```toml
|
||||
[profile.coder]
|
||||
path = "profiles/coder.nix"
|
||||
description = "Project coder"
|
||||
```
|
||||
- Keep existing profile selection semantics.
|
||||
- `default`, unqualified names, source-qualified names, aliases, ambiguity handling, and default markers should continue to work.
|
||||
- Unqualified alias/default targets remain source-local by default.
|
||||
- TUI fresh-spawn profile row and manifest-cascade opt-out should continue to work.
|
||||
- Update docs and tests.
|
||||
- Documentation must describe `profiles.toml`, not `[profiles]` in `manifest.toml`.
|
||||
- Tests should create `.insomnia/profiles.toml` / user `profiles.toml`, not `.insomnia/manifest.toml`, for registry behavior.
|
||||
- Add coverage proving `[profiles]` in `manifest.toml` is ignored for discovery.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- User profile registry is discovered from `<config_dir>/profiles.toml`.
|
||||
- Project profile registry is discovered from closest `.insomnia/profiles.toml`.
|
||||
- `[profiles]` in user/project `manifest.toml` is not read for profile registry/default/alias discovery.
|
||||
- `INSOMNIA_USER_MANIFEST` does not change profile registry discovery.
|
||||
- Existing CLI/TUI selector behavior still works with `profiles.toml`.
|
||||
- Docs no longer instruct users to put profile registry metadata in `manifest.toml`.
|
||||
- Focused manifest/tui/pod/client profile tests pass.
|
||||
- `cargo fmt --check`
|
||||
- Relevant checks pass.
|
||||
|
||||
|
||||
---
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
---
|
||||
id: 20260529-181528-remove-profile-aliases
|
||||
slug: remove-profile-aliases
|
||||
title: Remove profile aliases from profile registry
|
||||
status: closed
|
||||
kind: bug
|
||||
priority: P1
|
||||
labels: [profiles, config, simplification]
|
||||
created_at: 2026-05-29T18:15:28Z
|
||||
updated_at: 2026-05-29T18:20:44Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
The initial profile registry added `[alias]` as a convenience layer for redirecting one profile name to another. In practice this adds name-resolution complexity without a clear use case. It already caused bugs around source-local alias resolution and defaults pointing at aliases.
|
||||
|
||||
Profile selection should stay simple: a registry contains profile entries and an optional default that points directly at one profile entry. If users want a short or stable name, they can choose that name as the profile registry key.
|
||||
|
||||
There is no compatibility requirement for aliases because the feature has just landed and has not become a stable user-facing contract.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Remove profile alias support from registry parsing and selection.
|
||||
- Delete `ProfileAlias` and alias maps/resolution paths.
|
||||
- Remove `[alias]` from the `profiles.toml` schema.
|
||||
- Do not support alias-to-alias or alias-to-profile indirection.
|
||||
- Keep profile registry semantics simple.
|
||||
- Supported schema:
|
||||
```toml
|
||||
default = "coder"
|
||||
|
||||
[profile]
|
||||
coder = "profiles/coder.nix"
|
||||
researcher = "profiles/researcher.nix"
|
||||
```
|
||||
- Table form may remain:
|
||||
```toml
|
||||
[profile.coder]
|
||||
path = "profiles/coder.nix"
|
||||
description = "Coding profile"
|
||||
```
|
||||
- `default` must name a profile entry directly.
|
||||
- Unqualified defaults resolve within the declaring source.
|
||||
- Source-qualified defaults such as `user:coder` may remain if already implemented and useful.
|
||||
- Keep existing selector behavior for real profiles.
|
||||
- explicit path / `path:<path>`
|
||||
- discovered unqualified names
|
||||
- `default`
|
||||
- source-qualified names such as `project:coder`
|
||||
- ambiguity fail-closed
|
||||
- TUI manifest-cascade opt-out
|
||||
- Update docs and tests.
|
||||
- Remove alias examples and alias-specific tests.
|
||||
- Add/keep coverage proving default points directly at a profile entry.
|
||||
- Diagnostics for a missing default target should mention the missing profile, not alias behavior.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `profiles.toml` no longer accepts or documents `[alias]` as a supported feature.
|
||||
- `ProfileRegistry` has no alias state or alias resolution path.
|
||||
- Existing CLI/TUI profile selection works with direct profile entries and defaults.
|
||||
- Ambiguous unqualified real profile names still fail closed.
|
||||
- Docs describe only entries + default.
|
||||
- Focused manifest/tui/pod/client profile tests pass.
|
||||
- `cargo fmt --check`
|
||||
- Relevant checks pass.
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
---
|
||||
id: 20260529-181528-remove-profile-aliases
|
||||
slug: remove-profile-aliases
|
||||
title: Remove profile aliases from profile registry
|
||||
status: closed
|
||||
kind: bug
|
||||
priority: P1
|
||||
labels: [profiles, config, simplification]
|
||||
created_at: 2026-05-29T18:15:28Z
|
||||
updated_at: 2026-05-29T18:20:43Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
The initial profile registry added `[alias]` as a convenience layer for redirecting one profile name to another. In practice this adds name-resolution complexity without a clear use case. It already caused bugs around source-local alias resolution and defaults pointing at aliases.
|
||||
|
||||
Profile selection should stay simple: a registry contains profile entries and an optional default that points directly at one profile entry. If users want a short or stable name, they can choose that name as the profile registry key.
|
||||
|
||||
There is no compatibility requirement for aliases because the feature has just landed and has not become a stable user-facing contract.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Remove profile alias support from registry parsing and selection.
|
||||
- Delete `ProfileAlias` and alias maps/resolution paths.
|
||||
- Remove `[alias]` from the `profiles.toml` schema.
|
||||
- Do not support alias-to-alias or alias-to-profile indirection.
|
||||
- Keep profile registry semantics simple.
|
||||
- Supported schema:
|
||||
```toml
|
||||
default = "coder"
|
||||
|
||||
[profile]
|
||||
coder = "profiles/coder.nix"
|
||||
researcher = "profiles/researcher.nix"
|
||||
```
|
||||
- Table form may remain:
|
||||
```toml
|
||||
[profile.coder]
|
||||
path = "profiles/coder.nix"
|
||||
description = "Coding profile"
|
||||
```
|
||||
- `default` must name a profile entry directly.
|
||||
- Unqualified defaults resolve within the declaring source.
|
||||
- Source-qualified defaults such as `user:coder` may remain if already implemented and useful.
|
||||
- Keep existing selector behavior for real profiles.
|
||||
- explicit path / `path:<path>`
|
||||
- discovered unqualified names
|
||||
- `default`
|
||||
- source-qualified names such as `project:coder`
|
||||
- ambiguity fail-closed
|
||||
- TUI manifest-cascade opt-out
|
||||
- Update docs and tests.
|
||||
- Remove alias examples and alias-specific tests.
|
||||
- Add/keep coverage proving default points directly at a profile entry.
|
||||
- Diagnostics for a missing default target should mention the missing profile, not alias behavior.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `profiles.toml` no longer accepts or documents `[alias]` as a supported feature.
|
||||
- `ProfileRegistry` has no alias state or alias resolution path.
|
||||
- Existing CLI/TUI profile selection works with direct profile entries and defaults.
|
||||
- Ambiguous unqualified real profile names still fail closed.
|
||||
- Docs describe only entries + default.
|
||||
- Focused manifest/tui/pod/client profile tests pass.
|
||||
- `cargo fmt --check`
|
||||
- Relevant checks pass.
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
<!-- event: create author: tickets.sh at: 2026-05-29T18:15:28Z -->
|
||||
|
||||
## Created
|
||||
|
||||
Created by tickets.sh create.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: close author: hare at: 2026-05-29T18:20:44Z status: closed -->
|
||||
|
||||
## Closed
|
||||
|
||||
---
|
||||
id: 20260529-181528-remove-profile-aliases
|
||||
slug: remove-profile-aliases
|
||||
title: Remove profile aliases from profile registry
|
||||
status: closed
|
||||
kind: bug
|
||||
priority: P1
|
||||
labels: [profiles, config, simplification]
|
||||
created_at: 2026-05-29T18:15:28Z
|
||||
updated_at: 2026-05-29T18:20:43Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
The initial profile registry added `[alias]` as a convenience layer for redirecting one profile name to another. In practice this adds name-resolution complexity without a clear use case. It already caused bugs around source-local alias resolution and defaults pointing at aliases.
|
||||
|
||||
Profile selection should stay simple: a registry contains profile entries and an optional default that points directly at one profile entry. If users want a short or stable name, they can choose that name as the profile registry key.
|
||||
|
||||
There is no compatibility requirement for aliases because the feature has just landed and has not become a stable user-facing contract.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Remove profile alias support from registry parsing and selection.
|
||||
- Delete `ProfileAlias` and alias maps/resolution paths.
|
||||
- Remove `[alias]` from the `profiles.toml` schema.
|
||||
- Do not support alias-to-alias or alias-to-profile indirection.
|
||||
- Keep profile registry semantics simple.
|
||||
- Supported schema:
|
||||
```toml
|
||||
default = "coder"
|
||||
|
||||
[profile]
|
||||
coder = "profiles/coder.nix"
|
||||
researcher = "profiles/researcher.nix"
|
||||
```
|
||||
- Table form may remain:
|
||||
```toml
|
||||
[profile.coder]
|
||||
path = "profiles/coder.nix"
|
||||
description = "Coding profile"
|
||||
```
|
||||
- `default` must name a profile entry directly.
|
||||
- Unqualified defaults resolve within the declaring source.
|
||||
- Source-qualified defaults such as `user:coder` may remain if already implemented and useful.
|
||||
- Keep existing selector behavior for real profiles.
|
||||
- explicit path / `path:<path>`
|
||||
- discovered unqualified names
|
||||
- `default`
|
||||
- source-qualified names such as `project:coder`
|
||||
- ambiguity fail-closed
|
||||
- TUI manifest-cascade opt-out
|
||||
- Update docs and tests.
|
||||
- Remove alias examples and alias-specific tests.
|
||||
- Add/keep coverage proving default points directly at a profile entry.
|
||||
- Diagnostics for a missing default target should mention the missing profile, not alias behavior.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `profiles.toml` no longer accepts or documents `[alias]` as a supported feature.
|
||||
- `ProfileRegistry` has no alias state or alias resolution path.
|
||||
- Existing CLI/TUI profile selection works with direct profile entries and defaults.
|
||||
- Ambiguous unqualified real profile names still fail closed.
|
||||
- Docs describe only entries + default.
|
||||
- Focused manifest/tui/pod/client profile tests pass.
|
||||
- `cargo fmt --check`
|
||||
- Relevant checks pass.
|
||||
|
||||
|
||||
---
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
---
|
||||
id: 20260529-183318-builtin-profile-remove-manifest-cascade
|
||||
slug: builtin-profile-remove-manifest-cascade
|
||||
title: Add builtin Nix profile and remove manifest cascade mode
|
||||
status: closed
|
||||
kind: feature
|
||||
priority: P1
|
||||
labels: [profiles, manifest, nix, config]
|
||||
created_at: 2026-05-29T18:33:18Z
|
||||
updated_at: 2026-05-29T19:38:49Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
Manifest profiles are now the primary way to choose a Pod runtime configuration. The old ambient manifest cascade is no longer the desired model. A manifest is the resolved/low-level Pod runtime config, not an application profile selection mechanism, and it should not be implicitly assembled from user + project layers during normal startup.
|
||||
|
||||
The default dogfooding behavior currently expressed in this repository's `.insomnia/manifest.toml` should be converted to a bundled builtin Nix profile and registered as the default builtin profile. Users/projects can still override the selected default through `profiles.toml`, but the runtime config should come from the selected profile artifact.
|
||||
|
||||
The existing defaulting/required-field logic should remain shared: profile-produced artifacts and one-file manifests should both flow through `PodManifestConfig::builtin_defaults()` and `PodManifest::try_from(...)` so defaults and required-field validation are not duplicated.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Add a builtin Nix profile equivalent to the current repository `.insomnia/manifest.toml` dogfooding config.
|
||||
- Register it through builtin profile discovery.
|
||||
- It should be selectable as a builtin profile and usable as the fallback default when no user/project profile default is configured.
|
||||
- Preserve current behavior as closely as possible: model/provider, scope, worker language, compaction, memory settings, skills, web/tool settings, permissions, etc.
|
||||
- Be careful with relative paths such as `.` from the current manifest. A builtin profile must not accidentally scope writes to `resources/nix/profiles`; it should resolve workspace-root-sensitive paths correctly or introduce an explicit resolver input for the startup cwd/project root.
|
||||
- Remove ambient manifest cascade from normal startup.
|
||||
- Do not implicitly merge user manifest + project manifest + overlay for normal Pod/TUI creation.
|
||||
- Normal new Pod creation should choose a profile, defaulting through profile discovery.
|
||||
- User/project `profiles.toml` remains a discovery/default registry only.
|
||||
- Keep one-file manifest support as an explicit compatibility/debug path.
|
||||
- `--manifest <PATH>` should load exactly that file plus builtin defaults/validation.
|
||||
- It should not read user/project manifests.
|
||||
- It should remain mutually exclusive with `--profile` and restore/attach modes as appropriate.
|
||||
- Preserve shared default/required validation.
|
||||
- `PodManifestConfig::builtin_defaults()` remains the common low-level default layer.
|
||||
- `PodManifest::try_from(PodManifestConfig)` remains the common required-field/type validation boundary.
|
||||
- Profile artifact resolution and one-file manifest loading must both use this common path.
|
||||
- Update TUI/default behavior.
|
||||
- Fresh-spawn UI should default to the builtin profile when no user/project profile default exists.
|
||||
- `manifest cascade` wording should be removed or changed because cascade is no longer normal behavior.
|
||||
- If an explicit one-file manifest fallback remains selectable, label it accurately.
|
||||
- Update docs/tests.
|
||||
- Documentation should describe builtin profile defaulting and explicit one-file manifest usage.
|
||||
- Remove references to ambient manifest cascade as normal startup config.
|
||||
- Tests should prove normal startup/profile discovery does not read user/project manifests as a cascade.
|
||||
- Tests should prove `--manifest` is single-file only and still shares default/required validation.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Builtin profile discovery lists the converted default profile.
|
||||
- With no user/project `profiles.toml`, fresh Pod creation selects the builtin profile by default.
|
||||
- The converted builtin profile produces a valid `PodManifest` through the same validation path as other profiles.
|
||||
- Normal startup no longer reads/merges user/project `manifest.toml` files.
|
||||
- `--manifest <PATH>` remains available as single-file explicit mode and does not cascade.
|
||||
- Defaults and required-field errors are shared between profile and one-file manifest paths.
|
||||
- TUI labels no longer describe the default opt-out as `manifest cascade`.
|
||||
- Focused manifest/profile/pod/tui/client tests pass.
|
||||
- `cargo fmt --check`
|
||||
- Relevant checks pass.
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
---
|
||||
id: 20260529-183318-builtin-profile-remove-manifest-cascade
|
||||
slug: builtin-profile-remove-manifest-cascade
|
||||
title: Add builtin Nix profile and remove manifest cascade mode
|
||||
status: closed
|
||||
kind: feature
|
||||
priority: P1
|
||||
labels: [profiles, manifest, nix, config]
|
||||
created_at: 2026-05-29T18:33:18Z
|
||||
updated_at: 2026-05-29T19:38:49Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
Manifest profiles are now the primary way to choose a Pod runtime configuration. The old ambient manifest cascade is no longer the desired model. A manifest is the resolved/low-level Pod runtime config, not an application profile selection mechanism, and it should not be implicitly assembled from user + project layers during normal startup.
|
||||
|
||||
The default dogfooding behavior currently expressed in this repository's `.insomnia/manifest.toml` should be converted to a bundled builtin Nix profile and registered as the default builtin profile. Users/projects can still override the selected default through `profiles.toml`, but the runtime config should come from the selected profile artifact.
|
||||
|
||||
The existing defaulting/required-field logic should remain shared: profile-produced artifacts and one-file manifests should both flow through `PodManifestConfig::builtin_defaults()` and `PodManifest::try_from(...)` so defaults and required-field validation are not duplicated.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Add a builtin Nix profile equivalent to the current repository `.insomnia/manifest.toml` dogfooding config.
|
||||
- Register it through builtin profile discovery.
|
||||
- It should be selectable as a builtin profile and usable as the fallback default when no user/project profile default is configured.
|
||||
- Preserve current behavior as closely as possible: model/provider, scope, worker language, compaction, memory settings, skills, web/tool settings, permissions, etc.
|
||||
- Be careful with relative paths such as `.` from the current manifest. A builtin profile must not accidentally scope writes to `resources/nix/profiles`; it should resolve workspace-root-sensitive paths correctly or introduce an explicit resolver input for the startup cwd/project root.
|
||||
- Remove ambient manifest cascade from normal startup.
|
||||
- Do not implicitly merge user manifest + project manifest + overlay for normal Pod/TUI creation.
|
||||
- Normal new Pod creation should choose a profile, defaulting through profile discovery.
|
||||
- User/project `profiles.toml` remains a discovery/default registry only.
|
||||
- Keep one-file manifest support as an explicit compatibility/debug path.
|
||||
- `--manifest <PATH>` should load exactly that file plus builtin defaults/validation.
|
||||
- It should not read user/project manifests.
|
||||
- It should remain mutually exclusive with `--profile` and restore/attach modes as appropriate.
|
||||
- Preserve shared default/required validation.
|
||||
- `PodManifestConfig::builtin_defaults()` remains the common low-level default layer.
|
||||
- `PodManifest::try_from(PodManifestConfig)` remains the common required-field/type validation boundary.
|
||||
- Profile artifact resolution and one-file manifest loading must both use this common path.
|
||||
- Update TUI/default behavior.
|
||||
- Fresh-spawn UI should default to the builtin profile when no user/project profile default exists.
|
||||
- `manifest cascade` wording should be removed or changed because cascade is no longer normal behavior.
|
||||
- If an explicit one-file manifest fallback remains selectable, label it accurately.
|
||||
- Update docs/tests.
|
||||
- Documentation should describe builtin profile defaulting and explicit one-file manifest usage.
|
||||
- Remove references to ambient manifest cascade as normal startup config.
|
||||
- Tests should prove normal startup/profile discovery does not read user/project manifests as a cascade.
|
||||
- Tests should prove `--manifest` is single-file only and still shares default/required validation.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Builtin profile discovery lists the converted default profile.
|
||||
- With no user/project `profiles.toml`, fresh Pod creation selects the builtin profile by default.
|
||||
- The converted builtin profile produces a valid `PodManifest` through the same validation path as other profiles.
|
||||
- Normal startup no longer reads/merges user/project `manifest.toml` files.
|
||||
- `--manifest <PATH>` remains available as single-file explicit mode and does not cascade.
|
||||
- Defaults and required-field errors are shared between profile and one-file manifest paths.
|
||||
- TUI labels no longer describe the default opt-out as `manifest cascade`.
|
||||
- Focused manifest/profile/pod/tui/client tests pass.
|
||||
- `cargo fmt --check`
|
||||
- Relevant checks pass.
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
<!-- event: create author: tickets.sh at: 2026-05-29T18:33:18Z -->
|
||||
|
||||
## Created
|
||||
|
||||
Created by tickets.sh create.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: review author: insomnia at: 2026-05-29T19:37:45Z status: approve -->
|
||||
|
||||
## Review: approve
|
||||
|
||||
Reviewed merged implementation from branch `work/builtin-profile-remove-manifest-cascade` (`625730c`, follow-up `20ac0c9`, merged as `merge: builtin profile default startup`).
|
||||
|
||||
Approved after blocking fixes:
|
||||
|
||||
- `insomnia-pod --overlay` is no longer accepted as a user-facing generic TOML layer; SpawnPod now uses hidden typed `--spawn-config-json` and TUI restore uses typed `--session-pod-name` / `--resume-scope-json`.
|
||||
- `insomnia-pod --pod <name>` fresh-create compatibility is explicit: absent Pod metadata resolves the default profile and applies the requested pod name as a typed override, with test coverage.
|
||||
- TUI fresh spawn no longer has `cascade_has_scope`, `CwdDefault`, or cwd write-scope injection. Scope comes from the selected profile; session restore passes only the persisted scope snapshot.
|
||||
- `resources/nix/profiles/default.nix` matches the current dogfooding manifest values and builtin profile resolution resolves `target = "."` against the launch workspace/current directory rather than the bundled profile directory.
|
||||
- Profile and one-file manifest paths share `PodManifestConfig::builtin_defaults()` plus `PodManifest::try_from(...)` validation, and docs now describe prompt-loader limitations without reviving ambient manifest discovery.
|
||||
|
||||
Validation run in the implementation worktree:
|
||||
|
||||
- `cargo fmt --check`
|
||||
- `cargo test -p manifest profile -- --nocapture`
|
||||
- `cargo test -p pod profile -- --nocapture`
|
||||
- `cargo test -p client spawn -- --nocapture`
|
||||
- `cargo test -p tui spawn -- --nocapture`
|
||||
- `nix eval --json --file resources/nix/profiles/default.nix >/dev/null`
|
||||
- `cargo test -p pod --bin insomnia-pod -- --nocapture`
|
||||
- `cargo test -p pod spawn_config -- --nocapture`
|
||||
- `cargo test -p manifest -p pod -p tui --lib --bins`
|
||||
- `./tickets.sh doctor`
|
||||
- `git diff --check`
|
||||
|
||||
Known unrelated full integration failures in `cargo test -p manifest -p pod -p tui` remain in existing pod rollback integration tests and were not introduced by this ticket.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: close author: hare at: 2026-05-29T19:38:49Z status: closed -->
|
||||
|
||||
## Closed
|
||||
|
||||
---
|
||||
id: 20260529-183318-builtin-profile-remove-manifest-cascade
|
||||
slug: builtin-profile-remove-manifest-cascade
|
||||
title: Add builtin Nix profile and remove manifest cascade mode
|
||||
status: closed
|
||||
kind: feature
|
||||
priority: P1
|
||||
labels: [profiles, manifest, nix, config]
|
||||
created_at: 2026-05-29T18:33:18Z
|
||||
updated_at: 2026-05-29T19:38:49Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
Manifest profiles are now the primary way to choose a Pod runtime configuration. The old ambient manifest cascade is no longer the desired model. A manifest is the resolved/low-level Pod runtime config, not an application profile selection mechanism, and it should not be implicitly assembled from user + project layers during normal startup.
|
||||
|
||||
The default dogfooding behavior currently expressed in this repository's `.insomnia/manifest.toml` should be converted to a bundled builtin Nix profile and registered as the default builtin profile. Users/projects can still override the selected default through `profiles.toml`, but the runtime config should come from the selected profile artifact.
|
||||
|
||||
The existing defaulting/required-field logic should remain shared: profile-produced artifacts and one-file manifests should both flow through `PodManifestConfig::builtin_defaults()` and `PodManifest::try_from(...)` so defaults and required-field validation are not duplicated.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Add a builtin Nix profile equivalent to the current repository `.insomnia/manifest.toml` dogfooding config.
|
||||
- Register it through builtin profile discovery.
|
||||
- It should be selectable as a builtin profile and usable as the fallback default when no user/project profile default is configured.
|
||||
- Preserve current behavior as closely as possible: model/provider, scope, worker language, compaction, memory settings, skills, web/tool settings, permissions, etc.
|
||||
- Be careful with relative paths such as `.` from the current manifest. A builtin profile must not accidentally scope writes to `resources/nix/profiles`; it should resolve workspace-root-sensitive paths correctly or introduce an explicit resolver input for the startup cwd/project root.
|
||||
- Remove ambient manifest cascade from normal startup.
|
||||
- Do not implicitly merge user manifest + project manifest + overlay for normal Pod/TUI creation.
|
||||
- Normal new Pod creation should choose a profile, defaulting through profile discovery.
|
||||
- User/project `profiles.toml` remains a discovery/default registry only.
|
||||
- Keep one-file manifest support as an explicit compatibility/debug path.
|
||||
- `--manifest <PATH>` should load exactly that file plus builtin defaults/validation.
|
||||
- It should not read user/project manifests.
|
||||
- It should remain mutually exclusive with `--profile` and restore/attach modes as appropriate.
|
||||
- Preserve shared default/required validation.
|
||||
- `PodManifestConfig::builtin_defaults()` remains the common low-level default layer.
|
||||
- `PodManifest::try_from(PodManifestConfig)` remains the common required-field/type validation boundary.
|
||||
- Profile artifact resolution and one-file manifest loading must both use this common path.
|
||||
- Update TUI/default behavior.
|
||||
- Fresh-spawn UI should default to the builtin profile when no user/project profile default exists.
|
||||
- `manifest cascade` wording should be removed or changed because cascade is no longer normal behavior.
|
||||
- If an explicit one-file manifest fallback remains selectable, label it accurately.
|
||||
- Update docs/tests.
|
||||
- Documentation should describe builtin profile defaulting and explicit one-file manifest usage.
|
||||
- Remove references to ambient manifest cascade as normal startup config.
|
||||
- Tests should prove normal startup/profile discovery does not read user/project manifests as a cascade.
|
||||
- Tests should prove `--manifest` is single-file only and still shares default/required validation.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Builtin profile discovery lists the converted default profile.
|
||||
- With no user/project `profiles.toml`, fresh Pod creation selects the builtin profile by default.
|
||||
- The converted builtin profile produces a valid `PodManifest` through the same validation path as other profiles.
|
||||
- Normal startup no longer reads/merges user/project `manifest.toml` files.
|
||||
- `--manifest <PATH>` remains available as single-file explicit mode and does not cascade.
|
||||
- Defaults and required-field errors are shared between profile and one-file manifest paths.
|
||||
- TUI labels no longer describe the default opt-out as `manifest cascade`.
|
||||
- Focused manifest/profile/pod/tui/client tests pass.
|
||||
- `cargo fmt --check`
|
||||
- Relevant checks pass.
|
||||
|
||||
|
||||
---
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
<!-- event: migration author: tickets.sh-migration at: 2026-05-27T00:00:22Z -->
|
||||
|
||||
## Migrated
|
||||
|
||||
Migrated from TODO.md entry without a legacy ticket file. No legacy review file was present at migration time.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: plan author: hare at: 2026-05-29T16:09:27Z -->
|
||||
|
||||
## Plan
|
||||
|
||||
Implementation will proceed through a child orchestrator Pod in a dedicated worktree as an experiment in nested Pod delegation.
|
||||
|
||||
Initial implementation target:
|
||||
|
||||
- Introduce Nix profile resolution as a new manifest source before the existing manifest cascade.
|
||||
- Start with explicit path-based profiles; discovered-name/default selection and rich TUI picker can be staged after the core resolver if necessary.
|
||||
- Provide a minimal bundled Nix helper that can produce a typed resolved manifest/config artifact.
|
||||
- Keep existing TOML manifest loading as compatibility/debug/test infrastructure.
|
||||
- Persist enough profile identity and resolved snapshot data for future restore semantics; do not silently re-evaluate profiles on resume.
|
||||
- Secret values must remain references only; plaintext secrets are out of scope for the profile resolver.
|
||||
|
||||
The child orchestrator may split implementation among sub-Pods, but final merge/close remains parent-side.
|
||||
|
||||
|
||||
---
|
||||
91
work-items/open/20260529-161928-mcp-integration/item.md
Normal file
91
work-items/open/20260529-161928-mcp-integration/item.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
---
|
||||
id: 20260529-161928-mcp-integration
|
||||
slug: mcp-integration
|
||||
title: MCP integration as external tool/resource/prompt provider
|
||||
status: open
|
||||
kind: feature
|
||||
priority: P2
|
||||
labels: [mcp, tools, security, profiles]
|
||||
created_at: 2026-05-29T16:19:28Z
|
||||
updated_at: 2026-05-29T16:19:28Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
MCP (Model Context Protocol) is an open JSON-RPC based protocol for connecting AI applications to external systems. MCP servers can expose tools, resources, and prompts; clients/hosts can also expose capabilities such as roots, elicitation, sampling, logging, progress, and cancellation. Common transports are local stdio and remote Streamable HTTP.
|
||||
|
||||
Insomnia already has a built-in tool registry, manifest/profile-driven policy, scoped filesystem permissions, prompt/workflow assets, and bounded tool output. MCP should integrate with those existing safety and orchestration layers rather than bypass them.
|
||||
|
||||
MCP servers must be treated as untrusted external capability providers. Tool descriptions, annotations, resource content, and prompt templates returned by a server are data from an external system and must not implicitly weaken Insomnia scope, permission, prompt-context, or history-persistence rules.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Add MCP server configuration through the manifest/profile system.
|
||||
- Profiles should be able to declare named MCP servers.
|
||||
- Server configuration should support local stdio as the first transport.
|
||||
- Streamable HTTP can be a later phase, but the design should not preclude it.
|
||||
- Server process commands, arguments, environment/credential references, and working directory must be explicit.
|
||||
- Implement MCP client foundation.
|
||||
- JSON-RPC 2.0 message handling.
|
||||
- lifecycle initialization and capability negotiation.
|
||||
- `notifications/initialized` after successful initialization.
|
||||
- graceful shutdown and clear diagnostics for startup/protocol failures.
|
||||
- Bridge MCP tools into Insomnia's tool system.
|
||||
- Use `tools/list` to discover tools.
|
||||
- Register discovered MCP tools under stable names that include the server namespace.
|
||||
- Execute tool calls through `tools/call`.
|
||||
- Preserve existing PreToolCall permission policy and manifest/profile tool controls.
|
||||
- Bound tool result size and serialize results without allowing server-provided data to act as instructions.
|
||||
- Support `notifications/tools/list_changed` by refreshing the registered tool list when safe.
|
||||
- Treat MCP resources and prompts as explicit context sources, not hidden injections.
|
||||
- Initial implementation may defer resources/prompts, but the design must specify that `resources/read` and `prompts/get` are explicit user/model-visible operations with permission/policy gates.
|
||||
- Do not silently inject resource or prompt content into LLM context outside history.
|
||||
- Connect filesystem roots to Insomnia scope.
|
||||
- If MCP roots are supported, expose only authorized scope roots.
|
||||
- A server must not learn or operate on paths outside the configured scope.
|
||||
- Keep client-side MCP capabilities conservative.
|
||||
- Sampling is powerful and should be disabled initially or require explicit approval because it lets an MCP server request LLM completions.
|
||||
- Elicitation should require an approval/UI path before a server can request user input.
|
||||
- Logging/progress notifications should be surfaced as diagnostics without polluting model context.
|
||||
- Security and trust constraints.
|
||||
- Tool descriptions and schemas from MCP servers are untrusted metadata.
|
||||
- All MCP tool invocations remain subject to Insomnia tool permission policy.
|
||||
- Server-provided content must not override system/developer instructions.
|
||||
- Secrets are passed only through explicit env/secret references and must not be logged or exposed to model context.
|
||||
- Remote MCP servers require an explicit future design for auth, TLS, redirects, private network policy, and output limits.
|
||||
- UX and observability.
|
||||
- Startup failures should identify the MCP server and failing phase.
|
||||
- Tool list changes and server disconnects should be visible to the user/TUI.
|
||||
- Provide enough diagnostics for debugging without printing secrets.
|
||||
- Documentation.
|
||||
- Explain MCP's trust model in Insomnia.
|
||||
- Show examples for local stdio MCP servers.
|
||||
- Document how MCP tool names, permissions, scope, and profiles interact.
|
||||
|
||||
## Suggested implementation phases
|
||||
|
||||
1. stdio MCP client foundation with mock-server tests.
|
||||
2. `tools/list` / `tools/call` bridge into the existing tool registry and permission policy.
|
||||
3. Manifest/profile configuration and CLI/Pod startup integration.
|
||||
4. TUI diagnostics for server startup/disconnect/tool-list changes.
|
||||
5. Resources/prompts support as explicit operations.
|
||||
6. Streamable HTTP transport and auth design.
|
||||
7. Sampling/elicitation only after an approval/resume protocol exists.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- A manifest/profile can configure at least one local stdio MCP server.
|
||||
- Pod startup initializes the configured MCP server and reports clear diagnostics on failure.
|
||||
- `tools/list` results are registered as Insomnia tools with namespaced stable names.
|
||||
- Calling a registered MCP tool invokes `tools/call` and returns bounded structured output to the model.
|
||||
- Existing tool permission policy applies to MCP tools before execution.
|
||||
- MCP server metadata/content is treated as untrusted and does not bypass prompt-context or history-persistence rules.
|
||||
- Secrets used by MCP server configuration are represented as explicit env/secret references and are not logged or serialized as plaintext.
|
||||
- Filesystem roots, if exposed, are derived from authorized Insomnia scope only.
|
||||
- Sampling and elicitation are disabled or fail closed unless an explicit approval path is implemented.
|
||||
- Focused tests cover protocol lifecycle, tool discovery, tool call success/failure, permission denial, bounded output, server disconnect, and no-secret diagnostics.
|
||||
- Documentation includes a local stdio MCP server example and security guidance.
|
||||
- `cargo fmt --check`
|
||||
- Relevant manifest/tools/pod/tui tests pass.
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<!-- event: create author: tickets.sh at: 2026-05-29T16:19:28Z -->
|
||||
|
||||
## Created
|
||||
|
||||
Created by tickets.sh create.
|
||||
|
||||
---
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
---
|
||||
id: 20260529-163047-pod-event-scope-subdelegation-control-only
|
||||
slug: pod-event-scope-subdelegation-control-only
|
||||
title: Keep scope sub-delegation PodEvent out of agent notifications
|
||||
status: open
|
||||
kind: bug
|
||||
priority: P2
|
||||
labels: [pod, events, orchestration, context]
|
||||
created_at: 2026-05-29T16:30:47Z
|
||||
updated_at: 2026-05-29T16:30:47Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
Nested Pod orchestration currently emits a visible notification when a child Pod sub-delegates scope to its own child, for example:
|
||||
|
||||
```text
|
||||
pod `orchestrate-nix-manifest-profiles` sub-delegated scope to `manifest-profiles-audit-20260529`
|
||||
```
|
||||
|
||||
This comes from `PodEvent::ScopeSubDelegated`. The event itself is useful as control-plane data: parent Pods need it to update spawned-child registry state, preserve delegated scope ownership, and propagate the child/grandchild relationship upward. However, it does not usually require the parent LLM to take action.
|
||||
|
||||
At the moment all `PodEvent` values are pushed into the notification buffer and can trigger `RunForNotification` when the receiving Pod is idle. That makes scope delegation a model-visible semantic notification, adds noise to history/context, and can cause unnecessary auto-kicked LLM turns during nested orchestration.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Keep `PodEvent::ScopeSubDelegated` as a control-plane event.
|
||||
- Existing registry side effects must still run.
|
||||
- Scope ownership/reclaim behavior must not regress.
|
||||
- Upward propagation to higher-level parents must still happen when needed.
|
||||
- Do not expose scope sub-delegation as an agent notification.
|
||||
- Do not push `ScopeSubDelegated` into the Pod notification buffer.
|
||||
- Do not persist it as model-visible notification history.
|
||||
- Do not trigger `PendingRun::RunForNotification` solely because scope was sub-delegated.
|
||||
- Preserve agent-visible notifications for events that need orchestration attention.
|
||||
- `TurnEnded` should remain agent-visible.
|
||||
- `Errored` should remain agent-visible.
|
||||
- `ShutDown` should remain agent-visible unless a later design explicitly separates it.
|
||||
- Make the event visibility boundary explicit in code.
|
||||
- Prefer a small helper such as `PodEvent::should_notify_agent()` or an equivalent visibility classification.
|
||||
- Keep side effects and agent notification decisions separate so future control-plane events do not accidentally become model-visible.
|
||||
- Keep context/history principles intact.
|
||||
- Control-plane-only events must not be injected into LLM context without first becoming intentional history content.
|
||||
- Avoid extra prompt-cache churn and token use for events that are not actionable by the model.
|
||||
|
||||
## Suggested implementation notes
|
||||
|
||||
Likely areas:
|
||||
|
||||
- `crates/protocol/src/lib.rs`: add an explicit visibility/helper on `PodEvent`.
|
||||
- `crates/pod/src/controller.rs`: after `apply_event_side_effects`, only call `pod.push_pod_event_notify(event)` and set `PendingRun::RunForNotification` when the event is agent-visible.
|
||||
- `crates/pod/src/ipc/event.rs`: keep `ScopeSubDelegated` side effects unchanged.
|
||||
- `crates/pod/tests/controller_test.rs`: update/add coverage for control-only scope delegation and agent-visible lifecycle events.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `ScopeSubDelegated` still updates/propagates spawned-child registry state exactly as before.
|
||||
- `ScopeSubDelegated` no longer produces `[Notification] ... sub-delegated scope ...` in the parent Pod's agent-visible output/history.
|
||||
- `ScopeSubDelegated` does not auto-kick an idle parent Pod into a model run.
|
||||
- `TurnEnded`, `Errored`, and `ShutDown` still produce agent-visible notifications and can still wake an idle parent when appropriate.
|
||||
- Tests cover both the control-only `ScopeSubDelegated` path and at least one agent-visible `PodEvent` path.
|
||||
- `cargo fmt --check`
|
||||
- Relevant pod/protocol tests pass.
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<!-- event: create author: tickets.sh at: 2026-05-29T16:30:47Z -->
|
||||
|
||||
## Created
|
||||
|
||||
Created by tickets.sh create.
|
||||
|
||||
---
|
||||
Loading…
Reference in New Issue
Block a user