Compare commits

..

138 Commits

Author SHA1 Message Date
5428837605
ticket: queue 00001KVSFXY88 2026-06-23 15:08:42 +09:00
87482df956
merge: sync orchestration before queue 00001KVSFXY88 2026-06-23 15:08:42 +09:00
d775e09688
ticket: update workspace web tickets 2026-06-23 15:07:46 +09:00
f1ab40bf01
ticket: start kanban lazy rows review 2026-06-23 15:06:57 +09:00
c92bc447cc
ticket: record kanban lazy rows implementation 2026-06-23 15:06:11 +09:00
f39127b752
ticket: start kanban lazy rows 2026-06-23 14:55:34 +09:00
a6f9019edd
ticket: accept kanban lazy rows 2026-06-23 14:54:43 +09:00
2865abb65a
ticket: queue 00001KVSGFM65 2026-06-23 14:53:22 +09:00
f6eb11e567
merge: sync orchestration before queue 00001KVSGFM65 2026-06-23 14:53:22 +09:00
ee750363d2
ticket: add workspace kanban improvement 2026-06-23 14:51:34 +09:00
d8f467e30f
ticket: start protocol ts generation 2026-06-23 14:42:24 +09:00
746c51c701
ticket: accept protocol ts generation 2026-06-23 14:41:34 +09:00
f9ca777afe
ticket: queue 00001KVSEBF56 2026-06-23 14:40:01 +09:00
4189b80004
ticket: add protocol type generation work 2026-06-23 14:14:17 +09:00
6013048f68
merge: orchestration 2026-06-22 20:15:48 +09:00
1d626cdea6
ui: refine workspace web layout 2026-06-22 20:03:54 +09:00
615c02501a
ticket: record workspace schema cleanup 2026-06-22 18:27:16 +09:00
7a7891d467
ticket: complete workspace db schema v0 2026-06-22 18:26:32 +09:00
38bd122dd0
merge: workspace db schema v0 2026-06-22 18:24:14 +09:00
9c0c7badcf
ticket: approve workspace db schema v0 2026-06-22 18:24:14 +09:00
c3798559d2
ticket: record workspace schema migration fix 2026-06-22 18:18:37 +09:00
d89ace5b9c
workspace: canonicalize legacy workspaces 2026-06-22 18:17:21 +09:00
1994d2d668
ticket: request changes on workspace schema migration 2026-06-22 18:13:17 +09:00
1c3ec71361
ticket: start workspace db schema review 2026-06-22 18:03:59 +09:00
e7333f2e4d
ticket: record workspace db schema implementation 2026-06-22 18:03:09 +09:00
5149ab703f
workspace: implement db schema v0 bootstrap 2026-06-22 18:01:38 +09:00
1e0f2158ba
ticket: start workspace db schema v0 2026-06-22 17:51:17 +09:00
f17a458a04
ticket: accept workspace db schema v0 2026-06-22 17:50:22 +09:00
5f06af81cb
ticket: queue 00001KVNKD56W 2026-06-22 17:48:39 +09:00
8407ce22b4
merge: sync orchestration before queue 00001KVNKD56W 2026-06-22 17:48:39 +09:00
c6ddff159f
docs: design workspace db schema v0 2026-06-22 17:48:37 +09:00
b7c890d3f6
ticket: record repository objective cleanup 2026-06-22 02:31:56 +09:00
4b1f1e593d
ticket: complete repository objective pages 2026-06-22 02:31:21 +09:00
7ee702b162
merge: repository objective pages 2026-06-22 02:30:17 +09:00
680b2a4160
ticket: approve repository objective pages 2026-06-22 02:30:17 +09:00
076c504640
ticket: start repository objective review 2026-06-22 02:24:06 +09:00
2c76675110
ticket: record repository objective implementation 2026-06-22 02:23:23 +09:00
ceb1ee3b56
feat: add repository objective pages 2026-06-22 02:21:51 +09:00
de43209643
ticket: start repository objective pages 2026-06-22 02:03:46 +09:00
0f7e78c164
ticket: accept repository objective pages 2026-06-22 02:03:04 +09:00
eb2e5907ea
ticket: record workspace sidebar cleanup 2026-06-22 02:01:56 +09:00
c29eba0c70
ticket: complete workspace sidebar 2026-06-22 02:01:22 +09:00
613f412659
merge: workspace sidebar navigation 2026-06-22 02:00:22 +09:00
5d55e47e9b
ticket: approve workspace sidebar 2026-06-22 02:00:22 +09:00
8066f71b51
ticket: start sidebar review 2026-06-22 01:54:57 +09:00
5ad588f059
ticket: record sidebar conflict resolution 2026-06-22 01:54:16 +09:00
4ab696b434
Merge branch 'orchestration' into impl/00001KVNG9B9Z-workspace-sidebar
# Conflicts:
#	web/workspace/src/routes/+page.svelte
2026-06-22 01:52:53 +09:00
d3b8bdfddc
feat: add workspace sidebar skeleton 2026-06-22 01:45:46 +09:00
a607a1f20d
ticket: defer repository pages behind sidebar 2026-06-22 01:41:16 +09:00
520209f38c
ticket: queue 00001KVNGJPRG 2026-06-22 01:40:36 +09:00
ae2d80ba5a
merge: sync orchestration before queue 00001KVNGJPRG 2026-06-22 01:40:35 +09:00
8652041855
ticket: add repository objective pages 2026-06-22 01:40:33 +09:00
04f15623f2
ticket: record workspace host worker cleanup 2026-06-22 01:39:51 +09:00
a4ed5fb082
ticket: complete workspace host workers 2026-06-22 01:39:21 +09:00
c884d51702
merge: workspace host workers 2026-06-22 01:38:12 +09:00
ea47e54399
ticket: approve workspace host workers 2026-06-22 01:38:12 +09:00
d953049d7d
ticket: start workspace sidebar work 2026-06-22 01:37:40 +09:00
2c7ef24a29
ticket: accept workspace sidebar ui 2026-06-22 01:36:58 +09:00
48c09fd709
ticket: queue 00001KVNG9B9Z 2026-06-22 01:35:06 +09:00
6ebe4f7752
merge: sync orchestration before queue 00001KVNG9B9Z 2026-06-22 01:35:06 +09:00
d4de8e26ce
ticket: start workspace host worker review 2026-06-22 01:32:27 +09:00
42c9e9144c
ticket: record workspace host worker implementation 2026-06-22 01:31:54 +09:00
06a9f2f5fd
ticket: add workspace sidebar UI 2026-06-22 01:30:58 +09:00
58143ead83
feat: expose workspace hosts and workers 2026-06-22 01:30:48 +09:00
b193e3e088
ticket: start workspace host workers 2026-06-22 01:11:53 +09:00
e1f02ffca3
ticket: accept workspace host workers 2026-06-22 01:10:56 +09:00
bd5f2b75c3
ticket: queue 00001KVNEKH9Q 2026-06-22 01:09:10 +09:00
2bad74046e
merge: integrate orchestration branch 2026-06-22 01:06:48 +09:00
dfbfd6ed82
dev: add workspace web hot reload setup 2026-06-22 01:06:37 +09:00
de1a20c007
ticket: add local host worker API 2026-06-22 01:01:43 +09:00
7abc3c7751
ticket: record plugin websocket cleanup 2026-06-21 22:27:34 +09:00
e8103477a4
ticket: complete plugin websocket host api 2026-06-21 22:26:32 +09:00
354f1e1081
merge: plugin websocket host api 2026-06-21 22:21:07 +09:00
8a5b341e5e
ticket: approve plugin websocket api 2026-06-21 22:20:58 +09:00
2232149be0
ticket: record plugin websocket bounds fix 2026-06-21 22:16:11 +09:00
a766048f29
fix: bound plugin websocket open 2026-06-21 22:14:58 +09:00
168951b668
fix: declare deno workspace frontend deps 2026-06-21 22:08:37 +09:00
519730e7d3
ticket: request changes on websocket bounds 2026-06-21 21:43:11 +09:00
27f6b3366c
ticket: start plugin websocket review 2026-06-21 21:33:07 +09:00
07782704d4
ticket: record plugin websocket branch hygiene 2026-06-21 21:32:27 +09:00
e58355e7e3
ticket: record plugin websocket implementation 2026-06-21 21:21:02 +09:00
ce62d23502
chore: keep plugin websocket branch code-only 2026-06-21 21:20:55 +09:00
4c1b8c3d0a
feat: add plugin websocket host api 2026-06-21 21:19:28 +09:00
8578bc1c29
ticket: record inflight snapshot cleanup 2026-06-21 21:00:38 +09:00
77b5276fd3
ticket: complete inflight snapshot fix 2026-06-21 21:00:10 +09:00
b21638f56c
merge: inflight reconnect snapshot 2026-06-21 20:56:55 +09:00
08edc767b5
ticket: approve inflight snapshot fix 2026-06-21 20:56:49 +09:00
4cd4ae9cb5
ticket: record inflight snapshot race fix 2026-06-21 20:53:05 +09:00
061136d798
fix: close in-flight snapshot commit race 2026-06-21 20:51:48 +09:00
ecdc52fce9
ticket: request changes on inflight snapshot 2026-06-21 20:37:40 +09:00
406c057025
ticket: start plugin websocket implementation 2026-06-21 20:37:08 +09:00
3eac7f8eae
ticket: accept plugin websocket host api 2026-06-21 20:36:06 +09:00
79a0e45dbc
ticket: queue 00001KVMGAEJN 2026-06-21 20:34:07 +09:00
2e2fdae8ef
merge: sync orchestration before queue 00001KVMGAEJN 2026-06-21 20:34:07 +09:00
d802778104
ticket: start inflight snapshot review 2026-06-21 20:32:03 +09:00
de5f3ba49e
ticket: record inflight snapshot implementation 2026-06-21 20:31:18 +09:00
74aca6f6c5
fix: snapshot in-flight stream state 2026-06-21 20:30:01 +09:00
2a23b8d770
ticket: record deno tooling cleanup 2026-06-21 20:14:32 +09:00
54d325aeb4
ticket: complete deno tooling migration 2026-06-21 20:14:07 +09:00
6dc78e3f2a
merge: workspace spa deno tooling 2026-06-21 20:13:04 +09:00
108b6dc787
ticket: approve deno tooling migration 2026-06-21 20:12:57 +09:00
395de19639
ticket: start deno tooling review 2026-06-21 20:06:08 +09:00
cc25201c4f
ticket: record deno tooling implementation 2026-06-21 20:05:32 +09:00
66f04e0424
feat: migrate workspace spa tooling to deno 2026-06-21 20:04:08 +09:00
5f9797fdd6
ticket: ready websocket host api 2026-06-21 20:01:28 +09:00
b4d2b3a442
ticket: start deno and inflight snapshot work 2026-06-21 20:00:13 +09:00
155e039e66
ticket: route deno and inflight snapshot work 2026-06-21 19:58:49 +09:00
b4786b407a
ticket: queue 00001KVMT2J25 2026-06-21 19:56:32 +09:00
a59d935bd3
ticket: queue 00001KVMV03QY 2026-06-21 19:56:31 +09:00
6e95e497f7
ticket: add workspace follow-up items 2026-06-21 19:56:10 +09:00
ef12c6b185
ticket: close workspace server launcher 2026-06-21 19:56:04 +09:00
e6f68496b4
feat: add workspace server launcher 2026-06-21 19:55:55 +09:00
be517417ff
ticket: start workspace server launcher 2026-06-21 19:55:46 +09:00
3fc0dd0bde
merge: integrate orchestration branch 2026-06-21 18:01:45 +09:00
89eb59505b
ticket: record plugin request cleanup 2026-06-21 17:12:42 +09:00
2601bfa9f0
ticket: complete plugin request host api 2026-06-21 17:12:13 +09:00
8a15cca567
merge: plugin request host api 2026-06-21 17:08:13 +09:00
ece35c391c
ticket: approve plugin request api 2026-06-21 17:08:09 +09:00
6c3ac08c54
ticket: record plugin request grant fix 2026-06-21 17:04:55 +09:00
0e14e7c14e
plugin: align request grant inspection coverage 2026-06-21 17:04:03 +09:00
2704b8c4bd
ticket: request changes on plugin request grants 2026-06-21 16:58:12 +09:00
d5338f9244
ticket: start plugin request review 2026-06-21 16:48:59 +09:00
e447f177e0
ticket: record plugin request implementation 2026-06-21 16:48:13 +09:00
962b769989
plugin: replace https host api with request grants 2026-06-21 16:47:06 +09:00
52a786c780
ticket: record workspace web cleanup 2026-06-21 16:46:59 +09:00
f33415c7e2
ticket: complete workspace web bootstrap 2026-06-21 16:46:25 +09:00
3e03e53627
merge: workspace web control plane 2026-06-21 16:44:59 +09:00
794e43a534
ticket: approve workspace web bootstrap 2026-06-21 16:44:54 +09:00
9f721ba437
ticket: start workspace web review 2026-06-21 16:38:19 +09:00
4a5e28067e
ticket: record workspace web implementation 2026-06-21 16:37:39 +09:00
ab7658c1f2
feat: bootstrap workspace web control plane 2026-06-21 16:34:36 +09:00
6cc0065d15
ticket: start plugin request implementation 2026-06-21 16:19:01 +09:00
4cd4a06e98
ticket: route plugin request capabilities 2026-06-21 16:17:57 +09:00
f164483e62
ticket: queue 00001KVMGAEJN 2026-06-21 16:15:42 +09:00
faf9bb0a82
ticket: queue 00001KVMG8FTW 2026-06-21 16:15:41 +09:00
df150647c4
merge: sync orchestration before queue 00001KVMG8FTW 2026-06-21 16:15:41 +09:00
9f664c751a
ticket: start workspace web implementation 2026-06-21 16:15:08 +09:00
1d27f6c90a
ticket: accept workspace web control plane 2026-06-21 16:13:54 +09:00
126 changed files with 15944 additions and 612 deletions

1
.yoi/.gitignore vendored
View File

@ -1,2 +1,3 @@
/memory/ /memory/
tickets/.ticket-backend.lock tickets/.ticket-backend.lock
/workspace.db*

View File

@ -2,7 +2,7 @@
title: "Team workspace control plane and runner architecture" title: "Team workspace control plane and runner architecture"
state: "active" state: "active"
created_at: "2026-06-20T14:26:29Z" created_at: "2026-06-20T14:26:29Z"
updated_at: "2026-06-21T06:57:06Z" updated_at: "2026-06-21T18:10:00Z"
linked_tickets: ["00001KVMFFYVX"] linked_tickets: ["00001KVMFFYVX"]
--- ---
@ -175,6 +175,10 @@ Web UI は Ticket、Objective、Memory、Knowledge、Run、Runner、Artifact を
Cloud/remote execution を成立させるには、多数のエージェント実行を安く管理できる必要がある。logical agent session と runtime process/resource placement を分ける。 Cloud/remote execution を成立させるには、多数のエージェント実行を安く管理できる必要がある。logical agent session と runtime process/resource placement を分ける。
初期 Workspace DB では、Worker を canonical table として永続化しない。Host / Worker 一覧は backend-local runtime inspection や将来の Host protocol から逐次取得する live view とし、Ticket に関わった Worker は Ticket thread events と WorkerRef snapshot / TicketWorkerLink として記録する。
Worker の一元管理、データ永続化、アーカイブは将来的には必要になる。これは Host protocol、remote/self-hosted/hosted worker lifecycle、worker identity、retention policy、audit requirements が固まった後に、dedicated Worker registry / archive model として追加する。v0 で Pod metadata の代替として Worker table を作らない。
検討対象: 検討対象:
- Agent identity と process/runtime placement の分離。 - Agent identity と process/runtime placement の分離。
@ -241,4 +245,5 @@ Cloud/remote execution を成立させるには、多数のエージェント実
- Web frontend を最初の primary team UI とする。Desktop app は web/control-plane model が安定した後に検討する。 - Web frontend を最初の primary team UI とする。Desktop app は web/control-plane model が安定した後に検討する。
- Git は重要な Repository provider / materialization backend として使うが、Workspace identity と authority を Git Repository root に固定しない。 - Git は重要な Repository provider / materialization backend として使うが、Workspace identity と authority を Git Repository root に固定しない。
- Ticket と Objective は Workspace 配下に平たく持つ。対象コードベースや ref は Repository target selector として表現し、Run が concrete RepositoryPoint に解決する。 - Ticket と Objective は Workspace 配下に平たく持つ。対象コードベースや ref は Repository target selector として表現し、Run が concrete RepositoryPoint に解決する。
- Memory の本格再設計は後回しにする。先に Workspace / Ticket / Run / Repository / Runner / Control plane の基盤を固め、Memory の保存先を Workspace backend に移すタイミングで、意味論・抽出・承認・検索・staleness 処理をまとめて回収する。 - Memory の本格再設計は後回しにする。先に Workspace / Ticket / Repository / Host/Worker live view / Control plane の基盤を固め、Memory の保存先を Workspace backend に移すタイミングで、意味論・抽出・承認・検索・staleness 処理をまとめて回収する。
- Worker の一元管理・データ永続化・アーカイブも後続設計に回す。初期 DB では Worker を Pod metadata の代替として永続化せず、live view と Ticket-linked WorkerRef 記録に留める。

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260621-071337-1","ticket_id":"00001KVMFFYVX","kind":"accepted_plan","accepted_plan":{"summary":"Bootstrap a single-workspace Rust Workspace control-plane backend with store abstraction + SQLite, bounded read APIs over workspace/tickets/objectives, static SvelteKit SPA skeleton serving, and package/Nix-safe frontend handling while preserving existing `.yoi` record workflows.","branch":"impl/00001KVMFFYVX-workspace-web-control-plane","worktree":"/home/hare/Projects/yoi/.worktree/00001KVMFFYVX-workspace-web-control-plane","role_plan":"Orchestrator records acceptance and creates child worktree. Coder receives narrow write scope for that worktree and implements backend crate/store/SPA/API/bootstrap. Reviewer will be spawned read-only after Coder reports implementation commit(s). After approval, Orchestrator integrates into `orchestration`, validates, records closure, and cleans only the child worktree/branch."},"author":"yoi-orchestrator","at":"2026-06-21T07:13:37Z"}

View File

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

View File

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

View File

@ -30,4 +30,409 @@ Marked ready by `yoi ticket state`.
Ticket を `workspace-panel` が queued にしました。 Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-21T07:13:24Z -->
## Decision
Routing decision: `implementation_ready`
Reason:
- Ticket body は Workspace web control plane の bootstrap slice として backend crate、store abstraction + SQLite、static SPA skeleton、initial read API、static serving、packaging/Nix hygiene、validation criteria まで具体化されている。
- Objective `00001KVJPT2PP` は Web frontend を primary team UI とし、control plane / runner architecture を段階実装する背景として整合している。
- Relations / orchestration plan に blocker はない。
- Current queued Ticket はこの Ticket のみ。
- Orchestrator worktree は clean on `orchestration` at `5fa0846d` で、対象 Ticket 用 worktree / branch は未作成。
- Visible Pods に対象 Ticket の child Pod は存在しない。
Evidence checked:
- Ticket body / thread via direct read。
- Objective `00001KVJPT2PP` via direct read。
- `TicketRelationQuery(00001KVMFFYVX)`: no relations / blockers。
- `TicketOrchestrationPlanQuery(00001KVMFFYVX)`: no records。
- `TicketList(state=queued)`: queued Ticket はこの Ticket のみ。
- `ListPods`: current visible Pods に対象 Ticket の coder/reviewer はない。
- Orchestrator git state / worktree list / branch list checked from `/home/hare/Projects/yoi/.worktree/orchestration` only。
- Bounded code map:
- `Cargo.toml` workspace members exist under `crates/*`
- Existing project-record / Objective CLI code is in `crates/yoi/src/objective_cli.rs` and Ticket CLI code in `crates/yoi/src/ticket_cli.rs`
- No existing frontend package/root was found in active source; frontend skeleton location is an implementation decision。
- Dependency search found no current web backend crate; adding one is expected。
IntentPacket:
Intent:
- Bootstrap a local single-workspace Workspace web control plane that can serve a static SPA and bounded read APIs while preserving existing `.yoi` Ticket / Objective workflows as canonical project records。
- Establish architecture seams for future multi-workspace hosted control plane, runner connections, event streams, and store implementations without implementing hosted SaaS or runner scheduling in this Ticket。
Binding decisions / invariants:
- Product CLI ownership remains in `yoi` crate; new backend crate must not become the product CLI façade。
- Initial server is single-workspace and local/dev oriented, but internal API/state models should carry `workspace_id` or equivalent to avoid blocking multi-workspace later。
- Store layer has an explicit trait/interface boundary; SQLite is the initial implementation, not an authority leak through frontend or handlers。
- SQLite setup should include server-appropriate basics: WAL, foreign keys, busy timeout, and minimal schema versioning/migration mechanism。
- Existing `.yoi/tickets` and `.yoi/objectives` local record workflow remains canonical and must not be migrated or broken in this Ticket。
- Frontend is static SPA skeleton, not SSR authority and not a place for lifecycle/business authority。
- Rust backend must separate `/api/...` from SPA fallback/static serving。
- Auth can be explicit local-only/dev-token placeholder, but must not imply production SaaS auth is solved。
- No write API, runner dispatch, billing/quota, memory migration, or hosted multi-tenant operations in this Ticket。
- Packaging/Nix/repository hygiene must remain valid; generated build artifacts should not be checked in unless explicitly justified。
Requirements / acceptance criteria:
- Add a workspace control plane backend Rust crate to Cargo workspace。
- Provide HTTP API + static SPA serving surfaces and future extension points for event stream / runner connection。
- Add store abstraction plus initial SQLite implementation with migration/versioning。
- Add bounded initial read APIs at least for workspace, tickets, and objectives; candidate additional empty/skeleton endpoints for runs/runners are allowed if clean。
- Add SvelteKit static SPA skeleton in monorepo and document/encode package manager + lockfile + build artifact handling。
- Backend can serve built static assets and use SPA routing fallback separately from `/api/...`
- Existing local `.yoi` Ticket / Objective record workflow remains working。
- Validation before completion includes `cargo fmt --check`, relevant `cargo test` / `cargo check`, frontend check/build, `git diff --check`, `yoi ticket doctor`, `yoi objective doctor`, and `nix build .#yoi --no-link`
Implementation latitude:
- Crate names and paths may be chosen by Coder, with preference for clear names such as `workspace-server` / `workspace` / `control-plane` and avoiding ambiguity with runtime workspace root semantics。
- Static asset embedding/serving may be implemented as fallback directory serving, optional embedded assets, or a documented dev/static-dir path if the initial bootstrap remains runnable and package-safe。
- SQLite crate choice may follow current project style/dependency constraints; dependency/package hash updates must be handled if new dependencies are added。
- Frontend package manager may be npm/pnpm/etc. if lockfile and Nix/package handling are explicit and reproducible enough for this bootstrap。
- API JSON schemas can be minimal and bounded; do not overbuild mutation or runner dispatch。
- Add focused tests around store migration, `.yoi` record read bridge, handler/API shape, and static/API route separation.
Escalate if:
- Adding frontend build tooling cannot be reconciled with Nix/package build in this slice。
- SQLite dependency/package updates create unresolved Nix cargo hash/source-filter failures。
- Serving built SPA assets would require checking in generated artifacts without agreement。
- `.yoi` Ticket/Objective canonical record compatibility becomes ambiguous or requires migration。
- Implementing this bootstrap forces public auth/hosted SaaS decisions beyond local/dev mode。
- The change grows into write API / runner dispatch / scheduler design rather than bootstrap/read-heavy surface。
Validation plan for Coder and Reviewer:
- Rust: `cargo fmt --check`, focused tests for new crate / yoi integration, `cargo check` for affected crates and workspace-facing binary。
- Frontend: install/check/build command appropriate to chosen package manager, with lockfile committed if needed。
- Repository/package: `git diff --check`, `yoi ticket doctor`, `yoi objective doctor`, `nix build .#yoi --no-link` if dependency/package/source-filter/frontend handling changed。
- Smoke: start or exercise server routes in a test/noninteractive way for `/api/workspace`, `/api/tickets`, `/api/objectives`, and static/SPA fallback。
Critical risks / reviewer focus:
- Keep authority in Rust backend/store, not frontend。
- Ensure `/api/...` routes do not fall through to SPA fallback incorrectly。
- Ensure local `.yoi` records remain canonical and existing CLI doctor workflows still pass。
- Ensure SQLite migrations are deterministic and not tied to process cwd accidentally。
- Ensure frontend package files and generated artifacts do not pollute package/Nix builds。
- Ensure new dependencies and Nix cargo hash/source filtering are updated consistently。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-21T07:13:29Z from: queued to: inprogress reason: human_authorized_unblocked_implementation field: state -->
## State changed
Human authorized queue routing from Workspace Dashboard. Ticket has concrete acceptance criteria and no recorded blockers, so Orchestrator accepts implementation and will create a child implementation worktree before spawning sibling Coder/Reviewer roles.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T07:15:03Z -->
## Implementation report
Implementation start report:
- Created child implementation worktree:
- `/home/hare/Projects/yoi/.worktree/00001KVMFFYVX-workspace-web-control-plane`
- Created branch:
- `impl/00001KVMFFYVX-workspace-web-control-plane`
- Base commit:
- `1d27f6c9 ticket: accept workspace web control plane`
- 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-00001KVMFFYVX`
- Coder scope:
- read: `/home/hare/Projects/yoi`
- write: `/home/hare/Projects/yoi/.worktree/00001KVMFFYVX-workspace-web-control-plane`
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,11 +1,13 @@
--- ---
title: 'Plugin: host_api.https を廃止して URL 権限ベースの host_api.request に統合する' title: 'Plugin: host_api.https を廃止して URL 権限ベースの host_api.request に統合する'
state: 'ready' state: 'closed'
created_at: '2026-06-21T07:10:30Z' created_at: '2026-06-21T07:10:30Z'
updated_at: '2026-06-21T07:10:30Z' updated_at: '2026-06-21T08:12:34Z'
assignee: null assignee: null
readiness: 'implementation_ready' readiness: 'implementation_ready'
risk_flags: ['plugin', 'host-api', 'public-api', 'permissions', 'security', 'local-network', 'breaking-change'] risk_flags: ['plugin', 'host-api', 'public-api', 'permissions', 'security', 'local-network', 'breaking-change']
queued_by: 'workspace-panel'
queued_at: '2026-06-21T07:15:41Z'
--- ---
## User claims / request snapshot ## User claims / request snapshot

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

@ -5,3 +5,507 @@
LocalTicketBackend によって作成されました。 LocalTicketBackend によって作成されました。
--- ---
<!-- event: state_changed author: workspace-panel at: 2026-06-21T07:15:41Z from: ready to: queued reason: queued field: state -->
## State changed
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 @@
{"id":"orch-plan-20260621-113559-1","ticket_id":"00001KVMGAEJN","kind":"accepted_plan","accepted_plan":{"summary":"Implement separate `host_api.websocket` Plugin capability with manifest WebSocket target declarations, enablement grants, static inspection/CLI diagnostics, host-owned bounded connection handles, runtime allow/deny policy, request API continued WebSocket rejection, docs/WIT/API updates, and focused tests.","branch":"impl/00001KVMGAEJN-plugin-websocket-host-api","worktree":"/home/hare/Projects/yoi/.worktree/00001KVMGAEJN-plugin-websocket-host-api","role_plan":"Orchestrator creates a dedicated child worktree and spawns a narrow-scope Coder. Reviewer will be spawned read-only after Coder reports implementation commit(s). After approval, Orchestrator integrates into `orchestration`, validates plugin manifest/runtime/CLI/docs tests and Nix if dependency changes occur, records closure, and cleans only the child worktree/branch."},"author":"yoi-orchestrator","at":"2026-06-21T11:35:59Z"}

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,11 +1,13 @@
--- ---
title: 'Plugin: URL 権限ベースの別 capability として WebSocket support を設計する' title: 'Plugin: URL 権限ベースの WebSocket host API を実装する'
state: 'ready' state: 'closed'
created_at: '2026-06-21T07:11:34Z' created_at: '2026-06-21T07:11:34Z'
updated_at: '2026-06-21T07:14:05Z' updated_at: '2026-06-21T13:27:28Z'
assignee: null assignee: null
readiness: 'requirements_sync_needed' readiness: 'implementation_ready'
risk_flags: ['plugin', 'host-api', 'websocket', 'service', 'ingress', 'lifecycle', 'permissions', 'security', 'persistence'] risk_flags: ['plugin', 'host-api', 'websocket', 'service', 'ingress', 'lifecycle', 'permissions', 'security', 'persistence']
queued_by: 'workspace-panel'
queued_at: '2026-06-21T11:34:07Z'
--- ---
## User claims / request snapshot ## User claims / request snapshot
@ -13,124 +15,128 @@ risk_flags: ['plugin', 'host-api', 'websocket', 'service', 'ingress', 'lifecycle
- WebSocket / bidirectional communication は `host_api.request` に混ぜず、別 capability としてサポートする。 - WebSocket / bidirectional communication は `host_api.request` に混ぜず、別 capability としてサポートする。
- WebSocket も、対象 URL を Plugin 側が権限として要求する前提でよい。 - WebSocket も、対象 URL を Plugin 側が権限として要求する前提でよい。
- 任意 URL access は便利だが大きすぎる権限であり、導入時に何が可能になる権限を要求しているかがわかりやすいことを重視する。 - 任意 URL access は便利だが大きすぎる権限であり、導入時に何が可能になる権限を要求しているかがわかりやすいことを重視する。
- Regex を許す余地はあるが、本来の権限ニーズは permission review の可読性にある。 - Service/Plugin instance は状態を持てるため、WebSocket API 自体は「WebSocket を扱う host API」として提供すればよい。
- WebSocket 接続後の処理、incoming message の解釈、Ingress 発火、Service 状態更新などは Plugin instance 側が既存 Service/Ingress lifecycle と host-mediated action path を使って組み立てる。
## Confirmed facts / sources ## Confirmed facts / sources
- `docs/development/plugin-development.md` は現行 `https` host API を outbound-only / grant-gated とし、WebSocket/Gateway/inbound HTTP surface ではないと説明している。 - Closed Ticket `00001KVMG8FTW` は one-shot request/response を `host_api.request` に統合し、WebSocket / persistent streaming / bidirectional connection は別 capability とする方針で完了した。
- Closed Ticket `00001KVFDX9AF` は WebSocket / SSE / timer host APIs, Service surface lifecycle, Ingress surface, Discord Gateway bridge, Inbound HTTP server を non-goals として HTTPS host API を実装した。 - Closed Ticket `00001KVJHYP4Q` は Plugin Service/Ingress component lifecycle surface を実装し、Plugin instance が lifecycle と state を持てる前提を作った。
- Closed Ticket `00001KVJHYP4Q` は Plugin Service/Ingress component lifecycle surface を実装し、PluginInstance lifecycle と in-process ingress dispatch path の基盤を入れたが、実外部 socket/event source は scope 外だった。 - `docs/development/plugin-development.md` と Plugin design docs は Tool call の中に long-lived connection を隠さず、Service/Ingress surface に分ける方針を持つ。
- `docs/design/plugin-component-model.md` / `docs/design/plugin-packages.md` は Plugin instance lifecycle、Service/Ingress、future MCP/plugin bridge、external process authority/lifecycle/permission/diagnostics/trust model の必要性に触れている。
- 現行 code map では WebSocket 専用 host API / persistent bidirectional Plugin transport はまだ active API として確認できていない。 - 現行 code map では WebSocket 専用 host API / persistent bidirectional Plugin transport はまだ active API として確認できていない。
## Unverified hypotheses
- WebSocket capability は `host_api.websocket` のような host API になるか、Service surface 専用 transport になる可能性がある。
- Incoming messages は PluginInstance / Service / Ingress lifecycle と接続するのが自然だが、connection ownership と dispatch route は設計判断が必要。
- URL permission model は `request` と共通概念にできるが、WebSocket は persistent lifecycle / reconnect / shutdown / inbound events を持つため、grant と runtime management は別 schema になる可能性が高い。
## Undecided points / open questions
- WebSocket connection を Yoi host が所有するのか、Plugin instance が host API を通じて所有するのか。
- Incoming message を Ingress に dispatch するのか、Service event/status stream として扱うのか。
- Reconnect / backoff / heartbeat / shutdown / cancellation / restore 時の扱いをどこまで初回に含めるか。
- WebSocket auth/headers/secrets を URL permission とどう分けて表示・grant するか。
- 初回 work item を design/spec のみで閉じるか、最小実装 slice まで含めるか。
## Background ## Background
`host_api.request` は one-shot request/response の authority として設計し、WebSocket / persistent connection / bidirectional event handling を含めない方針になった。一方で、Plugin と外部プロセス・Gateway・bridge service が双方向通信するにはWebSocket 等の persistent transport が必要になる。 `host_api.request` は one-shot request/response の authority として設計し、WebSocket / persistent connection / bidirectional event handling を含めない方針になった。一方で、Plugin と外部プロセス・Gateway・bridge service が双方向通信するには WebSocket 等の persistent transport が必要になる。
この Ticket は、WebSocket を `request` とは別 capability として扱い、URL permission を前提にした権限表示・grant・runtime lifecycle・Service/Ingress 接続を設計するための concrete design/spec work item である。実装に進む場合も、Tool call の中に長時間 connection や background daemon を隠さないことを前提にする。 この Ticket は、WebSocket を `request` とは別の `host_api.websocket` capability として追加する実装 work item である。WebSocket は Service/Plugin instance が使う host API であり、Yoi が独自に ingress routing policy を抱え込むものではない。Plugin instance は接続 handle と内部状態を保持し、受信メッセージをどう扱うか、Ingress/action/status にどう変換するかを Plugin の Service/Ingress logic として実装する。
## Requirements
- WebSocket は `host_api.request` に混ぜず、別 capability / host API / transport として設計する。
- WebSocket も URL permission を前提にし、Plugin package manifest 側が必要な WebSocket URL targets を静的に要求できること。
- Workspace/user enablement grant は、manifest-declared WebSocket URL permission を明示的に承認する model にすること。
- Arbitrary WebSocket URL / broad network access は通常 target grant と区別して「大きい権限」として表示・診断すること。
- Regex を許す場合も、導入時に可能範囲が読める normalized display / warning / label を持つこと。
- Tool call の中に long-lived WebSocket connection や background daemon を隠さないこと。
- Service / Ingress / PluginInstance lifecycle との関係を設計すること。
- Connection ownership, shutdown, cancellation, reconnect/backoff, heartbeat, diagnostics, bounds を設計対象に含めること。
- Incoming messages が history/model context に入る場合は hidden context injection にせず、durable/visible/routable path を通すこと。
- Secrets/credentials/auth headers は ambient env ではなく、explicit config / SecretRef / grant model に乗せること。
- Package discovery / `yoi plugin list/show` は Plugin code 実行、socket connection、external process startup を行わないこと。
## Acceptance criteria
- WebSocket responsibility が `host_api.request` から明確に分離される。
- WebSocket URL permission の manifest declaration と enablement grant の関係が定義される。
- Plugin inspection で WebSocket URL permission 要求と grant 状態が bounded / human-readable に表示される設計になる。
- Arbitrary URL / broad WebSocket access が通常 target grant と区別して表示される。
- Connection ownership と lifecycle boundary が明確になる。
- Incoming messages の dispatch path が Service / Ingress / PluginInstance / host routing のどこに属するか決まる。
- Hidden context injection を避ける durable/visible event path が定義される。
- Cancellation, shutdown, reconnect/backoff, timeout, message size bounds, diagnostics, redaction の方針が定義される。
- Auth/secrets handling が explicit config / SecretRef / grant に限定される。
- 最小実装 slice と non-goals が明確になる。
## Binding decisions / invariants ## Binding decisions / invariants
- WebSocket は `host_api.request` に含めない。 - WebSocket は `host_api.request` に含めない。
- WebSocket authority は URL permission を前提にする。 - WebSocket authority は URL permission を前提にする。
- Plugin 側が対象 WebSocket URL scope を権限として要求し、user/workspace enablement がそれを承認する二段階 model にする。 - Public/config-facing name は `host_api.websocket` とする。
- 任意 URL / broad WebSocket access は大きい権限として明示されなければならない。 - Plugin package manifest は必要な WebSocket URL targets を静的に要求する。
- Long-lived connection を Tool call の中に隠さない。 - Workspace/user enablement grant は、manifest-declared WebSocket URL target を明示的に承認する。
- External incoming messages を hidden context injection しない。 - WebSocket 接続は Plugin instance / Service lifecycle の中で使う host API resource とする。
- Yoi host は permission check、resource bounds、redaction、shutdown/cancellation cleanup を担当する。
- Plugin instance は connection handle を使い、send/receive/close と自身の state/lifecycle を管理する。
- Incoming message は Yoi が自動的に history/model context に注入しない。
- Incoming message の解釈、Ingress 発火、SystemItem/Notify/diagnostic 等への変換は Plugin instance が既存の host-mediated API / action path を使って行う。
- Long-lived connection を Tool call の中に隠さない。Tool は必要なら Service instance に command/query を投げるだけにする。
- Package discovery / static inspection は socket connection や process startup を意味しない。 - Package discovery / static inspection は socket connection や process startup を意味しない。
- External content is untrusted and bounded. - External content is untrusted and bounded.
- Broad/arbitrary WebSocket URL access は大きい権限として表示・診断する。
## Requirements
- `host_api.websocket` capability / host API を追加する。
- WebSocket URL permission は `host_api.request` の URL target model と整合させる。
- 少なくとも scheme, host, optional port, path prefix を人間が読める形で表現できること。
- `ws` / `wss` を扱う。HTTP request の method permission とは分ける。
- Plugin manifest 側に WebSocket target permission declaration を追加する。
- Plugin enablement grant 側に WebSocket target grant を追加する。
- Runtime authorization は「manifest で要求された target」かつ「enablement で grant された target」だけを許可する。
- Manifest で要求されていない target への WebSocket 接続は grant だけがあっても fail closed する、または明示 override として安全に診断される。
- WebSocket host API は Plugin instance が保持できる connection handle/resource を返す。
- Host API は最低限の操作を提供する。
- connect/open
- send text/binary
- receive next message with bounds/timeout/cancellation
- close
- status/diagnostic where needed
- Host は message size bounds、timeout/cancellation、shutdown cleanup、diagnostics、redaction を実装する。
- Reconnect/backoff/heartbeat policy は初回 host API の必須機能にしない。
- Plugin Service が state と timer/lifecycle で組み立ててよい。
- Host は failed/closed/cancelled を bounded diagnostic として返す。
- Auth/headers/secrets は ambient env ではなく explicit config / SecretRef / grant model に乗せる。
- 初回で SecretRef header injection が未実装の場合は non-goal として fail closed / future follow-up にする。
- `yoi plugin list/show` / static inspection は WebSocket URL permission 要求と grant 状態を bounded / human-readable に表示する。
- Arbitrary URL / broad WebSocket access は通常 target grant と区別して表示・診断する。
- Docs/templates/tests を `host_api.request` と WebSocket 別 capability 方針に更新する。
## Acceptance criteria
- `host_api.websocket``host_api.request` とは別 capability として定義される。
- Plugin manifest だけを見れば、その Plugin がどの WebSocket URL target 権限を要求しているか分かる。
- Enablement grant が manifest-declared WebSocket target と照合される。
- Grant されていない requested target への WebSocket connect は fail closed する。
- Manifest で要求されていない target への WebSocket connect は fail closed する、または明示 override として安全に診断される。
- `yoi plugin show` 相当の inspection で requested/granted/denied/broad WebSocket permission が bounded / human-readable に表示される。
- Plugin instance / Service lifecycle 内で WebSocket connection handle を保持し、send/receive/close できる。
- Incoming messages は自動で history/model context に入らず、Plugin instance の処理を通る。
- Hidden context injection が導入されていない。
- Tool call の中に long-lived WebSocket connection を隠さない設計・テストになっている。
- Shutdown/cancellation で open connection が cleanup される。
- Message size bounds / timeout / redaction / diagnostics のテストがある。
- Existing `host_api.request` behavior は壊れない。
## Implementation latitude ## Implementation latitude
- 初回は design/spec Ticket として閉じてもよい。 - Internal type names are implementation latitude, but public/config-facing names should use `websocket`.
- `host_api.websocket` として設計するか、Service surface 専用 transport として設計するかは比較して決めてよい。 - URL permission expression は `host_api.request` と共通の exact scheme/host/port/path model を流用してよい。
- URL permission expression は `host_api.request` と共通の exact scheme/host/port/path model を流用してもよい。 - Regex support は入れても入れなくてもよい。入れる場合は permission review の可読性を守ること
- Regex support は入れても入れなくてもよいが、入れる場合は permission review の可読性を守ること。 - Initial implementation may support only text messages if binary support would expand scope too much, but binary handling must then fail closed and be documented.
- Reconnect/backoff は初回実装 non-goal にしてもよいが、その場合も明示的な failure/diagnostic behavior を定義すること。 - Reconnect/backoff/heartbeat は host API ではなく Plugin Service layer の responsibility としてよい
## Readiness ## Readiness
- readiness: requirements_sync_needed - readiness: implementation_ready
- risk_flags: [plugin, host-api, websocket, service, ingress, lifecycle, permissions, security, persistence] - risk_flags: [plugin, host-api, websocket, service, ingress, lifecycle, permissions, security, persistence]
## Escalation conditions ## Escalation conditions
- WebSocket を `host_api.request` に混ぜたくなる場合。 - WebSocket を `host_api.request` に混ぜたくなる場合。
- Connection lifecycle が Tool call / Tool result に隠れそうな場合。 - WebSocket runtime が Tool call / Tool result に隠れそうな場合。
- Incoming message が history/context に非永続・非可視に注入されそうな場合。 - Incoming message が history/context に非永続・非可視に注入されそうな場合。
- URL permission が broad なのに導入時表示で目立たない場合。 - URL permission が broad なのに導入時表示で目立たない場合。
- Secret/auth handling が ambient env や raw config leakage に寄る場合。 - Secret/auth handling が ambient env や raw config leakage に寄る場合。
- Restore / cancellation / shutdown / reconnect 方針が決まらないまま実装に進みそうな場合。 - Host 側が Plugin Service の reconnect/application protocol policy まで抱え込みそうな場合。
## Validation ## Validation
Design/spec の場合: - Focused WebSocket host API tests.
- Docs/design update review.
- Existing Plugin Service/Ingress design との整合性確認。
- Ticket body の open questions が resolution / follow-up Ticket に変換されていること。
実装を含める場合:
- Focused WebSocket capability tests.
- Manifest-declared WebSocket URL permission parsing/resolution tests. - Manifest-declared WebSocket URL permission parsing/resolution tests.
- Grant allow/deny tests. - Grant allow/deny tests.
- Requested-but-ungranted and granted-but-unrequested denial tests.
- Broad/arbitrary URL display/diagnostic tests. - Broad/arbitrary URL display/diagnostic tests.
- Lifecycle shutdown/cancellation tests. - Lifecycle shutdown/cancellation tests.
- Message bounds/redaction diagnostics tests. - Message bounds/redaction diagnostics tests.
- No hidden context injection tests. - No hidden context injection tests.
- `host_api.request` regression tests.
- Docs/template updates.
- `cargo fmt --check` - `cargo fmt --check`
- relevant `cargo test` - relevant `cargo test`
- `cargo check` - `cargo check`
- `git diff --check` - `git diff --check`
- `nix build .#yoi --no-link`
## Related work ## Related work
- `00001KVFDX9AF` — Plugin HTTPS host API, closed. - `00001KVFDX9AF` — Plugin HTTPS host API, closed.
- `00001KVJHYP4Q` — Plugin Service/Ingress component lifecycle surface, closed. - `00001KVJHYP4Q` — Plugin Service/Ingress component lifecycle surface, closed.
- `00001KSXRQ4G8` — Plugin runtime/surface/host API design record, closed/superseded. - `00001KSXRQ4G8` — Plugin runtime/surface/host API design record, closed/superseded.
- `00001KVMG8FTW` — Plugin: host_api.https を廃止して URL 権限ベースの host_api.request に統合する - `00001KVMG8FTW` — Plugin: host_api.https を廃止して URL 権限ベースの host_api.request に統合する, closed.
- `docs/development/plugin-development.md` - `docs/development/plugin-development.md`
- `docs/design/plugin-component-model.md` - `docs/design/plugin-component-model.md`
- `docs/design/plugin-packages.md` - `docs/design/plugin-packages.md`

View File

@ -0,0 +1,28 @@
URL permission based Plugin WebSocket host API を実装し、Orchestrator worktree の `orchestration` branch に統合した。
主な成果:
- `host_api.websocket``host_api.request` とは別 capability として追加。
- Manifest `[[websocket]]` target declaration と enablement `grants.websocket` を追加し、request targets/grants とは独立させた。
- Static inspection / `yoi plugin show` が WebSocket requested/granted/missing/grant-only/broad diagnostics を request diagnostics とは別に表示するようにした。
- Runtime connect は manifest target と enablement grant の両方が URL を許可する場合のみ network I/O に進む。
- URL checks cover scheme (`ws`/`wss`), host, port, and path prefix。
- Local/private/loopback WebSocket targets は ambient ではなく、明示 declaration + grant が必要。
- Host-owned WebSocket handle API を追加: open, send_text / send-text, recv, close。
- Text-only / explicit bounded receive とし、binary receive は fail closed / unsupported。
- Guest arbitrary handshake headers / embedded credentials を reject。
- Request API は WebSocket/SSE/persistent attempts を引き続き reject。
- Open path は pre-dial capacity reservation と bounded async `tokio-tungstenite` open under `tokio::time::timeout` により max-open / timeout semantics を network I/O 前から enforce。
- Reservation cleanup on open failure / failed commit を追加。
- WIT resource `yoi:host/websocket@1.0.0` と docs を更新。
- `tungstenite`, `tokio-tungstenite`, `futures-util` dependencies と `Cargo.lock` / `package.nix` cargo hash を更新。
統合・検証:
- Merge commit: `354f1e10 merge: plugin websocket host api`
- Implementation commits: `4c1b8c3d`, `ce62d235`, `a766048f`
- Reviewer final verdict: approve
- Validation passed: `cargo fmt --check`, `git diff --check HEAD^1..HEAD`, `cargo test -p pod websocket`, `cargo test -p manifest websocket`, `cargo test -p yoi render_show_distinguishes_request_grant_statuses_and_broad_targets`, `cargo test -p manifest request_host_api_manifest_and_grant_parse_with_request_names`, `cargo check -p manifest -p pod -p yoi`, `cargo run -p yoi -- ticket doctor`, and `nix build .#yoi --no-link`
範囲外:
- Discord bridge 本体は実装していない。
- Reconnect/backoff/heartbeat scheduler、hidden context/history injection、Dashboard channel、Ticket mutation、direct model Tool invocation は追加していない。
- SecretRef-based credential injection は future follow-up。

View File

@ -21,3 +21,593 @@ LocalTicketBackend によって作成されました。
ユーザーから「readyにして」と明示されたため `planning` から `ready` へ遷移する。未決定点は残っているため、後続 Orchestrator routing では requirements/design sync として扱う。 ユーザーから「readyにして」と明示されたため `planning` から `ready` へ遷移する。未決定点は残っているため、後続 Orchestrator routing では requirements/design sync として扱う。
--- ---
<!-- event: state_changed author: workspace-panel at: 2026-06-21T07:15:42Z from: ready to: queued reason: queued field: state -->
## State changed
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.
---
<!-- event: intake_summary author: hare at: 2026-06-21T11:01:20Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-06-21T11:01:20Z from: planning to: ready reason: cli_state field: state -->
## State changed
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: workspace-panel at: 2026-06-21T11:34:07Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-21T11:35:44Z -->
## Decision
Routing decision: `implementation_ready`
Reason:
- Ticket was previously returned to planning because it needed concrete WebSocket connection ownership / lifecycle / permission model decisions。
- Current Ticket body is now retitled and refined as implementation work: `Plugin: URL 権限ベースの WebSocket host API を実装する`
- It defines explicit `host_api.websocket` semantics, URL permission/grant model, lifecycle/bounds/non-goals, acceptance criteria, tests, docs, and validation。
- Recorded dependency `00001KVMGAEJN depends_on 00001KVMG8FTW` is now satisfied because `00001KVMG8FTW` is `closed` and `host_api.request` URL permission model is integrated。
- Relations / orchestration plan have no remaining blockers。
- Active `00001KVMT2J25` work is protocol/pod/TUI reconnect snapshot and is semantically separate from Plugin WebSocket host API; parallel implementation is acceptable。
- Orchestrator worktree is clean on `orchestration` at `d8027781` before routing side effects; target worktree / branch is not present。
Evidence checked:
- Ticket body / thread / artifacts via `TicketShow` and direct `item.md` read。
- `TicketRelationQuery(00001KVMGAEJN)`: one `depends_on 00001KVMG8FTW`, target Ticket is `closed`
- `TicketOrchestrationPlanQuery(00001KVMGAEJN)`: no records。
- `TicketList(state=queued)`: this Ticket is the only queued Ticket。
- `ListPods`: only active child for other work is `yoi-reviewer-00001KVMT2J25-r1`
- Orchestrator git state / worktree list / branch list checked from `/home/hare/Projects/yoi/.worktree/orchestration` only。
- Bounded code map:
- `crates/manifest/src/plugin.rs` now has `host_api.request`, `PluginRequestGrant`, and manifest request target schema。
- `crates/pod/src/feature/plugin.rs` has `PluginRequestClient`, `validate_plugin_request_request`, request allowlist inspection, and explicit WebSocket rejection in request path。
- No existing tungstenite/tokio-tungstenite/websocket dependency found in Cargo manifests。
- Docs currently state WebSocket/persistent transports require a separate Plugin capability。
IntentPacket:
Intent:
- Add a separate URL-permission-based Plugin WebSocket host API, not an extension of `host_api.request`, suitable as a foundation for Discord/gateway-like integrations without implementing Discord itself。
Binding decisions / invariants:
- API name is `host_api.websocket`; do not fold WebSocket into `host_api.request`
- URL permission model should mirror/reuse the `host_api.request` target/grant review semantics where sensible, while keeping websocket-specific lifecycle/bounds explicit。
- Authority requires both manifest-declared WebSocket target and enablement grant before opening a connection。
- WebSocket connection is host-owned and Plugin-driven: guest requests open/send/recv/close via host API, but host enforces handles, bounds, timeouts, and shutdown cleanup。
- No ambient network/socket access, no raw WASI sockets, no arbitrary URL by default。
- Secrets/auth headers are not solved by guest-memory arbitrary credential headers; keep credential-bearing header policy conservative and explicit。
- Incoming messages from WebSocket are delivered to the guest through explicit host API return values or bounded polling/receive operations, not hidden model context injection。
- No direct model Tool calls, Ticket mutation, Dashboard UI channel, or hidden history/context mutation。
- `host_api.request` must keep rejecting WebSocket/SSE/persistent connection attempts。
- First slice should avoid full background daemon scheduler unless it is minimal and bounded; preserve instance lifecycle cleanup。
Requirements / acceptance criteria:
- Manifest can declare WebSocket targets independently from request targets。
- Enablement config can grant WebSocket targets independently from request grants。
- Static inspection / `yoi plugin show` reports WebSocket requested/granted/missing/broad diagnostics separately from request。
- Runtime refuses connect unless manifest target and grant both allow the URL。
- URL checks cover scheme (`ws`/`wss`), host, port, path prefix, and any method/protocol constraints chosen for handshake。
- Local/private/loopback WebSocket targets require explicit declaration+grant。
- WebSocket API has bounded handle lifetime, max frame/message size, max open connections per Plugin instance, timeout/cancellation behavior, and cleanup on instance stop/trap/drop。
- Send/receive operations are bounded and typed; binary/text behavior is documented。
- Credential-like headers are rejected or explicitly not supported until SecretRef/grants exist。
- Tests cover allow/deny, grant-only/missing-grant, loopback allow/deny, broad diagnostics, request API still rejecting WebSocket, bounds/cleanup, and no hidden context mutation。
Implementation latitude:
- Rust dependency choice is Coders decision, e.g. `tokio-tungstenite` if suitable, but dependency/package/Nix implications must be handled。
- WIT/API shape can be handle-based with `open`, `send_text`/`send_binary`, `recv`, `close`, or similar. Keep it minimal and reviewable。
- If a fully live network integration test is hard, use local test server / mock client abstraction to validate runtime policy and handle lifecycle。
- Reuse request target/grant matching helpers where appropriate, but avoid overgeneralizing if it obscures WebSocket semantics。
Escalate if:
- Implementing safe host-owned connection handles requires a broader Plugin service/instance actor redesign than current runtime supports。
- SecretRef/auth header requirements become necessary for useful functionality。
- Background receive delivery requires hidden context/history mutation or direct model Tool invocation。
- Cargo/Nix dependency updates become blocked by unavailable crates or source filtering。
- Public API compatibility for new WIT world/import names becomes unclear。
Validation plan:
- `cargo fmt --check`
- Focused manifest tests for websocket permission/target/grant parsing and independence from request。
- Focused pod plugin tests for connection allow/deny policy, loopback explicit grants, grant-only/missing-grant, bounds, handle cleanup, and request API rejection of WebSocket。
- Focused yoi plugin CLI tests for WebSocket diagnostics。
- `cargo check` for affected crates。
- `git diff --check`
- stale/boundary grep to ensure request docs still reject WebSocket and WebSocket docs do not claim hidden event injection。
- `yoi ticket doctor`
- `nix build .#yoi --no-link` if dependencies/package/source-filter changed。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-21T11:35:51Z from: queued to: inprogress reason: human_authorized_dependency_satisfied_websocket_implementation field: state -->
## State changed
Human authorized queue routing from Workspace Dashboard. Previously recorded dependency on `00001KVMG8FTW` is now satisfied because `host_api.request` is closed/integrated, and this Ticket now has implementation-ready WebSocket host API requirements. Orchestrator accepts implementation.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T11:37:01Z -->
## Implementation report
Implementation start report:
- Created child implementation worktree:
- `/home/hare/Projects/yoi/.worktree/00001KVMGAEJN-plugin-websocket-host-api`
- Created branch:
- `impl/00001KVMGAEJN-plugin-websocket-host-api`
- Base commit:
- `3eac7f8e ticket: accept plugin websocket host api`
- 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-00001KVMGAEJN`
- Coder scope:
- read: `/home/hare/Projects/yoi`
- write: `/home/hare/Projects/yoi/.worktree/00001KVMGAEJN-plugin-websocket-host-api`
Parallelization note:
- `00001KVMT2J25` remains active in review and targets protocol/pod/TUI reconnect snapshot semantics. This Ticket should stay limited to Plugin WebSocket host API / manifest / runtime / CLI / docs / tests。
Next action:
- Wait for Coder implementation report, then spawn Reviewer read-only for the implementation diff。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T12:20:55Z -->
## Implementation report
Coder implementation report received from `yoi-coder-00001KVMGAEJN`.
Implementation commit reported:
- `4c1b8c3d feat: add plugin websocket host api`
Changed areas reported:
- Manifest/config schema:
- Added independent `host_api.websocket` capability。
- Added manifest `[[websocket]]` targets and enablement `grants.websocket`
- Runtime:
- Added host-owned WebSocket handle lifecycle for raw Wasm and component imports。
- Added `open`, `send_text` / `send-text`, `recv`, and `close`
- Enforces manifest+grant URL allowlist before network I/O。
- Bounds open descriptor size, text/message size, max open connections, recv timeout, handle lifetime, and cleanup。
- Rejects embedded credentials, guest handshake headers, unsupported subprotocols, non-`ws`/`wss` schemes, and binary message receive。
- Static inspection / CLI:
- Reports WebSocket requested/granted/missing/grant-only/broad diagnostics separately from request diagnostics。
- `yoi plugin show` now includes `configured_websocket_grants`
- Docs/WIT:
- Added `yoi:host/websocket@1.0.0` WIT interface。
- Documented text-only API, explicit bounded receive, no hidden context/history injection, and future SecretRef credential follow-up。
- Dependency/package:
- Added `tungstenite` to `crates/pod`
- Updated `Cargo.lock`
- Updated `package.nix` cargo hash to `sha256-TZrw6nJclXVRpFIUlYvimGTDXlxBMaQt6oM5C5DIGIU=`
Coder validation reported:
- `cargo fmt --check`: passed
- `cargo test -p manifest websocket`: passed
- `cargo test -p pod websocket`: passed
- `cargo test -p yoi render_show_distinguishes_request_grant_statuses_and_broad_targets`: passed
- `cargo test -p manifest request_host_api_manifest_and_grant_parse_with_request_names`: passed
- `cargo check -p manifest -p pod -p yoi`: passed
- `git diff --check`: passed
- stale/boundary grep for request/WebSocket docs and hidden context claims: passed
- `cargo run -p yoi -- ticket doctor`: passed (`doctor: ok`)
Nix status reported:
- First `nix build .#yoi --no-link` exposed expected cargo hash mismatch; Coder updated `package.nix`
- After hash update, build advanced into dependency compilation then failed in sandbox compiling `aws-lc-sys` with `No space left on device`
- Coder treated this as environmental disk-space failure, not Rust/package hash failure。
Orchestrator evidence checked:
- Worktree clean at `4c1b8c3d`
- Diff `3eac7f8e..HEAD` includes expected code/docs/package areas but also includes child worktree edits to `.yoi/tickets/00001KVMGAEJN/{item.md,thread.md}`
- Because Orchestrator owns durable Ticket records in this workflow, Coder was asked to remove those `.yoi/tickets` edits from the implementation branch before Reviewer routing。
Next action:
- Wait for Coder hygiene fix, then route code-only implementation diff to Reviewer。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T12:32:21Z -->
## Implementation report
Coder hygiene fix received from `yoi-coder-00001KVMGAEJN`.
New commit:
- `ce62d235 chore: keep plugin websocket branch code-only`
Result:
- Implementation branch now contains code/docs/package changes only; child-worktree Ticket record edits were removed from the branch diff。
- `git diff --name-status 3eac7f8e..HEAD -- .yoi/tickets/00001KVMGAEJN`: no output。
- Worktree clean at `ce62d235`
- Diff `3eac7f8e..HEAD` now touches 11 expected files: Cargo/package, manifest/pod/yoi plugin CLI, docs, and WIT resources。
- `git diff --check 3eac7f8e..HEAD`: passed。
Next action:
- Route to read-only Reviewer Pod for acceptance review against the Ticket。
---
<!-- event: comment author: yoi-orchestrator at: 2026-06-21T12:33:03Z -->
## Comment
Review routing:
Spawned read-only Reviewer Pod:
- `yoi-reviewer-00001KVMGAEJN-r1`
Review target:
- Worktree: `/home/hare/Projects/yoi/.worktree/00001KVMGAEJN-plugin-websocket-host-api`
- Branch: `impl/00001KVMGAEJN-plugin-websocket-host-api`
- Baseline: `3eac7f8e`
- Implementation commits:
- `4c1b8c3d feat: add plugin websocket host api`
- `ce62d235 chore: keep plugin websocket branch code-only`
- Diff: `3eac7f8e..ce62d235`
Reviewer scope:
- read-only `/home/hare/Projects/yoi`
Reviewer focus:
- `host_api.websocket` remains distinct from `host_api.request`
- Manifest target + enablement grant are both required before opening a connection。
- Host-owned WebSocket handle lifecycle is bounded and cleaned up。
- Credential/header, binary/text, local/private/loopback, broad diagnostics, and no-hidden-context semantics are safe。
- Request API still rejects WebSocket/SSE/persistent attempts。
- Cargo/package/Nix changes are sound; Coder reported Nix advanced past hash check then failed due `No space left on device` in `aws-lc-sys` build。
Orchestrator will wait for reviewer verdict before integration。
---
<!-- event: review author: yoi-reviewer-00001KVMGAEJN-r1 at: 2026-06-21T12:43:06Z status: request_changes -->
## Review: request changes
Verdict: `request_changes`
確認範囲:
- Diff `3eac7f8e..ce62d235` in worktree `/home/hare/Projects/yoi/.worktree/00001KVMGAEJN-plugin-websocket-host-api`:
- `Cargo.lock`
- `crates/manifest/src/plugin.rs`
- `crates/pod/Cargo.toml`
- `crates/pod/src/feature/plugin.rs`
- `crates/pod/src/pod.rs`
- `crates/yoi/src/plugin_cli.rs`
- `docs/development/plugin-development.md`
- `package.nix`
- `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`
- Ticket authority:
- `.yoi/tickets/00001KVMGAEJN/item.md`
- `.yoi/tickets/00001KVMGAEJN/thread.md`
Blocking issue:
1. WebSocket open が実際には bounded / cancellable ではなく、open-connection limit も network I/O 後にしか適用されていない。
Evidence:
- `crates/pod/src/feature/plugin.rs:922-931`
- `execute_plugin_websocket_open` は URL/grant validation 後に `client.open(...)` を呼び、その後で handle table に insert している。
- `crates/pod/src/feature/plugin.rs:2536-2558`
- `TungstenitePluginWebSocketClient::open` は最初に `tungstenite::client::connect_with_config(...)` を呼ぶ。
- `set_system_websocket_timeouts(...)` は connection established 後にしか呼ばれない。
- `crates/pod/src/feature/plugin.rs:2687-2698`
- `PLUGIN_WEBSOCKET_MAX_OPEN_CONNECTIONS``PluginWebSocketHandleTable::insert` 内で check されるため、`client.open(...)` 完了後、つまり network I/O 後にしか適用されない。
Implications:
- DNS/TCP/TLS/WebSocket handshake が Yoi の WebSocket timeout 適用前に OS/library behavior に従って block し得る。
- `PLUGIN_WASM_TIMEOUT` wrapper は plugin Tool execution に timeout を返すが、spawned blocking task の open 自体は cancel しないため background で継続し得る。
- handle table が満杯でも、新しい outbound WebSocket connection を開いた後で reject するため、max open connections の resource-bound intent を破り、意図しない network side effect が発生し得る。
Why this blocks:
- Ticket は WebSocket API に bounded handle lifetime、max open connections、timeout/cancellation behavior、cleanup を要求している。
- capacity / timeout / cancellation の enforcement が network I/O 前に fail closed していないため、この acceptance criteria を満たしていない。
Required fix:
- Network I/O 前に capacity を reserve/check する。
- Open path を bounded/cancellable にする、または bound を保証できない場合は dialing 前に fail closed する。
- Timeout/cancellation/resource-bound semantics を tests で固定する。
Non-blocking concerns:
- WebSocket host API は component/tool WIT surface に追加され、raw helper path tests はあるが、「long-lived WebSocket connection を Tool call 内に隠さず Plugin instance/Service lifecycle として扱う」という Ticket invariant を pin する integration test/diagnostic は見当たらなかった。現行 Plugin instance が Tool/Service state を共有するなら許容可能だが、lifecycle boundary の regression test/diagnostic があるとより明確。
- `close_all` / drop cleanup と component `stop` cleanup はあるが、active blocking host calls は blocking operation が戻るまで handle-table mutex を保持し得る。これは open timeout/cancellation blocker の副次的問題。
Reviewer validation:
- `cargo fmt --check`: passed
- `git diff --check 3eac7f8e..HEAD`: passed
- `cargo test -p manifest websocket`: passed
- `cargo test -p pod websocket`: passed, 6 focused pod WebSocket tests
- `cargo test -p yoi render_show_distinguishes_request_grant_statuses_and_broad_targets`: passed
- `cargo test -p manifest request_host_api_manifest_and_grant_parse_with_request_names`: passed
- `cargo check -p manifest -p pod -p yoi`: passed
- `cargo run -p yoi -- ticket doctor`: passed (`doctor: ok`)
- `nix build .#yoi --no-link`: passed; reviewer did not reproduce earlier disk-space failure。
- Boundary/stale grep over docs/resources/manifest/pod/yoi CLI for `host_api.request`, `host_api.websocket`, WebSocket/SSE, hidden context/history, Dashboard/Ticket/Discord/reconnect terms found no hidden context injection, Ticket mutation, Dashboard channel, reconnect daemon, or Discord-specific overreach in inspected changes。
Worktree status:
- Clean on `impl/00001KVMGAEJN-plugin-websocket-host-api`
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T13:16:05Z -->
## Implementation report
Coder r1-fix report received from `yoi-coder-00001KVMGAEJN`.
New fix commit:
- `a766048f fix: bound plugin websocket open`
Branch commits now:
- `4c1b8c3d feat: add plugin websocket host api`
- `ce62d235 chore: keep plugin websocket branch code-only`
- `a766048f fix: bound plugin websocket open`
Fix summary:
- `crates/pod/src/feature/plugin.rs`
- Added pre-dial WebSocket capacity reservation / permit flow。
- Max-open-connections now fails before invoking network client/open path。
- Reservation is released on open failure。
- Replaced sync `tungstenite` open path with bounded async `tokio-tungstenite` open under `tokio::time::timeout`
- Added deterministic fail-closed abstraction for clients that cannot guarantee bounded/cancellable open。
- Added focused tests for max-open pre-dial rejection, open failure reservation cleanup, and unbounded open client fail-closed before dialing。
- `crates/pod/Cargo.toml` / `Cargo.lock`
- Added `tokio-tungstenite` and `futures-util`
- `package.nix`
- Updated `cargoHash` to `sha256-cZxkmM42kbDp1Rv9gn4sCD5WIQLc0wCbjj4GbKjuA9Q=`
Coder validation reported:
- `cargo fmt --check`: passed
- `cargo test -p pod websocket`: passed
- `cargo test -p manifest websocket`: passed
- `cargo test -p yoi render_show_distinguishes_request_grant_statuses_and_broad_targets`: passed
- `cargo test -p manifest request_host_api_manifest_and_grant_parse_with_request_names`: passed
- `cargo check -p manifest -p pod -p yoi`: passed
- `git diff --check 3eac7f8e..HEAD`: passed
- `cargo run -p yoi -- ticket doctor`: passed (`doctor: ok`)
- `nix build .#yoi --no-link`: passed
Orchestrator evidence checked:
- Worktree clean on `impl/00001KVMGAEJN-plugin-websocket-host-api` at `a766048f`
- r1-fix diff `ce62d235..HEAD` touches only `Cargo.lock`, `crates/pod/Cargo.toml`, `crates/pod/src/feature/plugin.rs`, and `package.nix`
- `git diff --name-status 3eac7f8e..HEAD -- .yoi/tickets/00001KVMGAEJN`: no output。
- `git diff --check 3eac7f8e..HEAD`: passed。
Next action:
- Route back to Reviewer for r2 verification of WebSocket open capacity/timeout/cancellation bounds and final acceptance。
---
<!-- event: review author: yoi-reviewer-00001KVMGAEJN-r1 at: 2026-06-21T13:20:53Z status: approve -->
## Review: approve
Verdict: `approve`
r1-fix diff inspected:
- Reviewed `ce62d235..a766048f`:
- `Cargo.lock`
- `crates/pod/Cargo.toml`
- `crates/pod/src/feature/plugin.rs`
- `package.nix`
- Also re-checked overall implementation diff `3eac7f8e..a766048f` against Ticket authority and previous r1 blocker。
Previous blocker verification:
- Resolved。
Evidence in `crates/pod/src/feature/plugin.rs`:
- `execute_plugin_websocket_open` now:
- validates URL / manifest / grant before dialing。
- fails closed if selected client cannot guarantee bounded open。
- reserves WebSocket capacity before `client.open(...)`
- commits reservation only after successful open。
- `PluginWebSocketHandles` / `PluginWebSocketHandleTable` now track reserved slots separately from active handles, so `PLUGIN_WEBSOCKET_MAX_OPEN_CONNECTIONS` rejects before network I/O。
- Reservation cleanup is RAII-style:
- failed open drops the reservation。
- failed commit also drops/releases reservation。
- `TungstenitePluginWebSocketClient` now uses `tokio-tungstenite` async connect under `tokio::time::timeout(...)`, replacing previous synchronous `tungstenite::client::connect_with_config(...)` path。
- Focused tests cover:
- max-open rejecting before network open。
- open failure releasing reservation。
- unbounded clients failing closed before dialing。
Overall Ticket fit:
- `host_api.websocket` remains separate from `host_api.request`
- Manifest `[[websocket]]` targets and `grants.websocket` remain independent from request targets/grants。
- Runtime connect requires both manifest target and grant before network I/O。
- URL matching covers scheme, host, port, and path prefix。
- WebSocket API is text-only, explicit receive only, and does not inject incoming messages into hidden context/history。
- Guest arbitrary handshake headers / embedded credentials remain rejected。
- Request API continues to reject WebSocket/SSE/persistent request attempts。
- Static inspection / `yoi plugin show` diagnostics distinguish request and WebSocket grant/target states。
- No Ticket mutation, Dashboard channel, scheduler/reconnect daemon, direct model Tool call, or Discord-specific overreach found in reviewed branch。
Blocking issues:
- None。
Non-blocking concerns:
- None requiring changes。
Reviewer validation:
- `cargo fmt --check`: passed
- `git diff --check 3eac7f8e..HEAD`: passed
- `cargo test -p pod websocket`: passed
- `cargo test -p manifest websocket`: passed
- `cargo test -p yoi render_show_distinguishes_request_grant_statuses_and_broad_targets`: passed
- `cargo test -p manifest request_host_api_manifest_and_grant_parse_with_request_names`: passed
- `cargo check -p manifest -p pod -p yoi`: passed
- `cargo run -p yoi -- ticket doctor`: passed
- `nix build .#yoi --no-link`: passed; disk-space failure did not reproduce。
Worktree status:
- Clean on `impl/00001KVMGAEJN-plugin-websocket-host-api` at `a766048f`
- Branch diff no longer includes `.yoi/tickets/00001KVMGAEJN/*`
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T13:26:05Z -->
## Implementation report
Reviewer r2 approvalを受けて、実装ブランチを Orchestrator worktree の `orchestration` branch に統合した。
統合:
- Merge commit: `354f1e10 merge: plugin websocket host api`
- Merged branch: `impl/00001KVMGAEJN-plugin-websocket-host-api`
- Implementation commits:
- `4c1b8c3d feat: add plugin websocket host api`
- `ce62d235 chore: keep plugin websocket branch code-only`
- `a766048f fix: bound plugin websocket open`
Orchestrator worktree validation:
- `cargo fmt --check`: passed
- `git diff --check HEAD^1..HEAD`: passed
- `cargo test -p pod websocket`: passed
- `cargo test -p manifest websocket`: passed
- `cargo test -p yoi render_show_distinguishes_request_grant_statuses_and_broad_targets`: passed
- `cargo test -p manifest request_host_api_manifest_and_grant_parse_with_request_names`: passed
- `cargo check -p manifest -p pod -p yoi`: passed
- `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 `354f1e10`
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-21T13:26:12Z from: inprogress to: done reason: implementation_merged_and_validated field: state -->
## State changed
Reviewer approval、Orchestrator worktree への統合、focused manifest/pod/yoi plugin tests、cargo check、Ticket doctor、Nix build が完了したため `done` に遷移する。
---
<!-- event: state_changed author: hare at: 2026-06-21T13:26:27Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-21T13:26:27Z status: closed -->
## 完了
URL permission based Plugin WebSocket host API を実装し、Orchestrator worktree の `orchestration` branch に統合した。
主な成果:
- `host_api.websocket``host_api.request` とは別 capability として追加。
- Manifest `[[websocket]]` target declaration と enablement `grants.websocket` を追加し、request targets/grants とは独立させた。
- Static inspection / `yoi plugin show` が WebSocket requested/granted/missing/grant-only/broad diagnostics を request diagnostics とは別に表示するようにした。
- Runtime connect は manifest target と enablement grant の両方が URL を許可する場合のみ network I/O に進む。
- URL checks cover scheme (`ws`/`wss`), host, port, and path prefix。
- Local/private/loopback WebSocket targets は ambient ではなく、明示 declaration + grant が必要。
- Host-owned WebSocket handle API を追加: open, send_text / send-text, recv, close。
- Text-only / explicit bounded receive とし、binary receive は fail closed / unsupported。
- Guest arbitrary handshake headers / embedded credentials を reject。
- Request API は WebSocket/SSE/persistent attempts を引き続き reject。
- Open path は pre-dial capacity reservation と bounded async `tokio-tungstenite` open under `tokio::time::timeout` により max-open / timeout semantics を network I/O 前から enforce。
- Reservation cleanup on open failure / failed commit を追加。
- WIT resource `yoi:host/websocket@1.0.0` と docs を更新。
- `tungstenite`, `tokio-tungstenite`, `futures-util` dependencies と `Cargo.lock` / `package.nix` cargo hash を更新。
統合・検証:
- Merge commit: `354f1e10 merge: plugin websocket host api`
- Implementation commits: `4c1b8c3d`, `ce62d235`, `a766048f`
- Reviewer final verdict: approve
- Validation passed: `cargo fmt --check`, `git diff --check HEAD^1..HEAD`, `cargo test -p pod websocket`, `cargo test -p manifest websocket`, `cargo test -p yoi render_show_distinguishes_request_grant_statuses_and_broad_targets`, `cargo test -p manifest request_host_api_manifest_and_grant_parse_with_request_names`, `cargo check -p manifest -p pod -p yoi`, `cargo run -p yoi -- ticket doctor`, and `nix build .#yoi --no-link`
範囲外:
- Discord bridge 本体は実装していない。
- Reconnect/backoff/heartbeat scheduler、hidden context/history injection、Dashboard channel、Ticket mutation、direct model Tool invocation は追加していない。
- SecretRef-based credential injection は future follow-up。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T13:27:28Z -->
## Implementation report
Post-close cleanup completed。
- Stopped child Pods and reclaimed scope:
- `yoi-coder-00001KVMGAEJN`
- `yoi-reviewer-00001KVMGAEJN-r1`
- Removed implementation worktree:
- `/home/hare/Projects/yoi/.worktree/00001KVMGAEJN-plugin-websocket-host-api`
- Deleted implementation branch:
- `impl/00001KVMGAEJN-plugin-websocket-host-api`
- Orchestrator worktree remains clean on `orchestration` at `e8103477`
Root/original workspace was not used for merge/validation/cleanup operations。
---

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260621-105843-1","ticket_id":"00001KVMT2J25","kind":"accepted_plan","accepted_plan":{"summary":"Implement protocol/pod/TUI support for connect-time snapshot of in-flight assistant text, thinking, and tool-call args so late attach/reconnect displays unfinished blocks and continues live deltas without gaps or duplicates.","branch":"impl/00001KVMT2J25-inflight-snapshot","worktree":"/home/hare/Projects/yoi/.worktree/00001KVMT2J25-inflight-snapshot","role_plan":"Orchestrator creates a dedicated child worktree and spawns a narrow-scope Coder. Reviewer will be spawned read-only after Coder reports implementation commit(s). After approval, Orchestrator integrates into `orchestration`, validates protocol/pod/TUI tests, records closure, and cleans only the child worktree/branch."},"author":"yoi-orchestrator","at":"2026-06-21T10:58:43Z"}

View File

@ -0,0 +1,120 @@
---
title: 'Pod protocol: in-flight LLM response reconnect snapshot should include unfinished blocks'
state: 'closed'
created_at: '2026-06-21T10:02:01Z'
updated_at: '2026-06-21T12:00:31Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['protocol', 'session-history', 'persistence', 'tui-reconnect', 'stream-state']
queued_by: 'workspace-panel'
queued_at: '2026-06-21T10:56:32Z'
---
## User claims / request snapshot
- プロトコル実装の問題として、LLM 応答中に接続すると、まだ完了していない block の途中内容が欠落する。
- 応答完了後に接続し直すと見える。
- 対象 workspace は `yoi`。Panel handoff の orchestrator Pod は `yoi-orchestrator`
## Confirmed facts / sources
- 既存 Ticket 確認:
- active duplicate は見当たらない。
- `00001KSVP63K8` は in-flight TUI composer injection で、実行中 turn への入力注入の設計 Ticket。今回の「途中出力の late attach / reconnect 表示欠落」とは別件。
- `crates/protocol/src/lib.rs`
- `Event::Snapshot` は接続開始時に一度送られ、`entries` は subscribe 時点の session-log mirror。
- コメント上、Snapshot 後の live 更新は `TextDelta` / `ToolCall*` / `ToolResult` 等で流れ、generic な committed entry broadcast はない。
- `crates/pod/src/segment_log_sink.rs`
- `SegmentLogSink::subscribe_with_snapshot()` は committed `LogEntry` の prefix と live receiver を gap-free に分ける設計。
- `AssistantItem` / `ToolResult` などは mirror には反映されるが live broadcast されず、live 表示は streaming events に依存する。
- `crates/pod/src/controller.rs`
- text / thinking / tool-call args の途中 delta は `Event::TextDelta` / `ThinkingDelta` / `ToolCallArgsDelta` として direct broadcast される。
- `crates/tui/src/app.rs`
- Snapshot は `restore_snapshot(&entries, greeting)` で session-log entries から復元される。
- live delta は TUI 側で block に追記される。
- 以上から、コード上も「接続前に流れたがまだ committed history になっていない途中 delta」を late subscriber が復元する lane が見当たらない。
## Unverified hypotheses
- 実際の欠落原因は、未完了 block の accumulator が `Event::Snapshot` に含まれず、live subscriber は subscribe 後の delta しか受け取れないことだと思われる。
- 応答完了後に再接続すると見えるのは、finalized assistant/tool history が session log mirror に committed され、Snapshot entries から復元できるためだと思われる。
- 修正は、protocol-level に in-flight block state を snapshot へ含める、または bounded replay/sequence 付き live event buffer を導入する形が自然そう。
## Undecided points / open questions
- blocking な未決定点はなし。
- 実装戦術として、`Event::Snapshot` に structured `in_flight` state を追加するか、sequence 付き replay buffer を使うかは Coder がコード調査して選んでよい。
- protocol crate の wire shape 変更なので、既存 serde roundtrip / older snapshot fallback をどこまで持つかは実装時に最小限で判断する。不要な後方互換は作らない。
## Background
LLM の応答中に Console / TUI / attach client が接続した場合、ユーザーはその時点までに出ている assistant text、thinking、tool-call args などの unfinished block を見られる必要がある。現在の構造では、接続時 Snapshot は committed session-log entries だけを seed し、途中 delta は live broadcast のみなので、接続前に流れた unfinished delta が見えない可能性がある。
## Requirements
- LLM 応答中に新しく接続・再接続した client が、接続時点までに蓄積済みの unfinished block 内容を表示できるようにする。
- 対象 block は少なくとも以下を含む:
- assistant text streaming block
- thinking/reasoning streaming block
- tool-call arguments streaming block
- Snapshot と Snapshot 後の live events の境界で、欠落も重複も起こさない。
- 応答完了後の reconnect では、従来通り finalized session-log から完全な表示を復元できること。
- protocol-level の整合性として直す。TUI だけの偶然の workaround にしない。
- 未完了 model output を、finalized assistant history として誤って永続化しない。
- prompt/history/context に hidden injection しない。
## Acceptance criteria
- LLM 応答中に client が接続した場合、接続前に生成済みの unfinished text / thinking / tool-call args が表示される。
- 接続後に続く delta は同じ block に継続して追記され、途中内容の欠落・二重表示がない。
- Run 完了後に接続し直しても、finalized transcript は従来通り Snapshot entries から復元される。
- Snapshot/live 境界の gap-free / duplicate-free 性をテストで確認する。
- TUI の `Event::Snapshot` 処理と live delta 処理の regression がない。
- focused validation として、少なくとも protocol/pod/TUI の関連 test または unit test が追加・更新される。
## Binding decisions / invariants
- 「応答完了後に接続し直せば見える」は workaround であり、正しい完了条件ではない。
- Late attach は、実行中 Pod の現在表示可能な stream state を復元できるべき。
- Committed session-log の gap-free semantics は壊さない。
- Unfinished block は finalized assistant history と混同しない。
- Provider stream 自体を巻き戻したり mutate したりしない。
- Hidden context/history injection はしない。
## Implementation latitude
- `Event::Snapshot` に in-flight block state を追加する案、または bounded/sequence 付き stream replay buffer を導入する案のどちらでもよい。
- Controller / Pod 側で text/thinking/tool-call args の current accumulator を保持する設計にしてよい。
- TUI 側は Snapshot から unfinished block を seed し、その後の live delta を同一 block に継続適用できればよい。
- wire compatibility は必要最小限。長期保守・型安全性を優先する。
## Readiness
- readiness: implementation_ready
- risk_flags: [protocol, session-history, persistence, tui-reconnect, stream-state]
## Escalation conditions
- unfinished output をどの durable history item として永続化するかの設計変更が必要になった場合。
- Snapshot に含める in-flight state が大きくなり、boundedness / memory usage / truncation policy が必要になった場合。
- protocol public surface として互換方針を決める必要が出た場合。
- TUI だけではなく Dashboard / Pod list preview など複数 surface の UX 方針に広がる場合。
## Validation
- `cargo test -p protocol` の relevant roundtrip / serialization tests。
- `cargo test -p pod` の subscriber/snapshot/live-stream focused tests。
- `cargo test -p tui` または targeted app snapshot/live delta tests。
- `cargo fmt --check`
- 必要なら `cargo check -p pod -p tui -p protocol`
## Related work
- Related but not duplicate:
- `00001KSVP63K8` — Support immediate in-flight TUI composer injection
- Relevant files:
- `crates/protocol/src/lib.rs`
- `crates/pod/src/segment_log_sink.rs`
- `crates/pod/src/controller.rs`
- `crates/pod/src/pod.rs`
- `crates/tui/src/app.rs`

View File

@ -0,0 +1,20 @@
In-flight LLM response 中の reconnect / late attach snapshot に unfinished blocks を含める protocol/pod/TUI 実装を統合した。
主な成果:
- `Event::Snapshot` に typed `InFlightSnapshot` / `InFlightBlock` を追加。
- Pod 側に assistant text / thinking / tool-call args の in-flight accumulator を追加。
- Streaming callbacks が accumulator 更新と live delta broadcast を同じ stream path で行うようにした。
- Connect-time snapshot が in-flight stream state を含むようにした。
- Session-log mirror snapshot と in-flight snapshot、および finalized `AssistantItem` publish/clear の critical section を揃え、mirror-only assistant commit が snapshot/live boundary で消える race を防止した。
- Finalized assistant item が committed snapshot に含まれる場合は matching in-flight state を clear して duplicate を防ぐ。
- TUI snapshot restore が unfinished text/thinking/tool-call args blocks を seed し、後続 live deltas が同じ logical block に continuation されるようにした。
- Serialization/default compatibility、snapshot/live no-gap/no-duplicate、TUI continuation の focused regression tests を追加。
統合・検証:
- Merge commit: `b21638f5 merge: inflight reconnect snapshot`
- Implementation commits: `74aca6f6`, `061136d7`
- Reviewer final verdict: approve
- Validation passed: `cargo fmt --all --check`, `git diff --check HEAD^1..HEAD`, `cargo test -p protocol`, `cargo test -p pod --lib in_flight`, `cargo test -p pod session_log_and_in_flight_snapshot_prevents_mirror_only_assistant_gap`, `cargo test -p pod committed_assistant_snapshot_does_not_duplicate_in_flight_block`, `cargo test -p tui snapshot_in_flight_blocks_continue_with_live_deltas`, `cargo test -p tui`, `cargo check -p protocol -p pod -p tui`, and `cargo run -p yoi -- ticket doctor`
既知の無関係事項:
- Full `cargo test -p pod` は既存の prompt-resource assertion 2 件で失敗することが reviewer により確認済み。この Ticket の差分とは無関係。

View File

@ -0,0 +1,583 @@
<!-- event: create author: LocalTicketBackend at: 2026-06-21T10:02:01Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: state_changed author: workspace-panel at: 2026-06-21T10:07:42Z from: ready to: planning reason: panel_return_to_planning field: state -->
## State changed
Panel returned ready Ticket 00001KVMT2J25 to planning for requirements sync. This is not Queue routing and must not start implementation.
## User refinement instruction
今あるフロントSPAをnodeからdenoにしたい。
https://hareworks.net/blog/tech/sveltekit-with-deno
---
<!-- event: comment author: ticket-intake at: 2026-06-21T10:09:52Z -->
## Comment
## Intake requirements sync result
対象 Ticket `00001KVMT2J25` の item/thread/artifacts と関連 Ticket/source を確認した結果、今回の refinement instruction はこの Ticket の既存スコープとは一致しないため、`00001KVMT2J25` を ready に戻したり queue routing したりせず、requirements sync の結果としてここに記録する。
### User claims / request snapshot
- ユーザーは「今あるフロントSPAを node から deno にしたい」と述べた。
- 参考 URL として `https://hareworks.net/blog/tech/sveltekit-with-deno` が提示された。
### Confirmed facts / sources
- `00001KVMT2J25` の item は「Attach mid-stream TUI client without losing in-flight content」で、protocol / pod / TUI reconnect / stream-state の実装 Ticket である。
- `00001KVMT2J25` の thread には、Panel が ready から planning に戻した理由として上記 frontend/Deno refinement instruction が記録されている。
- 関連する closed Ticket `00001KVMFFYVX` は Workspace web control plane bootstrap で、SvelteKit static SPA skeleton を `web/workspace` に追加した作業である。
- `web/workspace/README.md` は現在の package manager を npm とし、`npm install`, `npm run check`, `npm run build` を案内している。
- `web/workspace/package.json` は SvelteKit static SPA の scripts / devDependencies を持ち、`package-lock.json` が存在する。
- `web/workspace/svelte.config.js``@sveltejs/adapter-static` を使い、`build/` に static output を出す設定である。
- `package.nix``web/workspace/node_modules`, `.svelte-kit`, `build` を source filter から除外している。
- `devshell.nix` には `deno` が含まれている。
### Intake conclusion
- この refinement は `00001KVMT2J25` の late-attach / stream-state work item とは別目的であり、同一 Ticket の requirements / acceptance criteria に混ぜると work item が破綻する。
- 既存の frontend bootstrap Ticket `00001KVMFFYVX` は closed であり、現在の npm 採用は当時の bootstrap decision / implementation detail として記録されている。Deno 移行は closed Ticket の再オープンではなく、別の concrete follow-up Ticket として扱うのが妥当。
- 現時点では、ユーザーが「新規 Ticket を作成して」と明示していないため、duplicate/new Ticket は作成しない。
### Candidate follow-up draft if user approves new Ticket
Title: Workspace web SPA の frontend tooling を npm/Node から Deno に移行する
Readiness: implementation_ready
Risk flags: [frontend-tooling, packaging, nix-source-filter, validation]
Requirements:
- `web/workspace` の開発・検証・build 手順を npm/Node 前提から Deno 前提へ移行する。
- SvelteKit static SPA であること、Rust backend が business/API authority を持つこと、static output が `web/workspace/build/` に出ることは維持する。
- `web/workspace/README.md` の package manager / command guidance を Deno 前提に更新する。
- lockfile と依存関係管理を Deno 前提で明確にし、古い npm lockfile/source-of-truth が残る場合は理由を明示する。
- generated artifacts (`node_modules`, `.svelte-kit`, `build` など) が git/Nix package source に混入しない状態を維持する。
Acceptance criteria:
- `web/workspace` の check/build が Deno command で成功する。
- README と project files から、通常の frontend 作業者が Deno で install/check/build できることが分かる。
- npm lockfile / npm scripts / Node-only guidance が不要なら削除され、必要なら Deno 移行後も残す理由が code/docs に明確である。
- `package.nix` / devshell / repository hygiene が Deno 移行後の generated artifacts と矛盾しない。
- 既存の Rust backend static serving assumptions と `.yoi` records as authority の境界を変えない。
Implementation latitude:
- Deno project layout は `deno.json` / `deno.lock` / `deno task` を使う形でよい。
- SvelteKit/Vite を Deno の npm compatibility で動かすか、最小限の package metadata を残すかは実装時に選んでよい。ただし source-of-truth と command guidance は曖昧にしない。
Validation:
- `cd web/workspace && deno task check`
- `cd web/workspace && deno task build`
- `git diff --check`
- packaging/source-filter を変更する場合は `nix build .#yoi --no-link` または Orchestrator が妥当と判断する同等確認
Next routing recommendation:
- `00001KVMT2J25` はこの refinement では implementation_ready に戻さない。
- ユーザーが Deno 移行の新規 Ticket 作成を承認した場合、上記 draft を別 Ticket として materialize する。
- ユーザーがあくまで `00001KVMT2J25` の差し替えを望む場合は、既存 Ticket を repurpose するのではなく、Orchestrator/maintainer に cancel/close/replacement 方針の判断を戻す。
---
<!-- event: intake_summary author: ticket-intake at: 2026-06-21T10:23:54Z -->
## Intake summary
ユーザーから、Deno 移行 refinement は `00001KVMT2J25` に誤って付いたものであり、`00001KVMT2J25` は ready に戻してよいとの明示指示があった。再確認したところ、`00001KVMT2J25` の body は late attach / in-flight stream snapshot の concrete work item で、readiness は `implementation_ready`、blocking open question はない。Deno 移行は別 Ticket `00001KVMV03QY` として作成済みであり、本 Ticket の要件には混ぜない。
---
<!-- event: state_changed author: ticket-intake at: 2026-06-21T10:23:54Z from: planning to: ready reason: requirements_sync_resolved field: state -->
## State changed
Deno 移行 refinement は誤付与として分離済み。`00001KVMT2J25` は元の protocol reconnect / unfinished block snapshot Ticket として Orchestrator routing 可能な ready 状態へ戻す。queue routing や implementation start は行わない。
---
<!-- event: state_changed author: workspace-panel at: 2026-06-21T10:56:32Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-21T10:58:27Z -->
## Decision
Routing decision: `implementation_ready`
Reason:
- Ticket body は in-flight LLM response reconnect snapshot の問題、affected blocks、protocol/pod/TUI relevant files、acceptance criteria、validation が具体化されている。
- `readiness: implementation_ready` で、relations / orchestration plan に blocker はない。
- Requirements sync で Deno refinement は誤付与として分離済みで、この Ticket は original protocol reconnect scope に戻されている。
- 同時 queued の `00001KVMV03QY` は frontend Deno tooling migration であり、この Ticket の protocol/pod/TUI stream-state work と主対象が異なるため並列実装可能と判断する。
- Orchestrator worktree は clean on `orchestration` at `b4786b40` で、対象 Ticket 用 worktree / branch は未作成。
Evidence checked:
- Ticket body / thread / artifacts via `TicketShow` and direct `item.md` read。
- `TicketRelationQuery(00001KVMT2J25)`: no relations / blockers。
- `TicketOrchestrationPlanQuery(00001KVMT2J25)`: no records。
- Orchestrator git state / worktree list / branch list checked from `/home/hare/Projects/yoi/.worktree/orchestration` only。
- Bounded code map:
- `crates/protocol/src/lib.rs`: `Event::Snapshot`, `TextDelta`, `ThinkingDelta`, `ToolCallArgsDelta`, serialization tests。
- `crates/pod/src/segment_log_sink.rs`: committed `LogEntry` snapshot / live entry receiver。
- `crates/pod/src/controller.rs`: direct broadcast of streaming deltas and current controller comments around stream reconstruction。
- `crates/pod/src/ipc/server.rs`: connect-time snapshot event construction。
- `crates/tui/src/app.rs`: `restore_snapshot` and live delta handling for text/thinking/tool-call args。
IntentPacket:
Intent:
- Ensure late attach / reconnect during an in-flight LLM response can display already-generated unfinished text/thinking/tool-call args, then continue live deltas without gaps or duplicates。
Binding decisions / invariants:
- Fix at protocol/pod state level, not TUI-only workaround。
- Do not persist unfinished model output as finalized assistant history。
- Do not mutate/replay provider stream itself。
- Preserve committed session-log gap-free semantics。
- Preserve post-run reconnect behavior from finalized Snapshot entries。
- No hidden context/history injection。
- Keep in-flight snapshot bounded and typed; if large/unbounded policy is required, escalate。
Requirements / acceptance criteria:
- New client connecting during response sees unfinished assistant text, thinking/reasoning, and tool-call args generated before connect。
- Live deltas after connect append to same logical block without missing or duplicated content。
- Completed run reconnect still restores finalized transcript from normal Snapshot entries。
- Snapshot/live boundary gap-free / duplicate-free behavior is tested。
- TUI Snapshot restore + live delta handling has regression coverage。
- Focused validation covers protocol/pod/TUI relevant paths。
Implementation latitude:
- Add structured `in_flight` state to `Event::Snapshot`, or implement bounded/sequence replay buffer if cleaner。
- Controller/Pod may keep current accumulators for text/thinking/tool-call args。
- TUI may seed unfinished blocks from Snapshot and continue applying live deltas to the same block。
- Wire compatibility should be minimal; prioritize type safety and maintainability。
Escalate if:
- Design requires persisting unfinished output as durable history item。
- In-flight snapshot state becomes large enough to need truncation/bounding policy beyond a straightforward current-turn accumulator。
- Public protocol compatibility policy becomes a product decision。
- Scope spreads to Dashboard/Pod list preview or broader UX surfaces beyond TUI/console attach。
Validation plan:
- `cargo fmt --check`
- Focused `cargo test -p protocol` roundtrip/serialization tests for snapshot in-flight state。
- Focused `cargo test -p pod` tests for connect-time snapshot/live boundary and accumulator behavior。
- Focused `cargo test -p tui` tests for snapshot seeding plus live delta continuation。
- `cargo check -p protocol -p pod -p tui`
- `git diff --check`
- `yoi ticket doctor`
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-21T10:58:33Z from: queued to: inprogress reason: human_authorized_unblocked_protocol_stream_state_implementation field: state -->
## State changed
Human authorized queue routing from Workspace Dashboard. Ticket has concrete acceptance criteria, no recorded blockers, and is semantically separate from the frontend Deno tooling Ticket, so Orchestrator accepts implementation.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T11:00:09Z -->
## Implementation report
Implementation start report:
- Created child implementation worktree:
- `/home/hare/Projects/yoi/.worktree/00001KVMT2J25-inflight-snapshot`
- Created branch:
- `impl/00001KVMT2J25-inflight-snapshot`
- Base commit:
- `155e039e ticket: route deno and inflight snapshot work`
- 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-00001KVMT2J25`
- Coder scope:
- read: `/home/hare/Projects/yoi`
- write: `/home/hare/Projects/yoi/.worktree/00001KVMT2J25-inflight-snapshot`
Parallelization note:
- `00001KVMV03QY` is active in a separate child worktree and targets frontend Deno tooling. This Ticket should stay limited to protocol/pod/TUI in-flight snapshot semantics。
Next action:
- Wait for Coder implementation report, then spawn Reviewer read-only for the implementation diff。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T11:31:12Z -->
## Implementation report
Coder implementation report received from `yoi-coder-00001KVMT2J25`.
Implementation commit:
- `74aca6f6 fix: snapshot in-flight stream state`
Changed areas:
- `crates/protocol/src/lib.rs`
- Added typed `InFlightSnapshot` / `InFlightBlock` state on `Event::Snapshot`
- Added serialization/default compatibility coverage。
- `crates/pod/src/in_flight.rs`
- New in-flight accumulator for assistant text, thinking/reasoning, and tool-call args。
- Snapshot/live boundary tests for no gap/duplicate。
- `crates/pod/src/controller.rs`
- Streaming callbacks update the in-flight accumulator and broadcast live deltas from the same path。
- `crates/pod/src/ipc/alerter.rs`, `crates/pod/src/ipc/server.rs`
- Connect-time snapshot now includes alerts plus typed in-flight stream state while subscribing live events。
- `crates/pod/src/pod.rs`
- Clears matching in-flight blocks once committed assistant items reach durable session log entries。
- `crates/tui/src/app.rs`
- Snapshot restore seeds unfinished assistant text/thinking/tool-call blocks。
- Live deltas continue those seeded blocks。
- Added regression coverage for snapshot seeding plus live continuation。
- Other touched files update test Snapshot constructors for the new `in_flight` field。
Coder validation reported:
- `cargo fmt --all --check`: passed
- `cargo test -p protocol`: passed
- `cargo test -p protocol event_snapshot_in_flight_roundtrip_and_default`: passed
- `cargo test -p pod in_flight`: passed
- `cargo test -p pod subscribe_with_alerts_and_in_flight_snapshot_splits_snapshot_and_live_deltas`: passed
- `cargo test -p tui`: passed
- `cargo test -p tui snapshot_in_flight_blocks_continue_with_live_deltas`: passed
- `cargo check -p protocol -p pod -p tui`: passed
- `git diff --check`: passed
- `target/debug/yoi ticket doctor`: passed
Known unrelated validation note:
- Full `cargo test -p pod` still fails on two prompt-resource assertion tests unrelated to this change:
- `prompt::catalog::tests::pod_orchestration_guidance_section_renders_resource_body`
- `prompt::system::tests::pod_orchestration_guidance_is_included_for_pod_management_tools`
- Both assert missing text: `worktree status, diff, and test results`
Orchestrator evidence checked:
- Implementation worktree is clean on `impl/00001KVMT2J25-inflight-snapshot`
- HEAD is `74aca6f6`
- Diff `155e039e..HEAD` touches 17 protocol/pod/TUI files, adding `crates/pod/src/in_flight.rs` and updating Snapshot constructors/tests。
- `git diff --check 155e039e..HEAD` produced no diagnostics。
Next action:
- Route to read-only Reviewer Pod for acceptance review against the Ticket。
---
<!-- event: comment author: yoi-orchestrator at: 2026-06-21T11:31:55Z -->
## Comment
Review routing:
Spawned read-only Reviewer Pod:
- `yoi-reviewer-00001KVMT2J25-r1`
Review target:
- Worktree: `/home/hare/Projects/yoi/.worktree/00001KVMT2J25-inflight-snapshot`
- Branch: `impl/00001KVMT2J25-inflight-snapshot`
- Baseline: `155e039e`
- Implementation commit: `74aca6f6 fix: snapshot in-flight stream state`
- Diff: `155e039e..74aca6f6`
Reviewer scope:
- read-only `/home/hare/Projects/yoi`
Reviewer focus:
- Snapshot/live subscription ordering is gap-free。
- Seeded unfinished blocks and later live deltas do not duplicate content。
- Accumulator lifecycle clears after durable assistant commit and does not leak across runs。
- Tool-call args partial JSON string deltas accumulate correctly。
- Thinking/reasoning blocks remain separated and safe。
- Finished-run reconnect still restores finalized transcript normally。
- No unfinished model output is persisted as finalized history。
- TUI restore seeding is compatible with existing live delta handlers。
Orchestrator will wait for reviewer verdict before integration。
---
<!-- event: review author: yoi-reviewer-00001KVMT2J25-r1 at: 2026-06-21T11:37:35Z status: request_changes -->
## Review: request changes
Verdict: `request_changes`
確認範囲:
- Ticket authority:
- `.yoi/tickets/00001KVMT2J25/item.md`
- `.yoi/tickets/00001KVMT2J25/thread.md`
- Diff `155e039e..74aca6f6`, including:
- `crates/protocol/src/lib.rs`
- `crates/pod/src/in_flight.rs`
- `crates/pod/src/controller.rs`
- `crates/pod/src/ipc/alerter.rs`
- `crates/pod/src/ipc/server.rs`
- `crates/pod/src/pod.rs`
- `crates/pod/src/segment_log_sink.rs`
- `crates/tui/src/app.rs`
- constructor/test updates in pod/TUI/discovery/spawn/dashboard-related test files
Blocking issue:
1. Snapshot/live boundary still has a gap for finalized assistant items committed between the session-log snapshot and the in-flight snapshot。
The implementation makes in-flight streaming deltas mostly gap-free by holding the in-flight mutex across subscribe/snapshot, but the full connection snapshot is not atomic across the committed session-log mirror and the new in-flight accumulator。
Relevant path:
- `crates/pod/src/ipc/server.rs:112` takes `handle.sink.subscribe_with_snapshot()` first。
- `crates/pod/src/ipc/server.rs:118-120` then subscribes/snapshots alerts + in-flight state。
- `crates/pod/src/pod.rs:182-191` persists an entry, clears matching in-flight state for `LogEntry::AssistantItem`, then publishes to the session-log sink。
- `crates/pod/src/segment_log_sink.rs:90-103` documents that `AssistantItem` is mirror-only and not live-broadcast。
- `crates/pod/src/segment_log_sink.rs:120-127` confirms only `SegmentStart`, `UserInput`, `SystemItem`, and `Invoke` are live-relevant。
Race:
1. New client connects。
2. Server snapshots session-log entries at N。
3. LLM response finalizes and appends `LogEntry::AssistantItem`
4. `append_entry` clears the matching in-flight text/thinking block。
5. `SegmentLogSink::publish` mirrors the `AssistantItem`, but does not broadcast it live。
6. Server snapshots in-flight state, now empty/cleared。
7. Client receives `Event::Snapshot` with no finalized assistant item in `entries`, no unfinished/finalized in-flight block, and no live committed assistant event forthcoming。
This violates the Ticket invariant that Snapshot and post-Snapshot live events must be gap-free and duplicate-free。
Required fix:
- Make the connection snapshot atomic enough across session-log mirror and in-flight state, or otherwise ensure committed assistant items cannot disappear in this boundary。
- Add an integrated regression test around session-log snapshot + in-flight snapshot + mirror-only assistant commit boundary。
- Ensure the fix still avoids persisting unfinished output as final history and still does not duplicate content when final AssistantItem is included in committed snapshot。
Non-blocking concerns:
- The suggested focused test name `subscribe_with_alerts_and_in_flight_snapshot_splits_snapshot_and_live_deltas` selected 0 tests; no matching test exists。Add/rename an integrated focused test so validation can target the boundary reliably。
- Minor protocol doc issue: `Greeting` doc comment appears displaced so “Pod self-description...” now sits immediately before `InFlightSnapshot` in `crates/protocol/src/lib.rs`。Not blocking but should be cleaned up if easy。
Reviewer validation:
- `git diff --check 155e039e..HEAD`: passed
- `cargo fmt --all --check`: passed
- `cargo test -p protocol`: passed, 40 tests
- `cargo test -p pod in_flight`: passed selected tests
- `cargo test -p pod subscribe_with_alerts_and_in_flight_snapshot_splits_snapshot_and_live_deltas`: selected 0 tests / no matching test
- `cargo test -p tui snapshot_in_flight_blocks_continue_with_live_deltas`: passed
- `cargo test -p tui`: passed, 367 tests
- `cargo check -p protocol -p pod -p tui`: passed
- `target/debug/yoi ticket doctor`: passed (`doctor: ok`)
- Full `cargo test -p pod`: failed only on the two known unrelated prompt-resource assertion tests。
Worktree status:
- Clean on `impl/00001KVMT2J25-inflight-snapshot`
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T11:53:00Z -->
## Implementation report
Coder r1-fix report received from `yoi-coder-00001KVMT2J25`.
New fix commit:
- `061136d7 fix: close in-flight snapshot commit race`
Branch commits now:
- `74aca6f6 fix: snapshot in-flight stream state`
- `061136d7 fix: close in-flight snapshot commit race`
Fix summary:
- `crates/pod/src/ipc/server.rs`
- Connect-time snapshot now holds the in-flight stream lock while taking the session-log mirror snapshot, preventing finalized mirror-only `AssistantItem` entries from falling between committed snapshot entries and in-flight snapshot state。
- `crates/pod/src/pod.rs`
- `append_entry` now clears matching in-flight assistant state and publishes finalized `AssistantItem` to the mirror under the same in-flight critical section。
- `crates/pod/src/in_flight.rs`
- Added `clear_for_committed_item_then` helper for atomic clear + publish ordering。
- Added integrated regression tests:
- `session_log_and_in_flight_snapshot_prevents_mirror_only_assistant_gap`
- `committed_assistant_snapshot_does_not_duplicate_in_flight_block`
- `crates/pod/src/ipc/alerter.rs`
- Removed earlier non-atomic combined alert/in-flight snapshot helper。
- `crates/protocol/src/lib.rs`
- Fixed displaced `Greeting` doc comment。
Coder validation reported:
- `cargo fmt --all --check`: passed
- `cargo test -p protocol`: passed
- `cargo test -p pod --lib in_flight`: passed
- `cargo test -p pod session_log_and_in_flight_snapshot_prevents_mirror_only_assistant_gap`: passed
- `cargo test -p pod committed_assistant_snapshot_does_not_duplicate_in_flight_block`: passed
- `cargo test -p tui snapshot_in_flight_blocks_continue_with_live_deltas`: passed
- `cargo test -p tui`: passed
- `cargo check -p protocol -p pod -p tui`: passed
- `git diff --check`: passed
- `target/debug/yoi ticket doctor`: passed
Known unrelated validation note:
- Full `cargo test -p pod` still fails only on the known unrelated prompt-resource assertions:
- `prompt::catalog::tests::pod_orchestration_guidance_section_renders_resource_body`
- `prompt::system::tests::pod_orchestration_guidance_is_included_for_pod_management_tools`
Orchestrator evidence checked:
- Implementation worktree clean on `impl/00001KVMT2J25-inflight-snapshot`
- HEAD is `061136d7`
- r1-fix diff `74aca6f6..HEAD` touches expected protocol/pod files: `crates/pod/src/in_flight.rs`, `crates/pod/src/ipc/alerter.rs`, `crates/pod/src/ipc/server.rs`, `crates/pod/src/pod.rs`, and `crates/protocol/src/lib.rs`
- `git diff --check 155e039e..HEAD` produced no diagnostics。
Next action:
- Route back to Reviewer for r2 verification of the commit-boundary race fix and final acceptance。
---
<!-- event: review author: yoi-reviewer-00001KVMT2J25-r1 at: 2026-06-21T11:56:44Z status: approve -->
## Review: approve
Verdict: `approve`
r2 review result:
- r1 blocker is resolved。
- The fix establishes a single critical section around session-log mirror snapshot and in-flight snapshot, and uses the same in-flight lock while clearing/publishing finalized `AssistantItem`s。
Relevant safe interleavings:
- If connect-time snapshot gets the in-flight lock first: session-log snapshot may not include finalized mirror-only `AssistantItem`, but unfinished/finished in-flight block is still present in `Event::Snapshot.in_flight`
- If commit gets the in-flight lock first: it clears in-flight and publishes to session-log mirror under that lock, so later session-log snapshot includes finalized `AssistantItem` and `in_flight` is empty。
- Since `AssistantItem` remains mirror-only for live clients, this avoids the previous “absent from both snapshot and live events” gap。
- Duplicate prevention is preserved by clearing matching in-flight state before publishing finalized assistant entry。
Files / diff inspected:
- r1-fix diff `74aca6f6..061136d7`:
- `crates/pod/src/in_flight.rs`
- `crates/pod/src/ipc/alerter.rs`
- `crates/pod/src/ipc/server.rs`
- `crates/pod/src/pod.rs`
- `crates/protocol/src/lib.rs`
- Overall scope/diff `155e039e..061136d7` against Ticket authority:
- `.yoi/tickets/00001KVMT2J25/item.md`
- `.yoi/tickets/00001KVMT2J25/thread.md`
Blocking issues:
- None。
Non-blocking concerns:
- None requiring changes. Integrated regression tests cover the r1 commit-boundary race and duplicate case。
Reviewer validation:
- `git diff --check 155e039e..HEAD`: passed
- `cargo fmt --all --check`: passed
- `cargo test -p protocol`: passed, 40 tests
- `cargo test -p pod --lib in_flight`: passed, 5 selected tests
- `cargo test -p pod session_log_and_in_flight_snapshot_prevents_mirror_only_assistant_gap`: passed
- `cargo test -p pod committed_assistant_snapshot_does_not_duplicate_in_flight_block`: passed
- `cargo test -p tui snapshot_in_flight_blocks_continue_with_live_deltas`: passed
- `cargo test -p tui`: passed, 367 tests
- `cargo check -p protocol -p pod -p tui`: passed
- `target/debug/yoi ticket doctor`: passed (`doctor: ok`)
- `cargo test -p pod`: failed only on the known unrelated prompt-resource assertions; summary 424 passed, 2 failed。
Worktree status:
- Clean on `impl/00001KVMT2J25-inflight-snapshot` at `061136d7`
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T11:59:39Z -->
## Implementation report
Reviewer r2 approvalを受けて、実装ブランチを Orchestrator worktree の `orchestration` branch に統合した。
統合:
- Merge commit: `b21638f5 merge: inflight reconnect snapshot`
- Merged branch: `impl/00001KVMT2J25-inflight-snapshot`
- Implementation commits:
- `74aca6f6 fix: snapshot in-flight stream state`
- `061136d7 fix: close in-flight snapshot commit race`
Orchestrator worktree validation:
- `cargo fmt --all --check`: passed
- `git diff --check HEAD^1..HEAD`: passed
- `cargo test -p protocol`: passed (`40 passed`)
- `cargo test -p pod --lib in_flight`: passed (`5 selected tests`)
- `cargo test -p pod session_log_and_in_flight_snapshot_prevents_mirror_only_assistant_gap`: passed
- `cargo test -p pod committed_assistant_snapshot_does_not_duplicate_in_flight_block`: passed
- `cargo test -p tui snapshot_in_flight_blocks_continue_with_live_deltas`: passed
- `cargo test -p tui`: passed (`367 passed`)
- `cargo check -p protocol -p pod -p tui`: passed
- `cargo run -p yoi -- ticket doctor`: passed (`doctor: ok`)
Known unrelated validation note:
- Full `cargo test -p pod` was not repeated in Orchestrator after merge because Reviewer confirmed it still fails only on pre-existing prompt-resource assertions unrelated to this diff。
Final Orchestrator worktree status after validation: clean on `orchestration` at `b21638f5`
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-21T11:59:47Z from: inprogress to: done reason: implementation_merged_and_validated field: state -->
## State changed
Reviewer approval、Orchestrator worktree への統合、protocol/pod/TUI focused tests、cargo check、Ticket doctor が完了したため `done` に遷移する。
---
<!-- event: state_changed author: hare at: 2026-06-21T12:00:02Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-21T12:00:02Z status: closed -->
## 完了
In-flight LLM response 中の reconnect / late attach snapshot に unfinished blocks を含める protocol/pod/TUI 実装を統合した。
主な成果:
- `Event::Snapshot` に typed `InFlightSnapshot` / `InFlightBlock` を追加。
- Pod 側に assistant text / thinking / tool-call args の in-flight accumulator を追加。
- Streaming callbacks が accumulator 更新と live delta broadcast を同じ stream path で行うようにした。
- Connect-time snapshot が in-flight stream state を含むようにした。
- Session-log mirror snapshot と in-flight snapshot、および finalized `AssistantItem` publish/clear の critical section を揃え、mirror-only assistant commit が snapshot/live boundary で消える race を防止した。
- Finalized assistant item が committed snapshot に含まれる場合は matching in-flight state を clear して duplicate を防ぐ。
- TUI snapshot restore が unfinished text/thinking/tool-call args blocks を seed し、後続 live deltas が同じ logical block に continuation されるようにした。
- Serialization/default compatibility、snapshot/live no-gap/no-duplicate、TUI continuation の focused regression tests を追加。
統合・検証:
- Merge commit: `b21638f5 merge: inflight reconnect snapshot`
- Implementation commits: `74aca6f6`, `061136d7`
- Reviewer final verdict: approve
- Validation passed: `cargo fmt --all --check`, `git diff --check HEAD^1..HEAD`, `cargo test -p protocol`, `cargo test -p pod --lib in_flight`, `cargo test -p pod session_log_and_in_flight_snapshot_prevents_mirror_only_assistant_gap`, `cargo test -p pod committed_assistant_snapshot_does_not_duplicate_in_flight_block`, `cargo test -p tui snapshot_in_flight_blocks_continue_with_live_deltas`, `cargo test -p tui`, `cargo check -p protocol -p pod -p tui`, and `cargo run -p yoi -- ticket doctor`
既知の無関係事項:
- Full `cargo test -p pod` は既存の prompt-resource assertion 2 件で失敗することが reviewer により確認済み。この Ticket の差分とは無関係。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T12:00:31Z -->
## Implementation report
Post-close cleanup completed。
- Stopped child Pods and reclaimed scope:
- `yoi-coder-00001KVMT2J25`
- `yoi-reviewer-00001KVMT2J25-r1`
- Removed implementation worktree:
- `/home/hare/Projects/yoi/.worktree/00001KVMT2J25-inflight-snapshot`
- Deleted implementation branch:
- `impl/00001KVMT2J25-inflight-snapshot`
- Orchestrator worktree remains clean on `orchestration` at `77b5276f`
Root/original workspace was not used for merge/validation/cleanup operations。
---

View File

@ -0,0 +1,40 @@
---
title: 'Workspace server binary and CLI launcher'
state: 'closed'
created_at: '2026-06-21T10:15:30Z'
updated_at: '2026-06-21T10:41:20Z'
assignee: null
queued_by: 'yoi ticket'
queued_at: '2026-06-21T10:16:04Z'
---
## 背景
Workspace web control plane bootstrap により `crates/workspace-server` は library crate として HTTP router / `serve(...)` / SQLite store を提供している。一方で、現状は product CLI から直接起動できず、手元で試すには別の harness が必要になる。
方針として、workspace server は `yoi` binary にリンクして内包しない。`crates/workspace-server` 側に独立 binary entrypoint を置き、`yoi workspace serve` は外部 `yoi-workspace-server` executable を解決して exec/spawn する薄い launcher にする。
## 要件
- `crates/workspace-server/src/main.rs` を追加し、独立 binary `yoi-workspace-server` として起動できるようにする。
- server binary は少なくとも `serve` subcommand を持つ。
- server binary options:
- `--workspace <PATH>` / `--workspace=<PATH>`: default cwd。
- `--db <PATH>` / `--db=<PATH>`: default `<workspace>/.yoi/workspace.db`
- `--frontend <PATH>` / `--frontend=<PATH>`: optional static SPA build dir。
- `--listen <ADDR>` / `--listen=<ADDR>`: default `127.0.0.1:8787`
- `--help` / `-h`
- `yoi workspace serve ...` を追加する。
- `yoi` crate は `yoi-workspace-server` crate に依存しない。
- launcher は `YOI_WORKSPACE_SERVER_COMMAND` override または current exe と同じ directory の `yoi-workspace-server` を解決する。
- launcher は引数を外部 server binary に渡し、終了 status を反映する。
- package build では `yoi``yoi-workspace-server` を別 binary として build/install する。
- help に `yoi workspace serve` を表示する。
## 受け入れ条件
- `cargo run -p yoi-workspace-server -- serve --workspace . --db .yoi/workspace.db --listen 127.0.0.1:8787` 相当で server が起動できる。
- `yoi workspace serve ...` が外部 `yoi-workspace-server` binary を起動する。
- `crates/yoi``yoi-workspace-server` に依存しない。
- `yoi --help` / `yoi workspace --help` に起動方法が出る。
- `cargo fmt --check`、関連 `cargo test` / `cargo check`、`git diff --check`、`yoi ticket doctor`、`nix build .#yoi --no-link` が通る。

View File

@ -0,0 +1 @@
Added standalone yoi-workspace-server binary entrypoint with serve command and options for workspace/db/frontend/listen. Added yoi workspace serve launcher that resolves an external executable via YOI_WORKSPACE_SERVER_COMMAND or sibling yoi-workspace-server without linking the workspace-server crate into yoi. Updated package.nix to build/install both separate binaries. Validation passed: cargo fmt --check; cargo check -p yoi -p yoi-workspace-server; cargo test -p yoi workspace; cargo test -p yoi-workspace-server; cargo build -p yoi -p yoi-workspace-server; help/launch smoke tests; cargo tree dependency check; git diff --check; yoi ticket doctor; yoi objective doctor; nix build .#yoi --no-link.

View File

@ -0,0 +1,69 @@
<!-- event: create author: "yoi ticket" at: 2026-06-21T10:15:30Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: hare at: 2026-06-21T10:16:04Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-06-21T10:16:04Z from: planning to: ready reason: cli_state field: state -->
## State changed
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-06-21T10:16:04Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `yoi ticket` が queued にしました。
---
<!-- event: state_changed author: "yoi ticket" at: 2026-06-21T10:16:04Z from: queued to: inprogress reason: cli_state field: state -->
## State changed
State changed to `inprogress`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-06-21T10:41:20Z from: inprogress to: done reason: cli_state field: state -->
## State changed
State changed to `done`.
---
<!-- event: state_changed author: hare at: 2026-06-21T10:41:20Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-21T10:41:20Z status: closed -->
## 完了
Added standalone yoi-workspace-server binary entrypoint with serve command and options for workspace/db/frontend/listen. Added yoi workspace serve launcher that resolves an external executable via YOI_WORKSPACE_SERVER_COMMAND or sibling yoi-workspace-server without linking the workspace-server crate into yoi. Updated package.nix to build/install both separate binaries. Validation passed: cargo fmt --check; cargo check -p yoi -p yoi-workspace-server; cargo test -p yoi workspace; cargo test -p yoi-workspace-server; cargo build -p yoi -p yoi-workspace-server; help/launch smoke tests; cargo tree dependency check; git diff --check; yoi ticket doctor; yoi objective doctor; nix build .#yoi --no-link.
---

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260621-105758-1","ticket_id":"00001KVMV03QY","kind":"accepted_plan","accepted_plan":{"summary":"Migrate `web/workspace` SvelteKit static SPA frontend tooling from npm/Node-primary to Deno-primary, including Deno config/tasks/lockfile, README/source-of-truth cleanup, generated artifact ignore/source-filter checks, and Deno check/build validation without changing backend authority.","branch":"impl/00001KVMV03QY-workspace-spa-deno","worktree":"/home/hare/Projects/yoi/.worktree/00001KVMV03QY-workspace-spa-deno","role_plan":"Orchestrator creates a dedicated child worktree and spawns a narrow-scope Coder. Reviewer will be spawned read-only after Coder reports implementation commit(s). After approval, Orchestrator integrates into `orchestration`, validates Deno/frontend/package hygiene, records closure, and cleans only the child worktree/branch."},"author":"yoi-orchestrator","at":"2026-06-21T10:57:58Z"}

View File

@ -0,0 +1,129 @@
---
title: 'Workspace web SPA の frontend tooling を npm/Node から Deno に移行する'
state: 'closed'
created_at: '2026-06-21T10:18:10Z'
updated_at: '2026-06-21T11:14:27Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['frontend-tooling', 'packaging', 'nix-source-filter', 'validation']
queued_by: 'workspace-panel'
queued_at: '2026-06-21T10:56:31Z'
---
## User claims / request snapshot
- ユーザーは「今あるフロントSPAを node から deno にしたい」と述べた。
- 参考 URL として `https://hareworks.net/blog/tech/sveltekit-with-deno` が提示された。
- この依頼は、Panel から planning に戻された `00001KVMT2J25` の requirements sync 中に追加された。
## Confirmed facts / sources
- `00001KVMT2J25` は protocol / pod / TUI reconnect / stream-state の Ticket であり、frontend tooling 移行とは別目的である。
- `00001KVMT2J25` の thread に、今回の refinement は別 follow-up Ticket として扱うべきことを Intake comment として記録済み。
- Closed Ticket `00001KVMFFYVX` は Workspace web control plane bootstrap で、SvelteKit static SPA skeleton を `web/workspace` に追加した。
- `00001KVMFFYVX` の resolution は、frontend が npm + committed `package-lock.json` を使い、generated `node_modules/`, `.svelte-kit/`, `build/` を ignore/source-filter する方針だったことを記録している。
- 現在の `web/workspace/package.json` は npm scripts と SvelteKit/Vite devDependencies を持つ。
- 現在の `web/workspace/package-lock.json` は npm lockfile として存在する。
- 現在の `web/workspace/README.md` は package manager を npm とし、`npm install`, `npm run check`, `npm run build` を案内している。
- 現在の `web/workspace/svelte.config.js``@sveltejs/adapter-static` を使い、`web/workspace/build/` に static output を出す。
- 現在の `package.nix``web/workspace/node_modules`, `web/workspace/.svelte-kit`, `web/workspace/build` を source filter から除外している。
- 現在の `devshell.nix` には `deno` が含まれている。
- 参考 URL の内容は untrusted web content として確認した。記事は SvelteKit を Deno task / npm compatibility / `deno.json` ベースで運用する例、Svelte LSP 用 `tsconfig.json` が残りうる点、Deno 用 adapter への言及を含む。
## Unverified hypotheses
- `web/workspace` は static SPA skeleton なので、Deno 移行は backend/API authority を変えず frontend tooling の範囲に収められる可能性が高い。
- SvelteKit/Vite/svelte-check は Deno の npm compatibility と `deno task` で動かせる可能性が高い。
- Svelte LSP / svelte-check のために `tsconfig.json` を完全削除できない可能性がある。
- Deno 移行後も `node_modules` 相当の local generated state が発生する可能性があり、ignore/source-filter の再確認が必要。
## Undecided points / open questions
- blocking な未決定点はなし。
- `package.json` を完全に削除できるか、SvelteKit/Vite ecosystem 互換のため最小限残すかは実装調査で判断してよい。ただし source-of-truth と command guidance を曖昧にしない。
- `tsconfig.json` を維持するか `deno.json` へ寄せるかは、Svelte LSP / svelte-check の実動作に基づいて判断してよい。
- `@sveltejs/adapter-static` を維持するか Deno 向け adapter へ変えるかは、Rust backend が static assets を serve する現方針と矛盾しない範囲で判断してよい。
## Background
Workspace web control plane の frontend は `web/workspace` にある SvelteKit static SPA skeleton である。bootstrap 時点では npm + `package-lock.json` を採用したが、ユーザーは既存 frontend SPA の tooling を Node/npm 前提から Deno 前提へ移行したい。
この Ticket は frontend tooling / package-manager migration の concrete follow-up であり、protocol reconnect Ticket `00001KVMT2J25` とは別 work item として扱う。
## Requirements
- `web/workspace` の開発・検証・build 手順を npm/Node 前提から Deno 前提へ移行する。
- Deno 側の project configuration を明確にする。候補は `deno.json` または `deno.jsonc`、`deno.lock`、`deno task`。
- SvelteKit static SPA であることを維持する。
- Rust backend が business/API authority を持ち、frontend は lifecycle/business authority を持たない境界を維持する。
- static build output が backend から serve できる形を維持する。既存の `web/workspace/build/` を変える場合は backend/README/package hygiene と整合させる。
- `web/workspace/README.md` の package manager / command guidance を Deno 前提に更新する。
- lockfile と依存関係管理を Deno 前提で明確にする。
- 古い npm lockfile / npm scripts / Node-only guidance が不要なら削除し、残す必要がある場合は理由を project files または docs に明記する。
- generated artifacts (`node_modules`, `.svelte-kit`, `build` など) が git / Nix package source に混入しない状態を維持する。
- `package.nix` source filtering と devshell/package guidance が Deno 移行後の frontend generated state と矛盾しないことを確認する。
## Acceptance criteria
- `web/workspace` の check が Deno command で成功する。
- `web/workspace` の build が Deno command で成功する。
- README と project files から、通常の frontend 作業者が Deno で install/check/build できることが分かる。
- npm lockfile / npm scripts / Node-only guidance が不要なら削除されている。
- npm/Node 関連ファイルを残す場合、そのファイルが canonical source-of-truth なのか compatibility artifact なのかが明確である。
- SvelteKit static SPA と Rust backend static serving の前提が壊れていない。
- `web/workspace/build/`, `.svelte-kit/`, `node_modules` または Deno 移行後の同等 generated artifacts が git/Nix package source に混入しない。
- 既存の `.yoi` records as authority、Ticket/Objective workflow、Rust backend API authority を変更しない。
## Binding decisions / invariants
- この Ticket は frontend tooling migration であり、Workspace backend の API authority / Ticket lifecycle authority / `.yoi` canonical records を変更しない。
- Frontend を SSR authority や business/lifecycle authority にしない。
- Static SPA と Rust backend serving の境界を維持する。
- `00001KVMT2J25` は protocol reconnect Ticket として残し、本 Ticket に混ぜない。
- Tooling source-of-truth を npm と Deno の二重管理で曖昧にしない。
- Generated artifacts を committed source や Nix package source に混入させない。
## Implementation latitude
- Deno project layout は `deno.json` / `deno.lock` / `deno task` を使う形でよい。
- SvelteKit/Vite を Deno の npm compatibility で動かすか、最小限の package metadata を残すかは実装時に選んでよい。
- `tsconfig.json` は Svelte LSP / svelte-check の都合で必要なら残してよい。
- `@sveltejs/adapter-static` を維持してよい。Deno adapter を採用する場合は、この workspace の Rust backend static serving 方針と矛盾しないことを確認する。
- `package.nix` / devshell / README の更新範囲は、Deno 移行後の generated artifact と validation command に合わせて最小限でよい。
## Readiness
- readiness: implementation_ready
- risk_flags: [frontend-tooling, packaging, nix-source-filter, validation]
## Escalation conditions
- Deno だけでは SvelteKit check/build が安定せず、Node/npm を primary に残す必要が出た場合。
- `package.json` / `package-lock.json` を残すか削除するかが project policy decision になる場合。
- Static SPA ではなく SSR / Deno runtime server / Deno Deploy 前提へ移る必要が出た場合。
- Rust backend static serving path や package/Nix build 方針の大きな変更が必要になった場合。
- Generated artifacts の source-filter / ignore 境界が不明確になる場合。
## Validation
- `cd web/workspace && deno task check`
- `cd web/workspace && deno task build`
- `git diff --check`
- frontend generated artifacts が ignored / source-filtered されていることの確認。
- packaging/source-filter を変更する場合は `nix build .#yoi --no-link` または Orchestrator が妥当と判断する同等確認。
- 必要に応じて `cargo check -p yoi-workspace-server` または static serving 周辺の focused tests。
## Related work
- `00001KVMT2J25` — protocol reconnect / in-flight stream snapshot Ticket。今回の Deno 移行とは別件。
- `00001KVMFFYVX` — Workspace web control plane bootstrap。`web/workspace` の SvelteKit static SPA skeleton と npm/package-lock 採用元。
- Relevant files:
- `web/workspace/package.json`
- `web/workspace/package-lock.json`
- `web/workspace/README.md`
- `web/workspace/svelte.config.js`
- `web/workspace/vite.config.ts`
- `web/workspace/tsconfig.json`
- `web/workspace/.gitignore`
- `package.nix`
- `devshell.nix`

View File

@ -0,0 +1,22 @@
Workspace web SPA の frontend tooling を npm/Node-primary から Deno-primary に移行し、Orchestrator worktree の `orchestration` branch に統合した。
主な成果:
- `web/workspace/deno.json` を追加し、Deno task を canonical workflow にした。
- `web/workspace/deno.lock` を追加。
- `web/workspace/package-lock.json` を削除。
- `web/workspace/package.json` は SvelteKit/Vite ecosystem compatibility metadata のみに縮小し、scripts/dependencies を削除。
- README を Deno workflow (`deno install`, `deno task check`, `deno task build`, `deno task preview`) と source-of-truth 説明に更新。
- Static SPA output path `web/workspace/build/``@sveltejs/adapter-static` assumptions を維持。
- Rust backend/static serving code、Workspace API authority、Ticket/Objectives authority、`.yoi` canonical record workflows には変更を加えていない。
- Generated artifacts (`node_modules`, `.svelte-kit`, `build`) は ignored/source-filtered のまま。
統合・検証:
- Merge commit: `6dc78e3f merge: workspace spa deno tooling`
- Implementation commit: `66f04e04 feat: migrate workspace spa tooling to deno`
- Reviewer final verdict: approve
- Validation passed: `git diff --check HEAD^1..HEAD`, `deno task check`, `deno task build`, `deno task install`, `cargo check -p yoi-workspace-server`, `cargo run -p yoi -- ticket doctor`, and `nix build .#yoi --no-link`
範囲外:
- Backend API / static serving implementation は変更していない。
- Protocol reconnect work (`00001KVMT2J25`) には触れていない。
- SSR / Deno runtime server / Deno Deploy assumptions は追加していない。

View File

@ -0,0 +1,379 @@
<!-- event: create author: ticket-intake at: 2026-06-21T10:18:10Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: state_changed author: workspace-panel at: 2026-06-21T10:56:31Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-21T10:57:43Z -->
## Decision
Routing decision: `implementation_ready`
Reason:
- Ticket body は `web/workspace` の frontend tooling を npm/Node から Deno に移行する concrete follow-up として、requirements / acceptance criteria / validation が具体化されている。
- `readiness: implementation_ready` で、relations / orchestration plan に blocker はない。
- `00001KVMFFYVX` Workspace web control plane bootstrap は closed 済みで、移行対象の `web/workspace` が現在の `orchestration` に存在する。
- `00001KVMT2J25` は protocol reconnect Ticket として別件であり、この Ticket の scope には混ぜないことが body に明記されている。
- 同時 queued の `00001KVMT2J25` は protocol/pod/TUI stream-state work で、主対象が異なるため並列実装可能と判断する。
- Orchestrator worktree は clean on `orchestration` at `b4786b40` で、対象 Ticket 用 worktree / branch は未作成。
Evidence checked:
- Ticket body / thread / artifacts via `TicketShow` and direct `item.md` read。
- `TicketRelationQuery(00001KVMV03QY)`: no relations / blockers。
- `TicketOrchestrationPlanQuery(00001KVMV03QY)`: no records。
- Orchestrator git state / worktree list / branch list checked from `/home/hare/Projects/yoi/.worktree/orchestration` only。
- Bounded code map:
- `web/workspace/package.json`, `package-lock.json`, `README.md`, `svelte.config.js`, `vite.config.ts`, `tsconfig.json`, `.gitignore` are current frontend tooling files。
- `package.nix` currently excludes `web/workspace/node_modules`, `.svelte-kit`, and `build`
- `devshell.nix` already includes `deno`
IntentPacket:
Intent:
- Move Workspace web SPA frontend tooling from npm/Node-primary to Deno-primary while keeping SvelteKit static SPA and Rust backend authority unchanged。
Binding decisions / invariants:
- This is frontend tooling migration only; do not change Workspace backend API authority, Ticket/Objectives authority, or `.yoi` canonical record workflows。
- Keep static SPA and Rust backend static-serving boundary。
- Do not turn frontend into SSR/business/lifecycle authority。
- Avoid ambiguous dual source-of-truth between npm and Deno。
- Generated artifacts must remain out of git and Nix package source。
- `00001KVMT2J25` protocol reconnect work remains separate。
Requirements / acceptance criteria:
- `web/workspace` check/build succeeds through Deno commands。
- Add/clarify Deno project config such as `deno.json`/`deno.lock`/`deno task`。
- README and project files make normal Deno install/check/build workflow clear。
- Remove npm lockfile/scripts/Node-only guidance if unnecessary; if any npm/Node metadata remains, document whether it is compatibility artifact or canonical source-of-truth。
- Preserve static output usable by Rust backend, default `web/workspace/build/` unless deliberately changed and documented。
- Keep `web/workspace/build/`, `.svelte-kit/`, `node_modules` or Deno equivalents ignored/source-filtered。
- Update `package.nix` / `devshell` / README only as needed for generated artifacts and validation commands。
Implementation latitude:
- Use Deno npm compatibility for SvelteKit/Vite/svelte-check if practical。
- Keep `tsconfig.json` if Svelte LSP / svelte-check needs it。
- Keep `@sveltejs/adapter-static` if static backend serving remains compatible。
- Minimal `package.json` can remain if ecosystem requires it, but source-of-truth must be clear。
Escalate if:
- Deno-only check/build is unstable and Node/npm must remain primary。
- Keeping/removing `package.json` or `package-lock.json` becomes a policy decision rather than implementation detail。
- Migration requires SSR/Deno runtime server/Deno Deploy assumptions。
- Rust backend static serving path or Nix package strategy requires major redesign。
- Generated artifact boundaries become unclear。
Validation plan:
- `cd web/workspace && deno task check`
- `cd web/workspace && deno task build`
- `git diff --check`
- generated artifact ignore/source-filter check。
- `nix build .#yoi --no-link` if package/source-filter changes。
- `cargo check -p yoi-workspace-server` or focused server/static tests if static serving assumptions change。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-21T10:57:48Z from: queued to: inprogress reason: human_authorized_unblocked_frontend_tooling_migration field: state -->
## State changed
Human authorized queue routing from Workspace Dashboard. Ticket has concrete acceptance criteria, no recorded blockers, and is semantically separate from the protocol reconnect Ticket, so Orchestrator accepts implementation.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T11:00:00Z -->
## Implementation report
Implementation start report:
- Created child implementation worktree:
- `/home/hare/Projects/yoi/.worktree/00001KVMV03QY-workspace-spa-deno`
- Created branch:
- `impl/00001KVMV03QY-workspace-spa-deno`
- Base commit:
- `155e039e ticket: route deno and inflight snapshot work`
- 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-00001KVMV03QY`
- Coder scope:
- read: `/home/hare/Projects/yoi`
- write: `/home/hare/Projects/yoi/.worktree/00001KVMV03QY-workspace-spa-deno`
Parallelization note:
- `00001KVMT2J25` is active in a separate child worktree and targets protocol/pod/TUI stream state. This Ticket should stay limited to `web/workspace` frontend tooling and package/source-filter docs as needed。
Next action:
- Wait for Coder implementation report, then spawn Reviewer read-only for the implementation diff。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T11:05:28Z -->
## Implementation report
Coder implementation report received from `yoi-coder-00001KVMV03QY`.
Implementation commit:
- `66f04e04 feat: migrate workspace spa tooling to deno`
Changed areas:
- Added Deno-primary frontend tooling:
- `web/workspace/deno.json`
- `web/workspace/deno.lock`
- Removed npm lockfile:
- deleted `web/workspace/package-lock.json`
- Reduced `web/workspace/package.json` to minimal SvelteKit/Vite ecosystem metadata only:
- no scripts
- no dependencies
- `deno.json` / `deno.lock` are canonical source of truth
- Updated `web/workspace/README.md` with Deno workflow:
- `deno install`
- `deno task check`
- `deno task build`
- npm compatibility usage and generated artifact boundaries
- Preserved static SPA output path:
- `web/workspace/build/`
Coder validation reported:
- `cd web/workspace && deno task check`: passed
- `cd web/workspace && deno task build`: passed
- `cd web/workspace && deno task install`: passed
- `git diff --check`: passed
- `target/debug/yoi ticket doctor`: passed (`doctor: ok`)
- Generated artifact ignore/source-filter check:
- `web/workspace/node_modules`, `.svelte-kit`, and `build` are ignored by `web/workspace/.gitignore`
- existing `package.nix` source filter already excludes those same trees
Not run by Coder:
- `nix build .#yoi --no-link`: not run because `package.nix` / source filter were not changed。
- `cargo check -p yoi-workspace-server`: not run because Rust backend/static-serving assumptions and code were not changed。
Generated artifacts cleanup:
- Removed `web/workspace/node_modules/`, `.svelte-kit/`, and `build/` after validation。
Orchestrator evidence checked:
- Implementation worktree is clean on `impl/00001KVMV03QY-workspace-spa-deno`
- HEAD is `66f04e04`
- Diff `155e039e..HEAD` touches expected frontend tooling files only: `README.md`, `deno.json`, `deno.lock`, `package.json`, and deleted `package-lock.json`
- `git diff --check 155e039e..HEAD` produced no diagnostics。
- `git status --ignored --short web/workspace` showed no remaining generated frontend artifacts。
Next action:
- Route to read-only Reviewer Pod for acceptance review against the Ticket。
---
<!-- event: comment author: yoi-orchestrator at: 2026-06-21T11:06:03Z -->
## Comment
Review routing:
Spawned read-only Reviewer Pod:
- `yoi-reviewer-00001KVMV03QY-r1`
Review target:
- Worktree: `/home/hare/Projects/yoi/.worktree/00001KVMV03QY-workspace-spa-deno`
- Branch: `impl/00001KVMV03QY-workspace-spa-deno`
- Baseline: `155e039e`
- Implementation commit: `66f04e04 feat: migrate workspace spa tooling to deno`
- Diff: `155e039e..66f04e04`
Reviewer scope:
- read-only `/home/hare/Projects/yoi`
Reviewer focus:
- Deno task check/build works from clean generated-artifact state。
- `deno.json` / `deno.lock` are canonical source of truth。
- `package.json` remains only compatibility metadata if retained; no npm scripts / Node-only guidance remains active。
- Static output path and Rust backend serving assumptions remain valid。
- Generated artifacts remain ignored/source-filtered and are not committed。
- No protocol/backend authority scope creep。
Orchestrator will wait for reviewer verdict before integration。
---
<!-- event: review author: yoi-reviewer-00001KVMV03QY-r1 at: 2026-06-21T11:12:52Z status: approve -->
## Review: approve
Verdict: `approve`
確認範囲:
- Ticket authority:
- `.yoi/tickets/00001KVMV03QY/item.md`
- `.yoi/tickets/00001KVMV03QY/thread.md`
- Diff `155e039e..66f04e04`:
- `web/workspace/README.md`
- `web/workspace/deno.json`
- `web/workspace/deno.lock`
- `web/workspace/package.json`
- deleted `web/workspace/package-lock.json`
- Boundary / generated artifact files:
- `web/workspace/.gitignore`
- `web/workspace/svelte.config.js`
- `web/workspace/vite.config.ts`
- `web/workspace/tsconfig.json`
- `web/workspace/src/routes/+layout.ts`
- `package.nix`
- `devshell.nix`
Blocking issues:
- None。
Findings:
- Deno is now the clear frontend tooling source of truth:
- `web/workspace/deno.json` defines Deno tasks and pinned npm-compat dependencies。
- `web/workspace/deno.lock` is committed。
- `package-lock.json` is removed。
- `package.json` is reduced to package metadata only, with no scripts or dependencies。
- README explicitly says canonical tooling is `deno.json` / `deno.lock`, and `package.json` is compatibility metadata only。
- Static SPA assumptions are preserved:
- `svelte.config.js` still uses `@sveltejs/adapter-static`
- output remains `web/workspace/build/`
- `+layout.ts` still has `ssr = false` and `prerender = true`
- diff does not touch Rust backend/API authority, protocol reconnect scope, or `.yoi` canonical records。
- Generated artifact boundaries remain intact:
- `.gitignore` ignores `node_modules`, `.svelte-kit`, and `build`
- `package.nix` still excludes `web/workspace/node_modules`, `web/workspace/.svelte-kit`, and `web/workspace/build`
- `devshell.nix` already provides `deno`
Non-blocking concern:
- `deno task build` succeeds but upstream SvelteKit/Vite emits generic `Run npm run preview to preview your production build locally.` The project README and `deno.json` correctly provide `deno task preview`, and there are no npm scripts in the repo, so this is not blocking。
Reviewer validation:
- `git diff --check 155e039e..66f04e04`: passed
- `cd web/workspace && deno --version && rm -rf node_modules .svelte-kit build && deno task check && deno task build`: passed with Deno `2.7.14`; `svelte-check found 0 errors and 0 warnings`; build completed to `build`
- `cd web/workspace && deno task install`: passed
- `target/debug/yoi ticket doctor`: passed (`doctor: ok`)
- `nix build .#yoi --no-link`: passed
- `git show --no-patch --format='%H %s' 66f04e04` and `git diff --quiet 66f04e04..HEAD`: HEAD matches implementation commit
Generated artifacts:
- Reviewer did not clean generated ignored artifacts after validation。
- Ignored artifacts present in implementation worktree:
- `web/workspace/node_modules/`
- `web/workspace/.svelte-kit/`
- `web/workspace/build/`
- `git check-ignore -v` confirms all three are ignored by `web/workspace/.gitignore`; `package.nix` excludes same paths。
Worktree status:
- Tracked status clean at `66f04e04` on `impl/00001KVMV03QY-workspace-spa-deno`
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T11:13:42Z -->
## Implementation report
Reviewer r1 approvalを受けて、実装ブランチを Orchestrator worktree の `orchestration` branch に統合した。
統合:
- Merge commit: `6dc78e3f merge: workspace spa deno tooling`
- Merged branch: `impl/00001KVMV03QY-workspace-spa-deno`
- Implementation commit:
- `66f04e04 feat: migrate workspace spa tooling to deno`
Orchestrator worktree validation:
- `git diff --check HEAD^1..HEAD`: passed
- `cd web/workspace && rm -rf node_modules .svelte-kit build && deno task check`: passed (`svelte-check found 0 errors and 0 warnings`)
- `cd web/workspace && deno task build`: passed and wrote static site to `build/`
- `cd web/workspace && deno task install`: passed
- `cargo check -p yoi-workspace-server`: passed
- `cargo run -p yoi -- ticket 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 `6dc78e3f`
Non-blocking note:
- Upstream SvelteKit/Vite still prints generic `Run npm run preview...` during build; project README and `deno.json` provide `deno task preview`, and no npm scripts remain, so this is accepted as upstream output rather than project guidance。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-21T11:13:48Z from: inprogress to: done reason: implementation_merged_and_validated field: state -->
## State changed
Reviewer approval、Orchestrator worktree への統合、Deno check/build/install、workspace-server check、Ticket doctor、Nix build が完了したため `done` に遷移する。
---
<!-- event: state_changed author: hare at: 2026-06-21T11:14:00Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-21T11:14:00Z status: closed -->
## 完了
Workspace web SPA の frontend tooling を npm/Node-primary から Deno-primary に移行し、Orchestrator worktree の `orchestration` branch に統合した。
主な成果:
- `web/workspace/deno.json` を追加し、Deno task を canonical workflow にした。
- `web/workspace/deno.lock` を追加。
- `web/workspace/package-lock.json` を削除。
- `web/workspace/package.json` は SvelteKit/Vite ecosystem compatibility metadata のみに縮小し、scripts/dependencies を削除。
- README を Deno workflow (`deno install`, `deno task check`, `deno task build`, `deno task preview`) と source-of-truth 説明に更新。
- Static SPA output path `web/workspace/build/``@sveltejs/adapter-static` assumptions を維持。
- Rust backend/static serving code、Workspace API authority、Ticket/Objectives authority、`.yoi` canonical record workflows には変更を加えていない。
- Generated artifacts (`node_modules`, `.svelte-kit`, `build`) は ignored/source-filtered のまま。
統合・検証:
- Merge commit: `6dc78e3f merge: workspace spa deno tooling`
- Implementation commit: `66f04e04 feat: migrate workspace spa tooling to deno`
- Reviewer final verdict: approve
- Validation passed: `git diff --check HEAD^1..HEAD`, `deno task check`, `deno task build`, `deno task install`, `cargo check -p yoi-workspace-server`, `cargo run -p yoi -- ticket doctor`, and `nix build .#yoi --no-link`
範囲外:
- Backend API / static serving implementation は変更していない。
- Protocol reconnect work (`00001KVMT2J25`) には触れていない。
- SSR / Deno runtime server / Deno Deploy assumptions は追加していない。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T11:14:27Z -->
## Implementation report
Post-close cleanup completed。
- Stopped child Pods and reclaimed scope:
- `yoi-coder-00001KVMV03QY`
- `yoi-reviewer-00001KVMV03QY-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/00001KVMV03QY-workspace-spa-deno`
- Deleted implementation branch:
- `impl/00001KVMV03QY-workspace-spa-deno`
- Orchestrator worktree remains clean on `orchestration` at `54d325ae`
Root/original workspace was not used for merge/validation/cleanup operations。
---

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260621-161049-1","ticket_id":"00001KVNEKH9Q","kind":"accepted_plan","accepted_plan":{"summary":"Add read-only Workspace backend Host / Worker APIs over local Pod metadata/state, migrate/remove runners placeholder as needed, display Host/Worker list in static SPA, preserve Pod as implementation detail and avoid transcript/secret exposure.","branch":"impl/00001KVNEKH9Q-workspace-host-workers","worktree":"/home/hare/Projects/yoi/.worktree/00001KVNEKH9Q-workspace-host-workers","role_plan":"Orchestrator creates a dedicated child worktree and spawns a narrow-scope Coder. Reviewer will be spawned read-only after Coder reports implementation commit(s). After approval, Orchestrator integrates into `orchestration`, validates workspace-server/frontend/Nix as needed, records closure, and cleans only the child worktree/branch."},"author":"yoi-orchestrator","at":"2026-06-21T16:10:49Z"}

View File

@ -0,0 +1,13 @@
{
"version": 1,
"relations": [
{
"ticket_id": "00001KVNEKH9Q",
"kind": "related",
"target": "00001KVMFFYVX",
"note": "Extends workspace web control plane bootstrap with local host/worker inspection",
"author": "yoi ticket",
"at": "2026-06-21T16:01:33Z"
}
]
}

View File

@ -0,0 +1,69 @@
---
title: 'Workspace backend: expose local host and worker list'
state: 'closed'
created_at: '2026-06-21T16:00:49Z'
updated_at: '2026-06-21T16:39:43Z'
assignee: null
queued_by: 'workspace-panel'
queued_at: '2026-06-21T16:09:10Z'
---
## 背景
Workspace web control plane は read-only API / static SPA / SQLite skeleton まで立ち上がっているが、実行環境の表示はまだ `runner` placeholder のままで、実際に稼働中の local Pod / session を表示できない。
当面の設計語は `Host` / `Worker` とする。
- Host: 実行環境。最初の実装では workspace backend が動いている local machine を表す。
- Worker: Host 上で動いている agent/runtime session。現行 local runtime では Pod に対応する。
- Pod: 現行 local implementation detail。API では `implementation.kind = "local_pod"` のように表現する。
初期実装では、現行 TUI dashboard / panel が local state を見るのと同じ感覚で、backend process が動いている host 上の local Pod metadata / socket/session state を read-only に検出し、Workspace API から host / worker 一覧を返す。これは hosted/multi-host runner protocol の完成を待たずに、Web UI で現在の local execution state を見られるようにするための bridge である。
## 要件
- Workspace backend API に host / worker list endpoint を追加する。
- 例: `GET /api/hosts`
- 例: `GET /api/hosts/{host_id}/workers` または `GET /api/workers`
- 既存 `runner` placeholder は `host` naming に寄せる。破壊的変更でよい。
- 初期 Host は backend process が動いている local machine として検出する。
- stable-ish `host_id`、label、kind/status、last_seen/observed_at、capability summary を返す。
- capability は詳細実装しすぎず、local pod inspection available / workspace_root / os など bounded summary でよい。
- Worker list は現行 local Pod state から read-only に生成する。
- `~/.yoi/pods/<pod_name>/metadata.json` などの現在の Pod metadata authority を使う。
- active socket/session/runtime hints は利用してよいが、metadata と session logs を壊さない。
- local Pod は Worker の implementation detail として返す。
- API response は Web/control-plane domain を優先する。
- `worker_id`
- `host_id`
- `label` / `pod_name`
- `role` or `profile` if known
- `workspace_root`
- `state` / `status` if known
- `implementation: { kind: "local_pod", pod_name: ... }`
- bounded diagnostics
- Secrets / prompt contents / session transcript contents / hidden metadata は返さない。
- backend が local Pod data dir を読めない場合は fail closed ではなく、empty list + diagnostic または host capability unavailable として返す。
- Web UI skeleton で host / worker list を表示できる範囲までつなぐ。
- これは remote/self-hosted/cloud runner protocol の実装ではない。将来の Host/Worker protocol に置き換えられる local bridge として実装する。
## Non-goals
- Worker start/stop/attach/notify 操作。
- remote runner / cloud runner registration protocol。
- Run と Worker の完全な紐付け。
- Pod metadata schema migration。
- session transcript / model context / tool result content の表示。
- Host resource scheduling / quota / billing。
## 受け入れ条件
- `GET /api/hosts` が backend-local Host を bounded JSON で返す。
- `GET /api/workers` または `GET /api/hosts/{host_id}/workers` が local Pod 由来の Worker 一覧を bounded JSON で返す。
- response naming は `host` / `worker` を使い、`pod` は implementation detail に閉じる。
- 既存 `/api/runners` placeholder は削除または `hosts` へ移行され、frontend / tests も追従している。
- Web UI に Host / Worker 一覧が表示される。
- local Pod metadata が存在しない環境でも API は安全に empty/diagnostic response を返す。
- secrets、session transcript、prompt contents は response に含まれない。
- Focused tests cover host list, worker list, missing local pod data dir, and response bounds/redaction.
- Validation before completion includes `cargo fmt --check`, `cargo test -p yoi-workspace-server`, frontend check/build, `cargo check`, `git diff --check`, `yoi ticket doctor`, and `nix build .#yoi --no-link`.

View File

@ -0,0 +1,22 @@
Workspace backend に local Host / Worker read API を追加し、Web UI に Host / Worker list を表示する変更を統合した。
主な成果:
- Workspace backend に `/api/hosts`, `/api/workers`, `/api/hosts/{host_id}/workers` を追加。
- `/api/runners` placeholder を削除 / Host-Worker naming に移行。
- Local Pod metadata を read-only bridge として Worker domain object に投影。
- Pod は primary domain ではなく `implementation: { kind: "local_pod", pod_name: ... }` として表現。
- Worker response includes bounded `worker_id`, `host_id`, label/pod name, role/profile when known, `workspace_root`, state/status, implementation detail, and diagnostics。
- Missing/unreadable Pod metadata root は process/server failure ではなく empty workers + bounded diagnostics / unavailable capability に degrade。
- Session transcript / tool result / prompt contents / raw session JSONL は読まない。
- Web UI static SPA に Host / Worker list を追加。
- Existing Ticket / Objective canonical workflows remain unchanged。
統合・検証:
- Merge commit: `c884d517 merge: workspace host workers`
- Implementation commit: `58143ead feat: expose workspace hosts and workers`
- 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`, `deno task check`, `deno task build`, `cargo run -p yoi -- ticket doctor`, and `nix build .#yoi --no-link`
範囲外:
- Remote/cloud runner registration, scheduling, start/stop/attach/notify operations, and full run-worker correlation were not implemented。
- Frontend remains static SPA and does not own backend business/lifecycle authority。

View File

@ -0,0 +1,393 @@
<!-- event: create author: "yoi ticket" at: 2026-06-21T16:00:49Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: hare at: 2026-06-21T16:01:33Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-06-21T16:01:33Z from: planning to: ready reason: cli_state field: state -->
## State changed
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: workspace-panel at: 2026-06-21T16:09:10Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-21T16:10:32Z -->
## Decision
Routing decision: `implementation_ready`
Reason:
- Ticket body は Workspace backend に local Host / Worker read API を追加し、Web UI skeleton に表示する範囲まで具体化されている。
- Related Ticket `00001KVMFFYVX` は Workspace web control plane bootstrap で、既に `closed` / integrated。This Ticket はその read-only API / SPA skeleton の自然な follow-up。
- Relation metadata は `related` のみで blocker relation はない。
- Current queued Ticket はこの Ticket のみ。
- Orchestrator worktree is clean on `orchestration` at `7abc3c77` before routing side effects; target worktree / branch is not present。
- Visible Pods に対象 Ticket の child Pod は存在しない。
Evidence checked:
- Ticket body / thread / artifacts via `TicketShow` and direct `item.md` read。
- `TicketRelationQuery(00001KVNEKH9Q)`: one non-blocking `related` relation to closed `00001KVMFFYVX`
- `TicketOrchestrationPlanQuery(00001KVNEKH9Q)`: no records。
- `TicketList(state=queued)`: this Ticket is the only queued Ticket。
- Orchestrator git state / worktree list / branch list checked from `/home/hare/Projects/yoi/.worktree/orchestration` only。
- Bounded code map:
- `crates/workspace-server/src/{server.rs,store.rs,records.rs}` contains current read-only workspace/ticket/objective/runs/runners API skeleton。
- `web/workspace/src/routes/+page.svelte` currently renders workspace/tickets/objectives/runs/runners sections through Deno/SvelteKit frontend。
- `crates/pod-store/src/lib.rs` contains current Pod metadata authority (`{data_dir}/pods/<pod_name>/metadata.json`) and list/read helpers。
- `manifest::paths::data_dir()` resolves local data dir.
IntentPacket:
Intent:
- Extend the Workspace web control plane so it can display the local execution environment as Host / Worker domain objects, with local Pods exposed only as implementation details。
Binding decisions / invariants:
- API naming should prioritize Host / Worker; Pod remains implementation detail under `implementation.kind = "local_pod"` / `pod_name` fields。
- This is read-only local bridge over existing Pod metadata/state; do not mutate Pod metadata, session logs, Tickets, Objectives, or runtime state。
- Do not expose secrets, prompt contents, session transcript contents, tool result contents, or hidden metadata。
- Missing/unreadable local Pod data dir must degrade to empty worker list + bounded diagnostic or host capability unavailable, not a process-fatal error。
- Existing `.yoi` Ticket / Objective canonical workflows remain unchanged。
- This is not remote runner / cloud runner registration protocol, scheduling, start/stop/attach/notify, or full run-worker correlation。
- Existing `/api/runners` placeholder may be removed or migrated to host/worker naming; breaking API change is acceptable。
- Frontend remains static SPA; do not introduce SSR/business authority。
Requirements / acceptance criteria:
- `GET /api/hosts` returns backend-local Host as bounded JSON with stable-ish `host_id`, label, kind/status, observed/last-seen timestamp, and bounded capability summary。
- `GET /api/workers` or `GET /api/hosts/{host_id}/workers` returns Worker list from local Pod metadata/state。
- Worker response uses `worker_id`, `host_id`, `label`/`pod_name`, optional role/profile if known, `workspace_root`, state/status, `implementation: { kind: "local_pod", pod_name: ... }`, and bounded diagnostics。
- Local Pod metadata absence/unreadability yields safe empty/diagnostic response。
- `/api/runners` placeholder is removed or migrated to Host/Worker naming and tests/frontend are updated。
- Web UI displays Host / Worker list。
- Focused tests cover host list, worker list, missing local Pod data dir, and redaction/bounds。
Implementation latitude:
- Reuse `pod-store` crate for metadata listing if practical; otherwise use a narrow read-only adapter with the same authority/path semantics。
- Host id can be deterministic local id derived from workspace/data-dir/machine label if no existing host id exists; keep it stable-ish and non-secret。
- Worker status can be coarse from metadata active flag / socket hint if available; avoid live socket probing unless bounded and read-only。
- Runners placeholder can be removed or preserved only as compatibility alias if clearly documented; Ticket allows breaking migration to host naming。
- Frontend UI can be simple table/cards; prioritize data shape and redaction over visual polish。
Escalate if:
- Pod metadata schema lacks enough information to derive bounded worker fields without reading session transcripts。
- Need to read session JSONL contents to satisfy requested fields。
- Host id generation would require storing new durable host metadata in `.yoi`
- Existing frontend/backend API shape requires a broad redesign beyond local bridge。
- Deno/frontend tooling or package/Nix changes become unrelatedly unstable。
Validation plan:
- `cargo fmt --check`
- `cargo test -p yoi-workspace-server`
- `cargo check -p yoi-workspace-server`
- frontend: `cd web/workspace && deno task check && deno task build`
- `git diff --check`
- `cargo run -p yoi -- ticket doctor`
- `nix build .#yoi --no-link` if package/source-filter/frontend/dependency handling changed; otherwise strongly preferred because Workspace backend/frontend package surface is touched。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-21T16:10:40Z from: queued to: inprogress reason: human_authorized_unblocked_host_worker_api_implementation field: state -->
## State changed
Human authorized queue routing from Workspace Dashboard. Ticket has concrete acceptance criteria, related bootstrap work is complete, and no blockers are recorded, so Orchestrator accepts implementation.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T16:11:47Z -->
## Implementation report
Implementation start report:
- Created child implementation worktree:
- `/home/hare/Projects/yoi/.worktree/00001KVNEKH9Q-workspace-host-workers`
- Created branch:
- `impl/00001KVNEKH9Q-workspace-host-workers`
- Base commit:
- `e1f02ffc ticket: accept workspace host workers`
- 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-00001KVNEKH9Q`
- Coder scope:
- read: `/home/hare/Projects/yoi`
- write: `/home/hare/Projects/yoi/.worktree/00001KVNEKH9Q-workspace-host-workers`
Next action:
- Wait for Coder implementation report, then spawn Reviewer read-only for the implementation diff。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T16:31:48Z -->
## Implementation report
Coder implementation report received from `yoi-coder-00001KVNEKH9Q`.
Implementation commit:
- `58143ead feat: expose workspace hosts and workers`
Changed areas observed by Orchestrator:
- `crates/workspace-server/src/hosts.rs` added:
- Host / Worker API domain models and local Pod metadata bridge。
- Safe degradation for missing/unreadable Pod metadata directory via bounded diagnostics。
- Redaction/bounding tests。
- `crates/workspace-server/src/server.rs` updated:
- Host / Worker API routes added。
- `/api/runners` placeholder removed/migrated away from runner naming。
- Tests updated for host/worker API and static/API separation。
- `crates/workspace-server/src/store.rs` updated:
- Runners placeholder removed。
- `web/workspace/src/routes/+page.svelte` updated:
- UI displays Host / Worker list instead of runners placeholder。
- Package/dependency updates:
- `crates/workspace-server/Cargo.toml`
- `Cargo.lock`
- `package.nix`
Orchestrator evidence checked:
- Implementation worktree clean on `impl/00001KVNEKH9Q-workspace-host-workers`
- HEAD is `58143ead`
- Diff `e1f02ffc..HEAD` touches 8 expected backend/frontend/package files, about 913 insertions / 75 deletions。
- `git diff --check e1f02ffc..HEAD` produced no diagnostics。
- Validation had produced ignored frontend artifacts in child worktree:
- `web/workspace/node_modules/`
- `web/workspace/.svelte-kit/`
- `web/workspace/build/`
- Orchestrator removed those ignored artifacts before reviewer routing; tracked worktree remains clean。
Next action:
- Route to read-only Reviewer Pod for acceptance review against the Ticket。
---
<!-- event: comment author: yoi-orchestrator at: 2026-06-21T16:32:22Z -->
## Comment
Review routing:
Spawned read-only Reviewer Pod:
- `yoi-reviewer-00001KVNEKH9Q-r1`
Review target:
- Worktree: `/home/hare/Projects/yoi/.worktree/00001KVNEKH9Q-workspace-host-workers`
- Branch: `impl/00001KVNEKH9Q-workspace-host-workers`
- Baseline: `e1f02ffc`
- Implementation commit: `58143ead feat: expose workspace hosts and workers`
- Diff: `e1f02ffc..58143ead`
Reviewer scope:
- read-only `/home/hare/Projects/yoi`
Reviewer focus:
- Host / Worker API domain shape and redaction/bounding。
- Safe read-only local Pod metadata bridge。
- Missing/unreadable Pod metadata dir degradation。
- `/api/runners` placeholder migration/removal and frontend/test updates。
- Static SPA displays Host / Worker list without backend authority changes。
- Package/Nix/frontend generated artifact hygiene。
Orchestrator will wait for reviewer verdict before integration。
---
<!-- event: review author: yoi-reviewer-00001KVNEKH9Q-r1 at: 2026-06-21T16:38:05Z status: approve -->
## Review: approve
Verdict: `approve`
確認範囲:
- Ticket authority:
- `.yoi/tickets/00001KVNEKH9Q/item.md`
- `.yoi/tickets/00001KVNEKH9Q/thread.md`
- Diff/changed areas `e1f02ffc..58143ead`:
- `crates/workspace-server/src/hosts.rs`
- `crates/workspace-server/src/server.rs`
- `crates/workspace-server/src/store.rs`
- `crates/workspace-server/src/lib.rs`
- `crates/workspace-server/src/main.rs`
- `crates/workspace-server/Cargo.toml`
- `Cargo.lock`
- `package.nix`
- `web/workspace/src/routes/+page.svelte`
- `web/workspace/svelte.config.js`
- Supporting inspection:
- `crates/pod-store/src/lib.rs`
- `runners` references and session/prompt/tool-result exposure grep。
Blocking issues:
- None。
Acceptance verification:
- API domain naming is Host / Worker:
- Routes added for `/api/hosts`, `/api/workers`, and `/api/hosts/{host_id}/workers`
- `/api/runners` placeholder is removed and test-covered as `404`
- Local Pods are implementation detail:
- Worker model exposes domain fields (`worker_id`, `host_id`, `label`, `state`, `status`, etc.)。
- Pod-specific detail is limited to `pod_name` and `implementation: { kind: "local_pod", pod_name: ... }`
- Local Pod bridge is read-only:
- Reads local Pod `metadata.json` under configured local data dir。
- No mutation of Pod runtime metadata, session logs, Tickets, or Objectives observed。
- Redaction / sensitive data:
- No session JSONL/transcript/tool-result/prompt content reads introduced。
- `resolved_manifest_snapshot` is not serialized wholesale; role/profile extraction is bounded/sanitized。
- Tests include a snapshot containing `secret_token` / `system_prompt` and assert they are not returned。
- Missing/unreadable metadata degradation:
- Missing Pod metadata root returns empty workers plus bounded diagnostics。
- Host capability degrades to unavailable instead of failing the server。
- Frontend:
- Static Svelte page fetches/displays Hosts and Workers。
- No SSR/business authority addition observed。
- Existing Ticket/Objective workflows remain preserved; `ticket doctor` passes。
Non-blocking concerns:
- None。
Reviewer validation:
- `cargo fmt --check`: passed
- `git diff --check e1f02ffc..HEAD`: passed
- `cargo test -p yoi-workspace-server`: passed (`6 passed`)
- `cargo check -p yoi-workspace-server`: passed
- `cd web/workspace && deno task check && deno task build`: passed (`svelte-check found 0 errors and 0 warnings`; static build completed)
- `cargo run -p yoi -- ticket doctor`: passed (`doctor: ok`)
- `nix build .#yoi --no-link`: passed
Generated artifacts:
- Frontend validation produced ignored generated artifacts only:
- `web/workspace/.svelte-kit/`
- `web/workspace/build/`
- `web/workspace/node_modules/`
- Reviewer did not clean them due review-only scope; they are ignored and not tracked。
Worktree status:
- Tracked/untracked status is clean on `impl/00001KVNEKH9Q-workspace-host-workers`
- Ignored generated frontend artifacts remain as listed above。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T16:38:57Z -->
## Implementation report
Reviewer r1 approvalを受けて、実装ブランチを Orchestrator worktree の `orchestration` branch に統合した。
統合:
- Merge commit: `c884d517 merge: workspace host workers`
- Merged branch: `impl/00001KVNEKH9Q-workspace-host-workers`
- Implementation commit:
- `58143ead feat: expose workspace hosts and workers`
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
- `cd web/workspace && rm -rf node_modules .svelte-kit build && deno task check`: passed (`svelte-check found 0 errors and 0 warnings`)
- `cd web/workspace && deno task build`: passed
- `cargo run -p yoi -- ticket 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 `c884d517`
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-21T16:39:02Z from: inprogress to: done reason: implementation_merged_and_validated field: state -->
## State changed
Reviewer approval、Orchestrator worktree への統合、workspace-server tests/check、Deno check/build、Ticket doctor、Nix build が完了したため `done` に遷移する。
---
<!-- event: state_changed author: hare at: 2026-06-21T16:39:15Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-21T16:39:15Z status: closed -->
## 完了
Workspace backend に local Host / Worker read API を追加し、Web UI に Host / Worker list を表示する変更を統合した。
主な成果:
- Workspace backend に `/api/hosts`, `/api/workers`, `/api/hosts/{host_id}/workers` を追加。
- `/api/runners` placeholder を削除 / Host-Worker naming に移行。
- Local Pod metadata を read-only bridge として Worker domain object に投影。
- Pod は primary domain ではなく `implementation: { kind: "local_pod", pod_name: ... }` として表現。
- Worker response includes bounded `worker_id`, `host_id`, label/pod name, role/profile when known, `workspace_root`, state/status, implementation detail, and diagnostics。
- Missing/unreadable Pod metadata root は process/server failure ではなく empty workers + bounded diagnostics / unavailable capability に degrade。
- Session transcript / tool result / prompt contents / raw session JSONL は読まない。
- Web UI static SPA に Host / Worker list を追加。
- Existing Ticket / Objective canonical workflows remain unchanged。
統合・検証:
- Merge commit: `c884d517 merge: workspace host workers`
- Implementation commit: `58143ead feat: expose workspace hosts and workers`
- 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`, `deno task check`, `deno task build`, `cargo run -p yoi -- ticket doctor`, and `nix build .#yoi --no-link`
範囲外:
- Remote/cloud runner registration, scheduling, start/stop/attach/notify operations, and full run-worker correlation were not implemented。
- Frontend remains static SPA and does not own backend business/lifecycle authority。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T16:39:43Z -->
## Implementation report
Post-close cleanup completed。
- Stopped child Pods and reclaimed scope:
- `yoi-coder-00001KVNEKH9Q`
- `yoi-reviewer-00001KVNEKH9Q-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/00001KVNEKH9Q-workspace-host-workers`
- Deleted implementation branch:
- `impl/00001KVNEKH9Q-workspace-host-workers`
- Orchestrator worktree remains clean on `orchestration` at `a4ed5fb0`
Root/original workspace was not used for merge/validation/cleanup operations。
Note for related active work:
- `00001KVNG9B9Z` sidebar UI work was branched before this merge and may need to integrate the Host/Worker UI/API changes from `c884d517` during review/merge。
---

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260621-163647-1","ticket_id":"00001KVNG9B9Z","kind":"accepted_plan","accepted_plan":{"summary":"Add left sidebar navigation skeleton to `web/workspace` static SPA with workspace header/settings placeholder, repositories/objectives/workers sections, objectives API read, workers placeholder/API boundary, responsive layout, and section-level error/empty states while avoiding backend authority changes.","branch":"impl/00001KVNG9B9Z-workspace-sidebar","worktree":"/home/hare/Projects/yoi/.worktree/00001KVNG9B9Z-workspace-sidebar","role_plan":"Orchestrator creates a dedicated child worktree and spawns a narrow-scope frontend Coder. Reviewer will be spawned read-only after Coder reports implementation commit(s). After approval, Orchestrator integrates into `orchestration`, resolving any conflict with Host/Worker work if it has merged, validates Deno/frontend/Nix as needed, records closure, and cleans only the child worktree/branch."},"author":"yoi-orchestrator","at":"2026-06-21T16:36:47Z"}

View File

@ -0,0 +1,21 @@
{
"version": 1,
"relations": [
{
"ticket_id": "00001KVNG9B9Z",
"kind": "related",
"target": "00001KVMFFYVX",
"note": "Extends workspace web control plane bootstrap with initial navigation UI",
"author": "yoi ticket",
"at": "2026-06-21T16:30:49Z"
},
{
"ticket_id": "00001KVNG9B9Z",
"kind": "related",
"target": "00001KVNEKH9Q",
"note": "Workers section will consume or placeholder the local host/worker API",
"author": "yoi ticket",
"at": "2026-06-21T16:30:49Z"
}
]
}

View File

@ -0,0 +1,72 @@
---
title: 'Workspace web UI: add sidebar navigation panel'
state: 'closed'
created_at: '2026-06-21T16:30:12Z'
updated_at: '2026-06-21T17:01:46Z'
assignee: null
queued_by: 'workspace-panel'
queued_at: '2026-06-21T16:35:06Z'
---
## 背景
Workspace web UI は static SPA skeleton と read-only API まで立ち上がっている。次に、Workspace control plane の基本情報へ移動するための左サイドパネルを作り、Workspace / Repository / Objective / Worker を一覧できる最初の navigation surface を用意する。
初期イメージ:
```text
my-workspace ⚙
---
repositories
- yoi
objectives
---
<workers list>
```
この Ticket は UX skeleton の実装を対象にする。深い編集機能や complex layout はまだ扱わず、Web UI の基本構造を早めに固定する。
## 要件
- Workspace web SPA に左サイドパネルを追加する。
- サイドパネル上部に workspace name / label を表示する。
- 例: `my-workspace`
- 右側に settings entry point として `⚙` または同等の icon/button を置く。
- settings は初期実装では disabled / placeholder / diagnostics panel でもよい。
- `repositories` section を表示する。
- 初期は API 由来の Repository list が未実装なら placeholder / local workspace repository summary でよい。
- 既存 API に合わせて、後続で Repository API に接続しやすい component boundary にする。
- `objectives` section を表示する。
- 既存 `/api/objectives` から objective list を取得して表示する。
- title と state が分かる最小表示でよい。
- `workers` section を表示する。
- `00001KVNEKH9Q` の Host/Worker API が入る前は placeholder でよい。
- API が存在する場合は `GET /api/workers` または `GET /api/hosts/.../workers` に接続できるよう component boundary を分ける。
- 表示名は `workers` とし、Pod は implementation detail として出さない。
- main content とは layout を分け、サイドパネルが常時表示される基本構成にする。
- narrow viewport では壊れない最低限の responsive behavior を持つ。
- 初期は横スクロール回避 / fixed min-width 程度でよい。
- API failure は sidebar 全体を落とさず、section ごとに bounded error / empty state を表示する。
- style は現行 skeleton に合わせ、過剰な design system 化はしない。
## Non-goals
- settings 画面の本実装。
- Repository CRUD / Repository API の本実装。
- Objective detail/edit UI。
- Worker start/stop/attach 操作。
- drag-and-drop / collapsible tree / complex navigation。
- auth / multi-workspace switcher。
## 受け入れ条件
- Workspace web UI に左サイドパネルが表示される。
- sidebar header に workspace name と settings placeholder が表示される。
- `repositories` / `objectives` / `workers` の section が表示される。
- Objectives section は existing `/api/objectives` 由来の data を表示する。
- Workers section は Host/Worker API 未実装でも placeholder として安全に表示され、API 接続を後で差し替えやすい component boundary になっている。
- API failure / empty state が section 単位で表示される。
- `deno task check``deno task build` が通る。
- `cargo test -p yoi-workspace-server` または backend 変更がある場合の relevant tests が通る。
- `git diff --check`、`yoi ticket doctor`、`nix build .#yoi --no-link` が通る。

View File

@ -0,0 +1,26 @@
Workspace web UI に sidebar navigation panel を追加し、Orchestrator worktree の `orchestration` branch に統合した。
主な成果:
- `web/workspace/src/lib/workspace-sidebar/` に sidebar components を追加:
- `WorkspaceSidebar.svelte`
- `RepositoriesNavSection.svelte`
- `ObjectivesNavSection.svelte`
- `WorkersNavSection.svelte`
- `types.ts`
- `web/workspace/src/routes/+page.svelte` を sidebar + main content の responsive two-column layout に更新。
- Sidebar header に workspace label/name と disabled settings placeholder を表示。
- `repositories`, `objectives`, `workers` sections を追加。
- Objectives section は `/api/objectives` を読み、title/state と empty/error states を section-local に表示。
- Workers section は `/api/workers` を読み、Worker label/state/status/role を表示し、Pod を primary UI naming として露出しない。
- Repository section は future Repository API に繋げやすい placeholder seam として実装。
- Host/Worker API/UI merge 後の `+page.svelte` conflict を解消し、main Host/Worker content と sidebar skeleton を両方維持。
- Backend/API authority, SSR, mutation/business logic は追加していない。
統合・検証:
- Merge commit: `613f4126 merge: workspace sidebar navigation`
- Implementation commits: `d3b8bdfd`, `4ab696b4`
- Reviewer final verdict: approve
- Validation passed: `git diff --check HEAD^1..HEAD`, `deno task check`, `deno task build`, `cargo test -p yoi-workspace-server`, `cargo run -p yoi -- ticket doctor`, and `nix build .#yoi --no-link`
範囲外:
- Settings page, Repository CRUD/API, Objective edit/detail UI, Worker start/stop/attach controls, drag/drop/collapsible tree, auth, multi-workspace switcher は実装していない。

View File

@ -0,0 +1,388 @@
<!-- event: create author: "yoi ticket" at: 2026-06-21T16:30:12Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: hare at: 2026-06-21T16:30:49Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-06-21T16:30:49Z from: planning to: ready reason: cli_state field: state -->
## State changed
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: workspace-panel at: 2026-06-21T16:35:06Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-21T16:36:30Z -->
## Decision
Routing decision: `implementation_ready_parallel_with_conflict_risk`
Reason:
- Ticket body は Workspace web UI に左 sidebar navigation panel を追加する UX skeleton として、header / repositories / objectives / workers sections、responsive behavior、section-level error handling、Deno validation まで具体化されている。
- Relations are `related` only to `00001KVMFFYVX` and `00001KVNEKH9Q`; no blocking dependency is recorded。
- `00001KVMFFYVX` is closed/integrated。
- `00001KVNEKH9Q` is currently in review and touches Host/Worker API plus `web/workspace/src/routes/+page.svelte`; this creates merge-conflict risk but not an authority blocker. Ticket body explicitly allows Workers section to be placeholder before Host/Worker API exists and asks for component boundary that can later connect to that API。
- Current queued Ticket is this Ticket only。
- Orchestrator worktree is clean on `orchestration` at `d4de8e26`; target worktree / branch is not present。
Evidence checked:
- Ticket body via direct `item.md` read。
- `relations.json`: related to `00001KVMFFYVX` and `00001KVNEKH9Q` only。
- `TicketOrchestrationPlanQuery(00001KVNG9B9Z)`: no records。
- `TicketList(state=queued)`: this Ticket is the only queued Ticket。
- `00001KVNEKH9Q` current state is `inprogress` / reviewer running; no approval/merge yet。
- Orchestrator git state / worktree list / branch list checked from `/home/hare/Projects/yoi/.worktree/orchestration` only。
IntentPacket:
Intent:
- Add a left sidebar navigation skeleton to Workspace web SPA that surfaces Workspace / Repository / Objective / Worker navigation without adding deep editing or business authority。
Binding decisions / invariants:
- Frontend-only UI skeleton unless a minimal API read is already available; do not change backend authority without need。
- Keep static SPA; no SSR or frontend lifecycle/business authority。
- Objective section should read existing `/api/objectives` and display bounded title/state data。
- Worker section should use a component/data boundary that can connect to Host/Worker API, but must safely show placeholder/empty/error if API is absent or in flux。
- Do not expose Pod as primary UI/domain naming; use `workers`
- API failures are section-local and must not take down the whole page。
- Avoid broad design-system churn; keep current skeleton style direction。
- Be aware that `00001KVNEKH9Q` may merge Host/Worker UI/API changes concurrently; keep sidebar changes narrow and componentized to reduce conflict。
Requirements / acceptance criteria:
- Left sidebar is visible in Workspace web UI。
- Sidebar header displays workspace name/label plus settings placeholder/icon/button。
- Sections present: `repositories`, `objectives`, `workers`
- Objectives section fetches existing `/api/objectives` and displays title/state minimal info。
- Workers section safely displays placeholder/empty/error if Host/Worker API is not yet integrated; if API is available in branch, it may use it through a component boundary。
- Main content and sidebar layout are separated and responsive enough to avoid narrow-viewport breakage / horizontal overflow。
- Section-level error/empty states are bounded。
- Deno check/build passes。
Implementation latitude:
- Split Svelte components under `web/workspace/src/lib` if useful。
- Keep repository section placeholder/local summary if no Repository API exists。
- Use simple CSS layout; no full design system。
- If concurrent `00001KVNEKH9Q` merge creates conflict, report rather than broad rewrite。
Escalate if:
- Implementing sidebar requires backend API redesign beyond minimal frontend skeleton。
- Workers section cannot be safely decoupled from unmerged Host/Worker API。
- Deno/Svelte tooling fails for reasons unrelated to this Ticket。
- Merge conflict with `00001KVNEKH9Q` becomes non-trivial and requires orchestration sequencing。
Validation plan:
- `cd web/workspace && deno task check && deno task build`
- `git diff --check`
- `cargo run -p yoi -- ticket doctor`
- `nix build .#yoi --no-link` preferred because Workspace frontend package surface is touched。
- `cargo test -p yoi-workspace-server` only if backend code changes。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-21T16:36:37Z from: queued to: inprogress reason: human_authorized_unblocked_sidebar_ui_implementation field: state -->
## State changed
Human authorized queue routing from Workspace Dashboard. Ticket has concrete frontend acceptance criteria and no blocking relation; related Host/Worker API work is in review and creates merge-conflict risk but not an authority blocker. Orchestrator accepts implementation with narrow frontend scope.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T16:37:34Z -->
## Implementation report
Implementation start report:
- Created child implementation worktree:
- `/home/hare/Projects/yoi/.worktree/00001KVNG9B9Z-workspace-sidebar`
- Created branch:
- `impl/00001KVNG9B9Z-workspace-sidebar`
- Base commit:
- `2c7ef24a ticket: accept workspace sidebar ui`
- 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-00001KVNG9B9Z`
- Coder scope:
- read: `/home/hare/Projects/yoi`
- write: `/home/hare/Projects/yoi/.worktree/00001KVNG9B9Z-workspace-sidebar`
Parallelization note:
- `00001KVNEKH9Q` remains active in review and may merge Host/Worker API plus `web/workspace/src/routes/+page.svelte` changes. Coder was instructed to keep sidebar work narrow/componentized and report if conflicts become non-trivial。
Next action:
- Wait for Coder implementation report, then spawn Reviewer read-only for the implementation diff。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T16:54:08Z -->
## Implementation report
Coder conflict-resolution report received from `yoi-coder-00001KVNG9B9Z`.
Final implementation HEAD:
- `4ab696b4` merge commit from current `orchestration` into `impl/00001KVNG9B9Z-workspace-sidebar`
Relevant commits:
- `d3b8bdfd feat: add workspace sidebar skeleton`
- `4ab696b4` merge/conflict-resolution commit
Conflict resolution summary:
- Resolved `web/workspace/src/routes/+page.svelte` conflict after Host/Worker API/UI from `00001KVNEKH9Q` landed。
- Preserved merged Host/Worker main content/cards/table。
- Preserved sidebar navigation skeleton。
- Updated sidebar Workers section to consume available `/api/workers` response through typed `ListResponse<Worker>`
- Kept Pod naming out of sidebar, presenting workers by label/state/status/role。
- Changes remain scoped to Workspace frontend。
Coder validation reported:
- `cd web/workspace && deno task check`: passed
- `cd web/workspace && deno task build`: passed
- `git diff --check`: passed
- `git diff --check orchestration..HEAD`: passed
- `cargo run -p yoi -- ticket doctor`: passed
- `nix build .#yoi --no-link`: passed
- `cargo test -p yoi-workspace-server`: not run because backend code was not changed during conflict resolution
Generated artifact cleanup:
- Coder removed generated/ignored frontend artifacts after validation:
- `web/workspace/.svelte-kit`
- `web/workspace/build`
- `web/workspace/node_modules`
Orchestrator evidence checked:
- Worktree clean on `impl/00001KVNG9B9Z-workspace-sidebar` at `4ab696b4`
- Diff `orchestration..HEAD` touches expected frontend files only: new `web/workspace/src/lib/workspace-sidebar/*` components and `web/workspace/src/routes/+page.svelte`
- `git diff --check orchestration..HEAD`: passed。
- Merge-tree conflict scan vs current `orchestration`: no conflict markers / no changed-in-both diagnostics。
Next action:
- Route to read-only Reviewer Pod for acceptance review。
---
<!-- event: comment author: yoi-orchestrator at: 2026-06-21T16:54:49Z -->
## Comment
Review routing:
Spawned read-only Reviewer Pod:
- `yoi-reviewer-00001KVNG9B9Z-r1`
Review target:
- Worktree: `/home/hare/Projects/yoi/.worktree/00001KVNG9B9Z-workspace-sidebar`
- Branch: `impl/00001KVNG9B9Z-workspace-sidebar`
- Current implementation HEAD: `4ab696b4`
- Implementation commits:
- `d3b8bdfd feat: add workspace sidebar skeleton`
- `4ab696b4` merge/conflict-resolution commit from current `orchestration`
- Primary review diff: `orchestration..HEAD` inside implementation worktree, so review sees final sidebar changes on top of current Host/Worker API/UI state。
Reviewer scope:
- read-only `/home/hare/Projects/yoi`
Reviewer focus:
- Sidebar visibility/layout/responsive behavior。
- Workspace header/settings placeholder。
- Repositories/objectives/workers sections。
- Objective and worker section bounded loading/empty/error handling。
- Worker naming avoids Pod-primary UI。
- Main Host/Worker content from `00001KVNEKH9Q` remains preserved after conflict resolution。
- No backend authority/SSR/business logic changes。
Orchestrator will wait for reviewer verdict before integration。
---
<!-- event: review author: yoi-reviewer-00001KVNG9B9Z-r1 at: 2026-06-21T17:00:16Z status: approve -->
## Review: approve
Verdict: `approve`
確認範囲:
- Ticket authority:
- `.yoi/tickets/00001KVNG9B9Z/item.md`
- `.yoi/tickets/00001KVNG9B9Z/thread.md`
- Implementation diff:
- `git diff --name-status orchestration..HEAD`
- implementation-only diff against merge-base `a607a1f20d0622b1f556c36cca155da68109fe25..HEAD`
- Frontend files:
- `web/workspace/src/routes/+page.svelte`
- `web/workspace/src/lib/workspace-sidebar/WorkspaceSidebar.svelte`
- `web/workspace/src/lib/workspace-sidebar/RepositoriesNavSection.svelte`
- `web/workspace/src/lib/workspace-sidebar/ObjectivesNavSection.svelte`
- `web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte`
- `web/workspace/src/lib/workspace-sidebar/types.ts`
- API shape spot-check:
- `crates/workspace-server/src/server.rs`
- `crates/workspace-server/src/hosts.rs`
- `crates/workspace-server/src/records.rs`
Blocking issues:
- None。
Acceptance verification:
- Left sidebar is wired into `+page.svelte` and layout is separated from main content。
- Sidebar header shows workspace label/name and disabled settings placeholder。
- `repositories`, `objectives`, and `workers` sections are present。
- Objectives section fetches `/api/objectives` and handles loading/error/empty locally。
- Workers section fetches `/api/workers`, uses Worker naming in visible UI, and handles 404/empty/error locally。
- Main Host/Worker content from current orchestration branch is preserved。
- No backend/API authority changes were introduced by the implementation-only diff。
- Responsive CSS uses a two-column layout on wide viewports and single-column layout below 760px, with `min-width: 0`, wrapping, and table overflow containment。
Non-blocking concerns:
- `orchestration..HEAD` currently shows apparent `.yoi/tickets/00001KVNG9B9Z/item.md` / `thread.md` reversions because `orchestration` has newer review-routing/report commits after the implementation branchs merge-base. The implementation-only diff against the merge-base is frontend-only, and `git merge-tree --write-tree orchestration HEAD` reports a clean merge. This should not block a normal merge, but avoid applying the literal `orchestration..HEAD` patch/squash in a way that drops newer ticket thread entries。
Reviewer validation:
- `git diff --check`: passed
- `git diff --check orchestration..HEAD`: passed
- `git diff --check $(git merge-base orchestration HEAD)..HEAD`: passed
- `cd web/workspace && deno task check`: passed (`svelte-check found 0 errors and 0 warnings`)
- `cd web/workspace && deno task build`: passed; static adapter wrote site to `build`
- `cargo run -p yoi -- ticket doctor`: passed (`doctor: ok`)
- `nix build .#yoi --no-link`: passed
- `cargo test -p yoi-workspace-server`: passed (`6 passed; 0 failed`)
- `git merge-tree --write-tree orchestration HEAD`: passed with status 0; produced merged tree `24e0e026264333fc63d60c0e2d649839445acb64`
Generated artifacts:
- Reviewer did not clean generated ignored artifacts after validation:
- `web/workspace/.svelte-kit/`
- `web/workspace/build/`
- `web/workspace/node_modules/`
- They are ignored and not tracked; `git ls-files --others --exclude-standard` reports `0`
Worktree status:
- Clean tracked state on `impl/00001KVNG9B9Z-workspace-sidebar` at `4ab696b4`
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T17:00:46Z -->
## Implementation report
Reviewer r1 approvalを受けて、実装ブランチを Orchestrator worktree の `orchestration` branch に統合した。
統合:
- Merge commit: `613f4126 merge: workspace sidebar navigation`
- Merged branch: `impl/00001KVNG9B9Z-workspace-sidebar`
- Implementation commits:
- `d3b8bdfd feat: add workspace sidebar skeleton`
- `4ab696b4` merge/conflict-resolution commit from current `orchestration`
Orchestrator worktree validation:
- `git diff --check HEAD^1..HEAD`: passed
- `cd web/workspace && rm -rf node_modules .svelte-kit build && deno task check`: passed (`svelte-check found 0 errors and 0 warnings`)
- `cd web/workspace && deno task build`: passed
- `cargo test -p yoi-workspace-server`: passed (`6 passed`)
- `cargo run -p yoi -- ticket 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 `613f4126`
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-21T17:00:59Z from: inprogress to: done reason: implementation_merged_and_validated field: state -->
## State changed
Reviewer approval、Orchestrator worktree への統合、Deno check/build、workspace-server tests、Ticket doctor、Nix build が完了したため `done` に遷移する。
---
<!-- event: state_changed author: hare at: 2026-06-21T17:01:13Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-21T17:01:13Z status: closed -->
## 完了
Workspace web UI に sidebar navigation panel を追加し、Orchestrator worktree の `orchestration` branch に統合した。
主な成果:
- `web/workspace/src/lib/workspace-sidebar/` に sidebar components を追加:
- `WorkspaceSidebar.svelte`
- `RepositoriesNavSection.svelte`
- `ObjectivesNavSection.svelte`
- `WorkersNavSection.svelte`
- `types.ts`
- `web/workspace/src/routes/+page.svelte` を sidebar + main content の responsive two-column layout に更新。
- Sidebar header に workspace label/name と disabled settings placeholder を表示。
- `repositories`, `objectives`, `workers` sections を追加。
- Objectives section は `/api/objectives` を読み、title/state と empty/error states を section-local に表示。
- Workers section は `/api/workers` を読み、Worker label/state/status/role を表示し、Pod を primary UI naming として露出しない。
- Repository section は future Repository API に繋げやすい placeholder seam として実装。
- Host/Worker API/UI merge 後の `+page.svelte` conflict を解消し、main Host/Worker content と sidebar skeleton を両方維持。
- Backend/API authority, SSR, mutation/business logic は追加していない。
統合・検証:
- Merge commit: `613f4126 merge: workspace sidebar navigation`
- Implementation commits: `d3b8bdfd`, `4ab696b4`
- Reviewer final verdict: approve
- Validation passed: `git diff --check HEAD^1..HEAD`, `deno task check`, `deno task build`, `cargo test -p yoi-workspace-server`, `cargo run -p yoi -- ticket doctor`, and `nix build .#yoi --no-link`
範囲外:
- Settings page, Repository CRUD/API, Objective edit/detail UI, Worker start/stop/attach controls, drag/drop/collapsible tree, auth, multi-workspace switcher は実装していない。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T17:01:46Z -->
## Implementation report
Post-close cleanup completed。
- Stopped child Pods and reclaimed scope:
- `yoi-coder-00001KVNG9B9Z`
- `yoi-reviewer-00001KVNG9B9Z-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/00001KVNG9B9Z-workspace-sidebar`
- Deleted implementation branch:
- `impl/00001KVNG9B9Z-workspace-sidebar`
- Orchestrator worktree remains clean on `orchestration` at `c29eba0c`
Root/original workspace was not used for merge/validation/cleanup operations。
Follow-up note:
- `00001KVNGJPRG` had been left queued behind this sidebar work due `do_not_parallelize`; this blocker is now cleared for re-routing on top of the settled sidebar/navigation structure。
---

View File

@ -0,0 +1,2 @@
{"id":"orch-plan-20260621-164056-1","ticket_id":"00001KVNGJPRG","kind":"do_not_parallelize","related_ticket":"00001KVNG9B9Z","note":"Repository/objective pages and sidebar navigation both modify the Workspace SPA navigation/layout surface, especially `web/workspace/src/routes/+page.svelte` and route/component structure. Start this Ticket after `00001KVNG9B9Z` lands or after its implementation diff is known enough to route a non-conflicting continuation.","author":"yoi-orchestrator","at":"2026-06-21T16:40:56Z"}
{"id":"orch-plan-20260621-170253-2","ticket_id":"00001KVNGJPRG","kind":"accepted_plan","accepted_plan":{"summary":"Add read-only current-workspace Repository APIs/pages with bounded Git summary/log and Ticket Kanban, plus Objective list page and sidebar links, preserving filesystem Ticket/Objective authority and static SPA boundaries.","branch":"impl/00001KVNGJPRG-repository-objective-pages","worktree":"/home/hare/Projects/yoi/.worktree/00001KVNGJPRG-repository-objective-pages","role_plan":"Orchestrator creates a dedicated child worktree from current sidebar-integrated `orchestration` and spawns a narrow-scope Coder. Reviewer will be spawned read-only after Coder reports implementation commit(s). After approval, Orchestrator integrates into `orchestration`, validates workspace-server/frontend/Nix, records closure, and cleans only the child worktree/branch."},"author":"yoi-orchestrator","at":"2026-06-21T17:02:53Z"}

View File

@ -0,0 +1,21 @@
{
"version": 1,
"relations": [
{
"ticket_id": "00001KVNGJPRG",
"kind": "related",
"target": "00001KVMFFYVX",
"note": "Extends workspace web bootstrap with repository/objective pages",
"author": "yoi ticket",
"at": "2026-06-21T16:36:06Z"
},
{
"ticket_id": "00001KVNGJPRG",
"kind": "related",
"target": "00001KVNG9B9Z",
"note": "Sidebar navigation should link to repository and objective pages",
"author": "yoi ticket",
"at": "2026-06-21T16:36:06Z"
}
]
}

View File

@ -0,0 +1,83 @@
---
title: 'Workspace web: repository and objective pages'
state: 'closed'
created_at: '2026-06-21T16:35:19Z'
updated_at: '2026-06-21T17:31:43Z'
assignee: null
queued_by: 'workspace-panel'
queued_at: '2026-06-21T16:40:35Z'
---
## 背景
Workspace web control plane の初期段階では、Ticket / Objective の操作系が Web で十分に実装され、移行できる状態になるまでは、既存 `.yoi/tickets` / `.yoi/objectives` の filesystem record を read-through authority として扱う。
SQLite は Workspace server の runtime/projection/store seam として使うが、Ticket / Objective の canonical write path を中途半端に DB へ移さない。少なくとも Ticket 作成・コメント・状態遷移・close、Objective 作成/更新、validation/audit が Web/API 側で成立するまでは、Web UI は filesystem records を読む方向で進める。
次の UI slice として、Workspace sidebar から遷移できる Repository page と Objective list page を追加する。
Repository page では、当面は backend が動いている Workspace root の Git repository を primary Repository として扱う。Git repository である場合は、簡易的な Git 情報、直近 log、Repository に関係する Ticket の Kanban view を表示する。Repository target metadata がまだ Ticket schema に十分無い場合は、初期実装では workspace-local tickets 全体または既存 metadata から安全に導ける範囲を表示し、target selector 対応は follow-up にできる。
## 方針
- Ticket / Objective は当面 filesystem read-through で表示する。
- Web UI からの mutation / DB migration は、この Ticket の主目的にしない。
- Repository は Git 専売の概念ではないが、初期 page は Git Repository の read-only summary から始める。
- Repository page は将来の Repository provider / RepositoryPoint / target selector model に繋がる形にする。
- Ticket Kanban は Ticket state を column として表示する。
- Objective list は existing `/api/objectives` を使い、Objective の title/state/summary を一覧できるようにする。
## 要件
### Backend / API
- Repository list/detail の read-only API を追加する、または既存 `/api/workspace` に必要最小限の Repository summary を追加する。
- 初期は current workspace root を 1 つの local Repository として返してよい。
- Git repository の場合は branch/head/root/dirty status/remote URL summary などを bounded に返す。
- Git でない場合は `kind = "local"` / `git = unavailable` 相当の diagnostic を返す。
- Git log summary API を追加する。
- 直近 N 件だけ返す。
- commit hash、subject、author name/email の扱い、timestamp を bounded にする。
- full diff / patch / file contents は返さない。
- Repository Ticket Kanban 用の read model を追加する。
- 初期は Ticket state ごとに group した bounded list でよい。
- Ticket target Repository metadata がない場合の fallback を明記する。
- 将来 target selector が入ったら Repository ごとの filter に差し替えられる形にする。
- Objective list/detail は既存 filesystem read-through API を継続利用する。
### Frontend
- Sidebar の `repositories` section から Repository page に遷移できる。
- Repository page を追加する。
- Repository summary。
- Git summary / recent log。
- Ticket Kanban columns。
- API failure / non-Git / empty tickets を section 単位で表示。
- Objective list page を追加する。
- Objective title/state/updated_at などの一覧。
- Objective detail への遷移は可能なら行う。無理なら placeholder でよい。
- UI は static SPA のまま実装し、frontend に authority logic を持たせない。
## Non-goals
- Ticket / Objective の DB canonical migration。
- Web からの Ticket mutation / Objective mutation。
- Repository CRUD / remote Git hosting integration。
- Full Git diff viewer / file browser / blame。
- Ticket target selector schema の完成。
- Multi-repository selection UI の完成。
- Kanban drag-and-drop / state mutation。
## 受け入れ条件
- Ticket / Objective は引き続き filesystem read-through authority から表示される。
- Repository page が表示できる。
- Git repository の場合、Repository summary と recent log が bounded に表示される。
- Repository Ticket Kanban が state columns で表示される。
- Ticket target metadata が未整備でも安全な fallback 表示になる。
- Objective list page が表示できる。
- Sidebar から repositories / objectives に遷移できる。
- API failure、non-Git repository、empty state が UI で壊れず表示される。
- `deno task check``deno task build` が通る。
- backend 変更がある場合は `cargo test -p yoi-workspace-server` が通る。
- `cargo fmt --check`、`cargo check`、`git diff --check`、`yoi ticket doctor`、`nix build .#yoi --no-link` が通る。

View File

@ -0,0 +1,30 @@
Workspace web に Repository / Objective pages を追加し、Orchestrator worktree の `orchestration` branch に統合した。
主な成果:
- Read-only Repository backend APIs を追加:
- `/api/repositories`
- `/api/repositories/local`
- `/api/repositories/local/log`
- `/api/repositories/local/tickets`
- Current workspace root を local Repository として扱う bounded repository summary を追加。
- Git repository では bounded branch/head/root/dirty/remote/recent log summary を返す。
- Non-Git workspace では `git.status = unavailable` と bounded diagnostics に degrade。
- Git log summary は recent commit hash/subject/author/timestamp に限定し、diff/patch/file content/blame/config は読まない。
- Remote URL summary は URL-scheme userinfo を redact。
- Read-only Ticket Kanban を Ticket state ごとに grouping し、workspace-local Ticket fallback diagnostic を含めた。
- Objective list summaries を filesystem Objective records から追加。
- Static SPA に hash-navigation Repository / Objectives pages を追加。
- Sidebar Repository/Objectives links を新 pages に接続。
- Repository page に summary, Git summary/log, diagnostics, read-only Ticket Kanban を表示。
- Objective page に title/state/updated_at/summary と detail placeholder links を表示。
- Ticket / Objective canonical authority remains filesystem read-through records; mutation API / DB canonical migration は追加していない。
統合・検証:
- Merge commit: `7ee702b1 merge: repository objective pages`
- Implementation commit: `ceb1ee3b feat: add repository objective pages`
- 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`, `deno task check`, `deno task build`, `cargo run -p yoi -- ticket doctor`, and `nix build .#yoi --no-link`
範囲外 / follow-up notes:
- Repository CRUD/API, Objective edit/detail mutation, full Git browser/diff/file views, drag/drop Kanban, and write APIs were not implemented。
- Reviewer noted possible follow-ups: keep `#/objectives` sidebar link visible even on objective empty/error states, and further tighten remote URL sanitization for query-param or SCP-like token forms if needed。

View File

@ -0,0 +1,414 @@
<!-- event: create author: "yoi ticket" at: 2026-06-21T16:35:19Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: hare at: 2026-06-21T16:36:06Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-06-21T16:36:06Z from: planning to: ready reason: cli_state field: state -->
## State changed
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: workspace-panel at: 2026-06-21T16:40:35Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-21T16:41:09Z -->
## Decision
Routing decision: `wait_do_not_parallelize_with_sidebar`
Reason:
- Ticket body is implementation-ready in isolation, but it is directly coupled to the currently active sidebar navigation work `00001KVNG9B9Z`
- Relations show this Ticket is related to `00001KVNG9B9Z` with note: “Sidebar navigation should link to repository and objective pages”。
- `00001KVNG9B9Z` is already `inprogress`, has a live Coder Pod, and is expected to change `web/workspace/src/routes/+page.svelte` / sidebar component structure。
- Starting this Ticket in parallel now would likely produce overlapping route/layout/sidebar changes and non-trivial semantic merge work, rather than independent implementation。
- Host/Worker API work `00001KVNEKH9Q` just landed, further increasing the need to base the page/navigation work on current SPA state。
Decision:
- Do not create a worktree or spawn Coder for `00001KVNGJPRG` yet。
- Leave Ticket `queued` and record an orchestration `do_not_parallelize` note with `00001KVNG9B9Z`
- Re-route this Ticket after sidebar work lands, or if sidebar implementation report shows a stable component boundary that makes repository/objective pages non-conflicting。
No implementation side effects were performed for this Ticket。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-21T17:02:35Z -->
## Decision
Routing decision: `implementation_ready_after_sidebar_landed`
Reason:
- This Ticket was previously left queued due `do_not_parallelize` with `00001KVNG9B9Z` because repository/objective pages and sidebar navigation both touched the Workspace SPA navigation/layout surface。
- `00001KVNG9B9Z` is now `closed` and integrated (`613f4126 merge: workspace sidebar navigation`), so the sequencing blocker is cleared。
- Related bootstrap Ticket `00001KVMFFYVX` is also closed/integrated。
- Ticket body has concrete backend/API/frontend requirements, acceptance criteria, and validation requirements。
- Relations are `related` only; no blocking relation remains。
- Orchestrator worktree is clean on `orchestration` at `eb2e5907`; target worktree / branch is not present。
IntentPacket:
Intent:
- Add Repository and Objective pages reachable from the Workspace sidebar, using filesystem read-through Ticket/Objective authority and bounded read-only Repository/Git summaries。
Binding decisions / invariants:
- Ticket / Objective canonical authority remains existing filesystem records; do not migrate canonical writes to SQLite or add mutation APIs。
- Repository page is read-only initial slice for current workspace root as local Repository。
- Git info and log summaries must be bounded; do not expose full diffs, file contents, blame, or secret-like config。
- Repository Ticket Kanban is read-only and grouped by Ticket state; no drag/drop or state mutation。
- Objective list uses existing filesystem read-through `/api/objectives` data, with detail links/placeholders as practical。
- Frontend remains static SPA; no SSR/business authority。
- Sidebar links should use the now-landed navigation/component structure。
- API failures, non-Git repo, and empty state must be section-local。
Requirements / acceptance criteria:
- Add read-only Repository list/detail/summary API or minimal workspace repository summary。
- For Git repository: bounded branch/head/root/dirty/remote/recent log summary。
- For non-Git repository: safe `kind = local` / git unavailable diagnostic。
- Add bounded Git log summary API returning recent N commit hash/subject/author/timestamp only。
- Add Repository Ticket Kanban read model grouped by Ticket state, with safe fallback to workspace-local tickets when target metadata is absent。
- Add Repository page showing summary, Git summary/log, and Ticket Kanban。
- Add Objective list page showing objective title/state/updated_at/summary and detail link/placeholder if feasible。
- Sidebar repositories/objectives links navigate to these pages。
- Deno check/build and backend validation pass。
Implementation latitude:
- Use SvelteKit static SPA client-side routing/hash/router approach or simple stateful navigation if current skeleton lacks route generation; keep static output compatible。
- Backend API names may be pragmatic, e.g. `/api/repositories`, `/api/repositories/{id}`, `/api/repositories/{id}/log`, `/api/repositories/{id}/tickets`
- Current workspace root can be the only repository for this slice。
- Ticket Kanban fallback can group all workspace tickets by state with diagnostic that target metadata is not yet available。
- Keep UI simple and avoid broad design-system churn。
Escalate if:
- Implementing repository APIs would require reading file contents/diffs or unsafe Git config/secrets。
- Objective detail requires mutation or schema changes。
- Static SPA route approach conflicts with SvelteKit adapter/static constraints。
- Sidebar/navigation architecture from `00001KVNG9B9Z` cannot support pages without broad rewrite。
Validation plan:
- `cargo fmt --check`
- `cargo test -p yoi-workspace-server`
- `cargo check -p yoi-workspace-server`
- `cd web/workspace && deno task check && deno task build`
- `git diff --check`
- `cargo run -p yoi -- ticket doctor`
- `nix build .#yoi --no-link`
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-21T17:02:42Z from: queued to: inprogress reason: sidebar_dependency_landed_unblocked_implementation field: state -->
## State changed
Previously recorded `do_not_parallelize` sequencing reason is cleared because sidebar navigation Ticket `00001KVNG9B9Z` is closed and integrated. Human queue authorization remains applicable, and the Ticket has concrete implementation requirements, so Orchestrator accepts implementation.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T17:03:39Z -->
## Implementation report
Implementation start report:
- Created child implementation worktree:
- `/home/hare/Projects/yoi/.worktree/00001KVNGJPRG-repository-objective-pages`
- Created branch:
- `impl/00001KVNGJPRG-repository-objective-pages`
- Base commit:
- `0f7e78c1 ticket: accept repository objective pages`
- 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-00001KVNGJPRG`
- Coder scope:
- read: `/home/hare/Projects/yoi`
- write: `/home/hare/Projects/yoi/.worktree/00001KVNGJPRG-repository-objective-pages`
Next action:
- Wait for Coder implementation report, then spawn Reviewer read-only for the implementation diff。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T17:23:16Z -->
## Implementation report
Coder implementation report received from `yoi-coder-00001KVNGJPRG`.
Implementation commit:
- `ceb1ee3b feat: add repository objective pages`
Changed areas:
- Backend repository read APIs:
- Added `crates/workspace-server/src/repositories.rs`
- Added bounded local repository/Git summary and log reader。
- Added endpoints:
- `/api/repositories`
- `/api/repositories/local`
- `/api/repositories/local/log`
- `/api/repositories/local/tickets`
- Added read-only Ticket Kanban grouping by Ticket state with workspace-local fallback diagnostic。
- Added Objective list summaries from existing filesystem Objective records。
- Frontend Workspace SPA:
- Added hash-navigation pages for Repository and Objectives。
- Wired sidebar Repository/Objectives links to the new pages。
- Added Repository summary, Git summary/log, diagnostics, and read-only Ticket Kanban UI。
- Added Objective list with title/state/updated_at/summary and detail placeholder links。
- Tests:
- Extended workspace-server API tests for repository endpoints and Objective summary。
- Added Git URL sanitization/log parsing unit tests。
Coder validation reported:
- `cargo fmt --all`: passed
- `cargo fmt --check`: passed
- `cargo test -p yoi-workspace-server`: passed
- `cargo check -p yoi-workspace-server`: passed
- `cd web/workspace && deno task check`: passed
- `cd web/workspace && deno task build`: passed
- `git diff --check`: passed
- `cargo run -p yoi -- ticket doctor`: passed
- `nix build .#yoi --no-link`: passed after staging newly added `repositories.rs` so Git-backed flake source filtering included it。
Generated artifact cleanup:
- Coder removed `web/workspace/build` and `web/workspace/.svelte-kit`
- Orchestrator additionally removed ignored `web/workspace/node_modules/` before Reviewer routing。
Orchestrator evidence checked:
- Worktree clean on `impl/00001KVNGJPRG-repository-objective-pages` at `ceb1ee3b`
- Diff `0f7e78c1..HEAD` touches 8 expected backend/frontend files, about 1228 insertions / 142 deletions。
- `git diff --check 0f7e78c1..HEAD`: passed。
- No generated frontend artifacts remain in child worktree status。
Next action:
- Route to read-only Reviewer Pod for acceptance review。
---
<!-- event: comment author: yoi-orchestrator at: 2026-06-21T17:23:58Z -->
## Comment
Review routing:
Spawned read-only Reviewer Pod:
- `yoi-reviewer-00001KVNGJPRG-r1`
Review target:
- Worktree: `/home/hare/Projects/yoi/.worktree/00001KVNGJPRG-repository-objective-pages`
- Branch: `impl/00001KVNGJPRG-repository-objective-pages`
- Baseline: `0f7e78c1`
- Implementation commit: `ceb1ee3b feat: add repository objective pages`
- Diff: `0f7e78c1..ceb1ee3b`
Reviewer scope:
- read-only `/home/hare/Projects/yoi`
Reviewer focus:
- Read-only bounded Repository APIs and Git summaries。
- No diffs/file contents/blame/secret-like config exposure。
- Sanitized remote URL summary。
- Non-Git degradation。
- Read-only Ticket Kanban grouped by state。
- Objective list uses filesystem read-through records。
- Static SPA navigation/pages and section-local error handling。
- Generated artifact / Nix source filtering hygiene。
Orchestrator will wait for reviewer verdict before integration。
---
<!-- event: review author: yoi-reviewer-00001KVNGJPRG-r1 at: 2026-06-21T17:30:09Z status: approve -->
## Review: approve
Verdict: `approve`
確認範囲:
- Ticket authority:
- `.yoi/tickets/00001KVNGJPRG/item.md`
- `.yoi/tickets/00001KVNGJPRG/thread.md`
- Diff `0f7e78c1..ceb1ee3b`:
- `crates/workspace-server/src/lib.rs`
- `crates/workspace-server/src/records.rs`
- `crates/workspace-server/src/repositories.rs`
- `crates/workspace-server/src/server.rs`
- `web/workspace/src/lib/workspace-sidebar/ObjectivesNavSection.svelte`
- `web/workspace/src/lib/workspace-sidebar/RepositoriesNavSection.svelte`
- `web/workspace/src/lib/workspace-sidebar/types.ts`
- `web/workspace/src/routes/+page.svelte`
Blocking issues:
- None found。
Acceptance verification:
- Repository APIs are read-only `GET` routes only。
- Git commands use fixed argument vectors through `Command::new("git").arg("-C").arg(workspace_root).args(args)`; no shell interpolation。
- Git summary/log is bounded:
- log limit clamps to `1..=50`
- command output capped at `4096`
- fields capped at `240`, hash at `40`
- no diff/patch/file content/blame/config commands are used。
- Remote URL summary strips URL-scheme userinfo and marks `redacted`
- Non-Git workspaces return `git.status = "unavailable"` plus diagnostics rather than failing the Repository page model。
- Ticket Kanban reads filesystem Ticket summaries and groups by state; no Ticket mutation route/UI was added。
- Objective list continues to use filesystem read-through `/api/objectives`; no Objective mutation/API authority added。
- Frontend remains static SPA/hash navigation; no SSR/business authority introduced。
- Nix build succeeds with the new Rust source file。
Non-blocking concerns:
- `ObjectivesNavSection.svelte` only shows the “All objectives” sidebar link when objectives are successfully loaded and non-empty. In an empty/error state, the page is still reachable from hero links, but the sidebar itself does not provide a stable top-level Objectives link. Consider keeping `#/objectives` visible in all non-loading states in a follow-up。
- Remote sanitization removes URL-scheme userinfo, but does not attempt query-param or SCP-like token redaction. Acceptable for the initial bounded summary, but worth tightening if remote URLs may include local secret-bearing config。
Reviewer validation:
- `cargo fmt --check`: passed
- `git diff --check 0f7e78c1..HEAD`: passed
- `cargo test -p yoi-workspace-server`: passed (`8 passed`)
- `cargo check -p yoi-workspace-server`: passed
- `cd web/workspace && deno task check`: passed (`svelte-check found 0 errors and 0 warnings`)
- `cd web/workspace && deno task build`: passed
- `cargo run -p yoi -- ticket doctor`: passed (`doctor: ok`)
- `nix build .#yoi --no-link`: passed
Generated artifacts:
- Reviewer did not clean generated ignored artifacts after validation。
- Ignored generated artifacts present:
- `target/`
- `web/workspace/.svelte-kit/`
- `web/workspace/build/`
- `web/workspace/node_modules/`
- No untracked non-ignored files。
Worktree status:
- Clean tracked/non-ignored state on `impl/00001KVNGJPRG-repository-objective-pages` at `ceb1ee3b`
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T17:30:51Z -->
## Implementation report
Reviewer r1 approvalを受けて、実装ブランチを Orchestrator worktree の `orchestration` branch に統合した。
統合:
- Merge commit: `7ee702b1 merge: repository objective pages`
- Merged branch: `impl/00001KVNGJPRG-repository-objective-pages`
- Implementation commit:
- `ceb1ee3b feat: add repository objective pages`
Orchestrator worktree validation:
- `cargo fmt --check`: passed
- `git diff --check HEAD^1..HEAD`: passed
- `cargo test -p yoi-workspace-server`: passed (`8 passed`)
- `cargo check -p yoi-workspace-server`: passed
- `cd web/workspace && rm -rf node_modules .svelte-kit build && deno task check`: passed (`svelte-check found 0 errors and 0 warnings`)
- `cd web/workspace && deno task build`: passed
- `cargo run -p yoi -- ticket 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 `7ee702b1`
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-21T17:30:58Z from: inprogress to: done reason: implementation_merged_and_validated field: state -->
## State changed
Reviewer approval、Orchestrator worktree への統合、workspace-server tests/check、Deno check/build、Ticket doctor、Nix build が完了したため `done` に遷移する。
---
<!-- event: state_changed author: hare at: 2026-06-21T17:31:14Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-21T17:31:14Z status: closed -->
## 完了
Workspace web に Repository / Objective pages を追加し、Orchestrator worktree の `orchestration` branch に統合した。
主な成果:
- Read-only Repository backend APIs を追加:
- `/api/repositories`
- `/api/repositories/local`
- `/api/repositories/local/log`
- `/api/repositories/local/tickets`
- Current workspace root を local Repository として扱う bounded repository summary を追加。
- Git repository では bounded branch/head/root/dirty/remote/recent log summary を返す。
- Non-Git workspace では `git.status = unavailable` と bounded diagnostics に degrade。
- Git log summary は recent commit hash/subject/author/timestamp に限定し、diff/patch/file content/blame/config は読まない。
- Remote URL summary は URL-scheme userinfo を redact。
- Read-only Ticket Kanban を Ticket state ごとに grouping し、workspace-local Ticket fallback diagnostic を含めた。
- Objective list summaries を filesystem Objective records から追加。
- Static SPA に hash-navigation Repository / Objectives pages を追加。
- Sidebar Repository/Objectives links を新 pages に接続。
- Repository page に summary, Git summary/log, diagnostics, read-only Ticket Kanban を表示。
- Objective page に title/state/updated_at/summary と detail placeholder links を表示。
- Ticket / Objective canonical authority remains filesystem read-through records; mutation API / DB canonical migration は追加していない。
統合・検証:
- Merge commit: `7ee702b1 merge: repository objective pages`
- Implementation commit: `ceb1ee3b feat: add repository objective pages`
- 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`, `deno task check`, `deno task build`, `cargo run -p yoi -- ticket doctor`, and `nix build .#yoi --no-link`
範囲外 / follow-up notes:
- Repository CRUD/API, Objective edit/detail mutation, full Git browser/diff/file views, drag/drop Kanban, and write APIs were not implemented。
- Reviewer noted possible follow-ups: keep `#/objectives` sidebar link visible even on objective empty/error states, and further tighten remote URL sanitization for query-param or SCP-like token forms if needed。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-21T17:31:43Z -->
## Implementation report
Post-close cleanup completed。
- Stopped child Pods and reclaimed scope:
- `yoi-coder-00001KVNGJPRG`
- `yoi-reviewer-00001KVNGJPRG-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/00001KVNGJPRG-repository-objective-pages`
- Deleted implementation branch:
- `impl/00001KVNGJPRG-repository-objective-pages`
- Orchestrator worktree remains clean on `orchestration` at `4b1f1e59`
Root/original workspace was not used for merge/validation/cleanup operations。
---

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260622-085006-1","ticket_id":"00001KVNKD56W","kind":"accepted_plan","accepted_plan":{"summary":"Align Workspace server SQLite bootstrap/migrations and tests with the canonical `schema-v0.md` artifact: typed repository/ticket-target/worker-link/artifact/audit schema, no canonical runs/hosts/workers/generic JSON/validation result tables, while preserving filesystem Ticket/Objective authority and existing read APIs.","branch":"impl/00001KVNKD56W-workspace-db-schema-v0","worktree":"/home/hare/Projects/yoi/.worktree/00001KVNKD56W-workspace-db-schema-v0","role_plan":"Orchestrator creates a dedicated child worktree and spawns a narrow-scope Coder. Reviewer will be spawned read-only after Coder reports implementation/design alignment commit(s). After approval, Orchestrator integrates into `orchestration`, validates workspace-server schema/tests/Nix if needed, records closure, and cleans only the child worktree/branch."},"author":"yoi-orchestrator","at":"2026-06-22T08:50:06Z"}

View File

@ -0,0 +1,21 @@
{
"version": 1,
"relations": [
{
"ticket_id": "00001KVNKD56W",
"kind": "related",
"target": "00001KVMFFYVX",
"note": "Defines canonical DB model for workspace web control plane",
"author": "yoi ticket",
"at": "2026-06-21T17:25:34Z"
},
{
"ticket_id": "00001KVNKD56W",
"kind": "related",
"target": "00001KVNEKH9Q",
"note": "Host/Worker naming and local execution state should align with canonical DB schema",
"author": "yoi ticket",
"at": "2026-06-21T17:25:34Z"
}
]
}

View File

@ -0,0 +1,520 @@
# Workspace DB canonical schema v0 design
## Purpose
This document defines the first concrete Workspace control-plane schema target. It is precise enough that implementation work can create migrations and API read/write surfaces without inventing meanings ad hoc.
The important corrections in this version are:
- **Ticket thread/events remain the execution history authority**.
- A separate top-level `Run` entity is not part of v0.
- Separate `validation_results` / CI tables are not part of v0.
- Worker is not a DB-canonical entity in v0. Worker state is obtained from runtime inspection / Host protocol as a live view.
- Ticket-to-Worker management is represented by typed WorkerRef snapshots on Ticket events and Ticket-Worker association records.
- v0 does **not** use generic JSON payload/metadata columns. If a value matters, give it a typed column or a small relation table. If it is large evidence, store it as an Artifact.
## Schema categories
1. **Current-state records**: long-lived records with stable ids and current snapshots, such as Ticket, Objective, Repository, Artifact.
2. **Event logs**: append-oriented records attached to current-state records, primarily `ticket_events` and `audit_events`.
3. **Relationship records**: explicit links such as Ticket-to-WorkerRef, Ticket-to-Repository target, Objective-to-Ticket.
4. **Snapshot references**: typed authorship / worker / host references embedded in event or relation records. These are not full entities in v0.
5. **Live views**: API results produced by inspecting local runtime or future Host protocol state. Host/Worker lists are live views in v0, not canonical DB tables.
All main tables include `workspace_id`. v0 is SQLite-first, but table shapes should not prevent later Postgres/multi-workspace hosting.
## Design rules
- Ticket and Objective belong to Workspace, not to Repository.
- Repository is a Workspace-connected source/storage. Git Repository is one provider, not the definition of Repository.
- Ticket target selectors are mutable intent/scope. Evidence artifacts may record the concrete repository revision they were produced from with typed source fields.
- Ticket thread is the human-readable and structured execution/audit history for work on that Ticket.
- Ticket current state is a snapshot derived/maintained from structured state transition events.
- Worker is a logical agent/session participating in work, but Worker registry/persistence is out of v0 DB scope.
- Host is an execution environment or observed placement. In v0, Host/Worker information is returned as a live view from local runtime inspection or future Host protocol, not stored as canonical DB records.
- Ticket-associated Worker management uses WorkerRef fields and `ticket_worker_links` snapshots. This lets the Ticket be managed without making the Worker itself DB-canonical.
- Orchestrator should be able to operate from DB/API records only: Ticket, TicketEvents, TicketWorkerLinks, live Host/Worker views, Artifact, and review/evidence summaries.
- Raw fs/Bash/Git authority belongs to Host/Worker execution, not to Orchestrator.
- Memory/Knowledge are intentionally out of v0 canonical schema. They are deferred until Workspace storage migration for Memory.
- Event authorship is mandatory, but a full Actor table is not required in v0.
- Generic JSON columns are intentionally excluded in v0. Do not add `metadata_json`, `payload_json`, `diagnostics_json`, or similar catch-all fields.
## Common columns and conventions
### IDs
Use opaque string ids allocated by the control plane for DB-canonical records.
Recommended prefixes are implementation detail, but the type must be obvious from column names:
- `workspace_id`
- `ticket_id`
- `event_id`
- `objective_id`
- `repository_id`
- `target_id`
- `artifact_id`
- `audit_event_id`
Worker and Host references use `*_ref_kind` / `*_ref_key` in v0 because they are not canonical DB entities.
### Timestamps
Store UTC timestamps as RFC3339 strings in SQLite v0.
Common names:
- `created_at`
- `updated_at`
- `observed_at`
- `started_at`
- `finished_at`
- `closed_at`
- `last_seen_at`
### No catch-all payload columns
v0 avoids generic JSON/text payload columns because they make the schema ambiguous and move authority into untyped blobs.
Rules:
- Fields used for lifecycle transitions, permissions, joins, filtering, or orchestration decisions must be typed columns or relation tables.
- Event kinds may have nullable typed columns such as `subject_kind`, `subject_id`, `previous_state`, `new_state`, `status`, `activity_id`, or `artifact_id`.
- Repository capabilities are derived from `repositories.kind` / `repositories.provider` and backend configuration in v0; do not add a separate capability table until provider-specific overrides are actually needed.
- Paths use relation tables such as `ticket_target_paths`.
- Diagnostics that matter should be Ticket events or Artifacts.
- Large logs, diffs, transcripts, prompts, raw tool outputs, and file contents must not be embedded in records. Store them in an artifact file/blob store and link through Artifact URI records.
- Secrets are never stored in this schema. Secret references, if needed, use typed reference columns such as `auth_ref_kind` and `auth_ref_key`.
## Authorship fields v0
Authorship is an embedded typed snapshot, not a full table in v0.
Use the following columns on event/request/audit records that need authorship:
```text
author_kind text not null
author_key text not null
author_display text not null
author_source_kind text null
author_source_key text null
```
`author_kind` allowed values:
- `human`
- `agent`
- `system`
- `integration`
- `unknown`
`author_key` is stable within its source namespace, for example:
- `local-user`
- `agent:orchestrator`
- `worker:<worker_ref_key>`
- `system:yoi-control-plane`
- `integration:ci:<provider>`
`author_display` is a display snapshot at event creation time. It must be sufficient for historical display even if a future Actor/User record changes name.
`author_source_kind` and `author_source_key` can point to bounded source context such as `worker`, `profile`, `external_account`, or `provider`. They must not hold secrets.
A future `actors` table may be added for auth, assignment, team membership, and permissions. v0 must not require it. If it is added later, historical events still keep their authorship snapshot and may optionally link to `actor_id`.
## WorkerRef and HostRef v0
Worker and Host are runtime concepts in v0. They are referenced by typed snapshots instead of DB foreign keys.
Use WorkerRef fields where a Ticket event, Ticket association, artifact, or check report needs to identify a Worker:
```text
worker_ref_kind text null -- local_pod | remote_worker | hosted_worker | external | unknown
worker_ref_key text null
worker_display text null
```
Examples:
- `worker_ref_kind = local_pod`, `worker_ref_key = coder-sidebar`, `worker_display = Coder sidebar`
- `worker_ref_kind = hosted_worker`, `worker_ref_key = worker_...`, `worker_display = Hosted coder`
Use HostRef fields only when observed placement matters:
```text
host_ref_kind text null -- local | self_hosted | cloud | external | unknown
host_ref_key text null
host_display text null
```
HostRef is not ownership. It means “this Worker or event was observed on this execution environment at this time”.
Future work may add canonical `workers`, `hosts`, `worker_archive`, and `host_connections` tables when Worker lifecycle, persistence, and archive requirements are concrete. v0 deliberately does not create those tables.
## Execution model without a Run entity
v0 does not create a separate `runs` table.
A concrete execution attempt is represented by:
- a `ticket_event` such as `execution_requested`, `worker_assigned`, `worker_status`, `implementation_report`, `review`, `check_report`, `artifact_link`, or `state_transition`;
- optional `activity_id` on related `ticket_events` to group a burst of execution activity;
- `ticket_worker_links` records showing which WorkerRefs are associated with the Ticket and in what role/status;
- `artifacts` linked to `ticket_id`, `event_id`, optional WorkerRef fields, and optional typed repository source revision fields.
`activity_id` is a correlation key, not an authority entity. It can be generated when a user/Orchestrator accepts an execution request, but the Ticket thread remains the authority.
This avoids duplicating Ticket events and Run records while preserving machine-readable execution state.
## Live Host/Worker API view
v0 API may expose Host and Worker lists, but they are live views, not DB tables.
Examples:
- `GET /api/hosts` may inspect the backend-local machine and return one synthetic local Host.
- `GET /api/workers` may scan current local Pod metadata and sockets and return Worker summaries.
- Future Host protocol can provide the same API shape from heartbeat/connection state.
These API responses must not imply DB persistence. If a Worker disappears from runtime inspection, it can disappear from the live view. Durable history belongs to Ticket events, TicketWorkerLinks, and Artifacts.
## Tables
### `workspaces`
```text
workspace_id text primary key
display_name text not null
state text not null -- active | archived
created_at text not null
updated_at text not null
```
### `tickets`
Current Ticket state and body snapshot.
```text
workspace_id text not null
ticket_id text primary key
title text not null
state text not null -- planning | ready | queued | inprogress | done | closed
priority text null
assignee_kind text null
assignee_key text null
assignee_display text null
body_md text not null
created_at text not null
updated_at text not null
closed_at text null
resolution_event_id text null
```
Notes:
- `tickets` stores the current read model.
- Historical changes belong to `ticket_events`.
- Ticket state transitions must be represented by structured `ticket_events`.
- Assignee is a snapshot, not a foreign key to `actors` in v0.
### `ticket_events`
Append-oriented Ticket thread/event log. This is also the execution history authority for work on a Ticket.
```text
workspace_id text not null
event_id text primary key
ticket_id text not null
event_seq integer not null
kind text not null
activity_id text null
author_kind text not null
author_key text not null
author_display text not null
author_source_kind text null
author_source_key text null
created_at text not null
body_md text null
subject_kind text null -- ticket | worker | artifact | check | repository | objective | system
subject_id text null
previous_state text null
new_state text null
status text null
artifact_id text null
worker_ref_kind text null
worker_ref_key text null
worker_display text null
host_ref_kind text null
host_ref_key text null
host_display text null
repository_id text null
caused_by_event_id text null
```
`kind` allowed values in v0:
- `comment`
- `plan`
- `decision`
- `review`
- `implementation_report`
- `state_transition`
- `close`
- `execution_requested`
- `worker_assigned`
- `worker_status`
- `check_report`
- `artifact_link`
- `system_note`
Constraints:
- unique `(ticket_id, event_seq)`.
- events are append-only except administrative repair migrations.
- state transitions and close events must include `previous_state` and `new_state` where applicable.
- execution events should use typed columns such as `activity_id`, WorkerRef fields, `artifact_id`, and `repository_id` instead of opaque payloads.
### `ticket_relations`
```text
workspace_id text not null
source_ticket_id text not null
target_ticket_id text not null
kind text not null -- depends_on | blocks | related | supersedes | duplicate_of
created_at text not null
author_kind text not null
author_key text not null
author_display text not null
author_source_kind text null
author_source_key text null
note text null
primary key (source_ticket_id, target_ticket_id, kind)
```
### `objectives`
```text
workspace_id text not null
objective_id text primary key
title text not null
state text not null -- active | paused | done | closed | archived
body_md text not null
created_at text not null
updated_at text not null
```
### `objective_ticket_links`
```text
workspace_id text not null
objective_id text not null
ticket_id text not null
kind text not null -- tracks | related | milestone | blocker
created_at text not null
primary key (objective_id, ticket_id, kind)
```
### `repositories`
Workspace-connected source/storage. Git is one provider.
```text
workspace_id text not null
repository_id text primary key
name text not null
kind text not null -- git | local | object_store | artifact_store | custom
provider text null -- git, local_fs, s3, etc.
uri text not null
default_ref text null
auth_ref_kind text null
auth_ref_key text null
created_at text not null
updated_at text not null
```
Notes:
- `uri` is identity/config data. It may be redacted in API responses.
- `auth_ref_kind` / `auth_ref_key` contain secret references only, never secret values.
- v0 does not store per-Repository capability rows. Capabilities are derived from `kind`, `provider`, and backend configuration. Add explicit capability/override records later only if a real provider needs per-Repository variance.
### `ticket_targets`
Ticket scope/intent against one or more Repositories.
```text
workspace_id text not null
ticket_id text not null
target_id text not null
repository_id text not null
role text not null -- primary | related | reference | check | output
intent text not null -- read | change | check | output
ref_selector text null
created_at text not null
updated_at text not null
primary key (ticket_id, target_id)
```
### `ticket_target_paths`
```text
workspace_id text not null
ticket_id text not null
target_id text not null
path text not null
primary key (ticket_id, target_id, path)
```
### `ticket_worker_links`
Current relationship between Ticket and a WorkerRef.
```text
workspace_id text not null
ticket_id text not null
worker_ref_kind text not null
worker_ref_key text not null
worker_display text null
role text not null -- companion | intake | orchestrator | coder | reviewer | validator | custom
status text not null -- requested | assigned | active | blocked | completed | released | failed | cancelled
activity_id text null
assigned_at text null
released_at text null
last_event_id text null
primary key (ticket_id, worker_ref_kind, worker_ref_key, role)
```
Notes:
- This is the main DB management relation for Ticket-associated Workers.
- It is not a Worker registry.
- Ticket thread events should record assignment/release/status changes.
### `artifacts`
Evidence/output linked to Ticket, Objective, event, WorkerRef, or Repository source revision.
Artifact content is not stored inline in the DB. Every Artifact points to a URI. The URI may be served by the Workspace backend's artifact/static-file service, a blob store, or an external system.
```text
workspace_id text not null
artifact_id text primary key
kind text not null -- diff | patch | log | report | check_report | review | file | external_link | summary
uri text not null
media_type text null
sha256 text null
size_bytes integer null
summary text null
created_at text not null
created_by_kind text not null
created_by_key text not null
created_by_display text not null
created_by_source_kind text null
created_by_source_key text null
ticket_id text null
objective_id text null
event_id text null
worker_ref_kind text null
worker_ref_key text null
worker_display text null
repository_id text null
source_kind text null -- git_commit | file_snapshot | object_version | custom
source_revision text null -- commit hash, snapshot id, or object version id
```
Rules:
- `uri` is mandatory.
- DB rows store metadata and summary only, never artifact body content.
- `source_kind` / `source_revision` are optional typed source fields for artifacts produced against a concrete repository revision. They do not represent branch/ref selectors; mutable selectors remain on `ticket_targets.ref_selector` or in the related Ticket event.
- Workspace-owned artifact content should use a stable internal URI scheme or backend-served URL, for example `artifact://<workspace_id>/<artifact_id>` or `/api/artifacts/<artifact_id>/content`.
- External artifacts may use redacted `https://...` or provider-specific URIs when policy allows.
- API list/detail responses return artifact metadata and URI by default. Fetching content is a separate artifact-content operation with bounds and permission checks.
## CI / actions-like checks are future work
v0 does not add `validation_results`, `ci_results`, or action tables.
For now, local checks, CI summaries, and check evidence are represented by:
- `ticket_events.kind = check_report` or `artifact_link`;
- Artifacts such as logs, check reports, or external CI URLs;
- Ticket state transitions or review events that reference those artifacts.
If first-class CI status is needed, design it as a separate actions-like subsystem rather than a generic validation table inside the core Ticket schema. That future subsystem should model workflow/check names, jobs, steps, attempts, statuses, logs, annotations, external provider ids, retention, and rerun semantics explicitly.
### `audit_events`
Control-plane operation audit trail.
```text
workspace_id text not null
audit_event_id text primary key
created_at text not null
actor_kind text not null
actor_key text not null
actor_display text not null
actor_source_kind text null
actor_source_key text null
action text not null
target_kind text not null
target_id text null
outcome text not null -- allowed | denied | succeeded | failed
request_id text null
summary text null
```
Audit events record the control-plane action and outcome. They should not duplicate full Ticket event payloads unless needed for audit.
## Read surfaces for Orchestrator without fs/Bash
The DB/API must let an Orchestrator read:
- Ticket current state, thread events, relations, targets, and TicketWorkerLinks.
- Objective body and linked Tickets.
- Repository summaries, Ticket target selectors, and Artifact source revision fields.
- Live Host/Worker views from runtime inspection or future Host protocol.
- Artifact summaries and selected artifact contents through bounded artifact APIs.
- Check/CI summaries as TicketEvents and Artifacts.
- Review evidence as TicketEvents/Artifacts.
## Write surfaces for Orchestrator without fs/Bash
The DB/API must let an Orchestrator create:
- Ticket comments/decisions/state transition requests.
- Ticket execution request events with target selectors and optional `activity_id`.
- TicketWorkerLink assignment/release/status changes.
- Review/check request events.
- Artifact links for logs, reports, diffs, CI/external check URLs, and review evidence.
- Close/done decisions that reference evidence artifacts and structured Ticket events.
The Orchestrator must not need raw repository filesystem reads, shell execution, or direct Git merge authority to perform control-plane routing.
## Migration stance
v0 implementation should support three modes conceptually:
1. `filesystem_read_through`: current `.yoi/tickets` and `.yoi/objectives` remain authority; DB holds runtime/projection tables.
2. `imported_projection`: filesystem records are imported into DB read models, but filesystem remains the write authority.
3. `db_authority`: Ticket/Objective write path moves to DB; filesystem export becomes compatibility/export snapshot.
This Ticket designs the schema target and can implement non-breaking migrations, but it does not require switching active authority to DB.
## Minimal implementation guidance
If implementation is included in this Ticket, prefer a small non-breaking migration:
- Keep Host/Worker API as live runtime views in v0.
- Add explicit schema versioning.
- Add tables that are safe to create empty: `repositories`, `ticket_targets`, `ticket_target_paths`, `ticket_worker_links`, `artifacts`, `audit_events`.
- Keep existing filesystem read APIs working.
- Do not create a full `actors` table in v0.
- Do not create `hosts` / `workers` canonical tables in v0.
- Do not create a separate `runs` table in v0; use structured Ticket events and TicketWorkerLink relationships.
## Implementation alignment notes
The `yoi-workspace-server` SQLite bootstrap migration implements this v0 schema as schema version 2. Fresh databases create the typed tables listed above and deliberately do not create canonical `runs`, `hosts`, `workers`, `actors`, or check/validation result tables. Host and Worker HTTP read APIs remain live runtime views backed by local inspection, not DB tables.
For databases created by the earlier workspace-server bootstrap, migration version 2 preserves old `workspaces`, `repositories`, `runs`, `artifacts`, `ticket_projections`, and `objective_projections` data by renaming those tables to `legacy_workspaces`, `legacy_repositories`, `legacy_runs`, `legacy_artifacts`, `legacy_ticket_projections`, and `legacy_objective_projections`, then creating the v0 typed tables. Existing legacy workspace rows are copied into the canonical v0 `workspaces` table with `state = active` when the old row had no typed state. The legacy names are compatibility preservation only and are not canonical schema tables or active write authority.

View File

@ -0,0 +1,131 @@
---
title: 'Workspace DB canonical schema design'
state: 'closed'
created_at: '2026-06-21T17:24:43Z'
updated_at: '2026-06-22T09:27:06Z'
assignee: null
queued_by: 'workspace-panel'
queued_at: '2026-06-22T08:48:39Z'
---
## 背景
Workspace web control plane は、Rust backend / SQLite store / static SPA / read-only filesystem bridge まで立ち上がっている。ただし現在の SQLite schema は起動・API skeleton 用の足場であり、Ticket / Objective / Repository / Host / Worker / Artifact を将来 DB 正本にするための canonical schema はまだ固まっていない。
DB 設計は過度に難しく扱わず、まずは既存 filesystem Ticket/Objective model と現在の orchestration 運用を素直に relational/event model へ写す。重要なのは、`.yoi` filesystem record をすぐ捨てることではなく、Web/API/Orchestrator が将来 DB authority に移れるだけの record 境界を決めること。
長期方針:
- Ticket / Objective は Workspace 配下に平たく存在する。
- Repository は Workspace に接続される source/storage であり、Git Repository はその一種。
- Ticket は必要に応じて Repository target selector を持つ。
- Ticket thread/events が実行履歴の authority であり、Ticket state は current snapshot として持つ。
- Worker は Ticket に関連づく logical agent/session だが、v0 では DB 正本として永続化しない。Host/Worker 一覧は runtime inspection / future Host protocol から逐次取得する live view とする。
- Worker の一元管理、データ永続化、アーカイブは将来必要になるが、Host/Worker protocol と lifecycle requirements が固まるまで v0 schema には入れない。
- Host は Worker が観測される実行環境または capacity を表すが、v0 では canonical table ではなく live view とする。
- CI/check information should be represented as Ticket events plus Artifact links in v0. If richer CI status is needed, design it later as a separate actions-like subsystem rather than a generic validation table.
- Orchestrator は将来的に fs/Bash/Git を直接持たず、Ticket / TicketEvent / TicketWorkerLink / live Host/Worker view / Artifact / Review の DB/API surface だけを見る。
- Memory / Knowledge の本格再設計はこの Ticket では扱わず、保存先を Workspace backend へ移す時に回収する。
## 要件
### Canonical records / tables
DB design は `artifacts/schema-v0.md` を主たる設計記録とする。Ticket 本文では scope と要求だけを示す。
`schema-v0.md` では以下を明確に定義する。
- record / event / reference の区別。
- ID / timestamp / no-catch-all-payload column conventions。
- `AuthorRef` v0。
- event author/source を記録する typed snapshot。
- 初期実装では full `actors` table を必須にしない。
- auth / permission / assignment / team membership が必要になった時点で `actors` table へ昇格できる。
- table candidates and required columns:
- `workspaces`
- `tickets`
- `ticket_events`
- `ticket_relations`
- `objectives`
- `objective_ticket_links`
- `repositories`
- `ticket_targets`
- `ticket_target_paths`
- `ticket_worker_links`
- `artifacts`
- `audit_events`
- Orchestrator no-fs/no-bash の read/write surfaces。
- filesystem read-through から DB authority への migration modes。
初期設計で曖昧な `actors` entity を置かない。必要なのは event authorship であり、v0 では `AuthorRef` fields として扱う。
### Authority / migration stance
- 当面は `.yoi/tickets` / `.yoi/objectives` filesystem read-through を維持する。
- DB schema は canonical target として設計するが、この Ticket だけで full migration はしない。
- import/projection/export の方針を決める。
- filesystem -> DB import。
- DB -> filesystem export / compatibility snapshot。
- read-through bridge と DB authority の切り替え条件。
- 二重正本を避けるため、write path をいつ DB に切り替えるかを明記する。
- SQLite first でよいが、将来 Postgres/multi-tenant へ進めるよう、`workspace_id` を全主要 record に含める。
### Orchestrator no-fs/no-bash surface
DB/API だけを見て Orchestrator が判断できるように、以下の read/write surface を設計する。
Orchestrator が読むもの:
- Ticket state/thread/relations/targets。
- Ticket-associated WorkerRef links and worker status events。
- Objective context。
- Live Host/Worker view from runtime inspection or future Host protocol。
- Repository target and artifact source revision fields。
- Artifact summaries/diff metadata/log summaries。
- Review evidence。
Orchestrator が作るもの:
- Ticket comment/decision/state transition request。
- Ticket execution request / WorkerRef assignment / worker status events。
- Worker job request / assignment request。
- Review/check request。
- Close/done decision with evidence references。
fs/Bash/Git 操作は Host/Worker に閉じ込める。Orchestrator は raw repository filesystem や shell access を authority として持たない。
### Initial implementation slice
この Ticket は design-only でもよいが、可能なら最小 schema migration まで含める。
- `crates/workspace-server` の SQLite schema を canonical design に合わせて整理する。
- 既存 placeholder `runs` / `runners` naming を、structured ticket events / live Host/Worker API / `ticket_worker_links` へ寄せる。
- Migration versioning の方針を明記する。
- Existing read API を壊さない。
## Non-goals
- Full DB migration of existing `.yoi/tickets` / `.yoi/objectives`
- Web UI からの Ticket/Objective mutation 実装。
- Memory / Knowledge の本格再設計。
- Multi-tenant production SaaS schema の完全設計。
- Auth/billing/quota/security の完全実装。
- Git hosting service の実装。
- Orchestrator profile から fs/Bash を実際に剥がすこと。
## 受け入れ条件
- Workspace DB canonical schema design が `artifacts/schema-v0.md` または同等の design record として記録されている。
- `schema-v0.md` が record / event / reference の区別を定義している。
- Table/record 境界として Workspace / Ticket / TicketEvent / TicketRelation / Objective / ObjectiveTicketLink / Repository / TicketTarget / TicketTargetPath / TicketWorkerLink / Artifact / AuditEvent が定義されている。
- Separate top-level Run entity/table を v0 では作らず、structured Ticket events と TicketWorkerLink relation を execution history/management surface とする方針が明記されている。
- Host/Worker は v0 DB canonical table ではなく live runtime view とし、Ticket には WorkerRef snapshot/link を保存する方針が明記されている。
- Event/request/audit authorship は `AuthorRef` v0 として required fields まで定義されている。
- 初期実装で full Actor entity/table を必須にしない方針と、将来 actors table へ昇格できる条件が明記されている。
- CI/check status は v0 core schema では Ticket events + Artifact links として扱い、actions-like subsystem は future work とする方針が明記されている。
- `.yoi` filesystem read-through から DB authority へ移る migration/export/import 方針が明記されている。
- Orchestrator no-fs/no-bash を可能にする DB/API read/write surface が明記されている。
- Memory / Knowledge は deferred として扱われ、この schema design の必須 scope から外れている。
- SQLite first だが、全主要 record に `workspace_id` を持たせる方針が明記されている。
- 既存 `runner` placeholder naming を Host/Worker に移す方針が明記されている。
- 実装まで含める場合は `cargo fmt --check`、`cargo test -p yoi-workspace-server`、`cargo check`、`git diff --check`、`yoi ticket doctor`、`nix build .#yoi --no-link` が通る。

View File

@ -0,0 +1,36 @@
Workspace DB canonical schema v0 design を `schema-v0.md` artifact と workspace-server SQLite bootstrap/migrations に整合させ、Orchestrator worktree の `orchestration` branch に統合した。
主な成果:
- SQLite schema version を `2` に進め、canonical schema v0 を実装。
- Typed v0 tables を追加:
- `tickets`
- `ticket_events`
- `ticket_relations`
- `objectives`
- `objective_ticket_links`
- `repositories`
- `ticket_targets`
- `ticket_target_paths`
- `ticket_worker_links`
- `artifacts`
- `audit_events`
- Canonical v0 から top-level `runs`, `hosts`, `workers`, `actors`, validation/CI result tables を除外。
- Generic `metadata_json`, `payload_json`, `diagnostics_json` のような catch-all payload columns を canonical v0 tables に追加しない方針を維持。
- `/api/runs` と frontend Runs card/reference を削除し、404 test を追加。
- Host/Worker APIs は DB authority ではなく live runtime views として維持。
- Legacy bootstrap tables は non-canonical `legacy_*` preservation tables に demote。
- Legacy `workspaces``legacy_workspaces` に preserve し、active canonical `workspaces` を v0 column set で作り直して既存行を copy。
- Post-upgrade `upsert_workspace()` for new workspace id が通る regression test を追加。
- `schema-v0.md` に SQLite version 2 / legacy preservation alignment notes を追加。
統合・検証:
- Merge commit: `38bd122d merge: workspace db schema v0`
- Implementation commits: `5149ab70`, `d89ace5b`
- 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`, `deno task check`, `deno task build`, `cargo run -p yoi -- ticket doctor`, and `nix build .#yoi --no-link`
範囲外:
- Ticket/Objective write authority migration to DB is not implemented。
- Host/Worker canonical DB tables are not added。
- Validation/CI result tables and Actor table are not added。
- Full TicketEvents/TicketWorkerLinks/Artifacts write surfaces are not implemented beyond schema/bootstrap alignment。

View File

@ -0,0 +1,541 @@
<!-- event: create author: "yoi ticket" at: 2026-06-21T17:24:43Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: hare at: 2026-06-21T17:25:35Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-06-21T17:25:35Z from: planning to: ready reason: cli_state field: state -->
## State changed
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: workspace-panel at: 2026-06-22T08:48:39Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-22T08:49:46Z -->
## Decision
Routing decision: `implementation_ready`
Reason:
- Ticket includes a concrete `schema-v0.md` artifact defining the Workspace DB canonical schema target and migration stance。
- Artifact is precise enough to implement a non-breaking schema/migration alignment slice and/or finish design documentation without inventing semantics。
- Relations are non-blocking context relations; no blocker relation is recorded。
- Current Workspace backend has an older bootstrap SQLite schema in `crates/workspace-server/src/store.rs` with `runs`, `ticket_projections`, `objective_projections`, and run-linked artifacts, while `schema-v0.md` explicitly says no separate `Run` entity and Host/Worker are live views. This gives a concrete implementation alignment target。
- Orchestrator worktree is clean on `orchestration` at `b7c890d3`; target worktree / branch is not present。
- Current queued Ticket is this Ticket only。
Evidence checked:
- Ticket body / thread / artifacts via `TicketShow` and direct read of `artifacts/schema-v0.md`
- `TicketRelationQuery(00001KVNKD56W)`: 2 relation records, no blocking relation reported。
- `TicketOrchestrationPlanQuery(00001KVNKD56W)`: no records。
- `TicketList(state=queued)`: this Ticket is the only queued Ticket。
- Orchestrator git state / branch list checked from `/home/hare/Projects/yoi/.worktree/orchestration` only。
- Bounded code map:
- `crates/workspace-server/src/store.rs` currently owns SQLite migrations and schema versioning。
- Current store still has bootstrap `runs` and projection-oriented tables; `schema-v0.md` says v0 should not include separate `runs`, Host/Worker canonical DB tables, generic JSON blobs, or validation result tables。
- Current repository/objective/ticket read APIs are filesystem read-through and must keep working。
IntentPacket:
Intent:
- Turn the Workspace DB canonical schema v0 design into a durable implementation/design artifact and align the current SQLite bootstrap migration where safely non-breaking。
Binding decisions / invariants:
- Ticket and Objective filesystem records remain the active authority; do not switch write authority to DB。
- No generic JSON/payload/metadata columns in the v0 canonical schema。
- Do not add canonical `hosts` / `workers` DB tables; Host/Worker remain live runtime views。
- Do not add a top-level `runs` canonical table; execution history belongs to Ticket events, TicketWorkerLinks, and Artifacts。
- Do not add CI/validation result tables in this Ticket。
- Do not add Actor table in v0; authorship is embedded typed snapshot fields。
- Keep existing Workspace APIs/tests working。
- Non-breaking migration is acceptable; destructive migration of existing user DBs is not required in this slice unless clearly safe and tested。
Requirements / acceptance criteria:
- `schema-v0.md` remains consistent with implemented/bootstrap migration semantics。
- Workspace-server migration/schema tests cover new v0 schema version and expected tables/absence of forbidden tables。
- Empty/safe tables from minimal implementation guidance may be added: `repositories`, `ticket_targets`, `ticket_target_paths`, `ticket_worker_links`, `artifacts`, `audit_events`, and supporting Ticket/Objective current/event tables if included。
- Remove or replace obsolete `runs` table/API/store concept if it conflicts with v0, or clearly demote it to non-canonical compatibility only if removal is out of scope。
- No canonical host/worker tables are added。
- Existing filesystem read-through APIs for Tickets/Objectives/Repository pages keep passing。
- Docs/tests make clear that Host/Worker API is live view and Ticket thread/events are execution authority。
Implementation latitude:
- If full schema migration is too large, implement the smallest safe migration alignment plus tests and record remaining tables as future follow-up comments。
- Prefer typed tables/columns over catch-all text/json columns。
- SQLite schema version can advance if migrations change。
- `runs` API placeholder may be removed if no longer used; ensure frontend/tests do not depend on it。
- Keep API surface read-only unless Ticket explicitly requires otherwise; this one does not require write APIs。
Escalate if:
- Removing current `runs` table/API would break existing accepted frontend or tests in a way that needs product decision。
- Existing DB migration compatibility requires a complex destructive migration or data backfill policy。
- Implementing full TicketEvents/TicketWorkerLinks/Artifacts read/write surfaces grows beyond schema/bootstrap alignment。
- Schema design conflicts with recent Repository/Objectives pages in a way not anticipated by artifact。
Validation plan:
- `cargo fmt --check`
- `cargo test -p yoi-workspace-server`
- `cargo check -p yoi-workspace-server`
- `git diff --check`
- `cargo run -p yoi -- ticket doctor`
- `nix build .#yoi --no-link` if package/source/dependency behavior changes。
- Deno check/build only if frontend/runs API removal touches frontend。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-22T08:49:57Z from: queued to: inprogress reason: human_authorized_schema_design_alignment field: state -->
## State changed
Human authorized queue routing from Workspace Dashboard. Ticket has a concrete schema-v0 design artifact and current workspace-server migration has a clear non-breaking alignment target, so Orchestrator accepts implementation/design alignment.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-22T08:51:00Z -->
## Implementation report
Implementation start report:
- Created child implementation worktree:
- `/home/hare/Projects/yoi/.worktree/00001KVNKD56W-workspace-db-schema-v0`
- Created branch:
- `impl/00001KVNKD56W-workspace-db-schema-v0`
- Base commit:
- `f17a458a ticket: accept workspace db schema v0`
- 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-00001KVNKD56W`
- Coder scope:
- read: `/home/hare/Projects/yoi`
- write: `/home/hare/Projects/yoi/.worktree/00001KVNKD56W-workspace-db-schema-v0`
Next action:
- Wait for Coder implementation/design-alignment report, then spawn Reviewer read-only for the implementation diff。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-22T09:02:51Z -->
## Implementation report
Coder implementation report received from `yoi-coder-00001KVNKD56W`.
Implementation commit:
- `5149ab70 workspace: implement db schema v0 bootstrap`
Changed areas:
- `.yoi/tickets/00001KVNKD56W/artifacts/schema-v0.md`
- Added implementation alignment notes for SQLite schema version 2。
- Documented legacy preservation/demotion for old bootstrap tables。
- `crates/workspace-server/src/store.rs`
- Replaced old bootstrap schema with Workspace DB canonical schema v0 tables。
- Advanced schema version to `2`
- Added typed v0 tables: `tickets`, `ticket_events`, `ticket_relations`, `objectives`, `objective_ticket_links`, `repositories`, `ticket_targets`, `ticket_target_paths`, `ticket_worker_links`, `artifacts`, `audit_events`
- Removed active/canonical `runs` store concept and `RunSummary`
- Added legacy migration handling that renames old bootstrap tables to `legacy_*` preservation tables instead of treating them as canonical。
- Added tests for fresh v0 schema boundaries and legacy upgrade behavior, including forbidden table absence。
- `crates/workspace-server/src/server.rs`
- Removed `/api/runs` route。
- Kept Host/Worker APIs as live runtime views。
- Added test coverage that `/api/runs` is no longer served。
- `web/workspace/src/routes/+page.svelte`
- Removed Runs endpoint card/reference。
Coder validation reported:
- `cargo fmt --check`: passed
- `cargo test -p yoi-workspace-server`: passed
- `cargo check -p yoi-workspace-server`: passed
- `git diff --check`: passed
- `cargo run -p yoi -- ticket doctor`: passed (`doctor: ok`)
- `cd web/workspace && deno task check`: passed
- `cd web/workspace && deno task build`: passed
Not run by Coder:
- `nix build .#yoi --no-link`: not run because no dependency/package/source-filter changes。
Orchestrator evidence checked:
- Worktree clean on `impl/00001KVNKD56W-workspace-db-schema-v0` at `5149ab70`
- Diff `f17a458a..HEAD` touches expected design/backend/frontend files only。
- `git diff --check f17a458a..HEAD`: passed。
- Deno validation generated ignored frontend artifacts; Orchestrator removed `web/workspace/node_modules/`, `.svelte-kit/`, and `build/` before Reviewer routing。
Next action:
- Route to read-only Reviewer Pod for acceptance review。
---
<!-- event: comment author: yoi-orchestrator at: 2026-06-22T09:03:49Z -->
## Comment
Review routing:
Spawned read-only Reviewer Pod:
- `yoi-reviewer-00001KVNKD56W-r1`
Review target:
- Worktree: `/home/hare/Projects/yoi/.worktree/00001KVNKD56W-workspace-db-schema-v0`
- Branch: `impl/00001KVNKD56W-workspace-db-schema-v0`
- Baseline: `f17a458a`
- Implementation commit: `5149ab70 workspace: implement db schema v0 bootstrap`
- Diff: `f17a458a..5149ab70`
Reviewer scope:
- read-only `/home/hare/Projects/yoi`
Reviewer focus:
- Consistency between `schema-v0.md` and implemented SQLite migration/version 2。
- Absence of canonical `runs`, `hosts`, `workers`, `actors`, generic JSON/payload/metadata columns, and validation/CI result tables。
- Legacy table preservation/demotion behavior。
- Existing read APIs for Tickets/Objectives/Repositories/Hosts/Workers still work。
- `/api/runs` removal is intentional and test-covered。
- Design artifact updates remain concise and consistent。
Orchestrator will wait for reviewer verdict before integration。
---
<!-- event: review author: yoi-reviewer-00001KVNKD56W-r1 at: 2026-06-22T09:13:06Z status: request_changes -->
## Review: request changes
Verdict: `request_changes`
確認範囲:
- Ticket/design authority:
- `.yoi/tickets/00001KVNKD56W/item.md`
- `.yoi/tickets/00001KVNKD56W/thread.md`
- `.yoi/tickets/00001KVNKD56W/artifacts/schema-v0.md`
- Implementation diff `f17a458a..5149ab70`:
- `crates/workspace-server/src/store.rs`
- `crates/workspace-server/src/server.rs`
- `web/workspace/src/routes/+page.svelte`
- schema artifact updates
- Target branch/head:
- Branch: `impl/00001KVNKD56W-workspace-db-schema-v0`
- Head: `5149ab703f63`
Blocking issue:
1. Legacy-upgraded `workspaces` table remains non-canonical and can reject new workspace inserts。
Fresh schema defines canonical `workspaces` as:
- `workspace_id`
- `display_name`
- `state`
- `created_at`
- `updated_at`
This matches `schema-v0.md`
However, legacy migration does not rebuild or demote the old bootstrap `workspaces` table. It only adds `state`:
- `crates/workspace-server/src/store.rs:198-200`
- `ALTER TABLE workspaces ADD COLUMN state TEXT NOT NULL DEFAULT 'active';`
Then `create_schema_v0_tables()` is called, but because it uses `CREATE TABLE IF NOT EXISTS workspaces`, the old table is left in place:
- `crates/workspace-server/src/store.rs:218-227`
The old v1 bootstrap `workspaces` table had extra `local_root TEXT NOT NULL` and `record_authority TEXT NOT NULL` columns. Those remain on upgraded DBs even though they are not part of canonical v0。
This is not merely cosmetic: `upsert_workspace()` now inserts only v0 columns:
- `crates/workspace-server/src/store.rs:87-89`
For an upgraded legacy DB, inserting a new `workspace_id` would fail on old `local_root` / `record_authority` NOT NULL columns because the insert no longer supplies them. Updating the already-existing legacy workspace may pass due conflict/update path, which is why current tests do not catch this。
Why this blocks:
- Upgraded canonical schema does not actually match `schema-v0.md`
- Legacy handling is not safe enough because active canonical `workspaces` remains partly old schema and incompatible with the new write path。
Required fix:
- Rebuild/copy `workspaces` into canonical v0 shape, or otherwise explicitly preserve old workspace data without leaving obsolete NOT NULL columns on the active canonical table。
- Add tests asserting upgraded `workspaces` column set。
- Add a post-upgrade insert/upsert test for a new workspace id。
Non-blocking concerns:
- Existing tests cover fresh forbidden table absence and legacy demotion for old `runs` / projections / artifact / repository tables, but not upgraded `workspaces` column set or post-upgrade insert; add coverage with the fix。
- Runs card/reference removal is narrow and `/api/runs` removal is test-covered with 404 assertion。
Reviewer validation:
- `cargo fmt --check`: passed
- `git diff --check f17a458a..HEAD`: passed
- `cargo test -p yoi-workspace-server`: passed (`10 passed`)
- `cargo check -p yoi-workspace-server`: passed
- `cd web/workspace && rm -rf node_modules .svelte-kit build && deno task check && deno task build`: passed
- `cargo run -p yoi -- ticket doctor`: passed (`doctor: ok`)
- `nix build .#yoi --no-link`: passed
- `nix build .#yoi --no-link --print-out-paths`: passed/cached output `/nix/store/i0dr920x6kf217zddbnaqyljh8l1gwri-yoi-0.1.0`
Generated/ignored artifacts remain in reviewer worktree after validation:
- `target/`
- `web/workspace/node_modules/`
- `web/workspace/.svelte-kit/`
- `web/workspace/build/`
Worktree status:
- Tracked status clean; only ignored generated artifacts present。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-22T09:18:29Z -->
## Implementation report
Coder r1-fix report received from `yoi-coder-00001KVNKD56W`.
New fix commit:
- `d89ace5b workspace: canonicalize legacy workspaces`
Branch commits now:
- `5149ab70 workspace: implement db schema v0 bootstrap`
- `d89ace5b workspace: canonicalize legacy workspaces`
Fix summary:
- `crates/workspace-server/src/store.rs`
- Legacy migration now preserves old non-canonical `workspaces` by renaming it to `legacy_workspaces`
- Active canonical v0 `workspaces` is created with only:
- `workspace_id`
- `display_name`
- `state`
- `created_at`
- `updated_at`
- Existing legacy workspace rows are copied into canonical `workspaces`, defaulting `state` to `active` when no typed state existed。
- Previous legacy demotion for `repositories`, `runs`, `artifacts`, `ticket_projections`, and `objective_projections` is preserved。
- Added upgrade test assertions for:
- upgraded `workspaces` column set exactly matches v0。
- `legacy_workspaces` preserves old obsolete columns。
- old workspace data is copied into canonical table。
- post-upgrade `upsert_workspace()` succeeds for a new workspace id。
- `.yoi/tickets/00001KVNKD56W/artifacts/schema-v0.md`
- Updated implementation alignment notes to document `legacy_workspaces` preservation and canonical copy behavior。
Coder validation reported:
- `cargo fmt --check`: passed
- `cargo test -p yoi-workspace-server`: passed
- `cargo check -p yoi-workspace-server`: passed
- `git diff --check f17a458a..HEAD`: passed
- `cargo run -p yoi -- ticket doctor`: passed
Not rerun by Coder for the r1 fix:
- Deno check/build and Nix build, because the fix touched only schema artifact and workspace-server store code and Reviewer had validated frontend/Nix before the blocker。
Generated artifact cleanup:
- Coder removed reviewer-generated frontend artifacts:
- `web/workspace/node_modules/`
- `web/workspace/.svelte-kit/`
- `web/workspace/build/`
Orchestrator evidence checked:
- Worktree clean on `impl/00001KVNKD56W-workspace-db-schema-v0` at `d89ace5b`
- r1-fix diff `5149ab70..HEAD` touches only `crates/workspace-server/src/store.rs` and `schema-v0.md`
- `git diff --check f17a458a..HEAD`: passed。
- No ignored frontend artifacts remain under `web/workspace`
Next action:
- Route back to Reviewer for r2 verification of legacy `workspaces` canonicalization and final acceptance。
---
<!-- event: review author: yoi-reviewer-00001KVNKD56W-r1 at: 2026-06-22T09:24:03Z status: approve -->
## Review: approve
Verdict: `approve`
確認範囲:
- r1-fix diff `5149ab70..d89ace5b`:
- `crates/workspace-server/src/store.rs`
- `.yoi/tickets/00001KVNKD56W/artifacts/schema-v0.md`
- Overall diff `f17a458a..d89ace5b`:
- `.yoi/tickets/00001KVNKD56W/artifacts/schema-v0.md`
- `crates/workspace-server/src/server.rs`
- `crates/workspace-server/src/store.rs`
- `web/workspace/src/routes/+page.svelte`
Previous blocker verification:
- Resolved。
Evidence:
- Non-canonical legacy `workspaces` is now detected by exact v0 column comparison and renamed to `legacy_workspaces`
- Canonical v0 `workspaces` is then created with exactly:
- `workspace_id`
- `display_name`
- `state`
- `created_at`
- `updated_at`
- Legacy workspace rows are copied into canonical `workspaces`, with `state` defaulted to `active` when absent。
- Tests assert:
- upgraded `workspaces` columns exactly match v0。
- `legacy_workspaces` preserves old obsolete columns。
- legacy workspace data is readable through canonical `get_workspace`
- post-upgrade `upsert_workspace()` succeeds for a new workspace id。
Blocking issues:
- None。
Non-blocking concerns:
- None requiring changes。
Ticket/design fit:
- Fresh schema creates typed v0 tables and does not create canonical top-level `runs`, `hosts`, `workers`, `actors`, or validation/CI result tables。
- Legacy `runs` is preserved only as `legacy_runs`; it does not masquerade as canonical。
- Host/Worker APIs remain live runtime views, not DB authority。
- `/api/runs` remains removed/404-tested。
- Canonical schema avoids generic `metadata_json`, `payload_json`, `diagnostics_json`, and catch-all payload columns。
- `schema-v0.md` update is narrow and consistent with migration behavior。
- No accidental frontend authority/business-logic expansion found。
Reviewer validation:
- `cargo fmt --check`: passed
- `git diff --check f17a458a..HEAD`: passed
- `cargo test -p yoi-workspace-server`: passed (`10 passed`)
- `cargo check -p yoi-workspace-server`: passed
- `cargo run -p yoi -- ticket doctor`: passed (`doctor: ok`)
- `nix build .#yoi --no-link --print-out-paths`: passed, output `/nix/store/fnz7650sshl3md2vdwb4a1q8dc0hf542-yoi-0.1.0`
Not rerun in r2:
- Deno frontend check/build, because r1-fix diff touches only `store.rs` and `schema-v0.md`; r1 had already validated frontend for original `/api/runs` card removal。
Worktree status:
- Clean tracked status on `impl/00001KVNKD56W-workspace-db-schema-v0` at `d89ace5b`
- Ignored artifact present: `target/` only; no ignored frontend artifacts under `web/workspace`
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-22T09:25:49Z -->
## Implementation report
Reviewer r2 approvalを受けて、実装ブランチを Orchestrator worktree の `orchestration` branch に統合した。
統合:
- Merge commit: `38bd122d merge: workspace db schema v0`
- Merged branch: `impl/00001KVNKD56W-workspace-db-schema-v0`
- Implementation commits:
- `5149ab70 workspace: implement db schema v0 bootstrap`
- `d89ace5b workspace: canonicalize legacy workspaces`
Orchestrator worktree validation:
- `cargo fmt --check`: passed
- `git diff --check HEAD^1..HEAD`: passed
- `cargo test -p yoi-workspace-server`: passed (`10 passed`)
- `cargo check -p yoi-workspace-server`: passed
- `cd web/workspace && rm -rf node_modules .svelte-kit build && deno task check`: passed (`svelte-check found 0 errors and 0 warnings`)
- `cd web/workspace && deno task build`: passed
- `cargo run -p yoi -- ticket 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 `38bd122d`
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-22T09:26:02Z from: inprogress to: done reason: implementation_merged_and_validated field: state -->
## State changed
Reviewer approval、Orchestrator worktree への統合、workspace-server tests/check、Deno check/build、Ticket doctor、Nix build が完了したため `done` に遷移する。
---
<!-- event: state_changed author: hare at: 2026-06-22T09:26:19Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-22T09:26:19Z status: closed -->
## 完了
Workspace DB canonical schema v0 design を `schema-v0.md` artifact と workspace-server SQLite bootstrap/migrations に整合させ、Orchestrator worktree の `orchestration` branch に統合した。
主な成果:
- SQLite schema version を `2` に進め、canonical schema v0 を実装。
- Typed v0 tables を追加:
- `tickets`
- `ticket_events`
- `ticket_relations`
- `objectives`
- `objective_ticket_links`
- `repositories`
- `ticket_targets`
- `ticket_target_paths`
- `ticket_worker_links`
- `artifacts`
- `audit_events`
- Canonical v0 から top-level `runs`, `hosts`, `workers`, `actors`, validation/CI result tables を除外。
- Generic `metadata_json`, `payload_json`, `diagnostics_json` のような catch-all payload columns を canonical v0 tables に追加しない方針を維持。
- `/api/runs` と frontend Runs card/reference を削除し、404 test を追加。
- Host/Worker APIs は DB authority ではなく live runtime views として維持。
- Legacy bootstrap tables は non-canonical `legacy_*` preservation tables に demote。
- Legacy `workspaces``legacy_workspaces` に preserve し、active canonical `workspaces` を v0 column set で作り直して既存行を copy。
- Post-upgrade `upsert_workspace()` for new workspace id が通る regression test を追加。
- `schema-v0.md` に SQLite version 2 / legacy preservation alignment notes を追加。
統合・検証:
- Merge commit: `38bd122d merge: workspace db schema v0`
- Implementation commits: `5149ab70`, `d89ace5b`
- 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`, `deno task check`, `deno task build`, `cargo run -p yoi -- ticket doctor`, and `nix build .#yoi --no-link`
範囲外:
- Ticket/Objective write authority migration to DB is not implemented。
- Host/Worker canonical DB tables are not added。
- Validation/CI result tables and Actor table are not added。
- Full TicketEvents/TicketWorkerLinks/Artifacts write surfaces are not implemented beyond schema/bootstrap alignment。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-22T09:27:06Z -->
## Implementation report
Post-close cleanup completed。
- Stopped child Pods and reclaimed scope:
- `yoi-coder-00001KVNKD56W`
- `yoi-reviewer-00001KVNKD56W-r1`
- Removed ignored frontend validation artifacts from child worktree before worktree removal if present:
- `web/workspace/node_modules/`
- `web/workspace/.svelte-kit/`
- `web/workspace/build/`
- Removed implementation worktree:
- `/home/hare/Projects/yoi/.worktree/00001KVNKD56W-workspace-db-schema-v0`
- Deleted implementation branch:
- `impl/00001KVNKD56W-workspace-db-schema-v0`
- Orchestrator worktree remains clean on `orchestration` at `7a7891d4`
Root/original workspace was not used for merge/validation/cleanup operations。
---

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260623-054125-1","ticket_id":"00001KVSEBF56","kind":"accepted_plan","accepted_plan":{"summary":"Feature-split `crates/protocol` so tokio JSONL stream is optional, generate Workspace web TypeScript types from protocol wire DTOs with drift checking, import generated types from frontend, and record minimal backend proxy stance without implementing full Worker operation UI.","branch":"impl/00001KVSEBF56-protocol-typescript-types","worktree":"/home/hare/Projects/yoi/.worktree/00001KVSEBF56-protocol-typescript-types","role_plan":"Orchestrator creates a dedicated child worktree and spawns a narrow-scope Coder. Reviewer will be spawned read-only after Coder reports implementation commit(s). After approval, Orchestrator integrates into `orchestration`, validates protocol/frontend/Nix, records closure, and cleans only the child worktree/branch."},"author":"yoi-orchestrator","at":"2026-06-23T05:41:25Z"}

View File

@ -0,0 +1,87 @@
---
title: 'Generate Workspace web TypeScript types from protocol crate'
state: 'inprogress'
created_at: '2026-06-23T05:13:22Z'
updated_at: '2026-06-23T05:42:14Z'
assignee: null
queued_by: 'workspace-panel'
queued_at: '2026-06-23T05:40:01Z'
---
## 背景
Workspace web から Worker / Pod 操作 UI を実装するにあたり、Frontend と Backend が Pod protocol の wire 型を共有する必要がある。TypeScript 側で protocol 型を手書きすると Rust 側の `crates/protocol` と容易に乖離するため、Rust の serde DTO を authority として TypeScript 型を自動生成する。
方針:
- `crates/protocol` を Pod protocol の wire schema authority とする。
- Browser は Pod Unix socket に直接接続せず、Workspace backend が proxy する。
- Backend proxy は Worker identity 解決、socket 接続、stale/missing Worker の検出、必要最小限の method block/allow 判断を行う。
- 現時点では user permission model がないため、web 専用 protocol を過剰に多重化しない。Pod protocol の `Method` / `Event` を基本に扱い、危険または UI 対象外の操作だけ backend 側で止める。
- TypeScript 型共有の本線は `ts-rs` 等による自動生成とする。wasm 化は protocol crate の portability / optional codec 用であり、TS discriminated union 型生成の代替として扱わない。
## 要件
### protocol crate の transport 非依存化
- `crates/protocol` の DTO 部分を browser/wasm target でも compile できるようにする。
- `stream.rs``tokio::io` JSONL reader/writer は optional feature に分離する。
- 例: default feature `stream`
- `tokio` dependency は `stream` feature 配下の optional dependency にする。
- `lib.rs``#[cfg(feature = "stream")] pub mod stream;` のようにし、`--no-default-features` では DTO のみが compile されるようにする。
- 少なくとも以下が通る状態にする。
- `cargo check -p protocol --target wasm32-unknown-unknown --no-default-features`
### TypeScript 型自動生成
- `protocol` の主要 wire 型から Workspace web 用 TypeScript 型を自動生成する。
- 手書きの protocol mirror 型を増やさない。
- 第一候補は `ts-rs`
- `serde` attribute compatibility を使い、`#[serde(tag = ..., content = ..., rename_all = ...)]` の wire 表現と TS 型を合わせる。
- `uuid::Uuid`、`serde_json::Value`、`PathBuf` などの扱いを明示する。必要なら feature や `#[ts(type = "...")]` / `#[ts(as = "...")]` を使う。
- 生成対象の root type は少なくとも以下を含める。
- `Method`
- `Event`
- `Segment`
- Worker 操作 UI / stream 表示で直接参照する関連 DTO
- 生成ファイルは Workspace web が import できる場所に置く。
- 例: `web/workspace/src/lib/generated/protocol.ts` または同等の generated directory。
- 生成物の更新漏れを検出できる検証手段を用意する。
- 例: cargo test / xtask / generator binary で生成し、差分が出たら失敗する check。
### Workspace backend proxy 方針
- Browser から Pod protocol を直接 socket に送らず、Workspace backend 経由にする設計にする。
- Backend は `worker_id` から local Pod identity/socket を解決する。
- Backend は proxy 時に最低限以下を確認する。
- 対象 Worker が Workspace runtime view から見えること。
- Pod socket が存在し接続可能であること。
- UI/API から許可する `Method` であること。
- stale/missing/disconnected Worker の error を明示的に返すこと。
- user permission model が入るまでは、別 protocol による権限モデルを先取りしない。
### Frontend integration stance
- Workspace web は generated TypeScript types を import して Worker 操作 UI / event handling に使う。
- Protocol 型の handwritten mirror を残す場合は、暫定互換や UI 専用 view model として明示し、wire type と混同しない。
- WebSocket/SSE/HTTP の transport 選択は後続実装で決めてよいが、wire payload 型は generated protocol types に寄せる。
## Non-goals
- Full Worker operation UI の完成。
- Full WebSocket session UI の完成。
- User / role / permission model の設計。
- Browser から Pod Unix socket へ直接接続する実装。
- Pod protocol の全面的な再設計。
- OpenAPI / JSON Schema への全面移行。
## 受け入れ条件
- `crates/protocol``tokio` stream 依存が optional feature 化されている。
- `cargo check -p protocol --target wasm32-unknown-unknown --no-default-features` が通る。
- `protocol` の主要 wire DTO から TypeScript 型が自動生成される。
- Workspace web が手書き mirror ではなく generated TypeScript 型を import できる。
- `serde` tagged enum / rename などの wire 表現と generated TS 型の整合性が検証されている。
- 生成物の更新漏れを検出する check がある。
- Backend proxy は browser request を Pod socket に直接丸投げせず、Worker identity 解決と最低限の method allow/block 判断を挟む方針が実装または設計記録に反映されている。
- 実装まで含める場合は `cargo test -p protocol`、`cargo check -p protocol --target wasm32-unknown-unknown --no-default-features`、`cd web/workspace && deno task check && deno task build`、`git diff --check`、`nix build .#yoi --no-link` が通る。

View File

@ -0,0 +1,128 @@
<!-- event: create author: "yoi ticket" at: 2026-06-23T05:13:22Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: hare at: 2026-06-23T05:14:09Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-06-23T05:14:09Z from: planning to: ready reason: cli_state field: state -->
## State changed
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: workspace-panel at: 2026-06-23T05:40:01Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-23T05:41:02Z -->
## Decision
Routing decision: `implementation_ready`
Reason:
- Ticket body has concrete requirements for protocol DTO feature-splitting, generated TypeScript types, generated-output drift check, and Workspace web integration stance。
- No blocking relations / orchestration plan records exist。
- Current queued Ticket is this Ticket only。
- Orchestrator worktree is clean on `orchestration` at `615c0250`; target worktree / branch is not present。
- Existing code map confirms `crates/protocol` currently has `tokio` dependency and `stream.rs` enabled unconditionally, while frontend has local hand-written types for Workspace entities but no generated protocol types yet。
IntentPacket:
Intent:
- Make `crates/protocol` the Rust authority for Pod wire DTOs and generate Workspace web TypeScript types from those DTOs instead of hand-writing protocol mirror types。
Binding decisions / invariants:
- DTO authority remains Rust `crates/protocol`; do not create a separate web-only protocol authority。
- Browser must not connect directly to Pod Unix sockets。
- Backend proxy may be design-recorded or minimally scaffolded, but must preserve Worker identity resolution and method allow/block boundary; do not implement broad Worker operation UI in this Ticket。
- `stream.rs` / JSONL tokio transport should be optional behind a default `stream` feature; DTO-only crate should compile without tokio for wasm/no-default-features。
- Generated TypeScript must reflect serde wire shape for tagged enums / rename conventions as closely as practical。
- Generated artifacts should be committed only if they are intended import artifacts and have a drift check。
- Avoid broad protocol redesign or permission-model invention。
Requirements / acceptance criteria:
- `cargo check -p protocol --target wasm32-unknown-unknown --no-default-features` passes, or if target installation is unavailable in environment, the code is structured correctly and validation limitation is documented。
- Protocol root types at least include `Method`, `Event`, `Segment`, and related DTOs needed by Worker operation UI / stream display。
- Workspace web can import generated protocol TypeScript types from a generated directory/file。
- Generated-output drift check exists, e.g. cargo test/generator check that fails if committed generated TS is stale。
- `cargo test -p protocol` passes。
- Deno check/build passes。
- `git diff --check` and `nix build .#yoi --no-link` pass if dependencies/package hash changed。
- Backend proxy stance is represented in implementation or design notes without direct browser-to-Pod socket exposure。
Implementation latitude:
- Use `ts-rs` if it fits; otherwise choose a small generator that preserves serde-tagged shape and is reviewable。
- Put generated types under `web/workspace/src/lib/generated/protocol.ts` or similarly clear generated path。
- Generator can be a test in `protocol`, a small binary/example, or another deterministic Cargo path; keep it simple and reproducible。
- If adding `ts-rs` feature annotations is invasive, cover a useful initial subset and document unsupported types clearly, but keep `Method`, `Event`, and `Segment` included。
- For `uuid::Uuid`, `PathBuf`, `serde_json::Value`, choose clear TS representations (`string`, `unknown`/JSON value, etc.) and document/test them。
Escalate if:
- `ts-rs` cannot represent current serde-tagged enums without unacceptable manual mirrors。
- Making protocol DTOs wasm/no-default-features compatible requires broad type redesign beyond optional `stream` feature。
- Generated file would include secret/project-local content or nondeterministic ordering。
- Backend proxy implementation scope expands into full Worker operation UI/session WebSocket implementation。
Validation plan:
- `cargo fmt --check`
- `cargo test -p protocol`
- `cargo check -p protocol --target wasm32-unknown-unknown --no-default-features`
- Type generation drift check command/test。
- `cd web/workspace && deno task check && deno task build`
- `git diff --check`
- `cargo run -p yoi -- ticket doctor`
- `nix build .#yoi --no-link` if Cargo.lock/package.nix/source behavior changes。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-23T05:41:13Z from: queued to: inprogress reason: human_authorized_unblocked_protocol_types_generation field: state -->
## State changed
Human authorized queue routing from Workspace Dashboard. Ticket has concrete acceptance criteria and no recorded blockers, so Orchestrator accepts implementation.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-23T05:42:14Z -->
## Implementation report
Implementation start report:
- Created child implementation worktree:
- `/home/hare/Projects/yoi/.worktree/00001KVSEBF56-protocol-typescript-types`
- Created branch:
- `impl/00001KVSEBF56-protocol-typescript-types`
- Base commit:
- `746c51c7 ticket: accept protocol ts generation`
- 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-00001KVSEBF56`
- Coder scope:
- read: `/home/hare/Projects/yoi`
- write: `/home/hare/Projects/yoi/.worktree/00001KVSEBF56-protocol-typescript-types`
Next action:
- Wait for Coder implementation report, then spawn Reviewer read-only for the implementation diff。
---

View File

@ -0,0 +1,103 @@
---
title: 'TUI Dashboard の冗長な key hints と selected-row 状態表示を削る'
state: 'queued'
created_at: '2026-06-23T05:40:56Z'
updated_at: '2026-06-23T06:08:42Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['tui-ux', 'terminal-layout']
queued_by: 'workspace-panel'
queued_at: '2026-06-23T06:08:42Z'
---
## User claims / request snapshot
- ユーザーは TUI Dashboard の key hints を消したい。
- 対象として、Composer 下部の hint、最上行の hint、最上行側に出ている selected-row / 選択中表示が挙げられた。
- 選択中の情報は Composer 側で賄えているため、別表示はいらない、という意図。
## Confirmed facts / sources
- `crates/tui/src/dashboard/render.rs``draw_title` が最上行の guidance/hint を描画している。
- `crates/tui/src/dashboard/render.rs``target_status_line` が composer target と selected Ticket / selected Pod / selected Intake Pod / no row selected などの状態行を描画している。
- `crates/tui/src/dashboard/render.rs``actionbar_left_text` / `actionbar_right_text` が Composer 下部の actionbar/key hints を描画している。
- `crates/tui/src/dashboard/render.rs``panel_row_title_line` / `push_ticket_primary_marker_span` などは list 内の選択マーカーを描画しており、今回の「最上行の選択中表示」とは分けて扱うのが安全そう。
- closed Ticket `00001KTFQ109V` / `Remove workspace panel direct Pod send` は direct Pod send の UI/key hints を除去済みで、今回の依頼と矛盾しない。
## Unverified hypotheses
- 「最上行の選択中の表示」は、list 内の `▶` marker ではなく、`target_status_line` に出ている selected Ticket / selected Pod などの textual status を指している、という前提。
- Composer 下部の「奴」は `draw_actionbar` で描画される actionbar 全体、または少なくとも右側の key hint string を指している、という前提。
## Undecided points / open questions
- blocking open question はない。
- 実装時の確認前提: list 内の選択マーカー `▶` / selected row の色・bold は残す。消すのは説明文・key hint・選択中状態のテキスト表示であり、キーボード操作自体は変えない。
## Background
Dashboard は composer target と row selection の関係を複数箇所で説明しているが、現在は Composer 側で十分に文脈を伝えられるため、画面上の hint / status 表示が冗長になっている。
## Requirements
- Dashboard の最上行から key hint / 操作説明の guidance を削る。
- Composer 下部の actionbar から常時表示される key hints を削る、または最小化する。
- selected Ticket / selected Pod / selected Intake Pod / no row selected などの専用 status line 表示を削る。
- Composer target / draft submit の必要な情報は Composer 近傍または既存 composer 表示で賄う。
- row selection / keyboard operation の実際の挙動は維持する。
- Ticket Queue / Intake launch / Pod open-attach / Companion target の既存動作は変えない。
## Acceptance criteria
- `yoi panel` の最上行に `Row selection`、`blank Enter`、`Tab target` 等の key hint 文言が出ない。
- Composer 下部の actionbar に常時 key hint 群が出ない。
- selected row の textual status line が出ない、または selected-row 表示として認識される冗長情報がなくなる。
- list 内では現在の選択行を視認できる。
- 既存の Dashboard 操作は維持される:
- blank Enter の row action
- text Enter の composer target action
- Tab target switching
- Esc clear selection
- Pod open/attach
- Ticket Intake / Queue 関連動作
- 関連 render/unit tests が新しい表示仕様に更新される。
## Binding decisions / invariants
- この Ticket では Dashboard の表示整理だけを扱う。
- Console / single-Pod TUI の key hints は対象外。
- list 内の選択行マーカーや keyboard navigation visibility は削らない。
- direct selected-Pod send を再導入しない。
- Companion lifecycle / Orchestrator lifecycle / Ticket workflow semantics は変更しない。
## Implementation latitude
- `target_status_line` の layout row を完全に削るか、空/最小表示にするかは実装側で判断してよい。
- actionbar は完全削除・左側 notice のみ残す・一時 notice のみ残す等、既存 layout とテストに自然な形を選んでよい。
- 既存 tests は snapshot 的に文字列を確認している箇所を中心に更新してよい。
## Readiness
- readiness: implementation_ready
- risk_flags: [tui-ux, terminal-layout]
## Escalation conditions
- row selection marker まで削る必要が出た場合。
- Composer だけでは current target / action が分からなくなり、代替表示が必要だと判断した場合。
- layout row を削ることで terminal resize / hitbox / mouse selection に副作用が出る場合。
## Validation
- `cargo test -p tui dashboard --lib` または Dashboard 関連の focused tests。
- 必要に応じて `cargo test -p tui workspace_panel --lib`
- `cargo fmt --check`
- `git diff --check`
- 可能なら `yoi panel` の実表示確認。
## Related work
- `00001KTFQ109V` — Remove workspace panel direct Pod send
- `crates/tui/src/dashboard/render.rs`
- `crates/tui/src/dashboard/mod.rs`
- `crates/tui/src/dashboard/tests.rs`

View File

@ -0,0 +1,16 @@
<!-- event: create author: ticket-intake at: 2026-06-23T05:40:56Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: state_changed author: workspace-panel at: 2026-06-23T06:08:42Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260623-055433-1","ticket_id":"00001KVSGFM65","kind":"accepted_plan","accepted_plan":{"summary":"Refactor Repository Ticket Kanban into grouped frontend component with display-only planning+ready and queued+inprogress groups, priority sort, per-group scroll areas, independent lazy visible counts, original state badges, and design-token styling without backend lifecycle changes.","branch":"impl/00001KVSGFM65-kanban-lazy-rows","worktree":"/home/hare/Projects/yoi/.worktree/00001KVSGFM65-kanban-lazy-rows","role_plan":"Orchestrator creates a dedicated child worktree and spawns a narrow-scope frontend Coder. Reviewer will be spawned read-only after Coder reports implementation commit(s). After approval, Orchestrator integrates into `orchestration`, validates Deno/Nix, records closure, and cleans only the child worktree/branch."},"author":"yoi-orchestrator","at":"2026-06-23T05:54:33Z"}

View File

@ -0,0 +1,83 @@
---
title: 'Improve Workspace web ticket Kanban grouping and lazy rows'
state: 'inprogress'
created_at: '2026-06-23T05:50:36Z'
updated_at: '2026-06-23T06:06:45Z'
assignee: null
queued_by: 'workspace-panel'
queued_at: '2026-06-23T05:53:22Z'
---
## 背景
Workspace web の Repository Ticket Kanban は Ticket state ごとに縦方向へ列/行を増やして表示しているが、`closed` など件数の多い state が画面を圧迫しやすい。実運用では `planning``ready`、`queued` と `inprogress` は近い段階としてまとめて見たい一方で、各 group 内では実行に近い state を上に表示したい。
Repository page の Ticket 表示を、state group ごとの独立スクロール領域と lazy row loading に変更する。必要に応じて Kanban 表示コンポーネントを切り出し、page component の責務を軽くする。
## 要件
### State grouping / sort
- `planning``ready` を同じ表示 group に統合する。
- 表示 label は実装時に自然なものを選んでよいが、両 state が同じ group だと分かること。
- group 内の sort は `ready``planning` より上に来ること。
- `queued``inprogress` を同じ表示 group に統合する。
- group 内の sort は `inprogress``queued` より上に来ること。
- その他の state は既存意味を保つ。
- 例: `done`, `closed` は独立 group のままでよい。
- 各 Ticket row には元の `state` が識別できる表示を残す。
### Per-group independent scroll
- すべての Kanban group / row list は独立した scroll area を持つ。
- 各 scroll area の高さはおおむね Ticket 10件分を表示できる高さにする。
- 件数の多い state/group が page 全体を圧迫しないこと。
- page 全体の scroll と group 内 scroll の責務が不自然に競合しないこと。
### Lazy row loading
- 各 group は初期表示 30件までに制限する。
- group 内 scroll area の下部付近まで到達したら、その group だけ追加件数を読み込んで表示する。
- lazy loading は group ごとに独立していること。
- `closed` を下まで scroll しても `ready/planning` など他 group の表示件数は増えない。
- Backend API の pagination 実装までは必須にしない。
- 既存 API がまとめて返す範囲内で、Frontend 側の表示件数を段階的に増やす実装でよい。
- ただし将来 backend pagination に差し替えられるよう、表示 state は group 単位で管理する。
### Component boundary
- Kanban 表示は `WorkspacePage.svelte` から切り出してよい。
- 切り出す場合は、少なくとも以下の責務を component に寄せる。
- state grouping
- group 内 sort
- per-group visible count
- scroll bottom detection
- Ticket row rendering
- page component は repository data fetch / high-level layout に寄せる。
### Design system alignment
- Workspace web design system に沿い、不要な角丸/縁取り/filled card を増やさない。
- group 境界は余白・文字サイズ・文字色・細い rule を優先して表現する。
- 色は `web/workspace/src/app.css` の token を使い、raw color を追加しない。
## Non-goals
- Backend pagination API の本格実装。
- Ticket state lifecycle の変更。
- Ticket mutation UI の追加。
- Repository target metadata / DB schema の実装。
- TUI Dashboard / Panel の Kanban 表示変更。
## 受け入れ条件
- Repository Ticket Kanban で `planning``ready` が同一 group として表示される。
- その group 内で `ready` Ticket が `planning` Ticket より上に表示される。
- Repository Ticket Kanban で `queued``inprogress` が同一 group として表示される。
- その group 内で `inprogress` Ticket が `queued` Ticket より上に表示される。
- 各 group は独立 scroll area を持ち、おおむね 10件分の高さでスクロールできる。
- 各 group は初期 30件だけ表示し、下部まで scroll するとその group だけ追加表示される。
- 件数の多い `closed` group が page 全体の縦幅を大きく圧迫しない。
- Ticket row には元 state が分かる表示がある。
- 必要な場合、Kanban 表示が再利用可能な Svelte component に切り出されている。
- `cd web/workspace && deno task check && deno task build`、`git diff --check`、`nix build .#yoi --no-link` が通る。

View File

@ -0,0 +1,208 @@
<!-- event: create author: "yoi ticket" at: 2026-06-23T05:50:36Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: hare at: 2026-06-23T05:51:34Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-06-23T05:51:34Z from: planning to: ready reason: cli_state field: state -->
## State changed
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: workspace-panel at: 2026-06-23T05:53:22Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-23T05:54:11Z -->
## Decision
Routing decision: `implementation_ready_parallel`
Reason:
- Ticket body has concrete frontend requirements for Repository Ticket Kanban grouping, sort order, per-group independent scroll, lazy row loading, component boundary, and Deno/Nix validation。
- No relations / blockers / orchestration plan records exist。
- Active Ticket `00001KVSEBF56` is protocol TypeScript generation and is expected to touch protocol crate / generated types; this Kanban ticket primarily targets Workspace Repository page UI and can proceed in parallel with narrow frontend scope。
- Current queued Ticket is this Ticket only。
- Orchestrator worktree is clean on `orchestration` at `2865abb6`; target worktree / branch is not present。
- Current code map shows Repository Ticket Kanban is currently inline in `web/workspace/src/lib/workspace-pages/WorkspacePage.svelte`, using `repositoryTickets.columns` from `/api/repositories/local/tickets`
IntentPacket:
Intent:
- Improve Repository Ticket Kanban display so high-volume states do not dominate the page, related states are grouped, and each group lazy-loads independently。
Binding decisions / invariants:
- Frontend display behavior only unless absolutely necessary; do not change Ticket lifecycle semantics or add mutation UI。
- Backend pagination is not required; frontend-only visible count per group is acceptable。
- Original Ticket state must remain visible on each row。
- Grouping is display-only: `planning+ready`, `queued+inprogress`, other states independent。
- Group sort priority: `ready` before `planning`, `inprogress` before `queued`
- Each group has independent scroll/visible count state; scrolling one group must not increase other groups。
- Use existing design tokens from `web/workspace/src/app.css`; avoid raw colors and heavy card chrome。
- Keep static SPA / Deno tooling boundaries。
- Do not touch protocol TS generation scope from `00001KVSEBF56` unless unavoidable。
Requirements / acceptance criteria:
- `planning` and `ready` display in the same group, with `ready` above `planning`
- `queued` and `inprogress` display in the same group, with `inprogress` above `queued`
- `done`, `closed`, and other states keep independent display meaning。
- Each group has an independent scroll area approximately 10 rows tall。
- Initial visible rows per group are capped at 30。
- Near-bottom scroll within a group increases only that groups visible count。
- `closed` or other high-volume group does not expand page height excessively。
- Row displays original Ticket state。
- Kanban display is preferably split into reusable Svelte component(s), moving grouping/sort/visible-count/scroll logic out of `WorkspacePage.svelte`
- Deno check/build, `git diff --check`, and `nix build .#yoi --no-link` pass。
Implementation latitude:
- Component can live under `web/workspace/src/lib/workspace-pages/` or another clear frontend component path。
- Backend response shape may remain unchanged; grouping can be performed client-side from returned columns/items。
- Add minimal frontend tests only if current tooling supports it cheaply; otherwise validation via Svelte check/build is acceptable。
- CSS can be simple rule/typography/spacing approach; no need for full design system。
Escalate if:
- Svelte 5 event/scroll handling requires a broader frontend state management rewrite。
- Backend response shape prevents group-level lazy display without changing API semantics。
- Deno build/check breaks due unrelated protocol type generation work。
Validation plan:
- `cd web/workspace && deno task check && deno task build`
- `git diff --check`
- `cargo run -p yoi -- ticket doctor`
- `nix build .#yoi --no-link`
- `cargo test -p yoi-workspace-server` only if backend API changes。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-23T05:54:22Z from: queued to: inprogress reason: human_authorized_unblocked_frontend_kanban_improvement field: state -->
## State changed
Human authorized queue routing from Workspace Dashboard. Ticket has concrete frontend acceptance criteria and no recorded blockers; active protocol TypeScript generation work is separate enough for parallel implementation.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-23T05:55:23Z -->
## Implementation report
Implementation start report:
- Created child implementation worktree:
- `/home/hare/Projects/yoi/.worktree/00001KVSGFM65-kanban-lazy-rows`
- Created branch:
- `impl/00001KVSGFM65-kanban-lazy-rows`
- Base commit:
- `a6f9019e ticket: accept kanban lazy rows`
- 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-00001KVSGFM65`
- Coder scope:
- read: `/home/hare/Projects/yoi`
- write: `/home/hare/Projects/yoi/.worktree/00001KVSGFM65-kanban-lazy-rows`
Parallelization note:
- `00001KVSEBF56` remains active and targets protocol TypeScript generation. This Ticket should stay limited to Workspace Repository Ticket Kanban frontend display。
Next action:
- Wait for Coder implementation report, then spawn Reviewer read-only for the implementation diff。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-23T06:05:59Z -->
## Implementation report
Coder implementation report received from `yoi-coder-00001KVSGFM65`.
Implementation commit:
- `6f68bb8d web: group repository ticket kanban rows`
Changed areas:
- `web/workspace/src/lib/workspace-pages/RepositoryTicketKanban.svelte`
- New reusable Kanban component。
- Groups `ready + planning`, with `ready` sorted above `planning`
- Groups `inprogress + queued`, with `inprogress` sorted above `queued`
- Keeps `done`, `closed`, and `other` as distinct display groups。
- Shows each rows original `ticket.state`
- Caps initial rendered rows per group at 30。
- Adds independent per-group scroll/lazy row expansion。
- Uses design tokens from `app.css`
- `web/workspace/src/lib/workspace-pages/WorkspacePage.svelte`
- Replaced inline Repository Ticket Kanban markup with extracted component。
- `web/workspace/src/app.css`
- Removed now-unused global inline Kanban styles。
Coder validation reported:
- `cd web/workspace && deno task check`: passed
- `cd web/workspace && deno task build`: passed
- `git diff --check HEAD~1..HEAD`: passed
- `cargo run -p yoi -- ticket doctor`: passed
- `nix build .#yoi --no-link`: passed
Notes:
- No backend API changes; `cargo test -p yoi-workspace-server` was not run。
- No protocol TypeScript generation scope touched。
Orchestrator evidence checked:
- Worktree clean on `impl/00001KVSGFM65-kanban-lazy-rows` at `6f68bb8d`
- Diff `a6f9019e..HEAD` touches expected frontend files only。
- `git diff --check a6f9019e..HEAD`: passed。
- Validation generated ignored frontend artifacts; Orchestrator removed `web/workspace/node_modules/`, `.svelte-kit/`, and `build/` before Reviewer routing。
Next action:
- Route to read-only Reviewer Pod for acceptance review。
---
<!-- event: comment author: yoi-orchestrator at: 2026-06-23T06:06:45Z -->
## Comment
Review routing:
Spawned read-only Reviewer Pod:
- `yoi-reviewer-00001KVSGFM65-r1`
Review target:
- Worktree: `/home/hare/Projects/yoi/.worktree/00001KVSGFM65-kanban-lazy-rows`
- Branch: `impl/00001KVSGFM65-kanban-lazy-rows`
- Baseline: `a6f9019e`
- Implementation commit: `6f68bb8d web: group repository ticket kanban rows`
- Diff: `a6f9019e..6f68bb8d`
Reviewer scope:
- read-only `/home/hare/Projects/yoi`
Reviewer focus:
- Display grouping/sort order: `ready > planning`, `inprogress > queued`
- Per-group independent visible count and scroll expansion。
- High-volume `closed` bounded by group scroll area。
- Original Ticket state visible per row。
- Component boundary and design-token styling。
- No backend/lifecycle/protocol scope creep。
Orchestrator will wait for reviewer verdict before integration。
---

203
Cargo.lock generated
View File

@ -196,6 +196,58 @@ dependencies = [
"fs_extra", "fs_extra",
] ]
[[package]]
name = "axum"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"serde_core",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
@ -824,6 +876,12 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "data-encoding"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]] [[package]]
name = "deadpool" name = "deadpool"
version = "0.12.3" version = "0.12.3"
@ -1039,6 +1097,18 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]] [[package]]
name = "fancy-regex" name = "fancy-regex"
version = "0.11.0" version = "0.11.0"
@ -1429,6 +1499,15 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "hashlink"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [
"hashbrown 0.15.5",
]
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.5.0" version = "0.5.0"
@ -1968,6 +2047,17 @@ dependencies = [
"redox_syscall 0.7.4", "redox_syscall 0.7.4",
] ]
[[package]]
name = "libsqlite3-sys"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "line-clipping" name = "line-clipping"
version = "0.3.7" version = "0.3.7"
@ -2201,6 +2291,12 @@ dependencies = [
"regex-automata", "regex-automata",
] ]
[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]] [[package]]
name = "mcp" name = "mcp"
version = "0.1.0" version = "0.1.0"
@ -2789,6 +2885,7 @@ dependencies = [
"dotenv", "dotenv",
"fs4", "fs4",
"futures", "futures",
"futures-util",
"include_dir", "include_dir",
"libc", "libc",
"llm-worker", "llm-worker",
@ -2810,9 +2907,11 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
"ticket", "ticket",
"tokio", "tokio",
"tokio-tungstenite",
"toml", "toml",
"tools", "tools",
"tracing", "tracing",
"tungstenite",
"uuid", "uuid",
"wasmi", "wasmi",
"wasmtime", "wasmtime",
@ -3376,6 +3475,20 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "rusqlite"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f"
dependencies = [
"bitflags 2.11.0",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]] [[package]]
name = "rustc-demangle" name = "rustc-demangle"
version = "0.1.27" version = "0.1.27"
@ -3692,6 +3805,17 @@ dependencies = [
"zmij", "zmij",
] ]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]] [[package]]
name = "serde_spanned" name = "serde_spanned"
version = "1.1.1" version = "1.1.1"
@ -3701,6 +3825,18 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]] [[package]]
name = "serde_yaml" name = "serde_yaml"
version = "0.9.34+deprecated" version = "0.9.34+deprecated"
@ -3776,6 +3912,17 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]] [[package]]
name = "sha2" name = "sha2"
version = "0.10.9" version = "0.10.9"
@ -4326,6 +4473,20 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tokio-tungstenite"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
dependencies = [
"futures-util",
"log",
"native-tls",
"tokio",
"tokio-native-tls",
"tungstenite",
]
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.18" version = "0.7.18"
@ -4419,6 +4580,7 @@ dependencies = [
"tokio", "tokio",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing",
] ]
[[package]] [[package]]
@ -4457,6 +4619,7 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [ dependencies = [
"log",
"pin-project-lite", "pin-project-lite",
"tracing-attributes", "tracing-attributes",
"tracing-core", "tracing-core",
@ -4567,6 +4730,25 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "tungstenite"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"native-tls",
"rand 0.9.4",
"sha1",
"thiserror 2.0.18",
"url",
"utf-8",
]
[[package]] [[package]]
name = "type1-encoding-parser" name = "type1-encoding-parser"
version = "0.1.1" version = "0.1.1"
@ -5839,6 +6021,27 @@ dependencies = [
"wit-bindgen", "wit-bindgen",
] ]
[[package]]
name = "yoi-workspace-server"
version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"manifest",
"pod-store",
"project-record",
"rusqlite",
"serde",
"serde_json",
"serde_yaml",
"tempfile",
"thiserror 2.0.18",
"ticket",
"tokio",
"tower",
"tracing",
]
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.2" version = "0.8.2"

View File

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

View File

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

View File

@ -39,6 +39,9 @@ session-metrics = { workspace = true }
arc-swap = "1.9.1" arc-swap = "1.9.1"
wasmi = { version = "0.51.1", default-features = false, features = ["std", "extra-checks"] } wasmi = { version = "0.51.1", default-features = false, features = ["std", "extra-checks"] }
wasmtime = { version = "45.0.2", default-features = false, features = ["std", "runtime", "cranelift", "component-model"] } wasmtime = { version = "45.0.2", default-features = false, features = ["std", "runtime", "cranelift", "component-model"] }
tungstenite = { version = "0.28.0", default-features = false, features = ["handshake", "native-tls", "url"] }
tokio-tungstenite = { version = "0.28.0", default-features = false, features = ["native-tls", "connect"] }
futures-util = { version = "0.3", features = ["sink"] }
[dev-dependencies] [dev-dependencies]
dotenv = "0.15.0" dotenv = "0.15.0"

View File

@ -14,6 +14,7 @@ use tracing::{debug, warn};
use crate::discovery::{PodDiscovery, list_pods_tool, restore_pod_tool, send_to_peer_pod_tool}; use crate::discovery::{PodDiscovery, list_pods_tool, restore_pod_tool, send_to_peer_pod_tool};
use crate::feature::FeatureRegistryBuilder; use crate::feature::FeatureRegistryBuilder;
use crate::in_flight::InFlightEvents;
use crate::ipc::alerter::Alerter; use crate::ipc::alerter::Alerter;
use crate::ipc::notify_buffer::NotifyBuffer; use crate::ipc::notify_buffer::NotifyBuffer;
use crate::ipc::server::SocketServer; use crate::ipc::server::SocketServer;
@ -47,6 +48,7 @@ pub struct PodHandle {
pub shared_state: Arc<PodSharedState>, pub shared_state: Arc<PodSharedState>,
pub runtime_dir: Arc<RuntimeDir>, pub runtime_dir: Arc<RuntimeDir>,
pub alerter: Alerter, pub alerter: Alerter,
pub in_flight: InFlightEvents,
/// Segment-log mirror + broadcast handle. The IPC server snapshots /// Segment-log mirror + broadcast handle. The IPC server snapshots
/// it on every new connection (Event::Snapshot) and forwards /// it on every new connection (Event::Snapshot) and forwards
/// subsequent commits (Event::Entry) on the receiver. /// subsequent commits (Event::Entry) on the receiver.
@ -159,6 +161,8 @@ impl PodController {
let (method_tx, method_rx) = mpsc::channel::<Method>(32); let (method_tx, method_rx) = mpsc::channel::<Method>(32);
let (event_tx, _) = broadcast::channel::<Event>(256); let (event_tx, _) = broadcast::channel::<Event>(256);
let alerter = Alerter::new(event_tx.clone()); let alerter = Alerter::new(event_tx.clone());
let in_flight = InFlightEvents::new(event_tx.clone());
pod.attach_in_flight_events(in_flight.clone());
// Runtime directory is created before tool registration because // Runtime directory is created before tool registration because
// the spawn-tool factories need its socket path, and before the // the spawn-tool factories need its socket path, and before the
@ -225,7 +229,7 @@ impl PodController {
pod.wire_history_persistence(); pod.wire_history_persistence();
// === 2. Worker event bridge wiring === // === 2. Worker event bridge wiring ===
wire_event_bridges_on_worker(&mut pod, &event_tx, &alerter); wire_event_bridges_on_worker(&mut pod, &event_tx, &alerter, &in_flight);
// === 3. Tool registration (builtin / memory / spawn-orchestration) === // === 3. Tool registration (builtin / memory / spawn-orchestration) ===
let fs_for_view = register_pod_tools( let fs_for_view = register_pod_tools(
@ -289,6 +293,7 @@ impl PodController {
shared_state: shared_state.clone(), shared_state: shared_state.clone(),
runtime_dir: runtime_dir.clone(), runtime_dir: runtime_dir.clone(),
alerter: alerter.clone(), alerter: alerter.clone(),
in_flight: in_flight.clone(),
sink: pod.sink(), sink: pod.sink(),
}; };
@ -333,6 +338,7 @@ fn wire_event_bridges_on_worker<C, St>(
pod: &mut Pod<C, St>, pod: &mut Pod<C, St>,
event_tx: &broadcast::Sender<Event>, event_tx: &broadcast::Sender<Event>,
alerter: &Alerter, alerter: &Alerter,
in_flight: &InFlightEvents,
) where ) where
C: LlmClient + Clone + 'static, C: LlmClient + Clone + 'static,
St: Store + PodMetadataStore + Clone + 'static, St: Store + PodMetadataStore + Clone + 'static,
@ -386,83 +392,66 @@ fn wire_event_bridges_on_worker<C, St>(
}); });
}); });
let tx = event_tx.clone(); let in_flight_text = in_flight.clone();
let activity = ai_activity.clone(); let activity = ai_activity.clone();
worker.on_text_block(move |block| { worker.on_text_block(move |block| {
let tx_d = tx.clone(); let block_id = in_flight_text.start_text_block();
let in_flight_d = in_flight_text.clone();
let activity_d = activity.clone(); let activity_d = activity.clone();
block.on_delta(move |text| { block.on_delta(move |text| {
activity_d.fetch_add(1, Ordering::SeqCst); activity_d.fetch_add(1, Ordering::SeqCst);
let _ = tx_d.send(Event::TextDelta { in_flight_d.text_delta(block_id, text.to_owned());
text: text.to_owned(),
});
}); });
let tx_s = tx.clone(); let in_flight_s = in_flight_text.clone();
let activity_s = activity.clone(); let activity_s = activity.clone();
block.on_stop(move |text| { block.on_stop(move |text| {
if !text.is_empty() { if !text.is_empty() {
activity_s.fetch_add(1, Ordering::SeqCst); activity_s.fetch_add(1, Ordering::SeqCst);
} }
let _ = tx_s.send(Event::TextDone { in_flight_s.text_done(block_id, text.to_owned());
text: text.to_owned(),
});
}); });
}); });
let tx = event_tx.clone(); let in_flight_thinking = in_flight.clone();
let activity = ai_activity.clone(); let activity = ai_activity.clone();
worker.on_thinking_block(move |block| { worker.on_thinking_block(move |block| {
// Start fires unconditionally so the TUI can show "Thinking..." // Start fires unconditionally so the TUI can show "Thinking..."
// even when the provider doesn't emit plaintext deltas. // even when the provider doesn't emit plaintext deltas.
activity.fetch_add(1, Ordering::SeqCst); activity.fetch_add(1, Ordering::SeqCst);
let _ = tx.send(Event::ThinkingStart); let block_id = in_flight_thinking.thinking_start();
let tx_d = tx.clone(); let in_flight_d = in_flight_thinking.clone();
let activity_d = activity.clone(); let activity_d = activity.clone();
block.on_delta(move |text| { block.on_delta(move |text| {
activity_d.fetch_add(1, Ordering::SeqCst); activity_d.fetch_add(1, Ordering::SeqCst);
let _ = tx_d.send(Event::ThinkingDelta { in_flight_d.thinking_delta(block_id, text.to_owned());
text: text.to_owned(),
});
}); });
let tx_s = tx.clone(); let in_flight_s = in_flight_thinking.clone();
let activity_s = activity.clone(); let activity_s = activity.clone();
block.on_stop(move |text| { block.on_stop(move |text| {
if !text.is_empty() { if !text.is_empty() {
activity_s.fetch_add(1, Ordering::SeqCst); activity_s.fetch_add(1, Ordering::SeqCst);
} }
let _ = tx_s.send(Event::ThinkingDone { in_flight_s.thinking_done(block_id, text.to_owned());
text: text.to_owned(),
});
}); });
}); });
let tx = event_tx.clone(); let in_flight_tool = in_flight.clone();
let activity = ai_activity.clone(); let activity = ai_activity.clone();
worker.on_tool_use_block(move |start, block| { worker.on_tool_use_block(move |start, block| {
activity.fetch_add(1, Ordering::SeqCst); activity.fetch_add(1, Ordering::SeqCst);
let _ = tx.send(Event::ToolCallStart { let block_id = in_flight_tool.tool_call_start(start.id.clone(), start.name.clone());
id: start.id.clone(),
name: start.name.clone(),
});
let id_for_delta = start.id.clone(); let id_for_delta = start.id.clone();
let tx_d = tx.clone(); let in_flight_d = in_flight_tool.clone();
let activity_d = activity.clone(); let activity_d = activity.clone();
block.on_delta(move |json| { block.on_delta(move |json| {
activity_d.fetch_add(1, Ordering::SeqCst); activity_d.fetch_add(1, Ordering::SeqCst);
let _ = tx_d.send(Event::ToolCallArgsDelta { in_flight_d.tool_call_args_delta(block_id, id_for_delta.clone(), json.to_owned());
id: id_for_delta.clone(),
json: json.to_owned(),
});
}); });
let tx_s = tx.clone(); let in_flight_s = in_flight_tool.clone();
let activity_s = activity.clone(); let activity_s = activity.clone();
block.on_stop(move |call| { block.on_stop(move |call| {
activity_s.fetch_add(1, Ordering::SeqCst); activity_s.fetch_add(1, Ordering::SeqCst);
let _ = tx_s.send(Event::ToolCallDone { in_flight_s.tool_call_done(block_id, call.id.clone(), call.input.to_string());
id: call.id.clone(),
name: call.name.clone(),
arguments: call.input.to_string(),
});
}); });
}); });
@ -1535,6 +1524,7 @@ mod tests {
context_tokens: 0, context_tokens: 0,
}, },
status: PodStatus::Idle, status: PodStatus::Idle,
in_flight: Default::default(),
}) })
.await .await
.ok()?; .ok()?;

View File

@ -1463,6 +1463,7 @@ mod tests {
context_tokens: 0, context_tokens: 0,
}, },
status: PodStatus::Idle, status: PodStatus::Idle,
in_flight: Default::default(),
}) })
.await .await
.unwrap(); .unwrap();
@ -1494,6 +1495,7 @@ mod tests {
context_tokens: 0, context_tokens: 0,
}, },
status: PodStatus::Idle, status: PodStatus::Idle,
in_flight: Default::default(),
}) })
.await .await
.unwrap(); .unwrap();
@ -1579,6 +1581,7 @@ mod tests {
context_tokens: 0, context_tokens: 0,
}, },
status: PodStatus::Idle, status: PodStatus::Idle,
in_flight: Default::default(),
}) })
.await .await
.unwrap(); .unwrap();
@ -1601,6 +1604,7 @@ mod tests {
context_tokens: 0, context_tokens: 0,
}, },
status: PodStatus::Idle, status: PodStatus::Idle,
in_flight: Default::default(),
}) })
.await .await
.unwrap(); .unwrap();
@ -1700,6 +1704,7 @@ mod tests {
context_tokens: 0, context_tokens: 0,
}, },
status: PodStatus::Paused, status: PodStatus::Paused,
in_flight: Default::default(),
}) })
.await .await
.unwrap(); .unwrap();
@ -1748,6 +1753,7 @@ mod tests {
context_tokens: 0, context_tokens: 0,
}, },
status: PodStatus::Idle, status: PodStatus::Idle,
in_flight: Default::default(),
}) })
.await; .await;
}); });

File diff suppressed because it is too large Load Diff

477
crates/pod/src/in_flight.rs Normal file
View File

@ -0,0 +1,477 @@
use std::sync::{Arc, Mutex, MutexGuard};
use protocol::{Event, InFlightBlock, InFlightSnapshot, InFlightToolCallState};
use session_store::{LoggedContentPart, LoggedItem};
use tokio::sync::broadcast;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InFlightBlockId(u64);
#[derive(Debug, Clone)]
pub struct InFlightEvents {
inner: Arc<Mutex<InFlightInner>>,
event_tx: broadcast::Sender<Event>,
}
#[derive(Debug)]
pub(crate) struct InFlightInner {
next_block_id: u64,
blocks: Vec<TrackedBlock>,
}
#[derive(Debug, Clone)]
enum TrackedBlock {
Text {
block_id: InFlightBlockId,
text: String,
finished: bool,
},
Thinking {
block_id: InFlightBlockId,
text: String,
finished: bool,
},
ToolCall {
block_id: InFlightBlockId,
id: String,
name: String,
args: String,
state: InFlightToolCallState,
},
}
impl InFlightEvents {
pub(crate) fn new(event_tx: broadcast::Sender<Event>) -> Self {
Self {
inner: Arc::new(Mutex::new(InFlightInner {
next_block_id: 1,
blocks: Vec::new(),
})),
event_tx,
}
}
pub(crate) fn snapshot_guard(&self) -> MutexGuard<'_, InFlightInner> {
self.inner.lock().expect("in-flight event mutex poisoned")
}
pub(crate) fn start_text_block(&self) -> InFlightBlockId {
let mut inner = self.lock();
let block_id = inner.next_id();
inner.blocks.push(TrackedBlock::Text {
block_id,
text: String::new(),
finished: false,
});
block_id
}
pub(crate) fn text_delta(&self, block_id: InFlightBlockId, text: String) {
let mut inner = self.lock();
if let Some(TrackedBlock::Text {
text: current,
finished,
..
}) = inner.find_block_mut(block_id)
{
current.push_str(&text);
*finished = false;
}
let _ = self.event_tx.send(Event::TextDelta { text });
}
pub(crate) fn text_done(&self, block_id: InFlightBlockId, text: String) {
let mut inner = self.lock();
if let Some(TrackedBlock::Text {
text: current,
finished,
..
}) = inner.find_block_mut(block_id)
{
if current.is_empty() {
*current = text.clone();
}
*finished = true;
}
let _ = self.event_tx.send(Event::TextDone { text });
}
pub(crate) fn thinking_start(&self) -> InFlightBlockId {
let mut inner = self.lock();
let block_id = inner.next_id();
inner.blocks.push(TrackedBlock::Thinking {
block_id,
text: String::new(),
finished: false,
});
let _ = self.event_tx.send(Event::ThinkingStart);
block_id
}
pub(crate) fn thinking_delta(&self, block_id: InFlightBlockId, text: String) {
let mut inner = self.lock();
if let Some(TrackedBlock::Thinking {
text: current,
finished,
..
}) = inner.find_block_mut(block_id)
{
current.push_str(&text);
*finished = false;
}
let _ = self.event_tx.send(Event::ThinkingDelta { text });
}
pub(crate) fn thinking_done(&self, block_id: InFlightBlockId, text: String) {
let mut inner = self.lock();
if let Some(TrackedBlock::Thinking {
text: current,
finished,
..
}) = inner.find_block_mut(block_id)
{
if current.is_empty() {
*current = text.clone();
}
*finished = true;
}
let _ = self.event_tx.send(Event::ThinkingDone { text });
}
pub(crate) fn tool_call_start(&self, id: String, name: String) -> InFlightBlockId {
let mut inner = self.lock();
let block_id = inner.next_id();
inner.blocks.push(TrackedBlock::ToolCall {
block_id,
id: id.clone(),
name: name.clone(),
args: String::new(),
state: InFlightToolCallState::Pending,
});
let _ = self.event_tx.send(Event::ToolCallStart { id, name });
block_id
}
pub(crate) fn tool_call_args_delta(
&self,
block_id: InFlightBlockId,
id: String,
delta: String,
) {
let mut inner = self.lock();
if let Some(TrackedBlock::ToolCall { args, state, .. }) = inner.find_block_mut(block_id) {
args.push_str(&delta);
*state = InFlightToolCallState::StreamingArgs;
}
let _ = self
.event_tx
.send(Event::ToolCallArgsDelta { id, json: delta });
}
pub(crate) fn tool_call_done(&self, block_id: InFlightBlockId, id: String, args: String) {
let mut inner = self.lock();
let mut name = String::new();
if let Some(TrackedBlock::ToolCall {
name: current_name,
args: current,
state,
..
}) = inner.find_block_mut(block_id)
{
name = current_name.clone();
if current.is_empty() {
*current = args.clone();
}
*state = InFlightToolCallState::Done;
}
let _ = self.event_tx.send(Event::ToolCallDone {
id,
name,
arguments: args,
});
}
pub(crate) fn clear_for_committed_item_then<R>(
&self,
item: &LoggedItem,
f: impl FnOnce() -> R,
) -> R {
let mut inner = self.lock();
inner.clear_for_committed_item(item);
f()
}
fn lock(&self) -> MutexGuard<'_, InFlightInner> {
self.inner.lock().expect("in-flight event mutex poisoned")
}
}
impl InFlightInner {
fn next_id(&mut self) -> InFlightBlockId {
let id = InFlightBlockId(self.next_block_id);
self.next_block_id = self.next_block_id.saturating_add(1);
id
}
fn find_block_mut(&mut self, block_id: InFlightBlockId) -> Option<&mut TrackedBlock> {
self.blocks
.iter_mut()
.find(|block| block.block_id() == block_id)
}
fn clear_for_committed_item(&mut self, item: &LoggedItem) {
match item {
LoggedItem::Message { role, content }
if matches!(role, session_store::LoggedRole::Assistant) =>
{
let text = content
.iter()
.filter_map(|part| match part {
LoggedContentPart::Text { text } => Some(text.as_str()),
LoggedContentPart::Refusal { refusal } => Some(refusal.as_str()),
})
.collect::<String>();
if !text.is_empty() {
self.remove_first_text_matching(&text);
}
}
LoggedItem::Reasoning { text, .. } => {
self.remove_first_thinking_matching(text);
}
LoggedItem::ToolCall { call_id, .. } => {
self.remove_tool_call(call_id);
}
_ => {}
}
}
fn snapshot(&self) -> InFlightSnapshot {
InFlightSnapshot {
blocks: self
.blocks
.iter()
.filter_map(TrackedBlock::to_snapshot_block)
.collect(),
}
}
fn remove_first_text_matching(&mut self, committed: &str) {
if let Some(index) = self.blocks.iter().position(|block| match block {
TrackedBlock::Text { text, .. } => text == committed,
_ => false,
}) {
self.blocks.remove(index);
}
}
fn remove_first_thinking_matching(&mut self, committed: &str) {
if let Some(index) = self.blocks.iter().position(|block| match block {
TrackedBlock::Thinking { text, .. } => text == committed,
_ => false,
}) {
self.blocks.remove(index);
}
}
fn remove_tool_call(&mut self, call_id: &str) {
if let Some(index) = self.blocks.iter().position(|block| match block {
TrackedBlock::ToolCall { id, .. } => id == call_id,
_ => false,
}) {
self.blocks.remove(index);
}
}
}
impl TrackedBlock {
fn block_id(&self) -> InFlightBlockId {
match self {
TrackedBlock::Text { block_id, .. }
| TrackedBlock::Thinking { block_id, .. }
| TrackedBlock::ToolCall { block_id, .. } => *block_id,
}
}
fn to_snapshot_block(&self) -> Option<InFlightBlock> {
match self {
TrackedBlock::Text { text, finished, .. } => {
if text.is_empty() {
None
} else {
Some(InFlightBlock::Text {
text: text.clone(),
finished: *finished,
})
}
}
TrackedBlock::Thinking { text, finished, .. } => Some(InFlightBlock::Thinking {
text: text.clone(),
finished: *finished,
}),
TrackedBlock::ToolCall {
id,
name,
args,
state,
..
} => Some(InFlightBlock::ToolCall {
id: id.clone(),
name: name.clone(),
args: args.clone(),
state: *state,
}),
}
}
}
pub(crate) fn snapshot_from_guard(guard: &MutexGuard<'_, InFlightInner>) -> InFlightSnapshot {
guard.snapshot()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn snapshot_boundary_does_not_duplicate_or_gap_delta_sent_after_subscribe() {
let (event_tx, _) = broadcast::channel(16);
let in_flight = InFlightEvents::new(event_tx.clone());
let block_id = in_flight.start_text_block();
in_flight.text_delta(block_id, "hel".into());
let guard = in_flight.snapshot_guard();
let mut rx = event_tx.subscribe();
let snapshot = snapshot_from_guard(&guard);
drop(guard);
in_flight.text_delta(block_id, "lo".into());
assert_eq!(
snapshot.blocks,
vec![InFlightBlock::Text {
text: "hel".into(),
finished: false,
}]
);
assert!(matches!(
rx.try_recv().unwrap(),
Event::TextDelta { text } if text == "lo"
));
assert!(rx.try_recv().is_err());
}
#[test]
fn session_log_and_in_flight_snapshot_prevents_mirror_only_assistant_gap() {
use std::sync::mpsc;
use std::thread;
use crate::segment_log_sink::SegmentLogSink;
use session_store::{LogEntry, LoggedRole};
let (event_tx, _) = broadcast::channel(16);
let sink = SegmentLogSink::new();
let in_flight = InFlightEvents::new(event_tx);
let block_id = in_flight.start_text_block();
in_flight.text_delta(block_id, "done".into());
in_flight.text_done(block_id, "done".into());
let assistant_item = LoggedItem::Message {
role: LoggedRole::Assistant,
content: vec![LoggedContentPart::Text {
text: "done".into(),
}],
};
let assistant_entry = LogEntry::AssistantItem {
ts: 1,
item: assistant_item.clone(),
};
let in_flight_guard = in_flight.snapshot_guard();
let in_flight_for_commit = in_flight.clone();
let sink_for_commit = sink.clone();
let (committed_tx, committed_rx) = mpsc::channel();
let commit_thread = thread::spawn(move || {
// This mirrors Pod::append_entry ordering: clear in-flight first,
// then publish the finalized AssistantItem. AssistantItem entries
// are mirror-only and are not delivered as live entry events.
in_flight_for_commit.clear_for_committed_item_then(&assistant_item, || {
sink_for_commit.publish(assistant_entry);
});
committed_tx.send(()).unwrap();
});
let (entries_snapshot, mut entry_rx) = sink.subscribe_with_snapshot();
let in_flight_snapshot = snapshot_from_guard(&in_flight_guard);
drop(in_flight_guard);
committed_rx.recv().unwrap();
commit_thread.join().unwrap();
assert!(entries_snapshot.is_empty());
assert!(matches!(
in_flight_snapshot.blocks.as_slice(),
[InFlightBlock::Text { text, finished: true }] if text == "done"
));
assert!(entry_rx.try_recv().is_err());
let post_commit_guard = in_flight.snapshot_guard();
assert!(snapshot_from_guard(&post_commit_guard).is_empty());
}
#[test]
fn committed_assistant_snapshot_does_not_duplicate_in_flight_block() {
use crate::segment_log_sink::SegmentLogSink;
use session_store::{LogEntry, LoggedRole};
let (event_tx, _) = broadcast::channel(16);
let sink = SegmentLogSink::new();
let in_flight = InFlightEvents::new(event_tx);
let block_id = in_flight.start_text_block();
in_flight.text_delta(block_id, "done".into());
in_flight.text_done(block_id, "done".into());
let assistant_item = LoggedItem::Message {
role: LoggedRole::Assistant,
content: vec![LoggedContentPart::Text {
text: "done".into(),
}],
};
let assistant_entry = LogEntry::AssistantItem {
ts: 1,
item: assistant_item.clone(),
};
in_flight.clear_for_committed_item_then(&assistant_item, || {
sink.publish(assistant_entry);
});
let in_flight_guard = in_flight.snapshot_guard();
let (entries_snapshot, _entry_rx) = sink.subscribe_with_snapshot();
let in_flight_snapshot = snapshot_from_guard(&in_flight_guard);
assert!(matches!(
entries_snapshot.as_slice(),
[LogEntry::AssistantItem { item, .. }] if item == &assistant_item
));
assert!(in_flight_snapshot.is_empty());
}
#[test]
fn committed_item_clears_matching_in_flight_block() {
let (event_tx, _) = broadcast::channel(16);
let in_flight = InFlightEvents::new(event_tx);
let block_id = in_flight.start_text_block();
in_flight.text_delta(block_id, "done".into());
in_flight.clear_for_committed_item_then(
&LoggedItem::Message {
role: session_store::LoggedRole::Assistant,
content: vec![LoggedContentPart::Text {
text: "done".into(),
}],
},
|| (),
);
let guard = in_flight.snapshot_guard();
assert!(snapshot_from_guard(&guard).is_empty());
}
}

View File

@ -7,6 +7,7 @@ use tokio::net::UnixListener;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use crate::controller::PodHandle; use crate::controller::PodHandle;
use crate::in_flight::snapshot_from_guard;
use protocol::{Event, Method}; use protocol::{Event, Method};
/// Unix socket server for Pod Protocol. /// Unix socket server for Pod Protocol.
@ -104,18 +105,22 @@ async fn handle_connection(stream: tokio::net::UnixStream, handle: PodHandle) {
let mut reader = JsonLineReader::new(reader); let mut reader = JsonLineReader::new(reader);
let mut writer = JsonLineWriter::new(writer); let mut writer = JsonLineWriter::new(writer);
// Atomically subscribe to the session-log mirror first. The // Hold the in-flight stream lock while taking the session-log mirror
// returned (snapshot, rx) pair partitions the entry timeline: // snapshot. `LogEntry::AssistantItem` is mirror-only for live clients,
// entries committed before this call appear in `entries`, every // so a finalized assistant block must be observed either as an already
// entry after lands on `entry_rx`. Doing this before the alert // committed entry or as the still-present in-flight block. This lock
// snapshot keeps both ordering pairs internally consistent. // order matches `append_entry` (in-flight clear before sink publish) and
let (entries_snapshot, mut entry_rx) = handle.sink.subscribe_with_snapshot(); // keeps the snapshot/live boundary gap-free.
let (entries_snapshot, mut entry_rx, alert_snapshot, mut rx, in_flight) = {
let in_flight_guard = handle.in_flight.snapshot_guard();
let (entries_snapshot, entry_rx) = handle.sink.subscribe_with_snapshot();
// Atomically subscribe and snapshot buffered alerts so that // Atomically subscribe and snapshot buffered alerts so that warnings
// warnings emitted before this client connected are replayed // emitted before this client connected are replayed exactly once.
// exactly once — they appear in the snapshot, and any alert let (alert_snapshot, rx) = handle.alerter.subscribe_with_snapshot();
// arriving afterwards reaches us through `rx`. let in_flight = snapshot_from_guard(&in_flight_guard);
let (alert_snapshot, mut rx) = handle.alerter.subscribe_with_snapshot(); (entries_snapshot, entry_rx, alert_snapshot, rx, in_flight)
};
for alert in alert_snapshot { for alert in alert_snapshot {
if writer.write(&Event::Alert(alert)).await.is_err() { if writer.write(&Event::Alert(alert)).await.is_err() {
return; return;
@ -131,6 +136,7 @@ async fn handle_connection(stream: tokio::net::UnixStream, handle: PodHandle) {
.collect(), .collect(),
greeting: handle.shared_state.greeting.clone(), greeting: handle.shared_state.greeting.clone(),
status: handle.shared_state.get_status(), status: handle.shared_state.get_status(),
in_flight,
}; };
if writer.write(&snapshot_event).await.is_err() { if writer.write(&snapshot_event).await.is_err() {
return; return;

View File

@ -6,6 +6,7 @@ pub mod entrypoint;
pub mod feature; pub mod feature;
pub mod fs_view; pub mod fs_view;
pub mod hook; pub mod hook;
pub(crate) mod in_flight;
pub mod ipc; pub mod ipc;
pub mod prompt; pub mod prompt;
pub mod runtime; pub mod runtime;

View File

@ -35,6 +35,7 @@ use crate::hook::{
Hook, HookRegistryBuilder, OnAbort, OnPromptSubmit, OnTurnEnd, PostToolCall, PreLlmRequest, Hook, HookRegistryBuilder, OnAbort, OnPromptSubmit, OnTurnEnd, PostToolCall, PreLlmRequest,
PreToolCall, PreToolCall,
}; };
use crate::in_flight::InFlightEvents;
use crate::ipc::alerter::Alerter; use crate::ipc::alerter::Alerter;
use crate::ipc::interceptor::PodInterceptor; use crate::ipc::interceptor::PodInterceptor;
use crate::ipc::notify_buffer::NotifyBuffer; use crate::ipc::notify_buffer::NotifyBuffer;
@ -167,6 +168,7 @@ pub struct LogWriterHandle<St: Clone> {
pub store: St, pub store: St,
pub state: Arc<SegmentState>, pub state: Arc<SegmentState>,
pub sink: SegmentLogSink, pub sink: SegmentLogSink,
pub in_flight: Option<InFlightEvents>,
} }
impl<St> LogWriterHandle<St> impl<St> LogWriterHandle<St>
@ -181,6 +183,15 @@ where
let loc = self.state.location(); let loc = self.state.location();
self.store.append(loc.session_id, loc.segment_id, &entry)?; self.store.append(loc.session_id, loc.segment_id, &entry)?;
self.state.increment_entries(); self.state.increment_entries();
if let Some(in_flight) = &self.in_flight {
if let LogEntry::AssistantItem { item, .. } = &entry {
let item_for_clear = item.clone();
in_flight.clear_for_committed_item_then(&item_for_clear, || {
self.sink.publish(entry);
});
return Ok(());
}
}
self.sink.publish(entry); self.sink.publish(entry);
Ok(()) Ok(())
} }
@ -296,6 +307,7 @@ pub struct Pod<C: LlmClient, St: Store> {
/// notifications, events sent here are NOT replayed to clients that /// notifications, events sent here are NOT replayed to clients that
/// connect after the fact — they are fire-and-forget broadcasts. /// connect after the fact — they are fire-and-forget broadcasts.
event_tx: Option<broadcast::Sender<Event>>, event_tx: Option<broadcast::Sender<Event>>,
in_flight: Option<InFlightEvents>,
/// Monotonic counter incremented by worker event bridges when an /// Monotonic counter incremented by worker event bridges when an
/// assistant-side execution artifact becomes visible to clients before /// assistant-side execution artifact becomes visible to clients before
/// it is necessarily committed to history (e.g. streaming text deltas). /// it is necessarily committed to history (e.g. streaming text deltas).
@ -449,6 +461,7 @@ impl<C: LlmClient + Clone + 'static, St: Store + Clone + 'static> Pod<C, St> {
system_prompt_template: None, system_prompt_template: None,
alerter: self.alerter.clone(), alerter: self.alerter.clone(),
event_tx: self.event_tx.clone(), event_tx: self.event_tx.clone(),
in_flight: self.in_flight.clone(),
ai_activity_counter: self.ai_activity_counter.clone(), ai_activity_counter: self.ai_activity_counter.clone(),
pending_notifies: NotifyBuffer::new(), pending_notifies: NotifyBuffer::new(),
pending_attachments: Arc::new(Mutex::new(Vec::<SystemItem>::new())), pending_attachments: Arc::new(Mutex::new(Vec::<SystemItem>::new())),
@ -484,6 +497,7 @@ impl<C: LlmClient + Clone + 'static, St: Store + Clone + 'static> Pod<C, St> {
store: self.store.clone(), store: self.store.clone(),
state: self.segment_state.clone(), state: self.segment_state.clone(),
sink: self.sink.clone(), sink: self.sink.clone(),
in_flight: self.in_flight.clone(),
} }
} }
@ -495,6 +509,10 @@ impl<C: LlmClient + Clone + 'static, St: Store + Clone + 'static> Pod<C, St> {
self.log_writer = Some(writer); self.log_writer = Some(writer);
} }
pub fn attach_in_flight_events(&mut self, in_flight: InFlightEvents) {
self.in_flight = Some(in_flight);
}
/// Wire `Worker::on_history_append` to commit each appended item /// Wire `Worker::on_history_append` to commit each appended item
/// directly as a singular `LogEntry::AssistantItem` / `ToolResult` /// directly as a singular `LogEntry::AssistantItem` / `ToolResult`
/// through the writer. The controller calls this once per spawned /// through the writer. The controller calls this once per spawned
@ -633,6 +651,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
system_prompt_template: None, system_prompt_template: None,
alerter: None, alerter: None,
event_tx: None, event_tx: None,
in_flight: None,
ai_activity_counter: Arc::new(AtomicUsize::new(0)), ai_activity_counter: Arc::new(AtomicUsize::new(0)),
pending_notifies: NotifyBuffer::new(), pending_notifies: NotifyBuffer::new(),
pending_attachments: Arc::new(Mutex::new(Vec::<SystemItem>::new())), pending_attachments: Arc::new(Mutex::new(Vec::<SystemItem>::new())),
@ -3842,6 +3861,7 @@ where
system_prompt_template: common.system_prompt_template, system_prompt_template: common.system_prompt_template,
alerter: None, alerter: None,
event_tx: None, event_tx: None,
in_flight: None,
ai_activity_counter: Arc::new(AtomicUsize::new(0)), ai_activity_counter: Arc::new(AtomicUsize::new(0)),
pending_notifies: NotifyBuffer::new(), pending_notifies: NotifyBuffer::new(),
pending_attachments: Arc::new(Mutex::new(Vec::<SystemItem>::new())), pending_attachments: Arc::new(Mutex::new(Vec::<SystemItem>::new())),
@ -3951,6 +3971,7 @@ where
system_prompt_template: common.system_prompt_template, system_prompt_template: common.system_prompt_template,
alerter: None, alerter: None,
event_tx: None, event_tx: None,
in_flight: None,
ai_activity_counter: Arc::new(AtomicUsize::new(0)), ai_activity_counter: Arc::new(AtomicUsize::new(0)),
pending_notifies: NotifyBuffer::new(), pending_notifies: NotifyBuffer::new(),
pending_attachments: Arc::new(Mutex::new(Vec::<SystemItem>::new())), pending_attachments: Arc::new(Mutex::new(Vec::<SystemItem>::new())),
@ -4187,6 +4208,7 @@ where
system_prompt_template: None, system_prompt_template: None,
alerter: None, alerter: None,
event_tx: None, event_tx: None,
in_flight: None,
ai_activity_counter: Arc::new(AtomicUsize::new(0)), ai_activity_counter: Arc::new(AtomicUsize::new(0)),
pending_notifies: NotifyBuffer::new(), pending_notifies: NotifyBuffer::new(),
pending_attachments: Arc::new(Mutex::new(Vec::<SystemItem>::new())), pending_attachments: Arc::new(Mutex::new(Vec::<SystemItem>::new())),
@ -5378,6 +5400,8 @@ permission = "read"
services: vec![], services: vec![],
ingresses: vec![], ingresses: vec![],
permissions: vec![], permissions: vec![],
request: vec![],
websocket: vec![],
}, },
enabled_surfaces: vec![manifest::plugin::PluginSurface::Hook], enabled_surfaces: vec![manifest::plugin::PluginSurface::Hook],
grants: manifest::plugin::PluginGrantConfig::default(), grants: manifest::plugin::PluginGrantConfig::default(),

View File

@ -515,6 +515,7 @@ mod tests {
context_tokens: 0, context_tokens: 0,
}, },
status: PodStatus::Idle, status: PodStatus::Idle,
in_flight: Default::default(),
} }
} }

View File

@ -435,6 +435,7 @@ mod tests {
context_tokens: 0, context_tokens: 0,
}, },
status: PodStatus::Idle, status: PodStatus::Idle,
in_flight: Default::default(),
}) })
.await .await
.unwrap(); .unwrap();
@ -457,6 +458,7 @@ mod tests {
context_tokens: 0, context_tokens: 0,
}, },
status: PodStatus::Idle, status: PodStatus::Idle,
in_flight: Default::default(),
}) })
.await .await
.unwrap(); .unwrap();

View File

@ -129,6 +129,7 @@ fn empty_snapshot() -> Event {
context_tokens: 0, context_tokens: 0,
}, },
status: protocol::PodStatus::Idle, status: protocol::PodStatus::Idle,
in_flight: Default::default(),
} }
} }
@ -203,6 +204,7 @@ fn serve_history(listener: UnixListener, items: Vec<Item>) -> JoinHandle<()> {
context_tokens: 0, context_tokens: 0,
}, },
status: protocol::PodStatus::Idle, status: protocol::PodStatus::Idle,
in_flight: Default::default(),
}; };
let _ = writer.write(&event).await; let _ = writer.write(&event).await;
} }

View File

@ -91,6 +91,7 @@ fn empty_snapshot() -> Event {
context_tokens: 0, context_tokens: 0,
}, },
status: PodStatus::Idle, status: PodStatus::Idle,
in_flight: Default::default(),
} }
} }

View File

@ -123,6 +123,7 @@ fn accept_one_method(listener: UnixListener) -> tokio::task::JoinHandle<Option<M
context_tokens: 0, context_tokens: 0,
}, },
status: protocol::PodStatus::Idle, status: protocol::PodStatus::Idle,
in_flight: Default::default(),
}) })
.await .await
.is_err() .is_err()

View File

@ -12,6 +12,10 @@ fn is_true(value: &bool) -> bool {
*value *value
} }
fn is_false(value: &bool) -> bool {
!*value
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Method (Client → Pod via Unix Socket) // Method (Client → Pod via Unix Socket)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -453,6 +457,10 @@ pub enum Event {
greeting: Greeting, greeting: Greeting,
#[serde(default)] #[serde(default)]
status: PodStatus, status: PodStatus,
/// Unfinished model output that has already streamed in the current
/// run but is not yet represented by committed snapshot entries.
#[serde(default, skip_serializing_if = "InFlightSnapshot::is_empty")]
in_flight: InFlightSnapshot,
}, },
/// Server-side segment log rotated to a fresh `SegmentStart`. /// Server-side segment log rotated to a fresh `SegmentStart`.
/// ///
@ -631,6 +639,62 @@ pub struct RewindSummary {
pub tool_side_effect_warning: bool, pub tool_side_effect_warning: bool,
} }
/// Unfinished model output included in `Event::Snapshot` for clients that
/// attach while an LLM response is still streaming.
///
/// These blocks are presentation state only: they are reconstructed from the
/// active Pod controller and must not be treated as committed assistant
/// history. Finalized assistant items continue to come from ordinary snapshot
/// entries.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct InFlightSnapshot {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub blocks: Vec<InFlightBlock>,
}
impl InFlightSnapshot {
pub fn is_empty(&self) -> bool {
self.blocks.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum InFlightBlock {
Text {
text: String,
#[serde(default, skip_serializing_if = "is_false")]
finished: bool,
},
Thinking {
text: String,
#[serde(default, skip_serializing_if = "is_false")]
finished: bool,
},
ToolCall {
id: String,
name: String,
args: String,
#[serde(default, skip_serializing_if = "InFlightToolCallState::is_pending")]
state: InFlightToolCallState,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum InFlightToolCallState {
#[default]
Pending,
StreamingArgs,
Done,
}
impl InFlightToolCallState {
pub fn is_pending(&self) -> bool {
matches!(self, Self::Pending)
}
}
/// Pod self-description rendered by the TUI when a session starts empty. /// Pod self-description rendered by the TUI when a session starts empty.
/// ///
/// Built once in the Pod controller from the resolved manifest and /// Built once in the Pod controller from the resolved manifest and
@ -1129,6 +1193,7 @@ mod tests {
context_tokens: 42_000, context_tokens: 42_000,
}, },
status: PodStatus::Paused, status: PodStatus::Paused,
in_flight: InFlightSnapshot::default(),
}; };
let json = serde_json::to_string(&event).unwrap(); let json = serde_json::to_string(&event).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
@ -1142,6 +1207,62 @@ mod tests {
assert_eq!(parsed["data"]["status"], "paused"); assert_eq!(parsed["data"]["status"], "paused");
} }
#[test]
fn event_snapshot_in_flight_roundtrip_and_default() {
let inbound = r#"{"event":"snapshot","data":{"entries":[],"greeting":{"pod_name":"test","cwd":"/tmp","provider":"p","model":"m","scope_summary":"s","tools":[]},"status":"running"}}"#;
let decoded: Event = serde_json::from_str(inbound).unwrap();
match decoded {
Event::Snapshot { in_flight, .. } => assert!(in_flight.is_empty()),
other => panic!("expected Snapshot, got {other:?}"),
}
let event = Event::Snapshot {
entries: Vec::new(),
greeting: Greeting {
pod_name: "test".into(),
cwd: "/tmp".into(),
provider: "p".into(),
model: "m".into(),
scope_summary: "s".into(),
tools: Vec::new(),
context_window: 0,
context_tokens: 0,
},
status: PodStatus::Running,
in_flight: InFlightSnapshot {
blocks: vec![
InFlightBlock::Text {
text: "hel".into(),
finished: false,
},
InFlightBlock::Thinking {
text: "why".into(),
finished: true,
},
InFlightBlock::ToolCall {
id: "call_1".into(),
name: "Read".into(),
args: r#"{"file"#.into(),
state: InFlightToolCallState::StreamingArgs,
},
],
},
};
let json = serde_json::to_string(&event).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["data"]["in_flight"]["blocks"][0]["text"], "hel");
assert_eq!(parsed["data"]["in_flight"]["blocks"][1]["finished"], true);
assert_eq!(
parsed["data"]["in_flight"]["blocks"][2]["state"],
"streaming_args"
);
match serde_json::from_str::<Event>(&json).unwrap() {
Event::Snapshot { in_flight, .. } => assert_eq!(in_flight.blocks.len(), 3),
other => panic!("expected Snapshot, got {other:?}"),
}
}
#[test] #[test]
fn event_segment_rotated_roundtrip() { fn event_segment_rotated_roundtrip() {
let event = Event::SegmentRotated { let event = Event::SegmentRotated {

View File

@ -3,8 +3,8 @@ use std::path::Path;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use protocol::{ use protocol::{
AlertLevel, AlertSource, CompletionEntry, CompletionKind, ErrorCode, Event, Method, PodStatus, AlertLevel, AlertSource, CompletionEntry, CompletionKind, ErrorCode, Event, InFlightBlock,
RewindTarget, RunResult, Segment, InFlightSnapshot, InFlightToolCallState, Method, PodStatus, RewindTarget, RunResult, Segment,
}; };
use crate::block::{ use crate::block::{
@ -1279,9 +1279,10 @@ impl App {
entries, entries,
greeting, greeting,
status, status,
in_flight,
} => { } => {
self.rewind_refresh_fence = false; self.rewind_refresh_fence = false;
self.restore_snapshot(&entries, greeting); self.restore_snapshot(&entries, greeting, in_flight);
self.set_pod_status(status); self.set_pod_status(status);
} }
Event::Status { status } => { Event::Status { status } => {
@ -1410,6 +1411,50 @@ impl App {
}); });
} }
fn apply_in_flight_snapshot(&mut self, snapshot: InFlightSnapshot) {
for block in snapshot.blocks {
match block {
InFlightBlock::Text { text, finished } => {
self.blocks.push(Block::AssistantText { text });
self.assistant_streaming = !finished;
}
InFlightBlock::Thinking { text, finished } => {
let state = if finished {
ThinkingState::Finished { elapsed_secs: None }
} else {
ThinkingState::Streaming {
started_at: Instant::now(),
}
};
self.blocks
.push(Block::Thinking(ThinkingBlock { text, state }));
}
InFlightBlock::ToolCall {
id,
name,
args,
state,
} => {
let (tool_state, arguments) = match state {
InFlightToolCallState::Pending => (ToolCallState::Pending, None),
InFlightToolCallState::StreamingArgs => (ToolCallState::Streaming, None),
InFlightToolCallState::Done => {
(ToolCallState::Executing, Some(args.clone()))
}
};
self.blocks.push(Block::ToolCall(ToolCallBlock {
id,
name,
args_stream: args,
arguments,
state: tool_state,
edit_snapshot: None,
}));
}
}
}
}
fn append_assistant_text(&mut self, text: &str) { fn append_assistant_text(&mut self, text: &str) {
if self.assistant_streaming { if self.assistant_streaming {
if let Some(Block::AssistantText { text: existing }) = self.blocks.last_mut() { if let Some(Block::AssistantText { text: existing }) = self.blocks.last_mut() {
@ -1913,11 +1958,17 @@ impl App {
/// LogEntry variant into the same blocks live events would have /// LogEntry variant into the same blocks live events would have
/// produced. Followed by `Event::Entry` updates for anything /// produced. Followed by `Event::Entry` updates for anything
/// committed after the snapshot. /// committed after the snapshot.
fn restore_snapshot(&mut self, entries: &[serde_json::Value], greeting: protocol::Greeting) { fn restore_snapshot(
&mut self,
entries: &[serde_json::Value],
greeting: protocol::Greeting,
in_flight: InFlightSnapshot,
) {
self.greeting = Some(greeting.clone()); self.greeting = Some(greeting.clone());
self.context_window = greeting.context_window; self.context_window = greeting.context_window;
self.session_context_tokens = greeting.context_tokens; self.session_context_tokens = greeting.context_tokens;
self.restore_entries(entries, Some(greeting)); self.restore_entries(entries, Some(greeting));
self.apply_in_flight_snapshot(in_flight);
} }
/// Restore after a successful destructive rewind. The Pod's /// Restore after a successful destructive rewind. The Pod's
@ -3151,6 +3202,7 @@ mod completion_flow_tests {
greeting: test_greeting(), greeting: test_greeting(),
entries: vec![session_start_value], entries: vec![session_start_value],
status: PodStatus::Running, status: PodStatus::Running,
in_flight: Default::default(),
}); });
assert!(matches!(app.pod_status, PodStatus::Running)); assert!(matches!(app.pod_status, PodStatus::Running));
@ -3161,6 +3213,54 @@ mod completion_flow_tests {
)); ));
} }
#[test]
fn snapshot_in_flight_blocks_continue_with_live_deltas() {
let mut app = App::new("test".into());
app.handle_pod_event(Event::Snapshot {
greeting: test_greeting(),
entries: Vec::new(),
status: PodStatus::Running,
in_flight: InFlightSnapshot {
blocks: vec![
InFlightBlock::Thinking {
text: "why".into(),
finished: false,
},
InFlightBlock::ToolCall {
id: "call_1".into(),
name: "Read".into(),
args: r#"{\"file"#.into(),
state: InFlightToolCallState::StreamingArgs,
},
InFlightBlock::Text {
text: "hel".into(),
finished: false,
},
],
},
});
app.handle_pod_event(Event::TextDelta { text: "lo".into() });
app.handle_pod_event(Event::ThinkingDelta { text: "?".into() });
app.handle_pod_event(Event::ToolCallArgsDelta {
id: "call_1".into(),
json: r#"\":\"src/lib.rs\"}"#.into(),
});
assert!(matches!(
app.blocks.iter().find(|block| matches!(block, Block::AssistantText { .. })),
Some(Block::AssistantText { text }) if text == "hello"
));
assert!(matches!(
app.blocks.iter().find(|block| matches!(block, Block::Thinking(_))),
Some(Block::Thinking(thinking)) if thinking.text == "why?"
));
assert!(matches!(
app.blocks.iter().find(|block| matches!(block, Block::ToolCall(_))),
Some(Block::ToolCall(call)) if call.args_stream == r#"{\"file\":\"src/lib.rs\"}"#
));
}
#[test] #[test]
fn live_system_item_workflow_appends_system_message_block() { fn live_system_item_workflow_appends_system_message_block() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
@ -3294,6 +3394,7 @@ mod completion_flow_tests {
entries: Vec::new(), entries: Vec::new(),
greeting, greeting,
status: PodStatus::Idle, status: PodStatus::Idle,
in_flight: Default::default(),
}); });
assert_eq!(app.context_window, 123_000); assert_eq!(app.context_window, 123_000);
@ -3492,6 +3593,7 @@ mod completion_flow_tests {
greeting: test_greeting(), greeting: test_greeting(),
entries: assistant_item_entries, entries: assistant_item_entries,
status: PodStatus::Running, status: PodStatus::Running,
in_flight: Default::default(),
}); });
let tasks = app.task_store.tasks(); let tasks = app.task_store.tasks();

View File

@ -1922,6 +1922,7 @@ mod tests {
greeting: test_greeting(), greeting: test_greeting(),
entries: vec![], entries: vec![],
status: PodStatus::Idle, status: PodStatus::Idle,
in_flight: Default::default(),
}); });
app.handle_pod_event(Event::RewindApplied { app.handle_pod_event(Event::RewindApplied {
entries: vec![], entries: vec![],
@ -1947,6 +1948,7 @@ mod tests {
greeting: test_greeting(), greeting: test_greeting(),
entries: vec![], entries: vec![],
status: PodStatus::Idle, status: PodStatus::Idle,
in_flight: Default::default(),
}); });
type_keys(&mut app, "draft"); type_keys(&mut app, "draft");

View File

@ -868,6 +868,7 @@ async fn ticket_queue_notification_sends_notify_when_socket_available() {
context_tokens: 0, context_tokens: 0,
}, },
status: PodStatus::Idle, status: PodStatus::Idle,
in_flight: Default::default(),
}) })
.await .await
.unwrap(); .unwrap();
@ -908,6 +909,7 @@ async fn send_notify_only_can_deliver_weak_notification_without_auto_run() {
context_tokens: 0, context_tokens: 0,
}, },
status: PodStatus::Idle, status: PodStatus::Idle,
in_flight: Default::default(),
}) })
.await .await
.unwrap(); .unwrap();

View File

@ -819,6 +819,7 @@ mod tests {
entries: vec![], entries: vec![],
greeting: test_greeting(), greeting: test_greeting(),
status: PodStatus::Idle, status: PodStatus::Idle,
in_flight: Default::default(),
}, },
]; ];

View File

@ -0,0 +1,26 @@
[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
manifest = { workspace = true }
pod-store = { 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", "macros", "net", "rt-multi-thread", "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,552 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use pod_store::{PodMetadata, validate_pod_name};
use serde::{Deserialize, Serialize};
const MAX_DIAGNOSTICS: usize = 20;
const MAX_LABEL_LEN: usize = 120;
const MAX_PATH_LEN: usize = 512;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimeDiagnostic {
pub code: String,
pub severity: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct HostSummary {
pub host_id: String,
pub label: String,
pub kind: String,
pub status: String,
pub observed_at: String,
pub last_seen_at: String,
pub capabilities: HostCapabilitySummary,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct HostCapabilitySummary {
pub local_pod_inspection: String,
pub workspace_root: String,
pub os: String,
pub arch: String,
pub max_workers: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkerSummary {
pub worker_id: String,
pub host_id: String,
pub label: String,
pub pod_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace_root: Option<String>,
pub state: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_seen_at: Option<String>,
pub implementation: WorkerImplementation,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkerImplementation {
pub kind: String,
pub pod_name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocalRuntimeBridge {
workspace_id: String,
workspace_root: PathBuf,
data_dir: Option<PathBuf>,
}
impl LocalRuntimeBridge {
pub fn new(
workspace_id: impl Into<String>,
workspace_root: impl Into<PathBuf>,
data_dir: Option<PathBuf>,
) -> Self {
Self {
workspace_id: workspace_id.into(),
workspace_root: workspace_root.into(),
data_dir,
}
}
pub fn host_id(&self) -> String {
stable_local_host_id(&self.workspace_id)
}
pub fn list_hosts(&self, limit: usize) -> (Vec<HostSummary>, Vec<RuntimeDiagnostic>) {
if limit == 0 {
return (Vec::new(), Vec::new());
}
let observed_at = unix_timestamp(SystemTime::now());
let mut diagnostics = pod_root_diagnostics(self.pod_root().as_deref());
let local_pod_inspection = if diagnostics.is_empty() {
"available"
} else {
"unavailable"
}
.to_string();
let status = if local_pod_inspection == "available" {
"available"
} else {
"degraded"
}
.to_string();
truncate_diagnostics(&mut diagnostics);
let host = HostSummary {
host_id: self.host_id(),
label: format!(
"Local host ({})",
self.workspace_root
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("workspace")
),
kind: "local_host".to_string(),
status,
observed_at: observed_at.clone(),
last_seen_at: observed_at,
capabilities: HostCapabilitySummary {
local_pod_inspection,
workspace_root: bounded_path(&self.workspace_root),
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
max_workers: limit.min(200),
},
diagnostics: diagnostics.clone(),
};
(vec![host], diagnostics)
}
pub fn list_workers(&self, limit: usize) -> (Vec<WorkerSummary>, Vec<RuntimeDiagnostic>) {
let limit = limit.min(200);
let Some(pod_root) = self.pod_root() else {
return (
Vec::new(),
vec![RuntimeDiagnostic::new(
"local_yoi_data_dir_unavailable",
"warning",
"local Yoi data directory is not configured; local Pod workers cannot be inspected",
)],
);
};
let mut diagnostics = Vec::new();
if !pod_root.exists() {
diagnostics.push(RuntimeDiagnostic::new(
"local_pod_metadata_root_missing",
"info",
"local Pod metadata directory is absent; no local workers were discovered",
));
return (Vec::new(), diagnostics);
}
let entries = match fs::read_dir(&pod_root) {
Ok(entries) => entries,
Err(error) => {
diagnostics.push(RuntimeDiagnostic::new(
"local_pod_metadata_root_unreadable",
"warning",
format!("local Pod metadata directory cannot be read: {error}"),
));
return (Vec::new(), diagnostics);
}
};
let mut workers = Vec::new();
let mut candidate_names = Vec::new();
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(error) => {
push_diagnostic(
&mut diagnostics,
RuntimeDiagnostic::new(
"local_pod_metadata_entry_unreadable",
"warning",
format!("one local Pod metadata entry cannot be read: {error}"),
),
);
continue;
}
};
let file_type = match entry.file_type() {
Ok(file_type) => file_type,
Err(error) => {
push_diagnostic(
&mut diagnostics,
RuntimeDiagnostic::new(
"local_pod_metadata_entry_type_unreadable",
"warning",
format!("one local Pod metadata entry type cannot be read: {error}"),
),
);
continue;
}
};
if !file_type.is_dir() {
continue;
}
let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else {
push_diagnostic(
&mut diagnostics,
RuntimeDiagnostic::new(
"local_pod_name_non_utf8",
"warning",
"one local Pod metadata directory has a non-UTF-8 name and was skipped",
),
);
continue;
};
if validate_pod_name(&name).is_err() || name.len() > MAX_LABEL_LEN {
push_diagnostic(
&mut diagnostics,
RuntimeDiagnostic::new(
"local_pod_name_invalid",
"warning",
"one local Pod metadata directory has an invalid or oversized name and was skipped",
),
);
continue;
}
if entry.path().join("metadata.json").exists() {
candidate_names.push(name);
}
}
candidate_names.sort();
candidate_names.truncate(limit);
for pod_name in candidate_names {
match read_worker(&pod_root, &pod_name, &self.host_id()) {
Ok(worker) => workers.push(worker),
Err(diagnostic) => push_diagnostic(&mut diagnostics, diagnostic),
}
}
truncate_diagnostics(&mut diagnostics);
(workers, diagnostics)
}
fn pod_root(&self) -> Option<PathBuf> {
self.data_dir.as_ref().map(|data_dir| data_dir.join("pods"))
}
}
impl RuntimeDiagnostic {
pub fn new(
code: impl Into<String>,
severity: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self {
code: truncate_string(&code.into(), MAX_LABEL_LEN),
severity: truncate_string(&severity.into(), MAX_LABEL_LEN),
message: truncate_string(&message.into(), 240),
}
}
}
fn read_worker(
pod_root: &Path,
pod_name: &str,
host_id: &str,
) -> Result<WorkerSummary, RuntimeDiagnostic> {
let metadata_path = pod_root.join(pod_name).join("metadata.json");
let last_seen_at = metadata_path
.metadata()
.ok()
.and_then(|metadata| metadata.modified().ok())
.map(unix_timestamp);
let raw = fs::read_to_string(&metadata_path).map_err(|error| {
RuntimeDiagnostic::new(
"local_pod_metadata_unreadable",
"warning",
format!("local Pod metadata for `{pod_name}` cannot be read: {error}"),
)
})?;
let metadata: PodMetadata = serde_json::from_str(&raw).map_err(|error| {
RuntimeDiagnostic::new(
"local_pod_metadata_invalid",
"warning",
format!("local Pod metadata for `{pod_name}` is invalid: {error}"),
)
})?;
let mut worker_diagnostics = Vec::new();
if metadata.pod_name != pod_name {
worker_diagnostics.push(RuntimeDiagnostic::new(
"local_pod_metadata_name_mismatch",
"warning",
"metadata pod_name differed from its directory name; the directory name was used",
));
}
let state = if metadata.active.is_some() {
"active"
} else {
"inactive"
}
.to_string();
let status = match metadata.active.as_ref() {
Some(active) if active.segment_id.is_some() => "active_segment_known",
Some(_) => "active_session_pending_segment",
None => "metadata_only",
}
.to_string();
let (role, profile) = extract_safe_role_profile(metadata.resolved_manifest_snapshot.as_ref());
Ok(WorkerSummary {
worker_id: format!("local-pod-{}", sanitize_identifier(pod_name, MAX_LABEL_LEN)),
host_id: host_id.to_string(),
label: truncate_string(pod_name, MAX_LABEL_LEN),
pod_name: truncate_string(pod_name, MAX_LABEL_LEN),
role,
profile,
workspace_root: metadata
.workspace_root
.as_ref()
.map(|path| bounded_path(path)),
state,
status,
last_seen_at,
implementation: WorkerImplementation {
kind: "local_pod".to_string(),
pod_name: truncate_string(pod_name, MAX_LABEL_LEN),
},
diagnostics: worker_diagnostics,
})
}
fn pod_root_diagnostics(pod_root: Option<&Path>) -> Vec<RuntimeDiagnostic> {
let Some(pod_root) = pod_root else {
return vec![RuntimeDiagnostic::new(
"local_yoi_data_dir_unavailable",
"warning",
"local Yoi data directory is not configured; local Pod inspection is unavailable",
)];
};
if !pod_root.exists() {
return vec![RuntimeDiagnostic::new(
"local_pod_metadata_root_missing",
"info",
"local Pod metadata directory is absent; local Pod inspection found no workers",
)];
}
match fs::read_dir(pod_root) {
Ok(_) => Vec::new(),
Err(error) => vec![RuntimeDiagnostic::new(
"local_pod_metadata_root_unreadable",
"warning",
format!("local Pod metadata directory cannot be read: {error}"),
)],
}
}
fn extract_safe_role_profile(
snapshot: Option<&serde_json::Value>,
) -> (Option<String>, Option<String>) {
let Some(snapshot) = snapshot else {
return (None, None);
};
let role = snapshot
.get("role")
.and_then(|value| value.as_str())
.and_then(safe_metadata_label);
let profile = snapshot
.get("profile")
.and_then(|profile| {
profile
.get("name")
.or_else(|| profile.get("selector"))
.or_else(|| profile.get("id"))
})
.and_then(|value| value.as_str())
.and_then(safe_metadata_label);
(role, profile)
}
fn safe_metadata_label(value: &str) -> Option<String> {
if value.is_empty()
|| value.len() > MAX_LABEL_LEN
|| value.contains('/')
|| value.contains('\\')
|| value.contains('\0')
|| value.chars().any(|ch| ch.is_control())
{
return None;
}
Some(value.to_string())
}
fn stable_local_host_id(workspace_id: &str) -> String {
format!("local-{}", sanitize_identifier(workspace_id, 96))
}
fn sanitize_identifier(value: &str, max_len: usize) -> String {
let mut output = String::new();
for ch in value.chars() {
if output.len() >= max_len {
break;
}
if ch.is_ascii_alphanumeric() {
output.push(ch.to_ascii_lowercase());
} else if !output.ends_with('-') {
output.push('-');
}
}
let output = output.trim_matches('-');
if output.is_empty() {
"local".to_string()
} else {
output.to_string()
}
}
fn bounded_path(path: &Path) -> String {
truncate_string(&path.to_string_lossy(), MAX_PATH_LEN)
}
fn truncate_string(value: &str, max_len: usize) -> String {
if value.len() <= max_len {
return value.to_string();
}
let mut end = max_len;
while !value.is_char_boundary(end) {
end -= 1;
}
value[..end].to_string()
}
fn push_diagnostic(diagnostics: &mut Vec<RuntimeDiagnostic>, diagnostic: RuntimeDiagnostic) {
if diagnostics.len() < MAX_DIAGNOSTICS {
diagnostics.push(diagnostic);
}
}
fn truncate_diagnostics(diagnostics: &mut Vec<RuntimeDiagnostic>) {
diagnostics.truncate(MAX_DIAGNOSTICS);
}
fn unix_timestamp(time: SystemTime) -> String {
match time.duration_since(UNIX_EPOCH) {
Ok(duration) => format!("unix:{}", duration.as_secs()),
Err(_) => "unix:0".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn lists_workers_from_local_pod_metadata_without_exposing_snapshot_contents() {
let temp = tempfile::tempdir().unwrap();
let data_dir = temp.path().join("data");
let worker_dir = data_dir.join("pods/coder");
fs::create_dir_all(&worker_dir).unwrap();
fs::write(
worker_dir.join("metadata.json"),
serde_json::to_vec_pretty(&json!({
"pod_name": "coder",
"active": {
"session_id": "018f4b8e-7c8a-7b41-8d66-111111111111",
"segment_id": "018f4b8e-7c8a-7b41-8d66-222222222222"
},
"workspace_root": "/workspace/project",
"resolved_manifest_snapshot": {
"role": "coder",
"profile": { "name": "builtin-coder" },
"secret_token": "do-not-return",
"system_prompt": "do-not-return"
}
}))
.unwrap(),
)
.unwrap();
let bridge = LocalRuntimeBridge::new("local:test", "/workspace/project", Some(data_dir));
let (workers, diagnostics) = bridge.list_workers(20);
assert!(diagnostics.is_empty());
assert_eq!(workers.len(), 1);
let worker = &workers[0];
assert_eq!(worker.worker_id, "local-pod-coder");
assert_eq!(worker.host_id, "local-local-test");
assert_eq!(worker.pod_name, "coder");
assert_eq!(worker.role.as_deref(), Some("coder"));
assert_eq!(worker.profile.as_deref(), Some("builtin-coder"));
assert_eq!(worker.workspace_root.as_deref(), Some("/workspace/project"));
assert_eq!(worker.state, "active");
assert_eq!(worker.status, "active_segment_known");
assert_eq!(worker.implementation.kind, "local_pod");
assert_eq!(worker.implementation.pod_name, "coder");
let response_json = serde_json::to_string(&workers).unwrap();
assert!(!response_json.contains("do-not-return"));
assert!(!response_json.contains("system_prompt"));
assert!(!response_json.contains("session_id"));
assert!(!response_json.contains("segment_id"));
}
#[test]
fn missing_local_pod_data_dir_degrades_to_empty_workers_and_diagnostic() {
let temp = tempfile::tempdir().unwrap();
let bridge = LocalRuntimeBridge::new(
"local:test",
temp.path(),
Some(temp.path().join("missing-data")),
);
let (workers, diagnostics) = bridge.list_workers(20);
assert!(workers.is_empty());
assert_eq!(diagnostics[0].code, "local_pod_metadata_root_missing");
let (hosts, host_diagnostics) = bridge.list_hosts(20);
assert_eq!(hosts.len(), 1);
assert_eq!(hosts[0].status, "degraded");
assert_eq!(hosts[0].capabilities.local_pod_inspection, "unavailable");
assert_eq!(host_diagnostics[0].code, "local_pod_metadata_root_missing");
}
#[test]
fn worker_list_and_diagnostics_are_bounded() {
let temp = tempfile::tempdir().unwrap();
let data_dir = temp.path().join("data");
let pod_root = data_dir.join("pods");
fs::create_dir_all(&pod_root).unwrap();
for index in 0..250 {
let worker_dir = pod_root.join(format!("worker-{index:03}"));
fs::create_dir_all(&worker_dir).unwrap();
fs::write(
worker_dir.join("metadata.json"),
serde_json::to_vec_pretty(&json!({
"pod_name": format!("worker-{index:03}"),
"workspace_root": "/workspace/project"
}))
.unwrap(),
)
.unwrap();
}
let bridge = LocalRuntimeBridge::new("local:test", "/workspace/project", Some(data_dir));
let (workers, diagnostics) = bridge.list_workers(5);
assert_eq!(workers.len(), 5);
assert!(diagnostics.len() <= MAX_DIAGNOSTICS);
assert_eq!(workers[0].pod_name, "worker-000");
assert_eq!(workers[4].pod_name, "worker-004");
}
}

View File

@ -0,0 +1,45 @@
//! 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 hosts;
pub mod records;
pub mod repositories;
pub mod server;
pub mod store;
pub use records::{
LocalProjectRecordReader, ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary,
};
pub use repositories::{
GitCommitSummary, GitRemoteSummary, GitRepositorySummary, LocalRepositoryReader,
RepositoryLogRead, RepositorySummary,
};
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("unknown local host `{0}`")]
UnknownHost(String),
#[error("unknown local repository `{0}`")]
UnknownRepository(String),
#[error("store error: {0}")]
Store(String),
}

View File

@ -0,0 +1,185 @@
use std::net::SocketAddr;
use std::path::PathBuf;
use std::process::ExitCode;
use std::sync::Arc;
use tokio::net::TcpListener;
use yoi_workspace_server::{ServerConfig, SqliteWorkspaceStore, serve};
#[derive(Debug)]
struct ServeOptions {
workspace: PathBuf,
db: Option<PathBuf>,
frontend: Option<PathBuf>,
listen: SocketAddr,
}
#[derive(Debug)]
struct CliError(String);
impl std::fmt::Display for CliError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for CliError {}
#[tokio::main]
async fn main() -> ExitCode {
match run().await {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("yoi-workspace-server: {error}");
ExitCode::FAILURE
}
}
}
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let args = std::env::args().skip(1).collect::<Vec<_>>();
let Some((command, rest)) = args.split_first() else {
print_help();
return Ok(());
};
match command.as_str() {
"serve" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
print_serve_help();
return Ok(());
}
let options = parse_serve_options(rest)?;
run_serve(options).await?;
Ok(())
}
"--help" | "-h" => {
print_help();
Ok(())
}
other => Err(Box::new(CliError(format!(
"unknown command `{other}`; expected `serve`"
)))),
}
}
async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Error>> {
let db = options
.db
.unwrap_or_else(|| options.workspace.join(".yoi/workspace.db"));
if let Some(parent) = db.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let store = Arc::new(SqliteWorkspaceStore::open(&db)?);
let mut config = ServerConfig::local_dev(&options.workspace);
config.static_assets_dir = options.frontend;
let listener = TcpListener::bind(options.listen).await?;
eprintln!(
"yoi-workspace-server: serving workspace `{}` on http://{}",
options.workspace.display(),
listener.local_addr()?
);
serve(config, store, listener).await?;
Ok(())
}
fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
let mut workspace = std::env::current_dir()
.map_err(|error| CliError(format!("failed to resolve current directory: {error}")))?;
let mut db = None;
let mut frontend = None;
let mut listen = "127.0.0.1:8787".parse::<SocketAddr>().unwrap();
let mut index = 0;
while index < args.len() {
let arg = &args[index];
match arg.as_str() {
"--workspace" => {
index += 1;
let value = args
.get(index)
.ok_or_else(|| CliError("--workspace requires a value".to_string()))?;
workspace = PathBuf::from(value);
}
"--db" => {
index += 1;
let value = args
.get(index)
.ok_or_else(|| CliError("--db requires a value".to_string()))?;
db = Some(PathBuf::from(value));
}
"--frontend" => {
index += 1;
let value = args
.get(index)
.ok_or_else(|| CliError("--frontend requires a value".to_string()))?;
frontend = Some(PathBuf::from(value));
}
"--listen" => {
index += 1;
let value = args
.get(index)
.ok_or_else(|| CliError("--listen requires a value".to_string()))?;
listen = parse_listen(value)?;
}
_ if arg.starts_with("--workspace=") => {
workspace = PathBuf::from(value_after_equals(arg, "--workspace")?);
}
_ if arg.starts_with("--db=") => {
db = Some(PathBuf::from(value_after_equals(arg, "--db")?));
}
_ if arg.starts_with("--frontend=") => {
frontend = Some(PathBuf::from(value_after_equals(arg, "--frontend")?));
}
_ if arg.starts_with("--listen=") => {
listen = parse_listen(value_after_equals(arg, "--listen")?)?;
}
_ if arg.starts_with('-') => {
return Err(CliError(format!("unknown serve option `{arg}`")));
}
_ => {
return Err(CliError(format!(
"unexpected positional argument `{arg}`; use --workspace <PATH>"
)));
}
}
index += 1;
}
Ok(ServeOptions {
workspace,
db,
frontend,
listen,
})
}
fn value_after_equals<'a>(arg: &'a str, flag: &str) -> Result<&'a str, CliError> {
let value = arg
.strip_prefix(flag)
.and_then(|rest| rest.strip_prefix('='))
.unwrap_or_default();
if value.is_empty() {
return Err(CliError(format!("{flag} requires a value")));
}
Ok(value)
}
fn parse_listen(value: &str) -> Result<SocketAddr, CliError> {
value
.parse()
.map_err(|_| CliError(format!("invalid --listen address `{value}`")))
}
fn print_help() {
println!(
"yoi-workspace-server\n\nUsage:\n yoi-workspace-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
);
}
fn print_serve_help() {
println!(
"yoi-workspace-server serve\n\nUsage:\n yoi-workspace-server serve [OPTIONS]\n\nOptions:\n --workspace <PATH> Workspace root containing .yoi project records (defaults to cwd)\n --db <PATH> SQLite database path (defaults to <workspace>/.yoi/workspace.db)\n --frontend <PATH> Static SPA build directory to serve\n --listen <ADDR> Listen address (defaults to 127.0.0.1:8787)\n -h, --help Print help"
);
}

View File

@ -0,0 +1,358 @@
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;
const SUMMARY_BODY_LIMIT: usize = 240;
#[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 summary: 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, body) = 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,
summary: summarize_body(body),
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 summarize_body(body: &str) -> String {
let summary = body
.lines()
.map(str::trim)
.find(|line| !line.is_empty() && !line.starts_with('#'))
.unwrap_or_default();
let (summary, truncated) = truncate_body(summary, SUMMARY_BODY_LIMIT);
if truncated {
format!("{summary}")
} else {
summary
}
}
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,336 @@
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use serde::{Deserialize, Serialize};
use crate::hosts::RuntimeDiagnostic;
const LOCAL_REPOSITORY_ID: &str = "local";
const MAX_COMMAND_OUTPUT: usize = 4096;
const DEFAULT_LOG_LIMIT: usize = 10;
const MAX_LOG_LIMIT: usize = 50;
const MAX_FIELD_LEN: usize = 240;
#[derive(Debug, Clone)]
pub struct LocalRepositoryReader {
workspace_root: PathBuf,
}
impl LocalRepositoryReader {
pub fn new(workspace_root: impl Into<PathBuf>) -> Self {
Self {
workspace_root: workspace_root.into(),
}
}
pub fn list(&self, workspace_display_name: &str) -> Vec<RepositorySummary> {
vec![self.summary(workspace_display_name)]
}
pub fn summary(&self, workspace_display_name: &str) -> RepositorySummary {
let git = inspect_git(&self.workspace_root);
RepositorySummary {
id: LOCAL_REPOSITORY_ID.to_string(),
display_name: workspace_display_name.to_string(),
kind: "local".to_string(),
workspace_root: self.workspace_root.clone(),
record_authority: "local_workspace_root".to_string(),
git,
}
}
pub fn recent_log(&self, requested_limit: Option<usize>) -> RepositoryLogRead {
let limit = requested_limit
.unwrap_or(DEFAULT_LOG_LIMIT)
.clamp(1, MAX_LOG_LIMIT);
git_log(&self.workspace_root, limit)
}
pub fn is_local_repository_id(id: &str) -> bool {
id == LOCAL_REPOSITORY_ID
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RepositorySummary {
pub id: String,
pub display_name: String,
pub kind: String,
pub workspace_root: PathBuf,
pub record_authority: String,
pub git: GitRepositorySummary,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GitRepositorySummary {
pub status: String,
pub root: Option<PathBuf>,
pub branch: Option<String>,
pub head: Option<String>,
pub dirty: Option<bool>,
pub dirty_scope: String,
pub remote: Option<GitRemoteSummary>,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GitRemoteSummary {
pub name: String,
pub url: String,
pub redacted: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GitCommitSummary {
pub hash: String,
pub subject: String,
pub author_name: String,
pub author_email: String,
pub timestamp: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RepositoryLogRead {
pub limit: usize,
pub items: Vec<GitCommitSummary>,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
fn inspect_git(workspace_root: &Path) -> GitRepositorySummary {
let mut diagnostics = Vec::new();
let root = match git_stdout(workspace_root, &["rev-parse", "--show-toplevel"]) {
Ok(root) => PathBuf::from(root.trim()),
Err(message) => {
diagnostics.push(diagnostic(
"git_unavailable",
"info",
format!("Workspace root is not available as a Git repository: {message}"),
));
return GitRepositorySummary {
status: "unavailable".to_string(),
root: None,
branch: None,
head: None,
dirty: None,
dirty_scope: "tracked_changes_only".to_string(),
remote: None,
diagnostics,
};
}
};
let branch = git_stdout(workspace_root, &["branch", "--show-current"])
.ok()
.map(|value| truncate_field(value.trim(), MAX_FIELD_LEN))
.filter(|value| !value.is_empty())
.or_else(|| Some("detached".to_string()));
let head = match git_stdout(workspace_root, &["rev-parse", "--verify", "HEAD"]) {
Ok(value) => Some(truncate_field(value.trim(), 40)),
Err(message) => {
diagnostics.push(diagnostic(
"git_head_unavailable",
"warn",
format!("Git HEAD summary is unavailable: {message}"),
));
None
}
};
let dirty = match git_stdout(
workspace_root,
&["status", "--porcelain=v1", "--untracked-files=no"],
) {
Ok(value) => Some(!value.trim().is_empty()),
Err(message) => {
diagnostics.push(diagnostic(
"git_status_unavailable",
"warn",
format!("Git dirty status is unavailable: {message}"),
));
None
}
};
let remote = match git_stdout(workspace_root, &["remote", "get-url", "origin"]) {
Ok(value) => {
let (url, redacted) = sanitize_remote_url(value.trim());
Some(GitRemoteSummary {
name: "origin".to_string(),
url,
redacted,
})
}
Err(_) => {
diagnostics.push(diagnostic(
"git_origin_remote_missing",
"info",
"No origin remote is configured or visible through the bounded Git summary."
.to_string(),
));
None
}
};
GitRepositorySummary {
status: "available".to_string(),
root: Some(root),
branch,
head,
dirty,
dirty_scope: "tracked_changes_only".to_string(),
remote,
diagnostics,
}
}
fn git_log(workspace_root: &Path, limit: usize) -> RepositoryLogRead {
let mut diagnostics = Vec::new();
if let Err(message) = git_stdout(workspace_root, &["rev-parse", "--show-toplevel"]) {
diagnostics.push(diagnostic(
"git_unavailable",
"info",
format!("Recent Git log is unavailable for this local repository: {message}"),
));
return RepositoryLogRead {
limit,
items: Vec::new(),
diagnostics,
};
}
match git_stdout(
workspace_root,
&[
"log",
"--no-show-signature",
"--date=iso-strict",
"--format=%H%x1f%an%x1f%ae%x1f%aI%x1f%s%x1e",
"-n",
&limit.to_string(),
],
) {
Ok(output) => RepositoryLogRead {
limit,
items: parse_log(output.as_str()),
diagnostics,
},
Err(message) => {
diagnostics.push(diagnostic(
"git_log_unavailable",
"warn",
format!("Recent Git log is unavailable: {message}"),
));
RepositoryLogRead {
limit,
items: Vec::new(),
diagnostics,
}
}
}
}
fn parse_log(output: &str) -> Vec<GitCommitSummary> {
output
.split('\u{1e}')
.filter_map(|record| {
let record = record.trim_matches('\n');
if record.is_empty() {
return None;
}
let mut fields = record.split('\u{1f}');
Some(GitCommitSummary {
hash: truncate_field(fields.next()?, 40),
author_name: truncate_field(fields.next().unwrap_or_default(), MAX_FIELD_LEN),
author_email: truncate_field(fields.next().unwrap_or_default(), MAX_FIELD_LEN),
timestamp: truncate_field(fields.next().unwrap_or_default(), MAX_FIELD_LEN),
subject: truncate_field(fields.next().unwrap_or_default(), MAX_FIELD_LEN),
})
})
.collect()
}
fn git_stdout(workspace_root: &Path, args: &[&str]) -> Result<String, String> {
let output = Command::new("git")
.arg("-C")
.arg(workspace_root)
.args(args)
.output()
.map_err(|error| truncate_field(&error.to_string(), MAX_FIELD_LEN))?;
command_stdout(output)
}
fn command_stdout(output: Output) -> Result<String, String> {
if output.status.success() {
return Ok(truncate_output(
String::from_utf8_lossy(&output.stdout).as_ref(),
));
}
let stderr = truncate_output(String::from_utf8_lossy(&output.stderr).as_ref());
if stderr.trim().is_empty() {
Err(format!("git exited with status {}", output.status))
} else {
Err(stderr.trim().to_string())
}
}
fn sanitize_remote_url(raw: &str) -> (String, bool) {
let bounded = truncate_field(raw, MAX_FIELD_LEN);
let Some(separator) = bounded.find("://") else {
return (bounded, false);
};
let scheme_end = separator + 3;
let after_scheme = &bounded[scheme_end..];
let Some(at_index) = after_scheme.find('@') else {
return (bounded, false);
};
let host_and_path = &after_scheme[(at_index + 1)..];
(format!("{}{}", &bounded[..scheme_end], host_and_path), true)
}
fn truncate_output(value: &str) -> String {
truncate_field(value, MAX_COMMAND_OUTPUT)
}
fn truncate_field(value: &str, limit: usize) -> String {
if value.len() <= limit {
return value.to_string();
}
let mut end = limit;
while !value.is_char_boundary(end) {
end -= 1;
}
value[..end].to_string()
}
fn diagnostic(code: &str, severity: &str, message: String) -> RuntimeDiagnostic {
RuntimeDiagnostic {
code: code.to_string(),
severity: severity.to_string(),
message,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitizes_userinfo_from_url_remotes() {
assert_eq!(
sanitize_remote_url("https://token@example.com/org/repo.git"),
("https://example.com/org/repo.git".to_string(), true)
);
assert_eq!(
sanitize_remote_url("git@example.com:org/repo.git"),
("git@example.com:org/repo.git".to_string(), false)
);
}
#[test]
fn parses_bounded_git_log_records() {
let parsed = parse_log(
"0123456789abcdef\u{1f}Alice\u{1f}a@example.test\u{1f}2026-01-01T00:00:00+00:00\u{1f}Subject\u{1e}\n",
);
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].hash, "0123456789abcdef");
assert_eq!(parsed[0].subject, "Subject");
}
}

View File

@ -0,0 +1,845 @@
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use axum::extract::{Path as AxumPath, Query, 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::hosts::{HostSummary, LocalRuntimeBridge, RuntimeDiagnostic, WorkerSummary};
use crate::records::{
LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary,
};
use crate::repositories::{LocalRepositoryReader, RepositoryLogRead, RepositorySummary};
use crate::store::{ControlPlaneStore, 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,
pub local_runtime_data_dir: Option<PathBuf>,
}
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,
local_runtime_data_dir: manifest::paths::data_dir(),
}
}
}
#[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,
state: "active".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()
}
fn local_runtime_bridge(&self) -> LocalRuntimeBridge {
LocalRuntimeBridge::new(
self.config.workspace_id.clone(),
self.config.workspace_root.clone(),
self.config.local_runtime_data_dir.clone(),
)
}
fn local_repository_reader(&self) -> LocalRepositoryReader {
LocalRepositoryReader::new(self.config.workspace_root.clone())
}
fn workspace_display_name(&self) -> String {
self.config
.workspace_root
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("workspace")
.to_string()
}
}
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/repositories", get(list_repositories))
.route("/api/repositories/{repository_id}", get(repository_detail))
.route("/api/repositories/{repository_id}/log", get(repository_log))
.route(
"/api/repositories/{repository_id}/tickets",
get(repository_tickets),
)
.route("/api/hosts", get(list_hosts))
.route("/api/workers", get(list_workers))
.route("/api/hosts/{host_id}/workers", get(list_host_workers))
.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 host_worker_bridge: 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,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RepositoryListResponse {
pub workspace_id: String,
pub items: Vec<RepositorySummary>,
pub source: String,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RepositoryDetailResponse {
pub workspace_id: String,
pub item: RepositorySummary,
pub source: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RepositoryLogResponse {
pub workspace_id: String,
pub repository_id: String,
pub limit: usize,
pub items: Vec<crate::repositories::GitCommitSummary>,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RepositoryTicketsResponse {
pub workspace_id: String,
pub repository_id: String,
pub limit: usize,
pub columns: Vec<TicketKanbanColumn>,
pub invalid_records: Vec<crate::records::InvalidProjectRecord>,
pub record_authority: String,
pub source: String,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TicketKanbanColumn {
pub state: String,
pub items: Vec<TicketSummary>,
}
#[derive(Debug, Deserialize)]
struct LogQuery {
limit: Option<usize>,
}
#[derive(Debug, Deserialize)]
struct TicketKanbanQuery {
limit: Option<usize>,
}
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(),
},
host_worker_bridge: ExtensionPointState {
status: "read_only_local".to_string(),
note: "Local Hosts and Workers are exposed as a read-only bridge over existing Pod metadata; no scheduling or lifecycle control 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_repositories(
State(api): State<WorkspaceApi>,
) -> ApiResult<Json<RepositoryListResponse>> {
let reader = api.local_repository_reader();
let items = reader.list(&api.workspace_display_name());
Ok(Json(RepositoryListResponse {
workspace_id: api.config.workspace_id,
items,
source: "local_workspace_root".to_string(),
diagnostics: Vec::new(),
}))
}
async fn repository_detail(
State(api): State<WorkspaceApi>,
AxumPath(repository_id): AxumPath<String>,
) -> ApiResult<Json<RepositoryDetailResponse>> {
ensure_local_repository(&repository_id)?;
let reader = api.local_repository_reader();
Ok(Json(RepositoryDetailResponse {
workspace_id: api.config.workspace_id.clone(),
item: reader.summary(&api.workspace_display_name()),
source: "local_workspace_root".to_string(),
}))
}
async fn repository_log(
State(api): State<WorkspaceApi>,
AxumPath(repository_id): AxumPath<String>,
Query(query): Query<LogQuery>,
) -> ApiResult<Json<RepositoryLogResponse>> {
ensure_local_repository(&repository_id)?;
let RepositoryLogRead {
limit,
items,
diagnostics,
} = api.local_repository_reader().recent_log(query.limit);
Ok(Json(RepositoryLogResponse {
workspace_id: api.config.workspace_id,
repository_id,
limit,
items,
diagnostics,
}))
}
async fn repository_tickets(
State(api): State<WorkspaceApi>,
AxumPath(repository_id): AxumPath<String>,
Query(query): Query<TicketKanbanQuery>,
) -> ApiResult<Json<RepositoryTicketsResponse>> {
ensure_local_repository(&repository_id)?;
let limit = query.limit.unwrap_or(api.config.max_records).min(200);
let ProjectRecordList {
items,
invalid_records,
record_authority,
} = api.records.list_tickets(limit)?;
Ok(Json(RepositoryTicketsResponse {
workspace_id: api.config.workspace_id,
repository_id,
limit,
columns: ticket_kanban_columns(items),
invalid_records,
record_authority,
source: "workspace_local_ticket_fallback".to_string(),
diagnostics: vec![RuntimeDiagnostic {
code: "repository_ticket_target_metadata_absent".to_string(),
severity: "info".to_string(),
message: "Ticket target Repository metadata is not available yet; Kanban groups all workspace-local Tickets by state as a read-only fallback.".to_string(),
}],
}))
}
async fn list_hosts(
State(api): State<WorkspaceApi>,
) -> ApiResult<Json<RuntimeListResponse<HostSummary>>> {
let limit = api.config.max_records.min(200);
let bridge = api.local_runtime_bridge();
let (items, diagnostics) = bridge.list_hosts(limit);
Ok(Json(RuntimeListResponse {
workspace_id: api.config.workspace_id,
limit,
items,
source: "local_pod_metadata".to_string(),
diagnostics,
}))
}
async fn list_workers(
State(api): State<WorkspaceApi>,
) -> ApiResult<Json<RuntimeListResponse<WorkerSummary>>> {
workers_response(api).map(Json)
}
async fn list_host_workers(
State(api): State<WorkspaceApi>,
AxumPath(host_id): AxumPath<String>,
) -> ApiResult<Json<RuntimeListResponse<WorkerSummary>>> {
let bridge = api.local_runtime_bridge();
if host_id != bridge.host_id() {
return Err(Error::UnknownHost(host_id).into());
}
workers_response(api).map(Json)
}
fn workers_response(api: WorkspaceApi) -> ApiResult<RuntimeListResponse<WorkerSummary>> {
let limit = api.config.max_records.min(200);
let bridge = api.local_runtime_bridge();
let (items, diagnostics) = bridge.list_workers(limit);
Ok(RuntimeListResponse {
workspace_id: api.config.workspace_id,
limit,
items,
source: "local_pod_metadata".to_string(),
diagnostics,
})
}
fn ensure_local_repository(repository_id: &str) -> Result<()> {
if LocalRepositoryReader::is_local_repository_id(repository_id) {
Ok(())
} else {
Err(Error::UnknownRepository(repository_id.to_string()))
}
}
fn ticket_kanban_columns(items: Vec<TicketSummary>) -> Vec<TicketKanbanColumn> {
let mut columns = vec![
TicketKanbanColumn {
state: "planning".to_string(),
items: Vec::new(),
},
TicketKanbanColumn {
state: "ready".to_string(),
items: Vec::new(),
},
TicketKanbanColumn {
state: "queued".to_string(),
items: Vec::new(),
},
TicketKanbanColumn {
state: "inprogress".to_string(),
items: Vec::new(),
},
TicketKanbanColumn {
state: "done".to_string(),
items: Vec::new(),
},
TicketKanbanColumn {
state: "closed".to_string(),
items: Vec::new(),
},
TicketKanbanColumn {
state: "other".to_string(),
items: Vec::new(),
},
];
for item in items {
let index = match item.state.as_str() {
"planning" => 0,
"ready" => 1,
"queued" => 2,
"inprogress" => 3,
"done" => 4,
"closed" => 5,
_ => 6,
};
columns[index].items.push(item);
}
columns
}
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(_)
| Error::UnknownHost(_)
| Error::UnknownRepository(_) => 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);
config.local_runtime_data_dir = Some(dir.path().join("data"));
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"]["host_worker_bridge"]["status"],
"read_only_local"
);
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");
assert_eq!(objectives["items"][0]["summary"], "Objective body.");
let repositories = get_json(app.clone(), "/api/repositories").await;
assert_eq!(repositories["items"][0]["id"], "local");
assert_eq!(repositories["items"][0]["kind"], "local");
let repository_detail = get_json(app.clone(), "/api/repositories/local").await;
assert_eq!(repository_detail["item"]["id"], "local");
let repository_log = get_json(app.clone(), "/api/repositories/local/log?limit=3").await;
assert_eq!(repository_log["repository_id"], "local");
assert_eq!(repository_log["limit"], 3);
let repository_tickets = get_json(app.clone(), "/api/repositories/local/tickets").await;
assert_eq!(repository_tickets["repository_id"], "local");
let ready_column = repository_tickets["columns"]
.as_array()
.unwrap()
.iter()
.find(|column| column["state"] == "ready")
.unwrap();
assert_eq!(ready_column["items"][0]["id"], "00000000001J2");
assert_eq!(
repository_tickets["diagnostics"][0]["code"],
"repository_ticket_target_metadata_absent"
);
let unknown_repository_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/repositories/nope")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(unknown_repository_response.status(), StatusCode::NOT_FOUND);
let hosts = get_json(app.clone(), "/api/hosts").await;
assert_eq!(hosts["items"][0]["host_id"], "local-local-test");
assert_eq!(hosts["items"][0]["kind"], "local_host");
assert_eq!(
hosts["items"][0]["capabilities"]["local_pod_inspection"],
"unavailable"
);
let workers = get_json(app.clone(), "/api/workers").await;
assert!(workers["items"].as_array().unwrap().is_empty());
assert_eq!(
workers["diagnostics"][0]["code"],
"local_pod_metadata_root_missing"
);
let host_workers = get_json(app.clone(), "/api/hosts/local-local-test/workers").await;
assert!(host_workers["items"].as_array().unwrap().is_empty());
let runs_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/runs")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(runs_response.status(), StatusCode::NOT_FOUND);
let runners_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/runners")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(runners_response.status(), StatusCode::NOT_FOUND);
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,842 @@
use std::path::Path;
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 WORKSPACES_V0_COLUMNS: &[&str] = &[
"workspace_id",
"display_name",
"state",
"created_at",
"updated_at",
];
const MIGRATIONS: &[Migration] = &[
Migration {
version: 1,
name: "workspace db canonical schema v0 bootstrap",
apply: create_schema_v0_tables,
},
Migration {
version: 2,
name: "align legacy workspace bootstrap with schema v0",
apply: align_legacy_bootstrap_schema,
},
];
struct Migration {
version: i64,
name: &'static str,
apply: fn(&Connection) -> Result<()>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkspaceRecord {
pub workspace_id: String,
pub display_name: String,
pub state: String,
pub created_at: String,
pub updated_at: 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>>;
}
#[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, state, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(workspace_id) DO UPDATE SET
display_name = excluded.display_name,
state = excluded.state,
updated_at = excluded.updated_at"#,
params![
record.workspace_id,
record.display_name,
record.state,
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, state, 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)?,
state: row.get(2)?,
created_at: row.get(3)?,
updated_at: row.get(4)?,
})
},
)
.optional()
.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()?;
(migration.apply)(&tx)?;
tx.execute(
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
params![migration.version, migration.name],
)?;
tx.commit()?;
}
Ok(())
}
fn align_legacy_bootstrap_schema(conn: &Connection) -> Result<()> {
if table_exists(conn, "repositories")?
&& column_exists(conn, "repositories", "local_root")?
&& !column_exists(conn, "repositories", "uri")?
{
rename_legacy_table(conn, "repositories", "legacy_repositories")?;
}
if table_exists(conn, "runs")? {
rename_legacy_table(conn, "runs", "legacy_runs")?;
}
if table_exists(conn, "artifacts")?
&& (column_exists(conn, "artifacts", "run_id")?
|| column_exists(conn, "artifacts", "path")?
|| !column_exists(conn, "artifacts", "uri")?)
{
rename_legacy_table(conn, "artifacts", "legacy_artifacts")?;
}
if table_exists(conn, "ticket_projections")? {
rename_legacy_table(conn, "ticket_projections", "legacy_ticket_projections")?;
}
if table_exists(conn, "objective_projections")? {
rename_legacy_table(
conn,
"objective_projections",
"legacy_objective_projections",
)?;
}
let legacy_workspaces = preserve_noncanonical_workspaces(conn)?;
create_schema_v0_tables(conn)?;
if let Some(legacy_table) = legacy_workspaces {
copy_legacy_workspaces(conn, &legacy_table)?;
}
Ok(())
}
fn preserve_noncanonical_workspaces(conn: &Connection) -> Result<Option<String>> {
if !table_exists(conn, "workspaces")? {
return Ok(None);
}
let columns = table_columns(conn, "workspaces")?;
if columns
.iter()
.map(String::as_str)
.eq(WORKSPACES_V0_COLUMNS.iter().copied())
{
return Ok(None);
}
let legacy_table = "legacy_workspaces";
rename_legacy_table(conn, "workspaces", legacy_table)?;
Ok(Some(legacy_table.to_string()))
}
fn copy_legacy_workspaces(conn: &Connection, legacy_table: &str) -> Result<()> {
let columns = table_columns(conn, legacy_table)?;
for required_column in ["workspace_id", "display_name", "created_at", "updated_at"] {
if !columns.iter().any(|column| column == required_column) {
return Err(Error::Store(format!(
"cannot migrate legacy workspaces: `{legacy_table}` is missing `{required_column}`"
)));
}
}
let state_expr = if columns.iter().any(|column| column == "state") {
"COALESCE(NULLIF(state, ''), 'active')"
} else {
"'active'"
};
conn.execute_batch(&format!(
r#"INSERT OR IGNORE INTO workspaces (
workspace_id, display_name, state, created_at, updated_at
)
SELECT workspace_id, display_name, {state_expr}, created_at, updated_at
FROM {legacy_table};"#
))?;
Ok(())
}
fn rename_legacy_table(conn: &Connection, table_name: &str, legacy_name: &str) -> Result<()> {
if table_exists(conn, legacy_name)? {
return Err(Error::Store(format!(
"cannot preserve legacy table `{table_name}` because `{legacy_name}` already exists"
)));
}
conn.execute_batch(&format!(
"ALTER TABLE {table_name} RENAME TO {legacy_name};"
))?;
Ok(())
}
fn create_schema_v0_tables(conn: &Connection) -> Result<()> {
conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS workspaces (
workspace_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
state TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tickets (
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
ticket_id TEXT PRIMARY KEY,
title TEXT NOT NULL,
state TEXT NOT NULL,
priority TEXT,
assignee_kind TEXT,
assignee_key TEXT,
assignee_display TEXT,
body_md TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
closed_at TEXT,
resolution_event_id TEXT
);
CREATE TABLE IF NOT EXISTS ticket_events (
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
event_id TEXT PRIMARY KEY,
ticket_id TEXT NOT NULL REFERENCES tickets(ticket_id) ON DELETE CASCADE,
event_seq INTEGER NOT NULL,
kind TEXT NOT NULL,
activity_id TEXT,
author_kind TEXT NOT NULL,
author_key TEXT NOT NULL,
author_display TEXT NOT NULL,
author_source_kind TEXT,
author_source_key TEXT,
created_at TEXT NOT NULL,
body_md TEXT,
subject_kind TEXT,
subject_id TEXT,
previous_state TEXT,
new_state TEXT,
status TEXT,
artifact_id TEXT,
worker_ref_kind TEXT,
worker_ref_key TEXT,
worker_display TEXT,
host_ref_kind TEXT,
host_ref_key TEXT,
host_display TEXT,
repository_id TEXT,
caused_by_event_id TEXT,
UNIQUE (ticket_id, event_seq)
);
CREATE TABLE IF NOT EXISTS ticket_relations (
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
source_ticket_id TEXT NOT NULL REFERENCES tickets(ticket_id) ON DELETE CASCADE,
target_ticket_id TEXT NOT NULL REFERENCES tickets(ticket_id) ON DELETE CASCADE,
kind TEXT NOT NULL,
created_at TEXT NOT NULL,
author_kind TEXT NOT NULL,
author_key TEXT NOT NULL,
author_display TEXT NOT NULL,
author_source_kind TEXT,
author_source_key TEXT,
note TEXT,
PRIMARY KEY (source_ticket_id, target_ticket_id, kind)
);
CREATE TABLE IF NOT EXISTS objectives (
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
objective_id TEXT PRIMARY KEY,
title TEXT NOT NULL,
state TEXT NOT NULL,
body_md TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS objective_ticket_links (
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
objective_id TEXT NOT NULL REFERENCES objectives(objective_id) ON DELETE CASCADE,
ticket_id TEXT NOT NULL REFERENCES tickets(ticket_id) ON DELETE CASCADE,
kind TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (objective_id, ticket_id, kind)
);
CREATE TABLE IF NOT EXISTS repositories (
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
repository_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
kind TEXT NOT NULL,
provider TEXT,
uri TEXT NOT NULL,
default_ref TEXT,
auth_ref_kind TEXT,
auth_ref_key TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS ticket_targets (
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
ticket_id TEXT NOT NULL REFERENCES tickets(ticket_id) ON DELETE CASCADE,
target_id TEXT NOT NULL,
repository_id TEXT NOT NULL REFERENCES repositories(repository_id) ON DELETE CASCADE,
role TEXT NOT NULL,
intent TEXT NOT NULL,
ref_selector TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (ticket_id, target_id)
);
CREATE TABLE IF NOT EXISTS ticket_target_paths (
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
ticket_id TEXT NOT NULL,
target_id TEXT NOT NULL,
path TEXT NOT NULL,
PRIMARY KEY (ticket_id, target_id, path),
FOREIGN KEY (ticket_id, target_id) REFERENCES ticket_targets(ticket_id, target_id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS ticket_worker_links (
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
ticket_id TEXT NOT NULL REFERENCES tickets(ticket_id) ON DELETE CASCADE,
worker_ref_kind TEXT NOT NULL,
worker_ref_key TEXT NOT NULL,
worker_display TEXT,
role TEXT NOT NULL,
status TEXT NOT NULL,
activity_id TEXT,
assigned_at TEXT,
released_at TEXT,
last_event_id TEXT,
PRIMARY KEY (ticket_id, worker_ref_kind, worker_ref_key, role)
);
CREATE TABLE IF NOT EXISTS artifacts (
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
artifact_id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
uri TEXT NOT NULL,
media_type TEXT,
sha256 TEXT,
size_bytes INTEGER,
summary TEXT,
created_at TEXT NOT NULL,
created_by_kind TEXT NOT NULL,
created_by_key TEXT NOT NULL,
created_by_display TEXT NOT NULL,
created_by_source_kind TEXT,
created_by_source_key TEXT,
ticket_id TEXT,
objective_id TEXT,
event_id TEXT,
worker_ref_kind TEXT,
worker_ref_key TEXT,
worker_display TEXT,
repository_id TEXT,
source_kind TEXT,
source_revision TEXT
);
CREATE TABLE IF NOT EXISTS audit_events (
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
audit_event_id TEXT PRIMARY KEY,
created_at TEXT NOT NULL,
actor_kind TEXT NOT NULL,
actor_key TEXT NOT NULL,
actor_display TEXT NOT NULL,
actor_source_kind TEXT,
actor_source_key TEXT,
action TEXT NOT NULL,
target_kind TEXT NOT NULL,
target_id TEXT,
outcome TEXT NOT NULL,
request_id TEXT,
summary TEXT
);
"#,
)?;
Ok(())
}
fn table_exists(conn: &Connection, table_name: &str) -> Result<bool> {
conn.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?1)",
params![table_name],
|row| row.get::<_, bool>(0),
)
.map_err(Error::from)
}
fn column_exists(conn: &Connection, table_name: &str, column_name: &str) -> Result<bool> {
Ok(table_columns(conn, table_name)?
.iter()
.any(|column| column == column_name))
}
fn table_columns(conn: &Connection, table_name: &str) -> Result<Vec<String>> {
let mut stmt = conn.prepare(&format!("PRAGMA table_info({table_name})"))?;
let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Error::from)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
#[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(), 2);
let record = WorkspaceRecord {
workspace_id: "local-dev".to_string(),
display_name: "Yoi Dev".to_string(),
state: "active".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(), 2);
assert_eq!(
reopened.get_workspace("local-dev").await.unwrap(),
Some(record)
);
}
#[test]
fn fresh_schema_matches_workspace_db_v0_boundaries() {
let conn = Connection::open_in_memory().unwrap();
configure_sqlite(&conn).unwrap();
apply_migrations(&conn).unwrap();
let tables = table_names(&conn);
for expected in [
"workspaces",
"tickets",
"ticket_events",
"ticket_relations",
"objectives",
"objective_ticket_links",
"repositories",
"ticket_targets",
"ticket_target_paths",
"ticket_worker_links",
"artifacts",
"audit_events",
] {
assert!(
tables.contains(expected),
"missing expected v0 table {expected}"
);
}
for forbidden in [
"runs",
"hosts",
"workers",
"actors",
"validation_results",
"ci_results",
] {
assert!(
!tables.contains(forbidden),
"fresh v0 schema must not create forbidden table {forbidden}"
);
}
assert!(
!tables.iter().any(|table| table.starts_with("legacy_")),
"fresh v0 schema should not create legacy compatibility tables: {tables:?}"
);
assert_columns(
&conn,
"workspaces",
[
"workspace_id",
"display_name",
"state",
"created_at",
"updated_at",
],
);
assert_columns(
&conn,
"repositories",
[
"workspace_id",
"repository_id",
"name",
"kind",
"provider",
"uri",
"default_ref",
"auth_ref_kind",
"auth_ref_key",
"created_at",
"updated_at",
],
);
assert_columns(
&conn,
"ticket_events",
[
"workspace_id",
"event_id",
"ticket_id",
"event_seq",
"kind",
"activity_id",
"author_kind",
"author_key",
"author_display",
"author_source_kind",
"author_source_key",
"created_at",
"body_md",
"subject_kind",
"subject_id",
"previous_state",
"new_state",
"status",
"artifact_id",
"worker_ref_kind",
"worker_ref_key",
"worker_display",
"host_ref_kind",
"host_ref_key",
"host_display",
"repository_id",
"caused_by_event_id",
],
);
assert_columns(
&conn,
"artifacts",
[
"workspace_id",
"artifact_id",
"kind",
"uri",
"media_type",
"sha256",
"size_bytes",
"summary",
"created_at",
"created_by_kind",
"created_by_key",
"created_by_display",
"created_by_source_kind",
"created_by_source_key",
"ticket_id",
"objective_id",
"event_id",
"worker_ref_kind",
"worker_ref_key",
"worker_display",
"repository_id",
"source_kind",
"source_revision",
],
);
for table in ["workspaces", "repositories", "ticket_events", "artifacts"] {
let columns = table_columns(&conn, table).unwrap();
for forbidden_column in [
"payload",
"payload_json",
"metadata",
"metadata_json",
"diagnostics_json",
"run_id",
"local_root",
"record_authority",
] {
assert!(
!columns.iter().any(|column| column == forbidden_column),
"{table} must not contain obsolete/generic column {forbidden_column}"
);
}
}
}
#[tokio::test]
async fn upgrades_legacy_bootstrap_without_canonical_runs_table() {
let conn = Connection::open_in_memory().unwrap();
configure_sqlite(&conn).unwrap();
conn.execute_batch(LEGACY_BOOTSTRAP_SQL).unwrap();
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)"#,
params![
"legacy-workspace",
"Legacy Workspace",
"/tmp/legacy-workspace",
"local_yoi_project_records",
"2026-01-01T00:00:00Z",
"2026-01-02T00:00:00Z",
],
)
.unwrap();
conn.execute(
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (1, 'bootstrap workspace control plane')",
[],
)
.unwrap();
let store = SqliteWorkspaceStore::from_connection(conn).unwrap();
assert_eq!(store.schema_version().await.unwrap(), 2);
store
.with_conn(|conn| {
let tables = table_names(conn);
for expected in [
"workspaces",
"repositories",
"tickets",
"ticket_events",
"ticket_worker_links",
"artifacts",
"audit_events",
"legacy_workspaces",
"legacy_repositories",
"legacy_runs",
"legacy_artifacts",
"legacy_ticket_projections",
"legacy_objective_projections",
] {
assert!(
tables.contains(expected),
"missing {expected} after upgrade"
);
}
for forbidden in ["runs", "hosts", "workers", "actors", "validation_results"] {
assert!(
!tables.contains(forbidden),
"upgraded schema must not retain forbidden canonical table {forbidden}"
);
}
assert_columns(
conn,
"workspaces",
[
"workspace_id",
"display_name",
"state",
"created_at",
"updated_at",
],
);
let legacy_workspace_columns = table_columns(conn, "legacy_workspaces")?;
assert!(
legacy_workspace_columns
.iter()
.any(|column| column == "local_root")
);
assert!(
legacy_workspace_columns
.iter()
.any(|column| column == "record_authority")
);
let artifact_columns = table_columns(conn, "artifacts")?;
assert!(artifact_columns.iter().any(|column| column == "uri"));
assert!(!artifact_columns.iter().any(|column| column == "run_id"));
Ok(())
})
.unwrap();
assert_eq!(
store.get_workspace("legacy-workspace").await.unwrap(),
Some(WorkspaceRecord {
workspace_id: "legacy-workspace".to_string(),
display_name: "Legacy Workspace".to_string(),
state: "active".to_string(),
created_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: "2026-01-02T00:00:00Z".to_string(),
})
);
let new_record = WorkspaceRecord {
workspace_id: "new-workspace".to_string(),
display_name: "New Workspace".to_string(),
state: "active".to_string(),
created_at: "2026-02-01T00:00:00Z".to_string(),
updated_at: "2026-02-01T00:00:00Z".to_string(),
};
store.upsert_workspace(&new_record).await.unwrap();
assert_eq!(
store.get_workspace("new-workspace").await.unwrap(),
Some(new_record)
);
}
fn table_names(conn: &Connection) -> BTreeSet<String> {
let mut stmt = conn
.prepare(
"SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
)
.unwrap();
let rows = stmt.query_map([], |row| row.get::<_, String>(0)).unwrap();
rows.collect::<rusqlite::Result<BTreeSet<_>>>().unwrap()
}
fn assert_columns<const N: usize>(conn: &Connection, table: &str, expected: [&str; N]) {
let columns = table_columns(conn, table).unwrap();
let expected = expected.map(str::to_string).to_vec();
assert_eq!(columns, expected, "unexpected columns for {table}");
}
const LEGACY_BOOTSTRAP_SQL: &str = r#"
CREATE TABLE 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 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
);
CREATE TABLE 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 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 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 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
);
"#;
}

View File

@ -5,9 +5,10 @@ mod plugin_cli;
mod session_cli; mod session_cli;
mod ticket_cli; mod ticket_cli;
use std::ffi::OsString;
use std::fmt; use std::fmt;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::ExitCode; use std::process::{Command, ExitCode};
use client::PodRuntimeCommand; use client::PodRuntimeCommand;
use memory_lint::{LintCliOptions, LintStatus}; use memory_lint::{LintCliOptions, LintStatus};
@ -25,6 +26,8 @@ enum Mode {
Objective(objective_cli::ObjectiveCli), Objective(objective_cli::ObjectiveCli),
Session(session_cli::SessionCli), Session(session_cli::SessionCli),
Ticket(ticket_cli::TicketCli), Ticket(ticket_cli::TicketCli),
WorkspaceHelp,
WorkspaceServe(Vec<String>),
PodRuntime(Vec<String>), PodRuntime(Vec<String>),
Keys, Keys,
SetupModel, SetupModel,
@ -69,6 +72,11 @@ async fn main() -> ExitCode {
print_memory_lint_help(); print_memory_lint_help();
ExitCode::SUCCESS ExitCode::SUCCESS
} }
Mode::WorkspaceHelp => {
print_workspace_help();
ExitCode::SUCCESS
}
Mode::WorkspaceServe(args) => run_workspace_server(args),
Mode::MemoryLint(options) => match memory_lint::run(&options) { Mode::MemoryLint(options) => match memory_lint::run(&options) {
Ok(LintStatus::Clean) => ExitCode::SUCCESS, Ok(LintStatus::Clean) => ExitCode::SUCCESS,
Ok(LintStatus::Failed) => ExitCode::FAILURE, Ok(LintStatus::Failed) => ExitCode::FAILURE,
@ -200,6 +208,9 @@ fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
let plugin_cli = parse_plugin_args(&args[1..])?; let plugin_cli = parse_plugin_args(&args[1..])?;
return Ok(Mode::Plugin(plugin_cli)); return Ok(Mode::Plugin(plugin_cli));
} }
"workspace" => {
return parse_workspace_args(&args[1..]);
}
"mcp" => { "mcp" => {
let mcp_cli = parse_mcp_args(&args[1..])?; let mcp_cli = parse_mcp_args(&args[1..])?;
return Ok(Mode::Mcp(mcp_cli)); return Ok(Mode::Mcp(mcp_cli));
@ -472,6 +483,63 @@ fn current_dir() -> Result<PathBuf, ParseError> {
.map_err(|e| ParseError(format!("failed to resolve current directory: {e}"))) .map_err(|e| ParseError(format!("failed to resolve current directory: {e}")))
} }
fn parse_workspace_args(args: &[String]) -> Result<Mode, ParseError> {
let Some((subcommand, rest)) = args.split_first() else {
return Err(ParseError(
"yoi workspace requires `serve` (try `yoi workspace --help`)".to_string(),
));
};
match subcommand.as_str() {
"serve" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
return Ok(Mode::WorkspaceHelp);
}
Ok(Mode::WorkspaceServe(rest.to_vec()))
}
"--help" | "-h" => Ok(Mode::WorkspaceHelp),
other => Err(ParseError(format!(
"unknown yoi workspace subcommand `{other}`"
))),
}
}
fn run_workspace_server(args: Vec<String>) -> ExitCode {
let command = match resolve_workspace_server_command() {
Ok(command) => command,
Err(error) => {
eprintln!("yoi workspace: {error}");
return ExitCode::FAILURE;
}
};
let mut child = Command::new(&command);
child.arg("serve");
child.args(args);
match child.status() {
Ok(status) if status.success() => ExitCode::SUCCESS,
Ok(status) => ExitCode::from(status.code().unwrap_or(1).min(255) as u8),
Err(error) => {
eprintln!(
"yoi workspace: failed to launch `{}`: {error}",
command.to_string_lossy()
);
ExitCode::FAILURE
}
}
}
fn resolve_workspace_server_command() -> Result<OsString, ParseError> {
if let Some(value) = std::env::var_os("YOI_WORKSPACE_SERVER_COMMAND") {
if !value.is_empty() {
return Ok(value);
}
}
let current = std::env::current_exe()
.map_err(|error| ParseError(format!("failed to resolve current executable: {error}")))?;
let sibling = current.with_file_name("yoi-workspace-server");
Ok(sibling.into_os_string())
}
fn parse_plugin_args(args: &[String]) -> Result<plugin_cli::PluginCliCommand, ParseError> { fn parse_plugin_args(args: &[String]) -> Result<plugin_cli::PluginCliCommand, ParseError> {
let Some((subcommand, rest)) = args.split_first() else { let Some((subcommand, rest)) = args.split_first() else {
return Err(ParseError( return Err(ParseError(
@ -810,7 +878,13 @@ fn parse_session_id(value: &str) -> Result<SegmentId, ParseError> {
fn print_help() { fn print_help() {
println!( println!(
"yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace <PATH>] [--all]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi ticket <COMMAND> [OPTIONS]\n yoi plugin new rust-component-tool <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp show <SERVER> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Pod chat/client surface (default, --pod, yoi resume)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace <PATH> Runtime workspace root for default Console/--pod (defaults to cwd)\n --pod <NAME> Open the Pod Console by name (attach/restore/create)\n --socket <PATH> Attach a Pod Console to a specific socket with --pod\n --session <UUID> Resume a specific session segment in the Pod Console\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n" "yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace <PATH>] [--all]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi ticket <COMMAND> [OPTIONS]\n yoi workspace serve [OPTIONS]\n yoi plugin new rust-component-tool <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp show <SERVER> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Pod chat/client surface (default, --pod, yoi resume)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace <PATH> Runtime workspace root for default Console/--pod (defaults to cwd)\n --pod <NAME> Open the Pod Console by name (attach/restore/create)\n --socket <PATH> Attach a Pod Console to a specific socket with --pod\n --session <UUID> Resume a specific session segment in the Pod Console\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
);
}
fn print_workspace_help() {
println!(
"yoi workspace\n\nUsage:\n yoi workspace serve [OPTIONS]\n\nDescription:\n Launches the separate yoi-workspace-server executable. The yoi binary does not link the workspace server crate.\n\nOptions forwarded to yoi-workspace-server serve:\n --workspace <PATH> Workspace root containing .yoi project records (defaults to cwd)\n --db <PATH> SQLite database path (defaults to <workspace>/.yoi/workspace.db)\n --frontend <PATH> Static SPA build directory to serve\n --listen <ADDR> Listen address (defaults to 127.0.0.1:8787)\n -h, --help Print help\n\nEnvironment:\n YOI_WORKSPACE_SERVER_COMMAND Path to yoi-workspace-server executable override\n"
); );
} }
@ -931,6 +1005,22 @@ mod tests {
} }
} }
#[test]
fn parse_workspace_serve_passthrough() {
match parse_args_from(["workspace", "serve", "--listen", "127.0.0.1:0"]).unwrap() {
Mode::WorkspaceServe(args) => assert_eq!(args, vec!["--listen", "127.0.0.1:0"]),
other => panic!("unexpected mode: {other:?}"),
}
}
#[test]
fn parse_workspace_help() {
assert!(matches!(
parse_args_from(["workspace", "--help"]).unwrap(),
Mode::WorkspaceHelp
));
}
#[test] #[test]
fn parse_keys_subcommand() { fn parse_keys_subcommand() {
match parse_args_from(["keys"]).unwrap() { match parse_args_from(["keys"]).unwrap() {

View File

@ -292,7 +292,8 @@ fn inspect_materialized_package(
)), )),
digest: Some(materialized.package.digest.clone()), digest: Some(materialized.package.digest.clone()),
permissions: requested_permissions, permissions: requested_permissions,
https: Vec::new(), request: Vec::new(),
websocket: Vec::new(),
fs: Vec::new(), fs: Vec::new(),
}, },
config: None, config: None,
@ -723,7 +724,7 @@ fn render_show(reference: &str, args: &PluginCliArgs) -> Result<String> {
return Ok(format!("{}\n", serde_json::to_string_pretty(item)?)); return Ok(format!("{}\n", serde_json::to_string_pretty(item)?));
} }
render_item_human(item) render_item_human(&item)
} }
fn render_item_human(item: &PluginInspectionItem) -> Result<String> { fn render_item_human(item: &PluginInspectionItem) -> Result<String> {
@ -799,8 +800,13 @@ fn render_item_human(item: &PluginInspectionItem) -> Result<String> {
)?; )?;
writeln!( writeln!(
out, out,
" configured_https_grants: {}", " configured_request_grants: {}",
join_or_none(&item.configured_https_grants) join_or_none(&item.configured_request_grants)
)?;
writeln!(
out,
" configured_websocket_grants: {}",
join_or_none(&item.configured_websocket_grants)
)?; )?;
writeln!( writeln!(
out, out,
@ -976,7 +982,8 @@ fn snapshot_from_resolution(
builder.configured = true; builder.configured = true;
builder.enabled_surfaces = surface_strings(enablement.surfaces.iter().copied()); builder.enabled_surfaces = surface_strings(enablement.surfaces.iter().copied());
builder.configured_grants = permission_strings(&enablement.grants.permissions); builder.configured_grants = permission_strings(&enablement.grants.permissions);
builder.configured_https_grants = https_grant_strings(&enablement.grants.https); builder.configured_request_grants = request_grant_strings(&enablement.grants.request);
builder.configured_websocket_grants = websocket_grant_strings(&enablement.grants.websocket);
builder.configured_fs_grants = fs_grant_strings(&enablement.grants.fs); builder.configured_fs_grants = fs_grant_strings(&enablement.grants.fs);
if let Ok(identity) = SourceQualifiedPluginId::parse(&enablement.id) { if let Ok(identity) = SourceQualifiedPluginId::parse(&enablement.id) {
builder builder
@ -1069,7 +1076,8 @@ fn fill_resolved(builder: &mut ItemBuilder, resolved: &ResolvedPlugin) {
builder.enabled_surfaces = surface_strings(resolved.enabled_surfaces.iter().copied()); builder.enabled_surfaces = surface_strings(resolved.enabled_surfaces.iter().copied());
builder.requested_permissions = permission_strings(&resolved.manifest.permissions); builder.requested_permissions = permission_strings(&resolved.manifest.permissions);
builder.configured_grants = permission_strings(&resolved.grants.permissions); builder.configured_grants = permission_strings(&resolved.grants.permissions);
builder.configured_https_grants = https_grant_strings(&resolved.grants.https); builder.configured_request_grants = request_grant_strings(&resolved.grants.request);
builder.configured_websocket_grants = websocket_grant_strings(&resolved.grants.websocket);
builder.configured_fs_grants = fs_grant_strings(&resolved.grants.fs); builder.configured_fs_grants = fs_grant_strings(&resolved.grants.fs);
let record = ResolvedPluginRecord::from_resolved(resolved); let record = ResolvedPluginRecord::from_resolved(resolved);
@ -1178,7 +1186,14 @@ fn permission_strings(permissions: &[PluginPermission]) -> Vec<String> {
values values
} }
fn https_grant_strings(grants: &[manifest::plugin::PluginHttpsGrant]) -> Vec<String> { fn request_grant_strings(grants: &[manifest::plugin::PluginRequestGrant]) -> Vec<String> {
let mut values: Vec<_> = grants.iter().map(|grant| grant.label()).collect();
values.sort();
values.dedup();
values
}
fn websocket_grant_strings(grants: &[manifest::plugin::PluginWebSocketGrant]) -> Vec<String> {
let mut values: Vec<_> = grants.iter().map(|grant| grant.label()).collect(); let mut values: Vec<_> = grants.iter().map(|grant| grant.label()).collect();
values.sort(); values.sort();
values.dedup(); values.dedup();
@ -1262,7 +1277,8 @@ struct PluginInspectionItem {
enabled_surfaces: Vec<String>, enabled_surfaces: Vec<String>,
requested_permissions: Vec<String>, requested_permissions: Vec<String>,
configured_grants: Vec<String>, configured_grants: Vec<String>,
configured_https_grants: Vec<String>, configured_request_grants: Vec<String>,
configured_websocket_grants: Vec<String>,
configured_fs_grants: Vec<String>, configured_fs_grants: Vec<String>,
tools: Vec<ToolSummary>, tools: Vec<ToolSummary>,
static_runtime: Option<PluginStaticInspection>, static_runtime: Option<PluginStaticInspection>,
@ -1331,7 +1347,8 @@ struct ItemBuilder {
enabled_surfaces: Vec<String>, enabled_surfaces: Vec<String>,
requested_permissions: Vec<String>, requested_permissions: Vec<String>,
configured_grants: Vec<String>, configured_grants: Vec<String>,
configured_https_grants: Vec<String>, configured_request_grants: Vec<String>,
configured_websocket_grants: Vec<String>,
configured_fs_grants: Vec<String>, configured_fs_grants: Vec<String>,
tools: Vec<ToolSummary>, tools: Vec<ToolSummary>,
static_runtime: Option<PluginStaticInspection>, static_runtime: Option<PluginStaticInspection>,
@ -1358,7 +1375,8 @@ impl ItemBuilder {
enabled_surfaces: Vec::new(), enabled_surfaces: Vec::new(),
requested_permissions: Vec::new(), requested_permissions: Vec::new(),
configured_grants: Vec::new(), configured_grants: Vec::new(),
configured_https_grants: Vec::new(), configured_request_grants: Vec::new(),
configured_websocket_grants: Vec::new(),
configured_fs_grants: Vec::new(), configured_fs_grants: Vec::new(),
tools: Vec::new(), tools: Vec::new(),
static_runtime: None, static_runtime: None,
@ -1430,7 +1448,8 @@ impl ItemBuilder {
enabled_surfaces: self.enabled_surfaces, enabled_surfaces: self.enabled_surfaces,
requested_permissions: self.requested_permissions, requested_permissions: self.requested_permissions,
configured_grants: self.configured_grants, configured_grants: self.configured_grants,
configured_https_grants: self.configured_https_grants, configured_request_grants: self.configured_request_grants,
configured_websocket_grants: self.configured_websocket_grants,
configured_fs_grants: self.configured_fs_grants, configured_fs_grants: self.configured_fs_grants,
tools: self.tools, tools: self.tools,
static_runtime: self.static_runtime, static_runtime: self.static_runtime,
@ -1443,6 +1462,7 @@ impl ItemBuilder {
mod tests { mod tests {
use super::*; use super::*;
use manifest::plugin::{PluginEnablementConfig, PluginExactVersion, PluginGrantConfig}; use manifest::plugin::{PluginEnablementConfig, PluginExactVersion, PluginGrantConfig};
use pod::feature::plugin::{PluginPermissionEligibility, PluginRuntimeEligibility};
use tempfile::tempdir; use tempfile::tempdir;
#[test] #[test]
@ -1494,7 +1514,7 @@ mod tests {
assert_eq!(show_json["configured_grants"][0], "surfaces.tool"); assert_eq!(show_json["configured_grants"][0], "surfaces.tool");
assert_eq!(show_json["tools"][0]["permission"], "tool.Echo"); assert_eq!(show_json["tools"][0]["permission"], "tool.Echo");
let show = render_item_human(item).unwrap(); let show = render_item_human(&item).unwrap();
assert!(show.contains("status: active")); assert!(show.contains("status: active"));
assert!(show.contains("schema_version: 1")); assert!(show.contains("schema_version: 1"));
assert!(show.contains("api_version: 1")); assert!(show.contains("api_version: 1"));
@ -1503,6 +1523,108 @@ mod tests {
assert!(show.contains("configured_grants: surfaces.tool, tool.Echo")); assert!(show.contains("configured_grants: surfaces.tool, tool.Echo"));
} }
#[test]
fn render_show_distinguishes_request_grant_statuses_and_broad_targets() {
let item = PluginInspectionItem {
reference: "project:req".to_string(),
local_ref: Some("project:req".to_string()),
status: "configured".to_string(),
source: Some("project".to_string()),
package: Some("req".to_string()),
package_path: None,
version: Some("0.1.0".to_string()),
schema_version: Some(1),
api_version: Some(1),
digest: None,
configured: true,
discovered: true,
resolved: true,
static_eligible: true,
declared_surfaces: vec!["tool".to_string()],
enabled_surfaces: vec!["tool".to_string()],
requested_permissions: vec!["host_api.request".to_string(), "host_api.websocket".to_string()],
configured_grants: vec!["host_api.request".to_string(), "host_api.websocket".to_string()],
configured_request_grants: vec!["*://* GET * [broad-request]".to_string()],
configured_websocket_grants: vec!["*://* * [broad-websocket]".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(),
),
},
PluginPermissionEligibility {
permission: "host_api.websocket target wss://gateway.example.test /gateway"
.to_string(),
requested: true,
granted: false,
eligible: false,
diagnostic: Some(
"missing enabled WebSocket grant for manifest target".to_string(),
),
},
PluginPermissionEligibility {
permission: "host_api.websocket grant-only *://* * [broad-websocket]"
.to_string(),
requested: false,
granted: true,
eligible: false,
diagnostic: Some(
"enabled WebSocket grant has no matching manifest declaration; broad/arbitrary target"
.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]"
);
assert_eq!(
json["configured_websocket_grants"][0],
"*://* * [broad-websocket]"
);
let human = render_item_human(&item).unwrap();
assert!(human.contains("configured_websocket_grants: *://* * [broad-websocket]"));
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("host_api.websocket target wss://gateway.example.test"));
assert!(human.contains("host_api.websocket grant-only *://*"));
assert!(human.contains("missing enabled WebSocket grant"));
assert!(human.contains("broad/arbitrary"));
}
#[test] #[test]
fn service_only_enablement_ignores_unselected_tool_static_grants() { fn service_only_enablement_ignores_unselected_tool_static_grants() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
@ -1522,7 +1644,8 @@ mod tests {
PluginPermission::surface(PluginSurface::Service), PluginPermission::surface(PluginSurface::Service),
PluginPermission::service("svc"), PluginPermission::service("svc"),
], ],
https: Vec::new(), request: Vec::new(),
websocket: Vec::new(),
fs: Vec::new(), fs: Vec::new(),
}, },
config: None, config: None,
@ -1576,7 +1699,8 @@ mod tests {
PluginPermission::surface(PluginSurface::Tool), PluginPermission::surface(PluginSurface::Tool),
PluginPermission::tool("Echo"), PluginPermission::tool("Echo"),
], ],
https: Vec::new(), request: Vec::new(),
websocket: Vec::new(),
fs: Vec::new(), fs: Vec::new(),
}, },
config: None, config: None,
@ -1594,7 +1718,8 @@ mod tests {
PluginPermission::surface(PluginSurface::Tool), PluginPermission::surface(PluginSurface::Tool),
PluginPermission::tool("Echo"), PluginPermission::tool("Echo"),
], ],
https: Vec::new(), request: Vec::new(),
websocket: Vec::new(),
fs: Vec::new(), fs: Vec::new(),
}, },
config: None, config: None,
@ -1712,7 +1837,8 @@ mod tests {
PluginPermission::surface(PluginSurface::Tool), PluginPermission::surface(PluginSurface::Tool),
PluginPermission::tool("Echo"), PluginPermission::tool("Echo"),
], ],
https: Vec::new(), request: Vec::new(),
websocket: Vec::new(),
fs: Vec::new(), fs: Vec::new(),
}, },
config: None, config: None,
@ -2247,7 +2373,8 @@ lifecycle = "host-managed"
PluginPermission::surface(PluginSurface::Tool), PluginPermission::surface(PluginSurface::Tool),
PluginPermission::tool("Echo"), PluginPermission::tool("Echo"),
], ],
https: Vec::new(), request: Vec::new(),
websocket: Vec::new(),
fs: Vec::new(), fs: Vec::new(),
}, },
config: None, config: None,
@ -2278,7 +2405,8 @@ lifecycle = "host-managed"
version: Some(PluginExactVersion(version.to_string())), version: Some(PluginExactVersion(version.to_string())),
digest: Some(digest), digest: Some(digest),
permissions, permissions,
https: Vec::new(), request: Vec::new(),
websocket: Vec::new(),
fs: Vec::new(), fs: Vec::new(),
}, },
config: None, config: None,
@ -2306,7 +2434,8 @@ lifecycle = "host-managed"
version: Some(PluginExactVersion(version.to_string())), version: Some(PluginExactVersion(version.to_string())),
digest: None, digest: None,
permissions, permissions,
https: Vec::new(), request: Vec::new(),
websocket: Vec::new(),
fs: Vec::new(), fs: Vec::new(),
}, },
config: None, config: None,

View File

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

View File

@ -0,0 +1,78 @@
# Workspace web design system
This document defines the visual rules for `web/workspace`. The current authority for tokens and reusable page/sidebar styling is `web/workspace/src/app.css`.
## Design position
Workspace web should read as a control surface, not a set of detached widgets. Group information primarily through spacing, typography, and text contrast. Use borders, rounded rectangles, shadows, and filled panels only when they clarify hierarchy that spacing cannot express.
## Palette
Colors are defined as CSS custom properties in OKLCH. The palette supports light and dark modes through `prefers-color-scheme`.
Rules:
- Background and layout surfaces use zero chroma: `oklch(... 0 0)`.
- Primary text and code text are near-neutral warm colors. Because CSS OKLCH exposes chroma (`C`) rather than saturation directly, encode the “about 5% saturation” intent as very low warm chroma, around `C = 0.01` to `0.012`.
- Muted text reduces lightness/chroma before introducing new hues.
- Accent/status colors are semantic exceptions. They should mark state, focus, or navigation, not decorate containers.
- Do not introduce raw hex/rgb colors in Workspace web components. Add or reuse a token in `app.css`.
Core tokens:
```css
--bg
--bg-raised
--bg-subtle
--line
--line-strong
--text
--text-strong
--text-muted
--text-faint
--code
--accent
--success
--warning
--danger
```
## Layout and grouping
Prefer vertical rhythm and text hierarchy over card chrome.
- Page sections are separated by whitespace and a light top rule.
- Navigation selection uses a left rule rather than filled pills.
- Nested records use indentation or top rules, not repeated rounded containers.
- Shadows are avoided in the base system.
- Rounded corners are reserved for small controls where hit area shape matters.
## Typography
- Headings and primary labels use `--text-strong`.
- Body text uses `--text`.
- Metadata, helper text, timestamps, and table headings use `--text-muted` or `--text-faint`.
- Uppercase labels are acceptable for small metadata labels only; avoid large all-caps UI blocks.
## Component styling boundary
`app.css` owns the shared visual language:
- reset/base body styles
- OKLCH tokens
- layout primitives
- page cards/sections
- sidebar/navigation sections
- tables, kanban lists, diagnostics, record bodies
Svelte components should keep local styles only when a behavior is truly component-specific. If a style affects color, spacing, borders, text hierarchy, or repeated record layout, it belongs in `app.css`.
## Adding new UI
When adding Workspace web UI:
1. Start with semantic HTML and existing classes from `app.css`.
2. Use spacing and text contrast first.
3. Use a border only when the boundary carries meaning.
4. Use background fills only for page-level surfaces or read-only code/record bodies.
5. If a new color is needed, define it as an OKLCH token and document why the existing semantic tokens are insufficient.

View File

@ -294,29 +294,83 @@ rejected invalid manifest, incompatible API, digest mismatch, grant denial, etc
partial usable package with some rejected surfaces/tools partial usable package with some rejected surfaces/tools
``` ```
## `https` host API ## `request` host API
The `https` host API is outbound-only and grant-gated. It is meant for Tool calls such as JSON POSTs or REST requests. It is not a WebSocket/Gateway or inbound HTTP surface. The `request` host API is a one-shot outbound HTTP request API. It is meant for bounded Tool calls such as JSON POSTs or REST requests. It is not a WebSocket, SSE/event-stream, gateway, daemon, or inbound HTTP surface; persistent transports require a separate Plugin capability.
Manifest permissions should request `host_api.https` in addition to the Tool permissions. Enablement grants must then allow the API and constrain hosts/methods. Manifest permissions should request `host_api.request` in addition to the Tool permissions, and the package manifest must statically declare the URL targets it may call. Enablement grants must then allow the API and grant matching request targets. A grant without a matching manifest target is unsafe/unused and is shown as ineligible rather than expanding authority.
Example grant shape: Example manifest shape:
```toml
permissions = [
{ kind = "surface", surface = "tool" },
{ kind = "tool", name = "http_post_json" },
{ kind = "host_api", api = "request" },
]
[[request]]
scheme = "https"
host = "api.example.com"
methods = ["POST"]
path_prefixes = ["/v1/"]
```
Example enablement grant shape:
```toml ```toml
[plugins.enabled.grants] [plugins.enabled.grants]
permissions = [ permissions = [
{ kind = "surface", surface = "tool" }, { kind = "surface", surface = "tool" },
{ kind = "tool", name = "http_post_json" }, { kind = "tool", name = "http_post_json" },
{ kind = "host_api", api = "https" }, { kind = "host_api", api = "request" },
] ]
[[plugins.enabled.grants.https]] [[plugins.enabled.grants.request]]
scheme = "https"
host = "api.example.com" host = "api.example.com"
methods = ["POST"] methods = ["POST"]
path_prefixes = ["/v1/"] path_prefixes = ["/v1/"]
``` ```
Yoi rejects `http://`, localhost/private/link-local targets, disallowed hosts/methods, oversize requests/responses, and missing grants. Credentials must come from explicit config/secret references, not ambient environment variables. Yoi checks method, scheme, host, optional port, and path prefix against both the manifest declaration and enablement grant before any network I/O. `http://localhost`, loopback, private, and other local targets are never ambient; they require an explicit manifest request target and an explicit matching grant. The explicit request target is the declared URL authority; a granted DNS hostname may resolve to a loopback/private address without requiring a separate literal-IP grant, so reviewers should grant hostnames only when that resolution behavior is intended. Broad targets such as `host = "*"` are supported only as visibly broad request permissions in inspection/diagnostics. Embedded credentials, credential-like headers, oversize requests/responses, WebSocket URLs/upgrades, and SSE/event-stream requests are rejected.
## `websocket` host API
The `websocket` host API is a separate grant-gated capability named `host_api.websocket`, not an extension of `host_api.request`. It opens host-owned WebSocket connections only when both the package manifest and enablement config declare matching targets. Plugin code drives the lifecycle explicitly through `open`, `send-text`, `recv`, and `close`; incoming messages are returned only from bounded `recv` calls and are not injected into model context, history, Dashboard state, or Ticket state.
Example manifest shape:
```toml
permissions = [
{ kind = "surface", surface = "tool" },
{ kind = "tool", name = "gateway_step" },
{ kind = "host_api", api = "websocket" },
]
[[websocket]]
scheme = "wss"
host = "gateway.example.com"
path_prefixes = ["/gateway"]
```
Example enablement grant shape:
```toml
[plugins.enabled.grants]
permissions = [
{ kind = "surface", surface = "tool" },
{ kind = "tool", name = "gateway_step" },
{ kind = "host_api", api = "websocket" },
]
[[plugins.enabled.grants.websocket]]
scheme = "wss"
host = "gateway.example.com"
path_prefixes = ["/gateway"]
```
Yoi checks scheme (`ws`/`wss`), host, optional port, and path prefix against both declarations before opening the connection. Loopback/private/local targets are not ambient; they require explicit matching manifest and grant entries. Broad WebSocket targets such as `host = "*"` are reported as broad WebSocket diagnostics. v1 is text-only: `send-text` requires UTF-8, binary receive fails closed, guest-supplied handshake headers and embedded URL credentials are rejected, and SecretRef-based credential/header injection is future work. The host bounds open descriptors, text/message size, receive timeout, connection count, handle lifetime, and cleanup on close/instance stop/drop.
## `fs` host API ## `fs` host API

View File

@ -29,6 +29,9 @@ let
|| isExcludedTree ".worktree" || isExcludedTree ".worktree"
|| isExcludedTree "work-items" || isExcludedTree "work-items"
|| isExcludedTree "docs/report" || isExcludedTree "docs/report"
|| isExcludedTree "web/workspace/node_modules"
|| isExcludedTree "web/workspace/.svelte-kit"
|| isExcludedTree "web/workspace/build"
); );
in in
rustPlatform.buildRustPackage rec { rustPlatform.buildRustPackage rec {
@ -40,7 +43,7 @@ rustPlatform.buildRustPackage rec {
filter = sourceFilter; filter = sourceFilter;
}; };
cargoHash = "sha256-GUqhvq+JhJokk1R4VVeVz5cZe/6oSrVMyKjcltZEWqE="; cargoHash = "sha256-dKkAFUfTAMxSRHq9iNmwRXjQVSBHQBtb0+v8VHkgAGM=";
depsExtraArgs = { depsExtraArgs = {
# Older fetchCargoVendor utilities used crates.io's API download endpoint, # Older fetchCargoVendor utilities used crates.io's API download endpoint,
@ -88,17 +91,39 @@ rustPlatform.buildRustPackage rec {
"yoi" "yoi"
]; ];
postBuild = ''
cargo build --offline --profile release -p yoi-workspace-server --bin yoi-workspace-server
'';
# The package check is a credential-free install smoke check below. Running the # The package check is a credential-free install smoke check below. Running the
# workspace test suite is intentionally left to cargo-based CI because this # workspace test suite is intentionally left to cargo-based CI because this
# derivation is scoped to packaging the user-facing binaries. # derivation is scoped to packaging the user-facing binaries.
doCheck = false; doCheck = false;
installPhase = ''
runHook preInstall
yoi_bin=$(find . -type f -name yoi | head -n 1)
workspace_server_bin=$(find . -type f -name yoi-workspace-server | head -n 1)
if [ -z "$yoi_bin" ] || [ -z "$workspace_server_bin" ]; then
echo "built binaries not found" >&2
find . -maxdepth 6 -type f \( -name yoi -o -name yoi-workspace-server \) -print >&2
exit 1
fi
install -Dm755 "$yoi_bin" "$out/bin/yoi"
install -Dm755 "$workspace_server_bin" "$out/bin/yoi-workspace-server"
runHook postInstall
'';
doInstallCheck = true; doInstallCheck = true;
installCheckPhase = '' installCheckPhase = ''
runHook preInstallCheck runHook preInstallCheck
"$out/bin/yoi" pod --help >/dev/null "$out/bin/yoi" pod --help >/dev/null
test -x "$out/bin/yoi" test -x "$out/bin/yoi"
test -x "$out/bin/yoi-workspace-server"
"$out/bin/yoi-workspace-server" --help >/dev/null
test ! -e "$out/bin/yoi-pod" test ! -e "$out/bin/yoi-pod"
test ! -e "$out/share/yoi/resources" test ! -e "$out/share/yoi/resources"
if "$out/bin/yoi" --session not-a-uuid 2>yoi.err; then if "$out/bin/yoi" --session not-a-uuid 2>yoi.err; then

Some files were not shown because too many files have changed in this diff Show More