From f65f0e3b8fef6c1e5a547986c3ab6c251bc54ebd Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 05:13:59 +0900 Subject: [PATCH 01/46] ticket: route plugin runtime cleanup chain --- .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KVXK0WD3/item.md | 2 +- .yoi/tickets/00001KVXK0WD3/thread.md | 74 +++++++++++++++++++ .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KVXK0WDH/item.md | 2 +- .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KVXK0WDQ/item.md | 2 +- .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KVXK0WDX/item.md | 2 +- .../artifacts/orchestration-plan.jsonl | 2 + .yoi/tickets/00001KVXK0WE4/item.md | 2 +- .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KVXK0WEA/item.md | 2 +- 13 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 .yoi/tickets/00001KVXK0WD3/artifacts/orchestration-plan.jsonl create mode 100644 .yoi/tickets/00001KVXK0WDH/artifacts/orchestration-plan.jsonl create mode 100644 .yoi/tickets/00001KVXK0WDQ/artifacts/orchestration-plan.jsonl create mode 100644 .yoi/tickets/00001KVXK0WDX/artifacts/orchestration-plan.jsonl create mode 100644 .yoi/tickets/00001KVXK0WE4/artifacts/orchestration-plan.jsonl create mode 100644 .yoi/tickets/00001KVXK0WEA/artifacts/orchestration-plan.jsonl diff --git a/.yoi/tickets/00001KVXK0WD3/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KVXK0WD3/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..1ab5bd5b --- /dev/null +++ b/.yoi/tickets/00001KVXK0WD3/artifacts/orchestration-plan.jsonl @@ -0,0 +1 @@ +{"id":"orch-plan-20260624-201247-1","ticket_id":"00001KVXK0WD3","kind":"accepted_plan","accepted_plan":{"summary":"Ticket `00001KVXK0WD3` は dependency chain の先頭で implementation_ready。専用 worktree `/home/hare/Projects/yoi/.worktree/00001KVXK0WD3-remove-legacy-wasm-runtime` と branch `work/00001KVXK0WD3-remove-legacy-wasm-runtime` で、Pod runtime 内の `LegacyToolAdapter` / raw-WASM active execution path を削除し、Component Model path を唯一の active execution path にする。Manifest/CLI diagnostics rejection は後続 `00001KVXK0WDH` の範囲に残す。","branch":"work/00001KVXK0WD3-remove-legacy-wasm-runtime","worktree":"/home/hare/Projects/yoi/.worktree/00001KVXK0WD3-remove-legacy-wasm-runtime","role_plan":"Orchestrator: accept/routing, worktree creation, final integration/validation/cleanup. Coder: remove active legacy raw-WASM runtime path in dedicated child worktree. Reviewer: read-only review focusing on preserving component Tool execution, grants, discovery/enablement/ToolRegistry, and not implementing manifest/CLI rejection slice."},"author":"yoi-orchestrator","at":"2026-06-24T20:12:47Z"} diff --git a/.yoi/tickets/00001KVXK0WD3/item.md b/.yoi/tickets/00001KVXK0WD3/item.md index e0ac4924..d0699917 100644 --- a/.yoi/tickets/00001KVXK0WD3/item.md +++ b/.yoi/tickets/00001KVXK0WD3/item.md @@ -2,7 +2,7 @@ title: 'Remove legacy raw WASM Plugin runtime' state: 'queued' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-24T20:11:56Z' +updated_at: '2026-06-24T20:13:18Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:11:56Z' diff --git a/.yoi/tickets/00001KVXK0WD3/thread.md b/.yoi/tickets/00001KVXK0WD3/thread.md index 85ba252c..e48fbb70 100644 --- a/.yoi/tickets/00001KVXK0WD3/thread.md +++ b/.yoi/tickets/00001KVXK0WD3/thread.md @@ -30,4 +30,78 @@ Marked ready by `yoi ticket state`. Ticket を `workspace-panel` が queued にしました。 +--- + + + +## Decision + +Routing decision: implementation_ready + +Reason: +- Dashboard Queue により人間が Orchestrator routing を許可した queued Ticket として確認した。 +- Ticket body は legacy raw `wasm` runtime redesign のうち最初の concrete slice として、Pod runtime 内の `PluginInstanceRuntime::LegacyToolAdapter` 相当 active execution path を削除し、`wasm-component` / Component Model path を唯一の active runtime path にする範囲に限定している。 +- Manifest / CLI diagnostics rejection、Service / Ingress event queue、WebSocket driver、WIT / PDK / templates は明示的に non-goal / 後続 Ticket に分割されている。 +- `TicketRelationQuery` は 1 件で、この Ticket は後続 Ticket から参照される dependency chain の先頭であり、blocking outgoing dependency はない。 +- `TicketOrchestrationPlanQuery` は routing 前 plan 0 件。accepted plan `orch-plan-20260624-201247-1` を記録済み。 +- bounded context check で `crates/pod/src/feature/plugin.rs` の `LegacyToolAdapter` / raw `wasm` active path、`crates/manifest/src/plugin.rs` と `crates/yoi/src/plugin_cli.rs` の legacy manifest/diagnostic fixtures、docs の transitional runtime 記述を確認した。Ticket は manifest rejection を後続 Ticket に分けているため、残る不確実性は local implementation / test update に収まる。 + +Evidence checked: +- Ticket body / thread: `item.md`, `thread.md`。thread は create、planning->ready、ready->queued のみで未解決 blocker は記録されていない。 +- Relations / orchestration plan: relation 1 件(後続 Ticket がこの Ticket に依存する view)、routing 前 plan 0 件。 +- Code/docs context: `crates/pod/src/feature/plugin.rs` の `LegacyToolAdapter` / raw WASM handling、`crates/manifest/src/plugin.rs` / `crates/yoi/src/plugin_cli.rs` の legacy runtime constants/tests、Plugin docs の current transition notes。 +- Workspace state: `/home/hare/Projects/yoi/.worktree/orchestration` は clean。inprogress Ticket は 0 件。 +- Queue context: 他の queued Plugin follow-up Tickets は dependency chain 上でこの Ticket の完了後に進める。 + +IntentPacket: + +Intent: +- Pod Plugin runtime の active execution path から legacy raw core-WASM Tool adapter を削除し、Plugin Tool execution を Component Model runtime path に一本化する。 + +Binding decisions / invariants: +- この Ticket では active runtime execution path を整理する。Manifest / CLI の外向き rejection UX は後続 `00001KVXK0WDH` の範囲として残す。 +- Component Model Plugin Tool execution、Host API grant boundary、package discovery、enablement、digest pinning、ToolRegistry registration は維持する。 +- raw core-WASM path を compatibility fallback として active execution に残さない。 +- Service / Ingress event runtime、WebSocket driver、WIT/PDK/templates service event update は実装しない。 +- broad Plugin redesign や public registry/install/update policy に範囲を広げない。 + +Requirements / acceptance criteria: +- `PluginInstanceRuntime::LegacyToolAdapter` または同等の legacy raw-WASM adapter が active runtime から削除される。 +- raw `wasm` runtime を通じた Plugin Tool execution path が使われない。 +- Component Model Plugin Tool execution tests が通る。 +- Legacy runtime 削除に伴う dead code / dead tests / obsolete fixtures が整理される。 +- Discovery / enablement / Tool registration / grant validation の既存挙動が壊れない。 + +Implementation latitude: +- `PluginInstanceRuntime` enum の shape を単一 component path に畳むか、名前を残して variant を整理するかは既存 code style に合わせてよい。 +- Manifest layer の legacy constants/tests は、active runtime removal に必要な最小限だけ調整してよい。ただし user-facing rejection/diagnostic completion は後続 Ticket に残す。 +- Docs の compatibility/transitional wording は、active runtime removal と矛盾する部分を最小限更新してよい。 + +Escalate if: +- Component Model path だけでは既存 Tool execution / grant validation / ToolRegistry registration を維持できない。 +- Manifest/CLI outward rejection を同時実装しないと build/test が成立しない。 +- Wasmtime/component runtime limits or sandbox boundaries を再設計する必要がある。 +- Service/Ingress/WebSocket/WIT/PDK の設計変更が必要になる。 + +Validation: +- `cargo test -p pod` +- `cargo check -p yoi` +- `git diff --check` +- `nix build .#yoi --no-link` +- 必要に応じて focused Plugin tests / `cargo test -p manifest` / `cargo test -p yoi plugin_cli`。 + +Current code/docs map: +- Primary: `crates/pod/src/feature/plugin.rs`。 +- Secondary: `crates/manifest/src/plugin.rs`, `crates/yoi/src/plugin_cli.rs`, Plugin docs/templates only as needed for active runtime removal consistency。 +- Avoid: full service runtime, WebSocket driver, WIT/PDK event model, remote plugin registry, root/original workspace operations。 + +Critical risks / reviewer focus: +- legacy raw-WASM execution path accidentally remains as fallback。 +- Component Model Tool execution regression。 +- host API grant / digest pinning / enablement / ToolRegistry registration regression。 +- scope creep into manifest rejection or Service/Ingress runtime beyond this slice。 + +Next action: +- `queued -> inprogress` を記録してから worktree-workflow で `/home/hare/Projects/yoi/.worktree/00001KVXK0WD3-remove-legacy-wasm-runtime` を作成し、multi-agent-workflow で Coder/Reviewer sibling loop に進める。 + --- diff --git a/.yoi/tickets/00001KVXK0WDH/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KVXK0WDH/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..08df497d --- /dev/null +++ b/.yoi/tickets/00001KVXK0WDH/artifacts/orchestration-plan.jsonl @@ -0,0 +1 @@ +{"id":"orch-plan-20260624-201335-1","ticket_id":"00001KVXK0WDH","kind":"blocked_by","related_ticket":"00001KVXK0WD3","note":"Queue review: `00001KVXK0WDH` は manifest/CLI rejection slice だが、active legacy runtime path removal `00001KVXK0WD3` に depends_on している。`00001KVXK0WD3` を先に受理し、この Ticket は dependency completion 後に再 routing する。","author":"yoi-orchestrator","at":"2026-06-24T20:13:35Z"} diff --git a/.yoi/tickets/00001KVXK0WDH/item.md b/.yoi/tickets/00001KVXK0WDH/item.md index 5ef659e2..3755d988 100644 --- a/.yoi/tickets/00001KVXK0WDH/item.md +++ b/.yoi/tickets/00001KVXK0WDH/item.md @@ -2,7 +2,7 @@ title: 'Reject legacy Plugin runtime in manifest and CLI diagnostics' state: 'queued' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-24T20:11:58Z' +updated_at: '2026-06-24T20:13:35Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:11:58Z' diff --git a/.yoi/tickets/00001KVXK0WDQ/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KVXK0WDQ/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..17d18943 --- /dev/null +++ b/.yoi/tickets/00001KVXK0WDQ/artifacts/orchestration-plan.jsonl @@ -0,0 +1 @@ +{"id":"orch-plan-20260624-201335-1","ticket_id":"00001KVXK0WDQ","kind":"blocked_by","related_ticket":"00001KVXK0WDH","note":"Queue review: `00001KVXK0WDQ` は Service lifecycle / ingress queue runtime slice だが、Component Model-only runtime authority / manifest rejection slice `00001KVXK0WDH` に depends_on している。prerequisite completion 後に再 routing する。","author":"yoi-orchestrator","at":"2026-06-24T20:13:35Z"} diff --git a/.yoi/tickets/00001KVXK0WDQ/item.md b/.yoi/tickets/00001KVXK0WDQ/item.md index 0e6a174c..957ae605 100644 --- a/.yoi/tickets/00001KVXK0WDQ/item.md +++ b/.yoi/tickets/00001KVXK0WDQ/item.md @@ -2,7 +2,7 @@ title: 'Define Plugin Service lifecycle and ingress queue runtime' state: 'queued' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-24T20:12:00Z' +updated_at: '2026-06-24T20:13:35Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:12:00Z' diff --git a/.yoi/tickets/00001KVXK0WDX/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KVXK0WDX/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..b85a9c6d --- /dev/null +++ b/.yoi/tickets/00001KVXK0WDX/artifacts/orchestration-plan.jsonl @@ -0,0 +1 @@ +{"id":"orch-plan-20260624-201335-1","ticket_id":"00001KVXK0WDX","kind":"blocked_by","related_ticket":"00001KVXK0WDQ","note":"Queue review: `00001KVXK0WDX` は service output command model slice だが、output commands are returned by service ingress dispatch のため `00001KVXK0WDQ` に depends_on している。prerequisite completion 後に再 routing する。","author":"yoi-orchestrator","at":"2026-06-24T20:13:35Z"} diff --git a/.yoi/tickets/00001KVXK0WDX/item.md b/.yoi/tickets/00001KVXK0WDX/item.md index 154986c1..d444cfba 100644 --- a/.yoi/tickets/00001KVXK0WDX/item.md +++ b/.yoi/tickets/00001KVXK0WDX/item.md @@ -2,7 +2,7 @@ title: 'Add Plugin service output command model' state: 'queued' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-24T20:12:02Z' +updated_at: '2026-06-24T20:13:35Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:12:02Z' diff --git a/.yoi/tickets/00001KVXK0WE4/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KVXK0WE4/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..66d80a4b --- /dev/null +++ b/.yoi/tickets/00001KVXK0WE4/artifacts/orchestration-plan.jsonl @@ -0,0 +1,2 @@ +{"id":"orch-plan-20260624-201335-1","ticket_id":"00001KVXK0WE4","kind":"blocked_by","related_ticket":"00001KVXK0WDQ","note":"Queue review: `00001KVXK0WE4` は WebSocket driver slice だが、service ingress queue `00001KVXK0WDQ` と output command model `00001KVXK0WDX` に depends_on している。両 prerequisite completion 後に再 routing する。","author":"yoi-orchestrator","at":"2026-06-24T20:13:35Z"} +{"id":"orch-plan-20260624-201341-2","ticket_id":"00001KVXK0WE4","kind":"blocked_by","related_ticket":"00001KVXK0WDX","note":"Queue review: `00001KVXK0WE4` は output command dispatch/grant model `00001KVXK0WDX` にも depends_on している。`00001KVXK0WDQ` と `00001KVXK0WDX` の両方が完了するまで acceptance しない。","author":"yoi-orchestrator","at":"2026-06-24T20:13:41Z"} diff --git a/.yoi/tickets/00001KVXK0WE4/item.md b/.yoi/tickets/00001KVXK0WE4/item.md index 8cef2761..0788ffe9 100644 --- a/.yoi/tickets/00001KVXK0WE4/item.md +++ b/.yoi/tickets/00001KVXK0WE4/item.md @@ -2,7 +2,7 @@ title: 'Add host-owned WebSocket driver for Plugin services' state: 'queued' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-24T20:12:03Z' +updated_at: '2026-06-24T20:13:41Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:12:03Z' diff --git a/.yoi/tickets/00001KVXK0WEA/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KVXK0WEA/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..af9a6809 --- /dev/null +++ b/.yoi/tickets/00001KVXK0WEA/artifacts/orchestration-plan.jsonl @@ -0,0 +1 @@ +{"id":"orch-plan-20260624-201335-1","ticket_id":"00001KVXK0WEA","kind":"blocked_by","related_ticket":"00001KVXK0WE4","note":"Queue review: `00001KVXK0WEA` は WIT/PDK/templates finishing slice で、implemented WebSocket event/command model `00001KVXK0WE4` に depends_on している。prerequisite completion 後に再 routing する。","author":"yoi-orchestrator","at":"2026-06-24T20:13:35Z"} diff --git a/.yoi/tickets/00001KVXK0WEA/item.md b/.yoi/tickets/00001KVXK0WEA/item.md index d3fa144c..6f495bfe 100644 --- a/.yoi/tickets/00001KVXK0WEA/item.md +++ b/.yoi/tickets/00001KVXK0WEA/item.md @@ -2,7 +2,7 @@ title: 'Update Plugin WIT PDK templates for service event runtime' state: 'queued' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-24T20:12:05Z' +updated_at: '2026-06-24T20:13:35Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:12:05Z' From 1b5a39dbc91ff995b0caf2eba3b90c01f9670b47 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 05:14:13 +0900 Subject: [PATCH 02/46] ticket: accept legacy wasm removal task --- .yoi/tickets/00001KVXK0WD3/item.md | 4 ++-- .yoi/tickets/00001KVXK0WD3/thread.md | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KVXK0WD3/item.md b/.yoi/tickets/00001KVXK0WD3/item.md index d0699917..9e8c2139 100644 --- a/.yoi/tickets/00001KVXK0WD3/item.md +++ b/.yoi/tickets/00001KVXK0WD3/item.md @@ -1,8 +1,8 @@ --- title: 'Remove legacy raw WASM Plugin runtime' -state: 'queued' +state: 'inprogress' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-24T20:13:18Z' +updated_at: '2026-06-24T20:14:08Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:11:56Z' diff --git a/.yoi/tickets/00001KVXK0WD3/thread.md b/.yoi/tickets/00001KVXK0WD3/thread.md index e48fbb70..f16fcc94 100644 --- a/.yoi/tickets/00001KVXK0WD3/thread.md +++ b/.yoi/tickets/00001KVXK0WD3/thread.md @@ -105,3 +105,16 @@ Next action: - `queued -> inprogress` を記録してから worktree-workflow で `/home/hare/Projects/yoi/.worktree/00001KVXK0WD3-remove-legacy-wasm-runtime` を作成し、multi-agent-workflow で Coder/Reviewer sibling loop に進める。 --- + + + +## State changed + +Orchestrator acceptance: queued -> inprogress + +- 直前確認で `TicketShow` は state `queued`、blocking outgoing relation は 0 件、accepted plan `orch-plan-20260624-201247-1` を確認した。 +- 後続 queued Tickets (`00001KVXK0WDH`, `00001KVXK0WDQ`, `00001KVXK0WDX`, `00001KVXK0WE4`, `00001KVXK0WEA`) は dependency chain 上この Ticket またはその後続 prerequisites に blocked として orchestration plan に記録済み。 +- routing decision と IntentPacket は Ticket thread に記録済み。 +- これ以降に worktree-workflow で `/home/hare/Projects/yoi/.worktree/00001KVXK0WD3-remove-legacy-wasm-runtime` を作成し、multi-agent-workflow に接続する。 + +--- From 27117f3246395867a1e619473c1892220e25fbe4 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 05:15:02 +0900 Subject: [PATCH 03/46] ticket: record legacy wasm coder start --- .yoi/tickets/00001KVXK0WD3/item.md | 2 +- .yoi/tickets/00001KVXK0WD3/thread.md | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVXK0WD3/item.md b/.yoi/tickets/00001KVXK0WD3/item.md index 9e8c2139..7115167c 100644 --- a/.yoi/tickets/00001KVXK0WD3/item.md +++ b/.yoi/tickets/00001KVXK0WD3/item.md @@ -2,7 +2,7 @@ title: 'Remove legacy raw WASM Plugin runtime' state: 'inprogress' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-24T20:14:08Z' +updated_at: '2026-06-24T20:14:56Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:11:56Z' diff --git a/.yoi/tickets/00001KVXK0WD3/thread.md b/.yoi/tickets/00001KVXK0WD3/thread.md index f16fcc94..4b76638b 100644 --- a/.yoi/tickets/00001KVXK0WD3/thread.md +++ b/.yoi/tickets/00001KVXK0WD3/thread.md @@ -118,3 +118,18 @@ Orchestrator acceptance: queued -> inprogress - これ以降に worktree-workflow で `/home/hare/Projects/yoi/.worktree/00001KVXK0WD3-remove-legacy-wasm-runtime` を作成し、multi-agent-workflow に接続する。 --- + + + +## Implementation report + +Implementation orchestration update: + +- queue review で dependency chain を確認し、`00001KVXK0WD3` のみを先頭 prerequisite として受理した。 +- 後続 queued Tickets (`00001KVXK0WDH`, `00001KVXK0WDQ`, `00001KVXK0WDX`, `00001KVXK0WE4`, `00001KVXK0WEA`) は relation/orchestration plan 上の dependency blocker として待機理由を記録した。 +- worktree-workflow に従い、implementation worktree `/home/hare/Projects/yoi/.worktree/00001KVXK0WD3-remove-legacy-wasm-runtime` と branch `work/00001KVXK0WD3-remove-legacy-wasm-runtime` を作成した。 +- child worktree では tracked `.yoi` project records を見える状態にし、`.yoi/memory`、local/runtime/log/lock/secret-like `.yoi` paths を sparse-checkout で除外する方針にした。 +- Coder Pod `yoi-coder-00001KVXK0WD3-legacy-wasm-removal` を sibling として起動した。write scope は child worktree に限定し、runtime workspace root `/home/hare/Projects/yoi` は startup 要件を満たすため non-recursive read のみにした。 +- Coder には root/original workspace と orchestration worktree で read/write/validation/git operation を行わないよう明示した。 + +--- From 741d71327aec213e564d47a4b4c37141c727803c Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 05:42:52 +0900 Subject: [PATCH 04/46] refactor: remove legacy wasm plugin runtime --- Cargo.lock | 67 -- crates/pod/Cargo.toml | 1 - crates/pod/src/feature/plugin.rs | 1431 +++---------------------- docs/design/plugin-component-model.md | 13 +- package.nix | 2 +- 5 files changed, 160 insertions(+), 1354 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4205c9b3..1c90d22f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2913,7 +2913,6 @@ dependencies = [ "tracing", "tungstenite", "uuid", - "wasmi", "wasmtime", "wat", "workflow", @@ -4035,12 +4034,6 @@ 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" @@ -4053,16 +4046,6 @@ 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" @@ -5096,56 +5079,6 @@ 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" diff --git a/crates/pod/Cargo.toml b/crates/pod/Cargo.toml index f5ddde35..900abc08 100644 --- a/crates/pod/Cargo.toml +++ b/crates/pod/Cargo.toml @@ -37,7 +37,6 @@ 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"] } wasmtime = { version = "45.0.2", default-features = false, features = ["std", "runtime", "cranelift", "component-model"] } tungstenite = { version = "0.28.0", default-features = false, features = ["handshake", "native-tls", "url"] } tokio-tungstenite = { version = "0.28.0", default-features = false, features = ["native-tls", "connect"] } diff --git a/crates/pod/src/feature/plugin.rs b/crates/pod/src/feature/plugin.rs index 60306f19..959e7175 100644 --- a/crates/pod/src/feature/plugin.rs +++ b/crates/pod/src/feature/plugin.rs @@ -1,12 +1,11 @@ //! Plugin package contributions for model-visible Tool schemas. //! //! 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, environment, hook, service, -//! ingress, or ambient network authority. WASM Tools can only reach outbound Request -//! through the explicit `yoi:request` host import, and filesystem read/list/write -//! through the explicit `yoi:fs` host import, with matching permissions and -//! scoped allowlist grants. +//! executes Tool calls through the sandboxed Component Model `wasm-component` +//! runtime. It deliberately does not grant filesystem, environment, hook, +//! service, ingress, or ambient network authority. Components can only reach +//! host APIs through explicit imports with matching permissions and scoped +//! allowlist grants. use std::collections::{HashMap, HashSet}; use std::fs; @@ -23,10 +22,10 @@ use llm_worker::tool::{ }; use manifest::plugin::{ PLUGIN_COMPONENT_INSTANCE_WORLD, PLUGIN_COMPONENT_TOOL_WORLD, PLUGIN_RUNTIME_COMPONENT_KIND, - PLUGIN_RUNTIME_WASM_ABI, PLUGIN_RUNTIME_WASM_KIND, PluginConfig, PluginDiscoveryLimits, - PluginFsGrant, PluginFsOperation, PluginHostApi, PluginPermission, PluginRequestGrant, - PluginSurface, PluginToolManifest, PluginWebSocketGrant, ResolvedPluginRecord, - read_resolved_plugin_runtime_component, read_resolved_plugin_runtime_module, + PLUGIN_RUNTIME_WASM_KIND, PluginConfig, PluginDiscoveryLimits, PluginFsGrant, + PluginFsOperation, PluginHostApi, PluginPermission, PluginRequestGrant, PluginSurface, + PluginToolManifest, PluginWebSocketGrant, ResolvedPluginRecord, + read_resolved_plugin_runtime_component, }; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -215,29 +214,6 @@ pub struct PluginSurfaceEligibility { /// Inspect static plugin runtime/tool eligibility without executing plugin code. pub fn inspect_resolved_plugin_static(record: &ResolvedPluginRecord) -> PluginStaticInspection { let runtime = match &record.manifest.runtime { - Some(runtime) - if runtime.kind == PLUGIN_RUNTIME_WASM_KIND - && runtime.abi.as_deref() == Some(PLUGIN_RUNTIME_WASM_ABI) - && runtime.entry.is_some() => - { - PluginRuntimeEligibility { - eligible: true, - status: format!("{PLUGIN_RUNTIME_WASM_KIND}/{PLUGIN_RUNTIME_WASM_ABI}"), - diagnostic: None, - } - } - Some(runtime) if runtime.kind == PLUGIN_RUNTIME_WASM_KIND => { - let status = runtime - .abi - .as_deref() - .map(|abi| format!("{PLUGIN_RUNTIME_WASM_KIND}/{abi}")) - .unwrap_or_else(|| format!("{PLUGIN_RUNTIME_WASM_KIND}/")); - PluginRuntimeEligibility { - eligible: false, - status, - diagnostic: Some("unsupported or missing plugin runtime ABI".to_string()), - } - } Some(runtime) if runtime.kind == PLUGIN_RUNTIME_COMPONENT_KIND && matches!( @@ -259,6 +235,21 @@ pub fn inspect_resolved_plugin_static(record: &ResolvedPluginRecord) -> PluginSt diagnostic: None, } } + Some(runtime) if runtime.kind == PLUGIN_RUNTIME_WASM_KIND => { + let status = runtime + .abi + .as_deref() + .map(|abi| format!("{PLUGIN_RUNTIME_WASM_KIND}/{abi}")) + .unwrap_or_else(|| format!("{PLUGIN_RUNTIME_WASM_KIND}/")); + PluginRuntimeEligibility { + eligible: false, + status, + diagnostic: Some( + "legacy raw wasm plugin runtime is not an active execution path; use wasm-component" + .to_string(), + ), + } + } Some(runtime) if runtime.kind == PLUGIN_RUNTIME_COMPONENT_KIND => { let status = runtime .world @@ -2283,8 +2274,6 @@ fn permission_allows_tool(permissions: &[PluginPermission], tool_name: &str) -> }) } -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; @@ -2292,9 +2281,6 @@ 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; -const PLUGIN_WASM_REQUEST_MODULE: &str = "yoi:request"; -const PLUGIN_WASM_WEBSOCKET_MODULE: &str = "yoi:websocket"; -const PLUGIN_WASM_FS_MODULE: &str = "yoi:fs"; const PLUGIN_REQUEST_MAX_REQUEST_BYTES: usize = 48 * 1024; const PLUGIN_REQUEST_MAX_REQUEST_BODY_BYTES: usize = 32 * 1024; const PLUGIN_REQUEST_MAX_REQUEST_HEADERS: usize = 16; @@ -3110,11 +3096,11 @@ struct PluginInstance { impl PluginInstance { fn start(&mut self) -> Result<(), PluginWasmError> { match &mut self.runtime { - PluginInstanceRuntime::LegacyToolAdapter => { + PluginInstanceRuntime::ComponentToolAdapter => { self.lifecycle = PluginInstanceLifecycleState::Ready; self.diagnostics.push(PluginInstanceDiagnostic::new( PluginInstanceLifecycleState::Ready, - "legacy tool runtime adapted behind PluginInstanceRegistry", + "component tool runtime registered behind PluginInstanceRegistry", )); } #[cfg(test)] @@ -3158,8 +3144,8 @@ impl PluginInstance { )) })?; match &mut self.runtime { - PluginInstanceRuntime::LegacyToolAdapter => { - run_plugin_tool(self.record.clone(), tool_name.to_string(), input) + PluginInstanceRuntime::ComponentToolAdapter => { + run_plugin_component_tool(self.record.clone(), tool_name.to_string(), input) } #[cfg(test)] PluginInstanceRuntime::TestIngress { calls } => { @@ -3212,8 +3198,8 @@ impl PluginInstance { )) })?; match &mut self.runtime { - PluginInstanceRuntime::LegacyToolAdapter => Err(PluginWasmError::Module( - "legacy tool runtime does not expose ingress dispatch".to_string(), + PluginInstanceRuntime::ComponentToolAdapter => Err(PluginWasmError::Module( + "component tool runtime does not expose ingress dispatch".to_string(), )), #[cfg(test)] PluginInstanceRuntime::TestIngress { calls } => { @@ -3247,7 +3233,7 @@ impl PluginInstance { fn stop(&mut self) -> Result<(), PluginWasmError> { match &mut self.runtime { - PluginInstanceRuntime::LegacyToolAdapter => {} + PluginInstanceRuntime::ComponentToolAdapter => {} #[cfg(test)] PluginInstanceRuntime::TestIngress { .. } => {} PluginInstanceRuntime::ComponentInstance(runtime) => { @@ -3293,7 +3279,7 @@ impl PluginInstance { } enum PluginInstanceRuntime { - LegacyToolAdapter, + ComponentToolAdapter, #[cfg(test)] TestIngress { calls: u64, @@ -3304,12 +3290,13 @@ enum PluginInstanceRuntime { impl PluginInstanceRuntime { fn new(record: &ResolvedPluginRecord) -> Result { let Some(runtime) = record.manifest.runtime.as_ref() else { - return Ok(Self::LegacyToolAdapter); + return Err(PluginWasmError::Module( + "plugin runtime is not declared".to_string(), + )); }; match runtime.kind.as_str() { #[cfg(test)] "test-ingress" => Ok(Self::TestIngress { calls: 0 }), - PLUGIN_RUNTIME_WASM_KIND => Ok(Self::LegacyToolAdapter), PLUGIN_RUNTIME_COMPONENT_KIND if runtime.world.as_deref() == Some(PLUGIN_COMPONENT_INSTANCE_WORLD) => { @@ -3317,7 +3304,17 @@ impl PluginInstanceRuntime { PluginComponentInstanceRuntime::instantiate(record)?, )) } - PLUGIN_RUNTIME_COMPONENT_KIND => Ok(Self::LegacyToolAdapter), + PLUGIN_RUNTIME_COMPONENT_KIND + if runtime.world.as_deref() == Some(PLUGIN_COMPONENT_TOOL_WORLD) => + { + Ok(Self::ComponentToolAdapter) + } + PLUGIN_RUNTIME_COMPONENT_KIND => Err(PluginWasmError::Module( + "unsupported or missing plugin component world".to_string(), + )), + PLUGIN_RUNTIME_WASM_KIND => Err(PluginWasmError::Module( + "legacy raw wasm plugin runtime is not supported; use wasm-component".to_string(), + )), other => Err(PluginWasmError::Module(format!( "unsupported plugin runtime kind `{other}`" ))), @@ -3608,64 +3605,6 @@ impl Tool for PluginInstanceTool { } } -#[cfg(test)] -struct PluginWasmTool { - record: ResolvedPluginRecord, - name: String, - origin: ToolOrigin, -} - -#[cfg(test)] -#[async_trait] -impl Tool for PluginWasmTool { - async fn execute( - &self, - input_json: &str, - _ctx: ToolExecutionContext, - ) -> Result { - 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_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 tool `{}` from `{}` (digest {}) failed closed: {}", - self.name, - plugin_ref, - digest, - error.bounded_message() - ))), - Ok(Err(error)) => Err(ToolError::ExecutionFailed(format!( - "plugin tool `{}` from `{}` (digest {}) cancelled/failed to join: {}", - self.name, - plugin_ref, - digest, - bounded_message(error.to_string()) - ))), - Err(_) => Err(ToolError::ExecutionFailed(format!( - "plugin tool `{}` from `{}` (digest {}) timed out after {:?}", - self.name, plugin_ref, digest, PLUGIN_WASM_TIMEOUT - ))), - } - } -} - #[derive(Debug)] pub enum PluginWasmError { Package(String), @@ -3687,143 +3626,6 @@ impl PluginWasmError { } } -struct PluginWasmHostState { - record: ResolvedPluginRecord, - request_client: Arc, - websocket_client: Arc, - websocket_handles: PluginWebSocketHandles, - tool_name: Vec, - input: Vec, - output: Vec, - output_error: Option, - request_response: Vec, - websocket_response: Vec, - fs_response: Vec, - store_limits: wasmi::StoreLimits, -} - -fn run_plugin_tool( - record: ResolvedPluginRecord, - tool_name: String, - input: Vec, -) -> Result { - match record - .manifest - .runtime - .as_ref() - .map(|runtime| runtime.kind.as_str()) - { - Some(PLUGIN_RUNTIME_WASM_KIND) => run_plugin_wasm_tool(record, tool_name, input), - Some(PLUGIN_RUNTIME_COMPONENT_KIND) => run_plugin_component_tool(record, tool_name, input), - Some(other) => Err(PluginWasmError::Module(format!( - "unsupported plugin runtime kind `{other}`" - ))), - None => Err(PluginWasmError::Package( - "plugin runtime is not declared".to_string(), - )), - } -} - -fn run_plugin_wasm_tool( - record: ResolvedPluginRecord, - tool_name: String, - input: Vec, -) -> Result { - run_plugin_wasm_tool_with_request_client( - record, - tool_name, - input, - Arc::new(ReqwestPluginRequestClient), - ) -} - -fn run_plugin_wasm_tool_with_request_client( - record: ResolvedPluginRecord, - tool_name: String, - input: Vec, - request_client: Arc, -) -> Result { - let tool = record - .manifest - .tools - .iter() - .find(|tool| tool.name == tool_name) - .ok_or_else(|| { - PluginWasmError::Module("requested tool is not declared by plugin manifest".to_string()) - })?; - authorize_plugin_tool(&record, tool).map_err(|error| { - PluginWasmError::Module(format!( - "plugin permission denied: {}", - error.bounded_message() - )) - })?; - 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(&record, &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 { - record: record.clone(), - request_client, - websocket_client: Arc::new(TungstenitePluginWebSocketClient), - websocket_handles: PluginWebSocketHandles::default(), - tool_name: tool_name.into_bytes(), - input, - output: Vec::new(), - output_error: None, - request_response: Vec::new(), - websocket_response: Vec::new(), - fs_response: Vec::new(), - 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) -} - #[derive(Clone)] struct PluginComponentHostState { record: ResolvedPluginRecord, @@ -4123,515 +3925,6 @@ fn define_plugin_component_host_imports( .map_err(|error| PluginWasmError::Module(error.to_string()))?; Ok(()) } -fn validate_wasm_imports( - record: &ResolvedPluginRecord, - module: &wasmi::Module, -) -> Result<(), PluginWasmError> { - for import in module.imports() { - match 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, ambient network, environment, or WASI imports are available", - other - ))); - } - }, - PLUGIN_WASM_REQUEST_MODULE => { - authorize_plugin_host_api(record, PluginHostApi::Request).map_err(|error| { - PluginWasmError::Module(format!( - "plugin host API dispatch denied: {}", - error.bounded_message() - )) - })?; - match import.name() { - "request" | "response_len" | "response_read" => {} - other => { - return Err(PluginWasmError::Module(format!( - "unsupported request host import `{other}`" - ))); - } - } - } - PLUGIN_WASM_WEBSOCKET_MODULE => { - authorize_plugin_host_api(record, PluginHostApi::WebSocket).map_err(|error| { - PluginWasmError::Module(format!( - "plugin host API dispatch denied: {}", - error.bounded_message() - )) - })?; - match import.name() { - "open" | "send_text" | "recv" | "close" | "response_len" | "response_read" => {} - other => { - return Err(PluginWasmError::Module(format!( - "unsupported websocket host import `{other}`" - ))); - } - } - } - PLUGIN_WASM_FS_MODULE => { - authorize_plugin_host_api(record, PluginHostApi::Fs).map_err(|error| { - PluginWasmError::Module(format!( - "plugin host API dispatch denied: {}", - error.bounded_message() - )) - })?; - match import.name() { - "read" | "list" | "write" | "response_len" | "response_read" => {} - other => { - return Err(PluginWasmError::Module(format!( - "unsupported fs host import `{other}`" - ))); - } - } - } - other => { - return Err(PluginWasmError::Module(format!( - "unsupported import module `{}`; only `{}`, `{}`, `{}`, and `{}` are available", - other, - PLUGIN_WASM_HOST_MODULE, - PLUGIN_WASM_REQUEST_MODULE, - PLUGIN_WASM_WEBSOCKET_MODULE, - PLUGIN_WASM_FS_MODULE - ))); - } - } - } - 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()))?; - - linker - .func_wrap( - PLUGIN_WASM_REQUEST_MODULE, - "request", - |mut caller: wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32| -> i32 { - read_guest_request_request(&mut caller, ptr, len) - }, - ) - .map_err(|error| PluginWasmError::Module(error.to_string()))?; - linker - .func_wrap( - PLUGIN_WASM_REQUEST_MODULE, - "response_len", - |caller: wasmi::Caller<'_, PluginWasmHostState>| -> i32 { - caller.data().request_response.len() as i32 - }, - ) - .map_err(|error| PluginWasmError::Module(error.to_string()))?; - linker - .func_wrap( - PLUGIN_WASM_REQUEST_MODULE, - "response_read", - |mut caller: wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32| -> i32 { - write_host_bytes_to_guest(&mut caller, ptr, len, HostBuffer::RequestResponse) - }, - ) - .map_err(|error| PluginWasmError::Module(error.to_string()))?; - - linker - .func_wrap( - PLUGIN_WASM_WEBSOCKET_MODULE, - "open", - |mut caller: wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32| -> i32 { - read_guest_websocket_open(&mut caller, ptr, len) - }, - ) - .map_err(|error| PluginWasmError::Module(error.to_string()))?; - linker - .func_wrap( - PLUGIN_WASM_WEBSOCKET_MODULE, - "send_text", - |mut caller: wasmi::Caller<'_, PluginWasmHostState>, - handle: i32, - ptr: i32, - len: i32| - -> i32 { read_guest_websocket_send_text(&mut caller, handle, ptr, len) }, - ) - .map_err(|error| PluginWasmError::Module(error.to_string()))?; - linker - .func_wrap( - PLUGIN_WASM_WEBSOCKET_MODULE, - "recv", - |mut caller: wasmi::Caller<'_, PluginWasmHostState>, - handle: i32, - timeout_ms: i32| - -> i32 { read_guest_websocket_recv(&mut caller, handle, timeout_ms) }, - ) - .map_err(|error| PluginWasmError::Module(error.to_string()))?; - linker - .func_wrap( - PLUGIN_WASM_WEBSOCKET_MODULE, - "close", - |mut caller: wasmi::Caller<'_, PluginWasmHostState>, handle: i32| -> i32 { - read_guest_websocket_close(&mut caller, handle) - }, - ) - .map_err(|error| PluginWasmError::Module(error.to_string()))?; - linker - .func_wrap( - PLUGIN_WASM_WEBSOCKET_MODULE, - "response_len", - |caller: wasmi::Caller<'_, PluginWasmHostState>| -> i32 { - caller.data().websocket_response.len() as i32 - }, - ) - .map_err(|error| PluginWasmError::Module(error.to_string()))?; - linker - .func_wrap( - PLUGIN_WASM_WEBSOCKET_MODULE, - "response_read", - |mut caller: wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32| -> i32 { - write_host_bytes_to_guest(&mut caller, ptr, len, HostBuffer::WebSocketResponse) - }, - ) - .map_err(|error| PluginWasmError::Module(error.to_string()))?; - - linker - .func_wrap( - PLUGIN_WASM_FS_MODULE, - "read", - |mut caller: wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32| -> i32 { - read_guest_fs_request(&mut caller, ptr, len, PluginFsRuntimeOperation::Read) - }, - ) - .map_err(|error| PluginWasmError::Module(error.to_string()))?; - linker - .func_wrap( - PLUGIN_WASM_FS_MODULE, - "list", - |mut caller: wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32| -> i32 { - read_guest_fs_request(&mut caller, ptr, len, PluginFsRuntimeOperation::List) - }, - ) - .map_err(|error| PluginWasmError::Module(error.to_string()))?; - linker - .func_wrap( - PLUGIN_WASM_FS_MODULE, - "write", - |mut caller: wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32| -> i32 { - read_guest_fs_request(&mut caller, ptr, len, PluginFsRuntimeOperation::Write) - }, - ) - .map_err(|error| PluginWasmError::Module(error.to_string()))?; - linker - .func_wrap( - PLUGIN_WASM_FS_MODULE, - "response_len", - |caller: wasmi::Caller<'_, PluginWasmHostState>| -> i32 { - caller.data().fs_response.len() as i32 - }, - ) - .map_err(|error| PluginWasmError::Module(error.to_string()))?; - linker - .func_wrap( - PLUGIN_WASM_FS_MODULE, - "response_read", - |mut caller: wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32| -> i32 { - write_host_bytes_to_guest(&mut caller, ptr, len, HostBuffer::FsResponse) - }, - ) - .map_err(|error| PluginWasmError::Module(error.to_string()))?; - Ok(()) -} -#[derive(Clone, Copy, Debug)] -enum HostBuffer { - ToolName, - Input, - RequestResponse, - WebSocketResponse, - FsResponse, -} - -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(), - HostBuffer::RequestResponse => caller.data().request_response.clone(), - HostBuffer::WebSocketResponse => caller.data().websocket_response.clone(), - HostBuffer::FsResponse => caller.data().fs_response.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_request_request( - caller: &mut wasmi::Caller<'_, PluginWasmHostState>, - ptr: i32, - len: i32, -) -> i32 { - let bytes = match read_guest_bytes(caller, ptr, len, PLUGIN_REQUEST_MAX_REQUEST_BYTES) { - Ok(bytes) => bytes, - Err(error) => { - caller.data_mut().output_error = Some(error); - return -1; - } - }; - let record = caller.data().record.clone(); - let request_client = caller.data().request_client.clone(); - match execute_plugin_request_request(&record, request_client.as_ref(), &bytes) { - Ok(response) => { - caller.data_mut().request_response = response; - caller.data().request_response.len() as i32 - } - Err(error) => { - caller.data_mut().output_error = Some(error.0); - -1 - } - } -} - -fn read_guest_websocket_open( - caller: &mut wasmi::Caller<'_, PluginWasmHostState>, - ptr: i32, - len: i32, -) -> i32 { - let bytes = match read_guest_bytes(caller, ptr, len, PLUGIN_WEBSOCKET_MAX_OPEN_REQUEST_BYTES) { - Ok(bytes) => bytes, - Err(error) => { - caller.data_mut().output_error = Some(error); - return -1; - } - }; - let record = caller.data().record.clone(); - let websocket_client = caller.data().websocket_client.clone(); - let websocket_handles = caller.data().websocket_handles.clone(); - match execute_plugin_websocket_open( - &record, - websocket_client.as_ref(), - &websocket_handles, - &bytes, - ) { - Ok(response) => { - caller.data_mut().websocket_response = response; - caller.data().websocket_response.len() as i32 - } - Err(error) => { - caller.data_mut().output_error = Some(error.0); - -1 - } - } -} - -fn read_guest_websocket_send_text( - caller: &mut wasmi::Caller<'_, PluginWasmHostState>, - handle: i32, - ptr: i32, - len: i32, -) -> i32 { - let bytes = match read_guest_bytes(caller, ptr, len, PLUGIN_WEBSOCKET_MAX_TEXT_BYTES) { - Ok(bytes) => bytes, - Err(error) => { - caller.data_mut().output_error = Some(error); - return -1; - } - }; - match execute_plugin_websocket_send_text( - &caller.data().websocket_handles, - handle as u32, - &bytes, - ) { - Ok(response) => { - caller.data_mut().websocket_response = response; - caller.data().websocket_response.len() as i32 - } - Err(error) => { - caller.data_mut().output_error = Some(error.0); - -1 - } - } -} - -fn read_guest_websocket_recv( - caller: &mut wasmi::Caller<'_, PluginWasmHostState>, - handle: i32, - timeout_ms: i32, -) -> i32 { - match execute_plugin_websocket_recv( - &caller.data().websocket_handles, - handle as u32, - timeout_ms.max(0) as u32, - ) { - Ok(response) => { - caller.data_mut().websocket_response = response; - caller.data().websocket_response.len() as i32 - } - Err(error) => { - caller.data_mut().output_error = Some(error.0); - -1 - } - } -} - -fn read_guest_websocket_close( - caller: &mut wasmi::Caller<'_, PluginWasmHostState>, - handle: i32, -) -> i32 { - match execute_plugin_websocket_close(&caller.data().websocket_handles, handle as u32) { - Ok(response) => { - caller.data_mut().websocket_response = response; - caller.data().websocket_response.len() as i32 - } - Err(error) => { - caller.data_mut().output_error = Some(error.0); - -1 - } - } -} -fn read_guest_fs_request( - caller: &mut wasmi::Caller<'_, PluginWasmHostState>, - ptr: i32, - len: i32, - operation: PluginFsRuntimeOperation, -) -> i32 { - let bytes = match read_guest_bytes(caller, ptr, len, PLUGIN_FS_MAX_REQUEST_BYTES) { - Ok(bytes) => bytes, - Err(error) => { - caller.data_mut().output_error = Some(error); - return -1; - } - }; - let record = caller.data().record.clone(); - match execute_plugin_fs_request(&record, operation, &bytes) { - Ok(response) => { - caller.data_mut().fs_response = response; - caller.data().fs_response.len() as i32 - } - Err(error) => { - caller.data_mut().output_error = Some(error.message); - -1 - } - } -} - -fn read_guest_bytes( - caller: &mut wasmi::Caller<'_, PluginWasmHostState>, - ptr: i32, - len: i32, - max_len: usize, -) -> Result, String> { - if ptr < 0 || len < 0 { - return Err("guest input pointer/length is invalid".into()); - } - let len = len as usize; - if len > max_len { - return Err(format!("guest input exceeds {max_len} bytes")); - } - let Some(memory) = caller - .get_export("memory") - .and_then(|export| export.into_memory()) - else { - return Err("guest did not export linear memory".into()); - }; - let mut bytes = vec![0; len]; - memory - .read(&*caller, ptr as usize, &mut bytes) - .map_err(|_| "guest input memory range is invalid".to_string())?; - Ok(bytes) -} - -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( @@ -4968,7 +4261,13 @@ mod tests { version: "0.1.0".into(), description: None, surfaces: vec![PluginSurface::Tool], - runtime: None, + runtime: Some(manifest::plugin::PluginRuntimeManifest { + kind: "test-ingress".to_string(), + entry: None, + abi: None, + component: None, + world: None, + }), hooks: Vec::new(), tools, services: Vec::new(), @@ -5140,7 +4439,7 @@ mod tests { assert_eq!(report.reports[0].provided_services.len(), 1); assert_eq!( feature.instance_status().unwrap().lifecycle, - PluginInstanceLifecycleState::Ready + PluginInstanceLifecycleState::Started ); } @@ -5431,34 +4730,6 @@ mod tests { json!({ "method": method, "url": url }).to_string() } - fn wasm_tool_that_calls_request(request: &str) -> Vec { - let output = br#"{"summary":"request ok","content":"ordinary tool result path"}"#; - wat::parse_str(format!( - r#" - (module - (import "yoi:tool" "output_write" (func $output_write (param i32 i32) (result i32))) - (import "yoi:request" "request" (func $request_request (param i32 i32) (result i32))) - (import "yoi:request" "response_len" (func $request_response_len (result i32))) - (memory (export "memory") 1) - (data (i32.const 16) "{}") - (data (i32.const 4096) "{}") - (func (export "yoi_tool_call") - (local $n i32) - (local.set $n (call $request_request (i32.const 16) (i32.const {}))) - (if (i32.lt_s (local.get $n) (i32.const 0)) (then unreachable)) - (drop (call $request_response_len)) - (drop (call $output_write (i32.const 4096) (i32.const {}))) - ) - ) - "#, - wat_bytes(request.as_bytes()), - wat_bytes(output), - request.len(), - output.len() - )) - .expect("valid wat") - } - fn fs_request_json(path: &str) -> String { json!({ "path": path }).to_string() } @@ -5484,73 +4755,6 @@ mod tests { record } - fn runtime_record_with_fs_wasm( - wasm: Vec, - root: &Path, - operations: Vec, - ) -> (TempDir, ResolvedPluginRecord) { - let (dir, mut record) = resolved_record_with_wasm(wasm); - let fs_permission = PluginPermission::HostApi { - api: PluginHostApi::Fs, - }; - record.manifest.permissions.push(fs_permission.clone()); - record.grants.permissions.push(fs_permission); - record.grants.fs.push(PluginFsGrant { - root: root.to_string_lossy().into_owned(), - operations, - }); - (dir, record) - } - - fn wasm_tool_that_calls_fs_read(request: &str) -> Vec { - let output = br#"{"summary":"fs ok","content":"ordinary tool result path"}"#; - wat::parse_str(format!( - r#" - (module - (import "yoi:tool" "output_write" (func $output_write (param i32 i32) (result i32))) - (import "yoi:fs" "read" (func $fs_read (param i32 i32) (result i32))) - (import "yoi:fs" "response_len" (func $fs_response_len (result i32))) - (import "yoi:fs" "response_read" (func $fs_response_read (param i32 i32) (result i32))) - (memory (export "memory") 1) - (data (i32.const 16) "{}") - (data (i32.const 4096) "{}") - (func (export "yoi_tool_call") - (local $n i32) - (local.set $n (call $fs_read (i32.const 16) (i32.const {}))) - (if (i32.lt_s (local.get $n) (i32.const 0)) (then unreachable)) - (drop (call $fs_response_len)) - (drop (call $fs_response_read (i32.const 8192) (i32.const 4096))) - (drop (call $output_write (i32.const 4096) (i32.const {}))) - ) - ) - "#, - wat_bytes(request.as_bytes()), - wat_bytes(output), - request.len(), - output.len() - )) - .expect("valid wat") - } - - fn empty_wasm_tool() -> Vec { - let output = br#"{"summary":"no network","content":"no request import"}"#; - wat::parse_str(format!( - r#" - (module - (import "yoi:tool" "output_write" (func $output_write (param i32 i32) (result i32))) - (memory (export "memory") 1) - (data (i32.const 4096) "{}") - (func (export "yoi_tool_call") - (drop (call $output_write (i32.const 4096) (i32.const {}))) - ) - ) - "#, - wat_bytes(output), - output.len() - )) - .expect("valid wat") - } - #[test] fn rejects_invalid_root_schema() { let schema = json!({"type":"string"}); @@ -5633,21 +4837,6 @@ mod tests { .unwrap(); } - #[test] - fn wasm_tool_can_call_granted_fs_read_host_api() { - let root = TempDir::new().expect("temp root"); - fs::write(root.path().join("allowed.txt"), "hello fs").expect("write fixture"); - let (_dir, record) = runtime_record_with_fs_wasm( - wasm_tool_that_calls_fs_read(&fs_request_json("allowed.txt")), - root.path(), - vec![PluginFsOperation::Read], - ); - let output = run_plugin_wasm_tool(record, "PluginEcho".to_string(), Vec::new()) - .expect("tool output"); - assert_eq!(output.summary, "fs ok"); - assert_eq!(output.content.as_deref(), Some("ordinary tool result path")); - } - #[test] fn granted_fs_read_list_and_write_are_scoped() { let root = TempDir::new().expect("temp root"); @@ -5878,44 +5067,6 @@ mod tests { assert_eq!(origin.surface, "tool"); } - #[test] - fn wasm_tool_can_call_granted_request_host_api() { - let (_dir, record) = runtime_record_with_request_wasm(wasm_tool_that_calls_request( - &request_request_json("GET", "https://api.example.test/v1/data"), - )); - let client = Arc::new(MockRequestClient::default()); - let output = run_plugin_wasm_tool_with_request_client( - record, - "PluginEcho".to_string(), - Vec::new(), - client.clone(), - ) - .expect("tool output"); - assert_eq!(client.call_count(), 1); - assert_eq!(output.summary, "request ok"); - assert_eq!(output.content.as_deref(), Some("ordinary tool result path")); - } - - #[test] - fn missing_request_grant_denies_before_network() { - let (_dir, mut record) = resolved_record_with_wasm(wasm_tool_that_calls_request( - &request_request_json("GET", "https://api.example.test/v1/data"), - )); - record.manifest.permissions.push(PluginPermission::HostApi { - api: PluginHostApi::Request, - }); - let client = Arc::new(MockRequestClient::default()); - let error = run_plugin_wasm_tool_with_request_client( - record, - "PluginEcho".to_string(), - Vec::new(), - client.clone(), - ) - .expect_err("grant denied"); - assert_eq!(client.call_count(), 0); - assert!(error.bounded_message().contains("host_api.request")); - } - #[test] fn disallowed_request_targets_deny_before_network() { let record = record_with_request_grant(); @@ -6073,21 +5224,6 @@ mod tests { ); } - #[test] - fn no_network_without_request_import() { - let (_dir, record) = runtime_record_with_request_wasm(empty_wasm_tool()); - let client = Arc::new(MockRequestClient::default()); - let output = run_plugin_wasm_tool_with_request_client( - record, - "PluginEcho".to_string(), - Vec::new(), - client.clone(), - ) - .expect("tool output"); - assert_eq!(client.call_count(), 0); - assert_eq!(output.summary, "no network"); - } - #[test] fn enabled_plugin_tool_registers_model_visible_schema_and_origin() { let mut pending = Vec::new(); @@ -6129,9 +5265,14 @@ mod tests { "granted surfaces.tool permission is missing" )); - let error = run_plugin_wasm_tool(record, "PluginSearch".into(), br#"{}"#.to_vec()) - .unwrap_err() - .bounded_message(); + let (_dir, mut runtime_record) = resolved_record_with_component( + component_tool_that_returns(br#"{"summary":"should not run"}"#), + ); + runtime_record.grants = PluginGrantConfig::default(); + let error = + run_plugin_component_tool(runtime_record, "PluginEcho".into(), br#"{}"#.to_vec()) + .unwrap_err() + .bounded_message(); assert!(error.contains("plugin permission denied"), "{error}"); assert!( error.contains("granted surfaces.tool permission is missing"), @@ -6229,11 +5370,33 @@ mod tests { } #[test] - fn future_host_api_imports_are_permission_checked_before_unimplemented_boundary() { - let (_dir, mut record) = resolved_record_with_wasm(request_import_module()); - let error = run_plugin_wasm_tool(record.clone(), "PluginEcho".into(), br#"{}"#.to_vec()) - .unwrap_err() - .bounded_message(); + fn component_host_api_imports_are_permission_checked_by_manifest_and_grants() { + let (_dir, mut record) = resolved_record_with_component(component_tool_importing_request( + br#"{"summary":"should not run"}"#, + )); + record.manifest.permissions.retain(|permission| { + !matches!( + permission, + PluginPermission::HostApi { + api: PluginHostApi::Request + } + ) + }); + record.manifest.request.clear(); + record.grants.permissions.retain(|permission| { + !matches!( + permission, + PluginPermission::HostApi { + api: PluginHostApi::Request + } + ) + }); + record.grants.request.clear(); + + let error = + run_plugin_component_tool(record.clone(), "PluginEcho".into(), br#"{}"#.to_vec()) + .unwrap_err() + .bounded_message(); assert!( error.contains("requested host_api.request permission is missing"), "{error}" @@ -6247,9 +5410,10 @@ mod tests { .grants .permissions .push(PluginPermission::host_api(PluginHostApi::Request)); - let error = run_plugin_wasm_tool(record.clone(), "PluginEcho".into(), br#"{}"#.to_vec()) - .unwrap_err() - .bounded_message(); + let error = + run_plugin_component_tool(record.clone(), "PluginEcho".into(), br#"{}"#.to_vec()) + .unwrap_err() + .bounded_message(); assert!( error.contains("manifest host_api.request target declaration is missing"), "{error}" @@ -6262,7 +5426,7 @@ mod tests { methods: vec!["GET".to_string()], path_prefixes: vec!["/".to_string()], }); - let error = run_plugin_wasm_tool(record, "PluginEcho".into(), br#"{}"#.to_vec()) + let error = run_plugin_component_tool(record, "PluginEcho".into(), br#"{}"#.to_vec()) .unwrap_err() .bounded_message(); assert!( @@ -6359,33 +5523,6 @@ mod tests { assert!(has_diagnostic(&report, "$.properties.query")); } - #[tokio::test] - 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") - ); - } - #[test] fn pdk_tool_output_shape_is_accepted_by_wasm_decoder() { let pdk_output = @@ -6398,164 +5535,6 @@ mod tests { assert_eq!(output.content.as_deref(), Some(r#"{"answer":42}"#)); } - #[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 error = tool - .execute("{}", ToolExecutionContext::default()) - .await - .unwrap_err(); - 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: PLUGIN_RUNTIME_WASM_KIND.into(), - entry: Some("plugin.wasm".into()), - abi: Some(PLUGIN_RUNTIME_WASM_ABI.into()), - component: None, - world: None, - }); - record - } - - fn runtime_record_with_request_wasm(wasm: Vec) -> (TempDir, ResolvedPluginRecord) { - let (dir, mut record) = resolved_record_with_wasm(wasm); - let request_permission = PluginPermission::HostApi { - api: PluginHostApi::Request, - }; - record.manifest.permissions.push(request_permission.clone()); - record.manifest.request.push(PluginRequestGrant { - scheme: "https".to_string(), - host: "api.example.test".to_string(), - port: None, - methods: vec!["GET".to_string(), "POST".to_string()], - path_prefixes: vec!["/v1".to_string()], - }); - record.grants.permissions.push(request_permission); - record.grants.request.push(PluginRequestGrant { - scheme: "https".to_string(), - host: "api.example.test".to_string(), - port: None, - methods: vec!["GET".to_string(), "POST".to_string()], - path_prefixes: vec!["/v1".to_string()], - }); - (dir, 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); - let mut record = resolved.resolved[0].clone(); - record.grants = PluginGrantConfig { - id: Some(record.identity.to_string()), - version: Some(PluginExactVersion(record.version.clone())), - digest: Some(record.digest.clone()), - permissions: tool_permissions(&record.manifest.tools), - request: Vec::new(), - websocket: Vec::new(), - fs: Vec::new(), - }; - (dir, record) - } - fn write_component_plugin_package(path: &Path, component: &[u8], world: &str) { let manifest = format!( r#"schema_version = 1 @@ -6630,6 +5609,13 @@ input_schema = {{ type = "object", additionalProperties = true }} (dir, record) } + fn wat_bytes(bytes: &[u8]) -> String { + bytes + .iter() + .map(|byte| format!(r#"\{:02x}"#, byte)) + .collect() + } + fn component_instance_with_outputs( start: &[u8], status: &[u8], @@ -7046,6 +6032,38 @@ input_schema = {{ type = "object", additionalProperties = true }} ); } + #[test] + fn legacy_raw_wasm_runtime_is_rejected_without_fallback_execution() { + let mut record = record(vec![tool("PluginEcho")]); + record.manifest.runtime = Some(PluginRuntimeManifest { + kind: PLUGIN_RUNTIME_WASM_KIND.to_string(), + entry: Some("plugin.wasm".to_string()), + abi: Some("yoi-plugin-wasm-1".to_string()), + component: None, + world: None, + }); + + let inspection = inspect_resolved_plugin_static(&record); + assert!(!inspection.runtime.eligible); + assert!( + inspection + .runtime + .diagnostic + .as_deref() + .unwrap_or_default() + .contains("legacy raw wasm plugin runtime is not an active execution path") + ); + + let error = match PluginInstanceHandle::new(record) { + Ok(_) => panic!("legacy raw wasm runtime unexpectedly instantiated"), + Err(error) => error.bounded_message(), + }; + assert!( + error.contains("legacy raw wasm plugin runtime is not supported"), + "{error}" + ); + } + #[test] fn component_static_inspection_reports_component_runtime_without_execution() { let mut record = record(vec![tool("Echo")]); @@ -7068,159 +6086,16 @@ input_schema = {{ type = "object", additionalProperties = true }} assert!(inspection.runtime.diagnostic.is_none()); } - 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" - -[[permissions]] -kind = "surface" -surface = "tool" - -[[permissions]] -kind = "tool" -name = "PluginEcho" - -[[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 request_import_module() -> Vec { - wat::parse_str( - r#"(module - (import "yoi:request" "request" (func $request)) - (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() - } - #[test] fn static_inspection_does_not_read_or_execute_package() { let mut record = record(vec![tool("Echo")]); record.package_path = std::path::PathBuf::from("/no/such/plugin.wasm"); record.manifest.runtime = Some(PluginRuntimeManifest { - kind: PLUGIN_RUNTIME_WASM_KIND.to_string(), - entry: Some("plugin.wasm".to_string()), - abi: Some(PLUGIN_RUNTIME_WASM_ABI.to_string()), - component: None, - world: None, + kind: PLUGIN_RUNTIME_COMPONENT_KIND.to_string(), + entry: None, + abi: None, + component: Some("plugin.component.wasm".to_string()), + world: Some(PLUGIN_COMPONENT_TOOL_WORLD.to_string()), }); let inspection = inspect_resolved_plugin_static(&record); @@ -7235,11 +6110,11 @@ input_schema = { type = "object", additionalProperties = true } fn static_inspection_reports_missing_tool_grant() { let mut record = record(vec![tool("Echo")]); record.manifest.runtime = Some(PluginRuntimeManifest { - kind: PLUGIN_RUNTIME_WASM_KIND.to_string(), - entry: Some("plugin.wasm".to_string()), - abi: Some(PLUGIN_RUNTIME_WASM_ABI.to_string()), - component: None, - world: None, + kind: PLUGIN_RUNTIME_COMPONENT_KIND.to_string(), + entry: None, + abi: None, + component: Some("plugin.component.wasm".to_string()), + world: Some(PLUGIN_COMPONENT_TOOL_WORLD.to_string()), }); record.grants.permissions = vec![PluginPermission::surface(PluginSurface::Tool)]; @@ -7262,11 +6137,11 @@ input_schema = { type = "object", additionalProperties = true } bad_schema.input_schema = json!({"type":"string"}); let mut record = record(vec![bad_schema]); record.manifest.runtime = Some(PluginRuntimeManifest { - kind: PLUGIN_RUNTIME_WASM_KIND.to_string(), - entry: Some("plugin.wasm".to_string()), - abi: Some(PLUGIN_RUNTIME_WASM_ABI.to_string()), - component: None, - world: None, + kind: PLUGIN_RUNTIME_COMPONENT_KIND.to_string(), + entry: None, + abi: None, + component: Some("plugin.component.wasm".to_string()), + world: Some(PLUGIN_COMPONENT_TOOL_WORLD.to_string()), }); let inspection = inspect_resolved_plugin_static(&record); @@ -7291,11 +6166,11 @@ input_schema = { type = "object", additionalProperties = true } second_duplicate.input_schema = json!({"type":"object"}); let mut record = record(vec![invalid, first_duplicate, second_duplicate]); record.manifest.runtime = Some(PluginRuntimeManifest { - kind: PLUGIN_RUNTIME_WASM_KIND.to_string(), - entry: Some("plugin.wasm".to_string()), - abi: Some(PLUGIN_RUNTIME_WASM_ABI.to_string()), - component: None, - world: None, + kind: PLUGIN_RUNTIME_COMPONENT_KIND.to_string(), + entry: None, + abi: None, + component: Some("plugin.component.wasm".to_string()), + world: Some(PLUGIN_COMPONENT_TOOL_WORLD.to_string()), }); let inspection = inspect_resolved_plugin_static(&record); diff --git a/docs/design/plugin-component-model.md b/docs/design/plugin-component-model.md index 5b66ae6c..8842a509 100644 --- a/docs/design/plugin-component-model.md +++ b/docs/design/plugin-component-model.md @@ -105,9 +105,9 @@ The migration should be phased: ## Runtime/backend caution -The current implementation uses `wasmi` for core Wasm. Component Model support will likely require a different backend or a significantly richer component adapter path, such as `wasmtime::component` plus generated bindings. That has consequences for binary size, Nix packaging, build time, runtime limits, and sandbox policy. The migration Ticket must measure and validate those effects explicitly. +The legacy core-Wasm implementation used `wasmi` as a transitional backend. The active Plugin Tool runtime is now selected by package runtime metadata and executed through `wasmtime::component`; discovery and static inspection must continue to avoid executing package code. -If a component backend is added, keep it selected by package runtime metadata and Profile/feature policy. Do not make all Plugin packages depend on component execution during discovery or inspection. +Keep the component backend selected by package runtime metadata and Profile/feature policy. Do not make all Plugin packages depend on component execution during discovery or inspection. ## Relationship to pending host APIs @@ -133,8 +133,7 @@ component = "plugin.component.wasm" world = "yoi:plugin/tool@1.0.0" ``` -The legacy core-Wasm ABI remains explicit and is not reinterpreted as a -component: +The legacy core-Wasm ABI remains explicit metadata for migration diagnostics and is not reinterpreted as a component or executed by the active runtime path: ```toml [runtime] @@ -145,9 +144,9 @@ abi = "yoi-plugin-wasm-1" The component runtime uses `wasmtime::component` and expects the exported world `yoi:plugin/tool@1.0.0` with a `call(tool-name: string, input-json: string) -> -string` export. The returned string is the same ToolOutput JSON used by the raw -runtime, so registration and execution still flow through the existing -ToolRegistry and Worker Tool-result history path. +string` export. The returned string is the normal ToolOutput JSON, so +registration and execution still flow through the existing ToolRegistry and +Worker Tool-result history path. Host imports are stable names under `yoi:host/*@1.0.0`; the repository WIT files live in `resources/plugin/wit/`. Importing `yoi:host/request@1.0.0` or diff --git a/package.nix b/package.nix index 06fdc54f..17891ce9 100644 --- a/package.nix +++ b/package.nix @@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec { filter = sourceFilter; }; - cargoHash = "sha256-8mo2/IZMq3tfnv8fKRxJOdfb+T3NOheUmqT8TiR+Wag="; + cargoHash = "sha256-kO1hvSYAVOVOIubP0Tm2Gpx2EWK7jwUKe30I8qTTU00="; depsExtraArgs = { # Older fetchCargoVendor utilities used crates.io's API download endpoint, From 19ff3724edc2278b9900a39efd44b40d431700e0 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 05:43:58 +0900 Subject: [PATCH 05/46] ticket: record legacy wasm removal report --- .yoi/tickets/00001KVXK0WD3/item.md | 2 +- .yoi/tickets/00001KVXK0WD3/thread.md | 40 ++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVXK0WD3/item.md b/.yoi/tickets/00001KVXK0WD3/item.md index 7115167c..4cd4fa2c 100644 --- a/.yoi/tickets/00001KVXK0WD3/item.md +++ b/.yoi/tickets/00001KVXK0WD3/item.md @@ -2,7 +2,7 @@ title: 'Remove legacy raw WASM Plugin runtime' state: 'inprogress' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-24T20:14:56Z' +updated_at: '2026-06-24T20:43:50Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:11:56Z' diff --git a/.yoi/tickets/00001KVXK0WD3/thread.md b/.yoi/tickets/00001KVXK0WD3/thread.md index 4b76638b..28e5a96d 100644 --- a/.yoi/tickets/00001KVXK0WD3/thread.md +++ b/.yoi/tickets/00001KVXK0WD3/thread.md @@ -133,3 +133,43 @@ Implementation orchestration update: - Coder には root/original workspace と orchestration worktree で read/write/validation/git operation を行わないよう明示した。 --- + + + +## Implementation report + +Implementation report: + +実装概要: +- `PluginInstanceRuntime::LegacyToolAdapter` 相当の raw core-WASM fallback 実行経路を削除した。 +- `wasm-component` の Tool world は `ComponentToolAdapter` として明示的に扱い、ToolRegistry 経由の実行は `run_plugin_component_tool` のみを使うよう変更した。 +- raw `wasm` runtime metadata は static inspection / instance startup の両方で非対応として扱い、実行 fallback しないようにした。 +- Component Model Plugin Tool / Component Instance 実行経路、host API grant boundary、ToolRegistry 登録経路は維持した。 +- raw WASM 実行用の `wasmi` 依存・テスト・古い helper を削除した。 +- `package.nix` の `cargoHash` を更新した。 +- Component Model design doc の現状記述を active runtime に合わせて更新した。 + +変更ファイル: +- `crates/pod/src/feature/plugin.rs` +- `crates/pod/Cargo.toml` +- `Cargo.lock` +- `package.nix` +- `docs/design/plugin-component-model.md` + +Validation: +- `cargo test -p pod feature::plugin --lib`: success +- `cargo check -p yoi`: success +- `git diff --check`: success +- `nix build .#yoi --no-link`: success +- `cargo test -p pod`: failed in unrelated prompt snapshot tests: + - `workflow::tests::workflow_usage_guidance_contract_snapshot_matches_prompt_resource` + - `tests::companion_prompt_matches_resource_snapshot` + +Commit: +- `741d71327aec213e564d47a4b4c37141c727803c refactor: remove legacy wasm plugin runtime` + +Residual risks / notes: +- Full `cargo test -p pod` failure appears unrelated to this Plugin runtime change and was not fixed here. +- Follow-up Ticket `00001KVXK0WDH` remains responsible for manifest / CLI legacy runtime rejection UX. This implementation only removes active runtime fallback and minimal static inspection consistency. + +--- From 0591fd528c7fe87b93e045392a1f448e329e60d4 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 05:49:01 +0900 Subject: [PATCH 06/46] ticket: approve legacy wasm removal --- .yoi/tickets/00001KVXK0WD3/item.md | 2 +- .yoi/tickets/00001KVXK0WD3/thread.md | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVXK0WD3/item.md b/.yoi/tickets/00001KVXK0WD3/item.md index 4cd4fa2c..363fe72d 100644 --- a/.yoi/tickets/00001KVXK0WD3/item.md +++ b/.yoi/tickets/00001KVXK0WD3/item.md @@ -2,7 +2,7 @@ title: 'Remove legacy raw WASM Plugin runtime' state: 'inprogress' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-24T20:43:50Z' +updated_at: '2026-06-24T20:48:55Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:11:56Z' diff --git a/.yoi/tickets/00001KVXK0WD3/thread.md b/.yoi/tickets/00001KVXK0WD3/thread.md index 28e5a96d..628c79e7 100644 --- a/.yoi/tickets/00001KVXK0WD3/thread.md +++ b/.yoi/tickets/00001KVXK0WD3/thread.md @@ -173,3 +173,29 @@ Residual risks / notes: - Follow-up Ticket `00001KVXK0WDH` remains responsible for manifest / CLI legacy runtime rejection UX. This implementation only removes active runtime fallback and minimal static inspection consistency. --- + + + +## Review: approve + +Review result: approve + +確認範囲: +- child worktree `/home/hare/Projects/yoi/.worktree/00001KVXK0WD3-remove-legacy-wasm-runtime` のみで read-only inspection。 +- Ticket / IntentPacket / `1b5a39db..HEAD` の diff を確認。 +- reviewer では cargo/nix は再実行していない。`git diff --check 1b5a39db..HEAD` は問題なし。 + +承認根拠: +- Ticket / IntentPacket の核心である「Pod Plugin runtime の active execution path から legacy raw core-WASM Tool adapter を削除し、Component Model path に一本化」が満たされている。 +- `crates/pod/src/feature/plugin.rs` から `LegacyToolAdapter` / `PluginWasmTool` / `run_plugin_wasm_tool` / `wasmi` host adapter 系が削除され、`crates/pod/Cargo.toml` からも `wasmi` dependency が削除されている。 +- raw `wasm` runtime は static inspection で `eligible: false` かつ active execution path ではない diagnostic になり、`PluginInstanceRuntime::new` でも `PLUGIN_RUNTIME_WASM_KIND` が fail-closed するため、fallback 実行は残っていない。 +- Component Model Tool path は `ComponentToolAdapter -> run_plugin_component_tool -> read_resolved_plugin_runtime_component -> wasmtime::component` に整理され、ToolRegistry 登録も既存の `ToolContribution` / `plugin_instance_tool_definition` 経由を維持している。 +- Host API grant / digest binding / tool permission validation は `authorize_plugin_tool`、`authorize_plugin_host_api`、`validate_component_imports` 側で維持され、component import permission/grant check tests も残っている。 +- Manifest / CLI rejection UX には踏み込んでおらず、raw `wasm` constants / manifest parsing が残っているのは後続 Ticket `00001KVXK0WDH` の範囲として許容できる。 +- Service / Ingress / WebSocket / WIT / PDK runtime への不要な scope creep は見当たらない。 + +Non-blocking notes: +- 報告された `cargo test -p pod` 全体失敗は prompt snapshot 2 件で、今回 diff の変更対象外に見えるため blocker とは判断しない。ただし完了処理時には既知 unrelated failure として扱う根拠を残すか、別対応後に再実行するのが安全。 +- `docs/development/plugin-development.md` には raw core-Wasm compatibility がまだ有効に読める文言が残っている。今回 Ticket の主目的は active runtime removal で、外向き rejection/docs 全面更新は後続範囲なので blocker ではないが、後続 Ticket で整理した方がよい。 + +--- From 66c5be16f8b279d2bc3fbbcb5c5be6f9348b8004 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 05:50:51 +0900 Subject: [PATCH 07/46] ticket: mark legacy wasm removal done --- .yoi/tickets/00001KVXK0WD3/item.md | 4 +- .yoi/tickets/00001KVXK0WD3/thread.md | 60 ++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KVXK0WD3/item.md b/.yoi/tickets/00001KVXK0WD3/item.md index 363fe72d..6b63921d 100644 --- a/.yoi/tickets/00001KVXK0WD3/item.md +++ b/.yoi/tickets/00001KVXK0WD3/item.md @@ -1,8 +1,8 @@ --- title: 'Remove legacy raw WASM Plugin runtime' -state: 'inprogress' +state: 'done' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-24T20:48:55Z' +updated_at: '2026-06-24T20:50:45Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:11:56Z' diff --git a/.yoi/tickets/00001KVXK0WD3/thread.md b/.yoi/tickets/00001KVXK0WD3/thread.md index 628c79e7..d7a2967e 100644 --- a/.yoi/tickets/00001KVXK0WD3/thread.md +++ b/.yoi/tickets/00001KVXK0WD3/thread.md @@ -199,3 +199,63 @@ Non-blocking notes: - `docs/development/plugin-development.md` には raw core-Wasm compatibility がまだ有効に読める文言が残っている。今回 Ticket の主目的は active runtime removal で、外向き rejection/docs 全面更新は後続範囲なので blocker ではないが、後続 Ticket で整理した方がよい。 --- + + + +## Implementation report + +Integration outcome: + +- Coder `yoi-coder-00001KVXK0WD3-legacy-wasm-removal` が implementation branch `work/00001KVXK0WD3-remove-legacy-wasm-runtime` に実装を commit した。 + - implementation commit: `741d7132 refactor: remove legacy wasm plugin runtime` +- Reviewer `yoi-reviewer-00001KVXK0WD3-legacy-wasm-removal` は read-only review で `approve`。legacy raw-WASM fallback execution path が削除され、Component Model Tool path、host API grant、digest binding、ToolRegistry registration が維持され、manifest/CLI rejection や Service/Ingress/WebSocket/WIT/PDK へ scope creep していないことを確認した。 +- Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration` で branch `work/00001KVXK0WD3-remove-legacy-wasm-runtime` を merge 済み。 + - merge commit: `29953111 merge: 00001KVXK0WD3 legacy wasm removal` + +Implemented behavior: +- `crates/pod/src/feature/plugin.rs` から `LegacyToolAdapter` / raw core-WASM Tool execution helper / `wasmi` host adapter 系を削除。 +- raw `wasm` runtime は static inspection と instance startup で fail-closed / non-executable diagnostic になり、active fallback execution path は残していない。 +- Component Model Tool runtime path は `ComponentToolAdapter` / `run_plugin_component_tool` / `wasmtime::component` に整理。 +- `crates/pod/Cargo.toml` から `wasmi` dependency を削除し、`Cargo.lock` と `package.nix` cargoHash を更新。 +- `docs/design/plugin-component-model.md` の transitional runtime wording を active runtime removal に合わせて最小更新。 + +Validation in Orchestrator worktree: +- `cargo test -p pod feature::plugin --lib`: success +- `cargo check -p yoi`: success +- `git diff --check`: success +- `nix build .#yoi --no-link`: success +- `cargo test -p pod`: failed in two prompt guidance snapshot assertions that are outside this Plugin diff: + - `prompt::catalog::tests::pod_orchestration_guidance_section_renders_resource_body` + - `prompt::system::tests::pod_orchestration_guidance_is_included_for_pod_management_tools` + - Plugin focused tests within the same full run passed, including `core_wasm_is_not_silently_reinterpreted_as_component`, component execution, component grant/import, lifecycle, memory/table/output cap tests. + +Notes: +- Full `cargo test -p pod` failure is recorded as an existing/unrelated prompt-resource assertion issue and not treated as this Ticket blocker because changed files do not touch prompt resources or those test modules, focused Plugin tests pass, reviewer approved, and `nix build .#yoi --no-link` succeeds. +- Follow-up `00001KVXK0WDH` remains responsible for external manifest/CLI rejection diagnostics and broader docs cleanup such as `docs/development/plugin-development.md` compatibility wording. + +Next action: +- Mark Ticket done after this integration/validation evidence. +- Then stop related child Pods and remove only the child implementation worktree/branch. +- Dependency-unblocked queued Ticket `00001KVXK0WDH` can be re-routed next. + +--- + + + +## State changed + +Implementation is merged into the orchestration branch and validated with one unrelated existing full-suite caveat. + +Evidence: +- merge commit: `29953111 merge: 00001KVXK0WD3 legacy wasm removal` +- reviewer result: approve +- validation in `/home/hare/Projects/yoi/.worktree/orchestration`: + - `cargo test -p pod feature::plugin --lib`: success + - `cargo check -p yoi`: success + - `git diff --check`: success + - `nix build .#yoi --no-link`: success + - `cargo test -p pod`: failed only in prompt guidance snapshot assertions outside this Plugin diff; focused Plugin tests in the same run passed. + +Closure is not performed here; this state records implementation completion after merge/review/focused validation. + +--- From 237c985f2c9e15a8294113cb5632f95dd7e80cdd Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 05:51:08 +0900 Subject: [PATCH 08/46] ticket: correct legacy wasm merge record --- .yoi/tickets/00001KVXK0WD3/item.md | 2 +- .yoi/tickets/00001KVXK0WD3/thread.md | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVXK0WD3/item.md b/.yoi/tickets/00001KVXK0WD3/item.md index 6b63921d..fde739ba 100644 --- a/.yoi/tickets/00001KVXK0WD3/item.md +++ b/.yoi/tickets/00001KVXK0WD3/item.md @@ -2,7 +2,7 @@ title: 'Remove legacy raw WASM Plugin runtime' state: 'done' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-24T20:50:45Z' +updated_at: '2026-06-24T20:51:02Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:11:56Z' diff --git a/.yoi/tickets/00001KVXK0WD3/thread.md b/.yoi/tickets/00001KVXK0WD3/thread.md index d7a2967e..80c0a71c 100644 --- a/.yoi/tickets/00001KVXK0WD3/thread.md +++ b/.yoi/tickets/00001KVXK0WD3/thread.md @@ -259,3 +259,15 @@ Evidence: Closure is not performed here; this state records implementation completion after merge/review/focused validation. --- + + + +## Implementation report + +Correction: + +- 直前の integration outcome / state-change body に記載した merge commit `29953111` は誤記。 +- 正しい merge commit は `bedbb670 merge: 00001KVXK0WD3 legacy wasm removal`。 +- 実装 commit `741d7132`、review approve、validation results、Ticket done 判断には変更なし。 + +--- From ceb7b95096f6f3aac621ebceeb75f10135bc558a Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 05:53:14 +0900 Subject: [PATCH 09/46] ticket: route plugin manifest rejection --- .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KVXK0WDH/item.md | 2 +- .yoi/tickets/00001KVXK0WDH/thread.md | 77 +++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVXK0WDH/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KVXK0WDH/artifacts/orchestration-plan.jsonl index 08df497d..49542432 100644 --- a/.yoi/tickets/00001KVXK0WDH/artifacts/orchestration-plan.jsonl +++ b/.yoi/tickets/00001KVXK0WDH/artifacts/orchestration-plan.jsonl @@ -1 +1,2 @@ {"id":"orch-plan-20260624-201335-1","ticket_id":"00001KVXK0WDH","kind":"blocked_by","related_ticket":"00001KVXK0WD3","note":"Queue review: `00001KVXK0WDH` は manifest/CLI rejection slice だが、active legacy runtime path removal `00001KVXK0WD3` に depends_on している。`00001KVXK0WD3` を先に受理し、この Ticket は dependency completion 後に再 routing する。","author":"yoi-orchestrator","at":"2026-06-24T20:13:35Z"} +{"id":"orch-plan-20260624-205226-2","ticket_id":"00001KVXK0WDH","kind":"accepted_plan","accepted_plan":{"summary":"Ticket `00001KVXK0WDH` は prerequisite `00001KVXK0WD3` が done になったため implementation_ready。専用 worktree `/home/hare/Projects/yoi/.worktree/00001KVXK0WDH-plugin-manifest-reject-legacy` と branch `work/00001KVXK0WDH-plugin-manifest-reject-legacy` で、legacy raw `wasm` / `yoi-plugin-wasm-1` manifest/CLI diagnostics を拒否・整理する。Service/Ingress/WebSocket/WIT/PDK runtime work は後続 Tickets に残す。","branch":"work/00001KVXK0WDH-plugin-manifest-reject-legacy","worktree":"/home/hare/Projects/yoi/.worktree/00001KVXK0WDH-plugin-manifest-reject-legacy","role_plan":"Orchestrator: accept/routing, worktree creation, final integration/validation/cleanup. Coder: manifest/CLI/docs diagnostics implementation in dedicated child worktree. Reviewer: read-only review focusing on clear legacy rejection, component package non-regression, no runtime-scope creep."},"author":"yoi-orchestrator","at":"2026-06-24T20:52:26Z"} diff --git a/.yoi/tickets/00001KVXK0WDH/item.md b/.yoi/tickets/00001KVXK0WDH/item.md index 3755d988..bf831fef 100644 --- a/.yoi/tickets/00001KVXK0WDH/item.md +++ b/.yoi/tickets/00001KVXK0WDH/item.md @@ -2,7 +2,7 @@ title: 'Reject legacy Plugin runtime in manifest and CLI diagnostics' state: 'queued' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-24T20:13:35Z' +updated_at: '2026-06-24T20:52:56Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:11:58Z' diff --git a/.yoi/tickets/00001KVXK0WDH/thread.md b/.yoi/tickets/00001KVXK0WDH/thread.md index 179c604c..a468f7d6 100644 --- a/.yoi/tickets/00001KVXK0WDH/thread.md +++ b/.yoi/tickets/00001KVXK0WDH/thread.md @@ -30,4 +30,81 @@ Marked ready by `yoi ticket state`. Ticket を `workspace-panel` が queued にしました。 +--- + + + +## Decision + +Routing decision: implementation_ready + +Reason: +- Dashboard Queue により人間が Orchestrator routing を許可した queued Ticket として確認した。 +- この Ticket は legacy Plugin runtime redesign chain の 2 番目の concrete slice で、manifest validation / CLI diagnostics / docs cleanup に範囲が限定されている。 +- outgoing `depends_on` は `00001KVXK0WD3` だが、`00001KVXK0WD3` は done / merged / reviewed / validated 済み。`TicketShow` derived blockers は空で、implementation acceptance blocker は残っていない。 +- incoming dependent `00001KVXK0WDQ` はこの Ticket 完了後に進めるべき後続であり、この Ticket の acceptance blocker ではない。 +- `TicketOrchestrationPlanQuery` には以前の `blocked_by 00001KVXK0WD3` があるが、prerequisite 完了により解消済みとして扱い、accepted plan `orch-plan-20260624-205226-2` を記録した。 +- bounded context check で current orchestration branch の `crates/manifest/src/plugin.rs`, `crates/yoi/src/plugin_cli.rs`, `docs/development/plugin-development.md`, `docs/design/plugin-component-model.md`, `docs/design/plugin-packages.md` 周辺に raw `wasm` / `yoi-plugin-wasm-1` / transitional wording が残っていることを確認した。Ticket の scope はこれらの outward schema/diagnostic/docs 整理として十分に具体的。 + +Evidence checked: +- Ticket body / thread: `item.md`, `thread.md`。未解決 planning question は記録されていない。 +- Relations / orchestration plan: outgoing depends_on `00001KVXK0WD3` は done。incoming dependent `00001KVXK0WDQ` は後続。 +- Related Ticket: `00001KVXK0WD3` は done。active legacy runtime fallback removal completed with corrected merge commit `bedbb670`。 +- Code/docs context: `crates/manifest/src/plugin.rs`, `crates/yoi/src/plugin_cli.rs`, `docs/development/plugin-development.md`, `docs/design/plugin-component-model.md`, `docs/design/plugin-packages.md`。 +- Workspace state: `/home/hare/Projects/yoi/.worktree/orchestration` は clean。inprogress Ticket は 0 件。 + +IntentPacket: + +Intent: +- Legacy raw `wasm` / `yoi-plugin-wasm-1` Plugin runtime を external manifest/schema/CLI/docs surface でも rejected/retired として扱い、Component Model `wasm-component` を正の public/recommended runtime に一本化する。 + +Binding decisions / invariants: +- `00001KVXK0WD3` の active runtime removal を前提にする。raw-WASM execution fallback を戻さない。 +- `runtime.kind = "wasm-component"` が positive accepted runtime kind。 +- legacy raw `wasm` / `yoi-plugin-wasm-1` は new/current Plugin package として validation/inspection/check/list/show 上曖昧に active 表示しない。 +- Component Model package の `check/list/show`、package discovery、digest, grants, Tool schema diagnostics は regress させない。 +- Service / Ingress runtime、WebSocket driver、WIT/PDK/templates service event update は実装しない。 + +Requirements / acceptance criteria: +- Legacy raw `wasm` runtime manifest が validation/check で明確に rejected/unsupported になる。 +- `yoi plugin check` は legacy package を invalid/unsupported として bounded diagnostic し、exit behavior が current check semantics と整合する。 +- `yoi plugin list/show` は legacy package を有効/active Plugin として曖昧に表示しない。 +- Component Model packages の check/list/show は通る。 +- docs/design / docs/development / templates/examples から raw core-Wasm compatibility bridge 前提を削除または撤回済み方針に更新する。 +- Manifest schema / static inspection / error message tests を更新する。 + +Implementation latitude: +- `PLUGIN_RUNTIME_WASM_KIND` / `PLUGIN_RUNTIME_WASM_ABI` constants を削除するか、legacy rejection diagnostic 用に internal-only に残すかは code style と tests に合わせてよい。 +- Legacy fixture は削除または rejected fixture に変換してよい。 +- CLI human/json diagnostic wording は bounded and clear であれば具体文言は coder が選んでよい。 + +Escalate if: +- Manifest parser cannot reject legacy runtime without breaking component packages。 +- Plugin list/show architecture cannot represent rejected package diagnostics without broader API redesign。 +- Implementing rejection requires Service/Ingress/WebSocket/WIT/PDK changes。 +- Existing closed Ticket constraints force raw core-Wasm compatibility to remain externally supported。 + +Validation: +- `cargo test -p manifest` +- `cargo test -p yoi` +- `cargo check -p yoi` +- `git diff --check` +- `nix build .#yoi --no-link` +- Add/run focused plugin CLI/check/list/show tests as appropriate。 + +Current code/docs map: +- Primary: `crates/manifest/src/plugin.rs`, `crates/yoi/src/plugin_cli.rs`。 +- Secondary: `docs/development/plugin-development.md`, `docs/design/plugin-component-model.md`, `docs/design/plugin-packages.md`, plugin templates/examples only as needed。 +- Avoid: `crates/pod` active runtime implementation unless minimal diagnostic alignment is needed; Service/Ingress/WebSocket/WIT/PDK event model。 + +Critical risks / reviewer focus: +- legacy package still appears active/eligible in CLI inspection。 +- component package check/list/show regression。 +- raw-WASM execution fallback being reintroduced。 +- diagnostic wording that implies compatibility support remains。 +- scope creep into later service runtime Tickets。 + +Next action: +- `queued -> inprogress` を記録してから worktree-workflow で `/home/hare/Projects/yoi/.worktree/00001KVXK0WDH-plugin-manifest-reject-legacy` を作成し、multi-agent-workflow で Coder/Reviewer sibling loop に進める。 + --- From ef1d8d9af25e93b5e146483e5bb6245652aed5e8 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 05:53:53 +0900 Subject: [PATCH 10/46] ticket: accept plugin manifest rejection task --- .yoi/tickets/00001KVXK0WDH/item.md | 4 ++-- .yoi/tickets/00001KVXK0WDH/thread.md | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KVXK0WDH/item.md b/.yoi/tickets/00001KVXK0WDH/item.md index bf831fef..f93741db 100644 --- a/.yoi/tickets/00001KVXK0WDH/item.md +++ b/.yoi/tickets/00001KVXK0WDH/item.md @@ -1,8 +1,8 @@ --- title: 'Reject legacy Plugin runtime in manifest and CLI diagnostics' -state: 'queued' +state: 'inprogress' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-24T20:52:56Z' +updated_at: '2026-06-24T20:53:45Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:11:58Z' diff --git a/.yoi/tickets/00001KVXK0WDH/thread.md b/.yoi/tickets/00001KVXK0WDH/thread.md index a468f7d6..2d1d9eed 100644 --- a/.yoi/tickets/00001KVXK0WDH/thread.md +++ b/.yoi/tickets/00001KVXK0WDH/thread.md @@ -108,3 +108,17 @@ Next action: - `queued -> inprogress` を記録してから worktree-workflow で `/home/hare/Projects/yoi/.worktree/00001KVXK0WDH-plugin-manifest-reject-legacy` を作成し、multi-agent-workflow で Coder/Reviewer sibling loop に進める。 --- + + + +## State changed + +Orchestrator acceptance: queued -> inprogress + +- 直前確認で `TicketShow` は state `queued`、derived blockers は空。 +- outgoing dependency `00001KVXK0WD3` は done / merged / reviewed / validated 済み。 +- accepted plan `orch-plan-20260624-205226-2` を確認した。 +- routing decision と IntentPacket は Ticket thread に記録済み。 +- これ以降に worktree-workflow で `/home/hare/Projects/yoi/.worktree/00001KVXK0WDH-plugin-manifest-reject-legacy` を作成し、multi-agent-workflow に接続する。 + +--- From 390f46847191ed2647539ecae2530eb58b9975b6 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 05:54:51 +0900 Subject: [PATCH 11/46] ticket: record plugin manifest coder start --- .yoi/tickets/00001KVXK0WDH/item.md | 2 +- .yoi/tickets/00001KVXK0WDH/thread.md | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVXK0WDH/item.md b/.yoi/tickets/00001KVXK0WDH/item.md index f93741db..b9fe9740 100644 --- a/.yoi/tickets/00001KVXK0WDH/item.md +++ b/.yoi/tickets/00001KVXK0WDH/item.md @@ -2,7 +2,7 @@ title: 'Reject legacy Plugin runtime in manifest and CLI diagnostics' state: 'inprogress' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-24T20:53:45Z' +updated_at: '2026-06-24T20:54:43Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:11:58Z' diff --git a/.yoi/tickets/00001KVXK0WDH/thread.md b/.yoi/tickets/00001KVXK0WDH/thread.md index 2d1d9eed..3a849a41 100644 --- a/.yoi/tickets/00001KVXK0WDH/thread.md +++ b/.yoi/tickets/00001KVXK0WDH/thread.md @@ -122,3 +122,17 @@ Orchestrator acceptance: queued -> inprogress - これ以降に worktree-workflow で `/home/hare/Projects/yoi/.worktree/00001KVXK0WDH-plugin-manifest-reject-legacy` を作成し、multi-agent-workflow に接続する。 --- + + + +## Implementation report + +Implementation orchestration update: + +- prerequisite `00001KVXK0WD3` が done になったため、`00001KVXK0WDH` を再 routing して受理した。 +- worktree-workflow に従い、implementation worktree `/home/hare/Projects/yoi/.worktree/00001KVXK0WDH-plugin-manifest-reject-legacy` と branch `work/00001KVXK0WDH-plugin-manifest-reject-legacy` を作成した。 +- child worktree では tracked `.yoi` project records を見える状態にし、`.yoi/memory`、local/runtime/log/lock/secret-like `.yoi` paths を sparse-checkout で除外する方針にした。 +- Coder Pod `yoi-coder-00001KVXK0WDH-plugin-manifest-reject` を sibling として起動した。write scope は child worktree に限定し、runtime workspace root `/home/hare/Projects/yoi` は startup 要件を満たすため non-recursive read のみにした。 +- Coder には root/original workspace と orchestration worktree で read/write/validation/git operation を行わないよう明示した。 + +--- From 6086099fe4807b5b12c8d13333f5544a4174e3d9 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 06:11:03 +0900 Subject: [PATCH 12/46] feat: reject legacy plugin runtime manifests --- crates/manifest/src/plugin.rs | 261 +++++++------------------ crates/pod/src/feature/plugin.rs | 19 +- crates/yoi/src/plugin_cli.rs | 125 ++++++++++-- docs/design/plugin-component-model.md | 46 +---- docs/design/plugin-packages.md | 34 +--- docs/development/plugin-development.md | 16 +- 6 files changed, 208 insertions(+), 293 deletions(-) diff --git a/crates/manifest/src/plugin.rs b/crates/manifest/src/plugin.rs index cfcd483f..ddf77a05 100644 --- a/crates/manifest/src/plugin.rs +++ b/crates/manifest/src/plugin.rs @@ -560,13 +560,14 @@ impl PluginPackageManifest { } } -pub const PLUGIN_RUNTIME_WASM_KIND: &str = "wasm"; -pub const PLUGIN_RUNTIME_WASM_ABI: &str = "yoi-plugin-wasm-1"; -/// Manifest runtime kind for WebAssembly Component Model Tool packages. +const LEGACY_PLUGIN_RUNTIME_WASM_KIND: &str = "wasm"; +const LEGACY_PLUGIN_RUNTIME_WASM_ABI: &str = "yoi-plugin-wasm-1"; +/// Manifest runtime kind for current WebAssembly Component Model packages. /// /// Component runtime manifests must set `component` to the packaged component -/// artifact path and `world` to [`PLUGIN_COMPONENT_TOOL_WORLD`]. Raw core-Wasm -/// packages remain explicit `kind = "wasm"` plus `abi = "yoi-plugin-wasm-1"`. +/// artifact path and `world` to [`PLUGIN_COMPONENT_TOOL_WORLD`] or +/// [`PLUGIN_COMPONENT_INSTANCE_WORLD`]. Legacy raw core-Wasm manifests are +/// intentionally rejected by validation; `wasm-component` is the public runtime. pub const PLUGIN_RUNTIME_COMPONENT_KIND: &str = "wasm-component"; pub const PLUGIN_COMPONENT_TOOL_WORLD: &str = "yoi:plugin/tool@1.0.0"; pub const PLUGIN_COMPONENT_INSTANCE_WORLD: &str = "yoi:plugin/instance@1.0.0"; @@ -1060,158 +1061,6 @@ 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 != PLUGIN_RUNTIME_WASM_KIND { - 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(PLUGIN_RUNTIME_WASM_ABI) { - 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 entry = runtime.entry.as_deref().ok_or_else(|| { - PluginDiagnostic::new( - PluginDiagnosticKind::Missing, - PluginDiagnosticPhase::Manifest, - "plugin WASM runtime entry is required", - ) - .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( - entry, - &archive, - &record.package_label, - record.source, - &record.manifest.id, - )?; - let normalized = normalize_archive_path(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) - }) -} - /// Reads the WebAssembly Component Model artifact selected by a resolved plugin /// package manifest while preserving package digest pinning. pub fn read_resolved_plugin_runtime_component( @@ -2046,38 +1895,21 @@ fn validate_manifest( } if let Some(runtime) = &manifest.runtime { match runtime.kind.as_str() { - PLUGIN_RUNTIME_WASM_KIND => { - if runtime.abi.as_deref() != Some(PLUGIN_RUNTIME_WASM_ABI) { - return Err(PluginDiagnostic::new( - PluginDiagnosticKind::Api, - PluginDiagnosticPhase::Manifest, - "plugin WASM ABI is unsupported", - ) - .with_source(source) - .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) - .with_package(label)); - } - let Some(entry) = runtime.entry.as_deref() else { - return Err(PluginDiagnostic::new( - PluginDiagnosticKind::Missing, - PluginDiagnosticPhase::Manifest, - "plugin WASM runtime entry is required", - ) - .with_source(source) - .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) - .with_package(label)); - }; - if runtime.component.is_some() || runtime.world.is_some() { - return Err(PluginDiagnostic::new( - PluginDiagnosticKind::Malformed, - PluginDiagnosticPhase::Manifest, - "plugin WASM runtime must not declare component metadata", - ) - .with_source(source) - .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) - .with_package(label)); - } - validate_manifest_path(entry, archive, label, source, &manifest.id)?; + LEGACY_PLUGIN_RUNTIME_WASM_KIND => { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Api, + PluginDiagnosticPhase::Manifest, + format!( + "legacy raw wasm plugin runtime `{LEGACY_PLUGIN_RUNTIME_WASM_KIND}` / `{}` is retired; use `{PLUGIN_RUNTIME_COMPONENT_KIND}`", + runtime + .abi + .as_deref() + .unwrap_or(LEGACY_PLUGIN_RUNTIME_WASM_ABI) + ), + ) + .with_source(source) + .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) + .with_package(label)); } PLUGIN_RUNTIME_COMPONENT_KIND => { if runtime.abi.is_some() || runtime.entry.is_some() { @@ -2844,6 +2676,51 @@ description = "bad" assert!(err.message.contains("service/ingress")); } + #[test] + fn legacy_raw_wasm_runtime_manifest_is_rejected() { + let temp = TempDir::new().unwrap(); + let workspace = temp.path().join("workspace"); + let plugins = workspace.join(".yoi/plugins"); + fs::create_dir_all(&plugins).unwrap(); + let manifest = r#" +schema_version = 1 +id = "legacy" +name = "Legacy" +version = "0.1.0" +surfaces = ["tool"] + +[runtime] +kind = "wasm" +entry = "plugin.wasm" +abi = "yoi-plugin-wasm-1" + +[[tools]] +name = "Echo" +description = "legacy" +input_schema = { type = "object" } +"#; + write_stored_zip( + &plugins.join("legacy.yoi-plugin"), + &[ + ("plugin.toml", manifest.as_bytes().to_vec(), 0), + ("plugin.wasm", b"not wasm".to_vec(), 0), + ], + ); + + let report = discover_plugins(&PluginDiscoveryOptions::new(&workspace)); + + assert!(report.packages.is_empty()); + let diagnostic = report + .diagnostics + .iter() + .find(|diag| diag.kind == PluginDiagnosticKind::Api) + .unwrap(); + assert_eq!(diagnostic.phase, PluginDiagnosticPhase::Manifest); + assert_eq!(diagnostic.identity.as_deref(), Some("project:legacy")); + assert!(diagnostic.message.contains("legacy raw wasm")); + assert!(diagnostic.message.contains(PLUGIN_RUNTIME_COMPONENT_KIND)); + } + #[test] fn discovers_valid_user_and_workspace_packages() { let temp = TempDir::new().unwrap(); @@ -3521,9 +3398,9 @@ version = "1.0.0" surfaces = ["tool"] [runtime] -kind = "wasm" -entry = "plugin.wasm" -abi = "yoi-plugin-wasm-1" +kind = "wasm-component" +component = "plugin.component.wasm" +world = "yoi:plugin/tool@1.0.0" [[permissions]] kind = "host_api" diff --git a/crates/pod/src/feature/plugin.rs b/crates/pod/src/feature/plugin.rs index 959e7175..ca05dddd 100644 --- a/crates/pod/src/feature/plugin.rs +++ b/crates/pod/src/feature/plugin.rs @@ -22,10 +22,9 @@ use llm_worker::tool::{ }; use manifest::plugin::{ PLUGIN_COMPONENT_INSTANCE_WORLD, PLUGIN_COMPONENT_TOOL_WORLD, PLUGIN_RUNTIME_COMPONENT_KIND, - PLUGIN_RUNTIME_WASM_KIND, PluginConfig, PluginDiscoveryLimits, PluginFsGrant, - PluginFsOperation, PluginHostApi, PluginPermission, PluginRequestGrant, PluginSurface, - PluginToolManifest, PluginWebSocketGrant, ResolvedPluginRecord, - read_resolved_plugin_runtime_component, + PluginConfig, PluginDiscoveryLimits, PluginFsGrant, PluginFsOperation, PluginHostApi, + PluginPermission, PluginRequestGrant, PluginSurface, PluginToolManifest, PluginWebSocketGrant, + ResolvedPluginRecord, read_resolved_plugin_runtime_component, }; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -35,6 +34,8 @@ use tokio::runtime::{ use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::protocol::{Message, WebSocketConfig}; +const LEGACY_PLUGIN_RUNTIME_WASM_KIND: &str = "wasm"; + use super::{ FeatureDescriptor, FeatureId, FeatureInstallContext, FeatureInstallError, FeatureModule, FeatureRuntimeKind, ServiceDeclaration, ServiceId, ToolContribution, ToolDeclaration, @@ -235,12 +236,12 @@ pub fn inspect_resolved_plugin_static(record: &ResolvedPluginRecord) -> PluginSt diagnostic: None, } } - Some(runtime) if runtime.kind == PLUGIN_RUNTIME_WASM_KIND => { + Some(runtime) if runtime.kind == LEGACY_PLUGIN_RUNTIME_WASM_KIND => { let status = runtime .abi .as_deref() - .map(|abi| format!("{PLUGIN_RUNTIME_WASM_KIND}/{abi}")) - .unwrap_or_else(|| format!("{PLUGIN_RUNTIME_WASM_KIND}/")); + .map(|abi| format!("{LEGACY_PLUGIN_RUNTIME_WASM_KIND}/{abi}")) + .unwrap_or_else(|| format!("{LEGACY_PLUGIN_RUNTIME_WASM_KIND}/")); PluginRuntimeEligibility { eligible: false, status, @@ -3312,7 +3313,7 @@ impl PluginInstanceRuntime { PLUGIN_RUNTIME_COMPONENT_KIND => Err(PluginWasmError::Module( "unsupported or missing plugin component world".to_string(), )), - PLUGIN_RUNTIME_WASM_KIND => Err(PluginWasmError::Module( + LEGACY_PLUGIN_RUNTIME_WASM_KIND => Err(PluginWasmError::Module( "legacy raw wasm plugin runtime is not supported; use wasm-component".to_string(), )), other => Err(PluginWasmError::Module(format!( @@ -6036,7 +6037,7 @@ input_schema = {{ type = "object", additionalProperties = true }} fn legacy_raw_wasm_runtime_is_rejected_without_fallback_execution() { let mut record = record(vec![tool("PluginEcho")]); record.manifest.runtime = Some(PluginRuntimeManifest { - kind: PLUGIN_RUNTIME_WASM_KIND.to_string(), + kind: LEGACY_PLUGIN_RUNTIME_WASM_KIND.to_string(), entry: Some("plugin.wasm".to_string()), abi: Some("yoi-plugin-wasm-1".to_string()), component: None, diff --git a/crates/yoi/src/plugin_cli.rs b/crates/yoi/src/plugin_cli.rs index f3dc94fd..da347b0b 100644 --- a/crates/yoi/src/plugin_cli.rs +++ b/crates/yoi/src/plugin_cli.rs @@ -1853,6 +1853,39 @@ mod tests { ); } + #[test] + fn legacy_raw_wasm_package_is_rejected_not_active_or_eligible() { + let dir = tempdir().unwrap(); + let workspace = dir.path(); + fs::create_dir_all(workspace.join(".yoi/plugins")).unwrap(); + write_stored_zip( + &workspace.join(".yoi/plugins/legacy.yoi-plugin"), + &[ + ("plugin.toml", plugin_legacy_manifest("legacy").as_bytes()), + ("plugin.wasm", b"not wasm"), + ], + ); + + let snapshot = inspect_snapshot(workspace, &PluginConfig::default()); + let legacy = select_item(&snapshot, "project:legacy").unwrap(); + assert_eq!(legacy.status, "rejected"); + assert!(!legacy.discovered); + assert!(!legacy.configured); + assert!(legacy.enabled_surfaces.is_empty()); + assert!(legacy.diagnostics.iter().any(|diagnostic| { + diagnostic.kind == "api" + && diagnostic.message.contains("legacy raw wasm") + && diagnostic.message.contains("wasm-component") + })); + + let list_output = render_list_snapshot_human(&snapshot).unwrap(); + assert!(list_output.contains("project:legacy [rejected]")); + assert!(!list_output.contains("project:legacy [active]")); + let show_output = render_item_human(legacy).unwrap(); + assert!(show_output.contains("status: rejected")); + assert!(show_output.contains("legacy raw wasm")); + } + #[test] fn configured_invalid_or_incompatible_package_is_rejected_not_missing() { let dir = tempdir().unwrap(); @@ -1867,7 +1900,7 @@ mod tests { &workspace.join(".yoi/plugins/incompat.yoi-plugin"), &[ ("plugin.toml", incompatible_manifest.as_bytes()), - ("plugin.wasm", b"not wasm"), + ("plugin.component.wasm", b"not wasm"), ], ); let mut config = PluginConfig::default(); @@ -1934,7 +1967,7 @@ mod tests { fs::create_dir_all(workspace.join(".yoi/plugins")).unwrap(); write_stored_zip( &workspace.join(".yoi/plugins/no_manifest.yoi-plugin"), - &[("plugin.wasm", b"not wasm")], + &[("plugin.component.wasm", b"not wasm")], ); let missing_runtime_manifest = plugin_manifest_missing_runtime_entry("missing_runtime"); write_stored_zip( @@ -2140,7 +2173,7 @@ mod tests { plugin_manifest("echo", "echo", "object", &["echo"]), ) .unwrap(); - fs::write(plugin.join("plugin.wasm"), b"not wasm").unwrap(); + fs::write(plugin.join("plugin.component.wasm"), b"not wasm").unwrap(); let human = render_check(&plugin, &PluginCliArgs::default()).unwrap(); assert!(human.contains("[active]")); @@ -2163,6 +2196,42 @@ mod tests { assert_eq!(value["safety"]["no_plugin_execution"], true); } + #[test] + fn plugin_check_rejects_legacy_raw_wasm_package() { + let dir = tempdir().unwrap(); + let plugin = dir.path().join("legacy"); + fs::create_dir_all(&plugin).unwrap(); + fs::write(plugin.join("plugin.toml"), plugin_legacy_manifest("legacy")).unwrap(); + fs::write(plugin.join("plugin.wasm"), b"not wasm").unwrap(); + + let report = build_check_report(&plugin); + assert_eq!(report.status, "rejected"); + assert!(report.diagnostics.iter().any(|diagnostic| { + diagnostic.kind == "api" + && diagnostic.message.contains("legacy raw wasm") + && diagnostic.message.contains("wasm-component") + })); + let human = render_check_report(&report, &PluginCliArgs::default()).unwrap(); + assert!(human.contains("[rejected]")); + assert!(human.contains("legacy raw wasm")); + let json = render_check_report( + &report, + &PluginCliArgs { + json: true, + ..PluginCliArgs::default() + }, + ) + .unwrap(); + let value: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(value["status"], "rejected"); + assert!( + value["diagnostics"][0]["message"] + .as_str() + .unwrap_or_default() + .contains("wasm-component") + ); + } + #[test] fn plugin_check_rejects_invalid_manifest_and_missing_runtime_artifact() { let dir = tempdir().unwrap(); @@ -2228,7 +2297,7 @@ mod tests { plugin_manifest("echo", "echo", "object", &["echo"]), ) .unwrap(); - fs::write(plugin.join("plugin.wasm"), b"not wasm").unwrap(); + fs::write(plugin.join("plugin.component.wasm"), b"not wasm").unwrap(); let first = dir.path().join("first.yoi-plugin"); let second = dir.path().join("second.yoi-plugin"); @@ -2466,9 +2535,9 @@ surfaces = ["tool"] permissions = [{{ kind = "surface", surface = "tool" }}, {{ kind = "tool", name = "Echo" }}] [runtime] -kind = "wasm" -entry = "missing.wasm" -abi = "yoi-plugin-wasm-1" +kind = "wasm-component" +component = "missing.component.wasm" +world = "yoi:plugin/tool@1.0.0" [[tools]] name = "Echo" @@ -2500,9 +2569,9 @@ surfaces = ["tool"] permissions = [{{ kind = "surface", surface = "tool" }}, {permissions}] [runtime] -kind = "wasm" -entry = "plugin.wasm" -abi = "yoi-plugin-wasm-1" +kind = "wasm-component" +component = "plugin.component.wasm" +world = "yoi:plugin/tool@1.0.0" [[tools]] name = "{tool_name}" @@ -2512,6 +2581,28 @@ input_schema = {{ type = "{schema_type}" }} ) } + fn plugin_legacy_manifest(id: &str) -> String { + format!( + r#" +schema_version = 1 +id = "{id}" +name = "{id}" +version = "0.1.0" +surfaces = ["tool"] + +[runtime] +kind = "wasm" +entry = "plugin.wasm" +abi = "yoi-plugin-wasm-1" + +[[tools]] +name = "Echo" +description = "Legacy raw wasm tool" +input_schema = {{ type = "object" }} +"# + ) + } + fn write_plugin_package(workspace: &Path, id: &str) -> String { let manifest = format!( r#" @@ -2523,9 +2614,9 @@ surfaces = ["tool"] permissions = [{{ kind = "surface", surface = "tool" }}, {{ kind = "tool", name = "Echo" }}] [runtime] -kind = "wasm" -entry = "plugin.wasm" -abi = "yoi-plugin-wasm-1" +kind = "wasm-component" +component = "plugin.component.wasm" +world = "yoi:plugin/tool@1.0.0" [[tools]] name = "Echo" @@ -2547,9 +2638,9 @@ surfaces = ["tool"] permissions = [{{ kind = "surface", surface = "tool" }}, {{ kind = "tool", name = "Echo" }}, {{ kind = "tool", name = "Other" }}] [runtime] -kind = "wasm" -entry = "plugin.wasm" -abi = "yoi-plugin-wasm-1" +kind = "wasm-component" +component = "plugin.component.wasm" +world = "yoi:plugin/tool@1.0.0" [[tools]] name = "Echo" @@ -2573,7 +2664,7 @@ input_schema = {{ type = "object" }} &package, &[ ("plugin.toml", manifest.as_bytes()), - ("plugin.wasm", b"not wasm"), + ("plugin.component.wasm", b"not wasm"), ], ); diff --git a/docs/design/plugin-component-model.md b/docs/design/plugin-component-model.md index 8842a509..11f7f062 100644 --- a/docs/design/plugin-component-model.md +++ b/docs/design/plugin-component-model.md @@ -1,8 +1,8 @@ # Plugin Component Model migration -Yoi's current Plugin Tool runtime uses a narrow core-WebAssembly ABI. That was the right MVP shape because it made sandboxing, bounded input/output, and fail-closed host imports explicit. It should not become the long-term authoring interface. +Yoi's original Plugin Tool runtime used a narrow core-WebAssembly ABI. That was the right MVP shape because it made sandboxing, bounded input/output, and fail-closed host imports explicit, but it is no longer the public authoring interface. -The preferred direction is to adopt the WebAssembly Component Model for Plugin Tool authoring and host APIs. Component Model adoption means Plugin interfaces are described as typed WIT worlds and lowered through the canonical ABI, instead of every Plugin author or SDK wrapper hand-writing pointer/length memory plumbing. +The supported runtime kind is now `wasm-component`, using the WebAssembly Component Model for Plugin Tool authoring and host APIs. Component Model adoption means Plugin interfaces are described as typed WIT worlds and lowered through the canonical ABI, instead of every Plugin author or SDK wrapper hand-writing pointer/length memory plumbing. ## What Component Model changes @@ -74,34 +74,15 @@ Adopting the Component Model must not change Yoi's authority model: ## Migration shape -Yoi should support Component Model as an explicit runtime kind rather than silently changing existing raw-ABI packages. +`runtime.kind = "wasm-component"` is the sole public Plugin runtime kind. Legacy raw core-Wasm declarations (`kind = "wasm"` / `abi = "yoi-plugin-wasm-1"`) are rejected by manifest validation and are surfaced only as bounded diagnostics; they are not active/eligible Plugins and are not executed. -Possible manifest direction: +The migration is now focused on the component surface: -```toml -[runtime] -kind = "wasm-component" -component = "plugin.component.wasm" -world = "yoi:plugin/tool@1.0.0" -``` - -The current raw core-Wasm runtime can remain explicit during migration: - -```toml -[runtime] -kind = "wasm" -entry = "plugin.wasm" -abi = "yoi-plugin-wasm-1" -``` - -The migration should be phased: - -1. Define WIT packages/worlds for Tool Plugin and initial host APIs. -2. Add manifest/schema support for `runtime.kind = "wasm-component"` without executing it during discovery. -3. Add a component runtime backend and typed host import/export binding. -4. Port `https` and `fs` host API designs to WIT-compatible interfaces. -5. Add a Rust PDK/template around the component world. -6. Decide whether the raw ABI remains supported, becomes legacy-only, or is deprecated after examples and tests move. +1. Keep WIT packages/worlds for Tool Plugin and initial host APIs versioned under `resources/plugin/wit`. +2. Keep manifest/schema support centered on `runtime.kind = "wasm-component"`. +3. Keep the component runtime backend and typed host import/export binding as the active execution path. +4. Port future host API designs to WIT-compatible interfaces. +5. Keep the Rust PDK/template aligned with the component world. ## Runtime/backend caution @@ -133,14 +114,7 @@ component = "plugin.component.wasm" world = "yoi:plugin/tool@1.0.0" ``` -The legacy core-Wasm ABI remains explicit metadata for migration diagnostics and is not reinterpreted as a component or executed by the active runtime path: - -```toml -[runtime] -kind = "wasm" -entry = "plugin.wasm" -abi = "yoi-plugin-wasm-1" -``` +Legacy core-Wasm metadata is accepted only far enough to produce migration diagnostics: package checks and discovery reject `kind = "wasm"` / `abi = "yoi-plugin-wasm-1"`, `list`/`show` report those packages as rejected rather than active/eligible, and the active runtime path does not execute them. The component runtime uses `wasmtime::component` and expects the exported world `yoi:plugin/tool@1.0.0` with a `call(tool-name: string, input-json: string) -> diff --git a/docs/design/plugin-packages.md b/docs/design/plugin-packages.md index 0a303361..74852697 100644 --- a/docs/design/plugin-packages.md +++ b/docs/design/plugin-packages.md @@ -6,7 +6,7 @@ The initial goal is a durable `.yoi-plugin` package format that later Tickets ca ## Package shape -A `.yoi-plugin` file is a single-file archive. The initial archive format should be a constrained ZIP profile because it is easy to inspect without executing code and can carry text manifests, WASM modules, schemas, and license material. +A `.yoi-plugin` file is a single-file archive. The archive format is a constrained ZIP profile because it is easy to inspect without executing code and can carry text manifests, WebAssembly Component Model modules, schemas, and license material. The archive root must contain `plugin.toml` directly at the root. Packages should not require a wrapping directory whose name must match the plugin id. @@ -14,7 +14,7 @@ Recommended root layout: ```text plugin.toml # required package manifest -module.wasm # optional; required when plugin.toml declares a WASM runtime +plugin.component.wasm # required when plugin.toml declares the component runtime hooks/*.toml # optional declarative hook definitions schemas/*.schema.json # optional JSON schemas for configuration or tool input/output README.md # recommended human description @@ -43,16 +43,7 @@ id = "summary" file = "hooks/summary.md" ``` -The package archive must contain both root `plugin.toml` and the referenced `hooks/summary.md` entry. Optional WASM metadata is accepted only for the declared future runtime boundary and is not executed: - -```toml -[runtime] -kind = "wasm" -entry = "plugin.wasm" -abi = "yoi-plugin-wasm-1" -``` - -The preferred WASM authoring/runtime shape is the WebAssembly Component Model, recorded in [Plugin Component Model migration](plugin-component-model.md). Component packages should be explicit and source-compatible rather than silently changing the existing raw core-Wasm runtime: +The package archive must contain both root `plugin.toml` and referenced runtime/content entries. Component runtime metadata is explicit and static inspection never executes the artifact: ```toml [runtime] @@ -61,13 +52,15 @@ component = "plugin.component.wasm" world = "yoi:plugin/tool@1.0.0" ``` +`wasm-component` is the public/recommended runtime kind, recorded in [Plugin Component Model migration](plugin-component-model.md). Legacy raw core-Wasm declarations (`kind = "wasm"` / `abi = "yoi-plugin-wasm-1"`) are retired: manifest validation rejects them and CLI inspection reports the package as rejected rather than active/eligible. + First-pass fields accepted by the parser: - `schema_version`: required integer; unsupported versions fail closed. - `id`: required unqualified local id. It is scoped by the source that discovered the package; it is not globally unique by itself. - `name`, `version`, `description`: human metadata used in listings and diagnostics. - `surfaces`: optional declared contribution surface names. -- `runtime`: optional WASM metadata only. Discovery records metadata and never executes it. +- `runtime`: optional component runtime metadata. Discovery records metadata and never executes it; unsupported/retired runtime kinds fail closed. - `hooks`: optional hook metadata. Discovery records metadata and does not register hooks. Future descriptor sections such as `[package]`, `[permissions]`, richer `contributions`, or `runtime.kind = "declarative"` are aspirational and are intentionally rejected by the current strict parser until implemented safely. @@ -223,17 +216,4 @@ documents a future out-of-tree pinned git `rev` dependency pattern. Crates.io publication, remote template fetching, and package authoring commands are not part of the current package/runtime contract. -This is separate from the legacy raw core-Wasm runtime: - -```toml -[runtime] -kind = "wasm" -entry = "plugin.wasm" -abi = "yoi-plugin-wasm-1" -``` - -Component packages must not use `entry`/`abi`; raw packages must not use -`component`/`world`. Discovery reports the selected runtime kind/world without -executing the artifact. Component execution still requires explicit package -enablement, exact source/version/digest grants, and matching Tool/host API -permissions. +Legacy raw core-Wasm metadata remains documented only as a rejected migration diagnostic. Packages must not use `entry`/`abi`; discovery reports `kind = "wasm"` / `abi = "yoi-plugin-wasm-1"` packages as rejected without executing the artifact. Component execution still requires explicit package enablement, exact source/version/digest grants, and matching Tool/host API permissions. diff --git a/docs/development/plugin-development.md b/docs/development/plugin-development.md index d6237483..75df3828 100644 --- a/docs/development/plugin-development.md +++ b/docs/development/plugin-development.md @@ -25,7 +25,7 @@ Keep these layers separate when designing a Plugin. Do not make package discover Yoi's preferred Plugin shape is **Tool first**. A good Tool Plugin has a narrow schema, deterministic input/output behavior, explicit side-effect metadata, and a minimal grant set. Long-running services, inbound events, and autonomous routing are future Service/Ingress work; they should not be hidden inside a Tool package. -Component Model authoring is the preferred path for new Plugins. The raw core-Wasm ABI exists for compatibility and tests, but authors should use the Rust PDK/template unless they are deliberately testing the low-level runtime. +Component Model authoring is the supported path for Plugins. Legacy raw core-Wasm manifests (`kind = "wasm"` / `abi = "yoi-plugin-wasm-1"`) are retired and rejected by `yoi plugin check`, discovery, `list`, and `show`; use the Rust PDK/template and `kind = "wasm-component"` instead. ## Current status @@ -35,7 +35,6 @@ Implemented foundation: - explicit enablement resolution; - Tool surface registration; - Plugin permission grants; -- raw core-Wasm Tool runtime; - Component Model Tool runtime; - first-party Rust PDK helpers for Component Model Tool guests; - embedded Rust Component Tool starter template; @@ -152,20 +151,13 @@ input_schema = { type = "object", properties = { text = { type = "string" } }, r external_write = false ``` -The preferred new runtime is `wasm-component`. The older raw core-Wasm runtime remains explicit for compatibility: - -```toml -[runtime] -kind = "wasm" -entry = "plugin.wasm" -abi = "yoi-plugin-wasm-1" -``` +`wasm-component` is the public runtime kind. Legacy raw core-Wasm declarations such as `kind = "wasm"` / `abi = "yoi-plugin-wasm-1"` are no longer compatibility paths: static validation rejects them with a bounded diagnostic and they are not displayed as active/eligible Plugins. Do not rely on package presence to activate anything. Discovery only records inventory. ## Rust PDK authoring -Rust authoring with `yoi-plugin-pdk` is the preferred path for new Tool Plugins. The raw core-Wasm ABI remains available only as compatibility/transitional runtime support. +Rust authoring with `yoi-plugin-pdk` is the supported path for new Tool Plugins. Raw core-Wasm ABI packages are retired and should be rewritten as Component Model packages before enabling. Create a starter with: @@ -412,4 +404,4 @@ Yoi normalizes paths, rejects `..` traversal, rejects symlink/root escapes, and - Request only the minimal host APIs and grants needed. - Keep Tool output bounded and structured. - Prefer Component Model authoring for new Plugins. -- Treat raw core-Wasm ABI support as transitional compatibility. +- Treat raw core-Wasm ABI support as retired; migration diagnostics may mention it, but authors should publish `wasm-component` packages. From 436bcc812d54037921c173beee859c93ea15970e Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 06:12:05 +0900 Subject: [PATCH 13/46] ticket: record plugin manifest rejection report --- .yoi/tickets/00001KVXK0WDH/item.md | 2 +- .yoi/tickets/00001KVXK0WDH/thread.md | 57 ++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVXK0WDH/item.md b/.yoi/tickets/00001KVXK0WDH/item.md index b9fe9740..07c62353 100644 --- a/.yoi/tickets/00001KVXK0WDH/item.md +++ b/.yoi/tickets/00001KVXK0WDH/item.md @@ -2,7 +2,7 @@ title: 'Reject legacy Plugin runtime in manifest and CLI diagnostics' state: 'inprogress' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-24T20:54:43Z' +updated_at: '2026-06-24T21:11:58Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:11:58Z' diff --git a/.yoi/tickets/00001KVXK0WDH/thread.md b/.yoi/tickets/00001KVXK0WDH/thread.md index 3a849a41..ef1e0b2a 100644 --- a/.yoi/tickets/00001KVXK0WDH/thread.md +++ b/.yoi/tickets/00001KVXK0WDH/thread.md @@ -136,3 +136,60 @@ Implementation orchestration update: - Coder には root/original workspace と orchestration worktree で read/write/validation/git operation を行わないよう明示した。 --- + + + +## Implementation report + +Implementation report: + +対象 Ticket: +- `00001KVXK0WDH` — Reject legacy Plugin runtime in manifest and CLI diagnostics + +作業ブランチ / Worktree: +- Worktree: `/home/hare/Projects/yoi/.worktree/00001KVXK0WDH-plugin-manifest-reject-legacy` +- Branch: `work/00001KVXK0WDH-plugin-manifest-reject-legacy` + +変更内容: +- `crates/manifest/src/plugin.rs` + - legacy raw `wasm` / `yoi-plugin-wasm-1` runtime を manifest validation で reject。 + - `wasm-component` を現行 public runtime としてドキュメントコメントを更新。 + - raw-WASM runtime module 読み込み helper を削除。 + - legacy manifest rejection のテストを追加。 +- `crates/yoi/src/plugin_cli.rs` + - CLI テスト fixture を `wasm-component` package へ移行。 + - `yoi plugin check` が legacy raw-WASM package を `rejected` として返すテストを追加。 + - `list/show` inspection 上で legacy package が active/eligible 表示されないことをテスト。 +- `crates/pod/src/feature/plugin.rs` + - public manifest constant 依存を外し、legacy runtime 名は内部診断用 const に限定。 + - 既存の runtime fallback rejection 経路を維持。 +- `docs/development/plugin-development.md` + - raw-WASM runtime を「互換/推奨」ではなく retired/rejected として記述更新。 + - `wasm-component` を supported/public runtime として明示。 +- `docs/design/plugin-component-model.md` + - Component Model runtime を現行 supported runtime として更新。 + - raw-WASM は bounded diagnostic 用の rejected metadata として整理。 +- `docs/design/plugin-packages.md` + - package runtime 記述を `wasm-component` 中心に更新。 + - legacy raw-WASM package は active/eligible ではなく rejected として表示されることを明記。 + +Validation: +- `cargo fmt --check`: success +- `cargo test -p manifest`: success +- `cargo test -p yoi`: success +- `cargo check -p yoi`: success +- `cargo test -p pod legacy_raw_wasm_runtime_is_rejected_without_fallback_execution`: success +- `git diff --check`: success +- `nix build .#yoi --no-link`: success + - dirty tree warning は未コミット検証時点の通常 warning。ビルド自体は成功。 + +Commit: +- `6086099fe4807b5b12c8d13333f5544a4174e3d9 feat: reject legacy plugin runtime manifests` + +Residual risks / notes: +- 既知の残リスクなし。 +- raw-WASM execution fallback は再導入していない。 +- legacy runtime 名は manifest/pod 内部の rejected diagnostic fixture/判定用としてのみ残している。 +- Service/Ingress runtime、WebSocket driver、WIT/PDK/templates の service event 更新、broader runtime redesign は実施していない。 + +--- From e66efc746f1671bcd6797377db067915f89995b2 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 06:19:01 +0900 Subject: [PATCH 14/46] ticket: approve plugin manifest rejection --- .yoi/tickets/00001KVXK0WDH/item.md | 2 +- .yoi/tickets/00001KVXK0WDH/thread.md | 45 ++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVXK0WDH/item.md b/.yoi/tickets/00001KVXK0WDH/item.md index 07c62353..33c187ec 100644 --- a/.yoi/tickets/00001KVXK0WDH/item.md +++ b/.yoi/tickets/00001KVXK0WDH/item.md @@ -2,7 +2,7 @@ title: 'Reject legacy Plugin runtime in manifest and CLI diagnostics' state: 'inprogress' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-24T21:11:58Z' +updated_at: '2026-06-24T21:18:53Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:11:58Z' diff --git a/.yoi/tickets/00001KVXK0WDH/thread.md b/.yoi/tickets/00001KVXK0WDH/thread.md index ef1e0b2a..465268d9 100644 --- a/.yoi/tickets/00001KVXK0WDH/thread.md +++ b/.yoi/tickets/00001KVXK0WDH/thread.md @@ -193,3 +193,48 @@ Residual risks / notes: - Service/Ingress runtime、WebSocket driver、WIT/PDK/templates の service event 更新、broader runtime redesign は実施していない。 --- + + + +## Review: approve + +Review result: approve + +確認範囲: +- child worktree `/home/hare/Projects/yoi/.worktree/00001KVXK0WDH-plugin-manifest-reject-legacy` +- branch `work/00001KVXK0WDH-plugin-manifest-reject-legacy` +- HEAD `6086099fe4807b5b12c8d13333f5544a4174e3d9` +- 実履歴上の親 `ef1d8d9a` → HEAD の差分をレビューした。指定 base `187e6426` はこの worktree では object missing だったが、HEAD の直前 commit は `ticket: accept plugin manifest rejection task` で、レビュー対象差分として妥当と判断した。 + +承認根拠: +- Ticket / IntentPacket の要件と照合済み。 +- `crates/manifest/src/plugin.rs`: + - legacy `wasm` / `yoi-plugin-wasm-1` は public const ではなく internal rejection 用 const になっている。 + - `validate_manifest` で `kind = "wasm"` を `legacy raw wasm ... retired; use wasm-component` として `Api/Manifest` diagnostic 付きで拒否している。 + - `wasm-component` は正の runtime kind として `component` / `world` validation を維持している。 + - legacy rejection test が追加され、discovery package には入らず diagnostic になることを確認している。 +- `crates/yoi/src/plugin_cli.rs`: + - `plugin check` は manifest/discovery rejection を `status: "rejected"` として bounded diagnostic に載せ、既存 check semantics 通り rejected で Err を返す。 + - list/show snapshot は discovery/resolution diagnostics から rejected item を構成し、legacy package を active/eligible として扱わない。 + - component fixture は `wasm-component` / `plugin.component.wasm` に更新され、legacy fixture は rejected fixture に変換されている。 +- `crates/pod/src/feature/plugin.rs`: + - manifest crate の legacy runtime public const 依存は削除済み。 + - legacy name は internal diagnostic/rejection matching のみ。 + - `PluginInstanceRuntime::new` は legacy `wasm` を即エラーにし、raw-WASM fallback 実行は戻っていない。 +- docs: + - `docs/development/plugin-development.md` + - `docs/design/plugin-component-model.md` + - `docs/design/plugin-packages.md` + で raw core-Wasm compatibility 前提は撤回され、`wasm-component` が supported/public runtime と明記されている。 +- `rg 'plugin\.wasm|kind = "wasm"|abi = "yoi-plugin-wasm-1"'` で残存箇所を確認。残りは rejection docs/tests/diagnostics と component build artifact 名の文脈のみで、active public compatibility 表現は見当たらない。 +- 差分対象は manifest / CLI / pod diagnostic alignment / docs の 6 files に限定され、Service/Ingress/WebSocket/WIT/PDK runtime redesign への scope creep は確認していない。 + +Validation: +- reviewer は read-only 制約のため `cargo test` / `cargo check` / `nix build` は再実行していない。 +- reviewer 側で `git diff --check HEAD^..HEAD`: success。 +- Coder reported validation (`cargo fmt --check`, `cargo test -p manifest`, `cargo test -p yoi`, `cargo check -p yoi`, focused pod test, `nix build .#yoi --no-link`) は実装内容と整合しており、追加で疑う材料はない。 + +Non-blocking note: +- 親から指定された review base hash `187e6426` は誤記/存在しない hash だった。実レビューは child branch の実 parent `ef1d8d9a` を base として行われた。 + +--- From 7d64b443f27ed2cf7b66b221778f144ee23ebee4 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 06:20:51 +0900 Subject: [PATCH 15/46] ticket: mark plugin manifest rejection done --- .yoi/tickets/00001KVXK0WDH/item.md | 4 +- .yoi/tickets/00001KVXK0WDH/thread.md | 66 ++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KVXK0WDH/item.md b/.yoi/tickets/00001KVXK0WDH/item.md index 33c187ec..814395a1 100644 --- a/.yoi/tickets/00001KVXK0WDH/item.md +++ b/.yoi/tickets/00001KVXK0WDH/item.md @@ -1,8 +1,8 @@ --- title: 'Reject legacy Plugin runtime in manifest and CLI diagnostics' -state: 'inprogress' +state: 'done' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-24T21:18:53Z' +updated_at: '2026-06-24T21:20:45Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:11:58Z' diff --git a/.yoi/tickets/00001KVXK0WDH/thread.md b/.yoi/tickets/00001KVXK0WDH/thread.md index 465268d9..23bf8ae4 100644 --- a/.yoi/tickets/00001KVXK0WDH/thread.md +++ b/.yoi/tickets/00001KVXK0WDH/thread.md @@ -238,3 +238,69 @@ Non-blocking note: - 親から指定された review base hash `187e6426` は誤記/存在しない hash だった。実レビューは child branch の実 parent `ef1d8d9a` を base として行われた。 --- + + + +## Implementation report + +Integration outcome: + +- Coder `yoi-coder-00001KVXK0WDH-plugin-manifest-reject` が implementation branch `work/00001KVXK0WDH-plugin-manifest-reject-legacy` に実装を commit した。 + - implementation commit: `6086099f feat: reject legacy plugin runtime manifests` +- Reviewer `yoi-reviewer-00001KVXK0WDH-plugin-manifest-reject` は read-only review で `approve`。legacy `wasm` / `yoi-plugin-wasm-1` manifest rejection、CLI check/list/show diagnostic behavior、component package non-regression、pod diagnostic alignment、docs update、scope creep なしを確認した。 +- Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration` で branch `work/00001KVXK0WDH-plugin-manifest-reject-legacy` を merge 済み。 + - merge commit: `449745ee merge: 00001KVXK0WDH plugin manifest rejection` + +Implemented behavior: +- `crates/manifest/src/plugin.rs`: + - legacy raw `wasm` / `yoi-plugin-wasm-1` runtime を manifest validation で rejected diagnostic にする。 + - `wasm-component` を supported public runtime として維持。 +- `crates/yoi/src/plugin_cli.rs`: + - `yoi plugin check` は legacy package を `status: "rejected"` として bounded diagnostic に載せ、既存 check semantics 通り失敗扱いにする。 + - list/show fixture は legacy package を active/eligible として表示しない。 +- `crates/pod/src/feature/plugin.rs`: + - legacy runtime public constant dependency を外し、legacy runtime name は internal diagnostic/rejection matching のみに限定。 + - raw-WASM fallback execution は再導入していない。 +- docs: + - `docs/development/plugin-development.md`, `docs/design/plugin-component-model.md`, `docs/design/plugin-packages.md` で raw core-Wasm compatibility 前提を撤回し、`wasm-component` を supported/public runtime と明記。 + +Validation in Orchestrator worktree: +- `cargo fmt --check`: success +- `cargo test -p manifest`: success +- `cargo test -p yoi`: success +- `cargo check -p yoi`: success +- `cargo test -p pod legacy_raw_wasm_runtime_is_rejected_without_fallback_execution --lib`: success +- `git diff --check`: success +- `nix build .#yoi --no-link`: success + +Notes: +- Reviewer non-blocking note: Orchestrator から渡した review base `187e6426` は誤記で、child branch の実 parent `ef1d8d9a` を base として review された。review scope は適切だった。 +- Follow-up `00001KVXK0WDQ` は this Ticket completion により dependency-unblocked and can be re-routed next. + +Next action: +- Mark Ticket done after this integration/validation evidence. +- Then stop related child Pods and remove only the child implementation worktree/branch. + +--- + + + +## State changed + +Implementation is merged into the orchestration branch and validated. + +Evidence: +- merge commit: `449745ee merge: 00001KVXK0WDH plugin manifest rejection` +- reviewer result: approve +- validation in `/home/hare/Projects/yoi/.worktree/orchestration` succeeded: + - `cargo fmt --check` + - `cargo test -p manifest` + - `cargo test -p yoi` + - `cargo check -p yoi` + - `cargo test -p pod legacy_raw_wasm_runtime_is_rejected_without_fallback_execution --lib` + - `git diff --check` + - `nix build .#yoi --no-link` + +Closure is not performed here; this state records implementation completion after merge/validation. + +--- From 437ef5b56b72496ee2d5236f9e6b0acca73d8759 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 06:23:06 +0900 Subject: [PATCH 16/46] ticket: route plugin service lifecycle task --- .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KVXK0WDQ/item.md | 2 +- .yoi/tickets/00001KVXK0WDQ/thread.md | 77 +++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVXK0WDQ/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KVXK0WDQ/artifacts/orchestration-plan.jsonl index 17d18943..f4a488ce 100644 --- a/.yoi/tickets/00001KVXK0WDQ/artifacts/orchestration-plan.jsonl +++ b/.yoi/tickets/00001KVXK0WDQ/artifacts/orchestration-plan.jsonl @@ -1 +1,2 @@ {"id":"orch-plan-20260624-201335-1","ticket_id":"00001KVXK0WDQ","kind":"blocked_by","related_ticket":"00001KVXK0WDH","note":"Queue review: `00001KVXK0WDQ` は Service lifecycle / ingress queue runtime slice だが、Component Model-only runtime authority / manifest rejection slice `00001KVXK0WDH` に depends_on している。prerequisite completion 後に再 routing する。","author":"yoi-orchestrator","at":"2026-06-24T20:13:35Z"} +{"id":"orch-plan-20260624-212209-2","ticket_id":"00001KVXK0WDQ","kind":"accepted_plan","accepted_plan":{"summary":"Ticket `00001KVXK0WDQ` は prerequisites `00001KVXK0WD3` と `00001KVXK0WDH` が done になったため implementation_ready。専用 worktree `/home/hare/Projects/yoi/.worktree/00001KVXK0WDQ-plugin-service-lifecycle` と branch `work/00001KVXK0WDQ-plugin-service-lifecycle` で、Plugin Service lifecycle と ingress queue runtime を実装する。Output command model / WebSocket driver / WIT-PDK templates は後続 Tickets に残す。","branch":"work/00001KVXK0WDQ-plugin-service-lifecycle","worktree":"/home/hare/Projects/yoi/.worktree/00001KVXK0WDQ-plugin-service-lifecycle","role_plan":"Orchestrator: accept/routing, worktree creation, final integration/validation/cleanup. Coder: implement service lifecycle and ingress queue runtime in dedicated child worktree. Reviewer: read-only review focusing on bounded in-process event queue, no WebSocket driver/output command scope creep, grant/capability boundaries, and component Tool regression."},"author":"yoi-orchestrator","at":"2026-06-24T21:22:09Z"} diff --git a/.yoi/tickets/00001KVXK0WDQ/item.md b/.yoi/tickets/00001KVXK0WDQ/item.md index 957ae605..cb1f2e31 100644 --- a/.yoi/tickets/00001KVXK0WDQ/item.md +++ b/.yoi/tickets/00001KVXK0WDQ/item.md @@ -2,7 +2,7 @@ title: 'Define Plugin Service lifecycle and ingress queue runtime' state: 'queued' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-24T20:13:35Z' +updated_at: '2026-06-24T21:22:37Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:12:00Z' diff --git a/.yoi/tickets/00001KVXK0WDQ/thread.md b/.yoi/tickets/00001KVXK0WDQ/thread.md index 021411dc..7f63816f 100644 --- a/.yoi/tickets/00001KVXK0WDQ/thread.md +++ b/.yoi/tickets/00001KVXK0WDQ/thread.md @@ -30,4 +30,81 @@ Marked ready by `yoi ticket state`. Ticket を `workspace-panel` が queued にしました。 +--- + + + +## Decision + +Routing decision: implementation_ready + +Reason: +- Dashboard Queue により人間が Orchestrator routing を許可した queued Ticket として確認した。 +- `00001KVXK0WDQ` は Service lifecycle / ingress queue runtime の concrete slice で、WebSocket driver、output command model、WIT/PDK/templates update を non-goal として後続に分離している。 +- outgoing `depends_on` は `00001KVXK0WDH` だが、`00001KVXK0WDH` は done / merged / reviewed / validated 済み。`TicketShow` derived blockers は空で、implementation acceptance blocker は残っていない。 +- incoming dependents (`00001KVXK0WDX`, `00001KVXK0WE4`) はこの Ticket 完了後に進めるべき後続であり、この Ticket の acceptance blocker ではない。 +- bounded context check で `crates/pod/src/feature/plugin.rs` の current `PluginInstanceRegistry`, `PluginInstanceHandle`, `PluginIngressEvent`, `PluginIngressDispatchReport`, `ComponentInstanceRuntime` lifecycle methods を確認した。Ticket は in-process service lifecycle / bounded ingress queue / serial dispatch / diagnostics に収まっており、残る不確実性は local implementation に閉じる。 + +Evidence checked: +- Ticket body / thread: `item.md`, `thread.md`。未解決 planning question は記録されていない。 +- Relations / orchestration plan: outgoing depends_on `00001KVXK0WDH` は done。routing 前 plan は historical blocked_by `00001KVXK0WDH` のみで、prerequisite 完了により解消済み。accepted plan `orch-plan-20260624-212209-2` を記録済み。 +- Related Tickets: `00001KVXK0WD3` / `00001KVXK0WDH` は done。 +- Code context: `crates/pod/src/feature/plugin.rs` の service/ingress registration, instance registry, start/status/stop/handle-ingress, component runtime tests。 +- Workspace state: `/home/hare/Projects/yoi/.worktree/orchestration` は clean。inprogress Ticket は 0 件。 + +IntentPacket: + +Intent: +- Plugin Service を host-managed lifecycle と bounded ingress queue を持つ in-process runtime として扱い、`start()` を initialization-only にし、後続 ingress events を queue 経由で serial dispatch できるようにする。 + +Binding decisions / invariants: +- Existing Tool Plugin execution は request-response operation として維持し、service queue に巻き込まない。 +- v0 dispatch は per-plugin serial dispatch。concurrent per-plugin event execution は non-goal。 +- Queue は bounded。full / timeout / failed service / stop 中 event / invalid event は typed error / diagnostic として扱う。 +- `start()` は long-running loop / polling loop / recv loop を担わない。 +- Durable cross-process event queue は non-goal。まず host-managed in-process queue/lifecycle として実装する。 +- WebSocket driver と output command model は後続 Tickets (`00001KVXK0WE4`, `00001KVXK0WDX`) に残す。 +- Component Model-only runtime authority and manifest rejection from prerequisites must not regress. + +Requirements / acceptance criteria: +- Service Plugin instance が ready/starting/running/stopping/stopped/failed 相当の lifecycle state を持つ。 +- `start()` return 後も ingress event を queue 経由で配送できる。 +- Ingress event has source / ingress name / payload / created_at / attempt / correlation id. +- Queue depth / lifecycle state / last error / dispatch counters are visible in status diagnostics. +- Unit tests cover lifecycle start/stop/failure, bounded queue full, serial dispatch, timeout/failure diagnostics, stop-time event rejection, Tool execution regression. +- `cargo test -p pod`, `cargo check -p yoi`, `git diff --check`, `nix build .#yoi --no-link` are validation targets. + +Implementation latitude: +- Choose exact structs/enums/names and whether queue processing is sync-step driven or background worker, as long as start returns promptly and dispatch remains host-managed/serial. +- Existing `PluginInstanceRegistry` may be extended or refactored if authority boundaries remain clear. +- Tests may use existing `test-ingress` runtime or focused component fixtures. + +Escalate if: +- Implementation requires durable cross-process queue or scheduler semantics. +- WebSocket driver or output command model must be implemented to make lifecycle tests pass. +- Component Tool execution has to be routed through service queue. +- Host API grant/runtime authority or Component Model manifest behavior needs redesign. + +Validation: +- `cargo test -p pod` +- `cargo check -p yoi` +- `git diff --check` +- `nix build .#yoi --no-link` +- Focused tests for service lifecycle/queue may be run during development. + +Current code map: +- Primary: `crates/pod/src/feature/plugin.rs`。 +- Secondary only if needed: manifest service/ingress declarations and docs comments. +- Avoid: WebSocket driver, output command model, WIT/PDK/templates event update, remote runtime protocol, durable cross-process queue. + +Critical risks / reviewer focus: +- accidentally making Service runtime a scheduler/durable queue. +- Tool execution regression or Tool path accidentally using Service queue. +- unbounded queue or hidden background loop. +- missing stop/failure/timeout diagnostics. +- scope creep into WebSocket/output commands/WIT-PDK. + +Next action: +- `queued -> inprogress` を記録してから worktree-workflow で `/home/hare/Projects/yoi/.worktree/00001KVXK0WDQ-plugin-service-lifecycle` を作成し、multi-agent-workflow で Coder/Reviewer sibling loop に進める。 + --- From f26c7e0d0904efaaa658974efdcfaee72cee7622 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 06:23:22 +0900 Subject: [PATCH 17/46] ticket: accept plugin service lifecycle task --- .yoi/tickets/00001KVXK0WDQ/item.md | 4 ++-- .yoi/tickets/00001KVXK0WDQ/thread.md | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KVXK0WDQ/item.md b/.yoi/tickets/00001KVXK0WDQ/item.md index cb1f2e31..573b5b71 100644 --- a/.yoi/tickets/00001KVXK0WDQ/item.md +++ b/.yoi/tickets/00001KVXK0WDQ/item.md @@ -1,8 +1,8 @@ --- title: 'Define Plugin Service lifecycle and ingress queue runtime' -state: 'queued' +state: 'inprogress' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-24T21:22:37Z' +updated_at: '2026-06-24T21:23:16Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:12:00Z' diff --git a/.yoi/tickets/00001KVXK0WDQ/thread.md b/.yoi/tickets/00001KVXK0WDQ/thread.md index 7f63816f..a50e34b5 100644 --- a/.yoi/tickets/00001KVXK0WDQ/thread.md +++ b/.yoi/tickets/00001KVXK0WDQ/thread.md @@ -108,3 +108,17 @@ Next action: - `queued -> inprogress` を記録してから worktree-workflow で `/home/hare/Projects/yoi/.worktree/00001KVXK0WDQ-plugin-service-lifecycle` を作成し、multi-agent-workflow で Coder/Reviewer sibling loop に進める。 --- + + + +## State changed + +Orchestrator acceptance: queued -> inprogress + +- 直前確認で `TicketShow` は state `queued`、derived blockers は空。 +- outgoing dependency `00001KVXK0WDH` は done / merged / reviewed / validated 済み。 +- accepted plan `orch-plan-20260624-212209-2` を確認した。 +- routing decision と IntentPacket は Ticket thread に記録済み。 +- これ以降に worktree-workflow で `/home/hare/Projects/yoi/.worktree/00001KVXK0WDQ-plugin-service-lifecycle` を作成し、multi-agent-workflow に接続する。 + +--- From 1839acb3d0512ad92e7152f7c4b595d4440cc311 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 06:24:15 +0900 Subject: [PATCH 18/46] ticket: record plugin service coder start --- .yoi/tickets/00001KVXK0WDQ/item.md | 2 +- .yoi/tickets/00001KVXK0WDQ/thread.md | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVXK0WDQ/item.md b/.yoi/tickets/00001KVXK0WDQ/item.md index 573b5b71..d670c43e 100644 --- a/.yoi/tickets/00001KVXK0WDQ/item.md +++ b/.yoi/tickets/00001KVXK0WDQ/item.md @@ -2,7 +2,7 @@ title: 'Define Plugin Service lifecycle and ingress queue runtime' state: 'inprogress' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-24T21:23:16Z' +updated_at: '2026-06-24T21:24:10Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:12:00Z' diff --git a/.yoi/tickets/00001KVXK0WDQ/thread.md b/.yoi/tickets/00001KVXK0WDQ/thread.md index a50e34b5..1ddb9e0e 100644 --- a/.yoi/tickets/00001KVXK0WDQ/thread.md +++ b/.yoi/tickets/00001KVXK0WDQ/thread.md @@ -122,3 +122,17 @@ Orchestrator acceptance: queued -> inprogress - これ以降に worktree-workflow で `/home/hare/Projects/yoi/.worktree/00001KVXK0WDQ-plugin-service-lifecycle` を作成し、multi-agent-workflow に接続する。 --- + + + +## Implementation report + +Implementation orchestration update: + +- prerequisite `00001KVXK0WDH` が done になったため、`00001KVXK0WDQ` を再 routing して受理した。 +- worktree-workflow に従い、implementation worktree `/home/hare/Projects/yoi/.worktree/00001KVXK0WDQ-plugin-service-lifecycle` と branch `work/00001KVXK0WDQ-plugin-service-lifecycle` を作成した。 +- child worktree では tracked `.yoi` project records を見える状態にし、`.yoi/memory`、local/runtime/log/lock/secret-like `.yoi` paths を sparse-checkout で除外する方針にした。 +- Coder Pod `yoi-coder-00001KVXK0WDQ-service-lifecycle` を sibling として起動した。write scope は child worktree に限定し、runtime workspace root `/home/hare/Projects/yoi` は startup 要件を満たすため non-recursive read のみにした。 +- Coder には root/original workspace と orchestration worktree で read/write/validation/git operation を行わないよう明示した。 + +--- From 4e138b7e365d940bf31397aa64cf14a58b6bc51a Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 06:42:56 +0900 Subject: [PATCH 19/46] feat: add plugin service ingress queue --- crates/pod/src/feature/plugin.rs | 710 +++++++++++++++++++++++++++---- 1 file changed, 636 insertions(+), 74 deletions(-) diff --git a/crates/pod/src/feature/plugin.rs b/crates/pod/src/feature/plugin.rs index ca05dddd..cf880c5b 100644 --- a/crates/pod/src/feature/plugin.rs +++ b/crates/pod/src/feature/plugin.rs @@ -7,7 +7,7 @@ //! host APIs through explicit imports with matching permissions and scoped //! allowlist grants. -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::fs; use std::io::{Read as _, Write as _}; use std::net::{IpAddr, SocketAddr, ToSocketAddrs}; @@ -103,16 +103,18 @@ impl PluginToolFeature { &self, ingress_name: &str, event: PluginIngressEvent, - ) -> Result { + ) -> Result { if !surface_enabled(&self.record, PluginSurface::Ingress) { - return Err(PluginWasmError::Module( + return Err(PluginIngressDispatchError::InvalidEvent( "plugin ingress surface is not enabled".to_string(), )); } let handle = self .registry .handle(&self.record.identity.to_string()) - .ok_or_else(|| PluginWasmError::Module("plugin instance is not started".to_string()))?; + .ok_or_else(|| PluginIngressDispatchError::ServiceUnavailable { + state: PluginInstanceLifecycleState::Stopped, + })?; handle.deliver_ingress(ingress_name, event) } @@ -2280,6 +2282,11 @@ 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_SERVICE_INGRESS_QUEUE_CAPACITY: usize = 32; +#[cfg(test)] +const PLUGIN_SERVICE_INGRESS_DISPATCH_TIMEOUT: Duration = Duration::from_millis(25); +#[cfg(not(test))] +const PLUGIN_SERVICE_INGRESS_DISPATCH_TIMEOUT: Duration = Duration::from_secs(1); const PLUGIN_WASM_MEMORY_BYTES: usize = 2 * 1024 * 1024; const PLUGIN_WASM_TABLE_ELEMENTS: usize = 256; const PLUGIN_REQUEST_MAX_REQUEST_BYTES: usize = 48 * 1024; @@ -2933,31 +2940,68 @@ impl PluginRequestError { #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub enum PluginInstanceLifecycleState { Ready, - Started, + Starting, + Running, + Stopping, Stopped, Failed, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub enum PluginInstanceDiagnosticKind { + Lifecycle, + InvalidEvent, + QueueFull, + DispatchTimeout, + DispatchFailed, + ServiceUnavailable, + ServiceFailed, + ServiceStopped, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct PluginInstanceDiagnostic { + pub kind: PluginInstanceDiagnosticKind, pub state: PluginInstanceLifecycleState, pub message: String, } impl PluginInstanceDiagnostic { pub fn new(state: PluginInstanceLifecycleState, message: impl Into) -> Self { + Self::with_kind(PluginInstanceDiagnosticKind::Lifecycle, state, message) + } + + pub fn with_kind( + kind: PluginInstanceDiagnosticKind, + state: PluginInstanceLifecycleState, + message: impl Into, + ) -> Self { Self { + kind, state, - message: bounded_message(message.into()), + message: bounded_message(redact_secret_like(&message.into())), } } } +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] +pub struct PluginIngressDispatchCounters { + pub enqueued: u64, + pub dispatched: u64, + pub rejected: u64, + pub failed: u64, + pub timed_out: u64, +} + #[derive(Clone, Debug, PartialEq, Serialize)] pub struct PluginInstanceStatus { pub plugin_ref: String, pub lifecycle: PluginInstanceLifecycleState, pub component_status: Option, + pub queue_depth: usize, + pub queue_capacity: usize, + pub last_error: Option, + pub dispatch_counters: PluginIngressDispatchCounters, pub diagnostics: Vec, } @@ -2965,7 +3009,30 @@ pub struct PluginInstanceStatus { pub struct PluginIngressEvent { pub kind: String, pub source: String, + pub ingress_name: String, pub payload: Value, + pub created_at: String, + pub attempt: u32, + pub correlation_id: String, +} + +impl PluginIngressEvent { + pub fn new( + ingress_name: impl Into, + kind: impl Into, + source: impl Into, + payload: Value, + ) -> Self { + Self { + kind: kind.into(), + source: source.into(), + ingress_name: ingress_name.into(), + payload, + created_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true), + attempt: 1, + correlation_id: uuid::Uuid::now_v7().to_string(), + } + } } #[derive(Clone, Debug, PartialEq, Serialize)] @@ -2974,9 +3041,80 @@ pub struct PluginIngressDispatchReport { pub ingress: String, pub accepted: bool, pub output: Value, + pub queue_depth: usize, + pub dispatch_counters: PluginIngressDispatchCounters, pub diagnostics: Vec, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PluginIngressDispatchError { + InvalidEvent(String), + QueueFull { capacity: usize }, + ServiceUnavailable { state: PluginInstanceLifecycleState }, + ServiceFailed(String), + ServiceStopped { state: PluginInstanceLifecycleState }, + DispatchTimeout { timeout: Duration }, + DispatchFailed(String), +} + +impl PluginIngressDispatchError { + fn bounded_message(&self) -> String { + match self { + Self::InvalidEvent(message) => bounded_message(format!( + "invalid plugin ingress event: {}", + redact_secret_like(message) + )), + Self::QueueFull { capacity } => bounded_message(format!( + "plugin ingress queue is full (capacity {capacity})" + )), + Self::ServiceUnavailable { state } => bounded_message(format!( + "plugin service is not running for ingress dispatch (state {state:?})" + )), + Self::ServiceFailed(message) => bounded_message(format!( + "plugin service is failed for ingress dispatch: {}", + redact_secret_like(message) + )), + Self::ServiceStopped { state } => bounded_message(format!( + "plugin service rejects ingress while stopping/stopped (state {state:?})" + )), + Self::DispatchTimeout { timeout } => bounded_message(format!( + "plugin ingress dispatch timed out after {timeout:?}" + )), + Self::DispatchFailed(message) => bounded_message(format!( + "plugin ingress dispatch failed closed: {}", + redact_secret_like(message) + )), + } + } + + fn diagnostic_kind(&self) -> PluginInstanceDiagnosticKind { + match self { + Self::InvalidEvent(_) => PluginInstanceDiagnosticKind::InvalidEvent, + Self::QueueFull { .. } => PluginInstanceDiagnosticKind::QueueFull, + Self::ServiceUnavailable { .. } => PluginInstanceDiagnosticKind::ServiceUnavailable, + Self::ServiceFailed(_) => PluginInstanceDiagnosticKind::ServiceFailed, + Self::ServiceStopped { .. } => PluginInstanceDiagnosticKind::ServiceStopped, + Self::DispatchTimeout { .. } => PluginInstanceDiagnosticKind::DispatchTimeout, + Self::DispatchFailed(_) => PluginInstanceDiagnosticKind::DispatchFailed, + } + } +} + +impl std::fmt::Display for PluginIngressDispatchError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.bounded_message()) + } +} + +impl std::error::Error for PluginIngressDispatchError {} + +#[derive(Clone, Debug)] +struct QueuedPluginIngress { + ingress_name: String, + event: PluginIngressEvent, + enqueued_at: Instant, +} + #[derive(Clone, Default)] pub struct PluginInstanceRegistry { instances: Arc>>, @@ -3043,6 +3181,10 @@ impl PluginInstanceHandle { runtime, lifecycle: PluginInstanceLifecycleState::Ready, component_status: None, + ingress_queue: VecDeque::new(), + ingress_queue_capacity: PLUGIN_SERVICE_INGRESS_QUEUE_CAPACITY, + dispatch_counters: PluginIngressDispatchCounters::default(), + last_error: None, diagnostics: Vec::new(), }; instance.start()?; @@ -3060,11 +3202,10 @@ impl PluginInstanceHandle { &self, ingress_name: &str, event: PluginIngressEvent, - ) -> Result { - self.0 - .lock() - .expect("plugin instance poisoned") - .deliver_ingress(ingress_name, event) + ) -> Result { + let mut instance = self.0.lock().expect("plugin instance poisoned"); + instance.enqueue_ingress(ingress_name, event)?; + instance.dispatch_next_ingress() } pub fn status(&self) -> PluginInstanceStatus { @@ -3081,6 +3222,7 @@ impl PluginInstanceHandle { fn record_diagnostic(&self, diagnostic: PluginInstanceDiagnostic) { if let Ok(mut instance) = self.0.lock() { instance.lifecycle = diagnostic.state.clone(); + instance.last_error = Some(diagnostic.message.clone()); instance.diagnostics.push(diagnostic); } } @@ -3091,28 +3233,56 @@ struct PluginInstance { runtime: PluginInstanceRuntime, lifecycle: PluginInstanceLifecycleState, component_status: Option, + ingress_queue: VecDeque, + ingress_queue_capacity: usize, + dispatch_counters: PluginIngressDispatchCounters, + last_error: Option, diagnostics: Vec, } impl PluginInstance { fn start(&mut self) -> Result<(), PluginWasmError> { - match &mut self.runtime { + self.lifecycle = PluginInstanceLifecycleState::Starting; + let start_result = match &mut self.runtime { PluginInstanceRuntime::ComponentToolAdapter => { self.lifecycle = PluginInstanceLifecycleState::Ready; self.diagnostics.push(PluginInstanceDiagnostic::new( PluginInstanceLifecycleState::Ready, "component tool runtime registered behind PluginInstanceRegistry", )); + Ok(()) } #[cfg(test)] PluginInstanceRuntime::TestIngress { .. } => { - self.lifecycle = PluginInstanceLifecycleState::Started; + self.lifecycle = PluginInstanceLifecycleState::Running; + self.diagnostics.push(PluginInstanceDiagnostic::new( + PluginInstanceLifecycleState::Running, + "test ingress runtime initialized", + )); + Ok(()) } PluginInstanceRuntime::ComponentInstance(runtime) => { - let status = runtime.start(&self.record)?; - self.component_status = Some(status); - self.lifecycle = PluginInstanceLifecycleState::Started; + match runtime.start(&self.record) { + Ok(status) => { + self.component_status = Some(status); + self.lifecycle = PluginInstanceLifecycleState::Running; + self.diagnostics.push(PluginInstanceDiagnostic::new( + PluginInstanceLifecycleState::Running, + "component instance start returned; host-managed ingress queue is running", + )); + Ok(()) + } + Err(error) => Err(error), + } } + }; + if let Err(error) = start_result { + self.lifecycle = PluginInstanceLifecycleState::Failed; + self.record_runtime_error( + PluginInstanceDiagnosticKind::ServiceFailed, + format!("plugin component start failed: {}", error.bounded_message()), + ); + return Err(error); } Ok(()) } @@ -3149,10 +3319,10 @@ impl PluginInstance { run_plugin_component_tool(self.record.clone(), tool_name.to_string(), input) } #[cfg(test)] - PluginInstanceRuntime::TestIngress { calls } => { - *calls += 1; + PluginInstanceRuntime::TestIngress { tool_calls, .. } => { + *tool_calls += 1; Ok(ToolOutput { - summary: format!("{tool_name}: {calls}"), + summary: format!("{tool_name}: {tool_calls}"), content: Some(String::from_utf8_lossy(&input).to_string()), }) } @@ -3162,53 +3332,208 @@ impl PluginInstance { } } - fn deliver_ingress( + fn enqueue_ingress( &mut self, ingress_name: &str, event: PluginIngressEvent, - ) -> Result { + ) -> Result<(), PluginIngressDispatchError> { + self.validate_ingress_event(ingress_name, &event) + .map_err(|error| self.record_rejection(error))?; + if self.ingress_queue.len() >= self.ingress_queue_capacity { + let error = PluginIngressDispatchError::QueueFull { + capacity: self.ingress_queue_capacity, + }; + return Err(self.record_rejection(error)); + } + self.ingress_queue.push_back(QueuedPluginIngress { + ingress_name: ingress_name.to_string(), + event, + enqueued_at: Instant::now(), + }); + self.dispatch_counters.enqueued += 1; + Ok(()) + } + + fn validate_ingress_event( + &self, + ingress_name: &str, + event: &PluginIngressEvent, + ) -> Result<(), PluginIngressDispatchError> { if !surface_enabled(&self.record, PluginSurface::Ingress) { - return Err(PluginWasmError::Module( + return Err(PluginIngressDispatchError::InvalidEvent( "plugin ingress surface is not enabled".to_string(), )); } - if serde_json::to_vec(&event) + match self.lifecycle { + PluginInstanceLifecycleState::Running => {} + PluginInstanceLifecycleState::Failed => { + return Err(PluginIngressDispatchError::ServiceFailed( + self.last_error + .clone() + .unwrap_or_else(|| "service is failed".to_string()), + )); + } + PluginInstanceLifecycleState::Stopping | PluginInstanceLifecycleState::Stopped => { + return Err(PluginIngressDispatchError::ServiceStopped { + state: self.lifecycle.clone(), + }); + } + _ => { + return Err(PluginIngressDispatchError::ServiceUnavailable { + state: self.lifecycle.clone(), + }); + } + } + if event.source.trim().is_empty() { + return Err(PluginIngressDispatchError::InvalidEvent( + "source must not be empty".to_string(), + )); + } + if event.ingress_name != ingress_name { + return Err(PluginIngressDispatchError::InvalidEvent(format!( + "event ingress `{}` does not match dispatch ingress `{ingress_name}`", + event.ingress_name + ))); + } + if event.kind.trim().is_empty() { + return Err(PluginIngressDispatchError::InvalidEvent( + "event kind must not be empty".to_string(), + )); + } + if event.created_at.trim().is_empty() { + return Err(PluginIngressDispatchError::InvalidEvent( + "created_at must not be empty".to_string(), + )); + } + if event.attempt == 0 { + return Err(PluginIngressDispatchError::InvalidEvent( + "attempt must be greater than zero".to_string(), + )); + } + if event.correlation_id.trim().is_empty() { + return Err(PluginIngressDispatchError::InvalidEvent( + "correlation_id must not be empty".to_string(), + )); + } + if serde_json::to_vec(event) .map(|bytes| bytes.len()) .unwrap_or(usize::MAX) > PLUGIN_WASM_MAX_INPUT_BYTES { - return Err(PluginWasmError::Module(format!( + return Err(PluginIngressDispatchError::InvalidEvent(format!( "plugin ingress event exceeds {} bytes", PLUGIN_WASM_MAX_INPUT_BYTES ))); } - self.record + let ingress = self + .record .manifest .ingresses .iter() .find(|ingress| ingress.name == ingress_name) .ok_or_else(|| { - PluginWasmError::Module( + PluginIngressDispatchError::InvalidEvent( "requested ingress is not declared by plugin manifest".to_string(), ) })?; + if !ingress.event_kinds.is_empty() + && !ingress.event_kinds.iter().any(|kind| kind == &event.kind) + { + return Err(PluginIngressDispatchError::InvalidEvent(format!( + "event kind `{}` is not declared for ingress `{ingress_name}`", + event.kind + ))); + } authorize_plugin_ingress(&self.record, ingress_name).map_err(|error| { - PluginWasmError::Module(format!( + PluginIngressDispatchError::InvalidEvent(format!( "plugin ingress permission denied: {}", error.bounded_message() )) })?; + Ok(()) + } + + fn dispatch_next_ingress( + &mut self, + ) -> Result { + let Some(queued) = self.ingress_queue.pop_front() else { + return Err(PluginIngressDispatchError::InvalidEvent( + "plugin ingress queue is empty".to_string(), + )); + }; + let started_at = Instant::now(); + let result = self.dispatch_ingress_now(&queued.ingress_name, queued.event); + let elapsed = started_at.elapsed(); + if elapsed > PLUGIN_SERVICE_INGRESS_DISPATCH_TIMEOUT { + let error = PluginIngressDispatchError::DispatchTimeout { + timeout: PLUGIN_SERVICE_INGRESS_DISPATCH_TIMEOUT, + }; + self.dispatch_counters.timed_out += 1; + self.dispatch_counters.failed += 1; + self.lifecycle = PluginInstanceLifecycleState::Failed; + self.record_dispatch_error(&error); + return Err(error); + } + match result { + Ok(mut report) => { + let queue_latency_ms = queued.enqueued_at.elapsed().as_millis() as u64; + if report.output.get("queue_latency_ms").is_none() { + if let Some(map) = report.output.as_object_mut() { + map.insert( + "queue_latency_ms".to_string(), + Value::from(queue_latency_ms), + ); + } + } + self.dispatch_counters.dispatched += 1; + report.queue_depth = self.ingress_queue.len(); + report.dispatch_counters = self.dispatch_counters.clone(); + report.diagnostics = self.diagnostics.clone(); + Ok(report) + } + Err(error) => { + self.dispatch_counters.failed += 1; + self.lifecycle = PluginInstanceLifecycleState::Failed; + let dispatch_error = + PluginIngressDispatchError::DispatchFailed(error.bounded_message()); + self.record_dispatch_error(&dispatch_error); + Err(dispatch_error) + } + } + } + + fn dispatch_ingress_now( + &mut self, + ingress_name: &str, + event: PluginIngressEvent, + ) -> Result { match &mut self.runtime { PluginInstanceRuntime::ComponentToolAdapter => Err(PluginWasmError::Module( "component tool runtime does not expose ingress dispatch".to_string(), )), #[cfg(test)] - PluginInstanceRuntime::TestIngress { calls } => { + PluginInstanceRuntime::TestIngress { + tool_calls, + ingress_calls, + } => { + if let Some(sleep_ms) = event.payload.get("sleep_ms").and_then(Value::as_u64) { + std::thread::sleep(Duration::from_millis(sleep_ms)); + } + if event.payload.get("fail").and_then(Value::as_bool) == Some(true) { + return Err(PluginWasmError::Execution( + "test ingress requested failure".to_string(), + )); + } + *ingress_calls += 1; let output = serde_json::json!({ "ingress": ingress_name, "kind": event.kind, "source": event.source, - "calls": *calls, + "ingress_name": event.ingress_name, + "attempt": event.attempt, + "correlation_id": event.correlation_id, + "calls": *tool_calls, + "ingress_calls": *ingress_calls, "payload": event.payload, }); Ok(PluginIngressDispatchReport { @@ -3216,6 +3541,8 @@ impl PluginInstance { ingress: ingress_name.to_string(), accepted: true, output, + queue_depth: self.ingress_queue.len(), + dispatch_counters: self.dispatch_counters.clone(), diagnostics: self.diagnostics.clone(), }) } @@ -3226,6 +3553,8 @@ impl PluginInstance { ingress: ingress_name.to_string(), accepted: true, output, + queue_depth: self.ingress_queue.len(), + dispatch_counters: self.dispatch_counters.clone(), diagnostics: self.diagnostics.clone(), }) } @@ -3233,15 +3562,40 @@ impl PluginInstance { } fn stop(&mut self) -> Result<(), PluginWasmError> { - match &mut self.runtime { - PluginInstanceRuntime::ComponentToolAdapter => {} + if self.lifecycle == PluginInstanceLifecycleState::Stopped { + return Ok(()); + } + self.lifecycle = PluginInstanceLifecycleState::Stopping; + self.diagnostics.push(PluginInstanceDiagnostic::new( + PluginInstanceLifecycleState::Stopping, + "plugin service stop requested; ingress queue is closed", + )); + self.ingress_queue.clear(); + let stop_result = match &mut self.runtime { + PluginInstanceRuntime::ComponentToolAdapter => Ok(()), #[cfg(test)] - PluginInstanceRuntime::TestIngress { .. } => {} - PluginInstanceRuntime::ComponentInstance(runtime) => { - self.component_status = Some(runtime.stop()?); - } + PluginInstanceRuntime::TestIngress { .. } => Ok(()), + PluginInstanceRuntime::ComponentInstance(runtime) => match runtime.stop() { + Ok(status) => { + self.component_status = Some(status); + Ok(()) + } + Err(error) => Err(error), + }, + }; + if let Err(error) = stop_result { + self.lifecycle = PluginInstanceLifecycleState::Failed; + self.record_runtime_error( + PluginInstanceDiagnosticKind::ServiceFailed, + format!("plugin component stop failed: {}", error.bounded_message()), + ); + return Err(error); } self.lifecycle = PluginInstanceLifecycleState::Stopped; + self.diagnostics.push(PluginInstanceDiagnostic::new( + PluginInstanceLifecycleState::Stopped, + "plugin service stopped", + )); Ok(()) } @@ -3250,40 +3604,79 @@ impl PluginInstance { plugin_ref: self.record.identity.to_string(), lifecycle: self.lifecycle.clone(), component_status: self.component_status.clone(), + queue_depth: self.ingress_queue.len(), + queue_capacity: self.ingress_queue_capacity, + last_error: self.last_error.clone(), + dispatch_counters: self.dispatch_counters.clone(), diagnostics: self.diagnostics.clone(), } } fn status(&mut self) -> PluginInstanceStatus { - if let PluginInstanceRuntime::ComponentInstance(runtime) = &mut self.runtime { - match runtime.status() { - Ok(status) => self.component_status = Some(status), - Err(error) => { - self.lifecycle = PluginInstanceLifecycleState::Failed; - self.diagnostics.push(PluginInstanceDiagnostic::new( - PluginInstanceLifecycleState::Failed, - format!( - "plugin component status failed: {}", - error.bounded_message() - ), - )); + if self.lifecycle == PluginInstanceLifecycleState::Running { + if let PluginInstanceRuntime::ComponentInstance(runtime) = &mut self.runtime { + match runtime.status() { + Ok(status) => self.component_status = Some(status), + Err(error) => { + self.lifecycle = PluginInstanceLifecycleState::Failed; + self.record_runtime_error( + PluginInstanceDiagnosticKind::ServiceFailed, + format!( + "plugin component status failed: {}", + error.bounded_message() + ), + ); + } } } } - PluginInstanceStatus { - plugin_ref: self.record.identity.to_string(), - lifecycle: self.lifecycle.clone(), - component_status: self.component_status.clone(), - diagnostics: self.diagnostics.clone(), + self.snapshot_status() + } + + fn record_rejection( + &mut self, + error: PluginIngressDispatchError, + ) -> PluginIngressDispatchError { + self.dispatch_counters.rejected += 1; + self.record_dispatch_error(&error); + error + } + + fn record_dispatch_error(&mut self, error: &PluginIngressDispatchError) { + let state = match error { + PluginIngressDispatchError::DispatchFailed(_) + | PluginIngressDispatchError::DispatchTimeout { .. } + | PluginIngressDispatchError::ServiceFailed(_) => PluginInstanceLifecycleState::Failed, + PluginIngressDispatchError::ServiceStopped { .. } => self.lifecycle.clone(), + _ => self.lifecycle.clone(), + }; + self.record_runtime_error(error.diagnostic_kind(), error.bounded_message()); + if matches!(state, PluginInstanceLifecycleState::Failed) { + self.lifecycle = PluginInstanceLifecycleState::Failed; } } + + fn record_runtime_error( + &mut self, + kind: PluginInstanceDiagnosticKind, + message: impl Into, + ) { + let message = bounded_message(redact_secret_like(&message.into())); + self.last_error = Some(message.clone()); + self.diagnostics.push(PluginInstanceDiagnostic::with_kind( + kind, + self.lifecycle.clone(), + message, + )); + } } enum PluginInstanceRuntime { ComponentToolAdapter, #[cfg(test)] TestIngress { - calls: u64, + tool_calls: u64, + ingress_calls: u64, }, ComponentInstance(PluginComponentInstanceRuntime), } @@ -3297,7 +3690,10 @@ impl PluginInstanceRuntime { }; match runtime.kind.as_str() { #[cfg(test)] - "test-ingress" => Ok(Self::TestIngress { calls: 0 }), + "test-ingress" => Ok(Self::TestIngress { + tool_calls: 0, + ingress_calls: 0, + }), PLUGIN_RUNTIME_COMPONENT_KIND if runtime.world.as_deref() == Some(PLUGIN_COMPONENT_INSTANCE_WORLD) => { @@ -4348,7 +4744,7 @@ mod tests { Some(PLUGIN_COMPONENT_INSTANCE_WORLD.into()); let handle = PluginInstanceHandle::new(record).unwrap(); let status = handle.status(); - assert_eq!(status.lifecycle, PluginInstanceLifecycleState::Started); + assert_eq!(status.lifecycle, PluginInstanceLifecycleState::Running); assert_eq!(status.component_status.unwrap()["data"]["phase"], "status"); let stopped = handle.stop().unwrap(); assert_eq!(stopped.lifecycle, PluginInstanceLifecycleState::Stopped); @@ -4418,6 +4814,10 @@ mod tests { .push(PluginPermission::ingress(name)); } + fn test_ingress_event(ingress_name: &str, payload: Value) -> PluginIngressEvent { + PluginIngressEvent::new(ingress_name, "test", "unit", payload) + } + #[test] fn service_selected_ignores_unselected_tool_without_grants() { let mut record = record(vec![tool("hidden_tool")]); @@ -4440,7 +4840,7 @@ mod tests { assert_eq!(report.reports[0].provided_services.len(), 1); assert_eq!( feature.instance_status().unwrap().lifecycle, - PluginInstanceLifecycleState::Started + PluginInstanceLifecycleState::Running ); } @@ -4462,11 +4862,7 @@ mod tests { assert!(report.reports[0].provided_services.is_empty()); let dispatch = feature.dispatch_ingress( "hidden_ingress", - PluginIngressEvent { - kind: "test".into(), - source: "unit".into(), - payload: serde_json::json!({}), - }, + test_ingress_event("hidden_ingress", serde_json::json!({})), ); assert!( dispatch @@ -4494,7 +4890,174 @@ mod tests { "{report:#?}" ); let status = feature.instance_status().expect("service instance started"); - assert_eq!(status.lifecycle, PluginInstanceLifecycleState::Started); + assert_eq!(status.lifecycle, PluginInstanceLifecycleState::Running); + assert_eq!(status.queue_depth, 0); + assert_eq!(status.queue_capacity, PLUGIN_SERVICE_INGRESS_QUEUE_CAPACITY); + assert!(status.last_error.is_none()); + } + + fn test_service_ingress_record() -> ResolvedPluginRecord { + let mut record = record(Vec::new()); + add_service(&mut record, "svc"); + add_ingress(&mut record, "shared_ingress"); + record.manifest.runtime = Some(manifest::plugin::PluginRuntimeManifest { + kind: "test-ingress".into(), + entry: None, + abi: None, + component: None, + world: Some(PLUGIN_COMPONENT_INSTANCE_WORLD.into()), + }); + record + } + + #[test] + fn ingress_queue_dispatches_serially_and_reports_status() { + let handle = PluginInstanceHandle::new(test_service_ingress_record()).unwrap(); + assert_eq!( + handle.status().lifecycle, + PluginInstanceLifecycleState::Running + ); + + let first = handle + .deliver_ingress( + "shared_ingress", + test_ingress_event("shared_ingress", serde_json::json!({ "seq": 1 })), + ) + .unwrap(); + let second = handle + .deliver_ingress( + "shared_ingress", + test_ingress_event("shared_ingress", serde_json::json!({ "seq": 2 })), + ) + .unwrap(); + + assert_eq!(first.output["ingress_calls"], 1); + assert_eq!(second.output["ingress_calls"], 2); + assert_eq!(second.queue_depth, 0); + assert_eq!(second.dispatch_counters.enqueued, 2); + assert_eq!(second.dispatch_counters.dispatched, 2); + let status = handle.status(); + assert_eq!(status.queue_depth, 0); + assert_eq!(status.dispatch_counters.enqueued, 2); + assert_eq!(status.dispatch_counters.dispatched, 2); + assert!(status.last_error.is_none()); + } + + #[test] + fn bounded_ingress_queue_rejects_full_queue() { + let handle = PluginInstanceHandle::new(test_service_ingress_record()).unwrap(); + let mut instance = handle.0.lock().unwrap(); + instance.ingress_queue_capacity = 1; + instance + .enqueue_ingress( + "shared_ingress", + test_ingress_event("shared_ingress", serde_json::json!({ "seq": 1 })), + ) + .unwrap(); + let error = instance + .enqueue_ingress( + "shared_ingress", + test_ingress_event("shared_ingress", serde_json::json!({ "seq": 2 })), + ) + .unwrap_err(); + assert!(matches!( + error, + PluginIngressDispatchError::QueueFull { capacity: 1 } + )); + assert_eq!(instance.ingress_queue.len(), 1); + assert_eq!(instance.dispatch_counters.rejected, 1); + assert_eq!( + instance.diagnostics.last().unwrap().kind, + PluginInstanceDiagnosticKind::QueueFull + ); + } + + #[test] + fn ingress_dispatch_failure_marks_service_failed_and_rejects_later_events() { + let handle = PluginInstanceHandle::new(test_service_ingress_record()).unwrap(); + let error = handle + .deliver_ingress( + "shared_ingress", + test_ingress_event("shared_ingress", serde_json::json!({ "fail": true })), + ) + .unwrap_err(); + assert!(matches!( + error, + PluginIngressDispatchError::DispatchFailed(_) + )); + let status = handle.status(); + assert_eq!(status.lifecycle, PluginInstanceLifecycleState::Failed); + assert_eq!(status.dispatch_counters.failed, 1); + assert_eq!( + status.diagnostics.last().unwrap().kind, + PluginInstanceDiagnosticKind::DispatchFailed + ); + + let retry = handle + .deliver_ingress( + "shared_ingress", + test_ingress_event("shared_ingress", serde_json::json!({ "seq": 3 })), + ) + .unwrap_err(); + assert!(matches!( + retry, + PluginIngressDispatchError::ServiceFailed(_) + )); + } + + #[test] + fn ingress_dispatch_timeout_records_typed_diagnostic() { + let handle = PluginInstanceHandle::new(test_service_ingress_record()).unwrap(); + let error = handle + .deliver_ingress( + "shared_ingress", + test_ingress_event("shared_ingress", serde_json::json!({ "sleep_ms": 50 })), + ) + .unwrap_err(); + assert!(matches!( + error, + PluginIngressDispatchError::DispatchTimeout { .. } + )); + let status = handle.status(); + assert_eq!(status.lifecycle, PluginInstanceLifecycleState::Failed); + assert_eq!(status.dispatch_counters.timed_out, 1); + assert_eq!( + status.diagnostics.last().unwrap().kind, + PluginInstanceDiagnosticKind::DispatchTimeout + ); + } + + #[test] + fn stopped_service_rejects_ingress_events() { + let handle = PluginInstanceHandle::new(test_service_ingress_record()).unwrap(); + handle.stop().unwrap(); + let error = handle + .deliver_ingress( + "shared_ingress", + test_ingress_event("shared_ingress", serde_json::json!({ "seq": 1 })), + ) + .unwrap_err(); + assert!(matches!( + error, + PluginIngressDispatchError::ServiceStopped { + state: PluginInstanceLifecycleState::Stopped + } + )); + } + + #[test] + fn invalid_ingress_event_is_typed_rejection() { + let handle = PluginInstanceHandle::new(test_service_ingress_record()).unwrap(); + let mut event = test_ingress_event("shared_ingress", serde_json::json!({})); + event.correlation_id.clear(); + let error = handle.deliver_ingress("shared_ingress", event).unwrap_err(); + assert!(matches!(error, PluginIngressDispatchError::InvalidEvent(_))); + let status = handle.status(); + assert_eq!(status.dispatch_counters.rejected, 1); + assert_eq!( + status.diagnostics.last().unwrap().kind, + PluginInstanceDiagnosticKind::InvalidEvent + ); } #[test] @@ -4530,11 +5093,7 @@ mod tests { let report = feature .dispatch_ingress( "shared_ingress", - PluginIngressEvent { - kind: "test".into(), - source: "unit".into(), - payload: serde_json::json!({ "hello": "world" }), - }, + test_ingress_event("shared_ingress", serde_json::json!({ "hello": "world" })), ) .unwrap(); assert!(report.accepted); @@ -4575,9 +5134,16 @@ mod tests { .push(PluginPermission::ingress("shared_ingress")); let handle = PluginInstanceHandle(Arc::new(Mutex::new(PluginInstance { record, - runtime: PluginInstanceRuntime::TestIngress { calls: 0 }, - lifecycle: PluginInstanceLifecycleState::Started, + runtime: PluginInstanceRuntime::TestIngress { + tool_calls: 0, + ingress_calls: 0, + }, + lifecycle: PluginInstanceLifecycleState::Running, component_status: None, + ingress_queue: VecDeque::new(), + ingress_queue_capacity: PLUGIN_SERVICE_INGRESS_QUEUE_CAPACITY, + dispatch_counters: PluginIngressDispatchCounters::default(), + last_error: None, diagnostics: Vec::new(), }))); @@ -4587,11 +5153,7 @@ mod tests { let report = handle .deliver_ingress( "shared_ingress", - PluginIngressEvent { - kind: "test".into(), - source: "unit".into(), - payload: serde_json::json!({ "hello": "world" }), - }, + test_ingress_event("shared_ingress", serde_json::json!({ "hello": "world" })), ) .unwrap(); assert!(report.accepted); From bdd05dce4d0223a101f841d0fb3c6b1369a96702 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 06:44:06 +0900 Subject: [PATCH 20/46] ticket: record plugin service implementation report --- .yoi/tickets/00001KVXK0WDQ/item.md | 2 +- .yoi/tickets/00001KVXK0WDQ/thread.md | 66 ++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVXK0WDQ/item.md b/.yoi/tickets/00001KVXK0WDQ/item.md index d670c43e..5167e972 100644 --- a/.yoi/tickets/00001KVXK0WDQ/item.md +++ b/.yoi/tickets/00001KVXK0WDQ/item.md @@ -2,7 +2,7 @@ title: 'Define Plugin Service lifecycle and ingress queue runtime' state: 'inprogress' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-24T21:24:10Z' +updated_at: '2026-06-24T21:43:56Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:12:00Z' diff --git a/.yoi/tickets/00001KVXK0WDQ/thread.md b/.yoi/tickets/00001KVXK0WDQ/thread.md index 1ddb9e0e..1998567d 100644 --- a/.yoi/tickets/00001KVXK0WDQ/thread.md +++ b/.yoi/tickets/00001KVXK0WDQ/thread.md @@ -136,3 +136,69 @@ Implementation orchestration update: - Coder には root/original workspace と orchestration worktree で read/write/validation/git operation を行わないよう明示した。 --- + + + +## Implementation report + +Implementation report: + +対象 Ticket: +- `00001KVXK0WDQ` — Define Plugin Service lifecycle and ingress queue runtime + +変更ファイル: +- `crates/pod/src/feature/plugin.rs` + +主な実装: +- Plugin Service の host-managed lifecycle を追加。 + - `Ready / Starting / Running / Stopping / Stopped / Failed` +- in-process bounded ingress queue を追加。 + - v0 は per-plugin serial dispatch。 + - Tool Plugin 実行は従来通り request-response のまま維持。 +- `PluginIngressEvent` に以下を追加。 + - `source` + - `ingress_name` + - `payload` + - `created_at` + - `attempt` + - `correlation_id` +- typed ingress dispatch error / diagnostic を追加。 + - invalid event + - queue full + - service unavailable / failed / stopped + - dispatch timeout + - dispatch failure +- status diagnostics に以下を追加。 + - lifecycle state + - queue depth / capacity + - last error + - dispatch counters +- Unit tests を追加・更新。 + - lifecycle start/stop/failure + - bounded queue full + - serial dispatch + - timeout diagnostic + - dispatch failure diagnostic + - stopped service rejection + - invalid event rejection + - Tool execution regression + +Commit: +- `4e138b7e365d940bf31397aa64cf14a58b6bc51a feat: add plugin service ingress queue` + +Validation: +- `cargo test -p pod feature::plugin::tests`: success (`73 passed`) +- `cargo check -p yoi`: success +- `git diff --check`: success +- `git diff --check HEAD~1 HEAD`: success +- `nix build .#yoi --no-link`: success +- `cargo test -p pod`: failed in existing prompt guidance assertions outside this Ticket: + - `prompt::catalog::tests::pod_orchestration_guidance_section_renders_resource_body` + - `prompt::system::tests::pod_orchestration_guidance_is_included_for_pod_management_tools` + - failure: `assertion failed: rendered.contains("worktree status, diff, and test results")` + +Residual risks / notes: +- Full `cargo test -p pod` remains blocked by unrelated prompt/resource snapshot assertions; Plugin-focused tests pass. +- v0 dispatch timeout is implemented as elapsed check after synchronous dispatch completion and typed diagnostic. Force-cancelling executing Wasm in a separate worker is broader runtime redesign and remains non-goal. + +--- From db1a2f567b36af00c46c26295df12e6672012d73 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 06:49:43 +0900 Subject: [PATCH 21/46] ticket: approve plugin service lifecycle --- .yoi/tickets/00001KVXK0WDQ/item.md | 2 +- .yoi/tickets/00001KVXK0WDQ/thread.md | 38 ++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVXK0WDQ/item.md b/.yoi/tickets/00001KVXK0WDQ/item.md index 5167e972..0ec4bb4a 100644 --- a/.yoi/tickets/00001KVXK0WDQ/item.md +++ b/.yoi/tickets/00001KVXK0WDQ/item.md @@ -2,7 +2,7 @@ title: 'Define Plugin Service lifecycle and ingress queue runtime' state: 'inprogress' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-24T21:43:56Z' +updated_at: '2026-06-24T21:49:35Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:12:00Z' diff --git a/.yoi/tickets/00001KVXK0WDQ/thread.md b/.yoi/tickets/00001KVXK0WDQ/thread.md index 1998567d..a7c4d134 100644 --- a/.yoi/tickets/00001KVXK0WDQ/thread.md +++ b/.yoi/tickets/00001KVXK0WDQ/thread.md @@ -202,3 +202,41 @@ Residual risks / notes: - v0 dispatch timeout is implemented as elapsed check after synchronous dispatch completion and typed diagnostic. Force-cancelling executing Wasm in a separate worker is broader runtime redesign and remains non-goal. --- + + + +## Review: approve + +Review result: approve + +確認範囲: +- child worktree `/home/hare/Projects/yoi/.worktree/00001KVXK0WDQ-plugin-service-lifecycle` +- branch `work/00001KVXK0WDQ-plugin-service-lifecycle` +- HEAD `4e138b7e365d940bf31397aa64cf14a58b6bc51a` +- base `f26c7e0d` +- diff `f26c7e0d..HEAD` +- Ticket `00001KVXK0WDQ` の `item.md` / `thread.md` / orchestration plan + +判断: +- Ticket intent / IntentPacket に対して実装は妥当。 +- 変更は `crates/pod/src/feature/plugin.rs` のみで、WebSocket driver / output command model / WIT/PDK/templates / durable queue への scope creep は見当たらない。 +- Component Model-only runtime と legacy raw wasm rejection は維持されている。 + +主な確認根拠: +- `PluginInstanceLifecycleState` が `Ready / Starting / Running / Stopping / Stopped / Failed` に拡張され、start/stop/status/dispatch failure で lifecycle と diagnostics が更新される。 +- `PluginIngressEvent` に `source / ingress_name / payload / created_at / attempt / correlation_id` が揃っている。 +- `VecDeque` + capacity による in-process bounded queue、`QueueFull` / `InvalidEvent` / `ServiceFailed` / `ServiceStopped` / `DispatchTimeout` / `DispatchFailed` の typed error/diagnostic が追加されている。 +- dispatch は `Mutex` 下の per-plugin serial dispatch で、concurrent per-plugin execution にはしていない。 +- Tool execution path は `PluginInstanceTool::execute -> handle_tool` の request-response のままで、Service queue に流していない。 +- status に `queue_depth / queue_capacity / last_error / dispatch_counters / diagnostics` が出る。 +- unit tests は lifecycle、queue full、serial dispatch、timeout/failure、stopped rejection、invalid event、Tool regression をカバーしている。 + +検証: +- reviewer: `git diff --check f26c7e0d..HEAD`: success +- cargo/nix は read-only 指示のため再実行せず、coder 報告を確認対象として扱った。 + +Non-blocking notes: +- queue processing は sync-step driven で、現 public delivery は enqueue 直後に同じ lock 内で dispatch するため、通常経路では queue depth はほぼ 0 になる。Ticket の implementation latitude 内だが、後続の WebSocket driver 側で backpressure/queue depth をどう見せるかは改めて注意が必要。 +- `cargo test -p pod` は coder 報告では既存の prompt guidance assertion 由来の失敗あり。Plugin-focused tests / `cargo check -p yoi` / nix build は成功報告で、本 Ticket の blocker とは判断しない。 + +--- From 8e79c1dc96bb0b27f952a39baad5749d249475f6 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 06:51:18 +0900 Subject: [PATCH 22/46] ticket: mark plugin service lifecycle done --- .yoi/tickets/00001KVXK0WDQ/item.md | 4 +- .yoi/tickets/00001KVXK0WDQ/thread.md | 61 ++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KVXK0WDQ/item.md b/.yoi/tickets/00001KVXK0WDQ/item.md index 0ec4bb4a..b3a9fc75 100644 --- a/.yoi/tickets/00001KVXK0WDQ/item.md +++ b/.yoi/tickets/00001KVXK0WDQ/item.md @@ -1,8 +1,8 @@ --- title: 'Define Plugin Service lifecycle and ingress queue runtime' -state: 'inprogress' +state: 'done' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-24T21:49:35Z' +updated_at: '2026-06-24T21:51:13Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:12:00Z' diff --git a/.yoi/tickets/00001KVXK0WDQ/thread.md b/.yoi/tickets/00001KVXK0WDQ/thread.md index a7c4d134..c35eff19 100644 --- a/.yoi/tickets/00001KVXK0WDQ/thread.md +++ b/.yoi/tickets/00001KVXK0WDQ/thread.md @@ -240,3 +240,64 @@ Non-blocking notes: - `cargo test -p pod` は coder 報告では既存の prompt guidance assertion 由来の失敗あり。Plugin-focused tests / `cargo check -p yoi` / nix build は成功報告で、本 Ticket の blocker とは判断しない。 --- + + + +## Implementation report + +Integration outcome: + +- Coder `yoi-coder-00001KVXK0WDQ-service-lifecycle` が implementation branch `work/00001KVXK0WDQ-plugin-service-lifecycle` に実装を commit した。 + - implementation commit: `4e138b7e feat: add plugin service ingress queue` +- Reviewer `yoi-reviewer-00001KVXK0WDQ-service-lifecycle` は read-only review で `approve`。host-managed in-process lifecycle/queue、bounded queue、serial dispatch、typed diagnostics、Tool path non-regression、scope creep なしを確認した。 +- Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration` で branch `work/00001KVXK0WDQ-plugin-service-lifecycle` を merge 済み。 + - merge commit: `000afbbe merge: 00001KVXK0WDQ plugin service lifecycle` + +Implemented behavior: +- `crates/pod/src/feature/plugin.rs` に Plugin Service lifecycle state を追加。 + - `Ready / Starting / Running / Stopping / Stopped / Failed` +- in-process bounded ingress queue と per-plugin serial dispatch を追加。 +- `PluginIngressEvent` に `source`, `ingress_name`, `payload`, `created_at`, `attempt`, `correlation_id` を追加。 +- `QueueFull`, `InvalidEvent`, `ServiceFailed`, `ServiceStopped`, `DispatchTimeout`, `DispatchFailed` など typed error/diagnostic を追加。 +- status diagnostics に queue depth/capacity, last_error, dispatch_counters, lifecycle diagnostics を追加。 +- Tool Plugin execution は従来の request-response path のまま維持。 + +Validation in Orchestrator worktree: +- `cargo test -p pod feature::plugin::tests`: success +- `cargo check -p yoi`: success +- `git diff --check`: success +- `nix build .#yoi --no-link`: success +- `cargo test -p pod`: failed only in known prompt guidance snapshot assertions outside this Plugin diff: + - `prompt::catalog::tests::pod_orchestration_guidance_section_renders_resource_body` + - `prompt::system::tests::pod_orchestration_guidance_is_included_for_pod_management_tools` + - Plugin-focused tests in the same run passed, including service lifecycle/queue/timeout/Tool regression coverage. + +Notes: +- Reviewer non-blocking note: current queue processing is sync-step driven, and public delivery enqueues then dispatches immediately under the lock, so normal queue depth is usually 0. This remains within Ticket latitude, but follow-up WebSocket driver/backpressure work should revisit how queue depth is exposed. +- Follow-up `00001KVXK0WDX` is dependency-unblocked and can be re-routed next. + +Next action: +- Mark Ticket done after this integration/validation evidence. +- Then stop related child Pods and remove only the child implementation worktree/branch. + +--- + + + +## State changed + +Implementation is merged into the orchestration branch and validated with one unrelated known full-suite caveat. + +Evidence: +- merge commit: `000afbbe merge: 00001KVXK0WDQ plugin service lifecycle` +- reviewer result: approve +- validation in `/home/hare/Projects/yoi/.worktree/orchestration`: + - `cargo test -p pod feature::plugin::tests`: success + - `cargo check -p yoi`: success + - `git diff --check`: success + - `nix build .#yoi --no-link`: success + - `cargo test -p pod`: failed only in prompt guidance snapshot assertions outside this Plugin diff; Plugin-focused tests passed. + +Closure is not performed here; this state records implementation completion after merge/review/focused validation. + +--- From 84a8423611eb8e1dabc32af83b84e025885acc12 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 14:46:20 +0900 Subject: [PATCH 23/46] ticket: route plugin output command task --- .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KVXK0WDX/item.md | 2 +- .yoi/tickets/00001KVXK0WDX/thread.md | 78 +++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVXK0WDX/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KVXK0WDX/artifacts/orchestration-plan.jsonl index b85a9c6d..ed6ae5dc 100644 --- a/.yoi/tickets/00001KVXK0WDX/artifacts/orchestration-plan.jsonl +++ b/.yoi/tickets/00001KVXK0WDX/artifacts/orchestration-plan.jsonl @@ -1 +1,2 @@ {"id":"orch-plan-20260624-201335-1","ticket_id":"00001KVXK0WDX","kind":"blocked_by","related_ticket":"00001KVXK0WDQ","note":"Queue review: `00001KVXK0WDX` は service output command model slice だが、output commands are returned by service ingress dispatch のため `00001KVXK0WDQ` に depends_on している。prerequisite completion 後に再 routing する。","author":"yoi-orchestrator","at":"2026-06-24T20:13:35Z"} +{"id":"orch-plan-20260625-054531-2","ticket_id":"00001KVXK0WDX","kind":"accepted_plan","accepted_plan":{"summary":"Ticket `00001KVXK0WDX` は prerequisite `00001KVXK0WDQ` が done になったため implementation_ready。専用 worktree `/home/hare/Projects/yoi/.worktree/00001KVXK0WDX-plugin-output-commands` と branch `work/00001KVXK0WDX-plugin-output-commands` で、Plugin Service ingress handler の output command envelope / validation / grant-check / diagnostics を追加する。WebSocket transport、HTTP request dispatch 完成、WIT/PDK/templates update は後続 Tickets に残す。","branch":"work/00001KVXK0WDX-plugin-output-commands","worktree":"/home/hare/Projects/yoi/.worktree/00001KVXK0WDX-plugin-output-commands","role_plan":"Orchestrator: accept/routing, worktree creation, final integration/validation/cleanup. Coder: implement service output command envelope and grant-check diagnostics in dedicated child worktree. Reviewer: read-only review focusing on command/result separation from ToolOutput, fail-closed grants, no WebSocket/HTTP transport scope creep, and service lifecycle non-regression."},"author":"yoi-orchestrator","at":"2026-06-25T05:45:31Z"} diff --git a/.yoi/tickets/00001KVXK0WDX/item.md b/.yoi/tickets/00001KVXK0WDX/item.md index d444cfba..19235f76 100644 --- a/.yoi/tickets/00001KVXK0WDX/item.md +++ b/.yoi/tickets/00001KVXK0WDX/item.md @@ -2,7 +2,7 @@ title: 'Add Plugin service output command model' state: 'queued' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-24T20:13:35Z' +updated_at: '2026-06-25T05:46:02Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:12:02Z' diff --git a/.yoi/tickets/00001KVXK0WDX/thread.md b/.yoi/tickets/00001KVXK0WDX/thread.md index 26cd8944..8009b38a 100644 --- a/.yoi/tickets/00001KVXK0WDX/thread.md +++ b/.yoi/tickets/00001KVXK0WDX/thread.md @@ -30,4 +30,82 @@ Marked ready by `yoi ticket state`. Ticket を `workspace-panel` が queued にしました。 +--- + + + +## Decision + +Routing decision: implementation_ready + +Reason: +- ユーザーから「続けて」と明示 follow-up があり、queued dependency chain の次 Ticket として再確認した。 +- `00001KVXK0WDX` は Service output command model の concrete slice で、WebSocket transport、HTTP request dispatch completion、Domain operation command completion、WIT/PDK/templates update は non-goal として後続に分離されている。 +- outgoing `depends_on` は `00001KVXK0WDQ` だが、`00001KVXK0WDQ` は done / merged / reviewed / validated 済み。`TicketShow` derived blockers は空で、implementation acceptance blocker は残っていない。 +- incoming dependent `00001KVXK0WE4` は WebSocket driver slice で、この Ticket 完了後に進めるべき後続であり、この Ticket の acceptance blocker ではない。 +- bounded context check で current Plugin service lifecycle / ingress queue implementation が `crates/pod/src/feature/plugin.rs` に入り、event dispatch/status diagnostics の拡張点が存在することを確認した。Ticket の残る不確実性は command envelope / validation / grant-check / diagnostics の local implementation に閉じる。 + +Evidence checked: +- Ticket body / thread: `item.md`, `thread.md`。未解決 planning question は記録されていない。 +- Relations / orchestration plan: outgoing depends_on `00001KVXK0WDQ` は done。routing 前 plan は historical blocked_by `00001KVXK0WDQ` のみで、prerequisite 完了により解消済み。accepted plan `orch-plan-20260625-054531-2` を記録済み。 +- Related Tickets: `00001KVXK0WD3`, `00001KVXK0WDH`, `00001KVXK0WDQ` は done。 +- Code context: `crates/pod/src/feature/plugin.rs` の Plugin Service lifecycle / bounded ingress queue / `PluginIngressEvent` / status diagnostics / component runtime。 +- Workspace state: `/home/hare/Projects/yoi/.worktree/orchestration` は clean。inprogress Ticket は 0 件。 + +IntentPacket: + +Intent: +- Service Plugin ingress handler の戻り値として output command envelope を表現し、Host が command ごとに manifest declaration / enablement grant / runtime policy を fail-closed に検査し、結果を service diagnostics/status から追えるようにする。 + +Binding decisions / invariants: +- Service output command は Tool Plugin の ordinary `ToolOutput` path と型・処理経路・docs/tests で区別する。 +- v0 command kind は最小集合に留める: diagnostic/status update, host request dispatch placeholder, websocket send placeholder。 +- WebSocket send の実 transport 実装、HTTP request dispatch completion、Domain operation command completion は non-goal。 +- Unsupported / ungranted / malformed command は実行せず typed diagnostic にする。 +- Unrestricted shell / filesystem command や hidden LLM context injection は絶対に導入しない。 +- Existing Plugin Service lifecycle / bounded ingress queue and Component Model-only runtime from prerequisites must not regress. + +Requirements / acceptance criteria: +- `handle-ingress` / service event handler result can carry output command list. +- Command has correlation id / source event id / command id / kind / payload / requested_at. +- Host parses, validates, and grant-checks each command. +- Ungranted command is not executed and appears as typed diagnostic. +- Safe diagnostic/status update command is executed or recorded. +- WebSocket send / request dispatch placeholders are grant-checkable and safely unsupported without transport. +- Command execution result is visible from service status / diagnostics or run overview-equivalent state. +- Tests distinguish Tool Plugin output from Service output commands. + +Implementation latitude: +- Exact Rust names/enums and JSON/envelope shape may follow existing plugin code style. +- Manifest declaration/grant mapping can be minimal v0 as long as it is explicit and fail-closed. +- Existing tests/fixtures may be extended in `crates/pod/src/feature/plugin.rs`; docs/comments may be updated if needed. + +Escalate if: +- Implementing output commands requires actual WebSocket/HTTP transport. +- Current manifest/grant model cannot represent placeholder command grants without broader schema redesign. +- Command results must be persisted in a durable cross-process run overview to satisfy tests. +- Tool Plugin output must be routed through Service command processing. + +Validation: +- `cargo test -p pod` +- `cargo check -p yoi` +- `git diff --check` +- `nix build .#yoi --no-link` +- Focused plugin service command tests during development are expected. + +Current code map: +- Primary: `crates/pod/src/feature/plugin.rs`。 +- Secondary only if necessary: manifest grant declarations/tests/docs comments。 +- Avoid: WebSocket transport driver, HTTP request dispatch completion, WIT/PDK/templates service event update, durable cross-process queue, remote runtime protocol。 + +Critical risks / reviewer focus: +- output commands becoming ambient authority. +- ToolOutput and Service output commands being conflated. +- ungranted/malformed commands partially executing. +- placeholders accidentally performing network I/O。 +- scope creep into WebSocket driver or PDK/WIT updates. + +Next action: +- `queued -> inprogress` を記録してから worktree-workflow で `/home/hare/Projects/yoi/.worktree/00001KVXK0WDX-plugin-output-commands` を作成し、multi-agent-workflow で Coder/Reviewer sibling loop に進める。 + --- From 89a40db79e5458a69063572bb9bb7311f6710207 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 14:46:54 +0900 Subject: [PATCH 24/46] ticket: accept plugin output command task --- .yoi/tickets/00001KVXK0WDX/item.md | 4 ++-- .yoi/tickets/00001KVXK0WDX/thread.md | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KVXK0WDX/item.md b/.yoi/tickets/00001KVXK0WDX/item.md index 19235f76..5bb29357 100644 --- a/.yoi/tickets/00001KVXK0WDX/item.md +++ b/.yoi/tickets/00001KVXK0WDX/item.md @@ -1,8 +1,8 @@ --- title: 'Add Plugin service output command model' -state: 'queued' +state: 'inprogress' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-25T05:46:02Z' +updated_at: '2026-06-25T05:46:44Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:12:02Z' diff --git a/.yoi/tickets/00001KVXK0WDX/thread.md b/.yoi/tickets/00001KVXK0WDX/thread.md index 8009b38a..20363dcd 100644 --- a/.yoi/tickets/00001KVXK0WDX/thread.md +++ b/.yoi/tickets/00001KVXK0WDX/thread.md @@ -109,3 +109,17 @@ Next action: - `queued -> inprogress` を記録してから worktree-workflow で `/home/hare/Projects/yoi/.worktree/00001KVXK0WDX-plugin-output-commands` を作成し、multi-agent-workflow で Coder/Reviewer sibling loop に進める。 --- + + + +## State changed + +Orchestrator acceptance: queued -> inprogress + +- 直前確認で `TicketShow` は state `queued`、derived blockers は空。 +- outgoing dependency `00001KVXK0WDQ` は done / merged / reviewed / validated 済み。 +- accepted plan `orch-plan-20260625-054531-2` を確認した。 +- routing decision と IntentPacket は Ticket thread に記録済み。 +- これ以降に worktree-workflow で `/home/hare/Projects/yoi/.worktree/00001KVXK0WDX-plugin-output-commands` を作成し、multi-agent-workflow に接続する。 + +--- From 07f9793bc63e4d87c9273364fe216d29187c9855 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 14:47:52 +0900 Subject: [PATCH 25/46] ticket: record plugin output coder start --- .yoi/tickets/00001KVXK0WDX/item.md | 2 +- .yoi/tickets/00001KVXK0WDX/thread.md | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVXK0WDX/item.md b/.yoi/tickets/00001KVXK0WDX/item.md index 5bb29357..5beee2ab 100644 --- a/.yoi/tickets/00001KVXK0WDX/item.md +++ b/.yoi/tickets/00001KVXK0WDX/item.md @@ -2,7 +2,7 @@ title: 'Add Plugin service output command model' state: 'inprogress' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-25T05:46:44Z' +updated_at: '2026-06-25T05:47:43Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:12:02Z' diff --git a/.yoi/tickets/00001KVXK0WDX/thread.md b/.yoi/tickets/00001KVXK0WDX/thread.md index 20363dcd..51fb08e8 100644 --- a/.yoi/tickets/00001KVXK0WDX/thread.md +++ b/.yoi/tickets/00001KVXK0WDX/thread.md @@ -123,3 +123,17 @@ Orchestrator acceptance: queued -> inprogress - これ以降に worktree-workflow で `/home/hare/Projects/yoi/.worktree/00001KVXK0WDX-plugin-output-commands` を作成し、multi-agent-workflow に接続する。 --- + + + +## Implementation report + +Implementation orchestration update: + +- prerequisite `00001KVXK0WDQ` が done になったため、`00001KVXK0WDX` を再 routing して受理した。 +- worktree-workflow に従い、implementation worktree `/home/hare/Projects/yoi/.worktree/00001KVXK0WDX-plugin-output-commands` と branch `work/00001KVXK0WDX-plugin-output-commands` を作成した。 +- child worktree では tracked `.yoi` project records を見える状態にし、`.yoi/memory`、local/runtime/log/lock/secret-like `.yoi` paths を sparse-checkout で除外する方針にした。 +- Coder Pod `yoi-coder-00001KVXK0WDX-output-commands` を sibling として起動した。write scope は child worktree に限定し、runtime workspace root `/home/hare/Projects/yoi` は startup 要件を満たすため non-recursive read のみにした。 +- Coder には root/original workspace と orchestration worktree で read/write/validation/git operation を行わないよう明示した。 + +--- From 755d460f0d4e2dccb3f75d9ad980b1e701464a4e Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 15:11:30 +0900 Subject: [PATCH 26/46] feat: add plugin service output commands --- crates/pod/src/feature/plugin.rs | 668 +++++++++++++++++++++++++++++-- 1 file changed, 638 insertions(+), 30 deletions(-) diff --git a/crates/pod/src/feature/plugin.rs b/crates/pod/src/feature/plugin.rs index cf880c5b..e7109092 100644 --- a/crates/pod/src/feature/plugin.rs +++ b/crates/pod/src/feature/plugin.rs @@ -2283,6 +2283,9 @@ 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_SERVICE_INGRESS_QUEUE_CAPACITY: usize = 32; +const PLUGIN_SERVICE_OUTPUT_COMMAND_MAX_COUNT: usize = 16; +const PLUGIN_SERVICE_OUTPUT_COMMAND_MAX_PAYLOAD_BYTES: usize = 16 * 1024; +const PLUGIN_SERVICE_OUTPUT_COMMAND_MAX_RESULTS: usize = 32; #[cfg(test)] const PLUGIN_SERVICE_INGRESS_DISPATCH_TIMEOUT: Duration = Duration::from_millis(25); #[cfg(not(test))] @@ -2447,6 +2450,22 @@ struct PluginWebSocketOpenRequest { headers: Vec, } +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PluginServiceWebSocketSendCommandPayload { + url: String, + text: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PluginServiceDiagnosticStatusCommandPayload { + #[serde(default)] + message: Option, + #[serde(default)] + status: Option, +} + #[derive(Clone, Debug, Deserialize, Serialize)] struct PluginWebSocketOpenResponse { handle: u32, @@ -2957,6 +2976,9 @@ pub enum PluginInstanceDiagnosticKind { ServiceUnavailable, ServiceFailed, ServiceStopped, + ServiceOutputCommandRecorded, + ServiceOutputCommandRejected, + ServiceOutputCommandUnsupported, } #[derive(Clone, Debug, PartialEq, Eq, Serialize)] @@ -3002,6 +3024,9 @@ pub struct PluginInstanceStatus { pub queue_capacity: usize, pub last_error: Option, pub dispatch_counters: PluginIngressDispatchCounters, + /// Last bounded Service output command outcomes. These are produced only by + /// Service/Ingress dispatch and are intentionally separate from ToolOutput. + pub output_command_results: Vec, pub diagnostics: Vec, } @@ -3035,12 +3060,138 @@ impl PluginIngressEvent { } } +/// Host-validated output command envelope returned by Service/Ingress handlers. +/// +/// Service output commands are intentionally distinct from ordinary plugin +/// `ToolOutput`: handlers can request bounded side effects, but the host parses, +/// validates, grant-checks, records diagnostics, and fail-closes before executing +/// any supported command. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PluginServiceOutputCommandEnvelope { + pub correlation_id: String, + pub source_event_id: String, + pub command_id: String, + pub kind: PluginServiceOutputCommandKind, + pub payload: Value, + pub requested_at: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PluginServiceOutputCommandKind { + DiagnosticStatusUpdate, + HostRequestDispatch, + #[serde(rename = "websocket_send")] + WebSocketSend, +} + +impl PluginServiceOutputCommandKind { + fn as_str(self) -> &'static str { + match self { + Self::DiagnosticStatusUpdate => "diagnostic_status_update", + Self::HostRequestDispatch => "host_request_dispatch", + Self::WebSocketSend => "websocket_send", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub enum PluginServiceOutputCommandStatus { + Recorded, + Rejected, + Unsupported, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct PluginServiceOutputCommandResult { + pub correlation_id: Option, + pub source_event_id: Option, + pub command_id: Option, + pub kind: Option, + pub status: PluginServiceOutputCommandStatus, + pub message: String, + pub recorded_at: String, +} + +impl PluginServiceOutputCommandResult { + fn rejected(message: impl Into) -> Self { + Self::from_parts( + None, + None, + None, + None, + PluginServiceOutputCommandStatus::Rejected, + message, + ) + } + + fn rejected_for( + command: &PluginServiceOutputCommandEnvelope, + message: impl Into, + ) -> Self { + Self::from_command(command, PluginServiceOutputCommandStatus::Rejected, message) + } + + fn unsupported( + command: &PluginServiceOutputCommandEnvelope, + message: impl Into, + ) -> Self { + Self::from_command( + command, + PluginServiceOutputCommandStatus::Unsupported, + message, + ) + } + + fn recorded(command: &PluginServiceOutputCommandEnvelope, message: impl Into) -> Self { + Self::from_command(command, PluginServiceOutputCommandStatus::Recorded, message) + } + + fn from_command( + command: &PluginServiceOutputCommandEnvelope, + status: PluginServiceOutputCommandStatus, + message: impl Into, + ) -> Self { + Self::from_parts( + Some(command.correlation_id.clone()), + Some(command.source_event_id.clone()), + Some(command.command_id.clone()), + Some(command.kind), + status, + message, + ) + } + + fn from_parts( + correlation_id: Option, + source_event_id: Option, + command_id: Option, + kind: Option, + status: PluginServiceOutputCommandStatus, + message: impl Into, + ) -> Self { + Self { + correlation_id, + source_event_id, + command_id, + kind, + status, + message: bounded_message(redact_secret_like(&message.into())), + recorded_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true), + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize)] pub struct PluginIngressDispatchReport { pub plugin_ref: String, pub ingress: String, pub accepted: bool, pub output: Value, + /// Results of host-side Service output command parsing/validation/grant-checking. + /// This path never feeds ordinary plugin ToolOutput handling. + pub output_command_results: Vec, pub queue_depth: usize, pub dispatch_counters: PluginIngressDispatchCounters, pub diagnostics: Vec, @@ -3185,6 +3336,7 @@ impl PluginInstanceHandle { ingress_queue_capacity: PLUGIN_SERVICE_INGRESS_QUEUE_CAPACITY, dispatch_counters: PluginIngressDispatchCounters::default(), last_error: None, + output_command_results: Vec::new(), diagnostics: Vec::new(), }; instance.start()?; @@ -3237,6 +3389,7 @@ struct PluginInstance { ingress_queue_capacity: usize, dispatch_counters: PluginIngressDispatchCounters, last_error: Option, + output_command_results: Vec, diagnostics: Vec, } @@ -3507,10 +3660,12 @@ impl PluginInstance { ingress_name: &str, event: PluginIngressEvent, ) -> Result { - match &mut self.runtime { - PluginInstanceRuntime::ComponentToolAdapter => Err(PluginWasmError::Module( - "component tool runtime does not expose ingress dispatch".to_string(), - )), + let output = match &mut self.runtime { + PluginInstanceRuntime::ComponentToolAdapter => { + return Err(PluginWasmError::Module( + "component tool runtime does not expose ingress dispatch".to_string(), + )); + } #[cfg(test)] PluginInstanceRuntime::TestIngress { tool_calls, @@ -3525,40 +3680,40 @@ impl PluginInstance { )); } *ingress_calls += 1; - let output = serde_json::json!({ + let mut output = serde_json::json!({ "ingress": ingress_name, - "kind": event.kind, - "source": event.source, - "ingress_name": event.ingress_name, + "kind": event.kind.clone(), + "source": event.source.clone(), + "ingress_name": event.ingress_name.clone(), "attempt": event.attempt, - "correlation_id": event.correlation_id, + "correlation_id": event.correlation_id.clone(), "calls": *tool_calls, "ingress_calls": *ingress_calls, - "payload": event.payload, + "payload": event.payload.clone(), }); - Ok(PluginIngressDispatchReport { - plugin_ref: self.record.identity.to_string(), - ingress: ingress_name.to_string(), - accepted: true, - output, - queue_depth: self.ingress_queue.len(), - dispatch_counters: self.dispatch_counters.clone(), - diagnostics: self.diagnostics.clone(), - }) + if let (Some(map), Some(commands)) = ( + output.as_object_mut(), + event.payload.get("output_commands").cloned(), + ) { + map.insert("output_commands".to_string(), commands); + } + output } PluginInstanceRuntime::ComponentInstance(runtime) => { - let output = runtime.handle_ingress(ingress_name, &event)?; - Ok(PluginIngressDispatchReport { - plugin_ref: self.record.identity.to_string(), - ingress: ingress_name.to_string(), - accepted: true, - output, - queue_depth: self.ingress_queue.len(), - dispatch_counters: self.dispatch_counters.clone(), - diagnostics: self.diagnostics.clone(), - }) + runtime.handle_ingress(ingress_name, &event)? } - } + }; + let output_command_results = self.process_service_output_commands(&output, &event); + Ok(PluginIngressDispatchReport { + plugin_ref: self.record.identity.to_string(), + ingress: ingress_name.to_string(), + accepted: true, + output, + output_command_results, + queue_depth: self.ingress_queue.len(), + dispatch_counters: self.dispatch_counters.clone(), + diagnostics: self.diagnostics.clone(), + }) } fn stop(&mut self) -> Result<(), PluginWasmError> { @@ -3608,6 +3763,7 @@ impl PluginInstance { queue_capacity: self.ingress_queue_capacity, last_error: self.last_error.clone(), dispatch_counters: self.dispatch_counters.clone(), + output_command_results: self.output_command_results.clone(), diagnostics: self.diagnostics.clone(), } } @@ -3656,6 +3812,262 @@ impl PluginInstance { } } + fn process_service_output_commands( + &mut self, + output: &Value, + event: &PluginIngressEvent, + ) -> Vec { + let results = match self.validate_service_output_commands(output, event) { + Ok(commands) => { + let mut results = Vec::with_capacity(commands.len()); + for command in commands { + results.push(self.execute_service_output_command(command)); + } + results + } + Err(results) => results, + }; + self.record_service_output_command_results(&results); + results + } + + fn validate_service_output_commands( + &self, + output: &Value, + event: &PluginIngressEvent, + ) -> Result, Vec> + { + let Some(values) = output.get("output_commands") else { + return Ok(Vec::new()); + }; + let Some(values) = values.as_array() else { + return Err(vec![PluginServiceOutputCommandResult::rejected( + "service output_commands must be an array", + )]); + }; + if values.len() > PLUGIN_SERVICE_OUTPUT_COMMAND_MAX_COUNT { + return Err(vec![PluginServiceOutputCommandResult::rejected(format!( + "service output_commands exceeds {} commands", + PLUGIN_SERVICE_OUTPUT_COMMAND_MAX_COUNT + ))]); + } + + let mut commands = Vec::with_capacity(values.len()); + let mut rejected = Vec::new(); + for value in values { + let command: PluginServiceOutputCommandEnvelope = + match serde_json::from_value(value.clone()) { + Ok(command) => command, + Err(error) => { + rejected.push(PluginServiceOutputCommandResult::rejected(format!( + "malformed service output command envelope: {error}" + ))); + continue; + } + }; + if let Err(message) = self.validate_service_output_command_envelope(&command, event) { + rejected.push(PluginServiceOutputCommandResult::rejected_for( + &command, message, + )); + continue; + } + if let Err(message) = self.grant_check_service_output_command(&command) { + rejected.push(PluginServiceOutputCommandResult::rejected_for( + &command, message, + )); + continue; + } + commands.push(command); + } + + if rejected.is_empty() { + Ok(commands) + } else { + Err(rejected) + } + } + + fn validate_service_output_command_envelope( + &self, + command: &PluginServiceOutputCommandEnvelope, + event: &PluginIngressEvent, + ) -> Result<(), String> { + validate_output_command_id("correlation_id", &command.correlation_id)?; + validate_output_command_id("source_event_id", &command.source_event_id)?; + validate_output_command_id("command_id", &command.command_id)?; + if command.source_event_id != event.correlation_id { + return Err("source_event_id must match the ingress event correlation_id".to_string()); + } + chrono::DateTime::parse_from_rfc3339(&command.requested_at) + .map_err(|error| format!("requested_at must be RFC3339: {error}"))?; + let payload_bytes = serde_json::to_vec(&command.payload) + .map_err(|error| format!("payload is not serializable JSON: {error}"))?; + if payload_bytes.len() > PLUGIN_SERVICE_OUTPUT_COMMAND_MAX_PAYLOAD_BYTES { + return Err(format!( + "payload exceeds {} bytes", + PLUGIN_SERVICE_OUTPUT_COMMAND_MAX_PAYLOAD_BYTES + )); + } + match command.kind { + PluginServiceOutputCommandKind::DiagnosticStatusUpdate => { + serde_json::from_value::( + command.payload.clone(), + ) + .map_err(|error| format!("invalid diagnostic_status_update payload: {error}"))?; + } + PluginServiceOutputCommandKind::HostRequestDispatch => { + let request: PluginRequestRequest = serde_json::from_value(command.payload.clone()) + .map_err(|error| format!("invalid host_request_dispatch payload: {error}"))?; + validate_plugin_request_request(&self.record, &request) + .map_err(|error| format!("host_request_dispatch target denied: {}", error.0))?; + } + PluginServiceOutputCommandKind::WebSocketSend => { + let payload: PluginServiceWebSocketSendCommandPayload = + serde_json::from_value(command.payload.clone()) + .map_err(|error| format!("invalid websocket_send payload: {error}"))?; + self.validate_service_websocket_send_payload(&payload)?; + } + } + Ok(()) + } + + fn grant_check_service_output_command( + &self, + command: &PluginServiceOutputCommandEnvelope, + ) -> Result<(), String> { + match command.kind { + PluginServiceOutputCommandKind::DiagnosticStatusUpdate => Ok(()), + PluginServiceOutputCommandKind::HostRequestDispatch => { + authorize_plugin_host_api(&self.record, PluginHostApi::Request) + .map_err(|error| format!("host_request_dispatch not granted: {}", error.0)) + } + PluginServiceOutputCommandKind::WebSocketSend => { + authorize_plugin_host_api(&self.record, PluginHostApi::WebSocket) + .map_err(|error| format!("websocket_send not granted: {}", error.0)) + } + } + } + + fn validate_service_websocket_send_payload( + &self, + payload: &PluginServiceWebSocketSendCommandPayload, + ) -> Result<(), String> { + if payload.text.len() > PLUGIN_WEBSOCKET_MAX_MESSAGE_BYTES { + return Err(format!( + "websocket_send text exceeds {} bytes", + PLUGIN_WEBSOCKET_MAX_MESSAGE_BYTES + )); + } + let url = reqwest::Url::parse(&payload.url) + .map_err(|error| format!("invalid WebSocket URL: {error}"))?; + match url.scheme() { + "ws" | "wss" => {} + "http" | "https" => { + return Err("HTTP URLs are not supported by websocket_send".to_string()); + } + scheme => { + return Err(format!( + "unsupported WebSocket URL scheme {scheme:?}; only ws and wss are allowed" + )); + } + } + if url.host_str().is_none() { + return Err("WebSocket URL must include a host".to_string()); + } + if !url.username().is_empty() || url.password().is_some() { + return Err("WebSocket URLs with embedded credentials are not allowed".to_string()); + } + validate_static_request_target(&url).map_err(|error| error.0)?; + authorize_websocket_allowlist(&self.record, &url).map_err(|error| error.0)?; + Ok(()) + } + + fn execute_service_output_command( + &mut self, + command: PluginServiceOutputCommandEnvelope, + ) -> PluginServiceOutputCommandResult { + match command.kind { + PluginServiceOutputCommandKind::DiagnosticStatusUpdate => { + let payload: PluginServiceDiagnosticStatusCommandPayload = + match serde_json::from_value(command.payload.clone()) { + Ok(payload) => payload, + Err(error) => { + return PluginServiceOutputCommandResult::rejected_for( + &command, + format!("invalid diagnostic_status_update payload: {error}"), + ); + } + }; + if let Some(status) = payload.status { + self.component_status = Some(status); + } + let message = payload + .message + .as_deref() + .map(bounded_message) + .unwrap_or_else(|| "plugin service status update recorded".to_string()); + PluginServiceOutputCommandResult::recorded(&command, message) + } + PluginServiceOutputCommandKind::HostRequestDispatch => { + PluginServiceOutputCommandResult::unsupported( + &command, + "host_request_dispatch output command is grant-checked but transport dispatch is unsupported in v0", + ) + } + PluginServiceOutputCommandKind::WebSocketSend => { + PluginServiceOutputCommandResult::unsupported( + &command, + "websocket_send output command is grant-checked but WebSocket send transport is unsupported in v0", + ) + } + } + } + + fn record_service_output_command_results( + &mut self, + results: &[PluginServiceOutputCommandResult], + ) { + if results.is_empty() { + return; + } + for result in results { + let kind = match result.status { + PluginServiceOutputCommandStatus::Recorded => { + PluginInstanceDiagnosticKind::ServiceOutputCommandRecorded + } + PluginServiceOutputCommandStatus::Rejected => { + PluginInstanceDiagnosticKind::ServiceOutputCommandRejected + } + PluginServiceOutputCommandStatus::Unsupported => { + PluginInstanceDiagnosticKind::ServiceOutputCommandUnsupported + } + }; + let command_label = result.command_id.as_deref().unwrap_or(""); + let command_kind = result + .kind + .map(PluginServiceOutputCommandKind::as_str) + .unwrap_or("unknown"); + let message = bounded_message(format!( + "service output command {command_label} ({command_kind}): {}", + result.message + )); + if !matches!(result.status, PluginServiceOutputCommandStatus::Recorded) { + self.last_error = Some(message.clone()); + } + self.diagnostics.push(PluginInstanceDiagnostic::with_kind( + kind, + self.lifecycle.clone(), + message, + )); + } + self.output_command_results.extend_from_slice(results); + if self.output_command_results.len() > PLUGIN_SERVICE_OUTPUT_COMMAND_MAX_RESULTS { + let keep_from = + self.output_command_results.len() - PLUGIN_SERVICE_OUTPUT_COMMAND_MAX_RESULTS; + self.output_command_results.drain(0..keep_from); + } + } + fn record_runtime_error( &mut self, kind: PluginInstanceDiagnosticKind, @@ -4413,6 +4825,15 @@ fn bounded_message(message: impl Into) -> String { sanitized } +fn validate_output_command_id(field: &str, value: &str) -> Result<(), String> { + if value.is_empty() || value.len() > 128 || value.chars().any(char::is_control) { + return Err(format!( + "{field} is empty, too long, or contains control characters" + )); + } + Ok(()) +} + fn validate_declared_tool_names(record: &ResolvedPluginRecord) -> Result<(), FeatureInstallError> { let mut seen = HashSet::new(); for tool in &record.manifest.tools { @@ -4818,6 +5239,51 @@ mod tests { PluginIngressEvent::new(ingress_name, "test", "unit", payload) } + fn service_output_command( + event: &PluginIngressEvent, + command_id: &str, + kind: &str, + payload: Value, + ) -> Value { + json!({ + "correlation_id": event.correlation_id.clone(), + "source_event_id": event.correlation_id.clone(), + "command_id": command_id, + "kind": kind, + "payload": payload, + "requested_at": event.created_at.clone(), + }) + } + + fn add_request_output_grant(record: &mut ResolvedPluginRecord) { + let permission = PluginPermission::host_api(PluginHostApi::Request); + record.manifest.permissions.push(permission.clone()); + record.grants.permissions.push(permission); + let target = PluginRequestGrant { + scheme: "https".to_string(), + host: "api.example.test".to_string(), + port: None, + methods: vec!["POST".to_string()], + path_prefixes: vec!["/v1".to_string()], + }; + record.manifest.request.push(target.clone()); + record.grants.request.push(target); + } + + fn add_websocket_output_grant(record: &mut ResolvedPluginRecord) { + let permission = PluginPermission::host_api(PluginHostApi::WebSocket); + record.manifest.permissions.push(permission.clone()); + record.grants.permissions.push(permission); + let target = PluginWebSocketGrant { + scheme: "wss".to_string(), + host: "ws.example.test".to_string(), + port: None, + path_prefixes: vec!["/events".to_string()], + }; + record.manifest.websocket.push(target.clone()); + record.grants.websocket.push(target); + } + #[test] fn service_selected_ignores_unselected_tool_without_grants() { let mut record = record(vec![tool("hidden_tool")]); @@ -5060,6 +5526,147 @@ mod tests { ); } + #[test] + fn service_output_command_records_diagnostic_status_separately_from_tool_output() { + let handle = PluginInstanceHandle::new(test_service_ingress_record()).unwrap(); + let mut event = test_ingress_event("shared_ingress", json!({})); + let command = service_output_command( + &event, + "cmd-status", + "diagnostic_status_update", + json!({ + "message": "service became ready", + "status": {"ready": true} + }), + ); + event.payload = json!({"output_commands": [command]}); + + let report = handle.deliver_ingress("shared_ingress", event).unwrap(); + + assert_eq!(report.output_command_results.len(), 1); + assert_eq!( + report.output_command_results[0].kind, + Some(PluginServiceOutputCommandKind::DiagnosticStatusUpdate) + ); + assert_eq!( + report.output_command_results[0].status, + PluginServiceOutputCommandStatus::Recorded + ); + assert_eq!( + report.output["payload"]["output_commands"] + .as_array() + .unwrap() + .len(), + 1 + ); + let status = handle.status(); + assert_eq!(status.component_status, Some(json!({"ready": true}))); + assert_eq!(status.output_command_results.len(), 1); + assert!(status.diagnostics.iter().any(|diagnostic| { + diagnostic.kind == PluginInstanceDiagnosticKind::ServiceOutputCommandRecorded + && diagnostic.message.contains("cmd-status") + })); + } + + #[test] + fn service_output_command_rejects_ungranted_request_without_executing_status_update() { + let handle = PluginInstanceHandle::new(test_service_ingress_record()).unwrap(); + let mut event = test_ingress_event("shared_ingress", json!({})); + let status_command = service_output_command( + &event, + "cmd-status", + "diagnostic_status_update", + json!({"status": {"should_not_record": true}}), + ); + let request_command = service_output_command( + &event, + "cmd-request", + "host_request_dispatch", + json!({ + "method": "POST", + "url": "https://api.example.test/v1/events" + }), + ); + event.payload = json!({"output_commands": [status_command, request_command]}); + + let report = handle.deliver_ingress("shared_ingress", event).unwrap(); + + assert_eq!(report.output_command_results.len(), 1); + assert_eq!( + report.output_command_results[0].status, + PluginServiceOutputCommandStatus::Rejected + ); + assert_eq!(handle.status().component_status, None); + assert!(handle.status().diagnostics.iter().any(|diagnostic| { + diagnostic.kind == PluginInstanceDiagnosticKind::ServiceOutputCommandRejected + && diagnostic.message.contains("cmd-request") + })); + } + + #[test] + fn service_output_command_placeholders_are_grant_checked_and_unsupported() { + let mut record = test_service_ingress_record(); + add_request_output_grant(&mut record); + add_websocket_output_grant(&mut record); + let handle = PluginInstanceHandle::new(record).unwrap(); + let mut event = test_ingress_event("shared_ingress", json!({})); + let request_command = service_output_command( + &event, + "cmd-request", + "host_request_dispatch", + json!({ + "method": "POST", + "url": "https://api.example.test/v1/events" + }), + ); + let websocket_command = service_output_command( + &event, + "cmd-websocket", + "websocket_send", + json!({ + "url": "wss://ws.example.test/events", + "text": "hello" + }), + ); + event.payload = json!({"output_commands": [request_command, websocket_command]}); + + let report = handle.deliver_ingress("shared_ingress", event).unwrap(); + + assert_eq!(report.output_command_results.len(), 2); + assert!( + report + .output_command_results + .iter() + .all(|result| { result.status == PluginServiceOutputCommandStatus::Unsupported }) + ); + let status = handle.status(); + assert_eq!(status.output_command_results.len(), 2); + assert!(status.diagnostics.iter().any(|diagnostic| { + diagnostic.kind == PluginInstanceDiagnosticKind::ServiceOutputCommandUnsupported + && diagnostic.message.contains("cmd-websocket") + })); + } + + #[test] + fn service_output_command_rejects_malformed_envelope() { + let handle = PluginInstanceHandle::new(test_service_ingress_record()).unwrap(); + let mut event = test_ingress_event("shared_ingress", json!({})); + event.payload = json!({"output_commands": [{"kind": "diagnostic_status_update"}]}); + + let report = handle.deliver_ingress("shared_ingress", event).unwrap(); + + assert_eq!(report.output_command_results.len(), 1); + assert_eq!( + report.output_command_results[0].status, + PluginServiceOutputCommandStatus::Rejected + ); + assert!( + report.output_command_results[0] + .message + .contains("malformed service output command envelope") + ); + } + #[test] fn installed_ingress_dispatch_uses_retained_shared_instance() { let mut record = record(vec![tool("shared_tool")]); @@ -5144,6 +5751,7 @@ mod tests { ingress_queue_capacity: PLUGIN_SERVICE_INGRESS_QUEUE_CAPACITY, dispatch_counters: PluginIngressDispatchCounters::default(), last_error: None, + output_command_results: Vec::new(), diagnostics: Vec::new(), }))); From 799998639acc834f3afddca5820f371df83fcdd0 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 15:13:08 +0900 Subject: [PATCH 27/46] ticket: record plugin output implementation report --- .yoi/tickets/00001KVXK0WDX/item.md | 2 +- .yoi/tickets/00001KVXK0WDX/thread.md | 56 ++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVXK0WDX/item.md b/.yoi/tickets/00001KVXK0WDX/item.md index 5beee2ab..2e57d8a2 100644 --- a/.yoi/tickets/00001KVXK0WDX/item.md +++ b/.yoi/tickets/00001KVXK0WDX/item.md @@ -2,7 +2,7 @@ title: 'Add Plugin service output command model' state: 'inprogress' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-25T05:47:43Z' +updated_at: '2026-06-25T06:12:58Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:12:02Z' diff --git a/.yoi/tickets/00001KVXK0WDX/thread.md b/.yoi/tickets/00001KVXK0WDX/thread.md index 51fb08e8..d4a06f00 100644 --- a/.yoi/tickets/00001KVXK0WDX/thread.md +++ b/.yoi/tickets/00001KVXK0WDX/thread.md @@ -137,3 +137,59 @@ Implementation orchestration update: - Coder には root/original workspace と orchestration worktree で read/write/validation/git operation を行わないよう明示した。 --- + + + +## Implementation report + +Implementation report: + +対象 Ticket: +- `00001KVXK0WDX` — Add Plugin service output command model + +変更ファイル: +- `crates/pod/src/feature/plugin.rs` + +主な実装: +- Service/Ingress handler の戻り値から `output_commands` を読む Service output command model を追加。 +- command envelope に以下を追加。 + - `correlation_id` + - `source_event_id` + - `command_id` + - `kind` + - `payload` + - `requested_at` +- v0 command kind を追加。 + - `diagnostic_status_update` + - `host_request_dispatch` + - `websocket_send` +- Host 側で envelope parse / field validation / `requested_at` RFC3339 validation / `source_event_id` と ingress event correlation id の照合 / payload size bound / kind-specific payload validation / request・websocket grant check を行うようにした。 +- malformed / ungranted / unsupported command は typed diagnostic として記録し、実行しない。 +- `diagnostic_status_update` は安全に `component_status` / diagnostics に記録する。 +- `host_request_dispatch` / `websocket_send` は grant-check 可能だが、v0 では transport 実行せず `Unsupported` として記録する。 +- Service output command results を `PluginIngressDispatchReport` と `PluginInstanceStatus` から見えるように追加。 +- ordinary Tool Plugin `ToolOutput` path と Service output command path が混ざらないよう、型・コメント・テストを分離。 +- focused tests を追加。 + - diagnostic/status command の記録 + - ungranted request command の reject と status side effect 非実行 + - request/websocket placeholder の grant-check + unsupported 記録 + - malformed envelope の reject + +Commit: +- `755d460f0d4e2dccb3f75d9ad980b1e701464a4e feat: add plugin service output commands` + +Validation: +- `cargo fmt --check`: success +- `cargo test -p pod service_output_command -- --nocapture`: success (`4 passed`) +- `cargo test -p pod feature::plugin::tests`: success (`77 passed`) +- `cargo check -p yoi`: success +- `git diff --check`: success +- `nix build .#yoi --no-link`: success +- `cargo test -p pod`: failed in known prompt guidance snapshot assertions outside this Ticket (`prompt::catalog::tests::pod_orchestration_guidance_section_renders_resource_body`, `prompt::system::tests::pod_orchestration_guidance_is_included_for_pod_management_tools`), with Plugin tests passing. + +Residual risks / notes: +- WebSocket send transport と host request dispatch の実実行は Ticket non-goal に従い未実装で、grant-check 後に `Unsupported` diagnostic として記録する。 +- Service output command schema は Rust 側 JSON envelope として追加した。WIT/PDK/templates 更新は non-goal として触っていない。 +- Full `cargo test -p pod` の 2 failures は今回の Plugin diff 外の既知 prompt snapshot failure と一致している。 + +--- From 86017a5abc7f41340c40afb77381696e29bcc509 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 15:18:43 +0900 Subject: [PATCH 28/46] ticket: approve plugin output commands --- .yoi/tickets/00001KVXK0WDX/item.md | 2 +- .yoi/tickets/00001KVXK0WDX/thread.md | 29 ++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVXK0WDX/item.md b/.yoi/tickets/00001KVXK0WDX/item.md index 2e57d8a2..33b82d95 100644 --- a/.yoi/tickets/00001KVXK0WDX/item.md +++ b/.yoi/tickets/00001KVXK0WDX/item.md @@ -2,7 +2,7 @@ title: 'Add Plugin service output command model' state: 'inprogress' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-25T06:12:58Z' +updated_at: '2026-06-25T06:18:37Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:12:02Z' diff --git a/.yoi/tickets/00001KVXK0WDX/thread.md b/.yoi/tickets/00001KVXK0WDX/thread.md index d4a06f00..540421d7 100644 --- a/.yoi/tickets/00001KVXK0WDX/thread.md +++ b/.yoi/tickets/00001KVXK0WDX/thread.md @@ -193,3 +193,32 @@ Residual risks / notes: - Full `cargo test -p pod` の 2 failures は今回の Plugin diff 外の既知 prompt snapshot failure と一致している。 --- + + + +## Review: approve + +Review result: approve + +確認結果: +- Ticket `00001KVXK0WDX` の IntentPacket / 受け入れ条件に対して、実装 commit `755d460f feat: add plugin service output commands` は妥当。 + +承認根拠: +- 差分は `crates/pod/src/feature/plugin.rs` のみで、scope creep は見当たらない。 +- Service/Ingress handler output の `output_commands` を読む専用経路が追加され、ordinary Tool Plugin `ToolOutput` path とは型・処理・report/status field が分離されている。 +- envelope は `correlation_id` / `source_event_id` / `command_id` / `kind` / `payload` / `requested_at` を持ち、unknown fields reject、id / RFC3339 / `source_event_id == ingress correlation_id` / payload size / kind-specific payload を検証している。 +- `host_request_dispatch` と `websocket_send` は既存の manifest declaration + enabled grant / allowlist 検査に乗っており、実行段階では `Unsupported` result を返すだけで HTTP/WebSocket transport 実装に踏み込んでいない。 +- malformed / ungranted command は `ServiceOutputCommandRejected` diagnostic になり、mixed batch でも安全側に倒して部分的な status update を実行しないテストがある。 +- `diagnostic_status_update` は bounded payload/message 経由で `component_status` / diagnostics / `output_command_results` に記録され、外部 side effect や hidden LLM context injection はない。 +- Service command results は `PluginIngressDispatchReport` と `PluginInstanceStatus.output_command_results` から追跡可能。 +- 追加テストは recorded diagnostic、ungranted rejection、placeholder unsupported、malformed envelope を押さえている。 + +検証: +- Ticket `item.md` / `thread.md` / orchestration plan / relations を確認。 +- child worktree で `git status` / `git log` / `git diff --stat` / diff 内容を read-only 確認。 +- read-only 指示のため cargo/nix 等は再実行していない。 + +Non-blocking note: +- Coder 報告では `cargo test -p pod` が既知の prompt guidance snapshot assertion でのみ失敗、Plugin tests / `cargo check -p yoi` / `git diff --check` / `nix build .#yoi --no-link` は成功。最終統合時に既知 failure 扱いだけ確認すること。 + +--- From dc2f8b409d3b8d5e90796e4c3bc15185035474f3 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 15:20:33 +0900 Subject: [PATCH 29/46] ticket: mark plugin output commands done --- .yoi/tickets/00001KVXK0WDX/item.md | 4 +- .yoi/tickets/00001KVXK0WDX/thread.md | 75 ++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KVXK0WDX/item.md b/.yoi/tickets/00001KVXK0WDX/item.md index 33b82d95..36aa9295 100644 --- a/.yoi/tickets/00001KVXK0WDX/item.md +++ b/.yoi/tickets/00001KVXK0WDX/item.md @@ -1,8 +1,8 @@ --- title: 'Add Plugin service output command model' -state: 'inprogress' +state: 'done' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-25T06:18:37Z' +updated_at: '2026-06-25T06:20:26Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:12:02Z' diff --git a/.yoi/tickets/00001KVXK0WDX/thread.md b/.yoi/tickets/00001KVXK0WDX/thread.md index 540421d7..b1d4e258 100644 --- a/.yoi/tickets/00001KVXK0WDX/thread.md +++ b/.yoi/tickets/00001KVXK0WDX/thread.md @@ -222,3 +222,78 @@ Non-blocking note: - Coder 報告では `cargo test -p pod` が既知の prompt guidance snapshot assertion でのみ失敗、Plugin tests / `cargo check -p yoi` / `git diff --check` / `nix build .#yoi --no-link` は成功。最終統合時に既知 failure 扱いだけ確認すること。 --- + + + +## Implementation report + +Integration outcome: + +- Coder `yoi-coder-00001KVXK0WDX-output-commands` が implementation branch `work/00001KVXK0WDX-plugin-output-commands` に実装を commit した。 + - implementation commit: `755d460f feat: add plugin service output commands` +- Reviewer `yoi-reviewer-00001KVXK0WDX-output-commands` は read-only review で `approve`。Service output command path と ToolOutput path の分離、fail-closed validation/grant checks、placeholder command の non-transport behavior、diagnostic/status recording、scope creep なしを確認した。 +- Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration` で branch `work/00001KVXK0WDX-plugin-output-commands` を merge 済み。 + - merge commit: `d2aa92a7 merge: 00001KVXK0WDX plugin output commands` + +Implemented behavior: +- `crates/pod/src/feature/plugin.rs` に Service output command envelope handling を追加。 +- `output_commands` は `correlation_id`, `source_event_id`, `command_id`, `kind`, `payload`, `requested_at` を持つ。 +- v0 command kinds: + - `diagnostic_status_update` + - `host_request_dispatch` + - `websocket_send` +- Host-side validation: + - envelope parse / unknown field rejection + - id and RFC3339 validation + - `source_event_id` と ingress event correlation id の照合 + - payload size bound + - kind-specific payload validation + - manifest declaration + enablement grant / allowlist checks +- malformed / ungranted / unsupported commands は typed diagnostic になり、実行しない。 +- `diagnostic_status_update` は bounded payload/message で component status / diagnostics / output command results に記録。 +- `host_request_dispatch` / `websocket_send` は grant-check 後に `Unsupported` として記録し、HTTP/WebSocket transport には踏み込まない。 +- `PluginIngressDispatchReport` と `PluginInstanceStatus.output_command_results` から command result を追跡可能。 + +Validation in Orchestrator worktree: +- `cargo fmt --check`: success +- `cargo test -p pod service_output_command -- --nocapture`: success +- `cargo test -p pod feature::plugin::tests`: success +- `cargo check -p yoi`: success +- `git diff --check`: success +- `nix build .#yoi --no-link`: success +- `cargo test -p pod`: failed only in known prompt guidance snapshot assertions outside this Plugin diff: + - `prompt::catalog::tests::pod_orchestration_guidance_section_renders_resource_body` + - `prompt::system::tests::pod_orchestration_guidance_is_included_for_pod_management_tools` + - Plugin-focused tests in the same run passed. + +Notes: +- Full `cargo test -p pod` failure is recorded as the same known unrelated prompt-resource assertion issue seen in previous Plugin Tickets, not a blocker for this Ticket. +- Follow-up `00001KVXK0WE4` is dependency-unblocked and can be re-routed next. + +Next action: +- Mark Ticket done after this integration/validation evidence. +- Then stop related child Pods and remove only the child implementation worktree/branch. + +--- + + + +## State changed + +Implementation is merged into the orchestration branch and validated with one unrelated known full-suite caveat. + +Evidence: +- merge commit: `d2aa92a7 merge: 00001KVXK0WDX plugin output commands` +- reviewer result: approve +- validation in `/home/hare/Projects/yoi/.worktree/orchestration`: + - `cargo fmt --check`: success + - `cargo test -p pod service_output_command -- --nocapture`: success + - `cargo test -p pod feature::plugin::tests`: success + - `cargo check -p yoi`: success + - `git diff --check`: success + - `nix build .#yoi --no-link`: success + - `cargo test -p pod`: failed only in prompt guidance snapshot assertions outside this Plugin diff; Plugin-focused tests passed. + +Closure is not performed here; this state records implementation completion after merge/review/focused validation. + +--- From 4950749c5d06ddba68beb4116a16b35a7f5c42d9 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 15:22:55 +0900 Subject: [PATCH 30/46] ticket: route plugin websocket driver task --- .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KVXK0WE4/item.md | 2 +- .yoi/tickets/00001KVXK0WE4/thread.md | 80 +++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVXK0WE4/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KVXK0WE4/artifacts/orchestration-plan.jsonl index 66d80a4b..ffb04b9a 100644 --- a/.yoi/tickets/00001KVXK0WE4/artifacts/orchestration-plan.jsonl +++ b/.yoi/tickets/00001KVXK0WE4/artifacts/orchestration-plan.jsonl @@ -1,2 +1,3 @@ {"id":"orch-plan-20260624-201335-1","ticket_id":"00001KVXK0WE4","kind":"blocked_by","related_ticket":"00001KVXK0WDQ","note":"Queue review: `00001KVXK0WE4` は WebSocket driver slice だが、service ingress queue `00001KVXK0WDQ` と output command model `00001KVXK0WDX` に depends_on している。両 prerequisite completion 後に再 routing する。","author":"yoi-orchestrator","at":"2026-06-24T20:13:35Z"} {"id":"orch-plan-20260624-201341-2","ticket_id":"00001KVXK0WE4","kind":"blocked_by","related_ticket":"00001KVXK0WDX","note":"Queue review: `00001KVXK0WE4` は output command dispatch/grant model `00001KVXK0WDX` にも depends_on している。`00001KVXK0WDQ` と `00001KVXK0WDX` の両方が完了するまで acceptance しない。","author":"yoi-orchestrator","at":"2026-06-24T20:13:41Z"} +{"id":"orch-plan-20260625-062148-3","ticket_id":"00001KVXK0WE4","kind":"accepted_plan","accepted_plan":{"summary":"Ticket `00001KVXK0WE4` は prerequisites `00001KVXK0WDQ` と `00001KVXK0WDX` が done になったため implementation_ready。専用 worktree `/home/hare/Projects/yoi/.worktree/00001KVXK0WE4-plugin-websocket-driver` と branch `work/00001KVXK0WE4-plugin-websocket-driver` で、Plugin Service 用 host-owned WebSocket driver を追加し、incoming text frames を ingress queue に入れ、output command の websocket send を grant-check 後に送信する。Discord protocol/full reconnect/secret-store/browser WebSocket/WIT-PDK template update は non-goals。","branch":"work/00001KVXK0WE4-plugin-websocket-driver","worktree":"/home/hare/Projects/yoi/.worktree/00001KVXK0WE4-plugin-websocket-driver","role_plan":"Orchestrator: accept/routing, worktree creation, final integration/validation/cleanup. Coder: implement host-owned WebSocket driver slice in dedicated child worktree. Reviewer: read-only review focusing on host-owned connection authority, ingress queue integration, output command send execution, grant checks, and no protocol-specific/reconnect/scope creep."},"author":"yoi-orchestrator","at":"2026-06-25T06:21:48Z"} diff --git a/.yoi/tickets/00001KVXK0WE4/item.md b/.yoi/tickets/00001KVXK0WE4/item.md index 0788ffe9..bc0d9593 100644 --- a/.yoi/tickets/00001KVXK0WE4/item.md +++ b/.yoi/tickets/00001KVXK0WE4/item.md @@ -2,7 +2,7 @@ title: 'Add host-owned WebSocket driver for Plugin services' state: 'queued' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-24T20:13:41Z' +updated_at: '2026-06-25T06:22:18Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:12:03Z' diff --git a/.yoi/tickets/00001KVXK0WE4/thread.md b/.yoi/tickets/00001KVXK0WE4/thread.md index 8847a210..81dfca6a 100644 --- a/.yoi/tickets/00001KVXK0WE4/thread.md +++ b/.yoi/tickets/00001KVXK0WE4/thread.md @@ -30,4 +30,84 @@ Marked ready by `yoi ticket state`. Ticket を `workspace-panel` が queued にしました。 +--- + + + +## Decision + +Routing decision: implementation_ready + +Reason: +- ユーザーから「続けて」と明示 follow-up があり、queued dependency chain の次 Ticket として再確認した。 +- `00001KVXK0WE4` は host-owned WebSocket driver の concrete slice で、Discord protocol、full reconnect/resume policy、secret store/auth injection、browser-facing WebSocket API、WIT/PDK templates update は non-goal として分離されている。 +- outgoing `depends_on` は `00001KVXK0WDQ` と `00001KVXK0WDX` だが、両方とも done / merged / reviewed / validated 済み。`TicketShow` derived blockers は空で、implementation acceptance blocker は残っていない。 +- incoming dependent `00001KVXK0WEA` は WIT/PDK/templates update で、この Ticket 完了後に進めるべき後続であり、この Ticket の acceptance blocker ではない。 +- bounded context check で current `crates/pod/src/feature/plugin.rs` に existing pull `host_api.websocket` helpers、WebSocket grants/allowlist、Plugin Service lifecycle / ingress queue、Service output command model が存在することを確認した。Ticket の残る不確実性は host-owned connection driver / frame-to-ingress / output command send integration の local implementation に閉じる。 + +Evidence checked: +- Ticket body / thread: `item.md`, `thread.md`。未解決 planning question は記録されていない。 +- Relations / orchestration plan: outgoing depends_on `00001KVXK0WDQ` と `00001KVXK0WDX` は done。routing 前 plan は historical blocked_by records 2 件で、prerequisites 完了により解消済み。accepted plan `orch-plan-20260625-062148-3` を記録済み。 +- Related Tickets: `00001KVXK0WDQ` and `00001KVXK0WDX` are done. +- Code context: `crates/pod/src/feature/plugin.rs` の WebSocket allowlist/grants, existing pull API helpers, Plugin Service ingress queue, output command model。 +- Workspace state: `/home/hare/Projects/yoi/.worktree/orchestration` は clean。inprogress Ticket は 0 件。 + +IntentPacket: + +Intent: +- Service Plugin に紐づく host-owned WebSocket connection driver を追加し、incoming text frames を Plugin ingress queue に event として enqueue し、Service output command の `websocket_send` を grant-check 後に Host が送信できるようにする。 + +Binding decisions / invariants: +- Connection ownership is Host-side. Plugin code does not run a long-lived recv loop as recommended service path. +- Existing pull `host_api.websocket.recv(timeout)` API may remain for compatibility/internal bounded use, but docs/recommended service integration path must move away from it. +- WebSocket target authority is manifest declaration + enabled grant / allowlist. Unauthorized target/send fails closed. +- v0 handles text frames. Binary application payload is unsupported/diagnostic. ping/pong/close handling should be bounded/control/diagnostic, not app payload. +- Incoming frames are converted to service ingress events using existing bounded queue and lifecycle diagnostics. +- Outgoing sends use Service output command path from `00001KVXK0WDX`; do not bypass its validation/grant boundary. +- Full reconnect/resume policy, Discord/Slack protocol logic, secret injection design, browser-facing WebSocket API, WIT/PDK/templates update are non-goals. + +Requirements / acceptance criteria: +- Host-owned WebSocket driver can manage a connection associated with a Service Plugin. +- Incoming text frames enqueue Plugin ingress events. +- Close/error/reconnect-needed states become ingress events or status diagnostics. +- Plugin output command can trigger WebSocket text send after grant checks. +- Ungranted send / unauthorized target is fail-closed diagnostic. +- Connection status, last frame time, last error, queue drops, send failures are visible in diagnostics/status. +- Tests cover driver lifecycle, incoming frame to ingress, send command execution/rejection, close/error diagnostics, and frame kind handling. + +Implementation latitude: +- Exact struct names and whether tests use mock WebSocket client/connection or local bounded fake are coder choices. +- v0 may model reconnect-needed as diagnostic instead of automatic reconnect. +- Keep integration in `crates/pod/src/feature/plugin.rs` unless a small helper module split is clearly cleaner. + +Escalate if: +- Real network tests or external services are required. +- Secret store/auth injection becomes necessary. +- Durable cross-process queue or scheduler semantics are required. +- Implementing this requires WIT/PDK/template changes rather than Rust-side host runtime only. +- Browser-facing WebSocket API or protocol-specific Discord/Slack behavior is needed. + +Validation: +- `cargo test -p pod` +- `cargo check -p yoi` +- `git diff --check` +- `nix build .#yoi --no-link` +- Focused WebSocket driver / service ingress tests during development. + +Current code map: +- Primary: `crates/pod/src/feature/plugin.rs`。 +- Secondary only if necessary: docs/development recommended path wording。 +- Avoid: WIT/PDK/templates update (reserved for `00001KVXK0WEA`), protocol-specific integrations, full reconnect policy, secret store, browser WebSocket API。 + +Critical risks / reviewer focus: +- hidden ambient network authority or bypassing manifest/grant allowlist。 +- pull `recv(timeout)` remaining as recommended long-lived service path。 +- output command send bypassing Service command validation。 +- unbounded reader task/queue or dropped diagnostics。 +- tests that require real external network。 +- scope creep into PDK/templates or protocol-specific behavior。 + +Next action: +- `queued -> inprogress` を記録してから worktree-workflow で `/home/hare/Projects/yoi/.worktree/00001KVXK0WE4-plugin-websocket-driver` を作成し、multi-agent-workflow で Coder/Reviewer sibling loop に進める。 + --- From 62e467c0359baab3d2c59deb6a876ee2105afea1 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 15:23:25 +0900 Subject: [PATCH 31/46] ticket: accept plugin websocket driver task --- .yoi/tickets/00001KVXK0WE4/item.md | 4 ++-- .yoi/tickets/00001KVXK0WE4/thread.md | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KVXK0WE4/item.md b/.yoi/tickets/00001KVXK0WE4/item.md index bc0d9593..67f1f70f 100644 --- a/.yoi/tickets/00001KVXK0WE4/item.md +++ b/.yoi/tickets/00001KVXK0WE4/item.md @@ -1,8 +1,8 @@ --- title: 'Add host-owned WebSocket driver for Plugin services' -state: 'queued' +state: 'inprogress' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-25T06:22:18Z' +updated_at: '2026-06-25T06:23:16Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:12:03Z' diff --git a/.yoi/tickets/00001KVXK0WE4/thread.md b/.yoi/tickets/00001KVXK0WE4/thread.md index 81dfca6a..8d34d64b 100644 --- a/.yoi/tickets/00001KVXK0WE4/thread.md +++ b/.yoi/tickets/00001KVXK0WE4/thread.md @@ -111,3 +111,17 @@ Next action: - `queued -> inprogress` を記録してから worktree-workflow で `/home/hare/Projects/yoi/.worktree/00001KVXK0WE4-plugin-websocket-driver` を作成し、multi-agent-workflow で Coder/Reviewer sibling loop に進める。 --- + + + +## State changed + +Orchestrator acceptance: queued -> inprogress + +- 直前確認で `TicketShow` は state `queued`、derived blockers は空。 +- outgoing dependencies `00001KVXK0WDQ` と `00001KVXK0WDX` は done / merged / reviewed / validated 済み。 +- accepted plan `orch-plan-20260625-062148-3` を確認した。 +- routing decision と IntentPacket は Ticket thread に記録済み。 +- これ以降に worktree-workflow で `/home/hare/Projects/yoi/.worktree/00001KVXK0WE4-plugin-websocket-driver` を作成し、multi-agent-workflow に接続する。 + +--- From ef179553698280ecdfa83c58de347e58301c03b8 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 15:24:27 +0900 Subject: [PATCH 32/46] ticket: record plugin websocket coder start --- .yoi/tickets/00001KVXK0WE4/item.md | 2 +- .yoi/tickets/00001KVXK0WE4/thread.md | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVXK0WE4/item.md b/.yoi/tickets/00001KVXK0WE4/item.md index 67f1f70f..a6512b94 100644 --- a/.yoi/tickets/00001KVXK0WE4/item.md +++ b/.yoi/tickets/00001KVXK0WE4/item.md @@ -2,7 +2,7 @@ title: 'Add host-owned WebSocket driver for Plugin services' state: 'inprogress' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-25T06:23:16Z' +updated_at: '2026-06-25T06:24:16Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:12:03Z' diff --git a/.yoi/tickets/00001KVXK0WE4/thread.md b/.yoi/tickets/00001KVXK0WE4/thread.md index 8d34d64b..b8bc5054 100644 --- a/.yoi/tickets/00001KVXK0WE4/thread.md +++ b/.yoi/tickets/00001KVXK0WE4/thread.md @@ -125,3 +125,17 @@ Orchestrator acceptance: queued -> inprogress - これ以降に worktree-workflow で `/home/hare/Projects/yoi/.worktree/00001KVXK0WE4-plugin-websocket-driver` を作成し、multi-agent-workflow に接続する。 --- + + + +## Implementation report + +Implementation orchestration update: + +- prerequisite `00001KVXK0WDX` が done になったため、`00001KVXK0WE4` を再 routing して受理した。 +- worktree-workflow に従い、implementation worktree `/home/hare/Projects/yoi/.worktree/00001KVXK0WE4-plugin-websocket-driver` と branch `work/00001KVXK0WE4-plugin-websocket-driver` を作成した。 +- child worktree では tracked `.yoi` project records を見える状態にし、`.yoi/memory`、local/runtime/log/lock/secret-like `.yoi` paths を sparse-checkout で除外する方針にした。 +- Coder Pod `yoi-coder-00001KVXK0WE4-websocket-driver` を sibling として起動した。write scope は child worktree に限定し、runtime workspace root `/home/hare/Projects/yoi` は startup 要件を満たすため non-recursive read のみにした。 +- Coder には root/original workspace と orchestration worktree で read/write/validation/git operation を行わないよう明示した。 + +--- From f2c51ffe399fc4eaa0ab4e4052867d55e534b229 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 15:54:36 +0900 Subject: [PATCH 33/46] feat: add plugin websocket service driver --- crates/pod/src/feature/plugin.rs | 966 ++++++++++++++++++++++++- docs/development/plugin-development.md | 2 +- 2 files changed, 952 insertions(+), 16 deletions(-) diff --git a/crates/pod/src/feature/plugin.rs b/crates/pod/src/feature/plugin.rs index e7109092..8cf585f0 100644 --- a/crates/pod/src/feature/plugin.rs +++ b/crates/pod/src/feature/plugin.rs @@ -12,7 +12,10 @@ use std::fs; use std::io::{Read as _, Write as _}; use std::net::{IpAddr, SocketAddr, ToSocketAddrs}; use std::path::{Component, Path, PathBuf}; -use std::sync::{Arc, Mutex, OnceLock}; +use std::sync::{ + Arc, Mutex, OnceLock, + atomic::{AtomicBool, Ordering}, +}; use std::time::{Duration, Instant}; use async_trait::async_trait; @@ -35,6 +38,7 @@ use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::protocol::{Message, WebSocketConfig}; const LEGACY_PLUGIN_RUNTIME_WASM_KIND: &str = "wasm"; +const PLUGIN_SERVICE_WEBSOCKET_RECV_TIMEOUT: Duration = Duration::from_millis(250); use super::{ FeatureDescriptor, FeatureId, FeatureInstallContext, FeatureInstallError, FeatureModule, @@ -2440,7 +2444,7 @@ struct PluginRequestResponse { truncated: bool, } -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] struct PluginWebSocketOpenRequest { url: String, @@ -2979,6 +2983,10 @@ pub enum PluginInstanceDiagnosticKind { ServiceOutputCommandRecorded, ServiceOutputCommandRejected, ServiceOutputCommandUnsupported, + ServiceWebSocketConnected, + ServiceWebSocketClosed, + ServiceWebSocketError, + ServiceWebSocketSendFailed, } #[derive(Clone, Debug, PartialEq, Eq, Serialize)] @@ -3015,6 +3023,27 @@ pub struct PluginIngressDispatchCounters { pub timed_out: u64, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub enum PluginServiceWebSocketConnectionState { + Connecting, + Connected, + Closed, + Failed, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct PluginServiceWebSocketConnectionStatus { + pub url: String, + pub ingress_name: String, + pub state: PluginServiceWebSocketConnectionState, + pub last_frame_at: Option, + pub last_error: Option, + pub received_text_frames: u64, + pub sent_text_frames: u64, + pub queue_drops: u64, + pub send_failures: u64, +} + #[derive(Clone, Debug, PartialEq, Serialize)] pub struct PluginInstanceStatus { pub plugin_ref: String, @@ -3027,6 +3056,7 @@ pub struct PluginInstanceStatus { /// Last bounded Service output command outcomes. These are produced only by /// Service/Ingress dispatch and are intentionally separate from ToolOutput. pub output_command_results: Vec, + pub websocket_connections: Vec, pub diagnostics: Vec, } @@ -3321,11 +3351,496 @@ impl PluginInstanceRegistry { } } +#[derive(Clone, Debug)] +struct PluginServiceWebSocketSubscription { + ingress_name: String, + source: String, + url: String, +} + +#[derive(Clone, Default)] +struct PluginServiceWebSocketDriver { + inner: Arc>>, +} + +struct PluginServiceWebSocketConnection { + status: PluginServiceWebSocketConnectionStatus, + connection: Option>>>, + stop: Arc, +} + +impl PluginServiceWebSocketDriver { + fn start_connection( + &self, + handle: PluginInstanceHandle, + client: Arc, + subscription: PluginServiceWebSocketSubscription, + ) { + if self.connection_count() >= PLUGIN_WEBSOCKET_MAX_OPEN_CONNECTIONS { + let message = format!( + "host-owned WebSocket connection limit ({}) exceeded for {}", + PLUGIN_WEBSOCKET_MAX_OPEN_CONNECTIONS, subscription.url + ); + self.insert_failed_status(&subscription, message.clone()); + handle.record_service_websocket_diagnostic( + PluginInstanceDiagnosticKind::ServiceWebSocketError, + message, + true, + ); + return; + } + + let request = PluginWebSocketOpenRequest { + url: subscription.url.clone(), + protocols: Vec::new(), + headers: Vec::new(), + }; + self.insert_status( + &subscription, + PluginServiceWebSocketConnectionState::Connecting, + None, + ); + + let (request, url) = match validate_plugin_service_websocket_open_request(&handle, &request) + { + Ok(value) => value, + Err(message) => { + self.update_status_error( + &subscription.url, + PluginServiceWebSocketConnectionState::Failed, + message.clone(), + ); + handle.record_service_websocket_diagnostic( + PluginInstanceDiagnosticKind::ServiceWebSocketError, + message, + true, + ); + return; + } + }; + if !client.supports_bounded_open() { + let message = "host-owned WebSocket client cannot guarantee bounded/cancellable open; refusing to dial".to_string(); + self.update_status_error( + &subscription.url, + PluginServiceWebSocketConnectionState::Failed, + message.clone(), + ); + handle.record_service_websocket_diagnostic( + PluginInstanceDiagnosticKind::ServiceWebSocketError, + message, + true, + ); + return; + } + let connection = match client.open(&request, &url, PluginWebSocketLimits::default()) { + Ok(connection) => Arc::new(Mutex::new(connection)), + Err(error) => { + let message = format!( + "host-owned WebSocket open failed for {}: {}", + safe_url(&url), + error.0 + ); + self.update_status_error( + &subscription.url, + PluginServiceWebSocketConnectionState::Failed, + message.clone(), + ); + handle.record_service_websocket_diagnostic( + PluginInstanceDiagnosticKind::ServiceWebSocketError, + message, + true, + ); + return; + } + }; + + let stop = Arc::new(AtomicBool::new(false)); + self.attach_connection(&subscription.url, connection.clone(), stop.clone()); + self.update_status_connected(&subscription.url); + handle.record_service_websocket_diagnostic( + PluginInstanceDiagnosticKind::ServiceWebSocketConnected, + format!("host-owned WebSocket connected: {}", safe_url(&url)), + false, + ); + + let driver = self.clone(); + std::thread::spawn(move || { + driver.reader_loop(handle, subscription, connection, stop); + }); + } + + fn reader_loop( + &self, + handle: PluginInstanceHandle, + subscription: PluginServiceWebSocketSubscription, + connection: Arc>>, + stop: Arc, + ) { + while !stop.load(Ordering::SeqCst) { + let recv = { + let mut connection = connection + .lock() + .expect("service websocket connection poisoned"); + connection.recv_text( + PLUGIN_SERVICE_WEBSOCKET_RECV_TIMEOUT, + PLUGIN_WEBSOCKET_MAX_MESSAGE_BYTES, + ) + }; + match recv { + Ok(PluginWebSocketRecvResponse::Text { text }) => { + self.record_frame(&subscription.url); + let event = PluginIngressEvent::new( + subscription.ingress_name.clone(), + "websocket_text", + subscription.source.clone(), + serde_json::json!({ + "url": subscription.url, + "text": text, + }), + ); + if let Err(error) = handle.deliver_ingress(&subscription.ingress_name, event) { + let message = error.bounded_message(); + self.record_queue_drop(&subscription.url, message.clone()); + handle.record_service_websocket_diagnostic( + error.diagnostic_kind(), + format!("host-owned WebSocket ingress drop: {message}"), + true, + ); + } + } + Ok(PluginWebSocketRecvResponse::Closed) => { + let message = "host-owned WebSocket closed by peer".to_string(); + self.update_status_error( + &subscription.url, + PluginServiceWebSocketConnectionState::Closed, + message.clone(), + ); + handle.record_service_websocket_diagnostic( + PluginInstanceDiagnosticKind::ServiceWebSocketClosed, + message.clone(), + false, + ); + let event = PluginIngressEvent::new( + subscription.ingress_name.clone(), + "websocket_close", + subscription.source.clone(), + serde_json::json!({"url": subscription.url, "reason": message}), + ); + let _ = handle.deliver_ingress(&subscription.ingress_name, event); + break; + } + Err(error) if error.0.contains("timed out") => continue, + Err(error) => { + let message = format!("host-owned WebSocket receive failed: {}", error.0); + self.update_status_error( + &subscription.url, + PluginServiceWebSocketConnectionState::Failed, + message.clone(), + ); + handle.record_service_websocket_diagnostic( + PluginInstanceDiagnosticKind::ServiceWebSocketError, + message.clone(), + true, + ); + let event = PluginIngressEvent::new( + subscription.ingress_name.clone(), + "websocket_error", + subscription.source.clone(), + serde_json::json!({"url": subscription.url, "error": message}), + ); + let _ = handle.deliver_ingress(&subscription.ingress_name, event); + break; + } + } + } + } + + fn send_text(&self, url: &str, text: &str) -> Result { + if text.len() > PLUGIN_WEBSOCKET_MAX_TEXT_BYTES { + return Err(format!( + "websocket_send text exceeds {} bytes", + PLUGIN_WEBSOCKET_MAX_TEXT_BYTES + )); + } + let parsed = + reqwest::Url::parse(url).map_err(|error| format!("invalid WebSocket URL: {error}"))?; + let key = parsed.as_str().to_string(); + let (display_url, connection) = { + let guard = self + .inner + .lock() + .expect("service websocket driver poisoned"); + let entry = guard.get(&key).ok_or_else(|| { + format!( + "no host-owned WebSocket connection is active for {}", + safe_url(&parsed) + ) + })?; + let Some(connection) = &entry.connection else { + return Err(format!( + "host-owned WebSocket connection is not connected for {}", + safe_url(&parsed) + )); + }; + (entry.status.url.clone(), connection.clone()) + }; + let send = connection + .lock() + .expect("service websocket connection poisoned") + .send_text(text); + match send { + Ok(()) => { + self.record_send_success(&key); + Ok(format!( + "websocket_send sent {} bytes to {display_url}", + text.len() + )) + } + Err(error) => { + let message = format!("websocket_send failed for {display_url}: {}", error.0); + self.record_send_failure(&key, message.clone()); + Err(message) + } + } + } + + fn statuses(&self) -> Vec { + let mut statuses: Vec<_> = self + .inner + .lock() + .expect("service websocket driver poisoned") + .values() + .map(|entry| entry.status.clone()) + .collect(); + statuses.sort_by(|left, right| left.url.cmp(&right.url)); + statuses + } + + fn stop_all(&self) { + let connections: Vec<_> = { + let mut guard = self + .inner + .lock() + .expect("service websocket driver poisoned"); + guard + .values_mut() + .filter_map(|entry| { + entry.stop.store(true, Ordering::SeqCst); + entry.status.state = PluginServiceWebSocketConnectionState::Closed; + entry.connection.clone() + }) + .collect() + }; + for connection in connections { + if let Ok(mut connection) = connection.lock() { + let _ = connection.close(); + } + } + } + + fn connection_count(&self) -> usize { + self.inner + .lock() + .expect("service websocket driver poisoned") + .len() + } + + fn insert_status( + &self, + subscription: &PluginServiceWebSocketSubscription, + state: PluginServiceWebSocketConnectionState, + error: Option, + ) { + let url = reqwest::Url::parse(&subscription.url) + .map(|url| safe_url(&url)) + .unwrap_or_else(|_| safe_fs_path(&subscription.url)); + self.inner + .lock() + .expect("service websocket driver poisoned") + .insert( + subscription.url.clone(), + PluginServiceWebSocketConnection { + status: PluginServiceWebSocketConnectionStatus { + url, + ingress_name: subscription.ingress_name.clone(), + state, + last_frame_at: None, + last_error: error, + received_text_frames: 0, + sent_text_frames: 0, + queue_drops: 0, + send_failures: 0, + }, + connection: None, + stop: Arc::new(AtomicBool::new(false)), + }, + ); + } + + fn insert_failed_status( + &self, + subscription: &PluginServiceWebSocketSubscription, + error: String, + ) { + self.insert_status( + subscription, + PluginServiceWebSocketConnectionState::Failed, + Some(error), + ); + } + + fn attach_connection( + &self, + key: &str, + connection: Arc>>, + stop: Arc, + ) { + if let Some(entry) = self + .inner + .lock() + .expect("service websocket driver poisoned") + .get_mut(key) + { + entry.connection = Some(connection); + entry.stop = stop; + } + } + + fn update_status_connected(&self, key: &str) { + if let Some(entry) = self + .inner + .lock() + .expect("service websocket driver poisoned") + .get_mut(key) + { + entry.status.state = PluginServiceWebSocketConnectionState::Connected; + entry.status.last_error = None; + } + } + + fn update_status_error( + &self, + key: &str, + state: PluginServiceWebSocketConnectionState, + error: String, + ) { + if let Some(entry) = self + .inner + .lock() + .expect("service websocket driver poisoned") + .get_mut(key) + { + entry.status.state = state; + entry.status.last_error = Some(bounded_message(redact_secret_like(&error))); + } + } + + fn record_frame(&self, key: &str) { + if let Some(entry) = self + .inner + .lock() + .expect("service websocket driver poisoned") + .get_mut(key) + { + entry.status.last_frame_at = + Some(chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)); + entry.status.received_text_frames += 1; + } + } + + fn record_queue_drop(&self, key: &str, error: String) { + if let Some(entry) = self + .inner + .lock() + .expect("service websocket driver poisoned") + .get_mut(key) + { + entry.status.queue_drops += 1; + entry.status.last_error = Some(bounded_message(redact_secret_like(&error))); + } + } + + fn record_send_success(&self, key: &str) { + if let Some(entry) = self + .inner + .lock() + .expect("service websocket driver poisoned") + .get_mut(key) + { + entry.status.sent_text_frames += 1; + } + } + + fn record_send_failure(&self, key: &str, error: String) { + if let Some(entry) = self + .inner + .lock() + .expect("service websocket driver poisoned") + .get_mut(key) + { + entry.status.send_failures += 1; + entry.status.last_error = Some(bounded_message(redact_secret_like(&error))); + } + } +} + +fn validate_plugin_service_websocket_open_request( + handle: &PluginInstanceHandle, + request: &PluginWebSocketOpenRequest, +) -> Result<(PluginWebSocketOpenRequest, reqwest::Url), String> { + let record = { + let instance = handle.0.lock().expect("plugin instance poisoned"); + instance.record.clone() + }; + authorize_plugin_host_api(&record, PluginHostApi::WebSocket) + .map_err(|error| format!("host-owned websocket not granted: {}", error.0))?; + let bytes = serde_json::to_vec(request) + .map_err(|error| format!("failed to encode host-owned websocket open request: {error}"))?; + validate_plugin_websocket_open_request(&record, &bytes).map_err(|error| error.0) +} + +fn parse_plugin_service_websocket_source(source: &str) -> Option> { + let trimmed = source.trim(); + let raw = if let Some(rest) = trimmed.strip_prefix("websocket:") { + rest.trim() + } else if trimmed.starts_with("ws://") || trimmed.starts_with("wss://") { + trimmed + } else { + return None; + }; + if raw.is_empty() { + return Some(Err("websocket source URL is empty".to_string())); + } + let parsed = match reqwest::Url::parse(raw) { + Ok(parsed) => parsed, + Err(error) => return Some(Err(format!("invalid WebSocket URL: {error}"))), + }; + match parsed.scheme() { + "ws" | "wss" => Some(Ok(parsed.as_str().to_string())), + other => Some(Err(format!("unsupported WebSocket URL scheme: {other}"))), + } +} + #[derive(Clone)] pub struct PluginInstanceHandle(Arc>); impl PluginInstanceHandle { fn new(record: ResolvedPluginRecord) -> Result { + Self::new_with_service_websocket_client(record, Arc::new(TungstenitePluginWebSocketClient)) + } + + #[cfg(test)] + fn new_with_test_websocket_client( + record: ResolvedPluginRecord, + service_websocket_client: Arc, + ) -> Result { + Self::new_with_service_websocket_client(record, service_websocket_client) + } + + fn new_with_service_websocket_client( + record: ResolvedPluginRecord, + service_websocket_client: Arc, + ) -> Result { let runtime = PluginInstanceRuntime::new(&record)?; let mut instance = PluginInstance { record, @@ -3337,10 +3852,14 @@ impl PluginInstanceHandle { dispatch_counters: PluginIngressDispatchCounters::default(), last_error: None, output_command_results: Vec::new(), + service_websockets: PluginServiceWebSocketDriver::default(), + service_websocket_client, diagnostics: Vec::new(), }; instance.start()?; - Ok(Self(Arc::new(Mutex::new(instance)))) + let handle = Self(Arc::new(Mutex::new(instance))); + handle.start_service_websockets(); + Ok(handle) } fn handle_tool(&self, tool_name: &str, input: Vec) -> Result { @@ -3378,6 +3897,44 @@ impl PluginInstanceHandle { instance.diagnostics.push(diagnostic); } } + + fn record_service_websocket_diagnostic( + &self, + kind: PluginInstanceDiagnosticKind, + message: impl Into, + mark_error: bool, + ) { + if let Ok(mut instance) = self.0.lock() { + let message = bounded_message(redact_secret_like(&message.into())); + if mark_error { + instance.last_error = Some(message.clone()); + } + let state = instance.lifecycle.clone(); + instance + .diagnostics + .push(PluginInstanceDiagnostic::with_kind(kind, state, message)); + } + } + + fn start_service_websockets(&self) { + let (driver, client, subscriptions, diagnostics) = { + let instance = self.0.lock().expect("plugin instance poisoned"); + let (subscriptions, diagnostics) = instance.service_websocket_subscriptions(); + let driver = instance.service_websockets.clone(); + let client = instance.service_websocket_client.clone(); + (driver, client, subscriptions, diagnostics) + }; + for diagnostic in diagnostics { + self.record_service_websocket_diagnostic( + PluginInstanceDiagnosticKind::ServiceWebSocketError, + diagnostic, + true, + ); + } + for subscription in subscriptions { + driver.start_connection(self.clone(), client.clone(), subscription); + } + } } struct PluginInstance { @@ -3390,6 +3947,8 @@ struct PluginInstance { dispatch_counters: PluginIngressDispatchCounters, last_error: Option, output_command_results: Vec, + service_websockets: PluginServiceWebSocketDriver, + service_websocket_client: Arc, diagnostics: Vec, } @@ -3440,6 +3999,35 @@ impl PluginInstance { Ok(()) } + fn service_websocket_subscriptions( + &self, + ) -> (Vec, Vec) { + if !surface_enabled(&self.record, PluginSurface::Service) + || !surface_enabled(&self.record, PluginSurface::Ingress) + { + return (Vec::new(), Vec::new()); + } + let mut subscriptions = Vec::new(); + let mut diagnostics = Vec::new(); + for ingress in &self.record.manifest.ingresses { + for source in &ingress.sources { + match parse_plugin_service_websocket_source(source) { + None => {} + Some(Ok(url)) => subscriptions.push(PluginServiceWebSocketSubscription { + ingress_name: ingress.name.clone(), + source: source.clone(), + url, + }), + Some(Err(message)) => diagnostics.push(format!( + "invalid WebSocket ingress source for {}: {message}", + ingress.name + )), + } + } + } + (subscriptions, diagnostics) + } + fn handle_tool( &mut self, tool_name: &str, @@ -3726,6 +4314,7 @@ impl PluginInstance { "plugin service stop requested; ingress queue is closed", )); self.ingress_queue.clear(); + self.service_websockets.stop_all(); let stop_result = match &mut self.runtime { PluginInstanceRuntime::ComponentToolAdapter => Ok(()), #[cfg(test)] @@ -3764,6 +4353,7 @@ impl PluginInstance { last_error: self.last_error.clone(), dispatch_counters: self.dispatch_counters.clone(), output_command_results: self.output_command_results.clone(), + websocket_connections: self.service_websockets.statuses(), diagnostics: self.diagnostics.clone(), } } @@ -3952,10 +4542,10 @@ impl PluginInstance { &self, payload: &PluginServiceWebSocketSendCommandPayload, ) -> Result<(), String> { - if payload.text.len() > PLUGIN_WEBSOCKET_MAX_MESSAGE_BYTES { + if payload.text.len() > PLUGIN_WEBSOCKET_MAX_TEXT_BYTES { return Err(format!( "websocket_send text exceeds {} bytes", - PLUGIN_WEBSOCKET_MAX_MESSAGE_BYTES + PLUGIN_WEBSOCKET_MAX_TEXT_BYTES )); } let url = reqwest::Url::parse(&payload.url) @@ -4015,10 +4605,30 @@ impl PluginInstance { ) } PluginServiceOutputCommandKind::WebSocketSend => { - PluginServiceOutputCommandResult::unsupported( - &command, - "websocket_send output command is grant-checked but WebSocket send transport is unsupported in v0", - ) + let payload: PluginServiceWebSocketSendCommandPayload = + match serde_json::from_value(command.payload.clone()) { + Ok(payload) => payload, + Err(error) => { + return PluginServiceOutputCommandResult::rejected_for( + &command, + format!("invalid websocket_send payload: {error}"), + ); + } + }; + match self + .service_websockets + .send_text(&payload.url, &payload.text) + { + Ok(message) => PluginServiceOutputCommandResult::recorded(&command, message), + Err(message) => { + self.diagnostics.push(PluginInstanceDiagnostic::with_kind( + PluginInstanceDiagnosticKind::ServiceWebSocketSendFailed, + self.lifecycle.clone(), + bounded_message(redact_secret_like(&message)), + )); + PluginServiceOutputCommandResult::rejected_for(&command, message) + } + } } } } @@ -5284,6 +5894,123 @@ mod tests { record.grants.websocket.push(target); } + fn add_websocket_ingress_source(record: &mut ResolvedPluginRecord, source: &str) { + let ingress = record + .manifest + .ingresses + .iter_mut() + .find(|ingress| ingress.name == "shared_ingress") + .expect("shared ingress"); + ingress.event_kinds = vec![ + "test".into(), + "websocket_text".into(), + "websocket_close".into(), + "websocket_error".into(), + ]; + ingress.sources.push(source.to_string()); + } + + fn service_websocket_record() -> ResolvedPluginRecord { + let mut record = test_service_ingress_record(); + add_websocket_output_grant(&mut record); + add_websocket_ingress_source(&mut record, "websocket:wss://ws.example.test/events"); + record + } + + #[derive(Clone, Default)] + struct ServiceWebSocketClient { + events: Arc>>>, + sent: Arc>>, + opens: Arc, + send_error: Arc>>, + } + + impl ServiceWebSocketClient { + fn with_events(events: Vec>) -> Self { + Self { + events: Arc::new(Mutex::new(events.into())), + ..Self::default() + } + } + + fn sent(&self) -> Vec { + self.sent.lock().unwrap().clone() + } + + fn fail_sends_with(&self, message: &str) { + *self.send_error.lock().unwrap() = Some(message.to_string()); + } + } + + impl PluginWebSocketClient for ServiceWebSocketClient { + fn supports_bounded_open(&self) -> bool { + true + } + + fn open( + &self, + _request: &PluginWebSocketOpenRequest, + _url: &reqwest::Url, + _limits: PluginWebSocketLimits, + ) -> Result, PluginWebSocketError> { + self.opens.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(Box::new(ServiceWebSocketConnection { + events: self.events.clone(), + sent: self.sent.clone(), + send_error: self.send_error.clone(), + closed: Arc::new(std::sync::atomic::AtomicBool::new(false)), + })) + } + } + + struct ServiceWebSocketConnection { + events: Arc>>>, + sent: Arc>>, + send_error: Arc>>, + closed: Arc, + } + + impl PluginWebSocketConnection for ServiceWebSocketConnection { + fn send_text(&mut self, text: &str) -> Result<(), PluginWebSocketError> { + if let Some(error) = self.send_error.lock().unwrap().clone() { + return Err(PluginWebSocketError::new(error)); + } + self.sent.lock().unwrap().push(text.to_string()); + Ok(()) + } + + fn recv_text( + &mut self, + timeout: Duration, + _max_message_bytes: usize, + ) -> Result { + if self.closed.load(std::sync::atomic::Ordering::SeqCst) { + return Ok(PluginWebSocketRecvResponse::Closed); + } + if let Some(event) = self.events.lock().unwrap().pop_front() { + return event.map_err(PluginWebSocketError::new); + } + std::thread::sleep(timeout.min(Duration::from_millis(10))); + Err(PluginWebSocketError::new("receive timed out")) + } + + fn close(&mut self) -> Result<(), PluginWebSocketError> { + self.closed.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(()) + } + } + + fn wait_until(mut condition: impl FnMut() -> bool) { + let deadline = std::time::Instant::now() + Duration::from_secs(2); + while std::time::Instant::now() < deadline { + if condition() { + return; + } + std::thread::sleep(Duration::from_millis(10)); + } + assert!(condition(), "condition did not become true before timeout"); + } + #[test] fn service_selected_ignores_unselected_tool_without_grants() { let mut record = record(vec![tool("hidden_tool")]); @@ -5604,7 +6331,8 @@ mod tests { } #[test] - fn service_output_command_placeholders_are_grant_checked_and_unsupported() { + fn service_output_commands_are_grant_checked_and_supported_transport_rejects_without_connection() + { let mut record = test_service_ingress_record(); add_request_output_grant(&mut record); add_websocket_output_grant(&mut record); @@ -5633,20 +6361,226 @@ mod tests { let report = handle.deliver_ingress("shared_ingress", event).unwrap(); assert_eq!(report.output_command_results.len(), 2); - assert!( - report - .output_command_results - .iter() - .all(|result| { result.status == PluginServiceOutputCommandStatus::Unsupported }) + let request_result = report + .output_command_results + .iter() + .find(|result| result.command_id.as_deref() == Some("cmd-request")) + .unwrap(); + let websocket_result = report + .output_command_results + .iter() + .find(|result| result.command_id.as_deref() == Some("cmd-websocket")) + .unwrap(); + assert_eq!( + request_result.status, + PluginServiceOutputCommandStatus::Unsupported + ); + assert_eq!( + websocket_result.status, + PluginServiceOutputCommandStatus::Rejected ); let status = handle.status(); assert_eq!(status.output_command_results.len(), 2); assert!(status.diagnostics.iter().any(|diagnostic| { diagnostic.kind == PluginInstanceDiagnosticKind::ServiceOutputCommandUnsupported + && diagnostic.message.contains("cmd-request") + })); + assert!(status.diagnostics.iter().any(|diagnostic| { + diagnostic.kind == PluginInstanceDiagnosticKind::ServiceOutputCommandRejected && diagnostic.message.contains("cmd-websocket") })); } + #[test] + fn service_websocket_driver_enqueues_incoming_text_and_reports_close() { + let client = ServiceWebSocketClient::with_events(vec![ + Ok(PluginWebSocketRecvResponse::Text { + text: "hello service".into(), + }), + Ok(PluginWebSocketRecvResponse::Closed), + ]); + let handle = PluginInstanceHandle::new_with_test_websocket_client( + service_websocket_record(), + Arc::new(client.clone()), + ) + .unwrap(); + + wait_until(|| handle.status().dispatch_counters.dispatched >= 1); + let status = handle.status(); + assert!(status.dispatch_counters.dispatched >= 1); + assert_eq!(status.websocket_connections.len(), 1); + let connection = &status.websocket_connections[0]; + assert_eq!(connection.received_text_frames, 1); + assert!(connection.last_frame_at.is_some()); + assert_eq!(connection.queue_drops, 0); + assert!(status.diagnostics.iter().any(|diagnostic| { + diagnostic.kind == PluginInstanceDiagnosticKind::ServiceWebSocketClosed + })); + handle.stop().unwrap(); + } + + #[test] + fn websocket_send_output_command_sends_on_host_owned_connection() { + let client = ServiceWebSocketClient::default(); + let handle = PluginInstanceHandle::new_with_test_websocket_client( + service_websocket_record(), + Arc::new(client.clone()), + ) + .unwrap(); + wait_until(|| { + handle + .status() + .websocket_connections + .iter() + .any(|connection| { + connection.state == PluginServiceWebSocketConnectionState::Connected + }) + }); + let event = test_ingress_event("shared_ingress", json!({})); + let command_value = service_output_command( + &event, + "cmd-websocket", + "websocket_send", + json!({"url": "wss://ws.example.test/events", "text": "pong"}), + ); + let command: PluginServiceOutputCommandEnvelope = + serde_json::from_value(command_value).unwrap(); + + let result = handle + .0 + .lock() + .unwrap() + .execute_service_output_command(command); + + assert_eq!(result.status, PluginServiceOutputCommandStatus::Recorded); + assert_eq!(client.sent(), vec!["pong".to_string()]); + let status = handle.status(); + assert_eq!(status.websocket_connections[0].sent_text_frames, 1); + handle.stop().unwrap(); + } + + #[test] + fn websocket_send_output_command_records_send_failure_diagnostic() { + let client = ServiceWebSocketClient::default(); + client.fail_sends_with("transport write failed"); + let handle = PluginInstanceHandle::new_with_test_websocket_client( + service_websocket_record(), + Arc::new(client), + ) + .unwrap(); + wait_until(|| { + handle + .status() + .websocket_connections + .iter() + .any(|connection| { + connection.state == PluginServiceWebSocketConnectionState::Connected + }) + }); + let event = test_ingress_event("shared_ingress", json!({})); + let command: PluginServiceOutputCommandEnvelope = + serde_json::from_value(service_output_command( + &event, + "cmd-websocket-fail", + "websocket_send", + json!({"url": "wss://ws.example.test/events", "text": "pong"}), + )) + .unwrap(); + + let result = handle + .0 + .lock() + .unwrap() + .execute_service_output_command(command); + + assert_eq!(result.status, PluginServiceOutputCommandStatus::Rejected); + let status = handle.status(); + assert_eq!(status.websocket_connections[0].send_failures, 1); + assert!(status.diagnostics.iter().any(|diagnostic| { + diagnostic.kind == PluginInstanceDiagnosticKind::ServiceWebSocketSendFailed + && diagnostic.message.contains("transport write failed") + })); + handle.stop().unwrap(); + } + + #[test] + fn websocket_send_rejects_unauthorized_target_before_execution() { + let mut record = service_websocket_record(); + let handle = PluginInstanceHandle::new_with_test_websocket_client( + record.clone(), + Arc::new(ServiceWebSocketClient::default()), + ) + .unwrap(); + let event = test_ingress_event("shared_ingress", json!({})); + let command: PluginServiceOutputCommandEnvelope = + serde_json::from_value(service_output_command( + &event, + "cmd-websocket-denied", + "websocket_send", + json!({"url": "wss://ws.example.test/private", "text": "nope"}), + )) + .unwrap(); + + let error = handle + .0 + .lock() + .unwrap() + .validate_service_output_command_envelope(&command, &event) + .unwrap_err(); + + assert!( + error.contains("websocket_send target denied") + || error.contains("not declared by the plugin manifest"), + "{error}" + ); + assert_eq!(handle.status().websocket_connections[0].sent_text_frames, 0); + record.grants.permissions.retain(|permission| { + *permission != PluginPermission::host_api(PluginHostApi::WebSocket) + }); + let denied_handle = PluginInstanceHandle::new_with_test_websocket_client( + record, + Arc::new(ServiceWebSocketClient::default()), + ) + .unwrap(); + wait_until(|| !denied_handle.status().diagnostics.is_empty()); + assert!(denied_handle.status().diagnostics.iter().any(|diagnostic| { + diagnostic.kind == PluginInstanceDiagnosticKind::ServiceWebSocketError + && diagnostic.message.contains("not granted") + })); + handle.stop().unwrap(); + denied_handle.stop().unwrap(); + } + + #[test] + fn service_websocket_driver_reports_receive_error_as_diagnostic() { + let client = ServiceWebSocketClient::with_events(vec![Err( + "binary frames are not supported by host-owned service websocket".into(), + )]); + let handle = PluginInstanceHandle::new_with_test_websocket_client( + service_websocket_record(), + Arc::new(client), + ) + .unwrap(); + + wait_until(|| { + handle.status().websocket_connections[0].state + == PluginServiceWebSocketConnectionState::Failed + }); + let status = handle.status(); + assert!(status.diagnostics.iter().any(|diagnostic| { + diagnostic.kind == PluginInstanceDiagnosticKind::ServiceWebSocketError + && diagnostic.message.contains("binary frames") + })); + assert!( + status.websocket_connections[0] + .last_error + .as_deref() + .unwrap() + .contains("binary frames") + ); + handle.stop().unwrap(); + } + #[test] fn service_output_command_rejects_malformed_envelope() { let handle = PluginInstanceHandle::new(test_service_ingress_record()).unwrap(); @@ -5752,6 +6686,8 @@ mod tests { dispatch_counters: PluginIngressDispatchCounters::default(), last_error: None, output_command_results: Vec::new(), + service_websockets: PluginServiceWebSocketDriver::default(), + service_websocket_client: Arc::new(TungstenitePluginWebSocketClient), diagnostics: Vec::new(), }))); diff --git a/docs/development/plugin-development.md b/docs/development/plugin-development.md index 75df3828..881ff48e 100644 --- a/docs/development/plugin-development.md +++ b/docs/development/plugin-development.md @@ -329,7 +329,7 @@ Yoi checks method, scheme, host, optional port, and path prefix against both the ## `websocket` host API -The `websocket` host API is a separate grant-gated capability named `host_api.websocket`, not an extension of `host_api.request`. It opens host-owned WebSocket connections only when both the package manifest and enablement config declare matching targets. Plugin code drives the lifecycle explicitly through `open`, `send-text`, `recv`, and `close`; incoming messages are returned only from bounded `recv` calls and are not injected into model context, history, Dashboard state, or Ticket state. +The `websocket` host API is a separate grant-gated capability named `host_api.websocket`, not an extension of `host_api.request`. It opens host-owned WebSocket connections only when both the package manifest and enablement config declare matching targets. Tool-style/internal bounded use can still drive the lifecycle explicitly through `open`, `send-text`, `recv`, and `close`; incoming messages are returned only from bounded `recv` calls and are not injected into model context, history, Dashboard state, or Ticket state. Service Plugins should prefer the host-owned Service WebSocket driver instead of running a long-lived guest recv loop: declare a Service ingress source as `websocket:wss://host/path`, include the `websocket_text`/`websocket_close`/`websocket_error` event kinds you want delivered, and emit the Service output command `websocket_send` to send text back through the same grant-checked host connection. Example manifest shape: From 3a9ac1b1b76111f473ab1cd95db43f9119438d66 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 15:56:44 +0900 Subject: [PATCH 34/46] ticket: record plugin websocket implementation report --- .yoi/tickets/00001KVXK0WE4/item.md | 2 +- .yoi/tickets/00001KVXK0WE4/thread.md | 43 ++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVXK0WE4/item.md b/.yoi/tickets/00001KVXK0WE4/item.md index a6512b94..81efc150 100644 --- a/.yoi/tickets/00001KVXK0WE4/item.md +++ b/.yoi/tickets/00001KVXK0WE4/item.md @@ -2,7 +2,7 @@ title: 'Add host-owned WebSocket driver for Plugin services' state: 'inprogress' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-25T06:24:16Z' +updated_at: '2026-06-25T06:56:37Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:12:03Z' diff --git a/.yoi/tickets/00001KVXK0WE4/thread.md b/.yoi/tickets/00001KVXK0WE4/thread.md index b8bc5054..a974f36b 100644 --- a/.yoi/tickets/00001KVXK0WE4/thread.md +++ b/.yoi/tickets/00001KVXK0WE4/thread.md @@ -139,3 +139,46 @@ Implementation orchestration update: - Coder には root/original workspace と orchestration worktree で read/write/validation/git operation を行わないよう明示した。 --- + + + +## Implementation report + +Implementation report: + +- Ticket: `00001KVXK0WE4` +- Branch/worktree: `work/00001KVXK0WE4-plugin-websocket-driver` / `/home/hare/Projects/yoi/.worktree/00001KVXK0WE4-plugin-websocket-driver` +- Commit: `f2c51ffe399fc4eaa0ab4e4052867d55e534b229` +- Commit message: `feat: add plugin websocket service driver` + +変更ファイル: +- `crates/pod/src/feature/plugin.rs` + - Service Plugin 用の host-owned WebSocket driver を追加。 + - `ingress.sources` の `websocket:wss://...` / `ws(s)://...` を Service WebSocket 接続として扱う実装を追加。 + - incoming text frame を既存の bounded Service ingress queue へ `websocket_text` event として enqueue。 + - close/error を `websocket_close` / `websocket_error` ingress event と status diagnostic に反映。 + - `websocket_send` Service output command を host-owned connection 経由の text send に接続。 + - manifest declaration + enabled grant / allowlist を通らない open/send は fail-closed。 + - status に connection state、last frame time、last error、received/sent count、queue drops、send failures を追加。 + - driver lifecycle / incoming / send success / send rejection / send failure / close-error diagnostics / binary-like frame error の focused tests を追加。 +- `docs/development/plugin-development.md` + - Service Plugin の推奨 WebSocket integration を、guest long-lived recv loop ではなく host-owned Service WebSocket driver + `websocket_send` output command に更新。 + +検証結果: +- `cargo test -p pod --lib websocket_`: success (`14 passed`) +- `cargo test -p pod --lib service_output_command`: success (`4 passed`) +- `cargo check -p pod`: success +- `cargo check -p yoi`: success +- `git diff --check`: success +- `nix build .#yoi --no-link`: success + - Nix dirty warning は worktree の ignored `target/` によるもので、tracked changes はなし。 +- `cargo test -p pod`: failed only in known prompt guidance snapshot tests outside this Ticket (`438 passed; 2 failed`) + - `prompt::catalog::tests::pod_orchestration_guidance_section_renders_resource_body` + - `prompt::system::tests::pod_orchestration_guidance_is_included_for_pod_management_tools` + +Residual risks / notes: +- v0 は自動 reconnect/resume policy を実装せず、close/error は diagnostic/event 化に留めている。 +- Service WebSocket source の manifest 表現は既存 `ingress.sources` の文字列規約として追加。WIT/PDK/templates 更新は Ticket `00001KVXK0WEA` 側に残している。 +- 実ネットワーク test は non-goal のため、mock WebSocket client/connection による bounded focused tests で検証している。 + +--- From ccd4d83d43d21db4b53757604228be2e3a6baf61 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 16:03:57 +0900 Subject: [PATCH 35/46] ticket: approve plugin websocket driver --- .yoi/tickets/00001KVXK0WE4/item.md | 2 +- .yoi/tickets/00001KVXK0WE4/thread.md | 37 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVXK0WE4/item.md b/.yoi/tickets/00001KVXK0WE4/item.md index 81efc150..b42e448e 100644 --- a/.yoi/tickets/00001KVXK0WE4/item.md +++ b/.yoi/tickets/00001KVXK0WE4/item.md @@ -2,7 +2,7 @@ title: 'Add host-owned WebSocket driver for Plugin services' state: 'inprogress' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-25T06:56:37Z' +updated_at: '2026-06-25T07:03:47Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:12:03Z' diff --git a/.yoi/tickets/00001KVXK0WE4/thread.md b/.yoi/tickets/00001KVXK0WE4/thread.md index a974f36b..aa4e27e8 100644 --- a/.yoi/tickets/00001KVXK0WE4/thread.md +++ b/.yoi/tickets/00001KVXK0WE4/thread.md @@ -182,3 +182,40 @@ Residual risks / notes: - 実ネットワーク test は non-goal のため、mock WebSocket client/connection による bounded focused tests で検証している。 --- + + + +## Review: approve + +Review result: approve + +読み取り専用で Ticket / IntentPacket / `62e467c0..HEAD` diff を確認した。Ticket review 記録・merge・close・検証コマンド実行は reviewer では行っていない。 + +確認した証拠: +- 変更範囲は `crates/pod/src/feature/plugin.rs` と `docs/development/plugin-development.md` のみで、WIT / PDK / templates へのスコープ拡大はない。 +- Host-owned Service WebSocket driver: + - Service/Ingress 有効時に `ingress.sources` から `websocket:` / `ws(s)://` source を subscription 化している。 + - instance start 後に host 側で connection start し、reader thread を生成している。 +- authority / fail-closed: + - open は `host_api.websocket` permission と manifest/grant allowlist を通した既存 validation を再利用している。 + - `websocket_send` は Service output command envelope の validation/grant check 経由でのみ実行され、URL scheme/credentials/static target/manifest+grant allowlist を検査している。 +- ingress queue / diagnostics: + - text frame は `websocket_text` event として既存 `deliver_ingress` path に渡され、失敗時は queue drop と diagnostic を記録する。 + - close/error は `websocket_close` / `websocket_error` event と status diagnostic に反映される。 + - status には connection state, last frame, last error, received/sent counts, queue drops, send failures が出る。 +- output command send: + - `PluginServiceOutputCommandKind::WebSocketSend` が追加され、実行時は host-owned connection の `send_text` に接続され、send failure diagnostic も記録する。 +- docs: + - `recv(timeout)` は bounded/internal/tool-style use として残しつつ、Service Plugin では host-owned Service WebSocket driver + `websocket_send` を推奨する文面に更新されている。 +- tests: + - mock/fake client による incoming text/close、send success/failure、unauthorized target、receive error/binary相当 diagnostic のテストが追加されている。 + +検証状況: +- reviewer は cargo/nix 等を実行していない(read-only / no writes 指示のため)。 +- diff・対象コード・Ticket acceptance を静的確認し、coder 報告の `cargo check` / focused tests / nix build 結果を前提証跡として扱った。 + +Non-blocking notes: +- `docs/development/plugin-development.md` の “Current status” 付近にはまだ Service/Ingress/WebSocket を future work と読む余地が残っているが、本 Ticket の受け入れ条件である「pull recv を recommended service path にしない」は満たしている。後続の docs/PDK/templates Ticket で整理するとよい。 +- close/error event の enqueue 失敗は text frame のような queue-drop counter には載せていないが、status diagnostic は記録されるため今回の acceptance 上は blocker ではない。 + +--- From 16247ce7c52445a66c7b2c20e723f553d3b442b8 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 16:06:42 +0900 Subject: [PATCH 36/46] ticket: mark plugin websocket driver done --- .yoi/tickets/00001KVXK0WE4/item.md | 4 +- .yoi/tickets/00001KVXK0WE4/thread.md | 67 ++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KVXK0WE4/item.md b/.yoi/tickets/00001KVXK0WE4/item.md index b42e448e..ef339720 100644 --- a/.yoi/tickets/00001KVXK0WE4/item.md +++ b/.yoi/tickets/00001KVXK0WE4/item.md @@ -1,8 +1,8 @@ --- title: 'Add host-owned WebSocket driver for Plugin services' -state: 'inprogress' +state: 'done' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-25T07:03:47Z' +updated_at: '2026-06-25T07:06:30Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:12:03Z' diff --git a/.yoi/tickets/00001KVXK0WE4/thread.md b/.yoi/tickets/00001KVXK0WE4/thread.md index aa4e27e8..5d8b7e79 100644 --- a/.yoi/tickets/00001KVXK0WE4/thread.md +++ b/.yoi/tickets/00001KVXK0WE4/thread.md @@ -219,3 +219,70 @@ Non-blocking notes: - close/error event の enqueue 失敗は text frame のような queue-drop counter には載せていないが、status diagnostic は記録されるため今回の acceptance 上は blocker ではない。 --- + + + +## Implementation report + +Integration outcome: + +- Coder `yoi-coder-00001KVXK0WE4-websocket-driver` が implementation branch `work/00001KVXK0WE4-plugin-websocket-driver` に実装を commit した。 + - implementation commit: `f2c51ffe feat: add plugin websocket service driver` +- Reviewer `yoi-reviewer-00001KVXK0WE4-websocket-driver` は read-only review で `approve`。host-owned connection authority、manifest/grant allowlist、ingress queue integration、`websocket_send` command validation、status diagnostics、scope creep なしを確認した。 +- Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration` で branch `work/00001KVXK0WE4-plugin-websocket-driver` を merge 済み。 + - merge commit: `b9e786e1 merge: 00001KVXK0WE4 plugin websocket driver` + +Implemented behavior: +- `crates/pod/src/feature/plugin.rs` に Service Plugin 用 host-owned WebSocket driver を追加。 +- `ingress.sources` の `websocket:wss://...` / `ws(s)://...` source を Service WebSocket connection として扱う。 +- incoming text frame を existing bounded Service ingress queue へ `websocket_text` event として enqueue。 +- close/error は `websocket_close` / `websocket_error` event と status diagnostic に反映。 +- `websocket_send` Service output command は host-owned connection 経由の text send に接続。 +- manifest declaration + enabled grant / allowlist を通らない open/send は fail-closed。 +- status に connection state, last frame time, last error, received/sent counts, queue drops, send failures を追加。 +- `docs/development/plugin-development.md` の Service Plugin 推奨 WebSocket integration を host-owned driver + `websocket_send` output command に更新。 + +Validation in Orchestrator worktree: +- `cargo test -p pod --lib websocket_`: success +- `cargo test -p pod --lib service_output_command`: success +- `cargo check -p pod`: success +- `cargo check -p yoi`: success +- `git diff --check`: success +- `nix build .#yoi --no-link`: success +- `cargo test -p pod`: failed only in known prompt guidance snapshot assertions outside this Plugin diff: + - `prompt::catalog::tests::pod_orchestration_guidance_section_renders_resource_body` + - `prompt::system::tests::pod_orchestration_guidance_is_included_for_pod_management_tools` + - Plugin/WebSocket focused tests in the same run passed. + +Notes: +- Reviewer non-blocking note: `docs/development/plugin-development.md` の “Current status” 付近にはまだ Service/Ingress/WebSocket を future work と読む余地が残る。後続 `00001KVXK0WEA` の docs/PDK/templates update で整理するのがよい。 +- Reviewer non-blocking note: close/error event enqueue failure は text frame と同じ queue-drop counter には載らないが、status diagnostic は記録されるため blocker ではない。 +- Follow-up `00001KVXK0WEA` is dependency-unblocked and can be re-routed next. + +Next action: +- Mark Ticket done after this integration/validation evidence. +- Then stop related child Pods and remove only the child implementation worktree/branch. + +--- + + + +## State changed + +Implementation is merged into the orchestration branch and validated with one unrelated known full-suite caveat. + +Evidence: +- merge commit: `b9e786e1 merge: 00001KVXK0WE4 plugin websocket driver` +- reviewer result: approve +- validation in `/home/hare/Projects/yoi/.worktree/orchestration`: + - `cargo test -p pod --lib websocket_`: success + - `cargo test -p pod --lib service_output_command`: success + - `cargo check -p pod`: success + - `cargo check -p yoi`: success + - `git diff --check`: success + - `nix build .#yoi --no-link`: success + - `cargo test -p pod`: failed only in prompt guidance snapshot assertions outside this Plugin diff; Plugin/WebSocket-focused tests passed. + +Closure is not performed here; this state records implementation completion after merge/review/focused validation. + +--- From a453c6e2da13e1dbbbc9077fe55b5f96cce6d3dd Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 16:08:34 +0900 Subject: [PATCH 37/46] ticket: route plugin pdk service events task --- .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KVXK0WEA/item.md | 2 +- .yoi/tickets/00001KVXK0WEA/thread.md | 76 +++++++++++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVXK0WEA/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KVXK0WEA/artifacts/orchestration-plan.jsonl index af9a6809..cce5a7c5 100644 --- a/.yoi/tickets/00001KVXK0WEA/artifacts/orchestration-plan.jsonl +++ b/.yoi/tickets/00001KVXK0WEA/artifacts/orchestration-plan.jsonl @@ -1 +1,2 @@ {"id":"orch-plan-20260624-201335-1","ticket_id":"00001KVXK0WEA","kind":"blocked_by","related_ticket":"00001KVXK0WE4","note":"Queue review: `00001KVXK0WEA` は WIT/PDK/templates finishing slice で、implemented WebSocket event/command model `00001KVXK0WE4` に depends_on している。prerequisite completion 後に再 routing する。","author":"yoi-orchestrator","at":"2026-06-24T20:13:35Z"} +{"id":"orch-plan-20260625-070739-2","ticket_id":"00001KVXK0WEA","kind":"accepted_plan","accepted_plan":{"summary":"Ticket `00001KVXK0WEA` は prerequisite `00001KVXK0WE4` が done になったため implementation_ready。専用 worktree `/home/hare/Projects/yoi/.worktree/00001KVXK0WEA-plugin-pdk-service-events` と branch `work/00001KVXK0WEA-plugin-pdk-service-events` で、Plugin WIT / Rust PDK / embedded templates / docs を Component Model-only Service ingress event + output command + host-owned WebSocket model に合わせて更新する。Runtime実装の再設計や protocol-specific integration は non-goal。","branch":"work/00001KVXK0WEA-plugin-pdk-service-events","worktree":"/home/hare/Projects/yoi/.worktree/00001KVXK0WEA-plugin-pdk-service-events","role_plan":"Orchestrator: accept/routing, worktree creation, final integration/validation/cleanup. Coder: update WIT/PDK/templates/docs/authoring CLI fixtures in dedicated child worktree. Reviewer: read-only review focusing on PDK/template consistency with implemented runtime, no legacy raw-wasm compatibility, and no runtime reimplementation."},"author":"yoi-orchestrator","at":"2026-06-25T07:07:39Z"} diff --git a/.yoi/tickets/00001KVXK0WEA/item.md b/.yoi/tickets/00001KVXK0WEA/item.md index 6f495bfe..fa30af76 100644 --- a/.yoi/tickets/00001KVXK0WEA/item.md +++ b/.yoi/tickets/00001KVXK0WEA/item.md @@ -2,7 +2,7 @@ title: 'Update Plugin WIT PDK templates for service event runtime' state: 'queued' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-24T20:13:35Z' +updated_at: '2026-06-25T07:08:09Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:12:05Z' diff --git a/.yoi/tickets/00001KVXK0WEA/thread.md b/.yoi/tickets/00001KVXK0WEA/thread.md index 3abe110d..a0a0f3c9 100644 --- a/.yoi/tickets/00001KVXK0WEA/thread.md +++ b/.yoi/tickets/00001KVXK0WEA/thread.md @@ -30,4 +30,80 @@ Marked ready by `yoi ticket state`. Ticket を `workspace-panel` が queued にしました。 +--- + + + +## Decision + +Routing decision: implementation_ready + +Reason: +- ユーザーから「続けて」と明示 follow-up があり、queued dependency chain の最終 Ticket として再確認した。 +- `00001KVXK0WEA` は WIT / PDK / templates / docs を、既に実装済みの Component Model-only runtime、Service lifecycle / ingress queue、Service output commands、host-owned WebSocket driver に合わせる finishing slice である。 +- outgoing `depends_on` は `00001KVXK0WE4` だが、`00001KVXK0WE4` は done / merged / reviewed / validated 済み。`TicketShow` derived blockers は空で、implementation acceptance blocker は残っていない。 +- bounded context check で `resources/plugin/wit/*`, `crates/plugin-pdk`, `resources/plugin/templates/rust-component-tool`, `crates/yoi/src/plugin_cli.rs`, `docs/development/plugin-development.md` 周辺に authoring-facing surfaces があることを確認した。Ticket は runtime 実装ではなく authoring surface alignment に限定されており、残る不確実性は local implementation / fixture update に閉じる。 + +Evidence checked: +- Ticket body / thread: `item.md`, `thread.md`。未解決 planning question は記録されていない。 +- Relations / orchestration plan: outgoing depends_on `00001KVXK0WE4` は done。routing 前 plan は historical blocked_by `00001KVXK0WE4` のみで、prerequisite 完了により解消済み。accepted plan `orch-plan-20260625-070739-2` を記録済み。 +- Related Tickets: `00001KVXK0WD3`, `00001KVXK0WDH`, `00001KVXK0WDQ`, `00001KVXK0WDX`, `00001KVXK0WE4` are done. +- Code/docs context: `resources/plugin/wit/*.wit`, `crates/plugin-pdk`, `resources/plugin/templates/rust-component-tool`, `crates/yoi/src/plugin_cli.rs`, `docs/development/plugin-development.md`。 +- Workspace state: `/home/hare/Projects/yoi/.worktree/orchestration` は clean。inprogress Ticket は 0 件。 + +IntentPacket: + +Intent: +- Plugin authoring surface (WIT, Rust PDK, embedded templates, docs, `yoi plugin new/check/pack` fixtures) を、Component Model-only runtime と Service ingress event / output command / host-owned WebSocket model に合わせて更新する。 + +Binding decisions / invariants: +- `plugin.toml` template は `wasm-component` runtime のみを生成する。 +- Service/WebSocket authoring pattern は polling `recv(timeout)` loop ではなく、ingress event handler + output command (`websocket_send`) を正とする。 +- Tool Plugin authoring support must continue to work. +- Runtime implementation from previous Tickets is not redesigned here; this Ticket updates WIT/PDK/templates/docs/tests to match it. +- No raw core-Wasm compatibility template or legacy runtime alias is introduced. +- No protocol-specific Discord/Slack integration, secret store/auth injection, or full reconnect policy is implemented. + +Requirements / acceptance criteria: +- WIT expresses Service ingress event payloads and output command model enough for authoring/tests. +- `yoi-plugin-pdk` exposes ergonomic Tool and Service/Ingress helpers aligned with runtime JSON envelopes. +- Embedded templates include current Tool template and a service-oriented template or equivalent examples using ingress event / output command pattern. +- `yoi plugin new` / `check` / `pack` are consistent with new templates/schema. +- Docs no longer recommend long-running `recv(timeout)` loop for Service WebSocket integration. +- Tests cover PDK helpers, template generation/check/pack, and docs/manifest fixture consistency. + +Implementation latitude: +- Exact WIT/interface names and Rust PDK helper APIs may follow existing PDK style, as long as runtime envelopes and docs are consistent. +- If a full new `plugin new` template name is too large, coder may add minimal service template/example plus CLI support needed to satisfy acceptance, but must keep scope bounded. +- Template wasm build/check may use existing test helpers and temporary target dirs. + +Escalate if: +- WIT/PDK update requires changing runtime JSON envelope semantics from previous Tickets. +- `yoi plugin new/check/pack` requires broad CLI redesign. +- Real WebSocket network/protocol integration or secret handling is needed. +- Legacy raw-WASM compatibility has to be restored for templates/tests. + +Validation: +- `cargo test -p yoi-plugin-pdk` +- `cargo test -p yoi plugin` or focused plugin CLI/template tests +- `cargo check -p yoi` +- `git diff --check` +- `nix build .#yoi --no-link` +- Template cargo-check if applicable, with cleanup of generated template artifacts. + +Current code/docs map: +- Primary: `resources/plugin/wit/*.wit`, `crates/plugin-pdk`, `resources/plugin/templates/rust-component-tool`, possible new template under `resources/plugin/templates/`, `crates/yoi/src/plugin_cli.rs`, `docs/development/plugin-development.md`。 +- Secondary: manifest tests/fixtures only as needed. +- Avoid: Pod runtime reimplementation, WebSocket driver changes unless minor doc/test alignment, protocol-specific integrations, secret store/auth injection, raw-WASM compatibility。 + +Critical risks / reviewer focus: +- PDK/template API drift from runtime JSON envelopes。 +- Tool template regression while adding Service support。 +- reintroducing `recv(timeout)` as recommended Service pattern。 +- template generation/check/pack writing outside destination or leaving build artifacts。 +- accidental legacy raw-WASM runtime compatibility in examples。 + +Next action: +- `queued -> inprogress` を記録してから worktree-workflow で `/home/hare/Projects/yoi/.worktree/00001KVXK0WEA-plugin-pdk-service-events` を作成し、multi-agent-workflow で Coder/Reviewer sibling loop に進める。 + --- From cd2006305e76578469684dc30cfbc79826a63ace Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 16:08:53 +0900 Subject: [PATCH 38/46] ticket: accept plugin pdk service events task --- .yoi/tickets/00001KVXK0WEA/item.md | 4 ++-- .yoi/tickets/00001KVXK0WEA/thread.md | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KVXK0WEA/item.md b/.yoi/tickets/00001KVXK0WEA/item.md index fa30af76..e63b34d8 100644 --- a/.yoi/tickets/00001KVXK0WEA/item.md +++ b/.yoi/tickets/00001KVXK0WEA/item.md @@ -1,8 +1,8 @@ --- title: 'Update Plugin WIT PDK templates for service event runtime' -state: 'queued' +state: 'inprogress' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-25T07:08:09Z' +updated_at: '2026-06-25T07:08:46Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:12:05Z' diff --git a/.yoi/tickets/00001KVXK0WEA/thread.md b/.yoi/tickets/00001KVXK0WEA/thread.md index a0a0f3c9..043c5d60 100644 --- a/.yoi/tickets/00001KVXK0WEA/thread.md +++ b/.yoi/tickets/00001KVXK0WEA/thread.md @@ -107,3 +107,17 @@ Next action: - `queued -> inprogress` を記録してから worktree-workflow で `/home/hare/Projects/yoi/.worktree/00001KVXK0WEA-plugin-pdk-service-events` を作成し、multi-agent-workflow で Coder/Reviewer sibling loop に進める。 --- + + + +## State changed + +Orchestrator acceptance: queued -> inprogress + +- 直前確認で `TicketShow` は state `queued`、derived blockers は空。 +- outgoing dependency `00001KVXK0WE4` は done / merged / reviewed / validated 済み。 +- accepted plan `orch-plan-20260625-070739-2` を確認した。 +- routing decision と IntentPacket は Ticket thread に記録済み。 +- これ以降に worktree-workflow で `/home/hare/Projects/yoi/.worktree/00001KVXK0WEA-plugin-pdk-service-events` を作成し、multi-agent-workflow に接続する。 + +--- From 103077dfae733e9bccfe5d3cd142de7854f9290b Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 16:09:47 +0900 Subject: [PATCH 39/46] ticket: record plugin pdk coder start --- .yoi/tickets/00001KVXK0WEA/item.md | 2 +- .yoi/tickets/00001KVXK0WEA/thread.md | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVXK0WEA/item.md b/.yoi/tickets/00001KVXK0WEA/item.md index e63b34d8..05cd6f3c 100644 --- a/.yoi/tickets/00001KVXK0WEA/item.md +++ b/.yoi/tickets/00001KVXK0WEA/item.md @@ -2,7 +2,7 @@ title: 'Update Plugin WIT PDK templates for service event runtime' state: 'inprogress' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-25T07:08:46Z' +updated_at: '2026-06-25T07:09:39Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:12:05Z' diff --git a/.yoi/tickets/00001KVXK0WEA/thread.md b/.yoi/tickets/00001KVXK0WEA/thread.md index 043c5d60..16ae08ba 100644 --- a/.yoi/tickets/00001KVXK0WEA/thread.md +++ b/.yoi/tickets/00001KVXK0WEA/thread.md @@ -121,3 +121,17 @@ Orchestrator acceptance: queued -> inprogress - これ以降に worktree-workflow で `/home/hare/Projects/yoi/.worktree/00001KVXK0WEA-plugin-pdk-service-events` を作成し、multi-agent-workflow に接続する。 --- + + + +## Implementation report + +Implementation orchestration update: + +- prerequisite `00001KVXK0WE4` が done になったため、`00001KVXK0WEA` を再 routing して受理した。 +- worktree-workflow に従い、implementation worktree `/home/hare/Projects/yoi/.worktree/00001KVXK0WEA-plugin-pdk-service-events` と branch `work/00001KVXK0WEA-plugin-pdk-service-events` を作成した。 +- child worktree では tracked `.yoi` project records を見える状態にし、`.yoi/memory`、local/runtime/log/lock/secret-like `.yoi` paths を sparse-checkout で除外する方針にした。 +- Coder Pod `yoi-coder-00001KVXK0WEA-pdk-service-events` を sibling として起動した。write scope は child worktree に限定し、runtime workspace root `/home/hare/Projects/yoi` は startup 要件を満たすため non-recursive read のみにした。 +- Coder には root/original workspace と orchestration worktree で read/write/validation/git operation を行わないよう明示した。 + +--- From 7a4fd975268b655dca2f0861b8f2bd767c1e914f Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 16:37:37 +0900 Subject: [PATCH 40/46] feat: update plugin service authoring templates --- crates/plugin-pdk/src/lib.rs | 181 +++++++++++++++++- crates/plugin-pdk/tests/template.rs | 71 ++++++- crates/yoi/src/main.rs | 4 +- crates/yoi/src/plugin_cli.rs | 111 +++++++++-- docs/development/plugin-development.md | 54 +++++- .../rust-component-instance/Cargo.toml | 18 +- .../rust-component-instance/README.md | 15 +- .../rust-component-instance/plugin.toml | 27 +-- .../rust-component-instance/src/lib.rs | 77 +++++--- .../plugin/wit/yoi-plugin-instance-v1.wit | 46 +++++ resources/plugin/wit/yoi-plugin-tool-v1.wit | 6 +- 11 files changed, 530 insertions(+), 80 deletions(-) diff --git a/crates/plugin-pdk/src/lib.rs b/crates/plugin-pdk/src/lib.rs index 8e2fd4fc..f1aa137d 100644 --- a/crates/plugin-pdk/src/lib.rs +++ b/crates/plugin-pdk/src/lib.rs @@ -468,15 +468,48 @@ mod tests { assert!(error.message().len() <= MAX_ERROR_MESSAGE_BYTES + "…".len()); } + #[test] + fn service_output_helper_builds_runtime_command_envelope() { + let event = PluginIngressEvent { + kind: "websocket_text".to_string(), + source: "websocket:wss://example.test/socket".to_string(), + ingress_name: "example_ws".to_string(), + payload: json!({"text":"ping"}), + created_at: "2026-06-25T00:00:00Z".to_string(), + attempt: 1, + correlation_id: "event-1".to_string(), + }; + + assert_eq!(event.websocket_text(), Some("ping")); + let output = + ServiceOutput::websocket_send(&event, "reply-1", "wss://example.test/socket", "pong") + .unwrap(); + let value = serde_json::to_value(output).unwrap(); + + assert_eq!(value["accepted"], true); + assert_eq!(value["output_commands"][0]["source_event_id"], "event-1"); + assert_eq!(value["output_commands"][0]["command_id"], "reply-1"); + assert_eq!(value["output_commands"][0]["kind"], "websocket_send"); + assert_eq!(value["output_commands"][0]["payload"]["text"], "pong"); + assert_eq!( + value["output_commands"][0]["requested_at"], + "2026-06-25T00:00:00Z" + ); + } + #[test] fn wit_constants_match_current_world() { assert!(TOOL_WIT.contains("package yoi:plugin@1.0.0")); assert!(TOOL_WIT.contains("world tool")); assert!(TOOL_WIT.contains("export call")); assert_eq!(TOOL_WORLD, "yoi:plugin/tool@1.0.0"); - assert!(HOST_WIT.contains("interface https")); + assert!(HOST_WIT.contains("interface request")); + assert!(HOST_WIT.contains("interface websocket")); assert!(HOST_WIT.contains("interface fs")); assert!(HOST_WIT.contains("%list: func")); + assert!(INSTANCE_WIT.contains("world instance")); + assert!(INSTANCE_WIT.contains("export handle-ingress")); + assert!(INSTANCE_WIT.contains("websocket_send")); } } @@ -488,12 +521,48 @@ pub const PLUGIN_INSTANCE_WORLD: &str = "yoi:plugin/instance@1.0.0"; pub const INSTANCE_WIT: &str = include_str!("../../../resources/plugin/wit/yoi-plugin-instance-v1.wit"); -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct PluginIngressEvent { pub kind: String, pub source: String, #[serde(default)] + pub ingress_name: String, + #[serde(default)] pub payload: Value, + #[serde(default)] + pub created_at: String, + #[serde(default = "default_attempt")] + pub attempt: u32, + #[serde(default)] + pub correlation_id: String, +} + +impl PluginIngressEvent { + /// Return the text payload carried by a host-owned WebSocket ingress event. + pub fn websocket_text(&self) -> Option<&str> { + self.payload.get("text").and_then(Value::as_str) + } + + /// Build a `websocket_send` output command that replies through the + /// host-owned Service WebSocket driver. The host still validates the target + /// URL and matching grants before sending. + pub fn websocket_send( + &self, + command_id: impl Into, + url: impl Into, + text: impl Into, + ) -> Result { + ServiceOutputCommand::new( + self, + command_id, + ServiceOutputCommandKind::WebSocketSend, + serde_json::json!({ "url": url.into(), "text": text.into() }), + ) + } +} + +fn default_attempt() -> u32 { + 1 } #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] @@ -519,12 +588,118 @@ impl PluginStatus { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ServiceOutputCommandKind { + DiagnosticStatusUpdate, + HostRequestDispatch, + #[serde(rename = "websocket_send")] + WebSocketSend, +} + +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct ServiceOutputCommand { + pub correlation_id: String, + pub source_event_id: String, + pub command_id: String, + pub kind: ServiceOutputCommandKind, + pub payload: Value, + pub requested_at: String, +} + +impl ServiceOutputCommand { + pub fn new( + event: &PluginIngressEvent, + command_id: impl Into, + kind: ServiceOutputCommandKind, + payload: impl Serialize, + ) -> Result { + let command_id = sanitize_command_id(command_id.into()); + if command_id.is_empty() { + return Err(ToolError::invalid_output( + "service output command_id must not be empty", + )); + } + let source_event_id = event.correlation_id.clone(); + if source_event_id.is_empty() { + return Err(ToolError::invalid_output( + "service output command requires ingress event correlation_id", + )); + } + let requested_at = if event.created_at.is_empty() { + "1970-01-01T00:00:00Z".to_string() + } else { + event.created_at.clone() + }; + Ok(Self { + correlation_id: sanitize_command_id(format!("{source_event_id}:{command_id}")), + source_event_id, + command_id, + kind, + payload: serde_json::to_value(payload).map_err(ToolError::serialization)?, + requested_at, + }) + } +} + +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct ServiceOutput { + #[serde(default)] + pub accepted: bool, + #[serde(default, skip_serializing_if = "Value::is_null")] + pub data: Value, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub output_commands: Vec, +} + +impl ServiceOutput { + pub fn accepted(data: impl Serialize) -> Result { + Ok(Self { + accepted: true, + data: serde_json::to_value(data).map_err(ToolError::serialization)?, + output_commands: Vec::new(), + }) + } + + pub fn empty() -> Self { + Self { + accepted: true, + data: Value::Null, + output_commands: Vec::new(), + } + } + + pub fn with_command(mut self, command: ServiceOutputCommand) -> Self { + self.output_commands.push(command); + self + } + + pub fn websocket_send( + event: &PluginIngressEvent, + command_id: impl Into, + url: impl Into, + text: impl Into, + ) -> Result { + Ok(Self::empty().with_command(event.websocket_send(command_id, url, text)?)) + } +} + +fn sanitize_command_id(value: String) -> String { + bounded_text( + value + .chars() + .map(|ch| if ch.is_control() { '-' } else { ch }) + .collect(), + 128, + ) +} + /// Rust-facing instance Plugin contract. Hosts call `start` once, then route /// Tool/Ingress surfaces through the same mutable instance. pub trait Plugin: Sized + 'static { fn start(config: Value) -> Result; fn handle_tool(&mut self, name: &str, input: Value) -> Result; - fn handle_ingress(&mut self, name: &str, event: PluginIngressEvent) -> Result; + fn handle_ingress(&mut self, name: &str, event: PluginIngressEvent) -> Result; fn status(&self) -> Result { Ok(PluginStatus::ready(Value::Null)) } diff --git a/crates/plugin-pdk/tests/template.rs b/crates/plugin-pdk/tests/template.rs index a5e2239d..526abb34 100644 --- a/crates/plugin-pdk/tests/template.rs +++ b/crates/plugin-pdk/tests/template.rs @@ -12,6 +12,14 @@ const TEMPLATE_PLUGIN: &str = include_str!("../../../resources/plugin/templates/rust-component-tool/plugin.toml"); const TEMPLATE_README: &str = include_str!("../../../resources/plugin/templates/rust-component-tool/README.md"); +const SERVICE_TEMPLATE_CARGO: &str = + include_str!("../../../resources/plugin/templates/rust-component-instance/Cargo.toml"); +const SERVICE_TEMPLATE_LIB: &str = + include_str!("../../../resources/plugin/templates/rust-component-instance/src/lib.rs"); +const SERVICE_TEMPLATE_PLUGIN: &str = + include_str!("../../../resources/plugin/templates/rust-component-instance/plugin.toml"); +const SERVICE_TEMPLATE_README: &str = + include_str!("../../../resources/plugin/templates/rust-component-instance/README.md"); const SAMPLE_LIB: &str = include_str!("../../../docs/examples/plugin-component-tool/lib.rs"); const PDK_CARGO: &str = include_str!("../Cargo.toml"); @@ -25,7 +33,7 @@ fn rust_component_tool_template_has_expected_files() { cargo["dependencies"]["yoi-plugin-pdk"]["path"].as_str(), Some("../../../../crates/plugin-pdk") ); - assert!(TEMPLATE_CARGO.contains("rev = \"\"")); + assert!(TEMPLATE_CARGO.contains("rev = \"\"")); let plugin: Value = toml::from_str(TEMPLATE_PLUGIN).expect("template plugin.toml parses"); assert_eq!(plugin["schema_version"].as_integer(), Some(1)); @@ -45,6 +53,56 @@ fn rust_component_tool_template_has_expected_files() { assert!(TEMPLATE_README.contains("Component Model Tool Plugin")); } +#[test] +fn rust_component_service_template_has_event_output_pattern() { + let cargo: Value = toml::from_str(SERVICE_TEMPLATE_CARGO).expect("service Cargo.toml parses"); + assert_eq!(cargo["package"]["edition"].as_str(), Some("2024")); + assert_eq!(cargo["lib"]["crate-type"][0].as_str(), Some("cdylib")); + assert_eq!( + cargo["dependencies"]["yoi-plugin-pdk"]["path"].as_str(), + Some("../../../../crates/plugin-pdk") + ); + assert!(SERVICE_TEMPLATE_CARGO.contains("rev = \"\"")); + + let plugin: Value = + toml::from_str(SERVICE_TEMPLATE_PLUGIN).expect("service plugin.toml parses"); + assert_eq!(plugin["schema_version"].as_integer(), Some(1)); + assert_eq!(plugin["runtime"]["kind"].as_str(), Some("wasm-component")); + assert_eq!( + plugin["runtime"]["world"].as_str(), + Some("yoi:plugin/instance@1.0.0") + ); + assert_eq!( + plugin["services"].as_array().expect("services array").len(), + 1 + ); + let ingress = &plugin["ingresses"].as_array().expect("ingresses array")[0]; + assert!( + ingress["event_kinds"] + .as_array() + .expect("event kinds") + .iter() + .any(|kind| kind.as_str() == Some("websocket_text")) + ); + assert!( + ingress["sources"] + .as_array() + .expect("sources") + .iter() + .any(|source| source + .as_str() + .unwrap_or_default() + .starts_with("websocket:wss://")) + ); + + assert!(SERVICE_TEMPLATE_LIB.contains("world: \"instance\"")); + assert!(SERVICE_TEMPLATE_LIB.contains("PluginIngressEvent")); + assert!(SERVICE_TEMPLATE_LIB.contains("ServiceOutput::websocket_send")); + assert!(!SERVICE_TEMPLATE_LIB.contains("recv(timeout")); + assert!(SERVICE_TEMPLATE_README.contains("output command")); + assert!(!SERVICE_TEMPLATE_PLUGIN.contains("kind = \"wasm\"")); +} + #[test] fn documented_sample_uses_pdk_component_path() { assert!(SAMPLE_LIB.contains("use yoi_plugin_pdk::wit_bindgen")); @@ -57,8 +115,17 @@ fn documented_sample_uses_pdk_component_path() { #[test] fn embedded_template_cargo_checks_for_wasm_target() { + cargo_check_template("rust-component-tool"); +} + +#[test] +fn embedded_service_template_cargo_checks_for_wasm_target() { + cargo_check_template("rust-component-instance"); +} + +fn cargo_check_template(template: &str) { let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let template_dir = crate_dir.join("../../resources/plugin/templates/rust-component-tool"); + let template_dir = crate_dir.join(format!("../../resources/plugin/templates/{template}")); let manifest_path = template_dir.join("Cargo.toml"); let lock_path = template_dir.join("Cargo.lock"); let _ = fs::remove_file(&lock_path); diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 3625681a..7d0e7263 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -727,7 +727,7 @@ fn parse_plugin_pack_args( } fn plugin_usage() -> &'static str { - "usage: yoi plugin new rust-component-tool [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n yoi plugin list [--workspace PATH] [--profile REF] [--json]\n yoi plugin show [--workspace PATH] [--profile REF] [--json]" + "usage: yoi plugin new [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n yoi plugin list [--workspace PATH] [--profile REF] [--json]\n yoi plugin show [--workspace PATH] [--profile REF] [--json]" } fn parse_mcp_args(args: &[String]) -> Result { @@ -901,7 +901,7 @@ fn parse_session_id(value: &str) -> Result { fn print_help() { println!( - "yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace ] [--all]\n yoi panel [--workspace ]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi pod delete [--force] [--dry-run]\n yoi pod prune --older-than [--force] [--dry-run]\n yoi objective [OPTIONS]\n yoi session analyze --json\n yoi session prune --unreferenced [--older-than ] [--force] [--dry-run]\n yoi ticket [OPTIONS]\n yoi workspace serve [OPTIONS]\n yoi plugin new rust-component-tool [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n yoi plugin list [--workspace ] [--profile ] [--json]\n yoi plugin show [--workspace ] [--profile ] [--json]\n yoi mcp list [--workspace ] [--profile ] [--json]\n yoi mcp show [--workspace ] [--profile ] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace ] [--profile ] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Pod chat/client surface (default, --pod, yoi resume)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace Runtime workspace root for default Console/--pod (defaults to cwd)\n --pod Open the Pod Console by name (attach/restore/create)\n --socket Attach a Pod Console to a specific socket with --pod\n --session Resume a specific session segment in the Pod Console\n --profile Select a reusable Profile recipe\n -h, --help Print help\n" + "yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace ] [--all]\n yoi panel [--workspace ]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi pod delete [--force] [--dry-run]\n yoi pod prune --older-than [--force] [--dry-run]\n yoi objective [OPTIONS]\n yoi session analyze --json\n yoi session prune --unreferenced [--older-than ] [--force] [--dry-run]\n yoi ticket [OPTIONS]\n yoi workspace serve [OPTIONS]\n yoi plugin new [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n yoi plugin list [--workspace ] [--profile ] [--json]\n yoi plugin show [--workspace ] [--profile ] [--json]\n yoi mcp list [--workspace ] [--profile ] [--json]\n yoi mcp show [--workspace ] [--profile ] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace ] [--profile ] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Pod chat/client surface (default, --pod, yoi resume)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace Runtime workspace root for default Console/--pod (defaults to cwd)\n --pod Open the Pod Console by name (attach/restore/create)\n --socket Attach a Pod Console to a specific socket with --pod\n --session Resume a specific session segment in the Pod Console\n --profile Select a reusable Profile recipe\n -h, --help Print help\n" ); } diff --git a/crates/yoi/src/plugin_cli.rs b/crates/yoi/src/plugin_cli.rs index da347b0b..69ffe40a 100644 --- a/crates/yoi/src/plugin_cli.rs +++ b/crates/yoi/src/plugin_cli.rs @@ -9,10 +9,10 @@ use manifest::plugin::{ MaterializedPluginPackage, PluginConfig, PluginDiagnostic, PluginDiagnosticKind, PluginDiagnosticPhase, PluginDiscoveryLimits, PluginDiscoveryOptions, PluginDiscoveryReport, PluginExactVersion, PluginGrantConfig, PluginPackageManifest, PluginPermission, - PluginResolution, PluginSourceKind, PluginSurface, RUST_COMPONENT_TOOL_TEMPLATE, - ResolvedPlugin, ResolvedPluginRecord, SourceQualifiedPluginId, discover_plugins, - read_plugin_directory, read_plugin_package_file, resolve_enabled_plugins, - write_plugin_package_file, + PluginResolution, PluginSourceKind, PluginSurface, PluginTemplateResource, + RUST_COMPONENT_INSTANCE_TEMPLATE, RUST_COMPONENT_TOOL_TEMPLATE, ResolvedPlugin, + ResolvedPluginRecord, SourceQualifiedPluginId, discover_plugins, read_plugin_directory, + read_plugin_package_file, resolve_enabled_plugins, write_plugin_package_file, }; use manifest::{ProfileResolveOptions, ProfileResolver, ProfileSelector, paths}; use pod::feature::plugin::{PluginStaticInspection, inspect_resolved_plugin_static}; @@ -85,27 +85,29 @@ pub(crate) fn run(command: PluginCliCommand) -> Result<()> { } fn render_new(template: &str, destination: &Path, args: &PluginCliArgs) -> Result { - if template != "rust-component-tool" { - return Err(format!( - "unsupported plugin template `{template}` (supported: rust-component-tool)" - ) - .into()); + let (template_name, resources) = embedded_template_resources(template)?; + materialize_template(destination, resources)?; + let mut next_steps = vec![ + "Review plugin.toml and generated Rust source.".to_string(), + "Replace the placeholder plugin.component.wasm with a real built component before enabling or execution.".to_string(), + "Run `yoi plugin check ` and then `yoi plugin pack `.".to_string(), + ]; + if template == "rust-component-service" { + next_steps.insert( + 1, + "Implement Service ingress logic in handle_ingress and return ServiceOutput output_commands for host-owned WebSocket sends.".to_string(), + ); } - materialize_template(destination)?; let report = NewReport { command: "new", - template: "rust-component-tool", + template: template_name, destination: destination.display().to_string(), - files: RUST_COMPONENT_TOOL_TEMPLATE + files: resources .iter() .map(|resource| resource.path.to_string()) .collect(), safety: AuthoringSafetyReport::default(), - next_steps: vec![ - "Review plugin.toml and generated Rust source.".to_string(), - "Replace the placeholder plugin.component.wasm with a real built component before enabling or execution.".to_string(), - "Run `yoi plugin check ` and then `yoi plugin pack `.".to_string(), - ], + next_steps, }; if args.json { return Ok(format!("{}\n", serde_json::to_string_pretty(&report)?)); @@ -113,7 +115,23 @@ fn render_new(template: &str, destination: &Path, args: &PluginCliArgs) -> Resul render_new_human(&report) } -fn materialize_template(destination: &Path) -> Result<()> { +fn embedded_template_resources( + template: &str, +) -> Result<(&'static str, &'static [PluginTemplateResource])> { + match template { + "rust-component-tool" => Ok(("rust-component-tool", RUST_COMPONENT_TOOL_TEMPLATE)), + "rust-component-service" => Ok(("rust-component-service", RUST_COMPONENT_INSTANCE_TEMPLATE)), + _ => Err(format!( + "unsupported plugin template `{template}` (supported: rust-component-tool, rust-component-service)" + ) + .into()), + } +} + +fn materialize_template( + destination: &Path, + resources: &'static [PluginTemplateResource], +) -> Result<()> { match fs::symlink_metadata(destination) { Ok(metadata) => { if metadata.file_type().is_symlink() { @@ -144,7 +162,7 @@ fn materialize_template(destination: &Path) -> Result<()> { Err(error) => return Err(error.into()), } - for resource in RUST_COMPONENT_TOOL_TEMPLATE { + for resource in resources { let relative = safe_template_relative_path(resource.path)?; let path = destination.join(relative); if let Some(parent) = path.parent() { @@ -2136,6 +2154,61 @@ mod tests { let human_check = render_check(&destination, &PluginCliArgs::default()).unwrap(); assert!(human_check.contains("[partial]")); assert!(human_check.contains("not ready to enable")); + + let service_destination = dir.path().join("my-service-plugin"); + let service_json = render_new( + "rust-component-service", + &service_destination, + &PluginCliArgs { + json: true, + ..PluginCliArgs::default() + }, + ) + .unwrap(); + let service_value: serde_json::Value = serde_json::from_str(&service_json).unwrap(); + assert_eq!(service_value["template"], "rust-component-service"); + assert!( + service_value["next_steps"] + .as_array() + .unwrap() + .iter() + .any(|step| step + .as_str() + .unwrap_or_default() + .contains("Service ingress")) + ); + for resource in RUST_COMPONENT_INSTANCE_TEMPLATE { + assert!( + service_destination.join(resource.path).is_file(), + "missing service {}", + resource.path + ); + } + let manifest = fs::read_to_string(service_destination.join("plugin.toml")).unwrap(); + assert!(manifest.contains("kind = \"wasm-component\"")); + assert!(manifest.contains("[[services]]")); + assert!(manifest.contains("[[ingresses]]")); + let source = fs::read_to_string(service_destination.join("src/lib.rs")).unwrap(); + assert!(source.contains("ServiceOutput::websocket_send")); + assert!(!source.contains("recv(timeout")); + let service_check = render_check(&service_destination, &PluginCliArgs::default()).unwrap(); + assert!(service_check.contains("plugin check:")); + assert!(service_check.contains("service")); + let service_package = dir.path().join("my-service-plugin.yoi-plugin"); + let service_pack_json = render_pack( + &service_destination, + Some(&service_package), + &PluginCliArgs { + json: true, + ..PluginCliArgs::default() + }, + ) + .unwrap(); + let service_pack_value: serde_json::Value = + serde_json::from_str(&service_pack_json).unwrap(); + assert_eq!(service_pack_value["status"], "packed"); + assert!(service_package.is_file()); + let error = render_new( "rust-component-tool", &destination, diff --git a/docs/development/plugin-development.md b/docs/development/plugin-development.md index 881ff48e..68a31066 100644 --- a/docs/development/plugin-development.md +++ b/docs/development/plugin-development.md @@ -23,7 +23,7 @@ Yoi's Plugin platform is meant to make extension behavior reviewable before it b Keep these layers separate when designing a Plugin. Do not make package discovery imply enablement. Do not make SDK/PDK convenience imply authority. Do not treat Rust helper APIs or host API wrappers as permission grants. The host always re-checks authority at registration/execution/API-call boundaries. -Yoi's preferred Plugin shape is **Tool first**. A good Tool Plugin has a narrow schema, deterministic input/output behavior, explicit side-effect metadata, and a minimal grant set. Long-running services, inbound events, and autonomous routing are future Service/Ingress work; they should not be hidden inside a Tool package. +Yoi's preferred Plugin shapes are **Tool first** for request/response capabilities and **Service/Ingress** for host-dispatched inbound events. A good Tool Plugin has a narrow schema, deterministic input/output behavior, explicit side-effect metadata, and a minimal grant set. A Service Plugin should keep long-lived transport ownership in the host and react to bounded ingress events by returning output commands. Component Model authoring is the supported path for Plugins. Legacy raw core-Wasm manifests (`kind = "wasm"` / `abi = "yoi-plugin-wasm-1"`) are retired and rejected by `yoi plugin check`, discovery, `list`, and `show`; use the Rust PDK/template and `kind = "wasm-component"` instead. @@ -42,10 +42,10 @@ Implemented foundation: - read-only `yoi plugin list/show` inspection; - local first-party authoring commands: `yoi plugin new`, `yoi plugin check`, and `yoi plugin pack`. -Still intentionally separate/future work: +Still intentionally limited or separate from this guide: - multi-language SDK/PDK crates; -- Service / Ingress surfaces; +- Service / Ingress surfaces, where the host owns transport lifecycle, dispatches bounded ingress events, and consumes output commands such as `websocket_send`; - WebSocket or inbound HTTP for bidirectional external event integrations; - public registry/install/update/signature tooling. @@ -78,6 +78,8 @@ Create a Rust Component Tool starter from embedded resources: ```bash yoi plugin new rust-component-tool ./my-plugin +# or, for a host-dispatched Service/Ingress example: +yoi plugin new rust-component-service ./my-service-plugin ``` `new` writes only inside the requested destination and refuses an existing non-empty destination or destination symlink. The generated template includes `plugin.toml`, Rust source, Cargo metadata, README next steps, and a placeholder `plugin.component.wasm` artifact so local `check`/`pack` validation can run immediately. Replace the placeholder with a real built component before enabling or executing the Plugin. @@ -115,7 +117,7 @@ For Tool Plugins: - return bounded summaries and content that are useful as Tool results; - avoid hiding long workflows, background daemons, or inbound event handling inside a Tool call. -A Tool should be a capability the model may choose to call, not a second agent runtime. If the desired behavior needs a long-lived connection, incoming events, or autonomous routing, treat that as future Service/Ingress design rather than stretching the Tool surface. +A Tool should be a capability the model may choose to call, not a second agent runtime. If the desired behavior needs a long-lived connection, incoming events, or autonomous routing, put the transport lifecycle behind a Service/Ingress surface and let the host dispatch bounded events; do not stretch the Tool surface into a hidden polling loop. Design package permissions as a review surface. A reviewer should be able to read `plugin.toml` plus the enablement grants and understand: @@ -163,6 +165,8 @@ Create a starter with: ```bash yoi plugin new rust-component-tool ./my-plugin +# or, for a host-dispatched Service/Ingress example: +yoi plugin new rust-component-service ./my-service-plugin ``` The generated package contains: @@ -327,6 +331,48 @@ path_prefixes = ["/v1/"] Yoi checks method, scheme, host, optional port, and path prefix against both the manifest declaration and enablement grant before any network I/O. `http://localhost`, loopback, private, and other local targets are never ambient; they require an explicit manifest request target and an explicit matching grant. The explicit request target is the declared URL authority; a granted DNS hostname may resolve to a loopback/private address without requiring a separate literal-IP grant, so reviewers should grant hostnames only when that resolution behavior is intended. Broad targets such as `host = "*"` are supported only as visibly broad request permissions in inspection/diagnostics. Embedded credentials, credential-like headers, oversize requests/responses, WebSocket URLs/upgrades, and SSE/event-stream requests are rejected. +## Service ingress and output commands + +Service Plugins export the `yoi:plugin/instance@1.0.0` world. The host starts one Plugin instance, owns external ingress transports, and calls `handle_ingress(name, event_json)` with bounded event envelopes. A WebSocket ingress event contains fields such as `kind`, `source`, `ingress_name`, `payload`, `created_at`, `attempt`, and `correlation_id`; the Rust PDK maps this to `PluginIngressEvent`. + +Service handlers return `ServiceOutput`, not ordinary ToolOutput. Side effects are requested through top-level `output_commands`. For a WebSocket reply, use the PDK helper: + +```rust +ServiceOutput::websocket_send( + &event, + "reply-1", + event.source.strip_prefix("websocket:").unwrap_or(&event.source), + "pong", +) +``` + +This serializes a `websocket_send` command with `source_event_id`, `command_id`, `payload.url`, `payload.text`, and a request timestamp. The host parses, bounds, grant-checks, and dispatches the command through the host-owned WebSocket driver. Do not create a long-running guest receive loop for Service integrations; incoming messages should arrive as ingress events. + +A minimal manifest shape is: + +```toml +surfaces = ["tool", "service", "ingress"] + +[runtime] +kind = "wasm-component" +world = "yoi:plugin/instance@1.0.0" +component = "plugin.component.wasm" + +[[services]] +name = "example_service" +description = "Host-managed service instance." +lifecycle = "host-managed" + +[[ingresses]] +name = "example_ws" +description = "Handles host-owned WebSocket text events." +event_kinds = ["websocket_text", "websocket_close", "websocket_error"] +sources = ["websocket:wss://gateway.example.com/gateway"] +input_schema = { type = "object" } +``` + +Generate a fuller example with `yoi plugin new rust-component-service ./my-service-plugin`. + ## `websocket` host API The `websocket` host API is a separate grant-gated capability named `host_api.websocket`, not an extension of `host_api.request`. It opens host-owned WebSocket connections only when both the package manifest and enablement config declare matching targets. Tool-style/internal bounded use can still drive the lifecycle explicitly through `open`, `send-text`, `recv`, and `close`; incoming messages are returned only from bounded `recv` calls and are not injected into model context, history, Dashboard state, or Ticket state. Service Plugins should prefer the host-owned Service WebSocket driver instead of running a long-lived guest recv loop: declare a Service ingress source as `websocket:wss://host/path`, include the `websocket_text`/`websocket_close`/`websocket_error` event kinds you want delivered, and emit the Service output command `websocket_send` to send text back through the same grant-checked host connection. diff --git a/resources/plugin/templates/rust-component-instance/Cargo.toml b/resources/plugin/templates/rust-component-instance/Cargo.toml index 06a0cf83..2ae7cfa0 100644 --- a/resources/plugin/templates/rust-component-instance/Cargo.toml +++ b/resources/plugin/templates/rust-component-instance/Cargo.toml @@ -1,14 +1,22 @@ -[workspace] - [package] -name = "example-yoi-instance-plugin" +name = "yoi-rust-component-service-template" version = "0.1.0" edition = "2024" +license = "MIT" +publish = false + +# Keep the embedded template checkable in-place without making it a member of +# Yoi's root workspace. A copied starter remains a normal standalone package. +[workspace] [lib] crate-type = ["cdylib"] [dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" yoi-plugin-pdk = { path = "../../../../crates/plugin-pdk" } -serde = { version = "1", features = ["derive"] } -serde_json = "1" + +# Out-of-tree Plugin packages should replace the local path with a pinned +# Yoi source revision. Use rev, not branch, for reproducible builds: +# yoi-plugin-pdk = { git = "https://gitea.hareworks.net/Hare/yoi.git", package = "yoi-plugin-pdk", rev = "" } diff --git a/resources/plugin/templates/rust-component-instance/README.md b/resources/plugin/templates/rust-component-instance/README.md index aad35af3..6682aed4 100644 --- a/resources/plugin/templates/rust-component-instance/README.md +++ b/resources/plugin/templates/rust-component-instance/README.md @@ -1,9 +1,10 @@ -# Yoi instance Plugin template +# Rust Service Plugin Template -This template targets `yoi:plugin/instance@1.0.0`. The host creates one -`PluginInstance` for the package; Tool, Service, and Ingress surfaces share that -instance state while each surface keeps separate permissions/grants. +This template targets the Component Model-only runtime (`runtime.kind = "wasm-component"`) and exports the `yoi:plugin/instance@1.0.0` world. -Tools still run only through ordinary model/user-initiated Tool calls. Ingress -handlers receive bounded typed untrusted events and must return explicit JSON -for host-mediated visible/durable paths. +It demonstrates both authoring surfaces supported by a shared Plugin instance: + +- `example_echo` is an ordinary request/response Tool handler. +- `example_ws` is a Service ingress handler. The host owns WebSocket receive/reconnect work and dispatches bounded `websocket_text` events into `handle_ingress`. The guest replies by returning a `websocket_send` output command in `ServiceOutput`; do not run a guest-side `recv(timeout)` polling loop. + +Build with `cargo component build --release` (or the project-specific build command used by your Plugin packaging flow), then run `yoi plugin check` / `yoi plugin pack` from the generated Plugin directory. diff --git a/resources/plugin/templates/rust-component-instance/plugin.toml b/resources/plugin/templates/rust-component-instance/plugin.toml index 71bbbaec..a7bfd629 100644 --- a/resources/plugin/templates/rust-component-instance/plugin.toml +++ b/resources/plugin/templates/rust-component-instance/plugin.toml @@ -1,16 +1,16 @@ schema_version = 1 -id = "example.rust_instance_plugin" -name = "Rust Instance Plugin Template" +id = "example.rust_service_plugin" +name = "Rust Service Plugin Template" version = "0.1.0" -description = "Example instance-oriented Yoi Plugin with shared Tool/Ingress state." +description = "Example Component Model Plugin with Tool and Service ingress handlers." surfaces = ["tool", "service", "ingress"] permissions = [ { kind = "surface", surface = "tool" }, - { kind = "tool", name = "example_instance_tool" }, + { kind = "tool", name = "example_echo" }, { kind = "surface", surface = "service" }, - { kind = "service", name = "example_instance_service" }, + { kind = "service", name = "example_service" }, { kind = "surface", surface = "ingress" }, - { kind = "ingress", name = "example_instance_ingress" }, + { kind = "ingress", name = "example_ws" }, ] [runtime] @@ -19,17 +19,18 @@ world = "yoi:plugin/instance@1.0.0" component = "plugin.component.wasm" [[tools]] -name = "example_instance_tool" -description = "Return the input and increment shared instance state." +name = "example_echo" +description = "Echo input text through the shared Plugin instance." input_schema = { type = "object" } [[services]] -name = "example_instance_service" -description = "Reports shared plugin instance lifecycle status." +name = "example_service" +description = "Host-managed service instance for bounded ingress events." lifecycle = "host-managed" [[ingresses]] -name = "example_instance_ingress" -description = "Accepts bounded in-process ingress events." -event_kinds = ["example"] +name = "example_ws" +description = "Handles host-owned WebSocket text events and returns websocket_send output commands." +event_kinds = ["websocket_text"] +sources = ["websocket:wss://example.com/socket"] input_schema = { type = "object" } diff --git a/resources/plugin/templates/rust-component-instance/src/lib.rs b/resources/plugin/templates/rust-component-instance/src/lib.rs index 0cc5a31a..1a2750ca 100644 --- a/resources/plugin/templates/rust-component-instance/src/lib.rs +++ b/resources/plugin/templates/rust-component-instance/src/lib.rs @@ -1,6 +1,10 @@ +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use yoi_plugin_pdk::wit_bindgen; -use yoi_plugin_pdk::{export_plugin_instance, Plugin, PluginIngressEvent, PluginStatus, ToolOutput}; +use yoi_plugin_pdk::{ + export_plugin_instance, Plugin, PluginIngressEvent, PluginStatus, ServiceOutput, ToolError, + ToolOutput, +}; wit_bindgen::generate!({ world: "instance", @@ -9,24 +13,42 @@ wit_bindgen::generate!({ runtime_path: "yoi_plugin_pdk::wit_bindgen::rt", }); +#[derive(Default)] struct ExamplePlugin { - calls: u64, + count: u64, +} + +#[derive(Deserialize)] +struct EchoInput { + text: String, +} + +#[derive(Serialize)] +struct EchoOutput { + text: String, + count: u64, } impl Plugin for ExamplePlugin { - fn start(_config: Value) -> yoi_plugin_pdk::Result { - Ok(Self { calls: 0 }) + fn start(config: Value) -> Result { + Ok(Self { + count: config.get("start_count").and_then(Value::as_u64).unwrap_or(0), + }) } - fn handle_tool(&mut self, name: &str, input: Value) -> yoi_plugin_pdk::Result { - self.calls += 1; + fn handle_tool(&mut self, name: &str, input: Value) -> Result { + if name != "example_echo" { + return Err(ToolError::invalid_input(format!("unknown tool: {name}"))); + } + let input: EchoInput = + serde_json::from_value(input).map_err(|err| ToolError::invalid_input(err.to_string()))?; + self.count += 1; ToolOutput::json( - format!("{name} handled by shared instance"), - json!({ - "tool": name, - "calls": self.calls, - "input": input - }), + format!("echoed {} bytes", input.text.len()), + EchoOutput { + text: input.text, + count: self.count, + }, ) } @@ -34,18 +56,29 @@ impl Plugin for ExamplePlugin { &mut self, name: &str, event: PluginIngressEvent, - ) -> yoi_plugin_pdk::Result { - Ok(json!({ - "ingress": name, - "kind": event.kind, - "source": event.source, - "calls": self.calls, - "accepted": true - })) + ) -> Result { + if name != "example_ws" { + return Ok(ServiceOutput::accepted(json!({ "ignored": name }))?); + } + + let Some(text) = event.websocket_text() else { + return Ok(ServiceOutput::accepted(json!({ + "accepted": true, + "kind": event.kind, + }))?); + }; + + self.count += 1; + ServiceOutput::websocket_send( + &event, + format!("example-reply-{}", self.count), + event.source.strip_prefix("websocket:").unwrap_or(&event.source), + format!("echo({}): {text}", self.count), + ) } - fn status(&self) -> yoi_plugin_pdk::Result { - Ok(PluginStatus::ready(json!({ "calls": self.calls }))) + fn status(&self) -> Result { + Ok(PluginStatus::ready(json!({ "count": self.count }))) } } diff --git a/resources/plugin/wit/yoi-plugin-instance-v1.wit b/resources/plugin/wit/yoi-plugin-instance-v1.wit index 59f39cb6..68a2e46f 100644 --- a/resources/plugin/wit/yoi-plugin-instance-v1.wit +++ b/resources/plugin/wit/yoi-plugin-instance-v1.wit @@ -5,9 +5,55 @@ world instance { import yoi:host/websocket@1.0.0; import yoi:host/fs@1.0.0; + /// Start one host-managed Plugin instance. `config-json` is the opaque + /// enablement config copied from the Profile/plugin grant record. The return + /// string is PluginStatus JSON: `{ "state": "ready|running|stopped|...", + /// "data": }`. export start: func(config-json: string) -> string; + + /// Execute a manifest-declared Tool on the shared instance. `input-json` is + /// ordinary Tool input JSON and the return string is ToolOutput JSON. export handle-tool: func(name: string, input-json: string) -> string; + + /// Handle one host-dispatched Service/Ingress event. `event-json` is an + /// ingress event envelope with at least: + /// + /// ```json + /// { + /// "kind": "websocket_text|websocket_close|websocket_error|...", + /// "source": "websocket:wss://host/path|...", + /// "ingress_name": "manifest_ingress_name", + /// "payload": { "text": "..." }, + /// "created_at": "RFC3339 timestamp", + /// "attempt": 1, + /// "correlation_id": "host event id" + /// } + /// ``` + /// + /// The return string is ServiceOutput JSON. To request host-mediated side + /// effects, return top-level `output_commands`, for example: + /// + /// ```json + /// { + /// "accepted": true, + /// "output_commands": [{ + /// "correlation_id": "command correlation id", + /// "source_event_id": "matching ingress correlation_id", + /// "command_id": "guest command id", + /// "kind": "websocket_send", + /// "payload": { "url": "wss://host/path", "text": "reply" }, + /// "requested_at": "RFC3339 timestamp" + /// }] + /// } + /// ``` + /// + /// Output commands are parsed, bounded, grant-checked, and executed by the + /// host. They are not ordinary ToolOutput and do not inject hidden context. export handle-ingress: func(name: string, event-json: string) -> string; + + /// Return PluginStatus JSON for the shared host-managed instance. export status: func() -> string; + + /// Stop the shared instance and return final PluginStatus JSON. export stop: func() -> string; } diff --git a/resources/plugin/wit/yoi-plugin-tool-v1.wit b/resources/plugin/wit/yoi-plugin-tool-v1.wit index 8dd7eebe..6bd1a034 100644 --- a/resources/plugin/wit/yoi-plugin-tool-v1.wit +++ b/resources/plugin/wit/yoi-plugin-tool-v1.wit @@ -5,8 +5,8 @@ world tool { import yoi:host/websocket@1.0.0; import yoi:host/fs@1.0.0; - /// Execute a manifest-declared Tool. `input-json` is the normal Tool input - /// JSON and the returned string is the same ToolOutput JSON accepted by the - /// legacy raw-Wasm ABI. + /// Execute a manifest-declared Tool. `input-json` is ordinary Tool input JSON + /// and the returned string is ToolOutput JSON accepted by the current + /// Component Model Plugin runtime. export call: func(tool-name: string, input-json: string) -> string; } From fa29cc2c957a91d1a39ab0eae373d20884572b0a Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 16:38:53 +0900 Subject: [PATCH 41/46] ticket: record plugin pdk implementation report --- .yoi/tickets/00001KVXK0WEA/item.md | 2 +- .yoi/tickets/00001KVXK0WEA/thread.md | 55 ++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVXK0WEA/item.md b/.yoi/tickets/00001KVXK0WEA/item.md index 05cd6f3c..4566e22c 100644 --- a/.yoi/tickets/00001KVXK0WEA/item.md +++ b/.yoi/tickets/00001KVXK0WEA/item.md @@ -2,7 +2,7 @@ title: 'Update Plugin WIT PDK templates for service event runtime' state: 'inprogress' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-25T07:09:39Z' +updated_at: '2026-06-25T07:38:44Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:12:05Z' diff --git a/.yoi/tickets/00001KVXK0WEA/thread.md b/.yoi/tickets/00001KVXK0WEA/thread.md index 16ae08ba..664aaaf6 100644 --- a/.yoi/tickets/00001KVXK0WEA/thread.md +++ b/.yoi/tickets/00001KVXK0WEA/thread.md @@ -135,3 +135,58 @@ Implementation orchestration update: - Coder には root/original workspace と orchestration worktree で read/write/validation/git operation を行わないよう明示した。 --- + + + +## Implementation report + +Implementation report: + +対象 Ticket: +- `00001KVXK0WEA` — Update Plugin WIT PDK templates for service event runtime + +実装概要: +- Plugin authoring surface を Component Model-only / Service ingress event / output command model に合わせて更新した。 + +変更ファイル: +- `resources/plugin/wit/yoi-plugin-instance-v1.wit` + - Service ingress event JSON と `output_commands` / `websocket_send` の authoring 向け説明を追加。 +- `resources/plugin/wit/yoi-plugin-tool-v1.wit` + - current runtime 前提の説明に整理。 +- `crates/plugin-pdk/src/lib.rs` + - `PluginIngressEvent` に `ingress_name`, `created_at`, `attempt`, `correlation_id` を追加。 + - `ServiceOutput`, `ServiceOutputCommand`, `ServiceOutputCommandKind` を追加。 + - `PluginIngressEvent::websocket_text()`, `websocket_send()`, `ServiceOutput::websocket_send()` を追加。 + - `Plugin::handle_ingress` の戻り値を `ServiceOutput` に変更。 +- `crates/plugin-pdk/tests/template.rs` + - PDK helper / WIT / template consistency tests を追加・更新。 +- `resources/plugin/templates/rust-component-instance/*` + - Service-oriented template に更新。 + - guest-side `recv(timeout)` loop ではなく、`handle_ingress` で ingress event を受け、`ServiceOutput::websocket_send` で output command を返す構成に変更。 + - `plugin.toml` は `runtime.kind = "wasm-component"` のみ。 + - Tool handler も残し、Tool Plugin authoring support を維持。 +- `crates/yoi/src/plugin_cli.rs`, `crates/yoi/src/main.rs` + - `yoi plugin new rust-component-service ` を追加。 + - service template の `new` / `check` / `pack` consistency を unit test で確認。 + - CLI usage を更新。 +- `docs/development/plugin-development.md` + - Service/Ingress は host-owned ingress event + output command model として記述。 + - Service WebSocket integration で long-running guest `recv(timeout)` loop を推奨しない内容に更新。 + +Validation: +- `cargo test -p yoi-plugin-pdk`: success +- `cargo test -p yoi plugin`: success +- `cargo check -p yoi`: success +- `git diff --check`: success +- `nix build .#yoi --no-link`: success (`nix-exit:0`) +- Template cargo-check: success + - `cargo test -p yoi-plugin-pdk` 内で `rust-component-tool` / `rust-component-instance` の `cargo check --target wasm32-unknown-unknown --offline --target-dir ` を実行し、temporary target dir を使用。 + +Commit: +- `7a4fd975268b655dca2f0861b8f2bd767c1e914f feat: update plugin service authoring templates` + +Residual risks / notes: +- Runtime envelope の再設計は行っていない。 +- protocol-specific integration / secret injection / reconnect policy / runtime reimplementation は範囲外として未実装。 + +--- From 41283c8dd944002e93d19993e3f2e0571119f361 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 16:44:57 +0900 Subject: [PATCH 42/46] ticket: request plugin pdk template fix --- .yoi/tickets/00001KVXK0WEA/item.md | 2 +- .yoi/tickets/00001KVXK0WEA/thread.md | 36 ++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVXK0WEA/item.md b/.yoi/tickets/00001KVXK0WEA/item.md index 4566e22c..99a59a5a 100644 --- a/.yoi/tickets/00001KVXK0WEA/item.md +++ b/.yoi/tickets/00001KVXK0WEA/item.md @@ -2,7 +2,7 @@ title: 'Update Plugin WIT PDK templates for service event runtime' state: 'inprogress' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-25T07:38:44Z' +updated_at: '2026-06-25T07:44:48Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:12:05Z' diff --git a/.yoi/tickets/00001KVXK0WEA/thread.md b/.yoi/tickets/00001KVXK0WEA/thread.md index 664aaaf6..b9521085 100644 --- a/.yoi/tickets/00001KVXK0WEA/thread.md +++ b/.yoi/tickets/00001KVXK0WEA/thread.md @@ -190,3 +190,39 @@ Residual risks / notes: - protocol-specific integration / secret injection / reconnect policy / runtime reimplementation は範囲外として未実装。 --- + + + +## Review: request changes + +Review result: request_changes + +実装は概ね Ticket の方向に沿っているが、Service WebSocket template の manifest が runtime の authority boundary と不整合で、生成テンプレートの中心例が実行時に `websocket_send` できない状態に見える。 + +Blocker: +- Service template が `websocket_send` に必要な manifest 権限/target を宣言していない。 + - `resources/plugin/templates/rust-component-instance/src/lib.rs` は `ServiceOutput::websocket_send(...)` を返す Service/WebSocket 例になっている。 + - `resources/plugin/templates/rust-component-instance/plugin.toml` は `surface/tool/service/ingress` と `tool/service/ingress` permission はあるが、`{ kind = "host_api", api = "websocket" }` がない。 + - 同 `plugin.toml` には matching `[[websocket]]` target declaration もない。 + - runtime 側の `websocket_send` 処理は manifest permissions の `host_api.websocket`、grants permissions の `host_api.websocket`、manifest の `[[websocket]]` target declaration、enablement 側の usable websocket grant を要求する。 + - そのため生成された service template は `yoi plugin check/pack` は通っても、実行時に `requested host_api.websocket permission is missing` / `manifest host_api.websocket target declaration is missing` 系で拒否されるはず。 + +Required fixes: +- `resources/plugin/templates/rust-component-instance/plugin.toml` に少なくとも以下を追加する。 + - `permissions` の `{ kind = "host_api", api = "websocket" }` + - `[[websocket]]` target declaration(例: `scheme = "wss"`, `host = "example.com"`, `path_prefixes = ["/socket"]`) +- template/CLI tests に、service template が `host_api.websocket` permission と matching `[[websocket]]` declaration を含むことの assertion を追加する。 +- 必要なら README/docs で、enablement grants も同じ WebSocket target を明示的に許可する必要があることを補足する。 + +Good points: +- WIT は `handle-ingress`、ingress event envelope、`output_commands` / `websocket_send` 例を記述している。 +- PDK は `PluginIngressEvent`、`ServiceOutput`、`ServiceOutputCommand`、`websocket_text()` / `websocket_send()` helper を追加し、runtime envelope 形状に概ね合っている。 +- Service template は `recv(timeout)` polling loop を推奨しておらず、`handle_ingress` + `ServiceOutput::websocket_send` の形になっている。 +- template `runtime.kind` は `wasm-component` で、raw `wasm` template 復活は見当たらない。 +- `yoi plugin new rust-component-service` は既存の path-safety 方針上に追加されており、広い CLI redesign や runtime reimplementation は見当たらない。 + +Validation: +- reviewer は read-only 指示のため cargo/nix/git diff validation は再実行していない。 +- Ticket item/thread、IntentPacket、`cd200630..HEAD` の変更、WIT / PDK / template / CLI / docs / runtime envelope 関連箇所を静的確認した。 + +--- From 6c8998878dc1d59ad70da3280efd503a9a060ebb Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 16:52:20 +0900 Subject: [PATCH 43/46] fix: declare plugin service websocket authority --- crates/plugin-pdk/tests/template.rs | 23 +++++++++++++++---- crates/yoi/src/plugin_cli.rs | 4 ++++ docs/development/plugin-development.md | 14 +++++++++++ .../rust-component-instance/README.md | 2 +- .../rust-component-instance/plugin.toml | 6 +++++ 5 files changed, 44 insertions(+), 5 deletions(-) diff --git a/crates/plugin-pdk/tests/template.rs b/crates/plugin-pdk/tests/template.rs index 526abb34..82b8ff13 100644 --- a/crates/plugin-pdk/tests/template.rs +++ b/crates/plugin-pdk/tests/template.rs @@ -72,6 +72,14 @@ fn rust_component_service_template_has_event_output_pattern() { plugin["runtime"]["world"].as_str(), Some("yoi:plugin/instance@1.0.0") ); + assert!( + plugin["permissions"] + .as_array() + .expect("permissions array") + .iter() + .any(|permission| permission["kind"].as_str() == Some("host_api") + && permission["api"].as_str() == Some("websocket")) + ); assert_eq!( plugin["services"].as_array().expect("services array").len(), 1 @@ -89,10 +97,17 @@ fn rust_component_service_template_has_event_output_pattern() { .as_array() .expect("sources") .iter() - .any(|source| source - .as_str() - .unwrap_or_default() - .starts_with("websocket:wss://")) + .any(|source| source.as_str() == Some("websocket:wss://example.com/socket")) + ); + let websocket = &plugin["websocket"].as_array().expect("websocket targets")[0]; + assert_eq!(websocket["scheme"].as_str(), Some("wss")); + assert_eq!(websocket["host"].as_str(), Some("example.com")); + assert!( + websocket["path_prefixes"] + .as_array() + .expect("websocket path prefixes") + .iter() + .any(|prefix| prefix.as_str() == Some("/socket")) ); assert!(SERVICE_TEMPLATE_LIB.contains("world: \"instance\"")); diff --git a/crates/yoi/src/plugin_cli.rs b/crates/yoi/src/plugin_cli.rs index 69ffe40a..c54251b3 100644 --- a/crates/yoi/src/plugin_cli.rs +++ b/crates/yoi/src/plugin_cli.rs @@ -2188,6 +2188,10 @@ mod tests { assert!(manifest.contains("kind = \"wasm-component\"")); assert!(manifest.contains("[[services]]")); assert!(manifest.contains("[[ingresses]]")); + assert!(manifest.contains("{ kind = \"host_api\", api = \"websocket\" }")); + assert!(manifest.contains("[[websocket]]")); + assert!(manifest.contains("host = \"example.com\"")); + assert!(manifest.contains("path_prefixes = [\"/socket\"]")); let source = fs::read_to_string(service_destination.join("src/lib.rs")).unwrap(); assert!(source.contains("ServiceOutput::websocket_send")); assert!(!source.contains("recv(timeout")); diff --git a/docs/development/plugin-development.md b/docs/development/plugin-development.md index 68a31066..0e745dd7 100644 --- a/docs/development/plugin-development.md +++ b/docs/development/plugin-development.md @@ -352,6 +352,13 @@ A minimal manifest shape is: ```toml surfaces = ["tool", "service", "ingress"] +permissions = [ + { kind = "surface", surface = "service" }, + { kind = "service", name = "example_service" }, + { kind = "surface", surface = "ingress" }, + { kind = "ingress", name = "example_ws" }, + { kind = "host_api", api = "websocket" }, +] [runtime] kind = "wasm-component" @@ -369,8 +376,15 @@ description = "Handles host-owned WebSocket text events." event_kinds = ["websocket_text", "websocket_close", "websocket_error"] sources = ["websocket:wss://gateway.example.com/gateway"] input_schema = { type = "object" } + +[[websocket]] +scheme = "wss" +host = "gateway.example.com" +path_prefixes = ["/gateway"] ``` +The `host_api.websocket` permission and `[[websocket]]` target are required for `websocket_send` output commands. Runtime enablement grants must explicitly allow the same WebSocket target; the manifest declaration alone is not authority. + Generate a fuller example with `yoi plugin new rust-component-service ./my-service-plugin`. ## `websocket` host API diff --git a/resources/plugin/templates/rust-component-instance/README.md b/resources/plugin/templates/rust-component-instance/README.md index 6682aed4..8a9930bc 100644 --- a/resources/plugin/templates/rust-component-instance/README.md +++ b/resources/plugin/templates/rust-component-instance/README.md @@ -5,6 +5,6 @@ This template targets the Component Model-only runtime (`runtime.kind = "wasm-co It demonstrates both authoring surfaces supported by a shared Plugin instance: - `example_echo` is an ordinary request/response Tool handler. -- `example_ws` is a Service ingress handler. The host owns WebSocket receive/reconnect work and dispatches bounded `websocket_text` events into `handle_ingress`. The guest replies by returning a `websocket_send` output command in `ServiceOutput`; do not run a guest-side `recv(timeout)` polling loop. +- `example_ws` is a Service ingress handler. The host owns WebSocket receive/reconnect work and dispatches bounded `websocket_text` events into `handle_ingress`. The guest replies by returning a `websocket_send` output command in `ServiceOutput`; do not run a guest-side `recv(timeout)` polling loop. The manifest declares `host_api.websocket` plus a matching `[[websocket]]` target for the example URL. Enablement grants must explicitly allow the same WebSocket target before the host will send output commands. Build with `cargo component build --release` (or the project-specific build command used by your Plugin packaging flow), then run `yoi plugin check` / `yoi plugin pack` from the generated Plugin directory. diff --git a/resources/plugin/templates/rust-component-instance/plugin.toml b/resources/plugin/templates/rust-component-instance/plugin.toml index a7bfd629..59495d37 100644 --- a/resources/plugin/templates/rust-component-instance/plugin.toml +++ b/resources/plugin/templates/rust-component-instance/plugin.toml @@ -11,6 +11,7 @@ permissions = [ { kind = "service", name = "example_service" }, { kind = "surface", surface = "ingress" }, { kind = "ingress", name = "example_ws" }, + { kind = "host_api", api = "websocket" }, ] [runtime] @@ -34,3 +35,8 @@ description = "Handles host-owned WebSocket text events and returns websocket_se event_kinds = ["websocket_text"] sources = ["websocket:wss://example.com/socket"] input_schema = { type = "object" } + +[[websocket]] +scheme = "wss" +host = "example.com" +path_prefixes = ["/socket"] From d2a8a79ac64327cf4260038731faf67f212de7b8 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 16:53:19 +0900 Subject: [PATCH 44/46] ticket: record plugin pdk template fix --- .yoi/tickets/00001KVXK0WEA/item.md | 2 +- .yoi/tickets/00001KVXK0WEA/thread.md | 42 ++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVXK0WEA/item.md b/.yoi/tickets/00001KVXK0WEA/item.md index 99a59a5a..5c3282a4 100644 --- a/.yoi/tickets/00001KVXK0WEA/item.md +++ b/.yoi/tickets/00001KVXK0WEA/item.md @@ -2,7 +2,7 @@ title: 'Update Plugin WIT PDK templates for service event runtime' state: 'inprogress' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-25T07:44:48Z' +updated_at: '2026-06-25T07:53:08Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:12:05Z' diff --git a/.yoi/tickets/00001KVXK0WEA/thread.md b/.yoi/tickets/00001KVXK0WEA/thread.md index b9521085..c101570f 100644 --- a/.yoi/tickets/00001KVXK0WEA/thread.md +++ b/.yoi/tickets/00001KVXK0WEA/thread.md @@ -226,3 +226,45 @@ Validation: - Ticket item/thread、IntentPacket、`cd200630..HEAD` の変更、WIT / PDK / template / CLI / docs / runtime envelope 関連箇所を静的確認した。 --- + + + +## Implementation report + +Follow-up implementation report after review request_changes: + +対応内容: +- Reviewer blocker に対応し、Service template の `websocket_send` output command に必要な runtime authority を追加した。 + +変更点: +- `resources/plugin/templates/rust-component-instance/plugin.toml` + - `permissions` に `{ kind = "host_api", api = "websocket" }` を追加。 + - matching target として `[[websocket]]` を追加。 + - `scheme = "wss"` + - `host = "example.com"` + - `path_prefixes = ["/socket"]` +- `crates/plugin-pdk/tests/template.rs` + - service template manifest に `host_api.websocket` permission が含まれることを検証。 + - `[[websocket]]` target が `wss://example.com/socket` に対応していることを検証。 +- `crates/yoi/src/plugin_cli.rs` + - `yoi plugin new rust-component-service ...` で生成される manifest に `host_api.websocket` permission / `[[websocket]]` declaration が含まれることを CLI test に追加。 +- `resources/plugin/templates/rust-component-instance/README.md` + - manifest 側の `host_api.websocket` / `[[websocket]]` declaration と、enablement grants 側でも同じ WebSocket target を明示許可する必要があることを追記。 +- `docs/development/plugin-development.md` + - Service ingress / output command の minimal manifest example に `host_api.websocket` permission と `[[websocket]]` target を追加。 + - `websocket_send` output command には manifest declaration だけでなく runtime enablement grants も必要であることを明記。 + +Validation: +- `cargo test -p yoi-plugin-pdk`: success +- `cargo test -p yoi plugin`: success +- `cargo check -p yoi`: success +- `git diff --check`: success +- `nix build .#yoi --no-link`: success (`nix-exit:0`) + +Commit: +- `6c8998878dc1d59ad70da3280efd503a9a060ebb fix: declare plugin service websocket authority` + +Residual risks / notes: +- なし。runtime envelope の再設計、raw wasm compatibility、protocol-specific integration、secret/reconnect policy、runtime reimplementation は行っていない。 + +--- From 307d38453f9e3fee69cc9f7e8f5c2acb30c1745b Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 16:55:35 +0900 Subject: [PATCH 45/46] ticket: approve plugin pdk service events --- .yoi/tickets/00001KVXK0WEA/item.md | 2 +- .yoi/tickets/00001KVXK0WEA/thread.md | 32 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVXK0WEA/item.md b/.yoi/tickets/00001KVXK0WEA/item.md index 5c3282a4..07e3aeec 100644 --- a/.yoi/tickets/00001KVXK0WEA/item.md +++ b/.yoi/tickets/00001KVXK0WEA/item.md @@ -2,7 +2,7 @@ title: 'Update Plugin WIT PDK templates for service event runtime' state: 'inprogress' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-25T07:53:08Z' +updated_at: '2026-06-25T07:55:26Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:12:05Z' diff --git a/.yoi/tickets/00001KVXK0WEA/thread.md b/.yoi/tickets/00001KVXK0WEA/thread.md index c101570f..3ca1b1f1 100644 --- a/.yoi/tickets/00001KVXK0WEA/thread.md +++ b/.yoi/tickets/00001KVXK0WEA/thread.md @@ -268,3 +268,35 @@ Residual risks / notes: - なし。runtime envelope の再設計、raw wasm compatibility、protocol-specific integration、secret/reconnect policy、runtime reimplementation は行っていない。 --- + + + +## Review: approve + +Review result: approve + +前回 blocker は解消されている。 + +確認した証拠: +- `resources/plugin/templates/rust-component-instance/plugin.toml` + - `permissions` に `{ kind = "host_api", api = "websocket" }` が追加済み。 + - `[[websocket]]` target が追加済みで、`wss://example.com/socket` と一致する `scheme = "wss"`, `host = "example.com"`, `path_prefixes = ["/socket"]` を宣言している。 + - `runtime.kind = "wasm-component"` のままで、raw `wasm` 互換は導入されていない。 +- Service template README: + - `websocket_send` には manifest の `host_api.websocket` と matching `[[websocket]]` target が必要で、enablement grants も同じ target を許可する必要がある、と明記済み。 +- Development docs: + - Service ingress/output command 節で `websocket_send` に必要な `host_api.websocket` permission / `[[websocket]]` target / enablement grant 境界を説明している。 + - polling `recv(timeout)` loop ではなく ingress event / output command pattern を推奨する説明を維持している。 +- Tests: + - PDK template test が service template manifest の websocket permission と matching target を assert するよう更新されている。 + - CLI `plugin new rust-component-service` test も生成 manifest に websocket permission / `[[websocket]]` / host / path_prefix が含まれることを assert している。 + +リスク確認: +- 前回懸念した「template は `ServiceOutput::websocket_send` を返すのに manifest authority が足りない」不整合は修正済み。 +- 今回差分は template/docs/tests の authority declaration 補正に閉じており、runtime redesign、protocol-specific integration、secret injection、raw-WASM compatibility の追加は見当たらない。 + +検証: +- reviewer は read-only 指定のため `cargo` / `nix` / `git diff --check` は再実行していない。 +- `7a4fd975..HEAD` と fix commit `6c899887` の静的レビューのみ実施。 + +--- From c261aea021d1f376c604dd6d75f02d28c8765f47 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 25 Jun 2026 16:57:31 +0900 Subject: [PATCH 46/46] ticket: mark plugin pdk service events done --- .yoi/tickets/00001KVXK0WEA/item.md | 4 +- .yoi/tickets/00001KVXK0WEA/thread.md | 61 ++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KVXK0WEA/item.md b/.yoi/tickets/00001KVXK0WEA/item.md index 07e3aeec..102bcb74 100644 --- a/.yoi/tickets/00001KVXK0WEA/item.md +++ b/.yoi/tickets/00001KVXK0WEA/item.md @@ -1,8 +1,8 @@ --- title: 'Update Plugin WIT PDK templates for service event runtime' -state: 'inprogress' +state: 'done' created_at: '2026-06-24T19:51:56Z' -updated_at: '2026-06-25T07:55:26Z' +updated_at: '2026-06-25T07:57:15Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-24T20:12:05Z' diff --git a/.yoi/tickets/00001KVXK0WEA/thread.md b/.yoi/tickets/00001KVXK0WEA/thread.md index 3ca1b1f1..ee7b4528 100644 --- a/.yoi/tickets/00001KVXK0WEA/thread.md +++ b/.yoi/tickets/00001KVXK0WEA/thread.md @@ -300,3 +300,64 @@ Review result: approve - `7a4fd975..HEAD` と fix commit `6c899887` の静的レビューのみ実施。 --- + + + +## Implementation report + +Integration outcome: + +- Coder `yoi-coder-00001KVXK0WEA-pdk-service-events` が implementation branch `work/00001KVXK0WEA-plugin-pdk-service-events` に実装と review fix を commit した。 + - implementation commit: `7a4fd975 feat: update plugin service authoring templates` + - fix commit: `6c899887 fix: declare plugin service websocket authority` +- Reviewer `yoi-reviewer-00001KVXK0WEA-pdk-service-events` は初回 review で request_changes。Service template が `ServiceOutput::websocket_send` を返す一方で `host_api.websocket` permission と matching `[[websocket]]` target declaration を持たない点を blocker とした。 +- Coder は service template manifest / tests / docs を修正し、follow-up review は `approve`。template authority declaration、enablement grants guidance、runtime envelope alignment、raw-WASM compatibility 非復活を確認した。 +- Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration` で branch `work/00001KVXK0WEA-plugin-pdk-service-events` を merge 済み。 + - merge commit: `8d4fee23 merge: 00001KVXK0WEA plugin pdk service events` + +Implemented behavior: +- WIT docs/descriptionsを Service ingress event JSON と `output_commands` / `websocket_send` authoring に合わせて更新。 +- `crates/plugin-pdk` に `PluginIngressEvent` 拡張、`ServiceOutput` / `ServiceOutputCommand` / `ServiceOutputCommandKind`、`websocket_text` / `websocket_send` helpers を追加。 +- `Plugin::handle_ingress` returns `ServiceOutput`。 +- `resources/plugin/templates/rust-component-instance` を service-oriented template に更新し、`handle_ingress` + `ServiceOutput::websocket_send` pattern を示す。 +- service template `plugin.toml` は `runtime.kind = "wasm-component"`、`host_api.websocket` permission、matching `[[websocket]]` target を含む。 +- `yoi plugin new rust-component-service ` を追加し、new/check/pack consistency tests を更新。 +- docs は long-running guest `recv(timeout)` loop ではなく host-owned ingress event + output command model を推奨し、enablement grants でも matching websocket target が必要と明記。 + +Validation in Orchestrator worktree: +- `cargo test -p yoi-plugin-pdk`: success +- `cargo test -p yoi plugin`: success +- `cargo check -p yoi`: success +- `git diff --check`: success +- `nix build .#yoi --no-link`: success +- Template cargo-check is covered by `cargo test -p yoi-plugin-pdk` using temporary target dirs. + +Notes: +- This Ticket completes the queued Plugin runtime redesign chain slices that were split from the original broad redesign request. +- No runtime envelope redesign, protocol-specific integration, secret injection, reconnect policy, runtime reimplementation, or raw-WASM compatibility was introduced. + +Next action: +- Mark Ticket done after this integration/validation evidence. +- Then stop related child Pods and remove only the child implementation worktree/branch. + +--- + + + +## State changed + +Implementation is merged into the orchestration branch and validated. + +Evidence: +- merge commit: `8d4fee23 merge: 00001KVXK0WEA plugin pdk service events` +- reviewer result: approve after requested changes were fixed +- validation in `/home/hare/Projects/yoi/.worktree/orchestration` succeeded: + - `cargo test -p yoi-plugin-pdk` + - `cargo test -p yoi plugin` + - `cargo check -p yoi` + - `git diff --check` + - `nix build .#yoi --no-link` + +Closure is not performed here; this state records implementation completion after merge/validation. + +---