merge: integrate orchestration branch

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

View File

@ -1,8 +1,8 @@
---
title: 'Workspace web control plane bootstrap'
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'

View File

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

View File

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

View File

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

View File

@ -1,8 +1,8 @@
---
title: 'Plugin: host_api.https を廃止して URL 権限ベースの host_api.request に統合する'
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']

View File

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

View File

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

View File

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

View File

@ -1,8 +1,8 @@
---
title: 'Plugin: URL 権限ベースの別 capability として WebSocket support を設計する'
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']

View File

@ -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
View File

@ -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"

View File

@ -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"

View File

@ -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

View File

@ -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(),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -292,7 +292,7 @@ fn inspect_materialized_package(
)),
digest: Some(materialized.package.digest.clone()),
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,

View File

@ -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.

View File

@ -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

View File

@ -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,

View File

@ -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;
}

View File

@ -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;

View File

@ -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
View File

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

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

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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