merge: integrate orchestration branch

This commit is contained in:
Keisuke Hirata 2026-06-21 18:01:45 +09:00
commit 3fc0dd0bde
No known key found for this signature in database
37 changed files with 5276 additions and 455 deletions

View File

@ -1,8 +1,8 @@
--- ---
title: 'Workspace web control plane bootstrap' title: 'Workspace web control plane bootstrap'
state: 'inprogress' state: 'closed'
created_at: '2026-06-21T06:57:06Z' created_at: '2026-06-21T06:57:06Z'
updated_at: '2026-06-21T07:15:03Z' updated_at: '2026-06-21T07:46:46Z'
assignee: null assignee: null
queued_by: 'workspace-panel' queued_by: 'workspace-panel'
queued_at: '2026-06-21T07:11:58Z' queued_at: '2026-06-21T07:11:58Z'

View File

@ -0,0 +1,25 @@
Workspace web control plane bootstrap を実装し、Orchestrator worktree の `orchestration` branch に統合した。
主な成果:
- New backend library crate `yoi-workspace-server` / `crates/workspace-server` を追加。
- Axum-based read-only HTTP API と static/SPA serving surface を追加。
- `/api/...` と static/SPA fallback を分離し、API route miss を SPA fallback に飲ませない設計にした。
- `ControlPlaneStore` trait と SQLite implementation `SqliteWorkspaceStore` を追加。
- SQLite migration/version table、WAL、foreign keys、busy timeout を設定。
- `.yoi/tickets``.yoi/objectives` を canonical read sources として扱う local project-record bridge を追加し、既存 record workflow を移行・変更しない。
- Read APIs: `/api/workspace`, `/api/tickets`, `/api/tickets/{id}`, `/api/objectives`, `/api/objectives/{id}`, `/api/runs`, `/api/runners`
- Future runner/event-stream extension seams を response/state shape に用意しつつ、scheduler/runner dispatch/write API は実装しない。
- SvelteKit static SPA skeleton を `web/workspace` に追加し、npm lockfile、static adapter、README、generated artifact ignore/source-filter handling を追加。
- `Cargo.lock``package.nix` cargo hash / source filtering を更新。
統合・検証:
- Merge commit: `3e03e536 merge: workspace web control plane`
- Implementation commit: `ab7658c1 feat: bootstrap workspace web control plane`
- Reviewer final verdict: approve
- Validation passed: `cargo fmt --check`, `git diff --check HEAD^1..HEAD`, `cargo test -p yoi-workspace-server`, `cargo check -p yoi-workspace-server`, `cargo check -p yoi`, `cd web/workspace && npm ci && npm run check && npm run build`, `cargo run -p yoi -- ticket doctor`, `cargo run -p yoi -- objective doctor`, and `nix build .#yoi --no-link`
範囲外 / deferrals:
- Product CLI/server launch command は未追加。backend library exposes `serve(...)`; launch surface は future Ticket で設計する。
- Write API、runner job dispatch、scheduler、hosted/multi-tenant auth、billing/quota、memory migration は実装していない。
- Event stream implementation は未実装で、extension seam のみ。
- Generated SPA assets are not committed; configured static directory such as `web/workspace/build` can be served after frontend build。

View File

@ -152,3 +152,287 @@ Next action:
- Wait for Coder implementation report, then spawn Reviewer read-only for the implementation diff. Orchestrator will not merge/close until reviewer approval and validation evidence are available。 - Wait for Coder implementation report, then spawn Reviewer read-only for the implementation diff. Orchestrator will not merge/close until reviewer approval and validation evidence are available。
--- ---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T07:37:30Z -->
## Implementation report
Coder implementation report received from `yoi-coder-00001KVMFFYVX`.
Implementation commit:
- `ab7658c1 feat: bootstrap workspace web control plane`
Changed areas:
- New Cargo workspace crate:
- `crates/workspace-server`
- HTTP router with `/api/...` separation from static/SPA fallback。
- Local/dev workspace API state carrying `workspace_id`
- Bounded read APIs for `/api/workspace`, `/api/tickets`, `/api/tickets/{id}`, `/api/objectives`, `/api/objectives/{id}`, `/api/runs`, `/api/runners`
- Reserved extension-point response fields for future event streams and runner connections。
- Store abstraction and SQLite implementation:
- `ControlPlaneStore` trait。
- `SqliteWorkspaceStore`
- migration/version table。
- WAL, foreign keys, busy timeout。
- initial schema for workspace/runtime/projection seams while keeping `.yoi` records canonical。
- Local project-record bridge:
- reads existing `.yoi/tickets`
- reads existing `.yoi/objectives`
- does not migrate or mutate canonical records。
- SvelteKit static SPA skeleton:
- `web/workspace`
- npm + committed `package-lock.json`
- static adapter build output to `web/workspace/build`
- generated artifact handling documented in `web/workspace/README.md`
- Packaging/source handling:
- added Rust dependencies and updated `Cargo.lock`
- updated `package.nix` cargo hash。
- excluded generated frontend directories from Nix package source filtering。
Coder validation reported:
- `cargo fmt --check`: passed
- `cargo test -p yoi-workspace-server`: passed
- `cargo check -p yoi-workspace-server`: passed
- `cargo check -p yoi`: passed
- `cd web/workspace && npm run check && npm run build`: passed
- `git diff --check`: passed
- `cargo run -p yoi -- ticket doctor`: passed
- `cargo run -p yoi -- objective doctor`: passed
- `nix build .#yoi --no-link`: passed after updating `cargoHash`
Coder dependency/package/frontend decisions:
- Used `axum` for HTTP routing。
- Used `rusqlite` with bundled SQLite to avoid system SQLite packaging requirements。
- Backend remains a library crate, not a product CLI façade。
- No product `yoi` CLI command was added in this slice。
- Frontend uses npm/SvelteKit static adapter with committed lockfile; generated build output is not checked in。
- Nix package does not build frontend in this bootstrap; it excludes generated frontend artifacts from source filtering。
Known deferrals reported:
- No write API。
- No runner job dispatch or scheduler。
- No hosted/multi-tenant auth or SaaS behavior。
- No event stream implementation yet; only reserved extension-point seam。
- No checked-in generated SPA assets; backend can serve configured static directory such as `web/workspace/build` after running frontend build。
Orchestrator evidence checked:
- Implementation worktree is clean on `impl/00001KVMFFYVX-workspace-web-control-plane`
- HEAD is `ab7658c1`
- Diff `1d27f6c9..HEAD` touches expected backend/frontend/package files: 18 files, about 3377 insertions / 1 deletion。
- `git diff --check 1d27f6c9..HEAD` produced no diagnostics。
Next action:
- Route to read-only Reviewer Pod for acceptance review against the Ticket and Objective context。
---
<!-- event: comment author: yoi-orchestrator at: 2026-06-21T07:38:14Z -->
## Comment
Review routing:
Spawned read-only Reviewer Pod:
- `yoi-reviewer-00001KVMFFYVX-r1`
Review target:
- Worktree: `/home/hare/Projects/yoi/.worktree/00001KVMFFYVX-workspace-web-control-plane`
- Branch: `impl/00001KVMFFYVX-workspace-web-control-plane`
- Baseline: `1d27f6c9`
- Implementation commit: `ab7658c1 feat: bootstrap workspace web control plane`
- Diff: `1d27f6c9..ab7658c1`
Reviewer scope:
- read-only `/home/hare/Projects/yoi`
Reviewer focus:
- Acceptance criteria for backend crate, store abstraction + SQLite, static SPA, bounded read APIs, static/API route separation, package/Nix/frontend hygiene。
- No write API / scheduler / SaaS overreach。
- `.yoi` Ticket / Objective records remain canonical and read-only。
- SQLite migrations/settings are deterministic and server-appropriate。
- Frontend generated artifacts are not committed and source filtering is safe。
- Validation evidence includes Rust tests/checks, frontend check/build, doctors, and `nix build .#yoi --no-link`
Orchestrator will wait for reviewer verdict before integration。
---
<!-- event: review author: yoi-reviewer-00001KVMFFYVX-r1 at: 2026-06-21T07:44:49Z status: approve -->
## Review: approve
Verdict: `approve`
確認範囲:
- Ticket/context:
- `.yoi/tickets/00001KVMFFYVX/item.md`
- `.yoi/tickets/00001KVMFFYVX/thread.md`
- `.yoi/objectives/00001KVJPT2PP/item.md`
- Diff `1d27f6c9..ab7658c1`:
- `Cargo.toml`
- `Cargo.lock`
- `crates/workspace-server/Cargo.toml`
- `crates/workspace-server/src/lib.rs`
- `crates/workspace-server/src/server.rs`
- `crates/workspace-server/src/store.rs`
- `crates/workspace-server/src/records.rs`
- `package.nix`
- `web/workspace/package.json`
- `web/workspace/package-lock.json`
- `web/workspace/.gitignore`
- `web/workspace/README.md`
- `web/workspace/svelte.config.js`
- `web/workspace/vite.config.ts`
- `web/workspace/tsconfig.json`
- `web/workspace/src/app.html`
- `web/workspace/src/routes/+layout.ts`
- `web/workspace/src/routes/+page.svelte`
Blocking issues:
- None found。
Acceptance verification:
- New `yoi-workspace-server` crate is a library/backend crate, not a product CLI façade。
- Existing `yoi` CLI ownership is preserved; `yoi` does not depend on the new crate。
- HTTP routes are read-only `GET` routes for `/api/workspace`, `/api/tickets`, `/api/tickets/{id}`, `/api/objectives`, `/api/objectives/{id}`, `/api/runs`, `/api/runners`
- SPA/static fallback explicitly rejects `/api` and `/api/...`, so API paths are not swallowed by SPA fallback。
- `.yoi/tickets` and `.yoi/objectives` remain canonical read sources; no `.yoi` write/migration path was introduced。
- Store abstraction is explicit via `ControlPlaneStore`; SQLite is behind `SqliteWorkspaceStore`
- SQLite setup includes migrations/version table, WAL, foreign keys, and busy timeout。
- Runtime tables for runs/runners are skeletal/read-only and do not implement scheduling or dispatch。
- Auth is explicitly represented as local/dev placeholder, not production SaaS auth。
- SvelteKit frontend is static SPA skeleton with npm lockfile; generated `node_modules`, `.svelte-kit`, and `build` are ignored/excluded。
- `package.nix` source filtering excludes frontend generated artifacts; `nix build .#yoi --no-link` passed with updated cargo hash。
Non-blocking concerns:
- No committed CLI/binary entrypoint or full listener smoke test for starting the server end-to-end. Library exposes `serve(...)` and router tests cover API/static separation, acceptable for bootstrap; follow-up should add launch surface when product command is designed。
- Detail responses for tickets/objectives do not include `workspace_id`, while list/runtime responses do. Future multi-workspace API expansion should make detail shapes consistently workspace-qualified。
- Missing valid objective IDs appear likely to map filesystem `Io` errors to HTTP 500 rather than 404. Tighten when read API contract is hardened。
- Static path traversal rejects `..`, but symlink escape hardening is not implemented. Acceptable for configured local build/static directory and local/dev bootstrap, revisit before non-local exposure。
Reviewer validation:
- `cargo fmt --check`: passed
- `git diff --check 1d27f6c9..HEAD`: passed
- `cargo test -p yoi-workspace-server`: passed
- `cargo check -p yoi-workspace-server`: passed
- `cargo check -p yoi`: passed
- `cd web/workspace && npm run check && npm run build`: initially failed because `node_modules` absent。
- `cd web/workspace && npm ci && npm run check && npm run build`: passed
- `cargo run -p yoi -- ticket doctor`: passed
- `cargo run -p yoi -- objective doctor`: passed
- `nix build .#yoi --no-link`: passed
- Focused route/static validation:
- Inspected router definitions and fallback logic。
- Existing `yoi-workspace-server` tests exercise `/api/workspace`, `/api/tickets`, `/api/objectives`, `/api/runners`, static asset serving, SPA fallback, and `/api/...` miss separation。
- Store migration/persistence and local record read bridge are covered by crate tests。
Worktree status:
- Tracked worktree clean at `ab7658c1` on `impl/00001KVMFFYVX-workspace-web-control-plane`
- Ignored validation artifacts remain in the implementation worktree: `web/workspace/node_modules/`, `web/workspace/.svelte-kit/`, `web/workspace/build/`
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T07:45:59Z -->
## Implementation report
Reviewer r1 approvalを受けて、実装ブランチを Orchestrator worktree の `orchestration` branch に統合した。
統合:
- Merge commit: `3e03e536 merge: workspace web control plane`
- Merged branch: `impl/00001KVMFFYVX-workspace-web-control-plane`
- Implementation commit:
- `ab7658c1 feat: bootstrap workspace web control plane`
Orchestrator worktree validation:
- `cargo fmt --check`: passed
- `git diff --check HEAD^1..HEAD`: passed
- `cargo test -p yoi-workspace-server`: passed
- `cargo check -p yoi-workspace-server`: passed
- `cargo check -p yoi`: passed
- `cd web/workspace && npm ci && npm run check && npm run build`: passed
- `npm ci` reported 4 low severity vulnerabilities via npm audit output; build/check succeeded and no blocking validation failure occurred。
- `cargo run -p yoi -- ticket doctor`: passed (`doctor: ok`)
- `cargo run -p yoi -- objective doctor`: passed (`doctor: ok`)
- `nix build .#yoi --no-link`: passed
Validation generated ignored frontend artifacts in Orchestrator worktree:
- `web/workspace/node_modules/`
- `web/workspace/.svelte-kit/`
- `web/workspace/build/`
These were removed after validation. Final Orchestrator worktree status after validation cleanup is clean on `orchestration` at `3e03e536`
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-21T07:46:04Z from: inprogress to: done reason: implementation_merged_and_validated field: state -->
## State changed
Reviewer approval、Orchestrator worktree への統合、Rust/frontend/Nix/doctor validation が完了したため `done` に遷移する。
---
<!-- event: state_changed author: hare at: 2026-06-21T07:46:19Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-21T07:46:19Z status: closed -->
## 完了
Workspace web control plane bootstrap を実装し、Orchestrator worktree の `orchestration` branch に統合した。
主な成果:
- New backend library crate `yoi-workspace-server` / `crates/workspace-server` を追加。
- Axum-based read-only HTTP API と static/SPA serving surface を追加。
- `/api/...` と static/SPA fallback を分離し、API route miss を SPA fallback に飲ませない設計にした。
- `ControlPlaneStore` trait と SQLite implementation `SqliteWorkspaceStore` を追加。
- SQLite migration/version table、WAL、foreign keys、busy timeout を設定。
- `.yoi/tickets``.yoi/objectives` を canonical read sources として扱う local project-record bridge を追加し、既存 record workflow を移行・変更しない。
- Read APIs: `/api/workspace`, `/api/tickets`, `/api/tickets/{id}`, `/api/objectives`, `/api/objectives/{id}`, `/api/runs`, `/api/runners`
- Future runner/event-stream extension seams を response/state shape に用意しつつ、scheduler/runner dispatch/write API は実装しない。
- SvelteKit static SPA skeleton を `web/workspace` に追加し、npm lockfile、static adapter、README、generated artifact ignore/source-filter handling を追加。
- `Cargo.lock``package.nix` cargo hash / source filtering を更新。
統合・検証:
- Merge commit: `3e03e536 merge: workspace web control plane`
- Implementation commit: `ab7658c1 feat: bootstrap workspace web control plane`
- Reviewer final verdict: approve
- Validation passed: `cargo fmt --check`, `git diff --check HEAD^1..HEAD`, `cargo test -p yoi-workspace-server`, `cargo check -p yoi-workspace-server`, `cargo check -p yoi`, `cd web/workspace && npm ci && npm run check && npm run build`, `cargo run -p yoi -- ticket doctor`, `cargo run -p yoi -- objective doctor`, and `nix build .#yoi --no-link`
範囲外 / deferrals:
- Product CLI/server launch command は未追加。backend library exposes `serve(...)`; launch surface は future Ticket で設計する。
- Write API、runner job dispatch、scheduler、hosted/multi-tenant auth、billing/quota、memory migration は実装していない。
- Event stream implementation は未実装で、extension seam のみ。
- Generated SPA assets are not committed; configured static directory such as `web/workspace/build` can be served after frontend build。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T07:46:46Z -->
## Implementation report
Post-close cleanup completed。
- Stopped child Pods and reclaimed scope:
- `yoi-coder-00001KVMFFYVX`
- `yoi-reviewer-00001KVMFFYVX-r1`
- Removed ignored frontend validation artifacts from child worktree before worktree removal:
- `web/workspace/node_modules/`
- `web/workspace/.svelte-kit/`
- `web/workspace/build/`
- Removed implementation worktree:
- `/home/hare/Projects/yoi/.worktree/00001KVMFFYVX-workspace-web-control-plane`
- Deleted implementation branch:
- `impl/00001KVMFFYVX-workspace-web-control-plane`
- Orchestrator worktree remains clean on `orchestration` at `f33415c7`
Root/original workspace was not used for merge/validation/cleanup operations。
---

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260621-071725-1","ticket_id":"00001KVMG8FTW","kind":"accepted_plan","accepted_plan":{"summary":"Refactor Plugin one-shot outbound host API from `host_api.https` / `grants.https` to URL-permission-based `host_api.request`, including manifest request targets, enablement grants, runtime two-stage authorization, local/private explicit permissions, inspection diagnostics, docs/templates, and focused tests.","branch":"impl/00001KVMG8FTW-plugin-request-host-api","worktree":"/home/hare/Projects/yoi/.worktree/00001KVMG8FTW-plugin-request-host-api","role_plan":"Orchestrator accepts parallel implementation, creates a child worktree, and spawns a narrow-scope Coder. Reviewer will be spawned read-only after Coder reports implementation commit(s). After approval, Orchestrator will integrate into `orchestration`, validate, record closure, and clean only the child worktree/branch. Coordinate manually if active Workspace web branch creates Cargo.lock/package.nix conflicts."},"author":"yoi-orchestrator","at":"2026-06-21T07:17:25Z"}

View File

@ -1,8 +1,8 @@
--- ---
title: 'Plugin: host_api.https を廃止して URL 権限ベースの host_api.request に統合する' title: 'Plugin: host_api.https を廃止して URL 権限ベースの host_api.request に統合する'
state: 'queued' state: 'closed'
created_at: '2026-06-21T07:10:30Z' created_at: '2026-06-21T07:10:30Z'
updated_at: '2026-06-21T07:15:41Z' updated_at: '2026-06-21T08:12:34Z'
assignee: null assignee: null
readiness: 'implementation_ready' readiness: 'implementation_ready'
risk_flags: ['plugin', 'host-api', 'public-api', 'permissions', 'security', 'local-network', 'breaking-change'] risk_flags: ['plugin', 'host-api', 'public-api', 'permissions', 'security', 'local-network', 'breaking-change']

View File

@ -0,0 +1,23 @@
Plugin host API の one-shot outbound request capability を `host_api.https` / `grants.https` から URL permission based `host_api.request` に置き換え、Orchestrator worktree の `orchestration` branch に統合した。
主な成果:
- Active API / docs / WIT naming を `request` に移行。
- Manifest に `host_api.request``[[request]]` target declaration を追加。
- Enablement grant を request target grant として扱うよう変更。
- Runtime authorization を manifest-declared request target と enabled request grant の両方が URL/method/scheme/host/port/path coverage で許可する場合のみ network I/O に進む形にした。
- Grant-only / missing-grant / broad / partial-coverage states を static inspection と `yoi plugin show` diagnostics で区別。
- Broad/covering grant と broad manifest + narrower grant の intersection semantics を runtime と static inspection で一致させた。
- Loopback/local/private target は ambient ではなく、URL host declaration + grant に基づく明示 authority として扱う方針を docs に記録。
- Embedded credentials、credential-like headers、WebSocket URLs/upgrades、SSE/event-stream requests を reject/unsupported にした。
- Old `host_api.https` / `grants.https` / `PluginHttps*` / old WIT names は active code/docs/resources から削除。
- Focused manifest / pod / yoi plugin CLI tests を追加・更新。
統合・検証:
- Merge commit: `8a15cca5 merge: plugin request host api`
- Implementation commits: `962b7699`, `0e14e7c1`
- Reviewer final verdict: approve
- Validation passed: `cargo fmt --check`, `git diff --check HEAD^1..HEAD`, `cargo test -p manifest request --quiet`, `cargo test -p pod feature::plugin::tests --lib --quiet`, `cargo test -p yoi plugin_cli::tests --quiet`, `cargo check -p manifest -p pod -p yoi --quiet`, stale active naming grep, `cargo run -p yoi -- ticket doctor`, and `nix build .#yoi --no-link`
範囲外:
- Regex URL target matching は追加していない。
- WebSocket/SSE/persistent connection support は `host_api.request` に含めていない。WebSocket は別 capability / design Ticket 側で扱う。

View File

@ -13,4 +13,499 @@ LocalTicketBackend によって作成されました。
Ticket を `workspace-panel` が queued にしました。 Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-21T07:17:14Z -->
## Decision
Routing decision: `implementation_ready`
Reason:
- Ticket body は `host_api.https` 廃止、`host_api.request` 統一、manifest-declared URL/request target permissions、enablement grant 照合、runtime fail-closed、local/private target 明示 grant、broad/arbitrary URL 表示、docs/tests/diagnostics 更新まで具体化されている。
- `readiness: implementation_ready` で、relations / orchestration plan に blocker はない。
- Current active implementation `00001KVMFFYVX` は Workspace web control plane bootstrap で、主対象は backend/frontend/store/Nix packaging。This Ticket の主対象は plugin manifest/pod runtime/plugin CLI/docs/tests で直接の semantic blocker はない。過去のユーザー指示「blocker無いなら並列に」に従い、並列実装可能と判断する。
- Orchestrator worktree is clean on `orchestration` at `f164483e` で、対象 Ticket 用 worktree / branch は未作成。
- Visible Pods に対象 Ticket の child Pod は存在しない。
Evidence checked:
- Ticket body / thread / artifacts via `TicketShow` and direct `item.md` read。
- `TicketRelationQuery(00001KVMG8FTW)`: no relations / blockers。
- `TicketOrchestrationPlanQuery(00001KVMG8FTW)`: no records。
- `ListPods`: active child is only `yoi-coder-00001KVMFFYVX`; no child for this Ticket。
- Orchestrator git state / worktree list / branch list checked from `/home/hare/Projects/yoi/.worktree/orchestration` only。
- Bounded code map:
- `crates/manifest/src/plugin.rs`: `PluginGrantConfig.https`, `PluginHttpsGrant`, `PluginHostApi::Https`, permission/grant resolution/tests。
- `crates/pod/src/feature/plugin.rs`: `PluginHttps*` runtime request path, `yoi:host/https@1.0.0` / raw wasm `yoi:https` imports, URL validation, request bounds, credential header checks, public-IP guard, allowlist checks, plugin tests。
- `crates/yoi/src/plugin_cli.rs`: inspection formatting for configured HTTPS grants。
- `docs/development/plugin-development.md`: active `host_api.https` / `grants.https` docs。
IntentPacket:
Intent:
- Replace public/model/config-facing `host_api.https` with URL-permission based one-shot `host_api.request`.
- Keep existing safe outbound request behavior where applicable, but generalize schemes/targets so explicit manifest + enablement grants can authorize loopback/private/local targets.
- Keep WebSocket / SSE / persistent connections out of `request`.
Binding decisions / invariants:
- Do not add backward compatibility aliases for `host_api.https`, `PluginHttps*`, or `grants.https` in active APIs unless explicitly escalated and reapproved。
- Model/config-facing naming must be `request`; internal names should also avoid `PluginHttps*` unless truly private transitional code is justified and not exposed。
- Runtime authorization requires both manifest-declared request target permission and enablement grant for that target。
- Grant-only without manifest request must fail closed or be explicitly diagnosed as unsafe/unused override; do not silently expand authority。
- Requested-but-ungranted target must fail closed before network I/O。
- Localhost/loopback/private/local targets are not ambient; they require manifest declaration and enablement grant。
- Arbitrary URL / broad network access must be visibly distinguished from normal target grants in inspection/diagnostics。
- Embedded credentials, credential-like headers, request/response bounds, external-content untrusted treatment, and no hidden context injection remain mandatory。
- WebSocket URL / upgrade / persistent stream must be rejected or explicitly unsupported by `request`
- Existing HTTPS request use cases must continue under `host_api.request` with explicit request permission/grant。
Requirements / acceptance criteria:
- Active API naming uses `host_api.request` / request grant naming。
- Plugin manifest statically declares request target permissions readable from manifest alone。
- Enablement config grants request targets and is matched against manifest-declared targets。
- Runtime checks method/scheme/host/port/path prefix against declared+granted URL permission。
- `http://localhost` / loopback request can be allowed only with explicit declaration+grant。
- Existing public HTTPS use case works as request。
- Broad/arbitrary URL is supported only with clear broad display/diagnostic if implemented。
- `yoi plugin show` / static inspection distinguishes requested, granted, denied/missing, and broad request permissions。
- Docs/templates/tests/diagnostics are updated to request naming and WebSocket separate-capability policy。
Implementation latitude:
- Exact Rust/TOML type names are up to Coder, but active names should be request-oriented, e.g. `PluginRequestGrant`, `PluginRequestTarget`, `host_api.request`.
- Regex support is optional. If added, it must include review-readable normalized display/warning/label and tests for broad/opaque handling。
- Request target schema may start with exact scheme/host/optional port/method/path prefix. Keep permission review human-readable。
- Internal runtime can reuse/refactor existing HTTPS client/request code, but reviewer should see active API renaming and policy changes。
- Raw wasm/component import migration may choose new import names with tests; if keeping an internal compatibility import is unavoidable, escalate before committing.
Escalate if:
- Compatibility alias for old `host_api.https` / `grants.https` seems required。
- Local/private target policy would open without both manifest declaration and grant。
- Arbitrary URL access becomes visually indistinguishable from normal grants。
- WebSocket/SSE/daemon lifecycle begins to enter `request`
- Secret-bearing headers/env/config would flow from guest memory without explicit SecretRef/grant design。
- Regex support becomes opaque or hard to review。
- Parallel active `00001KVMFFYVX` work creates unavoidable `Cargo.lock` / `package.nix` conflict; report rather than broad-rebasing alone。
Validation plan:
- `cargo fmt --check`
- Focused manifest plugin tests for request permission parsing/resolution and naming removal。
- Focused pod plugin tests for grant allow/deny, requested-but-ungranted, granted-but-unrequested, loopback/local allow/deny, WebSocket/upgrade rejection, request/response bounds/credential-header safety。
- Focused yoi plugin CLI tests for request permission/grant/broad/missing diagnostics。
- Docs/template grep ensuring active `host_api.https` / `grants.https` / `PluginHttps*` naming is removed except historical Ticket text or explicitly justified migration notes。
- `cargo check` for affected crates, `git diff --check`, `yoi ticket doctor`
Reviewer focus:
- Breaking-change intent is real: no stale active docs/templates/CLI inspection still teaching `host_api.https`
- Authorization must be two-stage: manifest request + grant。
- Local/private network must not become ambient。
- Old public HTTPS use case must still work under request naming。
- External content and hidden context constraints remain intact。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-21T07:17:18Z from: queued to: inprogress reason: human_authorized_unblocked_parallel_implementation field: state -->
## State changed
Human authorized queue routing from Workspace Dashboard. Ticket has concrete acceptance criteria and no recorded blockers; active Workspace web control plane implementation is semantically separate enough for parallel implementation. Orchestrator accepts this Plugin host API refactor into implementation.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T07:18:48Z -->
## Implementation report
Implementation start report:
- Created child implementation worktree:
- `/home/hare/Projects/yoi/.worktree/00001KVMG8FTW-plugin-request-host-api`
- Created branch:
- `impl/00001KVMG8FTW-plugin-request-host-api`
- Base commit:
- `4cd4a06e ticket: route plugin request capabilities`
- Confirmed tracked Ticket project records are visible in the child worktree。
- Confirmed `.yoi/memory` has no tracked/untracked entries in the child worktree check。
- Spawned sibling Coder Pod:
- `yoi-coder-00001KVMG8FTW`
- Coder scope:
- read: `/home/hare/Projects/yoi`
- write: `/home/hare/Projects/yoi/.worktree/00001KVMG8FTW-plugin-request-host-api`
Parallelization note:
- `00001KVMFFYVX` remains in progress in a separate child worktree. Coder was instructed to avoid unnecessary Cargo.lock/package.nix churn and report if unavoidable conflicts appear。
Next action:
- Wait for Coder implementation report, then spawn Reviewer read-only for the implementation diff. Orchestrator will not merge/close until reviewer approval and validation evidence are available。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T07:48:07Z -->
## Implementation report
Coder implementation report received from `yoi-coder-00001KVMG8FTW`.
Implementation commit:
- `962b7699 plugin: replace https host api with request grants`
Changed areas:
- `crates/manifest/src/plugin.rs`
- Replaced active host API/grant naming with `request`
- Added manifest-declared `[[request]]` URL targets with `scheme`, `host`, optional `port`, `methods`, and `path_prefixes`
- Added request grant parsing/labels/broad diagnostics and tests that legacy HTTPS-facing names are not accepted。
- `crates/pod/src/feature/plugin.rs`
- Renamed runtime path/imports to request-oriented naming。
- Runtime now requires both manifest request target declaration and enabled request grant before network I/O。
- Checks scheme/host/port/path/method, rejects embedded credentials, credential-like headers, WebSocket URLs/upgrades, and SSE/event-stream requests。
- Allows loopback/local/private targets only when explicitly declared and granted。
- Adds static inspection entries for requested/granted/missing/grant-only/broad request targets。
- `crates/yoi/src/plugin_cli.rs`
- Shows configured request grants and request target eligibility diagnostics, including broad/missing/grant-only states。
- `resources/plugin/wit/*`
- Updated WIT import/interface from HTTPS host API to `yoi:host/request@1.0.0`
- `docs/development/plugin-development.md`
- Rewrote host API docs for `request`, including manifest targets, enablement grants, loopback policy, broad target diagnostics, and WebSocket/SSE exclusion。
- `docs/design/plugin-component-model.md`
- Updated design wording/import references。
- `crates/pod/src/pod.rs`
- Updated test fixture initialization for the new manifest request target field。
Coder validation reported:
- `cargo fmt --check`: passed
- `cargo test -p manifest request`: passed
- `cargo test -p pod feature::plugin::tests --lib`: passed
- `cargo test -p yoi plugin_cli::tests`: passed
- `cargo check -p manifest -p pod -p yoi`: passed
- `git diff --check`: passed
- Active naming grep for `host_api.https|grants.https|PluginHttps|yoi:host/https|yoi:https` under `crates docs resources`: no matches
- `target/debug/yoi ticket doctor`: passed
Coder dependency/package report:
- No `Cargo.lock`, `package.nix`, or flake changes。
- No dependency/package churn。
Known deferrals:
- Regex URL target matching was not added。
- WebSocket/SSE/persistent connection support remains explicitly outside `host_api.request`
Orchestrator evidence checked:
- Implementation worktree is clean on `impl/00001KVMG8FTW-plugin-request-host-api`
- HEAD is `962b7699`
- Diff `4cd4a06e..HEAD` touches 9 expected manifest/pod/plugin-doc/WIT files, about 768 insertions / 448 deletions。
- Active stale naming grep under `crates docs resources` returned no matches。
- `git diff --check 4cd4a06e..HEAD` produced no diagnostics。
Next action:
- Route to read-only Reviewer Pod for acceptance review against the Ticket and IntentPacket。
---
<!-- event: comment author: yoi-orchestrator at: 2026-06-21T07:48:53Z -->
## Comment
Review routing:
Spawned read-only Reviewer Pod:
- `yoi-reviewer-00001KVMG8FTW-r1`
Review target:
- Worktree: `/home/hare/Projects/yoi/.worktree/00001KVMG8FTW-plugin-request-host-api`
- Branch: `impl/00001KVMG8FTW-plugin-request-host-api`
- Baseline: `4cd4a06e`
- Implementation commit: `962b7699 plugin: replace https host api with request grants`
- Diff: `4cd4a06e..962b7699`
Reviewer scope:
- read-only `/home/hare/Projects/yoi`
Reviewer focus:
- Active API/docs/WIT/CLI naming uses request, not stale https names。
- No compatibility alias remains for old `host_api.https` / `grants.https` unless explicitly justified。
- Runtime authorization requires both manifest request target and enablement grant before network I/O。
- Grant-only and missing-grant cases fail closed / diagnose clearly。
- Local/private/loopback targets require explicit declaration and grant。
- WebSocket/SSE/persistent stream behavior is rejected or explicitly unsupported by `request`
- Broad/arbitrary URL grants are visibly distinguished。
- Existing public HTTPS use case still works through request naming。
Orchestrator will wait for reviewer verdict before integration。
---
<!-- event: review author: yoi-reviewer-00001KVMG8FTW-r1 at: 2026-06-21T07:58:08Z status: request_changes -->
## Review: request changes
Verdict: `request_changes`
確認範囲:
- Ticket authority:
- `.yoi/tickets/00001KVMG8FTW/item.md`
- `.yoi/tickets/00001KVMG8FTW/thread.md`
- Implementation diff `4cd4a06e..962b7699`:
- `crates/manifest/src/plugin.rs`
- `crates/pod/src/feature/plugin.rs`
- `crates/pod/src/pod.rs`
- `crates/yoi/src/plugin_cli.rs`
- `docs/design/plugin-component-model.md`
- `docs/development/plugin-development.md`
- `resources/plugin/wit/deps/yoi-host/yoi-host-v1.wit`
- `resources/plugin/wit/yoi-plugin-instance-v1.wit`
- `resources/plugin/wit/yoi-plugin-tool-v1.wit`
Blocking issue:
1. Static inspection and runtime authorization disagree for broad / covering request grants。
Static inspection currently treats request target grants as matching only by exact equality:
- `crates/pod/src/feature/plugin.rs:420-425`
- `let granted = record.grants.request.iter().any(|grant| grant == target);`
- `crates/pod/src/feature/plugin.rs:440-455`
- grants without exact equality are reported as `grant-only`, `eligible=false`
Runtime authorization checks manifest targets and grants independently by URL coverage:
- `crates/pod/src/feature/plugin.rs:1397-1409`
- URL must be allowed by some manifest target and some enabled grant。
- `crates/pod/src/feature/plugin.rs:1420-1449`
- broad grants such as `*://*` or broader path/host scopes can cover a concrete URL even when not equal to the manifest target。
Concrete mismatch:
- Manifest declares exact target: `https://api.example.test ... /v1`
- Enablement grants broad target: `*://* GET *`
- Inspection reports:
- manifest target `requested=true granted=false eligible=false`
- broad grant as `grant-only ... eligible=false`
- Runtime still allows `GET https://api.example.test/v1/data`, because URL is covered by both manifest target and broad grant。
The added CLI test appears to codify this inconsistent reporting:
- `crates/yoi/src/plugin_cli.rs:1566-1574`
- expects broad grant display as `grant-only`
- expects requested target as `granted=false eligible=false`
This violates the Ticket acceptance/invariants that inspection distinguish requested/granted/denied/broad request permissions accurately, and that grant-only / requested-but-ungranted cases do not silently diverge from runtime behavior。
Required fix:
- Align static inspection and runtime semantics。
- If broad / covering grants are valid authorization for declared manifest targets, inspection should report the manifest target as covered/granted, while visibly labeling the covering grant as broad。
- If only exact target grants are intended to authorize, runtime should deny non-equal grant coverage before network I/O。
- Apply same reasoning to the inverse shape: broad manifest request plus narrower exact grant can be runtime-allowed for URLs in the intersection while inspection currently reports both sides as ineligible/grant-only。
Non-blocking concern:
- DNS/private-target semantics are now URL-permission based rather than resolved-address-category based。There is an explicit test allowing a declared/granted hostname to resolve to `127.0.0.1` (`crates/pod/src/feature/plugin.rs:4841-4847`)。This may be acceptable under the new model, but it should be documented/reconfirmed because the Ticket says local/private communication must not be ambient。The implementation interprets “explicit” as declared/granted URL host, not necessarily literal `localhost` / private IP grant。
Reviewer validation passed:
- `cargo fmt --check`
- `git diff --check 4cd4a06e..HEAD`
- `cargo test -p manifest request --quiet` (`7 passed`)
- `cargo test -p pod feature::plugin::tests --lib --quiet` (`67 passed`)
- `cargo test -p yoi plugin_cli::tests --quiet` (`18 passed`)
- `cargo check -p manifest -p pod -p yoi --quiet`
- `target/debug/yoi ticket doctor`
- Stale active naming grep under `crates docs resources` for `host_api.https|grants.https|PluginHttps|yoi:host/https|yoi:https`: no matches。
Worktree status:
- Clean on `impl/00001KVMG8FTW-plugin-request-host-api`
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T08:04:50Z -->
## Implementation report
Coder r1-fix report received from `yoi-coder-00001KVMG8FTW`.
New fix commit:
- `0e14e7c1 plugin: align request grant inspection coverage`
Branch commits now:
- `962b7699 plugin: replace https host api with request grants`
- `0e14e7c1 plugin: align request grant inspection coverage`
Fix summary:
- `crates/pod/src/feature/plugin.rs`
- Static inspection now uses request target coverage/intersection semantics instead of exact equality。
- Broad/covering grants are reported as granted/eligible when they cover or intersect manifest targets in a way runtime can allow。
- Broad manifest + narrower grant is also reported as eligible with partial-coverage diagnostics。
- Truly grant-only/no-manifest-intersection grants remain ineligible。
- Added focused static inspection tests for exact manifest target + broad grant and broad manifest target + narrower exact grant。
- `crates/yoi/src/plugin_cli.rs`
- Updated CLI/static inspection test expectations to match aligned broad grant semantics。
- `docs/development/plugin-development.md`
- Documented that explicit request authority is URL-host based, and a granted DNS hostname may resolve to loopback/private addresses if that hostname was intentionally declared/granted。
Coder validation reported:
- `cargo fmt --check`: passed
- `git diff --check 4cd4a06e..HEAD`: passed
- `cargo test -p manifest request --quiet`: passed
- `cargo test -p pod feature::plugin::tests --lib --quiet`: passed
- `cargo test -p yoi plugin_cli::tests --quiet`: passed
- `cargo check -p manifest -p pod -p yoi --quiet`: passed
- `target/debug/yoi ticket doctor`: passed
- stale active naming grep under `crates docs resources` for old HTTPS names: no matches
Orchestrator evidence checked:
- Implementation worktree clean on `impl/00001KVMG8FTW-plugin-request-host-api`
- HEAD is `0e14e7c1`
- r1-fix diff `962b7699..HEAD` touches only `crates/pod/src/feature/plugin.rs`, `crates/yoi/src/plugin_cli.rs`, and `docs/development/plugin-development.md`
- Active stale naming grep under `crates docs resources` returned no matches。
- `git diff --check 4cd4a06e..HEAD` produced no diagnostics。
Next action:
- Route back to Reviewer for r2 verification of static inspection/runtime coverage alignment and final acceptance。
---
<!-- event: review author: yoi-reviewer-00001KVMG8FTW-r1 at: 2026-06-21T08:08:04Z status: approve -->
## Review: approve
Verdict: `approve`
確認範囲:
- r1-fix diff `962b7699..0e14e7c1`:
- `crates/pod/src/feature/plugin.rs`
- `crates/yoi/src/plugin_cli.rs`
- `docs/development/plugin-development.md`
- Overall changed-file set `4cd4a06e..0e14e7c1`:
- `crates/manifest/src/plugin.rs`
- `crates/pod/src/feature/plugin.rs`
- `crates/pod/src/pod.rs`
- `crates/yoi/src/plugin_cli.rs`
- `docs/design/plugin-component-model.md`
- `docs/development/plugin-development.md`
- `resources/plugin/wit/deps/yoi-host/yoi-host-v1.wit`
- `resources/plugin/wit/yoi-plugin-instance-v1.wit`
- `resources/plugin/wit/yoi-plugin-tool-v1.wit`
Previous blocker verification:
- r1 blocker is resolved。
- Static inspection now evaluates request grant coverage/intersection rather than exact equality。
- Exact manifest target + broad grant is now reported as granted/eligible with broad diagnostics, not `grant-only`
- Broad manifest target + narrower exact grant is now reported as runtime-eligible with partial-coverage diagnostics。
- CLI/static inspection expectations were updated so broad grants are shown as usable grants, not ineligible grant-only。
- DNS/private-target reviewer concern is documented in `docs/development/plugin-development.md`
Blocking issues:
- None。
Non-blocking concerns:
- None requiring changes before merge。
Overall Ticket satisfaction spot-check:
- Active API/docs/WIT naming use `request`; stale active `host_api.https` / `grants.https` / `PluginHttps*` / old WIT names were not found under `crates docs resources`
- No compatibility alias for old HTTPS naming was visible in active code/docs。
- Runtime still requires both manifest `host_api.request` / request target declaration and enablement `host_api.request` / matching request grant。
- Runtime URL checks remain method/scheme/host/port/path based。
- WebSocket URLs, upgrade headers, and SSE/event-stream requests remain rejected。
- Embedded URL credentials and credential-like headers remain rejected。
- Local/private/loopback targets are not ambient; documented authority is URL-host declaration + grant。
Reviewer validation passed:
- `cargo fmt --check`
- `git diff --check 4cd4a06e..HEAD`
- `cargo test -p manifest request --quiet` (`7 passed`)
- `cargo test -p pod feature::plugin::tests --lib --quiet` (`69 passed`)
- `cargo test -p yoi plugin_cli::tests --quiet` (`18 passed`)
- `cargo check -p manifest -p pod -p yoi --quiet`
- `target/debug/yoi ticket doctor` (`doctor: ok`)
- Stale active naming grep under `crates docs resources`: no matches。
Worktree status:
- Clean at HEAD `0e14e7c1` on `impl/00001KVMG8FTW-plugin-request-host-api`
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T08:11:43Z -->
## Implementation report
Reviewer r2 approvalを受けて、実装ブランチを Orchestrator worktree の `orchestration` branch に統合した。
統合:
- Merge commit: `8a15cca5 merge: plugin request host api`
- Merged branch: `impl/00001KVMG8FTW-plugin-request-host-api`
- Implementation commits:
- `962b7699 plugin: replace https host api with request grants`
- `0e14e7c1 plugin: align request grant inspection coverage`
Orchestrator worktree validation:
- `cargo fmt --check`: passed
- `git diff --check HEAD^1..HEAD`: passed
- `cargo test -p manifest request --quiet`: passed (`7 passed`)
- `cargo test -p pod feature::plugin::tests --lib --quiet`: passed (`69 passed`)
- `cargo test -p yoi plugin_cli::tests --quiet`: passed (`18 passed`)
- `cargo check -p manifest -p pod -p yoi --quiet`: passed
- Stale active naming grep: `git grep -n -E 'host_api\.https|grants\.https|PluginHttps|yoi:host/https|yoi:https' -- crates docs resources`: no matches
- `cargo run -p yoi -- ticket doctor`: passed (`doctor: ok`)
- `nix build .#yoi --no-link`: passed
Final Orchestrator worktree status after validation: clean on `orchestration` at `8a15cca5`
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-21T08:11:51Z from: inprogress to: done reason: implementation_merged_and_validated field: state -->
## State changed
Reviewer approval、Orchestrator worktree への統合、focused plugin request tests/checks、stale naming grep、Ticket doctor、Nix build が完了したため `done` に遷移する。
---
<!-- event: state_changed author: hare at: 2026-06-21T08:12:06Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-21T08:12:06Z status: closed -->
## 完了
Plugin host API の one-shot outbound request capability を `host_api.https` / `grants.https` から URL permission based `host_api.request` に置き換え、Orchestrator worktree の `orchestration` branch に統合した。
主な成果:
- Active API / docs / WIT naming を `request` に移行。
- Manifest に `host_api.request``[[request]]` target declaration を追加。
- Enablement grant を request target grant として扱うよう変更。
- Runtime authorization を manifest-declared request target と enabled request grant の両方が URL/method/scheme/host/port/path coverage で許可する場合のみ network I/O に進む形にした。
- Grant-only / missing-grant / broad / partial-coverage states を static inspection と `yoi plugin show` diagnostics で区別。
- Broad/covering grant と broad manifest + narrower grant の intersection semantics を runtime と static inspection で一致させた。
- Loopback/local/private target は ambient ではなく、URL host declaration + grant に基づく明示 authority として扱う方針を docs に記録。
- Embedded credentials、credential-like headers、WebSocket URLs/upgrades、SSE/event-stream requests を reject/unsupported にした。
- Old `host_api.https` / `grants.https` / `PluginHttps*` / old WIT names は active code/docs/resources から削除。
- Focused manifest / pod / yoi plugin CLI tests を追加・更新。
統合・検証:
- Merge commit: `8a15cca5 merge: plugin request host api`
- Implementation commits: `962b7699`, `0e14e7c1`
- Reviewer final verdict: approve
- Validation passed: `cargo fmt --check`, `git diff --check HEAD^1..HEAD`, `cargo test -p manifest request --quiet`, `cargo test -p pod feature::plugin::tests --lib --quiet`, `cargo test -p yoi plugin_cli::tests --quiet`, `cargo check -p manifest -p pod -p yoi --quiet`, stale active naming grep, `cargo run -p yoi -- ticket doctor`, and `nix build .#yoi --no-link`
範囲外:
- Regex URL target matching は追加していない。
- WebSocket/SSE/persistent connection support は `host_api.request` に含めていない。WebSocket は別 capability / design Ticket 側で扱う。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T08:12:34Z -->
## Implementation report
Post-close cleanup completed。
- Stopped child Pods and reclaimed scope:
- `yoi-coder-00001KVMG8FTW`
- `yoi-reviewer-00001KVMG8FTW-r1`
- Removed implementation worktree:
- `/home/hare/Projects/yoi/.worktree/00001KVMG8FTW-plugin-request-host-api`
- Deleted implementation branch:
- `impl/00001KVMG8FTW-plugin-request-host-api`
- Orchestrator worktree remains clean on `orchestration` at `2601bfa9`
Root/original workspace was not used for merge/validation/cleanup operations。
--- ---

View File

@ -0,0 +1,13 @@
{
"version": 1,
"relations": [
{
"ticket_id": "00001KVMGAEJN",
"kind": "depends_on",
"target": "00001KVMG8FTW",
"note": "WebSocket capability design should reuse or deliberately diverge from the URL permission/request-target schema produced by `host_api.request`; current Ticket remains requirements_sync_needed until those design decisions are resolved.",
"author": "yoi-orchestrator",
"at": "2026-06-21T07:17:46Z"
}
]
}

View File

@ -1,8 +1,8 @@
--- ---
title: 'Plugin: URL 権限ベースの別 capability として WebSocket support を設計する' title: 'Plugin: URL 権限ベースの別 capability として WebSocket support を設計する'
state: 'queued' state: 'planning'
created_at: '2026-06-21T07:11:34Z' created_at: '2026-06-21T07:11:34Z'
updated_at: '2026-06-21T07:15:42Z' updated_at: '2026-06-21T07:17:52Z'
assignee: null assignee: null
readiness: 'requirements_sync_needed' readiness: 'requirements_sync_needed'
risk_flags: ['plugin', 'host-api', 'websocket', 'service', 'ingress', 'lifecycle', 'permissions', 'security', 'persistence'] risk_flags: ['plugin', 'host-api', 'websocket', 'service', 'ingress', 'lifecycle', 'permissions', 'security', 'persistence']

View File

@ -29,4 +29,43 @@ LocalTicketBackend によって作成されました。
Ticket を `workspace-panel` が queued にしました。 Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-21T07:17:40Z -->
## Decision
Routing decision: `return_to_planning_requirements_sync`
Reason:
- Ticket frontmatter has `readiness: requirements_sync_needed`
- Intake summary explicitly says this should be handled as WebSocket capability design/spec sync, not implementation-direct routing。
- Ticket body still lists concrete open questions that must be resolved before implementation or worktree side effects:
- WebSocket connection ownership: Yoi host-owned vs Plugin-instance-owned host API。
- Incoming message dispatch route: Ingress vs Service event/status stream vs other host routing。
- Reconnect / backoff / heartbeat / shutdown / cancellation / restore scope for first slice。
- Auth/headers/secrets model and grant/display split。
- Whether this work item closes as design/spec only or includes a minimal implementation slice。
- Related Ticket `00001KVMG8FTW` is now routed for implementation of the `host_api.request` URL permission model. WebSocket design should reuse or intentionally diverge from that resulting URL permission schema after it stabilizes。
Decision:
- Do not create implementation worktree or spawn Coder/Reviewer for this Ticket now。
- Return to `planning` with concrete missing requirements/design decisions。
- Record relation to `00001KVMG8FTW` so WebSocket design can be resumed after request permission model lands。
Required next planning output before queueing again:
- Decide whether this is design/spec only or includes a minimal implementation slice。
- Decide connection ownership and lifecycle boundary。
- Decide incoming-message durable/visible dispatch path and no-hidden-context behavior。
- Decide WebSocket URL permission/grant schema relative to `host_api.request` request-target schema。
- Decide auth/secrets display/grant model and non-goals for reconnect/backoff/heartbeat in first slice。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-21T07:17:52Z from: queued to: planning reason: requirements_sync_needed_connection_lifecycle_and_scope_decisions field: state -->
## State changed
Ticket remains `requirements_sync_needed` and has unresolved design questions around connection ownership, incoming-message dispatch path, lifecycle bounds, auth/secrets handling, and design-vs-implementation scope. Returning to planning rather than starting implementation side effects.
--- ---

148
Cargo.lock generated
View File

@ -196,6 +196,58 @@ dependencies = [
"fs_extra", "fs_extra",
] ]
[[package]]
name = "axum"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"serde_core",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
@ -1039,6 +1091,18 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]] [[package]]
name = "fancy-regex" name = "fancy-regex"
version = "0.11.0" version = "0.11.0"
@ -1429,6 +1493,15 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "hashlink"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [
"hashbrown 0.15.5",
]
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.5.0" version = "0.5.0"
@ -1968,6 +2041,17 @@ dependencies = [
"redox_syscall 0.7.4", "redox_syscall 0.7.4",
] ]
[[package]]
name = "libsqlite3-sys"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "line-clipping" name = "line-clipping"
version = "0.3.7" version = "0.3.7"
@ -2201,6 +2285,12 @@ dependencies = [
"regex-automata", "regex-automata",
] ]
[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]] [[package]]
name = "mcp" name = "mcp"
version = "0.1.0" version = "0.1.0"
@ -3376,6 +3466,20 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "rusqlite"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f"
dependencies = [
"bitflags 2.11.0",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]] [[package]]
name = "rustc-demangle" name = "rustc-demangle"
version = "0.1.27" version = "0.1.27"
@ -3692,6 +3796,17 @@ dependencies = [
"zmij", "zmij",
] ]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]] [[package]]
name = "serde_spanned" name = "serde_spanned"
version = "1.1.1" version = "1.1.1"
@ -3701,6 +3816,18 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]] [[package]]
name = "serde_yaml" name = "serde_yaml"
version = "0.9.34+deprecated" version = "0.9.34+deprecated"
@ -4419,6 +4546,7 @@ dependencies = [
"tokio", "tokio",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing",
] ]
[[package]] [[package]]
@ -4457,6 +4585,7 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [ dependencies = [
"log",
"pin-project-lite", "pin-project-lite",
"tracing-attributes", "tracing-attributes",
"tracing-core", "tracing-core",
@ -5839,6 +5968,25 @@ dependencies = [
"wit-bindgen", "wit-bindgen",
] ]
[[package]]
name = "yoi-workspace-server"
version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"project-record",
"rusqlite",
"serde",
"serde_json",
"serde_yaml",
"tempfile",
"thiserror 2.0.18",
"ticket",
"tokio",
"tower",
"tracing",
]
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.2" version = "0.8.2"

View File

@ -24,6 +24,7 @@ members = [
"crates/ticket", "crates/ticket",
"crates/project-record", "crates/project-record",
"crates/workflow", "crates/workflow",
"crates/workspace-server",
"tests/e2e", "tests/e2e",
] ]
default-members = [ default-members = [
@ -50,6 +51,7 @@ default-members = [
"crates/ticket", "crates/ticket",
"crates/project-record", "crates/project-record",
"crates/workflow", "crates/workflow",
"crates/workspace-server",
] ]
[workspace.package] [workspace.package]
@ -80,21 +82,26 @@ session-store = { path = "crates/session-store" }
secrets = { path = "crates/secrets" } secrets = { path = "crates/secrets" }
tools = { path = "crates/tools" } tools = { path = "crates/tools" }
tui = { path = "crates/tui" } tui = { path = "crates/tui" }
yoi-workspace-server = { path = "crates/workspace-server" }
# External # External
# Note: `reqwest` and `chrono` are not aggregated here because some crates # Note: `reqwest` and `chrono` are not aggregated here because some crates
# need `default-features = false`, which workspace inheritance cannot override. # need `default-features = false`, which workspace inheritance cannot override.
async-trait = "0.1" async-trait = "0.1"
axum = "0.8"
fs4 = "0.13" fs4 = "0.13"
futures = "0.3" futures = "0.3"
libc = "0.2" libc = "0.2"
schemars = "1.2" schemars = "1.2"
serde = "1.0" serde = "1.0"
serde_json = "1.0" serde_json = "1.0"
serde_yaml = "0.9.34"
rusqlite = { version = "0.37", features = ["bundled"] }
sha2 = "0.11" sha2 = "0.11"
tempfile = "3.27" tempfile = "3.27"
thiserror = "2.0" thiserror = "2.0"
tokio = "1.52" tokio = "1.52"
tower = "0.5"
toml = "1.1" toml = "1.1"
tracing = "0.1" tracing = "0.1"
uuid = "1.23" uuid = "1.23"

View File

@ -150,15 +150,15 @@ pub struct PluginGrantConfig {
pub digest: Option<String>, pub digest: Option<String>,
/// Explicit capabilities granted for the pinned package identity/version/digest. /// Explicit capabilities granted for the pinned package identity/version/digest.
pub permissions: Vec<PluginPermission>, pub permissions: Vec<PluginPermission>,
/// Bounded outbound HTTPS allowlist entries for `host_api.https`. /// Bounded outbound request allowlist entries for `host_api.request`.
pub https: Vec<PluginHttpsGrant>, pub request: Vec<PluginRequestGrant>,
/// Scoped filesystem allowlist entries for `host_api.fs`. /// Scoped filesystem allowlist entries for `host_api.fs`.
pub fs: Vec<PluginFsGrant>, pub fs: Vec<PluginFsGrant>,
} }
impl PluginGrantConfig { impl PluginGrantConfig {
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.permissions.is_empty() && self.https.is_empty() && self.fs.is_empty() self.permissions.is_empty() && self.request.is_empty() && self.fs.is_empty()
} }
pub fn binding_error( pub fn binding_error(
@ -212,17 +212,32 @@ pub enum PluginPermission {
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct PluginHttpsGrant { pub struct PluginRequestGrant {
/// Exact HTTPS request host allowed by this grant. Wildcards are intentionally unsupported. /// Exact URL scheme allowed by this target, for example `https` or `http`; `*` is broad.
pub scheme: String,
/// Exact request host allowed by this target. `*` is broad and must be surfaced in diagnostics.
pub host: String, pub host: String,
/// Uppercase HTTP methods allowed for this host, for example `GET` or `POST`. /// Optional exact port. `None` means the scheme default or any explicit port for that host.
pub port: Option<u16>,
/// Uppercase HTTP methods allowed for this target, for example `GET` or `POST`.
pub methods: Vec<String>, pub methods: Vec<String>,
/// Optional path prefixes allowed for this host. Empty means any absolute path on the host. /// Optional path prefixes allowed for this target. Empty means any absolute path on the host.
pub path_prefixes: Vec<String>, pub path_prefixes: Vec<String>,
} }
impl PluginHttpsGrant { impl PluginRequestGrant {
pub fn label(&self) -> String { pub fn label(&self) -> String {
let scheme = if self.scheme.trim().is_empty() {
"<no-scheme>"
} else {
self.scheme.as_str()
};
let host = if self.host.trim().is_empty() {
"<no-host>"
} else {
self.host.as_str()
};
let port = self.port.map(|port| format!(":{port}")).unwrap_or_default();
let methods = if self.methods.is_empty() { let methods = if self.methods.is_empty() {
"<no-methods>".to_string() "<no-methods>".to_string()
} else { } else {
@ -233,7 +248,16 @@ impl PluginHttpsGrant {
} else { } else {
self.path_prefixes.join(",") self.path_prefixes.join(",")
}; };
format!("{} {} {}", self.host, methods, paths) let broad = if self.is_broad() {
" [broad-request]"
} else {
""
};
format!("{scheme}://{host}{port} {methods} {paths}{broad}")
}
pub fn is_broad(&self) -> bool {
self.scheme.trim() == "*" || self.host.trim() == "*" || self.path_prefixes.is_empty()
} }
} }
@ -322,14 +346,14 @@ impl PluginPermission {
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum PluginHostApi { pub enum PluginHostApi {
Https, Request,
Fs, Fs,
} }
impl fmt::Display for PluginHostApi { impl fmt::Display for PluginHostApi {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
Self::Https => f.write_str("https"), Self::Request => f.write_str("request"),
Self::Fs => f.write_str("fs"), Self::Fs => f.write_str("fs"),
} }
} }
@ -452,6 +476,10 @@ pub struct PluginPackageManifest {
/// enablement grants must match them before runtime surfaces are exposed. /// enablement grants must match them before runtime surfaces are exposed.
#[serde(default)] #[serde(default)]
pub permissions: Vec<PluginPermission>, pub permissions: Vec<PluginPermission>,
/// Manifest-declared URL targets for `host_api.request`. These are static permission requests;
/// enablement grants must explicitly approve matching targets.
#[serde(default)]
pub request: Vec<PluginRequestGrant>,
} }
impl PluginPackageManifest { impl PluginPackageManifest {
@ -2586,6 +2614,100 @@ mod tests {
assert_eq!(manifest.tools.len(), 1); assert_eq!(manifest.tools.len(), 1);
} }
#[test]
fn request_host_api_manifest_and_grant_parse_with_request_names() {
let manifest: PluginPackageManifest = toml::from_str(
r#"
schema_version = 1
id = "example"
name = "Example"
version = "1.0.0"
description = "Example plugin"
surfaces = ["tool"]
[[permissions]]
kind = "host_api"
api = "request"
[[request]]
scheme = "https"
host = "api.example.com"
port = 443
methods = ["GET", "POST"]
path_prefixes = ["/v1/"]
"#,
)
.unwrap();
assert_eq!(
manifest.permissions,
vec![PluginPermission::host_api(PluginHostApi::Request)]
);
assert_eq!(manifest.request.len(), 1);
assert_eq!(manifest.request[0].scheme, "https");
assert_eq!(manifest.request[0].host, "api.example.com");
assert_eq!(manifest.request[0].port, Some(443));
assert_eq!(
manifest.request[0].label(),
"https://api.example.com:443 GET,POST /v1/"
);
let grants: PluginGrantConfig = toml::from_str(
r#"
permissions = [{ kind = "host_api", api = "request" }]
[[request]]
scheme = "http"
host = "localhost"
port = 8080
methods = ["GET"]
path_prefixes = ["/health"]
"#,
)
.unwrap();
assert_eq!(
grants.permissions,
vec![PluginPermission::host_api(PluginHostApi::Request)]
);
assert_eq!(grants.request[0].scheme, "http");
assert_eq!(grants.request[0].host, "localhost");
}
#[test]
fn legacy_https_request_names_are_not_accepted() {
let manifest_error = toml::from_str::<PluginPackageManifest>(
r#"
schema_version = 1
id = "example"
name = "Example"
version = "1.0.0"
description = "Example plugin"
surfaces = ["tool"]
[[permissions]]
kind = "host_api"
api = "https"
"#,
)
.expect_err(concat!(
"host_api.",
"https",
" must not be an active alias"
));
assert!(manifest_error.to_string().contains("unknown variant"));
let grant_error = toml::from_str::<PluginGrantConfig>(
r#"
permissions = [{ kind = "host_api", api = "request" }]
[[https]]
host = "api.example.com"
methods = ["GET"]
"#,
)
.expect_err(concat!("grants.", "https", " must not be an active alias"));
assert!(grant_error.to_string().contains("unknown field"));
}
#[test] #[test]
fn embedded_rust_component_instance_template_is_valid_package_shape() { fn embedded_rust_component_instance_template_is_valid_package_shape() {
let paths: BTreeSet<_> = RUST_COMPONENT_INSTANCE_TEMPLATE let paths: BTreeSet<_> = RUST_COMPONENT_INSTANCE_TEMPLATE
@ -3067,7 +3189,7 @@ input_schema = { type = "object", properties = { query = { type = "string" } },
version: Some(PluginExactVersion("0.1.0".to_string())), version: Some(PluginExactVersion("0.1.0".to_string())),
digest: Some(digest.clone()), digest: Some(digest.clone()),
permissions: vec![PluginPermission::surface(PluginSurface::Hook)], permissions: vec![PluginPermission::surface(PluginSurface::Hook)],
https: Vec::new(), request: Vec::new(),
fs: Vec::new(), fs: Vec::new(),
}; };
let resolution = resolve_enabled_plugins( let resolution = resolve_enabled_plugins(
@ -3094,7 +3216,7 @@ input_schema = { type = "object", properties = { query = { type = "string" } },
version: Some(PluginExactVersion("0.1.0".to_string())), version: Some(PluginExactVersion("0.1.0".to_string())),
digest: Some(digest.clone()), digest: Some(digest.clone()),
permissions: vec![PluginPermission::surface(PluginSurface::Hook)], permissions: vec![PluginPermission::surface(PluginSurface::Hook)],
https: Vec::new(), request: Vec::new(),
fs: Vec::new(), fs: Vec::new(),
}, },
PluginGrantConfig { PluginGrantConfig {
@ -3102,7 +3224,7 @@ input_schema = { type = "object", properties = { query = { type = "string" } },
version: Some(PluginExactVersion("0.1.1".to_string())), version: Some(PluginExactVersion("0.1.1".to_string())),
digest: Some(digest.clone()), digest: Some(digest.clone()),
permissions: vec![PluginPermission::surface(PluginSurface::Hook)], permissions: vec![PluginPermission::surface(PluginSurface::Hook)],
https: Vec::new(), request: Vec::new(),
fs: Vec::new(), fs: Vec::new(),
}, },
PluginGrantConfig { PluginGrantConfig {
@ -3110,7 +3232,7 @@ input_schema = { type = "object", properties = { query = { type = "string" } },
version: Some(PluginExactVersion("0.1.0".to_string())), version: Some(PluginExactVersion("0.1.0".to_string())),
digest: Some("sha256:unrelated".to_string()), digest: Some("sha256:unrelated".to_string()),
permissions: vec![PluginPermission::surface(PluginSurface::Hook)], permissions: vec![PluginPermission::surface(PluginSurface::Hook)],
https: Vec::new(), request: Vec::new(),
fs: Vec::new(), fs: Vec::new(),
}, },
] { ] {

File diff suppressed because it is too large Load Diff

View File

@ -5378,6 +5378,7 @@ permission = "read"
services: vec![], services: vec![],
ingresses: vec![], ingresses: vec![],
permissions: vec![], permissions: vec![],
request: vec![],
}, },
enabled_surfaces: vec![manifest::plugin::PluginSurface::Hook], enabled_surfaces: vec![manifest::plugin::PluginSurface::Hook],
grants: manifest::plugin::PluginGrantConfig::default(), grants: manifest::plugin::PluginGrantConfig::default(),

View File

@ -0,0 +1,24 @@
[package]
name = "yoi-workspace-server"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish = false
[dependencies]
async-trait.workspace = true
axum.workspace = true
project-record.workspace = true
rusqlite.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
serde_yaml.workspace = true
thiserror.workspace = true
ticket.workspace = true
tokio = { workspace = true, features = ["fs", "net", "rt", "sync"] }
tracing.workspace = true
[dev-dependencies]
tempfile.workspace = true
tower = { workspace = true, features = ["util"] }
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }

View File

@ -0,0 +1,35 @@
//! Local workspace web control plane backend bootstrap.
//!
//! This crate deliberately provides backend building blocks and an HTTP router;
//! it is not the product CLI facade. Existing `.yoi` Ticket and Objective files
//! remain the canonical project records and are read through bounded bridge APIs.
pub mod records;
pub mod server;
pub mod store;
pub use records::{
LocalProjectRecordReader, ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary,
};
pub use server::{AuthConfig, ServerConfig, WorkspaceApi, build_router, serve};
pub use store::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord};
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("sqlite error: {0}")]
Sqlite(#[from] rusqlite::Error),
#[error("ticket error: {0}")]
Ticket(#[from] ticket::TicketError),
#[error("yaml error: {0}")]
Yaml(#[from] serde_yaml::Error),
#[error("invalid project record id `{0}`")]
InvalidRecordId(String),
#[error("record `{0}` is missing frontmatter")]
MissingFrontmatter(String),
#[error("store error: {0}")]
Store(String),
}

View File

@ -0,0 +1,341 @@
use std::fs;
use std::path::{Path, PathBuf};
use project_record::validate_record_id;
use serde::{Deserialize, Serialize};
use ticket::{LocalTicketBackend, TicketFilter, TicketIdOrSlug};
use crate::{Error, Result};
const DETAIL_BODY_LIMIT: usize = 64 * 1024;
#[derive(Debug, Clone)]
pub struct LocalProjectRecordReader {
workspace_root: PathBuf,
ticket_backend: LocalTicketBackend,
}
impl LocalProjectRecordReader {
pub fn new(workspace_root: impl Into<PathBuf>) -> Self {
let workspace_root = workspace_root.into();
let ticket_root = workspace_root.join(".yoi/tickets");
Self {
workspace_root,
ticket_backend: LocalTicketBackend::new(ticket_root),
}
}
pub fn workspace_root(&self) -> &Path {
self.workspace_root.as_path()
}
pub fn list_tickets(&self, limit: usize) -> Result<ProjectRecordList<TicketSummary>> {
let partial = self.ticket_backend.list_partial(TicketFilter::all())?;
let mut items = partial
.tickets
.into_iter()
.map(|item| TicketSummary {
id: item.id,
title: item.title,
state: item.workflow_state.as_str().to_string(),
priority: item.priority,
updated_at: item.updated_at,
queued_by: item.queued_by,
queued_at: item.queued_at,
record_source: "local_yoi_ticket".to_string(),
})
.collect::<Vec<_>>();
items.sort_by(|a, b| {
b.updated_at
.cmp(&a.updated_at)
.then_with(|| a.id.cmp(&b.id))
});
items.truncate(limit.min(200));
Ok(ProjectRecordList {
items,
invalid_records: partial
.invalid_records
.into_iter()
.map(|record| InvalidProjectRecord {
label: record.label,
reason: record.reason,
})
.collect(),
record_authority: "local_yoi_project_records".to_string(),
})
}
pub fn ticket(&self, id: &str) -> Result<TicketDetail> {
validate_project_id(id)?;
let partial = self
.ticket_backend
.show_partial(TicketIdOrSlug::Id(id.to_string()))?;
let ticket = partial.ticket;
let (body, body_truncated) =
truncate_body(ticket.document.body.as_str(), DETAIL_BODY_LIMIT);
Ok(TicketDetail {
id: ticket.meta.id,
title: ticket.meta.title,
state: ticket.meta.workflow_state.as_str().to_string(),
priority: ticket.meta.priority,
created_at: ticket.meta.created_at,
updated_at: ticket.meta.updated_at,
queued_by: ticket.meta.queued_by,
queued_at: ticket.meta.queued_at,
risk_flags: ticket.meta.risk_flags,
body,
body_truncated,
event_count: ticket.events.len(),
artifact_count: ticket.artifacts.len(),
record_source: "local_yoi_ticket".to_string(),
})
}
pub fn list_objectives(&self, limit: usize) -> Result<ProjectRecordList<ObjectiveSummary>> {
let mut items = Vec::new();
let mut invalid_records = Vec::new();
let root = self.workspace_root.join(".yoi/objectives");
if !root.exists() {
return Ok(ProjectRecordList {
items,
invalid_records,
record_authority: "local_yoi_project_records".to_string(),
});
}
for entry in fs::read_dir(&root)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let id = entry.file_name().to_string_lossy().to_string();
match read_objective_summary(&path, &id) {
Ok(item) => items.push(item),
Err(error) => invalid_records.push(InvalidProjectRecord {
label: id,
reason: error.to_string(),
}),
}
}
items.sort_by(|a, b| {
b.updated_at
.cmp(&a.updated_at)
.then_with(|| a.id.cmp(&b.id))
});
items.truncate(limit.min(200));
Ok(ProjectRecordList {
items,
invalid_records,
record_authority: "local_yoi_project_records".to_string(),
})
}
pub fn objective(&self, id: &str) -> Result<ObjectiveDetail> {
validate_project_id(id)?;
let path = self.workspace_root.join(".yoi/objectives").join(id);
let raw = fs::read_to_string(path.join("item.md"))?;
let (frontmatter, body) = split_frontmatter(&raw, id)?;
let meta: ObjectiveFrontmatter = serde_yaml::from_str(frontmatter)?;
let (body, body_truncated) = truncate_body(body, DETAIL_BODY_LIMIT);
Ok(ObjectiveDetail {
id: id.to_string(),
title: meta.title,
state: meta.state,
created_at: meta.created_at,
updated_at: meta.updated_at,
linked_tickets: meta.linked_tickets,
body,
body_truncated,
record_source: "local_yoi_objective".to_string(),
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProjectRecordList<T> {
pub items: Vec<T>,
pub invalid_records: Vec<InvalidProjectRecord>,
pub record_authority: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct InvalidProjectRecord {
pub label: String,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TicketSummary {
pub id: String,
pub title: String,
pub state: String,
pub priority: String,
pub updated_at: Option<String>,
pub queued_by: Option<String>,
pub queued_at: Option<String>,
pub record_source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TicketDetail {
pub id: String,
pub title: String,
pub state: String,
pub priority: String,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub queued_by: Option<String>,
pub queued_at: Option<String>,
pub risk_flags: Vec<String>,
pub body: String,
pub body_truncated: bool,
pub event_count: usize,
pub artifact_count: usize,
pub record_source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ObjectiveSummary {
pub id: String,
pub title: String,
pub state: String,
pub updated_at: Option<String>,
pub linked_tickets: Vec<String>,
pub record_source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ObjectiveDetail {
pub id: String,
pub title: String,
pub state: String,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub linked_tickets: Vec<String>,
pub body: String,
pub body_truncated: bool,
pub record_source: String,
}
#[derive(Debug, Deserialize)]
struct ObjectiveFrontmatter {
title: String,
state: String,
#[serde(default)]
created_at: Option<String>,
#[serde(default)]
updated_at: Option<String>,
#[serde(default)]
linked_tickets: Vec<String>,
}
fn read_objective_summary(path: &Path, id: &str) -> Result<ObjectiveSummary> {
validate_project_id(id)?;
let raw = fs::read_to_string(path.join("item.md"))?;
let (frontmatter, _) = split_frontmatter(&raw, id)?;
let meta: ObjectiveFrontmatter = serde_yaml::from_str(frontmatter)?;
Ok(ObjectiveSummary {
id: id.to_string(),
title: meta.title,
state: meta.state,
updated_at: meta.updated_at,
linked_tickets: meta.linked_tickets,
record_source: "local_yoi_objective".to_string(),
})
}
fn split_frontmatter<'a>(raw: &'a str, label: &str) -> Result<(&'a str, &'a str)> {
let rest = raw
.strip_prefix("---\n")
.ok_or_else(|| Error::MissingFrontmatter(label.to_string()))?;
let Some((frontmatter, body)) = rest.split_once("\n---\n") else {
return Err(Error::MissingFrontmatter(label.to_string()));
};
Ok((frontmatter, body))
}
fn validate_project_id(id: &str) -> Result<()> {
validate_record_id(id).map_err(|_| Error::InvalidRecordId(id.to_string()))
}
fn truncate_body(body: &str, limit: usize) -> (String, bool) {
if body.len() <= limit {
return (body.to_string(), false);
}
let mut end = limit;
while !body.is_char_boundary(end) {
end -= 1;
}
(body[..end].to_string(), true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_local_yoi_ticket_and_objective_records_without_migration() {
let dir = tempfile::tempdir().unwrap();
write_ticket(dir.path(), "00000000001J2", "Read bridge", "ready");
write_objective(dir.path(), "00000000001J3", "Control plane", "active");
let reader = LocalProjectRecordReader::new(dir.path());
let tickets = reader.list_tickets(20).unwrap();
assert_eq!(tickets.record_authority, "local_yoi_project_records");
assert_eq!(tickets.items[0].id, "00000000001J2");
assert_eq!(tickets.items[0].state, "ready");
let ticket = reader.ticket("00000000001J2").unwrap();
assert!(ticket.body.contains("Ticket body"));
let objectives = reader.list_objectives(20).unwrap();
assert_eq!(objectives.items[0].id, "00000000001J3");
assert_eq!(objectives.items[0].linked_tickets, vec!["00000000001J2"]);
let objective = reader.objective("00000000001J3").unwrap();
assert!(objective.body.contains("Objective body"));
}
fn write_ticket(root: &Path, id: &str, title: &str, state: &str) {
let ticket_dir = root.join(".yoi/tickets").join(id);
fs::create_dir_all(&ticket_dir).unwrap();
fs::write(
ticket_dir.join("item.md"),
format!(
r#"---
title: "{title}"
state: "{state}"
created_at: "2026-01-01T00:00:00Z"
updated_at: "2026-01-02T00:00:00Z"
---
Ticket body.
"#,
),
)
.unwrap();
fs::write(ticket_dir.join("thread.md"), "").unwrap();
}
fn write_objective(root: &Path, id: &str, title: &str, state: &str) {
let objective_dir = root.join(".yoi/objectives").join(id);
fs::create_dir_all(&objective_dir).unwrap();
fs::write(
objective_dir.join("item.md"),
format!(
r#"---
title: "{title}"
state: "{state}"
created_at: "2026-01-01T00:00:00Z"
updated_at: "2026-01-02T00:00:00Z"
linked_tickets: ["00000000001J2"]
---
Objective body.
"#,
),
)
.unwrap();
}
}

View File

@ -0,0 +1,530 @@
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use axum::extract::{Path as AxumPath, State};
use axum::http::header::CONTENT_TYPE;
use axum::http::{StatusCode, Uri};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;
use crate::records::{LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail};
use crate::store::{ControlPlaneStore, RunSummary, RunnerSummary, WorkspaceRecord};
use crate::{Error, Result};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum AuthConfig {
/// Local/dev-only mode. If a token is configured by a future entrypoint, it
/// is a development guard only and not a production SaaS auth model.
LocalDevToken { token_configured: bool },
}
#[derive(Clone)]
pub struct ServerConfig {
pub workspace_id: String,
pub workspace_root: PathBuf,
pub static_assets_dir: Option<PathBuf>,
pub auth: AuthConfig,
pub max_records: usize,
}
impl ServerConfig {
pub fn local_dev(workspace_root: impl Into<PathBuf>) -> Self {
let workspace_root = workspace_root.into();
let display = workspace_root
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("workspace");
Self {
workspace_id: format!("local:{display}"),
workspace_root,
static_assets_dir: None,
auth: AuthConfig::LocalDevToken {
token_configured: false,
},
max_records: 200,
}
}
}
#[derive(Clone)]
pub struct WorkspaceApi {
config: ServerConfig,
store: Arc<dyn ControlPlaneStore>,
records: LocalProjectRecordReader,
}
impl WorkspaceApi {
pub async fn new(config: ServerConfig, store: Arc<dyn ControlPlaneStore>) -> Result<Self> {
let display_name = config
.workspace_root
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("workspace")
.to_string();
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: config.workspace_id.clone(),
display_name,
local_root: config.workspace_root.clone(),
record_authority: "local_yoi_project_records".to_string(),
created_at: "1970-01-01T00:00:00Z".to_string(),
updated_at: "1970-01-01T00:00:00Z".to_string(),
})
.await?;
Ok(Self {
records: LocalProjectRecordReader::new(config.workspace_root.clone()),
config,
store,
})
}
pub fn workspace_id(&self) -> &str {
self.config.workspace_id.as_str()
}
}
pub fn build_router(api: WorkspaceApi) -> Router {
Router::new()
.route("/api/workspace", get(get_workspace))
.route("/api/tickets", get(list_tickets))
.route("/api/tickets/{id}", get(get_ticket))
.route("/api/objectives", get(list_objectives))
.route("/api/objectives/{id}", get(get_objective))
.route("/api/runs", get(list_runs))
.route("/api/runners", get(list_runners))
.fallback(get(static_or_spa_fallback))
.with_state(api)
}
pub async fn serve(
config: ServerConfig,
store: Arc<dyn ControlPlaneStore>,
listener: TcpListener,
) -> Result<()> {
let api = WorkspaceApi::new(config, store).await?;
axum::serve(listener, build_router(api)).await?;
Ok(())
}
#[derive(Debug, Serialize, Deserialize)]
pub struct WorkspaceResponse {
pub workspace_id: String,
pub display_name: String,
pub local_root: PathBuf,
pub record_authority: String,
pub schema_version: i64,
pub auth: AuthConfig,
pub extension_points: ExtensionPoints,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ExtensionPoints {
pub store: String,
pub event_stream: ExtensionPointState,
pub runner_connection: ExtensionPointState,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ExtensionPointState {
pub status: String,
pub note: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ListResponse<T> {
pub workspace_id: String,
pub limit: usize,
pub items: Vec<T>,
pub invalid_records: Vec<crate::records::InvalidProjectRecord>,
pub record_authority: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RuntimeListResponse<T> {
pub workspace_id: String,
pub limit: usize,
pub items: Vec<T>,
pub source: String,
}
async fn get_workspace(State(api): State<WorkspaceApi>) -> ApiResult<Json<WorkspaceResponse>> {
let schema_version = api.store.schema_version().await?;
let stored = api.store.get_workspace(api.workspace_id()).await?;
let display_name = stored
.as_ref()
.map(|record| record.display_name.clone())
.or_else(|| {
api.config
.workspace_root
.file_name()
.and_then(|name| name.to_str())
.map(str::to_string)
})
.unwrap_or_else(|| "workspace".to_string());
Ok(Json(WorkspaceResponse {
workspace_id: api.config.workspace_id.clone(),
display_name,
local_root: api.config.workspace_root.clone(),
record_authority: "local_yoi_project_records".to_string(),
schema_version,
auth: api.config.auth.clone(),
extension_points: ExtensionPoints {
store: "sqlite".to_string(),
event_stream: ExtensionPointState {
status: "reserved".to_string(),
note: "No event stream is exposed in this bootstrap; route/state seams are reserved.".to_string(),
},
runner_connection: ExtensionPointState {
status: "reserved".to_string(),
note: "Runner connections are modeled, but no job dispatch or scheduler is implemented.".to_string(),
},
},
}))
}
async fn list_tickets(
State(api): State<WorkspaceApi>,
) -> ApiResult<Json<ListResponse<crate::records::TicketSummary>>> {
let limit = api.config.max_records.min(200);
let ProjectRecordList {
items,
invalid_records,
record_authority,
} = api.records.list_tickets(limit)?;
Ok(Json(ListResponse {
workspace_id: api.config.workspace_id,
limit,
items,
invalid_records,
record_authority,
}))
}
async fn get_ticket(
State(api): State<WorkspaceApi>,
AxumPath(id): AxumPath<String>,
) -> ApiResult<Json<TicketDetail>> {
Ok(Json(api.records.ticket(&id)?))
}
async fn list_objectives(
State(api): State<WorkspaceApi>,
) -> ApiResult<Json<ListResponse<crate::records::ObjectiveSummary>>> {
let limit = api.config.max_records.min(200);
let ProjectRecordList {
items,
invalid_records,
record_authority,
} = api.records.list_objectives(limit)?;
Ok(Json(ListResponse {
workspace_id: api.config.workspace_id,
limit,
items,
invalid_records,
record_authority,
}))
}
async fn get_objective(
State(api): State<WorkspaceApi>,
AxumPath(id): AxumPath<String>,
) -> ApiResult<Json<ObjectiveDetail>> {
Ok(Json(api.records.objective(&id)?))
}
async fn list_runs(
State(api): State<WorkspaceApi>,
) -> ApiResult<Json<RuntimeListResponse<RunSummary>>> {
let limit = api.config.max_records.min(200);
let items = api.store.list_runs(api.workspace_id(), limit).await?;
Ok(Json(RuntimeListResponse {
workspace_id: api.config.workspace_id,
limit,
items,
source: "sqlite_runtime_tables".to_string(),
}))
}
async fn list_runners(
State(api): State<WorkspaceApi>,
) -> ApiResult<Json<RuntimeListResponse<RunnerSummary>>> {
let limit = api.config.max_records.min(200);
let items = api.store.list_runners(api.workspace_id(), limit).await?;
Ok(Json(RuntimeListResponse {
workspace_id: api.config.workspace_id,
limit,
items,
source: "sqlite_runtime_tables".to_string(),
}))
}
async fn static_or_spa_fallback(State(api): State<WorkspaceApi>, uri: Uri) -> Response {
if uri.path().starts_with("/api/") || uri.path() == "/api" {
return (
StatusCode::NOT_FOUND,
[(CONTENT_TYPE, "application/json")],
Json(serde_json::json!({
"error": "not_found",
"message": "unknown api route"
}))
.to_string(),
)
.into_response();
}
let Some(static_root) = api.config.static_assets_dir.as_ref() else {
return StatusCode::NOT_FOUND.into_response();
};
match read_static_or_index(static_root, uri.path()).await {
Ok(StaticAsset {
bytes,
content_type,
}) => (StatusCode::OK, [(CONTENT_TYPE, content_type)], bytes).into_response(),
Err(error) => {
tracing::debug!(%error, path = %uri.path(), "failed to serve static asset");
StatusCode::NOT_FOUND.into_response()
}
}
}
struct StaticAsset {
bytes: Vec<u8>,
content_type: &'static str,
}
async fn read_static_or_index(root: &Path, request_path: &str) -> Result<StaticAsset> {
let candidate = safe_static_candidate(root, request_path)?;
let file = if tokio::fs::metadata(&candidate)
.await
.map(|m| m.is_file())
.unwrap_or(false)
{
candidate
} else {
root.join("index.html")
};
let content_type = content_type_for(&file);
let bytes = tokio::fs::read(file).await?;
Ok(StaticAsset {
bytes,
content_type,
})
}
fn safe_static_candidate(root: &Path, request_path: &str) -> Result<PathBuf> {
let mut path = root.to_path_buf();
let clean = request_path.trim_start_matches('/');
if clean.is_empty() {
path.push("index.html");
return Ok(path);
}
for component in Path::new(clean).components() {
match component {
Component::Normal(part) => path.push(part),
Component::CurDir => {}
_ => return Err(Error::Store("static path escape rejected".to_string())),
}
}
Ok(path)
}
fn content_type_for(path: &Path) -> &'static str {
match path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or_default()
{
"css" => "text/css; charset=utf-8",
"js" => "text/javascript; charset=utf-8",
"json" => "application/json",
"svg" => "image/svg+xml",
"html" | "" => "text/html; charset=utf-8",
_ => "application/octet-stream",
}
}
type ApiResult<T> = std::result::Result<T, ApiError>;
struct ApiError(Error);
impl From<Error> for ApiError {
fn from(error: Error) -> Self {
Self(error)
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = match &self.0 {
Error::InvalidRecordId(_) | Error::MissingFrontmatter(_) => StatusCode::NOT_FOUND,
Error::Ticket(_) => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
(
status,
[(CONTENT_TYPE, "application/json")],
Json(serde_json::json!({
"error": status.canonical_reason().unwrap_or("error"),
"message": self.0.to_string(),
}))
.to_string(),
)
.into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::{Body, to_bytes};
use axum::http::Request;
use serde_json::Value;
use tower::ServiceExt;
use crate::store::SqliteWorkspaceStore;
#[tokio::test]
async fn serves_bounded_read_apis_and_static_spa_separately() {
let dir = tempfile::tempdir().unwrap();
write_ticket(dir.path(), "00000000001J2", "API Ticket", "ready");
write_objective(dir.path(), "00000000001J3", "API Objective", "active");
let static_dir = dir.path().join("static");
std::fs::create_dir_all(static_dir.join("assets")).unwrap();
std::fs::write(static_dir.join("index.html"), "<main>Yoi Workspace</main>").unwrap();
std::fs::write(static_dir.join("assets/app.js"), "console.log('yoi');").unwrap();
let store = SqliteWorkspaceStore::in_memory().unwrap();
let mut config = ServerConfig::local_dev(dir.path());
config.workspace_id = "local:test".to_string();
config.static_assets_dir = Some(static_dir);
let api = WorkspaceApi::new(config, Arc::new(store)).await.unwrap();
let app = build_router(api);
let workspace = get_json(app.clone(), "/api/workspace").await;
assert_eq!(workspace["workspace_id"], "local:test");
assert_eq!(workspace["record_authority"], "local_yoi_project_records");
assert_eq!(
workspace["extension_points"]["runner_connection"]["status"],
"reserved"
);
let tickets = get_json(app.clone(), "/api/tickets").await;
assert_eq!(tickets["items"][0]["id"], "00000000001J2");
assert_eq!(tickets["items"][0]["state"], "ready");
let objectives = get_json(app.clone(), "/api/objectives").await;
assert_eq!(objectives["items"][0]["id"], "00000000001J3");
let runners = get_json(app.clone(), "/api/runners").await;
assert!(runners["items"].as_array().unwrap().is_empty());
let static_response = app
.clone()
.oneshot(
Request::builder()
.uri("/assets/app.js")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(static_response.status(), StatusCode::OK);
assert_eq!(
static_response.headers().get(CONTENT_TYPE).unwrap(),
"text/javascript; charset=utf-8"
);
let spa_response = app
.clone()
.oneshot(
Request::builder()
.uri("/tickets/00000000001J2")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(spa_response.status(), StatusCode::OK);
let bytes = to_bytes(spa_response.into_body(), usize::MAX)
.await
.unwrap();
assert!(
String::from_utf8(bytes.to_vec())
.unwrap()
.contains("Yoi Workspace")
);
let api_miss = app
.oneshot(
Request::builder()
.uri("/api/nope")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(api_miss.status(), StatusCode::NOT_FOUND);
let bytes = to_bytes(api_miss.into_body(), usize::MAX).await.unwrap();
assert!(
!String::from_utf8(bytes.to_vec())
.unwrap()
.contains("Yoi Workspace")
);
}
async fn get_json(app: Router, uri: &str) -> Value {
let response = app
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK, "{uri}");
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
serde_json::from_slice(&bytes).unwrap()
}
fn write_ticket(root: &Path, id: &str, title: &str, state: &str) {
let ticket_dir = root.join(".yoi/tickets").join(id);
std::fs::create_dir_all(&ticket_dir).unwrap();
std::fs::write(
ticket_dir.join("item.md"),
format!(
r#"---
title: "{title}"
state: "{state}"
created_at: "2026-01-01T00:00:00Z"
updated_at: "2026-01-02T00:00:00Z"
---
Ticket body.
"#,
),
)
.unwrap();
std::fs::write(ticket_dir.join("thread.md"), "").unwrap();
}
fn write_objective(root: &Path, id: &str, title: &str, state: &str) {
let objective_dir = root.join(".yoi/objectives").join(id);
std::fs::create_dir_all(&objective_dir).unwrap();
std::fs::write(
objective_dir.join("item.md"),
format!(
r#"---
title: "{title}"
state: "{state}"
created_at: "2026-01-01T00:00:00Z"
updated_at: "2026-01-02T00:00:00Z"
linked_tickets: ["00000000001J2"]
---
Objective body.
"#,
),
)
.unwrap();
}
}

View File

@ -0,0 +1,341 @@
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use async_trait::async_trait;
use rusqlite::{Connection, OptionalExtension, params};
use serde::{Deserialize, Serialize};
use crate::{Error, Result};
const MIGRATIONS: &[Migration] = &[Migration {
version: 1,
name: "bootstrap workspace control plane",
sql: r#"
CREATE TABLE IF NOT EXISTS workspaces (
workspace_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
local_root TEXT NOT NULL,
record_authority TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS repositories (
repository_id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
local_root TEXT NOT NULL,
role TEXT NOT NULL,
created_at TEXT NOT NULL
);
-- Projection tables are intentionally empty in this bootstrap: `.yoi/tickets`
-- and `.yoi/objectives` remain canonical, but the tables reserve a future
-- projection/cache seam without migrating authority.
CREATE TABLE IF NOT EXISTS ticket_projections (
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
ticket_id TEXT NOT NULL,
title TEXT NOT NULL,
state TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id)
);
CREATE TABLE IF NOT EXISTS objective_projections (
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
objective_id TEXT NOT NULL,
title TEXT NOT NULL,
state TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, objective_id)
);
CREATE TABLE IF NOT EXISTS runners (
runner_id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
label TEXT NOT NULL,
status TEXT NOT NULL,
last_seen_at TEXT
);
CREATE TABLE IF NOT EXISTS runs (
run_id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
subject_kind TEXT NOT NULL,
subject_id TEXT NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS artifacts (
artifact_id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
run_id TEXT REFERENCES runs(run_id) ON DELETE SET NULL,
path TEXT NOT NULL,
content_type TEXT,
created_at TEXT NOT NULL
);
"#,
}];
struct Migration {
version: i64,
name: &'static str,
sql: &'static str,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkspaceRecord {
pub workspace_id: String,
pub display_name: String,
pub local_root: PathBuf,
pub record_authority: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RunSummary {
pub run_id: String,
pub workspace_id: String,
pub subject_kind: String,
pub subject_id: String,
pub status: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RunnerSummary {
pub runner_id: String,
pub workspace_id: String,
pub label: String,
pub status: String,
pub last_seen_at: Option<String>,
}
#[async_trait]
pub trait ControlPlaneStore: Send + Sync {
async fn schema_version(&self) -> Result<i64>;
async fn upsert_workspace(&self, record: &WorkspaceRecord) -> Result<()>;
async fn get_workspace(&self, workspace_id: &str) -> Result<Option<WorkspaceRecord>>;
async fn list_runs(&self, workspace_id: &str, limit: usize) -> Result<Vec<RunSummary>>;
async fn list_runners(&self, workspace_id: &str, limit: usize) -> Result<Vec<RunnerSummary>>;
}
#[derive(Clone)]
pub struct SqliteWorkspaceStore {
conn: Arc<Mutex<Connection>>,
}
impl SqliteWorkspaceStore {
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
let conn = Connection::open(path)?;
Self::from_connection(conn)
}
pub fn in_memory() -> Result<Self> {
Self::from_connection(Connection::open_in_memory()?)
}
pub fn from_connection(conn: Connection) -> Result<Self> {
configure_sqlite(&conn)?;
apply_migrations(&conn)?;
Ok(Self {
conn: Arc::new(Mutex::new(conn)),
})
}
fn with_conn<T>(&self, f: impl FnOnce(&Connection) -> Result<T>) -> Result<T> {
let conn = self
.conn
.lock()
.map_err(|_| Error::Store("sqlite connection lock poisoned".to_string()))?;
f(&conn)
}
}
#[async_trait]
impl ControlPlaneStore for SqliteWorkspaceStore {
async fn schema_version(&self) -> Result<i64> {
self.with_conn(current_schema_version)
}
async fn upsert_workspace(&self, record: &WorkspaceRecord) -> Result<()> {
self.with_conn(|conn| {
conn.execute(
r#"INSERT INTO workspaces (
workspace_id, display_name, local_root, record_authority, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(workspace_id) DO UPDATE SET
display_name = excluded.display_name,
local_root = excluded.local_root,
record_authority = excluded.record_authority,
updated_at = excluded.updated_at"#,
params![
record.workspace_id,
record.display_name,
record.local_root.to_string_lossy(),
record.record_authority,
record.created_at,
record.updated_at,
],
)?;
Ok(())
})
}
async fn get_workspace(&self, workspace_id: &str) -> Result<Option<WorkspaceRecord>> {
self.with_conn(|conn| {
conn.query_row(
r#"SELECT workspace_id, display_name, local_root, record_authority, created_at, updated_at
FROM workspaces WHERE workspace_id = ?1"#,
params![workspace_id],
|row| {
Ok(WorkspaceRecord {
workspace_id: row.get(0)?,
display_name: row.get(1)?,
local_root: PathBuf::from(row.get::<_, String>(2)?),
record_authority: row.get(3)?,
created_at: row.get(4)?,
updated_at: row.get(5)?,
})
},
)
.optional()
.map_err(Error::from)
})
}
async fn list_runs(&self, workspace_id: &str, limit: usize) -> Result<Vec<RunSummary>> {
self.with_conn(|conn| {
let limit = limit.min(200) as i64;
let mut stmt = conn.prepare(
r#"SELECT run_id, workspace_id, subject_kind, subject_id, status, created_at, updated_at
FROM runs WHERE workspace_id = ?1 ORDER BY updated_at DESC, run_id DESC LIMIT ?2"#,
)?;
let rows = stmt.query_map(params![workspace_id, limit], |row| {
Ok(RunSummary {
run_id: row.get(0)?,
workspace_id: row.get(1)?,
subject_kind: row.get(2)?,
subject_id: row.get(3)?,
status: row.get(4)?,
created_at: row.get(5)?,
updated_at: row.get(6)?,
})
})?;
rows.collect::<rusqlite::Result<Vec<_>>>().map_err(Error::from)
})
}
async fn list_runners(&self, workspace_id: &str, limit: usize) -> Result<Vec<RunnerSummary>> {
self.with_conn(|conn| {
let limit = limit.min(200) as i64;
let mut stmt = conn.prepare(
r#"SELECT runner_id, workspace_id, label, status, last_seen_at
FROM runners WHERE workspace_id = ?1 ORDER BY runner_id ASC LIMIT ?2"#,
)?;
let rows = stmt.query_map(params![workspace_id, limit], |row| {
Ok(RunnerSummary {
runner_id: row.get(0)?,
workspace_id: row.get(1)?,
label: row.get(2)?,
status: row.get(3)?,
last_seen_at: row.get(4)?,
})
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Error::from)
})
}
}
fn configure_sqlite(conn: &Connection) -> Result<()> {
conn.busy_timeout(Duration::from_millis(5_000))?;
conn.execute_batch(
r#"
PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL;
PRAGMA busy_timeout = 5000;
CREATE TABLE IF NOT EXISTS __yoi_schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
"#,
)?;
Ok(())
}
fn current_schema_version(conn: &Connection) -> Result<i64> {
conn.query_row(
"SELECT COALESCE(MAX(version), 0) FROM __yoi_schema_migrations",
[],
|row| row.get(0),
)
.map_err(Error::from)
}
fn apply_migrations(conn: &Connection) -> Result<()> {
let current = current_schema_version(conn)?;
for migration in MIGRATIONS
.iter()
.filter(|migration| migration.version > current)
{
let tx = conn.unchecked_transaction()?;
tx.execute_batch(migration.sql)?;
tx.execute(
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
params![migration.version, migration.name],
)?;
tx.commit()?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn migrates_sqlite_and_preserves_workspace_record() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("control-plane.sqlite");
let store = SqliteWorkspaceStore::open(&db).unwrap();
assert_eq!(store.schema_version().await.unwrap(), 1);
let record = WorkspaceRecord {
workspace_id: "local-dev".to_string(),
display_name: "Yoi Dev".to_string(),
local_root: dir.path().to_path_buf(),
record_authority: "local_yoi_project_records".to_string(),
created_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: "2026-01-01T00:00:00Z".to_string(),
};
store.upsert_workspace(&record).await.unwrap();
let reopened = SqliteWorkspaceStore::open(&db).unwrap();
assert_eq!(reopened.schema_version().await.unwrap(), 1);
assert_eq!(
reopened.get_workspace("local-dev").await.unwrap(),
Some(record)
);
assert!(
reopened
.list_runs("local-dev", 20)
.await
.unwrap()
.is_empty()
);
assert!(
reopened
.list_runners("local-dev", 20)
.await
.unwrap()
.is_empty()
);
}
}

View File

@ -292,7 +292,7 @@ fn inspect_materialized_package(
)), )),
digest: Some(materialized.package.digest.clone()), digest: Some(materialized.package.digest.clone()),
permissions: requested_permissions, permissions: requested_permissions,
https: Vec::new(), request: Vec::new(),
fs: Vec::new(), fs: Vec::new(),
}, },
config: None, config: None,
@ -723,7 +723,7 @@ fn render_show(reference: &str, args: &PluginCliArgs) -> Result<String> {
return Ok(format!("{}\n", serde_json::to_string_pretty(item)?)); return Ok(format!("{}\n", serde_json::to_string_pretty(item)?));
} }
render_item_human(item) render_item_human(&item)
} }
fn render_item_human(item: &PluginInspectionItem) -> Result<String> { fn render_item_human(item: &PluginInspectionItem) -> Result<String> {
@ -799,8 +799,8 @@ fn render_item_human(item: &PluginInspectionItem) -> Result<String> {
)?; )?;
writeln!( writeln!(
out, out,
" configured_https_grants: {}", " configured_request_grants: {}",
join_or_none(&item.configured_https_grants) join_or_none(&item.configured_request_grants)
)?; )?;
writeln!( writeln!(
out, out,
@ -976,7 +976,7 @@ fn snapshot_from_resolution(
builder.configured = true; builder.configured = true;
builder.enabled_surfaces = surface_strings(enablement.surfaces.iter().copied()); builder.enabled_surfaces = surface_strings(enablement.surfaces.iter().copied());
builder.configured_grants = permission_strings(&enablement.grants.permissions); builder.configured_grants = permission_strings(&enablement.grants.permissions);
builder.configured_https_grants = https_grant_strings(&enablement.grants.https); builder.configured_request_grants = request_grant_strings(&enablement.grants.request);
builder.configured_fs_grants = fs_grant_strings(&enablement.grants.fs); builder.configured_fs_grants = fs_grant_strings(&enablement.grants.fs);
if let Ok(identity) = SourceQualifiedPluginId::parse(&enablement.id) { if let Ok(identity) = SourceQualifiedPluginId::parse(&enablement.id) {
builder builder
@ -1069,7 +1069,7 @@ fn fill_resolved(builder: &mut ItemBuilder, resolved: &ResolvedPlugin) {
builder.enabled_surfaces = surface_strings(resolved.enabled_surfaces.iter().copied()); builder.enabled_surfaces = surface_strings(resolved.enabled_surfaces.iter().copied());
builder.requested_permissions = permission_strings(&resolved.manifest.permissions); builder.requested_permissions = permission_strings(&resolved.manifest.permissions);
builder.configured_grants = permission_strings(&resolved.grants.permissions); builder.configured_grants = permission_strings(&resolved.grants.permissions);
builder.configured_https_grants = https_grant_strings(&resolved.grants.https); builder.configured_request_grants = request_grant_strings(&resolved.grants.request);
builder.configured_fs_grants = fs_grant_strings(&resolved.grants.fs); builder.configured_fs_grants = fs_grant_strings(&resolved.grants.fs);
let record = ResolvedPluginRecord::from_resolved(resolved); let record = ResolvedPluginRecord::from_resolved(resolved);
@ -1178,7 +1178,7 @@ fn permission_strings(permissions: &[PluginPermission]) -> Vec<String> {
values values
} }
fn https_grant_strings(grants: &[manifest::plugin::PluginHttpsGrant]) -> Vec<String> { fn request_grant_strings(grants: &[manifest::plugin::PluginRequestGrant]) -> Vec<String> {
let mut values: Vec<_> = grants.iter().map(|grant| grant.label()).collect(); let mut values: Vec<_> = grants.iter().map(|grant| grant.label()).collect();
values.sort(); values.sort();
values.dedup(); values.dedup();
@ -1262,7 +1262,7 @@ struct PluginInspectionItem {
enabled_surfaces: Vec<String>, enabled_surfaces: Vec<String>,
requested_permissions: Vec<String>, requested_permissions: Vec<String>,
configured_grants: Vec<String>, configured_grants: Vec<String>,
configured_https_grants: Vec<String>, configured_request_grants: Vec<String>,
configured_fs_grants: Vec<String>, configured_fs_grants: Vec<String>,
tools: Vec<ToolSummary>, tools: Vec<ToolSummary>,
static_runtime: Option<PluginStaticInspection>, static_runtime: Option<PluginStaticInspection>,
@ -1331,7 +1331,7 @@ struct ItemBuilder {
enabled_surfaces: Vec<String>, enabled_surfaces: Vec<String>,
requested_permissions: Vec<String>, requested_permissions: Vec<String>,
configured_grants: Vec<String>, configured_grants: Vec<String>,
configured_https_grants: Vec<String>, configured_request_grants: Vec<String>,
configured_fs_grants: Vec<String>, configured_fs_grants: Vec<String>,
tools: Vec<ToolSummary>, tools: Vec<ToolSummary>,
static_runtime: Option<PluginStaticInspection>, static_runtime: Option<PluginStaticInspection>,
@ -1358,7 +1358,7 @@ impl ItemBuilder {
enabled_surfaces: Vec::new(), enabled_surfaces: Vec::new(),
requested_permissions: Vec::new(), requested_permissions: Vec::new(),
configured_grants: Vec::new(), configured_grants: Vec::new(),
configured_https_grants: Vec::new(), configured_request_grants: Vec::new(),
configured_fs_grants: Vec::new(), configured_fs_grants: Vec::new(),
tools: Vec::new(), tools: Vec::new(),
static_runtime: None, static_runtime: None,
@ -1430,7 +1430,7 @@ impl ItemBuilder {
enabled_surfaces: self.enabled_surfaces, enabled_surfaces: self.enabled_surfaces,
requested_permissions: self.requested_permissions, requested_permissions: self.requested_permissions,
configured_grants: self.configured_grants, configured_grants: self.configured_grants,
configured_https_grants: self.configured_https_grants, configured_request_grants: self.configured_request_grants,
configured_fs_grants: self.configured_fs_grants, configured_fs_grants: self.configured_fs_grants,
tools: self.tools, tools: self.tools,
static_runtime: self.static_runtime, static_runtime: self.static_runtime,
@ -1443,6 +1443,7 @@ impl ItemBuilder {
mod tests { mod tests {
use super::*; use super::*;
use manifest::plugin::{PluginEnablementConfig, PluginExactVersion, PluginGrantConfig}; use manifest::plugin::{PluginEnablementConfig, PluginExactVersion, PluginGrantConfig};
use pod::feature::plugin::{PluginPermissionEligibility, PluginRuntimeEligibility};
use tempfile::tempdir; use tempfile::tempdir;
#[test] #[test]
@ -1494,7 +1495,7 @@ mod tests {
assert_eq!(show_json["configured_grants"][0], "surfaces.tool"); assert_eq!(show_json["configured_grants"][0], "surfaces.tool");
assert_eq!(show_json["tools"][0]["permission"], "tool.Echo"); assert_eq!(show_json["tools"][0]["permission"], "tool.Echo");
let show = render_item_human(item).unwrap(); let show = render_item_human(&item).unwrap();
assert!(show.contains("status: active")); assert!(show.contains("status: active"));
assert!(show.contains("schema_version: 1")); assert!(show.contains("schema_version: 1"));
assert!(show.contains("api_version: 1")); assert!(show.contains("api_version: 1"));
@ -1503,6 +1504,78 @@ mod tests {
assert!(show.contains("configured_grants: surfaces.tool, tool.Echo")); assert!(show.contains("configured_grants: surfaces.tool, tool.Echo"));
} }
#[test]
fn render_show_distinguishes_request_grant_statuses_and_broad_targets() {
let item = PluginInspectionItem {
reference: "project:req".to_string(),
local_ref: Some("project:req".to_string()),
status: "configured".to_string(),
source: Some("project".to_string()),
package: Some("req".to_string()),
package_path: None,
version: Some("0.1.0".to_string()),
schema_version: Some(1),
api_version: Some(1),
digest: None,
configured: true,
discovered: true,
resolved: true,
static_eligible: true,
declared_surfaces: vec!["tool".to_string()],
enabled_surfaces: vec!["tool".to_string()],
requested_permissions: vec!["host_api.request".to_string()],
configured_grants: vec!["host_api.request".to_string()],
configured_request_grants: vec!["*://* GET * [broad-request]".to_string()],
configured_fs_grants: Vec::new(),
tools: Vec::new(),
static_runtime: Some(PluginStaticInspection {
runtime: PluginRuntimeEligibility {
eligible: true,
status: "component".to_string(),
diagnostic: None,
},
host_apis: vec![
PluginPermissionEligibility {
permission: "host_api.request target https://api.example.test GET /v1/"
.to_string(),
requested: true,
granted: true,
eligible: true,
diagnostic: Some(
"covered by broad/arbitrary enabled request grant".to_string(),
),
},
PluginPermissionEligibility {
permission: "host_api.request grant *://* GET * [broad-request]"
.to_string(),
requested: true,
granted: true,
eligible: true,
diagnostic: Some(
"broad/arbitrary enabled request grant is constrained by manifest declarations"
.to_string(),
),
},
],
tools: Vec::new(),
services: Vec::new(),
ingresses: Vec::new(),
}),
diagnostics: Vec::new(),
};
let json = serde_json::to_value(&item).unwrap();
assert_eq!(
json["configured_request_grants"][0],
"*://* GET * [broad-request]"
);
let human = render_item_human(&item).unwrap();
assert!(human.contains("host_api.request target https://api.example.test"));
assert!(human.contains("requested=true granted=true eligible=true"));
assert!(human.contains("host_api.request grant *://*"));
assert!(human.contains("broad/arbitrary"));
}
#[test] #[test]
fn service_only_enablement_ignores_unselected_tool_static_grants() { fn service_only_enablement_ignores_unselected_tool_static_grants() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
@ -1522,7 +1595,7 @@ mod tests {
PluginPermission::surface(PluginSurface::Service), PluginPermission::surface(PluginSurface::Service),
PluginPermission::service("svc"), PluginPermission::service("svc"),
], ],
https: Vec::new(), request: Vec::new(),
fs: Vec::new(), fs: Vec::new(),
}, },
config: None, config: None,
@ -1576,7 +1649,7 @@ mod tests {
PluginPermission::surface(PluginSurface::Tool), PluginPermission::surface(PluginSurface::Tool),
PluginPermission::tool("Echo"), PluginPermission::tool("Echo"),
], ],
https: Vec::new(), request: Vec::new(),
fs: Vec::new(), fs: Vec::new(),
}, },
config: None, config: None,
@ -1594,7 +1667,7 @@ mod tests {
PluginPermission::surface(PluginSurface::Tool), PluginPermission::surface(PluginSurface::Tool),
PluginPermission::tool("Echo"), PluginPermission::tool("Echo"),
], ],
https: Vec::new(), request: Vec::new(),
fs: Vec::new(), fs: Vec::new(),
}, },
config: None, config: None,
@ -1712,7 +1785,7 @@ mod tests {
PluginPermission::surface(PluginSurface::Tool), PluginPermission::surface(PluginSurface::Tool),
PluginPermission::tool("Echo"), PluginPermission::tool("Echo"),
], ],
https: Vec::new(), request: Vec::new(),
fs: Vec::new(), fs: Vec::new(),
}, },
config: None, config: None,
@ -2247,7 +2320,7 @@ lifecycle = "host-managed"
PluginPermission::surface(PluginSurface::Tool), PluginPermission::surface(PluginSurface::Tool),
PluginPermission::tool("Echo"), PluginPermission::tool("Echo"),
], ],
https: Vec::new(), request: Vec::new(),
fs: Vec::new(), fs: Vec::new(),
}, },
config: None, config: None,
@ -2278,7 +2351,7 @@ lifecycle = "host-managed"
version: Some(PluginExactVersion(version.to_string())), version: Some(PluginExactVersion(version.to_string())),
digest: Some(digest), digest: Some(digest),
permissions, permissions,
https: Vec::new(), request: Vec::new(),
fs: Vec::new(), fs: Vec::new(),
}, },
config: None, config: None,
@ -2306,7 +2379,7 @@ lifecycle = "host-managed"
version: Some(PluginExactVersion(version.to_string())), version: Some(PluginExactVersion(version.to_string())),
digest: None, digest: None,
permissions, permissions,
https: Vec::new(), request: Vec::new(),
fs: Vec::new(), fs: Vec::new(),
}, },
config: None, config: None,

View File

@ -150,7 +150,7 @@ runtime, so registration and execution still flow through the existing
ToolRegistry and Worker Tool-result history path. ToolRegistry and Worker Tool-result history path.
Host imports are stable names under `yoi:host/*@1.0.0`; the repository WIT files Host imports are stable names under `yoi:host/*@1.0.0`; the repository WIT files
live in `resources/plugin/wit/`. Importing `yoi:host/https@1.0.0` or live in `resources/plugin/wit/`. Importing `yoi:host/request@1.0.0` or
`yoi:host/fs@1.0.0` is not authority. The runtime checks package grants before `yoi:host/fs@1.0.0` is not authority. The runtime checks package grants before
component instantiation and checks again on every host call. No WASI filesystem, component instantiation and checks again on every host call. No WASI filesystem,
network, environment, or other ambient imports are linked. network, environment, or other ambient imports are linked.
@ -176,7 +176,7 @@ The v1 component world intentionally keeps Tool input, Tool output, and host API
payloads as JSON strings. This is a migration bridge that preserves the existing payloads as JSON strings. This is a migration bridge that preserves the existing
ToolOutput schema, Tool history behavior, grant checks, and raw-Wasm host API ToolOutput schema, Tool history behavior, grant checks, and raw-Wasm host API
semantics while moving package authors onto WIT/canonical ABI bindings. semantics while moving package authors onto WIT/canonical ABI bindings.
Structured WIT records for Tool requests/responses/errors and host HTTPS/FS Structured WIT records for Tool requests/responses/errors and host request/FS
payloads are deferred to a follow-up API-design step rather than accidentally payloads are deferred to a follow-up API-design step rather than accidentally
omitted. omitted.

View File

@ -294,29 +294,46 @@ rejected invalid manifest, incompatible API, digest mismatch, grant denial, etc
partial usable package with some rejected surfaces/tools partial usable package with some rejected surfaces/tools
``` ```
## `https` host API ## `request` host API
The `https` host API is outbound-only and grant-gated. It is meant for Tool calls such as JSON POSTs or REST requests. It is not a WebSocket/Gateway or inbound HTTP surface. The `request` host API is a one-shot outbound HTTP request API. It is meant for bounded Tool calls such as JSON POSTs or REST requests. It is not a WebSocket, SSE/event-stream, gateway, daemon, or inbound HTTP surface; persistent transports require a separate Plugin capability.
Manifest permissions should request `host_api.https` in addition to the Tool permissions. Enablement grants must then allow the API and constrain hosts/methods. Manifest permissions should request `host_api.request` in addition to the Tool permissions, and the package manifest must statically declare the URL targets it may call. Enablement grants must then allow the API and grant matching request targets. A grant without a matching manifest target is unsafe/unused and is shown as ineligible rather than expanding authority.
Example grant shape: Example manifest shape:
```toml
permissions = [
{ kind = "surface", surface = "tool" },
{ kind = "tool", name = "http_post_json" },
{ kind = "host_api", api = "request" },
]
[[request]]
scheme = "https"
host = "api.example.com"
methods = ["POST"]
path_prefixes = ["/v1/"]
```
Example enablement grant shape:
```toml ```toml
[plugins.enabled.grants] [plugins.enabled.grants]
permissions = [ permissions = [
{ kind = "surface", surface = "tool" }, { kind = "surface", surface = "tool" },
{ kind = "tool", name = "http_post_json" }, { kind = "tool", name = "http_post_json" },
{ kind = "host_api", api = "https" }, { kind = "host_api", api = "request" },
] ]
[[plugins.enabled.grants.https]] [[plugins.enabled.grants.request]]
scheme = "https"
host = "api.example.com" host = "api.example.com"
methods = ["POST"] methods = ["POST"]
path_prefixes = ["/v1/"] path_prefixes = ["/v1/"]
``` ```
Yoi rejects `http://`, localhost/private/link-local targets, disallowed hosts/methods, oversize requests/responses, and missing grants. Credentials must come from explicit config/secret references, not ambient environment variables. 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.
## `fs` host API ## `fs` host API

View File

@ -29,6 +29,9 @@ let
|| isExcludedTree ".worktree" || isExcludedTree ".worktree"
|| isExcludedTree "work-items" || isExcludedTree "work-items"
|| isExcludedTree "docs/report" || isExcludedTree "docs/report"
|| isExcludedTree "web/workspace/node_modules"
|| isExcludedTree "web/workspace/.svelte-kit"
|| isExcludedTree "web/workspace/build"
); );
in in
rustPlatform.buildRustPackage rec { rustPlatform.buildRustPackage rec {
@ -40,7 +43,7 @@ rustPlatform.buildRustPackage rec {
filter = sourceFilter; filter = sourceFilter;
}; };
cargoHash = "sha256-GUqhvq+JhJokk1R4VVeVz5cZe/6oSrVMyKjcltZEWqE="; cargoHash = "sha256-RER/UXd74C2VhPHAeF36u6ruNBg0oLnR4YeQ/zLag88=";
depsExtraArgs = { depsExtraArgs = {
# Older fetchCargoVendor utilities used crates.io's API download endpoint, # Older fetchCargoVendor utilities used crates.io's API download endpoint,

View File

@ -1,9 +1,9 @@
package yoi:host@1.0.0; package yoi:host@1.0.0;
/// Grant-bound HTTPS host API. Importing this interface does not grant /// Grant-bound one-shot HTTP request host API. Importing this interface does not grant
/// authority; package grants are checked before registration/execution and on /// authority; package grants are checked before registration/execution and on
/// every host call. /// every host call.
interface https { interface request {
request: func(request-json: string) -> string; request: func(request-json: string) -> string;
} }

View File

@ -1,7 +1,7 @@
package yoi:plugin@1.0.0; package yoi:plugin@1.0.0;
world instance { world instance {
import yoi:host/https@1.0.0; import yoi:host/request@1.0.0;
import yoi:host/fs@1.0.0; import yoi:host/fs@1.0.0;
export start: func(config-json: string) -> string; export start: func(config-json: string) -> string;

View File

@ -1,7 +1,7 @@
package yoi:plugin@1.0.0; package yoi:plugin@1.0.0;
world tool { world tool {
import yoi:host/https@1.0.0; import yoi:host/request@1.0.0;
import yoi:host/fs@1.0.0; import yoi:host/fs@1.0.0;
/// Execute a manifest-declared Tool. `input-json` is the normal Tool input /// Execute a manifest-declared Tool. `input-json` is the normal Tool input

5
web/workspace/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules
.svelte-kit
build
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

21
web/workspace/README.md Normal file
View File

@ -0,0 +1,21 @@
# Workspace web SPA
This is the static SvelteKit shell for the local Yoi Workspace control plane.
It is intentionally a read-only UI bootstrap: `.yoi/tickets` and
`.yoi/objectives` remain canonical, and the Rust backend owns all business/API
semantics.
Package manager: npm with `package-lock.json` committed.
Commands:
```sh
npm install
npm run check
npm run build
```
Build output is `web/workspace/build/` and is not checked in. Point the Rust
backend `ServerConfig.static_assets_dir` at that directory (or another static
asset directory) to serve the SPA. `node_modules/`, `.svelte-kit/`, and `build/`
are generated local state and must remain ignored/excluded from package sources.

1673
web/workspace/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,21 @@
{
"name": "@yoi/workspace-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite dev",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.9",
"@sveltejs/kit": "^2.49.4",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"svelte": "^5.45.6",
"svelte-check": "^4.3.4",
"typescript": "^5.9.3",
"vite": "^7.2.7"
}
}

View File

@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@ -0,0 +1,2 @@
export const ssr = false;
export const prerender = true;

View File

@ -0,0 +1,176 @@
<script lang="ts">
type WorkspaceResponse = {
workspace_id: string;
display_name: string;
record_authority: string;
extension_points: {
event_stream: { status: string; note: string };
runner_connection: { status: string; note: string };
};
};
const endpoints = [
{ label: 'Workspace', path: '/api/workspace' },
{ label: 'Tickets', path: '/api/tickets' },
{ label: 'Objectives', path: '/api/objectives' },
{ label: 'Runs', path: '/api/runs' },
{ label: 'Runners', path: '/api/runners' }
];
let workspace = $state<WorkspaceResponse | null>(null);
let loadError = $state<string | null>(null);
async function loadWorkspace() {
try {
const response = await fetch('/api/workspace');
if (!response.ok) {
throw new Error(`GET /api/workspace failed: ${response.status}`);
}
workspace = await response.json();
} catch (error) {
loadError = error instanceof Error ? error.message : String(error);
}
}
$effect(() => {
void loadWorkspace();
});
</script>
<svelte:head>
<title>Yoi Workspace Control Plane</title>
<meta
name="description"
content="Local single-workspace Yoi control plane bootstrap"
/>
</svelte:head>
<main class="shell">
<section class="hero">
<p class="eyebrow">Local / single-workspace bootstrap</p>
<h1>Yoi Workspace Control Plane</h1>
<p>
Static SPA shell for reading canonical <code>.yoi</code> project records
through bounded backend APIs. Ticket and Objective lifecycle authority stays
in the existing local record workflow.
</p>
</section>
<section class="card">
<h2>Workspace</h2>
{#if workspace}
<dl>
<div>
<dt>ID</dt>
<dd>{workspace.workspace_id}</dd>
</div>
<div>
<dt>Name</dt>
<dd>{workspace.display_name}</dd>
</div>
<div>
<dt>Record authority</dt>
<dd>{workspace.record_authority}</dd>
</div>
</dl>
{:else if loadError}
<p class="error">{loadError}</p>
{:else}
<p>Waiting for <code>/api/workspace</code></p>
{/if}
</section>
<section class="grid">
<div class="card">
<h2>Read API surface</h2>
<ul>
{#each endpoints as endpoint}
<li><code>{endpoint.path}</code>{endpoint.label}</li>
{/each}
</ul>
</div>
<div class="card">
<h2>Reserved seams</h2>
<p>
Event streams and runner connections are represented as extension-point
state in the backend response, but no scheduler, write API, or hosted
multi-tenant behavior is implemented in this slice.
</p>
</div>
</section>
</main>
<style>
:global(body) {
margin: 0;
background: #0f172a;
color: #e2e8f0;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.shell {
width: min(980px, calc(100vw - 32px));
margin: 0 auto;
padding: 48px 0;
}
.hero {
margin-bottom: 24px;
}
.eyebrow {
color: #38bdf8;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
h1 {
margin: 0 0 16px;
font-size: clamp(2.5rem, 8vw, 5rem);
line-height: 0.95;
}
h2 {
margin-top: 0;
}
code {
color: #bae6fd;
}
.grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
.card {
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 20px;
background: rgba(15, 23, 42, 0.75);
padding: 24px;
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.35);
}
dl {
display: grid;
gap: 12px;
}
dt {
color: #94a3b8;
font-size: 0.85rem;
text-transform: uppercase;
}
dd {
margin: 0;
}
.error {
color: #fca5a5;
}
</style>

View File

@ -0,0 +1,18 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html',
precompress: false,
strict: true
})
}
};
export default config;

View File

@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}

View File

@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});