merge: 00001KVZG9BMS worker crate rename
This commit is contained in:
commit
2a7e877584
|
|
@ -11,12 +11,12 @@
|
||||||
|
|
||||||
LLM に投げる context への割り込みは、大きく2種類に分かれる。**前者は許されるが、後者は禁止**。
|
LLM に投げる context への割り込みは、大きく2種類に分かれる。**前者は許されるが、後者は禁止**。
|
||||||
|
|
||||||
Podの状態から純粋に再現可能で、且つ揮発性の無い操作であることが望ましい。(pruning、tool result の content 切り詰め、prompt cache anchor の付与等)。
|
Workerの状態から純粋に再現可能で、且つ揮発性の無い操作であることが望ましい。(pruning、tool result の content 切り詰め、prompt cache anchor の付与等)。
|
||||||
原則として、コンテキストは積み重ねるものであり、一時的にメッセージを差し込むことや、過去のメッセージを改ざんすることはKVキャッシュのヒット率を下げる。
|
原則として、コンテキストは積み重ねるものであり、一時的にメッセージを差し込むことや、過去のメッセージを改ざんすることはKVキャッシュのヒット率を下げる。
|
||||||
|
|
||||||
**禁止**: ターンを跨ぐことができない情報に基づいて、history に記録せずに context だけにコンテンツを差し込むこと。これをやると LLM はそれに反応して生成を行う一方、次以降のターンでhistoryに残らないため、「自分がなぜその発言/tool call をしたか」の根拠が消えるうえ、prompt cache のヒット率も低下させることになる。
|
**禁止**: ターンを跨ぐことができない情報に基づいて、history に記録せずに context だけにコンテンツを差し込むこと。これをやると LLM はそれに反応して生成を行う一方、次以降のターンでhistoryに残らないため、「自分がなぜその発言/tool call をしたか」の根拠が消えるうえ、prompt cache のヒット率も低下させることになる。
|
||||||
|
|
||||||
新しい input を context に乗せたいなら、必ず先に `worker.history` に append して commit すること。`history.json` への永続化はそこから自動的についてくる。Notify / PodEvent / `<system-reminder>` 系はこの原則で扱う。
|
新しい input を context に乗せたいなら、必ず先に `worker.history` に append して commit すること。`history.json` への永続化はそこから自動的についてくる。Notify / WorkerEvent / `<system-reminder>` 系はこの原則で扱う。
|
||||||
また、キャッシュを破壊するタイミングは正確にコントロールされる必要があり、キャッシュ破壊とトークン消費のトレードオフに基づいて慎重に設計されるべきである。
|
また、キャッシュを破壊するタイミングは正確にコントロールされる必要があり、キャッシュ破壊とトークン消費のトレードオフに基づいて慎重に設計されるべきである。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
94
Cargo.lock
generated
94
Cargo.lock
generated
|
|
@ -2873,52 +2873,6 @@ version = "0.2.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pod"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"arc-swap",
|
|
||||||
"async-trait",
|
|
||||||
"chrono",
|
|
||||||
"clap",
|
|
||||||
"client",
|
|
||||||
"dotenv",
|
|
||||||
"fs4",
|
|
||||||
"futures",
|
|
||||||
"futures-util",
|
|
||||||
"include_dir",
|
|
||||||
"libc",
|
|
||||||
"llm-engine",
|
|
||||||
"manifest",
|
|
||||||
"mcp",
|
|
||||||
"memory",
|
|
||||||
"minijinja",
|
|
||||||
"pod-registry",
|
|
||||||
"pod-store",
|
|
||||||
"protocol",
|
|
||||||
"provider",
|
|
||||||
"reqwest",
|
|
||||||
"schemars",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"session-metrics",
|
|
||||||
"session-store",
|
|
||||||
"tempfile",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"ticket",
|
|
||||||
"tokio",
|
|
||||||
"tokio-tungstenite",
|
|
||||||
"toml",
|
|
||||||
"tools",
|
|
||||||
"tracing",
|
|
||||||
"tungstenite",
|
|
||||||
"uuid",
|
|
||||||
"wasmtime",
|
|
||||||
"wat",
|
|
||||||
"workflow",
|
|
||||||
"yoi-plugin-pdk",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pod-registry"
|
name = "pod-registry"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
@ -5901,6 +5855,52 @@ dependencies = [
|
||||||
"wasmparser 0.248.0",
|
"wasmparser 0.248.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "worker"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"arc-swap",
|
||||||
|
"async-trait",
|
||||||
|
"chrono",
|
||||||
|
"clap",
|
||||||
|
"client",
|
||||||
|
"dotenv",
|
||||||
|
"fs4",
|
||||||
|
"futures",
|
||||||
|
"futures-util",
|
||||||
|
"include_dir",
|
||||||
|
"libc",
|
||||||
|
"llm-engine",
|
||||||
|
"manifest",
|
||||||
|
"mcp",
|
||||||
|
"memory",
|
||||||
|
"minijinja",
|
||||||
|
"pod-registry",
|
||||||
|
"pod-store",
|
||||||
|
"protocol",
|
||||||
|
"provider",
|
||||||
|
"reqwest",
|
||||||
|
"schemars",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"session-metrics",
|
||||||
|
"session-store",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"ticket",
|
||||||
|
"tokio",
|
||||||
|
"tokio-tungstenite",
|
||||||
|
"toml",
|
||||||
|
"tools",
|
||||||
|
"tracing",
|
||||||
|
"tungstenite",
|
||||||
|
"uuid",
|
||||||
|
"wasmtime",
|
||||||
|
"wat",
|
||||||
|
"workflow",
|
||||||
|
"yoi-plugin-pdk",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "workflow"
|
name = "workflow"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
@ -5942,7 +5942,6 @@ dependencies = [
|
||||||
"client",
|
"client",
|
||||||
"manifest",
|
"manifest",
|
||||||
"memory",
|
"memory",
|
||||||
"pod",
|
|
||||||
"pod-store",
|
"pod-store",
|
||||||
"project-record",
|
"project-record",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
@ -5954,6 +5953,7 @@ dependencies = [
|
||||||
"ticket",
|
"ticket",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tui",
|
"tui",
|
||||||
|
"worker",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ members = [
|
||||||
"crates/secrets",
|
"crates/secrets",
|
||||||
"crates/manifest",
|
"crates/manifest",
|
||||||
"crates/mcp",
|
"crates/mcp",
|
||||||
"crates/pod",
|
"crates/worker",
|
||||||
"crates/plugin-pdk",
|
"crates/plugin-pdk",
|
||||||
"crates/yoi",
|
"crates/yoi",
|
||||||
"crates/pod-store",
|
"crates/pod-store",
|
||||||
|
|
@ -35,7 +35,7 @@ default-members = [
|
||||||
"crates/secrets",
|
"crates/secrets",
|
||||||
"crates/manifest",
|
"crates/manifest",
|
||||||
"crates/mcp",
|
"crates/mcp",
|
||||||
"crates/pod",
|
"crates/worker",
|
||||||
"crates/plugin-pdk",
|
"crates/plugin-pdk",
|
||||||
"crates/yoi",
|
"crates/yoi",
|
||||||
"crates/pod-store",
|
"crates/pod-store",
|
||||||
|
|
@ -69,7 +69,7 @@ lint-common = { path = "crates/lint-common" }
|
||||||
memory = { path = "crates/memory" }
|
memory = { path = "crates/memory" }
|
||||||
ticket = { path = "crates/ticket" }
|
ticket = { path = "crates/ticket" }
|
||||||
project-record = { path = "crates/project-record" }
|
project-record = { path = "crates/project-record" }
|
||||||
pod = { path = "crates/pod" }
|
worker = { path = "crates/worker" }
|
||||||
yoi-plugin-pdk = { path = "crates/plugin-pdk" }
|
yoi-plugin-pdk = { path = "crates/plugin-pdk" }
|
||||||
yoi = { path = "crates/yoi" }
|
yoi = { path = "crates/yoi" }
|
||||||
pod-registry = { path = "crates/pod-registry" }
|
pod-registry = { path = "crates/pod-registry" }
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
Ticket を切るほどではないが、次に近所を触るときに合わせて拾いたい小粒な所見の置き場。
|
Ticket を切るほどではないが、次に近所を触るときに合わせて拾いたい小粒な所見の置き場。
|
||||||
|
|
||||||
- `crates/pod/src/controller.rs:1269-1278` — `worker_error_code` で `PodError::WorkflowResolve(_) => InvalidRequest` が post-commit な resolve エラー (`KnowledgeNotFound` 等) にも適用される。意味論的には妥当方向だが、resolve 系のエラー粒度を分けたくなったタイミングで再評価。
|
- `crates/worker/src/controller.rs:1453-1461` — `worker_error_code` で `WorkerError::WorkflowResolve(_) => InvalidRequest` が post-commit な resolve エラー (`KnowledgeNotFound` 等) にも適用される。意味論的には妥当方向だが、resolve 系のエラー粒度を分けたくなったタイミングで再評価。
|
||||||
- `crates/session-store/src/fs_store.rs:200-210` — `FsStore::read_entry_count` が `fs::read_to_string` で全文ロードしてから行数カウントするため O(n)。`ensure_head_or_fork` は run-start でしか呼ばれず現状は許容範囲だが、長期セッションが普通になった時点で `\n` バイト数の cheap count か末尾 seek に置き換える。
|
- `crates/session-store/src/fs_store.rs:200-210` — `FsStore::read_entry_count` が `fs::read_to_string` で全文ロードしてから行数カウントするため O(n)。`ensure_head_or_fork` は run-start でしか呼ばれず現状は許容範囲だが、長期セッションが普通になった時点で `\n` バイト数の cheap count か末尾 seek に置き換える。
|
||||||
- `crates/session-store/src/segment.rs:143-172` `ensure_head_or_fork` (free fn, test 専用・本番 caller ゼロ) と `crates/pod/src/pod.rs:1941-2006` `Pod::ensure_segment_head` (本番 inline) に live auto-fork の検知 + forked_from 記録が二重実装されている。entry-hash-abolish 以前からの重複で、両方独立にテスト済みだが drift 必至。session-store 側を本番から呼ぶ形に寄せるか free fn を畳むかは要設計判断。Pod state / fork 周辺を次に触るときに統合を検討。
|
- `crates/session-store/src/segment.rs:143-172` `ensure_head_or_fork` (free fn, test 専用・本番 caller ゼロ) と `crates/worker/src/worker.rs:2032` `Worker::ensure_segment_head` (本番 inline) に live auto-fork の検知 + forked_from 記録が二重実装されている。entry-hash-abolish 以前からの重複で、両方独立にテスト済みだが drift 必至。session-store 側を本番から呼ぶ形に寄せるか free fn を畳むかは要設計判断。Worker state / fork 周辺を次に触るときに統合を検討。
|
||||||
- `crates/pod/src/pod.rs:4100-4147` / `crates/pod/src/spawn/registry.rs:84-174` — restore 時の spawned child prune/reclaim が Pod restore path と spawned registry load path の両方に残っている。現状は安全側の重複チェックだが、Pod state / spawned registry 周辺を次に触るときに責務境界を再整理。
|
- `crates/worker/src/worker.rs` / `crates/worker/src/spawn/registry.rs:84-174` — restore 時の spawned child prune/reclaim が Worker restore path と spawned registry load path の両方に残っている。現状は安全側の重複チェックだが、Worker state / spawned registry 周辺を次に触るときに責務境界を再整理。
|
||||||
|
|
|
||||||
18
README.md
18
README.md
|
|
@ -1,19 +1,19 @@
|
||||||
# 夜居 / Yoi agent
|
# 夜居 / Yoi agent
|
||||||
|
|
||||||
Yoi is an agent runtime for building, running, and orchestrating LLM Pods while preserving explicit history, scoped capabilities, and developer-controlled workflows.
|
Yoi is an agent runtime for building, running, and orchestrating LLM Workers while preserving explicit history, scoped capabilities, and developer-controlled workflows.
|
||||||
|
|
||||||
## 1. Yoi agent
|
## 1. Yoi agent
|
||||||
|
|
||||||
Yoi focuses on long-running agent operation rather than one-off prompt execution. A named Pod can keep durable session history, run with explicit tool and filesystem authority, delegate bounded work to child Pods, and be inspected or restored through CLI/TUI surfaces.
|
Yoi focuses on long-running agent operation rather than one-off prompt execution. A named Worker can keep durable session history, run with explicit tool and filesystem authority, delegate bounded work to child Workers, and be inspected or restored through CLI/TUI surfaces.
|
||||||
|
|
||||||
Main highlights:
|
Main highlights:
|
||||||
|
|
||||||
- Named long-running **Pods** with durable session and metadata records.
|
- Named long-running **Workers** with durable session and metadata records.
|
||||||
- Explicit tool permissions and filesystem scopes.
|
- Explicit tool permissions and filesystem scopes.
|
||||||
- Multi-agent orchestration with scoped coder/reviewer Pods.
|
- Multi-agent orchestration with scoped coder/reviewer Workers.
|
||||||
- Profile, Manifest, and prompt-based runtime configuration.
|
- Profile, Manifest, and prompt-based runtime configuration.
|
||||||
- Local Tickets and workflow files for auditable project coordination.
|
- Local Tickets and workflow files for auditable project coordination.
|
||||||
- TUI and CLI entry points, including the `yoi panel` workspace Dashboard and single-Pod Console.
|
- TUI and CLI entry points, including the `yoi panel` workspace Dashboard and single-Worker Console.
|
||||||
|
|
||||||
Yoi is actively dogfooded in this repository. Public APIs, configuration formats, and workflows may still change.
|
Yoi is actively dogfooded in this repository. Public APIs, configuration formats, and workflows may still change.
|
||||||
|
|
||||||
|
|
@ -39,14 +39,14 @@ nix build .#yoi
|
||||||
yoi --help
|
yoi --help
|
||||||
yoi
|
yoi
|
||||||
yoi panel
|
yoi panel
|
||||||
yoi --pod <name>
|
yoi --worker <name>
|
||||||
yoi pod --help
|
yoi worker --help
|
||||||
```
|
```
|
||||||
|
|
||||||
Typical flow:
|
Typical flow:
|
||||||
|
|
||||||
1. Configure providers, models, profiles, prompts, and scopes.
|
1. Configure providers, models, profiles, prompts, and scopes.
|
||||||
2. Start or attach to a named Pod in the Console, or inspect workspace activity in the Dashboard.
|
2. Start or attach to a named Worker in the Console, or inspect workspace activity in the Dashboard.
|
||||||
3. Use explicit tools and scoped delegation for multi-agent work.
|
3. Use explicit tools and scoped delegation for multi-agent work.
|
||||||
4. Record project work through Tickets, workflow files, and git history.
|
4. Record project work through Tickets, workflow files, and git history.
|
||||||
|
|
||||||
|
|
@ -60,7 +60,7 @@ Key docs:
|
||||||
|
|
||||||
- [`docs/design/overview.md`](docs/design/overview.md) — architecture and crate ownership map.
|
- [`docs/design/overview.md`](docs/design/overview.md) — architecture and crate ownership map.
|
||||||
- [`docs/design/context-history.md`](docs/design/context-history.md) — history/context invariants.
|
- [`docs/design/context-history.md`](docs/design/context-history.md) — history/context invariants.
|
||||||
- [`docs/design/pod-session-state.md`](docs/design/pod-session-state.md) — Pod identity, metadata, and session logs.
|
- [`docs/design/worker-session-state.md`](docs/design/worker-session-state.md) — Worker identity, metadata, and session logs.
|
||||||
- [`docs/design/profiles-manifests-prompts.md`](docs/design/profiles-manifests-prompts.md) — Profiles, Manifests, and prompt resources.
|
- [`docs/design/profiles-manifests-prompts.md`](docs/design/profiles-manifests-prompts.md) — Profiles, Manifests, and prompt resources.
|
||||||
- [`docs/design/tool-permissions-scope.md`](docs/design/tool-permissions-scope.md) — tool policy and filesystem scope.
|
- [`docs/design/tool-permissions-scope.md`](docs/design/tool-permissions-scope.md) — tool policy and filesystem scope.
|
||||||
- [`docs/development/work-items.md`](docs/development/work-items.md) — Ticket workflow and project records.
|
- [`docs/development/work-items.md`](docs/development/work-items.md) — Ticket workflow and project records.
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@
|
||||||
|
|
||||||
## Role
|
## Role
|
||||||
|
|
||||||
`client` contains reusable socket-client and runtime-command mechanics for talking to Pods from CLI/TUI code.
|
`client` contains reusable socket-client and runtime-command mechanics for talking to Workers from CLI/TUI code.
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
Owns:
|
Owns:
|
||||||
|
|
||||||
- one-shot Pod socket client behavior
|
- one-shot Worker socket client behavior
|
||||||
- request/reply delivery mechanics
|
- request/reply delivery mechanics
|
||||||
- runtime command construction below the product façade
|
- runtime command construction below the product façade
|
||||||
- shared attach/status probing helpers used by higher layers
|
- shared attach/status probing helpers used by higher layers
|
||||||
|
|
@ -16,15 +16,15 @@ Owns:
|
||||||
Does not own:
|
Does not own:
|
||||||
|
|
||||||
- product command names (`yoi`)
|
- product command names (`yoi`)
|
||||||
- Pod state authority (`pod`, `pod-store`, `session-store`)
|
- Worker state authority (`worker`, `pod-store`, `session-store`)
|
||||||
- UI rendering (`tui`)
|
- UI rendering (`tui`)
|
||||||
- Engine turn semantics (`llm-engine`)
|
- Engine turn semantics (`llm-engine`)
|
||||||
|
|
||||||
## Design notes
|
## Design notes
|
||||||
|
|
||||||
The client boundary lets `tui` and `yoi` share Pod communication without making library crates depend on the product binary. Socket clients should drain connect-time snapshot/alert traffic before sending a method or deciding status.
|
The client boundary lets `tui` and `yoi` share Worker communication without making library crates depend on the product binary. Socket clients should drain connect-time snapshot/alert traffic before sending a method or deciding status.
|
||||||
|
|
||||||
## See also
|
## See also
|
||||||
|
|
||||||
- [`../../docs/design/pod-session-state.md`](../../docs/design/pod-session-state.md)
|
- [`../../docs/design/worker-session-state.md`](../../docs/design/worker-session-state.md)
|
||||||
- [`../../docs/design/overview.md`](../../docs/design/overview.md)
|
- [`../../docs/design/overview.md`](../../docs/design/overview.md)
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,28 @@
|
||||||
//! Pod プロトコルを喋るクライアント。
|
//! Worker プロトコルを喋るクライアント。
|
||||||
//!
|
//!
|
||||||
//! - [`PodClient`]: 既存 pod の Unix ソケットへ接続して `Method` を送り、
|
//! - [`WorkerClient`]: 既存 worker の Unix ソケットへ接続して `Method` を送り、
|
||||||
//! `Event` を受け取る低レベル接続。
|
//! `Event` を受け取る低レベル接続。
|
||||||
//! - [`spawn`]: pod バイナリをサブプロセスとして起動し、`YOI-READY`
|
//! - [`spawn`]: worker バイナリをサブプロセスとして起動し、`YOI-READY`
|
||||||
//! ハンドシェイクが終わるまで待つフロー。subprocess を立ち上げる必要が
|
//! ハンドシェイクが終わるまで待つフロー。subprocess を立ち上げる必要が
|
||||||
//! ない呼び出し側 (=既存 pod に attach する場合) は使わなくてよい。
|
//! ない呼び出し側 (=既存 worker に attach する場合) は使わなくてよい。
|
||||||
//!
|
//!
|
||||||
//! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。
|
//! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。
|
||||||
|
|
||||||
mod pod_client;
|
|
||||||
pub mod runtime_command;
|
pub mod runtime_command;
|
||||||
pub mod spawn;
|
pub mod spawn;
|
||||||
pub mod ticket_role;
|
pub mod ticket_role;
|
||||||
|
mod worker_client;
|
||||||
|
|
||||||
pub use runtime_command::PodRuntimeCommand;
|
pub use runtime_command::WorkerRuntimeCommand;
|
||||||
|
|
||||||
pub use pod_client::PodClient;
|
|
||||||
pub use spawn::{
|
pub use spawn::{
|
||||||
PodProcessLaunchConfig, PodProcessLaunchOptions, SpawnConfig, SpawnError, SpawnReady,
|
SpawnConfig, SpawnError, SpawnReady, WorkerProcessLaunchConfig, WorkerProcessLaunchOptions,
|
||||||
spawn_pod, spawn_pod_with_options,
|
spawn_worker, spawn_worker_with_options,
|
||||||
};
|
};
|
||||||
pub use ticket_role::{
|
pub use ticket_role::{
|
||||||
TicketRef, TicketRoleLaunchContext, TicketRoleLaunchError, TicketRoleLaunchOptions,
|
TicketRef, TicketRoleLaunchContext, TicketRoleLaunchError, TicketRoleLaunchOptions,
|
||||||
TicketRoleLaunchPlan, TicketRoleLaunchResult, TicketRolePreRunWarning, launch_ticket_role_pod,
|
TicketRoleLaunchPlan, TicketRoleLaunchResult, TicketRolePreRunWarning,
|
||||||
launch_ticket_role_pod_with_options, plan_ticket_role_launch,
|
launch_ticket_role_worker, launch_ticket_role_worker_with_options, plan_ticket_role_launch,
|
||||||
plan_ticket_role_launch_with_config,
|
plan_ticket_role_launch_with_config,
|
||||||
};
|
};
|
||||||
|
pub use worker_client::WorkerClient;
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,12 @@ use std::path::{Path, PathBuf};
|
||||||
const POD_RUNTIME_COMMAND_ENV: &str = "YOI_POD_RUNTIME_COMMAND";
|
const POD_RUNTIME_COMMAND_ENV: &str = "YOI_POD_RUNTIME_COMMAND";
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct PodRuntimeCommand {
|
pub struct WorkerRuntimeCommand {
|
||||||
pub program: PathBuf,
|
pub program: PathBuf,
|
||||||
pub prefix_args: Vec<OsString>,
|
pub prefix_args: Vec<OsString>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PodRuntimeCommand {
|
impl WorkerRuntimeCommand {
|
||||||
pub fn new(program: impl Into<PathBuf>, prefix_args: Vec<OsString>) -> Self {
|
pub fn new(program: impl Into<PathBuf>, prefix_args: Vec<OsString>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
program: program.into(),
|
program: program.into(),
|
||||||
|
|
@ -24,15 +24,15 @@ impl PodRuntimeCommand {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn for_executable(program: impl Into<PathBuf>) -> Self {
|
pub fn for_executable(program: impl Into<PathBuf>) -> Self {
|
||||||
Self::new(program, vec![OsString::from("pod")])
|
Self::new(program, vec![OsString::from("worker")])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the Pod runtime command used for subprocess launches.
|
/// Resolve the Worker runtime command used for subprocess launches.
|
||||||
///
|
///
|
||||||
/// The default launch path is always the current `yoi` executable plus
|
/// The default launch path is always the current `yoi` executable plus
|
||||||
/// the unified `pod` prefix argument. During development, a non-empty
|
/// the unified `worker` prefix argument. During development, a non-empty
|
||||||
/// `YOI_POD_RUNTIME_COMMAND` value replaces only the executable path;
|
/// `YOI_POD_RUNTIME_COMMAND` value replaces only the executable path;
|
||||||
/// the `pod` prefix is still added here and the env value is not parsed as a
|
/// the `worker` prefix is still added here and the env value is not parsed as a
|
||||||
/// shell command.
|
/// shell command.
|
||||||
pub fn resolve() -> io::Result<Self> {
|
pub fn resolve() -> io::Result<Self> {
|
||||||
Self::resolve_from_env_value(
|
Self::resolve_from_env_value(
|
||||||
|
|
@ -74,7 +74,7 @@ impl PodRuntimeCommand {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for PodRuntimeCommand {
|
impl fmt::Display for WorkerRuntimeCommand {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
write!(f, "{}", self.program.display())?;
|
write!(f, "{}", self.program.display())?;
|
||||||
for arg in &self.prefix_args {
|
for arg in &self.prefix_args {
|
||||||
|
|
@ -89,14 +89,14 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn yoi_binary_defaults_to_pod_prefix() {
|
fn yoi_binary_defaults_to_worker_prefix() {
|
||||||
let command = PodRuntimeCommand::for_executable("/opt/yoi/bin/yoi");
|
let command = WorkerRuntimeCommand::for_executable("/opt/yoi/bin/yoi");
|
||||||
|
|
||||||
assert_eq!(command.program(), Path::new("/opt/yoi/bin/yoi"));
|
assert_eq!(command.program(), Path::new("/opt/yoi/bin/yoi"));
|
||||||
assert_eq!(command.prefix_args(), [OsString::from("pod")]);
|
assert_eq!(command.prefix_args(), [OsString::from("worker")]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
command.argv_with(["--pod", "agent"]),
|
command.argv_with(["--worker", "agent"]),
|
||||||
vec!["pod", "--pod", "agent"]
|
vec!["worker", "--worker", "agent"]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(OsString::from)
|
.map(OsString::from)
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
|
|
@ -104,14 +104,14 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn any_runtime_executable_gets_pod_prefix() {
|
fn any_runtime_executable_gets_worker_prefix() {
|
||||||
let command = PodRuntimeCommand::for_executable("/opt/yoi/bin/custom-runtime");
|
let command = WorkerRuntimeCommand::for_executable("/opt/yoi/bin/custom-runtime");
|
||||||
|
|
||||||
assert_eq!(command.program(), Path::new("/opt/yoi/bin/custom-runtime"));
|
assert_eq!(command.program(), Path::new("/opt/yoi/bin/custom-runtime"));
|
||||||
assert_eq!(command.prefix_args(), [OsString::from("pod")]);
|
assert_eq!(command.prefix_args(), [OsString::from("worker")]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
command.argv_with(["--pod", "agent"]),
|
command.argv_with(["--worker", "agent"]),
|
||||||
vec!["pod", "--pod", "agent"]
|
vec!["worker", "--worker", "agent"]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(OsString::from)
|
.map(OsString::from)
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
|
|
@ -120,43 +120,43 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_uses_current_exe_when_override_is_unset() {
|
fn resolve_uses_current_exe_when_override_is_unset() {
|
||||||
let command = PodRuntimeCommand::resolve_from_env_value(None, || {
|
let command = WorkerRuntimeCommand::resolve_from_env_value(None, || {
|
||||||
Ok(PathBuf::from("/opt/yoi/bin/yoi"))
|
Ok(PathBuf::from("/opt/yoi/bin/yoi"))
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
command,
|
command,
|
||||||
PodRuntimeCommand::for_executable("/opt/yoi/bin/yoi")
|
WorkerRuntimeCommand::for_executable("/opt/yoi/bin/yoi")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_uses_current_exe_when_override_is_empty() {
|
fn resolve_uses_current_exe_when_override_is_empty() {
|
||||||
let command = PodRuntimeCommand::resolve_from_env_value(Some(OsString::new()), || {
|
let command = WorkerRuntimeCommand::resolve_from_env_value(Some(OsString::new()), || {
|
||||||
Ok(PathBuf::from("/opt/yoi/bin/yoi"))
|
Ok(PathBuf::from("/opt/yoi/bin/yoi"))
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
command,
|
command,
|
||||||
PodRuntimeCommand::for_executable("/opt/yoi/bin/yoi")
|
WorkerRuntimeCommand::for_executable("/opt/yoi/bin/yoi")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_override_replaces_only_program_and_keeps_pod_prefix() {
|
fn resolve_override_replaces_only_program_and_keeps_worker_prefix() {
|
||||||
let command = PodRuntimeCommand::resolve_from_env_value(
|
let command = WorkerRuntimeCommand::resolve_from_env_value(
|
||||||
Some(OsString::from("/tmp/rebuilt yoi")),
|
Some(OsString::from("/tmp/rebuilt yoi")),
|
||||||
|| panic!("override must not inspect current_exe"),
|
|| panic!("override must not inspect current_exe"),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(command.program(), Path::new("/tmp/rebuilt yoi"));
|
assert_eq!(command.program(), Path::new("/tmp/rebuilt yoi"));
|
||||||
assert_eq!(command.prefix_args(), [OsString::from("pod")]);
|
assert_eq!(command.prefix_args(), [OsString::from("worker")]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
command.argv_with(["--pod", "agent"]),
|
command.argv_with(["--worker", "agent"]),
|
||||||
vec!["pod", "--pod", "agent"]
|
vec!["worker", "--worker", "agent"]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(OsString::from)
|
.map(OsString::from)
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
//! Pod runtime command をサブプロセスとして立ち上げ、`YOI-READY` を待つ
|
//! Worker runtime command をサブプロセスとして立ち上げ、`YOI-READY` を待つ
|
||||||
//! ハンドシェイク。
|
//! ハンドシェイク。
|
||||||
//!
|
//!
|
||||||
//! - 親プロセス (TUI / GUI / E2E) は profile/default/typed restore flags を
|
//! - 親プロセス (TUI / GUI / E2E) は profile/default/typed restore flags を
|
||||||
//! 指定してこの関数に渡す。pod はそれを受けて socket を bind し、stderr に
|
//! 指定してこの関数に渡す。worker はそれを受けて socket を bind し、stderr に
|
||||||
//! `YOI-READY\t<name>\t<socket>` を吐く。
|
//! `YOI-READY\t<name>\t<socket>` を吐く。
|
||||||
//! - 待機中の stderr 行は `progress` コールバック越しに呼び出し側へ流す。
|
//! - 待機中の stderr 行は `progress` コールバック越しに呼び出し側へ流す。
|
||||||
//! UI の進捗表示や E2E のログ収集はここで賄う。
|
//! UI の進捗表示や E2E のログ収集はここで賄う。
|
||||||
//! - `kill_on_drop = false` + `process_group(0)` により、親プロセス
|
//! - `kill_on_drop = false` + `process_group(0)` により、親プロセス
|
||||||
//! ライフサイクルから切り離した detached pod を作る。ready 後の lifecycle
|
//! ライフサイクルから切り離した detached worker を作る。ready 後の lifecycle
|
||||||
//! 管理は runtime ディレクトリ / socket を介して行う。
|
//! 管理は runtime ディレクトリ / socket を介して行う。
|
||||||
|
|
||||||
use std::io;
|
use std::io;
|
||||||
|
|
@ -15,7 +15,7 @@ use std::path::{Path, PathBuf};
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::PodRuntimeCommand;
|
use crate::WorkerRuntimeCommand;
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
|
@ -23,14 +23,14 @@ const READY_PREFIX: &str = "YOI-READY\t";
|
||||||
const READY_TIMEOUT: Duration = Duration::from_secs(20);
|
const READY_TIMEOUT: Duration = Duration::from_secs(20);
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct PodProcessLaunchConfig {
|
pub struct WorkerProcessLaunchConfig {
|
||||||
pub runtime_command: PodRuntimeCommand,
|
pub runtime_command: WorkerRuntimeCommand,
|
||||||
/// `pod.name` として使う識別子。runtime ディレクトリ
|
/// `worker.name` として使う識別子。runtime ディレクトリ
|
||||||
/// (`manifest::paths::pod_runtime_dir`) の解決と、ready 行に乗る
|
/// (`manifest::paths::worker_runtime_dir`) の解決と、ready 行に乗る
|
||||||
/// 名前との突き合わせに使う。
|
/// 名前との突き合わせに使う。
|
||||||
pub pod_name: String,
|
pub worker_name: String,
|
||||||
/// Optional reusable Profile selector. Pod identity is always supplied
|
/// Optional reusable Profile selector. Worker identity is always supplied
|
||||||
/// separately with `--pod`; profile selection must not imply a name.
|
/// separately with `--worker`; profile selection must not imply a name.
|
||||||
pub profile: Option<String>,
|
pub profile: Option<String>,
|
||||||
/// Explicit runtime workspace root. The child receives it via
|
/// Explicit runtime workspace root. The child receives it via
|
||||||
/// `--workspace` so startup does not infer workspace identity from the
|
/// `--workspace` so startup does not infer workspace identity from the
|
||||||
|
|
@ -46,7 +46,7 @@ pub struct PodProcessLaunchConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
pub struct PodProcessLaunchOptions {
|
pub struct WorkerProcessLaunchOptions {
|
||||||
/// Extra child CLI arguments supplied by an upper resolver layer. The
|
/// Extra child CLI arguments supplied by an upper resolver layer. The
|
||||||
/// low-level launch config intentionally does not model Ticket IDs,
|
/// low-level launch config intentionally does not model Ticket IDs,
|
||||||
/// Ticket roles, orchestration roles, executable authority, or raw
|
/// Ticket roles, orchestration roles, executable authority, or raw
|
||||||
|
|
@ -54,7 +54,7 @@ pub struct PodProcessLaunchOptions {
|
||||||
pub extra_args: Vec<String>,
|
pub extra_args: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PodProcessLaunchOptions {
|
impl WorkerProcessLaunchOptions {
|
||||||
pub fn with_hidden_arg(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
|
pub fn with_hidden_arg(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
|
||||||
self.extra_args.extend([name.into(), value.into()]);
|
self.extra_args.extend([name.into(), value.into()]);
|
||||||
self
|
self
|
||||||
|
|
@ -65,11 +65,11 @@ impl PodProcessLaunchOptions {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type SpawnConfig = PodProcessLaunchConfig;
|
pub type SpawnConfig = WorkerProcessLaunchConfig;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct SpawnReady {
|
pub struct SpawnReady {
|
||||||
pub pod_name: String,
|
pub worker_name: String,
|
||||||
pub socket_path: PathBuf,
|
pub socket_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,11 +78,11 @@ pub enum SpawnError {
|
||||||
Io(io::Error),
|
Io(io::Error),
|
||||||
/// runtime ディレクトリが解決できなかった (環境変数未設定等)。
|
/// runtime ディレクトリが解決できなかった (環境変数未設定等)。
|
||||||
RuntimeDirUnavailable,
|
RuntimeDirUnavailable,
|
||||||
PodLaunchFailed {
|
WorkerLaunchFailed {
|
||||||
command: PodRuntimeCommand,
|
command: WorkerRuntimeCommand,
|
||||||
source: io::Error,
|
source: io::Error,
|
||||||
},
|
},
|
||||||
PodExitedEarly {
|
WorkerExitedEarly {
|
||||||
stderr_tail: String,
|
stderr_tail: String,
|
||||||
},
|
},
|
||||||
Timeout,
|
Timeout,
|
||||||
|
|
@ -96,20 +96,20 @@ impl std::fmt::Display for SpawnError {
|
||||||
f,
|
f,
|
||||||
"could not resolve runtime directory (set YOI_HOME, YOI_RUNTIME_DIR, XDG_RUNTIME_DIR, or HOME)"
|
"could not resolve runtime directory (set YOI_HOME, YOI_RUNTIME_DIR, XDG_RUNTIME_DIR, or HOME)"
|
||||||
),
|
),
|
||||||
Self::PodLaunchFailed { command, source } => write!(
|
Self::WorkerLaunchFailed { command, source } => write!(
|
||||||
f,
|
f,
|
||||||
"failed to launch pod runtime command `{command}`: {source}"
|
"failed to launch worker runtime command `{command}`: {source}"
|
||||||
),
|
),
|
||||||
Self::PodExitedEarly { stderr_tail } => {
|
Self::WorkerExitedEarly { stderr_tail } => {
|
||||||
if stderr_tail.is_empty() {
|
if stderr_tail.is_empty() {
|
||||||
write!(f, "pod exited before becoming ready")
|
write!(f, "worker exited before becoming ready")
|
||||||
} else {
|
} else {
|
||||||
write!(f, "pod exited before becoming ready: {stderr_tail}")
|
write!(f, "worker exited before becoming ready: {stderr_tail}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Self::Timeout => write!(
|
Self::Timeout => write!(
|
||||||
f,
|
f,
|
||||||
"pod did not become ready within {}s",
|
"worker did not become ready within {}s",
|
||||||
READY_TIMEOUT.as_secs()
|
READY_TIMEOUT.as_secs()
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
@ -119,8 +119,8 @@ impl std::fmt::Display for SpawnError {
|
||||||
impl std::error::Error for SpawnError {
|
impl std::error::Error for SpawnError {
|
||||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
match self {
|
match self {
|
||||||
Self::Io(error) | Self::PodLaunchFailed { source: error, .. } => Some(error),
|
Self::Io(error) | Self::WorkerLaunchFailed { source: error, .. } => Some(error),
|
||||||
Self::RuntimeDirUnavailable | Self::PodExitedEarly { .. } | Self::Timeout => None,
|
Self::RuntimeDirUnavailable | Self::WorkerExitedEarly { .. } | Self::Timeout => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -131,7 +131,10 @@ impl From<io::Error> for SpawnError {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn runtime_args(config: &PodProcessLaunchConfig, options: &PodProcessLaunchOptions) -> Vec<String> {
|
fn runtime_args(
|
||||||
|
config: &WorkerProcessLaunchConfig,
|
||||||
|
options: &WorkerProcessLaunchOptions,
|
||||||
|
) -> Vec<String> {
|
||||||
let mut args = vec![
|
let mut args = vec![
|
||||||
"--workspace".to_string(),
|
"--workspace".to_string(),
|
||||||
config.workspace_root.display().to_string(),
|
config.workspace_root.display().to_string(),
|
||||||
|
|
@ -140,11 +143,11 @@ fn runtime_args(config: &PodProcessLaunchConfig, options: &PodProcessLaunchOptio
|
||||||
args.extend([
|
args.extend([
|
||||||
"--session".to_string(),
|
"--session".to_string(),
|
||||||
id.to_string(),
|
id.to_string(),
|
||||||
"--pod".to_string(),
|
"--worker".to_string(),
|
||||||
config.pod_name.clone(),
|
config.worker_name.clone(),
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
args.extend(["--pod".to_string(), config.pod_name.clone()]);
|
args.extend(["--worker".to_string(), config.worker_name.clone()]);
|
||||||
if let Some(profile) = &config.profile {
|
if let Some(profile) = &config.profile {
|
||||||
args.extend(["--profile".to_string(), profile.clone()]);
|
args.extend(["--profile".to_string(), profile.clone()]);
|
||||||
}
|
}
|
||||||
|
|
@ -153,32 +156,32 @@ fn runtime_args(config: &PodProcessLaunchConfig, options: &PodProcessLaunchOptio
|
||||||
args
|
args
|
||||||
}
|
}
|
||||||
|
|
||||||
/// pod を spawn し、`YOI-READY` ハンドシェイクが終わるまで待つ。
|
/// worker を spawn し、`YOI-READY` ハンドシェイクが終わるまで待つ。
|
||||||
///
|
///
|
||||||
/// `progress` は ready 行を見つけるまでに観測した stderr の各行で呼ばれる
|
/// `progress` は ready 行を見つけるまでに観測した stderr の各行で呼ばれる
|
||||||
/// (ready 行自体は除外される)。UI の表示更新や E2E ログ取得に使う。
|
/// (ready 行自体は除外される)。UI の表示更新や E2E ログ取得に使う。
|
||||||
pub async fn spawn_pod<F>(
|
pub async fn spawn_worker<F>(
|
||||||
config: PodProcessLaunchConfig,
|
config: WorkerProcessLaunchConfig,
|
||||||
progress: F,
|
progress: F,
|
||||||
) -> Result<SpawnReady, SpawnError>
|
) -> Result<SpawnReady, SpawnError>
|
||||||
where
|
where
|
||||||
F: FnMut(&str),
|
F: FnMut(&str),
|
||||||
{
|
{
|
||||||
spawn_pod_with_options(config, PodProcessLaunchOptions::default(), progress).await
|
spawn_worker_with_options(config, WorkerProcessLaunchOptions::default(), progress).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn spawn_pod_with_options<F>(
|
pub async fn spawn_worker_with_options<F>(
|
||||||
config: PodProcessLaunchConfig,
|
config: WorkerProcessLaunchConfig,
|
||||||
options: PodProcessLaunchOptions,
|
options: WorkerProcessLaunchOptions,
|
||||||
mut progress: F,
|
mut progress: F,
|
||||||
) -> Result<SpawnReady, SpawnError>
|
) -> Result<SpawnReady, SpawnError>
|
||||||
where
|
where
|
||||||
F: FnMut(&str),
|
F: FnMut(&str),
|
||||||
{
|
{
|
||||||
let pod_runtime_dir = manifest::paths::pod_runtime_dir(&config.pod_name)
|
let worker_runtime_dir = manifest::paths::worker_runtime_dir(&config.worker_name)
|
||||||
.ok_or(SpawnError::RuntimeDirUnavailable)?;
|
.ok_or(SpawnError::RuntimeDirUnavailable)?;
|
||||||
std::fs::create_dir_all(&pod_runtime_dir).map_err(SpawnError::Io)?;
|
std::fs::create_dir_all(&worker_runtime_dir).map_err(SpawnError::Io)?;
|
||||||
let stderr_path = pod_runtime_dir.join("stderr.log");
|
let stderr_path = worker_runtime_dir.join("stderr.log");
|
||||||
let stderr_file = std::fs::File::create(&stderr_path).map_err(SpawnError::Io)?;
|
let stderr_file = std::fs::File::create(&stderr_path).map_err(SpawnError::Io)?;
|
||||||
|
|
||||||
let mut command = Command::new(config.runtime_command.program());
|
let mut command = Command::new(config.runtime_command.program());
|
||||||
|
|
@ -194,15 +197,15 @@ where
|
||||||
}
|
}
|
||||||
let mut child = command
|
let mut child = command
|
||||||
.spawn()
|
.spawn()
|
||||||
.map_err(|source| SpawnError::PodLaunchFailed {
|
.map_err(|source| SpawnError::WorkerLaunchFailed {
|
||||||
command: config.runtime_command.clone(),
|
command: config.runtime_command.clone(),
|
||||||
source,
|
source,
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Default `kill_on_drop = false` plus `process_group(0)` makes this
|
// Default `kill_on_drop = false` plus `process_group(0)` makes this
|
||||||
// a detached Pod once startup succeeds: dropping the handle does not
|
// a detached Worker once startup succeeds: dropping the handle does not
|
||||||
// terminate it, and terminal-generated signals for the parent's
|
// terminate it, and terminal-generated signals for the parent's
|
||||||
// process group do not hit the Pod. Runtime state/socket files are
|
// process group do not hit the Worker. Runtime state/socket files are
|
||||||
// the source of truth after that point.
|
// the source of truth after that point.
|
||||||
let ready = match wait_for_ready_file(&mut progress, &stderr_path, &mut child).await {
|
let ready = match wait_for_ready_file(&mut progress, &stderr_path, &mut child).await {
|
||||||
Ok(ready) => ready,
|
Ok(ready) => ready,
|
||||||
|
|
@ -240,10 +243,10 @@ where
|
||||||
for line in content[offset..].lines() {
|
for line in content[offset..].lines() {
|
||||||
if let Some(rest) = line.strip_prefix(READY_PREFIX) {
|
if let Some(rest) = line.strip_prefix(READY_PREFIX) {
|
||||||
let mut parts = rest.splitn(2, '\t');
|
let mut parts = rest.splitn(2, '\t');
|
||||||
let pod_name = parts.next().unwrap_or("").to_string();
|
let worker_name = parts.next().unwrap_or("").to_string();
|
||||||
let socket_str = parts.next().unwrap_or("").to_string();
|
let socket_str = parts.next().unwrap_or("").to_string();
|
||||||
if pod_name.is_empty() || socket_str.is_empty() {
|
if worker_name.is_empty() || socket_str.is_empty() {
|
||||||
return Err(SpawnError::PodExitedEarly {
|
return Err(SpawnError::WorkerExitedEarly {
|
||||||
stderr_tail: format!("malformed ready line: {line}"),
|
stderr_tail: format!("malformed ready line: {line}"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -258,7 +261,7 @@ where
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
return Ok(SpawnReady {
|
return Ok(SpawnReady {
|
||||||
pod_name,
|
worker_name,
|
||||||
socket_path,
|
socket_path,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -274,11 +277,11 @@ where
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
status = child.wait() => {
|
status = child.wait() => {
|
||||||
let _ = status;
|
let _ = status;
|
||||||
// Pod は exit 直前に最終 stderr 行を flush することがある。
|
// Worker は exit 直前に最終 stderr 行を flush することがある。
|
||||||
// child.wait() が解決した後に再読みして、原因行を取りこ
|
// child.wait() が解決した後に再読みして、原因行を取りこ
|
||||||
// ぼさず PodExitedEarly に載せる。
|
// ぼさず WorkerExitedEarly に載せる。
|
||||||
drain_stderr_into_tail(stderr_path, &mut tail, &mut offset).await;
|
drain_stderr_into_tail(stderr_path, &mut tail, &mut offset).await;
|
||||||
return Err(SpawnError::PodExitedEarly {
|
return Err(SpawnError::WorkerExitedEarly {
|
||||||
stderr_tail: tail.into_string(),
|
stderr_tail: tail.into_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -310,7 +313,7 @@ async fn wait_for_socket(
|
||||||
status = child.wait() => {
|
status = child.wait() => {
|
||||||
let _ = status;
|
let _ = status;
|
||||||
drain_stderr_into_tail(stderr_path, tail, offset).await;
|
drain_stderr_into_tail(stderr_path, tail, offset).await;
|
||||||
return Err(SpawnError::PodExitedEarly {
|
return Err(SpawnError::WorkerExitedEarly {
|
||||||
stderr_tail: tail.as_string(),
|
stderr_tail: tail.as_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -363,10 +366,10 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::ffi::OsString;
|
use std::ffi::OsString;
|
||||||
|
|
||||||
fn base_config() -> PodProcessLaunchConfig {
|
fn base_config() -> WorkerProcessLaunchConfig {
|
||||||
PodProcessLaunchConfig {
|
WorkerProcessLaunchConfig {
|
||||||
runtime_command: PodRuntimeCommand::new("/bin/yoi", vec![OsString::from("pod")]),
|
runtime_command: WorkerRuntimeCommand::new("/bin/yoi", vec![OsString::from("worker")]),
|
||||||
pod_name: "explicit-pod".to_string(),
|
worker_name: "explicit-worker".to_string(),
|
||||||
profile: Some("project:companion".to_string()),
|
profile: Some("project:companion".to_string()),
|
||||||
workspace_root: PathBuf::from("/work/other-project"),
|
workspace_root: PathBuf::from("/work/other-project"),
|
||||||
cwd: None,
|
cwd: None,
|
||||||
|
|
@ -375,14 +378,14 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_args_keep_workspace_pod_and_profile_separate() {
|
fn runtime_args_keep_workspace_worker_and_profile_separate() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
runtime_args(&base_config(), &PodProcessLaunchOptions::default()),
|
runtime_args(&base_config(), &WorkerProcessLaunchOptions::default()),
|
||||||
vec![
|
vec![
|
||||||
"--workspace",
|
"--workspace",
|
||||||
"/work/other-project",
|
"/work/other-project",
|
||||||
"--pod",
|
"--worker",
|
||||||
"explicit-pod",
|
"explicit-worker",
|
||||||
"--profile",
|
"--profile",
|
||||||
"project:companion",
|
"project:companion",
|
||||||
]
|
]
|
||||||
|
|
@ -394,14 +397,14 @@ mod tests {
|
||||||
let mut config = base_config();
|
let mut config = base_config();
|
||||||
config.resume_from = Some(Uuid::nil());
|
config.resume_from = Some(Uuid::nil());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
runtime_args(&config, &PodProcessLaunchOptions::default()),
|
runtime_args(&config, &WorkerProcessLaunchOptions::default()),
|
||||||
vec![
|
vec![
|
||||||
"--workspace",
|
"--workspace",
|
||||||
"/work/other-project",
|
"/work/other-project",
|
||||||
"--session",
|
"--session",
|
||||||
"00000000-0000-0000-0000-000000000000",
|
"00000000-0000-0000-0000-000000000000",
|
||||||
"--pod",
|
"--worker",
|
||||||
"explicit-pod",
|
"explicit-worker",
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -414,14 +417,14 @@ mod tests {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
runtime_args(
|
runtime_args(
|
||||||
&config,
|
&config,
|
||||||
&PodProcessLaunchOptions::default()
|
&WorkerProcessLaunchOptions::default()
|
||||||
.with_hidden_arg("--ticket-role", "orchestrator"),
|
.with_hidden_arg("--ticket-role", "orchestrator"),
|
||||||
),
|
),
|
||||||
vec![
|
vec![
|
||||||
"--workspace",
|
"--workspace",
|
||||||
"/work/other-project",
|
"/work/other-project",
|
||||||
"--pod",
|
"--worker",
|
||||||
"explicit-pod",
|
"explicit-worker",
|
||||||
"--profile",
|
"--profile",
|
||||||
"project:companion",
|
"project:companion",
|
||||||
"--ticket-role",
|
"--ticket-role",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
//! Ticket-role Pod launch planning and execution.
|
//! Ticket-role Worker launch planning and execution.
|
||||||
//!
|
//!
|
||||||
//! This module keeps Ticket role configuration, generated first-run input, and
|
//! This module keeps Ticket role configuration, generated first-run input, and
|
||||||
//! host-side Pod spawning behind the `client` crate so UI callers do not need to
|
//! host-side Worker spawning behind the `client` crate so UI callers do not need to
|
||||||
//! depend on `pod` internals.
|
//! depend on `worker` internals.
|
||||||
|
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
@ -15,8 +15,8 @@ pub use ticket::config::TicketRole;
|
||||||
use ticket::config::{TicketConfig, TicketConfigError, TicketRoleLaunchConfigError};
|
use ticket::config::{TicketConfig, TicketConfigError, TicketRoleLaunchConfigError};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
PodClient, PodProcessLaunchConfig, PodProcessLaunchOptions, PodRuntimeCommand, SpawnError,
|
SpawnError, SpawnReady, WorkerClient, WorkerProcessLaunchConfig, WorkerProcessLaunchOptions,
|
||||||
SpawnReady, spawn_pod_with_options,
|
WorkerRuntimeCommand, spawn_worker_with_options,
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_FIELD_CHARS: usize = 8_000;
|
const MAX_FIELD_CHARS: usize = 8_000;
|
||||||
|
|
@ -37,7 +37,7 @@ impl TicketRef {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pod_name_seed(&self) -> Option<&str> {
|
fn worker_name_seed(&self) -> Option<&str> {
|
||||||
non_empty(self.id.as_deref())
|
non_empty(self.id.as_deref())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,14 +55,17 @@ impl TicketRef {
|
||||||
/// Auditable panel handoff target included in a Ticket Intake launch.
|
/// Auditable panel handoff target included in a Ticket Intake launch.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct TicketIntakeHandoff {
|
pub struct TicketIntakeHandoff {
|
||||||
pub orchestrator_pod: String,
|
pub workspace_orchestrator_worker: String,
|
||||||
pub workspace_label: String,
|
pub workspace_label: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TicketIntakeHandoff {
|
impl TicketIntakeHandoff {
|
||||||
pub fn new(orchestrator_pod: impl Into<String>, workspace_label: impl Into<String>) -> Self {
|
pub fn new(
|
||||||
|
workspace_orchestrator_worker: impl Into<String>,
|
||||||
|
workspace_label: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
orchestrator_pod: orchestrator_pod.into(),
|
workspace_orchestrator_worker: workspace_orchestrator_worker.into(),
|
||||||
workspace_label: workspace_label.into(),
|
workspace_label: workspace_label.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -70,7 +73,11 @@ impl TicketIntakeHandoff {
|
||||||
fn append_submit_lines(&self, out: &mut String) {
|
fn append_submit_lines(&self, out: &mut String) {
|
||||||
out.push_str("\nPanel handoff:\n");
|
out.push_str("\nPanel handoff:\n");
|
||||||
push_bounded_bullet(out, "workspace", &self.workspace_label);
|
push_bounded_bullet(out, "workspace", &self.workspace_label);
|
||||||
push_bounded_bullet(out, "workspace_orchestrator_pod", &self.orchestrator_pod);
|
push_bounded_bullet(
|
||||||
|
out,
|
||||||
|
"workspace_workspace_orchestrator_worker",
|
||||||
|
&self.workspace_orchestrator_worker,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,7 +89,7 @@ pub struct TicketRoleLaunchContext {
|
||||||
pub original_workspace_root: Option<PathBuf>,
|
pub original_workspace_root: Option<PathBuf>,
|
||||||
pub target_workspace_root: Option<PathBuf>,
|
pub target_workspace_root: Option<PathBuf>,
|
||||||
pub role: TicketRole,
|
pub role: TicketRole,
|
||||||
pub pod_name: Option<String>,
|
pub worker_name: Option<String>,
|
||||||
pub ticket: Option<TicketRef>,
|
pub ticket: Option<TicketRef>,
|
||||||
pub user_instruction: Option<String>,
|
pub user_instruction: Option<String>,
|
||||||
pub intake_handoff: Option<TicketIntakeHandoff>,
|
pub intake_handoff: Option<TicketIntakeHandoff>,
|
||||||
|
|
@ -102,7 +109,7 @@ impl TicketRoleLaunchContext {
|
||||||
original_workspace_root: None,
|
original_workspace_root: None,
|
||||||
target_workspace_root: None,
|
target_workspace_root: None,
|
||||||
role,
|
role,
|
||||||
pod_name: None,
|
worker_name: None,
|
||||||
ticket: None,
|
ticket: None,
|
||||||
user_instruction: None,
|
user_instruction: None,
|
||||||
intake_handoff: None,
|
intake_handoff: None,
|
||||||
|
|
@ -156,7 +163,7 @@ pub struct TicketRoleLaunchPlan {
|
||||||
pub target_workspace_root: PathBuf,
|
pub target_workspace_root: PathBuf,
|
||||||
pub implementation_worktree_root: PathBuf,
|
pub implementation_worktree_root: PathBuf,
|
||||||
pub role: TicketRole,
|
pub role: TicketRole,
|
||||||
pub pod_name: String,
|
pub worker_name: String,
|
||||||
pub profile: String,
|
pub profile: String,
|
||||||
pub workflow: String,
|
pub workflow: String,
|
||||||
pub launch_prompt_ref: Option<String>,
|
pub launch_prompt_ref: Option<String>,
|
||||||
|
|
@ -172,14 +179,14 @@ impl TicketRoleLaunchPlan {
|
||||||
|
|
||||||
pub fn spawn_config(
|
pub fn spawn_config(
|
||||||
&self,
|
&self,
|
||||||
runtime_command: PodRuntimeCommand,
|
runtime_command: WorkerRuntimeCommand,
|
||||||
) -> Result<PodProcessLaunchConfig, TicketRoleLaunchError> {
|
) -> Result<WorkerProcessLaunchConfig, TicketRoleLaunchError> {
|
||||||
if self.profile == "inherit" {
|
if self.profile == "inherit" {
|
||||||
return Err(TicketRoleLaunchError::UnsupportedInheritProfile);
|
return Err(TicketRoleLaunchError::UnsupportedInheritProfile);
|
||||||
}
|
}
|
||||||
Ok(PodProcessLaunchConfig {
|
Ok(WorkerProcessLaunchConfig {
|
||||||
runtime_command,
|
runtime_command,
|
||||||
pod_name: self.pod_name.clone(),
|
worker_name: self.worker_name.clone(),
|
||||||
profile: Some(self.profile.clone()),
|
profile: Some(self.profile.clone()),
|
||||||
workspace_root: self.workspace_root.clone(),
|
workspace_root: self.workspace_root.clone(),
|
||||||
cwd: self.cwd.clone(),
|
cwd: self.cwd.clone(),
|
||||||
|
|
@ -187,8 +194,8 @@ impl TicketRoleLaunchPlan {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn spawn_options(&self) -> PodProcessLaunchOptions {
|
pub fn spawn_options(&self) -> WorkerProcessLaunchOptions {
|
||||||
PodProcessLaunchOptions::default()
|
WorkerProcessLaunchOptions::default()
|
||||||
.with_hidden_arg("--ticket-role", self.role.as_str().to_string())
|
.with_hidden_arg("--ticket-role", self.role.as_str().to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -208,7 +215,7 @@ pub struct TicketRoleLaunchResult {
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct TicketRoleLaunchAcceptanceEvidence {
|
pub struct TicketRoleLaunchAcceptanceEvidence {
|
||||||
pub pod_name: String,
|
pub worker_name: String,
|
||||||
pub accepted_run_segments: usize,
|
pub accepted_run_segments: usize,
|
||||||
pub event: TicketRoleLaunchAcceptanceEvent,
|
pub event: TicketRoleLaunchAcceptanceEvent,
|
||||||
}
|
}
|
||||||
|
|
@ -233,8 +240,8 @@ pub struct TicketRoleLaunchOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TicketRoleLaunchOptions {
|
impl TicketRoleLaunchOptions {
|
||||||
pub fn with_pre_run_peer_registration(mut self, pod_name: impl Into<String>) -> Self {
|
pub fn with_pre_run_peer_registration(mut self, worker_name: impl Into<String>) -> Self {
|
||||||
self.pre_run_peer_registrations.push(pod_name.into());
|
self.pre_run_peer_registrations.push(worker_name.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -253,30 +260,30 @@ pub enum TicketRoleLaunchError {
|
||||||
selector: String,
|
selector: String,
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
#[error("Ticket role Pod name must not be empty")]
|
#[error("Ticket role Worker name must not be empty")]
|
||||||
EmptyPodName,
|
EmptyWorkerName,
|
||||||
#[error(
|
#[error(
|
||||||
"Ticket role profile 'inherit' cannot be used for top-level launch execution; configure a concrete role profile selector"
|
"Ticket role profile 'inherit' cannot be used for top-level launch execution; configure a concrete role profile selector"
|
||||||
)]
|
)]
|
||||||
UnsupportedInheritProfile,
|
UnsupportedInheritProfile,
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Spawn(#[from] SpawnError),
|
Spawn(#[from] SpawnError),
|
||||||
#[error("failed to connect to spawned Ticket role Pod at {}: {source}", .socket_path.display())]
|
#[error("failed to connect to spawned Ticket role Worker at {}: {source}", .socket_path.display())]
|
||||||
Connect {
|
Connect {
|
||||||
socket_path: PathBuf,
|
socket_path: PathBuf,
|
||||||
#[source]
|
#[source]
|
||||||
source: io::Error,
|
source: io::Error,
|
||||||
},
|
},
|
||||||
#[error("failed to send first run input to spawned Ticket role Pod: {source}")]
|
#[error("failed to send first run input to spawned Ticket role Worker: {source}")]
|
||||||
SendRun {
|
SendRun {
|
||||||
#[source]
|
#[source]
|
||||||
source: io::Error,
|
source: io::Error,
|
||||||
},
|
},
|
||||||
#[error("Ticket role Pod rejected first run input with {code:?}: {message}")]
|
#[error("Ticket role Worker rejected first run input with {code:?}: {message}")]
|
||||||
RunRejected { code: ErrorCode, message: String },
|
RunRejected { code: ErrorCode, message: String },
|
||||||
#[error("Ticket role Pod closed before confirming first run acceptance")]
|
#[error("Ticket role Worker closed before confirming first run acceptance")]
|
||||||
RunAcceptanceClosed,
|
RunAcceptanceClosed,
|
||||||
#[error("timed out waiting for Ticket role Pod to confirm first run acceptance")]
|
#[error("timed out waiting for Ticket role Worker to confirm first run acceptance")]
|
||||||
RunAcceptanceTimeout,
|
RunAcceptanceTimeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -303,12 +310,17 @@ pub fn plan_ticket_role_launch_with_config(
|
||||||
.launch_prompt
|
.launch_prompt
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|prompt| prompt.as_str().to_string());
|
.map(|prompt| prompt.as_str().to_string());
|
||||||
let pod_name = match context.pod_name.as_deref().map(str::trim) {
|
let worker_name = match context.worker_name.as_deref().map(str::trim) {
|
||||||
Some("") => return Err(TicketRoleLaunchError::EmptyPodName),
|
Some("") => return Err(TicketRoleLaunchError::EmptyWorkerName),
|
||||||
Some(name) => name.to_string(),
|
Some(name) => name.to_string(),
|
||||||
None => default_pod_name(context.role, context.ticket.as_ref()),
|
None => default_worker_name(context.role, context.ticket.as_ref()),
|
||||||
};
|
};
|
||||||
validate_ticket_role_profile(context.role, &profile, &context.workspace_root, &pod_name)?;
|
validate_ticket_role_profile(
|
||||||
|
context.role,
|
||||||
|
&profile,
|
||||||
|
&context.workspace_root,
|
||||||
|
&worker_name,
|
||||||
|
)?;
|
||||||
let prompt = build_launch_prompt(&context);
|
let prompt = build_launch_prompt(&context);
|
||||||
|
|
||||||
let original_workspace_root = context.original_workspace_root().to_path_buf();
|
let original_workspace_root = context.original_workspace_root().to_path_buf();
|
||||||
|
|
@ -322,7 +334,7 @@ pub fn plan_ticket_role_launch_with_config(
|
||||||
target_workspace_root,
|
target_workspace_root,
|
||||||
implementation_worktree_root,
|
implementation_worktree_root,
|
||||||
role: context.role,
|
role: context.role,
|
||||||
pod_name,
|
worker_name,
|
||||||
profile,
|
profile,
|
||||||
workflow: workflow.clone(),
|
workflow: workflow.clone(),
|
||||||
launch_prompt_ref,
|
launch_prompt_ref,
|
||||||
|
|
@ -339,7 +351,7 @@ fn validate_ticket_role_profile(
|
||||||
role: TicketRole,
|
role: TicketRole,
|
||||||
profile: &str,
|
profile: &str,
|
||||||
workspace_root: &std::path::Path,
|
workspace_root: &std::path::Path,
|
||||||
pod_name: &str,
|
worker_name: &str,
|
||||||
) -> Result<(), TicketRoleLaunchError> {
|
) -> Result<(), TicketRoleLaunchError> {
|
||||||
let selector = ProfileSelector::parse_cli(profile);
|
let selector = ProfileSelector::parse_cli(profile);
|
||||||
let registry = ProfileDiscovery::for_cwd(workspace_root)
|
let registry = ProfileDiscovery::for_cwd(workspace_root)
|
||||||
|
|
@ -354,7 +366,7 @@ fn validate_ticket_role_profile(
|
||||||
.resolve_from_registry(
|
.resolve_from_registry(
|
||||||
&selector,
|
&selector,
|
||||||
®istry,
|
®istry,
|
||||||
ProfileResolveOptions::with_pod_name(pod_name),
|
ProfileResolveOptions::with_worker_name(worker_name),
|
||||||
)
|
)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
.map_err(|source| TicketRoleLaunchError::ProfileResolution {
|
.map_err(|source| TicketRoleLaunchError::ProfileResolution {
|
||||||
|
|
@ -364,17 +376,17 @@ fn validate_ticket_role_profile(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn the Pod, connect to its socket, send the first `Method::Run` input,
|
/// Spawn the Worker, connect to its socket, send the first `Method::Run` input,
|
||||||
/// and wait for bounded acceptance evidence from the Pod event stream.
|
/// and wait for bounded acceptance evidence from the Worker event stream.
|
||||||
pub async fn launch_ticket_role_pod<F>(
|
pub async fn launch_ticket_role_worker<F>(
|
||||||
context: TicketRoleLaunchContext,
|
context: TicketRoleLaunchContext,
|
||||||
runtime_command: PodRuntimeCommand,
|
runtime_command: WorkerRuntimeCommand,
|
||||||
progress: F,
|
progress: F,
|
||||||
) -> Result<TicketRoleLaunchResult, TicketRoleLaunchError>
|
) -> Result<TicketRoleLaunchResult, TicketRoleLaunchError>
|
||||||
where
|
where
|
||||||
F: FnMut(&str),
|
F: FnMut(&str),
|
||||||
{
|
{
|
||||||
launch_ticket_role_pod_with_options(
|
launch_ticket_role_worker_with_options(
|
||||||
context,
|
context,
|
||||||
runtime_command,
|
runtime_command,
|
||||||
progress,
|
progress,
|
||||||
|
|
@ -383,11 +395,11 @@ where
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn the Pod, run bounded pre-run launch options while it is still idle,
|
/// Spawn the Worker, run bounded pre-run launch options while it is still idle,
|
||||||
/// then send the first `Method::Run` input and wait for acceptance evidence.
|
/// then send the first `Method::Run` input and wait for acceptance evidence.
|
||||||
pub async fn launch_ticket_role_pod_with_options<F>(
|
pub async fn launch_ticket_role_worker_with_options<F>(
|
||||||
context: TicketRoleLaunchContext,
|
context: TicketRoleLaunchContext,
|
||||||
runtime_command: PodRuntimeCommand,
|
runtime_command: WorkerRuntimeCommand,
|
||||||
progress: F,
|
progress: F,
|
||||||
options: TicketRoleLaunchOptions,
|
options: TicketRoleLaunchOptions,
|
||||||
) -> Result<TicketRoleLaunchResult, TicketRoleLaunchError>
|
) -> Result<TicketRoleLaunchResult, TicketRoleLaunchError>
|
||||||
|
|
@ -397,8 +409,8 @@ where
|
||||||
let plan = plan_ticket_role_launch(context)?;
|
let plan = plan_ticket_role_launch(context)?;
|
||||||
let spawn_config = plan.spawn_config(runtime_command)?;
|
let spawn_config = plan.spawn_config(runtime_command)?;
|
||||||
let spawn_options = plan.spawn_options();
|
let spawn_options = plan.spawn_options();
|
||||||
let ready = spawn_pod_with_options(spawn_config, spawn_options, progress).await?;
|
let ready = spawn_worker_with_options(spawn_config, spawn_options, progress).await?;
|
||||||
let mut client = PodClient::connect(&ready.socket_path)
|
let mut client = WorkerClient::connect(&ready.socket_path)
|
||||||
.await
|
.await
|
||||||
.map_err(|source| TicketRoleLaunchError::Connect {
|
.map_err(|source| TicketRoleLaunchError::Connect {
|
||||||
socket_path: ready.socket_path.clone(),
|
socket_path: ready.socket_path.clone(),
|
||||||
|
|
@ -408,7 +420,7 @@ where
|
||||||
let acceptance_event =
|
let acceptance_event =
|
||||||
wait_for_run_acceptance(&mut client, &plan.run_segments, RUN_ACCEPTANCE_TIMEOUT).await?;
|
wait_for_run_acceptance(&mut client, &plan.run_segments, RUN_ACCEPTANCE_TIMEOUT).await?;
|
||||||
let acceptance_evidence = TicketRoleLaunchAcceptanceEvidence {
|
let acceptance_evidence = TicketRoleLaunchAcceptanceEvidence {
|
||||||
pod_name: ready.pod_name.clone(),
|
worker_name: ready.worker_name.clone(),
|
||||||
accepted_run_segments: plan.run_segments.len(),
|
accepted_run_segments: plan.run_segments.len(),
|
||||||
event: acceptance_event,
|
event: acceptance_event,
|
||||||
};
|
};
|
||||||
|
|
@ -421,7 +433,7 @@ where
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_pre_run_options_then_send_run(
|
async fn run_pre_run_options_then_send_run(
|
||||||
client: &mut PodClient,
|
client: &mut WorkerClient,
|
||||||
plan: &TicketRoleLaunchPlan,
|
plan: &TicketRoleLaunchPlan,
|
||||||
options: &TicketRoleLaunchOptions,
|
options: &TicketRoleLaunchOptions,
|
||||||
) -> Result<Vec<TicketRolePreRunWarning>, TicketRoleLaunchError> {
|
) -> Result<Vec<TicketRolePreRunWarning>, TicketRoleLaunchError> {
|
||||||
|
|
@ -439,7 +451,7 @@ async fn run_pre_run_options_then_send_run(
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn perform_pre_run_peer_registrations(
|
async fn perform_pre_run_peer_registrations(
|
||||||
client: &mut PodClient,
|
client: &mut WorkerClient,
|
||||||
peer_names: &[String],
|
peer_names: &[String],
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
) -> Vec<TicketRolePreRunWarning> {
|
) -> Vec<TicketRolePreRunWarning> {
|
||||||
|
|
@ -447,7 +459,7 @@ async fn perform_pre_run_peer_registrations(
|
||||||
for peer_name in peer_names {
|
for peer_name in peer_names {
|
||||||
if peer_name.trim().is_empty() {
|
if peer_name.trim().is_empty() {
|
||||||
warnings.push(TicketRolePreRunWarning {
|
warnings.push(TicketRolePreRunWarning {
|
||||||
message: "pre-run peer registration skipped: peer Pod name is empty".to_string(),
|
message: "pre-run peer registration skipped: peer Worker name is empty".to_string(),
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -459,7 +471,7 @@ async fn perform_pre_run_peer_registrations(
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn pre_run_register_peer(
|
async fn pre_run_register_peer(
|
||||||
client: &mut PodClient,
|
client: &mut WorkerClient,
|
||||||
peer_name: &str,
|
peer_name: &str,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
|
@ -503,7 +515,7 @@ async fn pre_run_register_peer(
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn wait_for_run_acceptance(
|
async fn wait_for_run_acceptance(
|
||||||
client: &mut PodClient,
|
client: &mut WorkerClient,
|
||||||
expected_segments: &[Segment],
|
expected_segments: &[Segment],
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
) -> Result<TicketRoleLaunchAcceptanceEvent, TicketRoleLaunchError> {
|
) -> Result<TicketRoleLaunchAcceptanceEvent, TicketRoleLaunchError> {
|
||||||
|
|
@ -593,10 +605,10 @@ fn append_operation_targets(out: &mut String, context: &TicketRoleLaunchContext)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_pod_name(role: TicketRole, ticket: Option<&TicketRef>) -> String {
|
fn default_worker_name(role: TicketRole, ticket: Option<&TicketRef>) -> String {
|
||||||
let mut name = format!("ticket-{}", role.as_str());
|
let mut name = format!("ticket-{}", role.as_str());
|
||||||
if let Some(seed) = ticket.and_then(TicketRef::pod_name_seed) {
|
if let Some(seed) = ticket.and_then(TicketRef::worker_name_seed) {
|
||||||
let suffix = sanitise_pod_name_component(seed);
|
let suffix = sanitise_worker_name_component(seed);
|
||||||
if !suffix.is_empty() {
|
if !suffix.is_empty() {
|
||||||
name.push('-');
|
name.push('-');
|
||||||
name.push_str(&suffix);
|
name.push_str(&suffix);
|
||||||
|
|
@ -605,7 +617,7 @@ fn default_pod_name(role: TicketRole, ticket: Option<&TicketRef>) -> String {
|
||||||
name.chars().take(MAX_POD_NAME_CHARS).collect()
|
name.chars().take(MAX_POD_NAME_CHARS).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sanitise_pod_name_component(value: &str) -> String {
|
fn sanitise_worker_name_component(value: &str) -> String {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
let mut last_was_dash = false;
|
let mut last_was_dash = false;
|
||||||
for ch in value.trim().chars() {
|
for ch in value.trim().chars() {
|
||||||
|
|
@ -680,7 +692,7 @@ fn non_empty(value: Option<&str>) -> Option<&str> {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use protocol::{Greeting, PodStatus};
|
use protocol::{Greeting, WorkerStatus};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader};
|
||||||
use tokio::net::UnixListener;
|
use tokio::net::UnixListener;
|
||||||
|
|
@ -723,7 +735,7 @@ mod tests {
|
||||||
Event::Snapshot {
|
Event::Snapshot {
|
||||||
entries: vec![],
|
entries: vec![],
|
||||||
greeting: Greeting {
|
greeting: Greeting {
|
||||||
pod_name: "ticket-intake".to_string(),
|
worker_name: "ticket-intake".to_string(),
|
||||||
cwd: "/tmp".to_string(),
|
cwd: "/tmp".to_string(),
|
||||||
provider: "test".to_string(),
|
provider: "test".to_string(),
|
||||||
model: "test".to_string(),
|
model: "test".to_string(),
|
||||||
|
|
@ -732,7 +744,7 @@ mod tests {
|
||||||
context_window: 0,
|
context_window: 0,
|
||||||
context_tokens: 0,
|
context_tokens: 0,
|
||||||
},
|
},
|
||||||
status: PodStatus::Idle,
|
status: WorkerStatus::Idle,
|
||||||
in_flight: protocol::InFlightSnapshot::default(),
|
in_flight: protocol::InFlightSnapshot::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -745,7 +757,7 @@ mod tests {
|
||||||
target_workspace_root: workspace.to_path_buf(),
|
target_workspace_root: workspace.to_path_buf(),
|
||||||
implementation_worktree_root: workspace.join(".worktree"),
|
implementation_worktree_root: workspace.join(".worktree"),
|
||||||
role: TicketRole::Intake,
|
role: TicketRole::Intake,
|
||||||
pod_name: "ticket-intake".to_string(),
|
worker_name: "ticket-intake".to_string(),
|
||||||
profile: "project:intake".to_string(),
|
profile: "project:intake".to_string(),
|
||||||
workflow: "ticket-intake-workflow".to_string(),
|
workflow: "ticket-intake-workflow".to_string(),
|
||||||
launch_prompt_ref: None,
|
launch_prompt_ref: None,
|
||||||
|
|
@ -758,7 +770,7 @@ mod tests {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn pre_run_peer_registration_is_sent_before_first_run_submission() {
|
async fn pre_run_peer_registration_is_sent_before_first_run_submission() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
let socket_path = temp.path().join("pod.sock");
|
let socket_path = temp.path().join("worker.sock");
|
||||||
let listener = UnixListener::bind(&socket_path).unwrap();
|
let listener = UnixListener::bind(&socket_path).unwrap();
|
||||||
let server = tokio::spawn(async move {
|
let server = tokio::spawn(async move {
|
||||||
let (stream, _) = listener.accept().await.unwrap();
|
let (stream, _) = listener.accept().await.unwrap();
|
||||||
|
|
@ -793,7 +805,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut client = PodClient::connect(&socket_path).await.unwrap();
|
let mut client = WorkerClient::connect(&socket_path).await.unwrap();
|
||||||
let options = TicketRoleLaunchOptions::default()
|
let options = TicketRoleLaunchOptions::default()
|
||||||
.with_pre_run_peer_registration("workspace-orchestrator");
|
.with_pre_run_peer_registration("workspace-orchestrator");
|
||||||
let warnings = run_pre_run_options_then_send_run(
|
let warnings = run_pre_run_options_then_send_run(
|
||||||
|
|
@ -811,7 +823,7 @@ mod tests {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn pre_run_peer_registration_failure_warns_but_still_sends_run() {
|
async fn pre_run_peer_registration_failure_warns_but_still_sends_run() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
let socket_path = temp.path().join("pod.sock");
|
let socket_path = temp.path().join("worker.sock");
|
||||||
let listener = UnixListener::bind(&socket_path).unwrap();
|
let listener = UnixListener::bind(&socket_path).unwrap();
|
||||||
let server = tokio::spawn(async move {
|
let server = tokio::spawn(async move {
|
||||||
let (stream, _) = listener.accept().await.unwrap();
|
let (stream, _) = listener.accept().await.unwrap();
|
||||||
|
|
@ -842,7 +854,7 @@ mod tests {
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut client = PodClient::connect(&socket_path).await.unwrap();
|
let mut client = WorkerClient::connect(&socket_path).await.unwrap();
|
||||||
let options = TicketRoleLaunchOptions::default()
|
let options = TicketRoleLaunchOptions::default()
|
||||||
.with_pre_run_peer_registration("workspace-orchestrator");
|
.with_pre_run_peer_registration("workspace-orchestrator");
|
||||||
let warnings = run_pre_run_options_then_send_run(
|
let warnings = run_pre_run_options_then_send_run(
|
||||||
|
|
@ -863,7 +875,7 @@ mod tests {
|
||||||
fn default_config_role_launch_plan_requires_explicit_role_config() {
|
fn default_config_role_launch_plan_requires_explicit_role_config() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
let mut context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Coder);
|
let mut context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Coder);
|
||||||
context.ticket = Some(TicketRef::id("Ticket Role Pod Launcher"));
|
context.ticket = Some(TicketRef::id("Ticket Role Worker Launcher"));
|
||||||
|
|
||||||
let err = plan_ticket_role_launch(context).unwrap_err();
|
let err = plan_ticket_role_launch(context).unwrap_err();
|
||||||
|
|
||||||
|
|
@ -1007,7 +1019,7 @@ profile = "builtin:default"
|
||||||
plan.profile = "inherit".to_string();
|
plan.profile = "inherit".to_string();
|
||||||
|
|
||||||
let err = plan
|
let err = plan
|
||||||
.spawn_config(PodRuntimeCommand::for_executable("/bin/yoi"))
|
.spawn_config(WorkerRuntimeCommand::for_executable("/bin/yoi"))
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
|
|
@ -1030,14 +1042,14 @@ workflow = "ticket-review-workflow"
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
let mut context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Reviewer);
|
let mut context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Reviewer);
|
||||||
context.pod_name = Some("reviewer-fixed".to_string());
|
context.worker_name = Some("reviewer-fixed".to_string());
|
||||||
context.ticket = Some(TicketRef::id("20260605-190330-ticket-role-pod-launcher"));
|
context.ticket = Some(TicketRef::id("20260605-190330-ticket-role-worker-launcher"));
|
||||||
context.user_instruction = Some("Review the submitted implementation.".to_string());
|
context.user_instruction = Some("Review the submitted implementation.".to_string());
|
||||||
|
|
||||||
let plan = plan_ticket_role_launch(context).unwrap();
|
let plan = plan_ticket_role_launch(context).unwrap();
|
||||||
let text = text_segment(&plan);
|
let text = text_segment(&plan);
|
||||||
|
|
||||||
assert_eq!(plan.pod_name, "reviewer-fixed");
|
assert_eq!(plan.worker_name, "reviewer-fixed");
|
||||||
assert_eq!(plan.profile, "builtin:default");
|
assert_eq!(plan.profile, "builtin:default");
|
||||||
assert_eq!(plan.workflow, "ticket-review-workflow");
|
assert_eq!(plan.workflow, "ticket-review-workflow");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -1055,13 +1067,13 @@ workflow = "ticket-review-workflow"
|
||||||
assert!(!text.contains("Role: reviewer"));
|
assert!(!text.contains("Role: reviewer"));
|
||||||
assert!(!text.contains("system_instruction"));
|
assert!(!text.contains("system_instruction"));
|
||||||
assert!(text.contains("Target Ticket:"));
|
assert!(text.contains("Target Ticket:"));
|
||||||
assert!(text.contains("id: 20260605-190330-ticket-role-pod-launcher"));
|
assert!(text.contains("id: 20260605-190330-ticket-role-worker-launcher"));
|
||||||
assert!(text.contains("Action instruction:"));
|
assert!(text.contains("Action instruction:"));
|
||||||
assert!(text.contains("Review the submitted implementation."));
|
assert!(text.contains("Review the submitted implementation."));
|
||||||
let spawn = plan
|
let spawn = plan
|
||||||
.spawn_config(PodRuntimeCommand::for_executable("/bin/yoi"))
|
.spawn_config(WorkerRuntimeCommand::for_executable("/bin/yoi"))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(spawn.pod_name, "reviewer-fixed");
|
assert_eq!(spawn.worker_name, "reviewer-fixed");
|
||||||
assert_eq!(spawn.profile.as_deref(), Some("builtin:default"));
|
assert_eq!(spawn.profile.as_deref(), Some("builtin:default"));
|
||||||
assert_eq!(spawn.workspace_root, temp.path());
|
assert_eq!(spawn.workspace_root, temp.path());
|
||||||
assert!(spawn.cwd.is_none());
|
assert!(spawn.cwd.is_none());
|
||||||
|
|
@ -1102,7 +1114,10 @@ workflow = "ticket-review-workflow"
|
||||||
let handoff_plan = plan_ticket_role_launch(handoff_intake).unwrap();
|
let handoff_plan = plan_ticket_role_launch(handoff_intake).unwrap();
|
||||||
let handoff_text = text_segment(&handoff_plan);
|
let handoff_text = text_segment(&handoff_plan);
|
||||||
assert!(handoff_text.contains("Panel handoff:"));
|
assert!(handoff_text.contains("Panel handoff:"));
|
||||||
assert!(handoff_text.contains("workspace_orchestrator_pod: panel-orchestrator-demo"));
|
assert!(
|
||||||
|
handoff_text
|
||||||
|
.contains("workspace_workspace_orchestrator_worker: panel-orchestrator-demo")
|
||||||
|
);
|
||||||
assert!(handoff_text.contains("workspace: Demo workspace"));
|
assert!(handoff_text.contains("workspace: Demo workspace"));
|
||||||
assert!(!handoff_text.contains("created_or_updated_ticket_id"));
|
assert!(!handoff_text.contains("created_or_updated_ticket_id"));
|
||||||
assert!(!handoff_text.contains("Ticket tool surface"));
|
assert!(!handoff_text.contains("Ticket tool surface"));
|
||||||
|
|
@ -1125,15 +1140,15 @@ workflow = "ticket-review-workflow"
|
||||||
assert!(!orchestrator_text.contains("role_cwd"));
|
assert!(!orchestrator_text.contains("role_cwd"));
|
||||||
|
|
||||||
let mut coder = TicketRoleLaunchContext::new(temp.path(), TicketRole::Coder);
|
let mut coder = TicketRoleLaunchContext::new(temp.path(), TicketRole::Coder);
|
||||||
coder.ticket = Some(TicketRef::id("20260605-190330-ticket-role-pod-launcher"));
|
coder.ticket = Some(TicketRef::id("20260605-190330-ticket-role-worker-launcher"));
|
||||||
coder.worktree_path = Some(PathBuf::from("/tmp/yoi-code"));
|
coder.worktree_path = Some(PathBuf::from("/tmp/yoi-code"));
|
||||||
coder.branch = Some("work/ticket-role-pod-launcher".into());
|
coder.branch = Some("work/ticket-role-worker-launcher".into());
|
||||||
coder.validation = vec!["cargo test -p client ticket_role".into()];
|
coder.validation = vec!["cargo test -p client ticket_role".into()];
|
||||||
coder.report_expectations = vec!["implementation report with validation".into()];
|
coder.report_expectations = vec!["implementation report with validation".into()];
|
||||||
let coder_plan = plan_ticket_role_launch(coder).unwrap();
|
let coder_plan = plan_ticket_role_launch(coder).unwrap();
|
||||||
let coder_text = text_segment(&coder_plan);
|
let coder_text = text_segment(&coder_plan);
|
||||||
assert!(coder_text.contains("path: /tmp/yoi-code"));
|
assert!(coder_text.contains("path: /tmp/yoi-code"));
|
||||||
assert!(coder_text.contains("branch: work/ticket-role-pod-launcher"));
|
assert!(coder_text.contains("branch: work/ticket-role-worker-launcher"));
|
||||||
assert!(coder_text.contains("cargo test -p client ticket_role"));
|
assert!(coder_text.contains("cargo test -p client ticket_role"));
|
||||||
assert!(coder_text.contains("implementation report with validation"));
|
assert!(coder_text.contains("implementation report with validation"));
|
||||||
assert!(!coder_text.contains("provided child worktree/branch"));
|
assert!(!coder_text.contains("provided child worktree/branch"));
|
||||||
|
|
@ -1141,14 +1156,14 @@ workflow = "ticket-review-workflow"
|
||||||
assert!(!coder_text.contains("Do not merge, push"));
|
assert!(!coder_text.contains("Do not merge, push"));
|
||||||
|
|
||||||
let mut reviewer = TicketRoleLaunchContext::new(temp.path(), TicketRole::Reviewer);
|
let mut reviewer = TicketRoleLaunchContext::new(temp.path(), TicketRole::Reviewer);
|
||||||
reviewer.ticket = Some(TicketRef::id("20260605-190330-ticket-role-pod-launcher"));
|
reviewer.ticket = Some(TicketRef::id("20260605-190330-ticket-role-worker-launcher"));
|
||||||
reviewer.worktree_path = Some(PathBuf::from("/tmp/yoi-review"));
|
reviewer.worktree_path = Some(PathBuf::from("/tmp/yoi-review"));
|
||||||
reviewer.branch = Some("work/ticket-role-pod-launcher".into());
|
reviewer.branch = Some("work/ticket-role-worker-launcher".into());
|
||||||
reviewer.report_expectations = vec!["approve or request changes".into()];
|
reviewer.report_expectations = vec!["approve or request changes".into()];
|
||||||
let reviewer_plan = plan_ticket_role_launch(reviewer).unwrap();
|
let reviewer_plan = plan_ticket_role_launch(reviewer).unwrap();
|
||||||
let reviewer_text = text_segment(&reviewer_plan);
|
let reviewer_text = text_segment(&reviewer_plan);
|
||||||
assert!(reviewer_text.contains("path: /tmp/yoi-review"));
|
assert!(reviewer_text.contains("path: /tmp/yoi-review"));
|
||||||
assert!(reviewer_text.contains("branch: work/ticket-role-pod-launcher"));
|
assert!(reviewer_text.contains("branch: work/ticket-role-worker-launcher"));
|
||||||
assert!(reviewer_text.contains("approve or request changes"));
|
assert!(reviewer_text.contains("approve or request changes"));
|
||||||
assert!(!reviewer_text.contains("read-only by default"));
|
assert!(!reviewer_text.contains("read-only by default"));
|
||||||
assert!(!reviewer_text.contains("Orchestrator-side integration"));
|
assert!(!reviewer_text.contains("Orchestrator-side integration"));
|
||||||
|
|
@ -1177,7 +1192,7 @@ workflow = "ticket-review-workflow"
|
||||||
);
|
);
|
||||||
assert_eq!(plan.target_workspace_root, temp.path().join("target"));
|
assert_eq!(plan.target_workspace_root, temp.path().join("target"));
|
||||||
let spawn_config = plan
|
let spawn_config = plan
|
||||||
.spawn_config(PodRuntimeCommand::for_executable("/bin/yoi"))
|
.spawn_config(WorkerRuntimeCommand::for_executable("/bin/yoi"))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(spawn_config.workspace_root, temp.path());
|
assert_eq!(spawn_config.workspace_root, temp.path());
|
||||||
assert_eq!(spawn_config.cwd, None);
|
assert_eq!(spawn_config.cwd, None);
|
||||||
|
|
@ -1194,15 +1209,15 @@ workflow = "ticket-review-workflow"
|
||||||
assert!(!text.contains("Orchestrator implementation integration guidance"));
|
assert!(!text.contains("Orchestrator implementation integration guidance"));
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn caller_provided_pod_name_is_used_exactly() {
|
fn caller_provided_worker_name_is_used_exactly() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
write_builtin_role_config(temp.path(), &[TicketRole::Intake]);
|
write_builtin_role_config(temp.path(), &[TicketRole::Intake]);
|
||||||
let mut context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Intake);
|
let mut context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Intake);
|
||||||
context.pod_name = Some("custom-intake-pod".into());
|
context.worker_name = Some("custom-intake-worker".into());
|
||||||
|
|
||||||
let plan = plan_ticket_role_launch(context).unwrap();
|
let plan = plan_ticket_role_launch(context).unwrap();
|
||||||
|
|
||||||
assert_eq!(plan.pod_name, "custom-intake-pod");
|
assert_eq!(plan.worker_name, "custom-intake-worker");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,13 @@ use tokio::net::UnixStream;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
pub struct PodClient {
|
pub struct WorkerClient {
|
||||||
writer: JsonLineWriter<tokio::io::WriteHalf<UnixStream>>,
|
writer: JsonLineWriter<tokio::io::WriteHalf<UnixStream>>,
|
||||||
event_rx: mpsc::Receiver<Event>,
|
event_rx: mpsc::Receiver<Event>,
|
||||||
reader_task: JoinHandle<()>,
|
reader_task: JoinHandle<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PodClient {
|
impl WorkerClient {
|
||||||
pub async fn connect(path: &Path) -> Result<Self, io::Error> {
|
pub async fn connect(path: &Path) -> Result<Self, io::Error> {
|
||||||
let stream = UnixStream::connect(path).await?;
|
let stream = UnixStream::connect(path).await?;
|
||||||
let (reader, writer) = tokio::io::split(stream);
|
let (reader, writer) = tokio::io::split(stream);
|
||||||
|
|
@ -50,7 +50,7 @@ impl PodClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for PodClient {
|
impl Drop for WorkerClient {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.reader_task.abort();
|
self.reader_task.abort();
|
||||||
}
|
}
|
||||||
|
|
@ -61,7 +61,7 @@ mod tests {
|
||||||
use std::io::ErrorKind;
|
use std::io::ErrorKind;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use protocol::{PodStatus, Segment};
|
use protocol::{Segment, WorkerStatus};
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
use tokio::net::UnixListener;
|
use tokio::net::UnixListener;
|
||||||
|
|
@ -91,13 +91,13 @@ mod tests {
|
||||||
let mut writer = JsonLineWriter::new(stream);
|
let mut writer = JsonLineWriter::new(stream);
|
||||||
writer
|
writer
|
||||||
.write(&Event::Status {
|
.write(&Event::Status {
|
||||||
status: PodStatus::Idle,
|
status: WorkerStatus::Idle,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut client = PodClient::connect(&socket_path).await.unwrap();
|
let mut client = WorkerClient::connect(&socket_path).await.unwrap();
|
||||||
|
|
||||||
let event = tokio::time::timeout(Duration::from_secs(1), client.next_event())
|
let event = tokio::time::timeout(Duration::from_secs(1), client.next_event())
|
||||||
.await
|
.await
|
||||||
|
|
@ -105,7 +105,7 @@ mod tests {
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
event,
|
event,
|
||||||
Some(Event::Status {
|
Some(Event::Status {
|
||||||
status: PodStatus::Idle
|
status: WorkerStatus::Idle
|
||||||
})
|
})
|
||||||
));
|
));
|
||||||
server.await.unwrap();
|
server.await.unwrap();
|
||||||
|
|
@ -122,7 +122,7 @@ mod tests {
|
||||||
reader.next::<Method>().await.unwrap()
|
reader.next::<Method>().await.unwrap()
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut client = PodClient::connect(&socket_path).await.unwrap();
|
let mut client = WorkerClient::connect(&socket_path).await.unwrap();
|
||||||
let method = Method::Run {
|
let method = Method::Run {
|
||||||
input: vec![Segment::text("hello")],
|
input: vec![Segment::text("hello")],
|
||||||
};
|
};
|
||||||
|
|
@ -155,7 +155,7 @@ mod tests {
|
||||||
});
|
});
|
||||||
|
|
||||||
for _ in 0..16 {
|
for _ in 0..16 {
|
||||||
let client = PodClient::connect(&socket_path).await.unwrap();
|
let client = WorkerClient::connect(&socket_path).await.unwrap();
|
||||||
drop(client);
|
drop(client);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -177,7 +177,7 @@ mod tests {
|
||||||
.await;
|
.await;
|
||||||
});
|
});
|
||||||
|
|
||||||
let client = PodClient::connect(&socket_path).await.unwrap();
|
let client = WorkerClient::connect(&socket_path).await.unwrap();
|
||||||
tokio::task::yield_now().await;
|
tokio::task::yield_now().await;
|
||||||
drop(client);
|
drop(client);
|
||||||
|
|
||||||
|
|
@ -20,7 +20,7 @@ Does not own:
|
||||||
|
|
||||||
## Design notes
|
## Design notes
|
||||||
|
|
||||||
Macros reduce boilerplate, but they must not imply capability. A generated tool definition is still subject to manifest permissions, Pod scope, and runtime policy.
|
Macros reduce boilerplate, but they must not imply capability. A generated tool definition is still subject to manifest permissions, Worker scope, and runtime policy.
|
||||||
|
|
||||||
## See also
|
## See also
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,10 @@ Owns:
|
||||||
|
|
||||||
Does not own:
|
Does not own:
|
||||||
|
|
||||||
- Pod names, sockets, process lifecycle, or scope delegation (`pod`)
|
- Worker names, sockets, process lifecycle, or scope delegation (`worker`)
|
||||||
- product CLI shape (`yoi`)
|
- product CLI shape (`yoi`)
|
||||||
- provider catalog and secret resolution (`provider`, `secrets`)
|
- provider catalog and secret resolution (`provider`, `secrets`)
|
||||||
- durable Pod current state (`pod-store`)
|
- durable Worker current state (`pod-store`)
|
||||||
|
|
||||||
## Design notes
|
## Design notes
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -213,7 +213,7 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable> {
|
||||||
/// stream events become visible.
|
/// stream events become visible.
|
||||||
lifecycle_trace_cbs: Vec<Arc<dyn Fn(usize, usize, &str, &Value) + Send + Sync>>,
|
lifecycle_trace_cbs: Vec<Arc<dyn Fn(usize, usize, &str, &Value) + Send + Sync>>,
|
||||||
/// Non-fatal warning callbacks. Invoked when the Engine wants to
|
/// Non-fatal warning callbacks. Invoked when the Engine wants to
|
||||||
/// surface an advisory message to the upper layer (e.g. Pod) so it
|
/// surface an advisory message to the upper layer (e.g. Worker) so it
|
||||||
/// can be forwarded to the user — distinct from `tracing::warn!`,
|
/// can be forwarded to the user — distinct from `tracing::warn!`,
|
||||||
/// which is for developer-facing logs.
|
/// which is for developer-facing logs.
|
||||||
warning_cbs: Vec<Box<dyn Fn(&str) + Send + Sync>>,
|
warning_cbs: Vec<Box<dyn Fn(&str) + Send + Sync>>,
|
||||||
|
|
@ -253,7 +253,7 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable> {
|
||||||
/// Plumbed into [`Request::cache_anchor`] at request build time.
|
/// Plumbed into [`Request::cache_anchor`] at request build time.
|
||||||
cache_anchor: Option<usize>,
|
cache_anchor: Option<usize>,
|
||||||
/// Conversation-scoped cache key, set by higher layers. Plumbed into
|
/// Conversation-scoped cache key, set by higher layers. Plumbed into
|
||||||
/// [`Request::cache_key`] at request build time. Pod 側では
|
/// [`Request::cache_key`] at request build time. Worker 側では
|
||||||
/// `SegmentId` を渡す。
|
/// `SegmentId` を渡す。
|
||||||
cache_key: Option<String>,
|
cache_key: Option<String>,
|
||||||
/// State marker
|
/// State marker
|
||||||
|
|
@ -487,7 +487,7 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
|
||||||
/// Fired after `post_tool_call` interceptors and any `content`
|
/// Fired after `post_tool_call` interceptors and any `content`
|
||||||
/// truncation from `tool_output_limits`, so the callback observes
|
/// truncation from `tool_output_limits`, so the callback observes
|
||||||
/// exactly what is persisted to history. Intended for upper layers
|
/// exactly what is persisted to history. Intended for upper layers
|
||||||
/// (e.g. Pod) to forward tool results to clients.
|
/// (e.g. Worker) to forward tool results to clients.
|
||||||
pub fn on_tool_result(&mut self, callback: impl Fn(&ToolResult) + Send + Sync + 'static) {
|
pub fn on_tool_result(&mut self, callback: impl Fn(&ToolResult) + Send + Sync + 'static) {
|
||||||
self.tool_result_cbs.push(Box::new(callback));
|
self.tool_result_cbs.push(Box::new(callback));
|
||||||
}
|
}
|
||||||
|
|
@ -1121,7 +1121,7 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drain interceptor-side inputs that are meant to land in
|
// Drain interceptor-side inputs that are meant to land in
|
||||||
// history (notifications, cross-Pod events, system
|
// history (notifications, cross-Worker events, system
|
||||||
// reminders). These are committed *before* the per-request
|
// reminders). These are committed *before* the per-request
|
||||||
// clone so they participate in the LLM request below and
|
// clone so they participate in the LLM request below and
|
||||||
// get persisted by the upper layer that owns history.json.
|
// get persisted by the upper layer that owns history.json.
|
||||||
|
|
@ -1302,7 +1302,7 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
|
||||||
|
|
||||||
// Collect and commit assistant items. Routed through
|
// Collect and commit assistant items. Routed through
|
||||||
// `append_history_items` so observers (e.g. the
|
// `append_history_items` so observers (e.g. the
|
||||||
// Pod-side per-item session-log committer) see each item
|
// Worker-side per-item session-log committer) see each item
|
||||||
// as it lands.
|
// as it lands.
|
||||||
let reasoning_items = self.thinking_block_collector.take_collected();
|
let reasoning_items = self.thinking_block_collector.take_collected();
|
||||||
let text_blocks = self.text_block_collector.take_collected();
|
let text_blocks = self.text_block_collector.take_collected();
|
||||||
|
|
@ -1603,7 +1603,7 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
|
||||||
}
|
}
|
||||||
Ok(ToolExecutionResult::Completed(results)) => {
|
Ok(ToolExecutionResult::Completed(results)) => {
|
||||||
// Route per-result pushes through the callback path so
|
// Route per-result pushes through the callback path so
|
||||||
// observers (e.g. the Pod-side per-item session-log
|
// observers (e.g. the Worker-side per-item session-log
|
||||||
// committer) see each tool result as it lands.
|
// committer) see each tool result as it lands.
|
||||||
let items = results.into_iter().map(|result| {
|
let items = results.into_iter().map(|result| {
|
||||||
Item::tool_result_item(
|
Item::tool_result_item(
|
||||||
|
|
@ -1708,7 +1708,7 @@ impl<C: LlmClient> Engine<C, Mutable> {
|
||||||
/// Install byte-size caps for tool execution `content`.
|
/// Install byte-size caps for tool execution `content`.
|
||||||
///
|
///
|
||||||
/// Passing `None` (the default) disables truncation. Higher layers
|
/// Passing `None` (the default) disables truncation. Higher layers
|
||||||
/// (e.g. Pod) translate manifest configuration into a concrete
|
/// (e.g. Worker) translate manifest configuration into a concrete
|
||||||
/// [`ToolOutputLimits`] and install it here.
|
/// [`ToolOutputLimits`] and install it here.
|
||||||
pub fn set_tool_output_limits(&mut self, limits: Option<ToolOutputLimits>) {
|
pub fn set_tool_output_limits(&mut self, limits: Option<ToolOutputLimits>) {
|
||||||
self.tool_output_limits = limits;
|
self.tool_output_limits = limits;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//! Interceptor - control flow delegation for the Engine execution loop
|
//! Interceptor - control flow delegation for the Engine execution loop
|
||||||
//!
|
//!
|
||||||
//! Defines the [`Interceptor`] trait that upper layers (e.g. Pod) implement
|
//! Defines the [`Interceptor`] trait that upper layers (e.g. Worker) implement
|
||||||
//! to inject orchestration decisions (approval, skip, pause, abort)
|
//! to inject orchestration decisions (approval, skip, pause, abort)
|
||||||
//! into the Engine's turn loop without the Engine knowing about
|
//! into the Engine's turn loop without the Engine knowing about
|
||||||
//! higher-level concepts.
|
//! higher-level concepts.
|
||||||
|
|
@ -132,7 +132,7 @@ pub struct ToolResultInfo {
|
||||||
/// Intercepts the Engine execution loop at key decision points.
|
/// Intercepts the Engine execution loop at key decision points.
|
||||||
///
|
///
|
||||||
/// All methods have default implementations that let the Engine
|
/// All methods have default implementations that let the Engine
|
||||||
/// proceed without intervention. Upper layers (e.g. Pod) provide
|
/// proceed without intervention. Upper layers (e.g. Worker) provide
|
||||||
/// richer implementations for approval flows, permission checks, etc.
|
/// richer implementations for approval flows, permission checks, etc.
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait Interceptor: Send + Sync {
|
pub trait Interceptor: Send + Sync {
|
||||||
|
|
@ -149,7 +149,7 @@ pub trait Interceptor: Send + Sync {
|
||||||
///
|
///
|
||||||
/// Use this for inputs that arrive from outside the LLM and need
|
/// Use this for inputs that arrive from outside the LLM and need
|
||||||
/// to be reflected in the on-disk history — notifications,
|
/// to be reflected in the on-disk history — notifications,
|
||||||
/// cross-Pod events, system reminders. Do **not** use
|
/// cross-Worker events, system reminders. Do **not** use
|
||||||
/// [`Self::pre_llm_request`] for that purpose: it mutates a
|
/// [`Self::pre_llm_request`] for that purpose: it mutates a
|
||||||
/// per-request clone, so any committed assistant response that
|
/// per-request clone, so any committed assistant response that
|
||||||
/// reacts to the injection would have no visible trigger on the
|
/// reacts to the injection would have no visible trigger on the
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ pub(crate) struct ResponsesRequest {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub top_p: Option<f32>,
|
pub top_p: Option<f32>,
|
||||||
/// 会話単位の安定キー。ChatGPT backend (codex-oauth) は明示キーが
|
/// 会話単位の安定キー。ChatGPT backend (codex-oauth) は明示キーが
|
||||||
/// 無いとプロンプトキャッシュがほぼ効かない。pod 側は `SegmentId`
|
/// 無いとプロンプトキャッシュがほぼ効かない。worker 側は `SegmentId`
|
||||||
/// を渡す。`Request::cache_key` が `None` のときはキー自体を送らない。
|
/// を渡す。`Request::cache_key` が `None` のときはキー自体を送らない。
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub prompt_cache_key: Option<String>,
|
pub prompt_cache_key: Option<String>,
|
||||||
|
|
|
||||||
|
|
@ -523,7 +523,7 @@ pub struct Request {
|
||||||
/// 会話単位の安定キー。`prompt_cache_key` として送られる
|
/// 会話単位の安定キー。`prompt_cache_key` として送られる
|
||||||
/// (OpenAI Responses)。ChatGPT backend (codex-oauth) は明示キーが
|
/// (OpenAI Responses)。ChatGPT backend (codex-oauth) は明示キーが
|
||||||
/// 無いと org/project ハッシュ衝突でプロンプトキャッシュが
|
/// 無いと org/project ハッシュ衝突でプロンプトキャッシュが
|
||||||
/// ほぼヒットしないため、pod 側で `SegmentId` を渡す運用を想定。
|
/// ほぼヒットしないため、worker 側で `SegmentId` を渡す運用を想定。
|
||||||
/// `cache_anchor` と違い名前空間キーであり、`prefix anchor` とは
|
/// `cache_anchor` と違い名前空間キーであり、`prefix anchor` とは
|
||||||
/// 別の概念。`cache_anchor` を読まない provider と同じく、
|
/// 別の概念。`cache_anchor` を読まない provider と同じく、
|
||||||
/// `prompt_cache_key` を持たない provider は無視する。
|
/// `prompt_cache_key` を持たない provider は無視する。
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
//!
|
//!
|
||||||
//! Prune は **コンテキスト射影** であり、history の変換ではない。
|
//! Prune は **コンテキスト射影** であり、history の変換ではない。
|
||||||
//! この crate が提供するのは pure な候補抽出 [`prunable_indices`] のみで、
|
//! この crate が提供するのは pure な候補抽出 [`prunable_indices`] のみで、
|
||||||
//! 射影の適用は上位層(`pod::prune_hook` 等)が LLM に送る一時コンテキスト
|
//! 射影の適用は上位層(`worker::prune_hook` 等)が LLM に送る一時コンテキスト
|
||||||
//! に対してだけ行う。Engine の永続履歴は決して変更されない。
|
//! に対してだけ行う。Engine の永続履歴は決して変更されない。
|
||||||
//!
|
//!
|
||||||
//! 保護境界は末尾 token budget で決めるが、この crate は usage 履歴を
|
//! 保護境界は末尾 token budget で決めるが、この crate は usage 履歴を
|
||||||
|
|
@ -75,7 +75,7 @@ pub enum PruneDecision {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Optional observer invoked after each prune evaluation, regardless of
|
/// Optional observer invoked after each prune evaluation, regardless of
|
||||||
/// branch. Pod 等の上位層が install して metrics を発行する。
|
/// branch. Worker 等の上位層が install して metrics を発行する。
|
||||||
pub type PruneObserver = Box<dyn Fn(&PruneEvaluation) + Send + Sync>;
|
pub type PruneObserver = Box<dyn Fn(&PruneEvaluation) + Send + Sync>;
|
||||||
|
|
||||||
/// Configuration for the Prune algorithm.
|
/// Configuration for the Prune algorithm.
|
||||||
|
|
|
||||||
|
|
@ -130,13 +130,13 @@ mod tests {
|
||||||
let mut timeline = Timeline::new();
|
let mut timeline = Timeline::new();
|
||||||
timeline.on_tool_use_block(collector.clone());
|
timeline.on_tool_use_block(collector.clone());
|
||||||
|
|
||||||
timeline.dispatch(&Event::tool_use_start(0, "tool_empty", "ListPods"));
|
timeline.dispatch(&Event::tool_use_start(0, "tool_empty", "ListWorkers"));
|
||||||
timeline.dispatch(&Event::tool_use_stop(0));
|
timeline.dispatch(&Event::tool_use_stop(0));
|
||||||
|
|
||||||
let calls = collector.take_collected();
|
let calls = collector.take_collected();
|
||||||
assert_eq!(calls.len(), 1);
|
assert_eq!(calls.len(), 1);
|
||||||
assert_eq!(calls[0].id, "tool_empty");
|
assert_eq!(calls[0].id, "tool_empty");
|
||||||
assert_eq!(calls[0].name, "ListPods");
|
assert_eq!(calls[0].name, "ListWorkers");
|
||||||
assert!(calls[0].input.is_object());
|
assert!(calls[0].input.is_object());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
calls[0].input,
|
calls[0].input,
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ impl ToolServerHandle {
|
||||||
/// Execute all pending factories and register the resulting tools.
|
/// Execute all pending factories and register the resulting tools.
|
||||||
///
|
///
|
||||||
/// Called implicitly by `Engine::lock()` before the first turn.
|
/// Called implicitly by `Engine::lock()` before the first turn.
|
||||||
/// Exposed as `pub` so higher layers (e.g. Pod) can force-materialise
|
/// Exposed as `pub` so higher layers (e.g. Worker) can force-materialise
|
||||||
/// tools earlier — for example when building a system-prompt template
|
/// tools earlier — for example when building a system-prompt template
|
||||||
/// context that needs the list of registered tool names. Redundant
|
/// context that needs the list of registered tool names. Redundant
|
||||||
/// calls are no-ops.
|
/// calls are no-ops.
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
//!
|
//!
|
||||||
//! 1 リクエストの送信時点での「ある history prefix 長で計測した占有量」を
|
//! 1 リクエストの送信時点での「ある history prefix 長で計測した占有量」を
|
||||||
//! 1 件分にまとめたもの。`UsageEvent` (provider stream イベント) を
|
//! 1 件分にまとめたもの。`UsageEvent` (provider stream イベント) を
|
||||||
//! 受けて呼び出し側 (typically Pod) が組み立て、永続化層
|
//! 受けて呼び出し側 (typically Worker) が組み立て、永続化層
|
||||||
//! (session-store) に流したり、token accounting (`token_counter`) で
|
//! (session-store) に流したり、token accounting (`token_counter`) で
|
||||||
//! 履歴として参照したりする。
|
//! 履歴として参照したりする。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
## Role
|
## Role
|
||||||
|
|
||||||
`manifest` resolves reusable profile/configuration inputs into the concrete runtime Manifest used to create or restore Pods.
|
`manifest` resolves reusable profile/configuration inputs into the concrete runtime Manifest used to create or restore Workers.
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
|
|
@ -17,13 +17,13 @@ Owns:
|
||||||
Does not own:
|
Does not own:
|
||||||
|
|
||||||
- provider HTTP clients or secret lookup implementation (`provider`, `secrets`)
|
- provider HTTP clients or secret lookup implementation (`provider`, `secrets`)
|
||||||
- Pod lifecycle (`pod`)
|
- Worker lifecycle (`worker`)
|
||||||
- product CLI parsing (`yoi`)
|
- product CLI parsing (`yoi`)
|
||||||
- generated memory records (`memory`)
|
- generated memory records (`memory`)
|
||||||
|
|
||||||
## Design notes
|
## Design notes
|
||||||
|
|
||||||
Profiles are reusable recipes; resolved Manifests are runtime contracts. Keep runtime-bound fields such as Pod name, concrete delegated scope, sockets, session pointers, and raw secrets out of reusable Profiles.
|
Profiles are reusable recipes; resolved Manifests are runtime contracts. Keep runtime-bound fields such as Worker name, concrete delegated scope, sockets, session pointers, and raw secrets out of reusable Profiles.
|
||||||
|
|
||||||
## See also
|
## See also
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
//! Partial-form of [`crate::PodManifest`] used as cascade layers.
|
//! Partial-form of [`crate::WorkerManifest`] used as cascade layers.
|
||||||
//!
|
//!
|
||||||
//! `PodManifestConfig` mirrors `PodManifest` but every field is optional
|
//! `WorkerManifestConfig` mirrors `WorkerManifest` but every field is optional
|
||||||
//! so individual layers (builtin defaults, user manifest, project
|
//! so individual layers (builtin defaults, user manifest, project
|
||||||
//! manifest, programmatic overlay) can be partial. Layers are combined
|
//! manifest, programmatic overlay) can be partial. Layers are combined
|
||||||
//! via [`PodManifestConfig::merge`] and the final config is converted to
|
//! via [`WorkerManifestConfig::merge`] and the final config is converted to
|
||||||
//! a validated [`PodManifest`] via `TryFrom`.
|
//! a validated [`WorkerManifest`] via `TryFrom`.
|
||||||
|
|
||||||
use std::collections::{BTreeSet, HashMap};
|
use std::collections::{BTreeSet, HashMap};
|
||||||
use std::num::NonZeroU32;
|
use std::num::NonZeroU32;
|
||||||
|
|
@ -17,19 +17,19 @@ use crate::defaults;
|
||||||
use crate::model::{AuthRef, ModelManifest, ReasoningControl};
|
use crate::model::{AuthRef, ModelManifest, ReasoningControl};
|
||||||
use crate::plugin::PluginConfig;
|
use crate::plugin::PluginConfig;
|
||||||
use crate::{
|
use crate::{
|
||||||
CompactionConfig, FeatureConfig, FeatureFlagConfig, FileUploadLimits, McpConfig, McpEnvValue,
|
CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
|
||||||
McpStdioCwdPolicy, MemoryConfig, PodManifest, PodMeta, ScopeConfig, SessionConfig,
|
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConfig, ScopeConfig, SessionConfig,
|
||||||
SkillsConfig, TicketFeatureAccessConfig, TicketFeatureConfig, ToolOutputLimits,
|
SkillsConfig, TicketFeatureAccessConfig, TicketFeatureConfig, ToolOutputLimits,
|
||||||
ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerManifest,
|
ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerManifest, WorkerMeta,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Partial-form Pod manifest. Every field is optional; one or more
|
/// Partial-form Worker manifest. Every field is optional; one or more
|
||||||
/// instances merge via [`PodManifestConfig::merge`] before being
|
/// instances merge via [`WorkerManifestConfig::merge`] before being
|
||||||
/// converted to a validated [`PodManifest`] via `TryFrom`.
|
/// converted to a validated [`WorkerManifest`] via `TryFrom`.
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct PodManifestConfig {
|
pub struct WorkerManifestConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub pod: PodMetaConfig,
|
pub worker: WorkerMetaConfig,
|
||||||
/// `[model]` セクションは partial でも完成形でも同じ
|
/// `[model]` セクションは partial でも完成形でも同じ
|
||||||
/// [`ModelManifest`] を使う。ref / inline の両形を受け入れるための
|
/// [`ModelManifest`] を使う。ref / inline の両形を受け入れるための
|
||||||
/// 全 Optional 構造なので、カスケード層と最終マニフェストで型を
|
/// 全 Optional 構造なので、カスケード層と最終マニフェストで型を
|
||||||
|
|
@ -37,10 +37,10 @@ pub struct PodManifestConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub model: ModelManifest,
|
pub model: ModelManifest,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub worker: WorkerManifestConfig,
|
pub engine: EngineManifestConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub scope: ScopeConfig,
|
pub scope: ScopeConfig,
|
||||||
/// Scope that may be subdelegated to spawned child Pods. Defaults empty.
|
/// Scope that may be subdelegated to spawned child Workers. Defaults empty.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub delegation_scope: ScopeConfig,
|
pub delegation_scope: ScopeConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|
@ -83,7 +83,7 @@ pub struct FeatureConfigPartial {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub web: Option<FeatureFlagConfigPartial>,
|
pub web: Option<FeatureFlagConfigPartial>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub pods: Option<FeatureFlagConfigPartial>,
|
pub workers: Option<FeatureFlagConfigPartial>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ticket: Option<TicketFeatureConfigPartial>,
|
pub ticket: Option<TicketFeatureConfigPartial>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|
@ -98,7 +98,7 @@ impl FeatureConfigPartial {
|
||||||
task: merge_option(self.task, other.task, FeatureFlagConfigPartial::merge),
|
task: merge_option(self.task, other.task, FeatureFlagConfigPartial::merge),
|
||||||
memory: merge_option(self.memory, other.memory, FeatureFlagConfigPartial::merge),
|
memory: merge_option(self.memory, other.memory, FeatureFlagConfigPartial::merge),
|
||||||
web: merge_option(self.web, other.web, FeatureFlagConfigPartial::merge),
|
web: merge_option(self.web, other.web, FeatureFlagConfigPartial::merge),
|
||||||
pods: merge_option(self.pods, other.pods, FeatureFlagConfigPartial::merge),
|
workers: merge_option(self.workers, other.workers, FeatureFlagConfigPartial::merge),
|
||||||
ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge),
|
ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge),
|
||||||
ticket_orchestration: merge_option(
|
ticket_orchestration: merge_option(
|
||||||
self.ticket_orchestration,
|
self.ticket_orchestration,
|
||||||
|
|
@ -150,7 +150,10 @@ impl From<FeatureConfigPartial> for FeatureConfig {
|
||||||
.map(FeatureFlagConfig::from)
|
.map(FeatureFlagConfig::from)
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
web: value.web.map(FeatureFlagConfig::from).unwrap_or_default(),
|
web: value.web.map(FeatureFlagConfig::from).unwrap_or_default(),
|
||||||
pods: value.pods.map(FeatureFlagConfig::from).unwrap_or_default(),
|
workers: value
|
||||||
|
.workers
|
||||||
|
.map(FeatureFlagConfig::from)
|
||||||
|
.unwrap_or_default(),
|
||||||
ticket: value
|
ticket: value
|
||||||
.ticket
|
.ticket
|
||||||
.map(TicketFeatureConfig::from)
|
.map(TicketFeatureConfig::from)
|
||||||
|
|
@ -207,7 +210,7 @@ impl From<FeatureConfig> for FeatureConfigPartial {
|
||||||
task: Some(value.task.into()),
|
task: Some(value.task.into()),
|
||||||
memory: Some(value.memory.into()),
|
memory: Some(value.memory.into()),
|
||||||
web: Some(value.web.into()),
|
web: Some(value.web.into()),
|
||||||
pods: Some(value.pods.into()),
|
workers: Some(value.workers.into()),
|
||||||
ticket: Some(value.ticket.into()),
|
ticket: Some(value.ticket.into()),
|
||||||
ticket_orchestration: Some(value.ticket_orchestration.into()),
|
ticket_orchestration: Some(value.ticket_orchestration.into()),
|
||||||
plugins: Some(value.plugins.into()),
|
plugins: Some(value.plugins.into()),
|
||||||
|
|
@ -216,18 +219,18 @@ impl From<FeatureConfig> for FeatureConfigPartial {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct PodMetaConfig {
|
pub struct WorkerMetaConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
/// Optional `PromptCatalog` manifest pack override. See
|
/// Optional `PromptCatalog` manifest pack override. See
|
||||||
/// [`crate::PodMeta::prompt_pack`] for semantics. Relative paths
|
/// [`crate::WorkerMeta::prompt_pack`] for semantics. Relative paths
|
||||||
/// are resolved through [`PodManifestConfig::resolve_paths`].
|
/// are resolved through [`WorkerManifestConfig::resolve_paths`].
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub prompt_pack: Option<PathBuf>,
|
pub prompt_pack: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct WorkerManifestConfig {
|
pub struct EngineManifestConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub instruction: Option<String>,
|
pub instruction: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|
@ -318,8 +321,8 @@ pub struct CompactionConfigPartial {
|
||||||
pub model: Option<ModelManifest>,
|
pub model: Option<ModelManifest>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Errors raised when converting a [`PodManifestConfig`] to a validated
|
/// Errors raised when converting a [`WorkerManifestConfig`] to a validated
|
||||||
/// [`PodManifest`] via `TryFrom`.
|
/// [`WorkerManifest`] via `TryFrom`.
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum ResolveError {
|
pub enum ResolveError {
|
||||||
#[error("missing required field: {0}")]
|
#[error("missing required field: {0}")]
|
||||||
|
|
@ -359,7 +362,7 @@ pub(crate) fn reject_removed_manifest_fields(s: &str) -> Result<(), toml::de::Er
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PodManifestConfig {
|
impl WorkerManifestConfig {
|
||||||
/// Parse a partial manifest from a TOML string. Unknown top-level or
|
/// Parse a partial manifest from a TOML string. Unknown top-level or
|
||||||
/// nested fields emit a `tracing::warn!` and are ignored; use
|
/// nested fields emit a `tracing::warn!` and are ignored; use
|
||||||
/// `tracing_subscriber` with `WARN` enabled to surface them to the
|
/// `tracing_subscriber` with `WARN` enabled to surface them to the
|
||||||
|
|
@ -378,12 +381,12 @@ impl PodManifestConfig {
|
||||||
/// from this layer so every per-field default lives at exactly one
|
/// from this layer so every per-field default lives at exactly one
|
||||||
/// call site (the `defaults` module).
|
/// call site (the `defaults` module).
|
||||||
///
|
///
|
||||||
/// `TryFrom<PodManifestConfig>` also reads the same constants as a
|
/// `TryFrom<WorkerManifestConfig>` also reads the same constants as a
|
||||||
/// belt-and-suspenders fallback, so a manually-constructed config
|
/// belt-and-suspenders fallback, so a manually-constructed config
|
||||||
/// that skips this layer still resolves to the same values.
|
/// that skips this layer still resolves to the same values.
|
||||||
pub fn builtin_defaults() -> Self {
|
pub fn builtin_defaults() -> Self {
|
||||||
Self {
|
Self {
|
||||||
worker: WorkerManifestConfig {
|
engine: EngineManifestConfig {
|
||||||
tool_output: ToolOutputLimitsPartial {
|
tool_output: ToolOutputLimitsPartial {
|
||||||
default_max_bytes: Some(defaults::TOOL_OUTPUT_MAX_BYTES),
|
default_max_bytes: Some(defaults::TOOL_OUTPUT_MAX_BYTES),
|
||||||
per_tool: HashMap::new(),
|
per_tool: HashMap::new(),
|
||||||
|
|
@ -415,7 +418,7 @@ impl PodManifestConfig {
|
||||||
base.display()
|
base.display()
|
||||||
);
|
);
|
||||||
resolve_auth_file(&mut self.model.auth, base);
|
resolve_auth_file(&mut self.model.auth, base);
|
||||||
if let Some(ref mut pack) = self.pod.prompt_pack {
|
if let Some(ref mut pack) = self.worker.prompt_pack {
|
||||||
*pack = join_if_relative(base, pack);
|
*pack = join_if_relative(base, pack);
|
||||||
}
|
}
|
||||||
for rule in &mut self.scope.allow {
|
for rule in &mut self.scope.allow {
|
||||||
|
|
@ -457,11 +460,11 @@ impl PodManifestConfig {
|
||||||
/// fields from `self`. Map entries merge key-wise with `upper`
|
/// fields from `self`. Map entries merge key-wise with `upper`
|
||||||
/// winning on conflict. Scope rules from both layers accumulate
|
/// winning on conflict. Scope rules from both layers accumulate
|
||||||
/// (see [`ScopeConfig`] semantics).
|
/// (see [`ScopeConfig`] semantics).
|
||||||
pub fn merge(self, upper: PodManifestConfig) -> Self {
|
pub fn merge(self, upper: WorkerManifestConfig) -> Self {
|
||||||
Self {
|
Self {
|
||||||
pod: self.pod.merge(upper.pod),
|
|
||||||
model: self.model.merge(upper.model),
|
|
||||||
worker: self.worker.merge(upper.worker),
|
worker: self.worker.merge(upper.worker),
|
||||||
|
model: self.model.merge(upper.model),
|
||||||
|
engine: self.engine.merge(upper.engine),
|
||||||
scope: merge_scope(self.scope, upper.scope),
|
scope: merge_scope(self.scope, upper.scope),
|
||||||
delegation_scope: merge_scope(self.delegation_scope, upper.delegation_scope),
|
delegation_scope: merge_scope(self.delegation_scope, upper.delegation_scope),
|
||||||
session: merge_option(self.session, upper.session, SessionConfigPartial::merge),
|
session: merge_option(self.session, upper.session, SessionConfigPartial::merge),
|
||||||
|
|
@ -575,7 +578,7 @@ impl MemoryConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PodMetaConfig {
|
impl WorkerMetaConfig {
|
||||||
fn merge(self, upper: Self) -> Self {
|
fn merge(self, upper: Self) -> Self {
|
||||||
Self {
|
Self {
|
||||||
name: upper.name.or(self.name),
|
name: upper.name.or(self.name),
|
||||||
|
|
@ -584,7 +587,7 @@ impl PodMetaConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkerManifestConfig {
|
impl EngineManifestConfig {
|
||||||
fn merge(self, upper: Self) -> Self {
|
fn merge(self, upper: Self) -> Self {
|
||||||
Self {
|
Self {
|
||||||
instruction: upper.instruction.or(self.instruction),
|
instruction: upper.instruction.or(self.instruction),
|
||||||
|
|
@ -696,9 +699,9 @@ fn join_if_relative(base: &Path, p: &Path) -> PathBuf {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Invariant check: every path in a fully-resolved [`PodManifestConfig`]
|
/// Invariant check: every path in a fully-resolved [`WorkerManifestConfig`]
|
||||||
/// must be absolute. Relative paths are resolved per-layer via
|
/// must be absolute. Relative paths are resolved per-layer via
|
||||||
/// [`PodManifestConfig::resolve_paths`]; if one reaches `TryFrom` it
|
/// [`WorkerManifestConfig::resolve_paths`]; if one reaches `TryFrom` it
|
||||||
/// indicates a caller skipped the per-layer resolve step.
|
/// indicates a caller skipped the per-layer resolve step.
|
||||||
fn ensure_absolute(field: &'static str, path: &Path) -> Result<(), ResolveError> {
|
fn ensure_absolute(field: &'static str, path: &Path) -> Result<(), ResolveError> {
|
||||||
if path.is_absolute() {
|
if path.is_absolute() {
|
||||||
|
|
@ -871,45 +874,48 @@ fn bounded_label(value: &str) -> String {
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TryFrom<PodManifestConfig> for PodManifest {
|
impl TryFrom<WorkerManifestConfig> for WorkerManifest {
|
||||||
type Error = ResolveError;
|
type Error = ResolveError;
|
||||||
|
|
||||||
fn try_from(cfg: PodManifestConfig) -> Result<Self, Self::Error> {
|
fn try_from(cfg: WorkerManifestConfig) -> Result<Self, Self::Error> {
|
||||||
let name = cfg.pod.name.ok_or(ResolveError::MissingField("pod.name"))?;
|
let name = cfg
|
||||||
let prompt_pack = cfg.pod.prompt_pack;
|
.worker
|
||||||
|
.name
|
||||||
|
.ok_or(ResolveError::MissingField("worker.name"))?;
|
||||||
|
let prompt_pack = cfg.worker.prompt_pack;
|
||||||
if let Some(ref p) = prompt_pack {
|
if let Some(ref p) = prompt_pack {
|
||||||
ensure_absolute("pod.prompt_pack", p)?;
|
ensure_absolute("worker.prompt_pack", p)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
validate_model_paths(&cfg.model, "model.auth.file")?;
|
validate_model_paths(&cfg.model, "model.auth.file")?;
|
||||||
|
|
||||||
let worker = WorkerManifest {
|
let engine = EngineManifest {
|
||||||
instruction: cfg
|
instruction: cfg
|
||||||
.worker
|
.engine
|
||||||
.instruction
|
.instruction
|
||||||
.unwrap_or_else(|| defaults::DEFAULT_INSTRUCTION.to_string()),
|
.unwrap_or_else(|| defaults::DEFAULT_INSTRUCTION.to_string()),
|
||||||
language: cfg
|
language: cfg
|
||||||
.worker
|
.engine
|
||||||
.language
|
.language
|
||||||
.unwrap_or_else(|| defaults::WORKER_LANGUAGE.to_string()),
|
.unwrap_or_else(|| defaults::WORKER_LANGUAGE.to_string()),
|
||||||
max_tokens: cfg.worker.max_tokens,
|
max_tokens: cfg.engine.max_tokens,
|
||||||
max_turns: cfg.worker.max_turns,
|
max_turns: cfg.engine.max_turns,
|
||||||
temperature: cfg.worker.temperature,
|
temperature: cfg.engine.temperature,
|
||||||
top_p: cfg.worker.top_p,
|
top_p: cfg.engine.top_p,
|
||||||
top_k: cfg.worker.top_k,
|
top_k: cfg.engine.top_k,
|
||||||
stop_sequences: cfg.worker.stop_sequences.unwrap_or_default(),
|
stop_sequences: cfg.engine.stop_sequences.unwrap_or_default(),
|
||||||
reasoning: cfg.worker.reasoning,
|
reasoning: cfg.engine.reasoning,
|
||||||
tool_output: ToolOutputLimits {
|
tool_output: ToolOutputLimits {
|
||||||
default_max_bytes: cfg
|
default_max_bytes: cfg
|
||||||
.worker
|
.engine
|
||||||
.tool_output
|
.tool_output
|
||||||
.default_max_bytes
|
.default_max_bytes
|
||||||
.unwrap_or(defaults::TOOL_OUTPUT_MAX_BYTES),
|
.unwrap_or(defaults::TOOL_OUTPUT_MAX_BYTES),
|
||||||
per_tool: cfg.worker.tool_output.per_tool,
|
per_tool: cfg.engine.tool_output.per_tool,
|
||||||
},
|
},
|
||||||
file_upload: FileUploadLimits {
|
file_upload: FileUploadLimits {
|
||||||
max_bytes: cfg
|
max_bytes: cfg
|
||||||
.worker
|
.engine
|
||||||
.file_upload
|
.file_upload
|
||||||
.max_bytes
|
.max_bytes
|
||||||
.unwrap_or(defaults::FILE_UPLOAD_MAX_BYTES),
|
.unwrap_or(defaults::FILE_UPLOAD_MAX_BYTES),
|
||||||
|
|
@ -1007,10 +1013,10 @@ impl TryFrom<PodManifestConfig> for PodManifest {
|
||||||
|
|
||||||
validate_mcp_config(&cfg.mcp)?;
|
validate_mcp_config(&cfg.mcp)?;
|
||||||
|
|
||||||
Ok(PodManifest {
|
Ok(WorkerManifest {
|
||||||
pod: PodMeta { name, prompt_pack },
|
worker: WorkerMeta { name, prompt_pack },
|
||||||
model: cfg.model,
|
model: cfg.model,
|
||||||
worker,
|
engine,
|
||||||
scope: cfg.scope,
|
scope: cfg.scope,
|
||||||
delegation_scope: cfg.delegation_scope,
|
delegation_scope: cfg.delegation_scope,
|
||||||
session,
|
session,
|
||||||
|
|
@ -1041,9 +1047,9 @@ mod tests {
|
||||||
AuthRef::ApiKey { file: Some(path) }
|
AuthRef::ApiKey { file: Some(path) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn minimal_valid() -> PodManifestConfig {
|
fn minimal_valid() -> WorkerManifestConfig {
|
||||||
PodManifestConfig {
|
WorkerManifestConfig {
|
||||||
pod: PodMetaConfig {
|
worker: WorkerMetaConfig {
|
||||||
name: Some("test".into()),
|
name: Some("test".into()),
|
||||||
prompt_pack: None,
|
prompt_pack: None,
|
||||||
},
|
},
|
||||||
|
|
@ -1052,10 +1058,10 @@ mod tests {
|
||||||
model_id: Some("claude-sonnet-4-20250514".into()),
|
model_id: Some("claude-sonnet-4-20250514".into()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
worker: WorkerManifestConfig::default(),
|
engine: EngineManifestConfig::default(),
|
||||||
scope: ScopeConfig {
|
scope: ScopeConfig {
|
||||||
allow: vec![ScopeRule {
|
allow: vec![ScopeRule {
|
||||||
target: abs("/pod"),
|
target: abs("/worker"),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
}],
|
}],
|
||||||
|
|
@ -1076,8 +1082,8 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_minimal_succeeds() {
|
fn resolve_minimal_succeeds() {
|
||||||
let manifest: PodManifest = minimal_valid().try_into().unwrap();
|
let manifest: WorkerManifest = minimal_valid().try_into().unwrap();
|
||||||
assert_eq!(manifest.pod.name, "test");
|
assert_eq!(manifest.worker.name, "test");
|
||||||
assert_eq!(manifest.model.scheme, Some(SchemeKind::Anthropic));
|
assert_eq!(manifest.model.scheme, Some(SchemeKind::Anthropic));
|
||||||
assert!(manifest.permissions.is_none());
|
assert!(manifest.permissions.is_none());
|
||||||
}
|
}
|
||||||
|
|
@ -1113,7 +1119,7 @@ mod tests {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let manifest: PodManifest = cfg.try_into().unwrap();
|
let manifest: WorkerManifest = cfg.try_into().unwrap();
|
||||||
|
|
||||||
assert_eq!(manifest.mcp.stdio_servers.len(), 1);
|
assert_eq!(manifest.mcp.stdio_servers.len(), 1);
|
||||||
let server = &manifest.mcp.stdio_servers[0];
|
let server = &manifest.mcp.stdio_servers[0];
|
||||||
|
|
@ -1137,7 +1143,7 @@ mod tests {
|
||||||
env: crate::McpEnvConfig::default(),
|
env: crate::McpEnvConfig::default(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let err = PodManifest::try_from(cfg).unwrap_err();
|
let err = WorkerManifest::try_from(cfg).unwrap_err();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
err,
|
err,
|
||||||
ResolveError::InvalidMcpConfig {
|
ResolveError::InvalidMcpConfig {
|
||||||
|
|
@ -1157,7 +1163,7 @@ mod tests {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let err = PodManifest::try_from(cfg).unwrap_err();
|
let err = WorkerManifest::try_from(cfg).unwrap_err();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
err,
|
err,
|
||||||
ResolveError::InvalidMcpConfig {
|
ResolveError::InvalidMcpConfig {
|
||||||
|
|
@ -1186,7 +1192,7 @@ mod tests {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let err = PodManifest::try_from(cfg).unwrap_err();
|
let err = WorkerManifest::try_from(cfg).unwrap_err();
|
||||||
let rendered = err.to_string();
|
let rendered = err.to_string();
|
||||||
assert!(rendered.contains("secret_ref"));
|
assert!(rendered.contains("secret_ref"));
|
||||||
assert!(!rendered.contains("bad secret id with spaces"));
|
assert!(!rendered.contains("bad secret id with spaces"));
|
||||||
|
|
@ -1208,7 +1214,7 @@ mod tests {
|
||||||
env: crate::McpEnvConfig::default(),
|
env: crate::McpEnvConfig::default(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let manifest: PodManifest = cfg.try_into().unwrap();
|
let manifest: WorkerManifest = cfg.try_into().unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
manifest.mcp.stdio_servers[0].command,
|
manifest.mcp.stdio_servers[0].command,
|
||||||
"definitely-not-a-command-yoi-must-spawn"
|
"definitely-not-a-command-yoi-must-spawn"
|
||||||
|
|
@ -1222,7 +1228,7 @@ mod tests {
|
||||||
record_event_trace: Some(true),
|
record_event_trace: Some(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
let manifest: PodManifest = cfg.try_into().unwrap();
|
let manifest: WorkerManifest = cfg.try_into().unwrap();
|
||||||
assert!(manifest.session.record_event_trace);
|
assert!(manifest.session.record_event_trace);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1234,7 +1240,7 @@ mod tests {
|
||||||
rules: Vec::new(),
|
rules: Vec::new(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let err = PodManifest::try_from(cfg).unwrap_err();
|
let err = WorkerManifest::try_from(cfg).unwrap_err();
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
err,
|
err,
|
||||||
|
|
@ -1261,7 +1267,7 @@ mod tests {
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
let manifest: PodManifest = cfg.try_into().unwrap();
|
let manifest: WorkerManifest = cfg.try_into().unwrap();
|
||||||
let permissions = manifest.permissions.unwrap();
|
let permissions = manifest.permissions.unwrap();
|
||||||
|
|
||||||
assert_eq!(permissions.default_action, crate::ToolPermissionAction::Ask);
|
assert_eq!(permissions.default_action, crate::ToolPermissionAction::Ask);
|
||||||
|
|
@ -1318,7 +1324,7 @@ mod tests {
|
||||||
fn try_from_invariant_rejects_lingering_relative_auth_file() {
|
fn try_from_invariant_rejects_lingering_relative_auth_file() {
|
||||||
let mut cfg = minimal_valid();
|
let mut cfg = minimal_valid();
|
||||||
cfg.model.auth = Some(api_key_file_auth(PathBuf::from("keys/relative")));
|
cfg.model.auth = Some(api_key_file_auth(PathBuf::from("keys/relative")));
|
||||||
let err = PodManifest::try_from(cfg).unwrap_err();
|
let err = WorkerManifest::try_from(cfg).unwrap_err();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
err,
|
err,
|
||||||
ResolveError::RelativePath {
|
ResolveError::RelativePath {
|
||||||
|
|
@ -1332,7 +1338,7 @@ mod tests {
|
||||||
fn try_from_invariant_rejects_lingering_relative_scope_target() {
|
fn try_from_invariant_rejects_lingering_relative_scope_target() {
|
||||||
let mut cfg = minimal_valid();
|
let mut cfg = minimal_valid();
|
||||||
cfg.scope.allow[0].target = PathBuf::from("docs");
|
cfg.scope.allow[0].target = PathBuf::from("docs");
|
||||||
let err = PodManifest::try_from(cfg).unwrap_err();
|
let err = WorkerManifest::try_from(cfg).unwrap_err();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
err,
|
err,
|
||||||
ResolveError::RelativePath {
|
ResolveError::RelativePath {
|
||||||
|
|
@ -1343,25 +1349,25 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_rejects_missing_pod_name() {
|
fn resolve_rejects_missing_worker_name() {
|
||||||
let mut cfg = minimal_valid();
|
let mut cfg = minimal_valid();
|
||||||
cfg.pod.name = None;
|
cfg.worker.name = None;
|
||||||
let err = PodManifest::try_from(cfg).unwrap_err();
|
let err = WorkerManifest::try_from(cfg).unwrap_err();
|
||||||
assert!(matches!(err, ResolveError::MissingField("pod.name")));
|
assert!(matches!(err, ResolveError::MissingField("worker.name")));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_accepts_empty_scope_for_profile_launch_policy() {
|
fn resolve_accepts_empty_scope_for_profile_launch_policy() {
|
||||||
let mut cfg = minimal_valid();
|
let mut cfg = minimal_valid();
|
||||||
cfg.scope.allow.clear();
|
cfg.scope.allow.clear();
|
||||||
let manifest = PodManifest::try_from(cfg).unwrap();
|
let manifest = WorkerManifest::try_from(cfg).unwrap();
|
||||||
assert!(manifest.scope.allow.is_empty());
|
assert!(manifest.scope.allow.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn merge_scalar_upper_wins() {
|
fn merge_scalar_upper_wins() {
|
||||||
let lower = PodManifestConfig {
|
let lower = WorkerManifestConfig {
|
||||||
pod: PodMetaConfig {
|
worker: WorkerMetaConfig {
|
||||||
name: Some("lower".into()),
|
name: Some("lower".into()),
|
||||||
prompt_pack: None,
|
prompt_pack: None,
|
||||||
},
|
},
|
||||||
|
|
@ -1371,30 +1377,30 @@ mod tests {
|
||||||
},
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let upper = PodManifestConfig {
|
let upper = WorkerManifestConfig {
|
||||||
pod: PodMetaConfig {
|
worker: WorkerMetaConfig {
|
||||||
name: Some("upper".into()),
|
name: Some("upper".into()),
|
||||||
prompt_pack: None,
|
prompt_pack: None,
|
||||||
},
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let merged = lower.merge(upper);
|
let merged = lower.merge(upper);
|
||||||
assert_eq!(merged.pod.name.as_deref(), Some("upper"));
|
assert_eq!(merged.worker.name.as_deref(), Some("upper"));
|
||||||
// model_id not present in upper — retain lower
|
// model_id not present in upper — retain lower
|
||||||
assert_eq!(merged.model.model_id.as_deref(), Some("lower-model"));
|
assert_eq!(merged.model.model_id.as_deref(), Some("lower-model"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn merge_worker_reasoning_upper_wins() {
|
fn merge_worker_reasoning_upper_wins() {
|
||||||
let lower = PodManifestConfig {
|
let lower = WorkerManifestConfig {
|
||||||
worker: WorkerManifestConfig {
|
engine: EngineManifestConfig {
|
||||||
reasoning: Some(ReasoningControl::Effort(ReasoningEffort::Low)),
|
reasoning: Some(ReasoningControl::Effort(ReasoningEffort::Low)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let upper = PodManifestConfig {
|
let upper = WorkerManifestConfig {
|
||||||
worker: WorkerManifestConfig {
|
engine: EngineManifestConfig {
|
||||||
reasoning: Some(ReasoningControl::BudgetTokens(4096)),
|
reasoning: Some(ReasoningControl::BudgetTokens(4096)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
|
|
@ -1404,15 +1410,15 @@ mod tests {
|
||||||
let merged = lower.merge(upper);
|
let merged = lower.merge(upper);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
merged.worker.reasoning,
|
merged.engine.reasoning,
|
||||||
Some(ReasoningControl::BudgetTokens(4096))
|
Some(ReasoningControl::BudgetTokens(4096))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn merge_worker_generation_settings_upper_wins() {
|
fn merge_worker_generation_settings_upper_wins() {
|
||||||
let lower = PodManifestConfig {
|
let lower = WorkerManifestConfig {
|
||||||
worker: WorkerManifestConfig {
|
engine: EngineManifestConfig {
|
||||||
top_p: Some(0.8),
|
top_p: Some(0.8),
|
||||||
top_k: Some(20),
|
top_k: Some(20),
|
||||||
stop_sequences: Some(vec!["lower".into()]),
|
stop_sequences: Some(vec!["lower".into()]),
|
||||||
|
|
@ -1420,8 +1426,8 @@ mod tests {
|
||||||
},
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let upper = PodManifestConfig {
|
let upper = WorkerManifestConfig {
|
||||||
worker: WorkerManifestConfig {
|
engine: EngineManifestConfig {
|
||||||
top_p: Some(0.9),
|
top_p: Some(0.9),
|
||||||
stop_sequences: Some(vec!["upper".into()]),
|
stop_sequences: Some(vec!["upper".into()]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
|
@ -1431,14 +1437,14 @@ mod tests {
|
||||||
|
|
||||||
let merged = lower.merge(upper);
|
let merged = lower.merge(upper);
|
||||||
|
|
||||||
assert_eq!(merged.worker.top_p, Some(0.9));
|
assert_eq!(merged.engine.top_p, Some(0.9));
|
||||||
assert_eq!(merged.worker.top_k, Some(20));
|
assert_eq!(merged.engine.top_k, Some(20));
|
||||||
assert_eq!(merged.worker.stop_sequences, Some(vec!["upper".into()]));
|
assert_eq!(merged.engine.stop_sequences, Some(vec!["upper".into()]));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn merge_scope_accumulates_allow_and_deny() {
|
fn merge_scope_accumulates_allow_and_deny() {
|
||||||
let lower = PodManifestConfig {
|
let lower = WorkerManifestConfig {
|
||||||
scope: ScopeConfig {
|
scope: ScopeConfig {
|
||||||
allow: vec![ScopeRule {
|
allow: vec![ScopeRule {
|
||||||
target: abs("/a"),
|
target: abs("/a"),
|
||||||
|
|
@ -1449,7 +1455,7 @@ mod tests {
|
||||||
},
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let upper = PodManifestConfig {
|
let upper = WorkerManifestConfig {
|
||||||
scope: ScopeConfig {
|
scope: ScopeConfig {
|
||||||
allow: vec![ScopeRule {
|
allow: vec![ScopeRule {
|
||||||
target: abs("/b"),
|
target: abs("/b"),
|
||||||
|
|
@ -1471,7 +1477,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn merge_permissions_accumulates_rules_and_upper_default_wins() {
|
fn merge_permissions_accumulates_rules_and_upper_default_wins() {
|
||||||
let lower = PodManifestConfig {
|
let lower = WorkerManifestConfig {
|
||||||
permissions: Some(PermissionConfigPartial {
|
permissions: Some(PermissionConfigPartial {
|
||||||
default_action: Some(crate::ToolPermissionAction::Allow),
|
default_action: Some(crate::ToolPermissionAction::Allow),
|
||||||
rules: vec![ToolPermissionRule {
|
rules: vec![ToolPermissionRule {
|
||||||
|
|
@ -1482,7 +1488,7 @@ mod tests {
|
||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let upper = PodManifestConfig {
|
let upper = WorkerManifestConfig {
|
||||||
permissions: Some(PermissionConfigPartial {
|
permissions: Some(PermissionConfigPartial {
|
||||||
default_action: Some(crate::ToolPermissionAction::Deny),
|
default_action: Some(crate::ToolPermissionAction::Deny),
|
||||||
rules: vec![ToolPermissionRule {
|
rules: vec![ToolPermissionRule {
|
||||||
|
|
@ -1507,8 +1513,8 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn merge_tool_output_per_tool_keywise() {
|
fn merge_tool_output_per_tool_keywise() {
|
||||||
let lower = PodManifestConfig {
|
let lower = WorkerManifestConfig {
|
||||||
worker: WorkerManifestConfig {
|
engine: EngineManifestConfig {
|
||||||
tool_output: ToolOutputLimitsPartial {
|
tool_output: ToolOutputLimitsPartial {
|
||||||
default_max_bytes: Some(8192),
|
default_max_bytes: Some(8192),
|
||||||
per_tool: [("Read".to_string(), 1024)].into_iter().collect(),
|
per_tool: [("Read".to_string(), 1024)].into_iter().collect(),
|
||||||
|
|
@ -1517,8 +1523,8 @@ mod tests {
|
||||||
},
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let upper = PodManifestConfig {
|
let upper = WorkerManifestConfig {
|
||||||
worker: WorkerManifestConfig {
|
engine: EngineManifestConfig {
|
||||||
tool_output: ToolOutputLimitsPartial {
|
tool_output: ToolOutputLimitsPartial {
|
||||||
default_max_bytes: None,
|
default_max_bytes: None,
|
||||||
per_tool: [("Read".to_string(), 2048), ("Grep".to_string(), 512)]
|
per_tool: [("Read".to_string(), 2048), ("Grep".to_string(), 512)]
|
||||||
|
|
@ -1530,7 +1536,7 @@ mod tests {
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let merged = lower.merge(upper);
|
let merged = lower.merge(upper);
|
||||||
let to = &merged.worker.tool_output;
|
let to = &merged.engine.tool_output;
|
||||||
assert_eq!(to.default_max_bytes, Some(8192));
|
assert_eq!(to.default_max_bytes, Some(8192));
|
||||||
assert_eq!(to.per_tool.get("Read"), Some(&2048));
|
assert_eq!(to.per_tool.get("Read"), Some(&2048));
|
||||||
assert_eq!(to.per_tool.get("Grep"), Some(&512));
|
assert_eq!(to.per_tool.get("Grep"), Some(&512));
|
||||||
|
|
@ -1538,8 +1544,8 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn merge_file_upload_max_bytes_upper_wins() {
|
fn merge_file_upload_max_bytes_upper_wins() {
|
||||||
let lower = PodManifestConfig {
|
let lower = WorkerManifestConfig {
|
||||||
worker: WorkerManifestConfig {
|
engine: EngineManifestConfig {
|
||||||
file_upload: FileUploadLimitsPartial {
|
file_upload: FileUploadLimitsPartial {
|
||||||
max_bytes: Some(8192),
|
max_bytes: Some(8192),
|
||||||
},
|
},
|
||||||
|
|
@ -1547,8 +1553,8 @@ mod tests {
|
||||||
},
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let upper = PodManifestConfig {
|
let upper = WorkerManifestConfig {
|
||||||
worker: WorkerManifestConfig {
|
engine: EngineManifestConfig {
|
||||||
file_upload: FileUploadLimitsPartial {
|
file_upload: FileUploadLimitsPartial {
|
||||||
max_bytes: Some(54_321),
|
max_bytes: Some(54_321),
|
||||||
},
|
},
|
||||||
|
|
@ -1557,11 +1563,11 @@ mod tests {
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let merged = lower.merge(upper);
|
let merged = lower.merge(upper);
|
||||||
assert_eq!(merged.worker.file_upload.max_bytes, Some(54_321));
|
assert_eq!(merged.engine.file_upload.max_bytes, Some(54_321));
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn merge_option_struct_field_wise() {
|
fn merge_option_struct_field_wise() {
|
||||||
let lower = PodManifestConfig {
|
let lower = WorkerManifestConfig {
|
||||||
compaction: Some(CompactionConfigPartial {
|
compaction: Some(CompactionConfigPartial {
|
||||||
threshold: Some(50_000),
|
threshold: Some(50_000),
|
||||||
prune_protected_tokens: Some(5_000),
|
prune_protected_tokens: Some(5_000),
|
||||||
|
|
@ -1577,7 +1583,7 @@ mod tests {
|
||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let upper = PodManifestConfig {
|
let upper = WorkerManifestConfig {
|
||||||
compaction: Some(CompactionConfigPartial {
|
compaction: Some(CompactionConfigPartial {
|
||||||
threshold: Some(80_000),
|
threshold: Some(80_000),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
|
@ -1604,32 +1610,32 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn from_toml_type_mismatch_is_hard_error() {
|
fn from_toml_type_mismatch_is_hard_error() {
|
||||||
let bad = r#"
|
let bad = r#"
|
||||||
[pod]
|
[worker]
|
||||||
name = "x"
|
name = "x"
|
||||||
|
|
||||||
[worker]
|
[engine]
|
||||||
max_tokens = "not-a-number"
|
max_tokens = "not-a-number"
|
||||||
"#;
|
"#;
|
||||||
assert!(PodManifestConfig::from_toml(bad).is_err());
|
assert!(WorkerManifestConfig::from_toml(bad).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn from_toml_accepts_unknown_field() {
|
fn from_toml_accepts_unknown_field() {
|
||||||
// Unknown keys are warn-and-ignored, not hard errors.
|
// Unknown keys are warn-and-ignored, not hard errors.
|
||||||
// `pod.pwd` specifically is silently dropped after the
|
// `worker.pwd` specifically is silently dropped after the
|
||||||
// path-resolution ticket — keep it in the fixture to exercise
|
// path-resolution ticket — keep it in the fixture to exercise
|
||||||
// that code path.
|
// that code path.
|
||||||
let ok = r#"
|
let ok = r#"
|
||||||
[pod]
|
[worker]
|
||||||
name = "x"
|
name = "x"
|
||||||
pwd = "/obsolete"
|
pwd = "/obsolete"
|
||||||
|
|
||||||
[worker]
|
[engine]
|
||||||
max_tokens = 1000
|
max_tokens = 1000
|
||||||
unknown_future_field = "tolerated"
|
unknown_future_field = "tolerated"
|
||||||
"#;
|
"#;
|
||||||
let cfg = PodManifestConfig::from_toml(ok).unwrap();
|
let cfg = WorkerManifestConfig::from_toml(ok).unwrap();
|
||||||
assert_eq!(cfg.worker.max_tokens, Some(1000));
|
assert_eq!(cfg.engine.max_tokens, Some(1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1638,7 +1644,7 @@ unknown_future_field = "tolerated"
|
||||||
[compaction]
|
[compaction]
|
||||||
prune_protected_turns = 3
|
prune_protected_turns = 3
|
||||||
"#;
|
"#;
|
||||||
let err = PodManifestConfig::from_toml(bad).unwrap_err();
|
let err = WorkerManifestConfig::from_toml(bad).unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
err.to_string().contains("compaction.prune_protected_turns"),
|
err.to_string().contains("compaction.prune_protected_turns"),
|
||||||
"unexpected error: {err}"
|
"unexpected error: {err}"
|
||||||
|
|
@ -1651,7 +1657,7 @@ prune_protected_turns = 3
|
||||||
[memory]
|
[memory]
|
||||||
extract_worker_max_input_tokens = 30000
|
extract_worker_max_input_tokens = 30000
|
||||||
"#;
|
"#;
|
||||||
let err = PodManifestConfig::from_toml(bad).unwrap_err();
|
let err = WorkerManifestConfig::from_toml(bad).unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
err.to_string()
|
err.to_string()
|
||||||
.contains("memory.extract_worker_max_input_tokens"),
|
.contains("memory.extract_worker_max_input_tokens"),
|
||||||
|
|
@ -1661,7 +1667,7 @@ extract_worker_max_input_tokens = 30000
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn from_toml_accepts_extract_worker_max_turns() {
|
fn from_toml_accepts_extract_worker_max_turns() {
|
||||||
let cfg = PodManifestConfig::from_toml(
|
let cfg = WorkerManifestConfig::from_toml(
|
||||||
r#"
|
r#"
|
||||||
[memory]
|
[memory]
|
||||||
extract_worker_max_turns = 2
|
extract_worker_max_turns = 2
|
||||||
|
|
@ -1673,36 +1679,36 @@ extract_worker_max_turns = 2
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn from_toml_accepts_worker_reasoning_string_or_integer() {
|
fn from_toml_accepts_worker_reasoning_string_or_integer() {
|
||||||
let effort = PodManifestConfig::from_toml(
|
let effort = WorkerManifestConfig::from_toml(
|
||||||
r#"
|
r#"
|
||||||
[worker]
|
[engine]
|
||||||
reasoning = "xhigh"
|
reasoning = "xhigh"
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
effort.worker.reasoning,
|
effort.engine.reasoning,
|
||||||
Some(ReasoningControl::Effort(ReasoningEffort::XHigh))
|
Some(ReasoningControl::Effort(ReasoningEffort::XHigh))
|
||||||
);
|
);
|
||||||
|
|
||||||
let budget = PodManifestConfig::from_toml(
|
let budget = WorkerManifestConfig::from_toml(
|
||||||
r#"
|
r#"
|
||||||
[worker]
|
[engine]
|
||||||
reasoning = -1
|
reasoning = -1
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
budget.worker.reasoning,
|
budget.engine.reasoning,
|
||||||
Some(ReasoningControl::BudgetTokens(-1))
|
Some(ReasoningControl::BudgetTokens(-1))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn from_toml_accepts_worker_generation_settings() {
|
fn from_toml_accepts_worker_generation_settings() {
|
||||||
let cfg = PodManifestConfig::from_toml(
|
let cfg = WorkerManifestConfig::from_toml(
|
||||||
r#"
|
r#"
|
||||||
[worker]
|
[engine]
|
||||||
top_p = 0.9
|
top_p = 0.9
|
||||||
top_k = 40
|
top_k = 40
|
||||||
stop_sequences = ["\n\n", "</stop>"]
|
stop_sequences = ["\n\n", "</stop>"]
|
||||||
|
|
@ -1710,17 +1716,17 @@ stop_sequences = ["\n\n", "</stop>"]
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(cfg.worker.top_p, Some(0.9));
|
assert_eq!(cfg.engine.top_p, Some(0.9));
|
||||||
assert_eq!(cfg.worker.top_k, Some(40));
|
assert_eq!(cfg.engine.top_k, Some(40));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cfg.worker.stop_sequences,
|
cfg.engine.stop_sequences,
|
||||||
Some(vec!["\n\n".into(), "</stop>".into()])
|
Some(vec!["\n\n".into(), "</stop>".into()])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn from_toml_accepts_worker_max_turns() {
|
fn from_toml_accepts_worker_max_turns() {
|
||||||
let cfg = PodManifestConfig::from_toml(
|
let cfg = WorkerManifestConfig::from_toml(
|
||||||
r#"
|
r#"
|
||||||
[compaction]
|
[compaction]
|
||||||
worker_max_turns = 7
|
worker_max_turns = 7
|
||||||
|
|
@ -1736,7 +1742,7 @@ worker_max_turns = 7
|
||||||
let mut cfg = minimal_valid();
|
let mut cfg = minimal_valid();
|
||||||
cfg.compaction = Some(CompactionConfigPartial::default());
|
cfg.compaction = Some(CompactionConfigPartial::default());
|
||||||
|
|
||||||
let manifest = PodManifest::try_from(cfg).unwrap();
|
let manifest = WorkerManifest::try_from(cfg).unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
manifest.compaction.unwrap().worker_max_turns,
|
manifest.compaction.unwrap().worker_max_turns,
|
||||||
|
|
@ -1746,18 +1752,18 @@ worker_max_turns = 7
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn feature_flags_default_disabled_in_resolved_manifest() {
|
fn feature_flags_default_disabled_in_resolved_manifest() {
|
||||||
let manifest: PodManifest = minimal_valid().try_into().unwrap();
|
let manifest: WorkerManifest = minimal_valid().try_into().unwrap();
|
||||||
assert!(!manifest.feature.task.enabled);
|
assert!(!manifest.feature.task.enabled);
|
||||||
assert!(!manifest.feature.memory.enabled);
|
assert!(!manifest.feature.memory.enabled);
|
||||||
assert!(!manifest.feature.web.enabled);
|
assert!(!manifest.feature.web.enabled);
|
||||||
assert!(!manifest.feature.pods.enabled);
|
assert!(!manifest.feature.workers.enabled);
|
||||||
assert!(!manifest.feature.ticket.enabled);
|
assert!(!manifest.feature.ticket.enabled);
|
||||||
assert!(!manifest.feature.ticket_orchestration.enabled);
|
assert!(!manifest.feature.ticket_orchestration.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn from_toml_parses_explicit_feature_flags() {
|
fn from_toml_parses_explicit_feature_flags() {
|
||||||
let cfg = PodManifestConfig::from_toml(
|
let cfg = WorkerManifestConfig::from_toml(
|
||||||
r#"
|
r#"
|
||||||
[feature.task]
|
[feature.task]
|
||||||
enabled = true
|
enabled = true
|
||||||
|
|
@ -1771,10 +1777,10 @@ enabled = true
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let manifest: PodManifest = PodManifestConfig::builtin_defaults()
|
let manifest: WorkerManifest = WorkerManifestConfig::builtin_defaults()
|
||||||
.merge(cfg)
|
.merge(cfg)
|
||||||
.merge(PodManifestConfig {
|
.merge(WorkerManifestConfig {
|
||||||
pod: PodMetaConfig {
|
worker: WorkerMetaConfig {
|
||||||
name: Some("feature-test".into()),
|
name: Some("feature-test".into()),
|
||||||
prompt_pack: None,
|
prompt_pack: None,
|
||||||
},
|
},
|
||||||
|
|
@ -1785,7 +1791,7 @@ enabled = true
|
||||||
},
|
},
|
||||||
scope: ScopeConfig {
|
scope: ScopeConfig {
|
||||||
allow: vec![ScopeRule {
|
allow: vec![ScopeRule {
|
||||||
target: abs("/pod"),
|
target: abs("/worker"),
|
||||||
permission: Permission::Read,
|
permission: Permission::Read,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
}],
|
}],
|
||||||
|
|
@ -1807,7 +1813,7 @@ enabled = true
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn feature_flags_merge_as_partial_profile_layers() {
|
fn feature_flags_merge_as_partial_profile_layers() {
|
||||||
let base = PodManifestConfig::from_toml(
|
let base = WorkerManifestConfig::from_toml(
|
||||||
r#"
|
r#"
|
||||||
[feature.memory]
|
[feature.memory]
|
||||||
enabled = true
|
enabled = true
|
||||||
|
|
@ -1818,7 +1824,7 @@ access = "read_only"
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let upper = PodManifestConfig::from_toml(
|
let upper = WorkerManifestConfig::from_toml(
|
||||||
r#"
|
r#"
|
||||||
[feature.ticket]
|
[feature.ticket]
|
||||||
access = "lifecycle"
|
access = "lifecycle"
|
||||||
|
|
@ -1828,11 +1834,11 @@ enabled = true
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let manifest: PodManifest = PodManifestConfig::builtin_defaults()
|
let manifest: WorkerManifest = WorkerManifestConfig::builtin_defaults()
|
||||||
.merge(base)
|
.merge(base)
|
||||||
.merge(upper)
|
.merge(upper)
|
||||||
.merge(PodManifestConfig {
|
.merge(WorkerManifestConfig {
|
||||||
pod: PodMetaConfig {
|
worker: WorkerMetaConfig {
|
||||||
name: Some("feature-merge-test".into()),
|
name: Some("feature-merge-test".into()),
|
||||||
prompt_pack: None,
|
prompt_pack: None,
|
||||||
},
|
},
|
||||||
|
|
@ -1843,7 +1849,7 @@ enabled = true
|
||||||
},
|
},
|
||||||
scope: ScopeConfig {
|
scope: ScopeConfig {
|
||||||
allow: vec![ScopeRule {
|
allow: vec![ScopeRule {
|
||||||
target: abs("/pod"),
|
target: abs("/worker"),
|
||||||
permission: Permission::Read,
|
permission: Permission::Read,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
}],
|
}],
|
||||||
|
|
@ -1860,7 +1866,7 @@ enabled = true
|
||||||
TicketFeatureAccessConfig::Lifecycle
|
TicketFeatureAccessConfig::Lifecycle
|
||||||
);
|
);
|
||||||
assert!(manifest.feature.web.enabled);
|
assert!(manifest.feature.web.enabled);
|
||||||
assert!(!manifest.feature.pods.enabled);
|
assert!(!manifest.feature.workers.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1871,20 +1877,20 @@ enabled = true
|
||||||
target = "/abs/project"
|
target = "/abs/project"
|
||||||
permission = "write"
|
permission = "write"
|
||||||
"#;
|
"#;
|
||||||
let cfg = PodManifestConfig::from_toml(toml).unwrap();
|
let cfg = WorkerManifestConfig::from_toml(toml).unwrap();
|
||||||
assert!(cfg.pod.name.is_none());
|
assert!(cfg.worker.name.is_none());
|
||||||
assert_eq!(cfg.scope.allow.len(), 1);
|
assert_eq!(cfg.scope.allow.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn builtin_defaults_populates_worker_limit_defaults() {
|
fn builtin_defaults_populates_worker_limit_defaults() {
|
||||||
let cfg = PodManifestConfig::builtin_defaults();
|
let cfg = WorkerManifestConfig::builtin_defaults();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cfg.worker.tool_output.default_max_bytes,
|
cfg.engine.tool_output.default_max_bytes,
|
||||||
Some(defaults::TOOL_OUTPUT_MAX_BYTES)
|
Some(defaults::TOOL_OUTPUT_MAX_BYTES)
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cfg.worker.file_upload.max_bytes,
|
cfg.engine.file_upload.max_bytes,
|
||||||
Some(defaults::FILE_UPLOAD_MAX_BYTES)
|
Some(defaults::FILE_UPLOAD_MAX_BYTES)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1892,10 +1898,10 @@ permission = "write"
|
||||||
#[test]
|
#[test]
|
||||||
fn builtin_defaults_merged_into_minimal_resolves_with_defaults() {
|
fn builtin_defaults_merged_into_minimal_resolves_with_defaults() {
|
||||||
// Starting from builtin_defaults and overlaying only the
|
// Starting from builtin_defaults and overlaying only the
|
||||||
// required fields must resolve to a PodManifest carrying the
|
// required fields must resolve to a WorkerManifest carrying the
|
||||||
// centralised default values.
|
// centralised default values.
|
||||||
let overlay = PodManifestConfig {
|
let overlay = WorkerManifestConfig {
|
||||||
pod: PodMetaConfig {
|
worker: WorkerMetaConfig {
|
||||||
name: Some("x".into()),
|
name: Some("x".into()),
|
||||||
prompt_pack: None,
|
prompt_pack: None,
|
||||||
},
|
},
|
||||||
|
|
@ -1906,7 +1912,7 @@ permission = "write"
|
||||||
},
|
},
|
||||||
scope: ScopeConfig {
|
scope: ScopeConfig {
|
||||||
allow: vec![ScopeRule {
|
allow: vec![ScopeRule {
|
||||||
target: abs("/pod"),
|
target: abs("/worker"),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
}],
|
}],
|
||||||
|
|
@ -1914,22 +1920,22 @@ permission = "write"
|
||||||
},
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let merged = PodManifestConfig::builtin_defaults().merge(overlay);
|
let merged = WorkerManifestConfig::builtin_defaults().merge(overlay);
|
||||||
let manifest: PodManifest = merged.try_into().unwrap();
|
let manifest: WorkerManifest = merged.try_into().unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
manifest.worker.tool_output.default_max_bytes,
|
manifest.engine.tool_output.default_max_bytes,
|
||||||
defaults::TOOL_OUTPUT_MAX_BYTES
|
defaults::TOOL_OUTPUT_MAX_BYTES
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
manifest.worker.file_upload.max_bytes,
|
manifest.engine.file_upload.max_bytes,
|
||||||
defaults::FILE_UPLOAD_MAX_BYTES
|
defaults::FILE_UPLOAD_MAX_BYTES
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn end_to_end_cascade() {
|
fn end_to_end_cascade() {
|
||||||
let builtin = PodManifestConfig::default();
|
let builtin = WorkerManifestConfig::default();
|
||||||
let user = PodManifestConfig::from_toml(
|
let user = WorkerManifestConfig::from_toml(
|
||||||
r#"
|
r#"
|
||||||
[model]
|
[model]
|
||||||
scheme = "anthropic"
|
scheme = "anthropic"
|
||||||
|
|
@ -1937,7 +1943,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let project = PodManifestConfig::from_toml(
|
let project = WorkerManifestConfig::from_toml(
|
||||||
r#"
|
r#"
|
||||||
[[scope.allow]]
|
[[scope.allow]]
|
||||||
target = "/abs/project"
|
target = "/abs/project"
|
||||||
|
|
@ -1945,17 +1951,17 @@ permission = "write"
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let overlay = PodManifestConfig::from_toml(
|
let overlay = WorkerManifestConfig::from_toml(
|
||||||
r#"
|
r#"
|
||||||
[pod]
|
[worker]
|
||||||
name = "dbg"
|
name = "dbg"
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let merged = builtin.merge(user).merge(project).merge(overlay);
|
let merged = builtin.merge(user).merge(project).merge(overlay);
|
||||||
let manifest: PodManifest = merged.try_into().unwrap();
|
let manifest: WorkerManifest = merged.try_into().unwrap();
|
||||||
assert_eq!(manifest.pod.name, "dbg");
|
assert_eq!(manifest.worker.name, "dbg");
|
||||||
assert_eq!(manifest.model.scheme, Some(SchemeKind::Anthropic));
|
assert_eq!(manifest.model.scheme, Some(SchemeKind::Anthropic));
|
||||||
assert_eq!(manifest.scope.allow.len(), 1);
|
assert_eq!(manifest.scope.allow.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
@ -1981,7 +1987,7 @@ name = "dbg"
|
||||||
cfg.skills = Some(SkillsConfig {
|
cfg.skills = Some(SkillsConfig {
|
||||||
directories: vec![PathBuf::from("relative/skills")],
|
directories: vec![PathBuf::from("relative/skills")],
|
||||||
});
|
});
|
||||||
let err = PodManifest::try_from(cfg).unwrap_err();
|
let err = WorkerManifest::try_from(cfg).unwrap_err();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
err,
|
err,
|
||||||
ResolveError::RelativePath {
|
ResolveError::RelativePath {
|
||||||
|
|
@ -1993,13 +1999,13 @@ name = "dbg"
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn skills_merge_extends_directories() {
|
fn skills_merge_extends_directories() {
|
||||||
let lower = PodManifestConfig {
|
let lower = WorkerManifestConfig {
|
||||||
skills: Some(SkillsConfig {
|
skills: Some(SkillsConfig {
|
||||||
directories: vec![PathBuf::from("/a")],
|
directories: vec![PathBuf::from("/a")],
|
||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let upper = PodManifestConfig {
|
let upper = WorkerManifestConfig {
|
||||||
skills: Some(SkillsConfig {
|
skills: Some(SkillsConfig {
|
||||||
directories: vec![PathBuf::from("/b")],
|
directories: vec![PathBuf::from("/b")],
|
||||||
}),
|
}),
|
||||||
|
|
@ -2013,13 +2019,13 @@ name = "dbg"
|
||||||
#[test]
|
#[test]
|
||||||
fn from_toml_parses_skills_section() {
|
fn from_toml_parses_skills_section() {
|
||||||
let toml = r#"
|
let toml = r#"
|
||||||
[pod]
|
[worker]
|
||||||
name = "x"
|
name = "x"
|
||||||
|
|
||||||
[skills]
|
[skills]
|
||||||
directories = [".claude/skills", ".cursor/skills"]
|
directories = [".claude/skills", ".cursor/skills"]
|
||||||
"#;
|
"#;
|
||||||
let cfg = PodManifestConfig::from_toml(toml).unwrap();
|
let cfg = WorkerManifestConfig::from_toml(toml).unwrap();
|
||||||
let dirs = cfg.skills.unwrap().directories;
|
let dirs = cfg.skills.unwrap().directories;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
dirs,
|
dirs,
|
||||||
|
|
@ -2032,14 +2038,14 @@ directories = [".claude/skills", ".cursor/skills"]
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn merge_preserves_ref() {
|
fn merge_preserves_ref() {
|
||||||
let lower = PodManifestConfig {
|
let lower = WorkerManifestConfig {
|
||||||
model: ModelManifest {
|
model: ModelManifest {
|
||||||
ref_: Some("anthropic/claude-sonnet-4-6".into()),
|
ref_: Some("anthropic/claude-sonnet-4-6".into()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let upper = PodManifestConfig {
|
let upper = WorkerManifestConfig {
|
||||||
model: ModelManifest {
|
model: ModelManifest {
|
||||||
// only override auth
|
// only override auth
|
||||||
auth: Some(AuthRef::None),
|
auth: Some(AuthRef::None),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
//! Single source of truth for manifest default values.
|
//! Single source of truth for manifest default values.
|
||||||
//!
|
//!
|
||||||
//! Every default that would otherwise be duplicated between serde
|
//! Every default that would otherwise be duplicated between serde
|
||||||
//! `#[serde(default = "...")]` attributes (on [`crate::PodManifest`])
|
//! `#[serde(default = "...")]` attributes (on [`crate::WorkerManifest`])
|
||||||
//! and the cascade resolution in [`crate::config`] lives here as a
|
//! and the cascade resolution in [`crate::config`] lives here as a
|
||||||
//! `pub const`. Both paths read from this module, so changing a
|
//! `pub const`. Both paths read from this module, so changing a
|
||||||
//! default requires editing exactly one line.
|
//! default requires editing exactly one line.
|
||||||
|
|
@ -48,7 +48,7 @@ pub const COMPACT_OVERVIEW_DEADLINE_TOKENS: u64 = 40_000;
|
||||||
pub const DEFAULT_INSTRUCTION: &str = "$yoi/default";
|
pub const DEFAULT_INSTRUCTION: &str = "$yoi/default";
|
||||||
|
|
||||||
/// Default language policy used by the main worker for normal prose
|
/// Default language policy used by the main worker for normal prose
|
||||||
/// responses. See [`crate::WorkerManifest::language`].
|
/// responses. See [`crate::EngineManifest::language`].
|
||||||
pub const WORKER_LANGUAGE: &str =
|
pub const WORKER_LANGUAGE: &str =
|
||||||
"match the user's language unless they explicitly request another language";
|
"match the user's language unless they explicitly request another language";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@ mod profile;
|
||||||
mod scope;
|
mod scope;
|
||||||
|
|
||||||
pub use config::{
|
pub use config::{
|
||||||
CompactionConfigPartial, FileUploadLimitsPartial, PermissionConfigPartial, PodManifestConfig,
|
CompactionConfigPartial, EngineManifestConfig, FileUploadLimitsPartial,
|
||||||
PodMetaConfig, ResolveError, SessionConfigPartial, ToolOutputLimitsPartial,
|
PermissionConfigPartial, ResolveError, SessionConfigPartial, ToolOutputLimitsPartial,
|
||||||
WorkerManifestConfig,
|
WorkerManifestConfig, WorkerMetaConfig,
|
||||||
};
|
};
|
||||||
pub use model::{
|
pub use model::{
|
||||||
AuthRef, ModelCapability, ModelManifest, ReasoningControl, ReasoningEffort, SchemeKind,
|
AuthRef, ModelCapability, ModelManifest, ReasoningControl, ReasoningEffort, SchemeKind,
|
||||||
|
|
@ -32,20 +32,20 @@ use std::path::PathBuf;
|
||||||
use serde::de::Error as _;
|
use serde::de::Error as _;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Declarative configuration for a Pod.
|
/// Declarative configuration for a Worker.
|
||||||
///
|
///
|
||||||
/// Parsed from a TOML manifest file. Describes the model, system prompt,
|
/// Parsed from a TOML manifest file. Describes the model, system prompt,
|
||||||
/// and directory scope (required). The Pod's working directory is **not**
|
/// and directory scope (required). The Worker's working directory is **not**
|
||||||
/// part of the manifest — it is the process's `std::env::current_dir()`
|
/// part of the manifest — it is the process's `std::env::current_dir()`
|
||||||
/// at construction time.
|
/// at construction time.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct PodManifest {
|
pub struct WorkerManifest {
|
||||||
pub pod: PodMeta,
|
pub worker: WorkerMeta,
|
||||||
pub model: ModelManifest,
|
pub model: ModelManifest,
|
||||||
pub worker: WorkerManifest,
|
pub engine: EngineManifest,
|
||||||
/// Direct filesystem authority for this Pod's own tools.
|
/// Direct filesystem authority for this Worker's own tools.
|
||||||
pub scope: ScopeConfig,
|
pub scope: ScopeConfig,
|
||||||
/// Filesystem authority this Pod may pass to spawned children. Missing
|
/// Filesystem authority this Worker may pass to spawned children. Missing
|
||||||
/// metadata/config defaults to no delegation authority.
|
/// metadata/config defaults to no delegation authority.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub delegation_scope: ScopeConfig,
|
pub delegation_scope: ScopeConfig,
|
||||||
|
|
@ -91,14 +91,14 @@ pub struct PodManifest {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub skills: Option<SkillsConfig>,
|
pub skills: Option<SkillsConfig>,
|
||||||
/// Optional profile provenance for manifests produced by profile resolution.
|
/// Optional profile provenance for manifests produced by profile resolution.
|
||||||
/// Stored only after profile resolution so Pod restore can prefer the
|
/// Stored only after profile resolution so Worker restore can prefer the
|
||||||
/// validated snapshot over current profile files or one-file Manifest input.
|
/// validated snapshot over current profile files or one-file Manifest input.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub profile: Option<profile::ProfileManifestSnapshot>,
|
pub profile: Option<profile::ProfileManifestSnapshot>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Explicit built-in feature/tool-surface enablement. These flags are
|
/// Explicit built-in feature/tool-surface enablement. These flags are
|
||||||
/// profile/config data only: they do not carry runtime Pod names, sockets,
|
/// profile/config data only: they do not carry runtime Worker names, sockets,
|
||||||
/// sessions, secrets, or resolved host state. Tool registration still applies
|
/// sessions, secrets, or resolved host state. Tool registration still applies
|
||||||
/// the normal scope, host-authority, backend, memory, and network checks.
|
/// the normal scope, host-authority, backend, memory, and network checks.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
|
@ -110,7 +110,7 @@ pub struct FeatureConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub web: FeatureFlagConfig,
|
pub web: FeatureFlagConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub pods: FeatureFlagConfig,
|
pub workers: FeatureFlagConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ticket: TicketFeatureConfig,
|
pub ticket: TicketFeatureConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|
@ -125,7 +125,7 @@ impl Default for FeatureConfig {
|
||||||
task: FeatureFlagConfig::disabled(),
|
task: FeatureFlagConfig::disabled(),
|
||||||
memory: FeatureFlagConfig::disabled(),
|
memory: FeatureFlagConfig::disabled(),
|
||||||
web: FeatureFlagConfig::disabled(),
|
web: FeatureFlagConfig::disabled(),
|
||||||
pods: FeatureFlagConfig::disabled(),
|
workers: FeatureFlagConfig::disabled(),
|
||||||
ticket: TicketFeatureConfig::default(),
|
ticket: TicketFeatureConfig::default(),
|
||||||
ticket_orchestration: FeatureFlagConfig::disabled(),
|
ticket_orchestration: FeatureFlagConfig::disabled(),
|
||||||
plugins: FeatureFlagConfig::disabled(),
|
plugins: FeatureFlagConfig::disabled(),
|
||||||
|
|
@ -197,7 +197,7 @@ pub struct SkillsConfig {
|
||||||
/// Skills *roots*. Children of each root must be individual
|
/// Skills *roots*. Children of each root must be individual
|
||||||
/// `<name>/SKILL.md` bundles; the directory itself is not a skill.
|
/// `<name>/SKILL.md` bundles; the directory itself is not a skill.
|
||||||
/// Resolved against the manifest base directory before
|
/// Resolved against the manifest base directory before
|
||||||
/// [`PodManifest`] is materialised.
|
/// [`WorkerManifest`] is materialised.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub directories: Vec<PathBuf>,
|
pub directories: Vec<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
@ -206,7 +206,7 @@ pub struct SkillsConfig {
|
||||||
///
|
///
|
||||||
/// The manifest layer records local stdio MCP server declarations but never
|
/// The manifest layer records local stdio MCP server declarations but never
|
||||||
/// starts them. Future lifecycle code must opt in to spawning and must keep MCP
|
/// starts them. Future lifecycle code must opt in to spawning and must keep MCP
|
||||||
/// process authority separate from Plugin permissions and `pod::feature` flags.
|
/// process authority separate from Plugin permissions and `worker::feature` flags.
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct McpConfig {
|
pub struct McpConfig {
|
||||||
|
|
@ -363,7 +363,7 @@ pub struct WebFetchConfig {
|
||||||
|
|
||||||
/// Memory subsystem configuration. Presence in the manifest enables
|
/// Memory subsystem configuration. Presence in the manifest enables
|
||||||
/// memory; `workspace_root` pins the memory workspace explicitly. When it
|
/// memory; `workspace_root` pins the memory workspace explicitly. When it
|
||||||
/// is absent, memory resolution searches upward from the Pod's pwd for a
|
/// is absent, memory resolution searches upward from the Worker's pwd for a
|
||||||
/// `.yoi/memory` marker rather than treating `.yoi` project records alone
|
/// `.yoi/memory` marker rather than treating `.yoi` project records alone
|
||||||
/// as a memory root.
|
/// as a memory root.
|
||||||
///
|
///
|
||||||
|
|
@ -396,7 +396,7 @@ pub struct MemoryConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub language: Option<String>,
|
pub language: Option<String>,
|
||||||
/// Optional model for the extract worker. When `None`,
|
/// Optional model for the extract worker. When `None`,
|
||||||
/// the main pod model is cloned via `clone_boxed()`. Lightweight
|
/// the main engine model is cloned via `clone_boxed()`. Lightweight
|
||||||
/// reasoning-capable models (Haiku / 4o-mini / Flash class) are
|
/// reasoning-capable models (Haiku / 4o-mini / Flash class) are
|
||||||
/// recommended.
|
/// recommended.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|
@ -414,7 +414,7 @@ pub struct MemoryConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub extract_worker_max_turns: Option<u32>,
|
pub extract_worker_max_turns: Option<u32>,
|
||||||
/// Optional model for the consolidation worker. When
|
/// Optional model for the consolidation worker. When
|
||||||
/// `None`, the main pod model is cloned via `clone_boxed()`.
|
/// `None`, the main engine model is cloned via `clone_boxed()`.
|
||||||
/// Reasoning-class models are recommended.
|
/// Reasoning-class models are recommended.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub consolidation_model: Option<ModelManifest>,
|
pub consolidation_model: Option<ModelManifest>,
|
||||||
|
|
@ -432,12 +432,12 @@ pub struct MemoryConfig {
|
||||||
pub consolidation_threshold_bytes: Option<u64>,
|
pub consolidation_threshold_bytes: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pod metadata.
|
/// Worker metadata.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct PodMeta {
|
pub struct WorkerMeta {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
/// Optional path to a TOML override file read as the top layer of
|
/// Optional path to a TOML override file read as the top layer of
|
||||||
/// `pod::PromptCatalog`. Subject to the same relative-path
|
/// `worker::PromptCatalog`. Subject to the same relative-path
|
||||||
/// resolution as other manifest paths (joined against the
|
/// resolution as other manifest paths (joined against the
|
||||||
/// manifest's base directory). `None` leaves the 4th overlay layer
|
/// manifest's base directory). `None` leaves the 4th overlay layer
|
||||||
/// empty; auto-discovered user and workspace packs still apply.
|
/// empty; auto-discovered user and workspace packs still apply.
|
||||||
|
|
@ -453,7 +453,7 @@ pub struct PodMeta {
|
||||||
|
|
||||||
/// Worker-level configuration embedded in the manifest.
|
/// Worker-level configuration embedded in the manifest.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct WorkerManifest {
|
pub struct EngineManifest {
|
||||||
/// Reference to the instruction prompt asset used as the body of
|
/// Reference to the instruction prompt asset used as the body of
|
||||||
/// the worker's system prompt. Uses the `PromptLoader` prefix
|
/// the worker's system prompt. Uses the `PromptLoader` prefix
|
||||||
/// addressing scheme (`$yoi/...`, `$user/...`,
|
/// addressing scheme (`$yoi/...`, `$user/...`,
|
||||||
|
|
@ -572,7 +572,7 @@ impl ToolOutputLimits {
|
||||||
|
|
||||||
/// Declarative scope configuration.
|
/// Declarative scope configuration.
|
||||||
///
|
///
|
||||||
/// A Pod may only touch paths whose effective permission (computed from
|
/// A Worker may only touch paths whose effective permission (computed from
|
||||||
/// allow/deny rules below) is at least `Read` / `Write`. See
|
/// allow/deny rules below) is at least `Read` / `Write`. See
|
||||||
/// [`Scope`] for the resolved runtime form.
|
/// [`Scope`] for the resolved runtime form.
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
|
@ -653,7 +653,7 @@ pub struct CompactionConfig {
|
||||||
|
|
||||||
/// Safety-net (between-requests) compaction threshold.
|
/// Safety-net (between-requests) compaction threshold.
|
||||||
///
|
///
|
||||||
/// Checked by `PodInterceptor::pre_llm_request` inside a turn. When
|
/// Checked by `WorkerInterceptor::pre_llm_request` inside a turn. When
|
||||||
/// current occupancy exceeds this value, the run yields so that the
|
/// current occupancy exceeds this value, the run yields so that the
|
||||||
/// Controller can compact before the next LLM request. `None`
|
/// Controller can compact before the next LLM request. `None`
|
||||||
/// disables the between-requests check.
|
/// disables the between-requests check.
|
||||||
|
|
@ -802,7 +802,7 @@ impl Default for CompactionConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PodManifest {
|
impl WorkerManifest {
|
||||||
/// Parse a manifest from a TOML string.
|
/// Parse a manifest from a TOML string.
|
||||||
pub fn from_toml(s: &str) -> Result<Self, toml::de::Error> {
|
pub fn from_toml(s: &str) -> Result<Self, toml::de::Error> {
|
||||||
config::reject_removed_manifest_fields(s)?;
|
config::reject_removed_manifest_fields(s)?;
|
||||||
|
|
@ -818,14 +818,14 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
const MINIMAL_REQUIRED: &str = r#"
|
const MINIMAL_REQUIRED: &str = r#"
|
||||||
[pod]
|
[worker]
|
||||||
name = "test-agent"
|
name = "test-agent"
|
||||||
|
|
||||||
[model]
|
[model]
|
||||||
scheme = "anthropic"
|
scheme = "anthropic"
|
||||||
model_id = "claude-sonnet-4-20250514"
|
model_id = "claude-sonnet-4-20250514"
|
||||||
|
|
||||||
[worker]
|
[engine]
|
||||||
|
|
||||||
[[scope.allow]]
|
[[scope.allow]]
|
||||||
target = "/abs/scope"
|
target = "/abs/scope"
|
||||||
|
|
@ -834,8 +834,8 @@ permission = "write"
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_minimal_manifest() {
|
fn parse_minimal_manifest() {
|
||||||
let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||||
assert_eq!(manifest.pod.name, "test-agent");
|
assert_eq!(manifest.worker.name, "test-agent");
|
||||||
assert_eq!(manifest.model.scheme, Some(SchemeKind::Anthropic));
|
assert_eq!(manifest.model.scheme, Some(SchemeKind::Anthropic));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
manifest.model.model_id.as_deref(),
|
manifest.model.model_id.as_deref(),
|
||||||
|
|
@ -846,19 +846,19 @@ permission = "write"
|
||||||
assert!(manifest.scope.deny.is_empty());
|
assert!(manifest.scope.deny.is_empty());
|
||||||
assert!(manifest.delegation_scope.allow.is_empty());
|
assert!(manifest.delegation_scope.allow.is_empty());
|
||||||
assert!(manifest.delegation_scope.deny.is_empty());
|
assert!(manifest.delegation_scope.deny.is_empty());
|
||||||
assert_eq!(manifest.worker.instruction, defaults::DEFAULT_INSTRUCTION);
|
assert_eq!(manifest.engine.instruction, defaults::DEFAULT_INSTRUCTION);
|
||||||
assert!(manifest.worker.top_p.is_none());
|
assert!(manifest.engine.top_p.is_none());
|
||||||
assert!(manifest.worker.top_k.is_none());
|
assert!(manifest.engine.top_k.is_none());
|
||||||
assert!(manifest.worker.stop_sequences.is_empty());
|
assert!(manifest.engine.stop_sequences.is_empty());
|
||||||
assert!(manifest.web.is_none());
|
assert!(manifest.web.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deserialize_old_manifest_snapshot_defaults_to_no_delegation() {
|
fn deserialize_old_manifest_snapshot_defaults_to_no_delegation() {
|
||||||
let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||||
let mut snapshot = serde_json::to_value(&manifest).unwrap();
|
let mut snapshot = serde_json::to_value(&manifest).unwrap();
|
||||||
snapshot.as_object_mut().unwrap().remove("delegation_scope");
|
snapshot.as_object_mut().unwrap().remove("delegation_scope");
|
||||||
let restored: PodManifest = serde_json::from_value(snapshot).unwrap();
|
let restored: WorkerManifest = serde_json::from_value(snapshot).unwrap();
|
||||||
assert_eq!(restored.scope.allow.len(), 1);
|
assert_eq!(restored.scope.allow.len(), 1);
|
||||||
assert!(restored.delegation_scope.allow.is_empty());
|
assert!(restored.delegation_scope.allow.is_empty());
|
||||||
assert!(restored.delegation_scope.deny.is_empty());
|
assert!(restored.delegation_scope.deny.is_empty());
|
||||||
|
|
@ -870,7 +870,7 @@ permission = "write"
|
||||||
"{}\n[web]\nenabled = true\n\n[web.search]\nprovider = \"brave\"\napi_key_secret = \"web/brave/default\"\ntimeout_secs = 12\n\n[web.fetch]\ntimeout_secs = 7\nredirect_limit = 3\nmax_response_bytes = 12345\nmax_output_bytes = 2048\n",
|
"{}\n[web]\nenabled = true\n\n[web.search]\nprovider = \"brave\"\napi_key_secret = \"web/brave/default\"\ntimeout_secs = 12\n\n[web.fetch]\ntimeout_secs = 7\nredirect_limit = 3\nmax_response_bytes = 12345\nmax_output_bytes = 2048\n",
|
||||||
MINIMAL_REQUIRED
|
MINIMAL_REQUIRED
|
||||||
);
|
);
|
||||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||||
let web = manifest.web.unwrap();
|
let web = manifest.web.unwrap();
|
||||||
assert_eq!(web.enabled, Some(true));
|
assert_eq!(web.enabled, Some(true));
|
||||||
let search = web.search.unwrap();
|
let search = web.search.unwrap();
|
||||||
|
|
@ -886,7 +886,7 @@ permission = "write"
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_full_manifest() {
|
fn parse_full_manifest() {
|
||||||
let toml = r#"
|
let toml = r#"
|
||||||
[pod]
|
[worker]
|
||||||
name = "code-reviewer"
|
name = "code-reviewer"
|
||||||
|
|
||||||
[model]
|
[model]
|
||||||
|
|
@ -894,7 +894,7 @@ scheme = "anthropic"
|
||||||
model_id = "claude-sonnet-4-20250514"
|
model_id = "claude-sonnet-4-20250514"
|
||||||
auth = { kind = "api_key", file = "/abs/keys/anthropic" }
|
auth = { kind = "api_key", file = "/abs/keys/anthropic" }
|
||||||
|
|
||||||
[worker]
|
[engine]
|
||||||
instruction = "$user/reviewer"
|
instruction = "$user/reviewer"
|
||||||
max_tokens = 4096
|
max_tokens = 4096
|
||||||
temperature = 0.3
|
temperature = 0.3
|
||||||
|
|
@ -924,21 +924,21 @@ permission = "write"
|
||||||
target = "/abs/project/tasks/private"
|
target = "/abs/project/tasks/private"
|
||||||
permission = "write"
|
permission = "write"
|
||||||
"#;
|
"#;
|
||||||
let manifest = PodManifest::from_toml(toml).unwrap();
|
let manifest = WorkerManifest::from_toml(toml).unwrap();
|
||||||
assert_eq!(manifest.pod.name, "code-reviewer");
|
assert_eq!(manifest.worker.name, "code-reviewer");
|
||||||
let file = match manifest.model.auth.as_ref() {
|
let file = match manifest.model.auth.as_ref() {
|
||||||
Some(AuthRef::ApiKey { file, .. }) => file.as_deref(),
|
Some(AuthRef::ApiKey { file, .. }) => file.as_deref(),
|
||||||
_ => panic!("expected ApiKey"),
|
_ => panic!("expected ApiKey"),
|
||||||
};
|
};
|
||||||
assert_eq!(file, Some(std::path::Path::new("/abs/keys/anthropic")));
|
assert_eq!(file, Some(std::path::Path::new("/abs/keys/anthropic")));
|
||||||
assert_eq!(manifest.worker.instruction, "$user/reviewer");
|
assert_eq!(manifest.engine.instruction, "$user/reviewer");
|
||||||
assert_eq!(manifest.worker.max_tokens, Some(4096));
|
assert_eq!(manifest.engine.max_tokens, Some(4096));
|
||||||
assert_eq!(manifest.worker.temperature, Some(0.3));
|
assert_eq!(manifest.engine.temperature, Some(0.3));
|
||||||
assert_eq!(manifest.worker.top_p, Some(0.9));
|
assert_eq!(manifest.engine.top_p, Some(0.9));
|
||||||
assert_eq!(manifest.worker.top_k, Some(40));
|
assert_eq!(manifest.engine.top_k, Some(40));
|
||||||
assert_eq!(manifest.worker.stop_sequences, vec!["\n\n", "</stop>"]);
|
assert_eq!(manifest.engine.stop_sequences, vec!["\n\n", "</stop>"]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
manifest.worker.reasoning,
|
manifest.engine.reasoning,
|
||||||
Some(ReasoningControl::Effort(ReasoningEffort::Medium))
|
Some(ReasoningControl::Effort(ReasoningEffort::Medium))
|
||||||
);
|
);
|
||||||
let allow = &manifest.scope.allow;
|
let allow = &manifest.scope.allow;
|
||||||
|
|
@ -960,16 +960,16 @@ permission = "write"
|
||||||
#[test]
|
#[test]
|
||||||
fn reject_missing_scope() {
|
fn reject_missing_scope() {
|
||||||
let toml = r#"
|
let toml = r#"
|
||||||
[pod]
|
[worker]
|
||||||
name = "missing-scope"
|
name = "missing-scope"
|
||||||
|
|
||||||
[model]
|
[model]
|
||||||
scheme = "anthropic"
|
scheme = "anthropic"
|
||||||
model_id = "claude-sonnet-4-20250514"
|
model_id = "claude-sonnet-4-20250514"
|
||||||
|
|
||||||
[worker]
|
[engine]
|
||||||
"#;
|
"#;
|
||||||
assert!(PodManifest::from_toml(toml).is_err());
|
assert!(WorkerManifest::from_toml(toml).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -984,7 +984,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||||
[plugins.enabled.config]\n\
|
[plugins.enabled.config]\n\
|
||||||
greeting = \"hello\"\n"
|
greeting = \"hello\"\n"
|
||||||
);
|
);
|
||||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||||
assert_eq!(manifest.plugins.enabled.len(), 1);
|
assert_eq!(manifest.plugins.enabled.len(), 1);
|
||||||
let enabled = &manifest.plugins.enabled[0];
|
let enabled = &manifest.plugins.enabled[0];
|
||||||
assert_eq!(enabled.id, "project:example");
|
assert_eq!(enabled.id, "project:example");
|
||||||
|
|
@ -1005,37 +1005,37 @@ model_id = "claude-sonnet-4-20250514"
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_max_turns() {
|
fn parse_max_turns() {
|
||||||
let toml = MINIMAL_REQUIRED.replace("[worker]\n", "[worker]\nmax_turns = 50\n");
|
let toml = MINIMAL_REQUIRED.replace("[engine]\n", "[engine]\nmax_turns = 50\n");
|
||||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||||
assert_eq!(manifest.worker.max_turns.unwrap().get(), 50);
|
assert_eq!(manifest.engine.max_turns.unwrap().get(), 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_reasoning_budget() {
|
fn parse_reasoning_budget() {
|
||||||
let toml = MINIMAL_REQUIRED.replace("[worker]\n", "[worker]\nreasoning = -1\n");
|
let toml = MINIMAL_REQUIRED.replace("[engine]\n", "[engine]\nreasoning = -1\n");
|
||||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
manifest.worker.reasoning,
|
manifest.engine.reasoning,
|
||||||
Some(ReasoningControl::BudgetTokens(-1))
|
Some(ReasoningControl::BudgetTokens(-1))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn omitted_max_turns_is_none() {
|
fn omitted_max_turns_is_none() {
|
||||||
let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||||
assert!(manifest.worker.max_turns.is_none());
|
assert!(manifest.engine.max_turns.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reject_max_turns_zero() {
|
fn reject_max_turns_zero() {
|
||||||
let toml = MINIMAL_REQUIRED.replace("[worker]\n", "[worker]\nmax_turns = 0\n");
|
let toml = MINIMAL_REQUIRED.replace("[engine]\n", "[engine]\nmax_turns = 0\n");
|
||||||
assert!(PodManifest::from_toml(&toml).is_err());
|
assert!(WorkerManifest::from_toml(&toml).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_compaction_config() {
|
fn parse_compaction_config() {
|
||||||
let toml = format!("{MINIMAL_REQUIRED}\n[compaction]\nthreshold = 80000\n");
|
let toml = format!("{MINIMAL_REQUIRED}\n[compaction]\nthreshold = 80000\n");
|
||||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||||
let c = manifest.compaction.unwrap();
|
let c = manifest.compaction.unwrap();
|
||||||
assert_eq!(c.prune_protected_tokens, 8000);
|
assert_eq!(c.prune_protected_tokens, 8000);
|
||||||
assert_eq!(c.prune_min_savings, 4096);
|
assert_eq!(c.prune_min_savings, 4096);
|
||||||
|
|
@ -1048,7 +1048,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||||
#[test]
|
#[test]
|
||||||
fn reject_removed_prune_protected_turns_field() {
|
fn reject_removed_prune_protected_turns_field() {
|
||||||
let toml = format!("{MINIMAL_REQUIRED}\n[compaction]\nprune_protected_turns = 3\n");
|
let toml = format!("{MINIMAL_REQUIRED}\n[compaction]\nprune_protected_turns = 3\n");
|
||||||
let err = PodManifest::from_toml(&toml).unwrap_err();
|
let err = WorkerManifest::from_toml(&toml).unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
err.to_string().contains("compaction.prune_protected_turns"),
|
err.to_string().contains("compaction.prune_protected_turns"),
|
||||||
"unexpected error: {err}"
|
"unexpected error: {err}"
|
||||||
|
|
@ -1062,7 +1062,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||||
[compaction]\n\
|
[compaction]\n\
|
||||||
worker_max_turns = 7\n"
|
worker_max_turns = 7\n"
|
||||||
);
|
);
|
||||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||||
let c = manifest.compaction.unwrap();
|
let c = manifest.compaction.unwrap();
|
||||||
assert_eq!(c.worker_max_turns, Some(7));
|
assert_eq!(c.worker_max_turns, Some(7));
|
||||||
}
|
}
|
||||||
|
|
@ -1075,7 +1075,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||||
threshold = 80000\n\
|
threshold = 80000\n\
|
||||||
request_threshold = 90000\n"
|
request_threshold = 90000\n"
|
||||||
);
|
);
|
||||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||||
let c = manifest.compaction.unwrap();
|
let c = manifest.compaction.unwrap();
|
||||||
assert_eq!(c.threshold, Some(80000));
|
assert_eq!(c.threshold, Some(80000));
|
||||||
assert_eq!(c.request_threshold, Some(90000));
|
assert_eq!(c.request_threshold, Some(90000));
|
||||||
|
|
@ -1088,7 +1088,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||||
[compaction]\n\
|
[compaction]\n\
|
||||||
request_threshold = 90000\n"
|
request_threshold = 90000\n"
|
||||||
);
|
);
|
||||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||||
let c = manifest.compaction.unwrap();
|
let c = manifest.compaction.unwrap();
|
||||||
assert_eq!(c.threshold, None);
|
assert_eq!(c.threshold, None);
|
||||||
assert_eq!(c.request_threshold, Some(90000));
|
assert_eq!(c.request_threshold, Some(90000));
|
||||||
|
|
@ -1104,7 +1104,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||||
scheme = \"gemini\"\n\
|
scheme = \"gemini\"\n\
|
||||||
model_id = \"gemini-2.0-flash\"\n"
|
model_id = \"gemini-2.0-flash\"\n"
|
||||||
);
|
);
|
||||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||||
let c = manifest.compaction.unwrap();
|
let c = manifest.compaction.unwrap();
|
||||||
let p = c.model.unwrap();
|
let p = c.model.unwrap();
|
||||||
assert_eq!(p.scheme, Some(SchemeKind::Gemini));
|
assert_eq!(p.scheme, Some(SchemeKind::Gemini));
|
||||||
|
|
@ -1113,20 +1113,20 @@ model_id = "claude-sonnet-4-20250514"
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn omitted_compaction_is_none() {
|
fn omitted_compaction_is_none() {
|
||||||
let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||||
assert!(manifest.compaction.is_none());
|
assert!(manifest.compaction.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn omitted_memory_is_none() {
|
fn omitted_memory_is_none() {
|
||||||
let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||||
assert!(manifest.memory.is_none());
|
assert!(manifest.memory.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_memory_section_enables_with_default_root() {
|
fn empty_memory_section_enables_with_default_root() {
|
||||||
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\n");
|
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\n");
|
||||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||||
let mem = manifest.memory.expect("memory section parsed");
|
let mem = manifest.memory.expect("memory section parsed");
|
||||||
assert!(mem.workspace_root.is_none());
|
assert!(mem.workspace_root.is_none());
|
||||||
assert_eq!(mem.inject_summary, None);
|
assert_eq!(mem.inject_summary, None);
|
||||||
|
|
@ -1135,7 +1135,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||||
#[test]
|
#[test]
|
||||||
fn memory_section_with_inject_summary_false() {
|
fn memory_section_with_inject_summary_false() {
|
||||||
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\ninject_summary = false\n");
|
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\ninject_summary = false\n");
|
||||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||||
let mem = manifest.memory.unwrap();
|
let mem = manifest.memory.unwrap();
|
||||||
assert_eq!(mem.inject_summary, Some(false));
|
assert_eq!(mem.inject_summary, Some(false));
|
||||||
}
|
}
|
||||||
|
|
@ -1143,7 +1143,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||||
#[test]
|
#[test]
|
||||||
fn memory_section_with_explicit_root() {
|
fn memory_section_with_explicit_root() {
|
||||||
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nworkspace_root = \"/some/where\"\n");
|
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nworkspace_root = \"/some/where\"\n");
|
||||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||||
let mem = manifest.memory.unwrap();
|
let mem = manifest.memory.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
mem.workspace_root.unwrap(),
|
mem.workspace_root.unwrap(),
|
||||||
|
|
@ -1154,7 +1154,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||||
#[test]
|
#[test]
|
||||||
fn memory_section_with_language() {
|
fn memory_section_with_language() {
|
||||||
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nlanguage = \"Japanese\"\n");
|
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nlanguage = \"Japanese\"\n");
|
||||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||||
let mem = manifest.memory.unwrap();
|
let mem = manifest.memory.unwrap();
|
||||||
assert_eq!(mem.language.as_deref(), Some("Japanese"));
|
assert_eq!(mem.language.as_deref(), Some("Japanese"));
|
||||||
}
|
}
|
||||||
|
|
@ -1163,62 +1163,62 @@ model_id = "claude-sonnet-4-20250514"
|
||||||
fn reject_unknown_scheme() {
|
fn reject_unknown_scheme() {
|
||||||
let toml =
|
let toml =
|
||||||
MINIMAL_REQUIRED.replace("scheme = \"anthropic\"", "scheme = \"unknown_scheme\"");
|
MINIMAL_REQUIRED.replace("scheme = \"anthropic\"", "scheme = \"unknown_scheme\"");
|
||||||
assert!(PodManifest::from_toml(&toml).is_err());
|
assert!(WorkerManifest::from_toml(&toml).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn omitted_limits_fall_back_to_defaults() {
|
fn omitted_limits_fall_back_to_defaults() {
|
||||||
let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||||
let limits = &manifest.worker.tool_output;
|
let limits = &manifest.engine.tool_output;
|
||||||
assert_eq!(limits.default_max_bytes, defaults::TOOL_OUTPUT_MAX_BYTES);
|
assert_eq!(limits.default_max_bytes, defaults::TOOL_OUTPUT_MAX_BYTES);
|
||||||
assert!(limits.per_tool.is_empty());
|
assert!(limits.per_tool.is_empty());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
manifest.worker.file_upload.max_bytes,
|
manifest.engine.file_upload.max_bytes,
|
||||||
defaults::FILE_UPLOAD_MAX_BYTES
|
defaults::FILE_UPLOAD_MAX_BYTES
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn worker_language_defaults_and_parses() {
|
fn worker_language_defaults_and_parses() {
|
||||||
let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||||
assert_eq!(manifest.worker.language, defaults::WORKER_LANGUAGE);
|
assert_eq!(manifest.engine.language, defaults::WORKER_LANGUAGE);
|
||||||
|
|
||||||
let toml = MINIMAL_REQUIRED.replace("[worker]\n", "[worker]\nlanguage = \"Japanese\"\n");
|
let toml = MINIMAL_REQUIRED.replace("[engine]\n", "[engine]\nlanguage = \"Japanese\"\n");
|
||||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||||
assert_eq!(manifest.worker.language, "Japanese");
|
assert_eq!(manifest.engine.language, "Japanese");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_worker_output_limits() {
|
fn parse_worker_output_limits() {
|
||||||
let toml = MINIMAL_REQUIRED.replace(
|
let toml = MINIMAL_REQUIRED.replace(
|
||||||
"[worker]\n",
|
"[engine]\n",
|
||||||
"[worker]\n\
|
"[engine]\n\
|
||||||
[worker.tool_output]\n\
|
[worker.tool_output]\n\
|
||||||
default_max_bytes = 8192\n\n\
|
default_max_bytes = 8192\n\n\
|
||||||
[worker.tool_output.per_tool]\n\
|
[worker.tool_output.per_tool]\n\
|
||||||
Read = 32768\n\
|
Read = 32768\n\
|
||||||
Grep = 4096\n\n\
|
Grep = 4096\n\n\
|
||||||
[worker.file_upload]\n\
|
[engine.file_upload]\n\
|
||||||
max_bytes = 12345\n",
|
max_bytes = 12345\n",
|
||||||
);
|
);
|
||||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||||
let limits = &manifest.worker.tool_output;
|
let limits = &manifest.engine.tool_output;
|
||||||
assert_eq!(limits.default_max_bytes, 8192);
|
assert_eq!(limits.default_max_bytes, 8192);
|
||||||
assert_eq!(limits.limit_for("Read"), 32768);
|
assert_eq!(limits.limit_for("Read"), 32768);
|
||||||
assert_eq!(limits.limit_for("Grep"), 4096);
|
assert_eq!(limits.limit_for("Grep"), 4096);
|
||||||
assert_eq!(limits.limit_for("Unknown"), 8192);
|
assert_eq!(limits.limit_for("Unknown"), 8192);
|
||||||
assert_eq!(manifest.worker.file_upload.max_bytes, 12345);
|
assert_eq!(manifest.engine.file_upload.max_bytes, 12345);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_tool_output_section_uses_default_max_bytes() {
|
fn empty_tool_output_section_uses_default_max_bytes() {
|
||||||
let toml = MINIMAL_REQUIRED.replace(
|
let toml = MINIMAL_REQUIRED.replace(
|
||||||
"[worker]\n",
|
"[engine]\n",
|
||||||
"[worker]\n\
|
"[engine]\n\
|
||||||
[worker.tool_output]\n",
|
[worker.tool_output]\n",
|
||||||
);
|
);
|
||||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||||
let limits = &manifest.worker.tool_output;
|
let limits = &manifest.engine.tool_output;
|
||||||
assert_eq!(limits.default_max_bytes, defaults::TOOL_OUTPUT_MAX_BYTES);
|
assert_eq!(limits.default_max_bytes, defaults::TOOL_OUTPUT_MAX_BYTES);
|
||||||
assert!(limits.per_tool.is_empty());
|
assert!(limits.per_tool.is_empty());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//! LLM モデル宣言型
|
//! LLM モデル宣言型
|
||||||
//!
|
//!
|
||||||
//! Pod マニフェストの `[model]` セクションで記述する型。`ref`(プロバイダ
|
//! Worker マニフェストの `[model]` セクションで記述する型。`ref`(プロバイダ
|
||||||
//! とモデルを両方指し示す短縮形)と inline 指定(`scheme` / `model_id`
|
//! とモデルを両方指し示す短縮形)と inline 指定(`scheme` / `model_id`
|
||||||
//! 直書き)の両方を受け入れるため、すべてのフィールドを `Option` として
|
//! 直書き)の両方を受け入れるため、すべてのフィールドを `Option` として
|
||||||
//! 持つ 1 つの型 [`ModelManifest`] に統合している。実解決(ref をプロバイダ
|
//! 持つ 1 つの型 [`ModelManifest`] に統合している。実解決(ref をプロバイダ
|
||||||
|
|
@ -18,7 +18,7 @@ use serde::{Deserialize, Serialize};
|
||||||
// マニフェストで任意に override できるよう型だけ再エクスポートする。
|
// マニフェストで任意に override できるよう型だけ再エクスポートする。
|
||||||
pub use llm_engine::llm_client::capability::{ModelCapability, ReasoningControl, ReasoningEffort};
|
pub use llm_engine::llm_client::capability::{ModelCapability, ReasoningControl, ReasoningEffort};
|
||||||
|
|
||||||
/// Pod マニフェストの `[model]` セクション。
|
/// Worker マニフェストの `[model]` セクション。
|
||||||
///
|
///
|
||||||
/// - ref だけ書く: `[model] ref = "anthropic/claude-sonnet-4-6"`
|
/// - ref だけ書く: `[model] ref = "anthropic/claude-sonnet-4-6"`
|
||||||
/// - ref + 一部 override: ref で基底を引き、`auth` 等だけ書き換え
|
/// - ref + 一部 override: ref で基底を引き、`auth` 等だけ書き換え
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
//! `providers.toml`, `models.toml`, `prompts/`, `prompts.toml` 等
|
//! `providers.toml`, `models.toml`, `prompts/`, `prompts.toml` 等
|
||||||
//! - **`data_dir`** — プログラムが書く永続データ。`sessions/` 等
|
//! - **`data_dir`** — プログラムが書く永続データ。`sessions/` 等
|
||||||
//! - **`runtime_dir`** — 再起動で消えてよいランタイム状態。socket,
|
//! - **`runtime_dir`** — 再起動で消えてよいランタイム状態。socket,
|
||||||
//! `pods.json`, `pid` ファイル等
|
//! `workers.json`, `pid` ファイル等
|
||||||
//!
|
//!
|
||||||
//! ## 解決順 (優先順位高 → 低)
|
//! ## 解決順 (優先順位高 → 低)
|
||||||
//!
|
//!
|
||||||
|
|
@ -46,7 +46,7 @@ pub fn data_dir() -> Option<PathBuf> {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ランタイムディレクトリ。socket, `pods.json`, Pod ごとの `pid` /
|
/// ランタイムディレクトリ。socket, `workers.json`, Worker ごとの `pid` /
|
||||||
/// `status.json` 等が置かれる。再起動で消えて構わない。
|
/// `status.json` 等が置かれる。再起動で消えて構わない。
|
||||||
pub fn runtime_dir() -> Option<PathBuf> {
|
pub fn runtime_dir() -> Option<PathBuf> {
|
||||||
resolve_runtime_dir_from_parts(
|
resolve_runtime_dir_from_parts(
|
||||||
|
|
@ -61,7 +61,7 @@ pub fn runtime_dir() -> Option<PathBuf> {
|
||||||
|
|
||||||
/// `<config_dir>/profiles.toml` — user profile registry/default configuration.
|
/// `<config_dir>/profiles.toml` — user profile registry/default configuration.
|
||||||
///
|
///
|
||||||
/// This is application/profile selection configuration, not a Pod manifest
|
/// This is application/profile selection configuration, not a Worker manifest
|
||||||
/// layer.
|
/// layer.
|
||||||
pub fn user_profiles_path() -> Option<PathBuf> {
|
pub fn user_profiles_path() -> Option<PathBuf> {
|
||||||
user_profiles_path_from_config_dir(config_dir())
|
user_profiles_path_from_config_dir(config_dir())
|
||||||
|
|
@ -88,24 +88,24 @@ pub fn sessions_dir() -> Option<PathBuf> {
|
||||||
sessions_dir_from_data_dir(data_dir())
|
sessions_dir_from_data_dir(data_dir())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `<runtime_dir>/pods.json` — machine-wide Pod allocation registry。
|
/// `<runtime_dir>/workers.json` — machine-wide Worker allocation registry。
|
||||||
pub fn pod_registry_path() -> Option<PathBuf> {
|
pub fn pod_registry_path() -> Option<PathBuf> {
|
||||||
pod_registry_path_from_runtime_dir(runtime_dir())
|
pod_registry_path_from_runtime_dir(runtime_dir())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `<runtime_dir>/<pod_name>/` — Pod ごとのランタイムディレクトリ。
|
/// `<runtime_dir>/<worker_name>/` — Worker ごとのランタイムディレクトリ。
|
||||||
pub fn pod_runtime_dir(pod_name: &str) -> Option<PathBuf> {
|
pub fn worker_runtime_dir(worker_name: &str) -> Option<PathBuf> {
|
||||||
pod_runtime_dir_from_runtime_dir(runtime_dir(), pod_name)
|
worker_runtime_dir_from_runtime_dir(runtime_dir(), worker_name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `<runtime_dir>/<pod_name>/sock` — Pod の Unix socket パス。
|
/// `<runtime_dir>/<worker_name>/sock` — Worker の Unix socket パス。
|
||||||
///
|
///
|
||||||
/// Pod プロセス内で実際に socket を作成するのは `pod` crate の
|
/// Worker プロセス内で実際に socket を作成するのは `worker` crate の
|
||||||
/// `RuntimeDir::socket_path()` で、Pod 名が分かっている外部 (TUI の
|
/// `RuntimeDir::socket_path()` で、Worker 名が分かっている外部 (TUI の
|
||||||
/// attach フロー等) からの**予測**はこの関数で行う。両者は同じパス
|
/// attach フロー等) からの**予測**はこの関数で行う。両者は同じパス
|
||||||
/// を返すことが期待される。
|
/// を返すことが期待される。
|
||||||
pub fn pod_socket_path(pod_name: &str) -> Option<PathBuf> {
|
pub fn pod_socket_path(worker_name: &str) -> Option<PathBuf> {
|
||||||
pod_socket_path_from_runtime_dir(runtime_dir(), pod_name)
|
pod_socket_path_from_runtime_dir(runtime_dir(), worker_name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- internals --------------------------------------------------------------
|
// ---- internals --------------------------------------------------------------
|
||||||
|
|
@ -184,21 +184,21 @@ fn sessions_dir_from_data_dir(data_dir: Option<PathBuf>) -> Option<PathBuf> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pod_registry_path_from_runtime_dir(runtime_dir: Option<PathBuf>) -> Option<PathBuf> {
|
fn pod_registry_path_from_runtime_dir(runtime_dir: Option<PathBuf>) -> Option<PathBuf> {
|
||||||
Some(runtime_dir?.join("pods.json"))
|
Some(runtime_dir?.join("workers.json"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pod_runtime_dir_from_runtime_dir(
|
fn worker_runtime_dir_from_runtime_dir(
|
||||||
runtime_dir: Option<PathBuf>,
|
runtime_dir: Option<PathBuf>,
|
||||||
pod_name: &str,
|
worker_name: &str,
|
||||||
) -> Option<PathBuf> {
|
) -> Option<PathBuf> {
|
||||||
Some(runtime_dir?.join(pod_name))
|
Some(runtime_dir?.join(worker_name))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pod_socket_path_from_runtime_dir(
|
fn pod_socket_path_from_runtime_dir(
|
||||||
runtime_dir: Option<PathBuf>,
|
runtime_dir: Option<PathBuf>,
|
||||||
pod_name: &str,
|
worker_name: &str,
|
||||||
) -> Option<PathBuf> {
|
) -> Option<PathBuf> {
|
||||||
Some(pod_runtime_dir_from_runtime_dir(runtime_dir, pod_name)?.join("sock"))
|
Some(worker_runtime_dir_from_runtime_dir(runtime_dir, worker_name)?.join("sock"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 空文字列の env は未設定として扱う。`std::env::var` は `Ok("")` と
|
/// 空文字列の env は未設定として扱う。`std::env::var` は `Ok("")` と
|
||||||
|
|
@ -397,10 +397,10 @@ mod tests {
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pod_registry_path_from_runtime_dir(runtime_dir.clone()).unwrap(),
|
pod_registry_path_from_runtime_dir(runtime_dir.clone()).unwrap(),
|
||||||
PathBuf::from("/sand/run/pods.json")
|
PathBuf::from("/sand/run/workers.json")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pod_runtime_dir_from_runtime_dir(runtime_dir.clone(), "foo").unwrap(),
|
worker_runtime_dir_from_runtime_dir(runtime_dir.clone(), "foo").unwrap(),
|
||||||
PathBuf::from("/sand/run/foo")
|
PathBuf::from("/sand/run/foo")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
//!
|
//!
|
||||||
//! Profiles are reusable, human-authored recipes. They are intentionally not
|
//! Profiles are reusable, human-authored recipes. They are intentionally not
|
||||||
//! complete runtime manifests: runtime-bound and authority-bearing fields such
|
//! complete runtime manifests: runtime-bound and authority-bearing fields such
|
||||||
//! as `pod.name` and concrete `scope.allow` rules are supplied by the resolver
|
//! as `worker.name` and concrete `scope.allow` rules are supplied by the resolver
|
||||||
//! from launch context.
|
//! from launch context.
|
||||||
|
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
|
|
@ -19,9 +19,9 @@ use crate::config::{
|
||||||
use crate::model::{AuthRef, ModelManifest};
|
use crate::model::{AuthRef, ModelManifest};
|
||||||
use crate::plugin::PluginConfig;
|
use crate::plugin::PluginConfig;
|
||||||
use crate::{
|
use crate::{
|
||||||
McpConfig, McpStdioCwdPolicy, MemoryConfig, Permission, PodManifest, PodManifestConfig,
|
EngineManifestConfig, McpConfig, McpStdioCwdPolicy, MemoryConfig, Permission, ResolveError,
|
||||||
PodMetaConfig, ResolveError, ScopeConfig, ScopeRule, SkillsConfig, WebConfig,
|
ScopeConfig, ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig,
|
||||||
WorkerManifestConfig, paths,
|
WorkerMetaConfig, paths,
|
||||||
};
|
};
|
||||||
|
|
||||||
const PROFILE_FORMAT_V1: &str = "yoi.lua-profile.v1";
|
const PROFILE_FORMAT_V1: &str = "yoi.lua-profile.v1";
|
||||||
|
|
@ -408,26 +408,26 @@ pub struct WorkspaceOverrideSnapshot {
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct WorkspaceOverrideLayer {
|
struct WorkspaceOverrideLayer {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
config: PodManifestConfig,
|
config: WorkerManifestConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ResolvedProfile {
|
pub struct ResolvedProfile {
|
||||||
pub source: ProfileSource,
|
pub source: ProfileSource,
|
||||||
pub profile: Option<ProfileMetadata>,
|
pub profile: Option<ProfileMetadata>,
|
||||||
pub manifest: PodManifest,
|
pub manifest: WorkerManifest,
|
||||||
pub manifest_snapshot: serde_json::Value,
|
pub manifest_snapshot: serde_json::Value,
|
||||||
pub raw_artifact: serde_json::Value,
|
pub raw_artifact: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct ProfileResolveOptions {
|
pub struct ProfileResolveOptions {
|
||||||
pub pod_name: Option<String>,
|
pub worker_name: Option<String>,
|
||||||
}
|
}
|
||||||
impl ProfileResolveOptions {
|
impl ProfileResolveOptions {
|
||||||
pub fn with_pod_name(name: impl Into<String>) -> Self {
|
pub fn with_worker_name(name: impl Into<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
pod_name: Some(name.into()),
|
worker_name: Some(name.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -469,8 +469,8 @@ impl ProfileResolver {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// Resolve a registry/default selector against an already-discovered
|
/// Resolve a registry/default selector against an already-discovered
|
||||||
/// registry. Callers such as SpawnPod use this to bind discovery to the
|
/// registry. Callers such as SpawnWorker use this to bind discovery to the
|
||||||
/// Pod's cwd instead of the process current directory.
|
/// Worker's cwd instead of the process current directory.
|
||||||
pub fn resolve_from_registry(
|
pub fn resolve_from_registry(
|
||||||
&self,
|
&self,
|
||||||
selector: &ProfileSelector,
|
selector: &ProfileSelector,
|
||||||
|
|
@ -604,22 +604,22 @@ fn resolve_lua_profile_value(
|
||||||
let profile: ProfileConfig = serde_json::from_value(value.clone())
|
let profile: ProfileConfig = serde_json::from_value(value.clone())
|
||||||
.map_err(|source| ProfileError::ProfileDeserialize { source })?;
|
.map_err(|source| ProfileError::ProfileDeserialize { source })?;
|
||||||
validate_profile_paths(&profile)?;
|
validate_profile_paths(&profile)?;
|
||||||
let pod_name = options
|
let worker_name = options
|
||||||
.pod_name
|
.worker_name
|
||||||
.ok_or(ProfileError::MissingRuntimePodName)?;
|
.ok_or(ProfileError::MissingRuntimeWorkerName)?;
|
||||||
let profile_meta = Some(ProfileMetadata {
|
let profile_meta = Some(ProfileMetadata {
|
||||||
name: profile.slug.clone().or_else(|| source_name(&source)),
|
name: profile.slug.clone().or_else(|| source_name(&source)),
|
||||||
description: profile.description.clone(),
|
description: profile.description.clone(),
|
||||||
format: Some(PROFILE_FORMAT_V1.to_string()),
|
format: Some(PROFILE_FORMAT_V1.to_string()),
|
||||||
});
|
});
|
||||||
let compaction = profile_compaction_to_partial(profile.compaction, &profile.model)?;
|
let compaction = profile_compaction_to_partial(profile.compaction, &profile.model)?;
|
||||||
let config = PodManifestConfig {
|
let config = WorkerManifestConfig {
|
||||||
pod: PodMetaConfig {
|
worker: WorkerMetaConfig {
|
||||||
name: Some(pod_name),
|
name: Some(worker_name),
|
||||||
prompt_pack: None,
|
prompt_pack: None,
|
||||||
},
|
},
|
||||||
model: profile.model.unwrap_or_default(),
|
model: profile.model.unwrap_or_default(),
|
||||||
worker: profile.worker.unwrap_or_default(),
|
engine: profile.engine.unwrap_or_default(),
|
||||||
scope: profile_scope_to_config(profile.scope, workspace_base)?,
|
scope: profile_scope_to_config(profile.scope, workspace_base)?,
|
||||||
delegation_scope: profile_delegation_scope_to_config(
|
delegation_scope: profile_delegation_scope_to_config(
|
||||||
profile.delegation_scope,
|
profile.delegation_scope,
|
||||||
|
|
@ -635,7 +635,8 @@ fn resolve_lua_profile_value(
|
||||||
memory: profile.memory,
|
memory: profile.memory,
|
||||||
skills: profile.skills,
|
skills: profile.skills,
|
||||||
};
|
};
|
||||||
let mut config = PodManifestConfig::builtin_defaults().merge(config.resolve_paths(profile_dir));
|
let mut config =
|
||||||
|
WorkerManifestConfig::builtin_defaults().merge(config.resolve_paths(profile_dir));
|
||||||
let workspace_override_snapshot = if let Some(override_layer) = workspace_override {
|
let workspace_override_snapshot = if let Some(override_layer) = workspace_override {
|
||||||
let override_base =
|
let override_base =
|
||||||
override_layer
|
override_layer
|
||||||
|
|
@ -652,7 +653,7 @@ fn resolve_lua_profile_value(
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
let mut manifest = PodManifest::try_from(config).map_err(ProfileError::ManifestResolve)?;
|
let mut manifest = WorkerManifest::try_from(config).map_err(ProfileError::ManifestResolve)?;
|
||||||
manifest.profile = Some(ProfileManifestSnapshot {
|
manifest.profile = Some(ProfileManifestSnapshot {
|
||||||
source: source.clone(),
|
source: source.clone(),
|
||||||
profile: profile_meta.clone(),
|
profile: profile_meta.clone(),
|
||||||
|
|
@ -679,7 +680,7 @@ struct ProfileConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
model: Option<ModelManifest>,
|
model: Option<ModelManifest>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
worker: Option<WorkerManifestConfig>,
|
engine: Option<EngineManifestConfig>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
scope: Option<ProfileScopeConfig>,
|
scope: Option<ProfileScopeConfig>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|
@ -814,16 +815,16 @@ fn load_workspace_override_file(path: &Path) -> Result<WorkspaceOverrideLayer, P
|
||||||
path: path.to_path_buf(),
|
path: path.to_path_buf(),
|
||||||
source,
|
source,
|
||||||
})?;
|
})?;
|
||||||
let config = PodManifestConfig::from_toml(&content).map_err(|source| {
|
let config = WorkerManifestConfig::from_toml(&content).map_err(|source| {
|
||||||
ProfileError::WorkspaceOverrideParse {
|
ProfileError::WorkspaceOverrideParse {
|
||||||
path: path.to_path_buf(),
|
path: path.to_path_buf(),
|
||||||
source,
|
source,
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
if config.pod.name.is_some() {
|
if config.worker.name.is_some() {
|
||||||
return Err(ProfileError::InvalidWorkspaceOverride {
|
return Err(ProfileError::InvalidWorkspaceOverride {
|
||||||
path: path.to_path_buf(),
|
path: path.to_path_buf(),
|
||||||
message: "workspace-local manifest overrides cannot set pod.name; Pod identity is a runtime input".into(),
|
message: "workspace-local manifest overrides cannot set worker.name; Worker identity is a runtime input".into(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(WorkspaceOverrideLayer {
|
Ok(WorkspaceOverrideLayer {
|
||||||
|
|
@ -1209,8 +1210,8 @@ fn reject_manifest_shaped_profile(value: &serde_json::Value) -> Result<(), Profi
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if map.contains_key("pod") {
|
if map.contains_key("worker") {
|
||||||
return Err(ProfileError::InvalidProfile("field `pod` is runtime-bound and is not allowed in reusable Profiles; pass the Pod name via CLI/TUI runtime inputs".into()));
|
return Err(ProfileError::InvalidProfile("field `worker` is runtime-bound and is not allowed in reusable Profiles; pass the Worker name via CLI/TUI runtime inputs".into()));
|
||||||
}
|
}
|
||||||
if let Some(scope) = map.get("scope").and_then(|v| v.as_object()) {
|
if let Some(scope) = map.get("scope").and_then(|v| v.as_object()) {
|
||||||
for key in ["allow", "deny"] {
|
for key in ["allow", "deny"] {
|
||||||
|
|
@ -1442,7 +1443,7 @@ pub fn resolve_profile_artifact(
|
||||||
source,
|
source,
|
||||||
base_dir,
|
base_dir,
|
||||||
base_dir,
|
base_dir,
|
||||||
ProfileResolveOptions::with_pod_name("artifact-pod"),
|
ProfileResolveOptions::with_worker_name("artifact-worker"),
|
||||||
raw_artifact.clone(),
|
raw_artifact.clone(),
|
||||||
raw_artifact,
|
raw_artifact,
|
||||||
None,
|
None,
|
||||||
|
|
@ -1489,8 +1490,8 @@ pub enum ProfileError {
|
||||||
InvalidWorkspaceOverride { path: PathBuf, message: String },
|
InvalidWorkspaceOverride { path: PathBuf, message: String },
|
||||||
#[error("no default profile is configured")]
|
#[error("no default profile is configured")]
|
||||||
NoDefaultProfile,
|
NoDefaultProfile,
|
||||||
#[error("profile resolution requires an explicit runtime Pod name")]
|
#[error("profile resolution requires an explicit runtime Worker name")]
|
||||||
MissingRuntimePodName,
|
MissingRuntimeWorkerName,
|
||||||
#[error("profile not found: {selector}")]
|
#[error("profile not found: {selector}")]
|
||||||
ProfileNotFound { selector: String },
|
ProfileNotFound { selector: String },
|
||||||
#[error("ambiguous profile name `{name}`; use a source-qualified selector such as {matches:?}")]
|
#[error("ambiguous profile name `{name}`; use a source-qualified selector such as {matches:?}")]
|
||||||
|
|
@ -1574,17 +1575,17 @@ mod tests {
|
||||||
.with_workspace_base(tmp.path())
|
.with_workspace_base(tmp.path())
|
||||||
.resolve(
|
.resolve(
|
||||||
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, expected),
|
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, expected),
|
||||||
ProfileResolveOptions::with_pod_name("role-pod"),
|
ProfileResolveOptions::with_worker_name("role-worker"),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolved.profile.as_ref().unwrap().name.as_deref(),
|
resolved.profile.as_ref().unwrap().name.as_deref(),
|
||||||
Some(expected)
|
Some(expected)
|
||||||
);
|
);
|
||||||
assert_eq!(resolved.manifest.pod.name, "role-pod");
|
assert_eq!(resolved.manifest.worker.name, "role-worker");
|
||||||
if matches!(expected, "intake" | "orchestrator" | "coder" | "reviewer") {
|
if matches!(expected, "intake" | "orchestrator" | "coder" | "reviewer") {
|
||||||
let expected_instruction = format!("$yoi/role/{expected}");
|
let expected_instruction = format!("$yoi/role/{expected}");
|
||||||
assert_eq!(resolved.manifest.worker.instruction, expected_instruction);
|
assert_eq!(resolved.manifest.engine.instruction, expected_instruction);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1597,7 +1598,7 @@ mod tests {
|
||||||
.with_workspace_base(tmp.path())
|
.with_workspace_base(tmp.path())
|
||||||
.resolve(
|
.resolve(
|
||||||
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, role),
|
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, role),
|
||||||
ProfileResolveOptions::with_pod_name("role-pod"),
|
ProfileResolveOptions::with_worker_name("role-worker"),
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.manifest
|
.manifest
|
||||||
|
|
@ -1605,7 +1606,7 @@ mod tests {
|
||||||
|
|
||||||
let companion = resolve("companion");
|
let companion = resolve("companion");
|
||||||
assert!(companion.feature.task.enabled);
|
assert!(companion.feature.task.enabled);
|
||||||
assert!(companion.feature.pods.enabled);
|
assert!(companion.feature.workers.enabled);
|
||||||
assert!(companion.feature.ticket.enabled);
|
assert!(companion.feature.ticket.enabled);
|
||||||
assert!(companion.scope.allow.is_empty());
|
assert!(companion.scope.allow.is_empty());
|
||||||
assert!(companion.scope.deny.is_empty());
|
assert!(companion.scope.deny.is_empty());
|
||||||
|
|
@ -1615,7 +1616,7 @@ mod tests {
|
||||||
|
|
||||||
let intake = resolve("intake");
|
let intake = resolve("intake");
|
||||||
assert!(!intake.feature.task.enabled);
|
assert!(!intake.feature.task.enabled);
|
||||||
assert!(!intake.feature.pods.enabled);
|
assert!(!intake.feature.workers.enabled);
|
||||||
assert!(intake.feature.ticket.enabled);
|
assert!(intake.feature.ticket.enabled);
|
||||||
assert!(intake.scope.allow.is_empty());
|
assert!(intake.scope.allow.is_empty());
|
||||||
assert!(intake.delegation_scope.allow.is_empty());
|
assert!(intake.delegation_scope.allow.is_empty());
|
||||||
|
|
@ -1625,7 +1626,7 @@ mod tests {
|
||||||
|
|
||||||
let orchestrator = resolve("orchestrator");
|
let orchestrator = resolve("orchestrator");
|
||||||
assert!(!orchestrator.feature.task.enabled);
|
assert!(!orchestrator.feature.task.enabled);
|
||||||
assert!(orchestrator.feature.pods.enabled);
|
assert!(orchestrator.feature.workers.enabled);
|
||||||
assert!(orchestrator.feature.ticket.enabled);
|
assert!(orchestrator.feature.ticket.enabled);
|
||||||
assert!(orchestrator.feature.ticket_orchestration.enabled);
|
assert!(orchestrator.feature.ticket_orchestration.enabled);
|
||||||
assert!(orchestrator.scope.allow.is_empty());
|
assert!(orchestrator.scope.allow.is_empty());
|
||||||
|
|
@ -1638,7 +1639,7 @@ mod tests {
|
||||||
|
|
||||||
let coder = resolve("coder");
|
let coder = resolve("coder");
|
||||||
assert!(coder.feature.task.enabled);
|
assert!(coder.feature.task.enabled);
|
||||||
assert!(!coder.feature.pods.enabled);
|
assert!(!coder.feature.workers.enabled);
|
||||||
assert!(coder.scope.allow.is_empty());
|
assert!(coder.scope.allow.is_empty());
|
||||||
assert!(coder.delegation_scope.allow.is_empty());
|
assert!(coder.delegation_scope.allow.is_empty());
|
||||||
assert_eq!(coder.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
|
assert_eq!(coder.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
|
||||||
|
|
@ -1646,7 +1647,7 @@ mod tests {
|
||||||
|
|
||||||
let reviewer = resolve("reviewer");
|
let reviewer = resolve("reviewer");
|
||||||
assert!(!reviewer.feature.task.enabled);
|
assert!(!reviewer.feature.task.enabled);
|
||||||
assert!(!reviewer.feature.pods.enabled);
|
assert!(!reviewer.feature.workers.enabled);
|
||||||
assert!(!reviewer.feature.ticket.enabled);
|
assert!(!reviewer.feature.ticket.enabled);
|
||||||
assert!(reviewer.scope.allow.is_empty());
|
assert!(reviewer.scope.allow.is_empty());
|
||||||
assert!(reviewer.delegation_scope.allow.is_empty());
|
assert!(reviewer.delegation_scope.allow.is_empty());
|
||||||
|
|
@ -1655,17 +1656,17 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn profile_resolution_requires_runtime_pod_name() {
|
fn profile_resolution_requires_runtime_worker_name() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let err = ProfileResolver::new()
|
let err = ProfileResolver::new()
|
||||||
.with_workspace_base(tmp.path())
|
.with_workspace_base(tmp.path())
|
||||||
.resolve(&ProfileSelector::Default, ProfileResolveOptions::default())
|
.resolve(&ProfileSelector::Default, ProfileResolveOptions::default())
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(err, ProfileError::MissingRuntimePodName));
|
assert!(matches!(err, ProfileError::MissingRuntimeWorkerName));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolves_plain_lua_profile_with_runtime_pod_name_and_scope_intent() {
|
fn resolves_plain_lua_profile_with_runtime_worker_name_and_scope_intent() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let profile = write_profile(
|
let profile = write_profile(
|
||||||
tmp.path(),
|
tmp.path(),
|
||||||
|
|
@ -1676,7 +1677,7 @@ local scope = require("yoi.scope")
|
||||||
return profile {
|
return profile {
|
||||||
slug = "coder",
|
slug = "coder",
|
||||||
model = { scheme = "anthropic", model_id = "claude-sonnet-4-20250514" },
|
model = { scheme = "anthropic", model_id = "claude-sonnet-4-20250514" },
|
||||||
worker = { reasoning = "high" },
|
engine = { reasoning = "high" },
|
||||||
scope = scope.workspace_read(),
|
scope = scope.workspace_read(),
|
||||||
}
|
}
|
||||||
"#,
|
"#,
|
||||||
|
|
@ -1687,13 +1688,13 @@ return profile {
|
||||||
.with_workspace_base(&workspace)
|
.with_workspace_base(&workspace)
|
||||||
.resolve(
|
.resolve(
|
||||||
&ProfileSelector::path(&profile),
|
&ProfileSelector::path(&profile),
|
||||||
ProfileResolveOptions::with_pod_name("runtime-pod"),
|
ProfileResolveOptions::with_worker_name("runtime-worker"),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resolved.manifest.pod.name, "runtime-pod");
|
assert_eq!(resolved.manifest.worker.name, "runtime-worker");
|
||||||
assert_eq!(resolved.manifest.model.scheme, Some(SchemeKind::Anthropic));
|
assert_eq!(resolved.manifest.model.scheme, Some(SchemeKind::Anthropic));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolved.manifest.worker.reasoning,
|
resolved.manifest.engine.reasoning,
|
||||||
Some(ReasoningControl::Effort(ReasoningEffort::High))
|
Some(ReasoningControl::Effort(ReasoningEffort::High))
|
||||||
);
|
);
|
||||||
assert_eq!(resolved.manifest.scope.allow[0].target, workspace);
|
assert_eq!(resolved.manifest.scope.allow[0].target, workspace);
|
||||||
|
|
@ -1748,7 +1749,7 @@ return profile {
|
||||||
.with_workspace_base(&workspace)
|
.with_workspace_base(&workspace)
|
||||||
.resolve(
|
.resolve(
|
||||||
&ProfileSelector::path(&profile),
|
&ProfileSelector::path(&profile),
|
||||||
ProfileResolveOptions::with_pod_name("runtime-pod"),
|
ProfileResolveOptions::with_worker_name("runtime-worker"),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|
@ -1785,7 +1786,7 @@ return profile {
|
||||||
task = { enabled = true },
|
task = { enabled = true },
|
||||||
memory = { enabled = false },
|
memory = { enabled = false },
|
||||||
web = { enabled = true },
|
web = { enabled = true },
|
||||||
pods = { enabled = true },
|
workers = { enabled = true },
|
||||||
ticket = { enabled = true, access = "read_only" },
|
ticket = { enabled = true, access = "read_only" },
|
||||||
ticket_orchestration = { enabled = false },
|
ticket_orchestration = { enabled = false },
|
||||||
},
|
},
|
||||||
|
|
@ -1798,14 +1799,14 @@ return profile {
|
||||||
.with_workspace_base(&workspace)
|
.with_workspace_base(&workspace)
|
||||||
.resolve(
|
.resolve(
|
||||||
&ProfileSelector::path(&profile),
|
&ProfileSelector::path(&profile),
|
||||||
ProfileResolveOptions::with_pod_name("runtime-pod"),
|
ProfileResolveOptions::with_worker_name("runtime-worker"),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resolved.manifest.pod.name, "runtime-pod");
|
assert_eq!(resolved.manifest.worker.name, "runtime-worker");
|
||||||
assert!(resolved.manifest.feature.task.enabled);
|
assert!(resolved.manifest.feature.task.enabled);
|
||||||
assert!(!resolved.manifest.feature.memory.enabled);
|
assert!(!resolved.manifest.feature.memory.enabled);
|
||||||
assert!(resolved.manifest.feature.web.enabled);
|
assert!(resolved.manifest.feature.web.enabled);
|
||||||
assert!(resolved.manifest.feature.pods.enabled);
|
assert!(resolved.manifest.feature.workers.enabled);
|
||||||
assert!(resolved.manifest.feature.ticket.enabled);
|
assert!(resolved.manifest.feature.ticket.enabled);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolved.manifest.feature.ticket.access,
|
resolved.manifest.feature.ticket.access,
|
||||||
|
|
@ -1844,7 +1845,7 @@ return yoi.profile {
|
||||||
.with_workspace_base(tmp.path())
|
.with_workspace_base(tmp.path())
|
||||||
.resolve(
|
.resolve(
|
||||||
&ProfileSelector::path(profile),
|
&ProfileSelector::path(profile),
|
||||||
ProfileResolveOptions::with_pod_name("p"),
|
ProfileResolveOptions::with_worker_name("p"),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -1877,7 +1878,7 @@ p.slug = "assigned"
|
||||||
p.model = yoi.models.catalog("anthropic/claude-sonnet-4-6")
|
p.model = yoi.models.catalog("anthropic/claude-sonnet-4-6")
|
||||||
p.feature = {
|
p.feature = {
|
||||||
task = { enabled = false },
|
task = { enabled = false },
|
||||||
pods = { enabled = true },
|
workers = { enabled = true },
|
||||||
}
|
}
|
||||||
p.web = { enabled = false }
|
p.web = { enabled = false }
|
||||||
p.compaction = yoi.compact.tokens { threshold = 123, request_threshold = 456 }
|
p.compaction = yoi.compact.tokens { threshold = 123, request_threshold = 456 }
|
||||||
|
|
@ -1888,7 +1889,7 @@ return p
|
||||||
.with_workspace_base(tmp.path())
|
.with_workspace_base(tmp.path())
|
||||||
.resolve(
|
.resolve(
|
||||||
&ProfileSelector::path(profile),
|
&ProfileSelector::path(profile),
|
||||||
ProfileResolveOptions::with_pod_name("p"),
|
ProfileResolveOptions::with_worker_name("p"),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -1896,7 +1897,7 @@ return p
|
||||||
Some("anthropic/claude-sonnet-4-6")
|
Some("anthropic/claude-sonnet-4-6")
|
||||||
);
|
);
|
||||||
assert!(!resolved.manifest.feature.task.enabled);
|
assert!(!resolved.manifest.feature.task.enabled);
|
||||||
assert!(resolved.manifest.feature.pods.enabled);
|
assert!(resolved.manifest.feature.workers.enabled);
|
||||||
assert_eq!(resolved.manifest.web.as_ref().unwrap().enabled, Some(false));
|
assert_eq!(resolved.manifest.web.as_ref().unwrap().enabled, Some(false));
|
||||||
assert!(resolved.manifest.web.as_ref().unwrap().search.is_none());
|
assert!(resolved.manifest.web.as_ref().unwrap().search.is_none());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -1925,7 +1926,7 @@ return yoi.profile.extend("builtin:default", {
|
||||||
.with_workspace_base(tmp.path())
|
.with_workspace_base(tmp.path())
|
||||||
.resolve(
|
.resolve(
|
||||||
&ProfileSelector::path(profile),
|
&ProfileSelector::path(profile),
|
||||||
ProfileResolveOptions::with_pod_name("p"),
|
ProfileResolveOptions::with_worker_name("p"),
|
||||||
)
|
)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
let message = err.to_string();
|
let message = err.to_string();
|
||||||
|
|
@ -1948,7 +1949,7 @@ return yoi.profile.extend("builtin:default", {
|
||||||
.with_workspace_base(tmp.path())
|
.with_workspace_base(tmp.path())
|
||||||
.resolve(
|
.resolve(
|
||||||
&ProfileSelector::path(path),
|
&ProfileSelector::path(path),
|
||||||
ProfileResolveOptions::with_pod_name("p"),
|
ProfileResolveOptions::with_worker_name("p"),
|
||||||
)
|
)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
|
|
@ -1962,7 +1963,7 @@ return yoi.profile.extend("builtin:default", {
|
||||||
for (value, needle) in [
|
for (value, needle) in [
|
||||||
(serde_json::json!({"manifest": {}}), "manifest"),
|
(serde_json::json!({"manifest": {}}), "manifest"),
|
||||||
(serde_json::json!({"config": {}}), "config"),
|
(serde_json::json!({"config": {}}), "config"),
|
||||||
(serde_json::json!({"pod": {"name": "bad"}}), "pod"),
|
(serde_json::json!({"worker": {"name": "bad"}}), "worker"),
|
||||||
(
|
(
|
||||||
serde_json::json!({"model": {"ref": "codex-oauth/gpt-5.5"}, "scope": {"allow": []}}),
|
serde_json::json!({"model": {"ref": "codex-oauth/gpt-5.5"}, "scope": {"allow": []}}),
|
||||||
"scope.allow",
|
"scope.allow",
|
||||||
|
|
@ -2008,7 +2009,7 @@ return profile {
|
||||||
.with_workspace_base(tmp.path())
|
.with_workspace_base(tmp.path())
|
||||||
.resolve(
|
.resolve(
|
||||||
&ProfileSelector::path(profile),
|
&ProfileSelector::path(profile),
|
||||||
ProfileResolveOptions::with_pod_name("p"),
|
ProfileResolveOptions::with_worker_name("p"),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let c = resolved.manifest.compaction.unwrap();
|
let c = resolved.manifest.compaction.unwrap();
|
||||||
|
|
@ -2023,10 +2024,10 @@ return profile {
|
||||||
.with_workspace_base(tmp.path())
|
.with_workspace_base(tmp.path())
|
||||||
.resolve(
|
.resolve(
|
||||||
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "default"),
|
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "default"),
|
||||||
ProfileResolveOptions::with_pod_name("runtime-workspace"),
|
ProfileResolveOptions::with_worker_name("runtime-workspace"),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resolved.manifest.pod.name, "runtime-workspace");
|
assert_eq!(resolved.manifest.worker.name, "runtime-workspace");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolved.manifest.model.ref_.as_deref(),
|
resolved.manifest.model.ref_.as_deref(),
|
||||||
Some("codex-oauth/gpt-5.5")
|
Some("codex-oauth/gpt-5.5")
|
||||||
|
|
@ -2060,9 +2061,9 @@ return profile {
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
&override_path,
|
&override_path,
|
||||||
r#"
|
r#"
|
||||||
[pod]
|
|
||||||
prompt_pack = "prompts.toml"
|
|
||||||
[worker]
|
[worker]
|
||||||
|
prompt_pack = "prompts.toml"
|
||||||
|
[engine]
|
||||||
language = "ja"
|
language = "ja"
|
||||||
[session]
|
[session]
|
||||||
record_event_trace = false
|
record_event_trace = false
|
||||||
|
|
@ -2074,15 +2075,15 @@ record_event_trace = false
|
||||||
.with_workspace_base(&nested)
|
.with_workspace_base(&nested)
|
||||||
.resolve(
|
.resolve(
|
||||||
&ProfileSelector::Default,
|
&ProfileSelector::Default,
|
||||||
ProfileResolveOptions::with_pod_name("runtime-pod"),
|
ProfileResolveOptions::with_worker_name("runtime-worker"),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(resolved.manifest.pod.name, "runtime-pod");
|
assert_eq!(resolved.manifest.worker.name, "runtime-worker");
|
||||||
assert_eq!(resolved.manifest.worker.language, "ja");
|
assert_eq!(resolved.manifest.engine.language, "ja");
|
||||||
assert!(!resolved.manifest.session.record_event_trace);
|
assert!(!resolved.manifest.session.record_event_trace);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolved.manifest.pod.prompt_pack.as_deref(),
|
resolved.manifest.worker.prompt_pack.as_deref(),
|
||||||
Some(yoi_dir.join("prompts.toml").as_path())
|
Some(yoi_dir.join("prompts.toml").as_path())
|
||||||
);
|
);
|
||||||
assert!(resolved.manifest.scope.allow.is_empty());
|
assert!(resolved.manifest.scope.allow.is_empty());
|
||||||
|
|
@ -2111,9 +2112,9 @@ record_event_trace = false
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
parent_yoi.join(WORKSPACE_OVERRIDE_LOCAL_FILENAME),
|
parent_yoi.join(WORKSPACE_OVERRIDE_LOCAL_FILENAME),
|
||||||
r#"
|
r#"
|
||||||
[pod]
|
|
||||||
prompt_pack = "parent-prompts.toml"
|
|
||||||
[worker]
|
[worker]
|
||||||
|
prompt_pack = "parent-prompts.toml"
|
||||||
|
[engine]
|
||||||
language = "parent"
|
language = "parent"
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|
@ -2122,9 +2123,9 @@ language = "parent"
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
&nested_override_path,
|
&nested_override_path,
|
||||||
r#"
|
r#"
|
||||||
[pod]
|
|
||||||
prompt_pack = "nested-prompts.toml"
|
|
||||||
[worker]
|
[worker]
|
||||||
|
prompt_pack = "nested-prompts.toml"
|
||||||
|
[engine]
|
||||||
language = "nested"
|
language = "nested"
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|
@ -2134,13 +2135,13 @@ language = "nested"
|
||||||
.with_workspace_base(&child)
|
.with_workspace_base(&child)
|
||||||
.resolve(
|
.resolve(
|
||||||
&ProfileSelector::Default,
|
&ProfileSelector::Default,
|
||||||
ProfileResolveOptions::with_pod_name("runtime-pod"),
|
ProfileResolveOptions::with_worker_name("runtime-worker"),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(resolved.manifest.worker.language, "nested");
|
assert_eq!(resolved.manifest.engine.language, "nested");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolved.manifest.pod.prompt_pack.as_deref(),
|
resolved.manifest.worker.prompt_pack.as_deref(),
|
||||||
Some(nested_yoi.join("nested-prompts.toml").as_path())
|
Some(nested_yoi.join("nested-prompts.toml").as_path())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -2155,13 +2156,13 @@ language = "nested"
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workspace_local_override_rejects_runtime_pod_name() {
|
fn workspace_local_override_rejects_runtime_worker_name() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let yoi_dir = tmp.path().join(".yoi");
|
let yoi_dir = tmp.path().join(".yoi");
|
||||||
std::fs::create_dir_all(&yoi_dir).unwrap();
|
std::fs::create_dir_all(&yoi_dir).unwrap();
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
yoi_dir.join(WORKSPACE_OVERRIDE_LOCAL_FILENAME),
|
yoi_dir.join(WORKSPACE_OVERRIDE_LOCAL_FILENAME),
|
||||||
"[pod]\nname = \"not-local\"\n",
|
"[worker]\nname = \"not-local\"\n",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|
@ -2169,11 +2170,11 @@ language = "nested"
|
||||||
.with_workspace_base(tmp.path())
|
.with_workspace_base(tmp.path())
|
||||||
.resolve(
|
.resolve(
|
||||||
&ProfileSelector::Default,
|
&ProfileSelector::Default,
|
||||||
ProfileResolveOptions::with_pod_name("runtime-pod"),
|
ProfileResolveOptions::with_worker_name("runtime-worker"),
|
||||||
)
|
)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(err, ProfileError::InvalidWorkspaceOverride { .. }));
|
assert!(matches!(err, ProfileError::InvalidWorkspaceOverride { .. }));
|
||||||
assert!(err.to_string().contains("pod.name"));
|
assert!(err.to_string().contains("worker.name"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
//! Runtime representation of a Pod's access scope.
|
//! Runtime representation of a Worker's access scope.
|
||||||
//!
|
//!
|
||||||
//! Built from [`crate::ScopeConfig`] via [`Scope::from_config`]. Every
|
//! Built from [`crate::ScopeConfig`] via [`Scope::from_config`]. Every
|
||||||
//! rule `target` must already be an absolute path — per-layer path
|
//! rule `target` must already be an absolute path — per-layer path
|
||||||
//! resolution runs earlier, inside [`crate::PodManifestConfig::resolve_paths`].
|
//! resolution runs earlier, inside [`crate::WorkerManifestConfig::resolve_paths`].
|
||||||
//! All rule `target` paths inside the [`Scope`] are canonicalised (where
|
//! All rule `target` paths inside the [`Scope`] are canonicalised (where
|
||||||
//! possible) so access checks are pure path comparisons.
|
//! possible) so access checks are pure path comparisons.
|
||||||
|
|
||||||
|
|
@ -14,7 +14,7 @@ use arc_swap::{ArcSwap, Guard};
|
||||||
|
|
||||||
use crate::{Permission, ScopeConfig, ScopeRule};
|
use crate::{Permission, ScopeConfig, ScopeRule};
|
||||||
|
|
||||||
/// Parsed, pwd-resolved set of allow/deny rules for a Pod.
|
/// Parsed, pwd-resolved set of allow/deny rules for a Worker.
|
||||||
///
|
///
|
||||||
/// Read/write access decisions are pure functions of the path being
|
/// Read/write access decisions are pure functions of the path being
|
||||||
/// queried and these rules — see [`Scope::permission_at`].
|
/// queried and these rules — see [`Scope::permission_at`].
|
||||||
|
|
@ -32,7 +32,7 @@ struct ResolvedRule {
|
||||||
recursive: bool,
|
recursive: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parsed filesystem authority this Pod may pass to spawned children.
|
/// Parsed filesystem authority this Worker may pass to spawned children.
|
||||||
///
|
///
|
||||||
/// Unlike [`Scope`], an empty allow list is valid and means no delegation
|
/// Unlike [`Scope`], an empty allow list is valid and means no delegation
|
||||||
/// authority. Direct tools never consult this type.
|
/// authority. Direct tools never consult this type.
|
||||||
|
|
@ -173,7 +173,7 @@ impl Scope {
|
||||||
///
|
///
|
||||||
/// Every `target` in `config` must already be absolute — per-layer
|
/// Every `target` in `config` must already be absolute — per-layer
|
||||||
/// resolution happens upstream in
|
/// resolution happens upstream in
|
||||||
/// [`crate::PodManifestConfig::resolve_paths`] so that cascade merge
|
/// [`crate::WorkerManifestConfig::resolve_paths`] so that cascade merge
|
||||||
/// operates on fully-qualified paths. A lingering relative target
|
/// operates on fully-qualified paths. A lingering relative target
|
||||||
/// here signals an upstream bug and is rejected.
|
/// here signals an upstream bug and is rejected.
|
||||||
pub fn from_config(config: &ScopeConfig) -> Result<Self, ScopeError> {
|
pub fn from_config(config: &ScopeConfig) -> Result<Self, ScopeError> {
|
||||||
|
|
@ -266,7 +266,7 @@ impl Scope {
|
||||||
|
|
||||||
/// Allow rules with their targets resolved to absolute paths.
|
/// Allow rules with their targets resolved to absolute paths.
|
||||||
///
|
///
|
||||||
/// Used by the pod-registry, where every Pod's allocation
|
/// Used by the pod-registry, where every Worker's allocation
|
||||||
/// must be expressed in absolute terms so prefix comparisons are
|
/// must be expressed in absolute terms so prefix comparisons are
|
||||||
/// meaningful across processes.
|
/// meaningful across processes.
|
||||||
pub fn allow_rules(&self) -> Vec<ScopeRule> {
|
pub fn allow_rules(&self) -> Vec<ScopeRule> {
|
||||||
|
|
@ -324,7 +324,7 @@ impl Scope {
|
||||||
|
|
||||||
/// Build a new [`Scope`] equal to `self` with `extra_deny` appended
|
/// Build a new [`Scope`] equal to `self` with `extra_deny` appended
|
||||||
/// to the deny set. Used by dynamic-scope shrink paths
|
/// to the deny set. Used by dynamic-scope shrink paths
|
||||||
/// (e.g. SpawnPod-style delegation that strips Write from the
|
/// (e.g. SpawnWorker-style delegation that strips Write from the
|
||||||
/// spawner without touching its allow rules).
|
/// spawner without touching its allow rules).
|
||||||
pub fn with_added_deny_rules(
|
pub fn with_added_deny_rules(
|
||||||
&self,
|
&self,
|
||||||
|
|
@ -420,7 +420,7 @@ impl Scope {
|
||||||
/// not lose each other's contributions.
|
/// not lose each other's contributions.
|
||||||
///
|
///
|
||||||
/// All clones share the same underlying state — a `SharedScope` cloned
|
/// All clones share the same underlying state — a `SharedScope` cloned
|
||||||
/// out to multiple consumers (Pod, ScopedFs, future grant/revoke
|
/// out to multiple consumers (Worker, ScopedFs, future grant/revoke
|
||||||
/// callers) sees every update.
|
/// callers) sees every update.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SharedScope {
|
pub struct SharedScope {
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ Owns:
|
||||||
Does not own:
|
Does not own:
|
||||||
|
|
||||||
- authoritative project records (`.yoi/tickets/`, git history)
|
- authoritative project records (`.yoi/tickets/`, git history)
|
||||||
- normal Pod turn orchestration (`llm-engine`)
|
- normal Worker turn orchestration (`llm-engine`)
|
||||||
- product CLI command shape (`yoi`)
|
- product CLI command shape (`yoi`)
|
||||||
- curated workflow definitions (`workflow`)
|
- curated workflow definitions (`workflow`)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ pub fn build_consolidate_input(
|
||||||
"consolidation input. Run the integration step first \
|
"consolidation input. Run the integration step first \
|
||||||
(fold the staging activity logs into memory and knowledge), then the \
|
(fold the staging activity logs into memory and knowledge), then the \
|
||||||
tidy step (clean up existing records). Use the memory tools for \
|
tidy step (clean up existing records). Use the memory tools for \
|
||||||
every write — direct file writes are denied by the pod scope.\n\n",
|
every write — direct file writes are denied by the worker scope.\n\n",
|
||||||
);
|
);
|
||||||
|
|
||||||
out.push_str("## Staging entries (consumed by this run)\n\n");
|
out.push_str("## Staging entries (consumed by this run)\n\n");
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
//!
|
//!
|
||||||
//! `docs/plan/memory.md` §並走防止 に従い:
|
//! `docs/plan/memory.md` §並走防止 に従い:
|
||||||
//!
|
//!
|
||||||
//! - ファイルが存在し、記録された Pod が動作している間、その Pod が排他占有
|
//! - ファイルが存在し、記録された Worker が動作している間、その Worker が排他占有
|
||||||
//! - クラッシュで残った stale lock は、所有者 PID が死んでいれば次回 spawn
|
//! - クラッシュで残った stale lock は、所有者 PID が死んでいれば次回 spawn
|
||||||
//! 時に上書き取得できる
|
//! 時に上書き取得できる
|
||||||
//! - cleanup は consumed ID の staging エントリのみ削除し、実行中に extract
|
//! - cleanup は consumed ID の staging エントリのみ削除し、実行中に extract
|
||||||
|
|
@ -22,12 +22,12 @@ use crate::workspace::WorkspaceLayout;
|
||||||
|
|
||||||
const LOCK_FILE: &str = ".consolidation.lock";
|
const LOCK_FILE: &str = ".consolidation.lock";
|
||||||
|
|
||||||
/// 占有ファイルの中身。`pid` で stale 判定し、`pod_name` / `started_at` /
|
/// 占有ファイルの中身。`pid` で stale 判定し、`worker_name` / `started_at` /
|
||||||
/// `consumed_ids` は診断とクラッシュ復旧時の参照に使う。
|
/// `consumed_ids` は診断とクラッシュ復旧時の参照に使う。
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct LockRecord {
|
pub struct LockRecord {
|
||||||
pub pid: u32,
|
pub pid: u32,
|
||||||
pub pod_name: String,
|
pub worker_name: String,
|
||||||
pub started_at: DateTime<Utc>,
|
pub started_at: DateTime<Utc>,
|
||||||
/// この consolidation run が起動時スナップショットで確定した consumed staging
|
/// この consolidation run が起動時スナップショットで確定した consumed staging
|
||||||
/// entry の UUIDv7 列。完了時はこの列のみ削除し、追加分は残す。
|
/// entry の UUIDv7 列。完了時はこの列のみ削除し、追加分は残す。
|
||||||
|
|
@ -38,8 +38,8 @@ pub struct LockRecord {
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum LockError {
|
pub enum LockError {
|
||||||
/// 占有ファイルが既にあり、所有者 PID が生きているのでスキップ。
|
/// 占有ファイルが既にあり、所有者 PID が生きているのでスキップ。
|
||||||
#[error("consolidation lock held by live pid {pid} (pod {pod_name:?})")]
|
#[error("consolidation lock held by live pid {pid} (worker {worker_name:?})")]
|
||||||
InUse { pid: u32, pod_name: String },
|
InUse { pid: u32, worker_name: String },
|
||||||
#[error("io error at {}: {source}", .path.display())]
|
#[error("io error at {}: {source}", .path.display())]
|
||||||
Io {
|
Io {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
|
|
@ -85,7 +85,7 @@ impl StagingLock {
|
||||||
pub fn acquire(
|
pub fn acquire(
|
||||||
layout: &WorkspaceLayout,
|
layout: &WorkspaceLayout,
|
||||||
pid: u32,
|
pid: u32,
|
||||||
pod_name: impl Into<String>,
|
worker_name: impl Into<String>,
|
||||||
consumed_ids: Vec<Uuid>,
|
consumed_ids: Vec<Uuid>,
|
||||||
) -> Result<Self, LockError> {
|
) -> Result<Self, LockError> {
|
||||||
let staging_dir = layout.staging_dir();
|
let staging_dir = layout.staging_dir();
|
||||||
|
|
@ -99,12 +99,12 @@ impl StagingLock {
|
||||||
if pid_is_alive(existing.pid) {
|
if pid_is_alive(existing.pid) {
|
||||||
return Err(LockError::InUse {
|
return Err(LockError::InUse {
|
||||||
pid: existing.pid,
|
pid: existing.pid,
|
||||||
pod_name: existing.pod_name,
|
worker_name: existing.worker_name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
stale_pid = existing.pid,
|
stale_pid = existing.pid,
|
||||||
stale_pod = %existing.pod_name,
|
stale_pod = %existing.worker_name,
|
||||||
"consolidation stale lock detected, taking over"
|
"consolidation stale lock detected, taking over"
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -114,7 +114,7 @@ impl StagingLock {
|
||||||
|
|
||||||
let record = LockRecord {
|
let record = LockRecord {
|
||||||
pid,
|
pid,
|
||||||
pod_name: pod_name.into(),
|
worker_name: worker_name.into(),
|
||||||
started_at: Utc::now(),
|
started_at: Utc::now(),
|
||||||
consumed_ids,
|
consumed_ids,
|
||||||
};
|
};
|
||||||
|
|
@ -213,11 +213,11 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn acquire_writes_lock_file() {
|
fn acquire_writes_lock_file() {
|
||||||
let (_dir, layout) = make_layout();
|
let (_dir, layout) = make_layout();
|
||||||
let lock = StagingLock::acquire(&layout, std::process::id(), "pod", Vec::new()).unwrap();
|
let lock = StagingLock::acquire(&layout, std::process::id(), "worker", Vec::new()).unwrap();
|
||||||
let path = layout.staging_dir().join(LOCK_FILE);
|
let path = layout.staging_dir().join(LOCK_FILE);
|
||||||
assert!(path.exists());
|
assert!(path.exists());
|
||||||
assert_eq!(lock.record().pid, std::process::id());
|
assert_eq!(lock.record().pid, std::process::id());
|
||||||
assert_eq!(lock.record().pod_name, "pod");
|
assert_eq!(lock.record().worker_name, "worker");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -225,8 +225,8 @@ mod tests {
|
||||||
let (_dir, layout) = make_layout();
|
let (_dir, layout) = make_layout();
|
||||||
// Use this test process's pid — it's definitely alive.
|
// Use this test process's pid — it's definitely alive.
|
||||||
let _first =
|
let _first =
|
||||||
StagingLock::acquire(&layout, std::process::id(), "pod-a", Vec::new()).unwrap();
|
StagingLock::acquire(&layout, std::process::id(), "worker-a", Vec::new()).unwrap();
|
||||||
let err = StagingLock::acquire(&layout, std::process::id(), "pod-b", Vec::new())
|
let err = StagingLock::acquire(&layout, std::process::id(), "worker-b", Vec::new())
|
||||||
.expect_err("expected InUse");
|
.expect_err("expected InUse");
|
||||||
assert!(matches!(err, LockError::InUse { .. }));
|
assert!(matches!(err, LockError::InUse { .. }));
|
||||||
}
|
}
|
||||||
|
|
@ -239,7 +239,7 @@ mod tests {
|
||||||
// dead on every platform we target.
|
// dead on every platform we target.
|
||||||
let stale = LockRecord {
|
let stale = LockRecord {
|
||||||
pid: u32::MAX,
|
pid: u32::MAX,
|
||||||
pod_name: "ghost".into(),
|
worker_name: "ghost".into(),
|
||||||
started_at: Utc::now(),
|
started_at: Utc::now(),
|
||||||
consumed_ids: Vec::new(),
|
consumed_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
@ -249,7 +249,7 @@ mod tests {
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let lock = StagingLock::acquire(&layout, std::process::id(), "pod", Vec::new())
|
let lock = StagingLock::acquire(&layout, std::process::id(), "worker", Vec::new())
|
||||||
.expect("stale lock must be overwritable");
|
.expect("stale lock must be overwritable");
|
||||||
assert_eq!(lock.record().pid, std::process::id());
|
assert_eq!(lock.record().pid, std::process::id());
|
||||||
}
|
}
|
||||||
|
|
@ -276,7 +276,7 @@ mod tests {
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let lock = StagingLock::acquire(&layout, std::process::id(), "pod", vec![id_a]).unwrap();
|
let lock = StagingLock::acquire(&layout, std::process::id(), "worker", vec![id_a]).unwrap();
|
||||||
let lock_path = lock.path().to_path_buf();
|
let lock_path = lock.path().to_path_buf();
|
||||||
lock.release_with_cleanup(&layout);
|
lock.release_with_cleanup(&layout);
|
||||||
|
|
||||||
|
|
@ -295,7 +295,8 @@ mod tests {
|
||||||
fn release_is_resilient_to_missing_consumed_entries() {
|
fn release_is_resilient_to_missing_consumed_entries() {
|
||||||
let (_dir, layout) = make_layout();
|
let (_dir, layout) = make_layout();
|
||||||
let phantom = uuid::Uuid::now_v7();
|
let phantom = uuid::Uuid::now_v7();
|
||||||
let lock = StagingLock::acquire(&layout, std::process::id(), "pod", vec![phantom]).unwrap();
|
let lock =
|
||||||
|
StagingLock::acquire(&layout, std::process::id(), "worker", vec![phantom]).unwrap();
|
||||||
let lock_path = lock.path().to_path_buf();
|
let lock_path = lock.path().to_path_buf();
|
||||||
// No file at <staging>/<phantom>.json — release must not panic.
|
// No file at <staging>/<phantom>.json — release must not panic.
|
||||||
lock.release_with_cleanup(&layout);
|
lock.release_with_cleanup(&layout);
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
//!
|
//!
|
||||||
//! extract が staging に残した活動ログを `memory/*` / `knowledge/*` に
|
//! extract が staging に残した活動ログを `memory/*` / `knowledge/*` に
|
||||||
//! 統合し、続けて既存 record を `outdated | superseded | unused | noisy`
|
//! 統合し、続けて既存 record を `outdated | superseded | unused | noisy`
|
||||||
//! の観点で整理する disposable Engine を、Pod 側が組み立てるための
|
//! の観点で整理する disposable Engine を、Worker 側が組み立てるための
|
||||||
//! ヘルパー群を提供する。Pod は次の手順で sub-Engine を構築する:
|
//! ヘルパー群を提供する。Worker は次の手順で sub-Engine を構築する:
|
||||||
//!
|
//!
|
||||||
//! - [`build_consolidate_input`] を sub-Engine の最初の user 入力に
|
//! - [`build_consolidate_input`] を sub-Engine の最初の user 入力に
|
||||||
//! - memory 専用 Tool (read / write / edit) と Knowledge / memory 検索ツールを登録
|
//! - memory 専用 Tool (read / write / edit) と Knowledge / memory 検索ツールを登録
|
||||||
|
|
@ -11,8 +11,8 @@
|
||||||
//! - sub-Engine run 完了後、[`StagingLock::release_with_cleanup`] で
|
//! - sub-Engine run 完了後、[`StagingLock::release_with_cleanup`] で
|
||||||
//! consumed ID 分の staging のみ削除し、占有ファイルを解放
|
//! consumed ID 分の staging のみ削除し、占有ファイルを解放
|
||||||
//!
|
//!
|
||||||
//! system prompt は Pod の `PromptCatalog`
|
//! system prompt は Worker の `PromptCatalog`
|
||||||
//! (`PodPrompt::MemoryConsolidationSystem`) で管理される。Usage report は
|
//! (`WorkerPrompt::MemoryConsolidationSystem`) で管理される。Usage report は
|
||||||
//! 判断材料として渡すだけで、ここでは Knowledge 化や protection の hard decision はしない
|
//! 判断材料として渡すだけで、ここでは Knowledge 化や protection の hard decision はしない
|
||||||
//! (`docs/plan/memory.md` §Consolidation / 整理材料)。
|
//! (`docs/plan/memory.md` §Consolidation / 整理材料)。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//! extract sub-Engine への入力テキスト組み立て。
|
//! extract sub-Engine への入力テキスト組み立て。
|
||||||
//!
|
//!
|
||||||
//! `crates/pod/src/pod.rs::build_summary_prompt` と同じ方針で
|
//! `crates/worker/src/worker.rs::build_summary_prompt` と同じ方針で
|
||||||
//! Item 列を flat な行に落とす(reasoning は省く、tool call は名前のみ、
|
//! Item 列を flat な行に落とす(reasoning は省く、tool call は名前のみ、
|
||||||
//! tool result は summary のみ)。conversation 全体を Markdown の単一
|
//! tool result は summary のみ)。conversation 全体を Markdown の単一
|
||||||
//! セクションとして渡し、抽出指示は system prompt 側に寄せる。
|
//! セクションとして渡し、抽出指示は system prompt 側に寄せる。
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
//! extract: 活動抽出。
|
//! extract: 活動抽出。
|
||||||
//!
|
//!
|
||||||
//! 通常 Pod の post-run hook で発火する disposable Engine と、その
|
//! 通常 Worker の post-run hook で発火する disposable Engine と、その
|
||||||
//! 出力を `<workspace>/.yoi/memory/_staging/<id>.json` に書き出す
|
//! 出力を `<workspace>/.yoi/memory/_staging/<id>.json` に書き出す
|
||||||
//! ヘルパーを提供する。Pod 側はこのモジュールから:
|
//! ヘルパーを提供する。Worker 側はこのモジュールから:
|
||||||
//!
|
//!
|
||||||
//! - [`build_extract_input`] を sub-Engine の最初の user 入力に
|
//! - [`build_extract_input`] を sub-Engine の最初の user 入力に
|
||||||
//! - [`write_extracted_tool`] を唯一のツールとして
|
//! - [`write_extracted_tool`] を唯一のツールとして
|
||||||
//! - [`write_staging`] で受け取った JSON を staging に書き出し
|
//! - [`write_staging`] で受け取った JSON を staging に書き出し
|
||||||
//!
|
//!
|
||||||
//! の順で組み立てる。system prompt は Pod の `PromptCatalog`
|
//! の順で組み立てる。system prompt は Worker の `PromptCatalog`
|
||||||
//! (`PodPrompt::MemoryExtractSystem`) で管理される。pointer 永続化
|
//! (`WorkerPrompt::MemoryExtractSystem`) で管理される。pointer 永続化
|
||||||
//! (session-store の `LogEntry::Extension`、domain `"memory.extract"`)は
|
//! (session-store の `LogEntry::Extension`、domain `"memory.extract"`)は
|
||||||
//! Pod 側が責務を持つ。
|
//! Worker 側が責務を持つ。
|
||||||
//!
|
//!
|
||||||
//! 出力 JSON の wrap は [`write_staging`] が `source: { segment_id, range }`
|
//! 出力 JSON の wrap は [`write_staging`] が `source: { segment_id, range }`
|
||||||
//! を機械付与する形で担当し、LLM には source を推論させない。
|
//! を機械付与する形で担当し、LLM には source を推論させない。
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//! extract 抽出の出力 schema。
|
//! extract 抽出の出力 schema。
|
||||||
//!
|
//!
|
||||||
//! LLM は [`ExtractedPayload`] そのもの(source 抜き)を返し、Pod 側
|
//! LLM は [`ExtractedPayload`] そのもの(source 抜き)を返し、Worker 側
|
||||||
//! ラッパーが [`StagingRecord`] に組み立てて staging へ書き出す。
|
//! ラッパーが [`StagingRecord`] に組み立てて staging へ書き出す。
|
||||||
//! source は機械付与する契約 (`docs/plan/memory.md` §Extract)。
|
//! source は機械付与する契約 (`docs/plan/memory.md` §Extract)。
|
||||||
|
|
||||||
|
|
@ -78,7 +78,7 @@ pub struct RequestEntry {
|
||||||
|
|
||||||
/// staging に書き出される 1 ファイル分のレコード。
|
/// staging に書き出される 1 ファイル分のレコード。
|
||||||
///
|
///
|
||||||
/// `source` は Pod 側ラッパーが segment_id と log entry range を
|
/// `source` は Worker 側ラッパーが segment_id と log entry range を
|
||||||
/// 機械付与する。LLM はこのフィールドを見ない / 推論しない。
|
/// 機械付与する。LLM はこのフィールドを見ない / 推論しない。
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct StagingRecord {
|
pub struct StagingRecord {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//! `LogEntry::Extension { domain: "memory.extract", payload }` の payload 形式と
|
//! `LogEntry::Extension { domain: "memory.extract", payload }` の payload 形式と
|
||||||
//! restore 時の fold ヘルパー。memory crate がドメインを所有するので、
|
//! restore 時の fold ヘルパー。memory crate がドメインを所有するので、
|
||||||
//! session-store / Pod は payload 構造を知らない。
|
//! session-store / Worker は payload 構造を知らない。
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
//!
|
//!
|
||||||
//! sub-Engine からは extract worker が出した [`ExtractedPayload`] を
|
//! sub-Engine からは extract worker が出した [`ExtractedPayload`] を
|
||||||
//! 受け取って `Mutex` 越しに [`ExtractWorkerContext`] に置くだけ。
|
//! 受け取って `Mutex` 越しに [`ExtractWorkerContext`] に置くだけ。
|
||||||
//! Pod 側はランループ完了後に `take_payload()` で取り出して
|
//! Worker 側はランループ完了後に `take_payload()` で取り出して
|
||||||
//! [`super::staging::write_staging`] に渡す。
|
//! [`super::staging::write_staging`] に渡す。
|
||||||
|
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
@ -22,7 +22,7 @@ the wrapper attaches provenance mechanically.";
|
||||||
pub struct ExtractWorkerContext {
|
pub struct ExtractWorkerContext {
|
||||||
payload: Mutex<Option<ExtractedPayload>>,
|
payload: Mutex<Option<ExtractedPayload>>,
|
||||||
/// `write_extracted` が複数回呼ばれた回数(debug 用)。
|
/// `write_extracted` が複数回呼ばれた回数(debug 用)。
|
||||||
/// 後勝ちで上書きするが、Pod 側で warn を出したい場合に参照する。
|
/// 後勝ちで上書きするが、Worker 側で warn を出したい場合に参照する。
|
||||||
call_count: Mutex<usize>,
|
call_count: Mutex<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -31,7 +31,7 @@ impl ExtractWorkerContext {
|
||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// sub-Engine 終了後に Pod が呼んで payload を取り出す。
|
/// sub-Engine 終了後に Worker が呼んで payload を取り出す。
|
||||||
/// 一度も `write_extracted` が呼ばれなければ `None`。
|
/// 一度も `write_extracted` が呼ばれなければ `None`。
|
||||||
pub fn take_payload(&self) -> Option<ExtractedPayload> {
|
pub fn take_payload(&self) -> Option<ExtractedPayload> {
|
||||||
self.payload
|
self.payload
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
//! Self-contained: provides its own Tool implementations (read/write/edit)
|
//! Self-contained: provides its own Tool implementations (read/write/edit)
|
||||||
//! that target `<workspace>/memory/` and `<workspace>/knowledge/` only,
|
//! that target `<workspace>/memory/` and `<workspace>/knowledge/` only,
|
||||||
//! with a pre-write Linter built in. Generic CRUD tools (in the `tools`
|
//! with a pre-write Linter built in. Generic CRUD tools (in the `tools`
|
||||||
//! crate) must not touch these directories — Pod is responsible for
|
//! crate) must not touch these directories — Worker is responsible for
|
||||||
//! denying them at the Scope level when memory is enabled.
|
//! denying them at the Scope level when memory is enabled.
|
||||||
|
|
||||||
pub mod audit;
|
pub mod audit;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//! Workspace memory resident-enumeration helpers.
|
//! Workspace memory resident-enumeration helpers.
|
||||||
//!
|
//!
|
||||||
//! Surfaces used by the Pod system-prompt assembler:
|
//! Surfaces used by the Worker system-prompt assembler:
|
||||||
//!
|
//!
|
||||||
//! - [`collect_resident_knowledge`] — resident-injection candidates
|
//! - [`collect_resident_knowledge`] — resident-injection candidates
|
||||||
//! (`model_invokation: true`) returned as `(slug, description)` pairs.
|
//! (`model_invokation: true`) returned as `(slug, description)` pairs.
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
//! `<workspace>/.yoi/memory/summary.md` when it parses as a summary
|
//! `<workspace>/.yoi/memory/summary.md` when it parses as a summary
|
||||||
//! record and has non-empty body.
|
//! record and has non-empty body.
|
||||||
//! - [`list_knowledge_slugs`] — every slug whose file parses, regardless
|
//! - [`list_knowledge_slugs`] — every slug whose file parses, regardless
|
||||||
//! of `model_invokation`. Used by the Pod IPC layer to answer TUI `#`
|
//! of `model_invokation`. Used by the Worker IPC layer to answer TUI `#`
|
||||||
//! completion (`model_invokation` is a resident-injection flag, not a
|
//! completion (`model_invokation` is a resident-injection flag, not a
|
||||||
//! user-visibility flag).
|
//! user-visibility flag).
|
||||||
//!
|
//!
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
//! Helpers for constructing `ScopeRule` entries that exclude the
|
//! Helpers for constructing `ScopeRule` entries that exclude the
|
||||||
//! memory tree from the generic CRUD tools' write surface.
|
//! memory tree from the generic CRUD tools' write surface.
|
||||||
//!
|
//!
|
||||||
//! Pod is expected to call [`deny_write_rules`] when memory is enabled
|
//! Worker is expected to call [`deny_write_rules`] when memory is enabled
|
||||||
//! and append the result to the manifest's `scope.deny` list before
|
//! and append the result to the manifest's `scope.deny` list before
|
||||||
//! constructing the [`Scope`] passed to `tools::ScopedFs`. The memory
|
//! constructing the [`Scope`] passed to `tools::ScopedFs`. The memory
|
||||||
//! tools themselves bypass `ScopedFs` and write directly under the
|
//! tools themselves bypass `ScopedFs` and write directly under the
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
//! `.yoi/workflow/`.
|
//! `.yoi/workflow/`.
|
||||||
//!
|
//!
|
||||||
//! `memory.workspace_root` pins this root explicitly. Without an explicit
|
//! `memory.workspace_root` pins this root explicitly. Without an explicit
|
||||||
//! root, resolution searches upward from the Pod pwd for a `.yoi/memory`
|
//! root, resolution searches upward from the Worker pwd for a `.yoi/memory`
|
||||||
//! marker; `.yoi` project records alone are not a memory marker.
|
//! marker; `.yoi` project records alone are not a memory marker.
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
|
||||||
|
|
@ -177,8 +177,8 @@ fn pdk_runtime_dependencies_are_guest_side_only() {
|
||||||
.as_table()
|
.as_table()
|
||||||
.expect("dependencies table");
|
.expect("dependencies table");
|
||||||
let forbidden = [
|
let forbidden = [
|
||||||
"pod",
|
"worker",
|
||||||
"yoi-pod",
|
"yoi-worker",
|
||||||
"llm-engine",
|
"llm-engine",
|
||||||
"tui",
|
"tui",
|
||||||
"yoi-tui",
|
"yoi-tui",
|
||||||
|
|
|
||||||
|
|
@ -2,29 +2,29 @@
|
||||||
|
|
||||||
## Role
|
## Role
|
||||||
|
|
||||||
`pod-registry` tracks live Pod process ownership and delegated scope locks at runtime.
|
`pod-registry` is the legacy-named crate that tracks live Worker process ownership and delegated scope locks at runtime.
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
Owns:
|
Owns:
|
||||||
|
|
||||||
- machine-local live Pod registration
|
- machine-local live Worker registration
|
||||||
- collision detection for running Pod names
|
- collision detection for running Worker names
|
||||||
- delegated scope lock bookkeeping
|
- delegated scope lock bookkeeping
|
||||||
- registry cleanup hooks for stopped or unreachable children
|
- registry cleanup hooks for stopped or unreachable children
|
||||||
|
|
||||||
Does not own:
|
Does not own:
|
||||||
|
|
||||||
- durable Pod metadata (`pod-store`)
|
- durable Worker metadata (`pod-store`)
|
||||||
- replayable session logs (`session-store`)
|
- replayable session logs (`session-store`)
|
||||||
- socket protocol definitions (`protocol`)
|
- socket protocol definitions (`protocol`)
|
||||||
- project work item state
|
- project work item state
|
||||||
|
|
||||||
## Design notes
|
## Design notes
|
||||||
|
|
||||||
The registry is a runtime coordination mechanism. It can help decide whether a Pod is live or colliding, but durable visibility/restoration should be backed by Pod metadata when possible.
|
The registry is a runtime coordination mechanism. It can help decide whether a Worker is live or colliding, but durable visibility/restoration should be backed by Worker metadata when possible.
|
||||||
|
|
||||||
## See also
|
## See also
|
||||||
|
|
||||||
- [`../../docs/design/pod-session-state.md`](../../docs/design/pod-session-state.md)
|
- [`../../docs/design/worker-session-state.md`](../../docs/design/worker-session-state.md)
|
||||||
- [`../../docs/design/tool-permissions-scope.md`](../../docs/design/tool-permissions-scope.md)
|
- [`../../docs/design/tool-permissions-scope.md`](../../docs/design/tool-permissions-scope.md)
|
||||||
|
|
|
||||||
|
|
@ -80,18 +80,18 @@ pub fn is_within_effective_write(lock: &LockFile, parent: &str, rule: &ScopeRule
|
||||||
!child_conflict
|
!child_conflict
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The Pod and rule that actually own a conflicting write scope.
|
/// The Worker and rule that actually own a conflicting write scope.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ConflictOwner {
|
pub struct ConflictOwner {
|
||||||
pub pod_name: String,
|
pub worker_name: String,
|
||||||
pub rule: ScopeRule,
|
pub rule: ScopeRule,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find the Pod/rule that actually owns a write scope overlapping `rule`.
|
/// Find the Worker/rule that actually owns a write scope overlapping `rule`.
|
||||||
///
|
///
|
||||||
/// Walks the delegation tree: if an allocation overlaps `rule`, we
|
/// Walks the delegation tree: if an allocation overlaps `rule`, we
|
||||||
/// descend into its children and return the deepest overlapping node
|
/// descend into its children and return the deepest overlapping node
|
||||||
/// as the true owner. `exempt` names a Pod whose ownership is
|
/// as the true owner. `exempt` names a Worker whose ownership is
|
||||||
/// permitted (used during delegation: the spawner itself is allowed
|
/// permitted (used during delegation: the spawner itself is allowed
|
||||||
/// to still own the rule's region because it is handing it down).
|
/// to still own the rule's region because it is handing it down).
|
||||||
pub fn find_conflict_owner(
|
pub fn find_conflict_owner(
|
||||||
|
|
@ -115,7 +115,7 @@ pub fn find_conflict_owners(
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|a| a.delegated_from.is_none())
|
.filter(|a| a.delegated_from.is_none())
|
||||||
.filter_map(|alloc| find_conflict_in_subtree(lock, alloc, rule))
|
.filter_map(|alloc| find_conflict_in_subtree(lock, alloc, rule))
|
||||||
.filter(|owner| Some(owner.pod_name.as_str()) != exempt)
|
.filter(|owner| Some(owner.worker_name.as_str()) != exempt)
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,14 +142,14 @@ fn find_conflict_in_subtree(
|
||||||
for child in lock
|
for child in lock
|
||||||
.allocations
|
.allocations
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|a| a.delegated_from.as_deref() == Some(alloc.pod_name.as_str()))
|
.filter(|a| a.delegated_from.as_deref() == Some(alloc.worker_name.as_str()))
|
||||||
{
|
{
|
||||||
if let Some(owner) = find_conflict_in_subtree(lock, child, rule) {
|
if let Some(owner) = find_conflict_in_subtree(lock, child, rule) {
|
||||||
return Some(owner);
|
return Some(owner);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(ConflictOwner {
|
Some(ConflictOwner {
|
||||||
pod_name: alloc.pod_name.clone(),
|
worker_name: alloc.worker_name.clone(),
|
||||||
rule: overlapping_rule.clone(),
|
rule: overlapping_rule.clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -158,7 +158,7 @@ fn find_conflict_in_subtree(
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::test_util::*;
|
use crate::test_util::*;
|
||||||
use crate::{ScopeLockError, delegate_scope, register_pod, register_pod_with_deny};
|
use crate::{ScopeLockError, delegate_scope, register_pod, register_worker_with_deny};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -192,9 +192,9 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn conflict_detection_descends_to_real_owner() {
|
fn conflict_detection_descends_to_real_owner() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let path = dir.path().join("pods.json");
|
let path = dir.path().join("workers.json");
|
||||||
let mut g = open_empty(&path);
|
let mut g = open_empty(&path);
|
||||||
register_pod(
|
register_worker(
|
||||||
&mut g,
|
&mut g,
|
||||||
"a".into(),
|
"a".into(),
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
|
|
@ -213,9 +213,9 @@ mod tests {
|
||||||
&delegation_scope(vec![write_rule("/src", true)]),
|
&delegation_scope(vec![write_rule("/src", true)]),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// A different top-level Pod trying to register /src/core/x
|
// A different top-level Worker trying to register /src/core/x
|
||||||
// should be blamed on B (deepest owner), not A.
|
// should be blamed on B (deepest owner), not A.
|
||||||
let err = register_pod(
|
let err = register_worker(
|
||||||
&mut g,
|
&mut g,
|
||||||
"x".into(),
|
"x".into(),
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
|
|
@ -233,9 +233,9 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn denied_write_region_is_not_claimed_by_restored_parent() {
|
fn denied_write_region_is_not_claimed_by_restored_parent() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let path = dir.path().join("pods.json");
|
let path = dir.path().join("workers.json");
|
||||||
let mut g = open_empty(&path);
|
let mut g = open_empty(&path);
|
||||||
register_pod_with_deny(
|
register_worker_with_deny(
|
||||||
&mut g,
|
&mut g,
|
||||||
"parent".into(),
|
"parent".into(),
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
|
|
@ -245,7 +245,7 @@ mod tests {
|
||||||
sid(),
|
sid(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
register_pod(
|
register_worker(
|
||||||
&mut g,
|
&mut g,
|
||||||
"child".into(),
|
"child".into(),
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
|
|
@ -259,9 +259,9 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn partial_deny_does_not_hide_parent_conflict() {
|
fn partial_deny_does_not_hide_parent_conflict() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let path = dir.path().join("pods.json");
|
let path = dir.path().join("workers.json");
|
||||||
let mut g = open_empty(&path);
|
let mut g = open_empty(&path);
|
||||||
register_pod_with_deny(
|
register_worker_with_deny(
|
||||||
&mut g,
|
&mut g,
|
||||||
"parent".into(),
|
"parent".into(),
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
|
|
@ -272,7 +272,7 @@ mod tests {
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let err = register_pod(
|
let err = register_worker(
|
||||||
&mut g,
|
&mut g,
|
||||||
"other".into(),
|
"other".into(),
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,10 @@ use session_store::SegmentId;
|
||||||
/// Errors raised by the mutating pod-registry operations.
|
/// Errors raised by the mutating pod-registry operations.
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum ScopeLockError {
|
pub enum ScopeLockError {
|
||||||
#[error("I/O error on pods.json: {0}")]
|
#[error("I/O error on workers.json: {0}")]
|
||||||
Io(#[from] io::Error),
|
Io(#[from] io::Error),
|
||||||
#[error("pod name `{0}` is already registered")]
|
#[error("pod name `{0}` is already registered")]
|
||||||
DuplicatePodName(String),
|
DuplicateWorkerName(String),
|
||||||
#[error("requested scope `{}` conflicts with pod `{competitor}` rule `{}`", .rule.target.display(), .competitor_rule.target.display())]
|
#[error("requested scope `{}` conflicts with pod `{competitor}` rule `{}`", .rule.target.display(), .competitor_rule.target.display())]
|
||||||
WriteConflict {
|
WriteConflict {
|
||||||
competitor: String,
|
competitor: String,
|
||||||
|
|
@ -27,14 +27,14 @@ pub enum ScopeLockError {
|
||||||
#[error("invalid delegation scope: {source}")]
|
#[error("invalid delegation scope: {source}")]
|
||||||
InvalidScope { source: ScopeError },
|
InvalidScope { source: ScopeError },
|
||||||
#[error("pod `{0}` is not registered")]
|
#[error("pod `{0}` is not registered")]
|
||||||
UnknownPod(String),
|
UnknownWorker(String),
|
||||||
#[error(
|
#[error(
|
||||||
"session {segment_id} is already held by pod `{pod_name}` at {}",
|
"session {segment_id} is already held by pod `{worker_name}` at {}",
|
||||||
.socket.display()
|
.socket.display()
|
||||||
)]
|
)]
|
||||||
SegmentConflict {
|
SegmentConflict {
|
||||||
segment_id: SegmentId,
|
segment_id: SegmentId,
|
||||||
pod_name: String,
|
worker_name: String,
|
||||||
socket: PathBuf,
|
socket: PathBuf,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
//! Machine-wide Pod allocation registry.
|
//! Machine-wide Worker allocation registry.
|
||||||
//!
|
//!
|
||||||
//! A single JSON file at `<runtime_dir>/pods.json` records every live
|
//! A single JSON file at `<runtime_dir>/workers.json` records every live
|
||||||
//! Pod's allocation (see [`manifest::paths::pod_registry_path`] for
|
//! Worker's allocation (see [`manifest::paths::pod_registry_path`] for
|
||||||
//! how the path is resolved). File-level `flock(2)` serialises access
|
//! how the path is resolved). File-level `flock(2)` serialises access
|
||||||
//! across processes so spawn sequences from unrelated Pods can't race.
|
//! across processes so spawn sequences from unrelated Workers can't race.
|
||||||
//!
|
//!
|
||||||
//! Each Pod, when starting, acquires the lock, reclaims stale entries
|
//! Each Worker, when starting, acquires the lock, reclaims stale entries
|
||||||
//! (Pods whose PID has died), checks that its requested write scope
|
//! (Workers whose PID has died), checks that its requested write scope
|
||||||
//! does not overlap any other allocation's effective write scope, and
|
//! does not overlap any other allocation's effective write scope, and
|
||||||
//! registers itself. When it exits normally, it removes its entry and
|
//! registers itself. When it exits normally, it removes its entry and
|
||||||
//! returns delegated scope to its `delegated_from` parent. Crash
|
//! returns delegated scope to its `delegated_from` parent. Crash
|
||||||
//! recovery rides on the next Pod that opens the file — no background
|
//! recovery rides on the next Worker that opens the file — no background
|
||||||
//! reaper.
|
//! reaper.
|
||||||
|
|
||||||
mod conflict;
|
mod conflict;
|
||||||
|
|
@ -31,7 +31,7 @@ pub use lifecycle::{
|
||||||
install_top_level_with_deny, lookup_segment, update_segment,
|
install_top_level_with_deny, lookup_segment, update_segment,
|
||||||
};
|
};
|
||||||
pub use mutate::{
|
pub use mutate::{
|
||||||
delegate_scope, reclaim_delegated_scope, reclaim_stale, reclaim_stale_with, register_pod,
|
delegate_scope, reclaim_delegated_scope, reclaim_stale, reclaim_stale_with, register_worker,
|
||||||
register_pod_with_deny, release_pod,
|
register_worker_with_deny, release_worker,
|
||||||
};
|
};
|
||||||
pub use table::{Allocation, LockFile, LockFileGuard, default_registry_path};
|
pub use table::{Allocation, LockFile, LockFileGuard, default_registry_path};
|
||||||
|
|
|
||||||
|
|
@ -8,21 +8,21 @@ use manifest::ScopeRule;
|
||||||
use session_store::SegmentId;
|
use session_store::SegmentId;
|
||||||
|
|
||||||
use crate::error::ScopeLockError;
|
use crate::error::ScopeLockError;
|
||||||
use crate::mutate::release_pod;
|
use crate::mutate::release_worker;
|
||||||
use crate::table::{LockFileGuard, default_registry_path};
|
use crate::table::{LockFileGuard, default_registry_path};
|
||||||
|
|
||||||
/// Owned allocation: on drop, opens the lock file and releases this
|
/// Owned allocation: on drop, opens the lock file and releases this
|
||||||
/// Pod's entry. The guard keeps only the name + lock-file path; it
|
/// Worker's entry. The guard keeps only the name + lock-file path; it
|
||||||
/// does not hold the `flock` for the Pod's lifetime.
|
/// does not hold the `flock` for the Worker's lifetime.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ScopeAllocationGuard {
|
pub struct ScopeAllocationGuard {
|
||||||
pod_name: String,
|
worker_name: String,
|
||||||
lock_path: PathBuf,
|
lock_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ScopeAllocationGuard {
|
impl ScopeAllocationGuard {
|
||||||
pub fn pod_name(&self) -> &str {
|
pub fn worker_name(&self) -> &str {
|
||||||
&self.pod_name
|
&self.worker_name
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn lock_path(&self) -> &Path {
|
pub fn lock_path(&self) -> &Path {
|
||||||
|
|
@ -33,28 +33,35 @@ impl ScopeAllocationGuard {
|
||||||
impl Drop for ScopeAllocationGuard {
|
impl Drop for ScopeAllocationGuard {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
if let Ok(mut guard) = LockFileGuard::open(&self.lock_path) {
|
if let Ok(mut guard) = LockFileGuard::open(&self.lock_path) {
|
||||||
let _ = release_pod(&mut guard, &self.pod_name);
|
let _ = release_worker(&mut guard, &self.worker_name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the default lock file, register a top-level Pod, and return a
|
/// Open the default lock file, register a top-level Worker, and return a
|
||||||
/// guard that will release the allocation on drop.
|
/// guard that will release the allocation on drop.
|
||||||
pub fn install_top_level(
|
pub fn install_top_level(
|
||||||
pod_name: String,
|
worker_name: String,
|
||||||
pid: u32,
|
pid: u32,
|
||||||
socket: PathBuf,
|
socket: PathBuf,
|
||||||
scope_allow: Vec<ScopeRule>,
|
scope_allow: Vec<ScopeRule>,
|
||||||
segment_id: SegmentId,
|
segment_id: SegmentId,
|
||||||
) -> Result<ScopeAllocationGuard, ScopeLockError> {
|
) -> Result<ScopeAllocationGuard, ScopeLockError> {
|
||||||
install_top_level_with_deny(pod_name, pid, socket, scope_allow, Vec::new(), segment_id)
|
install_top_level_with_deny(
|
||||||
|
worker_name,
|
||||||
|
pid,
|
||||||
|
socket,
|
||||||
|
scope_allow,
|
||||||
|
Vec::new(),
|
||||||
|
segment_id,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the default lock file, register a top-level Pod with explicit
|
/// Open the default lock file, register a top-level Worker with explicit
|
||||||
/// deny rules, and return a guard that will release the allocation on
|
/// deny rules, and return a guard that will release the allocation on
|
||||||
/// drop.
|
/// drop.
|
||||||
pub fn install_top_level_with_deny(
|
pub fn install_top_level_with_deny(
|
||||||
pod_name: String,
|
worker_name: String,
|
||||||
pid: u32,
|
pid: u32,
|
||||||
socket: PathBuf,
|
socket: PathBuf,
|
||||||
scope_allow: Vec<ScopeRule>,
|
scope_allow: Vec<ScopeRule>,
|
||||||
|
|
@ -63,9 +70,9 @@ pub fn install_top_level_with_deny(
|
||||||
) -> Result<ScopeAllocationGuard, ScopeLockError> {
|
) -> Result<ScopeAllocationGuard, ScopeLockError> {
|
||||||
let lock_path = default_registry_path()?;
|
let lock_path = default_registry_path()?;
|
||||||
let mut guard = LockFileGuard::open(&lock_path)?;
|
let mut guard = LockFileGuard::open(&lock_path)?;
|
||||||
crate::mutate::register_pod_with_deny(
|
crate::mutate::register_worker_with_deny(
|
||||||
&mut guard,
|
&mut guard,
|
||||||
pod_name.clone(),
|
worker_name.clone(),
|
||||||
pid,
|
pid,
|
||||||
socket,
|
socket,
|
||||||
scope_allow,
|
scope_allow,
|
||||||
|
|
@ -73,13 +80,13 @@ pub fn install_top_level_with_deny(
|
||||||
segment_id,
|
segment_id,
|
||||||
)?;
|
)?;
|
||||||
Ok(ScopeAllocationGuard {
|
Ok(ScopeAllocationGuard {
|
||||||
pod_name,
|
worker_name,
|
||||||
lock_path,
|
lock_path,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Take ownership of an existing allocation that was pre-registered by
|
/// Take ownership of an existing allocation that was pre-registered by
|
||||||
/// a spawning Pod.
|
/// a spawning Worker.
|
||||||
///
|
///
|
||||||
/// The spawning flow is two-stage: the spawner calls
|
/// The spawning flow is two-stage: the spawner calls
|
||||||
/// [`crate::delegate_scope`] (with its own pid as a live placeholder,
|
/// [`crate::delegate_scope`] (with its own pid as a live placeholder,
|
||||||
|
|
@ -88,7 +95,7 @@ pub fn install_top_level_with_deny(
|
||||||
/// segment_id to its own and claim the [`ScopeAllocationGuard`] so
|
/// segment_id to its own and claim the [`ScopeAllocationGuard`] so
|
||||||
/// the entry is released when the child exits.
|
/// the entry is released when the child exits.
|
||||||
pub fn adopt_allocation(
|
pub fn adopt_allocation(
|
||||||
pod_name: String,
|
worker_name: String,
|
||||||
new_pid: u32,
|
new_pid: u32,
|
||||||
segment_id: SegmentId,
|
segment_id: SegmentId,
|
||||||
) -> Result<ScopeAllocationGuard, ScopeLockError> {
|
) -> Result<ScopeAllocationGuard, ScopeLockError> {
|
||||||
|
|
@ -96,24 +103,24 @@ pub fn adopt_allocation(
|
||||||
let mut guard = LockFileGuard::open(&lock_path)?;
|
let mut guard = LockFileGuard::open(&lock_path)?;
|
||||||
let alloc = guard
|
let alloc = guard
|
||||||
.data_mut()
|
.data_mut()
|
||||||
.find_mut(&pod_name)
|
.find_mut(&worker_name)
|
||||||
.ok_or_else(|| ScopeLockError::UnknownPod(pod_name.clone()))?;
|
.ok_or_else(|| ScopeLockError::UnknownWorker(worker_name.clone()))?;
|
||||||
alloc.pid = new_pid;
|
alloc.pid = new_pid;
|
||||||
alloc.segment_id = Some(segment_id);
|
alloc.segment_id = Some(segment_id);
|
||||||
guard.save()?;
|
guard.save()?;
|
||||||
Ok(ScopeAllocationGuard {
|
Ok(ScopeAllocationGuard {
|
||||||
pod_name,
|
worker_name,
|
||||||
lock_path,
|
lock_path,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rewrite the `segment_id` recorded for `pod_name` to
|
/// Rewrite the `segment_id` recorded for `worker_name` to
|
||||||
/// `new_segment_id`.
|
/// `new_segment_id`.
|
||||||
///
|
///
|
||||||
/// The Pod's in-memory `segment_id` can change underneath the
|
/// The Worker's in-memory `segment_id` can change underneath the
|
||||||
/// allocation in two normal places:
|
/// allocation in two normal places:
|
||||||
///
|
///
|
||||||
/// - `Pod::compact` mints a fresh session and swaps it in.
|
/// - `Worker::compact` mints a fresh session and swaps it in.
|
||||||
/// - `session_store::ensure_head_or_fork` auto-forks when another
|
/// - `session_store::ensure_head_or_fork` auto-forks when another
|
||||||
/// writer has advanced the store head behind our back.
|
/// writer has advanced the store head behind our back.
|
||||||
///
|
///
|
||||||
|
|
@ -121,37 +128,37 @@ pub fn adopt_allocation(
|
||||||
/// find the live session id, not the old one. Without this update a
|
/// find the live session id, not the old one. Without this update a
|
||||||
/// concurrent `restore_from_manifest(new_id)` would see "no live
|
/// concurrent `restore_from_manifest(new_id)` would see "no live
|
||||||
/// writer" and proceed to register a competing allocation on the
|
/// writer" and proceed to register a competing allocation on the
|
||||||
/// session this Pod just moved into.
|
/// session this Worker just moved into.
|
||||||
///
|
///
|
||||||
/// The lock is opened once and the allocation is rewritten inside the
|
/// The lock is opened once and the allocation is rewritten inside the
|
||||||
/// guard, so the segment_id collision check is atomic with the
|
/// guard, so the segment_id collision check is atomic with the
|
||||||
/// rewrite.
|
/// rewrite.
|
||||||
pub fn update_segment(pod_name: &str, new_segment_id: SegmentId) -> Result<(), ScopeLockError> {
|
pub fn update_segment(worker_name: &str, new_segment_id: SegmentId) -> Result<(), ScopeLockError> {
|
||||||
let lock_path = default_registry_path()?;
|
let lock_path = default_registry_path()?;
|
||||||
let mut guard = LockFileGuard::open(&lock_path)?;
|
let mut guard = LockFileGuard::open(&lock_path)?;
|
||||||
if let Some(other) = guard.data().find_by_segment(new_segment_id) {
|
if let Some(other) = guard.data().find_by_segment(new_segment_id) {
|
||||||
if other.pod_name != pod_name {
|
if other.worker_name != worker_name {
|
||||||
return Err(ScopeLockError::SegmentConflict {
|
return Err(ScopeLockError::SegmentConflict {
|
||||||
segment_id: new_segment_id,
|
segment_id: new_segment_id,
|
||||||
pod_name: other.pod_name.clone(),
|
worker_name: other.worker_name.clone(),
|
||||||
socket: other.socket.clone(),
|
socket: other.socket.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let alloc = guard
|
let alloc = guard
|
||||||
.data_mut()
|
.data_mut()
|
||||||
.find_mut(pod_name)
|
.find_mut(worker_name)
|
||||||
.ok_or_else(|| ScopeLockError::UnknownPod(pod_name.into()))?;
|
.ok_or_else(|| ScopeLockError::UnknownWorker(worker_name.into()))?;
|
||||||
alloc.segment_id = Some(new_segment_id);
|
alloc.segment_id = Some(new_segment_id);
|
||||||
guard.save()?;
|
guard.save()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Information about a Pod that currently holds an allocation for a
|
/// Information about a Worker that currently holds an allocation for a
|
||||||
/// given session.
|
/// given session.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SegmentLockInfo {
|
pub struct SegmentLockInfo {
|
||||||
pub pod_name: String,
|
pub worker_name: String,
|
||||||
pub socket: PathBuf,
|
pub socket: PathBuf,
|
||||||
pub pid: u32,
|
pub pid: u32,
|
||||||
}
|
}
|
||||||
|
|
@ -159,7 +166,7 @@ pub struct SegmentLockInfo {
|
||||||
/// Open the default lock file, reclaim stale entries, and return the
|
/// Open the default lock file, reclaim stale entries, and return the
|
||||||
/// allocation currently writing to `segment_id`, if any.
|
/// allocation currently writing to `segment_id`, if any.
|
||||||
///
|
///
|
||||||
/// Used by `Pod::restore_from_manifest` to refuse a resume that would
|
/// Used by `Worker::restore_from_manifest` to refuse a resume that would
|
||||||
/// race a live writer on the same source session.
|
/// race a live writer on the same source session.
|
||||||
pub fn lookup_segment(segment_id: SegmentId) -> Result<Option<SegmentLockInfo>, ScopeLockError> {
|
pub fn lookup_segment(segment_id: SegmentId) -> Result<Option<SegmentLockInfo>, ScopeLockError> {
|
||||||
let lock_path = default_registry_path()?;
|
let lock_path = default_registry_path()?;
|
||||||
|
|
@ -169,7 +176,7 @@ pub fn lookup_segment(segment_id: SegmentId) -> Result<Option<SegmentLockInfo>,
|
||||||
.data()
|
.data()
|
||||||
.find_by_segment(segment_id)
|
.find_by_segment(segment_id)
|
||||||
.map(|a| SegmentLockInfo {
|
.map(|a| SegmentLockInfo {
|
||||||
pod_name: a.pod_name.clone(),
|
worker_name: a.worker_name.clone(),
|
||||||
socket: a.socket.clone(),
|
socket: a.socket.clone(),
|
||||||
pid: a.pid,
|
pid: a.pid,
|
||||||
}))
|
}))
|
||||||
|
|
@ -185,11 +192,11 @@ mod tests {
|
||||||
/// Mimic what the spawner does before the child comes up: push an
|
/// Mimic what the spawner does before the child comes up: push an
|
||||||
/// allocation for the child carrying the spawner's (live) pid as a
|
/// allocation for the child carrying the spawner's (live) pid as a
|
||||||
/// placeholder. Exists only in tests.
|
/// placeholder. Exists only in tests.
|
||||||
fn delegate_placeholder(g: &mut LockFileGuard, pod_name: &str, placeholder_pid: u32) {
|
fn delegate_placeholder(g: &mut LockFileGuard, worker_name: &str, placeholder_pid: u32) {
|
||||||
g.data_mut().allocations.push(Allocation {
|
g.data_mut().allocations.push(Allocation {
|
||||||
pod_name: pod_name.to_string(),
|
worker_name: worker_name.to_string(),
|
||||||
pid: placeholder_pid,
|
pid: placeholder_pid,
|
||||||
socket: sock(pod_name),
|
socket: sock(worker_name),
|
||||||
scope_allow: vec![write_rule("/tmp/child", true)],
|
scope_allow: vec![write_rule("/tmp/child", true)],
|
||||||
scope_deny: Vec::new(),
|
scope_deny: Vec::new(),
|
||||||
delegated_from: None,
|
delegated_from: None,
|
||||||
|
|
@ -202,7 +209,7 @@ mod tests {
|
||||||
fn scope_allocation_guard_releases_on_drop() {
|
fn scope_allocation_guard_releases_on_drop() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let _sandbox = RuntimeDirSandbox::new(dir.path());
|
let _sandbox = RuntimeDirSandbox::new(dir.path());
|
||||||
let lock_path = dir.path().join("pods.json");
|
let lock_path = dir.path().join("workers.json");
|
||||||
let guard = install_top_level(
|
let guard = install_top_level(
|
||||||
"a".into(),
|
"a".into(),
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
|
|
@ -226,7 +233,7 @@ mod tests {
|
||||||
fn adopt_allocation_rewrites_pid_and_releases_on_drop() {
|
fn adopt_allocation_rewrites_pid_and_releases_on_drop() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let _sandbox = RuntimeDirSandbox::new(dir.path());
|
let _sandbox = RuntimeDirSandbox::new(dir.path());
|
||||||
let lock_path = dir.path().join("pods.json");
|
let lock_path = dir.path().join("workers.json");
|
||||||
// Pre-register an allocation under spawner's pid, as delegate_scope would.
|
// Pre-register an allocation under spawner's pid, as delegate_scope would.
|
||||||
{
|
{
|
||||||
let mut g = LockFileGuard::open(&lock_path).unwrap();
|
let mut g = LockFileGuard::open(&lock_path).unwrap();
|
||||||
|
|
@ -251,7 +258,7 @@ mod tests {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let _sandbox = RuntimeDirSandbox::new(dir.path());
|
let _sandbox = RuntimeDirSandbox::new(dir.path());
|
||||||
let err = adopt_allocation("ghost".into(), 42, sid()).unwrap_err();
|
let err = adopt_allocation("ghost".into(), 42, sid()).unwrap_err();
|
||||||
assert!(matches!(err, ScopeLockError::UnknownPod(ref n) if n == "ghost"));
|
assert!(matches!(err, ScopeLockError::UnknownWorker(ref n) if n == "ghost"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -268,7 +275,7 @@ mod tests {
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let info = lookup_segment(s).unwrap().expect("expected live writer");
|
let info = lookup_segment(s).unwrap().expect("expected live writer");
|
||||||
assert_eq!(info.pod_name, "live");
|
assert_eq!(info.worker_name, "live");
|
||||||
assert_eq!(info.socket, sock("live"));
|
assert_eq!(info.socket, sock("live"));
|
||||||
drop(guard);
|
drop(guard);
|
||||||
// After the guard's release, the lookup goes back to None.
|
// After the guard's release, the lookup goes back to None.
|
||||||
|
|
@ -292,7 +299,7 @@ mod tests {
|
||||||
update_segment("p", updated).unwrap();
|
update_segment("p", updated).unwrap();
|
||||||
// lookup against the original is now empty, the updated id wins.
|
// lookup against the original is now empty, the updated id wins.
|
||||||
assert!(lookup_segment(original).unwrap().is_none());
|
assert!(lookup_segment(original).unwrap().is_none());
|
||||||
assert_eq!(lookup_segment(updated).unwrap().unwrap().pod_name, "p");
|
assert_eq!(lookup_segment(updated).unwrap().unwrap().worker_name, "p");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -321,11 +328,11 @@ mod tests {
|
||||||
let err = update_segment("a", s_b).unwrap_err();
|
let err = update_segment("a", s_b).unwrap_err();
|
||||||
match err {
|
match err {
|
||||||
ScopeLockError::SegmentConflict {
|
ScopeLockError::SegmentConflict {
|
||||||
pod_name,
|
worker_name,
|
||||||
segment_id,
|
segment_id,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(pod_name, "b");
|
assert_eq!(worker_name, "b");
|
||||||
assert_eq!(segment_id, s_b);
|
assert_eq!(segment_id, s_b);
|
||||||
}
|
}
|
||||||
other => panic!("expected SegmentConflict, got {other:?}"),
|
other => panic!("expected SegmentConflict, got {other:?}"),
|
||||||
|
|
|
||||||
|
|
@ -11,24 +11,24 @@ use crate::conflict::{find_conflict_owner, find_conflict_owners};
|
||||||
use crate::error::ScopeLockError;
|
use crate::error::ScopeLockError;
|
||||||
use crate::table::{Allocation, LockFileGuard};
|
use crate::table::{Allocation, LockFileGuard};
|
||||||
|
|
||||||
/// Register a top-level Pod (started directly by a human, no
|
/// Register a top-level Worker (started directly by a human, no
|
||||||
/// delegation parent). Reclaims stale entries before checking
|
/// delegation parent). Reclaims stale entries before checking
|
||||||
/// conflicts so a crashed Pod's allocation doesn't block the new one.
|
/// conflicts so a crashed Worker's allocation doesn't block the new one.
|
||||||
///
|
///
|
||||||
/// Rejects when another live allocation is already writing to
|
/// Rejects when another live allocation is already writing to
|
||||||
/// `segment_id`, so two `restore_from_manifest` calls under different
|
/// `segment_id`, so two `restore_from_manifest` calls under different
|
||||||
/// `pod_name`s cannot both grab the same session log.
|
/// `worker_name`s cannot both grab the same session log.
|
||||||
pub fn register_pod(
|
pub fn register_worker(
|
||||||
guard: &mut LockFileGuard,
|
guard: &mut LockFileGuard,
|
||||||
pod_name: String,
|
worker_name: String,
|
||||||
pid: u32,
|
pid: u32,
|
||||||
socket: PathBuf,
|
socket: PathBuf,
|
||||||
scope_allow: Vec<ScopeRule>,
|
scope_allow: Vec<ScopeRule>,
|
||||||
segment_id: SegmentId,
|
segment_id: SegmentId,
|
||||||
) -> Result<(), ScopeLockError> {
|
) -> Result<(), ScopeLockError> {
|
||||||
register_pod_with_deny(
|
register_worker_with_deny(
|
||||||
guard,
|
guard,
|
||||||
pod_name,
|
worker_name,
|
||||||
pid,
|
pid,
|
||||||
socket,
|
socket,
|
||||||
scope_allow,
|
scope_allow,
|
||||||
|
|
@ -37,21 +37,21 @@ pub fn register_pod(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register a top-level Pod with explicit deny rules that reduce the
|
/// Register a top-level Worker with explicit deny rules that reduce the
|
||||||
/// claimed effective write scope.
|
/// claimed effective write scope.
|
||||||
///
|
///
|
||||||
/// Conflict semantics: if every Pod overlapping a requested allow rule
|
/// Conflict semantics: if every Worker overlapping a requested allow rule
|
||||||
/// is fully covered by one of `scope_deny`, the conflict is suppressed
|
/// is fully covered by one of `scope_deny`, the conflict is suppressed
|
||||||
/// and the registration proceeds. The check is structural (deny ⊇
|
/// and the registration proceeds. The check is structural (deny ⊇
|
||||||
/// competitor.rule), not relational — it does not verify that the
|
/// competitor.rule), not relational — it does not verify that the
|
||||||
/// competitor actually descends from this Pod's prior delegations.
|
/// competitor actually descends from this Worker's prior delegations.
|
||||||
/// In practice this is safe because the canonical restore caller derives
|
/// In practice this is safe because the canonical restore caller derives
|
||||||
/// `scope_deny` from outstanding `pod-store` child delegations, so any
|
/// `scope_deny` from outstanding `pod-store` child delegations, so any
|
||||||
/// covered competitor is expected to be a descendant of the original
|
/// covered competitor is expected to be a descendant of the original
|
||||||
/// allocation. Direct callers must uphold the same invariant.
|
/// allocation. Direct callers must uphold the same invariant.
|
||||||
pub fn register_pod_with_deny(
|
pub fn register_worker_with_deny(
|
||||||
guard: &mut LockFileGuard,
|
guard: &mut LockFileGuard,
|
||||||
pod_name: String,
|
worker_name: String,
|
||||||
pid: u32,
|
pid: u32,
|
||||||
socket: PathBuf,
|
socket: PathBuf,
|
||||||
scope_allow: Vec<ScopeRule>,
|
scope_allow: Vec<ScopeRule>,
|
||||||
|
|
@ -59,13 +59,13 @@ pub fn register_pod_with_deny(
|
||||||
segment_id: SegmentId,
|
segment_id: SegmentId,
|
||||||
) -> Result<(), ScopeLockError> {
|
) -> Result<(), ScopeLockError> {
|
||||||
reclaim_stale(guard);
|
reclaim_stale(guard);
|
||||||
if guard.data().find(&pod_name).is_some() {
|
if guard.data().find(&worker_name).is_some() {
|
||||||
return Err(ScopeLockError::DuplicatePodName(pod_name));
|
return Err(ScopeLockError::DuplicateWorkerName(worker_name));
|
||||||
}
|
}
|
||||||
if let Some(existing) = guard.data().find_by_segment(segment_id) {
|
if let Some(existing) = guard.data().find_by_segment(segment_id) {
|
||||||
return Err(ScopeLockError::SegmentConflict {
|
return Err(ScopeLockError::SegmentConflict {
|
||||||
segment_id,
|
segment_id,
|
||||||
pod_name: existing.pod_name.clone(),
|
worker_name: existing.worker_name.clone(),
|
||||||
socket: existing.socket.clone(),
|
socket: existing.socket.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -86,14 +86,14 @@ pub fn register_pod_with_deny(
|
||||||
}
|
}
|
||||||
if let Some(competitor) = conflicts.into_iter().next() {
|
if let Some(competitor) = conflicts.into_iter().next() {
|
||||||
return Err(ScopeLockError::WriteConflict {
|
return Err(ScopeLockError::WriteConflict {
|
||||||
competitor: competitor.pod_name,
|
competitor: competitor.worker_name,
|
||||||
rule: rule.clone(),
|
rule: rule.clone(),
|
||||||
competitor_rule: competitor.rule,
|
competitor_rule: competitor.rule,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
guard.data_mut().allocations.push(Allocation {
|
guard.data_mut().allocations.push(Allocation {
|
||||||
pod_name,
|
worker_name,
|
||||||
pid,
|
pid,
|
||||||
socket,
|
socket,
|
||||||
scope_allow,
|
scope_allow,
|
||||||
|
|
@ -105,9 +105,9 @@ pub fn register_pod_with_deny(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register a spawned Pod whose scope is delegated from `spawner`.
|
/// Register a spawned Worker whose scope is delegated from `spawner`.
|
||||||
/// The requested scope must be within the spawner's delegation authority;
|
/// The requested scope must be within the spawner's delegation authority;
|
||||||
/// overlap with any Pod other than `spawner` is a conflict.
|
/// overlap with any Worker other than `spawner` is a conflict.
|
||||||
pub fn delegate_scope(
|
pub fn delegate_scope(
|
||||||
guard: &mut LockFileGuard,
|
guard: &mut LockFileGuard,
|
||||||
spawner: &str,
|
spawner: &str,
|
||||||
|
|
@ -119,10 +119,10 @@ pub fn delegate_scope(
|
||||||
) -> Result<(), ScopeLockError> {
|
) -> Result<(), ScopeLockError> {
|
||||||
reclaim_stale(guard);
|
reclaim_stale(guard);
|
||||||
if guard.data().find(&spawned).is_some() {
|
if guard.data().find(&spawned).is_some() {
|
||||||
return Err(ScopeLockError::DuplicatePodName(spawned));
|
return Err(ScopeLockError::DuplicateWorkerName(spawned));
|
||||||
}
|
}
|
||||||
if guard.data().find(spawner).is_none() {
|
if guard.data().find(spawner).is_none() {
|
||||||
return Err(ScopeLockError::UnknownPod(spawner.into()));
|
return Err(ScopeLockError::UnknownWorker(spawner.into()));
|
||||||
}
|
}
|
||||||
for rule in &scope_allow {
|
for rule in &scope_allow {
|
||||||
let allowed = delegation_scope
|
let allowed = delegation_scope
|
||||||
|
|
@ -137,7 +137,7 @@ pub fn delegate_scope(
|
||||||
if rule.permission == Permission::Write {
|
if rule.permission == Permission::Write {
|
||||||
if let Some(competitor) = find_conflict_owner(guard.data(), rule, Some(spawner)) {
|
if let Some(competitor) = find_conflict_owner(guard.data(), rule, Some(spawner)) {
|
||||||
return Err(ScopeLockError::WriteConflict {
|
return Err(ScopeLockError::WriteConflict {
|
||||||
competitor: competitor.pod_name,
|
competitor: competitor.worker_name,
|
||||||
rule: rule.clone(),
|
rule: rule.clone(),
|
||||||
competitor_rule: competitor.rule,
|
competitor_rule: competitor.rule,
|
||||||
});
|
});
|
||||||
|
|
@ -145,7 +145,7 @@ pub fn delegate_scope(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
guard.data_mut().allocations.push(Allocation {
|
guard.data_mut().allocations.push(Allocation {
|
||||||
pod_name: spawned,
|
worker_name: spawned,
|
||||||
pid,
|
pid,
|
||||||
socket,
|
socket,
|
||||||
scope_allow,
|
scope_allow,
|
||||||
|
|
@ -159,21 +159,21 @@ pub fn delegate_scope(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a Pod's allocation. Surviving children are reparented to
|
/// Remove a Worker's allocation. Surviving children are reparented to
|
||||||
/// the removed Pod's own `delegated_from`, so the delegation tree
|
/// the removed Worker's own `delegated_from`, so the delegation tree
|
||||||
/// stays connected.
|
/// stays connected.
|
||||||
pub fn release_pod(guard: &mut LockFileGuard, pod_name: &str) -> Result<(), ScopeLockError> {
|
pub fn release_worker(guard: &mut LockFileGuard, worker_name: &str) -> Result<(), ScopeLockError> {
|
||||||
let idx = guard
|
let idx = guard
|
||||||
.data()
|
.data()
|
||||||
.allocations
|
.allocations
|
||||||
.iter()
|
.iter()
|
||||||
.position(|a| a.pod_name == pod_name);
|
.position(|a| a.worker_name == worker_name);
|
||||||
let Some(idx) = idx else {
|
let Some(idx) = idx else {
|
||||||
return Err(ScopeLockError::UnknownPod(pod_name.into()));
|
return Err(ScopeLockError::UnknownWorker(worker_name.into()));
|
||||||
};
|
};
|
||||||
let removed = guard.data().allocations[idx].clone();
|
let removed = guard.data().allocations[idx].clone();
|
||||||
for alloc in guard.data_mut().allocations.iter_mut() {
|
for alloc in guard.data_mut().allocations.iter_mut() {
|
||||||
if alloc.delegated_from.as_deref() == Some(pod_name) {
|
if alloc.delegated_from.as_deref() == Some(worker_name) {
|
||||||
alloc.delegated_from.clone_from(&removed.delegated_from);
|
alloc.delegated_from.clone_from(&removed.delegated_from);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -187,7 +187,7 @@ pub fn release_pod(guard: &mut LockFileGuard, pod_name: &str) -> Result<(), Scop
|
||||||
/// This is idempotent for missing deny entries. For each delegated Write rule,
|
/// This is idempotent for missing deny entries. For each delegated Write rule,
|
||||||
/// at most one exact matching deny rule is removed from the parent's `scope_deny`
|
/// at most one exact matching deny rule is removed from the parent's `scope_deny`
|
||||||
/// even when the child allocation is already absent; restore reconciliation uses
|
/// even when the child allocation is already absent; restore reconciliation uses
|
||||||
/// that case when durable Pod-state still records an outstanding delegation but
|
/// that case when durable Worker-state still records an outstanding delegation but
|
||||||
/// the live lock file no longer has a child allocation.
|
/// the live lock file no longer has a child allocation.
|
||||||
pub fn reclaim_delegated_scope(
|
pub fn reclaim_delegated_scope(
|
||||||
guard: &mut LockFileGuard,
|
guard: &mut LockFileGuard,
|
||||||
|
|
@ -199,7 +199,7 @@ pub fn reclaim_delegated_scope(
|
||||||
.data()
|
.data()
|
||||||
.allocations
|
.allocations
|
||||||
.iter()
|
.iter()
|
||||||
.position(|a| a.pod_name == child);
|
.position(|a| a.worker_name == child);
|
||||||
let removed_child_parent = child_idx
|
let removed_child_parent = child_idx
|
||||||
.map(|idx| guard.data().allocations[idx].delegated_from.clone())
|
.map(|idx| guard.data().allocations[idx].delegated_from.clone())
|
||||||
.unwrap_or(None);
|
.unwrap_or(None);
|
||||||
|
|
@ -229,8 +229,8 @@ pub fn reclaim_delegated_scope(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove allocations whose PID is dead, reparenting children to the
|
/// Remove allocations whose PID is dead, reparenting children to the
|
||||||
/// dead Pod's `delegated_from`. Idempotent and best-effort — I/O
|
/// dead Worker's `delegated_from`. Idempotent and best-effort — I/O
|
||||||
/// errors on save are swallowed so a crashed Pod's entry never blocks
|
/// errors on save are swallowed so a crashed Worker's entry never blocks
|
||||||
/// forward progress.
|
/// forward progress.
|
||||||
pub fn reclaim_stale(guard: &mut LockFileGuard) {
|
pub fn reclaim_stale(guard: &mut LockFileGuard) {
|
||||||
reclaim_stale_with(guard, pid_alive);
|
reclaim_stale_with(guard, pid_alive);
|
||||||
|
|
@ -243,7 +243,7 @@ pub fn reclaim_stale_with(guard: &mut LockFileGuard, mut is_alive: impl FnMut(u3
|
||||||
.allocations
|
.allocations
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|a| !is_alive(a.pid))
|
.filter(|a| !is_alive(a.pid))
|
||||||
.map(|a| a.pod_name.clone())
|
.map(|a| a.worker_name.clone())
|
||||||
.collect();
|
.collect();
|
||||||
if dead.is_empty() {
|
if dead.is_empty() {
|
||||||
return;
|
return;
|
||||||
|
|
@ -253,7 +253,7 @@ pub fn reclaim_stale_with(guard: &mut LockFileGuard, mut is_alive: impl FnMut(u3
|
||||||
.data()
|
.data()
|
||||||
.allocations
|
.allocations
|
||||||
.iter()
|
.iter()
|
||||||
.position(|a| a.pod_name == *name)
|
.position(|a| a.worker_name == *name)
|
||||||
else {
|
else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
@ -294,9 +294,9 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn register_detects_write_conflict() {
|
fn register_detects_write_conflict() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let path = dir.path().join("pods.json");
|
let path = dir.path().join("workers.json");
|
||||||
let mut g = open_empty(&path);
|
let mut g = open_empty(&path);
|
||||||
register_pod(
|
register_worker(
|
||||||
&mut g,
|
&mut g,
|
||||||
"a".into(),
|
"a".into(),
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
|
|
@ -305,7 +305,7 @@ mod tests {
|
||||||
sid(),
|
sid(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let err = register_pod(
|
let err = register_worker(
|
||||||
&mut g,
|
&mut g,
|
||||||
"b".into(),
|
"b".into(),
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
|
|
@ -321,11 +321,11 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn duplicate_pod_name_rejected() {
|
fn duplicate_worker_name_rejected() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let path = dir.path().join("pods.json");
|
let path = dir.path().join("workers.json");
|
||||||
let mut g = open_empty(&path);
|
let mut g = open_empty(&path);
|
||||||
register_pod(
|
register_worker(
|
||||||
&mut g,
|
&mut g,
|
||||||
"a".into(),
|
"a".into(),
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
|
|
@ -334,7 +334,7 @@ mod tests {
|
||||||
sid(),
|
sid(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let err = register_pod(
|
let err = register_worker(
|
||||||
&mut g,
|
&mut g,
|
||||||
"a".into(),
|
"a".into(),
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
|
|
@ -343,15 +343,15 @@ mod tests {
|
||||||
sid(),
|
sid(),
|
||||||
)
|
)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(err, ScopeLockError::DuplicatePodName(ref n) if n == "a"));
|
assert!(matches!(err, ScopeLockError::DuplicateWorkerName(ref n) if n == "a"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn delegate_must_be_subset() {
|
fn delegate_must_be_subset() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let path = dir.path().join("pods.json");
|
let path = dir.path().join("workers.json");
|
||||||
let mut g = open_empty(&path);
|
let mut g = open_empty(&path);
|
||||||
register_pod(
|
register_worker(
|
||||||
&mut g,
|
&mut g,
|
||||||
"a".into(),
|
"a".into(),
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
|
|
@ -376,9 +376,9 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn delegate_uses_delegation_scope_not_direct_effective_write() {
|
fn delegate_uses_delegation_scope_not_direct_effective_write() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let path = dir.path().join("pods.json");
|
let path = dir.path().join("workers.json");
|
||||||
let mut g = open_empty(&path);
|
let mut g = open_empty(&path);
|
||||||
register_pod(
|
register_worker(
|
||||||
&mut g,
|
&mut g,
|
||||||
"orchestrator".into(),
|
"orchestrator".into(),
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
|
|
@ -406,9 +406,9 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn delegate_succeeds_within_parent_scope() {
|
fn delegate_succeeds_within_parent_scope() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let path = dir.path().join("pods.json");
|
let path = dir.path().join("workers.json");
|
||||||
let mut g = open_empty(&path);
|
let mut g = open_empty(&path);
|
||||||
register_pod(
|
register_worker(
|
||||||
&mut g,
|
&mut g,
|
||||||
"a".into(),
|
"a".into(),
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
|
|
@ -445,9 +445,9 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn delegate_rejects_sibling_overlap() {
|
fn delegate_rejects_sibling_overlap() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let path = dir.path().join("pods.json");
|
let path = dir.path().join("workers.json");
|
||||||
let mut g = open_empty(&path);
|
let mut g = open_empty(&path);
|
||||||
register_pod(
|
register_worker(
|
||||||
&mut g,
|
&mut g,
|
||||||
"a".into(),
|
"a".into(),
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
|
|
@ -486,9 +486,9 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn release_reparents_children() {
|
fn release_reparents_children() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let path = dir.path().join("pods.json");
|
let path = dir.path().join("workers.json");
|
||||||
let mut g = open_empty(&path);
|
let mut g = open_empty(&path);
|
||||||
register_pod(
|
register_worker(
|
||||||
&mut g,
|
&mut g,
|
||||||
"a".into(),
|
"a".into(),
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
|
|
@ -517,7 +517,7 @@ mod tests {
|
||||||
&delegation_scope(vec![write_rule("/src/core", true)]),
|
&delegation_scope(vec![write_rule("/src/core", true)]),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
release_pod(&mut g, "b").unwrap();
|
release_worker(&mut g, "b").unwrap();
|
||||||
// D should now list A as its delegated_from.
|
// D should now list A as its delegated_from.
|
||||||
let d = g.data().find("d").unwrap();
|
let d = g.data().find("d").unwrap();
|
||||||
assert_eq!(d.delegated_from.as_deref(), Some("a"));
|
assert_eq!(d.delegated_from.as_deref(), Some("a"));
|
||||||
|
|
@ -527,10 +527,10 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn reclaim_delegated_scope_removes_child_and_one_parent_deny_layer() {
|
fn reclaim_delegated_scope_removes_child_and_one_parent_deny_layer() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let path = dir.path().join("pods.json");
|
let path = dir.path().join("workers.json");
|
||||||
let mut g = open_empty(&path);
|
let mut g = open_empty(&path);
|
||||||
let delegated_rule = write_rule("/src/core", true);
|
let delegated_rule = write_rule("/src/core", true);
|
||||||
register_pod_with_deny(
|
register_worker_with_deny(
|
||||||
&mut g,
|
&mut g,
|
||||||
"a".into(),
|
"a".into(),
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
|
|
@ -540,7 +540,7 @@ mod tests {
|
||||||
sid(),
|
sid(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
register_pod(
|
register_worker(
|
||||||
&mut g,
|
&mut g,
|
||||||
"b".into(),
|
"b".into(),
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
|
|
@ -566,10 +566,10 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn reclaim_delegated_scope_removes_parent_deny_when_child_allocation_missing() {
|
fn reclaim_delegated_scope_removes_parent_deny_when_child_allocation_missing() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let path = dir.path().join("pods.json");
|
let path = dir.path().join("workers.json");
|
||||||
let mut g = open_empty(&path);
|
let mut g = open_empty(&path);
|
||||||
let delegated_rule = write_rule("/src/core", true);
|
let delegated_rule = write_rule("/src/core", true);
|
||||||
register_pod_with_deny(
|
register_worker_with_deny(
|
||||||
&mut g,
|
&mut g,
|
||||||
"a".into(),
|
"a".into(),
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
|
|
@ -595,9 +595,9 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn reclaim_stale_reparents_and_removes_dead_entries() {
|
fn reclaim_stale_reparents_and_removes_dead_entries() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let path = dir.path().join("pods.json");
|
let path = dir.path().join("workers.json");
|
||||||
let mut g = open_empty(&path);
|
let mut g = open_empty(&path);
|
||||||
register_pod(
|
register_worker(
|
||||||
&mut g,
|
&mut g,
|
||||||
"a".into(),
|
"a".into(),
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
|
|
@ -630,7 +630,7 @@ mod tests {
|
||||||
// will treat as dead.
|
// will treat as dead.
|
||||||
let fake_dead_pid: u32 = 0xffff_fff0;
|
let fake_dead_pid: u32 = 0xffff_fff0;
|
||||||
for alloc in g.data_mut().allocations.iter_mut() {
|
for alloc in g.data_mut().allocations.iter_mut() {
|
||||||
if alloc.pod_name == "b" {
|
if alloc.worker_name == "b" {
|
||||||
alloc.pid = fake_dead_pid;
|
alloc.pid = fake_dead_pid;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -643,9 +643,9 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn read_rules_do_not_conflict_with_write() {
|
fn read_rules_do_not_conflict_with_write() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let path = dir.path().join("pods.json");
|
let path = dir.path().join("workers.json");
|
||||||
let mut g = open_empty(&path);
|
let mut g = open_empty(&path);
|
||||||
register_pod(
|
register_worker(
|
||||||
&mut g,
|
&mut g,
|
||||||
"a".into(),
|
"a".into(),
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
|
|
@ -655,7 +655,7 @@ mod tests {
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// B only reads under the same tree — allowed.
|
// B only reads under the same tree — allowed.
|
||||||
register_pod(
|
register_worker(
|
||||||
&mut g,
|
&mut g,
|
||||||
"b".into(),
|
"b".into(),
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
|
|
@ -670,9 +670,9 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn releasing_pod_reopens_scope_for_fresh_registration() {
|
fn releasing_pod_reopens_scope_for_fresh_registration() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let path = dir.path().join("pods.json");
|
let path = dir.path().join("workers.json");
|
||||||
let mut g = open_empty(&path);
|
let mut g = open_empty(&path);
|
||||||
register_pod(
|
register_worker(
|
||||||
&mut g,
|
&mut g,
|
||||||
"a".into(),
|
"a".into(),
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
|
|
@ -681,8 +681,8 @@ mod tests {
|
||||||
sid(),
|
sid(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
release_pod(&mut g, "a").unwrap();
|
release_worker(&mut g, "a").unwrap();
|
||||||
register_pod(
|
register_worker(
|
||||||
&mut g,
|
&mut g,
|
||||||
"b".into(),
|
"b".into(),
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
|
|
@ -696,9 +696,9 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn delegated_scope_returns_to_parent_on_release() {
|
fn delegated_scope_returns_to_parent_on_release() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let path = dir.path().join("pods.json");
|
let path = dir.path().join("workers.json");
|
||||||
let mut g = open_empty(&path);
|
let mut g = open_empty(&path);
|
||||||
register_pod(
|
register_worker(
|
||||||
&mut g,
|
&mut g,
|
||||||
"a".into(),
|
"a".into(),
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
|
|
@ -722,7 +722,7 @@ mod tests {
|
||||||
"a",
|
"a",
|
||||||
&write_rule("/src/core", true)
|
&write_rule("/src/core", true)
|
||||||
));
|
));
|
||||||
release_pod(&mut g, "b").unwrap();
|
release_worker(&mut g, "b").unwrap();
|
||||||
// /src/core is back in A's effective write scope.
|
// /src/core is back in A's effective write scope.
|
||||||
assert!(is_within_effective_write(
|
assert!(is_within_effective_write(
|
||||||
g.data(),
|
g.data(),
|
||||||
|
|
@ -734,10 +734,10 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn register_pod_rejects_session_id_collision() {
|
fn register_pod_rejects_session_id_collision() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let path = dir.path().join("pods.json");
|
let path = dir.path().join("workers.json");
|
||||||
let mut g = open_empty(&path);
|
let mut g = open_empty(&path);
|
||||||
let shared_session = sid();
|
let shared_session = sid();
|
||||||
register_pod(
|
register_worker(
|
||||||
&mut g,
|
&mut g,
|
||||||
"first".into(),
|
"first".into(),
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
|
|
@ -747,9 +747,9 @@ mod tests {
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// Second registration tries to grab the same segment_id under
|
// Second registration tries to grab the same segment_id under
|
||||||
// a different pod_name. Without the SegmentConflict check both
|
// a different worker_name. Without the SegmentConflict check both
|
||||||
// would succeed and race on the same jsonl.
|
// would succeed and race on the same jsonl.
|
||||||
let err = register_pod(
|
let err = register_worker(
|
||||||
&mut g,
|
&mut g,
|
||||||
"second".into(),
|
"second".into(),
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
|
|
@ -761,11 +761,11 @@ mod tests {
|
||||||
match err {
|
match err {
|
||||||
ScopeLockError::SegmentConflict {
|
ScopeLockError::SegmentConflict {
|
||||||
segment_id,
|
segment_id,
|
||||||
pod_name,
|
worker_name,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(segment_id, shared_session);
|
assert_eq!(segment_id, shared_session);
|
||||||
assert_eq!(pod_name, "first");
|
assert_eq!(worker_name, "first");
|
||||||
}
|
}
|
||||||
other => panic!("expected SegmentConflict, got {other:?}"),
|
other => panic!("expected SegmentConflict, got {other:?}"),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,33 +22,33 @@ pub struct LockFile {
|
||||||
pub allocations: Vec<Allocation>,
|
pub allocations: Vec<Allocation>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One Pod's scope allocation.
|
/// One Worker's scope allocation.
|
||||||
///
|
///
|
||||||
/// `scope_allow` is the full set of allow rules the Pod was granted.
|
/// `scope_allow` is the full set of allow rules the Worker was granted.
|
||||||
/// Portions delegated out to child Pods are **not** subtracted in
|
/// Portions delegated out to child Workers are **not** subtracted in
|
||||||
/// storage — the effective write scope is derived on the fly by
|
/// storage — the effective write scope is derived on the fly by
|
||||||
/// removing rules owned by any Pod whose `delegated_from` points to
|
/// removing rules owned by any Worker whose `delegated_from` points to
|
||||||
/// this one. Keeping the raw allow set makes reparenting (stale
|
/// this one. Keeping the raw allow set makes reparenting (stale
|
||||||
/// reclaim) trivial.
|
/// reclaim) trivial.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Allocation {
|
pub struct Allocation {
|
||||||
/// Pod name — also the identity used throughout orchestration.
|
/// Worker name — also the identity used throughout orchestration.
|
||||||
pub pod_name: String,
|
pub worker_name: String,
|
||||||
/// Owning process. Checked with `kill(pid, 0)` for stale detection.
|
/// Owning process. Checked with `kill(pid, 0)` for stale detection.
|
||||||
pub pid: u32,
|
pub pid: u32,
|
||||||
/// Pod's Unix socket path.
|
/// Worker's Unix socket path.
|
||||||
pub socket: PathBuf,
|
pub socket: PathBuf,
|
||||||
/// Allow rules granted to this Pod (write + read).
|
/// Allow rules granted to this Worker (write + read).
|
||||||
pub scope_allow: Vec<ScopeRule>,
|
pub scope_allow: Vec<ScopeRule>,
|
||||||
/// Deny rules that cap this Pod's effective scope. Normally empty for
|
/// Deny rules that cap this Worker's effective scope. Normally empty for
|
||||||
/// fresh allocations; restored Pods use this to avoid reclaiming
|
/// fresh allocations; restored Workers use this to avoid reclaiming
|
||||||
/// previously delegated write regions.
|
/// previously delegated write regions.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub scope_deny: Vec<ScopeRule>,
|
pub scope_deny: Vec<ScopeRule>,
|
||||||
/// Name of the Pod that delegated scope to this one, or `None` for
|
/// Name of the Worker that delegated scope to this one, or `None` for
|
||||||
/// a top-level Pod started directly by a human.
|
/// a top-level Worker started directly by a human.
|
||||||
pub delegated_from: Option<String>,
|
pub delegated_from: Option<String>,
|
||||||
/// Segment ID this Pod is currently writing to. `None` means this
|
/// Segment ID this Worker is currently writing to. `None` means this
|
||||||
/// is a pre-reservation made by a spawner via [`crate::delegate_scope`]
|
/// is a pre-reservation made by a spawner via [`crate::delegate_scope`]
|
||||||
/// before the child has come up; the child fills it in at
|
/// before the child has come up; the child fills it in at
|
||||||
/// [`crate::adopt_allocation`] time.
|
/// [`crate::adopt_allocation`] time.
|
||||||
|
|
@ -57,12 +57,16 @@ pub struct Allocation {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LockFile {
|
impl LockFile {
|
||||||
pub fn find(&self, pod_name: &str) -> Option<&Allocation> {
|
pub fn find(&self, worker_name: &str) -> Option<&Allocation> {
|
||||||
self.allocations.iter().find(|a| a.pod_name == pod_name)
|
self.allocations
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.worker_name == worker_name)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn find_mut(&mut self, pod_name: &str) -> Option<&mut Allocation> {
|
pub fn find_mut(&mut self, worker_name: &str) -> Option<&mut Allocation> {
|
||||||
self.allocations.iter_mut().find(|a| a.pod_name == pod_name)
|
self.allocations
|
||||||
|
.iter_mut()
|
||||||
|
.find(|a| a.worker_name == worker_name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find the allocation currently writing to `segment_id`. Skips
|
/// Find the allocation currently writing to `segment_id`. Skips
|
||||||
|
|
@ -74,7 +78,7 @@ impl LockFile {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default on-disk path: `<runtime_dir>/pods.json` resolved via
|
/// Default on-disk path: `<runtime_dir>/workers.json` resolved via
|
||||||
/// [`manifest::paths::pod_registry_path`]. Tests should point this
|
/// [`manifest::paths::pod_registry_path`]. Tests should point this
|
||||||
/// elsewhere by setting `YOI_HOME` or `YOI_RUNTIME_DIR` to a
|
/// elsewhere by setting `YOI_HOME` or `YOI_RUNTIME_DIR` to a
|
||||||
/// tempdir.
|
/// tempdir.
|
||||||
|
|
@ -82,7 +86,7 @@ pub fn default_registry_path() -> io::Result<PathBuf> {
|
||||||
paths::pod_registry_path().ok_or_else(|| {
|
paths::pod_registry_path().ok_or_else(|| {
|
||||||
io::Error::new(
|
io::Error::new(
|
||||||
io::ErrorKind::NotFound,
|
io::ErrorKind::NotFound,
|
||||||
"could not resolve pods.json path (no YOI_HOME / \
|
"could not resolve workers.json path (no YOI_HOME / \
|
||||||
YOI_RUNTIME_DIR / XDG_RUNTIME_DIR / HOME)",
|
YOI_RUNTIME_DIR / XDG_RUNTIME_DIR / HOME)",
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
@ -173,7 +177,7 @@ impl LockFileGuard {
|
||||||
serde_json::from_str(&buf).map_err(|e| {
|
serde_json::from_str(&buf).map_err(|e| {
|
||||||
io::Error::new(
|
io::Error::new(
|
||||||
io::ErrorKind::InvalidData,
|
io::ErrorKind::InvalidData,
|
||||||
format!("pods.json parse error: {e}"),
|
format!("workers.json parse error: {e}"),
|
||||||
)
|
)
|
||||||
})?
|
})?
|
||||||
};
|
};
|
||||||
|
|
@ -215,7 +219,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn open_creates_empty_lock_file() {
|
fn open_creates_empty_lock_file() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let path = dir.path().join("pods.json");
|
let path = dir.path().join("workers.json");
|
||||||
let guard = LockFileGuard::open(&path).unwrap();
|
let guard = LockFileGuard::open(&path).unwrap();
|
||||||
assert!(guard.data().allocations.is_empty());
|
assert!(guard.data().allocations.is_empty());
|
||||||
assert!(path.exists());
|
assert!(path.exists());
|
||||||
|
|
@ -226,7 +230,7 @@ mod tests {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let parent = dir.path().join("yoi");
|
let parent = dir.path().join("yoi");
|
||||||
let path = parent.join("pods.json");
|
let path = parent.join("workers.json");
|
||||||
let _guard = LockFileGuard::open(&path).unwrap();
|
let _guard = LockFileGuard::open(&path).unwrap();
|
||||||
let file_mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
let file_mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||||
assert_eq!(file_mode, 0o600, "file mode = {file_mode:o}");
|
assert_eq!(file_mode, 0o600, "file mode = {file_mode:o}");
|
||||||
|
|
@ -237,10 +241,10 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn save_and_reopen_roundtrip() {
|
fn save_and_reopen_roundtrip() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let path = dir.path().join("pods.json");
|
let path = dir.path().join("workers.json");
|
||||||
{
|
{
|
||||||
let mut g = open_empty(&path);
|
let mut g = open_empty(&path);
|
||||||
register_pod(
|
register_worker(
|
||||||
&mut g,
|
&mut g,
|
||||||
"a".into(),
|
"a".into(),
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
|
|
@ -252,19 +256,19 @@ mod tests {
|
||||||
}
|
}
|
||||||
let guard = LockFileGuard::open(&path).unwrap();
|
let guard = LockFileGuard::open(&path).unwrap();
|
||||||
assert_eq!(guard.data().allocations.len(), 1);
|
assert_eq!(guard.data().allocations.len(), 1);
|
||||||
assert_eq!(guard.data().allocations[0].pod_name, "a");
|
assert_eq!(guard.data().allocations[0].worker_name, "a");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn find_by_session_skips_none_placeholders() {
|
fn find_by_session_skips_none_placeholders() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let path = dir.path().join("pods.json");
|
let path = dir.path().join("workers.json");
|
||||||
let mut g = open_empty(&path);
|
let mut g = open_empty(&path);
|
||||||
// Pre-reservation: delegate_scope leaves segment_id = None
|
// Pre-reservation: delegate_scope leaves segment_id = None
|
||||||
// until adopt_allocation rewrites it. find_by_segment must not
|
// until adopt_allocation rewrites it. find_by_segment must not
|
||||||
// match those placeholders, otherwise a freshly-spawning child
|
// match those placeholders, otherwise a freshly-spawning child
|
||||||
// would shadow itself before it has even chosen a session.
|
// would shadow itself before it has even chosen a session.
|
||||||
register_pod(
|
register_worker(
|
||||||
&mut g,
|
&mut g,
|
||||||
"parent".into(),
|
"parent".into(),
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
|
|
@ -292,6 +296,6 @@ mod tests {
|
||||||
// After adopt-style rewrite, the same allocation is now found.
|
// After adopt-style rewrite, the same allocation is now found.
|
||||||
g.data_mut().find_mut("child").unwrap().segment_id = Some(target_session);
|
g.data_mut().find_mut("child").unwrap().segment_id = Some(target_session);
|
||||||
let found = g.data().find_by_segment(target_session).unwrap();
|
let found = g.data().find_by_segment(target_session).unwrap();
|
||||||
assert_eq!(found.pod_name, "child");
|
assert_eq!(found.worker_name, "child");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "pod-store"
|
name = "pod-store"
|
||||||
description = "Durable Pod-name metadata/state persistence"
|
description = "Legacy-named durable Worker metadata/state persistence"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@
|
||||||
|
|
||||||
## Role
|
## Role
|
||||||
|
|
||||||
`pod-store` owns current Pod metadata keyed by Pod name.
|
`pod-store` is the legacy-named crate that owns current Worker metadata keyed by Worker name.
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
Owns:
|
Owns:
|
||||||
|
|
||||||
- persisted Pod metadata files
|
- persisted Worker metadata files
|
||||||
- current active/pending session pointers
|
- current active/pending session pointers
|
||||||
- resolved manifest snapshots for restoration
|
- resolved manifest snapshots for restoration
|
||||||
- parent-visible spawned-child metadata
|
- parent-visible spawned-child metadata
|
||||||
|
|
@ -23,8 +23,8 @@ Does not own:
|
||||||
|
|
||||||
## Design notes
|
## Design notes
|
||||||
|
|
||||||
Pod metadata is intentionally thin. It should answer current-state questions without duplicating transcripts or becoming a second session log.
|
Worker metadata is intentionally thin. It should answer current-state questions without duplicating transcripts or becoming a second session log.
|
||||||
|
|
||||||
## See also
|
## See also
|
||||||
|
|
||||||
- [`../../docs/design/pod-session-state.md`](../../docs/design/pod-session-state.md)
|
- [`../../docs/design/worker-session-state.md`](../../docs/design/worker-session-state.md)
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
//! Durable Pod-name metadata/state persistence.
|
//! Durable Worker-name metadata/state persistence.
|
||||||
//!
|
//!
|
||||||
//! This crate owns the name-keyed Pod state surface under a Pod-state root,
|
//! This crate owns the name-keyed Worker state surface under a Worker-state root,
|
||||||
//! e.g. `{data_dir}/pods/{pod_name}/metadata.json`. Session JSONL replay stays
|
//! e.g. `{data_dir}/workers/{worker_name}/metadata.json`. Session JSONL replay stays
|
||||||
//! in `session-store`; Pod metadata may point at a `(SessionId, SegmentId)` but
|
//! in `session-store`; Worker metadata may point at a `(SessionId, SegmentId)` but
|
||||||
//! does not own or replay session logs.
|
//! does not own or replay session logs.
|
||||||
//!
|
//!
|
||||||
//! `resolved_manifest_snapshot` is authority only for Pod-name restore before
|
//! `resolved_manifest_snapshot` is authority only for Worker-name restore before
|
||||||
//! loading the session log. Existing segment replay still uses `SegmentStart`
|
//! loading the session log. Existing segment replay still uses `SegmentStart`
|
||||||
//! entries from `session-store`. `spawned_children` is durable current parent
|
//! entries from `session-store`. `spawned_children` is durable current parent
|
||||||
//! Pod state for child registry/reclaim; child lifecycle messages shown to the
|
//! Worker state for child registry/reclaim; child lifecycle messages shown to the
|
||||||
//! model remain session JSONL history. Socket and callback paths are last-known
|
//! model remain session JSONL history. Socket and callback paths are last-known
|
||||||
//! runtime hints, not proof of liveness.
|
//! runtime hints, not proof of liveness.
|
||||||
|
|
||||||
|
|
@ -17,9 +17,9 @@ use session_store::{SegmentId, SessionId};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
/// Errors from Pod metadata persistence.
|
/// Errors from Worker metadata persistence.
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum PodStoreError {
|
pub enum WorkerStoreError {
|
||||||
#[error("I/O error: {0}")]
|
#[error("I/O error: {0}")]
|
||||||
Io(#[from] std::io::Error),
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
|
@ -30,15 +30,15 @@ pub enum PodStoreError {
|
||||||
InvalidPodName(String),
|
InvalidPodName(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Active Session/Segment pointer for a Pod.
|
/// Active Session/Segment pointer for a Worker.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct PodActiveSegmentRef {
|
pub struct WorkerActiveSegmentRef {
|
||||||
pub session_id: SessionId,
|
pub session_id: SessionId,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub segment_id: Option<SegmentId>,
|
pub segment_id: Option<SegmentId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PodActiveSegmentRef {
|
impl WorkerActiveSegmentRef {
|
||||||
/// Create a reference whose active Segment is not known yet.
|
/// Create a reference whose active Segment is not known yet.
|
||||||
pub fn pending_segment(session_id: SessionId) -> Self {
|
pub fn pending_segment(session_id: SessionId) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -57,21 +57,21 @@ impl PodActiveSegmentRef {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One delegated scope rule for a spawned child, kept local to avoid depending
|
/// One delegated scope rule for a spawned child, kept local to avoid depending
|
||||||
/// on manifest scope types in durable Pod state.
|
/// on manifest scope types in durable Worker state.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct PodSpawnedScopeRule {
|
pub struct WorkerSpawnedScopeRule {
|
||||||
pub target: PathBuf,
|
pub target: PathBuf,
|
||||||
pub permission: String,
|
pub permission: String,
|
||||||
pub recursive: bool,
|
pub recursive: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One child Pod spawned by this Pod and persisted with the spawner's
|
/// One child Worker spawned by this Worker and persisted with the spawner's
|
||||||
/// name-keyed Pod state. Runtime paths are last-known hints only.
|
/// name-keyed Worker state. Runtime paths are last-known hints only.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct PodSpawnedChild {
|
pub struct WorkerSpawnedChild {
|
||||||
pub pod_name: String,
|
pub worker_name: String,
|
||||||
pub socket_path: PathBuf,
|
pub socket_path: PathBuf,
|
||||||
pub scope_delegated: Vec<PodSpawnedScopeRule>,
|
pub scope_delegated: Vec<WorkerSpawnedScopeRule>,
|
||||||
pub callback_address: PathBuf,
|
pub callback_address: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -79,44 +79,44 @@ pub struct PodSpawnedChild {
|
||||||
/// restore can distinguish outstanding delegated scope from already-reclaimed
|
/// restore can distinguish outstanding delegated scope from already-reclaimed
|
||||||
/// child state without consulting session logs.
|
/// child state without consulting session logs.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct PodReclaimedChild {
|
pub struct WorkerReclaimedChild {
|
||||||
pub pod_name: String,
|
pub worker_name: String,
|
||||||
pub scope_delegated: Vec<PodSpawnedScopeRule>,
|
pub scope_delegated: Vec<WorkerSpawnedScopeRule>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One peer Pod made visible by an explicit peer handshake.
|
/// One peer Worker made visible by an explicit peer handshake.
|
||||||
///
|
///
|
||||||
/// Peer visibility is intentionally separate from spawned-child delegation: it
|
/// Peer visibility is intentionally separate from spawned-child delegation: it
|
||||||
/// does not carry filesystem scope, callback ownership, output cursors, or
|
/// does not carry filesystem scope, callback ownership, output cursors, or
|
||||||
/// lifecycle-notification authority.
|
/// lifecycle-notification authority.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct PodPeer {
|
pub struct WorkerPeer {
|
||||||
pub pod_name: String,
|
pub worker_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persistent metadata for a Pod name.
|
/// Persistent metadata for a Worker name.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct PodMetadata {
|
pub struct WorkerMetadata {
|
||||||
pub pod_name: String,
|
pub worker_name: String,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub active: Option<PodActiveSegmentRef>,
|
pub active: Option<WorkerActiveSegmentRef>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub workspace_root: Option<PathBuf>,
|
pub workspace_root: Option<PathBuf>,
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub spawned_children: Vec<PodSpawnedChild>,
|
pub spawned_children: Vec<WorkerSpawnedChild>,
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub reclaimed_children: Vec<PodReclaimedChild>,
|
pub reclaimed_children: Vec<WorkerReclaimedChild>,
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub peers: Vec<PodPeer>,
|
pub peers: Vec<WorkerPeer>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub resolved_manifest_snapshot: Option<serde_json::Value>,
|
pub resolved_manifest_snapshot: Option<serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PodMetadata {
|
impl WorkerMetadata {
|
||||||
/// Create Pod metadata for `pod_name`.
|
/// Create Worker metadata for `worker_name`.
|
||||||
pub fn new(pod_name: impl Into<String>, active: Option<PodActiveSegmentRef>) -> Self {
|
pub fn new(worker_name: impl Into<String>, active: Option<WorkerActiveSegmentRef>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
pod_name: pod_name.into(),
|
worker_name: worker_name.into(),
|
||||||
active,
|
active,
|
||||||
workspace_root: None,
|
workspace_root: None,
|
||||||
spawned_children: Vec::new(),
|
spawned_children: Vec::new(),
|
||||||
|
|
@ -132,35 +132,39 @@ impl PodMetadata {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sync persistence backend for Pod metadata.
|
/// Sync persistence backend for Worker metadata.
|
||||||
pub trait PodMetadataStore: Send + Sync {
|
pub trait WorkerMetadataStore: Send + Sync {
|
||||||
/// Create or replace metadata for its `pod_name` key.
|
/// Create or replace metadata for its `worker_name` key.
|
||||||
fn write(&self, metadata: &PodMetadata) -> Result<(), PodStoreError>;
|
fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError>;
|
||||||
|
|
||||||
/// Read metadata by Pod name. Returns `None` when no metadata exists.
|
/// Read metadata by Worker name. Returns `None` when no metadata exists.
|
||||||
fn read_by_name(&self, pod_name: &str) -> Result<Option<PodMetadata>, PodStoreError>;
|
fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError>;
|
||||||
|
|
||||||
/// List persisted Pod metadata keys.
|
/// List persisted Worker metadata keys.
|
||||||
fn list_names(&self) -> Result<Vec<String>, PodStoreError>;
|
fn list_names(&self) -> Result<Vec<String>, WorkerStoreError>;
|
||||||
|
|
||||||
/// Return the metadata root directory when this backend is path-backed.
|
/// Return the metadata root directory when this backend is path-backed.
|
||||||
fn root_dir(&self) -> Option<PathBuf> {
|
fn root_dir(&self) -> Option<PathBuf> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete metadata by Pod name. Missing metadata is a successful no-op.
|
/// Delete metadata by Worker name. Missing metadata is a successful no-op.
|
||||||
fn delete_by_name(&self, pod_name: &str) -> Result<(), PodStoreError>;
|
fn delete_by_name(&self, worker_name: &str) -> Result<(), WorkerStoreError>;
|
||||||
|
|
||||||
/// Merge an update into one Pod's metadata, preserving unrelated fields.
|
/// Merge an update into one Worker's metadata, preserving unrelated fields.
|
||||||
fn update_by_name<F>(&self, pod_name: &str, update: F) -> Result<PodMetadata, PodStoreError>
|
fn update_by_name<F>(
|
||||||
|
&self,
|
||||||
|
worker_name: &str,
|
||||||
|
update: F,
|
||||||
|
) -> Result<WorkerMetadata, WorkerStoreError>
|
||||||
where
|
where
|
||||||
F: FnOnce(&mut PodMetadata),
|
F: FnOnce(&mut WorkerMetadata),
|
||||||
{
|
{
|
||||||
let mut metadata = self
|
let mut metadata = self
|
||||||
.read_by_name(pod_name)?
|
.read_by_name(worker_name)?
|
||||||
.unwrap_or_else(|| PodMetadata::new(pod_name, None));
|
.unwrap_or_else(|| WorkerMetadata::new(worker_name, None));
|
||||||
update(&mut metadata);
|
update(&mut metadata);
|
||||||
metadata.pod_name = pod_name.to_string();
|
metadata.worker_name = worker_name.to_string();
|
||||||
self.write(&metadata)?;
|
self.write(&metadata)?;
|
||||||
Ok(metadata)
|
Ok(metadata)
|
||||||
}
|
}
|
||||||
|
|
@ -168,22 +172,22 @@ pub trait PodMetadataStore: Send + Sync {
|
||||||
/// Set the active pointer while preserving spawned children, workspace ownership, and manifest snapshot.
|
/// Set the active pointer while preserving spawned children, workspace ownership, and manifest snapshot.
|
||||||
fn set_active(
|
fn set_active(
|
||||||
&self,
|
&self,
|
||||||
pod_name: &str,
|
worker_name: &str,
|
||||||
active: Option<PodActiveSegmentRef>,
|
active: Option<WorkerActiveSegmentRef>,
|
||||||
resolved_manifest_snapshot: Option<serde_json::Value>,
|
resolved_manifest_snapshot: Option<serde_json::Value>,
|
||||||
) -> Result<PodMetadata, PodStoreError> {
|
) -> Result<WorkerMetadata, WorkerStoreError> {
|
||||||
self.set_active_with_workspace_root(pod_name, active, resolved_manifest_snapshot, None)
|
self.set_active_with_workspace_root(worker_name, active, resolved_manifest_snapshot, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the active pointer and workspace ownership while preserving unrelated fields.
|
/// Set the active pointer and workspace ownership while preserving unrelated fields.
|
||||||
fn set_active_with_workspace_root(
|
fn set_active_with_workspace_root(
|
||||||
&self,
|
&self,
|
||||||
pod_name: &str,
|
worker_name: &str,
|
||||||
active: Option<PodActiveSegmentRef>,
|
active: Option<WorkerActiveSegmentRef>,
|
||||||
resolved_manifest_snapshot: Option<serde_json::Value>,
|
resolved_manifest_snapshot: Option<serde_json::Value>,
|
||||||
workspace_root: Option<PathBuf>,
|
workspace_root: Option<PathBuf>,
|
||||||
) -> Result<PodMetadata, PodStoreError> {
|
) -> Result<WorkerMetadata, WorkerStoreError> {
|
||||||
self.update_by_name(pod_name, |metadata| {
|
self.update_by_name(worker_name, |metadata| {
|
||||||
metadata.active = active;
|
metadata.active = active;
|
||||||
metadata.resolved_manifest_snapshot = resolved_manifest_snapshot;
|
metadata.resolved_manifest_snapshot = resolved_manifest_snapshot;
|
||||||
if let Some(workspace_root) = workspace_root {
|
if let Some(workspace_root) = workspace_root {
|
||||||
|
|
@ -195,38 +199,56 @@ pub trait PodMetadataStore: Send + Sync {
|
||||||
/// Set spawned-child registry state while preserving active pointer and manifest snapshot.
|
/// Set spawned-child registry state while preserving active pointer and manifest snapshot.
|
||||||
fn set_spawned_children(
|
fn set_spawned_children(
|
||||||
&self,
|
&self,
|
||||||
pod_name: &str,
|
worker_name: &str,
|
||||||
children: Vec<PodSpawnedChild>,
|
children: Vec<WorkerSpawnedChild>,
|
||||||
) -> Result<PodMetadata, PodStoreError> {
|
) -> Result<WorkerMetadata, WorkerStoreError> {
|
||||||
self.update_by_name(pod_name, |metadata| {
|
self.update_by_name(worker_name, |metadata| {
|
||||||
metadata.spawned_children = children;
|
metadata.spawned_children = children;
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set peer visibility state while preserving active pointer, child state,
|
/// Set peer visibility state while preserving active pointer, child state,
|
||||||
/// and manifest snapshot.
|
/// and manifest snapshot.
|
||||||
fn set_peers(&self, pod_name: &str, peers: Vec<PodPeer>) -> Result<PodMetadata, PodStoreError> {
|
fn set_peers(
|
||||||
self.update_by_name(pod_name, |metadata| {
|
&self,
|
||||||
|
worker_name: &str,
|
||||||
|
peers: Vec<WorkerPeer>,
|
||||||
|
) -> Result<WorkerMetadata, WorkerStoreError> {
|
||||||
|
self.update_by_name(worker_name, |metadata| {
|
||||||
metadata.peers = peers;
|
metadata.peers = peers;
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add one peer if absent while preserving every other metadata field.
|
/// Add one peer if absent while preserving every other metadata field.
|
||||||
fn add_peer(&self, pod_name: &str, peer_name: &str) -> Result<PodMetadata, PodStoreError> {
|
fn add_peer(
|
||||||
self.update_by_name(pod_name, |metadata| {
|
&self,
|
||||||
if !metadata.peers.iter().any(|peer| peer.pod_name == peer_name) {
|
worker_name: &str,
|
||||||
metadata.peers.push(PodPeer {
|
peer_name: &str,
|
||||||
pod_name: peer_name.to_string(),
|
) -> Result<WorkerMetadata, WorkerStoreError> {
|
||||||
|
self.update_by_name(worker_name, |metadata| {
|
||||||
|
if !metadata
|
||||||
|
.peers
|
||||||
|
.iter()
|
||||||
|
.any(|peer| peer.worker_name == peer_name)
|
||||||
|
{
|
||||||
|
metadata.peers.push(WorkerPeer {
|
||||||
|
worker_name: peer_name.to_string(),
|
||||||
});
|
});
|
||||||
metadata.peers.sort_by(|a, b| a.pod_name.cmp(&b.pod_name));
|
metadata
|
||||||
|
.peers
|
||||||
|
.sort_by(|a, b| a.worker_name.cmp(&b.worker_name));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove one peer while preserving every other metadata field.
|
/// Remove one peer while preserving every other metadata field.
|
||||||
fn remove_peer(&self, pod_name: &str, peer_name: &str) -> Result<PodMetadata, PodStoreError> {
|
fn remove_peer(
|
||||||
self.update_by_name(pod_name, |metadata| {
|
&self,
|
||||||
metadata.peers.retain(|peer| peer.pod_name != peer_name);
|
worker_name: &str,
|
||||||
|
peer_name: &str,
|
||||||
|
) -> Result<WorkerMetadata, WorkerStoreError> {
|
||||||
|
self.update_by_name(worker_name, |metadata| {
|
||||||
|
metadata.peers.retain(|peer| peer.worker_name != peer_name);
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -234,47 +256,47 @@ pub trait PodMetadataStore: Send + Sync {
|
||||||
/// them in durable reclaim history.
|
/// them in durable reclaim history.
|
||||||
fn reclaim_spawned_children(
|
fn reclaim_spawned_children(
|
||||||
&self,
|
&self,
|
||||||
pod_name: &str,
|
worker_name: &str,
|
||||||
reclaimed: Vec<PodReclaimedChild>,
|
reclaimed: Vec<WorkerReclaimedChild>,
|
||||||
) -> Result<PodMetadata, PodStoreError> {
|
) -> Result<WorkerMetadata, WorkerStoreError> {
|
||||||
self.update_by_name(pod_name, |metadata| {
|
self.update_by_name(worker_name, |metadata| {
|
||||||
for reclaimed_child in &reclaimed {
|
for reclaimed_child in &reclaimed {
|
||||||
metadata
|
metadata
|
||||||
.spawned_children
|
.spawned_children
|
||||||
.retain(|child| child.pod_name != reclaimed_child.pod_name);
|
.retain(|child| child.worker_name != reclaimed_child.worker_name);
|
||||||
}
|
}
|
||||||
metadata.reclaimed_children.extend(reclaimed);
|
metadata.reclaimed_children.extend(reclaimed);
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Filesystem-backed Pod metadata store.
|
/// Filesystem-backed Worker metadata store.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct FsPodStore {
|
pub struct FsWorkerStore {
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FsPodStore {
|
impl FsWorkerStore {
|
||||||
/// Create a store rooted at the Pod-state directory, usually `{data_dir}/pods`.
|
/// Create a store rooted at the Worker-state directory, usually `{data_dir}/workers`.
|
||||||
pub fn new(root: impl Into<PathBuf>) -> Result<Self, PodStoreError> {
|
pub fn new(root: impl Into<PathBuf>) -> Result<Self, WorkerStoreError> {
|
||||||
let root = root.into();
|
let root = root.into();
|
||||||
fs::create_dir_all(&root)?;
|
fs::create_dir_all(&root)?;
|
||||||
Ok(Self { root })
|
Ok(Self { root })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pod_dir(&self, pod_name: &str) -> Result<PathBuf, PodStoreError> {
|
fn pod_dir(&self, worker_name: &str) -> Result<PathBuf, WorkerStoreError> {
|
||||||
validate_pod_name(pod_name)?;
|
validate_worker_name(worker_name)?;
|
||||||
Ok(self.root.join(pod_name))
|
Ok(self.root.join(worker_name))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn metadata_path(&self, pod_name: &str) -> Result<PathBuf, PodStoreError> {
|
fn metadata_path(&self, worker_name: &str) -> Result<PathBuf, WorkerStoreError> {
|
||||||
Ok(self.pod_dir(pod_name)?.join("metadata.json"))
|
Ok(self.pod_dir(worker_name)?.join("metadata.json"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PodMetadataStore for FsPodStore {
|
impl WorkerMetadataStore for FsWorkerStore {
|
||||||
fn write(&self, metadata: &PodMetadata) -> Result<(), PodStoreError> {
|
fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError> {
|
||||||
let path = self.metadata_path(&metadata.pod_name)?;
|
let path = self.metadata_path(&metadata.worker_name)?;
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent)?;
|
fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
|
|
@ -283,17 +305,17 @@ impl PodMetadataStore for FsPodStore {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_by_name(&self, pod_name: &str) -> Result<Option<PodMetadata>, PodStoreError> {
|
fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> {
|
||||||
let path = self.metadata_path(pod_name)?;
|
let path = self.metadata_path(worker_name)?;
|
||||||
let content = match fs::read_to_string(path) {
|
let content = match fs::read_to_string(path) {
|
||||||
Ok(content) => content,
|
Ok(content) => content,
|
||||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||||
Err(err) => return Err(PodStoreError::Io(err)),
|
Err(err) => return Err(WorkerStoreError::Io(err)),
|
||||||
};
|
};
|
||||||
Ok(Some(serde_json::from_str(&content)?))
|
Ok(Some(serde_json::from_str(&content)?))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn list_names(&self) -> Result<Vec<String>, PodStoreError> {
|
fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> {
|
||||||
let mut names = Vec::new();
|
let mut names = Vec::new();
|
||||||
if !self.root.exists() {
|
if !self.root.exists() {
|
||||||
return Ok(names);
|
return Ok(names);
|
||||||
|
|
@ -309,7 +331,7 @@ impl PodMetadataStore for FsPodStore {
|
||||||
let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else {
|
let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if validate_pod_name(&name).is_ok() {
|
if validate_worker_name(&name).is_ok() {
|
||||||
names.push(name);
|
names.push(name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -321,12 +343,12 @@ impl PodMetadataStore for FsPodStore {
|
||||||
Some(self.root.clone())
|
Some(self.root.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn delete_by_name(&self, pod_name: &str) -> Result<(), PodStoreError> {
|
fn delete_by_name(&self, worker_name: &str) -> Result<(), WorkerStoreError> {
|
||||||
let path = self.metadata_path(pod_name)?;
|
let path = self.metadata_path(worker_name)?;
|
||||||
match fs::remove_file(&path) {
|
match fs::remove_file(&path) {
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||||
Err(err) => return Err(PodStoreError::Io(err)),
|
Err(err) => return Err(WorkerStoreError::Io(err)),
|
||||||
}
|
}
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
let _ = fs::remove_dir(parent);
|
let _ = fs::remove_dir(parent);
|
||||||
|
|
@ -335,20 +357,20 @@ impl PodMetadataStore for FsPodStore {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn validate_pod_name(pod_name: &str) -> Result<(), PodStoreError> {
|
pub fn validate_worker_name(worker_name: &str) -> Result<(), WorkerStoreError> {
|
||||||
if pod_name.is_empty()
|
if worker_name.is_empty()
|
||||||
|| pod_name == "."
|
|| worker_name == "."
|
||||||
|| pod_name == ".."
|
|| worker_name == ".."
|
||||||
|| pod_name.contains('/')
|
|| worker_name.contains('/')
|
||||||
|| pod_name.contains('\0')
|
|| worker_name.contains('\0')
|
||||||
{
|
{
|
||||||
return Err(PodStoreError::InvalidPodName(pod_name.to_string()));
|
return Err(WorkerStoreError::InvalidPodName(worker_name.to_string()));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience composition for callers that want one handle carrying separate
|
/// Convenience composition for callers that want one handle carrying separate
|
||||||
/// session-log and Pod-state roots.
|
/// session-log and Worker-state roots.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct CombinedStore<S, P> {
|
pub struct CombinedStore<S, P> {
|
||||||
pub session_store: S,
|
pub session_store: S,
|
||||||
|
|
@ -442,25 +464,25 @@ where
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<S, P> PodMetadataStore for CombinedStore<S, P>
|
impl<S, P> WorkerMetadataStore for CombinedStore<S, P>
|
||||||
where
|
where
|
||||||
S: Send + Sync,
|
S: Send + Sync,
|
||||||
P: PodMetadataStore,
|
P: WorkerMetadataStore,
|
||||||
{
|
{
|
||||||
fn write(&self, metadata: &PodMetadata) -> Result<(), PodStoreError> {
|
fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError> {
|
||||||
self.pod_store.write(metadata)
|
self.pod_store.write(metadata)
|
||||||
}
|
}
|
||||||
fn read_by_name(&self, pod_name: &str) -> Result<Option<PodMetadata>, PodStoreError> {
|
fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> {
|
||||||
self.pod_store.read_by_name(pod_name)
|
self.pod_store.read_by_name(worker_name)
|
||||||
}
|
}
|
||||||
fn list_names(&self) -> Result<Vec<String>, PodStoreError> {
|
fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> {
|
||||||
self.pod_store.list_names()
|
self.pod_store.list_names()
|
||||||
}
|
}
|
||||||
fn root_dir(&self) -> Option<PathBuf> {
|
fn root_dir(&self) -> Option<PathBuf> {
|
||||||
self.pod_store.root_dir()
|
self.pod_store.root_dir()
|
||||||
}
|
}
|
||||||
fn delete_by_name(&self, pod_name: &str) -> Result<(), PodStoreError> {
|
fn delete_by_name(&self, worker_name: &str) -> Result<(), WorkerStoreError> {
|
||||||
self.pod_store.delete_by_name(pod_name)
|
self.pod_store.delete_by_name(worker_name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -470,9 +492,9 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pod_metadata_manifest_snapshot_roundtrips() {
|
fn pod_metadata_manifest_snapshot_roundtrips() {
|
||||||
let mut metadata = PodMetadata::new(
|
let mut metadata = WorkerMetadata::new(
|
||||||
"profile-pod",
|
"profile-pod",
|
||||||
Some(PodActiveSegmentRef::pending_segment(
|
Some(WorkerActiveSegmentRef::pending_segment(
|
||||||
session_store::new_session_id(),
|
session_store::new_session_id(),
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
|
|
@ -482,7 +504,7 @@ mod tests {
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let json = serde_json::to_string(&metadata).unwrap();
|
let json = serde_json::to_string(&metadata).unwrap();
|
||||||
let restored: PodMetadata = serde_json::from_str(&json).unwrap();
|
let restored: WorkerMetadata = serde_json::from_str(&json).unwrap();
|
||||||
|
|
||||||
assert_eq!(restored, metadata);
|
assert_eq!(restored, metadata);
|
||||||
}
|
}
|
||||||
|
|
@ -491,29 +513,29 @@ mod tests {
|
||||||
fn fs_store_writes_under_pod_state_root_only() {
|
fn fs_store_writes_under_pod_state_root_only() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
let session_root = tmp.path().join("sessions");
|
let session_root = tmp.path().join("sessions");
|
||||||
let pod_root = tmp.path().join("pods");
|
let pod_root = tmp.path().join("workers");
|
||||||
fs::create_dir_all(&session_root).unwrap();
|
fs::create_dir_all(&session_root).unwrap();
|
||||||
let store = FsPodStore::new(&pod_root).unwrap();
|
let store = FsWorkerStore::new(&pod_root).unwrap();
|
||||||
store
|
store
|
||||||
.write(&PodMetadata::new(
|
.write(&WorkerMetadata::new(
|
||||||
"agent",
|
"agent",
|
||||||
Some(PodActiveSegmentRef::pending_segment(
|
Some(WorkerActiveSegmentRef::pending_segment(
|
||||||
session_store::new_session_id(),
|
session_store::new_session_id(),
|
||||||
)),
|
)),
|
||||||
))
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(pod_root.join("agent/metadata.json").exists());
|
assert!(pod_root.join("agent/metadata.json").exists());
|
||||||
assert!(!session_root.join("pods/agent/metadata.json").exists());
|
assert!(!session_root.join("workers/agent/metadata.json").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn active_updates_preserve_children_and_manifest_snapshot() {
|
fn active_updates_preserve_children_and_manifest_snapshot() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
let store = FsPodStore::new(tmp.path()).unwrap();
|
let store = FsWorkerStore::new(tmp.path()).unwrap();
|
||||||
let mut metadata = PodMetadata::new("agent", None);
|
let mut metadata = WorkerMetadata::new("agent", None);
|
||||||
metadata.spawned_children.push(PodSpawnedChild {
|
metadata.spawned_children.push(WorkerSpawnedChild {
|
||||||
pod_name: "child".into(),
|
worker_name: "child".into(),
|
||||||
socket_path: std::path::Path::new("/tmp/child.sock").into(),
|
socket_path: std::path::Path::new("/tmp/child.sock").into(),
|
||||||
scope_delegated: vec![],
|
scope_delegated: vec![],
|
||||||
callback_address: std::path::Path::new("/tmp/parent.sock").into(),
|
callback_address: std::path::Path::new("/tmp/parent.sock").into(),
|
||||||
|
|
@ -525,7 +547,7 @@ mod tests {
|
||||||
store
|
store
|
||||||
.set_active(
|
.set_active(
|
||||||
"agent",
|
"agent",
|
||||||
Some(PodActiveSegmentRef::active_segment(
|
Some(WorkerActiveSegmentRef::active_segment(
|
||||||
session_store::new_session_id(),
|
session_store::new_session_id(),
|
||||||
session_store::new_segment_id(),
|
session_store::new_segment_id(),
|
||||||
)),
|
)),
|
||||||
|
|
@ -540,8 +562,8 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn child_updates_preserve_active_and_manifest_snapshot() {
|
fn child_updates_preserve_active_and_manifest_snapshot() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
let store = FsPodStore::new(tmp.path()).unwrap();
|
let store = FsWorkerStore::new(tmp.path()).unwrap();
|
||||||
let active = PodActiveSegmentRef::active_segment(
|
let active = WorkerActiveSegmentRef::active_segment(
|
||||||
session_store::new_session_id(),
|
session_store::new_session_id(),
|
||||||
session_store::new_segment_id(),
|
session_store::new_segment_id(),
|
||||||
);
|
);
|
||||||
|
|
@ -552,8 +574,8 @@ mod tests {
|
||||||
store
|
store
|
||||||
.set_spawned_children(
|
.set_spawned_children(
|
||||||
"agent",
|
"agent",
|
||||||
vec![PodSpawnedChild {
|
vec![WorkerSpawnedChild {
|
||||||
pod_name: "child".into(),
|
worker_name: "child".into(),
|
||||||
socket_path: std::path::Path::new("/tmp/child.sock").into(),
|
socket_path: std::path::Path::new("/tmp/child.sock").into(),
|
||||||
scope_delegated: vec![],
|
scope_delegated: vec![],
|
||||||
callback_address: std::path::Path::new("/tmp/parent.sock").into(),
|
callback_address: std::path::Path::new("/tmp/parent.sock").into(),
|
||||||
|
|
@ -568,8 +590,8 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn peer_updates_preserve_active_children_and_manifest_snapshot() {
|
fn peer_updates_preserve_active_children_and_manifest_snapshot() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
let store = FsPodStore::new(tmp.path()).unwrap();
|
let store = FsWorkerStore::new(tmp.path()).unwrap();
|
||||||
let active = PodActiveSegmentRef::active_segment(
|
let active = WorkerActiveSegmentRef::active_segment(
|
||||||
session_store::new_session_id(),
|
session_store::new_session_id(),
|
||||||
session_store::new_segment_id(),
|
session_store::new_segment_id(),
|
||||||
);
|
);
|
||||||
|
|
@ -580,8 +602,8 @@ mod tests {
|
||||||
store
|
store
|
||||||
.set_spawned_children(
|
.set_spawned_children(
|
||||||
"agent",
|
"agent",
|
||||||
vec![PodSpawnedChild {
|
vec![WorkerSpawnedChild {
|
||||||
pod_name: "child".into(),
|
worker_name: "child".into(),
|
||||||
socket_path: std::path::Path::new("/tmp/child.sock").into(),
|
socket_path: std::path::Path::new("/tmp/child.sock").into(),
|
||||||
scope_delegated: vec![],
|
scope_delegated: vec![],
|
||||||
callback_address: std::path::Path::new("/tmp/parent.sock").into(),
|
callback_address: std::path::Path::new("/tmp/parent.sock").into(),
|
||||||
|
|
@ -600,7 +622,7 @@ mod tests {
|
||||||
restored
|
restored
|
||||||
.peers
|
.peers
|
||||||
.iter()
|
.iter()
|
||||||
.map(|peer| peer.pod_name.as_str())
|
.map(|peer| peer.worker_name.as_str())
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
vec!["peer-a", "peer-b"]
|
vec!["peer-a", "peer-b"]
|
||||||
);
|
);
|
||||||
|
|
@ -608,14 +630,14 @@ mod tests {
|
||||||
store.remove_peer("agent", "peer-a").unwrap();
|
store.remove_peer("agent", "peer-a").unwrap();
|
||||||
let restored = store.read_by_name("agent").unwrap().unwrap();
|
let restored = store.read_by_name("agent").unwrap().unwrap();
|
||||||
assert_eq!(restored.peers.len(), 1);
|
assert_eq!(restored.peers.len(), 1);
|
||||||
assert_eq!(restored.peers[0].pod_name, "peer-b");
|
assert_eq!(restored.peers[0].worker_name, "peer-b");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reclaim_children_removes_outstanding_and_records_history() {
|
fn reclaim_children_removes_outstanding_and_records_history() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
let store = FsPodStore::new(tmp.path()).unwrap();
|
let store = FsWorkerStore::new(tmp.path()).unwrap();
|
||||||
let scope = PodSpawnedScopeRule {
|
let scope = WorkerSpawnedScopeRule {
|
||||||
target: std::path::Path::new("/tmp/delegated").into(),
|
target: std::path::Path::new("/tmp/delegated").into(),
|
||||||
permission: "write".into(),
|
permission: "write".into(),
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
|
@ -623,8 +645,8 @@ mod tests {
|
||||||
store
|
store
|
||||||
.set_spawned_children(
|
.set_spawned_children(
|
||||||
"agent",
|
"agent",
|
||||||
vec![PodSpawnedChild {
|
vec![WorkerSpawnedChild {
|
||||||
pod_name: "child".into(),
|
worker_name: "child".into(),
|
||||||
socket_path: std::path::Path::new("/tmp/child.sock").into(),
|
socket_path: std::path::Path::new("/tmp/child.sock").into(),
|
||||||
scope_delegated: vec![scope.clone()],
|
scope_delegated: vec![scope.clone()],
|
||||||
callback_address: std::path::Path::new("/tmp/parent.sock").into(),
|
callback_address: std::path::Path::new("/tmp/parent.sock").into(),
|
||||||
|
|
@ -635,8 +657,8 @@ mod tests {
|
||||||
store
|
store
|
||||||
.reclaim_spawned_children(
|
.reclaim_spawned_children(
|
||||||
"agent",
|
"agent",
|
||||||
vec![PodReclaimedChild {
|
vec![WorkerReclaimedChild {
|
||||||
pod_name: "child".into(),
|
worker_name: "child".into(),
|
||||||
scope_delegated: vec![scope.clone()],
|
scope_delegated: vec![scope.clone()],
|
||||||
}],
|
}],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
# pod
|
|
||||||
|
|
||||||
## Role
|
|
||||||
|
|
||||||
`pod` turns an `llm-engine` Engine into a named runtime entity with manifest configuration, scoped tools, session persistence, protocol handling, and Pod metadata integration.
|
|
||||||
|
|
||||||
## Boundaries
|
|
||||||
|
|
||||||
Owns:
|
|
||||||
|
|
||||||
- Pod lifecycle and socket protocol serving
|
|
||||||
- Engine construction around a resolved Manifest
|
|
||||||
- session-store and pod-store coordination
|
|
||||||
- built-in tool registration under scope/policy
|
|
||||||
- spawned-child orchestration hooks
|
|
||||||
|
|
||||||
Does not own:
|
|
||||||
|
|
||||||
- provider-specific wire formats (`provider` / `llm-engine` clients)
|
|
||||||
- product CLI parsing (`yoi`)
|
|
||||||
- TUI display authority (`tui`)
|
|
||||||
- current-state storage schema outside Pod metadata (`pod-store`)
|
|
||||||
|
|
||||||
## Design notes
|
|
||||||
|
|
||||||
A Pod is runtime authority, not UI state. It should commit model-visible events through history/session paths and keep current Pod-name state in Pod metadata rather than in transient runtime files.
|
|
||||||
|
|
||||||
## See also
|
|
||||||
|
|
||||||
- [`../../docs/design/pod-session-state.md`](../../docs/design/pod-session-state.md)
|
|
||||||
- [`../../docs/design/context-history.md`](../../docs/design/context-history.md)
|
|
||||||
- [`../../docs/design/tool-permissions-scope.md`](../../docs/design/tool-permissions-scope.md)
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
## Role
|
## Role
|
||||||
|
|
||||||
`protocol` defines the JSONL message boundary between Pod clients and Pod servers.
|
`protocol` defines the JSONL message boundary between Worker clients and Worker servers.
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
|
|
@ -14,7 +14,7 @@ Owns:
|
||||||
|
|
||||||
Does not own:
|
Does not own:
|
||||||
|
|
||||||
- Unix socket implementation details (`client`, `pod`)
|
- Unix socket implementation details (`client`, `worker`)
|
||||||
- TUI rendering (`tui`)
|
- TUI rendering (`tui`)
|
||||||
- Engine history semantics (`llm-engine`)
|
- Engine history semantics (`llm-engine`)
|
||||||
- durable storage (`session-store`, `pod-store`)
|
- durable storage (`session-store`, `pod-store`)
|
||||||
|
|
@ -23,9 +23,9 @@ Does not own:
|
||||||
|
|
||||||
The exact enum variants are code authority. The README should describe the boundary, not duplicate every message shape.
|
The exact enum variants are code authority. The README should describe the boundary, not duplicate every message shape.
|
||||||
|
|
||||||
Protocol events can inform UI and orchestration, but durable state changes still need to flow through Pod/session/metadata records.
|
Protocol events can inform UI and orchestration, but durable state changes still need to flow through Worker/session/metadata records.
|
||||||
|
|
||||||
## See also
|
## See also
|
||||||
|
|
||||||
- [`../../docs/design/pod-session-state.md`](../../docs/design/pod-session-state.md)
|
- [`../../docs/design/worker-session-state.md`](../../docs/design/worker-session-state.md)
|
||||||
- [`../../docs/design/context-history.md`](../../docs/design/context-history.md)
|
- [`../../docs/design/context-history.md`](../../docs/design/context-history.md)
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ fn is_false(value: &bool) -> bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Method (Client → Pod via Unix Socket)
|
// Method (Client → Worker via Unix Socket)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -30,34 +30,34 @@ pub enum Method {
|
||||||
Run {
|
Run {
|
||||||
input: Vec<Segment>,
|
input: Vec<Segment>,
|
||||||
},
|
},
|
||||||
/// Human-readable text injected into the target Pod's LLM context
|
/// Human-readable text injected into the target Worker's LLM context
|
||||||
/// as a non-blocking system message. `auto_run` controls whether an
|
/// as a non-blocking system message. `auto_run` controls whether an
|
||||||
/// idle target is kicked into `RunForNotification`; weak notifications
|
/// idle target is kicked into `RunForNotification`; weak notifications
|
||||||
/// (`auto_run: false`) are only queued for the next turn/resume/run.
|
/// (`auto_run: false`) are only queued for the next turn/resume/run.
|
||||||
/// No side effects beyond LLM context; use `PodEvent` for typed
|
/// No side effects beyond LLM context; use `WorkerEvent` for typed
|
||||||
/// lifecycle reports.
|
/// lifecycle reports.
|
||||||
Notify {
|
Notify {
|
||||||
message: String,
|
message: String,
|
||||||
#[serde(default = "default_true", skip_serializing_if = "is_true")]
|
#[serde(default = "default_true", skip_serializing_if = "is_true")]
|
||||||
auto_run: bool,
|
auto_run: bool,
|
||||||
},
|
},
|
||||||
/// Typed lifecycle report from a child Pod to its direct parent.
|
/// Typed lifecycle report from a child Worker to its direct parent.
|
||||||
PodEvent(PodEvent),
|
WorkerEvent(WorkerEvent),
|
||||||
Resume,
|
Resume,
|
||||||
Cancel,
|
Cancel,
|
||||||
/// Stop the in-flight turn and transition to `Paused`.
|
/// Stop the in-flight turn and transition to `Paused`.
|
||||||
///
|
///
|
||||||
/// Unlike `Cancel` (which discards and returns to `Idle`), a paused
|
/// Unlike `Cancel` (which discards and returns to `Idle`), a paused
|
||||||
/// Pod can resume the interrupted work via `Resume`, or start a
|
/// Worker can resume the interrupted work via `Resume`, or start a
|
||||||
/// fresh turn via `Run` (orphan `tool_use` items are closed with a
|
/// fresh turn via `Run` (orphan `tool_use` items are closed with a
|
||||||
/// synthetic tool result before the new user message is appended).
|
/// synthetic tool result before the new user message is appended).
|
||||||
Pause,
|
Pause,
|
||||||
/// Request an explicit compaction while the Pod is otherwise idle.
|
/// Request an explicit compaction while the Worker is otherwise idle.
|
||||||
///
|
///
|
||||||
/// This is a typed control method: clients must not send `compact` as a
|
/// This is a typed control method: clients must not send `compact` as a
|
||||||
/// `Method::Run` user message.
|
/// `Method::Run` user message.
|
||||||
Compact,
|
Compact,
|
||||||
/// Ask the Pod to list valid rewind targets from its authoritative session log.
|
/// Ask the Worker to list valid rewind targets from its authoritative session log.
|
||||||
ListRewindTargets,
|
ListRewindTargets,
|
||||||
/// Truncate the current session back to the selected rewind target and
|
/// Truncate the current session back to the selected rewind target and
|
||||||
/// return the selected user input to the client composer.
|
/// return the selected user input to the client composer.
|
||||||
|
|
@ -66,7 +66,7 @@ pub enum Method {
|
||||||
expected_head_entries: usize,
|
expected_head_entries: usize,
|
||||||
},
|
},
|
||||||
Shutdown,
|
Shutdown,
|
||||||
/// Request a list of completion candidates from the Pod.
|
/// Request a list of completion candidates from the Worker.
|
||||||
///
|
///
|
||||||
/// Reply is sent on the same socket as `Event::Completions` (not
|
/// Reply is sent on the same socket as `Event::Completions` (not
|
||||||
/// broadcast). The IPC server handles this directly and writes
|
/// broadcast). The IPC server handles this directly and writes
|
||||||
|
|
@ -77,15 +77,15 @@ pub enum Method {
|
||||||
kind: CompletionKind,
|
kind: CompletionKind,
|
||||||
prefix: String,
|
prefix: String,
|
||||||
},
|
},
|
||||||
/// List Pods visible to this Pod from durable Pod state and the spawned-child
|
/// List Workers visible to this Worker from durable Worker state and the spawned-child
|
||||||
/// registry. This is not a host-wide Pod universe query.
|
/// registry. This is not a host-wide Worker universe query.
|
||||||
ListPods,
|
ListWorkers,
|
||||||
/// Restore a visible stopped/restorable Pod, or report that it is already
|
/// Restore a visible stopped/restorable Worker, or report that it is already
|
||||||
/// live. Missing state and not-visible state are distinct errors.
|
/// live. Missing state and not-visible state are distinct errors.
|
||||||
RestorePod {
|
RestoreWorker {
|
||||||
name: String,
|
name: String,
|
||||||
},
|
},
|
||||||
/// Register another existing Pod as a reciprocal peer of this Pod.
|
/// Register another existing Worker as a reciprocal peer of this Worker.
|
||||||
///
|
///
|
||||||
/// This is metadata/control state only: it does not ask the target's live
|
/// This is metadata/control state only: it does not ask the target's live
|
||||||
/// controller for consent, and it must not grant delegated scope,
|
/// controller for consent, and it must not grant delegated scope,
|
||||||
|
|
@ -95,9 +95,9 @@ pub enum Method {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Typed lifecycle events sent from a child Pod to its parent.
|
/// Typed lifecycle events sent from a child Worker to its parent.
|
||||||
///
|
///
|
||||||
/// Delivered as `Method::PodEvent` over the parent's Unix socket. The
|
/// Delivered as `Method::WorkerEvent` over the parent's Unix socket. The
|
||||||
/// parent Controller always applies variant-specific side effects
|
/// parent Controller always applies variant-specific side effects
|
||||||
/// (registry / pod-registry updates). Agent-visible variants are also
|
/// (registry / pod-registry updates). Agent-visible variants are also
|
||||||
/// queued into the notification buffer; control-plane-only variants are
|
/// queued into the notification buffer; control-plane-only variants are
|
||||||
|
|
@ -105,39 +105,42 @@ pub enum Method {
|
||||||
///
|
///
|
||||||
/// Transport is fire-and-forget; receivers must tolerate out-of-order
|
/// Transport is fire-and-forget; receivers must tolerate out-of-order
|
||||||
/// delivery (e.g. `TurnEnded` arriving after `ShutDown` for the same
|
/// delivery (e.g. `TurnEnded` arriving after `ShutDown` for the same
|
||||||
/// child Pod).
|
/// child Worker).
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
pub enum PodEvent {
|
pub enum WorkerEvent {
|
||||||
/// Child finished one turn and is back to IDLE.
|
/// Child finished one turn and is back to IDLE.
|
||||||
TurnEnded { pod_name: String },
|
TurnEnded { worker_name: String },
|
||||||
|
|
||||||
/// Engine execution error occurred inside the child's turn.
|
/// Engine execution error occurred inside the child's turn.
|
||||||
///
|
///
|
||||||
/// Limited to worker runtime failures (provider / tool errors) —
|
/// Limited to worker runtime failures (provider / tool errors) —
|
||||||
/// does not include transient method-rejection responses such as
|
/// does not include transient method-rejection responses such as
|
||||||
/// `AlreadyRunning`.
|
/// `AlreadyRunning`.
|
||||||
Errored { pod_name: String, message: String },
|
Errored {
|
||||||
|
worker_name: String,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
|
||||||
/// Child has stopped (controller loop is exiting).
|
/// Child has stopped (controller loop is exiting).
|
||||||
ShutDown { pod_name: String },
|
ShutDown { worker_name: String },
|
||||||
|
|
||||||
/// Child sub-delegated scope to a grandchild Pod via `SpawnPod`.
|
/// Child sub-delegated scope to a grandchild Worker via `SpawnWorker`.
|
||||||
///
|
///
|
||||||
/// Control-plane only: receivers apply registry side effects and
|
/// Control-plane only: receivers apply registry side effects and
|
||||||
/// propagate upward, but do not expose this as an agent notification.
|
/// propagate upward, but do not expose this as an agent notification.
|
||||||
///
|
///
|
||||||
/// The parent uses this to add the grandchild to its own
|
/// The parent uses this to add the grandchild to its own
|
||||||
/// `spawned_pods.json` so it can manage the grandchild directly
|
/// `spawned_workers.json` so it can manage the grandchild directly
|
||||||
/// even if the intermediate child dies. The parent then re-fires
|
/// even if the intermediate child dies. The parent then re-fires
|
||||||
/// this event upward (if it has a parent of its own) to maintain
|
/// this event upward (if it has a parent of its own) to maintain
|
||||||
/// the chain to root.
|
/// the chain to root.
|
||||||
ScopeSubDelegated {
|
ScopeSubDelegated {
|
||||||
/// Sub-delegating Pod (= the sender itself).
|
/// Sub-delegating Worker (= the sender itself).
|
||||||
parent_pod: String,
|
parent_worker: String,
|
||||||
/// Name of the grandchild Pod.
|
/// Name of the grandchild Worker.
|
||||||
sub_pod: String,
|
sub_worker: String,
|
||||||
/// Unix-socket path where the grandchild is reachable.
|
/// Unix-socket path where the grandchild is reachable.
|
||||||
sub_socket: PathBuf,
|
sub_socket: PathBuf,
|
||||||
/// Scope delegated to the grandchild.
|
/// Scope delegated to the grandchild.
|
||||||
|
|
@ -145,7 +148,7 @@ pub enum PodEvent {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PodEvent {
|
impl WorkerEvent {
|
||||||
/// Whether this event should become an agent-visible notification/history item.
|
/// Whether this event should become an agent-visible notification/history item.
|
||||||
///
|
///
|
||||||
/// Control-plane-only events still travel over the same wire enum and still
|
/// Control-plane-only events still travel over the same wire enum and still
|
||||||
|
|
@ -153,10 +156,10 @@ impl PodEvent {
|
||||||
/// the notification buffer.
|
/// the notification buffer.
|
||||||
pub fn should_notify_agent(&self) -> bool {
|
pub fn should_notify_agent(&self) -> bool {
|
||||||
match self {
|
match self {
|
||||||
PodEvent::TurnEnded { .. } | PodEvent::Errored { .. } | PodEvent::ShutDown { .. } => {
|
WorkerEvent::TurnEnded { .. }
|
||||||
true
|
| WorkerEvent::Errored { .. }
|
||||||
}
|
| WorkerEvent::ShutDown { .. } => true,
|
||||||
PodEvent::ScopeSubDelegated { .. } => false,
|
WorkerEvent::ScopeSubDelegated { .. } => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -171,11 +174,11 @@ impl PodEvent {
|
||||||
/// clients (CLI piping, scripts) only need to produce a single
|
/// clients (CLI piping, scripts) only need to produce a single
|
||||||
/// `Segment::Text`; richer clients (TUI / GUI) construct typed atoms
|
/// `Segment::Text`; richer clients (TUI / GUI) construct typed atoms
|
||||||
/// (paste chips, file refs, knowledge refs, workflow invocations) and
|
/// (paste chips, file refs, knowledge refs, workflow invocations) and
|
||||||
/// send them through directly so the Pod side never has to re-parse a
|
/// send them through directly so the Worker side never has to re-parse a
|
||||||
/// flattened string.
|
/// flattened string.
|
||||||
///
|
///
|
||||||
/// Forward compat: payloads with unknown `kind` deserialize to
|
/// Forward compat: payloads with unknown `kind` deserialize to
|
||||||
/// `Segment::Unknown`. Pod treats this the same as known-but-unresolved
|
/// `Segment::Unknown`. Worker treats this the same as known-but-unresolved
|
||||||
/// variants — emits an alert and inserts a `[unknown input segment]`
|
/// variants — emits an alert and inserts a `[unknown input segment]`
|
||||||
/// placeholder into the LLM context so neither user nor LLM is blind to
|
/// placeholder into the LLM context so neither user nor LLM is blind to
|
||||||
/// the dropped intent.
|
/// the dropped intent.
|
||||||
|
|
@ -195,7 +198,7 @@ pub enum Segment {
|
||||||
lines: u32,
|
lines: u32,
|
||||||
content: String,
|
content: String,
|
||||||
},
|
},
|
||||||
/// `@<path>` file-system reference. Pod resolves readable files to
|
/// `@<path>` file-system reference. Worker resolves readable files to
|
||||||
/// `[File: <path>]` attachments and readable normal directories to shallow
|
/// `[File: <path>]` attachments and readable normal directories to shallow
|
||||||
/// `[Dir: <path>]` listings; the flattened user text keeps the literal
|
/// `[Dir: <path>]` listings; the flattened user text keeps the literal
|
||||||
/// `@<path>` placeholder either way.
|
/// `@<path>` placeholder either way.
|
||||||
|
|
@ -204,7 +207,7 @@ pub enum Segment {
|
||||||
KnowledgeRef { slug: String },
|
KnowledgeRef { slug: String },
|
||||||
/// `/<slug>` Workflow invocation (see `docs/plan/workflow.md`).
|
/// `/<slug>` Workflow invocation (see `docs/plan/workflow.md`).
|
||||||
WorkflowInvoke { slug: String },
|
WorkflowInvoke { slug: String },
|
||||||
/// Unknown variant from a newer client. Pod treats this as an
|
/// Unknown variant from a newer client. Worker treats this as an
|
||||||
/// unresolved input — surfaces an alert and inserts a placeholder.
|
/// unresolved input — surfaces an alert and inserts a placeholder.
|
||||||
/// Round-trip is lossy: re-serializing yields `{"kind":"unknown"}`.
|
/// Round-trip is lossy: re-serializing yields `{"kind":"unknown"}`.
|
||||||
#[serde(other)]
|
#[serde(other)]
|
||||||
|
|
@ -220,7 +223,7 @@ impl Segment {
|
||||||
/// Flatten a segment slice into the single string the LLM receives
|
/// Flatten a segment slice into the single string the LLM receives
|
||||||
/// as a user message. Pure — no I/O, no alerts. Callers that need
|
/// as a user message. Pure — no I/O, no alerts. Callers that need
|
||||||
/// to surface user-visible alerts for unresolved refs should do so
|
/// to surface user-visible alerts for unresolved refs should do so
|
||||||
/// alongside this call (Pod does so at submit time).
|
/// alongside this call (Worker does so at submit time).
|
||||||
///
|
///
|
||||||
/// Sigil-prefixed variants (`FileRef` / `KnowledgeRef` / `WorkflowInvoke`)
|
/// Sigil-prefixed variants (`FileRef` / `KnowledgeRef` / `WorkflowInvoke`)
|
||||||
/// flatten back to their literal sigil form (`@<path>`, `#<slug>`,
|
/// flatten back to their literal sigil form (`@<path>`, `#<slug>`,
|
||||||
|
|
@ -258,7 +261,7 @@ impl Segment {
|
||||||
|
|
||||||
impl Method {
|
impl Method {
|
||||||
/// Convenience: a `Run` carrying a single `Segment::Text`.
|
/// Convenience: a `Run` carrying a single `Segment::Text`.
|
||||||
/// Used by dumb clients, inter-Pod tools, and tests that only have
|
/// Used by dumb clients, inter-Worker tools, and tests that only have
|
||||||
/// a string to forward.
|
/// a string to forward.
|
||||||
pub fn run_text(s: impl Into<String>) -> Self {
|
pub fn run_text(s: impl Into<String>) -> Self {
|
||||||
Self::Run {
|
Self::Run {
|
||||||
|
|
@ -268,7 +271,7 @@ impl Method {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Event (Pod → Client via Unix Socket broadcast)
|
// Event (Worker → Client via Unix Socket broadcast)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -291,8 +294,8 @@ pub enum Event {
|
||||||
/// One agent-injected system item committed to history.
|
/// One agent-injected system item committed to history.
|
||||||
///
|
///
|
||||||
/// Carries the JSON form of `session_store::SystemItem`. Covers
|
/// Carries the JSON form of `session_store::SystemItem`. Covers
|
||||||
/// `Method::Notify` echoes, child-Pod lifecycle events from
|
/// `Method::Notify` echoes, child-Worker lifecycle events from
|
||||||
/// `Method::PodEvent`, `@<path>` / `#<slug>` / `/<slug>`
|
/// `Method::WorkerEvent`, `@<path>` / `#<slug>` / `/<slug>`
|
||||||
/// resolution payloads, and any future agent-side injection kind.
|
/// resolution payloads, and any future agent-side injection kind.
|
||||||
/// Clients dispatch on the `kind` tag for typed rendering instead
|
/// Clients dispatch on the `kind` tag for typed rendering instead
|
||||||
/// of parsing free-text prefixes like `[Notification] …` or
|
/// of parsing free-text prefixes like `[Notification] …` or
|
||||||
|
|
@ -309,13 +312,13 @@ pub enum Event {
|
||||||
/// Marker event for the start of an Invoke range; the range extends
|
/// Marker event for the start of an Invoke range; the range extends
|
||||||
/// implicitly until the next `InvokeStart`. Fires for every accepted
|
/// implicitly until the next `InvokeStart`. Fires for every accepted
|
||||||
/// `Method::Run` (kind=`UserSend`), `Method::Notify` (kind=`Notify`),
|
/// `Method::Run` (kind=`UserSend`), `Method::Notify` (kind=`Notify`),
|
||||||
/// `Method::PodEvent` re-injection (kind=`PodEvent`), and any other
|
/// `Method::WorkerEvent` re-injection (kind=`WorkerEvent`), and any other
|
||||||
/// IDLE-breaking trigger. Mid-run interrupts (e.g. hook output,
|
/// IDLE-breaking trigger. Mid-run interrupts (e.g. hook output,
|
||||||
/// `<system-reminder>` injection that doesn't break IDLE) do not
|
/// `<system-reminder>` injection that doesn't break IDLE) do not
|
||||||
/// emit `InvokeStart` — they appear as `SystemItem` only.
|
/// emit `InvokeStart` — they appear as `SystemItem` only.
|
||||||
///
|
///
|
||||||
/// Carries `kind` only; the payload (user text / notify message /
|
/// Carries `kind` only; the payload (user text / notify message /
|
||||||
/// pod event body) is delivered separately via the immediately
|
/// worker event body) is delivered separately via the immediately
|
||||||
/// following `UserMessage` / `SystemItem` event.
|
/// following `UserMessage` / `SystemItem` event.
|
||||||
InvokeStart {
|
InvokeStart {
|
||||||
kind: InvokeKind,
|
kind: InvokeKind,
|
||||||
|
|
@ -453,7 +456,7 @@ pub enum Event {
|
||||||
/// derived view.
|
/// derived view.
|
||||||
///
|
///
|
||||||
/// `greeting` and `status` accompany the snapshot so clients render
|
/// `greeting` and `status` accompany the snapshot so clients render
|
||||||
/// pod identity and current controller state without an extra round
|
/// worker identity and current controller state without an extra round
|
||||||
/// trip.
|
/// trip.
|
||||||
///
|
///
|
||||||
/// Live updates after the snapshot arrive through the streaming
|
/// Live updates after the snapshot arrive through the streaming
|
||||||
|
|
@ -465,7 +468,7 @@ pub enum Event {
|
||||||
entries: Vec<serde_json::Value>,
|
entries: Vec<serde_json::Value>,
|
||||||
greeting: Greeting,
|
greeting: Greeting,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
status: PodStatus,
|
status: WorkerStatus,
|
||||||
/// Unfinished model output that has already streamed in the current
|
/// Unfinished model output that has already streamed in the current
|
||||||
/// run but is not yet represented by committed snapshot entries.
|
/// run but is not yet represented by committed snapshot entries.
|
||||||
#[serde(default, skip_serializing_if = "InFlightSnapshot::is_empty")]
|
#[serde(default, skip_serializing_if = "InFlightSnapshot::is_empty")]
|
||||||
|
|
@ -483,10 +486,10 @@ pub enum Event {
|
||||||
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
|
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
|
||||||
entry: serde_json::Value,
|
entry: serde_json::Value,
|
||||||
},
|
},
|
||||||
/// Current Pod controller status. Broadcast on every controller-level
|
/// Current Worker controller status. Broadcast on every controller-level
|
||||||
/// transition and included in `History` snapshots for late attach.
|
/// transition and included in `History` snapshots for late attach.
|
||||||
Status {
|
Status {
|
||||||
status: PodStatus,
|
status: WorkerStatus,
|
||||||
},
|
},
|
||||||
/// Reply to `Method::ListCompletions`. Delivered only to the
|
/// Reply to `Method::ListCompletions`. Delivered only to the
|
||||||
/// requesting socket (not broadcast). `entries` is empty when no
|
/// requesting socket (not broadcast). `entries` is empty when no
|
||||||
|
|
@ -510,15 +513,15 @@ pub enum Event {
|
||||||
input: Vec<Segment>,
|
input: Vec<Segment>,
|
||||||
summary: RewindSummary,
|
summary: RewindSummary,
|
||||||
},
|
},
|
||||||
/// Reply to `Method::ListPods`. Payload is a stable JSON value so the Pod
|
/// Reply to `Method::ListWorkers`. Payload is a stable JSON value so the Worker
|
||||||
/// crate can evolve discovery fields without introducing a protocol
|
/// crate can evolve discovery fields without introducing a protocol
|
||||||
/// dependency on session-store.
|
/// dependency on session-store.
|
||||||
PodsListed {
|
WorkersListed {
|
||||||
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
|
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
|
||||||
pods: serde_json::Value,
|
workers: serde_json::Value,
|
||||||
},
|
},
|
||||||
/// Reply to `Method::RestorePod`.
|
/// Reply to `Method::RestoreWorker`.
|
||||||
PodRestored {
|
WorkerRestored {
|
||||||
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
|
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
|
||||||
result: serde_json::Value,
|
result: serde_json::Value,
|
||||||
},
|
},
|
||||||
|
|
@ -533,7 +536,7 @@ pub enum Event {
|
||||||
/// This is not part of LLM history or prompt context; clients may display it
|
/// This is not part of LLM history or prompt context; clients may display it
|
||||||
/// briefly as operational status.
|
/// briefly as operational status.
|
||||||
MemoryWorker(MemoryWorkerEvent),
|
MemoryWorker(MemoryWorkerEvent),
|
||||||
/// Pod has started compacting the current session.
|
/// Worker has started compacting the current session.
|
||||||
///
|
///
|
||||||
/// Fired immediately before a compaction run. Success is signalled by
|
/// Fired immediately before a compaction run. Success is signalled by
|
||||||
/// `CompactDone` (with the new `SegmentId`); failure by `CompactFailed`.
|
/// `CompactDone` (with the new `SegmentId`); failure by `CompactFailed`.
|
||||||
|
|
@ -554,10 +557,10 @@ pub enum Event {
|
||||||
Shutdown,
|
Shutdown,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// User-facing alert emitted from the Pod layer.
|
/// User-facing alert emitted from the Worker layer.
|
||||||
///
|
///
|
||||||
/// This is a separate channel from `tracing` (developer logs): entries
|
/// This is a separate channel from `tracing` (developer logs): entries
|
||||||
/// here are assembled explicitly by the Pod when a condition should be
|
/// here are assembled explicitly by the Worker when a condition should be
|
||||||
/// surfaced to the person driving the client. Keep messages short and
|
/// surfaced to the person driving the client. Keep messages short and
|
||||||
/// human-readable.
|
/// human-readable.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -596,7 +599,7 @@ pub enum AlertLevel {
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum AlertSource {
|
pub enum AlertSource {
|
||||||
Pod,
|
Worker,
|
||||||
Engine,
|
Engine,
|
||||||
Compactor,
|
Compactor,
|
||||||
AgentsMd,
|
AgentsMd,
|
||||||
|
|
@ -668,7 +671,7 @@ pub struct RewindSummary {
|
||||||
/// attach while an LLM response is still streaming.
|
/// attach while an LLM response is still streaming.
|
||||||
///
|
///
|
||||||
/// These blocks are presentation state only: they are reconstructed from the
|
/// These blocks are presentation state only: they are reconstructed from the
|
||||||
/// active Pod controller and must not be treated as committed assistant
|
/// active Worker controller and must not be treated as committed assistant
|
||||||
/// history. Finalized assistant items continue to come from ordinary snapshot
|
/// history. Finalized assistant items continue to come from ordinary snapshot
|
||||||
/// entries.
|
/// entries.
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|
@ -723,21 +726,21 @@ impl InFlightToolCallState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pod self-description rendered by the TUI when a session starts empty.
|
/// Worker self-description rendered by the TUI when a session starts empty.
|
||||||
///
|
///
|
||||||
/// Built once in the Pod controller from the resolved manifest and
|
/// Built once in the Worker controller from the resolved manifest and
|
||||||
/// transmitted alongside `Event::Snapshot` so clients don't need
|
/// transmitted alongside `Event::Snapshot` so clients don't need
|
||||||
/// their own view of the manifest.
|
/// their own view of the manifest.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct Greeting {
|
pub struct Greeting {
|
||||||
pub pod_name: String,
|
pub worker_name: String,
|
||||||
pub cwd: String,
|
pub cwd: String,
|
||||||
pub provider: String,
|
pub provider: String,
|
||||||
pub model: String,
|
pub model: String,
|
||||||
pub scope_summary: String,
|
pub scope_summary: String,
|
||||||
pub tools: Vec<String>,
|
pub tools: Vec<String>,
|
||||||
/// Model context window in tokens. Always filled by the Pod greeting.
|
/// Model context window in tokens. Always filled by the Worker greeting.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub context_window: u64,
|
pub context_window: u64,
|
||||||
/// Estimated current session context tokens at connect time.
|
/// Estimated current session context tokens at connect time.
|
||||||
|
|
@ -752,7 +755,7 @@ pub struct Greeting {
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum PodStatus {
|
pub enum WorkerStatus {
|
||||||
#[default]
|
#[default]
|
||||||
Idle,
|
Idle,
|
||||||
Running,
|
Running,
|
||||||
|
|
@ -771,7 +774,7 @@ pub enum TurnResult {
|
||||||
///
|
///
|
||||||
/// One Invoke groups all entries from this trigger up to the next
|
/// One Invoke groups all entries from this trigger up to the next
|
||||||
/// `Invoke` marker. The kind is the only payload — content (user text,
|
/// `Invoke` marker. The kind is the only payload — content (user text,
|
||||||
/// notify message, pod event body) is delivered by the immediately
|
/// notify message, worker event body) is delivered by the immediately
|
||||||
/// following Turn entry, not by the marker itself.
|
/// following Turn entry, not by the marker itself.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
|
@ -781,8 +784,8 @@ pub enum InvokeKind {
|
||||||
UserSend,
|
UserSend,
|
||||||
/// `Method::Notify` — free-text notification injected into history.
|
/// `Method::Notify` — free-text notification injected into history.
|
||||||
Notify,
|
Notify,
|
||||||
/// `Method::PodEvent` — typed lifecycle report from a child Pod.
|
/// `Method::WorkerEvent` — typed lifecycle report from a child Worker.
|
||||||
PodEvent,
|
WorkerEvent,
|
||||||
/// `<system-reminder>` etc. that crosses an IDLE boundary (mid-run
|
/// `<system-reminder>` etc. that crosses an IDLE boundary (mid-run
|
||||||
/// reminders that don't break IDLE are SystemItem-only and do not
|
/// reminders that don't break IDLE are SystemItem-only and do not
|
||||||
/// open a new Invoke).
|
/// open a new Invoke).
|
||||||
|
|
@ -801,8 +804,8 @@ pub enum RunResult {
|
||||||
Paused,
|
Paused,
|
||||||
LimitReached,
|
LimitReached,
|
||||||
/// The accepted Method::Run produced no assistant/tool output before
|
/// The accepted Method::Run produced no assistant/tool output before
|
||||||
/// user interruption, so the Pod rolled the submit-time turn state back
|
/// user interruption, so the Worker rolled the submit-time turn state back
|
||||||
/// to its pre-submit snapshot. Clients should treat the Pod as Idle and
|
/// to its pre-submit snapshot. Clients should treat the Worker as Idle and
|
||||||
/// restore the just-submitted input into the editable composer if desired.
|
/// restore the just-submitted input into the editable composer if desired.
|
||||||
RolledBack,
|
RolledBack,
|
||||||
}
|
}
|
||||||
|
|
@ -824,7 +827,7 @@ pub enum ErrorCode {
|
||||||
// Scope rule / permission (wire type)
|
// Scope rule / permission (wire type)
|
||||||
//
|
//
|
||||||
// Defined here so that both `manifest` (config parsing) and `protocol`
|
// Defined here so that both `manifest` (config parsing) and `protocol`
|
||||||
// itself (inter-pod messaging such as `PodEvent::ScopeSubDelegated`) can
|
// itself (inter-worker messaging such as `WorkerEvent::ScopeSubDelegated`) can
|
||||||
// reference the same type without introducing a reverse dependency.
|
// reference the same type without introducing a reverse dependency.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -925,9 +928,9 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn segment_unknown_variant_decodes_as_unknown() {
|
fn segment_unknown_variant_decodes_as_unknown() {
|
||||||
// A future client sends a segment kind this Pod has never heard of.
|
// A future client sends a segment kind this Worker has never heard of.
|
||||||
// Forward compat requirement: deserialization must succeed and the
|
// Forward compat requirement: deserialization must succeed and the
|
||||||
// unknown payload must surface as `Segment::Unknown` so the Pod
|
// unknown payload must surface as `Segment::Unknown` so the Worker
|
||||||
// fallback path (placeholder + alert) can fire.
|
// fallback path (placeholder + alert) can fire.
|
||||||
let json = r#"{"kind":"image_ref","url":"https://example.com/x.png"}"#;
|
let json = r#"{"kind":"image_ref","url":"https://example.com/x.png"}"#;
|
||||||
let seg: Segment = serde_json::from_str(json).unwrap();
|
let seg: Segment = serde_json::from_str(json).unwrap();
|
||||||
|
|
@ -1028,7 +1031,7 @@ mod tests {
|
||||||
for kind in [
|
for kind in [
|
||||||
InvokeKind::UserSend,
|
InvokeKind::UserSend,
|
||||||
InvokeKind::Notify,
|
InvokeKind::Notify,
|
||||||
InvokeKind::PodEvent,
|
InvokeKind::WorkerEvent,
|
||||||
InvokeKind::SystemReminder,
|
InvokeKind::SystemReminder,
|
||||||
InvokeKind::Wakeup,
|
InvokeKind::Wakeup,
|
||||||
] {
|
] {
|
||||||
|
|
@ -1219,7 +1222,7 @@ mod tests {
|
||||||
let event = Event::Snapshot {
|
let event = Event::Snapshot {
|
||||||
entries: vec![serde_json::json!({"kind": "user_input", "ts": 1, "segments": []})],
|
entries: vec![serde_json::json!({"kind": "user_input", "ts": 1, "segments": []})],
|
||||||
greeting: Greeting {
|
greeting: Greeting {
|
||||||
pod_name: "test".into(),
|
worker_name: "test".into(),
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
provider: "anthropic".into(),
|
provider: "anthropic".into(),
|
||||||
model: "claude".into(),
|
model: "claude".into(),
|
||||||
|
|
@ -1228,7 +1231,7 @@ mod tests {
|
||||||
context_window: 200_000,
|
context_window: 200_000,
|
||||||
context_tokens: 42_000,
|
context_tokens: 42_000,
|
||||||
},
|
},
|
||||||
status: PodStatus::Paused,
|
status: WorkerStatus::Paused,
|
||||||
in_flight: InFlightSnapshot::default(),
|
in_flight: InFlightSnapshot::default(),
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&event).unwrap();
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
|
|
@ -1236,7 +1239,7 @@ mod tests {
|
||||||
assert_eq!(parsed["event"], "snapshot");
|
assert_eq!(parsed["event"], "snapshot");
|
||||||
assert!(parsed["data"]["entries"].is_array());
|
assert!(parsed["data"]["entries"].is_array());
|
||||||
assert_eq!(parsed["data"]["entries"][0]["kind"], "user_input");
|
assert_eq!(parsed["data"]["entries"][0]["kind"], "user_input");
|
||||||
assert_eq!(parsed["data"]["greeting"]["pod_name"], "test");
|
assert_eq!(parsed["data"]["greeting"]["worker_name"], "test");
|
||||||
assert_eq!(parsed["data"]["greeting"]["tools"][0], "Read");
|
assert_eq!(parsed["data"]["greeting"]["tools"][0], "Read");
|
||||||
assert_eq!(parsed["data"]["greeting"]["context_window"], 200_000);
|
assert_eq!(parsed["data"]["greeting"]["context_window"], 200_000);
|
||||||
assert_eq!(parsed["data"]["greeting"]["context_tokens"], 42_000);
|
assert_eq!(parsed["data"]["greeting"]["context_tokens"], 42_000);
|
||||||
|
|
@ -1245,7 +1248,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn event_snapshot_in_flight_roundtrip_and_default() {
|
fn event_snapshot_in_flight_roundtrip_and_default() {
|
||||||
let inbound = r#"{"event":"snapshot","data":{"entries":[],"greeting":{"pod_name":"test","cwd":"/tmp","provider":"p","model":"m","scope_summary":"s","tools":[]},"status":"running"}}"#;
|
let inbound = r#"{"event":"snapshot","data":{"entries":[],"greeting":{"worker_name":"test","cwd":"/tmp","provider":"p","model":"m","scope_summary":"s","tools":[]},"status":"running"}}"#;
|
||||||
let decoded: Event = serde_json::from_str(inbound).unwrap();
|
let decoded: Event = serde_json::from_str(inbound).unwrap();
|
||||||
match decoded {
|
match decoded {
|
||||||
Event::Snapshot { in_flight, .. } => assert!(in_flight.is_empty()),
|
Event::Snapshot { in_flight, .. } => assert!(in_flight.is_empty()),
|
||||||
|
|
@ -1255,7 +1258,7 @@ mod tests {
|
||||||
let event = Event::Snapshot {
|
let event = Event::Snapshot {
|
||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
greeting: Greeting {
|
greeting: Greeting {
|
||||||
pod_name: "test".into(),
|
worker_name: "test".into(),
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
provider: "p".into(),
|
provider: "p".into(),
|
||||||
model: "m".into(),
|
model: "m".into(),
|
||||||
|
|
@ -1264,7 +1267,7 @@ mod tests {
|
||||||
context_window: 0,
|
context_window: 0,
|
||||||
context_tokens: 0,
|
context_tokens: 0,
|
||||||
},
|
},
|
||||||
status: PodStatus::Running,
|
status: WorkerStatus::Running,
|
||||||
in_flight: InFlightSnapshot {
|
in_flight: InFlightSnapshot {
|
||||||
blocks: vec![
|
blocks: vec![
|
||||||
InFlightBlock::Text {
|
InFlightBlock::Text {
|
||||||
|
|
@ -1334,7 +1337,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn event_status_format() {
|
fn event_status_format() {
|
||||||
let event = Event::Status {
|
let event = Event::Status {
|
||||||
status: PodStatus::Running,
|
status: WorkerStatus::Running,
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&event).unwrap();
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||||
|
|
@ -1345,20 +1348,20 @@ mod tests {
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
decoded,
|
decoded,
|
||||||
Event::Status {
|
Event::Status {
|
||||||
status: PodStatus::Running
|
status: WorkerStatus::Running
|
||||||
}
|
}
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn event_snapshot_legacy_without_status_defaults_to_idle() {
|
fn event_snapshot_legacy_without_status_defaults_to_idle() {
|
||||||
let json = r#"{"event":"snapshot","data":{"entries":[],"greeting":{"pod_name":"test","cwd":"/tmp","provider":"anthropic","model":"claude","scope_summary":"","tools":[]}}}"#;
|
let json = r#"{"event":"snapshot","data":{"entries":[],"greeting":{"worker_name":"test","cwd":"/tmp","provider":"anthropic","model":"claude","scope_summary":"","tools":[]}}}"#;
|
||||||
let decoded: Event = serde_json::from_str(json).unwrap();
|
let decoded: Event = serde_json::from_str(json).unwrap();
|
||||||
match decoded {
|
match decoded {
|
||||||
Event::Snapshot {
|
Event::Snapshot {
|
||||||
status, greeting, ..
|
status, greeting, ..
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(status, PodStatus::Idle);
|
assert_eq!(status, WorkerStatus::Idle);
|
||||||
assert_eq!(greeting.context_window, 0);
|
assert_eq!(greeting.context_window, 0);
|
||||||
assert_eq!(greeting.context_tokens, 0);
|
assert_eq!(greeting.context_tokens, 0);
|
||||||
}
|
}
|
||||||
|
|
@ -1367,34 +1370,37 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn method_pod_event_turn_ended_roundtrip() {
|
fn method_worker_event_turn_ended_roundtrip() {
|
||||||
let method = Method::PodEvent(PodEvent::TurnEnded {
|
let method = Method::WorkerEvent(WorkerEvent::TurnEnded {
|
||||||
pod_name: "child".into(),
|
worker_name: "child".into(),
|
||||||
});
|
});
|
||||||
let json = serde_json::to_string(&method).unwrap();
|
let json = serde_json::to_string(&method).unwrap();
|
||||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||||
assert_eq!(parsed["method"], "pod_event");
|
assert_eq!(parsed["method"], "worker_event");
|
||||||
assert_eq!(parsed["params"]["kind"], "turn_ended");
|
assert_eq!(parsed["params"]["kind"], "turn_ended");
|
||||||
assert_eq!(parsed["params"]["pod_name"], "child");
|
assert_eq!(parsed["params"]["worker_name"], "child");
|
||||||
|
|
||||||
let decoded: Method = serde_json::from_str(&json).unwrap();
|
let decoded: Method = serde_json::from_str(&json).unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
decoded,
|
decoded,
|
||||||
Method::PodEvent(PodEvent::TurnEnded { ref pod_name }) if pod_name == "child"
|
Method::WorkerEvent(WorkerEvent::TurnEnded { ref worker_name }) if worker_name == "child"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn method_pod_event_errored_roundtrip() {
|
fn method_worker_event_errored_roundtrip() {
|
||||||
let method = Method::PodEvent(PodEvent::Errored {
|
let method = Method::WorkerEvent(WorkerEvent::Errored {
|
||||||
pod_name: "child".into(),
|
worker_name: "child".into(),
|
||||||
message: "provider 429".into(),
|
message: "provider 429".into(),
|
||||||
});
|
});
|
||||||
let json = serde_json::to_string(&method).unwrap();
|
let json = serde_json::to_string(&method).unwrap();
|
||||||
let decoded: Method = serde_json::from_str(&json).unwrap();
|
let decoded: Method = serde_json::from_str(&json).unwrap();
|
||||||
match decoded {
|
match decoded {
|
||||||
Method::PodEvent(PodEvent::Errored { pod_name, message }) => {
|
Method::WorkerEvent(WorkerEvent::Errored {
|
||||||
assert_eq!(pod_name, "child");
|
worker_name,
|
||||||
|
message,
|
||||||
|
}) => {
|
||||||
|
assert_eq!(worker_name, "child");
|
||||||
assert_eq!(message, "provider 429");
|
assert_eq!(message, "provider 429");
|
||||||
}
|
}
|
||||||
other => panic!("expected Errored, got {other:?}"),
|
other => panic!("expected Errored, got {other:?}"),
|
||||||
|
|
@ -1402,43 +1408,43 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn method_pod_event_shutdown_roundtrip() {
|
fn method_worker_event_shutdown_roundtrip() {
|
||||||
let method = Method::PodEvent(PodEvent::ShutDown {
|
let method = Method::WorkerEvent(WorkerEvent::ShutDown {
|
||||||
pod_name: "child".into(),
|
worker_name: "child".into(),
|
||||||
});
|
});
|
||||||
let json = serde_json::to_string(&method).unwrap();
|
let json = serde_json::to_string(&method).unwrap();
|
||||||
let decoded: Method = serde_json::from_str(&json).unwrap();
|
let decoded: Method = serde_json::from_str(&json).unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
decoded,
|
decoded,
|
||||||
Method::PodEvent(PodEvent::ShutDown { ref pod_name }) if pod_name == "child"
|
Method::WorkerEvent(WorkerEvent::ShutDown { ref worker_name }) if worker_name == "child"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pod_event_agent_notification_classification() {
|
fn worker_event_agent_notification_classification() {
|
||||||
assert!(
|
assert!(
|
||||||
PodEvent::TurnEnded {
|
WorkerEvent::TurnEnded {
|
||||||
pod_name: "child".into()
|
worker_name: "child".into()
|
||||||
}
|
}
|
||||||
.should_notify_agent()
|
.should_notify_agent()
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
PodEvent::Errored {
|
WorkerEvent::Errored {
|
||||||
pod_name: "child".into(),
|
worker_name: "child".into(),
|
||||||
message: "boom".into()
|
message: "boom".into()
|
||||||
}
|
}
|
||||||
.should_notify_agent()
|
.should_notify_agent()
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
PodEvent::ShutDown {
|
WorkerEvent::ShutDown {
|
||||||
pod_name: "child".into()
|
worker_name: "child".into()
|
||||||
}
|
}
|
||||||
.should_notify_agent()
|
.should_notify_agent()
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!PodEvent::ScopeSubDelegated {
|
!WorkerEvent::ScopeSubDelegated {
|
||||||
parent_pod: "child".into(),
|
parent_worker: "child".into(),
|
||||||
sub_pod: "grandchild".into(),
|
sub_worker: "grandchild".into(),
|
||||||
sub_socket: "/tmp/grandchild.sock".into(),
|
sub_socket: "/tmp/grandchild.sock".into(),
|
||||||
scope: vec![],
|
scope: vec![],
|
||||||
}
|
}
|
||||||
|
|
@ -1447,10 +1453,10 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn method_pod_event_scope_sub_delegated_roundtrip() {
|
fn method_worker_event_scope_sub_delegated_roundtrip() {
|
||||||
let method = Method::PodEvent(PodEvent::ScopeSubDelegated {
|
let method = Method::WorkerEvent(WorkerEvent::ScopeSubDelegated {
|
||||||
parent_pod: "child".into(),
|
parent_worker: "child".into(),
|
||||||
sub_pod: "grandchild".into(),
|
sub_worker: "grandchild".into(),
|
||||||
sub_socket: "/run/yoi/grandchild/sock".into(),
|
sub_socket: "/run/yoi/grandchild/sock".into(),
|
||||||
scope: vec![ScopeRule {
|
scope: vec![ScopeRule {
|
||||||
target: "/tmp/work".into(),
|
target: "/tmp/work".into(),
|
||||||
|
|
@ -1461,14 +1467,14 @@ mod tests {
|
||||||
let json = serde_json::to_string(&method).unwrap();
|
let json = serde_json::to_string(&method).unwrap();
|
||||||
let decoded: Method = serde_json::from_str(&json).unwrap();
|
let decoded: Method = serde_json::from_str(&json).unwrap();
|
||||||
match decoded {
|
match decoded {
|
||||||
Method::PodEvent(PodEvent::ScopeSubDelegated {
|
Method::WorkerEvent(WorkerEvent::ScopeSubDelegated {
|
||||||
parent_pod,
|
parent_worker,
|
||||||
sub_pod,
|
sub_worker,
|
||||||
sub_socket,
|
sub_socket,
|
||||||
scope,
|
scope,
|
||||||
}) => {
|
}) => {
|
||||||
assert_eq!(parent_pod, "child");
|
assert_eq!(parent_worker, "child");
|
||||||
assert_eq!(sub_pod, "grandchild");
|
assert_eq!(sub_worker, "grandchild");
|
||||||
assert_eq!(sub_socket, PathBuf::from("/run/yoi/grandchild/sock"));
|
assert_eq!(sub_socket, PathBuf::from("/run/yoi/grandchild/sock"));
|
||||||
assert_eq!(scope.len(), 1);
|
assert_eq!(scope.len(), 1);
|
||||||
assert_eq!(scope[0].target, PathBuf::from("/tmp/work"));
|
assert_eq!(scope[0].target, PathBuf::from("/tmp/work"));
|
||||||
|
|
@ -1619,7 +1625,7 @@ mod tests {
|
||||||
fn event_error_format() {
|
fn event_error_format() {
|
||||||
let event = Event::Error {
|
let event = Event::Error {
|
||||||
code: ErrorCode::AlreadyRunning,
|
code: ErrorCode::AlreadyRunning,
|
||||||
message: "Pod is already executing a turn".into(),
|
message: "Worker is already executing a turn".into(),
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&event).unwrap();
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||||
|
|
@ -1652,10 +1658,10 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pod_discovery_methods_roundtrip() {
|
fn worker_discovery_methods_roundtrip() {
|
||||||
let methods = [
|
let methods = [
|
||||||
Method::ListPods,
|
Method::ListWorkers,
|
||||||
Method::RestorePod {
|
Method::RestoreWorker {
|
||||||
name: "child".into(),
|
name: "child".into(),
|
||||||
},
|
},
|
||||||
Method::RegisterPeer {
|
Method::RegisterPeer {
|
||||||
|
|
@ -1666,8 +1672,8 @@ mod tests {
|
||||||
let json = serde_json::to_string(&method).unwrap();
|
let json = serde_json::to_string(&method).unwrap();
|
||||||
let decoded: Method = serde_json::from_str(&json).unwrap();
|
let decoded: Method = serde_json::from_str(&json).unwrap();
|
||||||
match (decoded, method) {
|
match (decoded, method) {
|
||||||
(Method::ListPods, Method::ListPods)
|
(Method::ListWorkers, Method::ListWorkers)
|
||||||
| (Method::RestorePod { .. }, Method::RestorePod { .. })
|
| (Method::RestoreWorker { .. }, Method::RestoreWorker { .. })
|
||||||
| (Method::RegisterPeer { .. }, Method::RegisterPeer { .. }) => {}
|
| (Method::RegisterPeer { .. }, Method::RegisterPeer { .. }) => {}
|
||||||
(decoded, expected) => panic!("decoded {decoded:?}, expected {expected:?}"),
|
(decoded, expected) => panic!("decoded {decoded:?}, expected {expected:?}"),
|
||||||
}
|
}
|
||||||
|
|
@ -1675,12 +1681,12 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pod_discovery_events_roundtrip() {
|
fn worker_discovery_events_roundtrip() {
|
||||||
let events = [
|
let events = [
|
||||||
Event::PodsListed {
|
Event::WorkersListed {
|
||||||
pods: serde_json::json!([{ "pod_name": "child" }]),
|
workers: serde_json::json!([{ "worker_name": "child" }]),
|
||||||
},
|
},
|
||||||
Event::PodRestored {
|
Event::WorkerRestored {
|
||||||
result: serde_json::json!({ "action": "already_live" }),
|
result: serde_json::json!({ "action": "already_live" }),
|
||||||
},
|
},
|
||||||
Event::PeerRegistered {
|
Event::PeerRegistered {
|
||||||
|
|
@ -1691,10 +1697,10 @@ mod tests {
|
||||||
let json = serde_json::to_string(&event).unwrap();
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
let decoded: Event = serde_json::from_str(&json).unwrap();
|
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||||
match (decoded, event) {
|
match (decoded, event) {
|
||||||
(Event::PodsListed { pods }, Event::PodsListed { pods: expected }) => {
|
(Event::WorkersListed { workers }, Event::WorkersListed { workers: expected }) => {
|
||||||
assert_eq!(pods, expected)
|
assert_eq!(workers, expected)
|
||||||
}
|
}
|
||||||
(Event::PodRestored { result }, Event::PodRestored { result: expected }) => {
|
(Event::WorkerRestored { result }, Event::WorkerRestored { result: expected }) => {
|
||||||
assert_eq!(result, expected)
|
assert_eq!(result, expected)
|
||||||
}
|
}
|
||||||
(Event::PeerRegistered { result }, Event::PeerRegistered { result: expected }) => {
|
(Event::PeerRegistered { result }, Event::PeerRegistered { result: expected }) => {
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ use ts_rs::{Config, TS};
|
||||||
use crate::{
|
use crate::{
|
||||||
Alert, AlertLevel, AlertSource, CompletionEntry, CompletionKind, ErrorCode, Event, Greeting,
|
Alert, AlertLevel, AlertSource, CompletionEntry, CompletionKind, ErrorCode, Event, Greeting,
|
||||||
InFlightBlock, InFlightSnapshot, InFlightToolCallState, InvokeKind, MemoryWorkerEvent, Method,
|
InFlightBlock, InFlightSnapshot, InFlightToolCallState, InvokeKind, MemoryWorkerEvent, Method,
|
||||||
Permission, PodEvent, PodStatus, RewindSummary, RewindTarget, RewindTargetId, RunResult,
|
Permission, RewindSummary, RewindTarget, RewindTargetId, RunResult, ScopeRule, Segment,
|
||||||
ScopeRule, Segment, TurnResult,
|
TurnResult, WorkerEvent, WorkerStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
const GENERATED_RELATIVE_PATH: &str = "../../web/workspace/src/lib/generated/protocol.ts";
|
const GENERATED_RELATIVE_PATH: &str = "../../web/workspace/src/lib/generated/protocol.ts";
|
||||||
|
|
@ -16,7 +16,7 @@ pub fn generated_typescript_path() -> PathBuf {
|
||||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GENERATED_RELATIVE_PATH)
|
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GENERATED_RELATIVE_PATH)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render Workspace web TypeScript bindings for the Pod wire protocol DTOs.
|
/// Render Workspace web TypeScript bindings for the Worker wire protocol DTOs.
|
||||||
///
|
///
|
||||||
/// Rust DTOs in this crate remain the source of truth; this function is used by
|
/// Rust DTOs in this crate remain the source of truth; this function is used by
|
||||||
/// both the checked-in artifact generator and the stale-output drift test.
|
/// both the checked-in artifact generator and the stale-output drift test.
|
||||||
|
|
@ -31,7 +31,7 @@ pub fn generated_protocol_types() -> String {
|
||||||
push_decl::<AlertLevel>(&cfg, &mut output);
|
push_decl::<AlertLevel>(&cfg, &mut output);
|
||||||
push_decl::<AlertSource>(&cfg, &mut output);
|
push_decl::<AlertSource>(&cfg, &mut output);
|
||||||
push_decl::<CompletionKind>(&cfg, &mut output);
|
push_decl::<CompletionKind>(&cfg, &mut output);
|
||||||
push_decl::<PodStatus>(&cfg, &mut output);
|
push_decl::<WorkerStatus>(&cfg, &mut output);
|
||||||
push_decl::<TurnResult>(&cfg, &mut output);
|
push_decl::<TurnResult>(&cfg, &mut output);
|
||||||
push_decl::<InvokeKind>(&cfg, &mut output);
|
push_decl::<InvokeKind>(&cfg, &mut output);
|
||||||
push_decl::<RunResult>(&cfg, &mut output);
|
push_decl::<RunResult>(&cfg, &mut output);
|
||||||
|
|
@ -49,7 +49,7 @@ pub fn generated_protocol_types() -> String {
|
||||||
push_decl::<Alert>(&cfg, &mut output);
|
push_decl::<Alert>(&cfg, &mut output);
|
||||||
push_decl::<MemoryWorkerEvent>(&cfg, &mut output);
|
push_decl::<MemoryWorkerEvent>(&cfg, &mut output);
|
||||||
push_decl::<Segment>(&cfg, &mut output);
|
push_decl::<Segment>(&cfg, &mut output);
|
||||||
push_decl::<PodEvent>(&cfg, &mut output);
|
push_decl::<WorkerEvent>(&cfg, &mut output);
|
||||||
push_decl::<Method>(&cfg, &mut output);
|
push_decl::<Method>(&cfg, &mut output);
|
||||||
push_decl::<Event>(&cfg, &mut output);
|
push_decl::<Event>(&cfg, &mut output);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ Does not own:
|
||||||
|
|
||||||
- Engine turn lifecycle (`llm-engine`)
|
- Engine turn lifecycle (`llm-engine`)
|
||||||
- secret storage internals (`secrets`)
|
- secret storage internals (`secrets`)
|
||||||
- Pod lifecycle (`pod`)
|
- Worker lifecycle (`worker`)
|
||||||
- product CLI parsing (`yoi`)
|
- product CLI parsing (`yoi`)
|
||||||
|
|
||||||
## Design notes
|
## Design notes
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
//! Pod マニフェストの [`ModelManifest`] を [`Box<dyn LlmClient>`]
|
//! Worker マニフェストの [`ModelManifest`] を [`Box<dyn LlmClient>`]
|
||||||
//! に落とすファクトリ。
|
//! に落とすファクトリ。
|
||||||
//!
|
//!
|
||||||
//! 段階:
|
//! 段階:
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
//! Read-only analytics for Yoi session JSONL logs.
|
//! Read-only analytics for Yoi session JSONL logs.
|
||||||
//!
|
//!
|
||||||
//! This crate intentionally parses the persisted JSON shape tolerantly with
|
//! This crate intentionally parses the persisted JSON shape tolerantly with
|
||||||
//! `serde_json::Value` rather than depending on Pod runtime or TUI crates. The
|
//! `serde_json::Value` rather than depending on Worker runtime or TUI crates. The
|
||||||
//! report contains counts, paths, sizes, line/turn indexes, and bounded
|
//! report contains counts, paths, sizes, line/turn indexes, and bounded
|
||||||
//! diagnostics; raw user messages, tool arguments, and tool output snippets are
|
//! diagnostics; raw user messages, tool arguments, and tool output snippets are
|
||||||
//! not emitted.
|
//! not emitted.
|
||||||
|
|
@ -1592,12 +1592,17 @@ fn line_count(value: &str) -> usize {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tool_kind(name: &str) -> &'static str {
|
fn tool_kind(name: &str) -> &'static str {
|
||||||
|
const LEGACY_SEND_TO_PEER_POD_TOOL: &str = "SendToPeerPod";
|
||||||
|
|
||||||
match name {
|
match name {
|
||||||
"Read" | "Write" | "Edit" | "Glob" | "Grep" => "filesystem",
|
"Read" | "Write" | "Edit" | "Glob" | "Grep" => "filesystem",
|
||||||
"Bash" => "shell",
|
"Bash" => "shell",
|
||||||
"WebFetch" | "WebSearch" => "web",
|
"WebFetch" | "WebSearch" => "web",
|
||||||
"SpawnPod" | "SendToPod" | "ReadPodOutput" | "ListPods" | "StopPod" | "RestorePod"
|
"SpawnWorker" | "SendToWorker" | "SendToPeerWorker" | "ReadWorkerOutput"
|
||||||
| "SendToPeerPod" => "pod",
|
| "ListWorkers" | "StopWorker" | "RestoreWorker" => "worker",
|
||||||
|
// Legacy session logs used the pre-rename peer tool name; keep analytics classification only.
|
||||||
|
/* legacy session-log tool name only */
|
||||||
|
LEGACY_SEND_TO_PEER_POD_TOOL => "worker",
|
||||||
name if name.starts_with("Memory") || name.starts_with("Knowledge") => "memory",
|
name if name.starts_with("Memory") || name.starts_with("Knowledge") => "memory",
|
||||||
name if name.starts_with("Ticket") => "ticket",
|
name if name.starts_with("Ticket") => "ticket",
|
||||||
name if name.starts_with("Task") => "task",
|
name if name.starts_with("Task") => "task",
|
||||||
|
|
|
||||||
|
|
@ -15,18 +15,18 @@ Owns:
|
||||||
|
|
||||||
Does not own:
|
Does not own:
|
||||||
|
|
||||||
- current Pod-name metadata (`pod-store`)
|
- current Worker-name metadata (`pod-store`)
|
||||||
- live process/socket discovery (`pod-registry`, `client`)
|
- live process/socket discovery (`pod-registry`, `client`)
|
||||||
- UI state (`tui`)
|
- UI state (`tui`)
|
||||||
- generated memory summaries (`memory`)
|
- generated memory summaries (`memory`)
|
||||||
|
|
||||||
## Design notes
|
## Design notes
|
||||||
|
|
||||||
A session log records what happened. It is not the current Pod registry and should not be queried as the only source of "what does Pod X mean now?"
|
A session log records what happened. It is not the current Worker registry and should not be queried as the only source of "what does Worker X mean now?"
|
||||||
|
|
||||||
Prefer explicit current log variants over broad legacy compatibility when schema changes; hidden compatibility can make future replay bugs silent.
|
Prefer explicit current log variants over broad legacy compatibility when schema changes; hidden compatibility can make future replay bugs silent.
|
||||||
|
|
||||||
## See also
|
## See also
|
||||||
|
|
||||||
- [`../../docs/design/pod-session-state.md`](../../docs/design/pod-session-state.md)
|
- [`../../docs/design/worker-session-state.md`](../../docs/design/worker-session-state.md)
|
||||||
- [`../../docs/design/context-history.md`](../../docs/design/context-history.md)
|
- [`../../docs/design/context-history.md`](../../docs/design/context-history.md)
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
//! the same Session.
|
//! the same Session.
|
||||||
//!
|
//!
|
||||||
//! This crate provides free functions for persistence operations.
|
//! This crate provides free functions for persistence operations.
|
||||||
//! The caller (typically Pod) holds the Engine directly and calls these
|
//! The caller (typically Worker) holds the Engine directly and calls these
|
||||||
//! functions after state-mutating operations.
|
//! functions after state-mutating operations.
|
||||||
//!
|
//!
|
||||||
//! Debug-mode [`TraceEntry`] records capture raw stream events in a separate
|
//! Debug-mode [`TraceEntry`] records capture raw stream events in a separate
|
||||||
|
|
@ -51,7 +51,7 @@ pub use segment::{
|
||||||
};
|
};
|
||||||
pub use segment_log::{LogEntry, RestoredState, SegmentOrigin, collect_state};
|
pub use segment_log::{LogEntry, RestoredState, SegmentOrigin, collect_state};
|
||||||
pub use store::{Store, StoreError};
|
pub use store::{Store, StoreError};
|
||||||
pub use system_item::{SystemItem, SystemReminder, SystemReminderSource, render_pod_event};
|
pub use system_item::{SystemItem, SystemReminder, SystemReminderSource, render_worker_event};
|
||||||
|
|
||||||
/// Session identifier — the fork-tree root. UUID v7 (time-ordered).
|
/// Session identifier — the fork-tree root. UUID v7 (time-ordered).
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
//! Free functions for segment persistence operations.
|
//! Free functions for segment persistence operations.
|
||||||
//!
|
//!
|
||||||
//! These functions record and restore segment state without owning a Engine.
|
//! These functions record and restore segment state without owning a Engine.
|
||||||
//! The caller (typically Pod) holds the Engine directly and calls these
|
//! The caller (typically Worker) holds the Engine directly and calls these
|
||||||
//! functions after state-mutating operations.
|
//! functions after state-mutating operations.
|
||||||
|
|
||||||
use crate::logged_item::{LoggedItem, to_logged};
|
use crate::logged_item::{LoggedItem, to_logged};
|
||||||
|
|
@ -36,7 +36,7 @@ pub fn create_segment(
|
||||||
/// Write a fresh `SegmentStart` entry using pre-generated IDs.
|
/// Write a fresh `SegmentStart` entry using pre-generated IDs.
|
||||||
///
|
///
|
||||||
/// Used by callers that need to reserve `(session_id, segment_id)`
|
/// Used by callers that need to reserve `(session_id, segment_id)`
|
||||||
/// synchronously but defer the initial log append (e.g. Pod, which
|
/// synchronously but defer the initial log append (e.g. Worker, which
|
||||||
/// resolves a templated system prompt only at first turn).
|
/// resolves a templated system prompt only at first turn).
|
||||||
pub fn create_segment_with_ids(
|
pub fn create_segment_with_ids(
|
||||||
store: &impl Store,
|
store: &impl Store,
|
||||||
|
|
@ -102,7 +102,7 @@ pub fn restore(
|
||||||
/// Restore segment state when only the segment ID is known. Uses
|
/// Restore segment state when only the segment ID is known. Uses
|
||||||
/// [`Store::lookup_session_of`] to resolve the parent Session.
|
/// [`Store::lookup_session_of`] to resolve the parent Session.
|
||||||
///
|
///
|
||||||
/// Shim for legacy entry points (`pod-cli --session <UUID>` etc.) that
|
/// Shim for legacy entry points (`worker-cli --session <UUID>` etc.) that
|
||||||
/// receive a Segment ID without a Session ID.
|
/// receive a Segment ID without a Session ID.
|
||||||
pub fn restore_by_segment(
|
pub fn restore_by_segment(
|
||||||
store: &impl Store,
|
store: &impl Store,
|
||||||
|
|
@ -174,7 +174,7 @@ pub fn ensure_head_or_fork(
|
||||||
|
|
||||||
/// Log a `UserInput` entry from the original typed `Vec<Segment>`.
|
/// Log a `UserInput` entry from the original typed `Vec<Segment>`.
|
||||||
///
|
///
|
||||||
/// Submit-time entry. Pod calls this at the head of a `Run` turn before
|
/// Submit-time entry. Worker calls this at the head of a `Run` turn before
|
||||||
/// the worker pushes its flattened user message into history; replay
|
/// the worker pushes its flattened user message into history; replay
|
||||||
/// derives the worker `Item::user_message` from these segments via
|
/// derives the worker `Item::user_message` from these segments via
|
||||||
/// [`Segment::flatten_to_text`].
|
/// [`Segment::flatten_to_text`].
|
||||||
|
|
@ -250,7 +250,7 @@ pub fn classify_history_item(item: &Item, ts: u64) -> LogEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Append a single typed system item as `LogEntry::SystemItem`. Helper
|
/// Append a single typed system item as `LogEntry::SystemItem`. Helper
|
||||||
/// for the Pod-side interceptor commit path; mirrors the per-item
|
/// for the Worker-side interceptor commit path; mirrors the per-item
|
||||||
/// commit shape used for assistant / tool result entries.
|
/// commit shape used for assistant / tool result entries.
|
||||||
pub fn append_system_item(
|
pub fn append_system_item(
|
||||||
store: &impl Store,
|
store: &impl Store,
|
||||||
|
|
|
||||||
|
|
@ -58,10 +58,10 @@ pub enum LogEntry {
|
||||||
/// IDLE → active marker. Records the start of a new self-driving
|
/// IDLE → active marker. Records the start of a new self-driving
|
||||||
/// cycle (Invoke range). The range extends implicitly until the
|
/// cycle (Invoke range). The range extends implicitly until the
|
||||||
/// next `Invoke` entry; this entry carries the trigger only — the
|
/// next `Invoke` entry; this entry carries the trigger only — the
|
||||||
/// actual payload (user text / notify message / pod event body) is
|
/// actual payload (user text / notify message / worker event body) is
|
||||||
/// in the immediately following Turn entry (`UserInput` / `SystemItem`).
|
/// in the immediately following Turn entry (`UserInput` / `SystemItem`).
|
||||||
///
|
///
|
||||||
/// Used by `pod-session-fork` style operations: the fork-point seq
|
/// Used by `worker-session-fork` style operations: the fork-point seq
|
||||||
/// (`at_turn_index` in persistence-semantics) points at one of these
|
/// (`at_turn_index` in persistence-semantics) points at one of these
|
||||||
/// `Invoke` entries so "back to N-th send" maps cleanly to the
|
/// `Invoke` entries so "back to N-th send" maps cleanly to the
|
||||||
/// IDLE-break boundary the user sees.
|
/// IDLE-break boundary the user sees.
|
||||||
|
|
@ -87,7 +87,7 @@ pub enum LogEntry {
|
||||||
/// One tool-execution result appended to history.
|
/// One tool-execution result appended to history.
|
||||||
ToolResult { ts: u64, item: LoggedItem },
|
ToolResult { ts: u64, item: LoggedItem },
|
||||||
|
|
||||||
/// One typed agent-injected system item: notification, child-Pod
|
/// One typed agent-injected system item: notification, child-Worker
|
||||||
/// lifecycle event, `@<path>` / `#<slug>` / `/<slug>` resolution
|
/// lifecycle event, `@<path>` / `#<slug>` / `/<slug>` resolution
|
||||||
/// payload. Each `SystemItem` carries kind metadata that the LLM
|
/// payload. Each `SystemItem` carries kind metadata that the LLM
|
||||||
/// itself never sees (the LLM gets `Item::system_message` with the
|
/// itself never sees (the LLM gets `Item::system_message` with the
|
||||||
|
|
@ -117,7 +117,7 @@ pub enum LogEntry {
|
||||||
|
|
||||||
/// A paused interrupted turn was explicitly abandoned without calling
|
/// A paused interrupted turn was explicitly abandoned without calling
|
||||||
/// `run()` or `resume()` again. Replay clears the interrupted marker so
|
/// `run()` or `resume()` again. Replay clears the interrupted marker so
|
||||||
/// the restored Pod is idle and future user input starts a normal new turn.
|
/// the restored Worker is idle and future user input starts a normal new turn.
|
||||||
PausedTurnAbandoned { ts: u64 },
|
PausedTurnAbandoned { ts: u64 },
|
||||||
|
|
||||||
/// `RequestConfig` changed.
|
/// `RequestConfig` changed.
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@
|
||||||
//! `< 1 KiB` line on local fs and completes well below a millisecond. Going
|
//! `< 1 KiB` line on local fs and completes well below a millisecond. Going
|
||||||
//! through `tokio::fs` would force every caller — including `Engine`'s sync
|
//! through `tokio::fs` would force every caller — including `Engine`'s sync
|
||||||
//! `on_history_append` callback — to bridge sync → async via a channel +
|
//! `on_history_append` callback — to bridge sync → async via a channel +
|
||||||
//! drain task. Keeping the store sync lets the worker callback, Pod commit
|
//! drain task. Keeping the store sync lets the worker callback, Worker commit
|
||||||
//! paths, and `PodInterceptor` all share one direct `append_entry` call.
|
//! paths, and `WorkerInterceptor` all share one direct `append_entry` call.
|
||||||
|
|
||||||
use crate::event_trace::TraceEntry;
|
use crate::event_trace::TraceEntry;
|
||||||
use crate::segment_log::LogEntry;
|
use crate::segment_log::LogEntry;
|
||||||
|
|
@ -81,7 +81,7 @@ pub trait Store: Send + Sync {
|
||||||
|
|
||||||
/// Truncate a segment log to `entries_len` entries.
|
/// Truncate a segment log to `entries_len` entries.
|
||||||
///
|
///
|
||||||
/// Used by Pod's submit-time empty-turn rollback after it has proven
|
/// Used by Worker's submit-time empty-turn rollback after it has proven
|
||||||
/// that no LLM output from the accepted turn was materialized. The
|
/// that no LLM output from the accepted turn was materialized. The
|
||||||
/// default implementation rewrites the retained prefix through
|
/// default implementation rewrites the retained prefix through
|
||||||
/// `create_segment`, matching the append-only logical model while still
|
/// `create_segment`, matching the append-only logical model while still
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
//! Typed system-message items injected by the agent system.
|
//! Typed system-message items injected by the agent system.
|
||||||
//!
|
//!
|
||||||
//! Items in worker history with `role:system` are never produced by the
|
//! Items in worker history with `role:system` are never produced by the
|
||||||
//! LLM — they are always inserted by the Pod itself (notifications,
|
//! LLM — they are always inserted by the Worker itself (notifications,
|
||||||
//! file/knowledge/workflow ref resolutions, child-pod lifecycle events,
|
//! file/knowledge/workflow ref resolutions, child-worker lifecycle events,
|
||||||
//! future `<system-reminder>` tags, …). [`SystemItem`] carries the
|
//! future `<system-reminder>` tags, …). [`SystemItem`] carries the
|
||||||
//! typed shape of each such injection so clients can dispatch on
|
//! typed shape of each such injection so clients can dispatch on
|
||||||
//! `kind` instead of parsing text prefixes like `[Notification] …` or
|
//! `kind` instead of parsing text prefixes like `[Notification] …` or
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
//! system-message text.
|
//! system-message text.
|
||||||
|
|
||||||
use llm_engine::llm_client::types::Item;
|
use llm_engine::llm_client::types::Item;
|
||||||
use protocol::PodEvent;
|
use protocol::WorkerEvent;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
const SYSTEM_REMINDER_OPEN: &str = "<system-reminder>";
|
const SYSTEM_REMINDER_OPEN: &str = "<system-reminder>";
|
||||||
|
|
@ -105,7 +105,7 @@ fn render_system_reminder(body: &str) -> String {
|
||||||
/// One agent-injected system item, tagged by origin.
|
/// One agent-injected system item, tagged by origin.
|
||||||
///
|
///
|
||||||
/// Each variant carries the kind-specific raw data clients use for
|
/// Each variant carries the kind-specific raw data clients use for
|
||||||
/// typed rendering (`Notification.message`, `PodEvent.event`, file
|
/// typed rendering (`Notification.message`, `WorkerEvent.event`, file
|
||||||
/// path / knowledge slug / workflow slug / etc.), plus a pre-rendered
|
/// path / knowledge slug / workflow slug / etc.), plus a pre-rendered
|
||||||
/// `body` (where applicable) that is the exact `role:system` text the
|
/// `body` (where applicable) that is the exact `role:system` text the
|
||||||
/// LLM actually saw at commit time. `body` is denormalised so that
|
/// LLM actually saw at commit time. `body` is denormalised so that
|
||||||
|
|
@ -122,15 +122,15 @@ fn render_system_reminder(body: &str) -> String {
|
||||||
pub enum SystemItem {
|
pub enum SystemItem {
|
||||||
/// Free-form notification sent in by an external caller via
|
/// Free-form notification sent in by an external caller via
|
||||||
/// `Method::Notify`. `message` is the raw caller-supplied text;
|
/// `Method::Notify`. `message` is the raw caller-supplied text;
|
||||||
/// `body` is the wrapped LLM-context form (Pod renders it via
|
/// `body` is the wrapped LLM-context form (Worker renders it via
|
||||||
/// `notify_wrapper` at commit time).
|
/// `notify_wrapper` at commit time).
|
||||||
Notification { message: String, body: String },
|
Notification { message: String, body: String },
|
||||||
|
|
||||||
/// Lifecycle event reported by a child Pod via `Method::PodEvent`.
|
/// Lifecycle event reported by a child Worker via `Method::WorkerEvent`.
|
||||||
/// `event` is the typed payload (so the TUI can render per-child
|
/// `event` is the typed payload (so the TUI can render per-child
|
||||||
/// banners without re-parsing); `body` is the wrapped LLM-context
|
/// banners without re-parsing); `body` is the wrapped LLM-context
|
||||||
/// form (same `notify_wrapper` path as `Notification`).
|
/// form (same `notify_wrapper` path as `Notification`).
|
||||||
PodEvent { event: PodEvent, body: String },
|
WorkerEvent { event: WorkerEvent, body: String },
|
||||||
|
|
||||||
/// `@<path>` file reference resolution. `body` is the rendered
|
/// `@<path>` file reference resolution. `body` is the rendered
|
||||||
/// LLM-context text (`[File: <path>]\n…` for regular files,
|
/// LLM-context text (`[File: <path>]\n…` for regular files,
|
||||||
|
|
@ -140,7 +140,7 @@ pub enum SystemItem {
|
||||||
FileAttachment { path: String, body: String },
|
FileAttachment { path: String, body: String },
|
||||||
|
|
||||||
/// `#<slug>` Knowledge reference resolution. `body` is the
|
/// `#<slug>` Knowledge reference resolution. `body` is the
|
||||||
/// rendered text the LLM saw (Pod composes the `[Knowledge: …]`
|
/// rendered text the LLM saw (Worker composes the `[Knowledge: …]`
|
||||||
/// header + body).
|
/// header + body).
|
||||||
Knowledge { slug: String, body: String },
|
Knowledge { slug: String, body: String },
|
||||||
|
|
||||||
|
|
@ -169,7 +169,7 @@ impl SystemItem {
|
||||||
pub fn history_text(&self) -> String {
|
pub fn history_text(&self) -> String {
|
||||||
match self {
|
match self {
|
||||||
SystemItem::Notification { body, .. } => body.clone(),
|
SystemItem::Notification { body, .. } => body.clone(),
|
||||||
SystemItem::PodEvent { body, .. } => body.clone(),
|
SystemItem::WorkerEvent { body, .. } => body.clone(),
|
||||||
SystemItem::FileAttachment { body, .. } => body.clone(),
|
SystemItem::FileAttachment { body, .. } => body.clone(),
|
||||||
SystemItem::Knowledge { body, .. } => body.clone(),
|
SystemItem::Knowledge { body, .. } => body.clone(),
|
||||||
SystemItem::Workflow { body, .. } => body.clone(),
|
SystemItem::Workflow { body, .. } => body.clone(),
|
||||||
|
|
@ -189,7 +189,7 @@ impl SystemItem {
|
||||||
pub fn kind_label(&self) -> &'static str {
|
pub fn kind_label(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
SystemItem::Notification { .. } => "notification",
|
SystemItem::Notification { .. } => "notification",
|
||||||
SystemItem::PodEvent { .. } => "pod_event",
|
SystemItem::WorkerEvent { .. } => "worker_event",
|
||||||
SystemItem::FileAttachment { .. } => "file_attachment",
|
SystemItem::FileAttachment { .. } => "file_attachment",
|
||||||
SystemItem::Knowledge { .. } => "knowledge",
|
SystemItem::Knowledge { .. } => "knowledge",
|
||||||
SystemItem::Workflow { .. } => "workflow",
|
SystemItem::Workflow { .. } => "workflow",
|
||||||
|
|
@ -199,22 +199,25 @@ impl SystemItem {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render a `PodEvent` as the one-line notification text the agent
|
/// Render a `WorkerEvent` as the one-line notification text the agent
|
||||||
/// sees. Centralised here (rather than at the controller's render
|
/// sees. Centralised here (rather than at the controller's render
|
||||||
/// site) so persistence and broadcast share the same rendering.
|
/// site) so persistence and broadcast share the same rendering.
|
||||||
pub fn render_pod_event(event: &PodEvent) -> String {
|
pub fn render_worker_event(event: &WorkerEvent) -> String {
|
||||||
match event {
|
match event {
|
||||||
PodEvent::TurnEnded { pod_name } => format!("pod `{pod_name}` finished a turn"),
|
WorkerEvent::TurnEnded { worker_name } => format!("worker `{worker_name}` finished a turn"),
|
||||||
PodEvent::Errored { pod_name, message } => {
|
WorkerEvent::Errored {
|
||||||
format!("pod `{pod_name}` errored: {message}")
|
worker_name,
|
||||||
|
message,
|
||||||
|
} => {
|
||||||
|
format!("worker `{worker_name}` errored: {message}")
|
||||||
}
|
}
|
||||||
PodEvent::ShutDown { pod_name } => format!("pod `{pod_name}` shut down"),
|
WorkerEvent::ShutDown { worker_name } => format!("worker `{worker_name}` shut down"),
|
||||||
PodEvent::ScopeSubDelegated {
|
WorkerEvent::ScopeSubDelegated {
|
||||||
parent_pod,
|
parent_worker,
|
||||||
sub_pod,
|
sub_worker,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
format!("pod `{parent_pod}` sub-delegated scope to `{sub_pod}`")
|
format!("worker `{parent_worker}` sub-delegated scope to `{sub_worker}`")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -236,10 +239,10 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pod_event_history_text_returns_stored_body() {
|
fn worker_event_history_text_returns_stored_body() {
|
||||||
let item = SystemItem::PodEvent {
|
let item = SystemItem::WorkerEvent {
|
||||||
event: PodEvent::TurnEnded {
|
event: WorkerEvent::TurnEnded {
|
||||||
pod_name: "child".into(),
|
worker_name: "child".into(),
|
||||||
},
|
},
|
||||||
body: "[Notification]\npod `child` finished a turn\n\n(non-blocking hint…)".into(),
|
body: "[Notification]\npod `child` finished a turn\n\n(non-blocking hint…)".into(),
|
||||||
};
|
};
|
||||||
|
|
@ -321,21 +324,21 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn round_trip_pod_event() {
|
fn round_trip_worker_event() {
|
||||||
let item = SystemItem::PodEvent {
|
let item = SystemItem::WorkerEvent {
|
||||||
event: PodEvent::TurnEnded {
|
event: WorkerEvent::TurnEnded {
|
||||||
pod_name: "child".into(),
|
worker_name: "child".into(),
|
||||||
},
|
},
|
||||||
body: "[Notification] pod `child` finished a turn".into(),
|
body: "[Notification] worker `child` finished a turn".into(),
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&item).unwrap();
|
let json = serde_json::to_string(&item).unwrap();
|
||||||
let parsed: SystemItem = serde_json::from_str(&json).unwrap();
|
let parsed: SystemItem = serde_json::from_str(&json).unwrap();
|
||||||
match parsed {
|
match parsed {
|
||||||
SystemItem::PodEvent {
|
SystemItem::WorkerEvent {
|
||||||
event: PodEvent::TurnEnded { pod_name },
|
event: WorkerEvent::TurnEnded { worker_name },
|
||||||
body,
|
body,
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(pod_name, "child");
|
assert_eq!(worker_name, "child");
|
||||||
assert!(body.contains("`child`"));
|
assert!(body.contains("`child`"));
|
||||||
}
|
}
|
||||||
other => panic!("unexpected: {other:?}"),
|
other => panic!("unexpected: {other:?}"),
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ async fn run_and_persist(
|
||||||
segment_id: session_store::SegmentId,
|
segment_id: session_store::SegmentId,
|
||||||
input: &str,
|
input: &str,
|
||||||
) -> (Engine<MockLlmClient>, llm_engine::EngineResult) {
|
) -> (Engine<MockLlmClient>, llm_engine::EngineResult) {
|
||||||
// Mirror Pod's run-entry contract: log the user input as segments
|
// Mirror Worker's run-entry contract: log the user input as segments
|
||||||
// before the worker pushes its flattened user_message; save_delta
|
// before the worker pushes its flattened user_message; save_delta
|
||||||
// skips the resulting user_message item to avoid double-write.
|
// skips the resulting user_message item to avoid double-write.
|
||||||
session_store::save_user_input(
|
session_store::save_user_input(
|
||||||
|
|
@ -450,7 +450,7 @@ async fn session_auto_forks_on_conflict() {
|
||||||
// Writer tracked: just the SegmentStart we wrote.
|
// Writer tracked: just the SegmentStart we wrote.
|
||||||
let mut entries_written: usize = 1;
|
let mut entries_written: usize = 1;
|
||||||
|
|
||||||
// Simulate another Pod writing to the same segment behind our back.
|
// Simulate another Worker writing to the same segment behind our back.
|
||||||
let extra_entry = LogEntry::UserInput {
|
let extra_entry = LogEntry::UserInput {
|
||||||
ts: 9999,
|
ts: 9999,
|
||||||
segments: vec![protocol::Segment::text("Interloper")],
|
segments: vec![protocol::Segment::text("Interloper")],
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
//!
|
//!
|
||||||
//! The config file lives at `.yoi/ticket.config.toml` under a workspace root.
|
//! The config file lives at `.yoi/ticket.config.toml` under a workspace root.
|
||||||
//! It intentionally stores lightweight string references for Profile selectors,
|
//! It intentionally stores lightweight string references for Profile selectors,
|
||||||
//! launch prompts, and workflows so this crate remains independent from `pod`
|
//! launch prompts, and workflows so this crate remains independent from `worker`
|
||||||
//! and `manifest` runtime resolution.
|
//! and `manifest` runtime resolution.
|
||||||
|
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//! LLM tool implementations for typed Ticket backend operations.
|
//! LLM tool implementations for typed Ticket backend operations.
|
||||||
//!
|
//!
|
||||||
//! These tools are intentionally owned by the `ticket` crate so Pod features can
|
//! These tools are intentionally owned by the `ticket` crate so Worker features can
|
||||||
//! install Ticket behavior without reimplementing domain/backend logic or
|
//! install Ticket behavior without reimplementing domain/backend logic or
|
||||||
//! granting generic filesystem write authority.
|
//! granting generic filesystem write authority.
|
||||||
|
|
||||||
|
|
@ -154,7 +154,7 @@ fn base_tool_description(name: &str) -> &'static str {
|
||||||
///
|
///
|
||||||
/// `record_language` is the durable Ticket record/tool-body language, distinct from
|
/// `record_language` is the durable Ticket record/tool-body language, distinct from
|
||||||
/// worker response language and Memory/Knowledge language. Keeping this on the tool
|
/// worker response language and Memory/Knowledge language. Keeping this on the tool
|
||||||
/// surface ensures every Ticket-capable Pod sees the policy without hidden context
|
/// surface ensures every Ticket-capable Worker sees the policy without hidden context
|
||||||
/// injection or role-launch-only prose.
|
/// injection or role-launch-only prose.
|
||||||
pub fn ticket_tool_description(name: &str, record_language: Option<&str>) -> String {
|
pub fn ticket_tool_description(name: &str, record_language: Option<&str>) -> String {
|
||||||
let mut description = base_tool_description(name).to_string();
|
let mut description = base_tool_description(name).to_string();
|
||||||
|
|
@ -1909,7 +1909,7 @@ mod tests {
|
||||||
&json!({
|
&json!({
|
||||||
"ticket": created.id.clone(),
|
"ticket": created.id.clone(),
|
||||||
"intake_summary": "Requirements accepted; implementation can be queued.",
|
"intake_summary": "Requirements accepted; implementation can be queued.",
|
||||||
"author": "intake-pod"
|
"author": "intake-worker"
|
||||||
})
|
})
|
||||||
.to_string(),
|
.to_string(),
|
||||||
Default::default(),
|
Default::default(),
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@
|
||||||
|
|
||||||
## Role
|
## Role
|
||||||
|
|
||||||
`tools` implements built-in tools and shared tool execution helpers used by Pods.
|
`tools` implements built-in tools and shared tool execution helpers used by Workers.
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
Owns:
|
Owns:
|
||||||
|
|
||||||
- built-in filesystem, web, memory, and Pod-management tool implementations where applicable
|
- built-in filesystem, web, memory, and Worker-management tool implementations where applicable
|
||||||
- bounded tool output formatting
|
- bounded tool output formatting
|
||||||
- scope-aware file operation helpers
|
- scope-aware file operation helpers
|
||||||
- tool-facing diagnostics suitable for history/model consumption
|
- tool-facing diagnostics suitable for history/model consumption
|
||||||
|
|
@ -17,7 +17,7 @@ Does not own:
|
||||||
|
|
||||||
- manifest permission policy definition (`manifest`)
|
- manifest permission policy definition (`manifest`)
|
||||||
- Engine tool-loop semantics (`llm-engine`)
|
- Engine tool-loop semantics (`llm-engine`)
|
||||||
- Pod lifecycle decisions (`pod`)
|
- Worker lifecycle decisions (`worker`)
|
||||||
- UI presentation (`tui`)
|
- UI presentation (`tui`)
|
||||||
|
|
||||||
## Design notes
|
## Design notes
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ pub enum ToolsError {
|
||||||
OutOfScope(PathBuf),
|
OutOfScope(PathBuf),
|
||||||
|
|
||||||
#[error(
|
#[error(
|
||||||
"path resolves through a symlink outside allowed {required_permission} scope: {} -> {}; add the symlink target to the Pod {required_permission} scope, copy it into the workspace, or recreate the symlink with the correct target",
|
"path resolves through a symlink outside allowed {required_permission} scope: {} -> {}; add the symlink target to the Worker {required_permission} scope, copy it into the workspace, or recreate the symlink with the correct target",
|
||||||
.path.display(),
|
.path.display(),
|
||||||
.target.display()
|
.target.display()
|
||||||
)]
|
)]
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,14 @@
|
||||||
//! `llm-engine` `Tool` infrastructure. Filesystem access is mediated by
|
//! `llm-engine` `Tool` infrastructure. Filesystem access is mediated by
|
||||||
//! two orthogonal concerns:
|
//! two orthogonal concerns:
|
||||||
//!
|
//!
|
||||||
//! - [`ScopedFs`] — Pod-process lifetime, expresses the write-block
|
//! - [`ScopedFs`] — Worker-process lifetime, expresses the write-block
|
||||||
//! boundary for the current scope. Derived from the manifest; not
|
//! boundary for the current scope. Derived from the manifest; not
|
||||||
//! persisted across Pod restart.
|
//! persisted across Worker restart.
|
||||||
//! - [`Tracker`] — Pod-process lifetime, enforces the "read before edit"
|
//! - [`Tracker`] — Worker-process lifetime, enforces the "read before edit"
|
||||||
//! policy via content hashes and tracks the recency of touched files.
|
//! policy via content hashes and tracks the recency of touched files.
|
||||||
//! Recreated fresh on each Pod start (including resume).
|
//! Recreated fresh on each Worker start (including resume).
|
||||||
//!
|
//!
|
||||||
//! The Pod layer owns both instances and passes them to
|
//! The Worker layer owns both instances and passes them to
|
||||||
//! [`core_builtin_tools`] when registering tools on a `Engine`.
|
//! [`core_builtin_tools`] when registering tools on a `Engine`.
|
||||||
//!
|
//!
|
||||||
//! `Bash` is the lone exception — its child processes bypass `ScopedFs`
|
//! `Bash` is the lone exception — its child processes bypass `ScopedFs`
|
||||||
|
|
@ -41,13 +41,13 @@ pub use tracker::Tracker;
|
||||||
pub use web::{web_fetch_tool, web_search_tool};
|
pub use web::{web_fetch_tool, web_search_tool};
|
||||||
pub use write::write_tool;
|
pub use write::write_tool;
|
||||||
|
|
||||||
/// Register core builtin tools that do not require Pod-local task state,
|
/// Register core builtin tools that do not require Worker-local task state,
|
||||||
/// wiring them to a shared `ScopedFs` (Pod-process lifetime) and `Tracker`
|
/// wiring them to a shared `ScopedFs` (Worker-process lifetime) and `Tracker`
|
||||||
/// (Pod-process lifetime).
|
/// (Worker-process lifetime).
|
||||||
///
|
///
|
||||||
/// All returned factories share the same tracker instance so that
|
/// All returned factories share the same tracker instance so that
|
||||||
/// `Read` / `Write` / `Edit` see a consistent history across tool
|
/// `Read` / `Write` / `Edit` see a consistent history across tool
|
||||||
/// invocations within a single Pod run.
|
/// invocations within a single Worker run.
|
||||||
///
|
///
|
||||||
/// `bash_output_dir` is where the Bash tool spills long outputs. The
|
/// `bash_output_dir` is where the Bash tool spills long outputs. The
|
||||||
/// caller is responsible for adding that path to the readable scope
|
/// caller is responsible for adding that path to the readable scope
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
//! Scope-aware filesystem primitive.
|
//! Scope-aware filesystem primitive.
|
||||||
//!
|
//!
|
||||||
//! `ScopedFs` is the write/read gate layered on top of a [`manifest::Scope`]
|
//! `ScopedFs` is the write/read gate layered on top of a [`manifest::Scope`]
|
||||||
//! and a Pod's working directory. The scope decides which paths are
|
//! and a Worker's working directory. The scope decides which paths are
|
||||||
//! readable and writable; the cwd is carried alongside for convenience
|
//! readable and writable; the cwd is carried alongside for convenience
|
||||||
//! (Glob/Grep default their search base to it).
|
//! (Glob/Grep default their search base to it).
|
||||||
//!
|
//!
|
||||||
|
|
@ -27,7 +27,7 @@ struct ScopedFsInner {
|
||||||
///
|
///
|
||||||
/// The wrapped [`SharedScope`] is shared with every clone of this
|
/// The wrapped [`SharedScope`] is shared with every clone of this
|
||||||
/// `ScopedFs` and with whoever else holds the same `SharedScope`
|
/// `ScopedFs` and with whoever else holds the same `SharedScope`
|
||||||
/// handle (typically the owning Pod). Mutations to that `SharedScope`
|
/// handle (typically the owning Worker). Mutations to that `SharedScope`
|
||||||
/// propagate atomically; the next permission check inside any
|
/// propagate atomically; the next permission check inside any
|
||||||
/// `ScopedFs` reads the new view.
|
/// `ScopedFs` reads the new view.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
@ -63,7 +63,7 @@ impl ScopedFs {
|
||||||
/// Create a new [`ScopedFs`] wrapping `scope` and `cwd` in a fresh
|
/// Create a new [`ScopedFs`] wrapping `scope` and `cwd` in a fresh
|
||||||
/// [`SharedScope`]. Use [`ScopedFs::with_shared_scope`] when you
|
/// [`SharedScope`]. Use [`ScopedFs::with_shared_scope`] when you
|
||||||
/// need the resulting `ScopedFs` to share scope state with another
|
/// need the resulting `ScopedFs` to share scope state with another
|
||||||
/// holder of the `SharedScope` (typically the Pod).
|
/// holder of the `SharedScope` (typically the Worker).
|
||||||
pub fn new(scope: Scope, cwd: PathBuf) -> Self {
|
pub fn new(scope: Scope, cwd: PathBuf) -> Self {
|
||||||
Self::with_shared_scope(SharedScope::new(scope), cwd)
|
Self::with_shared_scope(SharedScope::new(scope), cwd)
|
||||||
}
|
}
|
||||||
|
|
@ -85,13 +85,13 @@ impl ScopedFs {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared scope handle backing this `ScopedFs`. Cloning it lets a
|
/// Shared scope handle backing this `ScopedFs`. Cloning it lets a
|
||||||
/// caller (usually the Pod) hold the same view and push updates
|
/// caller (usually the Worker) hold the same view and push updates
|
||||||
/// that are immediately reflected in subsequent permission checks.
|
/// that are immediately reflected in subsequent permission checks.
|
||||||
pub fn shared_scope(&self) -> &SharedScope {
|
pub fn shared_scope(&self) -> &SharedScope {
|
||||||
&self.inner.scope
|
&self.inner.scope
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The Pod's working directory. Glob/Grep default their search base
|
/// The Worker's working directory. Glob/Grep default their search base
|
||||||
/// to this path when callers omit an explicit `path` parameter.
|
/// to this path when callers omit an explicit `path` parameter.
|
||||||
pub fn cwd(&self) -> &Path {
|
pub fn cwd(&self) -> &Path {
|
||||||
&self.inner.cwd
|
&self.inner.cwd
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
//! Pod-lifetime tracker for file operations performed by the builtin
|
//! Worker-lifetime tracker for file operations performed by the builtin
|
||||||
//! file-manipulation tools.
|
//! file-manipulation tools.
|
||||||
//!
|
//!
|
||||||
//! A `Tracker` serves two orthogonal purposes:
|
//! A `Tracker` serves two orthogonal purposes:
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
//! verify that the file has not been externally modified since then.
|
//! verify that the file has not been externally modified since then.
|
||||||
//!
|
//!
|
||||||
//! 2. **Recency of touched files.** It keeps an LRU-ordered list of
|
//! 2. **Recency of touched files.** It keeps an LRU-ordered list of
|
||||||
//! files that have been touched by any of the tools, so the Pod
|
//! files that have been touched by any of the tools, so the Worker
|
||||||
//! layer can ask "which files did the agent recently look at?" —
|
//! layer can ask "which files did the agent recently look at?" —
|
||||||
//! used e.g. as a default reference set passed to context compaction.
|
//! used e.g. as a default reference set passed to context compaction.
|
||||||
//!
|
//!
|
||||||
|
|
@ -18,12 +18,12 @@
|
||||||
//!
|
//!
|
||||||
//! # Lifetime
|
//! # Lifetime
|
||||||
//!
|
//!
|
||||||
//! A `Tracker` is **Pod-process scoped**: the Pod layer creates a fresh
|
//! A `Tracker` is **Worker-process scoped**: the Worker layer creates a fresh
|
||||||
//! instance at the start of each Pod run (including resume) and discards
|
//! instance at the start of each Worker run (including resume) and discards
|
||||||
//! it when the process exits — it is not persisted, so a resumed
|
//! it when the process exits — it is not persisted, so a resumed
|
||||||
//! conversation starts with an empty read/edit history. The `ScopedFs`
|
//! conversation starts with an empty read/edit history. The `ScopedFs`
|
||||||
//! write boundary is likewise Pod-process scoped (derived from the
|
//! write boundary is likewise Worker-process scoped (derived from the
|
||||||
//! manifest). The two are orthogonal and the Pod wires them together
|
//! manifest). The two are orthogonal and the Worker wires them together
|
||||||
//! when registering builtin tools.
|
//! when registering builtin tools.
|
||||||
//!
|
//!
|
||||||
//! ```no_run
|
//! ```no_run
|
||||||
|
|
@ -31,7 +31,7 @@
|
||||||
//! # use manifest::Scope;
|
//! # use manifest::Scope;
|
||||||
//! # use tools::{ScopedFs, Tracker, core_builtin_tools};
|
//! # use tools::{ScopedFs, Tracker, core_builtin_tools};
|
||||||
//! let scope = Scope::writable("/workspace").unwrap();
|
//! let scope = Scope::writable("/workspace").unwrap();
|
||||||
//! let fs = ScopedFs::new(scope, PathBuf::from("/workspace")); // pod lifetime
|
//! let fs = ScopedFs::new(scope, PathBuf::from("/workspace")); // worker lifetime
|
||||||
//! let tracker = Tracker::new(); // session lifetime
|
//! let tracker = Tracker::new(); // session lifetime
|
||||||
//! let bash_outputs = PathBuf::from("/run/yoi/bash-output");
|
//! let bash_outputs = PathBuf::from("/run/yoi/bash-output");
|
||||||
//! let defs = core_builtin_tools(fs, tracker, bash_outputs, None);
|
//! let defs = core_builtin_tools(fs, tracker, bash_outputs, None);
|
||||||
|
|
@ -204,7 +204,7 @@ impl Tracker {
|
||||||
|
|
||||||
/// Return up to `n` most recently touched file paths, most-recent first.
|
/// Return up to `n` most recently touched file paths, most-recent first.
|
||||||
///
|
///
|
||||||
/// Intended for callers like the Pod's context-compaction path, which
|
/// Intended for callers like the Worker's context-compaction path, which
|
||||||
/// wants to know which files the agent has been working with so it
|
/// wants to know which files the agent has been working with so it
|
||||||
/// can pass them as default references to the compaction worker.
|
/// can pass them as default references to the compaction worker.
|
||||||
pub fn recent_files(&self, n: usize) -> Vec<PathBuf> {
|
pub fn recent_files(&self, n: usize) -> Vec<PathBuf> {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
## Role
|
## Role
|
||||||
|
|
||||||
`tui` implements terminal UI clients for the single-Pod Console and workspace Dashboard surfaces.
|
`tui` implements terminal UI clients for the single-Worker Console and workspace Dashboard surfaces.
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
|
|
@ -10,21 +10,21 @@ Owns:
|
||||||
|
|
||||||
- terminal rendering and input handling
|
- terminal rendering and input handling
|
||||||
- local composer state and UI affordances
|
- local composer state and UI affordances
|
||||||
- single-Pod Console attach/restore/chat screens
|
- single-Worker Console attach/restore/chat screens
|
||||||
- workspace Dashboard presentation and role-action UI
|
- workspace Dashboard presentation and role-action UI
|
||||||
|
|
||||||
Does not own:
|
Does not own:
|
||||||
|
|
||||||
- durable transcript authority (`session-store`)
|
- durable transcript authority (`session-store`)
|
||||||
- Pod current state (`pod-store`)
|
- Worker current state (`pod-store`)
|
||||||
- Pod lifecycle policy (`pod`)
|
- Worker lifecycle policy (`worker`)
|
||||||
- product CLI ownership (`yoi`)
|
- product CLI ownership (`yoi`)
|
||||||
|
|
||||||
## Design notes
|
## Design notes
|
||||||
|
|
||||||
The TUI should display committed events and Pod snapshots rather than inventing durable state. Local input history and optimistic UI affordances are editing conveniences; they must not become hidden model context.
|
The TUI should display committed events and Worker snapshots rather than inventing durable state. Local input history and optimistic UI affordances are editing conveniences; they must not become hidden model context.
|
||||||
|
|
||||||
## See also
|
## See also
|
||||||
|
|
||||||
- [`../../docs/design/context-history.md`](../../docs/design/context-history.md)
|
- [`../../docs/design/context-history.md`](../../docs/design/context-history.md)
|
||||||
- [`../../docs/design/pod-session-state.md`](../../docs/design/pod-session-state.md)
|
- [`../../docs/design/worker-session-state.md`](../../docs/design/worker-session-state.md)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use protocol::{
|
use protocol::{
|
||||||
AlertLevel, AlertSource, CompletionEntry, CompletionKind, ErrorCode, Event, InFlightBlock,
|
AlertLevel, AlertSource, CompletionEntry, CompletionKind, ErrorCode, Event, InFlightBlock,
|
||||||
InFlightSnapshot, InFlightToolCallState, Method, PodStatus, RewindTarget, RunResult, Segment,
|
InFlightSnapshot, InFlightToolCallState, Method, RewindTarget, RunResult, Segment,
|
||||||
|
WorkerStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::block::{
|
use crate::block::{
|
||||||
|
|
@ -40,7 +41,7 @@ pub struct CompletionState {
|
||||||
pub prefix_start: usize,
|
pub prefix_start: usize,
|
||||||
/// Text typed after the sigil (sigil itself excluded).
|
/// Text typed after the sigil (sigil itself excluded).
|
||||||
pub prefix: String,
|
pub prefix: String,
|
||||||
/// Latest candidate set returned by the Pod for `(kind, prefix)`.
|
/// Latest candidate set returned by the Worker for `(kind, prefix)`.
|
||||||
/// Initially empty until `Event::Completions` lands.
|
/// Initially empty until `Event::Completions` lands.
|
||||||
pub entries: Vec<CompletionEntry>,
|
pub entries: Vec<CompletionEntry>,
|
||||||
pub selected: usize,
|
pub selected: usize,
|
||||||
|
|
@ -71,7 +72,7 @@ pub struct RewindPickerState {
|
||||||
pub selected: usize,
|
pub selected: usize,
|
||||||
pub scroll: RewindPickerScroll,
|
pub scroll: RewindPickerScroll,
|
||||||
/// True after Enter submitted an authoritative `RewindTo` and before the
|
/// True after Enter submitted an authoritative `RewindTo` and before the
|
||||||
/// Pod replies with either `RewindApplied` or `Error`. While set, the
|
/// Worker replies with either `RewindApplied` or `Error`. While set, the
|
||||||
/// picker remains visible but further submits/navigation are ignored so a
|
/// picker remains visible but further submits/navigation are ignored so a
|
||||||
/// destructive rewind cannot be queued multiple times by key repeat.
|
/// destructive rewind cannot be queued multiple times by key repeat.
|
||||||
pub applying: bool,
|
pub applying: bool,
|
||||||
|
|
@ -227,14 +228,14 @@ impl ActionbarNotice {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
pub pod_name: String,
|
pub worker_name: String,
|
||||||
pub connected: bool,
|
pub connected: bool,
|
||||||
/// Last controller status reported by the Pod. Drives the status line
|
/// Last controller status reported by the Worker. Drives the status line
|
||||||
/// and Ctrl-key routing; do not infer this solely from replayed history.
|
/// and Ctrl-key routing; do not infer this solely from replayed history.
|
||||||
pub pod_status: PodStatus,
|
pub worker_status: WorkerStatus,
|
||||||
/// True while the Pod is in `PodStatus::Running`.
|
/// True while the Worker is in `WorkerStatus::Running`.
|
||||||
pub running: bool,
|
pub running: bool,
|
||||||
/// True while the Pod is in `PodStatus::Paused`.
|
/// True while the Worker is in `WorkerStatus::Paused`.
|
||||||
pub paused: bool,
|
pub paused: bool,
|
||||||
pub run_requests: usize,
|
pub run_requests: usize,
|
||||||
/// Sum of `input_tokens - cache_read_input_tokens` across the
|
/// Sum of `input_tokens - cache_read_input_tokens` across the
|
||||||
|
|
@ -243,7 +244,7 @@ pub struct App {
|
||||||
/// cache reads excluded). Reset on `RunEnd`.
|
/// cache reads excluded). Reset on `RunEnd`.
|
||||||
pub run_upload_tokens: u64,
|
pub run_upload_tokens: u64,
|
||||||
pub run_output_tokens: u64,
|
pub run_output_tokens: u64,
|
||||||
/// Latest session context tokens reported by the Pod. This is the raw
|
/// Latest session context tokens reported by the Worker. This is the raw
|
||||||
/// `input_tokens` value and is independent from per-run upload totals.
|
/// `input_tokens` value and is independent from per-run upload totals.
|
||||||
pub session_context_tokens: u64,
|
pub session_context_tokens: u64,
|
||||||
pub context_window: u64,
|
pub context_window: u64,
|
||||||
|
|
@ -264,9 +265,9 @@ pub struct App {
|
||||||
pub command_registry: CommandRegistry,
|
pub command_registry: CommandRegistry,
|
||||||
command_completion_selected: Option<usize>,
|
command_completion_selected: Option<usize>,
|
||||||
pub quit: bool,
|
pub quit: bool,
|
||||||
/// 2-tap guard for `Ctrl-C` when the Pod is not running. First press
|
/// 2-tap guard for `Ctrl-C` when the Worker is not running. First press
|
||||||
/// records the instant; a second press within the timeout exits the
|
/// records the instant; a second press within the timeout exits the
|
||||||
/// TUI (the Pod itself stays alive).
|
/// TUI (the Worker itself stays alive).
|
||||||
pub quit_confirm: Option<std::time::Instant>,
|
pub quit_confirm: Option<std::time::Instant>,
|
||||||
/// Full display history in render order.
|
/// Full display history in render order.
|
||||||
pub blocks: Vec<Block>,
|
pub blocks: Vec<Block>,
|
||||||
|
|
@ -284,18 +285,18 @@ pub struct App {
|
||||||
pub rewind_picker: Option<RewindPickerState>,
|
pub rewind_picker: Option<RewindPickerState>,
|
||||||
rewind_request_pending: bool,
|
rewind_request_pending: bool,
|
||||||
/// After a successful rewind restore, ignore any queued live-update events
|
/// After a successful rewind restore, ignore any queued live-update events
|
||||||
/// until the authoritative Pod status/snapshot catches up. This prevents
|
/// until the authoritative Worker status/snapshot catches up. This prevents
|
||||||
/// old stream tail events that were already in transit from re-polluting the
|
/// old stream tail events that were already in transit from re-polluting the
|
||||||
/// just-restored display.
|
/// just-restored display.
|
||||||
rewind_refresh_fence: bool,
|
rewind_refresh_fence: bool,
|
||||||
greeting: Option<protocol::Greeting>,
|
greeting: Option<protocol::Greeting>,
|
||||||
/// In-TUI mirror of the Pod's session task store, reconstructed
|
/// In-TUI mirror of the Worker's session task store, reconstructed
|
||||||
/// directly from observed `TaskCreate` / `TaskUpdate` tool calls and
|
/// directly from observed `TaskCreate` / `TaskUpdate` tool calls and
|
||||||
/// `[Session TaskStore snapshot]` system messages — no protocol
|
/// `[Session TaskStore snapshot]` system messages — no protocol
|
||||||
/// surface added on the Pod side.
|
/// surface added on the Worker side.
|
||||||
pub task_store: TaskStore,
|
pub task_store: TaskStore,
|
||||||
/// Transient single-Pod transcript text selection. This is viewport-local
|
/// Transient single-Worker transcript text selection. This is viewport-local
|
||||||
/// UI state only; it is never sent to the Pod, persisted, or appended to
|
/// UI state only; it is never sent to the Worker, persisted, or appended to
|
||||||
/// session history/model context.
|
/// session history/model context.
|
||||||
pub text_selection: TextSelectionState,
|
pub text_selection: TextSelectionState,
|
||||||
/// Whether the right-side task pane is currently open.
|
/// Whether the right-side task pane is currently open.
|
||||||
|
|
@ -303,14 +304,14 @@ pub struct App {
|
||||||
/// Top entry index of the task pane's visible window. Clamped on
|
/// Top entry index of the task pane's visible window. Clamped on
|
||||||
/// render so it never points past the end of the list.
|
/// render so it never points past the end of the list.
|
||||||
pub task_pane_scroll: usize,
|
pub task_pane_scroll: usize,
|
||||||
/// TUI-local FIFO of user inputs submitted while the Pod is already running.
|
/// TUI-local FIFO of user inputs submitted while the Worker is already running.
|
||||||
/// Entries have not been sent to the Pod yet, so they remain editable/cancellable locally.
|
/// Entries have not been sent to the Worker yet, so they remain editable/cancellable locally.
|
||||||
queued_inputs: VecDeque<QueuedInput>,
|
queued_inputs: VecDeque<QueuedInput>,
|
||||||
/// TUI-local readline-style composer input history. This is intentionally
|
/// TUI-local readline-style composer input history. This is intentionally
|
||||||
/// client-side only: recalled entries are plain drafts until submitted again.
|
/// client-side only: recalled entries are plain drafts until submitted again.
|
||||||
input_history: ComposerInputHistory,
|
input_history: ComposerInputHistory,
|
||||||
/// User-data backed persistence for composer recall entries. The saved
|
/// User-data backed persistence for composer recall entries. The saved
|
||||||
/// contents are private input drafts and must not be logged or sent to Pod.
|
/// contents are private input drafts and must not be logged or sent to Worker.
|
||||||
input_history_store: Option<ComposerHistoryStore>,
|
input_history_store: Option<ComposerHistoryStore>,
|
||||||
/// Local submit state kept until the accepted run either completes
|
/// Local submit state kept until the accepted run either completes
|
||||||
/// normally or reports that the empty assistant turn was rolled back.
|
/// normally or reports that the empty assistant turn was rolled back.
|
||||||
|
|
@ -321,11 +322,11 @@ pub struct App {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
pub fn new(pod_name: String) -> Self {
|
pub fn new(worker_name: String) -> Self {
|
||||||
Self {
|
Self {
|
||||||
pod_name,
|
worker_name,
|
||||||
connected: false,
|
connected: false,
|
||||||
pod_status: PodStatus::Idle,
|
worker_status: WorkerStatus::Idle,
|
||||||
running: false,
|
running: false,
|
||||||
paused: false,
|
paused: false,
|
||||||
run_requests: 0,
|
run_requests: 0,
|
||||||
|
|
@ -367,8 +368,8 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new_with_persistent_input_history(pod_name: String, workspace_root: &Path) -> Self {
|
pub fn new_with_persistent_input_history(worker_name: String, workspace_root: &Path) -> Self {
|
||||||
let mut app = Self::new(pod_name);
|
let mut app = Self::new(worker_name);
|
||||||
match ComposerHistoryStore::default_for_workspace(workspace_root) {
|
match ComposerHistoryStore::default_for_workspace(workspace_root) {
|
||||||
Ok(Some(store)) => {
|
Ok(Some(store)) => {
|
||||||
match store.load() {
|
match store.load() {
|
||||||
|
|
@ -407,8 +408,8 @@ impl App {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fn new_with_input_history_store(pod_name: String, store: ComposerHistoryStore) -> Self {
|
fn new_with_input_history_store(worker_name: String, store: ComposerHistoryStore) -> Self {
|
||||||
let mut app = Self::new(pod_name);
|
let mut app = Self::new(worker_name);
|
||||||
match store.load() {
|
match store.load() {
|
||||||
Ok(entries) => {
|
Ok(entries) => {
|
||||||
app.input_history = ComposerInputHistory::with_entries(entries);
|
app.input_history = ComposerInputHistory::with_entries(entries);
|
||||||
|
|
@ -442,10 +443,10 @@ impl App {
|
||||||
self.task_pane_scroll = self.task_pane_scroll.saturating_add(n);
|
self.task_pane_scroll = self.task_pane_scroll.saturating_add(n);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_pod_status(&mut self, status: PodStatus) {
|
pub fn set_worker_status(&mut self, status: WorkerStatus) {
|
||||||
self.pod_status = status;
|
self.worker_status = status;
|
||||||
self.running = status == PodStatus::Running;
|
self.running = status == WorkerStatus::Running;
|
||||||
self.paused = status == PodStatus::Paused;
|
self.paused = status == WorkerStatus::Paused;
|
||||||
if self.running {
|
if self.running {
|
||||||
self.quit_confirm = None;
|
self.quit_confirm = None;
|
||||||
}
|
}
|
||||||
|
|
@ -639,7 +640,7 @@ impl App {
|
||||||
pub fn submit_input(&mut self) -> Option<Method> {
|
pub fn submit_input(&mut self) -> Option<Method> {
|
||||||
let segments = self.input.submit_segments();
|
let segments = self.input.submit_segments();
|
||||||
if segments_are_blank(&segments) {
|
if segments_are_blank(&segments) {
|
||||||
// Empty Enter only does something meaningful when the Pod
|
// Empty Enter only does something meaningful when the Worker
|
||||||
// is paused: resume the interrupted turn. Otherwise no-op.
|
// is paused: resume the interrupted turn. Otherwise no-op.
|
||||||
if self.paused {
|
if self.paused {
|
||||||
self.input_history.cancel_browse();
|
self.input_history.cancel_browse();
|
||||||
|
|
@ -660,7 +661,7 @@ impl App {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn method_for_run(&mut self, segments: Vec<Segment>) -> Method {
|
fn method_for_run(&mut self, segments: Vec<Segment>) -> Method {
|
||||||
// TurnHeader / UserMessage blocks are pushed only after the Pod
|
// TurnHeader / UserMessage blocks are pushed only after the Worker
|
||||||
// emits `Event::UserMessage` from a committed `LogEntry::UserInput`.
|
// emits `Event::UserMessage` from a committed `LogEntry::UserInput`.
|
||||||
// Locally we only clear the input buffer and forward the method,
|
// Locally we only clear the input buffer and forward the method,
|
||||||
// while remembering enough local state to undo the visible submit if
|
// while remembering enough local state to undo the visible submit if
|
||||||
|
|
@ -812,7 +813,7 @@ impl App {
|
||||||
pub fn push_error(&mut self, message: impl Into<String>) {
|
pub fn push_error(&mut self, message: impl Into<String>) {
|
||||||
self.blocks.push(Block::Alert {
|
self.blocks.push(Block::Alert {
|
||||||
level: AlertLevel::Error,
|
level: AlertLevel::Error,
|
||||||
source: AlertSource::Pod,
|
source: AlertSource::Worker,
|
||||||
message: message.into(),
|
message: message.into(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -856,7 +857,7 @@ impl App {
|
||||||
self.blocks.push(Block::TurnHeader {
|
self.blocks.push(Block::TurnHeader {
|
||||||
turn: self.turn_index,
|
turn: self.turn_index,
|
||||||
});
|
});
|
||||||
// Pod attaches the original `Vec<Segment>` to user
|
// Worker attaches the original `Vec<Segment>` to user
|
||||||
// messages from live submissions, so we can rebuild
|
// messages from live submissions, so we can rebuild
|
||||||
// typed atoms (paste chips, refs) here. Seed history
|
// typed atoms (paste chips, refs) here. Seed history
|
||||||
// loaded post-compaction has no `segments` field —
|
// loaded post-compaction has no `segments` field —
|
||||||
|
|
@ -971,7 +972,7 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle_pod_event(&mut self, event: Event) -> Option<Method> {
|
pub fn handle_worker_event(&mut self, event: Event) -> Option<Method> {
|
||||||
if self.rewind_refresh_fence && event_is_stale_after_rewind(&event) {
|
if self.rewind_refresh_fence && event_is_stale_after_rewind(&event) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
@ -995,7 +996,7 @@ impl App {
|
||||||
self.assistant_streaming = false;
|
self.assistant_streaming = false;
|
||||||
}
|
}
|
||||||
Event::TurnStart { .. } => {
|
Event::TurnStart { .. } => {
|
||||||
self.set_pod_status(PodStatus::Running);
|
self.set_worker_status(WorkerStatus::Running);
|
||||||
self.run_requests += 1;
|
self.run_requests += 1;
|
||||||
self.current_tool = None;
|
self.current_tool = None;
|
||||||
self.latest_llm_wait_event = None;
|
self.latest_llm_wait_event = None;
|
||||||
|
|
@ -1175,7 +1176,7 @@ impl App {
|
||||||
};
|
};
|
||||||
self.blocks.push(Block::Alert {
|
self.blocks.push(Block::Alert {
|
||||||
level,
|
level,
|
||||||
source: AlertSource::Pod,
|
source: AlertSource::Worker,
|
||||||
message: format!("orphan tool result ({id}): {summary}"),
|
message: format!("orphan tool result ({id}): {summary}"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1211,9 +1212,9 @@ impl App {
|
||||||
});
|
});
|
||||||
self.pending_submit_rollback = None;
|
self.pending_submit_rollback = None;
|
||||||
self.reset_run_state(match result {
|
self.reset_run_state(match result {
|
||||||
RunResult::Paused => PodStatus::Paused,
|
RunResult::Paused => WorkerStatus::Paused,
|
||||||
RunResult::Finished | RunResult::LimitReached | RunResult::RolledBack => {
|
RunResult::Finished | RunResult::LimitReached | RunResult::RolledBack => {
|
||||||
PodStatus::Idle
|
WorkerStatus::Idle
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if matches!(result, RunResult::Finished | RunResult::LimitReached) {
|
if matches!(result, RunResult::Finished | RunResult::LimitReached) {
|
||||||
|
|
@ -1283,11 +1284,11 @@ impl App {
|
||||||
} => {
|
} => {
|
||||||
self.rewind_refresh_fence = false;
|
self.rewind_refresh_fence = false;
|
||||||
self.restore_snapshot(&entries, greeting, in_flight);
|
self.restore_snapshot(&entries, greeting, in_flight);
|
||||||
self.set_pod_status(status);
|
self.set_worker_status(status);
|
||||||
}
|
}
|
||||||
Event::Status { status } => {
|
Event::Status { status } => {
|
||||||
self.rewind_refresh_fence = false;
|
self.rewind_refresh_fence = false;
|
||||||
self.set_pod_status(status);
|
self.set_worker_status(status);
|
||||||
}
|
}
|
||||||
Event::Completions { kind, entries } => {
|
Event::Completions { kind, entries } => {
|
||||||
// Apply only if the popup is still on the same
|
// Apply only if the popup is still on the same
|
||||||
|
|
@ -1324,7 +1325,7 @@ impl App {
|
||||||
};
|
};
|
||||||
self.completion = None;
|
self.completion = None;
|
||||||
self.close_rewind_picker();
|
self.close_rewind_picker();
|
||||||
self.reset_run_state(self.pod_status);
|
self.reset_run_state(self.worker_status);
|
||||||
let mut message = if restored_composer {
|
let mut message = if restored_composer {
|
||||||
format!(
|
format!(
|
||||||
"Rewound session: discarded {} log entries; restored selected input to composer.",
|
"Rewound session: discarded {} log entries; restored selected input to composer.",
|
||||||
|
|
@ -1343,20 +1344,20 @@ impl App {
|
||||||
}
|
}
|
||||||
self.blocks.push(Block::Alert {
|
self.blocks.push(Block::Alert {
|
||||||
level: AlertLevel::Warn,
|
level: AlertLevel::Warn,
|
||||||
source: AlertSource::Pod,
|
source: AlertSource::Worker,
|
||||||
message,
|
message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Event::PodsListed { .. } | Event::PodRestored { .. } => {}
|
Event::WorkersListed { .. } | Event::WorkerRestored { .. } => {}
|
||||||
Event::PeerRegistered { result } => {
|
Event::PeerRegistered { result } => {
|
||||||
let source = result
|
let source = result
|
||||||
.get("source")
|
.get("source")
|
||||||
.and_then(serde_json::Value::as_str)
|
.and_then(serde_json::Value::as_str)
|
||||||
.unwrap_or("this Pod");
|
.unwrap_or("this Worker");
|
||||||
let peer = result
|
let peer = result
|
||||||
.get("peer")
|
.get("peer")
|
||||||
.and_then(serde_json::Value::as_str)
|
.and_then(serde_json::Value::as_str)
|
||||||
.unwrap_or("peer Pod");
|
.unwrap_or("peer Worker");
|
||||||
self.flash_actionbar_notice(
|
self.flash_actionbar_notice(
|
||||||
format!("Peer metadata registered: `{source}` ↔ `{peer}`"),
|
format!("Peer metadata registered: `{source}` ↔ `{peer}`"),
|
||||||
ActionbarNoticeLevel::Info,
|
ActionbarNoticeLevel::Info,
|
||||||
|
|
@ -1372,8 +1373,8 @@ impl App {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reset_run_state(&mut self, status: PodStatus) {
|
fn reset_run_state(&mut self, status: WorkerStatus) {
|
||||||
self.set_pod_status(status);
|
self.set_worker_status(status);
|
||||||
self.run_requests = 0;
|
self.run_requests = 0;
|
||||||
self.run_upload_tokens = 0;
|
self.run_upload_tokens = 0;
|
||||||
self.run_output_tokens = 0;
|
self.run_output_tokens = 0;
|
||||||
|
|
@ -1403,10 +1404,10 @@ impl App {
|
||||||
"Rolled back empty assistant turn; no local submitted input was available to restore."
|
"Rolled back empty assistant turn; no local submitted input was available to restore."
|
||||||
.to_owned()
|
.to_owned()
|
||||||
};
|
};
|
||||||
self.reset_run_state(PodStatus::Idle);
|
self.reset_run_state(WorkerStatus::Idle);
|
||||||
self.blocks.push(Block::Alert {
|
self.blocks.push(Block::Alert {
|
||||||
level: AlertLevel::Warn,
|
level: AlertLevel::Warn,
|
||||||
source: AlertSource::Pod,
|
source: AlertSource::Worker,
|
||||||
message: hint,
|
message: hint,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1706,15 +1707,17 @@ impl App {
|
||||||
|
|
||||||
pub fn request_rewind_picker(&mut self) -> Option<Method> {
|
pub fn request_rewind_picker(&mut self) -> Option<Method> {
|
||||||
if self.rewind_submit_pending() {
|
if self.rewind_submit_pending() {
|
||||||
self.push_command_diagnostic("rewind is already applying; wait for the Pod response");
|
self.push_command_diagnostic(
|
||||||
|
"rewind is already applying; wait for the Worker response",
|
||||||
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if !self.connected {
|
if !self.connected {
|
||||||
self.push_command_diagnostic("cannot rewind before the Pod is connected");
|
self.push_command_diagnostic("cannot rewind before the Worker is connected");
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if self.running {
|
if self.running {
|
||||||
self.push_command_diagnostic("cannot rewind while the Pod is running");
|
self.push_command_diagnostic("cannot rewind while the Worker is running");
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
self.completion = None;
|
self.completion = None;
|
||||||
|
|
@ -1731,7 +1734,7 @@ impl App {
|
||||||
pub fn cancel_rewind_picker(&mut self) {
|
pub fn cancel_rewind_picker(&mut self) {
|
||||||
if self.rewind_submit_pending() {
|
if self.rewind_submit_pending() {
|
||||||
self.flash_actionbar_notice(
|
self.flash_actionbar_notice(
|
||||||
"Rewind is applying; wait for the Pod response.",
|
"Rewind is applying; wait for the Worker response.",
|
||||||
ActionbarNoticeLevel::Warn,
|
ActionbarNoticeLevel::Warn,
|
||||||
ActionbarNoticeSource::Tui,
|
ActionbarNoticeSource::Tui,
|
||||||
Duration::from_secs(3),
|
Duration::from_secs(3),
|
||||||
|
|
@ -1764,12 +1767,14 @@ impl App {
|
||||||
|
|
||||||
pub fn submit_rewind_picker(&mut self) -> Option<Method> {
|
pub fn submit_rewind_picker(&mut self) -> Option<Method> {
|
||||||
if self.rewind_submit_pending() {
|
if self.rewind_submit_pending() {
|
||||||
self.push_command_diagnostic("rewind is already applying; wait for the Pod response");
|
self.push_command_diagnostic(
|
||||||
|
"rewind is already applying; wait for the Worker response",
|
||||||
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if self.paused {
|
if self.paused {
|
||||||
self.push_command_diagnostic(
|
self.push_command_diagnostic(
|
||||||
"cannot apply rewind while the Pod is paused; resume or wait for idle first",
|
"cannot apply rewind while the Worker is paused; resume or wait for idle first",
|
||||||
);
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
@ -1783,7 +1788,9 @@ impl App {
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
if picker.applying {
|
if picker.applying {
|
||||||
self.push_command_diagnostic("rewind is already applying; wait for the Pod response");
|
self.push_command_diagnostic(
|
||||||
|
"rewind is already applying; wait for the Worker response",
|
||||||
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let (target_id, expected_head_entries) = match picker.selected_target() {
|
let (target_id, expected_head_entries) = match picker.selected_target() {
|
||||||
|
|
@ -1849,7 +1856,7 @@ impl App {
|
||||||
fn push_command_diagnostic(&mut self, message: impl Into<String>) {
|
fn push_command_diagnostic(&mut self, message: impl Into<String>) {
|
||||||
self.blocks.push(Block::Alert {
|
self.blocks.push(Block::Alert {
|
||||||
level: AlertLevel::Warn,
|
level: AlertLevel::Warn,
|
||||||
source: AlertSource::Pod,
|
source: AlertSource::Worker,
|
||||||
message: format!("TUI command: {}", message.into()),
|
message: format!("TUI command: {}", message.into()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1971,7 +1978,7 @@ impl App {
|
||||||
self.apply_in_flight_snapshot(in_flight);
|
self.apply_in_flight_snapshot(in_flight);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restore after a successful destructive rewind. The Pod's
|
/// Restore after a successful destructive rewind. The Worker's
|
||||||
/// `RewindApplied` event already contains the authoritative post-rewind
|
/// `RewindApplied` event already contains the authoritative post-rewind
|
||||||
/// session tail; always clear/replay from it even if this TUI instance has
|
/// session tail; always clear/replay from it even if this TUI instance has
|
||||||
/// somehow lost connect-time greeting metadata. Skipping the restore in
|
/// somehow lost connect-time greeting metadata. Skipping the restore in
|
||||||
|
|
@ -1993,7 +2000,7 @@ impl App {
|
||||||
if missing_greeting {
|
if missing_greeting {
|
||||||
self.blocks.push(Block::Alert {
|
self.blocks.push(Block::Alert {
|
||||||
level: AlertLevel::Warn,
|
level: AlertLevel::Warn,
|
||||||
source: AlertSource::Pod,
|
source: AlertSource::Worker,
|
||||||
message: "Rewind applied, but greeting metadata was unavailable; restored the session tail without the header.".to_owned(),
|
message: "Rewind applied, but greeting metadata was unavailable; restored the session tail without the header.".to_owned(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -2023,7 +2030,7 @@ impl App {
|
||||||
|
|
||||||
/// Drop the derived view in preparation for replaying a new
|
/// Drop the derived view in preparation for replaying a new
|
||||||
/// `SegmentStart` (compaction / fork). Greeting is preserved
|
/// `SegmentStart` (compaction / fork). Greeting is preserved
|
||||||
/// because the Pod identity hasn't changed.
|
/// because the Worker identity hasn't changed.
|
||||||
fn reset_for_rotation(&mut self) {
|
fn reset_for_rotation(&mut self) {
|
||||||
let greeting = self.blocks.iter().find_map(|b| match b {
|
let greeting = self.blocks.iter().find_map(|b| match b {
|
||||||
Block::Greeting(g) => Some(g.clone()),
|
Block::Greeting(g) => Some(g.clone()),
|
||||||
|
|
@ -2084,7 +2091,7 @@ impl App {
|
||||||
///
|
///
|
||||||
/// Kind-based routing replaces the old free-text `[Notification]` /
|
/// Kind-based routing replaces the old free-text `[Notification]` /
|
||||||
/// `[File: …]` parsing path: each kind maps directly to a typed
|
/// `[File: …]` parsing path: each kind maps directly to a typed
|
||||||
/// block (`Block::Notify`, `Block::PodEvent`, …).
|
/// block (`Block::Notify`, `Block::WorkerEvent`, …).
|
||||||
fn apply_system_item(&mut self, value: &serde_json::Value) {
|
fn apply_system_item(&mut self, value: &serde_json::Value) {
|
||||||
let Ok(item) = serde_json::from_value::<session_store::SystemItem>(value.clone()) else {
|
let Ok(item) = serde_json::from_value::<session_store::SystemItem>(value.clone()) else {
|
||||||
// Unknown / forward-compat shape: fall back to rendering the
|
// Unknown / forward-compat shape: fall back to rendering the
|
||||||
|
|
@ -2101,8 +2108,8 @@ impl App {
|
||||||
session_store::SystemItem::Notification { message, .. } => {
|
session_store::SystemItem::Notification { message, .. } => {
|
||||||
self.blocks.push(Block::Notify { message });
|
self.blocks.push(Block::Notify { message });
|
||||||
}
|
}
|
||||||
session_store::SystemItem::PodEvent { event, .. } => {
|
session_store::SystemItem::WorkerEvent { event, .. } => {
|
||||||
self.blocks.push(Block::PodEvent { event });
|
self.blocks.push(Block::WorkerEvent { event });
|
||||||
}
|
}
|
||||||
session_store::SystemItem::FileAttachment { body, .. }
|
session_store::SystemItem::FileAttachment { body, .. }
|
||||||
| session_store::SystemItem::Knowledge { body, .. }
|
| session_store::SystemItem::Knowledge { body, .. }
|
||||||
|
|
@ -2230,7 +2237,7 @@ fn rollback_input_preview(text: &str) -> String {
|
||||||
|
|
||||||
pub fn alert_source_label(source: AlertSource) -> &'static str {
|
pub fn alert_source_label(source: AlertSource) -> &'static str {
|
||||||
match source {
|
match source {
|
||||||
AlertSource::Pod => "pod",
|
AlertSource::Worker => "worker",
|
||||||
AlertSource::Engine => "engine",
|
AlertSource::Engine => "engine",
|
||||||
AlertSource::Compactor => "compactor",
|
AlertSource::Compactor => "compactor",
|
||||||
AlertSource::AgentsMd => "AGENTS.md",
|
AlertSource::AgentsMd => "AGENTS.md",
|
||||||
|
|
@ -2244,7 +2251,7 @@ mod llm_wait_event_tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn llm_retry_updates_and_progress_clears_transient_status() {
|
fn llm_retry_updates_and_progress_clears_transient_status() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
app.handle_pod_event(Event::LlmRetry {
|
app.handle_worker_event(Event::LlmRetry {
|
||||||
llm_call: 2,
|
llm_call: 2,
|
||||||
failed_attempt: 1,
|
failed_attempt: 1,
|
||||||
max_attempts: 4,
|
max_attempts: 4,
|
||||||
|
|
@ -2258,14 +2265,14 @@ mod llm_wait_event_tests {
|
||||||
Some("retrying LLM request after HTTP 504 (attempt 2/4 in 1.2s)")
|
Some("retrying LLM request after HTTP 504 (attempt 2/4 in 1.2s)")
|
||||||
);
|
);
|
||||||
|
|
||||||
app.handle_pod_event(Event::TextDelta { text: "ok".into() });
|
app.handle_worker_event(Event::TextDelta { text: "ok".into() });
|
||||||
assert!(app.latest_llm_wait_event.is_none());
|
assert!(app.latest_llm_wait_event.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn llm_continuation_updates_transient_status() {
|
fn llm_continuation_updates_transient_status() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
app.handle_pod_event(Event::LlmContinuation {
|
app.handle_worker_event(Event::LlmContinuation {
|
||||||
llm_call: 3,
|
llm_call: 3,
|
||||||
attempt: 1,
|
attempt: 1,
|
||||||
max_attempts: 3,
|
max_attempts: 3,
|
||||||
|
|
@ -2289,7 +2296,7 @@ mod actionbar_notice_tests {
|
||||||
let duration = Duration::from_secs(2);
|
let duration = Duration::from_secs(2);
|
||||||
|
|
||||||
app.flash_actionbar_notice_at(
|
app.flash_actionbar_notice_at(
|
||||||
"Pod keeps running",
|
"Worker keeps running",
|
||||||
ActionbarNoticeLevel::Warn,
|
ActionbarNoticeLevel::Warn,
|
||||||
ActionbarNoticeSource::Tui,
|
ActionbarNoticeSource::Tui,
|
||||||
now,
|
now,
|
||||||
|
|
@ -2297,7 +2304,7 @@ mod actionbar_notice_tests {
|
||||||
);
|
);
|
||||||
|
|
||||||
let notice = app.current_actionbar_notice(now).expect("notice is active");
|
let notice = app.current_actionbar_notice(now).expect("notice is active");
|
||||||
assert_eq!(notice.text, "Pod keeps running");
|
assert_eq!(notice.text, "Worker keeps running");
|
||||||
assert_eq!(notice.level, ActionbarNoticeLevel::Warn);
|
assert_eq!(notice.level, ActionbarNoticeLevel::Warn);
|
||||||
assert_eq!(notice.source, ActionbarNoticeSource::Tui);
|
assert_eq!(notice.source, ActionbarNoticeSource::Tui);
|
||||||
assert_eq!(notice.expires_at, now + duration);
|
assert_eq!(notice.expires_at, now + duration);
|
||||||
|
|
@ -2325,7 +2332,7 @@ mod rewind_refresh_tests {
|
||||||
text: "old post-target output".into(),
|
text: "old post-target output".into(),
|
||||||
});
|
});
|
||||||
|
|
||||||
app.handle_pod_event(Event::RewindApplied {
|
app.handle_worker_event(Event::RewindApplied {
|
||||||
entries: vec![],
|
entries: vec![],
|
||||||
input: vec![Segment::text("selected rewind input")],
|
input: vec![Segment::text("selected rewind input")],
|
||||||
summary: summary(3),
|
summary: summary(3),
|
||||||
|
|
@ -2344,7 +2351,7 @@ mod rewind_refresh_tests {
|
||||||
text: "old live tail without greeting".into(),
|
text: "old live tail without greeting".into(),
|
||||||
});
|
});
|
||||||
|
|
||||||
app.handle_pod_event(Event::RewindApplied {
|
app.handle_worker_event(Event::RewindApplied {
|
||||||
entries: vec![],
|
entries: vec![],
|
||||||
input: vec![Segment::text("rewound input")],
|
input: vec![Segment::text("rewound input")],
|
||||||
summary: summary(1),
|
summary: summary(1),
|
||||||
|
|
@ -2367,7 +2374,7 @@ mod rewind_refresh_tests {
|
||||||
assert!(app.rewind_picker.as_ref().unwrap().applying);
|
assert!(app.rewind_picker.as_ref().unwrap().applying);
|
||||||
assert!(app.submit_rewind_picker().is_none());
|
assert!(app.submit_rewind_picker().is_none());
|
||||||
|
|
||||||
app.handle_pod_event(Event::Error {
|
app.handle_worker_event(Event::Error {
|
||||||
code: ErrorCode::InvalidRequest,
|
code: ErrorCode::InvalidRequest,
|
||||||
message: "stale rewind target".into(),
|
message: "stale rewind target".into(),
|
||||||
});
|
});
|
||||||
|
|
@ -2387,20 +2394,20 @@ mod rewind_refresh_tests {
|
||||||
text: "old tail before rewind".into(),
|
text: "old tail before rewind".into(),
|
||||||
});
|
});
|
||||||
|
|
||||||
app.handle_pod_event(Event::RewindApplied {
|
app.handle_worker_event(Event::RewindApplied {
|
||||||
entries: vec![],
|
entries: vec![],
|
||||||
input: vec![Segment::text("rewound input")],
|
input: vec![Segment::text("rewound input")],
|
||||||
summary: summary(2),
|
summary: summary(2),
|
||||||
});
|
});
|
||||||
app.handle_pod_event(Event::TextDelta {
|
app.handle_worker_event(Event::TextDelta {
|
||||||
text: "stale tail after rewind".into(),
|
text: "stale tail after rewind".into(),
|
||||||
});
|
});
|
||||||
assert!(!blocks_contain(&app, "stale tail after rewind"));
|
assert!(!blocks_contain(&app, "stale tail after rewind"));
|
||||||
|
|
||||||
app.handle_pod_event(Event::Status {
|
app.handle_worker_event(Event::Status {
|
||||||
status: PodStatus::Idle,
|
status: WorkerStatus::Idle,
|
||||||
});
|
});
|
||||||
app.handle_pod_event(Event::TextDelta {
|
app.handle_worker_event(Event::TextDelta {
|
||||||
text: "new live tail after status".into(),
|
text: "new live tail after status".into(),
|
||||||
});
|
});
|
||||||
assert!(blocks_contain(&app, "new live tail after status"));
|
assert!(blocks_contain(&app, "new live tail after status"));
|
||||||
|
|
@ -2431,7 +2438,7 @@ mod rewind_refresh_tests {
|
||||||
|
|
||||||
fn greeting() -> protocol::Greeting {
|
fn greeting() -> protocol::Greeting {
|
||||||
protocol::Greeting {
|
protocol::Greeting {
|
||||||
pod_name: "test".into(),
|
worker_name: "test".into(),
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
provider: "mock".into(),
|
provider: "mock".into(),
|
||||||
model: "mock".into(),
|
model: "mock".into(),
|
||||||
|
|
@ -2916,7 +2923,7 @@ mod completion_flow_tests {
|
||||||
}
|
}
|
||||||
let _ = app.refresh_completion();
|
let _ = app.refresh_completion();
|
||||||
// Reply for a different kind shouldn't overwrite state.
|
// Reply for a different kind shouldn't overwrite state.
|
||||||
app.handle_pod_event(Event::Completions {
|
app.handle_worker_event(Event::Completions {
|
||||||
kind: CompletionKind::Workflow,
|
kind: CompletionKind::Workflow,
|
||||||
entries: vec![CompletionEntry {
|
entries: vec![CompletionEntry {
|
||||||
value: "stale".into(),
|
value: "stale".into(),
|
||||||
|
|
@ -2939,10 +2946,10 @@ mod completion_flow_tests {
|
||||||
compacted_from: None,
|
compacted_from: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
app.handle_pod_event(Event::SegmentRotated {
|
app.handle_worker_event(Event::SegmentRotated {
|
||||||
entry: serde_json::to_value(start).expect("LogEntry is Serialize"),
|
entry: serde_json::to_value(start).expect("LogEntry is Serialize"),
|
||||||
});
|
});
|
||||||
app.handle_pod_event(Event::UserMessage {
|
app.handle_worker_event(Event::UserMessage {
|
||||||
segments: vec![Segment::text("first persisted message")],
|
segments: vec![Segment::text("first persisted message")],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2960,20 +2967,20 @@ mod completion_flow_tests {
|
||||||
let submitted = submit_text(&mut app, "please wait");
|
let submitted = submit_text(&mut app, "please wait");
|
||||||
assert_eq!(input_text(&app), "");
|
assert_eq!(input_text(&app), "");
|
||||||
|
|
||||||
app.handle_pod_event(Event::UserMessage {
|
app.handle_worker_event(Event::UserMessage {
|
||||||
segments: submitted,
|
segments: submitted,
|
||||||
});
|
});
|
||||||
// Simulate run-derived attachment display after the submitted user line.
|
// Simulate run-derived attachment display after the submitted user line.
|
||||||
app.blocks.push(Block::SystemMessage {
|
app.blocks.push(Block::SystemMessage {
|
||||||
text: "[File: README.md]".into(),
|
text: "[File: README.md]".into(),
|
||||||
});
|
});
|
||||||
app.handle_pod_event(Event::TurnStart { turn: 1 });
|
app.handle_worker_event(Event::TurnStart { turn: 1 });
|
||||||
app.handle_pod_event(Event::Usage {
|
app.handle_worker_event(Event::Usage {
|
||||||
input_tokens: Some(100),
|
input_tokens: Some(100),
|
||||||
output_tokens: Some(0),
|
output_tokens: Some(0),
|
||||||
cache_read_input_tokens: Some(40),
|
cache_read_input_tokens: Some(40),
|
||||||
});
|
});
|
||||||
app.handle_pod_event(Event::RunEnd {
|
app.handle_worker_event(Event::RunEnd {
|
||||||
result: RunResult::RolledBack,
|
result: RunResult::RolledBack,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2987,7 +2994,7 @@ mod completion_flow_tests {
|
||||||
| Block::TurnStats { .. }
|
| Block::TurnStats { .. }
|
||||||
)));
|
)));
|
||||||
assert!(warning_contains(&app, "restored your input"));
|
assert!(warning_contains(&app, "restored your input"));
|
||||||
assert!(matches!(app.pod_status, PodStatus::Idle));
|
assert!(matches!(app.worker_status, WorkerStatus::Idle));
|
||||||
assert!(!app.running);
|
assert!(!app.running);
|
||||||
assert!(!app.paused);
|
assert!(!app.paused);
|
||||||
assert_eq!(app.run_requests, 0);
|
assert_eq!(app.run_requests, 0);
|
||||||
|
|
@ -3000,14 +3007,14 @@ mod completion_flow_tests {
|
||||||
fn rolled_back_run_does_not_overwrite_existing_unsent_input() {
|
fn rolled_back_run_does_not_overwrite_existing_unsent_input() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
let submitted = submit_text(&mut app, "original submit");
|
let submitted = submit_text(&mut app, "original submit");
|
||||||
app.handle_pod_event(Event::UserMessage {
|
app.handle_worker_event(Event::UserMessage {
|
||||||
segments: submitted,
|
segments: submitted,
|
||||||
});
|
});
|
||||||
for c in "draft while running".chars() {
|
for c in "draft while running".chars() {
|
||||||
app.insert_char(c);
|
app.insert_char(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
app.handle_pod_event(Event::RunEnd {
|
app.handle_worker_event(Event::RunEnd {
|
||||||
result: RunResult::RolledBack,
|
result: RunResult::RolledBack,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -3028,10 +3035,10 @@ mod completion_flow_tests {
|
||||||
for result in [RunResult::Paused, RunResult::Finished] {
|
for result in [RunResult::Paused, RunResult::Finished] {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
let submitted = submit_text(&mut app, "normal run");
|
let submitted = submit_text(&mut app, "normal run");
|
||||||
app.handle_pod_event(Event::UserMessage {
|
app.handle_worker_event(Event::UserMessage {
|
||||||
segments: submitted,
|
segments: submitted,
|
||||||
});
|
});
|
||||||
app.handle_pod_event(Event::RunEnd { result });
|
app.handle_worker_event(Event::RunEnd { result });
|
||||||
|
|
||||||
assert_eq!(input_text(&app), "");
|
assert_eq!(input_text(&app), "");
|
||||||
assert!(
|
assert!(
|
||||||
|
|
@ -3057,7 +3064,7 @@ mod completion_flow_tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn running_submit_is_queued_locally_and_clears_composer() {
|
fn running_submit_is_queued_locally_and_clears_composer() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
app.set_pod_status(PodStatus::Running);
|
app.set_worker_status(WorkerStatus::Running);
|
||||||
insert_text(&mut app, "queued turn");
|
insert_text(&mut app, "queued turn");
|
||||||
|
|
||||||
assert!(app.submit_input().is_none());
|
assert!(app.submit_input().is_none());
|
||||||
|
|
@ -3070,11 +3077,11 @@ mod completion_flow_tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn finished_run_auto_sends_next_queued_input() {
|
fn finished_run_auto_sends_next_queued_input() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
app.set_pod_status(PodStatus::Running);
|
app.set_worker_status(WorkerStatus::Running);
|
||||||
insert_text(&mut app, "next turn");
|
insert_text(&mut app, "next turn");
|
||||||
assert!(app.submit_input().is_none());
|
assert!(app.submit_input().is_none());
|
||||||
|
|
||||||
let method = app.handle_pod_event(Event::RunEnd {
|
let method = app.handle_worker_event(Event::RunEnd {
|
||||||
result: RunResult::Finished,
|
result: RunResult::Finished,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -3090,11 +3097,11 @@ mod completion_flow_tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn limit_reached_run_auto_sends_next_queued_input() {
|
fn limit_reached_run_auto_sends_next_queued_input() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
app.set_pod_status(PodStatus::Running);
|
app.set_worker_status(WorkerStatus::Running);
|
||||||
insert_text(&mut app, "next after limit");
|
insert_text(&mut app, "next after limit");
|
||||||
assert!(app.submit_input().is_none());
|
assert!(app.submit_input().is_none());
|
||||||
|
|
||||||
let method = app.handle_pod_event(Event::RunEnd {
|
let method = app.handle_worker_event(Event::RunEnd {
|
||||||
result: RunResult::LimitReached,
|
result: RunResult::LimitReached,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -3111,11 +3118,11 @@ mod completion_flow_tests {
|
||||||
fn paused_and_rolled_back_run_do_not_auto_send_queue() {
|
fn paused_and_rolled_back_run_do_not_auto_send_queue() {
|
||||||
for result in [RunResult::Paused, RunResult::RolledBack] {
|
for result in [RunResult::Paused, RunResult::RolledBack] {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
app.set_pod_status(PodStatus::Running);
|
app.set_worker_status(WorkerStatus::Running);
|
||||||
insert_text(&mut app, "held turn");
|
insert_text(&mut app, "held turn");
|
||||||
assert!(app.submit_input().is_none());
|
assert!(app.submit_input().is_none());
|
||||||
|
|
||||||
let method = app.handle_pod_event(Event::RunEnd { result });
|
let method = app.handle_worker_event(Event::RunEnd { result });
|
||||||
|
|
||||||
assert!(method.is_none());
|
assert!(method.is_none());
|
||||||
assert_eq!(app.queued_input_count(), 1);
|
assert_eq!(app.queued_input_count(), 1);
|
||||||
|
|
@ -3126,7 +3133,7 @@ mod completion_flow_tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn paused_empty_submit_still_resumes_immediately() {
|
fn paused_empty_submit_still_resumes_immediately() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
app.set_pod_status(PodStatus::Paused);
|
app.set_worker_status(WorkerStatus::Paused);
|
||||||
|
|
||||||
assert!(matches!(app.submit_input(), Some(Method::Resume)));
|
assert!(matches!(app.submit_input(), Some(Method::Resume)));
|
||||||
assert_eq!(app.queued_input_count(), 0);
|
assert_eq!(app.queued_input_count(), 0);
|
||||||
|
|
@ -3135,7 +3142,7 @@ mod completion_flow_tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn queued_input_can_be_restored_to_composer_or_cleared() {
|
fn queued_input_can_be_restored_to_composer_or_cleared() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
app.set_pod_status(PodStatus::Running);
|
app.set_worker_status(WorkerStatus::Running);
|
||||||
insert_text(&mut app, "edit me");
|
insert_text(&mut app, "edit me");
|
||||||
assert!(app.submit_input().is_none());
|
assert!(app.submit_input().is_none());
|
||||||
|
|
||||||
|
|
@ -3198,14 +3205,14 @@ mod completion_flow_tests {
|
||||||
compacted_from: None,
|
compacted_from: None,
|
||||||
};
|
};
|
||||||
let session_start_value = serde_json::to_value(&session_start).unwrap();
|
let session_start_value = serde_json::to_value(&session_start).unwrap();
|
||||||
app.handle_pod_event(Event::Snapshot {
|
app.handle_worker_event(Event::Snapshot {
|
||||||
greeting: test_greeting(),
|
greeting: test_greeting(),
|
||||||
entries: vec![session_start_value],
|
entries: vec![session_start_value],
|
||||||
status: PodStatus::Running,
|
status: WorkerStatus::Running,
|
||||||
in_flight: Default::default(),
|
in_flight: Default::default(),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert!(matches!(app.pod_status, PodStatus::Running));
|
assert!(matches!(app.worker_status, WorkerStatus::Running));
|
||||||
assert!(app.running);
|
assert!(app.running);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
app.blocks.get(1),
|
app.blocks.get(1),
|
||||||
|
|
@ -3216,10 +3223,10 @@ mod completion_flow_tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn snapshot_in_flight_blocks_continue_with_live_deltas() {
|
fn snapshot_in_flight_blocks_continue_with_live_deltas() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
app.handle_pod_event(Event::Snapshot {
|
app.handle_worker_event(Event::Snapshot {
|
||||||
greeting: test_greeting(),
|
greeting: test_greeting(),
|
||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
status: PodStatus::Running,
|
status: WorkerStatus::Running,
|
||||||
in_flight: InFlightSnapshot {
|
in_flight: InFlightSnapshot {
|
||||||
blocks: vec![
|
blocks: vec![
|
||||||
InFlightBlock::Thinking {
|
InFlightBlock::Thinking {
|
||||||
|
|
@ -3240,9 +3247,9 @@ mod completion_flow_tests {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
app.handle_pod_event(Event::TextDelta { text: "lo".into() });
|
app.handle_worker_event(Event::TextDelta { text: "lo".into() });
|
||||||
app.handle_pod_event(Event::ThinkingDelta { text: "?".into() });
|
app.handle_worker_event(Event::ThinkingDelta { text: "?".into() });
|
||||||
app.handle_pod_event(Event::ToolCallArgsDelta {
|
app.handle_worker_event(Event::ToolCallArgsDelta {
|
||||||
id: "call_1".into(),
|
id: "call_1".into(),
|
||||||
json: r#"\":\"src/lib.rs\"}"#.into(),
|
json: r#"\":\"src/lib.rs\"}"#.into(),
|
||||||
});
|
});
|
||||||
|
|
@ -3269,7 +3276,7 @@ mod completion_flow_tests {
|
||||||
"slug": "build",
|
"slug": "build",
|
||||||
"body": "[Workflow /build]\nRun the build",
|
"body": "[Workflow /build]\nRun the build",
|
||||||
});
|
});
|
||||||
app.handle_pod_event(Event::SystemItem { item });
|
app.handle_worker_event(Event::SystemItem { item });
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
app.blocks.as_slice(),
|
app.blocks.as_slice(),
|
||||||
|
|
@ -3285,7 +3292,7 @@ mod completion_flow_tests {
|
||||||
"message": "hi",
|
"message": "hi",
|
||||||
"body": "[Notification] hi",
|
"body": "[Notification] hi",
|
||||||
});
|
});
|
||||||
app.handle_pod_event(Event::SystemItem { item });
|
app.handle_worker_event(Event::SystemItem { item });
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
app.blocks.as_slice(),
|
app.blocks.as_slice(),
|
||||||
[Block::Notify { message }] if message == "hi"
|
[Block::Notify { message }] if message == "hi"
|
||||||
|
|
@ -3293,20 +3300,20 @@ mod completion_flow_tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn live_system_item_pod_event_appends_pod_event_block() {
|
fn live_system_item_worker_event_appends_worker_event_block() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
let item = serde_json::json!({
|
let item = serde_json::json!({
|
||||||
"kind": "pod_event",
|
"kind": "worker_event",
|
||||||
"event": { "kind": "turn_ended", "pod_name": "child" },
|
"event": { "kind": "turn_ended", "worker_name": "child" },
|
||||||
"body": "[Notification] pod `child` finished a turn",
|
"body": "[Notification] worker `child` finished a turn",
|
||||||
});
|
});
|
||||||
app.handle_pod_event(Event::SystemItem { item });
|
app.handle_worker_event(Event::SystemItem { item });
|
||||||
assert_eq!(app.blocks.len(), 1);
|
assert_eq!(app.blocks.len(), 1);
|
||||||
match &app.blocks[0] {
|
match &app.blocks[0] {
|
||||||
Block::PodEvent {
|
Block::WorkerEvent {
|
||||||
event: protocol::PodEvent::TurnEnded { pod_name },
|
event: protocol::WorkerEvent::TurnEnded { worker_name },
|
||||||
} => assert_eq!(pod_name, "child"),
|
} => assert_eq!(worker_name, "child"),
|
||||||
_ => panic!("expected a PodEvent block"),
|
_ => panic!("expected a WorkerEvent block"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3315,8 +3322,8 @@ mod completion_flow_tests {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
let id = uuid::Uuid::parse_str("12345678-1234-5678-1234-567812345678").unwrap();
|
let id = uuid::Uuid::parse_str("12345678-1234-5678-1234-567812345678").unwrap();
|
||||||
|
|
||||||
app.handle_pod_event(Event::CompactStart);
|
app.handle_worker_event(Event::CompactStart);
|
||||||
app.handle_pod_event(Event::CompactDone { new_segment_id: id });
|
app.handle_worker_event(Event::CompactDone { new_segment_id: id });
|
||||||
|
|
||||||
assert_eq!(compact_block_count(&app), 1);
|
assert_eq!(compact_block_count(&app), 1);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
|
|
@ -3332,8 +3339,8 @@ mod completion_flow_tests {
|
||||||
fn compact_failed_replaces_live_block() {
|
fn compact_failed_replaces_live_block() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
|
|
||||||
app.handle_pod_event(Event::CompactStart);
|
app.handle_worker_event(Event::CompactStart);
|
||||||
app.handle_pod_event(Event::CompactFailed {
|
app.handle_worker_event(Event::CompactFailed {
|
||||||
error: "provider 429".into(),
|
error: "provider 429".into(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -3351,8 +3358,8 @@ mod completion_flow_tests {
|
||||||
fn shutdown_marks_live_compact_incomplete() {
|
fn shutdown_marks_live_compact_incomplete() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
|
|
||||||
app.handle_pod_event(Event::CompactStart);
|
app.handle_worker_event(Event::CompactStart);
|
||||||
app.handle_pod_event(Event::Shutdown);
|
app.handle_worker_event(Event::Shutdown);
|
||||||
|
|
||||||
assert!(app.quit);
|
assert!(app.quit);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
|
|
@ -3372,7 +3379,7 @@ mod completion_flow_tests {
|
||||||
|
|
||||||
fn test_greeting() -> protocol::Greeting {
|
fn test_greeting() -> protocol::Greeting {
|
||||||
protocol::Greeting {
|
protocol::Greeting {
|
||||||
pod_name: "test".into(),
|
worker_name: "test".into(),
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
provider: "test-provider".into(),
|
provider: "test-provider".into(),
|
||||||
model: "test-model".into(),
|
model: "test-model".into(),
|
||||||
|
|
@ -3390,10 +3397,10 @@ mod completion_flow_tests {
|
||||||
greeting.context_window = 123_000;
|
greeting.context_window = 123_000;
|
||||||
greeting.context_tokens = 45_000;
|
greeting.context_tokens = 45_000;
|
||||||
|
|
||||||
app.handle_pod_event(Event::Snapshot {
|
app.handle_worker_event(Event::Snapshot {
|
||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
greeting,
|
greeting,
|
||||||
status: PodStatus::Idle,
|
status: WorkerStatus::Idle,
|
||||||
in_flight: Default::default(),
|
in_flight: Default::default(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -3405,7 +3412,7 @@ mod completion_flow_tests {
|
||||||
fn usage_updates_session_context_tokens_without_cache_discount() {
|
fn usage_updates_session_context_tokens_without_cache_discount() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
|
|
||||||
app.handle_pod_event(Event::Usage {
|
app.handle_worker_event(Event::Usage {
|
||||||
input_tokens: Some(42_000),
|
input_tokens: Some(42_000),
|
||||||
output_tokens: Some(9),
|
output_tokens: Some(9),
|
||||||
cache_read_input_tokens: Some(40_000),
|
cache_read_input_tokens: Some(40_000),
|
||||||
|
|
@ -3420,7 +3427,7 @@ mod completion_flow_tests {
|
||||||
fn memory_worker_event_updates_actionbar_state() {
|
fn memory_worker_event_updates_actionbar_state() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
|
|
||||||
app.handle_pod_event(Event::MemoryWorker(protocol::MemoryWorkerEvent {
|
app.handle_worker_event(Event::MemoryWorker(protocol::MemoryWorkerEvent {
|
||||||
worker: "extract".into(),
|
worker: "extract".into(),
|
||||||
status: "done".into(),
|
status: "done".into(),
|
||||||
run_id: "00000000-0000-0000-0000-000000000000".into(),
|
run_id: "00000000-0000-0000-0000-000000000000".into(),
|
||||||
|
|
@ -3441,7 +3448,7 @@ mod completion_flow_tests {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
app.session_context_tokens = 42_000;
|
app.session_context_tokens = 42_000;
|
||||||
|
|
||||||
app.handle_pod_event(Event::CompactDone {
|
app.handle_worker_event(Event::CompactDone {
|
||||||
new_segment_id: uuid::Uuid::nil(),
|
new_segment_id: uuid::Uuid::nil(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -3453,8 +3460,8 @@ mod completion_flow_tests {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
app.session_context_tokens = 42_000;
|
app.session_context_tokens = 42_000;
|
||||||
|
|
||||||
app.handle_pod_event(Event::TurnStart { turn: 1 });
|
app.handle_worker_event(Event::TurnStart { turn: 1 });
|
||||||
app.handle_pod_event(Event::RunEnd {
|
app.handle_worker_event(Event::RunEnd {
|
||||||
result: RunResult::Finished,
|
result: RunResult::Finished,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -3464,11 +3471,11 @@ mod completion_flow_tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn live_task_create_updates_task_store() {
|
fn live_task_create_updates_task_store() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
app.handle_pod_event(Event::ToolCallStart {
|
app.handle_worker_event(Event::ToolCallStart {
|
||||||
id: "c1".into(),
|
id: "c1".into(),
|
||||||
name: "TaskCreate".into(),
|
name: "TaskCreate".into(),
|
||||||
});
|
});
|
||||||
app.handle_pod_event(Event::ToolCallDone {
|
app.handle_worker_event(Event::ToolCallDone {
|
||||||
id: "c1".into(),
|
id: "c1".into(),
|
||||||
name: "TaskCreate".into(),
|
name: "TaskCreate".into(),
|
||||||
arguments: r#"{"subject":"impl tasks","description":"do it"}"#.into(),
|
arguments: r#"{"subject":"impl tasks","description":"do it"}"#.into(),
|
||||||
|
|
@ -3491,11 +3498,11 @@ mod completion_flow_tests {
|
||||||
} else {
|
} else {
|
||||||
"TaskUpdate"
|
"TaskUpdate"
|
||||||
};
|
};
|
||||||
app.handle_pod_event(Event::ToolCallStart {
|
app.handle_worker_event(Event::ToolCallStart {
|
||||||
id: id.into(),
|
id: id.into(),
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
});
|
});
|
||||||
app.handle_pod_event(Event::ToolCallDone {
|
app.handle_worker_event(Event::ToolCallDone {
|
||||||
id: id.into(),
|
id: id.into(),
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
arguments: args.into(),
|
arguments: args.into(),
|
||||||
|
|
@ -3511,11 +3518,11 @@ mod completion_flow_tests {
|
||||||
fn live_system_snapshot_replaces_task_store() {
|
fn live_system_snapshot_replaces_task_store() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
// Stale entry that the snapshot must wipe out.
|
// Stale entry that the snapshot must wipe out.
|
||||||
app.handle_pod_event(Event::ToolCallStart {
|
app.handle_worker_event(Event::ToolCallStart {
|
||||||
id: "c1".into(),
|
id: "c1".into(),
|
||||||
name: "TaskCreate".into(),
|
name: "TaskCreate".into(),
|
||||||
});
|
});
|
||||||
app.handle_pod_event(Event::ToolCallDone {
|
app.handle_worker_event(Event::ToolCallDone {
|
||||||
id: "c1".into(),
|
id: "c1".into(),
|
||||||
name: "TaskCreate".into(),
|
name: "TaskCreate".into(),
|
||||||
arguments: r#"{"subject":"stale","description":""}"#.into(),
|
arguments: r#"{"subject":"stale","description":""}"#.into(),
|
||||||
|
|
@ -3528,7 +3535,7 @@ mod completion_flow_tests {
|
||||||
\"description\": \"d\"\n }\n ]\n}\n```\n";
|
\"description\": \"d\"\n }\n ]\n}\n```\n";
|
||||||
// Snapshot text injected as a workflow body (kind doesn't matter
|
// Snapshot text injected as a workflow body (kind doesn't matter
|
||||||
// for task-store parsing, only the text contents do).
|
// for task-store parsing, only the text contents do).
|
||||||
app.handle_pod_event(Event::SystemItem {
|
app.handle_worker_event(Event::SystemItem {
|
||||||
item: serde_json::json!({
|
item: serde_json::json!({
|
||||||
"kind": "workflow",
|
"kind": "workflow",
|
||||||
"slug": "task-snapshot",
|
"slug": "task-snapshot",
|
||||||
|
|
@ -3547,11 +3554,11 @@ mod completion_flow_tests {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
// Live tool call before the snapshot lands — restore must wipe
|
// Live tool call before the snapshot lands — restore must wipe
|
||||||
// this so it doesn't double-count after replay.
|
// this so it doesn't double-count after replay.
|
||||||
app.handle_pod_event(Event::ToolCallStart {
|
app.handle_worker_event(Event::ToolCallStart {
|
||||||
id: "live".into(),
|
id: "live".into(),
|
||||||
name: "TaskCreate".into(),
|
name: "TaskCreate".into(),
|
||||||
});
|
});
|
||||||
app.handle_pod_event(Event::ToolCallDone {
|
app.handle_worker_event(Event::ToolCallDone {
|
||||||
id: "live".into(),
|
id: "live".into(),
|
||||||
name: "TaskCreate".into(),
|
name: "TaskCreate".into(),
|
||||||
arguments: r#"{"subject":"live","description":""}"#.into(),
|
arguments: r#"{"subject":"live","description":""}"#.into(),
|
||||||
|
|
@ -3589,10 +3596,10 @@ mod completion_flow_tests {
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
app.handle_pod_event(Event::Snapshot {
|
app.handle_worker_event(Event::Snapshot {
|
||||||
greeting: test_greeting(),
|
greeting: test_greeting(),
|
||||||
entries: assistant_item_entries,
|
entries: assistant_item_entries,
|
||||||
status: PodStatus::Running,
|
status: WorkerStatus::Running,
|
||||||
in_flight: Default::default(),
|
in_flight: Default::default(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
|
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use protocol::{AlertLevel, AlertSource, Greeting, PodEvent, Segment};
|
use protocol::{AlertLevel, AlertSource, Greeting, Segment, WorkerEvent};
|
||||||
|
|
||||||
pub enum Block {
|
pub enum Block {
|
||||||
Greeting(Greeting),
|
Greeting(Greeting),
|
||||||
|
|
@ -25,16 +25,16 @@ pub enum Block {
|
||||||
SystemMessage {
|
SystemMessage {
|
||||||
text: String,
|
text: String,
|
||||||
},
|
},
|
||||||
/// Echo of `Method::Notify` received by this Pod, surfaced as a log
|
/// Echo of `Method::Notify` received by this Worker, surfaced as a log
|
||||||
/// element so subscribers see the external input that drove any
|
/// element so subscribers see the external input that drove any
|
||||||
/// following auto-kicked turn.
|
/// following auto-kicked turn.
|
||||||
Notify {
|
Notify {
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
/// Echo of `Method::PodEvent` received by this Pod. Same role as
|
/// Echo of `Method::WorkerEvent` received by this Worker. Same role as
|
||||||
/// `Notify` — an input log element, not a turn-control signal.
|
/// `Notify` — an input log element, not a turn-control signal.
|
||||||
PodEvent {
|
WorkerEvent {
|
||||||
event: PodEvent,
|
event: WorkerEvent,
|
||||||
},
|
},
|
||||||
AssistantText {
|
AssistantText {
|
||||||
text: String,
|
text: String,
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,7 @@ impl CommandRegistry {
|
||||||
name: "compact",
|
name: "compact",
|
||||||
aliases: &[],
|
aliases: &[],
|
||||||
usage: "compact",
|
usage: "compact",
|
||||||
description: "Request immediate Pod context compaction.",
|
description: "Request immediate Worker context compaction.",
|
||||||
argument_parser: compact_args,
|
argument_parser: compact_args,
|
||||||
can_execute: compact_available,
|
can_execute: compact_available,
|
||||||
executor: compact_command,
|
executor: compact_command,
|
||||||
|
|
@ -159,8 +159,8 @@ impl CommandRegistry {
|
||||||
registry.register(CommandSpec {
|
registry.register(CommandSpec {
|
||||||
name: "peer",
|
name: "peer",
|
||||||
aliases: &[],
|
aliases: &[],
|
||||||
usage: "peer <pod-name>",
|
usage: "peer <worker-name>",
|
||||||
description: "Register another existing Pod as a reciprocal metadata peer.",
|
description: "Register another existing Worker as a reciprocal metadata peer.",
|
||||||
argument_parser: peer_args,
|
argument_parser: peer_args,
|
||||||
can_execute: peer_available,
|
can_execute: peer_available,
|
||||||
executor: peer_command,
|
executor: peer_command,
|
||||||
|
|
@ -317,7 +317,7 @@ fn peer_args(raw: &str) -> Result<CommandArgs, CommandDiagnostic> {
|
||||||
Ok(args)
|
Ok(args)
|
||||||
} else {
|
} else {
|
||||||
Err(CommandDiagnostic::new(
|
Err(CommandDiagnostic::new(
|
||||||
"Invalid arguments. Usage: peer <pod-name>",
|
"Invalid arguments. Usage: peer <worker-name>",
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -325,17 +325,17 @@ fn peer_args(raw: &str) -> Result<CommandArgs, CommandDiagnostic> {
|
||||||
fn compact_available(environment: &CommandEnvironment) -> Result<(), CommandDiagnostic> {
|
fn compact_available(environment: &CommandEnvironment) -> Result<(), CommandDiagnostic> {
|
||||||
if !environment.connected {
|
if !environment.connected {
|
||||||
return Err(CommandDiagnostic::new(
|
return Err(CommandDiagnostic::new(
|
||||||
"Cannot compact: not connected to a Pod.",
|
"Cannot compact: not connected to a Worker.",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if environment.running {
|
if environment.running {
|
||||||
return Err(CommandDiagnostic::new(
|
return Err(CommandDiagnostic::new(
|
||||||
"Cannot compact while the Pod is running.",
|
"Cannot compact while the Worker is running.",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if environment.paused {
|
if environment.paused {
|
||||||
return Err(CommandDiagnostic::new(
|
return Err(CommandDiagnostic::new(
|
||||||
"Cannot compact while the Pod is paused; resume or start a fresh turn first.",
|
"Cannot compact while the Worker is paused; resume or start a fresh turn first.",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -344,12 +344,12 @@ fn compact_available(environment: &CommandEnvironment) -> Result<(), CommandDiag
|
||||||
fn rewind_available(environment: &CommandEnvironment) -> Result<(), CommandDiagnostic> {
|
fn rewind_available(environment: &CommandEnvironment) -> Result<(), CommandDiagnostic> {
|
||||||
if !environment.connected {
|
if !environment.connected {
|
||||||
return Err(CommandDiagnostic::new(
|
return Err(CommandDiagnostic::new(
|
||||||
"Cannot rewind before the Pod is connected.",
|
"Cannot rewind before the Worker is connected.",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if environment.running {
|
if environment.running {
|
||||||
return Err(CommandDiagnostic::new(
|
return Err(CommandDiagnostic::new(
|
||||||
"Cannot rewind while the Pod is running.",
|
"Cannot rewind while the Worker is running.",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -358,12 +358,12 @@ fn rewind_available(environment: &CommandEnvironment) -> Result<(), CommandDiagn
|
||||||
fn peer_available(environment: &CommandEnvironment) -> Result<(), CommandDiagnostic> {
|
fn peer_available(environment: &CommandEnvironment) -> Result<(), CommandDiagnostic> {
|
||||||
if !environment.connected {
|
if !environment.connected {
|
||||||
return Err(CommandDiagnostic::new(
|
return Err(CommandDiagnostic::new(
|
||||||
"Cannot register a peer before the Pod is connected.",
|
"Cannot register a peer before the Worker is connected.",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if environment.running {
|
if environment.running {
|
||||||
return Err(CommandDiagnostic::new(
|
return Err(CommandDiagnostic::new(
|
||||||
"Cannot register a peer while the Pod is running.",
|
"Cannot register a peer while the Worker is running.",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -596,7 +596,7 @@ mod tests {
|
||||||
let registry = CommandRegistry::builtins();
|
let registry = CommandRegistry::builtins();
|
||||||
let result = registry.dispatch("help peer", &env());
|
let result = registry.dispatch("help peer", &env());
|
||||||
assert!(result.method.is_none());
|
assert!(result.method.is_none());
|
||||||
assert!(result.diagnostics[0].message.contains("peer <pod-name>"));
|
assert!(result.diagnostics[0].message.contains("peer <worker-name>"));
|
||||||
assert!(result.diagnostics[0].message.contains("metadata peer"));
|
assert!(result.diagnostics[0].message.contains("metadata peer"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ impl ComposerEditAction {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared readline-style composer editing keymap used by the normal Pod TUI
|
/// Shared readline-style composer editing keymap used by the normal Worker TUI
|
||||||
/// and the workspace panel. Callers still own higher-level routing such as
|
/// and the workspace panel. Callers still own higher-level routing such as
|
||||||
/// completion popups, Enter submission, Tab target switching, Esc focus, and
|
/// completion popups, Enter submission, Tab target switching, Esc focus, and
|
||||||
/// row/list navigation.
|
/// row/list navigation.
|
||||||
|
|
|
||||||
|
|
@ -18,14 +18,14 @@ use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
|
||||||
use crossterm::{Command, execute};
|
use crossterm::{Command, execute};
|
||||||
#[cfg(feature = "e2e-test")]
|
#[cfg(feature = "e2e-test")]
|
||||||
use protocol::{Event, Greeting, RewindSummary, RewindTarget, RewindTargetId, Segment};
|
use protocol::{Event, Greeting, RewindSummary, RewindTarget, RewindTargetId, Segment};
|
||||||
use protocol::{Method, PodStatus};
|
use protocol::{Method, WorkerStatus};
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
use session_store::SegmentId;
|
use session_store::SegmentId;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||||
use client::{PodClient, PodRuntimeCommand};
|
use client::{WorkerClient, WorkerRuntimeCommand};
|
||||||
|
|
||||||
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
|
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
|
||||||
use crate::composer_keys::{ComposerEditAction, composer_edit_action};
|
use crate::composer_keys::{ComposerEditAction, composer_edit_action};
|
||||||
|
|
@ -35,9 +35,9 @@ use crate::{picker, spawn, ui};
|
||||||
|
|
||||||
pub(crate) type ConsoleTerminal = Terminal<CrosstermBackend<io::Stdout>>;
|
pub(crate) type ConsoleTerminal = Terminal<CrosstermBackend<io::Stdout>>;
|
||||||
|
|
||||||
/// Narrow request bridge used when the workspace Dashboard opens a Pod Console.
|
/// Narrow request bridge used when the workspace Dashboard opens a Worker Console.
|
||||||
pub(crate) struct DashboardConsoleOpenRequest {
|
pub(crate) struct DashboardConsoleOpenRequest {
|
||||||
pub(crate) pod_name: String,
|
pub(crate) worker_name: String,
|
||||||
pub(crate) socket_override: Option<PathBuf>,
|
pub(crate) socket_override: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,39 +128,39 @@ fn copy_selection_to_terminal(app: &mut App) -> bool {
|
||||||
copy_selection_to_writer(app, &mut stdout)
|
copy_selection_to_writer(app, &mut stdout)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_socket(pod_name: &str, override_path: Option<PathBuf>) -> PathBuf {
|
fn resolve_socket(worker_name: &str, override_path: Option<PathBuf>) -> PathBuf {
|
||||||
if let Some(p) = override_path {
|
if let Some(p) = override_path {
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
manifest::paths::pod_socket_path(pod_name).unwrap_or_else(|| {
|
manifest::paths::pod_socket_path(worker_name).unwrap_or_else(|| {
|
||||||
PathBuf::from("/tmp")
|
PathBuf::from("/tmp")
|
||||||
.join("yoi")
|
.join("yoi")
|
||||||
.join(pod_name)
|
.join(worker_name)
|
||||||
.join("sock")
|
.join("sock")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn run_pod_name(
|
pub(crate) async fn run_worker_name(
|
||||||
pod_name: String,
|
worker_name: String,
|
||||||
socket_override: Option<PathBuf>,
|
socket_override: Option<PathBuf>,
|
||||||
runtime_command: PodRuntimeCommand,
|
runtime_command: WorkerRuntimeCommand,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
#[cfg(feature = "e2e-test")]
|
#[cfg(feature = "e2e-test")]
|
||||||
if std::env::var_os("YOI_TUI_TEST_REWIND_FIXTURE").is_some() {
|
if std::env::var_os("YOI_TUI_TEST_REWIND_FIXTURE").is_some() {
|
||||||
let mut terminal = enter_fullscreen()?;
|
let mut terminal = enter_fullscreen()?;
|
||||||
terminal.clear()?;
|
terminal.clear()?;
|
||||||
let result = run_e2e_rewind_fixture(&mut terminal, pod_name).await;
|
let result = run_e2e_rewind_fixture(&mut terminal, worker_name).await;
|
||||||
let _ = leave_fullscreen(&mut terminal);
|
let _ = leave_fullscreen(&mut terminal);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(client) = try_connect_live_pod(&pod_name, socket_override.clone()).await {
|
if let Some(client) = try_connect_live_pod(&worker_name, socket_override.clone()).await {
|
||||||
let mut terminal = enter_fullscreen()?;
|
let mut terminal = enter_fullscreen()?;
|
||||||
run_connected_pod(&mut terminal, pod_name, client, runtime_command.clone()).await?;
|
run_connected_pod(&mut terminal, worker_name, client, runtime_command.clone()).await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let ready = match spawn::run_pod_name(pod_name, runtime_command.clone()).await? {
|
let ready = match spawn::run_worker_name(worker_name, runtime_command.clone()).await? {
|
||||||
SpawnOutcome::Ready(r) => r,
|
SpawnOutcome::Ready(r) => r,
|
||||||
SpawnOutcome::Cancelled => return Ok(()),
|
SpawnOutcome::Cancelled => return Ok(()),
|
||||||
};
|
};
|
||||||
|
|
@ -173,12 +173,12 @@ pub(crate) async fn run_pod_name(
|
||||||
|
|
||||||
async fn run_connected_pod(
|
async fn run_connected_pod(
|
||||||
terminal: &mut ConsoleTerminal,
|
terminal: &mut ConsoleTerminal,
|
||||||
pod_name: String,
|
worker_name: String,
|
||||||
client: PodClient,
|
client: WorkerClient,
|
||||||
runtime_command: PodRuntimeCommand,
|
runtime_command: WorkerRuntimeCommand,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
|
let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
|
||||||
let mut app = App::new_with_persistent_input_history(pod_name, &workspace_root);
|
let mut app = App::new_with_persistent_input_history(worker_name, &workspace_root);
|
||||||
app.connected = true;
|
app.connected = true;
|
||||||
run_loop(terminal, &mut app, client, runtime_command).await
|
run_loop(terminal, &mut app, client, runtime_command).await
|
||||||
}
|
}
|
||||||
|
|
@ -186,29 +186,29 @@ async fn run_connected_pod(
|
||||||
pub(crate) async fn open_from_dashboard(
|
pub(crate) async fn open_from_dashboard(
|
||||||
terminal: &mut ConsoleTerminal,
|
terminal: &mut ConsoleTerminal,
|
||||||
request: DashboardConsoleOpenRequest,
|
request: DashboardConsoleOpenRequest,
|
||||||
runtime_command: PodRuntimeCommand,
|
runtime_command: WorkerRuntimeCommand,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let DashboardConsoleOpenRequest {
|
let DashboardConsoleOpenRequest {
|
||||||
pod_name,
|
worker_name,
|
||||||
socket_override,
|
socket_override,
|
||||||
} = request;
|
} = request;
|
||||||
|
|
||||||
if let Some(client) = try_connect_live_pod(&pod_name, socket_override).await {
|
if let Some(client) = try_connect_live_pod(&worker_name, socket_override).await {
|
||||||
return run_connected_pod(terminal, pod_name, client, runtime_command.clone()).await;
|
return run_connected_pod(terminal, worker_name, client, runtime_command.clone()).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let ready =
|
let ready =
|
||||||
spawn_pod_name_from_fullscreen(terminal, &pod_name, runtime_command.clone()).await?;
|
spawn_worker_name_from_fullscreen(terminal, &worker_name, runtime_command.clone()).await?;
|
||||||
run_ready_pod(terminal, ready, runtime_command).await
|
run_ready_pod(terminal, ready, runtime_command).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn spawn_pod_name_from_fullscreen(
|
async fn spawn_worker_name_from_fullscreen(
|
||||||
terminal: &mut ConsoleTerminal,
|
terminal: &mut ConsoleTerminal,
|
||||||
pod_name: &str,
|
worker_name: &str,
|
||||||
runtime_command: PodRuntimeCommand,
|
runtime_command: WorkerRuntimeCommand,
|
||||||
) -> Result<SpawnReady, Box<dyn std::error::Error>> {
|
) -> Result<SpawnReady, Box<dyn std::error::Error>> {
|
||||||
leave_fullscreen(terminal)?;
|
leave_fullscreen(terminal)?;
|
||||||
let outcome = spawn::run_pod_name(pod_name.to_string(), runtime_command).await;
|
let outcome = spawn::run_worker_name(worker_name.to_string(), runtime_command).await;
|
||||||
enter_fullscreen_existing(terminal)?;
|
enter_fullscreen_existing(terminal)?;
|
||||||
terminal.clear()?;
|
terminal.clear()?;
|
||||||
|
|
||||||
|
|
@ -219,11 +219,11 @@ async fn spawn_pod_name_from_fullscreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn try_connect_live_pod(
|
async fn try_connect_live_pod(
|
||||||
pod_name: &str,
|
worker_name: &str,
|
||||||
socket_override: Option<PathBuf>,
|
socket_override: Option<PathBuf>,
|
||||||
) -> Option<PodClient> {
|
) -> Option<WorkerClient> {
|
||||||
let preferred_socket = resolve_socket(pod_name, socket_override.clone());
|
let preferred_socket = resolve_socket(worker_name, socket_override.clone());
|
||||||
connect_live_pod(pod_name, preferred_socket, socket_override.is_none())
|
connect_live_pod(worker_name, preferred_socket, socket_override.is_none())
|
||||||
.await
|
.await
|
||||||
.map(|(_, client)| client)
|
.map(|(_, client)| client)
|
||||||
}
|
}
|
||||||
|
|
@ -233,7 +233,7 @@ struct NestedOpenCancelled;
|
||||||
|
|
||||||
impl std::fmt::Display for NestedOpenCancelled {
|
impl std::fmt::Display for NestedOpenCancelled {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
f.write_str("Pod open was cancelled")
|
f.write_str("Worker open was cancelled")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -242,57 +242,57 @@ impl std::error::Error for NestedOpenCancelled {}
|
||||||
async fn run_ready_pod(
|
async fn run_ready_pod(
|
||||||
terminal: &mut ConsoleTerminal,
|
terminal: &mut ConsoleTerminal,
|
||||||
ready: SpawnReady,
|
ready: SpawnReady,
|
||||||
runtime_command: PodRuntimeCommand,
|
runtime_command: WorkerRuntimeCommand,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let SpawnReady {
|
let SpawnReady {
|
||||||
pod_name,
|
worker_name,
|
||||||
socket_path,
|
socket_path,
|
||||||
} = ready;
|
} = ready;
|
||||||
run(terminal, pod_name, &socket_path, runtime_command).await
|
run(terminal, worker_name, &socket_path, runtime_command).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn connect_live_pod(
|
async fn connect_live_pod(
|
||||||
pod_name: &str,
|
worker_name: &str,
|
||||||
preferred_socket: PathBuf,
|
preferred_socket: PathBuf,
|
||||||
allow_registry_fallback: bool,
|
allow_registry_fallback: bool,
|
||||||
) -> Option<(PathBuf, PodClient)> {
|
) -> Option<(PathBuf, WorkerClient)> {
|
||||||
if let Ok(client) = PodClient::connect(&preferred_socket).await {
|
if let Ok(client) = WorkerClient::connect(&preferred_socket).await {
|
||||||
return Some((preferred_socket, client));
|
return Some((preferred_socket, client));
|
||||||
}
|
}
|
||||||
|
|
||||||
if !allow_registry_fallback {
|
if !allow_registry_fallback {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let registry_socket = picker::live_socket_for_pod(pod_name)?;
|
let registry_socket = picker::live_socket_for_pod(worker_name)?;
|
||||||
if registry_socket == preferred_socket {
|
if registry_socket == preferred_socket {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
PodClient::connect(®istry_socket)
|
WorkerClient::connect(®istry_socket)
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.map(|client| (registry_socket, client))
|
.map(|client| (registry_socket, client))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn run_resume(
|
pub(crate) async fn run_resume(
|
||||||
runtime_command: PodRuntimeCommand,
|
runtime_command: WorkerRuntimeCommand,
|
||||||
workspace_root: PathBuf,
|
workspace_root: PathBuf,
|
||||||
all: bool,
|
all: bool,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
// Pick a Pod in its own inline viewport, dropping the viewport before
|
// Pick a Worker in its own inline viewport, dropping the viewport before
|
||||||
// attaching/restoring so each phase gets fresh vertical room.
|
// attaching/restoring so each phase gets fresh vertical room.
|
||||||
let picker_options = if all {
|
let picker_options = if all {
|
||||||
picker::PickerOptions::all()
|
picker::PickerOptions::all()
|
||||||
} else {
|
} else {
|
||||||
picker::PickerOptions::workspace(workspace_root)
|
picker::PickerOptions::workspace(workspace_root)
|
||||||
};
|
};
|
||||||
let (pod_name, socket_override) = match picker::run(picker_options).await? {
|
let (worker_name, socket_override) = match picker::run(picker_options).await? {
|
||||||
PickerOutcome::Picked {
|
PickerOutcome::Picked {
|
||||||
pod_name,
|
worker_name,
|
||||||
socket_override,
|
socket_override,
|
||||||
} => (pod_name, socket_override),
|
} => (worker_name, socket_override),
|
||||||
PickerOutcome::Cancelled => return Ok(()),
|
PickerOutcome::Cancelled => return Ok(()),
|
||||||
};
|
};
|
||||||
run_pod_name(pod_name, socket_override, runtime_command).await
|
run_worker_name(worker_name, socket_override, runtime_command).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn is_recoverable_dashboard_open_error(error: &(dyn Error + 'static)) -> bool {
|
pub(crate) fn is_recoverable_dashboard_open_error(error: &(dyn Error + 'static)) -> bool {
|
||||||
|
|
@ -301,32 +301,33 @@ pub(crate) fn is_recoverable_dashboard_open_error(error: &(dyn Error + 'static))
|
||||||
|
|
||||||
pub(crate) async fn run_spawn(
|
pub(crate) async fn run_spawn(
|
||||||
resume_from: Option<SegmentId>,
|
resume_from: Option<SegmentId>,
|
||||||
pod_name: Option<String>,
|
worker_name: Option<String>,
|
||||||
profile: Option<String>,
|
profile: Option<String>,
|
||||||
runtime_command: PodRuntimeCommand,
|
runtime_command: WorkerRuntimeCommand,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
#[cfg(feature = "e2e-test")]
|
#[cfg(feature = "e2e-test")]
|
||||||
if std::env::var_os("YOI_TUI_TEST_REWIND_FIXTURE").is_some() {
|
if std::env::var_os("YOI_TUI_TEST_REWIND_FIXTURE").is_some() {
|
||||||
let mut terminal = enter_fullscreen()?;
|
let mut terminal = enter_fullscreen()?;
|
||||||
terminal.clear()?;
|
terminal.clear()?;
|
||||||
let fixture_pod_name = pod_name.unwrap_or_else(|| "e2e-rewind".to_string());
|
let fixture_worker_name = worker_name.unwrap_or_else(|| "e2e-rewind".to_string());
|
||||||
let result = run_e2e_rewind_fixture(&mut terminal, fixture_pod_name).await;
|
let result = run_e2e_rewind_fixture(&mut terminal, fixture_worker_name).await;
|
||||||
let _ = leave_fullscreen(&mut terminal);
|
let _ = leave_fullscreen(&mut terminal);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
let ready = match spawn::run(resume_from, pod_name, profile, runtime_command.clone()).await? {
|
let ready = match spawn::run(resume_from, worker_name, profile, runtime_command.clone()).await?
|
||||||
|
{
|
||||||
SpawnOutcome::Ready(r) => r,
|
SpawnOutcome::Ready(r) => r,
|
||||||
SpawnOutcome::Cancelled => return Ok(()),
|
SpawnOutcome::Cancelled => return Ok(()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let SpawnReady {
|
let SpawnReady {
|
||||||
pod_name,
|
worker_name,
|
||||||
socket_path,
|
socket_path,
|
||||||
} = ready;
|
} = ready;
|
||||||
|
|
||||||
let mut terminal = enter_fullscreen()?;
|
let mut terminal = enter_fullscreen()?;
|
||||||
let result = run(&mut terminal, pod_name, &socket_path, runtime_command).await;
|
let result = run(&mut terminal, worker_name, &socket_path, runtime_command).await;
|
||||||
|
|
||||||
// Leave alt-screen explicitly before `main`'s terminal restore path.
|
// Leave alt-screen explicitly before `main`'s terminal restore path.
|
||||||
let _ = execute!(
|
let _ = execute!(
|
||||||
|
|
@ -383,17 +384,17 @@ pub(crate) fn leave_dashboard_fullscreen(terminal: &mut ConsoleTerminal) -> io::
|
||||||
|
|
||||||
async fn run(
|
async fn run(
|
||||||
terminal: &mut ConsoleTerminal,
|
terminal: &mut ConsoleTerminal,
|
||||||
pod_name: String,
|
worker_name: String,
|
||||||
socket_path: &std::path::Path,
|
socket_path: &std::path::Path,
|
||||||
runtime_command: PodRuntimeCommand,
|
runtime_command: WorkerRuntimeCommand,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
|
let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
|
||||||
let mut app = App::new_with_persistent_input_history(pod_name, &workspace_root);
|
let mut app = App::new_with_persistent_input_history(worker_name, &workspace_root);
|
||||||
|
|
||||||
match PodClient::connect(socket_path).await {
|
match WorkerClient::connect(socket_path).await {
|
||||||
Ok(client) => {
|
Ok(client) => {
|
||||||
app.connected = true;
|
app.connected = true;
|
||||||
// The Pod sends `Event::Snapshot` automatically on connect;
|
// The Worker sends `Event::Snapshot` automatically on connect;
|
||||||
// no explicit method call is required to fetch history.
|
// no explicit method call is required to fetch history.
|
||||||
run_loop(terminal, &mut app, client, runtime_command).await?;
|
run_loop(terminal, &mut app, client, runtime_command).await?;
|
||||||
}
|
}
|
||||||
|
|
@ -470,16 +471,16 @@ fn read_terminal_events(stop: Arc<AtomicBool>, tx: mpsc::UnboundedSender<Termina
|
||||||
#[cfg(feature = "e2e-test")]
|
#[cfg(feature = "e2e-test")]
|
||||||
async fn run_e2e_rewind_fixture(
|
async fn run_e2e_rewind_fixture(
|
||||||
terminal: &mut ConsoleTerminal,
|
terminal: &mut ConsoleTerminal,
|
||||||
pod_name: String,
|
worker_name: String,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
|
let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
|
||||||
let mut app = App::new_with_persistent_input_history(pod_name.clone(), &workspace_root);
|
let mut app = App::new_with_persistent_input_history(worker_name.clone(), &workspace_root);
|
||||||
app.connected = true;
|
app.connected = true;
|
||||||
app.handle_pod_event(Event::Snapshot {
|
app.handle_worker_event(Event::Snapshot {
|
||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
status: PodStatus::Idle,
|
status: WorkerStatus::Idle,
|
||||||
greeting: Greeting {
|
greeting: Greeting {
|
||||||
pod_name: pod_name.clone(),
|
worker_name: worker_name.clone(),
|
||||||
cwd: workspace_root.display().to_string(),
|
cwd: workspace_root.display().to_string(),
|
||||||
provider: "e2e-fixture".to_string(),
|
provider: "e2e-fixture".to_string(),
|
||||||
model: "canned".to_string(),
|
model: "canned".to_string(),
|
||||||
|
|
@ -500,9 +501,9 @@ async fn run_e2e_rewind_fixture(
|
||||||
let apply_delay = Duration::from_millis(400);
|
let apply_delay = Duration::from_millis(400);
|
||||||
#[cfg(feature = "e2e-test")]
|
#[cfg(feature = "e2e-test")]
|
||||||
crate::e2e_observer::emit(
|
crate::e2e_observer::emit(
|
||||||
"single_pod",
|
"single_worker",
|
||||||
"rewind_fixture_ready",
|
"rewind_fixture_ready",
|
||||||
serde_json::json!({ "pod": pod_name.clone() }),
|
serde_json::json!({ "worker": worker_name.clone() }),
|
||||||
);
|
);
|
||||||
terminal.draw(|frame| ui::draw(frame, &mut app))?;
|
terminal.draw(|frame| ui::draw(frame, &mut app))?;
|
||||||
|
|
||||||
|
|
@ -539,7 +540,7 @@ async fn run_e2e_rewind_fixture(
|
||||||
if let Some(method) = handle_key(&mut app, key) {
|
if let Some(method) = handle_key(&mut app, key) {
|
||||||
match method {
|
match method {
|
||||||
Method::ListRewindTargets => {
|
Method::ListRewindTargets => {
|
||||||
app.handle_pod_event(Event::RewindTargets {
|
app.handle_worker_event(Event::RewindTargets {
|
||||||
head_entries: 3,
|
head_entries: 3,
|
||||||
targets: vec![RewindTarget {
|
targets: vec![RewindTarget {
|
||||||
id: target_id.clone(),
|
id: target_id.clone(),
|
||||||
|
|
@ -554,7 +555,7 @@ async fn run_e2e_rewind_fixture(
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
crate::e2e_observer::emit(
|
crate::e2e_observer::emit(
|
||||||
"single_pod",
|
"single_worker",
|
||||||
"rewind_picker_opened",
|
"rewind_picker_opened",
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"targets": 1,
|
"targets": 1,
|
||||||
|
|
@ -569,7 +570,7 @@ async fn run_e2e_rewind_fixture(
|
||||||
rewind_submit_count += 1;
|
rewind_submit_count += 1;
|
||||||
pending_apply = Some(std::time::Instant::now());
|
pending_apply = Some(std::time::Instant::now());
|
||||||
crate::e2e_observer::emit(
|
crate::e2e_observer::emit(
|
||||||
"single_pod",
|
"single_worker",
|
||||||
"rewind_submit_sent",
|
"rewind_submit_sent",
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"segment_id": target.segment_id.to_string(),
|
"segment_id": target.segment_id.to_string(),
|
||||||
|
|
@ -583,7 +584,7 @@ async fn run_e2e_rewind_fixture(
|
||||||
}
|
}
|
||||||
} else if duplicate_enter_pending {
|
} else if duplicate_enter_pending {
|
||||||
crate::e2e_observer::emit(
|
crate::e2e_observer::emit(
|
||||||
"single_pod",
|
"single_worker",
|
||||||
"rewind_duplicate_enter_suppressed",
|
"rewind_duplicate_enter_suppressed",
|
||||||
serde_json::json!({ "submit_count": rewind_submit_count }),
|
serde_json::json!({ "submit_count": rewind_submit_count }),
|
||||||
);
|
);
|
||||||
|
|
@ -601,7 +602,7 @@ async fn run_e2e_rewind_fixture(
|
||||||
|
|
||||||
if let Some(submitted_at) = pending_apply {
|
if let Some(submitted_at) = pending_apply {
|
||||||
if submitted_at.elapsed() >= apply_delay {
|
if submitted_at.elapsed() >= apply_delay {
|
||||||
app.handle_pod_event(Event::RewindApplied {
|
app.handle_worker_event(Event::RewindApplied {
|
||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
input: vec![Segment::text("rewind-live-refresh")],
|
input: vec![Segment::text("rewind-live-refresh")],
|
||||||
summary: RewindSummary {
|
summary: RewindSummary {
|
||||||
|
|
@ -613,7 +614,7 @@ async fn run_e2e_rewind_fixture(
|
||||||
pending_apply = None;
|
pending_apply = None;
|
||||||
let composer_text = Segment::flatten_to_text(&app.input.submit_segments());
|
let composer_text = Segment::flatten_to_text(&app.input.submit_segments());
|
||||||
crate::e2e_observer::emit(
|
crate::e2e_observer::emit(
|
||||||
"single_pod",
|
"single_worker",
|
||||||
"rewind_applied",
|
"rewind_applied",
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"composer_text": composer_text,
|
"composer_text": composer_text,
|
||||||
|
|
@ -644,7 +645,7 @@ enum E2eRewindInput {
|
||||||
|
|
||||||
enum LoopInput<P> {
|
enum LoopInput<P> {
|
||||||
Terminal(TerminalEventResult),
|
Terminal(TerminalEventResult),
|
||||||
Pod(Option<P>),
|
Worker(Option<P>),
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn next_loop_input<P, F>(
|
async fn next_loop_input<P, F>(
|
||||||
|
|
@ -666,15 +667,15 @@ where
|
||||||
))
|
))
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
event = pod_next, if connected => LoopInput::Pod(event),
|
event = pod_next, if connected => LoopInput::Worker(event),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn drain_terminal_events(
|
async fn drain_terminal_events(
|
||||||
app: &mut App,
|
app: &mut App,
|
||||||
client: &mut PodClient,
|
client: &mut WorkerClient,
|
||||||
term_rx: &mut mpsc::UnboundedReceiver<TerminalEventResult>,
|
term_rx: &mut mpsc::UnboundedReceiver<TerminalEventResult>,
|
||||||
runtime_command: &PodRuntimeCommand,
|
runtime_command: &WorkerRuntimeCommand,
|
||||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||||
let mut handled = false;
|
let mut handled = false;
|
||||||
for _ in 0..TERMINAL_EVENT_DRAIN_LIMIT {
|
for _ in 0..TERMINAL_EVENT_DRAIN_LIMIT {
|
||||||
|
|
@ -698,16 +699,16 @@ async fn drain_terminal_events(
|
||||||
Ok(handled)
|
Ok(handled)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn drain_pod_events(
|
async fn drain_worker_events(
|
||||||
app: &mut App,
|
app: &mut App,
|
||||||
client: &mut PodClient,
|
client: &mut WorkerClient,
|
||||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||||
let mut handled = false;
|
let mut handled = false;
|
||||||
for _ in 0..POD_EVENT_DRAIN_LIMIT {
|
for _ in 0..POD_EVENT_DRAIN_LIMIT {
|
||||||
match client.try_next_event() {
|
match client.try_next_event() {
|
||||||
Some(ev) => {
|
Some(ev) => {
|
||||||
handled = true;
|
handled = true;
|
||||||
if let Some(method) = app.handle_pod_event(ev) {
|
if let Some(method) = app.handle_worker_event(ev) {
|
||||||
client.send(&method).await?;
|
client.send(&method).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -720,8 +721,8 @@ async fn drain_pod_events(
|
||||||
async fn run_loop(
|
async fn run_loop(
|
||||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||||
app: &mut App,
|
app: &mut App,
|
||||||
mut client: PodClient,
|
mut client: WorkerClient,
|
||||||
runtime_command: PodRuntimeCommand,
|
runtime_command: WorkerRuntimeCommand,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let (_terminal_reader, mut term_rx) = TerminalEventReader::spawn()?;
|
let (_terminal_reader, mut term_rx) = TerminalEventReader::spawn()?;
|
||||||
|
|
||||||
|
|
@ -737,8 +738,8 @@ async fn run_loop(
|
||||||
if app.quit {
|
if app.quit {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let handled_pod_event = drain_pod_events(app, &mut client).await?;
|
let handled_worker_event = drain_worker_events(app, &mut client).await?;
|
||||||
if handled_term_event || handled_pod_event {
|
if handled_term_event || handled_worker_event {
|
||||||
terminal.draw(|f| ui::draw(f, app))?;
|
terminal.draw(|f| ui::draw(f, app))?;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -747,9 +748,9 @@ async fn run_loop(
|
||||||
LoopInput::Terminal(term_event) => {
|
LoopInput::Terminal(term_event) => {
|
||||||
handle_terminal_event(app, &mut client, term_event?, &runtime_command).await?;
|
handle_terminal_event(app, &mut client, term_event?, &runtime_command).await?;
|
||||||
}
|
}
|
||||||
LoopInput::Pod(event) => match event {
|
LoopInput::Worker(event) => match event {
|
||||||
Some(ev) => {
|
Some(ev) => {
|
||||||
if let Some(method) = app.handle_pod_event(ev) {
|
if let Some(method) = app.handle_worker_event(ev) {
|
||||||
client.send(&method).await?;
|
client.send(&method).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -769,9 +770,9 @@ async fn run_loop(
|
||||||
|
|
||||||
async fn handle_terminal_event(
|
async fn handle_terminal_event(
|
||||||
app: &mut App,
|
app: &mut App,
|
||||||
client: &mut PodClient,
|
client: &mut WorkerClient,
|
||||||
event: TermEvent,
|
event: TermEvent,
|
||||||
_runtime_command: &PodRuntimeCommand,
|
_runtime_command: &WorkerRuntimeCommand,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
match event {
|
match event {
|
||||||
TermEvent::Key(key) => {
|
TermEvent::Key(key) => {
|
||||||
|
|
@ -937,12 +938,12 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
||||||
Some(None)
|
Some(None)
|
||||||
}
|
}
|
||||||
KeyCode::Char('c') if ctrl => Some(handle_pause_or_quit(app)),
|
KeyCode::Char('c') if ctrl => Some(handle_pause_or_quit(app)),
|
||||||
KeyCode::Char('x') if ctrl => Some(match app.pod_status {
|
KeyCode::Char('x') if ctrl => Some(match app.worker_status {
|
||||||
PodStatus::Running | PodStatus::Paused => {
|
WorkerStatus::Running | WorkerStatus::Paused => {
|
||||||
app.clear_queued_inputs();
|
app.clear_queued_inputs();
|
||||||
Some(Method::Cancel)
|
Some(Method::Cancel)
|
||||||
}
|
}
|
||||||
PodStatus::Idle => Some(Method::Shutdown),
|
WorkerStatus::Idle => Some(Method::Shutdown),
|
||||||
}),
|
}),
|
||||||
KeyCode::Char('d') if ctrl => {
|
KeyCode::Char('d') if ctrl => {
|
||||||
app.quit = true;
|
app.quit = true;
|
||||||
|
|
@ -1196,9 +1197,9 @@ fn handle_command_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
||||||
const CONFIRM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
|
const CONFIRM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
|
||||||
|
|
||||||
/// Running → send `Method::Pause`.
|
/// Running → send `Method::Pause`.
|
||||||
/// Idle / Paused → 2-tap to quit the TUI (the Pod keeps running).
|
/// Idle / Paused → 2-tap to quit the TUI (the Worker keeps running).
|
||||||
fn handle_pause_or_quit(app: &mut App) -> Option<Method> {
|
fn handle_pause_or_quit(app: &mut App) -> Option<Method> {
|
||||||
if app.pod_status == PodStatus::Running {
|
if app.worker_status == WorkerStatus::Running {
|
||||||
app.clear_queued_inputs();
|
app.clear_queued_inputs();
|
||||||
return Some(Method::Pause);
|
return Some(Method::Pause);
|
||||||
}
|
}
|
||||||
|
|
@ -1211,7 +1212,7 @@ fn handle_pause_or_quit(app: &mut App) -> Option<Method> {
|
||||||
}
|
}
|
||||||
app.quit_confirm = Some(std::time::Instant::now());
|
app.quit_confirm = Some(std::time::Instant::now());
|
||||||
app.flash_actionbar_notice(
|
app.flash_actionbar_notice(
|
||||||
"Press Ctrl-C again within 3 s to exit the TUI (the Pod keeps running).",
|
"Press Ctrl-C again within 3 s to exit the TUI (the Worker keeps running).",
|
||||||
ActionbarNoticeLevel::Warn,
|
ActionbarNoticeLevel::Warn,
|
||||||
ActionbarNoticeSource::Tui,
|
ActionbarNoticeSource::Tui,
|
||||||
CONFIRM_TIMEOUT,
|
CONFIRM_TIMEOUT,
|
||||||
|
|
@ -1226,7 +1227,7 @@ mod tests {
|
||||||
use protocol::{Event, RewindTarget, RewindTargetId, Segment};
|
use protocol::{Event, RewindTarget, RewindTargetId, Segment};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn single_pod_mouse_capture_avoids_drag_and_all_motion_modes() {
|
fn single_worker_mouse_capture_avoids_drag_and_all_motion_modes() {
|
||||||
let mut ansi = String::new();
|
let mut ansi = String::new();
|
||||||
Command::write_ansi(&EnableSinglePodMouseCapture, &mut ansi).unwrap();
|
Command::write_ansi(&EnableSinglePodMouseCapture, &mut ansi).unwrap();
|
||||||
|
|
||||||
|
|
@ -1238,7 +1239,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mouse_drag_updates_selection_state() {
|
fn mouse_drag_updates_selection_state() {
|
||||||
let mut app = App::new("pod".into());
|
let mut app = App::new("worker".into());
|
||||||
app.text_selection.set_history_snapshot(
|
app.text_selection.set_history_snapshot(
|
||||||
HistoryViewport {
|
HistoryViewport {
|
||||||
x: 1,
|
x: 1,
|
||||||
|
|
@ -1285,7 +1286,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn esc_clears_selection_without_editing_composer() {
|
fn esc_clears_selection_without_editing_composer() {
|
||||||
let mut app = App::new("pod".into());
|
let mut app = App::new("worker".into());
|
||||||
app.text_selection.set_history_snapshot(
|
app.text_selection.set_history_snapshot(
|
||||||
HistoryViewport {
|
HistoryViewport {
|
||||||
x: 0,
|
x: 0,
|
||||||
|
|
@ -1306,7 +1307,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn copy_selection_writes_osc52_and_clears_selection() {
|
fn copy_selection_writes_osc52_and_clears_selection() {
|
||||||
let mut app = App::new("pod".into());
|
let mut app = App::new("worker".into());
|
||||||
app.text_selection.set_history_snapshot(
|
app.text_selection.set_history_snapshot(
|
||||||
HistoryViewport {
|
HistoryViewport {
|
||||||
x: 0,
|
x: 0,
|
||||||
|
|
@ -1333,7 +1334,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn terminal_event_is_selected_before_ready_pod_event() {
|
async fn terminal_event_is_selected_before_ready_worker_event() {
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||||
tx.send(Ok(TermEvent::Key(KeyEvent::new(
|
tx.send(Ok(TermEvent::Key(KeyEvent::new(
|
||||||
KeyCode::Char('x'),
|
KeyCode::Char('x'),
|
||||||
|
|
@ -1345,17 +1346,17 @@ mod tests {
|
||||||
LoopInput::Terminal(Ok(TermEvent::Key(key))) => {
|
LoopInput::Terminal(Ok(TermEvent::Key(key))) => {
|
||||||
assert_eq!(key.code, KeyCode::Char('x'));
|
assert_eq!(key.code, KeyCode::Char('x'));
|
||||||
}
|
}
|
||||||
_ => panic!("ready terminal input should win over a ready Pod event"),
|
_ => panic!("ready terminal input should win over a ready Worker event"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn terminal_event_is_preserved_after_pod_event_wins() {
|
async fn terminal_event_is_preserved_after_worker_event_wins() {
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||||
|
|
||||||
match next_loop_input(&mut rx, true, std::future::ready(Some(1_u8))).await {
|
match next_loop_input(&mut rx, true, std::future::ready(Some(1_u8))).await {
|
||||||
LoopInput::Pod(Some(1)) => {}
|
LoopInput::Worker(Some(1)) => {}
|
||||||
_ => panic!("expected the first ready Pod event to win before any terminal input"),
|
_ => panic!("expected the first ready Worker event to win before any terminal input"),
|
||||||
}
|
}
|
||||||
|
|
||||||
tx.send(Ok(TermEvent::Key(KeyEvent::new(
|
tx.send(Ok(TermEvent::Key(KeyEvent::new(
|
||||||
|
|
@ -1368,14 +1369,14 @@ mod tests {
|
||||||
LoopInput::Terminal(Ok(TermEvent::Key(key))) => {
|
LoopInput::Terminal(Ok(TermEvent::Key(key))) => {
|
||||||
assert_eq!(key.code, KeyCode::Char('y'));
|
assert_eq!(key.code, KeyCode::Char('y'));
|
||||||
}
|
}
|
||||||
_ => panic!("queued terminal input should not be lost to subsequent Pod events"),
|
_ => panic!("queued terminal input should not be lost to subsequent Worker events"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn running_status_still_allows_text_editing() {
|
fn running_status_still_allows_text_editing() {
|
||||||
let mut app = App::new("agent".to_string());
|
let mut app = App::new("agent".to_string());
|
||||||
app.set_pod_status(PodStatus::Running);
|
app.set_worker_status(WorkerStatus::Running);
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
handle_key(
|
handle_key(
|
||||||
|
|
@ -1409,7 +1410,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn running_enter_queues_instead_of_sending_run() {
|
fn running_enter_queues_instead_of_sending_run() {
|
||||||
let mut app = App::new("agent".to_string());
|
let mut app = App::new("agent".to_string());
|
||||||
app.set_pod_status(PodStatus::Running);
|
app.set_worker_status(WorkerStatus::Running);
|
||||||
for c in "queued".chars() {
|
for c in "queued".chars() {
|
||||||
assert!(
|
assert!(
|
||||||
handle_key(
|
handle_key(
|
||||||
|
|
@ -1430,7 +1431,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn queued_input_keybindings_restore_and_clear() {
|
fn queued_input_keybindings_restore_and_clear() {
|
||||||
let mut app = App::new("agent".to_string());
|
let mut app = App::new("agent".to_string());
|
||||||
app.set_pod_status(PodStatus::Running);
|
app.set_worker_status(WorkerStatus::Running);
|
||||||
for c in "edit queued".chars() {
|
for c in "edit queued".chars() {
|
||||||
assert!(
|
assert!(
|
||||||
handle_key(
|
handle_key(
|
||||||
|
|
@ -1478,7 +1479,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn pause_and_cancel_clear_queued_input() {
|
fn pause_and_cancel_clear_queued_input() {
|
||||||
let mut app = App::new("agent".to_string());
|
let mut app = App::new("agent".to_string());
|
||||||
app.set_pod_status(PodStatus::Running);
|
app.set_worker_status(WorkerStatus::Running);
|
||||||
for c in "queued".chars() {
|
for c in "queued".chars() {
|
||||||
assert!(
|
assert!(
|
||||||
handle_key(
|
handle_key(
|
||||||
|
|
@ -1521,7 +1522,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn ctrl_x_cancels_paused_turn_without_shutdown() {
|
fn ctrl_x_cancels_paused_turn_without_shutdown() {
|
||||||
let mut app = App::new("agent".to_string());
|
let mut app = App::new("agent".to_string());
|
||||||
app.set_pod_status(PodStatus::Paused);
|
app.set_worker_status(WorkerStatus::Paused);
|
||||||
|
|
||||||
let cancel = handle_key(
|
let cancel = handle_key(
|
||||||
&mut app,
|
&mut app,
|
||||||
|
|
@ -1533,7 +1534,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn ctrl_x_shutdown_while_idle_is_unchanged() {
|
fn ctrl_x_shutdown_while_idle_is_unchanged() {
|
||||||
let mut app = App::new("agent".to_string());
|
let mut app = App::new("agent".to_string());
|
||||||
app.set_pod_status(PodStatus::Idle);
|
app.set_worker_status(WorkerStatus::Idle);
|
||||||
|
|
||||||
let shutdown = handle_key(
|
let shutdown = handle_key(
|
||||||
&mut app,
|
&mut app,
|
||||||
|
|
@ -1854,7 +1855,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn ctrl_c_quit_guard_uses_actionbar_notice_without_transcript_alert() {
|
fn ctrl_c_quit_guard_uses_actionbar_notice_without_transcript_alert() {
|
||||||
let mut app = App::new("agent".to_string());
|
let mut app = App::new("agent".to_string());
|
||||||
app.set_pod_status(PodStatus::Idle);
|
app.set_worker_status(WorkerStatus::Idle);
|
||||||
|
|
||||||
let method = handle_key(
|
let method = handle_key(
|
||||||
&mut app,
|
&mut app,
|
||||||
|
|
@ -1866,10 +1867,10 @@ mod tests {
|
||||||
let notice = app
|
let notice = app
|
||||||
.current_actionbar_notice(std::time::Instant::now())
|
.current_actionbar_notice(std::time::Instant::now())
|
||||||
.expect("quit guard notice is active");
|
.expect("quit guard notice is active");
|
||||||
assert!(notice.text.contains("Pod keeps running"));
|
assert!(notice.text.contains("Worker keeps running"));
|
||||||
assert_eq!(notice.level, ActionbarNoticeLevel::Warn);
|
assert_eq!(notice.level, ActionbarNoticeLevel::Warn);
|
||||||
assert_eq!(notice.source, ActionbarNoticeSource::Tui);
|
assert_eq!(notice.source, ActionbarNoticeSource::Tui);
|
||||||
assert!(!has_alert(&app, "Pod keeps running"));
|
assert!(!has_alert(&app, "Worker keeps running"));
|
||||||
|
|
||||||
let method = handle_key(
|
let method = handle_key(
|
||||||
&mut app,
|
&mut app,
|
||||||
|
|
@ -1889,7 +1890,7 @@ mod tests {
|
||||||
);
|
);
|
||||||
assert!(matches!(idle, Some(Method::ListRewindTargets)));
|
assert!(matches!(idle, Some(Method::ListRewindTargets)));
|
||||||
|
|
||||||
app.set_pod_status(PodStatus::Paused);
|
app.set_worker_status(WorkerStatus::Paused);
|
||||||
let paused = handle_key(
|
let paused = handle_key(
|
||||||
&mut app,
|
&mut app,
|
||||||
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
|
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
|
||||||
|
|
@ -1901,7 +1902,7 @@ mod tests {
|
||||||
fn ctrl_r_is_rejected_while_running() {
|
fn ctrl_r_is_rejected_while_running() {
|
||||||
let mut app = App::new("agent".to_string());
|
let mut app = App::new("agent".to_string());
|
||||||
app.connected = true;
|
app.connected = true;
|
||||||
app.set_pod_status(PodStatus::Running);
|
app.set_worker_status(WorkerStatus::Running);
|
||||||
|
|
||||||
let method = handle_key(
|
let method = handle_key(
|
||||||
&mut app,
|
&mut app,
|
||||||
|
|
@ -1909,14 +1910,14 @@ mod tests {
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(method.is_none());
|
assert!(method.is_none());
|
||||||
assert!(has_alert(&app, "cannot rewind while the Pod is running"));
|
assert!(has_alert(&app, "cannot rewind while the Worker is running"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rewind_picker_close_returns_to_history_view() {
|
fn rewind_picker_close_returns_to_history_view() {
|
||||||
let mut app = App::new("agent".to_string());
|
let mut app = App::new("agent".to_string());
|
||||||
app.connected = true;
|
app.connected = true;
|
||||||
app.handle_pod_event(Event::RewindTargets {
|
app.handle_worker_event(Event::RewindTargets {
|
||||||
head_entries: 1,
|
head_entries: 1,
|
||||||
targets: vec![],
|
targets: vec![],
|
||||||
});
|
});
|
||||||
|
|
@ -1927,7 +1928,7 @@ mod tests {
|
||||||
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
|
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
|
||||||
);
|
);
|
||||||
assert!(matches!(method, Some(Method::ListRewindTargets)));
|
assert!(matches!(method, Some(Method::ListRewindTargets)));
|
||||||
app.handle_pod_event(Event::RewindTargets {
|
app.handle_worker_event(Event::RewindTargets {
|
||||||
head_entries: 1,
|
head_entries: 1,
|
||||||
targets: vec![],
|
targets: vec![],
|
||||||
});
|
});
|
||||||
|
|
@ -1942,13 +1943,13 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn rewind_applied_reseeds_display_and_restores_composer() {
|
fn rewind_applied_reseeds_display_and_restores_composer() {
|
||||||
let mut app = App::new("agent".to_string());
|
let mut app = App::new("agent".to_string());
|
||||||
app.handle_pod_event(Event::Snapshot {
|
app.handle_worker_event(Event::Snapshot {
|
||||||
greeting: test_greeting(),
|
greeting: test_greeting(),
|
||||||
entries: vec![],
|
entries: vec![],
|
||||||
status: PodStatus::Idle,
|
status: WorkerStatus::Idle,
|
||||||
in_flight: Default::default(),
|
in_flight: Default::default(),
|
||||||
});
|
});
|
||||||
app.handle_pod_event(Event::RewindApplied {
|
app.handle_worker_event(Event::RewindApplied {
|
||||||
entries: vec![],
|
entries: vec![],
|
||||||
input: vec![Segment::Text {
|
input: vec![Segment::Text {
|
||||||
content: "retry this".into(),
|
content: "retry this".into(),
|
||||||
|
|
@ -1968,15 +1969,15 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn rewind_applied_keeps_non_empty_composer() {
|
fn rewind_applied_keeps_non_empty_composer() {
|
||||||
let mut app = App::new("agent".to_string());
|
let mut app = App::new("agent".to_string());
|
||||||
app.handle_pod_event(Event::Snapshot {
|
app.handle_worker_event(Event::Snapshot {
|
||||||
greeting: test_greeting(),
|
greeting: test_greeting(),
|
||||||
entries: vec![],
|
entries: vec![],
|
||||||
status: PodStatus::Idle,
|
status: WorkerStatus::Idle,
|
||||||
in_flight: Default::default(),
|
in_flight: Default::default(),
|
||||||
});
|
});
|
||||||
type_keys(&mut app, "draft");
|
type_keys(&mut app, "draft");
|
||||||
|
|
||||||
app.handle_pod_event(Event::RewindApplied {
|
app.handle_worker_event(Event::RewindApplied {
|
||||||
entries: vec![],
|
entries: vec![],
|
||||||
input: vec![Segment::Text {
|
input: vec![Segment::Text {
|
||||||
content: "retry this".into(),
|
content: "retry this".into(),
|
||||||
|
|
@ -2005,11 +2006,11 @@ mod tests {
|
||||||
|
|
||||||
let mut app = App::new("agent".to_string());
|
let mut app = App::new("agent".to_string());
|
||||||
app.rewind_picker = Some(crate::app::RewindPickerState::new(1, vec![rewind_target()]));
|
app.rewind_picker = Some(crate::app::RewindPickerState::new(1, vec![rewind_target()]));
|
||||||
app.set_pod_status(PodStatus::Paused);
|
app.set_worker_status(WorkerStatus::Paused);
|
||||||
assert!(app.submit_rewind_picker().is_none());
|
assert!(app.submit_rewind_picker().is_none());
|
||||||
assert!(has_alert(
|
assert!(has_alert(
|
||||||
&app,
|
&app,
|
||||||
"cannot apply rewind while the Pod is paused"
|
"cannot apply rewind while the Worker is paused"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2055,7 +2056,7 @@ mod tests {
|
||||||
|
|
||||||
fn test_greeting() -> protocol::Greeting {
|
fn test_greeting() -> protocol::Greeting {
|
||||||
protocol::Greeting {
|
protocol::Greeting {
|
||||||
pod_name: "agent".into(),
|
worker_name: "agent".into(),
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
provider: "test".into(),
|
provider: "test".into(),
|
||||||
model: "test".into(),
|
model: "test".into(),
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -296,7 +296,7 @@ pub(super) const TICKET_STATE_COLUMN_WIDTH: usize = 10;
|
||||||
pub(super) const POD_STATUS_COLUMN_WIDTH: usize = 18;
|
pub(super) const POD_STATUS_COLUMN_WIDTH: usize = 18;
|
||||||
|
|
||||||
pub(super) fn panel_row_lines(row: &PanelRow, selected: bool, width: u16) -> Vec<Line<'static>> {
|
pub(super) fn panel_row_lines(row: &PanelRow, selected: bool, width: u16) -> Vec<Line<'static>> {
|
||||||
if row.kind == PanelRowKind::TicketIntakePod {
|
if row.kind == PanelRowKind::TicketIntakeWorker {
|
||||||
vec![panel_intake_child_line(row, selected, width)]
|
vec![panel_intake_child_line(row, selected, width)]
|
||||||
} else {
|
} else {
|
||||||
vec![
|
vec![
|
||||||
|
|
@ -438,7 +438,7 @@ pub(super) fn panel_ticket_detail(row: &PanelRow) -> String {
|
||||||
return parts.join(" · ");
|
return parts.join(" · ");
|
||||||
}
|
}
|
||||||
|
|
||||||
if row.kind == PanelRowKind::TicketIntakePod {
|
if row.kind == PanelRowKind::TicketIntakeWorker {
|
||||||
let mut parts = row
|
let mut parts = row
|
||||||
.subtitle
|
.subtitle
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|
@ -538,8 +538,8 @@ pub(super) fn panel_ticket_reference(row: &PanelRow) -> String {
|
||||||
.map(|ticket| ticket.id.clone())
|
.map(|ticket| ticket.id.clone())
|
||||||
.unwrap_or_else(|| match &row.key {
|
.unwrap_or_else(|| match &row.key {
|
||||||
PanelRowKey::Ticket(id) | PanelRowKey::InvalidTicket(id) => id.clone(),
|
PanelRowKey::Ticket(id) | PanelRowKey::InvalidTicket(id) => id.clone(),
|
||||||
PanelRowKey::TicketIntakePod { ticket_id, .. } => ticket_id.clone(),
|
PanelRowKey::TicketIntakeWorker { ticket_id, .. } => ticket_id.clone(),
|
||||||
PanelRowKey::Pod(name) => name.clone(),
|
PanelRowKey::Worker(name) => name.clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -597,7 +597,7 @@ pub(super) fn intake_status_style(status: &str) -> Style {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn section_rows(
|
pub(super) fn section_rows(
|
||||||
list: &PodList,
|
list: &WorkerList,
|
||||||
section: &DashboardSection,
|
section: &DashboardSection,
|
||||||
selected: Option<&PanelRowKey>,
|
selected: Option<&PanelRowKey>,
|
||||||
width: u16,
|
width: u16,
|
||||||
|
|
@ -616,7 +616,7 @@ pub(super) fn section_rows(
|
||||||
)));
|
)));
|
||||||
for index in visible {
|
for index in visible {
|
||||||
if let Some(entry) = list.entries.get(index) {
|
if let Some(entry) = list.entries.get(index) {
|
||||||
let key = PanelRowKey::Pod(entry.name.clone());
|
let key = PanelRowKey::Worker(entry.name.clone());
|
||||||
let selected = selected == Some(&key);
|
let selected = selected == Some(&key);
|
||||||
rows.push(PanelListRow::selectable(
|
rows.push(PanelListRow::selectable(
|
||||||
row_line(entry, selected, width),
|
row_line(entry, selected, width),
|
||||||
|
|
@ -627,7 +627,7 @@ pub(super) fn section_rows(
|
||||||
rows
|
rows
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn row_line(entry: &PodListEntry, selected: bool, width: u16) -> Line<'static> {
|
pub(super) fn row_line(entry: &WorkerListEntry, selected: bool, width: u16) -> Line<'static> {
|
||||||
let marker = if selected { "▶ " } else { " " };
|
let marker = if selected { "▶ " } else { " " };
|
||||||
let name_style = if selected {
|
let name_style = if selected {
|
||||||
Style::default()
|
Style::default()
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -8,7 +8,7 @@
|
||||||
//!
|
//!
|
||||||
//! Display form: paste atoms render as
|
//! Display form: paste atoms render as
|
||||||
//! `[Clipboard #N | X chars, Y lines]`. Submit form: paste atoms expand
|
//! `[Clipboard #N | X chars, Y lines]`. Submit form: paste atoms expand
|
||||||
//! back to their original captured content so the Pod sees the full
|
//! back to their original captured content so the Worker sees the full
|
||||||
//! pasted text (without the placeholder label).
|
//! pasted text (without the placeholder label).
|
||||||
|
|
||||||
use ratatui::style::{Color, Style};
|
use ratatui::style::{Color, Style};
|
||||||
|
|
@ -33,7 +33,7 @@ impl PasteRef {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `@<path>` chip — confirmed completion of a file-system reference.
|
/// `@<path>` chip — confirmed completion of a file-system reference.
|
||||||
/// Directories remain valid chips because Pod resolves normal directory refs
|
/// Directories remain valid chips because Worker resolves normal directory refs
|
||||||
/// to shallow `[Dir: <path>]` listings at submit time.
|
/// to shallow `[Dir: <path>]` listings at submit time.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct FileRefAtom {
|
pub struct FileRefAtom {
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ mod input;
|
||||||
pub mod keys;
|
pub mod keys;
|
||||||
mod markdown;
|
mod markdown;
|
||||||
mod picker;
|
mod picker;
|
||||||
mod pod_list;
|
|
||||||
mod role_session_registry;
|
mod role_session_registry;
|
||||||
mod scroll;
|
mod scroll;
|
||||||
pub mod setup_model;
|
pub mod setup_model;
|
||||||
|
|
@ -22,6 +21,7 @@ mod text_selection;
|
||||||
mod tool;
|
mod tool;
|
||||||
mod ui;
|
mod ui;
|
||||||
mod view_mode;
|
mod view_mode;
|
||||||
|
mod worker_list;
|
||||||
mod workspace_panel;
|
mod workspace_panel;
|
||||||
|
|
||||||
use std::io;
|
use std::io;
|
||||||
|
|
@ -33,37 +33,37 @@ use crossterm::execute;
|
||||||
use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode, enable_raw_mode};
|
use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode, enable_raw_mode};
|
||||||
use session_store::SegmentId;
|
use session_store::SegmentId;
|
||||||
|
|
||||||
use client::PodRuntimeCommand;
|
use client::WorkerRuntimeCommand;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct LaunchOptions {
|
pub struct LaunchOptions {
|
||||||
pub mode: LaunchMode,
|
pub mode: LaunchMode,
|
||||||
pub runtime_command: PodRuntimeCommand,
|
pub runtime_command: WorkerRuntimeCommand,
|
||||||
pub workspace_root: PathBuf,
|
pub workspace_root: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum LaunchMode {
|
pub enum LaunchMode {
|
||||||
Spawn {
|
Spawn {
|
||||||
pod_name: Option<String>,
|
worker_name: Option<String>,
|
||||||
profile: Option<String>,
|
profile: Option<String>,
|
||||||
},
|
},
|
||||||
/// `yoi --pod <name>`: attach to a live Pod by name if possible;
|
/// `yoi --worker <name>`: attach to a live Worker by name if possible;
|
||||||
/// otherwise launch the Pod runtime command with `--pod <name>` so it
|
/// otherwise launch the Worker runtime command with `--worker <name>` so it
|
||||||
/// resumes from name-keyed state or creates a fresh same-name Pod.
|
/// resumes from name-keyed state or creates a fresh same-name Worker.
|
||||||
PodName {
|
WorkerName {
|
||||||
pod_name: String,
|
worker_name: String,
|
||||||
socket_override: Option<PathBuf>,
|
socket_override: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
/// `yoi resume`: open the Pod picker, then attach to the selected live Pod
|
/// `yoi resume`: open the Worker picker, then attach to the selected live Worker
|
||||||
/// or restore the selected stopped Pod by name. Without `--all`, the picker
|
/// or restore the selected stopped Worker by name. Without `--all`, the picker
|
||||||
/// is scoped to the current runtime workspace.
|
/// is scoped to the current runtime workspace.
|
||||||
Resume { all: bool },
|
Resume { all: bool },
|
||||||
/// `yoi --session <UUID>`: skip the picker, go straight to the
|
/// `yoi --session <UUID>`: skip the picker, go straight to the
|
||||||
/// resume name dialog with `id` baked in.
|
/// resume name dialog with `id` baked in.
|
||||||
ResumeWithSession {
|
ResumeWithSession {
|
||||||
id: SegmentId,
|
id: SegmentId,
|
||||||
pod_name: Option<String>,
|
worker_name: Option<String>,
|
||||||
},
|
},
|
||||||
/// `yoi panel`: open the workspace Dashboard from the current workspace.
|
/// `yoi panel`: open the workspace Dashboard from the current workspace.
|
||||||
Panel,
|
Panel,
|
||||||
|
|
@ -95,18 +95,19 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = match mode {
|
let result = match mode {
|
||||||
LaunchMode::Spawn { pod_name, profile } => {
|
LaunchMode::Spawn {
|
||||||
console::run_spawn(None, pod_name, profile, runtime_command).await
|
worker_name,
|
||||||
}
|
profile,
|
||||||
LaunchMode::PodName {
|
} => console::run_spawn(None, worker_name, profile, runtime_command).await,
|
||||||
pod_name,
|
LaunchMode::WorkerName {
|
||||||
|
worker_name,
|
||||||
socket_override,
|
socket_override,
|
||||||
} => console::run_pod_name(pod_name, socket_override, runtime_command).await,
|
} => console::run_worker_name(worker_name, socket_override, runtime_command).await,
|
||||||
LaunchMode::Resume { all } => {
|
LaunchMode::Resume { all } => {
|
||||||
console::run_resume(runtime_command, workspace_root.clone(), all).await
|
console::run_resume(runtime_command, workspace_root.clone(), all).await
|
||||||
}
|
}
|
||||||
LaunchMode::ResumeWithSession { id, pod_name } => {
|
LaunchMode::ResumeWithSession { id, worker_name } => {
|
||||||
console::run_spawn(Some(id), pod_name, None, runtime_command).await
|
console::run_spawn(Some(id), worker_name, None, runtime_command).await
|
||||||
}
|
}
|
||||||
LaunchMode::Panel => dashboard::launch(runtime_command).await,
|
LaunchMode::Panel => dashboard::launch(runtime_command).await,
|
||||||
};
|
};
|
||||||
|
|
@ -138,7 +139,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
|
||||||
// SpawnError has already been painted into the inline
|
// SpawnError has already been painted into the inline
|
||||||
// viewport's final frame, so it's already visible in the
|
// viewport's final frame, so it's already visible in the
|
||||||
// user's scrollback — printing it again would be a noisy
|
// user's scrollback — printing it again would be a noisy
|
||||||
// duplicate. Other errors (pod-name failures, terminal setup
|
// duplicate. Other errors (worker-name failures, terminal setup
|
||||||
// hiccups, etc.) need surfacing here.
|
// hiccups, etc.) need surfacing here.
|
||||||
if e.downcast_ref::<spawn::SpawnError>().is_none() {
|
if e.downcast_ref::<spawn::SpawnError>().is_none() {
|
||||||
eprintln!("yoi: {e}");
|
eprintln!("yoi: {e}");
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
//! Inline-viewport "pick a Pod to attach or restore" UX.
|
//! Inline-viewport "pick a Worker to attach or restore" UX.
|
||||||
//!
|
//!
|
||||||
//! Reads live Pod allocations from the runtime registry and stopped Pod state
|
//! Reads live Worker allocations from the runtime registry and stopped Worker state
|
||||||
//! from the pod-store name-keyed metadata. Picking a live row attaches to
|
//! from the pod-store name-keyed metadata. Picking a live row attaches to
|
||||||
//! its socket; picking a stopped row restores via the Pod runtime command.
|
//! its socket; picking a stopped row restores via the Worker runtime command.
|
||||||
|
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
|
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
|
||||||
use pod_store::FsPodStore;
|
use pod_store::FsWorkerStore;
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
use ratatui::layout::{Constraint, Layout};
|
use ratatui::layout::{Constraint, Layout};
|
||||||
|
|
@ -19,10 +19,10 @@ use ratatui::widgets::Paragraph;
|
||||||
use ratatui::{Frame, TerminalOptions, Viewport};
|
use ratatui::{Frame, TerminalOptions, Viewport};
|
||||||
use session_store::FsStore;
|
use session_store::FsStore;
|
||||||
|
|
||||||
use crate::pod_list::{
|
use crate::worker_list::{
|
||||||
LivePodInfo, PodList, PodListEntry, PodVisibilitySource, StoredMetadataState, StoredPodInfo,
|
LiveWorkerInfo, StoredMetadataState, StoredWorkerInfo, WorkerList, WorkerListEntry,
|
||||||
live_socket_for_pod as pod_list_live_socket_for_pod, read_reachable_live_pod_infos,
|
WorkerVisibilitySource, live_socket_for_pod as worker_list_live_socket_for_pod,
|
||||||
read_stored_pod_infos,
|
read_reachable_live_pod_infos, read_stored_worker_infos,
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_ROWS: usize = 10;
|
const MAX_ROWS: usize = 10;
|
||||||
|
|
@ -32,7 +32,7 @@ const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 4;
|
||||||
pub enum PickerError {
|
pub enum PickerError {
|
||||||
Io(io::Error),
|
Io(io::Error),
|
||||||
Store(session_store::StoreError),
|
Store(session_store::StoreError),
|
||||||
NoPods { all: bool },
|
NoWorkers { all: bool },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for PickerError {
|
impl std::fmt::Display for PickerError {
|
||||||
|
|
@ -40,13 +40,13 @@ impl std::fmt::Display for PickerError {
|
||||||
match self {
|
match self {
|
||||||
Self::Io(e) => write!(f, "io error: {e}"),
|
Self::Io(e) => write!(f, "io error: {e}"),
|
||||||
Self::Store(e) => write!(f, "session store error: {e}"),
|
Self::Store(e) => write!(f, "session store error: {e}"),
|
||||||
Self::NoPods { all: true } => write!(
|
Self::NoWorkers { all: true } => write!(
|
||||||
f,
|
f,
|
||||||
"no pods found — start a fresh pod with `yoi` and try again"
|
"no workers found — start a fresh Worker with `yoi` and try again"
|
||||||
),
|
),
|
||||||
Self::NoPods { all: false } => write!(
|
Self::NoWorkers { all: false } => write!(
|
||||||
f,
|
f,
|
||||||
"no pods found in this workspace — use `yoi resume --all` to list all host/data-dir Pods"
|
"no workers found in this workspace — use `yoi resume --all` to list all host/data-dir Workers"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -67,11 +67,11 @@ impl From<session_store::StoreError> for PickerError {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum PickerOutcome {
|
pub enum PickerOutcome {
|
||||||
/// User picked a Pod. `socket_override` is set for live rows when the
|
/// User picked a Worker. `socket_override` is set for live rows when the
|
||||||
/// runtime registry knows the exact socket path; stopped rows leave it
|
/// runtime registry knows the exact socket path; stopped rows leave it
|
||||||
/// empty so the caller restores by spawning the Pod runtime command.
|
/// empty so the caller restores by spawning the Worker runtime command.
|
||||||
Picked {
|
Picked {
|
||||||
pod_name: String,
|
worker_name: String,
|
||||||
socket_override: Option<PathBuf>,
|
socket_override: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
Cancelled,
|
Cancelled,
|
||||||
|
|
@ -103,13 +103,13 @@ enum PickerScope {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum PodRowState {
|
enum WorkerRowState {
|
||||||
Live,
|
Live,
|
||||||
Stopped,
|
Stopped,
|
||||||
Corrupt,
|
Corrupt,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PodRowState {
|
impl WorkerRowState {
|
||||||
fn label(self) -> &'static str {
|
fn label(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Live => "live",
|
Self::Live => "live",
|
||||||
|
|
@ -131,22 +131,22 @@ impl PodRowState {
|
||||||
|
|
||||||
fn list_for_options(
|
fn list_for_options(
|
||||||
options: &PickerOptions,
|
options: &PickerOptions,
|
||||||
stored_pods: Vec<StoredPodInfo>,
|
stored_workers: Vec<StoredWorkerInfo>,
|
||||||
live_pods: Vec<LivePodInfo>,
|
live_workers: Vec<LiveWorkerInfo>,
|
||||||
) -> PodList {
|
) -> WorkerList {
|
||||||
match &options.scope {
|
match &options.scope {
|
||||||
PickerScope::Workspace(workspace_root) => PodList::from_workspace_sources(
|
PickerScope::Workspace(workspace_root) => WorkerList::from_workspace_sources(
|
||||||
PodVisibilitySource::ResumePicker,
|
WorkerVisibilitySource::ResumePicker,
|
||||||
stored_pods,
|
stored_workers,
|
||||||
live_pods,
|
live_workers,
|
||||||
None,
|
None,
|
||||||
MAX_ROWS,
|
MAX_ROWS,
|
||||||
workspace_root,
|
workspace_root,
|
||||||
),
|
),
|
||||||
PickerScope::All => PodList::from_sources(
|
PickerScope::All => WorkerList::from_sources(
|
||||||
PodVisibilitySource::ResumePicker,
|
WorkerVisibilitySource::ResumePicker,
|
||||||
stored_pods,
|
stored_workers,
|
||||||
live_pods,
|
live_workers,
|
||||||
None,
|
None,
|
||||||
MAX_ROWS,
|
MAX_ROWS,
|
||||||
),
|
),
|
||||||
|
|
@ -156,14 +156,14 @@ fn list_for_options(
|
||||||
pub async fn run(options: PickerOptions) -> Result<PickerOutcome, PickerError> {
|
pub async fn run(options: PickerOptions) -> Result<PickerOutcome, PickerError> {
|
||||||
let store_dir = default_store_dir()?;
|
let store_dir = default_store_dir()?;
|
||||||
let store = FsStore::new(&store_dir)?;
|
let store = FsStore::new(&store_dir)?;
|
||||||
let pod_store = FsPodStore::new(default_pod_store_dir()?).map_err(io::Error::other)?;
|
let pod_store = FsWorkerStore::new(default_pod_store_dir()?).map_err(io::Error::other)?;
|
||||||
let stored_pods = read_stored_pod_infos(&store, &pod_store)?;
|
let stored_workers = read_stored_worker_infos(&store, &pod_store)?;
|
||||||
let live_pods = read_reachable_live_pod_infos(&store)
|
let live_workers = read_reachable_live_pod_infos(&store)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let mut list = list_for_options(&options, stored_pods, live_pods);
|
let mut list = list_for_options(&options, stored_workers, live_workers);
|
||||||
if list.entries.is_empty() {
|
if list.entries.is_empty() {
|
||||||
return Err(PickerError::NoPods {
|
return Err(PickerError::NoWorkers {
|
||||||
all: matches!(options.scope, PickerScope::All),
|
all: matches!(options.scope, PickerScope::All),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -185,9 +185,9 @@ pub async fn run(options: PickerOptions) -> Result<PickerOutcome, PickerError> {
|
||||||
}
|
}
|
||||||
Some(Action::Submit) => {
|
Some(Action::Submit) => {
|
||||||
close_viewport(&mut terminal)?;
|
close_viewport(&mut terminal)?;
|
||||||
let entry = list.selected_entry().expect("non-empty pod list");
|
let entry = list.selected_entry().expect("non-empty worker list");
|
||||||
return Ok(PickerOutcome::Picked {
|
return Ok(PickerOutcome::Picked {
|
||||||
pod_name: entry.name.clone(),
|
worker_name: entry.name.clone(),
|
||||||
socket_override: entry.attach_socket_path().map(PathBuf::from),
|
socket_override: entry.attach_socket_path().map(PathBuf::from),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -229,14 +229,14 @@ fn default_pod_store_dir() -> Result<PathBuf, PickerError> {
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
PickerError::Io(io::Error::new(
|
PickerError::Io(io::Error::new(
|
||||||
io::ErrorKind::NotFound,
|
io::ErrorKind::NotFound,
|
||||||
"could not resolve pod state directory \
|
"could not resolve worker state directory \
|
||||||
(set YOI_HOME, YOI_DATA_DIR, or HOME)",
|
(set YOI_HOME, YOI_DATA_DIR, or HOME)",
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn live_socket_for_pod(pod_name: &str) -> Option<PathBuf> {
|
pub(crate) fn live_socket_for_pod(worker_name: &str) -> Option<PathBuf> {
|
||||||
pod_list_live_socket_for_pod(pod_name)
|
worker_list_live_socket_for_pod(worker_name)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn make_inline_terminal() -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> {
|
fn make_inline_terminal() -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> {
|
||||||
|
|
@ -278,7 +278,7 @@ fn poll_event() -> io::Result<Option<Action>> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw(f: &mut Frame<'_>, list: &PodList) {
|
fn draw(f: &mut Frame<'_>, list: &WorkerList) {
|
||||||
let area = f.area();
|
let area = f.area();
|
||||||
let mut constraints: Vec<Constraint> = Vec::with_capacity(list.entries.len() + 3);
|
let mut constraints: Vec<Constraint> = Vec::with_capacity(list.entries.len() + 3);
|
||||||
constraints.push(Constraint::Length(1)); // title
|
constraints.push(Constraint::Length(1)); // title
|
||||||
|
|
@ -320,10 +320,10 @@ fn draw(f: &mut Frame<'_>, list: &PodList) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn picker_title() -> &'static str {
|
fn picker_title() -> &'static str {
|
||||||
"resume pod pick a pod"
|
"resume worker pick a worker"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn row_line(entry: &PodListEntry, selected: bool) -> Line<'_> {
|
fn row_line(entry: &WorkerListEntry, selected: bool) -> Line<'_> {
|
||||||
let marker = if selected { "▶ " } else { " " };
|
let marker = if selected { "▶ " } else { " " };
|
||||||
let name_style = if selected {
|
let name_style = if selected {
|
||||||
Style::default()
|
Style::default()
|
||||||
|
|
@ -361,18 +361,18 @@ fn row_line(entry: &PodListEntry, selected: bool) -> Line<'_> {
|
||||||
Line::from(spans)
|
Line::from(spans)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn row_state(entry: &PodListEntry) -> PodRowState {
|
fn row_state(entry: &WorkerListEntry) -> WorkerRowState {
|
||||||
if entry.live.as_ref().is_some_and(|live| live.reachable) {
|
if entry.live.as_ref().is_some_and(|live| live.reachable) {
|
||||||
return PodRowState::Live;
|
return WorkerRowState::Live;
|
||||||
}
|
}
|
||||||
if entry
|
if entry
|
||||||
.stored
|
.stored
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|stored| matches!(stored.metadata_state, StoredMetadataState::Corrupt(_)))
|
.is_some_and(|stored| matches!(stored.metadata_state, StoredMetadataState::Corrupt(_)))
|
||||||
{
|
{
|
||||||
return PodRowState::Corrupt;
|
return WorkerRowState::Corrupt;
|
||||||
}
|
}
|
||||||
PodRowState::Stopped
|
WorkerRowState::Stopped
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_updated_at(updated_at: u64) -> String {
|
fn format_updated_at(updated_at: u64) -> String {
|
||||||
|
|
@ -383,7 +383,7 @@ fn format_updated_at(updated_at: u64) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn debug_ids(entry: &PodListEntry) -> String {
|
fn debug_ids(entry: &WorkerListEntry) -> String {
|
||||||
let session = entry
|
let session = entry
|
||||||
.summary
|
.summary
|
||||||
.active_session_id
|
.active_session_id
|
||||||
|
|
@ -407,20 +407,20 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn picker_title_names_pods_not_sessions() {
|
fn picker_title_names_pods_not_sessions() {
|
||||||
assert_eq!(picker_title(), "resume pod pick a pod");
|
assert_eq!(picker_title(), "resume worker pick a worker");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn picker_no_pods_message_mentions_all_for_workspace_scope() {
|
fn picker_no_pods_message_mentions_all_for_workspace_scope() {
|
||||||
let message = PickerError::NoPods { all: false }.to_string();
|
let message = PickerError::NoWorkers { all: false }.to_string();
|
||||||
assert!(message.contains("no pods found in this workspace"));
|
assert!(message.contains("no workers found in this workspace"));
|
||||||
assert!(message.contains("yoi resume --all"));
|
assert!(message.contains("yoi resume --all"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn picker_no_pods_message_keeps_fresh_pod_hint_for_all_scope() {
|
fn picker_no_pods_message_keeps_fresh_pod_hint_for_all_scope() {
|
||||||
let message = PickerError::NoPods { all: true }.to_string();
|
let message = PickerError::NoWorkers { all: true }.to_string();
|
||||||
assert!(message.contains("start a fresh pod with `yoi`"));
|
assert!(message.contains("start a fresh Worker with `yoi`"));
|
||||||
assert!(!message.contains("yoi resume --all"));
|
assert!(!message.contains("yoi resume --all"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -464,9 +464,9 @@ mod tests {
|
||||||
assert_eq!(names, vec!["current", "other", "legacy"]);
|
assert_eq!(names, vec!["current", "other", "legacy"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stored_pod(name: &str, workspace_root: Option<&str>, updated_at: u64) -> StoredPodInfo {
|
fn stored_pod(name: &str, workspace_root: Option<&str>, updated_at: u64) -> StoredWorkerInfo {
|
||||||
StoredPodInfo {
|
StoredWorkerInfo {
|
||||||
pod_name: name.to_string(),
|
worker_name: name.to_string(),
|
||||||
metadata_state: StoredMetadataState::Present,
|
metadata_state: StoredMetadataState::Present,
|
||||||
active_session_id: None,
|
active_session_id: None,
|
||||||
active_segment_id: None,
|
active_segment_id: None,
|
||||||
|
|
@ -479,16 +479,16 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn picker_row_shows_live_pending_preview_and_runtime_segment_id() {
|
fn picker_row_shows_live_pending_preview_and_runtime_segment_id() {
|
||||||
let segment_id = session_store::new_segment_id();
|
let segment_id = session_store::new_segment_id();
|
||||||
let entry = PodList::from_sources(
|
let entry = WorkerList::from_sources(
|
||||||
PodVisibilitySource::ResumePicker,
|
WorkerVisibilitySource::ResumePicker,
|
||||||
vec![],
|
vec![],
|
||||||
vec![crate::pod_list::LivePodInfo {
|
vec![crate::worker_list::LiveWorkerInfo {
|
||||||
pod_name: "pending".to_string(),
|
worker_name: "pending".to_string(),
|
||||||
socket_path: PathBuf::from("/tmp/pending.sock"),
|
socket_path: PathBuf::from("/tmp/pending.sock"),
|
||||||
status: Some(protocol::PodStatus::Idle),
|
status: Some(protocol::WorkerStatus::Idle),
|
||||||
reachable: true,
|
reachable: true,
|
||||||
segment_id: Some(segment_id),
|
segment_id: Some(segment_id),
|
||||||
summary: crate::pod_list::PodEntrySummary::default(),
|
summary: crate::worker_list::WorkerEntrySummary::default(),
|
||||||
}],
|
}],
|
||||||
None,
|
None,
|
||||||
10,
|
10,
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ pub(crate) struct RoleSessionRegistry {
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub(crate) struct RoleSessionRecord {
|
pub(crate) struct RoleSessionRecord {
|
||||||
pub role: String,
|
pub role: String,
|
||||||
pub pod_name: String,
|
pub worker_name: String,
|
||||||
pub origin: RoleSessionOrigin,
|
pub origin: RoleSessionOrigin,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
|
|
@ -58,7 +58,7 @@ pub(crate) struct TicketClaim {
|
||||||
pub ticket_id: String,
|
pub ticket_id: String,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub ticket_slug: Option<String>,
|
pub ticket_slug: Option<String>,
|
||||||
pub pod_name: String,
|
pub worker_name: String,
|
||||||
pub role: String,
|
pub role: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,7 +89,7 @@ impl std::fmt::Display for PanelRegistryError {
|
||||||
Self::TicketAlreadyClaimed(claim) => write!(
|
Self::TicketAlreadyClaimed(claim) => write!(
|
||||||
f,
|
f,
|
||||||
"Ticket {} is already claimed locally by {} ({})",
|
"Ticket {} is already claimed locally by {} ({})",
|
||||||
claim.ticket_id, claim.pod_name, claim.role
|
claim.ticket_id, claim.worker_name, claim.role
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -165,33 +165,33 @@ impl PanelRegistryStore {
|
||||||
|
|
||||||
pub(crate) fn record_session(
|
pub(crate) fn record_session(
|
||||||
&self,
|
&self,
|
||||||
pod_name: impl Into<String>,
|
worker_name: impl Into<String>,
|
||||||
role: impl Into<String>,
|
role: impl Into<String>,
|
||||||
origin: RoleSessionOrigin,
|
origin: RoleSessionOrigin,
|
||||||
session_id: Option<String>,
|
session_id: Option<String>,
|
||||||
related_tickets: impl IntoIterator<Item = RelatedTicketRef>,
|
related_tickets: impl IntoIterator<Item = RelatedTicketRef>,
|
||||||
) -> Result<(), PanelRegistryError> {
|
) -> Result<(), PanelRegistryError> {
|
||||||
let pod_name = pod_name.into();
|
let worker_name = worker_name.into();
|
||||||
let role = role.into();
|
let role = role.into();
|
||||||
let related_tickets: Vec<RelatedTicketRef> = related_tickets.into_iter().collect();
|
let related_tickets: Vec<RelatedTicketRef> = related_tickets.into_iter().collect();
|
||||||
self.update_registry(|registry| {
|
self.update_registry(|registry| {
|
||||||
let now = now_timestamp_string();
|
let now = now_timestamp_string();
|
||||||
let mut tickets: BTreeSet<RelatedTicketRef> = registry
|
let mut tickets: BTreeSet<RelatedTicketRef> = registry
|
||||||
.sessions
|
.sessions
|
||||||
.get(&pod_name)
|
.get(&worker_name)
|
||||||
.map(|record| record.related_tickets.iter().cloned().collect())
|
.map(|record| record.related_tickets.iter().cloned().collect())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
tickets.extend(related_tickets);
|
tickets.extend(related_tickets);
|
||||||
let created_at = registry
|
let created_at = registry
|
||||||
.sessions
|
.sessions
|
||||||
.get(&pod_name)
|
.get(&worker_name)
|
||||||
.map(|record| record.created_at.clone())
|
.map(|record| record.created_at.clone())
|
||||||
.unwrap_or_else(|| now.clone());
|
.unwrap_or_else(|| now.clone());
|
||||||
registry.sessions.insert(
|
registry.sessions.insert(
|
||||||
pod_name.clone(),
|
worker_name.clone(),
|
||||||
RoleSessionRecord {
|
RoleSessionRecord {
|
||||||
role,
|
role,
|
||||||
pod_name,
|
worker_name,
|
||||||
origin,
|
origin,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
|
|
@ -207,7 +207,7 @@ impl PanelRegistryStore {
|
||||||
&self,
|
&self,
|
||||||
ticket_id: &str,
|
ticket_id: &str,
|
||||||
ticket_slug: Option<&str>,
|
ticket_slug: Option<&str>,
|
||||||
pod_name: &str,
|
worker_name: &str,
|
||||||
role: &str,
|
role: &str,
|
||||||
) -> Result<TicketClaimResult, PanelRegistryError> {
|
) -> Result<TicketClaimResult, PanelRegistryError> {
|
||||||
fs::create_dir_all(self.claims_dir())?;
|
fs::create_dir_all(self.claims_dir())?;
|
||||||
|
|
@ -215,13 +215,13 @@ impl PanelRegistryStore {
|
||||||
let claim = TicketClaim {
|
let claim = TicketClaim {
|
||||||
ticket_id: ticket_id.to_string(),
|
ticket_id: ticket_id.to_string(),
|
||||||
ticket_slug: ticket_slug.map(ToOwned::to_owned),
|
ticket_slug: ticket_slug.map(ToOwned::to_owned),
|
||||||
pod_name: pod_name.to_string(),
|
worker_name: worker_name.to_string(),
|
||||||
role: role.to_string(),
|
role: role.to_string(),
|
||||||
};
|
};
|
||||||
match self.create_claim_file(&claim_path, &claim) {
|
match self.create_claim_file(&claim_path, &claim) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
if let Err(error) = self.record_session(
|
if let Err(error) = self.record_session(
|
||||||
pod_name.to_string(),
|
worker_name.to_string(),
|
||||||
role.to_string(),
|
role.to_string(),
|
||||||
RoleSessionOrigin::TicketClaim,
|
RoleSessionOrigin::TicketClaim,
|
||||||
None,
|
None,
|
||||||
|
|
@ -237,7 +237,7 @@ impl PanelRegistryStore {
|
||||||
}
|
}
|
||||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
|
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
|
||||||
let existing = self.load_claim(ticket_id)?;
|
let existing = self.load_claim(ticket_id)?;
|
||||||
if existing.pod_name == pod_name && existing.role == role {
|
if existing.worker_name == worker_name && existing.role == role {
|
||||||
Ok(TicketClaimResult::AlreadyOwned(existing))
|
Ok(TicketClaimResult::AlreadyOwned(existing))
|
||||||
} else {
|
} else {
|
||||||
Err(PanelRegistryError::TicketAlreadyClaimed(existing))
|
Err(PanelRegistryError::TicketAlreadyClaimed(existing))
|
||||||
|
|
@ -485,7 +485,7 @@ mod tests {
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(error, PanelRegistryError::TicketAlreadyClaimed(_)));
|
assert!(matches!(error, PanelRegistryError::TicketAlreadyClaimed(_)));
|
||||||
let claim = store.claim_for_ticket("T-1").unwrap().unwrap();
|
let claim = store.claim_for_ticket("T-1").unwrap().unwrap();
|
||||||
assert_eq!(claim.pod_name, "ticket-one-intake");
|
assert_eq!(claim.worker_name, "ticket-one-intake");
|
||||||
assert_eq!(claim.ticket_slug.as_deref(), Some("ticket-one"));
|
assert_eq!(claim.ticket_slug.as_deref(), Some("ticket-one"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -526,12 +526,12 @@ mod tests {
|
||||||
let preticket = snapshot
|
let preticket = snapshot
|
||||||
.sessions
|
.sessions
|
||||||
.iter()
|
.iter()
|
||||||
.find(|session| session.pod_name == "ticket-intake-preticket")
|
.find(|session| session.worker_name == "ticket-intake-preticket")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let shared = snapshot
|
let shared = snapshot
|
||||||
.sessions
|
.sessions
|
||||||
.iter()
|
.iter()
|
||||||
.find(|session| session.pod_name == "ticket-intake-shared")
|
.find(|session| session.worker_name == "ticket-intake-shared")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(preticket.related_tickets.is_empty());
|
assert!(preticket.related_tickets.is_empty());
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ fn prompt_model_choice(
|
||||||
println!("yoi setup-model");
|
println!("yoi setup-model");
|
||||||
println!();
|
println!();
|
||||||
println!("Choose the default model Profile to write under the user config directory.");
|
println!("Choose the default model Profile to write under the user config directory.");
|
||||||
println!("This command only writes Profile config; it does not start or attach a Pod.");
|
println!("This command only writes Profile config; it does not start or attach a Worker.");
|
||||||
println!();
|
println!();
|
||||||
for (idx, choice) in choices.iter().enumerate() {
|
for (idx, choice) in choices.iter().enumerate() {
|
||||||
println!(
|
println!(
|
||||||
|
|
@ -237,7 +237,7 @@ return profile {{
|
||||||
task = {{ enabled = true }},
|
task = {{ enabled = true }},
|
||||||
memory = {{ enabled = true }},
|
memory = {{ enabled = true }},
|
||||||
web = {{ enabled = true }},
|
web = {{ enabled = true }},
|
||||||
pods = {{ enabled = false }},
|
workers = {{ enabled = false }},
|
||||||
ticket = {{ enabled = false, access = "lifecycle" }},
|
ticket = {{ enabled = false, access = "lifecycle" }},
|
||||||
ticket_orchestration = {{ enabled = false }},
|
ticket_orchestration = {{ enabled = false }},
|
||||||
}},
|
}},
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
//! Inline-viewport "spawn Pod and attach" UX.
|
//! Inline-viewport "spawn Worker and attach" UX.
|
||||||
//!
|
//!
|
||||||
//! Rendered at the user's current cursor position when `yoi` is invoked
|
//! Rendered at the user's current cursor position when `yoi` is invoked
|
||||||
//! with no positional argument. Discovers `.yoi/profiles.toml` profile
|
//! with no positional argument. Discovers `.yoi/profiles.toml` profile
|
||||||
//! choices plus bundled profiles, defaults to the builtin profile, prompts for
|
//! choices plus bundled profiles, defaults to the builtin profile, prompts for
|
||||||
//! the Pod's name, and on confirmation launches the Pod runtime command as an
|
//! the Worker's name, and on confirmation launches the Worker runtime command as an
|
||||||
//! independent process. Once the process reports its socket via the
|
//! independent process. Once the process reports its socket via the
|
||||||
//! `YOI-READY` stderr line, the dialog hands control back so main can
|
//! `YOI-READY` stderr line, the dialog hands control back so main can
|
||||||
//! switch the terminal to alternate-screen mode.
|
//! switch the terminal to alternate-screen mode.
|
||||||
|
|
@ -15,7 +15,7 @@ use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use client::{PodRuntimeCommand, SpawnConfig, spawn_pod};
|
use client::{SpawnConfig, WorkerRuntimeCommand, spawn_worker};
|
||||||
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
|
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
|
||||||
use manifest::ProfileDiscovery;
|
use manifest::ProfileDiscovery;
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
|
|
@ -30,7 +30,7 @@ use session_store::SegmentId;
|
||||||
const VIEWPORT_LINES: u16 = 6;
|
const VIEWPORT_LINES: u16 = 6;
|
||||||
|
|
||||||
pub struct SpawnReady {
|
pub struct SpawnReady {
|
||||||
pub pod_name: String,
|
pub worker_name: String,
|
||||||
pub socket_path: PathBuf,
|
pub socket_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -71,13 +71,13 @@ impl From<client::SpawnError> for SpawnError {
|
||||||
type InlineTerminal = Terminal<CrosstermBackend<io::Stdout>>;
|
type InlineTerminal = Terminal<CrosstermBackend<io::Stdout>>;
|
||||||
|
|
||||||
/// Source session for a resume run. `None` = fresh spawn (current
|
/// Source session for a resume run. `None` = fresh spawn (current
|
||||||
/// behaviour); `Some(id)` swaps the dialog into "Resume Pod" mode and
|
/// behaviour); `Some(id)` swaps the dialog into "Resume Worker" mode and
|
||||||
/// passes `--session <id>` to the spawned Pod runtime child.
|
/// passes `--session <id>` to the spawned Worker runtime child.
|
||||||
pub async fn run(
|
pub async fn run(
|
||||||
resume_from: Option<SegmentId>,
|
resume_from: Option<SegmentId>,
|
||||||
pod_name: Option<String>,
|
worker_name: Option<String>,
|
||||||
profile: Option<String>,
|
profile: Option<String>,
|
||||||
runtime_command: PodRuntimeCommand,
|
runtime_command: WorkerRuntimeCommand,
|
||||||
) -> Result<SpawnOutcome, SpawnError> {
|
) -> Result<SpawnOutcome, SpawnError> {
|
||||||
let defaults = load_spawn_defaults()?;
|
let defaults = load_spawn_defaults()?;
|
||||||
let mut profile_choices = if resume_from.is_some() {
|
let mut profile_choices = if resume_from.is_some() {
|
||||||
|
|
@ -91,7 +91,7 @@ pub async fn run(
|
||||||
defaults.default_profile_index,
|
defaults.default_profile_index,
|
||||||
);
|
);
|
||||||
|
|
||||||
let selected_name = pod_name.unwrap_or(defaults.default_name);
|
let selected_name = worker_name.unwrap_or(defaults.default_name);
|
||||||
let immediate = resume_from.is_some() || profile.is_some() && !selected_name.is_empty();
|
let immediate = resume_from.is_some() || profile.is_some() && !selected_name.is_empty();
|
||||||
let mut form = Form {
|
let mut form = Form {
|
||||||
cwd: defaults.cwd.clone(),
|
cwd: defaults.cwd.clone(),
|
||||||
|
|
@ -145,18 +145,18 @@ pub async fn run(
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: launch pod and wait for ready line. Drop the cursor
|
// Phase 2: launch worker and wait for ready line. Drop the cursor
|
||||||
// out of the name field — subsequent frames are passive status
|
// out of the name field — subsequent frames are passive status
|
||||||
// updates, not input — so the cursor doesn't end up parked there
|
// updates, not input — so the cursor doesn't end up parked there
|
||||||
// when the inline terminal is finally dropped.
|
// when the inline terminal is finally dropped.
|
||||||
form.editing = false;
|
form.editing = false;
|
||||||
form.message = Some(("starting pod...".to_string(), MessageKind::Progress));
|
form.message = Some(("starting worker...".to_string(), MessageKind::Progress));
|
||||||
terminal.draw(|f| draw_form(f, &form))?;
|
terminal.draw(|f| draw_form(f, &form))?;
|
||||||
|
|
||||||
match wait_for_ready(&mut terminal, &mut form, &runtime_command).await {
|
match wait_for_ready(&mut terminal, &mut form, &runtime_command).await {
|
||||||
Ok(ready) => {
|
Ok(ready) => {
|
||||||
form.message = Some((
|
form.message = Some((
|
||||||
format!("ready: {} attaching...", ready.pod_name),
|
format!("ready: {} attaching...", ready.worker_name),
|
||||||
MessageKind::Ok,
|
MessageKind::Ok,
|
||||||
));
|
));
|
||||||
terminal.draw(|f| draw_form(f, &form))?;
|
terminal.draw(|f| draw_form(f, &form))?;
|
||||||
|
|
@ -172,22 +172,22 @@ pub async fn run(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Launch a Pod runtime command with `--pod <name>` without opening the name dialog. The child Pod
|
/// Launch a Worker runtime command with `--worker <name>` without opening the name dialog. The child Worker
|
||||||
/// resolves persisted Pod metadata if present, or creates a fresh same-name Pod
|
/// resolves persisted Worker metadata if present, or creates a fresh same-name Worker
|
||||||
/// from the default profile.
|
/// from the default profile.
|
||||||
pub async fn run_pod_name(
|
pub async fn run_worker_name(
|
||||||
pod_name: String,
|
worker_name: String,
|
||||||
runtime_command: PodRuntimeCommand,
|
runtime_command: WorkerRuntimeCommand,
|
||||||
) -> Result<SpawnOutcome, SpawnError> {
|
) -> Result<SpawnOutcome, SpawnError> {
|
||||||
let defaults = load_spawn_defaults()?;
|
let defaults = load_spawn_defaults()?;
|
||||||
let mut form = form_for_pod_name(pod_name, defaults);
|
let mut form = form_for_worker_name(worker_name, defaults);
|
||||||
let mut terminal = make_inline_terminal()?;
|
let mut terminal = make_inline_terminal()?;
|
||||||
terminal.draw(|f| draw_form(f, &form))?;
|
terminal.draw(|f| draw_form(f, &form))?;
|
||||||
|
|
||||||
match wait_for_ready(&mut terminal, &mut form, &runtime_command).await {
|
match wait_for_ready(&mut terminal, &mut form, &runtime_command).await {
|
||||||
Ok(ready) => {
|
Ok(ready) => {
|
||||||
form.message = Some((
|
form.message = Some((
|
||||||
format!("ready: {} attaching...", ready.pod_name),
|
format!("ready: {} attaching...", ready.worker_name),
|
||||||
MessageKind::Ok,
|
MessageKind::Ok,
|
||||||
));
|
));
|
||||||
terminal.draw(|f| draw_form(f, &form))?;
|
terminal.draw(|f| draw_form(f, &form))?;
|
||||||
|
|
@ -226,7 +226,7 @@ fn load_spawn_defaults() -> Result<SpawnDefaults, SpawnError> {
|
||||||
.and_then(|s| s.to_str())
|
.and_then(|s| s.to_str())
|
||||||
.map(sanitise_default_name)
|
.map(sanitise_default_name)
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.unwrap_or_else(|| "pod".to_string());
|
.unwrap_or_else(|| "worker".to_string());
|
||||||
|
|
||||||
let (profile_choices, default_profile_index) = profile_choices_for_cwd(&cwd);
|
let (profile_choices, default_profile_index) = profile_choices_for_cwd(&cwd);
|
||||||
|
|
||||||
|
|
@ -290,13 +290,13 @@ fn initial_profile_index(
|
||||||
choices.len() - 1
|
choices.len() - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
fn form_for_pod_name(pod_name: String, defaults: SpawnDefaults) -> Form {
|
fn form_for_worker_name(worker_name: String, defaults: SpawnDefaults) -> Form {
|
||||||
Form {
|
Form {
|
||||||
cwd: defaults.cwd,
|
cwd: defaults.cwd,
|
||||||
scope_origin: defaults.scope_origin,
|
scope_origin: defaults.scope_origin,
|
||||||
name_cursor: pod_name.chars().count(),
|
name_cursor: worker_name.chars().count(),
|
||||||
name: pod_name,
|
name: worker_name,
|
||||||
message: Some(("resuming pod...".to_string(), MessageKind::Progress)),
|
message: Some(("resuming worker...".to_string(), MessageKind::Progress)),
|
||||||
editing: false,
|
editing: false,
|
||||||
resume_from: None,
|
resume_from: None,
|
||||||
profile_choices: Vec::new(),
|
profile_choices: Vec::new(),
|
||||||
|
|
@ -359,7 +359,7 @@ fn poll_event() -> io::Result<Option<Action>> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_safe_name_char(c: char) -> bool {
|
fn is_safe_name_char(c: char) -> bool {
|
||||||
// Filesystem-safe; pod.name becomes a runtime-dir name.
|
// Filesystem-safe; worker.name becomes a runtime-dir name.
|
||||||
c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')
|
c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -372,23 +372,23 @@ fn sanitise_default_name(s: &str) -> String {
|
||||||
async fn wait_for_ready(
|
async fn wait_for_ready(
|
||||||
terminal: &mut InlineTerminal,
|
terminal: &mut InlineTerminal,
|
||||||
form: &mut Form,
|
form: &mut Form,
|
||||||
runtime_command: &PodRuntimeCommand,
|
runtime_command: &WorkerRuntimeCommand,
|
||||||
) -> Result<SpawnReady, SpawnError> {
|
) -> Result<SpawnReady, SpawnError> {
|
||||||
let config = SpawnConfig {
|
let config = SpawnConfig {
|
||||||
runtime_command: runtime_command.clone(),
|
runtime_command: runtime_command.clone(),
|
||||||
pod_name: form.name.clone(),
|
worker_name: form.name.clone(),
|
||||||
profile: form.selected_profile_selector(),
|
profile: form.selected_profile_selector(),
|
||||||
workspace_root: form.cwd.clone(),
|
workspace_root: form.cwd.clone(),
|
||||||
cwd: None,
|
cwd: None,
|
||||||
resume_from: form.resume_from,
|
resume_from: form.resume_from,
|
||||||
};
|
};
|
||||||
let ready = spawn_pod(config, |line| {
|
let ready = spawn_worker(config, |line| {
|
||||||
form.message = Some((line.to_string(), MessageKind::Progress));
|
form.message = Some((line.to_string(), MessageKind::Progress));
|
||||||
let _ = terminal.draw(|f| draw_form(f, form));
|
let _ = terminal.draw(|f| draw_form(f, form));
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
Ok(SpawnReady {
|
Ok(SpawnReady {
|
||||||
pod_name: ready.pod_name,
|
worker_name: ready.worker_name,
|
||||||
socket_path: ready.socket_path,
|
socket_path: ready.socket_path,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -421,14 +421,14 @@ struct Form {
|
||||||
/// cursor stays out so it does not collide with the shell prompt
|
/// cursor stays out so it does not collide with the shell prompt
|
||||||
/// after the inline terminal is dropped.
|
/// after the inline terminal is dropped.
|
||||||
editing: bool,
|
editing: bool,
|
||||||
/// `Some(id)` flips the dialog into "Resume Pod" mode: the title
|
/// `Some(id)` flips the dialog into "Resume Worker" mode: the title
|
||||||
/// switches, the source session is shown to the user, and the
|
/// switches, the source session is shown to the user, and the
|
||||||
/// child pod is launched with `--session <id>` so it restores
|
/// child worker is launched with `--session <id>` so it restores
|
||||||
/// from `id` and appends to the same session log.
|
/// from `id` and appends to the same session log.
|
||||||
resume_from: Option<SegmentId>,
|
resume_from: Option<SegmentId>,
|
||||||
/// Optional profile choices passed with `--profile` for
|
/// Optional profile choices passed with `--profile` for
|
||||||
/// fresh spawns. This is not used for resume/attach flows because those must
|
/// fresh spawns. This is not used for resume/attach flows because those must
|
||||||
/// restore Pod state rather than re-evaluate a profile source.
|
/// restore Worker state rather than re-evaluate a profile source.
|
||||||
profile_choices: Vec<ProfileChoice>,
|
profile_choices: Vec<ProfileChoice>,
|
||||||
profile_index: usize,
|
profile_index: usize,
|
||||||
}
|
}
|
||||||
|
|
@ -526,8 +526,8 @@ fn draw_form(f: &mut Frame<'_>, form: &Form) {
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
let title_text = match form.resume_from {
|
let title_text = match form.resume_from {
|
||||||
Some(id) => format!("resume pod session: {}", short_segment(id)),
|
Some(id) => format!("resume worker session: {}", short_segment(id)),
|
||||||
None => "spawn pod".to_string(),
|
None => "spawn worker".to_string(),
|
||||||
};
|
};
|
||||||
let title = Paragraph::new(Line::from(vec![Span::styled(
|
let title = Paragraph::new(Line::from(vec![Span::styled(
|
||||||
title_text,
|
title_text,
|
||||||
|
|
@ -633,7 +633,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pod_name_form_restores_or_creates_by_pod_name() {
|
fn worker_name_form_restores_or_creates_by_worker_name() {
|
||||||
let defaults = SpawnDefaults {
|
let defaults = SpawnDefaults {
|
||||||
cwd: PathBuf::from("/work/example"),
|
cwd: PathBuf::from("/work/example"),
|
||||||
scope_origin: ScopeOrigin::FromProfile,
|
scope_origin: ScopeOrigin::FromProfile,
|
||||||
|
|
@ -641,7 +641,7 @@ mod tests {
|
||||||
default_profile_index: 0,
|
default_profile_index: 0,
|
||||||
profile_choices: Vec::new(),
|
profile_choices: Vec::new(),
|
||||||
};
|
};
|
||||||
let f = form_for_pod_name("agent".to_string(), defaults);
|
let f = form_for_worker_name("agent".to_string(), defaults);
|
||||||
|
|
||||||
assert_eq!(f.name, "agent");
|
assert_eq!(f.name, "agent");
|
||||||
assert_eq!(f.name_cursor, "agent".chars().count());
|
assert_eq!(f.name_cursor, "agent".chars().count());
|
||||||
|
|
@ -649,7 +649,7 @@ mod tests {
|
||||||
assert!(!f.editing);
|
assert!(!f.editing);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
f.message,
|
f.message,
|
||||||
Some(("resuming pod...".to_string(), MessageKind::Progress))
|
Some(("resuming worker...".to_string(), MessageKind::Progress))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
//! In-TUI mirror of the session-lifetime task store.
|
//! In-TUI mirror of the session-lifetime task store.
|
||||||
//!
|
//!
|
||||||
//! This deliberately does NOT depend on the Pod TaskStore. The TUI is a
|
//! This deliberately does NOT depend on the Worker TaskStore. The TUI is a
|
||||||
//! presentation layer; pulling in `pod` would drag along the runtime
|
//! presentation layer; pulling in `worker` would drag along the runtime
|
||||||
//! feature surface. Instead we mirror the small subset we
|
//! feature surface. Instead we mirror the small subset we
|
||||||
//! need:
|
//! need:
|
||||||
//!
|
//!
|
||||||
//! - `TaskEntry` / `TaskStatus`: shaped to round-trip with Pod Task JSON
|
//! - `TaskEntry` / `TaskStatus`: shaped to round-trip with Worker Task JSON
|
||||||
//! serialization (`#[serde(rename_all = "lowercase")]` on the status,
|
//! serialization (`#[serde(rename_all = "lowercase")]` on the status,
|
||||||
//! matching field names on the entry).
|
//! matching field names on the entry).
|
||||||
//! - Just enough state machine to apply `TaskCreate` / `TaskUpdate`
|
//! - Just enough state machine to apply `TaskCreate` / `TaskUpdate`
|
||||||
//! tool-call arguments and the `[Session TaskStore snapshot]` system
|
//! tool-call arguments and the `[Session TaskStore snapshot]` system
|
||||||
//! message that compaction emits.
|
//! message that compaction emits.
|
||||||
//!
|
//!
|
||||||
//! The snapshot text format is owned by the Pod Task feature. The TUI keeps
|
//! The snapshot text format is owned by the Worker Task feature. The TUI keeps
|
||||||
//! local compatibility fixtures for the `[Session TaskStore snapshot]` system
|
//! local compatibility fixtures for the `[Session TaskStore snapshot]` system
|
||||||
//! message shape emitted during compaction and restored on resume.
|
//! message shape emitted during compaction and restored on resume.
|
||||||
|
|
||||||
|
|
@ -90,7 +90,7 @@ impl TaskStore {
|
||||||
|
|
||||||
/// Apply a completed `TaskCreate` / `TaskUpdate` tool_call. Other
|
/// Apply a completed `TaskCreate` / `TaskUpdate` tool_call. Other
|
||||||
/// tool names and unparseable JSON are silent no-ops, matching the
|
/// tool names and unparseable JSON are silent no-ops, matching the
|
||||||
/// resilience of the Pod TaskStore history replay.
|
/// resilience of the Worker TaskStore history replay.
|
||||||
pub fn apply_tool_call(&mut self, name: &str, arguments: &str) {
|
pub fn apply_tool_call(&mut self, name: &str, arguments: &str) {
|
||||||
match name {
|
match name {
|
||||||
"TaskCreate" => {
|
"TaskCreate" => {
|
||||||
|
|
@ -236,8 +236,8 @@ mod tests {
|
||||||
assert_eq!(c.active(), 2);
|
assert_eq!(c.active(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshot text matches the wrapping `Pod::try_pre_run_compact` and the
|
/// Snapshot text matches the wrapping `Worker::try_pre_run_compact` and the
|
||||||
/// Pod Task feature snapshot fixture shape: header line, blank, overview
|
/// Worker Task feature snapshot fixture shape: header line, blank, overview
|
||||||
/// line, blank, fenced JSON, trailing prose.
|
/// line, blank, fenced JSON, trailing prose.
|
||||||
fn wrap_snapshot(json_body: &str, overview: &str) -> String {
|
fn wrap_snapshot(json_body: &str, overview: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
|
|
@ -314,16 +314,16 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshot format compatibility tests. The TUI deliberately re-implements a
|
/// Snapshot format compatibility tests. The TUI deliberately re-implements a
|
||||||
/// stripped-down TaskStore mirror instead of depending on the Pod Task feature;
|
/// stripped-down TaskStore mirror instead of depending on the Worker Task feature;
|
||||||
/// it only consumes task tool calls and `[Session TaskStore snapshot]` system
|
/// it only consumes task tool calls and `[Session TaskStore snapshot]` system
|
||||||
/// messages. These fixtures encode the Pod-owned Task snapshot JSON/text shape
|
/// messages. These fixtures encode the Worker-owned Task snapshot JSON/text shape
|
||||||
/// so accidental TUI parser drift still fails locally without making `tui`
|
/// so accidental TUI parser drift still fails locally without making `tui`
|
||||||
/// depend on `pod` or `tools`.
|
/// depend on `worker` or `tools`.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod snapshot_format_contract {
|
mod snapshot_format_contract {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// Mirrors the envelope `Pod::try_pre_run_compact` wraps the raw
|
/// Mirrors the envelope `Worker::try_pre_run_compact` wraps the raw
|
||||||
/// snapshot text in. Hand-rolled here so the test fails loudly if
|
/// snapshot text in. Hand-rolled here so the test fails loudly if
|
||||||
/// the prose around the JSON fence ever shifts.
|
/// the prose around the JSON fence ever shifts.
|
||||||
fn wrap_pod_style(snapshot_text: &str) -> String {
|
fn wrap_pod_style(snapshot_text: &str) -> String {
|
||||||
|
|
@ -397,7 +397,7 @@ mod snapshot_format_contract {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn taskentry_field_shape_deserializes_into_tui_taskentry() {
|
fn taskentry_field_shape_deserializes_into_tui_taskentry() {
|
||||||
// A single Pod TaskEntry as JSON. Field renames like `taskid` →
|
// A single Worker TaskEntry as JSON. Field renames like `taskid` →
|
||||||
// `task_id` or status case changes surface here as serde failures or
|
// `task_id` or status case changes surface here as serde failures or
|
||||||
// wrong-status assertions.
|
// wrong-status assertions.
|
||||||
let json = r#"{
|
let json = r#"{
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
//! Local, non-persistent text selection state for the single-Pod transcript view.
|
//! Local, non-persistent text selection state for the single-Worker transcript view.
|
||||||
//!
|
//!
|
||||||
//! This module deliberately stores only the most recent rendered history rows and
|
//! This module deliberately stores only the most recent rendered history rows and
|
||||||
//! the active drag endpoints. Selected/copied text never leaves TUI-local state
|
//! the active drag endpoints. Selected/copied text never leaves TUI-local state
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ use ratatui::widgets::{
|
||||||
};
|
};
|
||||||
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||||
|
|
||||||
use protocol::{AlertLevel, CompletionEntry, Greeting, PodEvent, Segment};
|
use protocol::{AlertLevel, CompletionEntry, Greeting, Segment, WorkerEvent};
|
||||||
|
|
||||||
use crate::app::{ActionbarNoticeLevel, App, CompletionState, alert_source_label, fmt_tokens};
|
use crate::app::{ActionbarNoticeLevel, App, CompletionState, alert_source_label, fmt_tokens};
|
||||||
use crate::block::{Block, CompactEvent, ThinkingBlock, ThinkingState};
|
use crate::block::{Block, CompactEvent, ThinkingBlock, ThinkingState};
|
||||||
|
|
@ -509,7 +509,7 @@ fn draw_rewind_picker(
|
||||||
.fg(Color::Yellow)
|
.fg(Color::Yellow)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
),
|
),
|
||||||
Span::raw(" waiting for Pod response"),
|
Span::raw(" waiting for Worker response"),
|
||||||
]
|
]
|
||||||
} else {
|
} else {
|
||||||
vec![
|
vec![
|
||||||
|
|
@ -871,7 +871,7 @@ fn render_block_into(lines: &mut Vec<Line<'static>>, block: &Block, width: u16,
|
||||||
match block {
|
match block {
|
||||||
Block::Greeting(g) => match mode {
|
Block::Greeting(g) => match mode {
|
||||||
Mode::Overview => {
|
Mode::Overview => {
|
||||||
let text = format!("{} {} ({})", g.pod_name, g.model, g.provider);
|
let text = format!("{} {} ({})", g.worker_name, g.model, g.provider);
|
||||||
lines.push(Line::from(Span::styled(
|
lines.push(Line::from(Span::styled(
|
||||||
text,
|
text,
|
||||||
Style::default().fg(Color::Cyan),
|
Style::default().fg(Color::Cyan),
|
||||||
|
|
@ -894,8 +894,8 @@ fn render_block_into(lines: &mut Vec<Line<'static>>, block: &Block, width: u16,
|
||||||
_ => push_padded_lines(lines, &text, MessageKind::Notify),
|
_ => push_padded_lines(lines, &text, MessageKind::Notify),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Block::PodEvent { event } => {
|
Block::WorkerEvent { event } => {
|
||||||
let text = format_pod_event(event);
|
let text = format_worker_event(event);
|
||||||
match mode {
|
match mode {
|
||||||
Mode::Overview => push_overview_line(lines, &text, width, MessageKind::Notify, ""),
|
Mode::Overview => push_overview_line(lines, &text, width, MessageKind::Notify, ""),
|
||||||
_ => push_padded_lines(lines, &text, MessageKind::Notify),
|
_ => push_padded_lines(lines, &text, MessageKind::Notify),
|
||||||
|
|
@ -1595,7 +1595,7 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
conn,
|
conn,
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
app.pod_name.clone(),
|
app.worker_name.clone(),
|
||||||
Style::default().add_modifier(Modifier::BOLD),
|
Style::default().add_modifier(Modifier::BOLD),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
@ -1823,7 +1823,7 @@ fn greeting_lines(g: &Greeting) -> Vec<Line<'static>> {
|
||||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||||
|
|
||||||
lines.push(Line::from(Span::styled(
|
lines.push(Line::from(Span::styled(
|
||||||
g.pod_name.clone(),
|
g.worker_name.clone(),
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(Color::Green)
|
.fg(Color::Green)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
|
|
@ -1856,9 +1856,9 @@ fn greeting_lines(g: &Greeting) -> Vec<Line<'static>> {
|
||||||
pub enum MessageKind {
|
pub enum MessageKind {
|
||||||
TurnHeader,
|
TurnHeader,
|
||||||
User,
|
User,
|
||||||
/// External-input echoes (`Method::Notify` / `Method::PodEvent`).
|
/// External-input echoes (`Method::Notify` / `Method::WorkerEvent`).
|
||||||
/// Visually distinct from User / Assistant / Notice so it's clear
|
/// Visually distinct from User / Assistant / Notice so it's clear
|
||||||
/// the line came from another Pod or operator, not the local user.
|
/// the line came from another Worker or operator, not the local user.
|
||||||
Notify,
|
Notify,
|
||||||
/// Persisted role:system history item preview.
|
/// Persisted role:system history item preview.
|
||||||
System,
|
System,
|
||||||
|
|
@ -1891,27 +1891,30 @@ pub fn kind_style(kind: MessageKind) -> Style {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One-line summary of a `PodEvent` for display in the activity log.
|
/// One-line summary of a `WorkerEvent` for display in the activity log.
|
||||||
/// Independent from the LLM-injection wrapper (`crate::ipc::event::render_event`
|
/// Independent from the LLM-injection wrapper (`crate::ipc::event::render_event`
|
||||||
/// in the pod crate) — that path applies prompt-pack wrapping, while
|
/// in the worker crate) — that path applies prompt-pack wrapping, while
|
||||||
/// this is the human-facing rendering of the raw structured event.
|
/// this is the human-facing rendering of the raw structured event.
|
||||||
fn format_pod_event(event: &PodEvent) -> String {
|
fn format_worker_event(event: &WorkerEvent) -> String {
|
||||||
match event {
|
match event {
|
||||||
PodEvent::TurnEnded { pod_name } => {
|
WorkerEvent::TurnEnded { worker_name } => {
|
||||||
format!("[pod_event] {pod_name} → turn_ended")
|
format!("[worker_event] {worker_name} → turn_ended")
|
||||||
}
|
}
|
||||||
PodEvent::Errored { pod_name, message } => {
|
WorkerEvent::Errored {
|
||||||
format!("[pod_event] {pod_name} → errored: {message}")
|
worker_name,
|
||||||
|
message,
|
||||||
|
} => {
|
||||||
|
format!("[worker_event] {worker_name} → errored: {message}")
|
||||||
}
|
}
|
||||||
PodEvent::ShutDown { pod_name } => {
|
WorkerEvent::ShutDown { worker_name } => {
|
||||||
format!("[pod_event] {pod_name} → shut_down")
|
format!("[worker_event] {worker_name} → shut_down")
|
||||||
}
|
}
|
||||||
PodEvent::ScopeSubDelegated {
|
WorkerEvent::ScopeSubDelegated {
|
||||||
parent_pod,
|
parent_worker,
|
||||||
sub_pod,
|
sub_worker,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
format!("[pod_event] {parent_pod} → scope_sub_delegated: {sub_pod}")
|
format!("[worker_event] {parent_worker} → scope_sub_delegated: {sub_worker}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1920,13 +1923,13 @@ fn format_pod_event(event: &PodEvent) -> String {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
|
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
|
||||||
use protocol::PodStatus;
|
use protocol::WorkerStatus;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn queue_status_text_includes_count_and_preview() {
|
fn queue_status_text_includes_count_and_preview() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
app.set_pod_status(PodStatus::Running);
|
app.set_worker_status(WorkerStatus::Running);
|
||||||
for c in "queued preview".chars() {
|
for c in "queued preview".chars() {
|
||||||
app.insert_char(c);
|
app.insert_char(c);
|
||||||
}
|
}
|
||||||
|
|
@ -1952,7 +1955,7 @@ mod tests {
|
||||||
app.latest_llm_wait_event = Some("retrying LLM request".into());
|
app.latest_llm_wait_event = Some("retrying LLM request".into());
|
||||||
app.latest_memory_worker_event = Some("memory extract running".into());
|
app.latest_memory_worker_event = Some("memory extract running".into());
|
||||||
app.flash_actionbar_notice_at(
|
app.flash_actionbar_notice_at(
|
||||||
"Pod keeps running. Press Ctrl-C again to exit TUI.",
|
"Worker keeps running. Press Ctrl-C again to exit TUI.",
|
||||||
ActionbarNoticeLevel::Warn,
|
ActionbarNoticeLevel::Warn,
|
||||||
ActionbarNoticeSource::Tui,
|
ActionbarNoticeSource::Tui,
|
||||||
now,
|
now,
|
||||||
|
|
@ -1961,10 +1964,10 @@ mod tests {
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
actionbar_left_item(&app, now).map(|(text, _)| text),
|
actionbar_left_item(&app, now).map(|(text, _)| text),
|
||||||
Some("Pod keeps running. Press Ctrl-C again to exit TUI.".into())
|
Some("Worker keeps running. Press Ctrl-C again to exit TUI.".into())
|
||||||
);
|
);
|
||||||
|
|
||||||
app.set_pod_status(PodStatus::Running);
|
app.set_worker_status(WorkerStatus::Running);
|
||||||
for c in "queued turn".chars() {
|
for c in "queued turn".chars() {
|
||||||
app.insert_char(c);
|
app.insert_char(c);
|
||||||
}
|
}
|
||||||
|
|
@ -2037,7 +2040,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn consecutive_thinking_blocks_render_as_one_normal_group() {
|
fn consecutive_thinking_blocks_render_as_one_normal_group() {
|
||||||
let mut app = App::new("pod".to_string());
|
let mut app = App::new("worker".to_string());
|
||||||
app.mode = Mode::Normal;
|
app.mode = Mode::Normal;
|
||||||
app.blocks = vec![finished_thinking("alpha"), finished_thinking("beta")];
|
app.blocks = vec![finished_thinking("alpha"), finished_thinking("beta")];
|
||||||
|
|
||||||
|
|
@ -2055,7 +2058,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn thinking_group_detail_keeps_each_body_readable() {
|
fn thinking_group_detail_keeps_each_body_readable() {
|
||||||
let mut app = App::new("pod".to_string());
|
let mut app = App::new("worker".to_string());
|
||||||
app.mode = Mode::Detail;
|
app.mode = Mode::Detail;
|
||||||
app.blocks = vec![
|
app.blocks = vec![
|
||||||
finished_thinking("alpha line 1\nalpha line 2"),
|
finished_thinking("alpha line 1\nalpha line 2"),
|
||||||
|
|
@ -2074,7 +2077,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn non_thinking_separator_breaks_thinking_group() {
|
fn non_thinking_separator_breaks_thinking_group() {
|
||||||
let mut app = App::new("pod".to_string());
|
let mut app = App::new("worker".to_string());
|
||||||
app.mode = Mode::Normal;
|
app.mode = Mode::Normal;
|
||||||
app.blocks = vec![
|
app.blocks = vec![
|
||||||
finished_thinking("alpha"),
|
finished_thinking("alpha"),
|
||||||
|
|
@ -2097,7 +2100,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn turn_header_breaks_thinking_group() {
|
fn turn_header_breaks_thinking_group() {
|
||||||
let mut app = App::new("pod".to_string());
|
let mut app = App::new("worker".to_string());
|
||||||
app.mode = Mode::Normal;
|
app.mode = Mode::Normal;
|
||||||
app.blocks = vec![
|
app.blocks = vec![
|
||||||
Block::TurnHeader { turn: 1 },
|
Block::TurnHeader { turn: 1 },
|
||||||
|
|
@ -2119,7 +2122,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn thinking_group_preserves_streaming_and_incomplete_state_visibility() {
|
fn thinking_group_preserves_streaming_and_incomplete_state_visibility() {
|
||||||
let mut app = App::new("pod".to_string());
|
let mut app = App::new("worker".to_string());
|
||||||
app.mode = Mode::Normal;
|
app.mode = Mode::Normal;
|
||||||
app.blocks = vec![
|
app.blocks = vec![
|
||||||
finished_thinking("finished"),
|
finished_thinking("finished"),
|
||||||
|
|
@ -2141,7 +2144,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn single_thinking_block_rendering_stays_unchanged() {
|
fn single_thinking_block_rendering_stays_unchanged() {
|
||||||
let mut app = App::new("pod".to_string());
|
let mut app = App::new("worker".to_string());
|
||||||
app.mode = Mode::Normal;
|
app.mode = Mode::Normal;
|
||||||
app.blocks = vec![Block::Thinking(ThinkingBlock {
|
app.blocks = vec![Block::Thinking(ThinkingBlock {
|
||||||
text: "private reasoning".to_string(),
|
text: "private reasoning".to_string(),
|
||||||
|
|
@ -2159,7 +2162,7 @@ mod tests {
|
||||||
fn single_tool_block_rendering_stays_unchanged() {
|
fn single_tool_block_rendering_stays_unchanged() {
|
||||||
use crate::block::{ToolCallBlock, ToolCallState};
|
use crate::block::{ToolCallBlock, ToolCallState};
|
||||||
|
|
||||||
let mut app = App::new("pod".to_string());
|
let mut app = App::new("worker".to_string());
|
||||||
app.mode = Mode::Normal;
|
app.mode = Mode::Normal;
|
||||||
app.blocks = vec![Block::ToolCall(ToolCallBlock {
|
app.blocks = vec![Block::ToolCall(ToolCallBlock {
|
||||||
id: "bash-1".to_string(),
|
id: "bash-1".to_string(),
|
||||||
|
|
@ -2183,7 +2186,7 @@ mod tests {
|
||||||
fn read_tool_aggregation_still_consumes_consecutive_tool_blocks() {
|
fn read_tool_aggregation_still_consumes_consecutive_tool_blocks() {
|
||||||
use crate::block::{ToolCallBlock, ToolCallState};
|
use crate::block::{ToolCallBlock, ToolCallState};
|
||||||
|
|
||||||
let mut app = App::new("pod".to_string());
|
let mut app = App::new("worker".to_string());
|
||||||
app.mode = Mode::Normal;
|
app.mode = Mode::Normal;
|
||||||
app.blocks = vec![
|
app.blocks = vec![
|
||||||
Block::ToolCall(ToolCallBlock {
|
Block::ToolCall(ToolCallBlock {
|
||||||
|
|
@ -2225,7 +2228,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn history_rows_mark_text_items_selectable_and_non_text_unselectable() {
|
fn history_rows_mark_text_items_selectable_and_non_text_unselectable() {
|
||||||
let mut app = App::new("pod".to_string());
|
let mut app = App::new("worker".to_string());
|
||||||
app.blocks = vec![
|
app.blocks = vec![
|
||||||
Block::UserMessage {
|
Block::UserMessage {
|
||||||
segments: vec![Segment::Text {
|
segments: vec![Segment::Text {
|
||||||
|
|
|
||||||
|
|
@ -3,45 +3,45 @@ use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use client::PodClient;
|
use client::WorkerClient;
|
||||||
use pod_registry::{LockFileGuard, default_registry_path};
|
use pod_registry::{LockFileGuard, default_registry_path};
|
||||||
use pod_store::{PodActiveSegmentRef, PodMetadata, PodMetadataStore};
|
use pod_store::{WorkerActiveSegmentRef, WorkerMetadata, WorkerMetadataStore};
|
||||||
use protocol::{Event, PodStatus};
|
use protocol::{Event, WorkerStatus};
|
||||||
use session_store::{FsStore, SegmentId, SessionId};
|
use session_store::{FsStore, SegmentId, SessionId};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct PodList {
|
pub(crate) struct WorkerList {
|
||||||
pub entries: Vec<PodListEntry>,
|
pub entries: Vec<WorkerListEntry>,
|
||||||
pub selected_name: Option<String>,
|
pub selected_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PodList {
|
impl WorkerList {
|
||||||
pub(crate) fn from_sources(
|
pub(crate) fn from_sources(
|
||||||
source: PodVisibilitySource,
|
source: WorkerVisibilitySource,
|
||||||
stored: Vec<StoredPodInfo>,
|
stored: Vec<StoredWorkerInfo>,
|
||||||
live: Vec<LivePodInfo>,
|
live: Vec<LiveWorkerInfo>,
|
||||||
selected_name: Option<String>,
|
selected_name: Option<String>,
|
||||||
max_entries: usize,
|
max_entries: usize,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let mut entries_by_name: BTreeMap<String, PodListEntry> = BTreeMap::new();
|
let mut entries_by_name: BTreeMap<String, WorkerListEntry> = BTreeMap::new();
|
||||||
|
|
||||||
for stored_info in stored {
|
for stored_info in stored {
|
||||||
let name = stored_info.pod_name.clone();
|
let name = stored_info.worker_name.clone();
|
||||||
entries_by_name
|
entries_by_name
|
||||||
.entry(name.clone())
|
.entry(name.clone())
|
||||||
.or_insert_with(|| PodListEntry::new(name, source))
|
.or_insert_with(|| WorkerListEntry::new(name, source))
|
||||||
.merge_stored(stored_info);
|
.merge_stored(stored_info);
|
||||||
}
|
}
|
||||||
|
|
||||||
for live_info in live {
|
for live_info in live {
|
||||||
let name = live_info.pod_name.clone();
|
let name = live_info.worker_name.clone();
|
||||||
entries_by_name
|
entries_by_name
|
||||||
.entry(name.clone())
|
.entry(name.clone())
|
||||||
.or_insert_with(|| PodListEntry::new(name, source))
|
.or_insert_with(|| WorkerListEntry::new(name, source))
|
||||||
.merge_live(live_info);
|
.merge_live(live_info);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut entries: Vec<PodListEntry> = entries_by_name.into_values().collect();
|
let mut entries: Vec<WorkerListEntry> = entries_by_name.into_values().collect();
|
||||||
for entry in &mut entries {
|
for entry in &mut entries {
|
||||||
entry.finalize();
|
entry.finalize();
|
||||||
}
|
}
|
||||||
|
|
@ -64,9 +64,9 @@ impl PodList {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn from_workspace_sources(
|
pub(crate) fn from_workspace_sources(
|
||||||
source: PodVisibilitySource,
|
source: WorkerVisibilitySource,
|
||||||
stored: Vec<StoredPodInfo>,
|
stored: Vec<StoredWorkerInfo>,
|
||||||
live: Vec<LivePodInfo>,
|
live: Vec<LiveWorkerInfo>,
|
||||||
selected_name: Option<String>,
|
selected_name: Option<String>,
|
||||||
max_entries: usize,
|
max_entries: usize,
|
||||||
workspace_root: &Path,
|
workspace_root: &Path,
|
||||||
|
|
@ -81,14 +81,14 @@ impl PodList {
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.is_some_and(|root| workspace_root_key(root) == current_workspace);
|
.is_some_and(|root| workspace_root_key(root) == current_workspace);
|
||||||
if matches {
|
if matches {
|
||||||
current_names.insert(info.pod_name.clone());
|
current_names.insert(info.worker_name.clone());
|
||||||
}
|
}
|
||||||
matches
|
matches
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let live = live
|
let live = live
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|info| current_names.contains(&info.pod_name))
|
.filter(|info| current_names.contains(&info.worker_name))
|
||||||
.collect();
|
.collect();
|
||||||
Self::from_sources(source, stored, live, selected_name, max_entries)
|
Self::from_sources(source, stored, live, selected_name, max_entries)
|
||||||
}
|
}
|
||||||
|
|
@ -124,7 +124,7 @@ impl PodList {
|
||||||
self.selected_name = self.entries.get(index).map(|entry| entry.name.clone());
|
self.selected_name = self.entries.get(index).map(|entry| entry.name.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn selected_entry(&self) -> Option<&PodListEntry> {
|
pub(crate) fn selected_entry(&self) -> Option<&WorkerListEntry> {
|
||||||
let index = self.selected_index();
|
let index = self.selected_index();
|
||||||
self.entries.get(index)
|
self.entries.get(index)
|
||||||
}
|
}
|
||||||
|
|
@ -134,7 +134,7 @@ fn workspace_root_key(path: &Path) -> PathBuf {
|
||||||
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
|
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn entry_belongs_to_workspace(entry: &PodListEntry, current_workspace: &Path) -> bool {
|
fn entry_belongs_to_workspace(entry: &WorkerListEntry, current_workspace: &Path) -> bool {
|
||||||
entry
|
entry
|
||||||
.stored
|
.stored
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|
@ -143,48 +143,49 @@ fn entry_belongs_to_workspace(entry: &PodListEntry, current_workspace: &Path) ->
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub(crate) enum PodVisibilitySource {
|
pub(crate) enum WorkerVisibilitySource {
|
||||||
ResumePicker,
|
ResumePicker,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub(crate) enum PodListSourceKind {
|
pub(crate) enum WorkerListSourceKind {
|
||||||
RuntimeRegistry,
|
RuntimeRegistry,
|
||||||
StoredMetadata,
|
StoredMetadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct PodListEntry {
|
pub(crate) struct WorkerListEntry {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub visibility: PodVisibilitySource,
|
pub visibility: WorkerVisibilitySource,
|
||||||
pub source_kinds: Vec<PodListSourceKind>,
|
pub source_kinds: Vec<WorkerListSourceKind>,
|
||||||
pub live: Option<LivePodInfo>,
|
pub live: Option<LiveWorkerInfo>,
|
||||||
pub stored: Option<StoredPodInfo>,
|
pub stored: Option<StoredWorkerInfo>,
|
||||||
pub summary: PodEntrySummary,
|
pub summary: WorkerEntrySummary,
|
||||||
pub actions: PodEntryActions,
|
pub actions: WorkerEntryActions,
|
||||||
pub diagnostics: Vec<PodEntryDiagnostic>,
|
pub diagnostics: Vec<WorkerEntryDiagnostic>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PodListEntry {
|
impl WorkerListEntry {
|
||||||
fn new(name: String, visibility: PodVisibilitySource) -> Self {
|
fn new(name: String, visibility: WorkerVisibilitySource) -> Self {
|
||||||
Self {
|
Self {
|
||||||
name,
|
name,
|
||||||
visibility,
|
visibility,
|
||||||
source_kinds: Vec::new(),
|
source_kinds: Vec::new(),
|
||||||
live: None,
|
live: None,
|
||||||
stored: None,
|
stored: None,
|
||||||
summary: PodEntrySummary::default(),
|
summary: WorkerEntrySummary::default(),
|
||||||
actions: PodEntryActions::default(),
|
actions: WorkerEntryActions::default(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn merge_live(&mut self, live: LivePodInfo) {
|
fn merge_live(&mut self, live: LiveWorkerInfo) {
|
||||||
if !self
|
if !self
|
||||||
.source_kinds
|
.source_kinds
|
||||||
.contains(&PodListSourceKind::RuntimeRegistry)
|
.contains(&WorkerListSourceKind::RuntimeRegistry)
|
||||||
{
|
{
|
||||||
self.source_kinds.push(PodListSourceKind::RuntimeRegistry);
|
self.source_kinds
|
||||||
|
.push(WorkerListSourceKind::RuntimeRegistry);
|
||||||
}
|
}
|
||||||
if live.summary.updated_at > self.summary.updated_at {
|
if live.summary.updated_at > self.summary.updated_at {
|
||||||
self.summary.updated_at = live.summary.updated_at;
|
self.summary.updated_at = live.summary.updated_at;
|
||||||
|
|
@ -201,12 +202,12 @@ impl PodListEntry {
|
||||||
self.live = Some(live);
|
self.live = Some(live);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn merge_stored(&mut self, stored: StoredPodInfo) {
|
fn merge_stored(&mut self, stored: StoredWorkerInfo) {
|
||||||
if !self
|
if !self
|
||||||
.source_kinds
|
.source_kinds
|
||||||
.contains(&PodListSourceKind::StoredMetadata)
|
.contains(&WorkerListSourceKind::StoredMetadata)
|
||||||
{
|
{
|
||||||
self.source_kinds.push(PodListSourceKind::StoredMetadata);
|
self.source_kinds.push(WorkerListSourceKind::StoredMetadata);
|
||||||
}
|
}
|
||||||
if stored.updated_at > self.summary.updated_at {
|
if stored.updated_at > self.summary.updated_at {
|
||||||
self.summary.updated_at = stored.updated_at;
|
self.summary.updated_at = stored.updated_at;
|
||||||
|
|
@ -254,18 +255,18 @@ impl PodListEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct LivePodInfo {
|
pub(crate) struct LiveWorkerInfo {
|
||||||
pub pod_name: String,
|
pub worker_name: String,
|
||||||
pub socket_path: PathBuf,
|
pub socket_path: PathBuf,
|
||||||
pub status: Option<PodStatus>,
|
pub status: Option<WorkerStatus>,
|
||||||
pub reachable: bool,
|
pub reachable: bool,
|
||||||
pub segment_id: Option<SegmentId>,
|
pub segment_id: Option<SegmentId>,
|
||||||
pub summary: PodEntrySummary,
|
pub summary: WorkerEntrySummary,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct StoredPodInfo {
|
pub(crate) struct StoredWorkerInfo {
|
||||||
pub pod_name: String,
|
pub worker_name: String,
|
||||||
pub metadata_state: StoredMetadataState,
|
pub metadata_state: StoredMetadataState,
|
||||||
pub active_session_id: Option<SessionId>,
|
pub active_session_id: Option<SessionId>,
|
||||||
pub active_segment_id: Option<SegmentId>,
|
pub active_segment_id: Option<SegmentId>,
|
||||||
|
|
@ -281,7 +282,7 @@ pub(crate) enum StoredMetadataState {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub(crate) struct PodEntrySummary {
|
pub(crate) struct WorkerEntrySummary {
|
||||||
pub active_session_id: Option<SessionId>,
|
pub active_session_id: Option<SessionId>,
|
||||||
pub active_segment_id: Option<SegmentId>,
|
pub active_segment_id: Option<SegmentId>,
|
||||||
pub updated_at: u64,
|
pub updated_at: u64,
|
||||||
|
|
@ -289,7 +290,7 @@ pub(crate) struct PodEntrySummary {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
pub(crate) struct PodEntryActions {
|
pub(crate) struct WorkerEntryActions {
|
||||||
pub can_open: bool,
|
pub can_open: bool,
|
||||||
pub can_restore: bool,
|
pub can_restore: bool,
|
||||||
pub can_send_now: bool,
|
pub can_send_now: bool,
|
||||||
|
|
@ -298,67 +299,67 @@ pub(crate) struct PodEntryActions {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub(crate) struct PodEntryDiagnostic {
|
pub(crate) struct WorkerEntryDiagnostic {
|
||||||
pub kind: PodEntryDiagnosticKind,
|
pub kind: WorkerEntryDiagnosticKind,
|
||||||
pub message: String,
|
pub message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub(crate) enum PodEntryDiagnosticKind {
|
pub(crate) enum WorkerEntryDiagnosticKind {
|
||||||
StoredMetadataCorrupt,
|
StoredMetadataCorrupt,
|
||||||
LiveUnreachable,
|
LiveUnreachable,
|
||||||
MissingStoredMetadata,
|
MissingStoredMetadata,
|
||||||
MissingLiveStatus,
|
MissingLiveStatus,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn read_stored_pod_infos(
|
pub(crate) fn read_stored_worker_infos(
|
||||||
store: &FsStore,
|
store: &FsStore,
|
||||||
pod_store: &impl PodMetadataStore,
|
pod_store: &impl WorkerMetadataStore,
|
||||||
) -> Result<Vec<StoredPodInfo>, io::Error> {
|
) -> Result<Vec<StoredWorkerInfo>, io::Error> {
|
||||||
let mut records = Vec::new();
|
let mut records = Vec::new();
|
||||||
for pod_name in pod_store.list_names().map_err(io::Error::other)? {
|
for worker_name in pod_store.list_names().map_err(io::Error::other)? {
|
||||||
let info = match pod_store.read_by_name(&pod_name) {
|
let info = match pod_store.read_by_name(&worker_name) {
|
||||||
Ok(Some(metadata)) => stored_info_from_metadata(store, pod_name, metadata),
|
Ok(Some(metadata)) => stored_info_from_metadata(store, worker_name, metadata),
|
||||||
Ok(None) => corrupt_stored_info(
|
Ok(None) => corrupt_stored_info(
|
||||||
pod_name,
|
worker_name,
|
||||||
"metadata disappeared during discovery".to_string(),
|
"metadata disappeared during discovery".to_string(),
|
||||||
),
|
),
|
||||||
Err(e) => corrupt_stored_info(pod_name, e.to_string()),
|
Err(e) => corrupt_stored_info(worker_name, e.to_string()),
|
||||||
};
|
};
|
||||||
records.push(info);
|
records.push(info);
|
||||||
}
|
}
|
||||||
Ok(records)
|
Ok(records)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn read_live_pod_infos() -> Result<Vec<LivePodInfo>, io::Error> {
|
pub(crate) fn read_live_pod_infos() -> Result<Vec<LiveWorkerInfo>, io::Error> {
|
||||||
let path = default_registry_path()?;
|
let path = default_registry_path()?;
|
||||||
let guard = LockFileGuard::open(&path)?;
|
let guard = LockFileGuard::open(&path)?;
|
||||||
Ok(guard
|
Ok(guard
|
||||||
.data()
|
.data()
|
||||||
.allocations
|
.allocations
|
||||||
.iter()
|
.iter()
|
||||||
.map(|allocation| LivePodInfo {
|
.map(|allocation| LiveWorkerInfo {
|
||||||
pod_name: allocation.pod_name.clone(),
|
worker_name: allocation.worker_name.clone(),
|
||||||
socket_path: allocation.socket.clone(),
|
socket_path: allocation.socket.clone(),
|
||||||
status: None,
|
status: None,
|
||||||
reachable: false,
|
reachable: false,
|
||||||
segment_id: allocation.segment_id,
|
segment_id: allocation.segment_id,
|
||||||
summary: PodEntrySummary::default(),
|
summary: WorkerEntrySummary::default(),
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn read_reachable_live_pod_infos(
|
pub(crate) async fn read_reachable_live_pod_infos(
|
||||||
store: &FsStore,
|
store: &FsStore,
|
||||||
) -> Result<Vec<LivePodInfo>, io::Error> {
|
) -> Result<Vec<LiveWorkerInfo>, io::Error> {
|
||||||
let records = read_live_pod_infos()?;
|
let records = read_live_pod_infos()?;
|
||||||
probe_reachable_live_pod_infos(store, records).await
|
probe_reachable_live_pod_infos(store, records).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn probe_reachable_live_pod_infos(
|
async fn probe_reachable_live_pod_infos(
|
||||||
_store: &FsStore,
|
_store: &FsStore,
|
||||||
records: Vec<LivePodInfo>,
|
records: Vec<LiveWorkerInfo>,
|
||||||
) -> Result<Vec<LivePodInfo>, io::Error> {
|
) -> Result<Vec<LiveWorkerInfo>, io::Error> {
|
||||||
let mut handles = Vec::with_capacity(records.len());
|
let mut handles = Vec::with_capacity(records.len());
|
||||||
for record in records {
|
for record in records {
|
||||||
handles.push(tokio::spawn(probe_live_pod_info(record)));
|
handles.push(tokio::spawn(probe_live_pod_info(record)));
|
||||||
|
|
@ -377,33 +378,33 @@ async fn probe_reachable_live_pod_infos(
|
||||||
Ok(reachable)
|
Ok(reachable)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn probe_live_pod_info(mut record: LivePodInfo) -> Result<LivePodInfo, io::Error> {
|
async fn probe_live_pod_info(mut record: LiveWorkerInfo) -> Result<LiveWorkerInfo, io::Error> {
|
||||||
let status = probe_live_status(&record.socket_path).await?;
|
let status = probe_live_status(&record.socket_path).await?;
|
||||||
record.reachable = true;
|
record.reachable = true;
|
||||||
record.status = status;
|
record.status = status;
|
||||||
Ok(record)
|
Ok(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn live_socket_for_pod(pod_name: &str) -> Option<PathBuf> {
|
pub(crate) fn live_socket_for_pod(worker_name: &str) -> Option<PathBuf> {
|
||||||
read_live_pod_infos()
|
read_live_pod_infos()
|
||||||
.ok()?
|
.ok()?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|pod| pod.pod_name == pod_name)
|
.find(|worker| worker.worker_name == worker_name)
|
||||||
.map(|pod| pod.socket_path)
|
.map(|worker| worker.socket_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stored_info_from_metadata(
|
fn stored_info_from_metadata(
|
||||||
store: &FsStore,
|
store: &FsStore,
|
||||||
pod_name: String,
|
worker_name: String,
|
||||||
metadata: PodMetadata,
|
metadata: WorkerMetadata,
|
||||||
) -> StoredPodInfo {
|
) -> StoredWorkerInfo {
|
||||||
let active = metadata.active;
|
let active = metadata.active;
|
||||||
let active_session_id = active.as_ref().map(|a| a.session_id);
|
let active_session_id = active.as_ref().map(|a| a.session_id);
|
||||||
let active_segment_id = active.as_ref().and_then(|a| a.segment_id);
|
let active_segment_id = active.as_ref().and_then(|a| a.segment_id);
|
||||||
let summary = summarize_metadata(store, active.as_ref());
|
let summary = summarize_metadata(store, active.as_ref());
|
||||||
|
|
||||||
StoredPodInfo {
|
StoredWorkerInfo {
|
||||||
pod_name,
|
worker_name,
|
||||||
metadata_state: StoredMetadataState::Present,
|
metadata_state: StoredMetadataState::Present,
|
||||||
active_session_id,
|
active_session_id,
|
||||||
active_segment_id,
|
active_segment_id,
|
||||||
|
|
@ -413,9 +414,9 @@ fn stored_info_from_metadata(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn corrupt_stored_info(pod_name: String, message: String) -> StoredPodInfo {
|
fn corrupt_stored_info(worker_name: String, message: String) -> StoredWorkerInfo {
|
||||||
StoredPodInfo {
|
StoredWorkerInfo {
|
||||||
pod_name,
|
worker_name,
|
||||||
metadata_state: StoredMetadataState::Corrupt(message.clone()),
|
metadata_state: StoredMetadataState::Corrupt(message.clone()),
|
||||||
active_session_id: None,
|
active_session_id: None,
|
||||||
active_segment_id: None,
|
active_segment_id: None,
|
||||||
|
|
@ -427,8 +428,8 @@ fn corrupt_stored_info(pod_name: String, message: String) -> StoredPodInfo {
|
||||||
|
|
||||||
const LIVE_STATUS_PROBE_TIMEOUT: Duration = Duration::from_millis(200);
|
const LIVE_STATUS_PROBE_TIMEOUT: Duration = Duration::from_millis(200);
|
||||||
|
|
||||||
async fn probe_live_status(socket_path: &Path) -> Result<Option<PodStatus>, io::Error> {
|
async fn probe_live_status(socket_path: &Path) -> Result<Option<WorkerStatus>, io::Error> {
|
||||||
let mut client = PodClient::connect(socket_path).await?;
|
let mut client = WorkerClient::connect(socket_path).await?;
|
||||||
let deadline = tokio::time::Instant::now() + LIVE_STATUS_PROBE_TIMEOUT;
|
let deadline = tokio::time::Instant::now() + LIVE_STATUS_PROBE_TIMEOUT;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
|
@ -446,7 +447,7 @@ async fn probe_live_status(socket_path: &Path) -> Result<Option<PodStatus>, io::
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn status_from_event(event: &Event) -> Option<PodStatus> {
|
fn status_from_event(event: &Event) -> Option<WorkerStatus> {
|
||||||
match event {
|
match event {
|
||||||
Event::Snapshot { status, .. } | Event::Status { status } => Some(*status),
|
Event::Snapshot { status, .. } | Event::Status { status } => Some(*status),
|
||||||
_ => None,
|
_ => None,
|
||||||
|
|
@ -459,7 +460,7 @@ struct SegmentSummary {
|
||||||
preview: Option<String>,
|
preview: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn summarize_metadata(_store: &FsStore, active: Option<&PodActiveSegmentRef>) -> SegmentSummary {
|
fn summarize_metadata(_store: &FsStore, active: Option<&WorkerActiveSegmentRef>) -> SegmentSummary {
|
||||||
let Some(active) = active else {
|
let Some(active) = active else {
|
||||||
return SegmentSummary {
|
return SegmentSummary {
|
||||||
updated_at: 0,
|
updated_at: 0,
|
||||||
|
|
@ -478,33 +479,33 @@ fn summarize_metadata(_store: &FsStore, active: Option<&PodActiveSegmentRef>) ->
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_diagnostics(entry: &PodListEntry) -> Vec<PodEntryDiagnostic> {
|
fn build_diagnostics(entry: &WorkerListEntry) -> Vec<WorkerEntryDiagnostic> {
|
||||||
let mut diagnostics = Vec::new();
|
let mut diagnostics = Vec::new();
|
||||||
|
|
||||||
if let Some(stored) = entry.stored.as_ref() {
|
if let Some(stored) = entry.stored.as_ref() {
|
||||||
if let StoredMetadataState::Corrupt(message) = &stored.metadata_state {
|
if let StoredMetadataState::Corrupt(message) = &stored.metadata_state {
|
||||||
diagnostics.push(PodEntryDiagnostic {
|
diagnostics.push(WorkerEntryDiagnostic {
|
||||||
kind: PodEntryDiagnosticKind::StoredMetadataCorrupt,
|
kind: WorkerEntryDiagnosticKind::StoredMetadataCorrupt,
|
||||||
message: format!("metadata: {}", trim_one_line(message, 80)),
|
message: format!("metadata: {}", trim_one_line(message, 80)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if entry.live.is_some() {
|
} else if entry.live.is_some() {
|
||||||
diagnostics.push(PodEntryDiagnostic {
|
diagnostics.push(WorkerEntryDiagnostic {
|
||||||
kind: PodEntryDiagnosticKind::MissingStoredMetadata,
|
kind: WorkerEntryDiagnosticKind::MissingStoredMetadata,
|
||||||
message: "no stored pod metadata".to_string(),
|
message: "no stored worker metadata".to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(live) = entry.live.as_ref() {
|
if let Some(live) = entry.live.as_ref() {
|
||||||
if !live.reachable {
|
if !live.reachable {
|
||||||
diagnostics.push(PodEntryDiagnostic {
|
diagnostics.push(WorkerEntryDiagnostic {
|
||||||
kind: PodEntryDiagnosticKind::LiveUnreachable,
|
kind: WorkerEntryDiagnosticKind::LiveUnreachable,
|
||||||
message: format!("socket unreachable: {}", live.socket_path.display()),
|
message: format!("socket unreachable: {}", live.socket_path.display()),
|
||||||
});
|
});
|
||||||
} else if live.status.is_none() {
|
} else if live.status.is_none() {
|
||||||
diagnostics.push(PodEntryDiagnostic {
|
diagnostics.push(WorkerEntryDiagnostic {
|
||||||
kind: PodEntryDiagnosticKind::MissingLiveStatus,
|
kind: WorkerEntryDiagnosticKind::MissingLiveStatus,
|
||||||
message: "live pod status was not reported".to_string(),
|
message: "live worker status was not reported".to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -512,7 +513,7 @@ fn build_diagnostics(entry: &PodListEntry) -> Vec<PodEntryDiagnostic> {
|
||||||
diagnostics
|
diagnostics
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_actions(entry: &PodListEntry) -> PodEntryActions {
|
fn build_actions(entry: &WorkerListEntry) -> WorkerEntryActions {
|
||||||
let live_reachable = entry.live.as_ref().is_some_and(|live| live.reachable);
|
let live_reachable = entry.live.as_ref().is_some_and(|live| live.reachable);
|
||||||
let stored_restorable = entry
|
let stored_restorable = entry
|
||||||
.stored
|
.stored
|
||||||
|
|
@ -522,19 +523,19 @@ fn build_actions(entry: &PodListEntry) -> PodEntryActions {
|
||||||
|
|
||||||
let can_restore = stored_restorable && !live_reachable;
|
let can_restore = stored_restorable && !live_reachable;
|
||||||
let can_open = live_reachable || stored_restorable;
|
let can_open = live_reachable || stored_restorable;
|
||||||
let can_send_now = live_reachable && live_status == Some(PodStatus::Idle);
|
let can_send_now = live_reachable && live_status == Some(WorkerStatus::Idle);
|
||||||
let can_queue_send = live_reachable && live_status == Some(PodStatus::Running);
|
let can_queue_send = live_reachable && live_status == Some(WorkerStatus::Running);
|
||||||
let disabled_reason = if can_open {
|
let disabled_reason = if can_open {
|
||||||
None
|
None
|
||||||
} else if entry.live.is_some() {
|
} else if entry.live.is_some() {
|
||||||
Some("live pod is unreachable".to_string())
|
Some("live worker is unreachable".to_string())
|
||||||
} else if entry.stored.is_some() {
|
} else if entry.stored.is_some() {
|
||||||
Some("stored pod metadata is corrupt".to_string())
|
Some("stored worker metadata is corrupt".to_string())
|
||||||
} else {
|
} else {
|
||||||
Some("no live or stored pod state".to_string())
|
Some("no live or stored worker state".to_string())
|
||||||
};
|
};
|
||||||
|
|
||||||
PodEntryActions {
|
WorkerEntryActions {
|
||||||
can_open,
|
can_open,
|
||||||
can_restore,
|
can_restore,
|
||||||
can_send_now,
|
can_send_now,
|
||||||
|
|
@ -559,15 +560,15 @@ mod tests {
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use llm_engine::llm_client::types::RequestConfig;
|
use llm_engine::llm_client::types::RequestConfig;
|
||||||
use pod_store::FsPodStore;
|
use pod_store::FsWorkerStore;
|
||||||
use pod_store::{PodActiveSegmentRef, PodMetadataStore};
|
use pod_store::{WorkerActiveSegmentRef, WorkerMetadataStore};
|
||||||
use protocol::stream::JsonLineWriter;
|
use protocol::stream::JsonLineWriter;
|
||||||
use session_store::{LogEntry, Store, new_segment_id, new_session_id};
|
use session_store::{LogEntry, Store, new_segment_id, new_session_id};
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
use tokio::net::UnixListener;
|
use tokio::net::UnixListener;
|
||||||
use tokio::sync::Barrier;
|
use tokio::sync::Barrier;
|
||||||
|
|
||||||
const SOURCE: PodVisibilitySource = PodVisibilitySource::ResumePicker;
|
const SOURCE: WorkerVisibilitySource = WorkerVisibilitySource::ResumePicker;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stored_metadata_summary_uses_segment_marker_without_reading_session_log() {
|
fn stored_metadata_summary_uses_segment_marker_without_reading_session_log() {
|
||||||
|
|
@ -585,7 +586,7 @@ mod tests {
|
||||||
"session log text should not be scanned",
|
"session log text should not be scanned",
|
||||||
);
|
);
|
||||||
|
|
||||||
let entry = single_entry(PodList::from_sources(
|
let entry = single_entry(WorkerList::from_sources(
|
||||||
SOURCE,
|
SOURCE,
|
||||||
vec![metadata_info(&store, "stored", session, segment)],
|
vec![metadata_info(&store, "stored", session, segment)],
|
||||||
vec![],
|
vec![],
|
||||||
|
|
@ -606,9 +607,9 @@ mod tests {
|
||||||
let stopped = (0..10)
|
let stopped = (0..10)
|
||||||
.map(|index| stopped_info_with_updated_at(&format!("stopped-{index}"), 1_000 - index))
|
.map(|index| stopped_info_with_updated_at(&format!("stopped-{index}"), 1_000 - index))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let live = live_info_with_updated_at("live-pending", PodStatus::Idle, 0);
|
let live = live_info_with_updated_at("live-pending", WorkerStatus::Idle, 0);
|
||||||
|
|
||||||
let entries = PodList::from_sources(SOURCE, stopped, vec![live], None, 10).entries;
|
let entries = WorkerList::from_sources(SOURCE, stopped, vec![live], None, 10).entries;
|
||||||
|
|
||||||
assert_eq!(entries.len(), 10);
|
assert_eq!(entries.len(), 10);
|
||||||
assert_eq!(entries[0].name, "live-pending");
|
assert_eq!(entries[0].name, "live-pending");
|
||||||
|
|
@ -617,11 +618,11 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reachable_live_sort_does_not_promote_unreachable_registry_allocations() {
|
fn reachable_live_sort_does_not_promote_unreachable_registry_allocations() {
|
||||||
let mut unreachable = live_info_with_updated_at("unreachable", PodStatus::Idle, 0);
|
let mut unreachable = live_info_with_updated_at("unreachable", WorkerStatus::Idle, 0);
|
||||||
unreachable.reachable = false;
|
unreachable.reachable = false;
|
||||||
unreachable.status = None;
|
unreachable.status = None;
|
||||||
|
|
||||||
let entries = PodList::from_sources(
|
let entries = WorkerList::from_sources(
|
||||||
SOURCE,
|
SOURCE,
|
||||||
vec![stopped_info_with_updated_at("stopped", 100)],
|
vec![stopped_info_with_updated_at("stopped", 100)],
|
||||||
vec![unreachable],
|
vec![unreachable],
|
||||||
|
|
@ -638,12 +639,12 @@ mod tests {
|
||||||
fn live_pending_with_runtime_segment_is_attach_only_and_gets_pending_preview() {
|
fn live_pending_with_runtime_segment_is_attach_only_and_gets_pending_preview() {
|
||||||
let session_id = new_session_id();
|
let session_id = new_session_id();
|
||||||
let runtime_segment_id = new_segment_id();
|
let runtime_segment_id = new_segment_id();
|
||||||
let entry = single_entry(PodList::from_sources(
|
let entry = single_entry(WorkerList::from_sources(
|
||||||
SOURCE,
|
SOURCE,
|
||||||
vec![pending_metadata_info("pending", session_id)],
|
vec![pending_metadata_info("pending", session_id)],
|
||||||
vec![live_info_with_segment(
|
vec![live_info_with_segment(
|
||||||
"pending",
|
"pending",
|
||||||
PodStatus::Idle,
|
WorkerStatus::Idle,
|
||||||
runtime_segment_id,
|
runtime_segment_id,
|
||||||
)],
|
)],
|
||||||
None,
|
None,
|
||||||
|
|
@ -668,12 +669,12 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn live_only_runtime_segment_is_attach_only_and_not_restorable() {
|
fn live_only_runtime_segment_is_attach_only_and_not_restorable() {
|
||||||
let runtime_segment_id = new_segment_id();
|
let runtime_segment_id = new_segment_id();
|
||||||
let entry = single_entry(PodList::from_sources(
|
let entry = single_entry(WorkerList::from_sources(
|
||||||
SOURCE,
|
SOURCE,
|
||||||
vec![],
|
vec![],
|
||||||
vec![live_info_with_segment(
|
vec![live_info_with_segment(
|
||||||
"runtime-only",
|
"runtime-only",
|
||||||
PodStatus::Idle,
|
WorkerStatus::Idle,
|
||||||
runtime_segment_id,
|
runtime_segment_id,
|
||||||
)],
|
)],
|
||||||
None,
|
None,
|
||||||
|
|
@ -701,7 +702,7 @@ mod tests {
|
||||||
let segment_id = new_segment_id();
|
let segment_id = new_segment_id();
|
||||||
append_start(&store, session_id, segment_id, 10);
|
append_start(&store, session_id, segment_id, 10);
|
||||||
|
|
||||||
let entry = single_entry(PodList::from_sources(
|
let entry = single_entry(WorkerList::from_sources(
|
||||||
SOURCE,
|
SOURCE,
|
||||||
vec![metadata_info(&store, "stored", session_id, segment_id)],
|
vec![metadata_info(&store, "stored", session_id, segment_id)],
|
||||||
vec![],
|
vec![],
|
||||||
|
|
@ -711,7 +712,10 @@ mod tests {
|
||||||
|
|
||||||
assert_eq!(entry.name, "stored");
|
assert_eq!(entry.name, "stored");
|
||||||
assert_eq!(entry.visibility, SOURCE);
|
assert_eq!(entry.visibility, SOURCE);
|
||||||
assert_eq!(entry.source_kinds, vec![PodListSourceKind::StoredMetadata]);
|
assert_eq!(
|
||||||
|
entry.source_kinds,
|
||||||
|
vec![WorkerListSourceKind::StoredMetadata]
|
||||||
|
);
|
||||||
assert!(entry.live.is_none());
|
assert!(entry.live.is_none());
|
||||||
assert!(entry.stored.is_some());
|
assert!(entry.stored.is_some());
|
||||||
assert!(entry.actions.can_open);
|
assert!(entry.actions.can_open);
|
||||||
|
|
@ -722,17 +726,20 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn live_idle_reachable_row_can_open_and_send_now() {
|
fn live_idle_reachable_row_can_open_and_send_now() {
|
||||||
let entry = single_entry(PodList::from_sources(
|
let entry = single_entry(WorkerList::from_sources(
|
||||||
SOURCE,
|
SOURCE,
|
||||||
vec![],
|
vec![],
|
||||||
vec![live_info("live", PodStatus::Idle)],
|
vec![live_info("live", WorkerStatus::Idle)],
|
||||||
None,
|
None,
|
||||||
10,
|
10,
|
||||||
));
|
));
|
||||||
|
|
||||||
assert_eq!(entry.name, "live");
|
assert_eq!(entry.name, "live");
|
||||||
assert_eq!(entry.visibility, SOURCE);
|
assert_eq!(entry.visibility, SOURCE);
|
||||||
assert_eq!(entry.source_kinds, vec![PodListSourceKind::RuntimeRegistry]);
|
assert_eq!(
|
||||||
|
entry.source_kinds,
|
||||||
|
vec![WorkerListSourceKind::RuntimeRegistry]
|
||||||
|
);
|
||||||
assert!(entry.actions.can_open);
|
assert!(entry.actions.can_open);
|
||||||
assert!(!entry.actions.can_restore);
|
assert!(!entry.actions.can_restore);
|
||||||
assert!(entry.actions.can_send_now);
|
assert!(entry.actions.can_send_now);
|
||||||
|
|
@ -745,11 +752,17 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn live_reachable_row_without_reported_status_can_open_but_not_send_now() {
|
fn live_reachable_row_without_reported_status_can_open_but_not_send_now() {
|
||||||
let mut live = live_info("live", PodStatus::Idle);
|
let mut live = live_info("live", WorkerStatus::Idle);
|
||||||
live.status = None;
|
live.status = None;
|
||||||
live.reachable = true;
|
live.reachable = true;
|
||||||
|
|
||||||
let entry = single_entry(PodList::from_sources(SOURCE, vec![], vec![live], None, 10));
|
let entry = single_entry(WorkerList::from_sources(
|
||||||
|
SOURCE,
|
||||||
|
vec![],
|
||||||
|
vec![live],
|
||||||
|
None,
|
||||||
|
10,
|
||||||
|
));
|
||||||
|
|
||||||
assert!(entry.actions.can_open);
|
assert!(entry.actions.can_open);
|
||||||
assert!(!entry.actions.can_restore);
|
assert!(!entry.actions.can_restore);
|
||||||
|
|
@ -763,16 +776,16 @@ mod tests {
|
||||||
!entry
|
!entry
|
||||||
.diagnostics
|
.diagnostics
|
||||||
.iter()
|
.iter()
|
||||||
.any(|diagnostic| diagnostic.kind == PodEntryDiagnosticKind::LiveUnreachable)
|
.any(|diagnostic| diagnostic.kind == WorkerEntryDiagnosticKind::LiveUnreachable)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn live_running_reachable_row_can_open_but_not_send_now() {
|
fn live_running_reachable_row_can_open_but_not_send_now() {
|
||||||
let entry = single_entry(PodList::from_sources(
|
let entry = single_entry(WorkerList::from_sources(
|
||||||
SOURCE,
|
SOURCE,
|
||||||
vec![],
|
vec![],
|
||||||
vec![live_info("live", PodStatus::Running)],
|
vec![live_info("live", WorkerStatus::Running)],
|
||||||
None,
|
None,
|
||||||
10,
|
10,
|
||||||
));
|
));
|
||||||
|
|
@ -785,11 +798,17 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn live_unreachable_row_has_diagnostic_and_cannot_open() {
|
fn live_unreachable_row_has_diagnostic_and_cannot_open() {
|
||||||
let mut live = live_info("live", PodStatus::Idle);
|
let mut live = live_info("live", WorkerStatus::Idle);
|
||||||
live.reachable = false;
|
live.reachable = false;
|
||||||
live.status = None;
|
live.status = None;
|
||||||
|
|
||||||
let entry = single_entry(PodList::from_sources(SOURCE, vec![], vec![live], None, 10));
|
let entry = single_entry(WorkerList::from_sources(
|
||||||
|
SOURCE,
|
||||||
|
vec![],
|
||||||
|
vec![live],
|
||||||
|
None,
|
||||||
|
10,
|
||||||
|
));
|
||||||
|
|
||||||
assert!(!entry.actions.can_open);
|
assert!(!entry.actions.can_open);
|
||||||
assert!(!entry.actions.can_restore);
|
assert!(!entry.actions.can_restore);
|
||||||
|
|
@ -797,11 +816,11 @@ mod tests {
|
||||||
assert!(!entry.actions.can_queue_send);
|
assert!(!entry.actions.can_queue_send);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
entry.actions.disabled_reason.as_deref(),
|
entry.actions.disabled_reason.as_deref(),
|
||||||
Some("live pod is unreachable")
|
Some("live worker is unreachable")
|
||||||
);
|
);
|
||||||
assert_eq!(entry.attach_socket_path(), None);
|
assert_eq!(entry.attach_socket_path(), None);
|
||||||
assert!(entry.diagnostics.iter().any(|diagnostic| {
|
assert!(entry.diagnostics.iter().any(|diagnostic| {
|
||||||
diagnostic.kind == PodEntryDiagnosticKind::LiveUnreachable
|
diagnostic.kind == WorkerEntryDiagnosticKind::LiveUnreachable
|
||||||
&& diagnostic.message.contains("/tmp/live.sock")
|
&& diagnostic.message.contains("/tmp/live.sock")
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
@ -811,20 +830,20 @@ mod tests {
|
||||||
let events = [
|
let events = [
|
||||||
Event::Alert(protocol::Alert {
|
Event::Alert(protocol::Alert {
|
||||||
level: protocol::AlertLevel::Warn,
|
level: protocol::AlertLevel::Warn,
|
||||||
source: protocol::AlertSource::Pod,
|
source: protocol::AlertSource::Worker,
|
||||||
message: "warming up".to_string(),
|
message: "warming up".to_string(),
|
||||||
timestamp_ms: 0,
|
timestamp_ms: 0,
|
||||||
}),
|
}),
|
||||||
Event::Snapshot {
|
Event::Snapshot {
|
||||||
entries: vec![],
|
entries: vec![],
|
||||||
greeting: test_greeting(),
|
greeting: test_greeting(),
|
||||||
status: PodStatus::Idle,
|
status: WorkerStatus::Idle,
|
||||||
in_flight: Default::default(),
|
in_flight: Default::default(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
let status = events.iter().find_map(status_from_event);
|
let status = events.iter().find_map(status_from_event);
|
||||||
assert_eq!(status, Some(PodStatus::Idle));
|
assert_eq!(status, Some(WorkerStatus::Idle));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|
@ -838,8 +857,8 @@ mod tests {
|
||||||
let mut servers = Vec::new();
|
let mut servers = Vec::new();
|
||||||
|
|
||||||
for index in 0..probe_count {
|
for index in 0..probe_count {
|
||||||
let pod_name = format!("pod-{index}");
|
let worker_name = format!("worker-{index}");
|
||||||
let socket_path = socket_dir.path().join(format!("{pod_name}.sock"));
|
let socket_path = socket_dir.path().join(format!("{worker_name}.sock"));
|
||||||
let listener = UnixListener::bind(&socket_path).unwrap();
|
let listener = UnixListener::bind(&socket_path).unwrap();
|
||||||
let barrier = Arc::clone(&barrier);
|
let barrier = Arc::clone(&barrier);
|
||||||
servers.push(tokio::spawn(async move {
|
servers.push(tokio::spawn(async move {
|
||||||
|
|
@ -848,12 +867,12 @@ mod tests {
|
||||||
let mut writer = JsonLineWriter::new(stream);
|
let mut writer = JsonLineWriter::new(stream);
|
||||||
writer
|
writer
|
||||||
.write(&Event::Status {
|
.write(&Event::Status {
|
||||||
status: PodStatus::Idle,
|
status: WorkerStatus::Idle,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}));
|
}));
|
||||||
records.push(live_probe_record(&pod_name, socket_path));
|
records.push(live_probe_record(&worker_name, socket_path));
|
||||||
}
|
}
|
||||||
|
|
||||||
let records = tokio::time::timeout(
|
let records = tokio::time::timeout(
|
||||||
|
|
@ -869,7 +888,7 @@ mod tests {
|
||||||
assert!(
|
assert!(
|
||||||
records
|
records
|
||||||
.iter()
|
.iter()
|
||||||
.all(|record| record.status == Some(PodStatus::Idle))
|
.all(|record| record.status == Some(WorkerStatus::Idle))
|
||||||
);
|
);
|
||||||
for server in servers {
|
for server in servers {
|
||||||
server.await.unwrap();
|
server.await.unwrap();
|
||||||
|
|
@ -896,7 +915,7 @@ mod tests {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(records.len(), 1);
|
assert_eq!(records.len(), 1);
|
||||||
assert_eq!(records[0].pod_name, "silent");
|
assert_eq!(records[0].worker_name, "silent");
|
||||||
assert!(records[0].reachable);
|
assert!(records[0].reachable);
|
||||||
assert_eq!(records[0].status, None);
|
assert_eq!(records[0].status, None);
|
||||||
assert_eq!(records[0].socket_path, socket_path);
|
assert_eq!(records[0].socket_path, socket_path);
|
||||||
|
|
@ -905,7 +924,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn corrupt_stored_metadata_has_diagnostic() {
|
fn corrupt_stored_metadata_has_diagnostic() {
|
||||||
let entry = single_entry(PodList::from_sources(
|
let entry = single_entry(WorkerList::from_sources(
|
||||||
SOURCE,
|
SOURCE,
|
||||||
vec![corrupt_stored_info(
|
vec![corrupt_stored_info(
|
||||||
"broken".to_string(),
|
"broken".to_string(),
|
||||||
|
|
@ -919,7 +938,7 @@ mod tests {
|
||||||
assert_eq!(entry.name, "broken");
|
assert_eq!(entry.name, "broken");
|
||||||
assert!(!entry.actions.can_open);
|
assert!(!entry.actions.can_open);
|
||||||
assert!(entry.diagnostics.iter().any(|diagnostic| {
|
assert!(entry.diagnostics.iter().any(|diagnostic| {
|
||||||
diagnostic.kind == PodEntryDiagnosticKind::StoredMetadataCorrupt
|
diagnostic.kind == WorkerEntryDiagnosticKind::StoredMetadataCorrupt
|
||||||
&& diagnostic.message.contains("expected value")
|
&& diagnostic.message.contains("expected value")
|
||||||
}));
|
}));
|
||||||
assert!(
|
assert!(
|
||||||
|
|
@ -933,25 +952,25 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn selected_pod_name_is_kept_after_rebuild() {
|
fn selected_worker_name_is_kept_after_rebuild() {
|
||||||
let first = PodList::from_sources(
|
let first = WorkerList::from_sources(
|
||||||
SOURCE,
|
SOURCE,
|
||||||
vec![],
|
vec![],
|
||||||
vec![
|
vec![
|
||||||
live_info("alpha", PodStatus::Idle),
|
live_info("alpha", WorkerStatus::Idle),
|
||||||
live_info("beta", PodStatus::Idle),
|
live_info("beta", WorkerStatus::Idle),
|
||||||
],
|
],
|
||||||
Some("alpha".to_string()),
|
Some("alpha".to_string()),
|
||||||
10,
|
10,
|
||||||
);
|
);
|
||||||
assert_eq!(first.selected_entry().unwrap().name, "alpha");
|
assert_eq!(first.selected_entry().unwrap().name, "alpha");
|
||||||
|
|
||||||
let rebuilt = PodList::from_sources(
|
let rebuilt = WorkerList::from_sources(
|
||||||
SOURCE,
|
SOURCE,
|
||||||
vec![],
|
vec![],
|
||||||
vec![
|
vec![
|
||||||
live_info_with_updated_at("beta", PodStatus::Idle, 20),
|
live_info_with_updated_at("beta", WorkerStatus::Idle, 20),
|
||||||
live_info_with_updated_at("alpha", PodStatus::Idle, 10),
|
live_info_with_updated_at("alpha", WorkerStatus::Idle, 10),
|
||||||
],
|
],
|
||||||
first.selected_name.clone(),
|
first.selected_name.clone(),
|
||||||
10,
|
10,
|
||||||
|
|
@ -963,17 +982,17 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn read_stored_pod_infos_reports_corrupt_metadata() {
|
fn read_stored_worker_infos_reports_corrupt_metadata() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let store = FsStore::new(dir.path()).unwrap();
|
let store = FsStore::new(dir.path()).unwrap();
|
||||||
let pod_store = FsPodStore::new(dir.path().join("pods")).unwrap();
|
let pod_store = FsWorkerStore::new(dir.path().join("pods")).unwrap();
|
||||||
let pod_dir = dir.path().join("pods").join("broken");
|
let pod_dir = dir.path().join("pods").join("broken");
|
||||||
std::fs::create_dir_all(&pod_dir).unwrap();
|
std::fs::create_dir_all(&pod_dir).unwrap();
|
||||||
std::fs::write(pod_dir.join("metadata.json"), "{not-json").unwrap();
|
std::fs::write(pod_dir.join("metadata.json"), "{not-json").unwrap();
|
||||||
|
|
||||||
let records = read_stored_pod_infos(&store, &pod_store).unwrap();
|
let records = read_stored_worker_infos(&store, &pod_store).unwrap();
|
||||||
assert_eq!(records.len(), 1);
|
assert_eq!(records.len(), 1);
|
||||||
assert_eq!(records[0].pod_name, "broken");
|
assert_eq!(records[0].worker_name, "broken");
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
records[0].metadata_state,
|
records[0].metadata_state,
|
||||||
StoredMetadataState::Corrupt(_)
|
StoredMetadataState::Corrupt(_)
|
||||||
|
|
@ -981,49 +1000,53 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn read_stored_pod_infos_reads_metadata() {
|
fn read_stored_worker_infos_reads_metadata() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let store = FsStore::new(dir.path()).unwrap();
|
let store = FsStore::new(dir.path()).unwrap();
|
||||||
let pod_store = FsPodStore::new(dir.path().join("pods")).unwrap();
|
let pod_store = FsWorkerStore::new(dir.path().join("pods")).unwrap();
|
||||||
let session_id = new_session_id();
|
let session_id = new_session_id();
|
||||||
let segment_id = new_segment_id();
|
let segment_id = new_segment_id();
|
||||||
pod_store
|
pod_store
|
||||||
.write(&PodMetadata::new(
|
.write(&WorkerMetadata::new(
|
||||||
"agent",
|
"agent",
|
||||||
Some(PodActiveSegmentRef::active_segment(session_id, segment_id)),
|
Some(WorkerActiveSegmentRef::active_segment(
|
||||||
|
session_id, segment_id,
|
||||||
|
)),
|
||||||
))
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let records = read_stored_pod_infos(&store, &pod_store).unwrap();
|
let records = read_stored_worker_infos(&store, &pod_store).unwrap();
|
||||||
assert_eq!(records.len(), 1);
|
assert_eq!(records.len(), 1);
|
||||||
assert_eq!(records[0].pod_name, "agent");
|
assert_eq!(records[0].worker_name, "agent");
|
||||||
assert_eq!(records[0].metadata_state, StoredMetadataState::Present);
|
assert_eq!(records[0].metadata_state, StoredMetadataState::Present);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn single_entry(list: PodList) -> PodListEntry {
|
fn single_entry(list: WorkerList) -> WorkerListEntry {
|
||||||
assert_eq!(list.entries.len(), 1);
|
assert_eq!(list.entries.len(), 1);
|
||||||
list.entries.into_iter().next().unwrap()
|
list.entries.into_iter().next().unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn metadata_info(
|
fn metadata_info(
|
||||||
store: &FsStore,
|
store: &FsStore,
|
||||||
pod_name: &str,
|
worker_name: &str,
|
||||||
session_id: SessionId,
|
session_id: SessionId,
|
||||||
segment_id: SegmentId,
|
segment_id: SegmentId,
|
||||||
) -> StoredPodInfo {
|
) -> StoredWorkerInfo {
|
||||||
stored_info_from_metadata(
|
stored_info_from_metadata(
|
||||||
store,
|
store,
|
||||||
pod_name.to_string(),
|
worker_name.to_string(),
|
||||||
PodMetadata::new(
|
WorkerMetadata::new(
|
||||||
pod_name,
|
worker_name,
|
||||||
Some(PodActiveSegmentRef::active_segment(session_id, segment_id)),
|
Some(WorkerActiveSegmentRef::active_segment(
|
||||||
|
session_id, segment_id,
|
||||||
|
)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pending_metadata_info(pod_name: &str, session_id: SessionId) -> StoredPodInfo {
|
fn pending_metadata_info(worker_name: &str, session_id: SessionId) -> StoredWorkerInfo {
|
||||||
StoredPodInfo {
|
StoredWorkerInfo {
|
||||||
pod_name: pod_name.to_string(),
|
worker_name: worker_name.to_string(),
|
||||||
metadata_state: StoredMetadataState::Present,
|
metadata_state: StoredMetadataState::Present,
|
||||||
active_session_id: Some(session_id),
|
active_session_id: Some(session_id),
|
||||||
active_segment_id: None,
|
active_segment_id: None,
|
||||||
|
|
@ -1033,9 +1056,9 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stopped_info_with_updated_at(pod_name: &str, updated_at: u64) -> StoredPodInfo {
|
fn stopped_info_with_updated_at(worker_name: &str, updated_at: u64) -> StoredWorkerInfo {
|
||||||
StoredPodInfo {
|
StoredWorkerInfo {
|
||||||
pod_name: pod_name.to_string(),
|
worker_name: worker_name.to_string(),
|
||||||
metadata_state: StoredMetadataState::Present,
|
metadata_state: StoredMetadataState::Present,
|
||||||
active_session_id: None,
|
active_session_id: None,
|
||||||
active_segment_id: None,
|
active_segment_id: None,
|
||||||
|
|
@ -1045,32 +1068,32 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn live_info(pod_name: &str, status: PodStatus) -> LivePodInfo {
|
fn live_info(worker_name: &str, status: WorkerStatus) -> LiveWorkerInfo {
|
||||||
live_info_with_updated_at(pod_name, status, 0)
|
live_info_with_updated_at(worker_name, status, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn live_info_with_segment(
|
fn live_info_with_segment(
|
||||||
pod_name: &str,
|
worker_name: &str,
|
||||||
status: PodStatus,
|
status: WorkerStatus,
|
||||||
segment_id: SegmentId,
|
segment_id: SegmentId,
|
||||||
) -> LivePodInfo {
|
) -> LiveWorkerInfo {
|
||||||
let mut info = live_info(pod_name, status);
|
let mut info = live_info(worker_name, status);
|
||||||
info.segment_id = Some(segment_id);
|
info.segment_id = Some(segment_id);
|
||||||
info
|
info
|
||||||
}
|
}
|
||||||
|
|
||||||
fn live_info_with_updated_at(
|
fn live_info_with_updated_at(
|
||||||
pod_name: &str,
|
worker_name: &str,
|
||||||
status: PodStatus,
|
status: WorkerStatus,
|
||||||
updated_at: u64,
|
updated_at: u64,
|
||||||
) -> LivePodInfo {
|
) -> LiveWorkerInfo {
|
||||||
LivePodInfo {
|
LiveWorkerInfo {
|
||||||
pod_name: pod_name.to_string(),
|
worker_name: worker_name.to_string(),
|
||||||
socket_path: PathBuf::from(format!("/tmp/{pod_name}.sock")),
|
socket_path: PathBuf::from(format!("/tmp/{worker_name}.sock")),
|
||||||
status: Some(status),
|
status: Some(status),
|
||||||
reachable: true,
|
reachable: true,
|
||||||
segment_id: None,
|
segment_id: None,
|
||||||
summary: PodEntrySummary {
|
summary: WorkerEntrySummary {
|
||||||
active_session_id: None,
|
active_session_id: None,
|
||||||
active_segment_id: None,
|
active_segment_id: None,
|
||||||
updated_at,
|
updated_at,
|
||||||
|
|
@ -1079,20 +1102,20 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn live_probe_record(pod_name: &str, socket_path: PathBuf) -> LivePodInfo {
|
fn live_probe_record(worker_name: &str, socket_path: PathBuf) -> LiveWorkerInfo {
|
||||||
LivePodInfo {
|
LiveWorkerInfo {
|
||||||
pod_name: pod_name.to_string(),
|
worker_name: worker_name.to_string(),
|
||||||
socket_path,
|
socket_path,
|
||||||
status: None,
|
status: None,
|
||||||
reachable: false,
|
reachable: false,
|
||||||
segment_id: None,
|
segment_id: None,
|
||||||
summary: PodEntrySummary::default(),
|
summary: WorkerEntrySummary::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn test_greeting() -> protocol::Greeting {
|
fn test_greeting() -> protocol::Greeting {
|
||||||
protocol::Greeting {
|
protocol::Greeting {
|
||||||
pod_name: "live".to_string(),
|
worker_name: "live".to_string(),
|
||||||
cwd: "/tmp".to_string(),
|
cwd: "/tmp".to_string(),
|
||||||
provider: "test".to_string(),
|
provider: "test".to_string(),
|
||||||
model: "test".to_string(),
|
model: "test".to_string(),
|
||||||
|
|
@ -1140,8 +1163,8 @@ mod tests {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stopped_info_for_workspace(pod_name: &str, workspace_root: &Path) -> StoredPodInfo {
|
fn stopped_info_for_workspace(worker_name: &str, workspace_root: &Path) -> StoredWorkerInfo {
|
||||||
let mut info = stopped_info_with_updated_at(pod_name, 10);
|
let mut info = stopped_info_with_updated_at(worker_name, 10);
|
||||||
info.workspace_root = Some(workspace_root.to_path_buf());
|
info.workspace_root = Some(workspace_root.to_path_buf());
|
||||||
info
|
info
|
||||||
}
|
}
|
||||||
|
|
@ -1151,7 +1174,7 @@ mod tests {
|
||||||
let current = tempdir().unwrap();
|
let current = tempdir().unwrap();
|
||||||
let external = tempdir().unwrap();
|
let external = tempdir().unwrap();
|
||||||
|
|
||||||
let list = PodList::from_workspace_sources(
|
let list = WorkerList::from_workspace_sources(
|
||||||
SOURCE,
|
SOURCE,
|
||||||
vec![
|
vec![
|
||||||
stopped_info_for_workspace("current", current.path()),
|
stopped_info_for_workspace("current", current.path()),
|
||||||
|
|
@ -1161,11 +1184,11 @@ mod tests {
|
||||||
corrupt_stored_info("corrupt".to_string(), "invalid metadata".to_string()),
|
corrupt_stored_info("corrupt".to_string(), "invalid metadata".to_string()),
|
||||||
],
|
],
|
||||||
vec![
|
vec![
|
||||||
live_info("current", PodStatus::Idle),
|
live_info("current", WorkerStatus::Idle),
|
||||||
live_info("current-orchestrator", PodStatus::Running),
|
live_info("current-orchestrator", WorkerStatus::Running),
|
||||||
live_info("other-workspace", PodStatus::Idle),
|
live_info("other-workspace", WorkerStatus::Idle),
|
||||||
live_info("legacy-unknown", PodStatus::Idle),
|
live_info("legacy-unknown", WorkerStatus::Idle),
|
||||||
live_info("live-only", PodStatus::Idle),
|
live_info("live-only", WorkerStatus::Idle),
|
||||||
],
|
],
|
||||||
None,
|
None,
|
||||||
10,
|
10,
|
||||||
|
|
@ -1186,20 +1209,20 @@ mod tests {
|
||||||
let current = tempdir().unwrap();
|
let current = tempdir().unwrap();
|
||||||
let worktree_cwd = current.path().join(".worktree/impl");
|
let worktree_cwd = current.path().join(".worktree/impl");
|
||||||
|
|
||||||
let list = PodList::from_workspace_sources(
|
let list = WorkerList::from_workspace_sources(
|
||||||
SOURCE,
|
SOURCE,
|
||||||
vec![stopped_info_for_workspace("ticket-role", current.path())],
|
vec![stopped_info_for_workspace("ticket-role", current.path())],
|
||||||
vec![live_info("ticket-role", PodStatus::Idle)],
|
vec![live_info("ticket-role", WorkerStatus::Idle)],
|
||||||
None,
|
None,
|
||||||
10,
|
10,
|
||||||
&worktree_cwd,
|
&worktree_cwd,
|
||||||
);
|
);
|
||||||
assert!(list.entries.is_empty());
|
assert!(list.entries.is_empty());
|
||||||
|
|
||||||
let list = PodList::from_workspace_sources(
|
let list = WorkerList::from_workspace_sources(
|
||||||
SOURCE,
|
SOURCE,
|
||||||
vec![stopped_info_for_workspace("ticket-role", current.path())],
|
vec![stopped_info_for_workspace("ticket-role", current.path())],
|
||||||
vec![live_info("ticket-role", PodStatus::Idle)],
|
vec![live_info("ticket-role", WorkerStatus::Idle)],
|
||||||
None,
|
None,
|
||||||
10,
|
10,
|
||||||
current.path(),
|
current.path(),
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,5 +1,5 @@
|
||||||
[package]
|
[package]
|
||||||
name = "pod"
|
name = "worker"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
32
crates/worker/README.md
Normal file
32
crates/worker/README.md
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
# worker
|
||||||
|
|
||||||
|
## Role
|
||||||
|
|
||||||
|
`worker` turns an `llm-engine` Engine into a named runtime entity with manifest configuration, scoped tools, session persistence, protocol handling, and Worker metadata integration.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
Owns:
|
||||||
|
|
||||||
|
- Worker lifecycle and socket protocol serving
|
||||||
|
- Engine construction around a resolved Manifest
|
||||||
|
- session-store and pod-store coordination
|
||||||
|
- built-in tool registration under scope/policy
|
||||||
|
- spawned-child orchestration hooks
|
||||||
|
|
||||||
|
Does not own:
|
||||||
|
|
||||||
|
- provider-specific wire formats (`provider` / `llm-engine` clients)
|
||||||
|
- product CLI parsing (`yoi`)
|
||||||
|
- TUI display authority (`tui`)
|
||||||
|
- current-state storage schema outside Worker metadata (`pod-store`)
|
||||||
|
|
||||||
|
## Design notes
|
||||||
|
|
||||||
|
A Worker is runtime authority, not UI state. It should commit model-visible events through history/session paths and keep current Worker-name state in Worker metadata rather than in transient runtime files.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [`../../docs/design/worker-session-state.md`](../../docs/design/worker-session-state.md)
|
||||||
|
- [`../../docs/design/context-history.md`](../../docs/design/context-history.md)
|
||||||
|
- [`../../docs/design/tool-permissions-scope.md`](../../docs/design/tool-permissions-scope.md)
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
//! Emits `$OUT_DIR/internal_keys.rs` containing the sorted list of keys
|
//! Emits `$OUT_DIR/internal_keys.rs` containing the sorted list of keys
|
||||||
//! present in `resources/prompts/internal.toml`. The generated slice is
|
//! present in `resources/prompts/internal.toml`. The generated slice is
|
||||||
//! included into `src/prompts.rs` where a `const _` assertion compares
|
//! included into `src/prompts.rs` where a `const _` assertion compares
|
||||||
//! it bidirectionally against the `PodPrompt` enum's own key list, so
|
//! it bidirectionally against the `WorkerPrompt` enum's own key list, so
|
||||||
//! that a mismatch fails the build (see ticket: pod-prompt-catalog).
|
//! that a mismatch fails the build (see ticket: worker-prompt-catalog).
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
//! Minimal example: Pod running a single prompt with persistence.
|
//! Minimal example: Worker running a single prompt with persistence.
|
||||||
//!
|
//!
|
||||||
//! Demonstrates the core yoi abstraction — a TOML manifest drives
|
//! Demonstrates the core yoi abstraction — a TOML manifest drives
|
||||||
//! provider selection, model config, and system prompt, while FsStore
|
//! provider selection, model config, and system prompt, while FsStore
|
||||||
|
|
@ -8,26 +8,26 @@
|
||||||
//!
|
//!
|
||||||
//! ```bash
|
//! ```bash
|
||||||
//! echo "ANTHROPIC_API_KEY=your-key" > .env
|
//! echo "ANTHROPIC_API_KEY=your-key" > .env
|
||||||
//! cargo run -p pod --example pod_cli
|
//! cargo run -p worker --example worker_cli
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use pod::{Pod, PodManifest, PodRunResult};
|
use pod_store::{CombinedStore, FsWorkerStore};
|
||||||
use pod_store::{CombinedStore, FsPodStore};
|
|
||||||
use session_store::FsStore;
|
use session_store::FsStore;
|
||||||
|
use worker::{Worker, WorkerManifest, WorkerRunResult};
|
||||||
|
|
||||||
fn manifest_toml(pwd: &std::path::Path) -> String {
|
fn manifest_toml(pwd: &std::path::Path) -> String {
|
||||||
let pwd = pwd.display();
|
let pwd = pwd.display();
|
||||||
format!(
|
format!(
|
||||||
r#"
|
r#"
|
||||||
[pod]
|
[worker]
|
||||||
name = "hello-pod"
|
name = "hello-worker"
|
||||||
pwd = "{pwd}"
|
pwd = "{pwd}"
|
||||||
|
|
||||||
[model]
|
[model]
|
||||||
scheme = "anthropic"
|
scheme = "anthropic"
|
||||||
model_id = "claude-sonnet-4-20250514"
|
model_id = "claude-sonnet-4-20250514"
|
||||||
|
|
||||||
[worker]
|
[engine]
|
||||||
system_prompt = "You are a concise assistant. Reply in one or two sentences."
|
system_prompt = "You are a concise assistant. Reply in one or two sentences."
|
||||||
max_tokens = 256
|
max_tokens = 256
|
||||||
|
|
||||||
|
|
@ -43,7 +43,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
dotenv::dotenv().ok();
|
dotenv::dotenv().ok();
|
||||||
|
|
||||||
// 1. Build a manifest rooted at the current working directory.
|
// 1. Build a manifest rooted at the current working directory.
|
||||||
// All paths in a manifest must be absolute — see the pod-factory ticket.
|
// All paths in a manifest must be absolute — see the worker-factory ticket.
|
||||||
let pwd = std::env::current_dir()?;
|
let pwd = std::env::current_dir()?;
|
||||||
let toml = manifest_toml(&pwd);
|
let toml = manifest_toml(&pwd);
|
||||||
|
|
||||||
|
|
@ -51,26 +51,26 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let tmp = tempfile::tempdir()?;
|
let tmp = tempfile::tempdir()?;
|
||||||
let store = CombinedStore::new(
|
let store = CombinedStore::new(
|
||||||
FsStore::new(tmp.path().join("sessions"))?,
|
FsStore::new(tmp.path().join("sessions"))?,
|
||||||
FsPodStore::new(tmp.path().join("pods"))?,
|
FsWorkerStore::new(tmp.path().join("pods"))?,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 3. Build the Pod from the single-layer manifest TOML
|
// 3. Build the Worker from the single-layer manifest TOML
|
||||||
let mut pod = Pod::from_manifest_toml(&toml, store).await?;
|
let mut worker = Worker::from_manifest_toml(&toml, store).await?;
|
||||||
let manifest: &PodManifest = pod.manifest();
|
let manifest: &WorkerManifest = worker.manifest();
|
||||||
println!("Pod: {}", manifest.pod.name);
|
println!("Worker: {}", manifest.worker.name);
|
||||||
println!("Segment: {}", pod.segment_id());
|
println!("Segment: {}", worker.segment_id());
|
||||||
|
|
||||||
// 4. Run a prompt
|
// 4. Run a prompt
|
||||||
let result = pod.run_text("What is the capital of France?").await?;
|
let result = worker.run_text("What is the capital of France?").await?;
|
||||||
match result {
|
match result {
|
||||||
PodRunResult::Finished => println!("(finished)"),
|
WorkerRunResult::Finished => println!("(finished)"),
|
||||||
PodRunResult::Paused => println!("(paused)"),
|
WorkerRunResult::Paused => println!("(paused)"),
|
||||||
PodRunResult::LimitReached => println!("(turn limit reached)"),
|
WorkerRunResult::LimitReached => println!("(turn limit reached)"),
|
||||||
PodRunResult::RolledBack => println!("(empty turn rolled back)"),
|
WorkerRunResult::RolledBack => println!("(empty turn rolled back)"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Extract the assistant's reply from history
|
// 5. Extract the assistant's reply from history
|
||||||
let history = pod.engine().history();
|
let history = worker.engine().history();
|
||||||
if let Some(text) = history
|
if let Some(text) = history
|
||||||
.iter()
|
.iter()
|
||||||
.rev()
|
.rev()
|
||||||
|
|
@ -81,7 +81,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Session ID for potential restore
|
// 6. Session ID for potential restore
|
||||||
println!("\nSegment ID: {}", pod.segment_id());
|
println!("\nSegment ID: {}", worker.segment_id());
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
//! Pod Protocol example: control a Pod via PodHandle and stream events.
|
//! Worker Protocol example: control a Worker via WorkerHandle and stream events.
|
||||||
//!
|
//!
|
||||||
//! ```bash
|
//! ```bash
|
||||||
//! echo "ANTHROPIC_API_KEY=your-key" > .env
|
//! echo "ANTHROPIC_API_KEY=your-key" > .env
|
||||||
//! cargo run -p pod --example pod_protocol
|
//! cargo run -p worker --example worker_protocol
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use pod::{Event, Method, PodController};
|
use pod_store::{CombinedStore, FsWorkerStore};
|
||||||
use pod_store::{CombinedStore, FsPodStore};
|
|
||||||
use session_store::FsStore;
|
use session_store::FsStore;
|
||||||
|
use worker::{Event, Method, WorkerController};
|
||||||
|
|
||||||
fn manifest_toml(pwd: &std::path::Path) -> String {
|
fn manifest_toml(pwd: &std::path::Path) -> String {
|
||||||
let pwd = pwd.display();
|
let pwd = pwd.display();
|
||||||
format!(
|
format!(
|
||||||
r#"
|
r#"
|
||||||
[pod]
|
[worker]
|
||||||
name = "protocol-demo"
|
name = "protocol-demo"
|
||||||
pwd = "{pwd}"
|
pwd = "{pwd}"
|
||||||
|
|
||||||
|
|
@ -21,7 +21,7 @@ pwd = "{pwd}"
|
||||||
scheme = "anthropic"
|
scheme = "anthropic"
|
||||||
model_id = "claude-sonnet-4-20250514"
|
model_id = "claude-sonnet-4-20250514"
|
||||||
|
|
||||||
[worker]
|
[engine]
|
||||||
system_prompt = "You are a concise assistant. Reply in one or two sentences."
|
system_prompt = "You are a concise assistant. Reply in one or two sentences."
|
||||||
max_tokens = 256
|
max_tokens = 256
|
||||||
|
|
||||||
|
|
@ -36,18 +36,18 @@ permission = "write"
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
dotenv::dotenv().ok();
|
dotenv::dotenv().ok();
|
||||||
|
|
||||||
// All manifest paths must be absolute — see the pod-factory ticket.
|
// All manifest paths must be absolute — see the worker-factory ticket.
|
||||||
let pwd = std::env::current_dir()?;
|
let pwd = std::env::current_dir()?;
|
||||||
let toml = manifest_toml(&pwd);
|
let toml = manifest_toml(&pwd);
|
||||||
let tmp = tempfile::tempdir()?;
|
let tmp = tempfile::tempdir()?;
|
||||||
let store = CombinedStore::new(
|
let store = CombinedStore::new(
|
||||||
FsStore::new(tmp.path().join("sessions"))?,
|
FsStore::new(tmp.path().join("sessions"))?,
|
||||||
FsPodStore::new(tmp.path().join("pods"))?,
|
FsWorkerStore::new(tmp.path().join("pods"))?,
|
||||||
);
|
);
|
||||||
let pod = pod::Pod::from_manifest_toml(&toml, store).await?;
|
let worker = worker::Worker::from_manifest_toml(&toml, store).await?;
|
||||||
|
|
||||||
let runtime_tmp = tempfile::tempdir()?;
|
let runtime_tmp = tempfile::tempdir()?;
|
||||||
let (handle, _shutdown_rx) = PodController::spawn(pod, runtime_tmp.path()).await?;
|
let (handle, _shutdown_rx) = WorkerController::spawn(worker, runtime_tmp.path()).await?;
|
||||||
|
|
||||||
// Check initial status via shared state
|
// Check initial status via shared state
|
||||||
println!("[shared_state] {}", handle.shared_state.status_json());
|
println!("[shared_state] {}", handle.shared_state.status_json());
|
||||||
|
|
@ -16,7 +16,7 @@ use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use session_store::{LogEntry, SystemItem, segment_log};
|
use session_store::{LogEntry, SystemItem, segment_log};
|
||||||
|
|
||||||
pub const DOMAIN: &str = "pod.active_workflows";
|
pub const DOMAIN: &str = "worker.active_workflows";
|
||||||
pub const REHYDRATION_MESSAGE_PREFIX: &str = "[Active workflow snapshot]";
|
pub const REHYDRATION_MESSAGE_PREFIX: &str = "[Active workflow snapshot]";
|
||||||
pub const INACTIVE_MESSAGE_PREFIX: &str = "[Active workflow state]";
|
pub const INACTIVE_MESSAGE_PREFIX: &str = "[Active workflow state]";
|
||||||
const SCHEMA_VERSION: u32 = 1;
|
const SCHEMA_VERSION: u32 = 1;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user