merge: workspace id route scoping
# Conflicts: # web/workspace/src/lib/workspace-sidebar/WorkspaceSidebar.svelte
This commit is contained in:
commit
fbc9ba411e
|
|
@ -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/<workspace-id>` / `/api/w/<workspace-id>` に 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"}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
---
|
---
|
||||||
title: 'Scope Workspace Browser routes and API by workspace id'
|
title: 'Scope Workspace Browser routes and API by workspace id'
|
||||||
state: 'queued'
|
state: 'closed'
|
||||||
created_at: '2026-07-06T19:25:08Z'
|
created_at: '2026-07-06T19:25:08Z'
|
||||||
updated_at: '2026-07-06T19:53:14Z'
|
updated_at: '2026-07-06T20:53:44Z'
|
||||||
assignee: null
|
assignee: null
|
||||||
queued_by: 'workspace-panel'
|
queued_by: 'workspace-panel'
|
||||||
queued_at: '2026-07-06T19:53:14Z'
|
queued_at: '2026-07-06T19:53:14Z'
|
||||||
|
|
|
||||||
32
.yoi/tickets/00001KWWE8E04/resolution.md
Normal file
32
.yoi/tickets/00001KWWE8E04/resolution.md
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
完了。
|
||||||
|
|
||||||
|
実装内容:
|
||||||
|
- Workspace Browser の canonical UI routes を `/w/<workspace-id>/...` に移行した。
|
||||||
|
- Browser-facing Workspace API に `/api/w/<workspace-id>/...` 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/<workspace-id>/...` helper 経由に変更した。
|
||||||
|
- Sidebar / settings / repository / objectives / worker console links は workspace id を含む scoped links を使うようにした。
|
||||||
|
- Runtime `/v1/...` API surface は変更しなかった。
|
||||||
|
- Unscoped browser routes は `/api/workspace` bootstrap から scoped `/w/<workspace-id>/...` へ 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 変更ではないため未実行。
|
||||||
|
|
@ -30,4 +30,315 @@ Marked ready by `yoi ticket state`.
|
||||||
Ticket を `workspace-panel` が queued にしました。
|
Ticket を `workspace-panel` が queued にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: decision author: orchestrator at: 2026-07-06T19:54:13Z -->
|
||||||
|
|
||||||
|
## 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/<workspace-id>/...` に移し、Frontend が Browser-facing API を `/api/w/<workspace-id>/...` 経由で呼ぶようにする。
|
||||||
|
- 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/<workspace-id>/...`。
|
||||||
|
- Browser-facing scoped API prefix は `/api/w/<workspace-id>/...`。
|
||||||
|
- 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/<workspace-id>` が overview を表示する。
|
||||||
|
- `/w/<workspace-id>/repositories/<repository-id>` が Repository detail を表示する。
|
||||||
|
- `/w/<workspace-id>/objectives` と `/w/<workspace-id>/objectives/<objective-id>` が動く。
|
||||||
|
- `/w/<workspace-id>/settings` が Settings を表示する。
|
||||||
|
- `/w/<workspace-id>/runtimes/<runtime-id>/workers/<worker-id>/console` が Worker console を表示する。
|
||||||
|
- Frontend API calls use `/api/w/<workspace-id>/...` 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。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: orchestrator at: 2026-07-06T19:54:30Z from: queued to: inprogress reason: routing_acceptance_implementation_ready field: state -->
|
||||||
|
|
||||||
|
## 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 を作成してから開始する。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-07-06T19:55:26Z -->
|
||||||
|
|
||||||
|
## 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` は既知の不具合があるため使用しない。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-07-06T20:27:15Z -->
|
||||||
|
|
||||||
|
## 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/<workspace-id>/...`。
|
||||||
|
- Added Browser-facing scoped API routes under `/api/w/<workspace-id>/...`。
|
||||||
|
- 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/<workspace-id>/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` は使わない。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: review author: reviewer at: 2026-07-06T20:36:50Z status: request_changes -->
|
||||||
|
|
||||||
|
## 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/<workspace-id>/...` を使う」と衝突する。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/<workspace-id>/...` に 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 を増やすとよい。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-07-06T20:45:34Z -->
|
||||||
|
|
||||||
|
## 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/<workspace-id>...`。
|
||||||
|
- 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/<workspace-id>/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。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: review author: reviewer at: 2026-07-06T20:52:40Z status: approve -->
|
||||||
|
|
||||||
|
## 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/<workspace-id>/...`; 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/<workspace-id>/...` via shared helpers; scoped routes and links use `/w/<workspace-id>/...`。
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: orchestrator at: 2026-07-06T20:53:31Z from: inprogress to: done reason: merged_validated_review_approved field: state -->
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: hare at: 2026-07-06T20:53:44Z from: done to: closed reason: closed field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を closed にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: close author: hare at: 2026-07-06T20:53:44Z status: closed -->
|
||||||
|
|
||||||
|
## 完了
|
||||||
|
|
||||||
|
完了。
|
||||||
|
|
||||||
|
実装内容:
|
||||||
|
- Workspace Browser の canonical UI routes を `/w/<workspace-id>/...` に移行した。
|
||||||
|
- Browser-facing Workspace API に `/api/w/<workspace-id>/...` 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/<workspace-id>/...` helper 経由に変更した。
|
||||||
|
- Sidebar / settings / repository / objectives / worker console links は workspace id を含む scoped links を使うようにした。
|
||||||
|
- Runtime `/v1/...` API surface は変更しなかった。
|
||||||
|
- Unscoped browser routes は `/api/workspace` bootstrap から scoped `/w/<workspace-id>/...` へ 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 変更ではないため未実行。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,8 @@ pub enum Error {
|
||||||
},
|
},
|
||||||
#[error("unknown local repository `{0}`")]
|
#[error("unknown local repository `{0}`")]
|
||||||
UnknownRepository(String),
|
UnknownRepository(String),
|
||||||
|
#[error("workspace id does not match this Workspace backend")]
|
||||||
|
WorkspaceIdMismatch,
|
||||||
#[error("workspace identity error: {0}")]
|
#[error("workspace identity error: {0}")]
|
||||||
WorkspaceIdentity(String),
|
WorkspaceIdentity(String),
|
||||||
#[error("store error: {0}")]
|
#[error("store error: {0}")]
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||||
|
|
||||||
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
||||||
use axum::extract::{Path as AxumPath, Query, State};
|
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::http::{StatusCode, Uri};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::routing::{delete, get, post};
|
use axum::routing::{delete, get, post};
|
||||||
|
|
@ -221,84 +221,193 @@ impl WorkspaceApi {
|
||||||
pub fn build_router(api: WorkspaceApi) -> Router {
|
pub fn build_router(api: WorkspaceApi) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/workspace", get(get_workspace))
|
.route("/api/workspace", get(get_workspace))
|
||||||
|
.route("/api/w/{workspace_id}/workspace", get(scoped_get_workspace))
|
||||||
.route("/api/tickets", get(list_tickets))
|
.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/tickets/{id}", get(get_ticket))
|
||||||
|
.route("/api/w/{workspace_id}/tickets/{id}", get(scoped_get_ticket))
|
||||||
.route("/api/objectives", get(list_objectives))
|
.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/objectives/{id}", get(get_objective))
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/objectives/{id}",
|
||||||
|
get(scoped_get_objective),
|
||||||
|
)
|
||||||
.route("/api/repositories", get(list_repositories))
|
.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/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/repositories/{repository_id}/log", get(repository_log))
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/repositories/{repository_id}/log",
|
||||||
|
get(scoped_repository_log),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/repositories/{repository_id}/tickets",
|
"/api/repositories/{repository_id}/tickets",
|
||||||
get(repository_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/hosts", get(list_hosts))
|
||||||
|
.route("/api/w/{workspace_id}/hosts", get(scoped_list_hosts))
|
||||||
.route("/api/runtimes", get(list_runtimes))
|
.route("/api/runtimes", get(list_runtimes))
|
||||||
|
.route("/api/w/{workspace_id}/runtimes", get(scoped_list_runtimes))
|
||||||
.route(
|
.route(
|
||||||
"/api/workers",
|
"/api/workers",
|
||||||
get(list_workers).post(create_workspace_worker),
|
get(list_workers).post(create_workspace_worker),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/workers",
|
||||||
|
get(scoped_list_workers).post(scoped_create_workspace_worker),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/workers/launch-options",
|
"/api/workers/launch-options",
|
||||||
get(get_worker_launch_options),
|
get(get_worker_launch_options),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/workers/launch-options",
|
||||||
|
get(scoped_get_worker_launch_options),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/settings/runtime-connections",
|
"/api/settings/runtime-connections",
|
||||||
get(get_runtime_connection_settings),
|
get(get_runtime_connection_settings),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/settings/runtime-connections",
|
||||||
|
get(scoped_get_runtime_connection_settings),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/settings/runtime-connections/remotes",
|
"/api/settings/runtime-connections/remotes",
|
||||||
post(add_remote_runtime_connection),
|
post(add_remote_runtime_connection),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/settings/runtime-connections/remotes",
|
||||||
|
post(scoped_add_remote_runtime_connection),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/settings/runtime-connections/remotes/{runtime_id}",
|
"/api/settings/runtime-connections/remotes/{runtime_id}",
|
||||||
delete(delete_remote_runtime_connection),
|
delete(delete_remote_runtime_connection),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/settings/runtime-connections/remotes/{runtime_id}",
|
||||||
|
delete(scoped_delete_remote_runtime_connection),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/settings/runtime-connections/remotes/{runtime_id}/test",
|
"/api/settings/runtime-connections/remotes/{runtime_id}/test",
|
||||||
post(test_remote_runtime_connection),
|
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/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/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/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/companion/cancel", post(post_companion_cancel))
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/companion/cancel",
|
||||||
|
post(scoped_post_companion_cancel),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/runtimes/{runtime_id}/workers",
|
"/api/runtimes/{runtime_id}/workers",
|
||||||
post(create_runtime_worker),
|
post(create_runtime_worker),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers",
|
||||||
|
post(scoped_create_runtime_worker),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/runtimes/{runtime_id}/config-bundles",
|
"/api/runtimes/{runtime_id}/config-bundles",
|
||||||
post(sync_runtime_config_bundle),
|
post(sync_runtime_config_bundle),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/runtimes/{runtime_id}/config-bundles",
|
||||||
|
post(scoped_sync_runtime_config_bundle),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/runtimes/{runtime_id}/config-bundles/{bundle_id}/availability",
|
"/api/runtimes/{runtime_id}/config-bundles/{bundle_id}/availability",
|
||||||
get(check_runtime_config_bundle),
|
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(
|
.route(
|
||||||
"/api/runtimes/{runtime_id}/workers/{worker_id}",
|
"/api/runtimes/{runtime_id}/workers/{worker_id}",
|
||||||
get(get_runtime_worker),
|
get(get_runtime_worker),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}",
|
||||||
|
get(scoped_get_runtime_worker),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/runtimes/{runtime_id}/workers/{worker_id}/input",
|
"/api/runtimes/{runtime_id}/workers/{worker_id}/input",
|
||||||
post(send_runtime_worker_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(
|
.route(
|
||||||
"/api/runtimes/{runtime_id}/workers/{worker_id}/stop",
|
"/api/runtimes/{runtime_id}/workers/{worker_id}/stop",
|
||||||
post(stop_runtime_worker),
|
post(stop_runtime_worker),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/stop",
|
||||||
|
post(scoped_stop_runtime_worker),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/runtimes/{runtime_id}/workers/{worker_id}/cancel",
|
"/api/runtimes/{runtime_id}/workers/{worker_id}/cancel",
|
||||||
post(cancel_runtime_worker),
|
post(cancel_runtime_worker),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/cancel",
|
||||||
|
post(scoped_cancel_runtime_worker),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/runtimes/{runtime_id}/workers/{worker_id}/transcript",
|
"/api/runtimes/{runtime_id}/workers/{worker_id}/transcript",
|
||||||
get(get_runtime_worker_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(
|
.route(
|
||||||
"/api/runtimes/{runtime_id}/workers/{worker_id}/events/ws",
|
"/api/runtimes/{runtime_id}/workers/{worker_id}/events/ws",
|
||||||
get(worker_observation_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/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))
|
.fallback(get(static_or_spa_fallback))
|
||||||
.with_state(api)
|
.with_state(api)
|
||||||
}
|
}
|
||||||
|
|
@ -522,6 +631,363 @@ struct TranscriptQuery {
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
|
) -> ApiResult<Json<WorkspaceResponse>> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
get_workspace(State(api)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_list_tickets(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
|
) -> ApiResult<Json<ListResponse<crate::records::TicketSummary>>> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
list_tickets(State(api)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_get_ticket(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedRecordPath>,
|
||||||
|
) -> ApiResult<Json<TicketDetail>> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
get_ticket(State(api), AxumPath(path.id)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_list_objectives(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
|
) -> ApiResult<Json<ListResponse<crate::records::ObjectiveSummary>>> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
list_objectives(State(api)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_get_objective(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedRecordPath>,
|
||||||
|
) -> ApiResult<Json<ObjectiveDetail>> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
get_objective(State(api), AxumPath(path.id)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_list_repositories(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
|
) -> ApiResult<Json<RepositoryListResponse>> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
list_repositories(State(api)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_repository_detail(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedRepositoryPath>,
|
||||||
|
) -> ApiResult<Json<RepositoryDetailResponse>> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
repository_detail(State(api), AxumPath(path.repository_id)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_repository_log(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedRepositoryPath>,
|
||||||
|
Query(query): Query<LogQuery>,
|
||||||
|
) -> ApiResult<Json<RepositoryLogResponse>> {
|
||||||
|
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<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedRepositoryPath>,
|
||||||
|
Query(query): Query<TicketKanbanQuery>,
|
||||||
|
) -> ApiResult<Json<RepositoryTicketsResponse>> {
|
||||||
|
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<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
|
) -> ApiResult<Json<RuntimeListResponse<HostSummary>>> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
list_hosts(State(api)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_list_runtimes(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
|
) -> ApiResult<Json<RuntimeListResponse<RuntimeSummary>>> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
list_runtimes(State(api)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_list_workers(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
|
) -> ApiResult<Json<RuntimeListResponse<WorkerSummary>>> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
list_workers(State(api)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_create_workspace_worker(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
|
Json(request): Json<BrowserCreateWorkerRequest>,
|
||||||
|
) -> ApiResult<Json<BrowserCreateWorkerResponse>> {
|
||||||
|
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<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
|
) -> ApiResult<Json<WorkerLaunchOptionsResponse>> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
get_worker_launch_options(State(api)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_get_runtime_connection_settings(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
|
) -> ApiResult<Json<RuntimeConnectionSettingsResponse>> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
get_runtime_connection_settings(State(api)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_add_remote_runtime_connection(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
|
Json(request): Json<AddRemoteRuntimeConnectionRequest>,
|
||||||
|
) -> ApiResult<Json<RuntimeConnectionMutationResponse>> {
|
||||||
|
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<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedRuntimePath>,
|
||||||
|
) -> ApiResult<Json<RuntimeConnectionMutationResponse>> {
|
||||||
|
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<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedRuntimePath>,
|
||||||
|
) -> ApiResult<Json<RemoteRuntimeTestResponse>> {
|
||||||
|
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<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
|
) -> ApiResult<Json<CompanionStatusResponse>> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
get_companion_status(State(api)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_get_companion_transcript(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
|
Query(query): Query<TranscriptQuery>,
|
||||||
|
) -> ApiResult<Json<CompanionTranscriptProjection>> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
get_companion_transcript(State(api), Query(query)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_post_companion_message(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
|
Json(request): Json<CompanionMessageRequest>,
|
||||||
|
) -> ApiResult<Json<CompanionMessageResponse>> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
post_companion_message(State(api), Json(request)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_post_companion_cancel(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
|
Json(request): Json<CompanionCancelRequest>,
|
||||||
|
) -> ApiResult<Json<CompanionMessageResponse>> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
post_companion_cancel(State(api), Json(request)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_create_runtime_worker(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedRuntimePath>,
|
||||||
|
Json(request): Json<WorkerSpawnRequest>,
|
||||||
|
) -> ApiResult<Json<WorkerSpawnResult>> {
|
||||||
|
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<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedRuntimePath>,
|
||||||
|
Json(request): Json<RuntimeConfigBundleSyncRequest>,
|
||||||
|
) -> ApiResult<Json<ConfigBundleSyncResult>> {
|
||||||
|
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<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedConfigBundlePath>,
|
||||||
|
Query(query): Query<RuntimeConfigBundleAvailabilityQuery>,
|
||||||
|
) -> ApiResult<Json<ConfigBundleCheckResult>> {
|
||||||
|
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<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedRuntimeWorkerPath>,
|
||||||
|
) -> ApiResult<Json<WorkerSummary>> {
|
||||||
|
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<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedRuntimeWorkerPath>,
|
||||||
|
Json(request): Json<WorkerInputRequest>,
|
||||||
|
) -> ApiResult<Json<WorkerInputResult>> {
|
||||||
|
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<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedRuntimeWorkerPath>,
|
||||||
|
Json(request): Json<WorkerLifecycleRequest>,
|
||||||
|
) -> ApiResult<Json<WorkerLifecycleResult>> {
|
||||||
|
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<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedRuntimeWorkerPath>,
|
||||||
|
Json(request): Json<WorkerLifecycleRequest>,
|
||||||
|
) -> ApiResult<Json<WorkerLifecycleResult>> {
|
||||||
|
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<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedRuntimeWorkerPath>,
|
||||||
|
Query(query): Query<TranscriptQuery>,
|
||||||
|
) -> ApiResult<Json<WorkerTranscriptProjection>> {
|
||||||
|
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<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedRuntimeWorkerPath>,
|
||||||
|
Query(query): Query<ClientWorkerEventsWsQuery>,
|
||||||
|
) -> 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<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedHostPath>,
|
||||||
|
) -> ApiResult<Json<RuntimeListResponse<WorkerSummary>>> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
list_host_workers(State(api), AxumPath(path.host_id)).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_workspace(State(api): State<WorkspaceApi>) -> ApiResult<Json<WorkspaceResponse>> {
|
async fn get_workspace(State(api): State<WorkspaceApi>) -> ApiResult<Json<WorkspaceResponse>> {
|
||||||
let schema_version = api.store.schema_version().await?;
|
let schema_version = api.store.schema_version().await?;
|
||||||
let stored = api.store.get_workspace(api.workspace_id()).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 runtime_id = worker.runtime_id.clone();
|
||||||
let worker_id = worker.worker_id.clone();
|
let worker_id = worker.worker_id.clone();
|
||||||
|
let workspace_id = api.workspace_id().to_string();
|
||||||
let console_href = format!(
|
let console_href = format!(
|
||||||
"/runtimes/{}/workers/{}/console",
|
"/w/{}/runtimes/{}/workers/{}/console",
|
||||||
|
encode_path_segment(&workspace_id),
|
||||||
encode_path_segment(&runtime_id),
|
encode_path_segment(&runtime_id),
|
||||||
encode_path_segment(&worker_id)
|
encode_path_segment(&worker_id)
|
||||||
);
|
);
|
||||||
Ok(Json(BrowserCreateWorkerResponse {
|
Ok(Json(BrowserCreateWorkerResponse {
|
||||||
workspace_id: api.config.workspace_id,
|
workspace_id,
|
||||||
runtime_id,
|
runtime_id,
|
||||||
worker_id,
|
worker_id,
|
||||||
console_href,
|
console_href,
|
||||||
|
|
@ -2263,6 +2731,18 @@ async fn static_or_spa_fallback(State(api): State<WorkspaceApi>, uri: Uri) -> Re
|
||||||
.into_response();
|
.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 {
|
let Some(static_root) = api.config.static_assets_dir.as_ref() else {
|
||||||
return StatusCode::NOT_FOUND.into_response();
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
};
|
};
|
||||||
|
|
@ -2279,6 +2759,40 @@ async fn static_or_spa_fallback(State(api): State<WorkspaceApi>, uri: Uri) -> Re
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn unscoped_workspace_ui_redirect(
|
||||||
|
path: &str,
|
||||||
|
query: Option<&str>,
|
||||||
|
workspace_id: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
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 {
|
struct StaticAsset {
|
||||||
bytes: Vec<u8>,
|
bytes: Vec<u8>,
|
||||||
content_type: &'static str,
|
content_type: &'static str,
|
||||||
|
|
@ -2337,6 +2851,18 @@ fn content_type_for(path: &Path) -> &'static str {
|
||||||
|
|
||||||
type ApiResult<T> = std::result::Result<T, ApiError>;
|
type ApiResult<T> = std::result::Result<T, ApiError>;
|
||||||
|
|
||||||
|
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 {
|
struct ApiError {
|
||||||
error: Error,
|
error: Error,
|
||||||
diagnostics: Vec<RuntimeDiagnostic>,
|
diagnostics: Vec<RuntimeDiagnostic>,
|
||||||
|
|
@ -2366,7 +2892,8 @@ impl IntoResponse for ApiError {
|
||||||
| Error::UnknownHost(_)
|
| Error::UnknownHost(_)
|
||||||
| Error::UnknownRuntime(_)
|
| Error::UnknownRuntime(_)
|
||||||
| Error::UnknownWorker { .. }
|
| Error::UnknownWorker { .. }
|
||||||
| Error::UnknownRepository(_) => StatusCode::NOT_FOUND,
|
| Error::UnknownRepository(_)
|
||||||
|
| Error::WorkspaceIdMismatch => StatusCode::NOT_FOUND,
|
||||||
Error::Ticket(_) => StatusCode::NOT_FOUND,
|
Error::Ticket(_) => StatusCode::NOT_FOUND,
|
||||||
Error::RuntimeCapabilityUnsupported { .. } => StatusCode::NOT_IMPLEMENTED,
|
Error::RuntimeCapabilityUnsupported { .. } => StatusCode::NOT_IMPLEMENTED,
|
||||||
Error::RuntimeOperationFailed { code, .. } if code == "repository_not_configured" => {
|
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;
|
let tickets = get_json(app.clone(), "/api/tickets").await;
|
||||||
assert_eq!(tickets["items"][0]["id"], "00000000001J2");
|
assert_eq!(tickets["items"][0]["id"], "00000000001J2");
|
||||||
assert_eq!(tickets["items"][0]["state"], "ready");
|
assert_eq!(tickets["items"][0]["state"], "ready");
|
||||||
|
|
@ -3083,6 +3655,18 @@ mod tests {
|
||||||
let objectives = get_json(app.clone(), "/api/objectives").await;
|
let objectives = get_json(app.clone(), "/api/objectives").await;
|
||||||
assert_eq!(objectives["items"][0]["id"], "00000000001J3");
|
assert_eq!(objectives["items"][0]["id"], "00000000001J3");
|
||||||
assert_eq!(objectives["items"][0]["summary"], "Objective body.");
|
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;
|
let repositories = get_json(app.clone(), "/api/repositories").await;
|
||||||
assert_eq!(repositories["items"][0]["id"], TEST_REPOSITORY_ID);
|
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;
|
let repository_detail = get_json(app.clone(), "/api/repositories/main").await;
|
||||||
assert_eq!(repository_detail["item"]["id"], TEST_REPOSITORY_ID);
|
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;
|
let repository_log = get_json(app.clone(), "/api/repositories/main/log?limit=3").await;
|
||||||
assert_eq!(repository_log["repository_id"], TEST_REPOSITORY_ID);
|
assert_eq!(repository_log["repository_id"], TEST_REPOSITORY_ID);
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
"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",
|
"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",
|
"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",
|
"build": "deno run -A npm:vite@7.2.7 build",
|
||||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
55
web/workspace/src/lib/workspace-api/http.test.ts
Normal file
55
web/workspace/src/lib/workspace-api/http.test.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { workspaceApiPath, workspaceRoute } from "./http.ts";
|
||||||
|
|
||||||
|
declare const Deno: {
|
||||||
|
test(name: string, fn: () => Promise<void> | void): void;
|
||||||
|
readTextFile(path: string | URL): Promise<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function assertEquals<T>(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<WorkspaceResponse>(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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -3,6 +3,19 @@ export type ApiResult<T> = {
|
||||||
error: string | null;
|
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<T>(
|
export async function loadJson<T>(
|
||||||
fetchFn: typeof fetch,
|
fetchFn: typeof fetch,
|
||||||
path: string,
|
path: string,
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,8 @@ Deno.test("workerConsoleHref encodes runtime and worker target authority", () =>
|
||||||
workerConsoleHref({
|
workerConsoleHref({
|
||||||
runtime_id: "local runtime",
|
runtime_id: "local runtime",
|
||||||
worker_id: "worker/one",
|
worker_id: "worker/one",
|
||||||
}) ===
|
}, "workspace-1") ===
|
||||||
"/runtimes/local%20runtime/workers/worker%2Fone/console",
|
"/w/workspace-1/runtimes/local%20runtime/workers/worker%2Fone/console",
|
||||||
"href should contain encoded runtime_id and worker_id segments",
|
"href should contain encoded runtime_id and worker_id segments",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import type {
|
||||||
Segment,
|
Segment,
|
||||||
} from "$lib/generated/protocol";
|
} from "$lib/generated/protocol";
|
||||||
import type { WorkerTranscriptItem } from "$lib/workspace-sidebar/types";
|
import type { WorkerTranscriptItem } from "$lib/workspace-sidebar/types";
|
||||||
|
import { workspaceRoute } from "$lib/workspace-api/http";
|
||||||
|
|
||||||
export type ConsoleLineKind =
|
export type ConsoleLineKind =
|
||||||
| "user"
|
| "user"
|
||||||
|
|
@ -40,16 +41,23 @@ export type WorkerTarget = {
|
||||||
worker_id: string;
|
worker_id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function workerConsoleHref(target: WorkerTarget): string {
|
export function workerConsoleHref(target: WorkerTarget, workspaceId: string): string {
|
||||||
return `/runtimes/${encodeURIComponent(target.runtime_id)}/workers/${
|
return workspaceRoute(
|
||||||
encodeURIComponent(
|
workspaceId,
|
||||||
target.worker_id,
|
`/runtimes/${encodeURIComponent(target.runtime_id)}/workers/${
|
||||||
)
|
encodeURIComponent(
|
||||||
}/console`;
|
target.worker_id,
|
||||||
|
)
|
||||||
|
}/console`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function workerConsolePath(runtimeId: string, workerId: string): string {
|
export function workerConsolePath(
|
||||||
return workerConsoleHref({ runtime_id: runtimeId, worker_id: workerId });
|
workspaceId: string,
|
||||||
|
runtimeId: string,
|
||||||
|
workerId: string,
|
||||||
|
): string {
|
||||||
|
return workerConsoleHref({ runtime_id: runtimeId, worker_id: workerId }, workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initialConsoleLines(items: WorkerTranscriptItem[]): ConsoleLine[] {
|
export function initialConsoleLines(items: WorkerTranscriptItem[]): ConsoleLine[] {
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
Deno.test("workspace Worker list and sidebar attach through Worker Console hrefs", async () => {
|
||||||
const workspacePage = await Deno.readTextFile(
|
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(
|
const workersNav = await Deno.readTextFile(
|
||||||
new URL("../workspace-sidebar/WorkersNavSection.svelte", import.meta.url),
|
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(
|
assert(
|
||||||
workspacePage.includes("workerConsoleHref(worker)") &&
|
workspacePage.includes("workerConsoleHref(worker, workspaceId)") &&
|
||||||
workspacePage.includes("Open Console"),
|
workspacePage.includes("Open Console"),
|
||||||
"top Worker list should expose an attach action per Worker",
|
"top Worker list should expose an attach action per Worker",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
workersNav.includes("workerConsoleHref(worker)") &&
|
workersNav.includes("workerConsoleHref(worker, workspaceId)") &&
|
||||||
workersNav.includes("aria-current"),
|
workersNav.includes("aria-current"),
|
||||||
"Workers sidebar rows should link to the Worker target Console route",
|
"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 () => {
|
Deno.test("Worker Console page is routed by runtime_id and worker_id through backend APIs", async () => {
|
||||||
const consolePage = await Deno.readTextFile(
|
const consolePage = await Deno.readTextFile(
|
||||||
new URL(
|
new URL(
|
||||||
"./../../routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
|
"./../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
|
||||||
import.meta.url,
|
import.meta.url,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const routeLoad = await Deno.readTextFile(
|
const routeLoad = await Deno.readTextFile(
|
||||||
new URL(
|
new URL(
|
||||||
"./../../routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.ts",
|
"./../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.ts",
|
||||||
import.meta.url,
|
import.meta.url,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
routeLoad.includes("runtimeId") && routeLoad.includes("workerId"),
|
routeLoad.includes("workspaceId") &&
|
||||||
"route load should expose both target ids",
|
routeLoad.includes("runtimeId") && routeLoad.includes("workerId"),
|
||||||
|
"route load should expose workspace and target ids",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
consolePage.includes(
|
consolePage.includes("workspaceApiPath(workspaceId, path)") &&
|
||||||
"/api/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}",
|
consolePage.includes(
|
||||||
),
|
"workerApiPath(`/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}`)",
|
||||||
"Worker detail should use the backend Worker detail API",
|
),
|
||||||
|
"Worker detail should use the scoped backend Worker detail API",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
consolePage.includes("/transcript?limit=200") &&
|
consolePage.includes("/transcript?limit=200") &&
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { workspaceRoute } from '$lib/workspace-api/http';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
currentPath?: string;
|
currentPath?: string;
|
||||||
|
workspaceId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { currentPath = '/' }: Props = $props();
|
let { currentPath = '/', workspaceId }: Props = $props();
|
||||||
|
let objectivesHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/objectives') : '/objectives');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="nav-section">
|
<section class="nav-section">
|
||||||
|
|
@ -11,7 +15,7 @@
|
||||||
<span>Objectives</span>
|
<span>Objectives</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<a class="objective-link" class:active={currentPath.startsWith('/objectives')} href="/objectives">
|
<a class="objective-link" class:active={currentPath.startsWith(objectivesHref)} href={objectivesHref}>
|
||||||
<span class="item-title">Open Objectives</span>
|
<span class="item-title">Open Objectives</span>
|
||||||
<span class="item-meta">workspace objectives</span>
|
<span class="item-meta">workspace objectives</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,11 @@
|
||||||
repositories: RepositoryListResponse | null;
|
repositories: RepositoryListResponse | null;
|
||||||
repositoriesError?: string | null;
|
repositoriesError?: string | null;
|
||||||
currentPath?: string;
|
currentPath?: string;
|
||||||
|
workspaceId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { repositories, repositoriesError = null, currentPath = '/' }: Props = $props();
|
let { repositories, repositoriesError = null, currentPath = '/', workspaceId }: Props = $props();
|
||||||
let navigation = $derived(projectRepositoryNav(repositories, currentPath));
|
let navigation = $derived(projectRepositoryNav(repositories, currentPath, workspaceId));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="nav-section" aria-labelledby="repositories-heading">
|
<section class="nav-section" aria-labelledby="repositories-heading">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { workspaceApiPath } from '$lib/workspace-api/http';
|
||||||
import { workerConsoleHref } from '$lib/workspace-console/model';
|
import { workerConsoleHref } from '$lib/workspace-console/model';
|
||||||
import { buildBrowserCreateWorkerRequest, defaultWorkerLaunchForm } from './worker-launch';
|
import { buildBrowserCreateWorkerRequest, defaultWorkerLaunchForm } from './worker-launch';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -12,9 +13,14 @@
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
currentPath?: string;
|
currentPath?: string;
|
||||||
|
workspaceId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { currentPath = '/' }: Props = $props();
|
let { currentPath = '/', workspaceId }: Props = $props();
|
||||||
|
|
||||||
|
function workerApiPath(path: string): string {
|
||||||
|
return workspaceApiPath(workspaceId, path);
|
||||||
|
}
|
||||||
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
|
|
@ -31,6 +37,13 @@
|
||||||
let initialText = $state('');
|
let initialText = $state('');
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
if (!workspaceId) {
|
||||||
|
loading = false;
|
||||||
|
workers = [];
|
||||||
|
options = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
void loadWorkers(controller.signal);
|
void loadWorkers(controller.signal);
|
||||||
void loadLaunchOptions(controller.signal);
|
void loadLaunchOptions(controller.signal);
|
||||||
|
|
@ -42,7 +55,7 @@
|
||||||
error = null;
|
error = null;
|
||||||
placeholder = null;
|
placeholder = null;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/workers', { signal });
|
const response = await fetch(workerApiPath('/workers'), { signal });
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
workers = [];
|
workers = [];
|
||||||
placeholder = 'Worker API is not integrated in this build yet.';
|
placeholder = 'Worker API is not integrated in this build yet.';
|
||||||
|
|
@ -72,7 +85,7 @@
|
||||||
async function loadLaunchOptions(signal?: AbortSignal) {
|
async function loadLaunchOptions(signal?: AbortSignal) {
|
||||||
optionsError = null;
|
optionsError = null;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/workers/launch-options', { signal });
|
const response = await fetch(workerApiPath('/workers/launch-options'), { signal });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`worker launch options failed (${response.status})`);
|
throw new Error(`worker launch options failed (${response.status})`);
|
||||||
}
|
}
|
||||||
|
|
@ -96,10 +109,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createWorker() {
|
async function createWorker() {
|
||||||
|
if (!workspaceId) {
|
||||||
|
submitError = 'workspace id is unavailable';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
submitError = null;
|
submitError = null;
|
||||||
submitting = true;
|
submitting = true;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/workers', {
|
const response = await fetch(workerApiPath('/workers'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify(buildBrowserCreateWorkerRequest({
|
body: JSON.stringify(buildBrowserCreateWorkerRequest({
|
||||||
|
|
@ -207,7 +225,7 @@
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="nav-list" aria-label="Workers">
|
<ul class="nav-list" aria-label="Workers">
|
||||||
{#each workers as worker (`${worker.runtime_id}:${worker.worker_id}`)}
|
{#each workers as worker (`${worker.runtime_id}:${worker.worker_id}`)}
|
||||||
{@const href = workerConsoleHref(worker)}
|
{@const href = workerConsoleHref(worker, workspaceId)}
|
||||||
<li>
|
<li>
|
||||||
<a href={href} class="nav-item worker-nav-item" class:active={currentPath === href} aria-current={currentPath === href ? 'page' : undefined}>
|
<a href={href} class="nav-item worker-nav-item" class:active={currentPath === href} aria-current={currentPath === href ? 'page' : undefined}>
|
||||||
<span class="worker-title-row">
|
<span class="worker-title-row">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { workspaceRoute } from '$lib/workspace-api/http';
|
||||||
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
|
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
|
||||||
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
|
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
|
||||||
import WorkersNavSection from './WorkersNavSection.svelte';
|
import WorkersNavSection from './WorkersNavSection.svelte';
|
||||||
|
|
@ -23,6 +24,10 @@
|
||||||
collapsed = false,
|
collapsed = false,
|
||||||
onToggleCollapsed
|
onToggleCollapsed
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
let workspaceId = $derived(workspace?.workspace_id ?? '');
|
||||||
|
let homeHref = $derived(workspaceId ? workspaceRoute(workspaceId) : '/');
|
||||||
|
let settingsHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/settings') : '/settings');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
|
|
@ -70,7 +75,7 @@
|
||||||
<div class="sidebar-actions-row">
|
<div class="sidebar-actions-row">
|
||||||
<a
|
<a
|
||||||
class="sidebar-icon-button"
|
class="sidebar-icon-button"
|
||||||
href="/"
|
href={homeHref}
|
||||||
aria-label="Open workspace overview"
|
aria-label="Open workspace overview"
|
||||||
title="Workspace overview"
|
title="Workspace overview"
|
||||||
>
|
>
|
||||||
|
|
@ -83,7 +88,7 @@
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="sidebar-icon-button"
|
class="sidebar-icon-button"
|
||||||
href="/settings"
|
href={settingsHref}
|
||||||
aria-label="Open Settings / Admin"
|
aria-label="Open Settings / Admin"
|
||||||
title="Settings / Admin"
|
title="Settings / Admin"
|
||||||
>
|
>
|
||||||
|
|
@ -99,9 +104,9 @@
|
||||||
|
|
||||||
{#if !collapsed}
|
{#if !collapsed}
|
||||||
<nav class="sidebar-sections" aria-label="Workspace sections">
|
<nav class="sidebar-sections" aria-label="Workspace sections">
|
||||||
<RepositoriesNavSection {repositories} {repositoriesError} {currentPath} />
|
<RepositoriesNavSection {repositories} {repositoriesError} {currentPath} {workspaceId} />
|
||||||
<ObjectivesNavSection {currentPath} />
|
<ObjectivesNavSection {currentPath} {workspaceId} />
|
||||||
<WorkersNavSection {currentPath} />
|
<WorkersNavSection {currentPath} {workspaceId} />
|
||||||
</nav>
|
</nav>
|
||||||
{/if}
|
{/if}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ Deno.test("repository nav does not invent main for an empty registry", () => {
|
||||||
message: "No repositories configured",
|
message: "No repositories configured",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
}, "/w/workspace-1", "workspace-1");
|
||||||
|
|
||||||
assertEquals(projection.count, 0);
|
assertEquals(projection.count, 0);
|
||||||
assertEquals(projection.items, []);
|
assertEquals(projection.items, []);
|
||||||
|
|
@ -56,14 +56,15 @@ Deno.test("repository nav links configured non-main repository ids", () => {
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
"/repositories/infra",
|
"/w/workspace-1/repositories/infra",
|
||||||
|
"workspace-1",
|
||||||
);
|
);
|
||||||
|
|
||||||
assertEquals(projection.count, 1);
|
assertEquals(projection.count, 1);
|
||||||
assertEquals(projection.items[0], {
|
assertEquals(projection.items[0], {
|
||||||
id: "infra",
|
id: "infra",
|
||||||
title: "Infrastructure",
|
title: "Infrastructure",
|
||||||
href: "/repositories/infra",
|
href: "/w/workspace-1/repositories/infra",
|
||||||
meta: "git repository · read-only",
|
meta: "git repository · read-only",
|
||||||
active: true,
|
active: true,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { workspaceRoute } from "$lib/workspace-api/http";
|
||||||
import type { Diagnostic, RepositoryListResponse } from "./types";
|
import type { Diagnostic, RepositoryListResponse } from "./types";
|
||||||
|
|
||||||
export type RepositoryNavItem = {
|
export type RepositoryNavItem = {
|
||||||
|
|
@ -16,14 +17,15 @@ export type RepositoryNavProjection = {
|
||||||
|
|
||||||
export function projectRepositoryNav(
|
export function projectRepositoryNav(
|
||||||
repositories: RepositoryListResponse | null,
|
repositories: RepositoryListResponse | null,
|
||||||
currentPath = "/",
|
currentPath: string,
|
||||||
|
workspaceId: string,
|
||||||
): RepositoryNavProjection {
|
): RepositoryNavProjection {
|
||||||
const summaries = repositories?.items ?? [];
|
const summaries = repositories?.items ?? [];
|
||||||
return {
|
return {
|
||||||
count: summaries.length,
|
count: summaries.length,
|
||||||
diagnostics: repositories?.diagnostics ?? [],
|
diagnostics: repositories?.diagnostics ?? [],
|
||||||
items: summaries.map((repository) => {
|
items: summaries.map((repository) => {
|
||||||
const href = `/repositories/${encodeURIComponent(repository.id)}`;
|
const href = workspaceRoute(workspaceId, `/repositories/${encodeURIComponent(repository.id)}`);
|
||||||
return {
|
return {
|
||||||
id: repository.id,
|
id: repository.id,
|
||||||
title: repository.display_name || repository.id,
|
title: repository.display_name || repository.id,
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,36 @@
|
||||||
import { loadJson } from "$lib/workspace-api/http";
|
import { redirect } from "@sveltejs/kit";
|
||||||
import type {
|
import { loadJson, workspaceApiPath, workspaceRoute } from "$lib/workspace-api/http";
|
||||||
RepositoryListResponse,
|
import type { RepositoryListResponse, WorkspaceResponse } from "$lib/workspace-sidebar/types";
|
||||||
WorkspaceResponse,
|
|
||||||
} from "$lib/workspace-sidebar/types";
|
|
||||||
import type { LayoutLoad } from "./$types";
|
import type { LayoutLoad } from "./$types";
|
||||||
|
|
||||||
export const ssr = false;
|
export const ssr = false;
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
export const load: LayoutLoad = async ({ fetch }) => {
|
export const load: LayoutLoad = async ({ fetch, params, url }) => {
|
||||||
|
const workspaceId = params.workspaceId;
|
||||||
|
|
||||||
|
if (!workspaceId) {
|
||||||
|
const workspace = await loadJson<WorkspaceResponse>(fetch, "/api/workspace");
|
||||||
|
if (workspace.data) {
|
||||||
|
const scopedPath = workspaceRoute(
|
||||||
|
workspace.data.workspace_id,
|
||||||
|
url.pathname === "/" ? "" : url.pathname,
|
||||||
|
);
|
||||||
|
throw redirect(307, `${scopedPath}${url.search}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
workspace: null,
|
||||||
|
workspaceError: workspace.error,
|
||||||
|
repositories: null,
|
||||||
|
repositoriesError: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiPath = (path: string) => workspaceApiPath(workspaceId, path);
|
||||||
|
|
||||||
const [workspace, repositories] = await Promise.all([
|
const [workspace, repositories] = await Promise.all([
|
||||||
loadJson<WorkspaceResponse>(fetch, "/api/workspace"),
|
loadJson<WorkspaceResponse>(fetch, apiPath("/workspace")),
|
||||||
loadJson<RepositoryListResponse>(fetch, "/api/repositories"),
|
loadJson<RepositoryListResponse>(fetch, apiPath("/repositories")),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,132 +1,6 @@
|
||||||
<script lang="ts">
|
<main class="workspace-panel-shell">
|
||||||
import { workerConsoleHref } from '$lib/workspace-console/model';
|
<section class="workspace-card">
|
||||||
import type { PageProps } from './$types';
|
<h1>Redirecting to scoped workspace…</h1>
|
||||||
|
<p class="section-note">This compatibility route bootstraps the current workspace id and redirects to the canonical <code>/w/<workspace-id></code> route.</p>
|
||||||
let { data }: PageProps = $props();
|
</section>
|
||||||
</script>
|
</main>
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>Yoi Workspace Control Plane</title>
|
|
||||||
<meta name="description" content="Local single-workspace Yoi control plane bootstrap" />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<section class="card">
|
|
||||||
<h2>Workspace</h2>
|
|
||||||
{#if data.workspace}
|
|
||||||
<dl>
|
|
||||||
<div>
|
|
||||||
<dt>ID</dt>
|
|
||||||
<dd>{data.workspace.workspace_id}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Name</dt>
|
|
||||||
<dd>{data.workspace.display_name}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Record authority</dt>
|
|
||||||
<dd>{data.workspace.record_authority}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Host / Worker bridge</dt>
|
|
||||||
<dd>{data.workspace.extension_points.host_worker_bridge.status}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
{:else if data.workspaceError}
|
|
||||||
<p class="error">{data.workspaceError}</p>
|
|
||||||
{:else}
|
|
||||||
<p>Waiting for <code>/api/workspace</code>…</p>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="grid runtime">
|
|
||||||
<div class="card">
|
|
||||||
<h2>Hosts</h2>
|
|
||||||
{#if data.hosts}
|
|
||||||
{#if data.hosts.items.length === 0}
|
|
||||||
<p>No local Hosts are visible.</p>
|
|
||||||
{:else}
|
|
||||||
<div class="stack">
|
|
||||||
{#each data.hosts.items as host}
|
|
||||||
<article class="runtime-card">
|
|
||||||
<div class="runtime-heading">
|
|
||||||
<strong>{host.label}</strong>
|
|
||||||
<span class:warn={host.status !== 'available'}>{host.status}</span>
|
|
||||||
</div>
|
|
||||||
<dl>
|
|
||||||
<div>
|
|
||||||
<dt>ID</dt>
|
|
||||||
<dd><code>{host.host_id}</code></dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Kind</dt>
|
|
||||||
<dd>{host.kind}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Runtime</dt>
|
|
||||||
<dd><code>{host.runtime_id}</code></dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Scope</dt>
|
|
||||||
<dd>{host.capabilities.workspace_scope}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Platform</dt>
|
|
||||||
<dd>{host.capabilities.os} / {host.capabilities.arch}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</article>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{:else if data.hostsError}
|
|
||||||
<p class="error">{data.hostsError}</p>
|
|
||||||
{:else}
|
|
||||||
<p>Waiting for <code>/api/hosts</code>…</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>Workers</h2>
|
|
||||||
{#if data.workers}
|
|
||||||
{#if data.workers.items.length === 0}
|
|
||||||
<p>No local Workers are visible.</p>
|
|
||||||
{:else}
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Worker</th>
|
|
||||||
<th>Host</th>
|
|
||||||
<th>State</th>
|
|
||||||
<th>Workspace</th>
|
|
||||||
<th>Implementation</th>
|
|
||||||
<th>Attach</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each data.workers.items as worker}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<strong>{worker.label}</strong>
|
|
||||||
{#if worker.role || worker.profile}
|
|
||||||
<small>{worker.role ?? 'role unknown'} / {worker.profile ?? 'profile unknown'}</small>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td><code>{worker.host_id}</code></td>
|
|
||||||
<td>{worker.state} · {worker.status}</td>
|
|
||||||
<td>{worker.workspace.visibility} · {worker.workspace.identity}</td>
|
|
||||||
<td>{worker.implementation.kind}</td>
|
|
||||||
<td><a class="inline-link" href={workerConsoleHref(worker)}>Open Console</a></td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{:else if data.workersError}
|
|
||||||
<p class="error">{data.workersError}</p>
|
|
||||||
{:else}
|
|
||||||
<p>Waiting for <code>/api/workers</code>…</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,3 @@
|
||||||
import { loadJson } from "$lib/workspace-api/http";
|
|
||||||
import type { Host, ListResponse, Worker } from "$lib/workspace-sidebar/types";
|
|
||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
export const load: PageLoad = async ({ fetch }) => {
|
export const load: PageLoad = async () => ({});
|
||||||
const [hosts, workers] = await Promise.all([
|
|
||||||
loadJson<ListResponse<Host>>(fetch, "/api/hosts"),
|
|
||||||
loadJson<ListResponse<Worker>>(fetch, "/api/workers"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
hosts: hosts.data,
|
|
||||||
hostsError: hosts.error,
|
|
||||||
workers: workers.data,
|
|
||||||
workersError: workers.error,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,6 @@
|
||||||
<script lang="ts">
|
<main class="objectives-page">
|
||||||
import { formatDate } from '$lib/workspace-api/http';
|
<section class="workspace-card">
|
||||||
import type { PageProps } from './$types';
|
<h1>Redirecting to scoped objectives…</h1>
|
||||||
|
<p class="section-note">Objectives are available at the canonical <code>/w/<workspace-id>/objectives</code> route.</p>
|
||||||
let { data }: PageProps = $props();
|
</section>
|
||||||
</script>
|
</main>
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>Objectives · Yoi Workspace</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<section class="card">
|
|
||||||
<h2>Objectives</h2>
|
|
||||||
<p class="section-note">Objectives are read from canonical filesystem records through <code>/api/objectives</code>.</p>
|
|
||||||
{#if data.objectives}
|
|
||||||
{#if data.objectives.items.length === 0}
|
|
||||||
<p>No Objective records are present.</p>
|
|
||||||
{:else}
|
|
||||||
<div class="objective-list">
|
|
||||||
{#each data.objectives.items as objective (objective.id)}
|
|
||||||
<a class="objective-row" href={`/objectives/${objective.id}`}>
|
|
||||||
<div class="objective-main">
|
|
||||||
<div class="objective-title-row">
|
|
||||||
<strong class="objective-title">{objective.title}</strong>
|
|
||||||
<span class="state-pill">{objective.state}</span>
|
|
||||||
</div>
|
|
||||||
<p class="objective-summary">{objective.summary || 'No summary text is available.'}</p>
|
|
||||||
</div>
|
|
||||||
<div class="objective-meta" aria-label="Objective metadata">
|
|
||||||
<span>Updated {objective.updated_at ? formatDate(objective.updated_at) : 'unknown'}</span>
|
|
||||||
<span>{objective.linked_tickets?.length ? `${objective.linked_tickets.length} linked ticket(s)` : 'No linked tickets'}</span>
|
|
||||||
<code>{objective.id}</code>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if data.objectives.invalid_records.length > 0}
|
|
||||||
<p class="error">{data.objectives.invalid_records.length} invalid objective record(s) hidden.</p>
|
|
||||||
{/if}
|
|
||||||
{:else if data.objectivesError}
|
|
||||||
<p class="error">{data.objectivesError}</p>
|
|
||||||
{:else}
|
|
||||||
<p>Waiting for <code>/api/objectives</code>…</p>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,3 @@
|
||||||
import { loadJson } from "$lib/workspace-api/http";
|
|
||||||
import type { ObjectiveListResponse } from "$lib/workspace-sidebar/types";
|
|
||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
export const load: PageLoad = async ({ fetch }) => {
|
export const load: PageLoad = async () => ({});
|
||||||
const objectives = await loadJson<ObjectiveListResponse>(
|
|
||||||
fetch,
|
|
||||||
"/api/objectives",
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
objectives: objectives.data,
|
|
||||||
objectivesError: objectives.error,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,76 +1,6 @@
|
||||||
<script lang="ts">
|
<main class="objective-detail-page">
|
||||||
import { formatDate } from '$lib/workspace-api/http';
|
<section class="workspace-card">
|
||||||
import type { PageProps } from './$types';
|
<h1>Redirecting to scoped objective…</h1>
|
||||||
|
<p class="section-note">Objective detail routes are canonical under <code>/w/<workspace-id>/objectives/<objective-id></code>.</p>
|
||||||
let { data }: PageProps = $props();
|
</section>
|
||||||
</script>
|
</main>
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>{data.objective?.title ?? data.objectiveId} · Objective</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<section class="card">
|
|
||||||
<h2>Objectives</h2>
|
|
||||||
{#if data.objectives}
|
|
||||||
<div class="objective-list compact">
|
|
||||||
{#each data.objectives.items as objective (objective.id)}
|
|
||||||
<a class="objective-row" class:active={objective.id === data.objectiveId} href={`/objectives/${objective.id}`}>
|
|
||||||
<div class="objective-main">
|
|
||||||
<div class="objective-title-row">
|
|
||||||
<strong class="objective-title">{objective.title}</strong>
|
|
||||||
<span class="state-pill">{objective.state}</span>
|
|
||||||
</div>
|
|
||||||
<p class="objective-summary">{objective.summary || 'No summary text is available.'}</p>
|
|
||||||
</div>
|
|
||||||
<div class="objective-meta" aria-label="Objective metadata">
|
|
||||||
<span>Updated {objective.updated_at ? formatDate(objective.updated_at) : 'unknown'}</span>
|
|
||||||
<code>{objective.id}</code>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else if data.objectivesError}
|
|
||||||
<p class="error">{data.objectivesError}</p>
|
|
||||||
{:else}
|
|
||||||
<p>Loading objectives…</p>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card objective-detail-card">
|
|
||||||
<h2>Objective detail</h2>
|
|
||||||
{#if data.objective}
|
|
||||||
<div class="objective-title-row detail">
|
|
||||||
<div>
|
|
||||||
<h3>{data.objective.title}</h3>
|
|
||||||
<p><code>{data.objective.id}</code></p>
|
|
||||||
</div>
|
|
||||||
<span class="state-pill">{data.objective.state}</span>
|
|
||||||
</div>
|
|
||||||
<dl>
|
|
||||||
<div>
|
|
||||||
<dt>Created</dt>
|
|
||||||
<dd>{data.objective.created_at ? formatDate(data.objective.created_at) : 'unknown'}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Updated</dt>
|
|
||||||
<dd>{data.objective.updated_at ? formatDate(data.objective.updated_at) : 'unknown'}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Record source</dt>
|
|
||||||
<dd>{data.objective.record_source}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Linked tickets</dt>
|
|
||||||
<dd>{data.objective.linked_tickets.length ? data.objective.linked_tickets.join(', ') : 'none'}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
<pre class="objective-body">{data.objective.body}</pre>
|
|
||||||
{#if data.objective.body_truncated}
|
|
||||||
<p class="error">Objective body was truncated by the Backend response limit.</p>
|
|
||||||
{/if}
|
|
||||||
{:else if data.objectiveError}
|
|
||||||
<p class="error">{data.objectiveError}</p>
|
|
||||||
{:else}
|
|
||||||
<p>Loading objective detail…</p>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,3 @@
|
||||||
import { loadJson } from "$lib/workspace-api/http";
|
|
||||||
import type {
|
|
||||||
ObjectiveDetail,
|
|
||||||
ObjectiveListResponse,
|
|
||||||
} from "$lib/workspace-sidebar/types";
|
|
||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
export const load: PageLoad = async ({ fetch, params }) => {
|
export const load: PageLoad = async () => ({});
|
||||||
const objectiveId = params.objectiveId;
|
|
||||||
const [objectives, objective] = await Promise.all([
|
|
||||||
loadJson<ObjectiveListResponse>(fetch, "/api/objectives"),
|
|
||||||
loadJson<ObjectiveDetail>(
|
|
||||||
fetch,
|
|
||||||
`/api/objectives/${encodeURIComponent(objectiveId)}`,
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
objectiveId,
|
|
||||||
objectives: objectives.data,
|
|
||||||
objectivesError: objectives.error,
|
|
||||||
objective: objective.data,
|
|
||||||
objectiveError: objective.error,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,105 +1,6 @@
|
||||||
<script lang="ts">
|
<main class="repository-detail-page">
|
||||||
import { formatDate } from '$lib/workspace-api/http';
|
<section class="workspace-card">
|
||||||
import RepositoryTicketKanban from '$lib/workspace-pages/RepositoryTicketKanban.svelte';
|
<h1>Redirecting to scoped repository…</h1>
|
||||||
import type { PageProps } from './$types';
|
<p class="section-note">Repository routes are canonical under <code>/w/<workspace-id>/repositories/<repository-id></code>.</p>
|
||||||
|
</section>
|
||||||
let { data }: PageProps = $props();
|
</main>
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>{data.repository?.item.display_name ?? data.repositoryId} · Repository</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<section class="card repository-detail-card">
|
|
||||||
<h2>Repository</h2>
|
|
||||||
{#if data.repository}
|
|
||||||
<div class="repository-detail-heading">
|
|
||||||
<div>
|
|
||||||
<h3>{data.repository.item.display_name}</h3>
|
|
||||||
<p><code>{data.repository.item.id}</code></p>
|
|
||||||
</div>
|
|
||||||
<span class="status-pill" class:warn={data.repository.item.git?.status !== 'clean'}>{data.repository.item.git?.status ?? 'not observed'}</span>
|
|
||||||
</div>
|
|
||||||
<dl>
|
|
||||||
<div>
|
|
||||||
<dt>Kind</dt>
|
|
||||||
<dd>{data.repository.item.kind}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Provider</dt>
|
|
||||||
<dd>{data.repository.item.provider}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Record authority</dt>
|
|
||||||
<dd>{data.repository.item.record_authority}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Default selector</dt>
|
|
||||||
<dd>{data.repository.item.default_selector ?? 'none configured'}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Branch</dt>
|
|
||||||
<dd>{data.repository.item.git?.branch ?? 'unknown'}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>HEAD</dt>
|
|
||||||
<dd><code>{data.repository.item.git?.head ?? 'unknown'}</code></dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Dirty</dt>
|
|
||||||
<dd>{data.repository.item.git?.dirty ? 'yes' : 'no'}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
{#if data.repository.item.diagnostics && data.repository.item.diagnostics.length > 0}
|
|
||||||
<ul class="diagnostics" aria-label="Repository diagnostics">
|
|
||||||
{#each data.repository.item.diagnostics as diagnostic}
|
|
||||||
<li><code>{diagnostic.code}</code>: {diagnostic.message}</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
{:else if data.repositoryError}
|
|
||||||
<p class="error">{data.repositoryError}</p>
|
|
||||||
{:else}
|
|
||||||
<p>Loading repository…</p>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card repository-log-card">
|
|
||||||
<h2>Recent commits</h2>
|
|
||||||
{#if data.repositoryLog}
|
|
||||||
{#if data.repositoryLog.items.length === 0}
|
|
||||||
<p>No recent commits are available.</p>
|
|
||||||
{:else}
|
|
||||||
<div class="commit-list">
|
|
||||||
{#each data.repositoryLog.items as commit}
|
|
||||||
<article class="commit-card">
|
|
||||||
<strong>{commit.summary}</strong>
|
|
||||||
<span><code>{commit.short_hash}</code> · {commit.author_name} · {formatDate(commit.author_date)}</span>
|
|
||||||
</article>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if data.repositoryLog.diagnostics.length > 0}
|
|
||||||
<ul class="diagnostics" aria-label="Repository log diagnostics">
|
|
||||||
{#each data.repositoryLog.diagnostics as diagnostic}
|
|
||||||
<li><code>{diagnostic.code}</code>: {diagnostic.message}</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
{:else if data.repositoryLogError}
|
|
||||||
<p class="error">{data.repositoryLogError}</p>
|
|
||||||
{:else}
|
|
||||||
<p>Loading repository commits…</p>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card repository-tickets-card">
|
|
||||||
<h2>Repository Tickets</h2>
|
|
||||||
{#if data.repositoryTickets}
|
|
||||||
<RepositoryTicketKanban tickets={data.repositoryTickets} />
|
|
||||||
{:else if data.repositoryTicketsError}
|
|
||||||
<p class="error">{data.repositoryTicketsError}</p>
|
|
||||||
{:else}
|
|
||||||
<p>Loading repository tickets…</p>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,3 @@
|
||||||
import { loadJson } from "$lib/workspace-api/http";
|
|
||||||
import type {
|
|
||||||
RepositoryDetailResponse,
|
|
||||||
RepositoryLogResponse,
|
|
||||||
RepositoryTicketsResponse,
|
|
||||||
} from "$lib/workspace-sidebar/types";
|
|
||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
export const load: PageLoad = async ({ fetch, params }) => {
|
export const load: PageLoad = async () => ({});
|
||||||
const repositoryId = params.repositoryId;
|
|
||||||
const [repository, log, tickets] = await Promise.all([
|
|
||||||
loadJson<RepositoryDetailResponse>(
|
|
||||||
fetch,
|
|
||||||
`/api/repositories/${encodeURIComponent(repositoryId)}`,
|
|
||||||
),
|
|
||||||
loadJson<RepositoryLogResponse>(
|
|
||||||
fetch,
|
|
||||||
`/api/repositories/${encodeURIComponent(repositoryId)}/log`,
|
|
||||||
),
|
|
||||||
loadJson<RepositoryTicketsResponse>(
|
|
||||||
fetch,
|
|
||||||
`/api/repositories/${encodeURIComponent(repositoryId)}/tickets`,
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
repositoryId,
|
|
||||||
repository: repository.data,
|
|
||||||
repositoryError: repository.error,
|
|
||||||
repositoryLog: log.data,
|
|
||||||
repositoryLogError: log.error,
|
|
||||||
repositoryTickets: tickets.data,
|
|
||||||
repositoryTicketsError: tickets.error,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,393 +1,6 @@
|
||||||
<script lang="ts">
|
<main class="worker-console-page">
|
||||||
import {
|
<section class="workspace-card">
|
||||||
projectConsole,
|
<h1>Redirecting to scoped Worker Console…</h1>
|
||||||
type ConsoleLine
|
<p class="section-note">Worker Console routes are canonical under <code>/w/<workspace-id>/runtimes/<runtime-id>/workers/<worker-id>/console</code>.</p>
|
||||||
} from '$lib/workspace-console/model';
|
</section>
|
||||||
import type {
|
</main>
|
||||||
ClientWorkerEventWsFrame,
|
|
||||||
Diagnostic,
|
|
||||||
Worker,
|
|
||||||
WorkerInputResult,
|
|
||||||
WorkerTranscriptProjection
|
|
||||||
} from '$lib/workspace-sidebar/types';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
data: {
|
|
||||||
runtimeId: string;
|
|
||||||
workerId: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
let { data }: Props = $props();
|
|
||||||
|
|
||||||
const runtimeId = $derived(data.runtimeId);
|
|
||||||
const workerId = $derived(data.workerId);
|
|
||||||
|
|
||||||
let worker = $state<Worker | null>(null);
|
|
||||||
let workerError = $state<string | null>(null);
|
|
||||||
let transcript = $state<WorkerTranscriptProjection | null>(null);
|
|
||||||
let transcriptError = $state<string | null>(null);
|
|
||||||
let draft = $state('');
|
|
||||||
let sending = $state(false);
|
|
||||||
let sendError = $state<string | null>(null);
|
|
||||||
let streamState = $state<'connecting' | 'open' | 'closed' | 'error'>('connecting');
|
|
||||||
let streamDiagnostics = $state<Diagnostic[]>([]);
|
|
||||||
let workerDetailsOpen = $state(false);
|
|
||||||
let observedEvents = $state<Array<{ cursor: string; event: ClientWorkerEventWsFrame & { kind: 'event' } }>>([]);
|
|
||||||
let nextReloadToken = 0;
|
|
||||||
let reloadToken = $state(0);
|
|
||||||
|
|
||||||
type ConsoleTarget = {
|
|
||||||
runtimeId: string;
|
|
||||||
workerId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const consoleTarget = $derived({ runtimeId, workerId });
|
|
||||||
|
|
||||||
const projection = $derived(
|
|
||||||
projectConsole(
|
|
||||||
transcript?.items ?? [],
|
|
||||||
observedEvents.map((item) => ({ cursor: item.cursor, event: item.event.envelope.payload }))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const lines = $derived(projection.lines);
|
|
||||||
const diagnostics = $derived(
|
|
||||||
mergeDiagnostics(worker?.diagnostics ?? [], transcript?.diagnostics ?? [], streamDiagnostics)
|
|
||||||
);
|
|
||||||
const canSend = $derived(Boolean(worker?.capabilities.can_accept_input) && draft.trim().length > 0 && !sending);
|
|
||||||
|
|
||||||
async function getJson<T>(path: string): Promise<T> {
|
|
||||||
const response = await fetch(path);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`GET ${path} failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
return response.json() as Promise<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function postJson<T>(path: string, body: unknown, timeoutMs = 30_000): Promise<T> {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
|
|
||||||
try {
|
|
||||||
const response = await fetch(path, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
signal: controller.signal
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
let detail = '';
|
|
||||||
try {
|
|
||||||
detail = await response.text();
|
|
||||||
} catch {
|
|
||||||
detail = '';
|
|
||||||
}
|
|
||||||
throw new Error(`POST ${path} failed: ${response.status}${detail ? ` ${detail}` : ''}`);
|
|
||||||
}
|
|
||||||
return response.json() as Promise<T>;
|
|
||||||
} finally {
|
|
||||||
window.clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadWorker(target: ConsoleTarget) {
|
|
||||||
workerError = null;
|
|
||||||
try {
|
|
||||||
worker = await getJson<Worker>(
|
|
||||||
`/api/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
workerError = error instanceof Error ? error.message : String(error);
|
|
||||||
worker = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadTranscript(target: ConsoleTarget) {
|
|
||||||
transcriptError = null;
|
|
||||||
try {
|
|
||||||
transcript = await getJson<WorkerTranscriptProjection>(
|
|
||||||
`/api/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}/transcript?limit=200`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
transcriptError = error instanceof Error ? error.message : String(error);
|
|
||||||
transcript = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadConsoleData(target: ConsoleTarget) {
|
|
||||||
await Promise.all([loadWorker(target), loadTranscript(target)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function advanceReloadToken(): number {
|
|
||||||
nextReloadToken += 1;
|
|
||||||
reloadToken = nextReloadToken;
|
|
||||||
return nextReloadToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendMessage(event: SubmitEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
const content = draft.trim();
|
|
||||||
if (!content || sending || !worker?.capabilities.can_accept_input) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
sending = true;
|
|
||||||
sendError = null;
|
|
||||||
try {
|
|
||||||
const result = await postJson<WorkerInputResult>(
|
|
||||||
`/api/runtimes/${encodeURIComponent(runtimeId)}/workers/${encodeURIComponent(workerId)}/input`,
|
|
||||||
{ kind: 'user', content }
|
|
||||||
);
|
|
||||||
if (result.state === 'accepted') {
|
|
||||||
draft = '';
|
|
||||||
} else {
|
|
||||||
sendError = diagnosticsToText(result.diagnostics) || `Input was ${result.state}.`;
|
|
||||||
}
|
|
||||||
await loadTranscript(consoleTarget);
|
|
||||||
} catch (error) {
|
|
||||||
sendError = error instanceof Error ? error.message : String(error);
|
|
||||||
} finally {
|
|
||||||
sending = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function connectObservation(targetWorker: Worker | null, token: number, target: ConsoleTarget) {
|
|
||||||
if (!targetWorker) {
|
|
||||||
streamState = 'closed';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
streamState = 'connecting';
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
const ws = new WebSocket(
|
|
||||||
`${protocol}//${window.location.host}/api/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(
|
|
||||||
target.workerId
|
|
||||||
)}/events/ws`
|
|
||||||
);
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
if (token === reloadToken) {
|
|
||||||
streamState = 'open';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
ws.onmessage = (message) => {
|
|
||||||
if (token !== reloadToken) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const frame = JSON.parse(String(message.data)) as ClientWorkerEventWsFrame;
|
|
||||||
if (frame.kind === 'event') {
|
|
||||||
observedEvents = [
|
|
||||||
...observedEvents,
|
|
||||||
{
|
|
||||||
cursor: frame.envelope.cursor,
|
|
||||||
event: frame
|
|
||||||
}
|
|
||||||
].slice(-500);
|
|
||||||
} else {
|
|
||||||
streamDiagnostics = [
|
|
||||||
...streamDiagnostics,
|
|
||||||
{
|
|
||||||
code: frame.diagnostic.code,
|
|
||||||
severity: 'warning',
|
|
||||||
message: frame.diagnostic.message
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
streamDiagnostics = [
|
|
||||||
...streamDiagnostics,
|
|
||||||
{
|
|
||||||
code: 'worker_observation_frame_invalid',
|
|
||||||
severity: 'warning',
|
|
||||||
message: error instanceof Error ? error.message : String(error)
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
ws.onerror = () => {
|
|
||||||
if (token === reloadToken) {
|
|
||||||
streamState = 'error';
|
|
||||||
streamDiagnostics = [
|
|
||||||
...streamDiagnostics,
|
|
||||||
{
|
|
||||||
code: 'worker_observation_ws_error',
|
|
||||||
severity: 'error',
|
|
||||||
message: 'Worker observation WebSocket failed.'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
ws.onclose = () => {
|
|
||||||
if (token === reloadToken && streamState !== 'error') {
|
|
||||||
streamState = 'closed';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return () => ws.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeDiagnostics(...groups: Diagnostic[][]): Diagnostic[] {
|
|
||||||
return groups.flat();
|
|
||||||
}
|
|
||||||
|
|
||||||
function diagnosticsToText(items: Diagnostic[]): string {
|
|
||||||
return items.map((item) => `${item.severity}: ${item.message}`).join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function lineClass(line: ConsoleLine): string {
|
|
||||||
return line.error ? 'error' : line.kind;
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const target = consoleTarget;
|
|
||||||
observedEvents = [];
|
|
||||||
streamDiagnostics = [];
|
|
||||||
advanceReloadToken();
|
|
||||||
void loadConsoleData(target);
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => connectObservation(worker, reloadToken, consoleTarget));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>Worker Console · Yoi Workspace</title>
|
|
||||||
<meta name="description" content="Worker attach console through Workspace Backend APIs" />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="console-shell worker-console-shell">
|
|
||||||
<section class="console-header card">
|
|
||||||
<div>
|
|
||||||
<h2>{worker?.label ?? workerId}</h2>
|
|
||||||
</div>
|
|
||||||
<div class="console-header-actions">
|
|
||||||
<div class="console-status-pill" class:warn={streamState !== 'open'}>
|
|
||||||
{worker?.state ?? 'unknown'} · {worker?.status ?? 'loading'} · stream {streamState}
|
|
||||||
</div>
|
|
||||||
<button type="button" class="secondary-button" aria-expanded={workerDetailsOpen} onclick={() => workerDetailsOpen = !workerDetailsOpen}>
|
|
||||||
Details
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="console-body">
|
|
||||||
<article class="card console-card worker-console-card">
|
|
||||||
{#if projection.status || projection.usage}
|
|
||||||
<p class="section-note">
|
|
||||||
{#if projection.status}status: {projection.status}{/if}
|
|
||||||
{#if projection.status && projection.usage} · {/if}
|
|
||||||
{#if projection.usage}usage: {projection.usage}{/if}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if workerError}
|
|
||||||
<p class="error">{workerError}</p>
|
|
||||||
{/if}
|
|
||||||
{#if transcriptError}
|
|
||||||
<p class="error">{transcriptError}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if lines.length === 0}
|
|
||||||
<p>No console output is available for this Worker yet.</p>
|
|
||||||
{:else}
|
|
||||||
<ol class="console-log">
|
|
||||||
{#each lines as item}
|
|
||||||
<li class:assistant={lineClass(item) === 'assistant'} class:user={lineClass(item) === 'user'} class:system={lineClass(item) !== 'assistant' && lineClass(item) !== 'user'} class:error-line={item.error}>
|
|
||||||
{#if lineClass(item) !== 'assistant' && lineClass(item) !== 'user'}
|
|
||||||
<div class="message-heading">
|
|
||||||
<span>{item.title}</span>
|
|
||||||
{#if item.streaming}<small>streaming</small>{/if}
|
|
||||||
</div>
|
|
||||||
{:else if item.streaming}
|
|
||||||
<div class="message-heading streaming-heading">
|
|
||||||
<small>streaming</small>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<pre>{item.body || '—'}</pre>
|
|
||||||
{#if item.detail}
|
|
||||||
<details class="message-detail">
|
|
||||||
<summary>detail</summary>
|
|
||||||
<p>{item.detail}</p>
|
|
||||||
</details>
|
|
||||||
{/if}
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ol>
|
|
||||||
{/if}
|
|
||||||
</article>
|
|
||||||
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{#if workerDetailsOpen}
|
|
||||||
<aside class="console-side-panel" aria-label="Worker detail">
|
|
||||||
<header class="side-panel-header">
|
|
||||||
<h3>Worker detail</h3>
|
|
||||||
<button type="button" class="secondary-button" onclick={() => workerDetailsOpen = false}>Close</button>
|
|
||||||
</header>
|
|
||||||
{#if worker}
|
|
||||||
<dl>
|
|
||||||
<div>
|
|
||||||
<dt>Runtime</dt>
|
|
||||||
<dd><code>{worker.runtime_id}</code></dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Worker</dt>
|
|
||||||
<dd><code>{worker.worker_id}</code></dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Host</dt>
|
|
||||||
<dd><code>{worker.host_id}</code></dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Role / profile</dt>
|
|
||||||
<dd>{worker.role ?? 'unknown'} / {worker.profile ?? 'unknown'}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Workspace</dt>
|
|
||||||
<dd>{worker.workspace.visibility} · {worker.workspace.identity}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Implementation</dt>
|
|
||||||
<dd>{worker.implementation.kind} · {worker.implementation.display_hint}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
<details class="metadata-details">
|
|
||||||
<summary>Capabilities</summary>
|
|
||||||
<ul>
|
|
||||||
<li>input: {worker.capabilities.can_accept_input ? 'available' : 'unsupported'}</li>
|
|
||||||
<li>stop: {worker.capabilities.can_stop ? 'available' : 'unsupported'}</li>
|
|
||||||
<li>follow-up spawn: {worker.capabilities.can_spawn_followup ? 'available' : 'unsupported'}</li>
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
{:else if !workerError}
|
|
||||||
<p>Loading Worker detail…</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if diagnostics.length > 0}
|
|
||||||
<details class="metadata-details" open={streamState === 'error'}>
|
|
||||||
<summary>Diagnostics ({diagnostics.length})</summary>
|
|
||||||
<ul>
|
|
||||||
{#each diagnostics as diagnostic}
|
|
||||||
<li>
|
|
||||||
<strong>{diagnostic.severity}</strong>
|
|
||||||
<code>{diagnostic.code}</code>
|
|
||||||
<span>{diagnostic.message}</span>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
{/if}
|
|
||||||
</aside>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<form class="console-composer card" onsubmit={sendMessage}>
|
|
||||||
<textarea
|
|
||||||
id="worker-console-message"
|
|
||||||
aria-label="Console input"
|
|
||||||
bind:value={draft}
|
|
||||||
disabled={!worker?.capabilities.can_accept_input || sending}
|
|
||||||
></textarea>
|
|
||||||
<div class="composer-actions">
|
|
||||||
<button type="submit" disabled={!canSend}>{sending ? 'Sending…' : 'Send'}</button>
|
|
||||||
{#if sendError}<p class="error">{sendError}</p>{/if}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,3 @@
|
||||||
export function load(
|
import type { PageLoad } from "./$types";
|
||||||
{ params }: { params: { runtimeId: string; workerId: string } },
|
|
||||||
) {
|
export const load: PageLoad = async () => ({});
|
||||||
return {
|
|
||||||
runtimeId: params.runtimeId,
|
|
||||||
workerId: params.workerId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,476 +1,6 @@
|
||||||
<script lang="ts">
|
<main class="settings-page">
|
||||||
import type { PageProps } from "./$types";
|
<section class="workspace-card">
|
||||||
import {
|
<h1>Redirecting to scoped settings…</h1>
|
||||||
SETTINGS_PATTERNS,
|
<p class="section-note">Settings are available at the canonical <code>/w/<workspace-id>/settings</code> route.</p>
|
||||||
SETTINGS_PERMISSION_NOTICE,
|
</section>
|
||||||
SETTINGS_SECTIONS,
|
</main>
|
||||||
diagnosticLabel,
|
|
||||||
settingsSectionHref,
|
|
||||||
type Diagnostic,
|
|
||||||
type RemoteRuntimeConnectionSummary,
|
|
||||||
type RemoteRuntimeTestResponse,
|
|
||||||
type RuntimeConnectionMutationResponse,
|
|
||||||
type RuntimeConnectionSettingsResponse,
|
|
||||||
} from "$lib/workspace-settings/model";
|
|
||||||
|
|
||||||
type RemoteAddForm = {
|
|
||||||
runtime_id: string;
|
|
||||||
display_name: string;
|
|
||||||
endpoint: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
|
||||||
let workspace = $derived(data.workspace);
|
|
||||||
let workspaceError = $derived(data.workspaceError);
|
|
||||||
|
|
||||||
let runtimeSettings = $state<RuntimeConnectionSettingsResponse | null>(null);
|
|
||||||
let runtimeLoading = $state(true);
|
|
||||||
let runtimeError = $state<string | null>(null);
|
|
||||||
let mutationMessage = $state<string | null>(null);
|
|
||||||
let mutationDiagnostics = $state<Diagnostic[]>([]);
|
|
||||||
let tests = $state<Record<string, RemoteRuntimeTestResponse>>({});
|
|
||||||
let deleting = $state<string | null>(null);
|
|
||||||
let testing = $state<string | null>(null);
|
|
||||||
let submitting = $state(false);
|
|
||||||
let showAddRuntimeForm = $state(false);
|
|
||||||
let remoteForm = $state<RemoteAddForm>({
|
|
||||||
runtime_id: "",
|
|
||||||
display_name: "",
|
|
||||||
endpoint: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
async function loadRuntimeSettings() {
|
|
||||||
runtimeLoading = true;
|
|
||||||
runtimeError = null;
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/settings/runtime-connections");
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`runtime settings request failed (${response.status})`);
|
|
||||||
}
|
|
||||||
const data = (await response.json()) as RuntimeConnectionSettingsResponse;
|
|
||||||
if (!cancelled) {
|
|
||||||
runtimeSettings = data;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (!cancelled) {
|
|
||||||
runtimeError = err instanceof Error ? err.message : "runtime settings request failed";
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) {
|
|
||||||
runtimeLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadRuntimeSettings();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
async function submitRemoteRuntime() {
|
|
||||||
submitting = true;
|
|
||||||
mutationMessage = null;
|
|
||||||
mutationDiagnostics = [];
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/settings/runtime-connections/remotes", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
runtime_id: remoteForm.runtime_id,
|
|
||||||
display_name: remoteForm.display_name || null,
|
|
||||||
endpoint: remoteForm.endpoint,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(await responseErrorMessage(response, "add remote Runtime failed"));
|
|
||||||
}
|
|
||||||
const data = (await response.json()) as RuntimeConnectionMutationResponse;
|
|
||||||
applyRuntimeMutation(data);
|
|
||||||
remoteForm = { runtime_id: "", display_name: "", endpoint: "" };
|
|
||||||
showAddRuntimeForm = false;
|
|
||||||
} catch (err) {
|
|
||||||
mutationMessage = err instanceof Error ? err.message : "add remote Runtime failed";
|
|
||||||
} finally {
|
|
||||||
submitting = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteRemoteRuntime(runtimeId: string) {
|
|
||||||
deleting = runtimeId;
|
|
||||||
mutationMessage = null;
|
|
||||||
mutationDiagnostics = [];
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/settings/runtime-connections/remotes/${encodeURIComponent(runtimeId)}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(await responseErrorMessage(response, "delete remote Runtime failed"));
|
|
||||||
}
|
|
||||||
const data = (await response.json()) as RuntimeConnectionMutationResponse;
|
|
||||||
applyRuntimeMutation(data);
|
|
||||||
const nextTests = { ...tests };
|
|
||||||
delete nextTests[runtimeId];
|
|
||||||
tests = nextTests;
|
|
||||||
} catch (err) {
|
|
||||||
mutationMessage = err instanceof Error ? err.message : "delete remote Runtime failed";
|
|
||||||
} finally {
|
|
||||||
deleting = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function testRemoteRuntime(runtimeId: string) {
|
|
||||||
testing = runtimeId;
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/settings/runtime-connections/remotes/${encodeURIComponent(runtimeId)}/test`, {
|
|
||||||
method: "POST",
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(await responseErrorMessage(response, "test remote Runtime failed"));
|
|
||||||
}
|
|
||||||
const data = (await response.json()) as RemoteRuntimeTestResponse;
|
|
||||||
tests = { ...tests, [runtimeId]: data };
|
|
||||||
} catch (err) {
|
|
||||||
tests = {
|
|
||||||
...tests,
|
|
||||||
[runtimeId]: {
|
|
||||||
workspace_id: runtimeSettings?.workspace_id ?? "unknown",
|
|
||||||
runtime_id: runtimeId,
|
|
||||||
checked_at: new Date().toISOString(),
|
|
||||||
state: "failed",
|
|
||||||
protocol_version: null,
|
|
||||||
compatibility_basis: "browser request failed",
|
|
||||||
capabilities: [],
|
|
||||||
health_result: "failed",
|
|
||||||
diagnostics: [
|
|
||||||
{
|
|
||||||
code: "browser_runtime_test_failed",
|
|
||||||
severity: "error",
|
|
||||||
message: err instanceof Error ? err.message : "test remote Runtime failed",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
testing = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function capabilityOperations(test: RemoteRuntimeTestResponse, state: "available" | "unknown" | "incompatible"): string[] {
|
|
||||||
const suffix = `:${state}`;
|
|
||||||
return test.capabilities
|
|
||||||
.filter((capability) => capability.endsWith(suffix))
|
|
||||||
.map((capability) => capability.slice(0, -suffix.length));
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyRuntimeMutation(data: RuntimeConnectionMutationResponse) {
|
|
||||||
runtimeSettings = runtimeSettings
|
|
||||||
? { ...runtimeSettings, remotes: data.remotes, diagnostics: data.diagnostics }
|
|
||||||
: {
|
|
||||||
workspace_id: data.workspace_id,
|
|
||||||
embedded: {
|
|
||||||
runtime_id: "embedded-worker-runtime",
|
|
||||||
display_name: "Embedded Runtime",
|
|
||||||
kind: "embedded_worker_runtime",
|
|
||||||
built_in: true,
|
|
||||||
config_managed: false,
|
|
||||||
active: false,
|
|
||||||
can_spawn_worker: false,
|
|
||||||
restart_required: false,
|
|
||||||
status: "unknown",
|
|
||||||
diagnostics: [],
|
|
||||||
},
|
|
||||||
remotes: data.remotes,
|
|
||||||
diagnostics: data.diagnostics,
|
|
||||||
};
|
|
||||||
mutationDiagnostics = data.diagnostics;
|
|
||||||
mutationMessage = data.restart_required
|
|
||||||
? "Runtime config saved. Restart the Workspace backend to apply live registry changes."
|
|
||||||
: "Runtime config saved.";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function responseErrorMessage(response: Response, fallback: string): Promise<string> {
|
|
||||||
try {
|
|
||||||
const payload = (await response.json()) as { error?: { message?: string; code?: string } | string; message?: string };
|
|
||||||
if (typeof payload.error === "object" && payload.error?.message) {
|
|
||||||
return `${payload.error.code ?? "request_failed"}: ${payload.error.message}`;
|
|
||||||
}
|
|
||||||
if (payload.message) {
|
|
||||||
const code = typeof payload.error === "string" ? payload.error : "request_failed";
|
|
||||||
return `${code}: ${payload.message}`;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// fall through
|
|
||||||
}
|
|
||||||
return `${fallback} (${response.status})`;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>Settings · Yoi Workspace</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="settings-shell" aria-labelledby="settings-title">
|
|
||||||
<section class="hero settings-hero">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Workspace Browser</p>
|
|
||||||
<h1 id="settings-title">Settings / Admin</h1>
|
|
||||||
<p class="hero-copy">
|
|
||||||
Local administration surfaces for the Workspace backend. Runtime Connections v0 is editable through typed APIs; broader admin controls remain bounded placeholders.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span class="badge warning">local only</span>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card settings-notice" aria-labelledby="settings-boundary-title">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Authority boundary</p>
|
|
||||||
<h2 id="settings-boundary-title">No browser admin permission model</h2>
|
|
||||||
<p>{SETTINGS_PERMISSION_NOTICE}</p>
|
|
||||||
</div>
|
|
||||||
<div class="settings-diagnostic" role="note">
|
|
||||||
<strong>Restart-required</strong>
|
|
||||||
<span>Runtime config changes are persisted, then applied after backend restart.</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="settings-nav-card" aria-label="Settings sections">
|
|
||||||
{#each SETTINGS_SECTIONS as section}
|
|
||||||
<a class="settings-nav-link" href={settingsSectionHref(section.id)}>
|
|
||||||
<span>{section.label}</span>
|
|
||||||
<small>{section.status === "editable" ? "Editable" : section.status === "read-only" ? "Read-only" : "Placeholder"}</small>
|
|
||||||
</a>
|
|
||||||
{/each}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card settings-section" id="runtime-connections" aria-labelledby="runtime-connections-title">
|
|
||||||
<header class="settings-section-header">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">editable</p>
|
|
||||||
<h2 id="runtime-connections-title">Runtime Connections</h2>
|
|
||||||
</div>
|
|
||||||
<span class="badge success">typed API</span>
|
|
||||||
</header>
|
|
||||||
<p>{SETTINGS_SECTIONS.find((section) => section.id === "runtime-connections")?.summary}</p>
|
|
||||||
|
|
||||||
{#if runtimeLoading}
|
|
||||||
<p class="status-message">Loading Runtime connections…</p>
|
|
||||||
{:else if runtimeError}
|
|
||||||
<p class="status-message error">Runtime connection settings unavailable: {runtimeError}</p>
|
|
||||||
{:else if runtimeSettings}
|
|
||||||
<div class="settings-action-row">
|
|
||||||
<button type="button" onclick={() => showAddRuntimeForm = !showAddRuntimeForm}>
|
|
||||||
{showAddRuntimeForm ? "Cancel adding Runtime" : "Add remote Runtime"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if showAddRuntimeForm}
|
|
||||||
<form class="settings-runtime-form" onsubmit={(event) => { event.preventDefault(); void submitRemoteRuntime(); }}>
|
|
||||||
<h3>Add remote Runtime</h3>
|
|
||||||
<p>Endpoint is submitted to the Backend but not echoed back in settings responses.</p>
|
|
||||||
<label>
|
|
||||||
<span>Runtime id</span>
|
|
||||||
<input bind:value={remoteForm.runtime_id} required maxlength="96" pattern="[A-Za-z0-9_.-]+" placeholder="team-runtime" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Display name</span>
|
|
||||||
<input bind:value={remoteForm.display_name} maxlength="80" placeholder="Team Runtime" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Endpoint</span>
|
|
||||||
<input bind:value={remoteForm.endpoint} required inputmode="url" placeholder="https://runtime.example" />
|
|
||||||
</label>
|
|
||||||
<button type="submit" disabled={submitting}>{submitting ? "Saving…" : "Add Runtime"}</button>
|
|
||||||
</form>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if mutationMessage}
|
|
||||||
<p class="status-message" class:error={mutationMessage.includes("failed")}>{mutationMessage}</p>
|
|
||||||
{/if}
|
|
||||||
{@render DiagnosticsList({ diagnostics: mutationDiagnostics })}
|
|
||||||
|
|
||||||
<div class="settings-runtime-list" aria-label="Runtime connections">
|
|
||||||
<h3>Runtimes</h3>
|
|
||||||
<div class="settings-runtime-table-wrap">
|
|
||||||
<table class="settings-runtime-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">Runtime</th>
|
|
||||||
<th scope="col">Source</th>
|
|
||||||
<th scope="col">Connection</th>
|
|
||||||
<th scope="col">Status</th>
|
|
||||||
<th scope="col">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr class:inactive={!runtimeSettings.embedded.active}>
|
|
||||||
<td>
|
|
||||||
<strong>{runtimeSettings.embedded.display_name}</strong>
|
|
||||||
<code>{runtimeSettings.embedded.runtime_id}</code>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<strong>embedded</strong>
|
|
||||||
<span>Workspace backend process</span>
|
|
||||||
</td>
|
|
||||||
<td>Local Backend runtime</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge" class:success={runtimeSettings.embedded.active} class:warning={!runtimeSettings.embedded.active}>{runtimeSettings.embedded.status}</span>
|
|
||||||
{#if runtimeSettings.embedded.restart_required}
|
|
||||||
<span class="badge warning">restart required</span>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td><span class="settings-muted-action">Managed by backend</span></td>
|
|
||||||
</tr>
|
|
||||||
{#if runtimeSettings.embedded.diagnostics.length > 0}
|
|
||||||
<tr class="settings-runtime-detail-row">
|
|
||||||
<td colspan="5">{@render DiagnosticsList({ diagnostics: runtimeSettings.embedded.diagnostics })}</td>
|
|
||||||
</tr>
|
|
||||||
{/if}
|
|
||||||
{#each runtimeSettings.remotes as remote (remote.runtime_id)}
|
|
||||||
<tr class:inactive={!remote.active}>
|
|
||||||
<td>
|
|
||||||
<strong>{remote.display_name}</strong>
|
|
||||||
<code>{remote.runtime_id}</code>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<strong>remote</strong>
|
|
||||||
<span>Configured Runtime endpoint</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span>Endpoint: {remote.endpoint_configured ? "configured" : "not configured"}</span>
|
|
||||||
{#if remote.endpoint_configured}<small>hidden</small>{/if}
|
|
||||||
<span>Token: {remote.token_ref_configured ? "configured" : "not configured"}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge" class:success={remote.active} class:warning={!remote.active}>{remote.status}</span>
|
|
||||||
{#if remote.restart_required}
|
|
||||||
<span class="badge warning">restart required</span>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="settings-action-row">
|
|
||||||
<button type="button" onclick={() => void testRemoteRuntime(remote.runtime_id)} disabled={testing === remote.runtime_id}>
|
|
||||||
{testing === remote.runtime_id ? "Testing…" : "Test"}
|
|
||||||
</button>
|
|
||||||
<button type="button" class="danger" onclick={() => void deleteRemoteRuntime(remote.runtime_id)} disabled={deleting === remote.runtime_id}>
|
|
||||||
{deleting === remote.runtime_id ? "Deleting…" : "Delete"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{#if remote.diagnostics.length > 0}
|
|
||||||
<tr class="settings-runtime-detail-row">
|
|
||||||
<td colspan="5">{@render DiagnosticsList({ diagnostics: remote.diagnostics })}</td>
|
|
||||||
</tr>
|
|
||||||
{/if}
|
|
||||||
{#if tests[remote.runtime_id]}
|
|
||||||
{@const test = tests[remote.runtime_id]}
|
|
||||||
{@const available = capabilityOperations(test, "available")}
|
|
||||||
{@const unchecked = capabilityOperations(test, "unknown")}
|
|
||||||
<tr class="settings-runtime-detail-row">
|
|
||||||
<td colspan="5">
|
|
||||||
<div class="settings-test-result">
|
|
||||||
<strong>Test: {test.state}</strong>
|
|
||||||
<span>{test.health_result} · {test.checked_at}</span>
|
|
||||||
<p>{test.compatibility_basis}</p>
|
|
||||||
{#if available.length > 0}
|
|
||||||
<p class="settings-test-verified">Verified areas: {available.join(', ')}</p>
|
|
||||||
{/if}
|
|
||||||
{#if unchecked.length > 0}
|
|
||||||
<p class="settings-test-verified">Unchecked warning areas: {unchecked.join(', ')}</p>
|
|
||||||
{/if}
|
|
||||||
{@render DiagnosticsList({ diagnostics: test.diagnostics })}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{#if runtimeSettings.remotes.length === 0}
|
|
||||||
<p class="status-message">No remote Runtime connections configured.</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="grid settings-grid">
|
|
||||||
{#each SETTINGS_SECTIONS.filter((section) => section.id !== "runtime-connections") as section}
|
|
||||||
<section class="card settings-section" id={section.id} aria-labelledby={`${section.id}-title`}>
|
|
||||||
<header class="settings-section-header">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">{section.status}</p>
|
|
||||||
<h2 id={`${section.id}-title`}>{section.label}</h2>
|
|
||||||
</div>
|
|
||||||
{#if section.status === "placeholder"}
|
|
||||||
<span class="badge neutral">not implemented</span>
|
|
||||||
{:else}
|
|
||||||
<span class="badge success">read-only</span>
|
|
||||||
{/if}
|
|
||||||
</header>
|
|
||||||
<p>{section.summary}</p>
|
|
||||||
<ul>
|
|
||||||
{#each section.bullets as bullet}
|
|
||||||
<li>{bullet}</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{#if section.id === "workspace-identity"}
|
|
||||||
<dl class="settings-identity-list">
|
|
||||||
<div>
|
|
||||||
<dt>Workspace id</dt>
|
|
||||||
<dd><code>{workspace?.workspace_id ?? "loading"}</code></dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Display name</dt>
|
|
||||||
<dd>{workspace?.display_name ?? "loading"}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Record authority</dt>
|
|
||||||
<dd>.yoi tickets/objectives through the Backend projection</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="card settings-patterns" aria-labelledby="settings-patterns-title">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Implementation patterns</p>
|
|
||||||
<h2 id="settings-patterns-title">How settings should appear</h2>
|
|
||||||
</div>
|
|
||||||
<div class="grid settings-pattern-grid">
|
|
||||||
{#each SETTINGS_PATTERNS as pattern}
|
|
||||||
<article class="settings-pattern">
|
|
||||||
<h3>{pattern.title}</h3>
|
|
||||||
<p>{pattern.body}</p>
|
|
||||||
</article>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{#if !workspace && !workspaceError}
|
|
||||||
<p class="status-message">Loading workspace summary…</p>
|
|
||||||
{:else if workspaceError}
|
|
||||||
<p class="status-message error">Workspace summary unavailable: {workspaceError}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#snippet DiagnosticsList({ diagnostics }: { diagnostics: Diagnostic[] })}
|
|
||||||
{#if diagnostics.length > 0}
|
|
||||||
<ul class="settings-diagnostics-list">
|
|
||||||
{#each diagnostics as diagnostic}
|
|
||||||
<li class={diagnostic.severity}>
|
|
||||||
<strong>{diagnosticLabel(diagnostic)}</strong>
|
|
||||||
<span>{diagnostic.message}</span>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
|
||||||
|
|
|
||||||
133
web/workspace/src/routes/w/[workspaceId]/+page.svelte
Normal file
133
web/workspace/src/routes/w/[workspaceId]/+page.svelte
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { workerConsoleHref } from '$lib/workspace-console/model';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
let workspaceId = $derived(data.workspace?.workspace_id ?? data.workspaceId);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Yoi Workspace Control Plane</title>
|
||||||
|
<meta name="description" content="Local single-workspace Yoi control plane bootstrap" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Workspace</h2>
|
||||||
|
{#if data.workspace}
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>ID</dt>
|
||||||
|
<dd>{data.workspace.workspace_id}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Name</dt>
|
||||||
|
<dd>{data.workspace.display_name}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Record authority</dt>
|
||||||
|
<dd>{data.workspace.record_authority}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Host / Worker bridge</dt>
|
||||||
|
<dd>{data.workspace.extension_points.host_worker_bridge.status}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{:else if data.workspaceError}
|
||||||
|
<p class="error">{data.workspaceError}</p>
|
||||||
|
{:else}
|
||||||
|
<p>Waiting for <code>/api/workspace</code>…</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid runtime">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Hosts</h2>
|
||||||
|
{#if data.hosts}
|
||||||
|
{#if data.hosts.items.length === 0}
|
||||||
|
<p>No local Hosts are visible.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="stack">
|
||||||
|
{#each data.hosts.items as host}
|
||||||
|
<article class="runtime-card">
|
||||||
|
<div class="runtime-heading">
|
||||||
|
<strong>{host.label}</strong>
|
||||||
|
<span class:warn={host.status !== 'available'}>{host.status}</span>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>ID</dt>
|
||||||
|
<dd><code>{host.host_id}</code></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Kind</dt>
|
||||||
|
<dd>{host.kind}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Runtime</dt>
|
||||||
|
<dd><code>{host.runtime_id}</code></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Scope</dt>
|
||||||
|
<dd>{host.capabilities.workspace_scope}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Platform</dt>
|
||||||
|
<dd>{host.capabilities.os} / {host.capabilities.arch}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else if data.hostsError}
|
||||||
|
<p class="error">{data.hostsError}</p>
|
||||||
|
{:else}
|
||||||
|
<p>Waiting for <code>/api/hosts</code>…</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Workers</h2>
|
||||||
|
{#if data.workers}
|
||||||
|
{#if data.workers.items.length === 0}
|
||||||
|
<p>No local Workers are visible.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Worker</th>
|
||||||
|
<th>Host</th>
|
||||||
|
<th>State</th>
|
||||||
|
<th>Workspace</th>
|
||||||
|
<th>Implementation</th>
|
||||||
|
<th>Attach</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each data.workers.items as worker}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{worker.label}</strong>
|
||||||
|
{#if worker.role || worker.profile}
|
||||||
|
<small>{worker.role ?? 'role unknown'} / {worker.profile ?? 'profile unknown'}</small>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td><code>{worker.host_id}</code></td>
|
||||||
|
<td>{worker.state} · {worker.status}</td>
|
||||||
|
<td>{worker.workspace.visibility} · {worker.workspace.identity}</td>
|
||||||
|
<td>{worker.implementation.kind}</td>
|
||||||
|
<td><a class="inline-link" href={workerConsoleHref(worker, workspaceId)}>Open Console</a></td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else if data.workersError}
|
||||||
|
<p class="error">{data.workersError}</p>
|
||||||
|
{:else}
|
||||||
|
<p>Waiting for <code>/api/workers</code>…</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
19
web/workspace/src/routes/w/[workspaceId]/+page.ts
Normal file
19
web/workspace/src/routes/w/[workspaceId]/+page.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { loadJson, workspaceApiPath } from "$lib/workspace-api/http";
|
||||||
|
import type { Host, ListResponse, Worker } from "$lib/workspace-sidebar/types";
|
||||||
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ fetch, params }) => {
|
||||||
|
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path);
|
||||||
|
const [hosts, workers] = await Promise.all([
|
||||||
|
loadJson<ListResponse<Host>>(fetch, apiPath("/hosts")),
|
||||||
|
loadJson<ListResponse<Worker>>(fetch, apiPath("/workers")),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
workspaceId: params.workspaceId,
|
||||||
|
hosts: hosts.data,
|
||||||
|
hostsError: hosts.error,
|
||||||
|
workers: workers.data,
|
||||||
|
workersError: workers.error,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { formatDate, workspaceRoute } from '$lib/workspace-api/http';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Objectives · Yoi Workspace</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Objectives</h2>
|
||||||
|
<p class="section-note">Objectives are read from canonical filesystem records through <code>/api/objectives</code>.</p>
|
||||||
|
{#if data.objectives}
|
||||||
|
{#if data.objectives.items.length === 0}
|
||||||
|
<p>No Objective records are present.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="objective-list">
|
||||||
|
{#each data.objectives.items as objective (objective.id)}
|
||||||
|
<a class="objective-row" href={workspaceRoute(data.workspaceId, `/objectives/${objective.id}`)}>
|
||||||
|
<div class="objective-main">
|
||||||
|
<div class="objective-title-row">
|
||||||
|
<strong class="objective-title">{objective.title}</strong>
|
||||||
|
<span class="state-pill">{objective.state}</span>
|
||||||
|
</div>
|
||||||
|
<p class="objective-summary">{objective.summary || 'No summary text is available.'}</p>
|
||||||
|
</div>
|
||||||
|
<div class="objective-meta" aria-label="Objective metadata">
|
||||||
|
<span>Updated {objective.updated_at ? formatDate(objective.updated_at) : 'unknown'}</span>
|
||||||
|
<span>{objective.linked_tickets?.length ? `${objective.linked_tickets.length} linked ticket(s)` : 'No linked tickets'}</span>
|
||||||
|
<code>{objective.id}</code>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if data.objectives.invalid_records.length > 0}
|
||||||
|
<p class="error">{data.objectives.invalid_records.length} invalid objective record(s) hidden.</p>
|
||||||
|
{/if}
|
||||||
|
{:else if data.objectivesError}
|
||||||
|
<p class="error">{data.objectivesError}</p>
|
||||||
|
{:else}
|
||||||
|
<p>Waiting for <code>/api/objectives</code>…</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
17
web/workspace/src/routes/w/[workspaceId]/objectives/+page.ts
Normal file
17
web/workspace/src/routes/w/[workspaceId]/objectives/+page.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { loadJson, workspaceApiPath } from "$lib/workspace-api/http";
|
||||||
|
import type { ObjectiveListResponse } from "$lib/workspace-sidebar/types";
|
||||||
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ fetch, params }) => {
|
||||||
|
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path);
|
||||||
|
const objectives = await loadJson<ObjectiveListResponse>(
|
||||||
|
fetch,
|
||||||
|
apiPath("/objectives"),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
workspaceId: params.workspaceId,
|
||||||
|
objectives: objectives.data,
|
||||||
|
objectivesError: objectives.error,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { formatDate, workspaceRoute } from '$lib/workspace-api/http';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{data.objective?.title ?? data.objectiveId} · Objective</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Objectives</h2>
|
||||||
|
{#if data.objectives}
|
||||||
|
<div class="objective-list compact">
|
||||||
|
{#each data.objectives.items as objective (objective.id)}
|
||||||
|
<a class="objective-row" class:active={objective.id === data.objectiveId} href={workspaceRoute(data.workspaceId, `/objectives/${objective.id}`)}>
|
||||||
|
<div class="objective-main">
|
||||||
|
<div class="objective-title-row">
|
||||||
|
<strong class="objective-title">{objective.title}</strong>
|
||||||
|
<span class="state-pill">{objective.state}</span>
|
||||||
|
</div>
|
||||||
|
<p class="objective-summary">{objective.summary || 'No summary text is available.'}</p>
|
||||||
|
</div>
|
||||||
|
<div class="objective-meta" aria-label="Objective metadata">
|
||||||
|
<span>Updated {objective.updated_at ? formatDate(objective.updated_at) : 'unknown'}</span>
|
||||||
|
<code>{objective.id}</code>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if data.objectivesError}
|
||||||
|
<p class="error">{data.objectivesError}</p>
|
||||||
|
{:else}
|
||||||
|
<p>Loading objectives…</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card objective-detail-card">
|
||||||
|
<h2>Objective detail</h2>
|
||||||
|
{#if data.objective}
|
||||||
|
<div class="objective-title-row detail">
|
||||||
|
<div>
|
||||||
|
<h3>{data.objective.title}</h3>
|
||||||
|
<p><code>{data.objective.id}</code></p>
|
||||||
|
</div>
|
||||||
|
<span class="state-pill">{data.objective.state}</span>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>Created</dt>
|
||||||
|
<dd>{data.objective.created_at ? formatDate(data.objective.created_at) : 'unknown'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Updated</dt>
|
||||||
|
<dd>{data.objective.updated_at ? formatDate(data.objective.updated_at) : 'unknown'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Record source</dt>
|
||||||
|
<dd>{data.objective.record_source}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Linked tickets</dt>
|
||||||
|
<dd>{data.objective.linked_tickets.length ? data.objective.linked_tickets.join(', ') : 'none'}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<pre class="objective-body">{data.objective.body}</pre>
|
||||||
|
{#if data.objective.body_truncated}
|
||||||
|
<p class="error">Objective body was truncated by the Backend response limit.</p>
|
||||||
|
{/if}
|
||||||
|
{:else if data.objectiveError}
|
||||||
|
<p class="error">{data.objectiveError}</p>
|
||||||
|
{:else}
|
||||||
|
<p>Loading objective detail…</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { loadJson, workspaceApiPath } from "$lib/workspace-api/http";
|
||||||
|
import type {
|
||||||
|
ObjectiveDetail,
|
||||||
|
ObjectiveListResponse,
|
||||||
|
} from "$lib/workspace-sidebar/types";
|
||||||
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ fetch, params }) => {
|
||||||
|
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path);
|
||||||
|
const objectiveId = params.objectiveId;
|
||||||
|
const [objectives, objective] = await Promise.all([
|
||||||
|
loadJson<ObjectiveListResponse>(fetch, apiPath('/objectives')),
|
||||||
|
loadJson<ObjectiveDetail>(
|
||||||
|
fetch,
|
||||||
|
apiPath(`/objectives/${encodeURIComponent(objectiveId)}`),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
workspaceId: params.workspaceId,
|
||||||
|
objectiveId,
|
||||||
|
objectives: objectives.data,
|
||||||
|
objectivesError: objectives.error,
|
||||||
|
objective: objective.data,
|
||||||
|
objectiveError: objective.error,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { formatDate } from '$lib/workspace-api/http';
|
||||||
|
import RepositoryTicketKanban from '$lib/workspace-pages/RepositoryTicketKanban.svelte';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{data.repository?.item.display_name ?? data.repositoryId} · Repository</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="card repository-detail-card">
|
||||||
|
<h2>Repository</h2>
|
||||||
|
{#if data.repository}
|
||||||
|
<div class="repository-detail-heading">
|
||||||
|
<div>
|
||||||
|
<h3>{data.repository.item.display_name}</h3>
|
||||||
|
<p><code>{data.repository.item.id}</code></p>
|
||||||
|
</div>
|
||||||
|
<span class="status-pill" class:warn={data.repository.item.git?.status !== 'clean'}>{data.repository.item.git?.status ?? 'not observed'}</span>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>Kind</dt>
|
||||||
|
<dd>{data.repository.item.kind}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Provider</dt>
|
||||||
|
<dd>{data.repository.item.provider}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Record authority</dt>
|
||||||
|
<dd>{data.repository.item.record_authority}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Default selector</dt>
|
||||||
|
<dd>{data.repository.item.default_selector ?? 'none configured'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Branch</dt>
|
||||||
|
<dd>{data.repository.item.git?.branch ?? 'unknown'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>HEAD</dt>
|
||||||
|
<dd><code>{data.repository.item.git?.head ?? 'unknown'}</code></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Dirty</dt>
|
||||||
|
<dd>{data.repository.item.git?.dirty ? 'yes' : 'no'}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{#if data.repository.item.diagnostics && data.repository.item.diagnostics.length > 0}
|
||||||
|
<ul class="diagnostics" aria-label="Repository diagnostics">
|
||||||
|
{#each data.repository.item.diagnostics as diagnostic}
|
||||||
|
<li><code>{diagnostic.code}</code>: {diagnostic.message}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{:else if data.repositoryError}
|
||||||
|
<p class="error">{data.repositoryError}</p>
|
||||||
|
{:else}
|
||||||
|
<p>Loading repository…</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card repository-log-card">
|
||||||
|
<h2>Recent commits</h2>
|
||||||
|
{#if data.repositoryLog}
|
||||||
|
{#if data.repositoryLog.items.length === 0}
|
||||||
|
<p>No recent commits are available.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="commit-list">
|
||||||
|
{#each data.repositoryLog.items as commit}
|
||||||
|
<article class="commit-card">
|
||||||
|
<strong>{commit.summary}</strong>
|
||||||
|
<span><code>{commit.short_hash}</code> · {commit.author_name} · {formatDate(commit.author_date)}</span>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if data.repositoryLog.diagnostics.length > 0}
|
||||||
|
<ul class="diagnostics" aria-label="Repository log diagnostics">
|
||||||
|
{#each data.repositoryLog.diagnostics as diagnostic}
|
||||||
|
<li><code>{diagnostic.code}</code>: {diagnostic.message}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{:else if data.repositoryLogError}
|
||||||
|
<p class="error">{data.repositoryLogError}</p>
|
||||||
|
{:else}
|
||||||
|
<p>Loading repository commits…</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card repository-tickets-card">
|
||||||
|
<h2>Repository Tickets</h2>
|
||||||
|
{#if data.repositoryTickets}
|
||||||
|
<RepositoryTicketKanban tickets={data.repositoryTickets} />
|
||||||
|
{:else if data.repositoryTicketsError}
|
||||||
|
<p class="error">{data.repositoryTicketsError}</p>
|
||||||
|
{:else}
|
||||||
|
<p>Loading repository tickets…</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { loadJson, workspaceApiPath } from "$lib/workspace-api/http";
|
||||||
|
import type {
|
||||||
|
RepositoryDetailResponse,
|
||||||
|
RepositoryLogResponse,
|
||||||
|
RepositoryTicketsResponse,
|
||||||
|
} from "$lib/workspace-sidebar/types";
|
||||||
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ fetch, params }) => {
|
||||||
|
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path);
|
||||||
|
const repositoryId = params.repositoryId;
|
||||||
|
const [repository, log, tickets] = await Promise.all([
|
||||||
|
loadJson<RepositoryDetailResponse>(
|
||||||
|
fetch,
|
||||||
|
apiPath(`/repositories/${encodeURIComponent(repositoryId)}`),
|
||||||
|
),
|
||||||
|
loadJson<RepositoryLogResponse>(
|
||||||
|
fetch,
|
||||||
|
apiPath(`/repositories/${encodeURIComponent(repositoryId)}/log`),
|
||||||
|
),
|
||||||
|
loadJson<RepositoryTicketsResponse>(
|
||||||
|
fetch,
|
||||||
|
apiPath(`/repositories/${encodeURIComponent(repositoryId)}/tickets`),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
repositoryId,
|
||||||
|
repository: repository.data,
|
||||||
|
repositoryError: repository.error,
|
||||||
|
repositoryLog: log.data,
|
||||||
|
repositoryLogError: log.error,
|
||||||
|
repositoryTickets: tickets.data,
|
||||||
|
repositoryTicketsError: tickets.error,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,399 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
projectConsole,
|
||||||
|
type ConsoleLine
|
||||||
|
} from '$lib/workspace-console/model';
|
||||||
|
import { workspaceApiPath } from '$lib/workspace-api/http';
|
||||||
|
import type {
|
||||||
|
ClientWorkerEventWsFrame,
|
||||||
|
Diagnostic,
|
||||||
|
Worker,
|
||||||
|
WorkerInputResult,
|
||||||
|
WorkerTranscriptProjection
|
||||||
|
} from '$lib/workspace-sidebar/types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
data: {
|
||||||
|
workspaceId: string;
|
||||||
|
runtimeId: string;
|
||||||
|
workerId: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
const workspaceId = $derived(data.workspaceId);
|
||||||
|
const runtimeId = $derived(data.runtimeId);
|
||||||
|
const workerId = $derived(data.workerId);
|
||||||
|
|
||||||
|
function workerApiPath(path: string): string {
|
||||||
|
return workspaceApiPath(workspaceId, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let worker = $state<Worker | null>(null);
|
||||||
|
let workerError = $state<string | null>(null);
|
||||||
|
let transcript = $state<WorkerTranscriptProjection | null>(null);
|
||||||
|
let transcriptError = $state<string | null>(null);
|
||||||
|
let draft = $state('');
|
||||||
|
let sending = $state(false);
|
||||||
|
let sendError = $state<string | null>(null);
|
||||||
|
let streamState = $state<'connecting' | 'open' | 'closed' | 'error'>('connecting');
|
||||||
|
let streamDiagnostics = $state<Diagnostic[]>([]);
|
||||||
|
let workerDetailsOpen = $state(false);
|
||||||
|
let observedEvents = $state<Array<{ cursor: string; event: ClientWorkerEventWsFrame & { kind: 'event' } }>>([]);
|
||||||
|
let nextReloadToken = 0;
|
||||||
|
let reloadToken = $state(0);
|
||||||
|
|
||||||
|
type ConsoleTarget = {
|
||||||
|
runtimeId: string;
|
||||||
|
workerId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const consoleTarget = $derived({ runtimeId, workerId });
|
||||||
|
|
||||||
|
const projection = $derived(
|
||||||
|
projectConsole(
|
||||||
|
transcript?.items ?? [],
|
||||||
|
observedEvents.map((item) => ({ cursor: item.cursor, event: item.event.envelope.payload }))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const lines = $derived(projection.lines);
|
||||||
|
const diagnostics = $derived(
|
||||||
|
mergeDiagnostics(worker?.diagnostics ?? [], transcript?.diagnostics ?? [], streamDiagnostics)
|
||||||
|
);
|
||||||
|
const canSend = $derived(Boolean(worker?.capabilities.can_accept_input) && draft.trim().length > 0 && !sending);
|
||||||
|
|
||||||
|
async function getJson<T>(path: string): Promise<T> {
|
||||||
|
const response = await fetch(path);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`GET ${path} failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJson<T>(path: string, body: unknown, timeoutMs = 30_000): Promise<T> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
let detail = '';
|
||||||
|
try {
|
||||||
|
detail = await response.text();
|
||||||
|
} catch {
|
||||||
|
detail = '';
|
||||||
|
}
|
||||||
|
throw new Error(`POST ${path} failed: ${response.status}${detail ? ` ${detail}` : ''}`);
|
||||||
|
}
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWorker(target: ConsoleTarget) {
|
||||||
|
workerError = null;
|
||||||
|
try {
|
||||||
|
worker = await getJson<Worker>(
|
||||||
|
workerApiPath(`/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}`)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
workerError = error instanceof Error ? error.message : String(error);
|
||||||
|
worker = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTranscript(target: ConsoleTarget) {
|
||||||
|
transcriptError = null;
|
||||||
|
try {
|
||||||
|
transcript = await getJson<WorkerTranscriptProjection>(
|
||||||
|
workerApiPath(`/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}/transcript?limit=200`)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
transcriptError = error instanceof Error ? error.message : String(error);
|
||||||
|
transcript = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConsoleData(target: ConsoleTarget) {
|
||||||
|
await Promise.all([loadWorker(target), loadTranscript(target)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function advanceReloadToken(): number {
|
||||||
|
nextReloadToken += 1;
|
||||||
|
reloadToken = nextReloadToken;
|
||||||
|
return nextReloadToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessage(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const content = draft.trim();
|
||||||
|
if (!content || sending || !worker?.capabilities.can_accept_input) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sending = true;
|
||||||
|
sendError = null;
|
||||||
|
try {
|
||||||
|
const result = await postJson<WorkerInputResult>(
|
||||||
|
workerApiPath(`/runtimes/${encodeURIComponent(runtimeId)}/workers/${encodeURIComponent(workerId)}/input`),
|
||||||
|
{ kind: 'user', content }
|
||||||
|
);
|
||||||
|
if (result.state === 'accepted') {
|
||||||
|
draft = '';
|
||||||
|
} else {
|
||||||
|
sendError = diagnosticsToText(result.diagnostics) || `Input was ${result.state}.`;
|
||||||
|
}
|
||||||
|
await loadTranscript(consoleTarget);
|
||||||
|
} catch (error) {
|
||||||
|
sendError = error instanceof Error ? error.message : String(error);
|
||||||
|
} finally {
|
||||||
|
sending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectObservation(targetWorker: Worker | null, token: number, target: ConsoleTarget) {
|
||||||
|
if (!targetWorker) {
|
||||||
|
streamState = 'closed';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
streamState = 'connecting';
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsPath = workerApiPath(`/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(
|
||||||
|
target.workerId
|
||||||
|
)}/events/ws`);
|
||||||
|
const ws = new WebSocket(`${protocol}//${window.location.host}${wsPath}`);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
if (token === reloadToken) {
|
||||||
|
streamState = 'open';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onmessage = (message) => {
|
||||||
|
if (token !== reloadToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const frame = JSON.parse(String(message.data)) as ClientWorkerEventWsFrame;
|
||||||
|
if (frame.kind === 'event') {
|
||||||
|
observedEvents = [
|
||||||
|
...observedEvents,
|
||||||
|
{
|
||||||
|
cursor: frame.envelope.cursor,
|
||||||
|
event: frame
|
||||||
|
}
|
||||||
|
].slice(-500);
|
||||||
|
} else {
|
||||||
|
streamDiagnostics = [
|
||||||
|
...streamDiagnostics,
|
||||||
|
{
|
||||||
|
code: frame.diagnostic.code,
|
||||||
|
severity: 'warning',
|
||||||
|
message: frame.diagnostic.message
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
streamDiagnostics = [
|
||||||
|
...streamDiagnostics,
|
||||||
|
{
|
||||||
|
code: 'worker_observation_frame_invalid',
|
||||||
|
severity: 'warning',
|
||||||
|
message: error instanceof Error ? error.message : String(error)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onerror = () => {
|
||||||
|
if (token === reloadToken) {
|
||||||
|
streamState = 'error';
|
||||||
|
streamDiagnostics = [
|
||||||
|
...streamDiagnostics,
|
||||||
|
{
|
||||||
|
code: 'worker_observation_ws_error',
|
||||||
|
severity: 'error',
|
||||||
|
message: 'Worker observation WebSocket failed.'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (token === reloadToken && streamState !== 'error') {
|
||||||
|
streamState = 'closed';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => ws.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeDiagnostics(...groups: Diagnostic[][]): Diagnostic[] {
|
||||||
|
return groups.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnosticsToText(items: Diagnostic[]): string {
|
||||||
|
return items.map((item) => `${item.severity}: ${item.message}`).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineClass(line: ConsoleLine): string {
|
||||||
|
return line.error ? 'error' : line.kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const target = consoleTarget;
|
||||||
|
observedEvents = [];
|
||||||
|
streamDiagnostics = [];
|
||||||
|
advanceReloadToken();
|
||||||
|
void loadConsoleData(target);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => connectObservation(worker, reloadToken, consoleTarget));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Worker Console · Yoi Workspace</title>
|
||||||
|
<meta name="description" content="Worker attach console through Workspace Backend APIs" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="console-shell worker-console-shell">
|
||||||
|
<section class="console-header card">
|
||||||
|
<div>
|
||||||
|
<h2>{worker?.label ?? workerId}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="console-header-actions">
|
||||||
|
<div class="console-status-pill" class:warn={streamState !== 'open'}>
|
||||||
|
{worker?.state ?? 'unknown'} · {worker?.status ?? 'loading'} · stream {streamState}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="secondary-button" aria-expanded={workerDetailsOpen} onclick={() => workerDetailsOpen = !workerDetailsOpen}>
|
||||||
|
Details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="console-body">
|
||||||
|
<article class="card console-card worker-console-card">
|
||||||
|
{#if projection.status || projection.usage}
|
||||||
|
<p class="section-note">
|
||||||
|
{#if projection.status}status: {projection.status}{/if}
|
||||||
|
{#if projection.status && projection.usage} · {/if}
|
||||||
|
{#if projection.usage}usage: {projection.usage}{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if workerError}
|
||||||
|
<p class="error">{workerError}</p>
|
||||||
|
{/if}
|
||||||
|
{#if transcriptError}
|
||||||
|
<p class="error">{transcriptError}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if lines.length === 0}
|
||||||
|
<p>No console output is available for this Worker yet.</p>
|
||||||
|
{:else}
|
||||||
|
<ol class="console-log">
|
||||||
|
{#each lines as item}
|
||||||
|
<li class:assistant={lineClass(item) === 'assistant'} class:user={lineClass(item) === 'user'} class:system={lineClass(item) !== 'assistant' && lineClass(item) !== 'user'} class:error-line={item.error}>
|
||||||
|
{#if lineClass(item) !== 'assistant' && lineClass(item) !== 'user'}
|
||||||
|
<div class="message-heading">
|
||||||
|
<span>{item.title}</span>
|
||||||
|
{#if item.streaming}<small>streaming</small>{/if}
|
||||||
|
</div>
|
||||||
|
{:else if item.streaming}
|
||||||
|
<div class="message-heading streaming-heading">
|
||||||
|
<small>streaming</small>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<pre>{item.body || '—'}</pre>
|
||||||
|
{#if item.detail}
|
||||||
|
<details class="message-detail">
|
||||||
|
<summary>detail</summary>
|
||||||
|
<p>{item.detail}</p>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
{/if}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if workerDetailsOpen}
|
||||||
|
<aside class="console-side-panel" aria-label="Worker detail">
|
||||||
|
<header class="side-panel-header">
|
||||||
|
<h3>Worker detail</h3>
|
||||||
|
<button type="button" class="secondary-button" onclick={() => workerDetailsOpen = false}>Close</button>
|
||||||
|
</header>
|
||||||
|
{#if worker}
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>Runtime</dt>
|
||||||
|
<dd><code>{worker.runtime_id}</code></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Worker</dt>
|
||||||
|
<dd><code>{worker.worker_id}</code></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Host</dt>
|
||||||
|
<dd><code>{worker.host_id}</code></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Role / profile</dt>
|
||||||
|
<dd>{worker.role ?? 'unknown'} / {worker.profile ?? 'unknown'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Workspace</dt>
|
||||||
|
<dd>{worker.workspace.visibility} · {worker.workspace.identity}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Implementation</dt>
|
||||||
|
<dd>{worker.implementation.kind} · {worker.implementation.display_hint}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<details class="metadata-details">
|
||||||
|
<summary>Capabilities</summary>
|
||||||
|
<ul>
|
||||||
|
<li>input: {worker.capabilities.can_accept_input ? 'available' : 'unsupported'}</li>
|
||||||
|
<li>stop: {worker.capabilities.can_stop ? 'available' : 'unsupported'}</li>
|
||||||
|
<li>follow-up spawn: {worker.capabilities.can_spawn_followup ? 'available' : 'unsupported'}</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{:else if !workerError}
|
||||||
|
<p>Loading Worker detail…</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if diagnostics.length > 0}
|
||||||
|
<details class="metadata-details" open={streamState === 'error'}>
|
||||||
|
<summary>Diagnostics ({diagnostics.length})</summary>
|
||||||
|
<ul>
|
||||||
|
{#each diagnostics as diagnostic}
|
||||||
|
<li>
|
||||||
|
<strong>{diagnostic.severity}</strong>
|
||||||
|
<code>{diagnostic.code}</code>
|
||||||
|
<span>{diagnostic.message}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
</aside>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form class="console-composer card" onsubmit={sendMessage}>
|
||||||
|
<textarea
|
||||||
|
id="worker-console-message"
|
||||||
|
aria-label="Console input"
|
||||||
|
bind:value={draft}
|
||||||
|
disabled={!worker?.capabilities.can_accept_input || sending}
|
||||||
|
></textarea>
|
||||||
|
<div class="composer-actions">
|
||||||
|
<button type="submit" disabled={!canSend}>{sending ? 'Sending…' : 'Send'}</button>
|
||||||
|
{#if sendError}<p class="error">{sendError}</p>{/if}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
export function load(
|
||||||
|
{ params }: { params: { workspaceId: string; runtimeId: string; workerId: string } },
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
workspaceId: params.workspaceId,
|
||||||
|
runtimeId: params.runtimeId,
|
||||||
|
workerId: params.workerId,
|
||||||
|
};
|
||||||
|
}
|
||||||
487
web/workspace/src/routes/w/[workspaceId]/settings/+page.svelte
Normal file
487
web/workspace/src/routes/w/[workspaceId]/settings/+page.svelte
Normal file
|
|
@ -0,0 +1,487 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PageProps } from "./$types";
|
||||||
|
import { workspaceApiPath, workspaceRoute } from "$lib/workspace-api/http";
|
||||||
|
import {
|
||||||
|
SETTINGS_PATTERNS,
|
||||||
|
SETTINGS_PERMISSION_NOTICE,
|
||||||
|
SETTINGS_SECTIONS,
|
||||||
|
diagnosticLabel,
|
||||||
|
settingsSectionHref,
|
||||||
|
type Diagnostic,
|
||||||
|
type RemoteRuntimeConnectionSummary,
|
||||||
|
type RemoteRuntimeTestResponse,
|
||||||
|
type RuntimeConnectionMutationResponse,
|
||||||
|
type RuntimeConnectionSettingsResponse,
|
||||||
|
} from "$lib/workspace-settings/model";
|
||||||
|
|
||||||
|
type RemoteAddForm = {
|
||||||
|
runtime_id: string;
|
||||||
|
display_name: string;
|
||||||
|
endpoint: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
let workspace = $derived(data.workspace);
|
||||||
|
let workspaceId = $derived(data.workspace?.workspace_id ?? '');
|
||||||
|
let workspaceError = $derived(data.workspaceError);
|
||||||
|
|
||||||
|
function settingsApiPath(path: string): string {
|
||||||
|
return workspaceApiPath(workspaceId, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let runtimeSettings = $state<RuntimeConnectionSettingsResponse | null>(null);
|
||||||
|
let runtimeLoading = $state(true);
|
||||||
|
let runtimeError = $state<string | null>(null);
|
||||||
|
let mutationMessage = $state<string | null>(null);
|
||||||
|
let mutationDiagnostics = $state<Diagnostic[]>([]);
|
||||||
|
let tests = $state<Record<string, RemoteRuntimeTestResponse>>({});
|
||||||
|
let deleting = $state<string | null>(null);
|
||||||
|
let testing = $state<string | null>(null);
|
||||||
|
let submitting = $state(false);
|
||||||
|
let showAddRuntimeForm = $state(false);
|
||||||
|
let remoteForm = $state<RemoteAddForm>({
|
||||||
|
runtime_id: "",
|
||||||
|
display_name: "",
|
||||||
|
endpoint: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!workspaceId) {
|
||||||
|
runtimeLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function loadRuntimeSettings() {
|
||||||
|
runtimeLoading = true;
|
||||||
|
runtimeError = null;
|
||||||
|
try {
|
||||||
|
const response = await fetch(settingsApiPath('/settings/runtime-connections'));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`runtime settings request failed (${response.status})`);
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as RuntimeConnectionSettingsResponse;
|
||||||
|
if (!cancelled) {
|
||||||
|
runtimeSettings = data;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
runtimeError = err instanceof Error ? err.message : "runtime settings request failed";
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
runtimeLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadRuntimeSettings();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submitRemoteRuntime() {
|
||||||
|
submitting = true;
|
||||||
|
mutationMessage = null;
|
||||||
|
mutationDiagnostics = [];
|
||||||
|
try {
|
||||||
|
const response = await fetch(settingsApiPath('/settings/runtime-connections/remotes'), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
runtime_id: remoteForm.runtime_id,
|
||||||
|
display_name: remoteForm.display_name || null,
|
||||||
|
endpoint: remoteForm.endpoint,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await responseErrorMessage(response, "add remote Runtime failed"));
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as RuntimeConnectionMutationResponse;
|
||||||
|
applyRuntimeMutation(data);
|
||||||
|
remoteForm = { runtime_id: "", display_name: "", endpoint: "" };
|
||||||
|
showAddRuntimeForm = false;
|
||||||
|
} catch (err) {
|
||||||
|
mutationMessage = err instanceof Error ? err.message : "add remote Runtime failed";
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRemoteRuntime(runtimeId: string) {
|
||||||
|
deleting = runtimeId;
|
||||||
|
mutationMessage = null;
|
||||||
|
mutationDiagnostics = [];
|
||||||
|
try {
|
||||||
|
const response = await fetch(settingsApiPath(`/settings/runtime-connections/remotes/${encodeURIComponent(runtimeId)}`), {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await responseErrorMessage(response, "delete remote Runtime failed"));
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as RuntimeConnectionMutationResponse;
|
||||||
|
applyRuntimeMutation(data);
|
||||||
|
const nextTests = { ...tests };
|
||||||
|
delete nextTests[runtimeId];
|
||||||
|
tests = nextTests;
|
||||||
|
} catch (err) {
|
||||||
|
mutationMessage = err instanceof Error ? err.message : "delete remote Runtime failed";
|
||||||
|
} finally {
|
||||||
|
deleting = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testRemoteRuntime(runtimeId: string) {
|
||||||
|
testing = runtimeId;
|
||||||
|
try {
|
||||||
|
const response = await fetch(settingsApiPath(`/settings/runtime-connections/remotes/${encodeURIComponent(runtimeId)}/test`), {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await responseErrorMessage(response, "test remote Runtime failed"));
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as RemoteRuntimeTestResponse;
|
||||||
|
tests = { ...tests, [runtimeId]: data };
|
||||||
|
} catch (err) {
|
||||||
|
tests = {
|
||||||
|
...tests,
|
||||||
|
[runtimeId]: {
|
||||||
|
workspace_id: runtimeSettings?.workspace_id ?? "unknown",
|
||||||
|
runtime_id: runtimeId,
|
||||||
|
checked_at: new Date().toISOString(),
|
||||||
|
state: "failed",
|
||||||
|
protocol_version: null,
|
||||||
|
compatibility_basis: "browser request failed",
|
||||||
|
capabilities: [],
|
||||||
|
health_result: "failed",
|
||||||
|
diagnostics: [
|
||||||
|
{
|
||||||
|
code: "browser_runtime_test_failed",
|
||||||
|
severity: "error",
|
||||||
|
message: err instanceof Error ? err.message : "test remote Runtime failed",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
testing = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function capabilityOperations(test: RemoteRuntimeTestResponse, state: "available" | "unknown" | "incompatible"): string[] {
|
||||||
|
const suffix = `:${state}`;
|
||||||
|
return test.capabilities
|
||||||
|
.filter((capability) => capability.endsWith(suffix))
|
||||||
|
.map((capability) => capability.slice(0, -suffix.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRuntimeMutation(data: RuntimeConnectionMutationResponse) {
|
||||||
|
runtimeSettings = runtimeSettings
|
||||||
|
? { ...runtimeSettings, remotes: data.remotes, diagnostics: data.diagnostics }
|
||||||
|
: {
|
||||||
|
workspace_id: data.workspace_id,
|
||||||
|
embedded: {
|
||||||
|
runtime_id: "embedded-worker-runtime",
|
||||||
|
display_name: "Embedded Runtime",
|
||||||
|
kind: "embedded_worker_runtime",
|
||||||
|
built_in: true,
|
||||||
|
config_managed: false,
|
||||||
|
active: false,
|
||||||
|
can_spawn_worker: false,
|
||||||
|
restart_required: false,
|
||||||
|
status: "unknown",
|
||||||
|
diagnostics: [],
|
||||||
|
},
|
||||||
|
remotes: data.remotes,
|
||||||
|
diagnostics: data.diagnostics,
|
||||||
|
};
|
||||||
|
mutationDiagnostics = data.diagnostics;
|
||||||
|
mutationMessage = data.restart_required
|
||||||
|
? "Runtime config saved. Restart the Workspace backend to apply live registry changes."
|
||||||
|
: "Runtime config saved.";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function responseErrorMessage(response: Response, fallback: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const payload = (await response.json()) as { error?: { message?: string; code?: string } | string; message?: string };
|
||||||
|
if (typeof payload.error === "object" && payload.error?.message) {
|
||||||
|
return `${payload.error.code ?? "request_failed"}: ${payload.error.message}`;
|
||||||
|
}
|
||||||
|
if (payload.message) {
|
||||||
|
const code = typeof payload.error === "string" ? payload.error : "request_failed";
|
||||||
|
return `${code}: ${payload.message}`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
return `${fallback} (${response.status})`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Settings · Yoi Workspace</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="settings-shell" aria-labelledby="settings-title">
|
||||||
|
<section class="hero settings-hero">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Workspace Browser</p>
|
||||||
|
<h1 id="settings-title">Settings / Admin</h1>
|
||||||
|
<p class="hero-copy">
|
||||||
|
Local administration surfaces for the Workspace backend. Runtime Connections v0 is editable through typed APIs; broader admin controls remain bounded placeholders.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge warning">local only</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card settings-notice" aria-labelledby="settings-boundary-title">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Authority boundary</p>
|
||||||
|
<h2 id="settings-boundary-title">No browser admin permission model</h2>
|
||||||
|
<p>{SETTINGS_PERMISSION_NOTICE}</p>
|
||||||
|
</div>
|
||||||
|
<div class="settings-diagnostic" role="note">
|
||||||
|
<strong>Restart-required</strong>
|
||||||
|
<span>Runtime config changes are persisted, then applied after backend restart.</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-nav-card" aria-label="Settings sections">
|
||||||
|
{#each SETTINGS_SECTIONS as section}
|
||||||
|
<a class="settings-nav-link" href={workspaceRoute(workspaceId, settingsSectionHref(section.id))}>
|
||||||
|
<span>{section.label}</span>
|
||||||
|
<small>{section.status === "editable" ? "Editable" : section.status === "read-only" ? "Read-only" : "Placeholder"}</small>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card settings-section" id="runtime-connections" aria-labelledby="runtime-connections-title">
|
||||||
|
<header class="settings-section-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">editable</p>
|
||||||
|
<h2 id="runtime-connections-title">Runtime Connections</h2>
|
||||||
|
</div>
|
||||||
|
<span class="badge success">typed API</span>
|
||||||
|
</header>
|
||||||
|
<p>{SETTINGS_SECTIONS.find((section) => section.id === "runtime-connections")?.summary}</p>
|
||||||
|
|
||||||
|
{#if runtimeLoading}
|
||||||
|
<p class="status-message">Loading Runtime connections…</p>
|
||||||
|
{:else if runtimeError}
|
||||||
|
<p class="status-message error">Runtime connection settings unavailable: {runtimeError}</p>
|
||||||
|
{:else if runtimeSettings}
|
||||||
|
<div class="settings-action-row">
|
||||||
|
<button type="button" onclick={() => showAddRuntimeForm = !showAddRuntimeForm}>
|
||||||
|
{showAddRuntimeForm ? "Cancel adding Runtime" : "Add remote Runtime"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showAddRuntimeForm}
|
||||||
|
<form class="settings-runtime-form" onsubmit={(event) => { event.preventDefault(); void submitRemoteRuntime(); }}>
|
||||||
|
<h3>Add remote Runtime</h3>
|
||||||
|
<p>Endpoint is submitted to the Backend but not echoed back in settings responses.</p>
|
||||||
|
<label>
|
||||||
|
<span>Runtime id</span>
|
||||||
|
<input bind:value={remoteForm.runtime_id} required maxlength="96" pattern="[A-Za-z0-9_.-]+" placeholder="team-runtime" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Display name</span>
|
||||||
|
<input bind:value={remoteForm.display_name} maxlength="80" placeholder="Team Runtime" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Endpoint</span>
|
||||||
|
<input bind:value={remoteForm.endpoint} required inputmode="url" placeholder="https://runtime.example" />
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={submitting}>{submitting ? "Saving…" : "Add Runtime"}</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if mutationMessage}
|
||||||
|
<p class="status-message" class:error={mutationMessage.includes("failed")}>{mutationMessage}</p>
|
||||||
|
{/if}
|
||||||
|
{@render DiagnosticsList({ diagnostics: mutationDiagnostics })}
|
||||||
|
|
||||||
|
<div class="settings-runtime-list" aria-label="Runtime connections">
|
||||||
|
<h3>Runtimes</h3>
|
||||||
|
<div class="settings-runtime-table-wrap">
|
||||||
|
<table class="settings-runtime-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Runtime</th>
|
||||||
|
<th scope="col">Source</th>
|
||||||
|
<th scope="col">Connection</th>
|
||||||
|
<th scope="col">Status</th>
|
||||||
|
<th scope="col">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class:inactive={!runtimeSettings.embedded.active}>
|
||||||
|
<td>
|
||||||
|
<strong>{runtimeSettings.embedded.display_name}</strong>
|
||||||
|
<code>{runtimeSettings.embedded.runtime_id}</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<strong>embedded</strong>
|
||||||
|
<span>Workspace backend process</span>
|
||||||
|
</td>
|
||||||
|
<td>Local Backend runtime</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" class:success={runtimeSettings.embedded.active} class:warning={!runtimeSettings.embedded.active}>{runtimeSettings.embedded.status}</span>
|
||||||
|
{#if runtimeSettings.embedded.restart_required}
|
||||||
|
<span class="badge warning">restart required</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td><span class="settings-muted-action">Managed by backend</span></td>
|
||||||
|
</tr>
|
||||||
|
{#if runtimeSettings.embedded.diagnostics.length > 0}
|
||||||
|
<tr class="settings-runtime-detail-row">
|
||||||
|
<td colspan="5">{@render DiagnosticsList({ diagnostics: runtimeSettings.embedded.diagnostics })}</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{#each runtimeSettings.remotes as remote (remote.runtime_id)}
|
||||||
|
<tr class:inactive={!remote.active}>
|
||||||
|
<td>
|
||||||
|
<strong>{remote.display_name}</strong>
|
||||||
|
<code>{remote.runtime_id}</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<strong>remote</strong>
|
||||||
|
<span>Configured Runtime endpoint</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span>Endpoint: {remote.endpoint_configured ? "configured" : "not configured"}</span>
|
||||||
|
{#if remote.endpoint_configured}<small>hidden</small>{/if}
|
||||||
|
<span>Token: {remote.token_ref_configured ? "configured" : "not configured"}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" class:success={remote.active} class:warning={!remote.active}>{remote.status}</span>
|
||||||
|
{#if remote.restart_required}
|
||||||
|
<span class="badge warning">restart required</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="settings-action-row">
|
||||||
|
<button type="button" onclick={() => void testRemoteRuntime(remote.runtime_id)} disabled={testing === remote.runtime_id}>
|
||||||
|
{testing === remote.runtime_id ? "Testing…" : "Test"}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="danger" onclick={() => void deleteRemoteRuntime(remote.runtime_id)} disabled={deleting === remote.runtime_id}>
|
||||||
|
{deleting === remote.runtime_id ? "Deleting…" : "Delete"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{#if remote.diagnostics.length > 0}
|
||||||
|
<tr class="settings-runtime-detail-row">
|
||||||
|
<td colspan="5">{@render DiagnosticsList({ diagnostics: remote.diagnostics })}</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{#if tests[remote.runtime_id]}
|
||||||
|
{@const test = tests[remote.runtime_id]}
|
||||||
|
{@const available = capabilityOperations(test, "available")}
|
||||||
|
{@const unchecked = capabilityOperations(test, "unknown")}
|
||||||
|
<tr class="settings-runtime-detail-row">
|
||||||
|
<td colspan="5">
|
||||||
|
<div class="settings-test-result">
|
||||||
|
<strong>Test: {test.state}</strong>
|
||||||
|
<span>{test.health_result} · {test.checked_at}</span>
|
||||||
|
<p>{test.compatibility_basis}</p>
|
||||||
|
{#if available.length > 0}
|
||||||
|
<p class="settings-test-verified">Verified areas: {available.join(', ')}</p>
|
||||||
|
{/if}
|
||||||
|
{#if unchecked.length > 0}
|
||||||
|
<p class="settings-test-verified">Unchecked warning areas: {unchecked.join(', ')}</p>
|
||||||
|
{/if}
|
||||||
|
{@render DiagnosticsList({ diagnostics: test.diagnostics })}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{#if runtimeSettings.remotes.length === 0}
|
||||||
|
<p class="status-message">No remote Runtime connections configured.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="grid settings-grid">
|
||||||
|
{#each SETTINGS_SECTIONS.filter((section) => section.id !== "runtime-connections") as section}
|
||||||
|
<section class="card settings-section" id={section.id} aria-labelledby={`${section.id}-title`}>
|
||||||
|
<header class="settings-section-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">{section.status}</p>
|
||||||
|
<h2 id={`${section.id}-title`}>{section.label}</h2>
|
||||||
|
</div>
|
||||||
|
{#if section.status === "placeholder"}
|
||||||
|
<span class="badge neutral">not implemented</span>
|
||||||
|
{:else}
|
||||||
|
<span class="badge success">read-only</span>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
<p>{section.summary}</p>
|
||||||
|
<ul>
|
||||||
|
{#each section.bullets as bullet}
|
||||||
|
<li>{bullet}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{#if section.id === "workspace-identity"}
|
||||||
|
<dl class="settings-identity-list">
|
||||||
|
<div>
|
||||||
|
<dt>Workspace id</dt>
|
||||||
|
<dd><code>{workspace?.workspace_id ?? "loading"}</code></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Display name</dt>
|
||||||
|
<dd>{workspace?.display_name ?? "loading"}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Record authority</dt>
|
||||||
|
<dd>.yoi tickets/objectives through the Backend projection</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="card settings-patterns" aria-labelledby="settings-patterns-title">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Implementation patterns</p>
|
||||||
|
<h2 id="settings-patterns-title">How settings should appear</h2>
|
||||||
|
</div>
|
||||||
|
<div class="grid settings-pattern-grid">
|
||||||
|
{#each SETTINGS_PATTERNS as pattern}
|
||||||
|
<article class="settings-pattern">
|
||||||
|
<h3>{pattern.title}</h3>
|
||||||
|
<p>{pattern.body}</p>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if !workspace && !workspaceError}
|
||||||
|
<p class="status-message">Loading workspace summary…</p>
|
||||||
|
{:else if workspaceError}
|
||||||
|
<p class="status-message error">Workspace summary unavailable: {workspaceError}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#snippet DiagnosticsList({ diagnostics }: { diagnostics: Diagnostic[] })}
|
||||||
|
{#if diagnostics.length > 0}
|
||||||
|
<ul class="settings-diagnostics-list">
|
||||||
|
{#each diagnostics as diagnostic}
|
||||||
|
<li class={diagnostic.severity}>
|
||||||
|
<strong>{diagnosticLabel(diagnostic)}</strong>
|
||||||
|
<span>{diagnostic.message}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
Loading…
Reference in New Issue
Block a user