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