From 54cc87132e60786f695dd823f815d764d6940de6 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 18 Jun 2026 21:06:11 +0900 Subject: [PATCH 1/9] ticket: resume queued implementation pods --- .yoi/tickets/00001KV5W3PHW/item.md | 2 +- .yoi/tickets/00001KV5W3PHW/thread.md | 19 +++++++++++++++++++ .yoi/tickets/00001KV62PF32/item.md | 2 +- .yoi/tickets/00001KV62PF32/thread.md | 19 +++++++++++++++++++ 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KV5W3PHW/item.md b/.yoi/tickets/00001KV5W3PHW/item.md index 38c96a22..408234ef 100644 --- a/.yoi/tickets/00001KV5W3PHW/item.md +++ b/.yoi/tickets/00001KV5W3PHW/item.md @@ -2,7 +2,7 @@ title: 'Plugin: execute Plugin Tool with minimal WASM runtime' state: 'inprogress' created_at: '2026-06-15T14:48:59Z' -updated_at: '2026-06-17T09:50:53Z' +updated_at: '2026-06-18T12:05:59Z' assignee: null readiness: 'implementation_ready' risk_flags: ['plugin', 'wasm', 'tool-runtime', 'sandbox', 'capability-boundary', 'cancellation'] diff --git a/.yoi/tickets/00001KV5W3PHW/thread.md b/.yoi/tickets/00001KV5W3PHW/thread.md index e7797a83..92145f86 100644 --- a/.yoi/tickets/00001KV5W3PHW/thread.md +++ b/.yoi/tickets/00001KV5W3PHW/thread.md @@ -128,3 +128,22 @@ State handling: - Ticket remains `inprogress` because acceptance and worktree side effects already happened; it is not silently returned to `queued`. --- + + + +## Implementation report + +Implementation resumed: + +Previous `SpawnPod` failure was recovered after Orchestrator runtime refresh. Reused existing clean child worktree and successfully spawned Coder Pod. + +Worktree: +- `/home/hare/Projects/yoi/.worktree/00001KV5W3PHW-plugin-wasm-tool-runtime` +- branch: `impl/00001KV5W3PHW-plugin-wasm-tool-runtime` + +Coder Pod: +- `yoi-coder-00001KV5W3PHW` + +No duplicate `queued -> inprogress` transition was performed; this resumes the already accepted in-progress work. + +--- diff --git a/.yoi/tickets/00001KV62PF32/item.md b/.yoi/tickets/00001KV62PF32/item.md index 309135c6..478eb749 100644 --- a/.yoi/tickets/00001KV62PF32/item.md +++ b/.yoi/tickets/00001KV62PF32/item.md @@ -2,7 +2,7 @@ title: 'Panel startup latency E2E を一覧データ描画完了基準に修正する' state: 'inprogress' created_at: '2026-06-15T16:44:06Z' -updated_at: '2026-06-17T09:50:53Z' +updated_at: '2026-06-18T12:05:59Z' assignee: null readiness: 'implementation_ready' risk_flags: ['panel', 'e2e', 'startup-latency', 'readiness-metric', 'ticket-list-rendering'] diff --git a/.yoi/tickets/00001KV62PF32/thread.md b/.yoi/tickets/00001KV62PF32/thread.md index b6e8ffcc..cc9396ed 100644 --- a/.yoi/tickets/00001KV62PF32/thread.md +++ b/.yoi/tickets/00001KV62PF32/thread.md @@ -124,3 +124,22 @@ State handling: - Ticket remains `inprogress` because acceptance and worktree side effects already happened; it is not silently returned to `queued`. --- + + + +## Implementation report + +Implementation resumed: + +Previous `SpawnPod` failure was recovered after Orchestrator runtime refresh. Reused existing clean child worktree and successfully spawned Coder Pod. + +Worktree: +- `/home/hare/Projects/yoi/.worktree/00001KV62PF32-panel-rows-ready-e2e` +- branch: `impl/00001KV62PF32-panel-rows-ready-e2e` + +Coder Pod: +- `yoi-coder-00001KV62PF32` + +No duplicate `queued -> inprogress` transition was performed; this resumes the already accepted in-progress work. + +--- From fffdfd2721fed5171d4dd9780f893b9bb323ab8a Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 18 Jun 2026 21:17:51 +0900 Subject: [PATCH 2/9] test: assert panel rows-ready fixture data --- crates/tui/src/multi_pod.rs | 4 + tests/e2e/src/lib.rs | 166 +++++++++++++++++++++++++++++++++++- tests/e2e/tests/panel.rs | 101 +++++++++++++++------- 3 files changed, 236 insertions(+), 35 deletions(-) diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index 0d0926ef..259d09ab 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -151,9 +151,13 @@ pub(crate) async fn run( #[cfg(feature = "e2e-test")] { if !emitted_panel_ready { + // `panel_ready` is a first-visible-frame signal only. E2E tests that need + // list/data readiness must wait for a concrete `rows_rendered` fixture row. crate::e2e_observer::emit("panel", "panel_ready", serde_json::json!({})); emitted_panel_ready = true; } + // Emit every drawn row snapshot separately so tests can assert data-backed row + // readiness without conflating it with the first frame. app.emit_rows_rendered(); } diff --git a/tests/e2e/src/lib.rs b/tests/e2e/src/lib.rs index 9d469df9..e551e9f1 100644 --- a/tests/e2e/src/lib.rs +++ b/tests/e2e/src/lib.rs @@ -267,12 +267,78 @@ pub struct RenderedPanelRow { pub rect: PanelRect, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExpectedPanelTicketRow { + pub id: String, + pub title: String, + pub status: String, +} + +impl ExpectedPanelTicketRow { + pub fn new(id: impl Into, title: impl Into, status: impl Into) -> Self { + Self { + id: id.into(), + title: title.into(), + status: status.into(), + } + } + + pub fn matches(&self, row: &RenderedPanelRow) -> bool { + row.key.kind == "ticket" + && row.key.id == self.id + && row.title == self.title + && row.status.as_deref() == Some(self.status.as_str()) + } + + fn description(&self) -> String { + format!( + "ticket row id={} title={:?} status={}", + self.id, self.title, self.status + ) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RowsRendered { pub selected: Option, pub rows: Vec, } +impl RowsRendered { + pub fn fixture_ticket_row( + &self, + expected: &ExpectedPanelTicketRow, + ) -> Option<&RenderedPanelRow> { + self.rows.iter().find(|row| expected.matches(row)) + } + + pub fn has_fixture_ticket_row(&self, expected: &ExpectedPanelTicketRow) -> bool { + self.fixture_ticket_row(expected).is_some() + } +} + +fn rows_rendered_event_has_fixture_ticket( + event: &HarnessEvent, + expected: &ExpectedPanelTicketRow, +) -> bool { + serde_json::from_value::(event.data.clone()) + .map(|rows| rows.has_fixture_ticket_row(expected)) + .unwrap_or(false) +} + +fn describe_rows(rows: &RowsRendered) -> String { + rows.rows + .iter() + .map(|row| { + format!( + "{}:{} title={:?} status={:?}", + row.key.kind, row.key.id, row.title, row.status + ) + }) + .collect::>() + .join(", ") +} + #[derive(Debug, Clone)] pub enum KeyPress { CtrlC, @@ -445,6 +511,69 @@ impl PanelHarness { } } + /// Waits for the legacy `panel_ready` observer event, which means the first + /// panel frame was painted. It intentionally does not mean workspace rows or + /// Ticket data are ready. + pub fn wait_for_first_visible_frame(&mut self, timeout: Duration) -> Result { + self.wait_for( + "first visible panel frame (panel_ready, before rows readiness)", + timeout, + |event| event.event == "panel_ready", + ) + } + + /// Waits until a concrete fixture Ticket row has rendered. This is the + /// startup rows-ready signal; it validates id + title + state rather than + /// using only the number of rendered rows. + pub fn wait_for_fixture_ticket_rows_ready( + &mut self, + expected: &ExpectedPanelTicketRow, + timeout: Duration, + ) -> Result { + let description = expected.description(); + let event = self.wait_for( + format!("fixture Ticket rows ready ({description})"), + timeout, + |event| { + event.event == "rows_rendered" + && rows_rendered_event_has_fixture_ticket(event, expected) + }, + )?; + serde_json::from_value(event.data).map_err(HarnessError::from) + } + + pub fn assert_fixture_ticket_row_not_rendered( + &mut self, + expected: &ExpectedPanelTicketRow, + duration: Duration, + ) -> Result<()> { + let start = Instant::now(); + while start.elapsed() < duration { + if let Some(rows) = self + .events()? + .iter() + .filter(|event| event.event == "rows_rendered") + .filter_map(|event| serde_json::from_value::(event.data.clone()).ok()) + .find(|rows| rows.has_fixture_ticket_row(expected)) + { + self.flush_output_artifact()?; + return Err(HarnessError::Protocol(format!( + "fixture Ticket row rendered before data-backed rows readiness was expected: {}; rows: {}", + expected.description(), + describe_rows(&rows) + ))); + } + if let Some(status) = self.child.try_wait()? { + self.flush_output_artifact()?; + return Err(HarnessError::Protocol(format!( + "process exited with {status} while asserting fixture Ticket rows stayed delayed" + ))); + } + thread::sleep(Duration::from_millis(20)); + } + Ok(()) + } + pub fn wait_for_rows(&mut self, min_rows: usize) -> Result { let event = self.wait_for("rows_rendered", DEFAULT_WAIT, |event| { event.event == "rows_rendered" @@ -781,6 +910,9 @@ pub struct FixtureCleanupReport { pub report_path: PathBuf, } +pub const READY_FIXTURE_TICKET_TITLE: &str = "Ready E2E Ticket"; +pub const PLANNING_FIXTURE_TICKET_TITLE: &str = "Planning E2E Ticket"; + #[derive(Debug)] pub struct FixtureWorkspace { temp_root: Option, @@ -792,6 +924,8 @@ pub struct FixtureWorkspace { pub xdg_config_home: PathBuf, pub xdg_runtime_dir: PathBuf, pub artifacts_dir: PathBuf, + pub ready_ticket_id: String, + pub planning_ticket_id: String, } impl FixtureWorkspace { @@ -832,7 +966,7 @@ impl FixtureWorkspace { fs::create_dir_all(dir)?; } - let fixture = Self { + let mut fixture = Self { temp_root: Some(temp_root), root, workspace, @@ -842,6 +976,8 @@ impl FixtureWorkspace { xdg_config_home, xdg_runtime_dir, artifacts_dir, + ready_ticket_id: String::new(), + planning_ticket_id: String::new(), }; fixture.write_fixture_metadata("created", None)?; @@ -867,7 +1003,7 @@ impl FixtureWorkspace { &fixture.xdg_config_home, &fixture.xdg_runtime_dir, &fixture.artifacts_dir, - "Ready E2E Ticket", + READY_FIXTURE_TICKET_TITLE, )?; run_yoi( binary, @@ -880,7 +1016,7 @@ impl FixtureWorkspace { &fixture.artifacts_dir, &["ticket", "state", &first, "ready"], )?; - let _second = create_ticket( + let second = create_ticket( binary, &fixture.workspace, &fixture.home, @@ -889,12 +1025,22 @@ impl FixtureWorkspace { &fixture.xdg_config_home, &fixture.xdg_runtime_dir, &fixture.artifacts_dir, - "Planning E2E Ticket", + PLANNING_FIXTURE_TICKET_TITLE, )?; + fixture.ready_ticket_id = first; + fixture.planning_ticket_id = second; fixture.write_fixture_metadata("ready", None)?; Ok(fixture) } + pub fn ready_fixture_ticket_row(&self) -> ExpectedPanelTicketRow { + ExpectedPanelTicketRow::new( + self.ready_ticket_id.clone(), + READY_FIXTURE_TICKET_TITLE, + "ready", + ) + } + pub fn panel_config(&self, binary: PathBuf) -> PanelHarnessConfig { PanelHarnessConfig { binary, @@ -999,6 +1145,18 @@ impl FixtureWorkspace { "xdg_config_home": &self.xdg_config_home, "xdg_runtime_dir": &self.xdg_runtime_dir, "artifacts_dir": &self.artifacts_dir, + "tickets": { + "ready": { + "id": &self.ready_ticket_id, + "title": READY_FIXTURE_TICKET_TITLE, + "state": "ready" + }, + "planning": { + "id": &self.planning_ticket_id, + "title": PLANNING_FIXTURE_TICKET_TITLE, + "state": "planning" + } + }, "env_runtime_policy": { "tested_yoi_uses_env_clear": true, "host_runtime_inherited": false, diff --git a/tests/e2e/tests/panel.rs b/tests/e2e/tests/panel.rs index 7990e9f6..09845e11 100644 --- a/tests/e2e/tests/panel.rs +++ b/tests/e2e/tests/panel.rs @@ -1,17 +1,62 @@ use std::time::{Duration, Instant}; const FIRST_VISIBLE_RENDER_BUDGET: Duration = Duration::from_millis(1500); -const FULL_READY_BUDGET: Duration = Duration::from_secs(5); +const ROWS_READY_BUDGET: Duration = Duration::from_secs(5); use yoi_e2e::{ - FixtureCleanupReport, FixtureWorkspace, KeyPress, PanelHarness, RenderedPanelRow, yoi_binary, + ExpectedPanelTicketRow, FixtureCleanupReport, FixtureWorkspace, KeyPress, PanelHarness, + PanelRect, PanelRowKey, RenderedPanelRow, RowsRendered, yoi_binary, }; +#[test] +fn panel_fixture_ticket_row_matcher_rejects_absent_fixture_data() { + let expected = ExpectedPanelTicketRow::new("0000000000000", "Ready E2E Ticket", "ready"); + let wrong_title = RenderedPanelRow { + key: PanelRowKey { + kind: "ticket".to_string(), + id: "0000000000000".to_string(), + }, + title: "Different Ticket".to_string(), + status: Some("ready".to_string()), + action: None, + rect: PanelRect { + x: 0, + y: 0, + width: 10, + height: 1, + }, + }; + let wrong_kind = RenderedPanelRow { + key: PanelRowKey { + kind: "pod".to_string(), + id: "0000000000000".to_string(), + }, + title: "Ready E2E Ticket".to_string(), + status: Some("ready".to_string()), + action: None, + rect: PanelRect { + x: 0, + y: 0, + width: 10, + height: 1, + }, + }; + + assert!(!expected.matches(&wrong_title)); + assert!(!expected.matches(&wrong_kind)); + let rows = RowsRendered { + selected: None, + rows: vec![wrong_title, wrong_kind], + }; + assert!(!rows.has_fixture_ticket_row(&expected)); +} + #[test] fn panel_first_visible_render_arrives_before_background_reload() -> yoi_e2e::Result<()> { let binary = yoi_binary()?; let fixture = FixtureWorkspace::new(&binary)?; assert_fixture_paths_are_isolated(&fixture); + let ready_ticket = fixture.ready_fixture_ticket_row(); let started = Instant::now(); let mut panel = @@ -19,17 +64,15 @@ fn panel_first_visible_render_arrives_before_background_reload() -> yoi_e2e::Res let remaining = FIRST_VISIBLE_RENDER_BUDGET .checked_sub(started.elapsed()) .unwrap_or_else(|| Duration::from_millis(0)); - panel.wait_for("first visible panel render", remaining, |event| { - event.event == "panel_ready" - })?; + panel.wait_for_first_visible_frame(remaining)?; let first_visible_elapsed = started.elapsed(); eprintln!( - "panel first visible render: {first_visible_elapsed:?} (budget {FIRST_VISIBLE_RENDER_BUDGET:?}); artifacts at {}", + "panel first visible frame: {first_visible_elapsed:?} (budget {FIRST_VISIBLE_RENDER_BUDGET:?}); artifacts at {}", panel.artifacts().dir.display() ); assert!( first_visible_elapsed <= FIRST_VISIBLE_RENDER_BUDGET, - "first visible render took {first_visible_elapsed:?}, budget {FIRST_VISIBLE_RENDER_BUDGET:?}; artifacts at {}", + "first visible frame took {first_visible_elapsed:?}, budget {FIRST_VISIBLE_RENDER_BUDGET:?}; artifacts at {}", panel.artifacts().dir.display() ); @@ -42,7 +85,7 @@ fn panel_first_visible_render_arrives_before_background_reload() -> yoi_e2e::Res events[..ready_index] .iter() .all(|event| event.event != "background_task_started"), - "initial render must be emitted before reload/background work starts; artifacts at {}", + "initial frame must be emitted before reload/background work starts; artifacts at {}", panel.artifacts().dir.display() ); @@ -54,12 +97,13 @@ fn panel_first_visible_render_arrives_before_background_reload() -> yoi_e2e::Res event.event == "background_task_started" && event.data.get("task").and_then(serde_json::Value::as_str) == Some("reload") }) - .expect("held reload should start after first visible render"); + .expect("held reload should start after first visible frame"); assert!( ready_index < reload_started_index, - "first visible render and reload ordering should remain separate; artifacts at {}", + "first visible frame and reload ordering should remain separate; artifacts at {}", panel.artifacts().dir.display() ); + panel.assert_fixture_ticket_row_not_rendered(&ready_ticket, Duration::from_millis(150))?; panel.press(KeyPress::CtrlC)?; let status = panel.expect_exit_within(PanelHarness::default_exit_wait())?; @@ -70,51 +114,46 @@ fn panel_first_visible_render_arrives_before_background_reload() -> yoi_e2e::Res } #[test] -fn panel_full_ready_has_separate_startup_budget() -> yoi_e2e::Result<()> { +fn panel_fixture_ticket_row_ready_has_startup_budget() -> yoi_e2e::Result<()> { let binary = yoi_binary()?; let fixture = FixtureWorkspace::new(&binary)?; assert_fixture_paths_are_isolated(&fixture); + let ready_ticket = fixture.ready_fixture_ticket_row(); let started = Instant::now(); let mut panel = PanelHarness::spawn(fixture.panel_config(binary))?; let first_visible_remaining = FIRST_VISIBLE_RENDER_BUDGET .checked_sub(started.elapsed()) .unwrap_or_else(|| Duration::from_millis(0)); - panel.wait_for( - "first visible panel render", - first_visible_remaining, - |event| event.event == "panel_ready", - )?; + panel.wait_for_first_visible_frame(first_visible_remaining)?; let first_visible_elapsed = started.elapsed(); eprintln!( - "panel first visible render: {first_visible_elapsed:?} (budget {FIRST_VISIBLE_RENDER_BUDGET:?}); artifacts at {}", + "panel first visible frame: {first_visible_elapsed:?} (budget {FIRST_VISIBLE_RENDER_BUDGET:?}); artifacts at {}", panel.artifacts().dir.display() ); assert!( first_visible_elapsed <= FIRST_VISIBLE_RENDER_BUDGET, - "first visible render took {first_visible_elapsed:?}, budget {FIRST_VISIBLE_RENDER_BUDGET:?}; artifacts at {}", + "first visible frame took {first_visible_elapsed:?}, budget {FIRST_VISIBLE_RENDER_BUDGET:?}; artifacts at {}", panel.artifacts().dir.display() ); - let full_ready_remaining = FULL_READY_BUDGET + let rows_ready_remaining = ROWS_READY_BUDGET .checked_sub(started.elapsed()) .unwrap_or_else(|| Duration::from_millis(0)); - panel.wait_for("full ready fixture rows", full_ready_remaining, |event| { - event.event == "rows_rendered" - && event - .data - .get("rows") - .and_then(serde_json::Value::as_array) - .is_some_and(|rows| rows.len() >= 2) - })?; - let full_ready_elapsed = started.elapsed(); + let rows = panel.wait_for_fixture_ticket_rows_ready(&ready_ticket, rows_ready_remaining)?; + assert!( + rows.has_fixture_ticket_row(&ready_ticket), + "rows-ready event must contain concrete ready fixture Ticket row; artifacts at {}", + panel.artifacts().dir.display() + ); + let rows_ready_elapsed = started.elapsed(); eprintln!( - "panel full ready: {full_ready_elapsed:?} (budget {FULL_READY_BUDGET:?}); artifacts at {}", + "panel fixture rows ready: {rows_ready_elapsed:?} (budget {ROWS_READY_BUDGET:?}); artifacts at {}", panel.artifacts().dir.display() ); assert!( - full_ready_elapsed <= FULL_READY_BUDGET, - "full ready took {full_ready_elapsed:?}, budget {FULL_READY_BUDGET:?}; artifacts at {}", + rows_ready_elapsed <= ROWS_READY_BUDGET, + "fixture rows ready took {rows_ready_elapsed:?}, budget {ROWS_READY_BUDGET:?}; artifacts at {}", panel.artifacts().dir.display() ); From 7de73dbd336b26308b8f091f5772be13bd4a9761 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 18 Jun 2026 21:19:51 +0900 Subject: [PATCH 3/9] ticket: record panel rows-ready implementation --- .yoi/tickets/00001KV62PF32/item.md | 2 +- .yoi/tickets/00001KV62PF32/thread.md | 53 ++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KV62PF32/item.md b/.yoi/tickets/00001KV62PF32/item.md index 478eb749..d2511ddd 100644 --- a/.yoi/tickets/00001KV62PF32/item.md +++ b/.yoi/tickets/00001KV62PF32/item.md @@ -2,7 +2,7 @@ title: 'Panel startup latency E2E を一覧データ描画完了基準に修正する' state: 'inprogress' created_at: '2026-06-15T16:44:06Z' -updated_at: '2026-06-18T12:05:59Z' +updated_at: '2026-06-18T12:19:45Z' assignee: null readiness: 'implementation_ready' risk_flags: ['panel', 'e2e', 'startup-latency', 'readiness-metric', 'ticket-list-rendering'] diff --git a/.yoi/tickets/00001KV62PF32/thread.md b/.yoi/tickets/00001KV62PF32/thread.md index cc9396ed..8cb2d3e0 100644 --- a/.yoi/tickets/00001KV62PF32/thread.md +++ b/.yoi/tickets/00001KV62PF32/thread.md @@ -143,3 +143,56 @@ Coder Pod: No duplicate `queued -> inprogress` transition was performed; this resumes the already accepted in-progress work. --- + + + +## Implementation report + +Implementation report from Coder Pod `yoi-coder-00001KV62PF32`: + +Commit: +- `fffdfd27 test: assert panel rows-ready fixture data` + +Changed files: +- `tests/e2e/src/lib.rs` + - Added concrete `ExpectedPanelTicketRow` matcher. + - Exposed fixture ready/planning Ticket ids/titles/states. + - Added `wait_for_first_visible_frame`, `wait_for_fixture_ticket_rows_ready`, and delayed-row negative assertion helpers. +- `tests/e2e/tests/panel.rs` + - Replaced main startup readiness assertion from `rows.len() >= 2` / “full ready” to concrete ready fixture Ticket row readiness. + - Added matcher regression test proving absent/wrong fixture data is rejected. + - Added delayed reload assertion that first frame can arrive while fixture rows are still not rendered. +- `crates/tui/src/multi_pod.rs` + - Added comments clarifying `panel_ready` is first-visible-frame only, while `rows_rendered` snapshots are the data-backed readiness surface. + +Behavior / evidence: +- Main startup readiness now distinguishes: + - first frame: legacy `panel_ready`, can be loading/empty; + - rows ready: concrete `rows_rendered` containing expected fixture Ticket id/title/state/kind; + - full/background completion: still observable via background task events, but no longer the startup readiness proxy. +- Startup readiness assertion is no longer satisfied by first frame alone or by weak `rows.len() >= N` checks. +- Delayed reload test asserts first visible frame can arrive while expected fixture Ticket row remains absent for the delayed window. + +Validation reported by coder: +- Passed: `cargo test -p yoi-e2e --features e2e --no-run` +- Passed: `cargo test -p yoi-e2e --features e2e panel -- --nocapture` +- Passed: `cargo check -p yoi-e2e -p yoi -p tui` +- Passed: `cargo fmt --check` +- Passed: `git diff --check` +- Not run: `nix build .#yoi` — no runtime/package/dependency changes. + +Measured values from focused post-commit reruns: +- `panel_fixture_ticket_row_ready_has_startup_budget` + - first visible frame: `20.403589ms` / budget `1.5s` + - fixture rows ready: `120.605891ms` / budget `5s` +- `panel_first_visible_render_arrives_before_background_reload` + - first visible frame: `20.311085ms` / budget `1.5s` + - reload held; test asserts ready fixture Ticket row remains absent for delayed window before exit. + +Repository status: +- Child implementation worktree clean at `fffdfd27`. + +Residual gaps: +- None known. + +--- From 49db85c09a0b85b8e692c0a71694d376501ee63f Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 18 Jun 2026 21:24:09 +0900 Subject: [PATCH 4/9] ticket: approve panel rows-ready e2e --- .yoi/tickets/00001KV62PF32/item.md | 2 +- .yoi/tickets/00001KV62PF32/thread.md | 42 ++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KV62PF32/item.md b/.yoi/tickets/00001KV62PF32/item.md index d2511ddd..a1f6c151 100644 --- a/.yoi/tickets/00001KV62PF32/item.md +++ b/.yoi/tickets/00001KV62PF32/item.md @@ -2,7 +2,7 @@ title: 'Panel startup latency E2E を一覧データ描画完了基準に修正する' state: 'inprogress' created_at: '2026-06-15T16:44:06Z' -updated_at: '2026-06-18T12:19:45Z' +updated_at: '2026-06-18T12:24:00Z' assignee: null readiness: 'implementation_ready' risk_flags: ['panel', 'e2e', 'startup-latency', 'readiness-metric', 'ticket-list-rendering'] diff --git a/.yoi/tickets/00001KV62PF32/thread.md b/.yoi/tickets/00001KV62PF32/thread.md index 8cb2d3e0..3d5d1da7 100644 --- a/.yoi/tickets/00001KV62PF32/thread.md +++ b/.yoi/tickets/00001KV62PF32/thread.md @@ -196,3 +196,45 @@ Residual gaps: - None known. --- + + + +## Review: approve + +Review result: approve + +Evidence checked: +- 対象 worktree / branch / commit が期待通りで clean であることを確認。 + - branch: `impl/00001KV62PF32-panel-rows-ready-e2e` + - HEAD: `fffdfd2721fed5171d4dd9780f893b9bb323ab8a` + - base: `d32fb3bc3c82f1cb7e4a29d4fdf6e908ba495c7f` +- Diff scope は以下3ファイルに限定されていることを確認。 + - `crates/tui/src/multi_pod.rs` + - `tests/e2e/src/lib.rs` + - `tests/e2e/tests/panel.rs` + +Acceptance / intent review: +- Main startup readiness は `panel_fixture_ticket_row_ready_has_startup_budget` で concrete fixture Ticket row readiness を待つ形になっている。 +- `rows.len() >= N` ではなく、fixture Ticket row の kind / id / title / status-state を matcher で確認している。 +- `panel_ready` / `wait_for_first_visible_frame` は first-visible-frame only としてコメント・helper 名で区別されている。 +- `wait_for_fixture_ticket_rows_ready` が startup rows-ready signal として定義されている。 +- `panel_fixture_ticket_row_matcher_rejects_absent_fixture_data` により absent/wrong fixture data を reject する挙動が確認されている。 +- delayed reload test は first frame が先に出る一方、held reload 中は expected fixture Ticket row が描画されないことを確認している。 +- 既存 Panel mouse / row selection E2E は残っており、action dispatch なし・mouse capture behavior の coverage は維持されている。 +- fixture isolation / no-provider / no-network 前提は維持されている。 + +Validation performed by reviewer: +- Passed: `cargo fmt --check` +- Passed: `git diff --check d32fb3bc..HEAD` + +Validation not run by reviewer: +- `cargo test` / `cargo check` は read-only review scope では build/artifact 書き込みを伴うため未実行。Coder reported validation を evidence として確認した。 + +Risk assessment: +- residual risk は低い。変更は test/harness focused で Ticket boundary に沿っている。 +- timing test は環境依存性が残るが、readiness predicate は first frame/loading paint ではなく concrete fixture row rendering を測るよう修正されている。 + +Conclusion: +- Approved. No blocker remains. + +--- From ca29cd3b893d794ec9f74552f9586b6d96a411ff Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 18 Jun 2026 21:25:23 +0900 Subject: [PATCH 5/9] ticket: complete panel rows-ready e2e --- .yoi/tickets/00001KV62PF32/item.md | 4 +-- .yoi/tickets/00001KV62PF32/thread.md | 44 ++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KV62PF32/item.md b/.yoi/tickets/00001KV62PF32/item.md index a1f6c151..9e01135b 100644 --- a/.yoi/tickets/00001KV62PF32/item.md +++ b/.yoi/tickets/00001KV62PF32/item.md @@ -1,8 +1,8 @@ --- title: 'Panel startup latency E2E を一覧データ描画完了基準に修正する' -state: 'inprogress' +state: 'done' created_at: '2026-06-15T16:44:06Z' -updated_at: '2026-06-18T12:24:00Z' +updated_at: '2026-06-18T12:25:14Z' assignee: null readiness: 'implementation_ready' risk_flags: ['panel', 'e2e', 'startup-latency', 'readiness-metric', 'ticket-list-rendering'] diff --git a/.yoi/tickets/00001KV62PF32/thread.md b/.yoi/tickets/00001KV62PF32/thread.md index 3d5d1da7..bad57f72 100644 --- a/.yoi/tickets/00001KV62PF32/thread.md +++ b/.yoi/tickets/00001KV62PF32/thread.md @@ -238,3 +238,47 @@ Conclusion: - Approved. No blocker remains. --- + + + +## Implementation report + +Integration report: + +Merged implementation branch into orchestration branch: +- branch: `impl/00001KV62PF32-panel-rows-ready-e2e` +- implementation commit: `fffdfd27 test: assert panel rows-ready fixture data` +- merge commit: `226eca7a merge: panel rows-ready e2e` + +Reviewer result: +- `approve` recorded from `yoi-reviewer-00001KV62PF32`. + +Validation in Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration`: +- Passed: `cargo test -p yoi-e2e --features e2e --no-run` +- Passed: `cargo test -p yoi-e2e --features e2e panel -- --nocapture` + - panel E2E: 6 passed + - first visible frame examples: ~20.34ms / budget 1.5s + - fixture rows ready example: ~120.54ms / budget 5s +- Passed: `cargo check -p yoi-e2e -p yoi -p tui` +- Passed: `cargo fmt --check` +- Passed: `git diff --check` + +Outcome: +- Startup readiness E2E now uses concrete fixture Ticket row render readiness instead of first visible/loading frame or weak row-count checks. +- `panel_ready` remains first-visible-frame only; `rows_rendered` fixture row matching is the data-backed readiness surface. +- Delayed reload coverage verifies first frame can arrive before rows-ready and expected fixture Ticket row remains absent while reload is held. +- Existing Panel mouse/row selection E2E remains covered. + +Cleanup planned: +- Stop related coder/reviewer Pods. +- Remove only child implementation worktree/branch for this Ticket. + +--- + + + +## State changed + +Reviewer approved, implementation branch merged into the orchestration branch, and E2E-focused validation passed in the Orchestrator worktree. Marking Ticket done in the orchestration branch. + +--- From 10d12148dd5d68db7b90bf202351ee62a7aa7999 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 18 Jun 2026 21:29:41 +0900 Subject: [PATCH 6/9] feat: run plugin tools through wasm runtime --- Cargo.lock | 129 ++++- crates/manifest/src/plugin.rs | 140 ++++++ crates/pod/Cargo.toml | 2 + crates/pod/src/feature/plugin.rs | 780 +++++++++++++++++++++++++++++-- package.nix | 2 +- 5 files changed, 1010 insertions(+), 43 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bccf8b7e..2b77cd3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1633,6 +1633,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.16" @@ -2360,6 +2366,8 @@ dependencies = [ "tools", "tracing", "uuid", + "wasmi", + "wat", "workflow", ] @@ -3342,6 +3350,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3354,6 +3368,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "string-interner" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23de088478b31c349c9ba67816fa55d9355232d63c3afea8bf513e31f0f1d2c0" +dependencies = [ + "hashbrown 0.15.5", + "serde", +] + [[package]] name = "string_cache" version = "0.8.9" @@ -4229,7 +4253,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ "leb128fmt", - "wasmparser", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.246.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61fb705ce81adde29d2a8e99d87995e39a6e927358c91398f374474746070ef7" +dependencies = [ + "leb128fmt", + "wasmparser 0.246.2", ] [[package]] @@ -4240,8 +4274,8 @@ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", "indexmap", - "wasm-encoder", - "wasmparser", + "wasm-encoder 0.244.0", + "wasmparser 0.244.0", ] [[package]] @@ -4257,6 +4291,56 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmi" +version = "0.51.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb321403ce594274827657a908e13d1d9918aa02257b8bf8391949d9764023ff" +dependencies = [ + "spin", + "wasmi_collections", + "wasmi_core", + "wasmi_ir", + "wasmparser 0.228.0", +] + +[[package]] +name = "wasmi_collections" +version = "0.51.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9b8e98e45a2a534489f8225e765cbf1cb9a3078072605e58158910cf4749172" +dependencies = [ + "string-interner", +] + +[[package]] +name = "wasmi_core" +version = "0.51.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c25f375c0cdf14810eab07f532f61f14d4966f09c747a55067fdf3196e8512e6" +dependencies = [ + "libm", +] + +[[package]] +name = "wasmi_ir" +version = "0.51.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "624e2a68a4293ecb8f564260b68394b29cf3b3edba6bce35532889a2cb33c3d9" +dependencies = [ + "wasmi_core", +] + +[[package]] +name = "wasmparser" +version = "0.228.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4abf1132c1fdf747d56bbc1bb52152400c70f336870f968b85e89ea422198ae3" +dependencies = [ + "bitflags 2.11.0", + "indexmap", +] + [[package]] name = "wasmparser" version = "0.244.0" @@ -4269,6 +4353,39 @@ dependencies = [ "semver", ] +[[package]] +name = "wasmparser" +version = "0.246.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71cde4757396defafd25417cfb36aa3161027d06d865b0c24baaae229aac005d" +dependencies = [ + "bitflags 2.11.0", + "indexmap", + "semver", +] + +[[package]] +name = "wast" +version = "246.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe3fe8e3bf88ad96d031b4181ddbd64634b17cb0d06dfc3de589ef43591a9a62" +dependencies = [ + "bumpalo", + "leb128fmt", + "memchr", + "unicode-width", + "wasm-encoder 0.246.2", +] + +[[package]] +name = "wat" +version = "1.246.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd7fda1199b94fff395c2d19a153f05dbe7807630316fa9673367666fd2ad8c" +dependencies = [ + "wast", +] + [[package]] name = "web-sys" version = "0.3.94" @@ -4730,9 +4847,9 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "wasm-encoder", + "wasm-encoder 0.244.0", "wasm-metadata", - "wasmparser", + "wasmparser 0.244.0", "wit-parser", ] @@ -4751,7 +4868,7 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser", + "wasmparser 0.244.0", ] [[package]] diff --git a/crates/manifest/src/plugin.rs b/crates/manifest/src/plugin.rs index d4460e8e..a6b3dd21 100644 --- a/crates/manifest/src/plugin.rs +++ b/crates/manifest/src/plugin.rs @@ -637,6 +637,146 @@ pub fn resolve_plugin_config_for_startup( snapshot } +/// Load the recorded WASM runtime module for a resolved plugin package. +/// +/// Restore and execution paths use this helper instead of reading arbitrary +/// package paths directly so module selection remains tied to the resolved +/// package identity, runtime manifest entry, and deterministic package digest. +pub fn read_resolved_plugin_runtime_module( + record: &ResolvedPluginRecord, + limits: &PluginDiscoveryLimits, +) -> Result, PluginDiagnostic> { + let runtime = record.manifest.runtime.as_ref().ok_or_else(|| { + PluginDiagnostic::new( + PluginDiagnosticKind::Missing, + PluginDiagnosticPhase::Manifest, + "resolved plugin package does not declare a WASM runtime", + ) + .with_source(record.source) + .with_identity(&record.identity) + .with_package(&record.package_label) + .with_digest(&record.digest) + })?; + + if runtime.kind != "wasm" { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Api, + PluginDiagnosticPhase::Manifest, + "plugin runtime kind is unsupported", + ) + .with_source(record.source) + .with_identity(&record.identity) + .with_package(&record.package_label) + .with_digest(&record.digest)); + } + if runtime.abi.as_deref() != Some("yoi-plugin-wasm-1") { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Api, + PluginDiagnosticPhase::Manifest, + "plugin WASM ABI is unsupported", + ) + .with_source(record.source) + .with_identity(&record.identity) + .with_package(&record.package_label) + .with_digest(&record.digest)); + } + + let metadata = fs::metadata(&record.package_path).map_err(|error| { + PluginDiagnostic::new( + PluginDiagnosticKind::Io, + PluginDiagnosticPhase::Discovery, + format!( + "resolved plugin package metadata could not be read: {}", + safe_io_error(&error) + ), + ) + .with_source(record.source) + .with_identity(&record.identity) + .with_package(&record.package_label) + .with_digest(&record.digest) + })?; + if !metadata.is_file() { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Malformed, + PluginDiagnosticPhase::Discovery, + "resolved plugin package is not a regular file", + ) + .with_source(record.source) + .with_identity(&record.identity) + .with_package(&record.package_label) + .with_digest(&record.digest)); + } + if metadata.len() > limits.max_package_size_bytes { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Bounds, + PluginDiagnosticPhase::Discovery, + "resolved plugin package exceeds the configured package size bound", + ) + .with_source(record.source) + .with_identity(&record.identity) + .with_package(&record.package_label) + .with_digest(&record.digest)); + } + + let bytes = fs::read(&record.package_path).map_err(|error| { + PluginDiagnostic::new( + PluginDiagnosticKind::Io, + PluginDiagnosticPhase::Discovery, + format!( + "resolved plugin package content could not be read: {}", + safe_io_error(&error) + ), + ) + .with_source(record.source) + .with_identity(&record.identity) + .with_package(&record.package_label) + .with_digest(&record.digest) + })?; + let archive = parse_stored_zip(&bytes, &record.package_label, record.source, limits)?; + let actual_digest = deterministic_digest(&archive.files); + if !digest_matches(&record.digest, &actual_digest) { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Digest, + PluginDiagnosticPhase::Resolution, + "resolved plugin package digest does not match current package content", + ) + .with_source(record.source) + .with_identity(&record.identity) + .with_package(&record.package_label) + .with_digest(actual_digest)); + } + + validate_manifest_path( + &runtime.entry, + &archive, + &record.package_label, + record.source, + &record.manifest.id, + )?; + let normalized = normalize_archive_path(&runtime.entry).ok_or_else(|| { + PluginDiagnostic::new( + PluginDiagnosticKind::Traversal, + PluginDiagnosticPhase::Manifest, + "plugin manifest references a path outside the package root", + ) + .with_source(record.source) + .with_identity(&record.identity) + .with_package(&record.package_label) + .with_digest(&record.digest) + })?; + archive.files.get(&normalized).cloned().ok_or_else(|| { + PluginDiagnostic::new( + PluginDiagnosticKind::Missing, + PluginDiagnosticPhase::Manifest, + "plugin runtime module entry is missing from the package", + ) + .with_source(record.source) + .with_identity(&record.identity) + .with_package(&record.package_label) + .with_digest(&record.digest) + }) +} + #[derive(Clone, Debug)] struct PluginStore { source: PluginSourceKind, diff --git a/crates/pod/Cargo.toml b/crates/pod/Cargo.toml index 80a89a86..d890c684 100644 --- a/crates/pod/Cargo.toml +++ b/crates/pod/Cargo.toml @@ -35,11 +35,13 @@ workflow-crate = { package = "workflow", path = "../workflow" } uuid = { workspace = true, features = ["v7"] } session-metrics = { workspace = true } arc-swap = "1.9.1" +wasmi = { version = "0.51.1", default-features = false, features = ["std", "extra-checks"] } [dev-dependencies] dotenv = "0.15.0" futures = { workspace = true } tempfile = { workspace = true } +wat = "1.241.2" [build-dependencies] toml = { workspace = true } diff --git a/crates/pod/src/feature/plugin.rs b/crates/pod/src/feature/plugin.rs index f6fc75f6..a5eae230 100644 --- a/crates/pod/src/feature/plugin.rs +++ b/crates/pod/src/feature/plugin.rs @@ -1,17 +1,23 @@ //! Plugin package contributions for model-visible Tool schemas. //! -//! This module registers *enabled* plugin package tool surface definitions as -//! unavailable Tool stubs. It deliberately does not execute plugin code or grant -//! plugin permissions; the runtime/WASM executor belongs to a later boundary. +//! This module registers *enabled* plugin package tool surface definitions and +//! executes Tool calls through the minimal sandboxed `yoi-plugin-wasm-1` WASM +//! ABI. It deliberately does not grant filesystem, network, environment, hook, +//! service, ingress, or richer host API authority; those remain follow-up +//! boundaries. use std::collections::HashSet; use std::sync::Arc; +use std::time::Duration; use async_trait::async_trait; use llm_worker::tool::{ Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOrigin, ToolOutput, }; -use manifest::plugin::{PluginConfig, PluginSurface, ResolvedPluginRecord}; +use manifest::plugin::{ + PluginConfig, PluginDiscoveryLimits, PluginSurface, ResolvedPluginRecord, + read_resolved_plugin_runtime_module, +}; use serde_json::Value; use super::{ @@ -115,7 +121,8 @@ impl FeatureModule for PluginToolFeature { })?; context.tools().register(ToolContribution::new( tool.name.clone(), - plugin_runtime_missing_definition( + plugin_wasm_tool_definition( + self.record.clone(), tool.name.clone(), tool.description.clone(), tool.input_schema.clone(), @@ -127,7 +134,18 @@ impl FeatureModule for PluginToolFeature { } } -fn plugin_runtime_missing_definition( +const PLUGIN_WASM_HOST_MODULE: &str = "yoi:tool"; +const PLUGIN_WASM_ENTRYPOINT: &str = "yoi_tool_call"; +const PLUGIN_WASM_MAX_INPUT_BYTES: usize = 64 * 1024; +const PLUGIN_WASM_MAX_OUTPUT_BYTES: usize = 64 * 1024; +const PLUGIN_WASM_MAX_SUMMARY_BYTES: usize = 1024; +const PLUGIN_WASM_FUEL: u64 = 5_000_000; +const PLUGIN_WASM_TIMEOUT: Duration = Duration::from_secs(1); +const PLUGIN_WASM_MEMORY_BYTES: usize = 2 * 1024 * 1024; +const PLUGIN_WASM_TABLE_ELEMENTS: usize = 256; + +fn plugin_wasm_tool_definition( + record: ResolvedPluginRecord, name: String, description: String, input_schema: Value, @@ -139,7 +157,8 @@ fn plugin_runtime_missing_definition( .description(description.clone()) .input_schema(input_schema.clone()) .origin(origin.clone()), - Arc::new(PluginRuntimeMissingTool { + Arc::new(PluginWasmTool { + record: record.clone(), name: name.clone(), origin: origin.clone(), }) as Arc, @@ -147,29 +166,390 @@ fn plugin_runtime_missing_definition( }) } -struct PluginRuntimeMissingTool { +struct PluginWasmTool { + record: ResolvedPluginRecord, name: String, origin: ToolOrigin, } #[async_trait] -impl Tool for PluginRuntimeMissingTool { +impl Tool for PluginWasmTool { async fn execute( &self, - _input_json: &str, + input_json: &str, _ctx: ToolExecutionContext, ) -> Result { - Err(ToolError::ExecutionFailed(format!( - "plugin tool runtime missing/unavailable for `{}` from `{}` (digest {}, package {} api {})", - self.name, - self.origin.plugin_ref, - self.origin.digest, - self.origin.package_version, - self.origin.package_api_version - ))) + if input_json.len() > PLUGIN_WASM_MAX_INPUT_BYTES { + return Err(ToolError::InvalidArgument(format!( + "plugin tool `{}` input exceeds {} bytes", + self.name, PLUGIN_WASM_MAX_INPUT_BYTES + ))); + } + serde_json::from_str::(input_json).map_err(|error| { + ToolError::InvalidArgument(format!( + "plugin tool `{}` input is not valid JSON: {}", + self.name, + bounded_message(error.to_string()) + )) + })?; + + let record = self.record.clone(); + let name = self.name.clone(); + let plugin_ref = self.origin.plugin_ref.clone(); + let digest = self.origin.digest.clone(); + let input = input_json.as_bytes().to_vec(); + let execution = + tokio::task::spawn_blocking(move || run_plugin_wasm_tool(record, name, input)); + match tokio::time::timeout(PLUGIN_WASM_TIMEOUT, execution).await { + Ok(Ok(Ok(output))) => Ok(output), + Ok(Ok(Err(error))) => Err(ToolError::ExecutionFailed(format!( + "plugin WASM tool `{}` from `{}` (digest {}) failed closed: {}", + self.name, + plugin_ref, + digest, + error.bounded_message() + ))), + Ok(Err(error)) => Err(ToolError::ExecutionFailed(format!( + "plugin WASM tool `{}` from `{}` (digest {}) cancelled/failed to join: {}", + self.name, + plugin_ref, + digest, + bounded_message(error.to_string()) + ))), + Err(_) => Err(ToolError::ExecutionFailed(format!( + "plugin WASM tool `{}` from `{}` (digest {}) timed out after {:?}", + self.name, plugin_ref, digest, PLUGIN_WASM_TIMEOUT + ))), + } } } +#[derive(Debug)] +enum PluginWasmError { + Package(String), + Module(String), + Execution(String), + Output(String), +} + +impl PluginWasmError { + fn bounded_message(&self) -> String { + match self { + Self::Package(message) => { + bounded_message(format!("package/module load error: {message}")) + } + Self::Module(message) => bounded_message(format!("WASM module error: {message}")), + Self::Execution(message) => bounded_message(format!("WASM execution error: {message}")), + Self::Output(message) => bounded_message(format!("WASM output error: {message}")), + } + } +} + +#[derive(Debug)] +struct PluginWasmHostState { + tool_name: Vec, + input: Vec, + output: Vec, + output_error: Option, + store_limits: wasmi::StoreLimits, +} + +fn run_plugin_wasm_tool( + record: ResolvedPluginRecord, + tool_name: String, + input: Vec, +) -> Result { + let limits = PluginDiscoveryLimits::default(); + let module_bytes = read_resolved_plugin_runtime_module(&record, &limits) + .map_err(|diagnostic| PluginWasmError::Package(diagnostic.message))?; + if module_bytes.len() > limits.max_file_size_bytes as usize { + return Err(PluginWasmError::Package(format!( + "WASM runtime module exceeds {} bytes", + limits.max_file_size_bytes + ))); + } + + let mut config = wasmi::Config::default(); + config.consume_fuel(true); + config.set_max_recursion_depth(64); + config.set_max_stack_height(8 * 1024 * 1024); + let engine = wasmi::Engine::new(&config); + let module = wasmi::Module::new(&engine, &module_bytes[..]) + .map_err(|error| PluginWasmError::Module(error.to_string()))?; + validate_wasm_imports(&module)?; + + let store_limits = wasmi::StoreLimitsBuilder::new() + .memory_size(PLUGIN_WASM_MEMORY_BYTES) + .table_elements(PLUGIN_WASM_TABLE_ELEMENTS) + .instances(1) + .tables(1) + .memories(1) + .trap_on_grow_failure(true) + .build(); + let mut store = wasmi::Store::new( + &engine, + PluginWasmHostState { + tool_name: tool_name.into_bytes(), + input, + output: Vec::new(), + output_error: None, + store_limits, + }, + ); + store.limiter(|state| &mut state.store_limits); + store + .set_fuel(PLUGIN_WASM_FUEL) + .map_err(|error| PluginWasmError::Execution(error.to_string()))?; + + let mut linker = wasmi::Linker::::new(&engine); + define_plugin_wasm_host_imports(&mut linker)?; + let instance = linker + .instantiate_and_start(&mut store, &module) + .map_err(|error| PluginWasmError::Execution(error.to_string()))?; + let entry = instance + .get_typed_func::<(), ()>(&store, PLUGIN_WASM_ENTRYPOINT) + .map_err(|error| PluginWasmError::Module(error.to_string()))?; + entry + .call(&mut store, ()) + .map_err(|error| PluginWasmError::Execution(error.to_string()))?; + + if let Some(error) = store.data().output_error.clone() { + return Err(PluginWasmError::Output(error)); + } + decode_plugin_wasm_output(&store.data().output) +} + +fn validate_wasm_imports(module: &wasmi::Module) -> Result<(), PluginWasmError> { + for import in module.imports() { + if import.module() != PLUGIN_WASM_HOST_MODULE { + return Err(PluginWasmError::Module(format!( + "unsupported import module `{}`; only `{}` is available", + import.module(), + PLUGIN_WASM_HOST_MODULE + ))); + } + match import.name() { + "tool_name_len" | "tool_name_read" | "input_len" | "input_read" | "output_write" => {} + other => { + return Err(PluginWasmError::Module(format!( + "unsupported host import `{}`; no filesystem, network, environment, or WASI imports are available", + other + ))); + } + } + } + Ok(()) +} + +fn define_plugin_wasm_host_imports( + linker: &mut wasmi::Linker, +) -> Result<(), PluginWasmError> { + linker + .func_wrap( + PLUGIN_WASM_HOST_MODULE, + "tool_name_len", + |caller: wasmi::Caller<'_, PluginWasmHostState>| -> i32 { + caller.data().tool_name.len() as i32 + }, + ) + .map_err(|error| PluginWasmError::Module(error.to_string()))?; + linker + .func_wrap( + PLUGIN_WASM_HOST_MODULE, + "input_len", + |caller: wasmi::Caller<'_, PluginWasmHostState>| -> i32 { + caller.data().input.len() as i32 + }, + ) + .map_err(|error| PluginWasmError::Module(error.to_string()))?; + linker + .func_wrap( + PLUGIN_WASM_HOST_MODULE, + "tool_name_read", + |mut caller: wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32| -> i32 { + write_host_bytes_to_guest(&mut caller, ptr, len, HostBuffer::ToolName) + }, + ) + .map_err(|error| PluginWasmError::Module(error.to_string()))?; + linker + .func_wrap( + PLUGIN_WASM_HOST_MODULE, + "input_read", + |mut caller: wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32| -> i32 { + write_host_bytes_to_guest(&mut caller, ptr, len, HostBuffer::Input) + }, + ) + .map_err(|error| PluginWasmError::Module(error.to_string()))?; + linker + .func_wrap( + PLUGIN_WASM_HOST_MODULE, + "output_write", + |mut caller: wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32| -> i32 { + read_guest_output(&mut caller, ptr, len) + }, + ) + .map_err(|error| PluginWasmError::Module(error.to_string()))?; + Ok(()) +} + +#[derive(Clone, Copy, Debug)] +enum HostBuffer { + ToolName, + Input, +} + +fn write_host_bytes_to_guest( + caller: &mut wasmi::Caller<'_, PluginWasmHostState>, + ptr: i32, + len: i32, + buffer: HostBuffer, +) -> i32 { + if ptr < 0 || len < 0 { + return -1; + } + let bytes = match buffer { + HostBuffer::ToolName => caller.data().tool_name.clone(), + HostBuffer::Input => caller.data().input.clone(), + }; + if len as usize != bytes.len() { + return -1; + } + let Some(memory) = caller + .get_export("memory") + .and_then(|export| export.into_memory()) + else { + return -1; + }; + match memory.write(caller, ptr as usize, &bytes) { + Ok(()) => bytes.len() as i32, + Err(_) => -1, + } +} + +fn read_guest_output( + caller: &mut wasmi::Caller<'_, PluginWasmHostState>, + ptr: i32, + len: i32, +) -> i32 { + if ptr < 0 || len < 0 { + caller.data_mut().output_error = Some("guest output pointer/length is invalid".into()); + return -1; + } + let len = len as usize; + if len > PLUGIN_WASM_MAX_OUTPUT_BYTES { + caller.data_mut().output_error = Some(format!( + "guest output exceeds {} bytes", + PLUGIN_WASM_MAX_OUTPUT_BYTES + )); + return -1; + } + let Some(memory) = caller + .get_export("memory") + .and_then(|export| export.into_memory()) + else { + caller.data_mut().output_error = Some("guest did not export linear memory".into()); + return -1; + }; + let mut output = vec![0; len]; + if memory.read(&*caller, ptr as usize, &mut output).is_err() { + caller.data_mut().output_error = Some("guest output memory range is invalid".into()); + return -1; + } + caller.data_mut().output = output; + len as i32 +} + +fn decode_plugin_wasm_output(bytes: &[u8]) -> Result { + if bytes.is_empty() { + return Err(PluginWasmError::Output( + "guest did not call output_write".into(), + )); + } + if bytes.len() > PLUGIN_WASM_MAX_OUTPUT_BYTES { + return Err(PluginWasmError::Output(format!( + "guest output exceeds {} bytes", + PLUGIN_WASM_MAX_OUTPUT_BYTES + ))); + } + let text = std::str::from_utf8(bytes) + .map_err(|error| PluginWasmError::Output(format!("guest output is not UTF-8: {error}")))?; + let value: Value = serde_json::from_str(text).map_err(|error| { + PluginWasmError::Output(format!("guest output is not valid JSON: {error}")) + })?; + let Value::Object(map) = value else { + return Err(PluginWasmError::Output( + "guest output JSON must be an object".into(), + )); + }; + for key in map.keys() { + if key != "summary" && key != "content" { + return Err(PluginWasmError::Output(format!( + "guest output contains unsupported key `{key}`" + ))); + } + } + let summary = match map.get("summary") { + Some(Value::String(summary)) if !summary.is_empty() => summary.clone(), + Some(Value::String(_)) => { + return Err(PluginWasmError::Output( + "guest output summary must not be empty".into(), + )); + } + Some(_) => { + return Err(PluginWasmError::Output( + "guest output summary must be a string".into(), + )); + } + None => { + return Err(PluginWasmError::Output( + "guest output must include a summary string".into(), + )); + } + }; + if summary.len() > PLUGIN_WASM_MAX_SUMMARY_BYTES { + return Err(PluginWasmError::Output(format!( + "guest output summary exceeds {} bytes", + PLUGIN_WASM_MAX_SUMMARY_BYTES + ))); + } + let content = match map.get("content") { + Some(Value::String(content)) => { + if content.len() > PLUGIN_WASM_MAX_OUTPUT_BYTES { + return Err(PluginWasmError::Output(format!( + "guest output content exceeds {} bytes", + PLUGIN_WASM_MAX_OUTPUT_BYTES + ))); + } + Some(content.clone()) + } + Some(Value::Null) | None => None, + Some(_) => { + return Err(PluginWasmError::Output( + "guest output content must be a string or null".into(), + )); + } + }; + Ok(ToolOutput { summary, content }) +} + +fn bounded_message(message: impl Into) -> String { + let message = message.into(); + let mut sanitized = String::with_capacity(message.len().min(512)); + for ch in message.chars() { + if ch.is_control() && ch != '\n' && ch != '\t' { + sanitized.push(' '); + } else { + sanitized.push(ch); + } + if sanitized.len() >= 512 { + sanitized.truncate(512); + sanitized.push('…'); + break; + } + } + sanitized +} + fn validate_declared_tool_names(record: &ResolvedPluginRecord) -> Result<(), FeatureInstallError> { let mut seen = HashSet::new(); for tool in &record.manifest.tools { @@ -371,8 +751,14 @@ fn is_supported_schema_keyword(key: &str) -> bool { #[cfg(test)] mod tests { use super::*; - use manifest::plugin::{PluginPackageManifest, SourceQualifiedPluginId}; + use manifest::plugin::{ + PluginDiscoveryOptions, PluginEnablementConfig, PluginPackageManifest, + PluginRuntimeManifest, SourceQualifiedPluginId, resolve_plugin_config_for_startup, + }; use serde_json::json; + use std::fs; + use std::path::Path; + use tempfile::TempDir; fn tool(name: &str) -> manifest::plugin::PluginToolManifest { manifest::plugin::PluginToolManifest { @@ -645,27 +1031,349 @@ mod tests { } #[tokio::test] - async fn registered_tool_executes_as_runtime_missing_error() { - let mut pending = Vec::new(); - let mut hooks = crate::hook::HookRegistryBuilder::new(); - let report = super::super::FeatureRegistryBuilder::default() - .with_module(PluginToolFeature::new(record(vec![tool("PluginSearch")]))) - .install_into_pending(&mut pending, &mut hooks); - assert!( - report - .reports - .iter() - .all(|feature_report| feature_report.diagnostics.is_empty()), - "{:#?}", - report.reports - ); + async fn registered_plugin_tool_executes_wasm_and_returns_normal_tool_result() { + let (_dir, record) = resolved_record_with_wasm(input_reaches_guest_module()); + let origin = PluginToolFeature::new(record.clone()).origin(); + let tool = PluginWasmTool { + record, + name: "PluginEcho".into(), + origin, + }; + + let output = tool + .execute(r#"{"x":1}"#, ToolExecutionContext::default()) + .await + .unwrap(); + assert_eq!(output.summary, "input reached"); + assert_eq!(output.content.as_deref(), Some("ordinary tool result path")); + + let result = llm_worker::tool::ToolResult::from_output("call-1", output); + assert_eq!(result.summary, "input reached"); + assert!( + result + .content + .unwrap() + .contains("ordinary tool result path") + ); + } + + #[tokio::test] + async fn malformed_input_json_fails_before_wasm_execution() { + let (_dir, record) = resolved_record_with_wasm(input_reaches_guest_module()); + let origin = PluginToolFeature::new(record.clone()).origin(); + let tool = PluginWasmTool { + record, + name: "PluginEcho".into(), + origin, + }; + + let error = tool + .execute("not json", ToolExecutionContext::default()) + .await + .unwrap_err(); + assert!(error.to_string().contains("input is not valid JSON")); + } + + #[tokio::test] + async fn malformed_output_fails_closed() { + let (_dir, record) = resolved_record_with_wasm(output_module(b"not json")); + let error = + run_plugin_wasm_tool(record, "PluginEcho".into(), br#"{}"#.to_vec()).unwrap_err(); + assert!(error.bounded_message().contains("not valid JSON")); + } + + #[tokio::test] + async fn schema_mismatch_output_fails_closed() { + let (_dir, record) = resolved_record_with_wasm(output_module(br#"{"summary":1}"#)); + let error = + run_plugin_wasm_tool(record, "PluginEcho".into(), br#"{}"#.to_vec()).unwrap_err(); + assert!(error.bounded_message().contains("summary must be a string")); + } + + #[tokio::test] + async fn oversize_output_fails_closed() { + let (_dir, record) = resolved_record_with_wasm(oversize_output_module()); + let error = + run_plugin_wasm_tool(record, "PluginEcho".into(), br#"{}"#.to_vec()).unwrap_err(); + assert!(error.bounded_message().contains("exceeds")); + } + + #[tokio::test] + async fn nonterminating_execution_fails_closed_with_fuel_boundary() { + let (_dir, record) = resolved_record_with_wasm(nonterminating_module()); + let error = + run_plugin_wasm_tool(record, "PluginEcho".into(), br#"{}"#.to_vec()).unwrap_err(); + let message = error.bounded_message(); + assert!( + message.contains("Execution") + || message.contains("fuel") + || message.contains("execution"), + "{message}" + ); + } + + #[tokio::test] + async fn missing_runtime_module_returns_safe_bounded_tool_error() { + let record = record_with_missing_package_runtime(); + let origin = PluginToolFeature::new(record.clone()).origin(); + let tool = PluginWasmTool { + record, + name: "PluginSearch".into(), + origin, + }; - let (_, tool) = pending[0](); let error = tool .execute("{}", ToolExecutionContext::default()) .await .unwrap_err(); - assert!(error.to_string().contains("runtime missing/unavailable")); - assert!(error.to_string().contains("project:example")); + let message = error.to_string(); + assert!(message.contains("failed closed")); + assert!(message.contains("metadata could not be read")); + assert!(message.len() < 900); + assert!(message.contains("project:example")); + } + + #[tokio::test] + async fn ambient_wasi_fs_network_env_imports_are_unavailable() { + let (_dir, record) = resolved_record_with_wasm(wasi_import_module()); + let error = + run_plugin_wasm_tool(record, "PluginEcho".into(), br#"{}"#.to_vec()).unwrap_err(); + let message = error.bounded_message(); + assert!(message.contains("unsupported import module"), "{message}"); + assert!(message.contains("wasi_snapshot_preview1"), "{message}"); + } + + fn record_with_missing_package_runtime() -> ResolvedPluginRecord { + let mut record = record(vec![tool("PluginSearch")]); + record.manifest.runtime = Some(PluginRuntimeManifest { + kind: "wasm".into(), + entry: "plugin.wasm".into(), + abi: Some("yoi-plugin-wasm-1".into()), + }); + record + } + + fn resolved_record_with_wasm(wasm: Vec) -> (TempDir, ResolvedPluginRecord) { + let dir = TempDir::new().unwrap(); + let package_dir = dir.path().join(".yoi/plugins"); + fs::create_dir_all(&package_dir).unwrap(); + let package_path = package_dir.join("example.yoi-plugin"); + write_plugin_package(&package_path, &wasm); + let config = PluginConfig { + enabled: vec![PluginEnablementConfig { + id: "project:example".parse().unwrap(), + surfaces: vec![PluginSurface::Tool], + ..PluginEnablementConfig::default() + }], + resolved: Vec::new(), + diagnostics: Vec::new(), + }; + let options = PluginDiscoveryOptions::new(dir.path()); + let resolved = resolve_plugin_config_for_startup(&config, &options); + assert!( + resolved.diagnostics.is_empty(), + "{:#?}", + resolved.diagnostics + ); + assert_eq!(resolved.resolved.len(), 1); + (dir, resolved.resolved[0].clone()) + } + + fn write_plugin_package(path: &Path, wasm: &[u8]) { + let manifest = br#"schema_version = 1 +id = "example" +name = "Example" +version = "1.0.0" +description = "Example plugin" +surfaces = ["tool"] + +[runtime] +kind = "wasm" +entry = "plugin.wasm" +abi = "yoi-plugin-wasm-1" + +[[tools]] +name = "PluginEcho" +description = "Echo plugin tool" +input_schema = { type = "object", additionalProperties = true } +"#; + write_stored_zip( + path, + &[("plugin.toml", manifest.as_slice()), ("plugin.wasm", wasm)], + ); + } + + fn input_reaches_guest_module() -> Vec { + let ok = br#"{"summary":"input reached","content":"ordinary tool result path"}"#; + let bad = br#"{"summary":"input missing"}"#; + let wat = format!( + r#"(module + (import "yoi:tool" "input_len" (func $input_len (result i32))) + (import "yoi:tool" "input_read" (func $input_read (param i32 i32) (result i32))) + (import "yoi:tool" "output_write" (func $output_write (param i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 0) "{}") + (data (i32.const 128) "{}") + (func (export "yoi_tool_call") + (local $len i32) + (local.set $len (call $input_len)) + (if (i32.eq (local.get $len) (i32.const 7)) + (then + (drop (call $input_read (i32.const 512) (local.get $len))) + (if (i32.eq (i32.load8_u (i32.const 517)) (i32.const 49)) + (then (drop (call $output_write (i32.const 0) (i32.const {})))) + (else (drop (call $output_write (i32.const 128) (i32.const {})))) + ) + ) + (else (drop (call $output_write (i32.const 128) (i32.const {})))) + ) + ) + )"#, + wat_bytes(ok), + wat_bytes(bad), + ok.len(), + bad.len(), + bad.len() + ); + wat::parse_str(wat).unwrap() + } + + fn output_module(output: &[u8]) -> Vec { + let wat = format!( + r#"(module + (import "yoi:tool" "output_write" (func $output_write (param i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 0) "{}") + (func (export "yoi_tool_call") + (drop (call $output_write (i32.const 0) (i32.const {}))) + ) + )"#, + wat_bytes(output), + output.len() + ); + wat::parse_str(wat).unwrap() + } + + fn oversize_output_module() -> Vec { + let wat = format!( + r#"(module + (import "yoi:tool" "output_write" (func $output_write (param i32 i32) (result i32))) + (memory (export "memory") 2) + (func (export "yoi_tool_call") + (drop (call $output_write (i32.const 0) (i32.const {}))) + ) + )"#, + PLUGIN_WASM_MAX_OUTPUT_BYTES + 1 + ); + wat::parse_str(wat).unwrap() + } + + fn nonterminating_module() -> Vec { + wat::parse_str( + r#"(module + (memory (export "memory") 1) + (func (export "yoi_tool_call") + (local $remaining i32) + (local.set $remaining (i32.const 100000000)) + (loop $again + (local.set $remaining (i32.sub (local.get $remaining) (i32.const 1))) + (br_if $again (local.get $remaining)) + ) + ) + )"#, + ) + .unwrap() + } + + fn wasi_import_module() -> Vec { + wat::parse_str( + r#"(module + (import "wasi_snapshot_preview1" "fd_write" (func $fd_write)) + (memory (export "memory") 1) + (func (export "yoi_tool_call")) + )"#, + ) + .unwrap() + } + + fn wat_bytes(bytes: &[u8]) -> String { + bytes + .iter() + .map(|byte| format!(r#"\{:02x}"#, byte)) + .collect() + } + + fn write_stored_zip(path: &Path, files: &[(&str, &[u8])]) { + let mut out = Vec::new(); + let mut central = Vec::new(); + for (name, data) in files { + let offset = out.len() as u32; + let name_bytes = name.as_bytes(); + let crc = crc32(data); + write_u32(&mut out, 0x0403_4b50); + write_u16(&mut out, 20); + write_u16(&mut out, 0); + write_u16(&mut out, 0); + write_u16(&mut out, 0); + write_u16(&mut out, 0); + write_u32(&mut out, crc); + write_u32(&mut out, data.len() as u32); + write_u32(&mut out, data.len() as u32); + write_u16(&mut out, name_bytes.len() as u16); + write_u16(&mut out, 0); + out.extend_from_slice(name_bytes); + out.extend_from_slice(data); + + write_u32(&mut central, 0x0201_4b50); + write_u16(&mut central, 20); + write_u16(&mut central, 20); + write_u16(&mut central, 0); + write_u16(&mut central, 0); + write_u16(&mut central, 0); + write_u16(&mut central, 0); + write_u32(&mut central, crc); + write_u32(&mut central, data.len() as u32); + write_u32(&mut central, data.len() as u32); + write_u16(&mut central, name_bytes.len() as u16); + write_u16(&mut central, 0); + write_u16(&mut central, 0); + write_u16(&mut central, 0); + write_u16(&mut central, 0); + write_u32(&mut central, 0); + write_u32(&mut central, offset); + central.extend_from_slice(name_bytes); + } + let central_offset = out.len() as u32; + let central_size = central.len() as u32; + out.extend_from_slice(¢ral); + write_u32(&mut out, 0x0605_4b50); + write_u16(&mut out, 0); + write_u16(&mut out, 0); + write_u16(&mut out, files.len() as u16); + write_u16(&mut out, files.len() as u16); + write_u32(&mut out, central_size); + write_u32(&mut out, central_offset); + write_u16(&mut out, 0); + fs::write(path, out).unwrap(); + } + + fn write_u16(out: &mut Vec, value: u16) { + out.extend_from_slice(&value.to_le_bytes()); + } + + fn write_u32(out: &mut Vec, value: u32) { + out.extend_from_slice(&value.to_le_bytes()); + } + + fn crc32(data: &[u8]) -> u32 { + let mut crc = 0xffff_ffffu32; + for &byte in data { + crc ^= byte as u32; + for _ in 0..8 { + let mask = if crc & 1 == 1 { 0xedb8_8320 } else { 0 }; + crc = (crc >> 1) ^ mask; + } + } + !crc } } diff --git a/package.nix b/package.nix index 91f865e0..df9a6d74 100644 --- a/package.nix +++ b/package.nix @@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec { filter = sourceFilter; }; - cargoHash = "sha256-Y1siH1oDe9It7ntx83DJO5fzV9LtC7+qq9V6RPlRxUY="; + cargoHash = "sha256-ud+3INcXnT5W26Bz0K4QXUqoqw3p/ER9c4F2Fhq3YuQ="; depsExtraArgs = { # Older fetchCargoVendor utilities used crates.io's API download endpoint, From f2d4194f37d806f9cdd91e0aea46e3813fcf234a Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 18 Jun 2026 21:31:09 +0900 Subject: [PATCH 7/9] ticket: record plugin wasm runtime implementation --- .yoi/tickets/00001KV5W3PHW/item.md | 2 +- .yoi/tickets/00001KV5W3PHW/thread.md | 75 ++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KV5W3PHW/item.md b/.yoi/tickets/00001KV5W3PHW/item.md index 408234ef..2e73589a 100644 --- a/.yoi/tickets/00001KV5W3PHW/item.md +++ b/.yoi/tickets/00001KV5W3PHW/item.md @@ -2,7 +2,7 @@ title: 'Plugin: execute Plugin Tool with minimal WASM runtime' state: 'inprogress' created_at: '2026-06-15T14:48:59Z' -updated_at: '2026-06-18T12:05:59Z' +updated_at: '2026-06-18T12:31:01Z' assignee: null readiness: 'implementation_ready' risk_flags: ['plugin', 'wasm', 'tool-runtime', 'sandbox', 'capability-boundary', 'cancellation'] diff --git a/.yoi/tickets/00001KV5W3PHW/thread.md b/.yoi/tickets/00001KV5W3PHW/thread.md index 92145f86..e276fca1 100644 --- a/.yoi/tickets/00001KV5W3PHW/thread.md +++ b/.yoi/tickets/00001KV5W3PHW/thread.md @@ -147,3 +147,78 @@ Coder Pod: No duplicate `queued -> inprogress` transition was performed; this resumes the already accepted in-progress work. --- + + + +## Implementation report + +Implementation report from Coder Pod `yoi-coder-00001KV5W3PHW`: + +Commit: +- `10d12148 feat: run plugin tools through wasm runtime` + +Changed files: +- `Cargo.lock` +- `crates/manifest/src/plugin.rs` +- `crates/pod/Cargo.toml` +- `crates/pod/src/feature/plugin.rs` +- `package.nix` + +Implemented behavior: +- Added minimal sandboxed Plugin Tool WASM runtime using `wasmi`. +- Enabled registered Plugin Tool calls now execute through normal `Tool::execute` / `ToolOutput` / `ToolResult` paths. +- Added manifest-side helper to load resolved runtime module from pinned package: + - validates runtime kind/ABI; + - re-reads package from resolved path; + - verifies deterministic package digest before selecting runtime entry; + - bounds package/module size and reports safe diagnostics. +- Added `yoi-plugin-wasm-1` host ABI: + - required export: `yoi_tool_call()`; + - required guest export: `memory`; + - host imports under `yoi:tool` only: + - `tool_name_len() -> i32` + - `tool_name_read(ptr, len) -> i32` + - `input_len() -> i32` + - `input_read(ptr, len) -> i32` + - `output_write(ptr, len) -> i32` +- No WASI, filesystem, network, environment, or arbitrary host imports are provided; unsupported imports fail closed. + +Bounds / failure behavior: +- Input JSON bound: `64 KiB`. +- Output JSON bound: `64 KiB`. +- Output summary bound: `1 KiB`. +- Linear memory bound: `2 MiB`. +- Table bound: `256`. +- Fuel bound: `5_000_000`. +- Wall timeout: `1s`. +- Diagnostics/errors are bounded and sanitized before surfacing as ordinary Tool errors. +- Malformed input JSON, malformed output JSON, output schema mismatch, oversized output, missing runtime/package/module, unsupported imports, load failure, and non-terminating execution fail closed. + +Tests added/updated cover: +- Sample Plugin package Tool executes through WASM runtime. +- Tool input JSON reaches the module. +- Output JSON returns through ordinary Tool result path. +- Malformed input JSON. +- Malformed output JSON. +- Output schema mismatch. +- Oversize output. +- Non-terminating/high-fuel execution fail closed. +- Missing runtime/package/module diagnostic is safe and bounded. +- No ambient WASI/fs/network/env import access. +- Existing Plugin Tool registration behavior. + +Validation reported by coder: +- Passed: `cargo fmt --check` +- Passed: `cargo check -p pod` +- Passed: `cargo test -p manifest plugin::` +- Passed: `cargo test -p pod feature::plugin` +- Passed: `git diff --check` +- Passed: `nix build .#yoi` +- Passed: `nix build .#yoi --no-link` + +Residual risks / notes: +- ABI is intentionally minimal and not a general Plugin capability model. +- Permission grants, richer host APIs, WASI-like APIs, per-plugin runtime configuration knobs, and schema-driven rich output contracts remain follow-up work. +- Cancellation has no explicit `ToolExecutionContext` cancellation token today; this implementation provides safe timeout/fuel-bounded execution and maps timeout/join failure into ordinary Tool errors. + +--- From c05bfaa9c4bcca521c3b2cb93b490a6b0c15390a Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 18 Jun 2026 21:37:21 +0900 Subject: [PATCH 8/9] ticket: approve plugin wasm runtime --- .yoi/tickets/00001KV5W3PHW/item.md | 2 +- .yoi/tickets/00001KV5W3PHW/thread.md | 44 ++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KV5W3PHW/item.md b/.yoi/tickets/00001KV5W3PHW/item.md index 2e73589a..797a3946 100644 --- a/.yoi/tickets/00001KV5W3PHW/item.md +++ b/.yoi/tickets/00001KV5W3PHW/item.md @@ -2,7 +2,7 @@ title: 'Plugin: execute Plugin Tool with minimal WASM runtime' state: 'inprogress' created_at: '2026-06-15T14:48:59Z' -updated_at: '2026-06-18T12:31:01Z' +updated_at: '2026-06-18T12:37:14Z' assignee: null readiness: 'implementation_ready' risk_flags: ['plugin', 'wasm', 'tool-runtime', 'sandbox', 'capability-boundary', 'cancellation'] diff --git a/.yoi/tickets/00001KV5W3PHW/thread.md b/.yoi/tickets/00001KV5W3PHW/thread.md index e276fca1..726bc49b 100644 --- a/.yoi/tickets/00001KV5W3PHW/thread.md +++ b/.yoi/tickets/00001KV5W3PHW/thread.md @@ -222,3 +222,47 @@ Residual risks / notes: - Cancellation has no explicit `ToolExecutionContext` cancellation token today; this implementation provides safe timeout/fuel-bounded execution and maps timeout/join failure into ordinary Tool errors. --- + + + +## Review: approve + +Review result: approve + +Evidence checked: +- Child worktree のみ確認。 + - `/home/hare/Projects/yoi/.worktree/00001KV5W3PHW-plugin-wasm-tool-runtime` + - implementation commit: `10d12148` + - base: `d32fb3bc` +- 変更箇所を確認。 + - `crates/pod/src/feature/plugin.rs` + - `crates/manifest/src/plugin.rs` + - `crates/pod/Cargo.toml` + - `Cargo.lock` + - `package.nix` +- Read-only validation: + - Passed: `git diff --check d32fb3bc..10d12148` + +Acceptance review: +- WASM runtime は `wasmi` を使い、WASI setup はなく、host import surface は `yoi_plugin_tool_v1` の最小 input/output 関数だけに限定されている。 +- `wasi_snapshot_preview1.fd_write` や `env.getenv` など ambient-style imports は拒否される。 +- Plugin Tools は ordinary `Feature` tool contribution / `ToolExecutor` path で `ToolOutput` / `ToolError` を返し、hidden context injection path は見当たらない。 +- module/input/output/summary/error/memory/table/stack/fuel などの bounds が実装されている。 +- malformed output、invalid UTF-8、schema mismatch、oversize output は `ToolError` として fail closed する。 +- runtime module selection は resolved plugin record、resolved package path、recorded digest、runtime kind/ABI、entry path に基づき、archive digest を再検証してから module bytes を使用している。 +- non-termination は fuel exhaustion と runtime timeout wrapper で fail closed する。現行 `ToolExecutionContext` に cancellation token がないため、明示的 cancellation-token integration がない点は、この Ticket の範囲では blocker ではないと判断した。 +- `wasmi` は `default-features = false` で `std` / `extra-checks` のみ、`wat` は dev-only。WASI/network/process capability library の追加は見当たらない。 + +Validation not run by reviewer: +- `cargo test` / `cargo check` / `nix build` は read-only review scope では build artifacts を書くため未実行。Coder reported validation を evidence とした。 + +Risk assessment: +- residual risk は低〜中程度。 +- sandbox boundary は Ticket scope に対して妥当。No WASI / no fs / no network / no env imports、bounded IO、bounded diagnostics、deterministic digest check がある。 +- `tokio::time::timeout` は `spawn_blocking` の実行中 task を強制 abort しないが、fuel により guest execution は有限化される。より強い cooperative cancellation semantics は Tool execution API/runtime cancellation model の follow-up として扱うべき。 +- Permission grants / richer host API authority は正しく未実装の follow-up に残っている。 + +Conclusion: +- Approved. No blocker remains. + +--- From 6641bf4860ada667f5393a11419c5a06349b9edc Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 18 Jun 2026 21:39:40 +0900 Subject: [PATCH 9/9] ticket: complete plugin wasm runtime --- .yoi/tickets/00001KV5W3PHW/item.md | 4 +-- .yoi/tickets/00001KV5W3PHW/thread.md | 44 ++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KV5W3PHW/item.md b/.yoi/tickets/00001KV5W3PHW/item.md index 797a3946..5a9ef879 100644 --- a/.yoi/tickets/00001KV5W3PHW/item.md +++ b/.yoi/tickets/00001KV5W3PHW/item.md @@ -1,8 +1,8 @@ --- title: 'Plugin: execute Plugin Tool with minimal WASM runtime' -state: 'inprogress' +state: 'done' created_at: '2026-06-15T14:48:59Z' -updated_at: '2026-06-18T12:37:14Z' +updated_at: '2026-06-18T12:39:30Z' assignee: null readiness: 'implementation_ready' risk_flags: ['plugin', 'wasm', 'tool-runtime', 'sandbox', 'capability-boundary', 'cancellation'] diff --git a/.yoi/tickets/00001KV5W3PHW/thread.md b/.yoi/tickets/00001KV5W3PHW/thread.md index 726bc49b..e2dedec2 100644 --- a/.yoi/tickets/00001KV5W3PHW/thread.md +++ b/.yoi/tickets/00001KV5W3PHW/thread.md @@ -266,3 +266,47 @@ Conclusion: - Approved. No blocker remains. --- + + + +## Implementation report + +Integration report: + +Merged implementation branch into orchestration branch: +- branch: `impl/00001KV5W3PHW-plugin-wasm-tool-runtime` +- implementation commit: `10d12148 feat: run plugin tools through wasm runtime` +- merge commit: `05cd788c merge: plugin wasm tool runtime` + +Reviewer result: +- `approve` recorded from `yoi-reviewer-00001KV5W3PHW`. + +Validation in Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration`: +- Passed: `cargo fmt --check` +- Passed: `cargo check -p pod` +- Passed: `cargo test -p manifest plugin::` +- Passed: `cargo test -p pod feature::plugin` + - 21 passed +- Passed: `git diff --check` +- Passed: `nix build .#yoi --no-link` + +Outcome: +- Enabled Plugin Tool calls now execute through minimal `wasmi` runtime with no WASI/fs/network/env host imports. +- Runtime module selection is deterministic from resolved package path/digest/runtime config and rechecks package digest. +- Tool input/output/errors are bounded and returned through ordinary Tool result/error path. +- Missing/malformed/oversize/schema-mismatch/non-terminating execution fails closed with safe Tool errors. +- Permission grants, richer host APIs, WASI-like APIs, and stronger cancellation-token integration remain follow-up scope. + +Cleanup planned: +- Stop related coder/reviewer Pods. +- Remove only child implementation worktree/branch for this Ticket. + +--- + + + +## State changed + +Reviewer approved, implementation branch merged into the orchestration branch, and focused plus packaging validation passed in the Orchestrator worktree. Marking Ticket done in the orchestration branch. + +---