merge: integrate orchestration branch
This commit is contained in:
commit
3fc0dd0bde
|
|
@ -1,8 +1,8 @@
|
|||
---
|
||||
title: 'Workspace web control plane bootstrap'
|
||||
state: 'inprogress'
|
||||
state: 'closed'
|
||||
created_at: '2026-06-21T06:57:06Z'
|
||||
updated_at: '2026-06-21T07:15:03Z'
|
||||
updated_at: '2026-06-21T07:46:46Z'
|
||||
assignee: null
|
||||
queued_by: 'workspace-panel'
|
||||
queued_at: '2026-06-21T07:11:58Z'
|
||||
|
|
|
|||
25
.yoi/tickets/00001KVMFFYVX/resolution.md
Normal file
25
.yoi/tickets/00001KVMFFYVX/resolution.md
Normal 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。
|
||||
|
|
@ -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。
|
||||
|
||||
---
|
||||
|
||||
<!-- 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。
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
---
|
||||
title: 'Plugin: host_api.https を廃止して URL 権限ベースの host_api.request に統合する'
|
||||
state: 'queued'
|
||||
state: 'closed'
|
||||
created_at: '2026-06-21T07:10:30Z'
|
||||
updated_at: '2026-06-21T07:15:41Z'
|
||||
updated_at: '2026-06-21T08:12:34Z'
|
||||
assignee: null
|
||||
readiness: 'implementation_ready'
|
||||
risk_flags: ['plugin', 'host-api', 'public-api', 'permissions', 'security', 'local-network', 'breaking-change']
|
||||
|
|
|
|||
23
.yoi/tickets/00001KVMG8FTW/resolution.md
Normal file
23
.yoi/tickets/00001KVMG8FTW/resolution.md
Normal 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 側で扱う。
|
||||
|
|
@ -13,4 +13,499 @@ LocalTicketBackend によって作成されました。
|
|||
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。
|
||||
|
||||
---
|
||||
|
|
|
|||
13
.yoi/tickets/00001KVMGAEJN/artifacts/relations.json
Normal file
13
.yoi/tickets/00001KVMGAEJN/artifacts/relations.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
---
|
||||
title: 'Plugin: URL 権限ベースの別 capability として WebSocket support を設計する'
|
||||
state: 'queued'
|
||||
state: 'planning'
|
||||
created_at: '2026-06-21T07:11:34Z'
|
||||
updated_at: '2026-06-21T07:15:42Z'
|
||||
updated_at: '2026-06-21T07:17:52Z'
|
||||
assignee: null
|
||||
readiness: 'requirements_sync_needed'
|
||||
risk_flags: ['plugin', 'host-api', 'websocket', 'service', 'ingress', 'lifecycle', 'permissions', 'security', 'persistence']
|
||||
|
|
|
|||
|
|
@ -29,4 +29,43 @@ LocalTicketBackend によって作成されました。
|
|||
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
148
Cargo.lock
generated
|
|
@ -196,6 +196,58 @@ dependencies = [
|
|||
"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]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
|
|
@ -1039,6 +1091,18 @@ dependencies = [
|
|||
"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]]
|
||||
name = "fancy-regex"
|
||||
version = "0.11.0"
|
||||
|
|
@ -1429,6 +1493,15 @@ dependencies = [
|
|||
"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]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
|
|
@ -1968,6 +2041,17 @@ dependencies = [
|
|||
"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]]
|
||||
name = "line-clipping"
|
||||
version = "0.3.7"
|
||||
|
|
@ -2201,6 +2285,12 @@ dependencies = [
|
|||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "0.1.0"
|
||||
|
|
@ -3376,6 +3466,20 @@ dependencies = [
|
|||
"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]]
|
||||
name = "rustc-demangle"
|
||||
version = "0.1.27"
|
||||
|
|
@ -3692,6 +3796,17 @@ dependencies = [
|
|||
"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]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.1"
|
||||
|
|
@ -3701,6 +3816,18 @@ dependencies = [
|
|||
"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]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
|
|
@ -4419,6 +4546,7 @@ dependencies = [
|
|||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4457,6 +4585,7 @@ version = "0.1.44"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
|
|
@ -5839,6 +5968,25 @@ dependencies = [
|
|||
"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]]
|
||||
name = "yoke"
|
||||
version = "0.8.2"
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ members = [
|
|||
"crates/ticket",
|
||||
"crates/project-record",
|
||||
"crates/workflow",
|
||||
"crates/workspace-server",
|
||||
"tests/e2e",
|
||||
]
|
||||
default-members = [
|
||||
|
|
@ -50,6 +51,7 @@ default-members = [
|
|||
"crates/ticket",
|
||||
"crates/project-record",
|
||||
"crates/workflow",
|
||||
"crates/workspace-server",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
|
@ -80,21 +82,26 @@ session-store = { path = "crates/session-store" }
|
|||
secrets = { path = "crates/secrets" }
|
||||
tools = { path = "crates/tools" }
|
||||
tui = { path = "crates/tui" }
|
||||
yoi-workspace-server = { path = "crates/workspace-server" }
|
||||
|
||||
# External
|
||||
# Note: `reqwest` and `chrono` are not aggregated here because some crates
|
||||
# need `default-features = false`, which workspace inheritance cannot override.
|
||||
async-trait = "0.1"
|
||||
axum = "0.8"
|
||||
fs4 = "0.13"
|
||||
futures = "0.3"
|
||||
libc = "0.2"
|
||||
schemars = "1.2"
|
||||
serde = "1.0"
|
||||
serde_json = "1.0"
|
||||
serde_yaml = "0.9.34"
|
||||
rusqlite = { version = "0.37", features = ["bundled"] }
|
||||
sha2 = "0.11"
|
||||
tempfile = "3.27"
|
||||
thiserror = "2.0"
|
||||
tokio = "1.52"
|
||||
tower = "0.5"
|
||||
toml = "1.1"
|
||||
tracing = "0.1"
|
||||
uuid = "1.23"
|
||||
|
|
|
|||
|
|
@ -150,15 +150,15 @@ pub struct PluginGrantConfig {
|
|||
pub digest: Option<String>,
|
||||
/// Explicit capabilities granted for the pinned package identity/version/digest.
|
||||
pub permissions: Vec<PluginPermission>,
|
||||
/// Bounded outbound HTTPS allowlist entries for `host_api.https`.
|
||||
pub https: Vec<PluginHttpsGrant>,
|
||||
/// Bounded outbound request allowlist entries for `host_api.request`.
|
||||
pub request: Vec<PluginRequestGrant>,
|
||||
/// Scoped filesystem allowlist entries for `host_api.fs`.
|
||||
pub fs: Vec<PluginFsGrant>,
|
||||
}
|
||||
|
||||
impl PluginGrantConfig {
|
||||
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(
|
||||
|
|
@ -212,17 +212,32 @@ pub enum PluginPermission {
|
|||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct PluginHttpsGrant {
|
||||
/// Exact HTTPS request host allowed by this grant. Wildcards are intentionally unsupported.
|
||||
pub struct PluginRequestGrant {
|
||||
/// 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,
|
||||
/// 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>,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
impl PluginHttpsGrant {
|
||||
impl PluginRequestGrant {
|
||||
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() {
|
||||
"<no-methods>".to_string()
|
||||
} else {
|
||||
|
|
@ -233,7 +248,16 @@ impl PluginHttpsGrant {
|
|||
} else {
|
||||
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)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PluginHostApi {
|
||||
Https,
|
||||
Request,
|
||||
Fs,
|
||||
}
|
||||
|
||||
impl fmt::Display for PluginHostApi {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Https => f.write_str("https"),
|
||||
Self::Request => f.write_str("request"),
|
||||
Self::Fs => f.write_str("fs"),
|
||||
}
|
||||
}
|
||||
|
|
@ -452,6 +476,10 @@ pub struct PluginPackageManifest {
|
|||
/// enablement grants must match them before runtime surfaces are exposed.
|
||||
#[serde(default)]
|
||||
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 {
|
||||
|
|
@ -2586,6 +2614,100 @@ mod tests {
|
|||
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]
|
||||
fn embedded_rust_component_instance_template_is_valid_package_shape() {
|
||||
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())),
|
||||
digest: Some(digest.clone()),
|
||||
permissions: vec![PluginPermission::surface(PluginSurface::Hook)],
|
||||
https: Vec::new(),
|
||||
request: Vec::new(),
|
||||
fs: Vec::new(),
|
||||
};
|
||||
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())),
|
||||
digest: Some(digest.clone()),
|
||||
permissions: vec![PluginPermission::surface(PluginSurface::Hook)],
|
||||
https: Vec::new(),
|
||||
request: Vec::new(),
|
||||
fs: Vec::new(),
|
||||
},
|
||||
PluginGrantConfig {
|
||||
|
|
@ -3102,7 +3224,7 @@ input_schema = { type = "object", properties = { query = { type = "string" } },
|
|||
version: Some(PluginExactVersion("0.1.1".to_string())),
|
||||
digest: Some(digest.clone()),
|
||||
permissions: vec![PluginPermission::surface(PluginSurface::Hook)],
|
||||
https: Vec::new(),
|
||||
request: Vec::new(),
|
||||
fs: Vec::new(),
|
||||
},
|
||||
PluginGrantConfig {
|
||||
|
|
@ -3110,7 +3232,7 @@ input_schema = { type = "object", properties = { query = { type = "string" } },
|
|||
version: Some(PluginExactVersion("0.1.0".to_string())),
|
||||
digest: Some("sha256:unrelated".to_string()),
|
||||
permissions: vec![PluginPermission::surface(PluginSurface::Hook)],
|
||||
https: Vec::new(),
|
||||
request: Vec::new(),
|
||||
fs: Vec::new(),
|
||||
},
|
||||
] {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -5378,6 +5378,7 @@ permission = "read"
|
|||
services: vec![],
|
||||
ingresses: vec![],
|
||||
permissions: vec![],
|
||||
request: vec![],
|
||||
},
|
||||
enabled_surfaces: vec![manifest::plugin::PluginSurface::Hook],
|
||||
grants: manifest::plugin::PluginGrantConfig::default(),
|
||||
|
|
|
|||
24
crates/workspace-server/Cargo.toml
Normal file
24
crates/workspace-server/Cargo.toml
Normal 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"] }
|
||||
35
crates/workspace-server/src/lib.rs
Normal file
35
crates/workspace-server/src/lib.rs
Normal 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),
|
||||
}
|
||||
341
crates/workspace-server/src/records.rs
Normal file
341
crates/workspace-server/src/records.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
530
crates/workspace-server/src/server.rs
Normal file
530
crates/workspace-server/src/server.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
341
crates/workspace-server/src/store.rs
Normal file
341
crates/workspace-server/src/store.rs
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -292,7 +292,7 @@ fn inspect_materialized_package(
|
|||
)),
|
||||
digest: Some(materialized.package.digest.clone()),
|
||||
permissions: requested_permissions,
|
||||
https: Vec::new(),
|
||||
request: Vec::new(),
|
||||
fs: Vec::new(),
|
||||
},
|
||||
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)?));
|
||||
}
|
||||
|
||||
render_item_human(item)
|
||||
render_item_human(&item)
|
||||
}
|
||||
|
||||
fn render_item_human(item: &PluginInspectionItem) -> Result<String> {
|
||||
|
|
@ -799,8 +799,8 @@ fn render_item_human(item: &PluginInspectionItem) -> Result<String> {
|
|||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
" configured_https_grants: {}",
|
||||
join_or_none(&item.configured_https_grants)
|
||||
" configured_request_grants: {}",
|
||||
join_or_none(&item.configured_request_grants)
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
|
|
@ -976,7 +976,7 @@ fn snapshot_from_resolution(
|
|||
builder.configured = true;
|
||||
builder.enabled_surfaces = surface_strings(enablement.surfaces.iter().copied());
|
||||
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);
|
||||
if let Ok(identity) = SourceQualifiedPluginId::parse(&enablement.id) {
|
||||
builder
|
||||
|
|
@ -1069,7 +1069,7 @@ fn fill_resolved(builder: &mut ItemBuilder, resolved: &ResolvedPlugin) {
|
|||
builder.enabled_surfaces = surface_strings(resolved.enabled_surfaces.iter().copied());
|
||||
builder.requested_permissions = permission_strings(&resolved.manifest.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);
|
||||
|
||||
let record = ResolvedPluginRecord::from_resolved(resolved);
|
||||
|
|
@ -1178,7 +1178,7 @@ fn permission_strings(permissions: &[PluginPermission]) -> Vec<String> {
|
|||
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();
|
||||
values.sort();
|
||||
values.dedup();
|
||||
|
|
@ -1262,7 +1262,7 @@ struct PluginInspectionItem {
|
|||
enabled_surfaces: Vec<String>,
|
||||
requested_permissions: Vec<String>,
|
||||
configured_grants: Vec<String>,
|
||||
configured_https_grants: Vec<String>,
|
||||
configured_request_grants: Vec<String>,
|
||||
configured_fs_grants: Vec<String>,
|
||||
tools: Vec<ToolSummary>,
|
||||
static_runtime: Option<PluginStaticInspection>,
|
||||
|
|
@ -1331,7 +1331,7 @@ struct ItemBuilder {
|
|||
enabled_surfaces: Vec<String>,
|
||||
requested_permissions: Vec<String>,
|
||||
configured_grants: Vec<String>,
|
||||
configured_https_grants: Vec<String>,
|
||||
configured_request_grants: Vec<String>,
|
||||
configured_fs_grants: Vec<String>,
|
||||
tools: Vec<ToolSummary>,
|
||||
static_runtime: Option<PluginStaticInspection>,
|
||||
|
|
@ -1358,7 +1358,7 @@ impl ItemBuilder {
|
|||
enabled_surfaces: Vec::new(),
|
||||
requested_permissions: Vec::new(),
|
||||
configured_grants: Vec::new(),
|
||||
configured_https_grants: Vec::new(),
|
||||
configured_request_grants: Vec::new(),
|
||||
configured_fs_grants: Vec::new(),
|
||||
tools: Vec::new(),
|
||||
static_runtime: None,
|
||||
|
|
@ -1430,7 +1430,7 @@ impl ItemBuilder {
|
|||
enabled_surfaces: self.enabled_surfaces,
|
||||
requested_permissions: self.requested_permissions,
|
||||
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,
|
||||
tools: self.tools,
|
||||
static_runtime: self.static_runtime,
|
||||
|
|
@ -1443,6 +1443,7 @@ impl ItemBuilder {
|
|||
mod tests {
|
||||
use super::*;
|
||||
use manifest::plugin::{PluginEnablementConfig, PluginExactVersion, PluginGrantConfig};
|
||||
use pod::feature::plugin::{PluginPermissionEligibility, PluginRuntimeEligibility};
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
|
|
@ -1494,7 +1495,7 @@ mod tests {
|
|||
assert_eq!(show_json["configured_grants"][0], "surfaces.tool");
|
||||
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("schema_version: 1"));
|
||||
assert!(show.contains("api_version: 1"));
|
||||
|
|
@ -1503,6 +1504,78 @@ mod tests {
|
|||
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]
|
||||
fn service_only_enablement_ignores_unselected_tool_static_grants() {
|
||||
let dir = tempdir().unwrap();
|
||||
|
|
@ -1522,7 +1595,7 @@ mod tests {
|
|||
PluginPermission::surface(PluginSurface::Service),
|
||||
PluginPermission::service("svc"),
|
||||
],
|
||||
https: Vec::new(),
|
||||
request: Vec::new(),
|
||||
fs: Vec::new(),
|
||||
},
|
||||
config: None,
|
||||
|
|
@ -1576,7 +1649,7 @@ mod tests {
|
|||
PluginPermission::surface(PluginSurface::Tool),
|
||||
PluginPermission::tool("Echo"),
|
||||
],
|
||||
https: Vec::new(),
|
||||
request: Vec::new(),
|
||||
fs: Vec::new(),
|
||||
},
|
||||
config: None,
|
||||
|
|
@ -1594,7 +1667,7 @@ mod tests {
|
|||
PluginPermission::surface(PluginSurface::Tool),
|
||||
PluginPermission::tool("Echo"),
|
||||
],
|
||||
https: Vec::new(),
|
||||
request: Vec::new(),
|
||||
fs: Vec::new(),
|
||||
},
|
||||
config: None,
|
||||
|
|
@ -1712,7 +1785,7 @@ mod tests {
|
|||
PluginPermission::surface(PluginSurface::Tool),
|
||||
PluginPermission::tool("Echo"),
|
||||
],
|
||||
https: Vec::new(),
|
||||
request: Vec::new(),
|
||||
fs: Vec::new(),
|
||||
},
|
||||
config: None,
|
||||
|
|
@ -2247,7 +2320,7 @@ lifecycle = "host-managed"
|
|||
PluginPermission::surface(PluginSurface::Tool),
|
||||
PluginPermission::tool("Echo"),
|
||||
],
|
||||
https: Vec::new(),
|
||||
request: Vec::new(),
|
||||
fs: Vec::new(),
|
||||
},
|
||||
config: None,
|
||||
|
|
@ -2278,7 +2351,7 @@ lifecycle = "host-managed"
|
|||
version: Some(PluginExactVersion(version.to_string())),
|
||||
digest: Some(digest),
|
||||
permissions,
|
||||
https: Vec::new(),
|
||||
request: Vec::new(),
|
||||
fs: Vec::new(),
|
||||
},
|
||||
config: None,
|
||||
|
|
@ -2306,7 +2379,7 @@ lifecycle = "host-managed"
|
|||
version: Some(PluginExactVersion(version.to_string())),
|
||||
digest: None,
|
||||
permissions,
|
||||
https: Vec::new(),
|
||||
request: Vec::new(),
|
||||
fs: Vec::new(),
|
||||
},
|
||||
config: None,
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ runtime, so registration and execution still flow through the existing
|
|||
ToolRegistry and Worker Tool-result history path.
|
||||
|
||||
Host imports are stable names under `yoi:host/*@1.0.0`; the repository WIT files
|
||||
live in `resources/plugin/wit/`. Importing `yoi:host/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
|
||||
component instantiation and checks again on every host call. No WASI filesystem,
|
||||
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
|
||||
ToolOutput schema, Tool history behavior, grant checks, and raw-Wasm host API
|
||||
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
|
||||
omitted.
|
||||
|
||||
|
|
|
|||
|
|
@ -294,29 +294,46 @@ rejected invalid manifest, incompatible API, digest mismatch, grant denial, etc
|
|||
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
|
||||
[plugins.enabled.grants]
|
||||
permissions = [
|
||||
{ kind = "surface", surface = "tool" },
|
||||
{ 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"
|
||||
methods = ["POST"]
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ let
|
|||
|| isExcludedTree ".worktree"
|
||||
|| isExcludedTree "work-items"
|
||||
|| isExcludedTree "docs/report"
|
||||
|| isExcludedTree "web/workspace/node_modules"
|
||||
|| isExcludedTree "web/workspace/.svelte-kit"
|
||||
|| isExcludedTree "web/workspace/build"
|
||||
);
|
||||
in
|
||||
rustPlatform.buildRustPackage rec {
|
||||
|
|
@ -40,7 +43,7 @@ rustPlatform.buildRustPackage rec {
|
|||
filter = sourceFilter;
|
||||
};
|
||||
|
||||
cargoHash = "sha256-GUqhvq+JhJokk1R4VVeVz5cZe/6oSrVMyKjcltZEWqE=";
|
||||
cargoHash = "sha256-RER/UXd74C2VhPHAeF36u6ruNBg0oLnR4YeQ/zLag88=";
|
||||
|
||||
depsExtraArgs = {
|
||||
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
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
|
||||
/// every host call.
|
||||
interface https {
|
||||
interface request {
|
||||
request: func(request-json: string) -> string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
package yoi:plugin@1.0.0;
|
||||
|
||||
world instance {
|
||||
import yoi:host/https@1.0.0;
|
||||
import yoi:host/request@1.0.0;
|
||||
import yoi:host/fs@1.0.0;
|
||||
|
||||
export start: func(config-json: string) -> string;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
package yoi:plugin@1.0.0;
|
||||
|
||||
world tool {
|
||||
import yoi:host/https@1.0.0;
|
||||
import yoi:host/request@1.0.0;
|
||||
import yoi:host/fs@1.0.0;
|
||||
|
||||
/// Execute a manifest-declared Tool. `input-json` is the normal Tool input
|
||||
|
|
|
|||
5
web/workspace/.gitignore
vendored
Normal file
5
web/workspace/.gitignore
vendored
Normal 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
21
web/workspace/README.md
Normal 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
1673
web/workspace/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
web/workspace/package.json
Normal file
21
web/workspace/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
11
web/workspace/src/app.html
Normal file
11
web/workspace/src/app.html
Normal 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>
|
||||
2
web/workspace/src/routes/+layout.ts
Normal file
2
web/workspace/src/routes/+layout.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export const ssr = false;
|
||||
export const prerender = true;
|
||||
176
web/workspace/src/routes/+page.svelte
Normal file
176
web/workspace/src/routes/+page.svelte
Normal 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>
|
||||
18
web/workspace/svelte.config.js
Normal file
18
web/workspace/svelte.config.js
Normal 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;
|
||||
14
web/workspace/tsconfig.json
Normal file
14
web/workspace/tsconfig.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
web/workspace/vite.config.ts
Normal file
6
web/workspace/vite.config.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()]
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user