diff --git a/.yoi/tickets/00001KWWE8E04/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KWWE8E04/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..b2f10e29 --- /dev/null +++ b/.yoi/tickets/00001KWWE8E04/artifacts/orchestration-plan.jsonl @@ -0,0 +1 @@ +{"id":"orch-plan-20260706-195421-1","ticket_id":"00001KWWE8E04","kind":"accepted_plan","accepted_plan":{"summary":"Ticket 00001KWWE8E04 は独立 queued Ticket で blocker なし。Workspace Browser の canonical route/API を `/w/` / `/api/w/` に scope する。human authorized routing 済みのため queued->inprogress acceptance 後に worktree + coder/reviewer loop へ進める。","branch":"work/00001KWWE8E04-workspace-id-routes","worktree":"/home/hare/Projects/yoi/.worktree/00001KWWE8E04-workspace-id-routes","role_plan":"単一 sibling Coder Pod に implementation worktree を委譲し、workspace id scoped Browser routes/API/backend mismatch guard/frontend link/API helpers/tests を実装する。完了後、別 sibling Reviewer Pod で Ticket IntentPacket / authority boundary / acceptance criteria に照らして read-only review する。"},"author":"orchestrator","at":"2026-07-06T19:54:21Z"} diff --git a/.yoi/tickets/00001KWWE8E04/item.md b/.yoi/tickets/00001KWWE8E04/item.md index f54fa8e8..93e402bf 100644 --- a/.yoi/tickets/00001KWWE8E04/item.md +++ b/.yoi/tickets/00001KWWE8E04/item.md @@ -1,8 +1,8 @@ --- title: 'Scope Workspace Browser routes and API by workspace id' -state: 'queued' +state: 'closed' created_at: '2026-07-06T19:25:08Z' -updated_at: '2026-07-06T19:53:14Z' +updated_at: '2026-07-06T20:53:44Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-06T19:53:14Z' diff --git a/.yoi/tickets/00001KWWE8E04/resolution.md b/.yoi/tickets/00001KWWE8E04/resolution.md new file mode 100644 index 00000000..e203ce9f --- /dev/null +++ b/.yoi/tickets/00001KWWE8E04/resolution.md @@ -0,0 +1,32 @@ +完了。 + +実装内容: +- Workspace Browser の canonical UI routes を `/w//...` に移行した。 +- Browser-facing Workspace API に `/api/w//...` scoped variants を追加した。 +- Backend current immutable `workspace_id` と path `workspace-id` を照合し、mismatch は typed sanitized `workspace_id_mismatch` diagnostic / 404 として fail closed するようにした。 +- Frontend の workspace-scoped data calls は `/api/w//...` helper 経由に変更した。 +- Sidebar / settings / repository / objectives / worker console links は workspace id を含む scoped links を使うようにした。 +- Runtime `/v1/...` API surface は変更しなかった。 +- Unscoped browser routes は `/api/workspace` bootstrap から scoped `/w//...` へ redirect する thin shim とし、通常の unscoped data route / duplicated co-canonical page surface を廃止した。 +- Backend SPA fallback でも unscoped Browser routes を scoped route に redirect するようにした。 +- Focused backend/web tests を追加/更新した。 + +主な commit / merge: +- implementation: `f6ad9cfc feat: scope workspace routes by id` +- review fix: `71340b89 fix: redirect unscoped workspace routes` +- merge into orchestration: `13e756e7 merge: workspace id route scoping` + +Review: +- 初回 external review は request_changes。 +- follow-up external review は approve。merge-blocking issue なし。 + +Final validation in Orchestrator worktree: +- `git diff --check`: pass +- `cd web/workspace && deno task check`: pass(0 errors / 0 warnings) +- `cd web/workspace && deno task test`: pass(17 tests) +- `cargo test -p yoi-workspace-server`: pass(65 lib tests + 2 main tests) +- `cargo check -p yoi`: pass +- `yoi ticket doctor`: ok + +未実行: +- `nix build .#yoi --no-link` は Cargo.lock / dependency / resource packaging / Nix 変更ではないため未実行。 \ No newline at end of file diff --git a/.yoi/tickets/00001KWWE8E04/thread.md b/.yoi/tickets/00001KWWE8E04/thread.md index f7f6ea45..857cf929 100644 --- a/.yoi/tickets/00001KWWE8E04/thread.md +++ b/.yoi/tickets/00001KWWE8E04/thread.md @@ -30,4 +30,315 @@ Marked ready by `yoi ticket state`. Ticket を `workspace-panel` が queued にしました。 +--- + + + +## Decision + +Routing decision: implementation_ready + +Reason: +- Ticket は Workspace Browser route/API を immutable workspace id で scope する具体的な URL/API contract、acceptance criteria、非目標を明記している。 +- 1 Backend = 1 Workspace の現状でも request path の `workspace-id` と Backend current `workspace_id` を照合するという authority boundary が明確。 +- Runtime `/v1/...` API とは混同しないこと、multi-workspace DB migration / slug / admin UI は非目標であることが明確。 +- typed relation blocker は 0 件、OrchestrationPlan record は 0 件。 +- queued notification は human authorized routing であり、今回の routing acceptance 後にのみ implementation side effect へ進める。 + +Evidence checked: +- Ticket body / thread / artifacts。 +- `TicketRelationQuery(00001KWWE8E04)`: 0 件。 +- `TicketOrchestrationPlanQuery(00001KWWE8E04)`: 0 件。 +- Orchestrator worktree git status: clean on `orchestration`。 +- queued Ticket 一覧: この Ticket 1件のみ。inprogress は 0 件。 +- visible Pods: previous execution-workspace child Pods が idle で残っているが、`StopPod` 不使用方針のため停止せず、新規 unique Pod を使う。capacity blocker ではない。 +- `TicketDoctor`: 0 errors / 既存 diagnostics のみ。 +- Bounded code map: `crates/workspace-server/src/{identity.rs,server.rs,hosts.rs,config.rs,records.rs,repositories.rs}`, `web/workspace/src/routes/**`, `web/workspace/src/lib/workspace-sidebar/**`, workspace settings/sidebar/console helpers。 + +IntentPacket: + +Intent: +- Workspace Browser の canonical UI route を `/w//...` に移し、Frontend が Browser-facing API を `/api/w//...` 経由で呼ぶようにする。 +- 1 Backend = 1 Workspace の現状でも、path workspace id と serving workspace id を照合し、mismatch を typed sanitized diagnostic として fail closed する。 + +Binding decisions / invariants: +- Workspace URL は slug/renameable handle ではなく immutable `workspace_id` を使う。 +- canonical route prefix は短い `/w//...`。 +- Browser-facing scoped API prefix は `/api/w//...`。 +- Runtime API `/v1/runtime`, `/v1/workers` はこの Ticket の scope 外であり、workspace id segment を追加しない。 +- Backend canonical store の multi-workspace DB migration、Workspace slug/alias/rename、global admin UI は非目標。 +- 既存 unscoped API/routes は互換 alias / redirect として残してよいが、Frontend の own calls/tests は scoped API/route を期待する。 +- workspace id mismatch response は typed sanitized error とし、raw path / internal store / authority-bearing internals を漏らさない。 + +Requirements / acceptance criteria: +- `/w/` が overview を表示する。 +- `/w//repositories/` が Repository detail を表示する。 +- `/w//objectives` と `/w//objectives/` が動く。 +- `/w//settings` が Settings を表示する。 +- `/w//runtimes//workers//console` が Worker console を表示する。 +- Frontend API calls use `/api/w//...` for workspace/repositories/objectives/workers/settings runtime-connections and relevant worker console endpoints。 +- Sidebar/settings/repository/objectives/worker-console links include workspace id。 +- mismatched workspace id returns typed 404 or equivalent mismatch diagnostic。 +- Focused backend/web tests cover scoped API, mismatch, and frontend scoped API/link construction。 + +Implementation latitude: +- Route grouping/layout structure, helper naming, redirect vs compatibility route choice, and scoped API client abstraction can follow current SvelteKit/backend style。 +- Existing unscoped API can remain as compatibility alias if scoped variants are canonical for frontend use。 +- A small workspace route helper/client module is acceptable to avoid string duplication。 +- Tests may cover helper/model/API routing rather than full browser E2E, consistent with current project constraints。 + +Escalate if: +- Multi-workspace DB/store authority must be implemented to satisfy routing。 +- Workspace slug/handle/rename/alias becomes necessary。 +- Runtime `/v1/...` API needs workspace id path segment。 +- Browser-facing mismatch diagnostics require exposing raw internal paths/store/runtime details。 + +Validation: +- `cd web/workspace && deno task check && deno task test` +- `cargo test -p yoi-workspace-server` +- `cargo check -p yoi` +- `git diff --check` +- `yoi ticket doctor` +- `nix build .#yoi --no-link` は Cargo.lock / dependency / resource packaging / Nix 変更がある場合だけ実行し、未実行なら理由を報告する。 + +Current code map: +- Workspace identity: `crates/workspace-server/src/identity.rs`, config/server setup in `config.rs`, `main.rs`。 +- Backend routes/API: `crates/workspace-server/src/server.rs`, host/worker API context in `hosts.rs`, project records in `records.rs`, repository API in `repositories.rs`。 +- Frontend routes: `web/workspace/src/routes/+layout.ts`, `+layout.svelte`, `+page.ts`, `+page.svelte`, `routes/repositories/[repositoryId]`, `routes/objectives`, `routes/settings`, `routes/runtimes/[runtimeId]/workers/[workerId]/console`。 +- Frontend navigation/API call surfaces: `web/workspace/src/lib/workspace-sidebar/**`, settings model/page, worker console page, repository navigation helpers。 + +Critical risks / reviewer focus: +- Frontend must not keep calling unscoped `/api/...` for workspace-scoped data。 +- Links must include the route `workspaceId`; no hardcoded current workspace routes should remain as canonical links。 +- Scoped API mismatch must fail closed with sanitized typed diagnostics。 +- Runtime `/v1/...` API must remain unchanged。 +- Compatibility aliases must not become the frontend canonical path。 +- workspace id should be treated as immutable id, not slug/alias。 + +--- + + + +## State changed + +Queued acceptance recorded after Workspace Dashboard Queue notification authorized Orchestrator routing. + +Checked context: +- Ticket body / thread / artifacts。 +- `TicketRelationQuery(00001KWWE8E04)`: blocking relation 0 件。 +- `TicketOrchestrationPlanQuery(00001KWWE8E04)`: prior record 0 件。今回 accepted_plan を記録済み。 +- Orchestrator worktree git status: clean on `orchestration`。 +- queued Ticket はこの Ticket 1件のみ。inprogress は 0 件。 +- visible Pods / TicketDoctor / bounded code map を確認。 + +Acceptance basis: +- concrete missing decision / information は残っていない。 +- Ticket の scope は Browser route/API workspace-id scoping に閉じており、multi-workspace DB migration / slug/alias / Runtime `/v1` scope change は非目標として明確。 +- side effect はこの `queued -> inprogress` acceptance 後に、worktree-workflow に沿って implementation worktree を作成してから開始する。 + +--- + + + +## Implementation report + +Implementation routing update: + +- Worktree-workflow に従い implementation worktree を作成した。 + - Worktree: `/home/hare/Projects/yoi/.worktree/00001KWWE8E04-workspace-id-routes` + - Branch: `work/00001KWWE8E04-workspace-id-routes` +- Worktree には tracked `.yoi` project records が見えている。 +- `.yoi/memory` / `.yoi/runtime` / `.yoi/log` / `.yoi/logs` / `.yoi/local` / `.yoi/*.lock` / `.yoi/*secret*` は作成直後に存在しないことを確認した。 +- Sibling Coder Pod `yoi-coder-00001KWWE8E04-workspace-routes` を起動した。 +- Scope policy: runtime workspace root は read-only、implementation worktree は write 可。Coder task では root/original workspace での read/write/validation/git operation を禁止し、この implementation worktree だけを作業対象に指定した。 +- 完了後は diff / validation evidence を Orchestrator が確認し、別 sibling Reviewer Pod に read-only review を依頼する。`StopPod` は既知の不具合があるため使用しない。 + +--- + + + +## Implementation report + +Implementation progress report: + +- Coder Pod completed implementation and committed `f6ad9cfc feat: scope workspace routes by id` on branch `work/00001KWWE8E04-workspace-id-routes`. +- Worktree status was clean after commit. +- Changed files include backend `crates/workspace-server/src/server.rs` / `lib.rs`, scoped route/API helpers/tests, sidebar/repository/objective/settings/worker console links, and new SvelteKit route files under `web/workspace/src/routes/w/[workspaceId]/...`. +- Implementation summary: + - Added canonical Browser routes under `/w//...`。 + - Added Browser-facing scoped API routes under `/api/w//...`。 + - Added backend scoped workspace id validation against current immutable backend workspace id。 + - mismatch returns typed sanitized `workspace_id_mismatch` diagnostic with 404。 + - Runtime `/v1/...` APIs were not changed。 + - Frontend links/API calls now use scoped route/API helpers for workspace Browser surfaces。 + - Worker creation console hrefs point at scoped `/w//runtimes/.../console`。 + - Focused backend and frontend tests cover scoped API/mismatch and route/API/link helpers。 +- Orchestrator reran validation in the implementation worktree: + - `git diff --check`: pass + - `cd web/workspace && deno task check`: pass(0 errors / 0 warnings) + - `cd web/workspace && deno task test`: pass(16 tests) + - `cargo test -p yoi-workspace-server`: pass(65 lib tests + 2 main tests) + - `cargo check -p yoi`: pass + - `yoi ticket doctor`: ok +- `nix build .#yoi --no-link` は Cargo.lock / dependency / resource packaging / Nix 変更ではないため未実行。 +- External review will be requested via a read-only sibling Reviewer Pod. `StopPod` は使わない。 + +--- + + + +## Review: request changes + +External review result: request_changes + +Blockers: + +1. Unscoped UI routes still perform unscoped frontend API calls。 + - `web/workspace/src/routes/+layout.ts:11-18` falls back to `/api${path}` when `params.workspaceId` is absent。 + - As a result `/`, `/objectives`, `/settings` などが workspace-scoped data を `/api/workspace` / `/api/repositories` から通常 load し続ける。 + - これは Ticket / Intent の「Frontend own calls は workspace-scoped data に `/api/w//...` を使う」と衝突する。Compatibility aliases は redirect/bootstrap-only などにして、通常の unscoped data routes として残さない必要がある。 + +2. scoped/unscoped browser routes が大きな duplicated pages として実装され、route logic drift を作っている。 + - `web/workspace/src/routes/w/[workspaceId]/**` に full page copies が追加され、旧 route files も残っている。 + - `settings/+page.svelte` と `w/[workspaceId]/settings/+page.svelte`、worker console page などが byte-identical。 + - これは critical pages の substantial duplicated UI/control logic であり、thin compatibility shim ではなく co-canonical surface になっている。 + - Unscoped routes を `/w//...` に redirect するか、shared route components/load helpers に factor して canonical scoped routes だけを maintained surface にする必要がある。 + +Validated/inspected: +- Ticket item/thread と routing IntentPacket。 +- implementation worktree 内で `f6ad9cfc` diff/stat/check を inspection。 +- Backend scoped route pattern と mismatch handling は方向性として妥当。`validate_workspace_scope` は fail closed し、`workspace_id_mismatch` diagnostics は raw path/internal store details を漏らしていない。 +- Runtime `/v1/...` API は inspected diff では変更されていない。 +- Orchestrator-reported validations は pass として確認。Reviewer は heavy checks を再実行していない。 + +Non-blocking follow-ups: +- 一部 UI placeholder/help text が旧 `/api/...` paths に触れているため、canonical route/API behavior 修正後に掃除するとよい。 +- redirect/shared-route refactor 後、scoped settings/workers/console endpoints の direct coverage を増やすとよい。 + +--- + + + +## Implementation report + +Review-fix implementation report: + +- Coder Pod completed requested changes and committed follow-up `71340b89 fix: redirect unscoped workspace routes` on branch `work/00001KWWE8E04-workspace-id-routes`. +- Worktree status was clean after commit. +- Fix summary: + - `+layout.ts` now uses `/api/workspace` only as bootstrap when route `workspaceId` is absent, then redirects to `/w/...`。 + - Silent `/api${path}` fallback for workspace-scoped data was removed。 + - Unscoped root/objectives/repository/settings/worker-console routes are thin redirect/shim surfaces instead of duplicated full data/control pages。 + - Backend SPA fallback redirects unscoped Browser routes such as `/objectives?...` to `/w//objectives?...`。 + - Canonical maintained page/control logic remains under `/w/[workspaceId]/**`。 + - Added/updated focused tests for bootstrap-then-redirect behavior, no unscoped workspace API fallback, thin settings shim, backend unscoped UI redirect, and canonical worker console scoped route references。 +- Orchestrator reran validation in the implementation worktree: + - `git diff --check`: pass + - `cd web/workspace && deno task check`: pass(0 errors / 0 warnings) + - `cd web/workspace && deno task test`: pass(17 tests) + - `cargo test -p yoi-workspace-server`: pass(65 lib tests + 2 main tests) + - `cargo check -p yoi`: pass + - `yoi ticket doctor`: ok +- `nix build .#yoi --no-link` は Cargo.lock / dependency / resource packaging / Nix 変更ではないため未実行。 +- Requesting follow-up external review against `f6ad9cfc..71340b89` and full combined implementation。 + +--- + + + +## Review: approve + +External follow-up review result: approve + +Blockers: none. + +Evidence reviewed in implementation worktree: +- Ticket `00001KWWE8E04`, fix commit `71340b89`, and combined relevant implementation。 + +Findings: +- Prior blocker 1 resolved: unscoped browser routes now bootstrap only through `/api/workspace` and redirect to `/w//...`; when no workspace id is available, layout returns empty/null state instead of loading workspace datasets through unscoped `/api/...`。 +- Prior blocker 2 resolved: canonical maintained UI/control logic is under `web/workspace/src/routes/w/[workspaceId]/...`; unscoped route files are thin empty-load/component shims and no longer duplicate critical page logic。 +- Frontend scoped API construction uses `/api/w//...` via shared helpers; scoped routes and links use `/w//...`。 +- Backend has scoped `/api/w/{workspace_id}/...` routes with centralized workspace-id assertion. Mismatches return typed/sanitized 404 diagnostic (`workspace_id_mismatch`) without raw paths, store paths, or runtime internals。 +- Runtime `/v1/...` API surface was not touched。 +- Tests meaningfully cover scoped API behavior, mismatch diagnostics, static unscoped redirect behavior, frontend scoped API/link construction, and unscoped shim/source-shape expectations。 + +Validation note: +- Reviewer did not rerun the full validation; reviewer relied on Orchestrator-reported passing validation and performed read-only inspection。 + +No merge-blocking issues found. + +--- + + + +## State changed + +Merge/validation acceptance: + +- Follow-up external review approved the implementation after blocker fix。 +- Implementation branch `work/00001KWWE8E04-workspace-id-routes` was merged into the orchestration branch with merge commit `13e756e7 merge: workspace id route scoping`。 +- Final validation in Orchestrator worktree passed: + - `git diff --check`: pass + - `cd web/workspace && deno task check`: pass(0 errors / 0 warnings) + - `cd web/workspace && deno task test`: pass(17 tests) + - `cargo test -p yoi-workspace-server`: pass(65 lib tests + 2 main tests) + - `cargo check -p yoi`: pass + - `yoi ticket doctor`: ok +- `nix build .#yoi --no-link` は Cargo.lock / dependency / resource packaging / Nix 変更ではないため未実行。 + +Implementation commits: +- `f6ad9cfc feat: scope workspace routes by id` +- `71340b89 fix: redirect unscoped workspace routes` + +No merge-blocking issues remain. + +--- + + + +## State changed + +Ticket を closed にしました。 + + +--- + + + +## 完了 + +完了。 + +実装内容: +- Workspace Browser の canonical UI routes を `/w//...` に移行した。 +- Browser-facing Workspace API に `/api/w//...` scoped variants を追加した。 +- Backend current immutable `workspace_id` と path `workspace-id` を照合し、mismatch は typed sanitized `workspace_id_mismatch` diagnostic / 404 として fail closed するようにした。 +- Frontend の workspace-scoped data calls は `/api/w//...` helper 経由に変更した。 +- Sidebar / settings / repository / objectives / worker console links は workspace id を含む scoped links を使うようにした。 +- Runtime `/v1/...` API surface は変更しなかった。 +- Unscoped browser routes は `/api/workspace` bootstrap から scoped `/w//...` へ redirect する thin shim とし、通常の unscoped data route / duplicated co-canonical page surface を廃止した。 +- Backend SPA fallback でも unscoped Browser routes を scoped route に redirect するようにした。 +- Focused backend/web tests を追加/更新した。 + +主な commit / merge: +- implementation: `f6ad9cfc feat: scope workspace routes by id` +- review fix: `71340b89 fix: redirect unscoped workspace routes` +- merge into orchestration: `13e756e7 merge: workspace id route scoping` + +Review: +- 初回 external review は request_changes。 +- follow-up external review は approve。merge-blocking issue なし。 + +Final validation in Orchestrator worktree: +- `git diff --check`: pass +- `cd web/workspace && deno task check`: pass(0 errors / 0 warnings) +- `cd web/workspace && deno task test`: pass(17 tests) +- `cargo test -p yoi-workspace-server`: pass(65 lib tests + 2 main tests) +- `cargo check -p yoi`: pass +- `yoi ticket doctor`: ok + +未実行: +- `nix build .#yoi --no-link` は Cargo.lock / dependency / resource packaging / Nix 変更ではないため未実行。 + --- diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index ad3dfc4b..d279a8d2 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -71,6 +71,8 @@ pub enum Error { }, #[error("unknown local repository `{0}`")] UnknownRepository(String), + #[error("workspace id does not match this Workspace backend")] + WorkspaceIdMismatch, #[error("workspace identity error: {0}")] WorkspaceIdentity(String), #[error("store error: {0}")] diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 7e266f3a..f77148ac 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; use axum::extract::{Path as AxumPath, Query, State}; -use axum::http::header::CONTENT_TYPE; +use axum::http::header::{CONTENT_TYPE, LOCATION}; use axum::http::{StatusCode, Uri}; use axum::response::{IntoResponse, Response}; use axum::routing::{delete, get, post}; @@ -221,84 +221,193 @@ impl WorkspaceApi { pub fn build_router(api: WorkspaceApi) -> Router { Router::new() .route("/api/workspace", get(get_workspace)) + .route("/api/w/{workspace_id}/workspace", get(scoped_get_workspace)) .route("/api/tickets", get(list_tickets)) + .route("/api/w/{workspace_id}/tickets", get(scoped_list_tickets)) .route("/api/tickets/{id}", get(get_ticket)) + .route("/api/w/{workspace_id}/tickets/{id}", get(scoped_get_ticket)) .route("/api/objectives", get(list_objectives)) + .route( + "/api/w/{workspace_id}/objectives", + get(scoped_list_objectives), + ) .route("/api/objectives/{id}", get(get_objective)) + .route( + "/api/w/{workspace_id}/objectives/{id}", + get(scoped_get_objective), + ) .route("/api/repositories", get(list_repositories)) + .route( + "/api/w/{workspace_id}/repositories", + get(scoped_list_repositories), + ) .route("/api/repositories/{repository_id}", get(repository_detail)) + .route( + "/api/w/{workspace_id}/repositories/{repository_id}", + get(scoped_repository_detail), + ) .route("/api/repositories/{repository_id}/log", get(repository_log)) + .route( + "/api/w/{workspace_id}/repositories/{repository_id}/log", + get(scoped_repository_log), + ) .route( "/api/repositories/{repository_id}/tickets", get(repository_tickets), ) + .route( + "/api/w/{workspace_id}/repositories/{repository_id}/tickets", + get(scoped_repository_tickets), + ) .route("/api/hosts", get(list_hosts)) + .route("/api/w/{workspace_id}/hosts", get(scoped_list_hosts)) .route("/api/runtimes", get(list_runtimes)) + .route("/api/w/{workspace_id}/runtimes", get(scoped_list_runtimes)) .route( "/api/workers", get(list_workers).post(create_workspace_worker), ) + .route( + "/api/w/{workspace_id}/workers", + get(scoped_list_workers).post(scoped_create_workspace_worker), + ) .route( "/api/workers/launch-options", get(get_worker_launch_options), ) + .route( + "/api/w/{workspace_id}/workers/launch-options", + get(scoped_get_worker_launch_options), + ) .route( "/api/settings/runtime-connections", get(get_runtime_connection_settings), ) + .route( + "/api/w/{workspace_id}/settings/runtime-connections", + get(scoped_get_runtime_connection_settings), + ) .route( "/api/settings/runtime-connections/remotes", post(add_remote_runtime_connection), ) + .route( + "/api/w/{workspace_id}/settings/runtime-connections/remotes", + post(scoped_add_remote_runtime_connection), + ) .route( "/api/settings/runtime-connections/remotes/{runtime_id}", delete(delete_remote_runtime_connection), ) + .route( + "/api/w/{workspace_id}/settings/runtime-connections/remotes/{runtime_id}", + delete(scoped_delete_remote_runtime_connection), + ) .route( "/api/settings/runtime-connections/remotes/{runtime_id}/test", post(test_remote_runtime_connection), ) + .route( + "/api/w/{workspace_id}/settings/runtime-connections/remotes/{runtime_id}/test", + post(scoped_test_remote_runtime_connection), + ) .route("/api/companion/status", get(get_companion_status)) + .route( + "/api/w/{workspace_id}/companion/status", + get(scoped_get_companion_status), + ) .route("/api/companion/transcript", get(get_companion_transcript)) + .route( + "/api/w/{workspace_id}/companion/transcript", + get(scoped_get_companion_transcript), + ) .route("/api/companion/messages", post(post_companion_message)) + .route( + "/api/w/{workspace_id}/companion/messages", + post(scoped_post_companion_message), + ) .route("/api/companion/cancel", post(post_companion_cancel)) + .route( + "/api/w/{workspace_id}/companion/cancel", + post(scoped_post_companion_cancel), + ) .route( "/api/runtimes/{runtime_id}/workers", post(create_runtime_worker), ) + .route( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers", + post(scoped_create_runtime_worker), + ) .route( "/api/runtimes/{runtime_id}/config-bundles", post(sync_runtime_config_bundle), ) + .route( + "/api/w/{workspace_id}/runtimes/{runtime_id}/config-bundles", + post(scoped_sync_runtime_config_bundle), + ) .route( "/api/runtimes/{runtime_id}/config-bundles/{bundle_id}/availability", get(check_runtime_config_bundle), ) + .route( + "/api/w/{workspace_id}/runtimes/{runtime_id}/config-bundles/{bundle_id}/availability", + get(scoped_check_runtime_config_bundle), + ) .route( "/api/runtimes/{runtime_id}/workers/{worker_id}", get(get_runtime_worker), ) + .route( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}", + get(scoped_get_runtime_worker), + ) .route( "/api/runtimes/{runtime_id}/workers/{worker_id}/input", post(send_runtime_worker_input), ) + .route( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/input", + post(scoped_send_runtime_worker_input), + ) .route( "/api/runtimes/{runtime_id}/workers/{worker_id}/stop", post(stop_runtime_worker), ) + .route( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/stop", + post(scoped_stop_runtime_worker), + ) .route( "/api/runtimes/{runtime_id}/workers/{worker_id}/cancel", post(cancel_runtime_worker), ) + .route( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/cancel", + post(scoped_cancel_runtime_worker), + ) .route( "/api/runtimes/{runtime_id}/workers/{worker_id}/transcript", get(get_runtime_worker_transcript), ) + .route( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/transcript", + get(scoped_get_runtime_worker_transcript), + ) .route( "/api/runtimes/{runtime_id}/workers/{worker_id}/events/ws", get(worker_observation_ws), ) + .route( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/events/ws", + get(scoped_worker_observation_ws), + ) .route("/api/hosts/{host_id}/workers", get(list_host_workers)) + .route( + "/api/w/{workspace_id}/hosts/{host_id}/workers", + get(scoped_list_host_workers), + ) .fallback(get(static_or_spa_fallback)) .with_state(api) } @@ -522,6 +631,363 @@ struct TranscriptQuery { limit: Option, } +#[derive(Debug, Deserialize)] +struct ScopedWorkspacePath { + workspace_id: String, +} + +#[derive(Debug, Deserialize)] +struct ScopedRecordPath { + workspace_id: String, + id: String, +} + +#[derive(Debug, Deserialize)] +struct ScopedRepositoryPath { + workspace_id: String, + repository_id: String, +} + +#[derive(Debug, Deserialize)] +struct ScopedHostPath { + workspace_id: String, + host_id: String, +} + +#[derive(Debug, Deserialize)] +struct ScopedRuntimePath { + workspace_id: String, + runtime_id: String, +} + +#[derive(Debug, Deserialize)] +struct ScopedConfigBundlePath { + workspace_id: String, + runtime_id: String, + bundle_id: String, +} + +#[derive(Debug, Deserialize)] +struct ScopedRuntimeWorkerPath { + workspace_id: String, + runtime_id: String, + worker_id: String, +} + +fn validate_workspace_scope(api: &WorkspaceApi, workspace_id: &str) -> ApiResult<()> { + if workspace_id == api.workspace_id() { + Ok(()) + } else { + Err(workspace_id_mismatch_error()) + } +} + +async fn scoped_get_workspace( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + get_workspace(State(api)).await +} + +async fn scoped_list_tickets( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult>> { + validate_workspace_scope(&api, &path.workspace_id)?; + list_tickets(State(api)).await +} + +async fn scoped_get_ticket( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + get_ticket(State(api), AxumPath(path.id)).await +} + +async fn scoped_list_objectives( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult>> { + validate_workspace_scope(&api, &path.workspace_id)?; + list_objectives(State(api)).await +} + +async fn scoped_get_objective( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + get_objective(State(api), AxumPath(path.id)).await +} + +async fn scoped_list_repositories( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + list_repositories(State(api)).await +} + +async fn scoped_repository_detail( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + repository_detail(State(api), AxumPath(path.repository_id)).await +} + +async fn scoped_repository_log( + State(api): State, + AxumPath(path): AxumPath, + Query(query): Query, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + repository_log(State(api), AxumPath(path.repository_id), Query(query)).await +} + +async fn scoped_repository_tickets( + State(api): State, + AxumPath(path): AxumPath, + Query(query): Query, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + repository_tickets(State(api), AxumPath(path.repository_id), Query(query)).await +} + +async fn scoped_list_hosts( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult>> { + validate_workspace_scope(&api, &path.workspace_id)?; + list_hosts(State(api)).await +} + +async fn scoped_list_runtimes( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult>> { + validate_workspace_scope(&api, &path.workspace_id)?; + list_runtimes(State(api)).await +} + +async fn scoped_list_workers( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult>> { + validate_workspace_scope(&api, &path.workspace_id)?; + list_workers(State(api)).await +} + +async fn scoped_create_workspace_worker( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + create_workspace_worker(State(api), Json(request)).await +} + +async fn scoped_get_worker_launch_options( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + get_worker_launch_options(State(api)).await +} + +async fn scoped_get_runtime_connection_settings( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + get_runtime_connection_settings(State(api)).await +} + +async fn scoped_add_remote_runtime_connection( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + add_remote_runtime_connection(State(api), Json(request)).await +} + +async fn scoped_delete_remote_runtime_connection( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + delete_remote_runtime_connection(State(api), AxumPath(path.runtime_id)).await +} + +async fn scoped_test_remote_runtime_connection( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + test_remote_runtime_connection(State(api), AxumPath(path.runtime_id)).await +} + +async fn scoped_get_companion_status( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + get_companion_status(State(api)).await +} + +async fn scoped_get_companion_transcript( + State(api): State, + AxumPath(path): AxumPath, + Query(query): Query, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + get_companion_transcript(State(api), Query(query)).await +} + +async fn scoped_post_companion_message( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + post_companion_message(State(api), Json(request)).await +} + +async fn scoped_post_companion_cancel( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + post_companion_cancel(State(api), Json(request)).await +} + +async fn scoped_create_runtime_worker( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + create_runtime_worker(State(api), AxumPath(path.runtime_id), Json(request)).await +} + +async fn scoped_sync_runtime_config_bundle( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + sync_runtime_config_bundle(State(api), AxumPath(path.runtime_id), Json(request)).await +} + +async fn scoped_check_runtime_config_bundle( + State(api): State, + AxumPath(path): AxumPath, + Query(query): Query, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + check_runtime_config_bundle( + State(api), + AxumPath((path.runtime_id, path.bundle_id)), + Query(query), + ) + .await +} + +async fn scoped_get_runtime_worker( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + get_runtime_worker(State(api), AxumPath((path.runtime_id, path.worker_id))).await +} + +async fn scoped_send_runtime_worker_input( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + send_runtime_worker_input( + State(api), + AxumPath((path.runtime_id, path.worker_id)), + Json(request), + ) + .await +} + +async fn scoped_stop_runtime_worker( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + stop_runtime_worker( + State(api), + AxumPath((path.runtime_id, path.worker_id)), + Json(request), + ) + .await +} + +async fn scoped_cancel_runtime_worker( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + cancel_runtime_worker( + State(api), + AxumPath((path.runtime_id, path.worker_id)), + Json(request), + ) + .await +} + +async fn scoped_get_runtime_worker_transcript( + State(api): State, + AxumPath(path): AxumPath, + Query(query): Query, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + get_runtime_worker_transcript( + State(api), + AxumPath((path.runtime_id, path.worker_id)), + Query(query), + ) + .await +} + +async fn scoped_worker_observation_ws( + ws: WebSocketUpgrade, + State(api): State, + AxumPath(path): AxumPath, + Query(query): Query, +) -> Response { + if let Err(err) = validate_workspace_scope(&api, &path.workspace_id) { + return err.into_response(); + } + worker_observation_ws( + State(api), + AxumPath((path.runtime_id, path.worker_id)), + Query(query), + ws, + ) + .await + .into_response() +} + +async fn scoped_list_host_workers( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult>> { + validate_workspace_scope(&api, &path.workspace_id)?; + list_host_workers(State(api), AxumPath(path.host_id)).await +} + async fn get_workspace(State(api): State) -> ApiResult> { let schema_version = api.store.schema_version().await?; let stored = api.store.get_workspace(api.workspace_id()).await?; @@ -1048,13 +1514,15 @@ async fn create_workspace_worker( })?; let runtime_id = worker.runtime_id.clone(); let worker_id = worker.worker_id.clone(); + let workspace_id = api.workspace_id().to_string(); let console_href = format!( - "/runtimes/{}/workers/{}/console", + "/w/{}/runtimes/{}/workers/{}/console", + encode_path_segment(&workspace_id), encode_path_segment(&runtime_id), encode_path_segment(&worker_id) ); Ok(Json(BrowserCreateWorkerResponse { - workspace_id: api.config.workspace_id, + workspace_id, runtime_id, worker_id, console_href, @@ -2263,6 +2731,18 @@ async fn static_or_spa_fallback(State(api): State, uri: Uri) -> Re .into_response(); } + if let Some(workspace_id) = workspace_id_from_ui_path(uri.path()) { + if workspace_id != api.workspace_id() { + return workspace_id_mismatch_error().into_response(); + } + } + + if let Some(location) = + unscoped_workspace_ui_redirect(uri.path(), uri.query(), api.workspace_id()) + { + return (StatusCode::TEMPORARY_REDIRECT, [(LOCATION, location)]).into_response(); + } + let Some(static_root) = api.config.static_assets_dir.as_ref() else { return StatusCode::NOT_FOUND.into_response(); }; @@ -2279,6 +2759,40 @@ async fn static_or_spa_fallback(State(api): State, uri: Uri) -> Re } } +fn unscoped_workspace_ui_redirect( + path: &str, + query: Option<&str>, + workspace_id: &str, +) -> Option { + let scoped_tail = if path == "/" { + "" + } else if ["/repositories", "/objectives", "/settings", "/runtimes"] + .iter() + .any(|prefix| path == *prefix || path.starts_with(&format!("{prefix}/"))) + { + path + } else { + return None; + }; + + let mut location = format!("/w/{}{}", encode_path_segment(workspace_id), scoped_tail); + if let Some(query) = query.filter(|query| !query.is_empty()) { + location.push('?'); + location.push_str(query); + } + Some(location) +} + +fn workspace_id_from_ui_path(path: &str) -> Option<&str> { + let tail = path.strip_prefix("/w/")?; + let workspace_id = tail.split('/').next().unwrap_or_default(); + if workspace_id.is_empty() { + None + } else { + Some(workspace_id) + } +} + struct StaticAsset { bytes: Vec, content_type: &'static str, @@ -2337,6 +2851,18 @@ fn content_type_for(path: &Path) -> &'static str { type ApiResult = std::result::Result; +fn workspace_id_mismatch_error() -> ApiError { + let message = "workspace id does not match this Workspace backend".to_string(); + ApiError::with_diagnostics( + Error::WorkspaceIdMismatch, + vec![RuntimeDiagnostic { + code: "workspace_id_mismatch".to_string(), + severity: DiagnosticSeverity::Error, + message, + }], + ) +} + struct ApiError { error: Error, diagnostics: Vec, @@ -2366,7 +2892,8 @@ impl IntoResponse for ApiError { | Error::UnknownHost(_) | Error::UnknownRuntime(_) | Error::UnknownWorker { .. } - | Error::UnknownRepository(_) => StatusCode::NOT_FOUND, + | Error::UnknownRepository(_) + | Error::WorkspaceIdMismatch => StatusCode::NOT_FOUND, Error::Ticket(_) => StatusCode::NOT_FOUND, Error::RuntimeCapabilityUnsupported { .. } => StatusCode::NOT_IMPLEMENTED, Error::RuntimeOperationFailed { code, .. } if code == "repository_not_configured" => { @@ -3076,6 +3603,51 @@ mod tests { ); } + let scoped_workspace = get_json( + app.clone(), + &format!("/api/w/{TEST_WORKSPACE_ID}/workspace"), + ) + .await; + assert_eq!(scoped_workspace["workspace_id"], TEST_WORKSPACE_ID); + + let mismatched_workspace = request_json( + app.clone(), + "GET", + "/api/w/not-this-workspace/workspace", + None, + StatusCode::NOT_FOUND, + ) + .await; + assert_eq!( + mismatched_workspace["diagnostics"][0]["code"], + "workspace_id_mismatch" + ); + assert!( + !mismatched_workspace + .to_string() + .contains(dir.path().to_string_lossy().as_ref()) + ); + + let unscoped_objectives = app + .clone() + .oneshot( + Request::builder() + .uri("/objectives?focus=active") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(unscoped_objectives.status(), StatusCode::TEMPORARY_REDIRECT); + let expected_location = format!("/w/{TEST_WORKSPACE_ID}/objectives?focus=active"); + assert_eq!( + unscoped_objectives + .headers() + .get(LOCATION) + .and_then(|value| value.to_str().ok()), + Some(expected_location.as_str()) + ); + let tickets = get_json(app.clone(), "/api/tickets").await; assert_eq!(tickets["items"][0]["id"], "00000000001J2"); assert_eq!(tickets["items"][0]["state"], "ready"); @@ -3083,6 +3655,18 @@ mod tests { 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 scoped_objectives = get_json( + app.clone(), + &format!("/api/w/{TEST_WORKSPACE_ID}/objectives"), + ) + .await; + assert_eq!(scoped_objectives["items"][0]["id"], "00000000001J3"); + let scoped_objective = get_json( + app.clone(), + &format!("/api/w/{TEST_WORKSPACE_ID}/objectives/00000000001J3"), + ) + .await; + assert_eq!(scoped_objective["id"], "00000000001J3"); let repositories = get_json(app.clone(), "/api/repositories").await; assert_eq!(repositories["items"][0]["id"], TEST_REPOSITORY_ID); @@ -3099,6 +3683,12 @@ mod tests { let repository_detail = get_json(app.clone(), "/api/repositories/main").await; assert_eq!(repository_detail["item"]["id"], TEST_REPOSITORY_ID); + let scoped_repository_detail = get_json( + app.clone(), + &format!("/api/w/{TEST_WORKSPACE_ID}/repositories/main"), + ) + .await; + assert_eq!(scoped_repository_detail["item"]["id"], TEST_REPOSITORY_ID); let repository_log = get_json(app.clone(), "/api/repositories/main/log?limit=3").await; assert_eq!(repository_log["repository_id"], TEST_REPOSITORY_ID); diff --git a/web/workspace/deno.json b/web/workspace/deno.json index ae8656b4..0511dfa3 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@7.2.7 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server -- serve --workspace . --db .yoi/workspace.db --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src src/lib/workspace-console/model.test.ts src/lib/workspace-console/worker-console.ui.test.ts src/lib/workspace-settings/model.test.ts src/lib/workspace-sidebar/worker-launch.test.ts src/lib/workspace-sidebar/repository-nav.test.ts", + "test": "deno test --allow-read=src src/lib/workspace-api/http.test.ts src/lib/workspace-console/model.test.ts src/lib/workspace-console/worker-console.ui.test.ts src/lib/workspace-settings/model.test.ts src/lib/workspace-sidebar/worker-launch.test.ts src/lib/workspace-sidebar/repository-nav.test.ts", "build": "deno run -A npm:vite@7.2.7 build", "preview": "deno run -A npm:vite@7.2.7 preview" }, diff --git a/web/workspace/src/lib/workspace-api/http.test.ts b/web/workspace/src/lib/workspace-api/http.test.ts new file mode 100644 index 00000000..d18f7d43 --- /dev/null +++ b/web/workspace/src/lib/workspace-api/http.test.ts @@ -0,0 +1,55 @@ +import { workspaceApiPath, workspaceRoute } from "./http.ts"; + +declare const Deno: { + test(name: string, fn: () => Promise | void): void; + readTextFile(path: string | URL): Promise; +}; + +function assertEquals(actual: T, expected: T): void { + const actualJson = JSON.stringify(actual); + const expectedJson = JSON.stringify(expected); + if (actualJson !== expectedJson) { + throw new Error(`Expected ${expectedJson}, got ${actualJson}`); + } +} + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} + +Deno.test("workspace route helpers scope browser routes and API by immutable workspace id", () => { + assertEquals(workspaceRoute("workspace 1"), "/w/workspace%201"); + assertEquals(workspaceRoute("workspace 1", "/objectives"), "/w/workspace%201/objectives"); + assertEquals( + workspaceApiPath("workspace 1", "/repositories/repo-a"), + "/api/w/workspace%201/repositories/repo-a", + ); +}); + +Deno.test("unscoped layout bootstraps then redirects instead of loading unscoped workspace data", async () => { + const layout = await Deno.readTextFile(new URL("../../routes/+layout.ts", import.meta.url)); + assert( + layout.includes('loadJson(fetch, "/api/workspace")'), + "unscoped layout may use only the workspace-id bootstrap endpoint", + ); + assert( + layout.includes("throw redirect(307") && layout.includes("workspaceRoute("), + "unscoped layout should redirect to the scoped workspace route", + ); + assert( + !layout.includes('`/api${path}`') && !layout.includes('"/api/repositories"'), + "layout must not fall back to unscoped workspace-scoped API calls", + ); + + const unscopedSettings = await Deno.readTextFile( + new URL("../../routes/settings/+page.svelte", import.meta.url), + ); + assert( + unscopedSettings.includes("Redirecting to scoped settings") && + !unscopedSettings.includes("fetch(") && + !unscopedSettings.includes("settingsApiPath"), + "unscoped settings route should remain a thin redirect shim, not a data/control surface", + ); +}); diff --git a/web/workspace/src/lib/workspace-api/http.ts b/web/workspace/src/lib/workspace-api/http.ts index 3f3573c8..92365eae 100644 --- a/web/workspace/src/lib/workspace-api/http.ts +++ b/web/workspace/src/lib/workspace-api/http.ts @@ -3,6 +3,19 @@ export type ApiResult = { error: string | null; }; +function normalizePath(path: string): string { + if (!path || path === '/') return ''; + return path.startsWith('/') ? path : `/${path}`; +} + +export function workspaceRoute(workspaceId: string, path = ''): string { + return `/w/${encodeURIComponent(workspaceId)}${normalizePath(path)}`; +} + +export function workspaceApiPath(workspaceId: string, path = ''): string { + return `/api/w/${encodeURIComponent(workspaceId)}${normalizePath(path)}`; +} + export async function loadJson( fetchFn: typeof fetch, path: string, diff --git a/web/workspace/src/lib/workspace-console/model.test.ts b/web/workspace/src/lib/workspace-console/model.test.ts index a918643a..42eaf18f 100644 --- a/web/workspace/src/lib/workspace-console/model.test.ts +++ b/web/workspace/src/lib/workspace-console/model.test.ts @@ -24,8 +24,8 @@ Deno.test("workerConsoleHref encodes runtime and worker target authority", () => workerConsoleHref({ runtime_id: "local runtime", worker_id: "worker/one", - }) === - "/runtimes/local%20runtime/workers/worker%2Fone/console", + }, "workspace-1") === + "/w/workspace-1/runtimes/local%20runtime/workers/worker%2Fone/console", "href should contain encoded runtime_id and worker_id segments", ); }); diff --git a/web/workspace/src/lib/workspace-console/model.ts b/web/workspace/src/lib/workspace-console/model.ts index d95d1feb..489c5169 100644 --- a/web/workspace/src/lib/workspace-console/model.ts +++ b/web/workspace/src/lib/workspace-console/model.ts @@ -4,6 +4,7 @@ import type { Segment, } from "$lib/generated/protocol"; import type { WorkerTranscriptItem } from "$lib/workspace-sidebar/types"; +import { workspaceRoute } from "$lib/workspace-api/http"; export type ConsoleLineKind = | "user" @@ -40,16 +41,23 @@ export type WorkerTarget = { worker_id: string; }; -export function workerConsoleHref(target: WorkerTarget): string { - return `/runtimes/${encodeURIComponent(target.runtime_id)}/workers/${ - encodeURIComponent( - target.worker_id, - ) - }/console`; +export function workerConsoleHref(target: WorkerTarget, workspaceId: string): string { + return workspaceRoute( + workspaceId, + `/runtimes/${encodeURIComponent(target.runtime_id)}/workers/${ + encodeURIComponent( + target.worker_id, + ) + }/console`, + ); } -export function workerConsolePath(runtimeId: string, workerId: string): string { - return workerConsoleHref({ runtime_id: runtimeId, worker_id: workerId }); +export function workerConsolePath( + workspaceId: string, + runtimeId: string, + workerId: string, +): string { + return workerConsoleHref({ runtime_id: runtimeId, worker_id: workerId }, workspaceId); } export function initialConsoleLines(items: WorkerTranscriptItem[]): ConsoleLine[] { diff --git a/web/workspace/src/lib/workspace-console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace-console/worker-console.ui.test.ts index 2bf86df6..2027b115 100644 --- a/web/workspace/src/lib/workspace-console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace-console/worker-console.ui.test.ts @@ -11,7 +11,7 @@ function assert(condition: unknown, message: string): asserts condition { Deno.test("workspace Worker list and sidebar attach through Worker Console hrefs", async () => { const workspacePage = await Deno.readTextFile( - new URL("./../../routes/+page.svelte", import.meta.url), + new URL("./../../routes/w/[workspaceId]/+page.svelte", import.meta.url), ); const workersNav = await Deno.readTextFile( new URL("../workspace-sidebar/WorkersNavSection.svelte", import.meta.url), @@ -21,12 +21,12 @@ Deno.test("workspace Worker list and sidebar attach through Worker Console hrefs ); assert( - workspacePage.includes("workerConsoleHref(worker)") && + workspacePage.includes("workerConsoleHref(worker, workspaceId)") && workspacePage.includes("Open Console"), "top Worker list should expose an attach action per Worker", ); assert( - workersNav.includes("workerConsoleHref(worker)") && + workersNav.includes("workerConsoleHref(worker, workspaceId)") && workersNav.includes("aria-current"), "Workers sidebar rows should link to the Worker target Console route", ); @@ -40,26 +40,28 @@ Deno.test("workspace Worker list and sidebar attach through Worker Console hrefs Deno.test("Worker Console page is routed by runtime_id and worker_id through backend APIs", async () => { const consolePage = await Deno.readTextFile( new URL( - "./../../routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte", + "./../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte", import.meta.url, ), ); const routeLoad = await Deno.readTextFile( new URL( - "./../../routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.ts", + "./../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.ts", import.meta.url, ), ); assert( - routeLoad.includes("runtimeId") && routeLoad.includes("workerId"), - "route load should expose both target ids", + routeLoad.includes("workspaceId") && + routeLoad.includes("runtimeId") && routeLoad.includes("workerId"), + "route load should expose workspace and target ids", ); assert( - consolePage.includes( - "/api/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}", - ), - "Worker detail should use the backend Worker detail API", + consolePage.includes("workspaceApiPath(workspaceId, path)") && + consolePage.includes( + "workerApiPath(`/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}`)", + ), + "Worker detail should use the scoped backend Worker detail API", ); assert( consolePage.includes("/transcript?limit=200") && diff --git a/web/workspace/src/lib/workspace-sidebar/ObjectivesNavSection.svelte b/web/workspace/src/lib/workspace-sidebar/ObjectivesNavSection.svelte index 7052d95b..f0c3f204 100644 --- a/web/workspace/src/lib/workspace-sidebar/ObjectivesNavSection.svelte +++ b/web/workspace/src/lib/workspace-sidebar/ObjectivesNavSection.svelte @@ -1,9 +1,13 @@