From 34836d97ae9439498a2a9446451fe647a11d9b99 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 7 Jul 2026 04:54:37 +0900 Subject: [PATCH 1/9] ticket: accept workspace id route scoping --- .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KWWE8E04/item.md | 4 +- .yoi/tickets/00001KWWE8E04/thread.md | 106 ++++++++++++++++++ 3 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 .yoi/tickets/00001KWWE8E04/artifacts/orchestration-plan.jsonl 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..2e531925 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: 'inprogress' created_at: '2026-07-06T19:25:08Z' -updated_at: '2026-07-06T19:53:14Z' +updated_at: '2026-07-06T19:54:30Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-06T19:53:14Z' diff --git a/.yoi/tickets/00001KWWE8E04/thread.md b/.yoi/tickets/00001KWWE8E04/thread.md index f7f6ea45..4f125f94 100644 --- a/.yoi/tickets/00001KWWE8E04/thread.md +++ b/.yoi/tickets/00001KWWE8E04/thread.md @@ -30,4 +30,110 @@ 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 を作成してから開始する。 + --- From ab33c30d7cc67f30283d1ebfc6b63616e0365bf6 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 7 Jul 2026 04:55:32 +0900 Subject: [PATCH 2/9] ticket: route workspace id scoping coder --- .yoi/tickets/00001KWWE8E04/item.md | 2 +- .yoi/tickets/00001KWWE8E04/thread.md | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KWWE8E04/item.md b/.yoi/tickets/00001KWWE8E04/item.md index 2e531925..745cce5a 100644 --- a/.yoi/tickets/00001KWWE8E04/item.md +++ b/.yoi/tickets/00001KWWE8E04/item.md @@ -2,7 +2,7 @@ title: 'Scope Workspace Browser routes and API by workspace id' state: 'inprogress' created_at: '2026-07-06T19:25:08Z' -updated_at: '2026-07-06T19:54:30Z' +updated_at: '2026-07-06T19:55:26Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-06T19:53:14Z' diff --git a/.yoi/tickets/00001KWWE8E04/thread.md b/.yoi/tickets/00001KWWE8E04/thread.md index 4f125f94..fc6edef7 100644 --- a/.yoi/tickets/00001KWWE8E04/thread.md +++ b/.yoi/tickets/00001KWWE8E04/thread.md @@ -137,3 +137,20 @@ Acceptance basis: - 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` は既知の不具合があるため使用しない。 + +--- From f6ad9cfcd3c4705e6bafc0a1d6333db6e8e5df1c Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 7 Jul 2026 05:26:03 +0900 Subject: [PATCH 3/9] feat: scope workspace routes by id --- crates/workspace-server/src/lib.rs | 2 + crates/workspace-server/src/server.rs | 546 +++++++++++++++++- web/workspace/deno.json | 2 +- .../src/lib/workspace-api/http.test.ts | 22 + web/workspace/src/lib/workspace-api/http.ts | 13 + .../src/lib/workspace-console/model.test.ts | 4 +- .../src/lib/workspace-console/model.ts | 24 +- .../worker-console.ui.test.ts | 22 +- .../ObjectivesNavSection.svelte | 8 +- .../RepositoriesNavSection.svelte | 5 +- .../WorkersNavSection.svelte | 16 +- .../workspace-sidebar/WorkspaceSidebar.svelte | 12 +- .../workspace-sidebar/repository-nav.test.ts | 7 +- .../lib/workspace-sidebar/repository-nav.ts | 6 +- web/workspace/src/routes/+layout.ts | 12 +- web/workspace/src/routes/+page.svelte | 3 +- web/workspace/src/routes/+page.ts | 12 +- .../src/routes/objectives/+page.svelte | 4 +- web/workspace/src/routes/objectives/+page.ts | 10 +- .../objectives/[objectiveId]/+page.svelte | 4 +- .../routes/objectives/[objectiveId]/+page.ts | 17 +- .../repositories/[repositoryId]/+page.ts | 13 +- .../workers/[workerId]/console/+page.svelte | 22 +- .../workers/[workerId]/console/+page.ts | 10 +- .../src/routes/settings/+page.svelte | 21 +- .../src/routes/w/[workspaceId]/+page.svelte | 133 +++++ .../src/routes/w/[workspaceId]/+page.ts | 19 + .../w/[workspaceId]/objectives/+page.svelte | 46 ++ .../w/[workspaceId]/objectives/+page.ts | 17 + .../objectives/[objectiveId]/+page.svelte | 76 +++ .../objectives/[objectiveId]/+page.ts | 27 + .../repositories/[repositoryId]/+page.svelte | 105 ++++ .../repositories/[repositoryId]/+page.ts | 36 ++ .../workers/[workerId]/console/+page.svelte | 399 +++++++++++++ .../workers/[workerId]/console/+page.ts | 9 + .../w/[workspaceId]/settings/+page.svelte | 487 ++++++++++++++++ 36 files changed, 2082 insertions(+), 89 deletions(-) create mode 100644 web/workspace/src/lib/workspace-api/http.test.ts create mode 100644 web/workspace/src/routes/w/[workspaceId]/+page.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/+page.ts create mode 100644 web/workspace/src/routes/w/[workspaceId]/objectives/+page.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/objectives/+page.ts create mode 100644 web/workspace/src/routes/w/[workspaceId]/objectives/[objectiveId]/+page.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/objectives/[objectiveId]/+page.ts create mode 100644 web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryId]/+page.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryId]/+page.ts create mode 100644 web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.ts create mode 100644 web/workspace/src/routes/w/[workspaceId]/settings/+page.svelte 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..9ff23033 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -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,12 @@ 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(); + } + } + let Some(static_root) = api.config.static_assets_dir.as_ref() else { return StatusCode::NOT_FOUND.into_response(); }; @@ -2279,6 +2753,16 @@ async fn static_or_spa_fallback(State(api): State, uri: Uri) -> Re } } +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 +2821,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 +2862,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 +3573,31 @@ 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 tickets = get_json(app.clone(), "/api/tickets").await; assert_eq!(tickets["items"][0]["id"], "00000000001J2"); assert_eq!(tickets["items"][0]["state"], "ready"); @@ -3083,6 +3605,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 +3633,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..99f1d73e --- /dev/null +++ b/web/workspace/src/lib/workspace-api/http.test.ts @@ -0,0 +1,22 @@ +import { workspaceApiPath, workspaceRoute } from "./http.ts"; + +declare const Deno: { + test(name: string, fn: () => void): void; +}; + +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}`); + } +} + +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", + ); +}); 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..a688f57f 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 @@ -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 @@