feat: scope workspace routes by id

This commit is contained in:
Keisuke Hirata 2026-07-07 05:26:03 +09:00
parent 34836d97ae
commit f6ad9cfcd3
No known key found for this signature in database
36 changed files with 2082 additions and 89 deletions

View File

@ -71,6 +71,8 @@ pub enum Error {
},
#[error("unknown local repository `{0}`")]
UnknownRepository(String),
#[error("workspace id does not match this Workspace backend")]
WorkspaceIdMismatch,
#[error("workspace identity error: {0}")]
WorkspaceIdentity(String),
#[error("store error: {0}")]

View File

@ -221,84 +221,193 @@ impl WorkspaceApi {
pub fn build_router(api: WorkspaceApi) -> Router {
Router::new()
.route("/api/workspace", get(get_workspace))
.route("/api/w/{workspace_id}/workspace", get(scoped_get_workspace))
.route("/api/tickets", get(list_tickets))
.route("/api/w/{workspace_id}/tickets", get(scoped_list_tickets))
.route("/api/tickets/{id}", get(get_ticket))
.route("/api/w/{workspace_id}/tickets/{id}", get(scoped_get_ticket))
.route("/api/objectives", get(list_objectives))
.route(
"/api/w/{workspace_id}/objectives",
get(scoped_list_objectives),
)
.route("/api/objectives/{id}", get(get_objective))
.route(
"/api/w/{workspace_id}/objectives/{id}",
get(scoped_get_objective),
)
.route("/api/repositories", get(list_repositories))
.route(
"/api/w/{workspace_id}/repositories",
get(scoped_list_repositories),
)
.route("/api/repositories/{repository_id}", get(repository_detail))
.route(
"/api/w/{workspace_id}/repositories/{repository_id}",
get(scoped_repository_detail),
)
.route("/api/repositories/{repository_id}/log", get(repository_log))
.route(
"/api/w/{workspace_id}/repositories/{repository_id}/log",
get(scoped_repository_log),
)
.route(
"/api/repositories/{repository_id}/tickets",
get(repository_tickets),
)
.route(
"/api/w/{workspace_id}/repositories/{repository_id}/tickets",
get(scoped_repository_tickets),
)
.route("/api/hosts", get(list_hosts))
.route("/api/w/{workspace_id}/hosts", get(scoped_list_hosts))
.route("/api/runtimes", get(list_runtimes))
.route("/api/w/{workspace_id}/runtimes", get(scoped_list_runtimes))
.route(
"/api/workers",
get(list_workers).post(create_workspace_worker),
)
.route(
"/api/w/{workspace_id}/workers",
get(scoped_list_workers).post(scoped_create_workspace_worker),
)
.route(
"/api/workers/launch-options",
get(get_worker_launch_options),
)
.route(
"/api/w/{workspace_id}/workers/launch-options",
get(scoped_get_worker_launch_options),
)
.route(
"/api/settings/runtime-connections",
get(get_runtime_connection_settings),
)
.route(
"/api/w/{workspace_id}/settings/runtime-connections",
get(scoped_get_runtime_connection_settings),
)
.route(
"/api/settings/runtime-connections/remotes",
post(add_remote_runtime_connection),
)
.route(
"/api/w/{workspace_id}/settings/runtime-connections/remotes",
post(scoped_add_remote_runtime_connection),
)
.route(
"/api/settings/runtime-connections/remotes/{runtime_id}",
delete(delete_remote_runtime_connection),
)
.route(
"/api/w/{workspace_id}/settings/runtime-connections/remotes/{runtime_id}",
delete(scoped_delete_remote_runtime_connection),
)
.route(
"/api/settings/runtime-connections/remotes/{runtime_id}/test",
post(test_remote_runtime_connection),
)
.route(
"/api/w/{workspace_id}/settings/runtime-connections/remotes/{runtime_id}/test",
post(scoped_test_remote_runtime_connection),
)
.route("/api/companion/status", get(get_companion_status))
.route(
"/api/w/{workspace_id}/companion/status",
get(scoped_get_companion_status),
)
.route("/api/companion/transcript", get(get_companion_transcript))
.route(
"/api/w/{workspace_id}/companion/transcript",
get(scoped_get_companion_transcript),
)
.route("/api/companion/messages", post(post_companion_message))
.route(
"/api/w/{workspace_id}/companion/messages",
post(scoped_post_companion_message),
)
.route("/api/companion/cancel", post(post_companion_cancel))
.route(
"/api/w/{workspace_id}/companion/cancel",
post(scoped_post_companion_cancel),
)
.route(
"/api/runtimes/{runtime_id}/workers",
post(create_runtime_worker),
)
.route(
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers",
post(scoped_create_runtime_worker),
)
.route(
"/api/runtimes/{runtime_id}/config-bundles",
post(sync_runtime_config_bundle),
)
.route(
"/api/w/{workspace_id}/runtimes/{runtime_id}/config-bundles",
post(scoped_sync_runtime_config_bundle),
)
.route(
"/api/runtimes/{runtime_id}/config-bundles/{bundle_id}/availability",
get(check_runtime_config_bundle),
)
.route(
"/api/w/{workspace_id}/runtimes/{runtime_id}/config-bundles/{bundle_id}/availability",
get(scoped_check_runtime_config_bundle),
)
.route(
"/api/runtimes/{runtime_id}/workers/{worker_id}",
get(get_runtime_worker),
)
.route(
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}",
get(scoped_get_runtime_worker),
)
.route(
"/api/runtimes/{runtime_id}/workers/{worker_id}/input",
post(send_runtime_worker_input),
)
.route(
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/input",
post(scoped_send_runtime_worker_input),
)
.route(
"/api/runtimes/{runtime_id}/workers/{worker_id}/stop",
post(stop_runtime_worker),
)
.route(
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/stop",
post(scoped_stop_runtime_worker),
)
.route(
"/api/runtimes/{runtime_id}/workers/{worker_id}/cancel",
post(cancel_runtime_worker),
)
.route(
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/cancel",
post(scoped_cancel_runtime_worker),
)
.route(
"/api/runtimes/{runtime_id}/workers/{worker_id}/transcript",
get(get_runtime_worker_transcript),
)
.route(
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/transcript",
get(scoped_get_runtime_worker_transcript),
)
.route(
"/api/runtimes/{runtime_id}/workers/{worker_id}/events/ws",
get(worker_observation_ws),
)
.route(
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/events/ws",
get(scoped_worker_observation_ws),
)
.route("/api/hosts/{host_id}/workers", get(list_host_workers))
.route(
"/api/w/{workspace_id}/hosts/{host_id}/workers",
get(scoped_list_host_workers),
)
.fallback(get(static_or_spa_fallback))
.with_state(api)
}
@ -522,6 +631,363 @@ struct TranscriptQuery {
limit: Option<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>> {
let schema_version = api.store.schema_version().await?;
let stored = api.store.get_workspace(api.workspace_id()).await?;
@ -1048,13 +1514,15 @@ async fn create_workspace_worker(
})?;
let runtime_id = worker.runtime_id.clone();
let worker_id = worker.worker_id.clone();
let workspace_id = api.workspace_id().to_string();
let console_href = format!(
"/runtimes/{}/workers/{}/console",
"/w/{}/runtimes/{}/workers/{}/console",
encode_path_segment(&workspace_id),
encode_path_segment(&runtime_id),
encode_path_segment(&worker_id)
);
Ok(Json(BrowserCreateWorkerResponse {
workspace_id: api.config.workspace_id,
workspace_id,
runtime_id,
worker_id,
console_href,
@ -2263,6 +2731,12 @@ async fn static_or_spa_fallback(State(api): State<WorkspaceApi>, uri: Uri) -> Re
.into_response();
}
if let Some(workspace_id) = workspace_id_from_ui_path(uri.path()) {
if workspace_id != api.workspace_id() {
return workspace_id_mismatch_error().into_response();
}
}
let Some(static_root) = api.config.static_assets_dir.as_ref() else {
return StatusCode::NOT_FOUND.into_response();
};
@ -2279,6 +2753,16 @@ async fn static_or_spa_fallback(State(api): State<WorkspaceApi>, uri: Uri) -> Re
}
}
fn workspace_id_from_ui_path(path: &str) -> Option<&str> {
let tail = path.strip_prefix("/w/")?;
let workspace_id = tail.split('/').next().unwrap_or_default();
if workspace_id.is_empty() {
None
} else {
Some(workspace_id)
}
}
struct StaticAsset {
bytes: Vec<u8>,
content_type: &'static str,
@ -2337,6 +2821,18 @@ fn content_type_for(path: &Path) -> &'static str {
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 {
error: Error,
diagnostics: Vec<RuntimeDiagnostic>,
@ -2366,7 +2862,8 @@ impl IntoResponse for ApiError {
| Error::UnknownHost(_)
| Error::UnknownRuntime(_)
| Error::UnknownWorker { .. }
| Error::UnknownRepository(_) => StatusCode::NOT_FOUND,
| Error::UnknownRepository(_)
| Error::WorkspaceIdMismatch => StatusCode::NOT_FOUND,
Error::Ticket(_) => StatusCode::NOT_FOUND,
Error::RuntimeCapabilityUnsupported { .. } => StatusCode::NOT_IMPLEMENTED,
Error::RuntimeOperationFailed { code, .. } if code == "repository_not_configured" => {
@ -3076,6 +3573,31 @@ mod tests {
);
}
let scoped_workspace = get_json(
app.clone(),
&format!("/api/w/{TEST_WORKSPACE_ID}/workspace"),
)
.await;
assert_eq!(scoped_workspace["workspace_id"], TEST_WORKSPACE_ID);
let mismatched_workspace = request_json(
app.clone(),
"GET",
"/api/w/not-this-workspace/workspace",
None,
StatusCode::NOT_FOUND,
)
.await;
assert_eq!(
mismatched_workspace["diagnostics"][0]["code"],
"workspace_id_mismatch"
);
assert!(
!mismatched_workspace
.to_string()
.contains(dir.path().to_string_lossy().as_ref())
);
let tickets = get_json(app.clone(), "/api/tickets").await;
assert_eq!(tickets["items"][0]["id"], "00000000001J2");
assert_eq!(tickets["items"][0]["state"], "ready");
@ -3083,6 +3605,18 @@ mod tests {
let objectives = get_json(app.clone(), "/api/objectives").await;
assert_eq!(objectives["items"][0]["id"], "00000000001J3");
assert_eq!(objectives["items"][0]["summary"], "Objective body.");
let scoped_objectives = get_json(
app.clone(),
&format!("/api/w/{TEST_WORKSPACE_ID}/objectives"),
)
.await;
assert_eq!(scoped_objectives["items"][0]["id"], "00000000001J3");
let scoped_objective = get_json(
app.clone(),
&format!("/api/w/{TEST_WORKSPACE_ID}/objectives/00000000001J3"),
)
.await;
assert_eq!(scoped_objective["id"], "00000000001J3");
let repositories = get_json(app.clone(), "/api/repositories").await;
assert_eq!(repositories["items"][0]["id"], TEST_REPOSITORY_ID);
@ -3099,6 +3633,12 @@ mod tests {
let repository_detail = get_json(app.clone(), "/api/repositories/main").await;
assert_eq!(repository_detail["item"]["id"], TEST_REPOSITORY_ID);
let scoped_repository_detail = get_json(
app.clone(),
&format!("/api/w/{TEST_WORKSPACE_ID}/repositories/main"),
)
.await;
assert_eq!(scoped_repository_detail["item"]["id"], TEST_REPOSITORY_ID);
let repository_log = get_json(app.clone(), "/api/repositories/main/log?limit=3").await;
assert_eq!(repository_log["repository_id"], TEST_REPOSITORY_ID);

View File

@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server -- serve --workspace . --db .yoi/workspace.db --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src src/lib/workspace-console/model.test.ts src/lib/workspace-console/worker-console.ui.test.ts src/lib/workspace-settings/model.test.ts src/lib/workspace-sidebar/worker-launch.test.ts src/lib/workspace-sidebar/repository-nav.test.ts",
"test": "deno test --allow-read=src src/lib/workspace-api/http.test.ts src/lib/workspace-console/model.test.ts src/lib/workspace-console/worker-console.ui.test.ts src/lib/workspace-settings/model.test.ts src/lib/workspace-sidebar/worker-launch.test.ts src/lib/workspace-sidebar/repository-nav.test.ts",
"build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview"
},

View File

@ -0,0 +1,22 @@
import { workspaceApiPath, workspaceRoute } from "./http.ts";
declare const Deno: {
test(name: string, fn: () => void): void;
};
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}`);
}
}
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",
);
});

View File

@ -3,6 +3,19 @@ export type ApiResult<T> = {
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>(
fetchFn: typeof fetch,
path: string,

View File

@ -24,8 +24,8 @@ Deno.test("workerConsoleHref encodes runtime and worker target authority", () =>
workerConsoleHref({
runtime_id: "local runtime",
worker_id: "worker/one",
}) ===
"/runtimes/local%20runtime/workers/worker%2Fone/console",
}, "workspace-1") ===
"/w/workspace-1/runtimes/local%20runtime/workers/worker%2Fone/console",
"href should contain encoded runtime_id and worker_id segments",
);
});

View File

@ -4,6 +4,7 @@ import type {
Segment,
} from "$lib/generated/protocol";
import type { WorkerTranscriptItem } from "$lib/workspace-sidebar/types";
import { workspaceRoute } from "$lib/workspace-api/http";
export type ConsoleLineKind =
| "user"
@ -40,16 +41,23 @@ export type WorkerTarget = {
worker_id: string;
};
export function workerConsoleHref(target: WorkerTarget): string {
return `/runtimes/${encodeURIComponent(target.runtime_id)}/workers/${
export function workerConsoleHref(target: WorkerTarget, workspaceId: string): string {
return workspaceRoute(
workspaceId,
`/runtimes/${encodeURIComponent(target.runtime_id)}/workers/${
encodeURIComponent(
target.worker_id,
)
}/console`;
}/console`,
);
}
export function workerConsolePath(runtimeId: string, workerId: string): string {
return workerConsoleHref({ runtime_id: runtimeId, worker_id: workerId });
export function workerConsolePath(
workspaceId: string,
runtimeId: string,
workerId: string,
): string {
return workerConsoleHref({ runtime_id: runtimeId, worker_id: workerId }, workspaceId);
}
export function initialConsoleLines(items: WorkerTranscriptItem[]): ConsoleLine[] {

View File

@ -21,12 +21,12 @@ Deno.test("workspace Worker list and sidebar attach through Worker Console hrefs
);
assert(
workspacePage.includes("workerConsoleHref(worker)") &&
workspacePage.includes("workerConsoleHref(worker, workspaceId)") &&
workspacePage.includes("Open Console"),
"top Worker list should expose an attach action per Worker",
);
assert(
workersNav.includes("workerConsoleHref(worker)") &&
workersNav.includes("workerConsoleHref(worker, workspaceId)") &&
workersNav.includes("aria-current"),
"Workers sidebar rows should link to the Worker target Console route",
);
@ -40,26 +40,28 @@ Deno.test("workspace Worker list and sidebar attach through Worker Console hrefs
Deno.test("Worker Console page is routed by runtime_id and worker_id through backend APIs", async () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
"./../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
import.meta.url,
),
);
const routeLoad = await Deno.readTextFile(
new URL(
"./../../routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.ts",
"./../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.ts",
import.meta.url,
),
);
assert(
routeLoad.includes("workspaceId") &&
routeLoad.includes("runtimeId") && routeLoad.includes("workerId"),
"route load should expose both target ids",
"route load should expose workspace and target ids",
);
assert(
consolePage.includes("workspaceApiPath(workspaceId, path)") &&
consolePage.includes(
"/api/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}",
"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(
consolePage.includes("/transcript?limit=200") &&

View File

@ -1,9 +1,13 @@
<script lang="ts">
import { workspaceRoute } from '$lib/workspace-api/http';
type Props = {
currentPath?: string;
workspaceId: string;
};
let { currentPath = '/' }: Props = $props();
let { currentPath = '/', workspaceId }: Props = $props();
let objectivesHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/objectives') : '/objectives');
</script>
<section class="nav-section">
@ -11,7 +15,7 @@
<span>Objectives</span>
</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-meta">workspace objectives</span>
</a>

View File

@ -6,10 +6,11 @@
repositories: RepositoryListResponse | null;
repositoriesError?: string | null;
currentPath?: string;
workspaceId: string;
};
let { repositories, repositoriesError = null, currentPath = '/' }: Props = $props();
let navigation = $derived(projectRepositoryNav(repositories, currentPath));
let { repositories, repositoriesError = null, currentPath = '/', workspaceId }: Props = $props();
let navigation = $derived(projectRepositoryNav(repositories, currentPath, workspaceId));
</script>
<section class="nav-section" aria-labelledby="repositories-heading">

View File

@ -1,4 +1,5 @@
<script lang="ts">
import { workspaceApiPath } from '$lib/workspace-api/http';
import { workerConsoleHref } from '$lib/workspace-console/model';
import { buildBrowserCreateWorkerRequest, defaultWorkerLaunchForm } from './worker-launch';
import type {
@ -12,9 +13,14 @@
type Props = {
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 error = $state<string | null>(null);
@ -42,7 +48,7 @@
error = null;
placeholder = null;
try {
const response = await fetch('/api/workers', { signal });
const response = await fetch(workerApiPath('/workers'), { signal });
if (response.status === 404) {
workers = [];
placeholder = 'Worker API is not integrated in this build yet.';
@ -72,7 +78,7 @@
async function loadLaunchOptions(signal?: AbortSignal) {
optionsError = null;
try {
const response = await fetch('/api/workers/launch-options', { signal });
const response = await fetch(workerApiPath('/workers/launch-options'), { signal });
if (!response.ok) {
throw new Error(`worker launch options failed (${response.status})`);
}
@ -99,7 +105,7 @@
submitError = null;
submitting = true;
try {
const response = await fetch('/api/workers', {
const response = await fetch(workerApiPath('/workers'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(buildBrowserCreateWorkerRequest({
@ -207,7 +213,7 @@
{:else}
<ul class="nav-list" aria-label="Workers">
{#each workers as worker (`${worker.runtime_id}:${worker.worker_id}`)}
{@const href = workerConsoleHref(worker)}
{@const href = workerConsoleHref(worker, workspaceId)}
<li>
<a href={href} class="nav-item worker-nav-item" class:active={currentPath === href} aria-current={currentPath === href ? 'page' : undefined}>
<span class="worker-title-row">

View File

@ -2,6 +2,7 @@
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
import WorkersNavSection from './WorkersNavSection.svelte';
import { workspaceRoute } from '$lib/workspace-api/http';
import type { RepositoryListResponse, WorkspaceResponse } from './types';
type Props = {
@ -19,6 +20,9 @@
repositoriesError = null,
currentPath = '/'
}: Props = $props();
let workspaceId = $derived(workspace?.workspace_id ?? '');
let settingsHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/settings') : '/settings');
</script>
<aside class="workspace-sidebar" aria-label="Workspace navigation">
@ -39,7 +43,7 @@
<a
class="settings-button"
href="/settings"
href={settingsHref}
aria-label="Open Settings / Admin"
title="Settings / Admin"
>
@ -48,9 +52,9 @@
</header>
<nav class="sidebar-sections" aria-label="Workspace sections">
<RepositoriesNavSection {repositories} {repositoriesError} {currentPath} />
<ObjectivesNavSection {currentPath} />
<WorkersNavSection {currentPath} />
<RepositoriesNavSection {repositories} {repositoriesError} {currentPath} {workspaceId} />
<ObjectivesNavSection {currentPath} {workspaceId} />
<WorkersNavSection {currentPath} {workspaceId} />
</nav>
</aside>

View File

@ -36,7 +36,7 @@ Deno.test("repository nav does not invent main for an empty registry", () => {
message: "No repositories configured",
},
],
});
}, "/w/workspace-1", "workspace-1");
assertEquals(projection.count, 0);
assertEquals(projection.items, []);
@ -56,14 +56,15 @@ Deno.test("repository nav links configured non-main repository ids", () => {
diagnostics: [],
},
]),
"/repositories/infra",
"/w/workspace-1/repositories/infra",
"workspace-1",
);
assertEquals(projection.count, 1);
assertEquals(projection.items[0], {
id: "infra",
title: "Infrastructure",
href: "/repositories/infra",
href: "/w/workspace-1/repositories/infra",
meta: "git repository · read-only",
active: true,
});

View File

@ -1,3 +1,4 @@
import { workspaceRoute } from "$lib/workspace-api/http";
import type { Diagnostic, RepositoryListResponse } from "./types";
export type RepositoryNavItem = {
@ -16,14 +17,15 @@ export type RepositoryNavProjection = {
export function projectRepositoryNav(
repositories: RepositoryListResponse | null,
currentPath = "/",
currentPath: string,
workspaceId: string,
): RepositoryNavProjection {
const summaries = repositories?.items ?? [];
return {
count: summaries.length,
diagnostics: repositories?.diagnostics ?? [],
items: summaries.map((repository) => {
const href = `/repositories/${encodeURIComponent(repository.id)}`;
const href = workspaceRoute(workspaceId, `/repositories/${encodeURIComponent(repository.id)}`);
return {
id: repository.id,
title: repository.display_name || repository.id,

View File

@ -1,4 +1,4 @@
import { loadJson } from "$lib/workspace-api/http";
import { loadJson, workspaceApiPath } from "$lib/workspace-api/http";
import type {
RepositoryListResponse,
WorkspaceResponse,
@ -8,10 +8,14 @@ import type { LayoutLoad } from "./$types";
export const ssr = false;
export const prerender = false;
export const load: LayoutLoad = async ({ fetch }) => {
export const load: LayoutLoad = async ({ fetch, params }) => {
const workspaceId = params.workspaceId;
const apiPath = (path: string) =>
workspaceId ? workspaceApiPath(workspaceId, path) : `/api${path}`;
const [workspace, repositories] = await Promise.all([
loadJson<WorkspaceResponse>(fetch, "/api/workspace"),
loadJson<RepositoryListResponse>(fetch, "/api/repositories"),
loadJson<WorkspaceResponse>(fetch, apiPath("/workspace")),
loadJson<RepositoryListResponse>(fetch, apiPath("/repositories")),
]);
return {

View File

@ -3,6 +3,7 @@
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let workspaceId = $derived(data.workspace?.workspace_id ?? '');
</script>
<svelte:head>
@ -116,7 +117,7 @@
<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>
<td><a class="inline-link" href={workerConsoleHref(worker, workspaceId)}>Open Console</a></td>
</tr>
{/each}
</tbody>

View File

@ -1,11 +1,15 @@
import { loadJson } from "$lib/workspace-api/http";
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 }) => {
export const load: PageLoad = async ({ fetch, parent }) => {
const layout = await parent();
const workspaceId = layout.workspace?.workspace_id ?? "";
const apiPath = (path: string) => workspaceApiPath(workspaceId, path);
const [hosts, workers] = await Promise.all([
loadJson<ListResponse<Host>>(fetch, "/api/hosts"),
loadJson<ListResponse<Worker>>(fetch, "/api/workers"),
loadJson<ListResponse<Host>>(fetch, apiPath("/hosts")),
loadJson<ListResponse<Worker>>(fetch, apiPath("/workers")),
]);
return {

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { formatDate } from '$lib/workspace-api/http';
import { formatDate, workspaceRoute } from '$lib/workspace-api/http';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
@ -18,7 +18,7 @@
{:else}
<div class="objective-list">
{#each data.objectives.items as objective (objective.id)}
<a class="objective-row" href={`/objectives/${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>

View File

@ -1,14 +1,16 @@
import { loadJson } from "$lib/workspace-api/http";
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 }) => {
export const load: PageLoad = async ({ fetch, parent }) => {
const layout = await parent();
const workspaceId = layout.workspace?.workspace_id ?? "";
const objectives = await loadJson<ObjectiveListResponse>(
fetch,
"/api/objectives",
workspaceApiPath(workspaceId, "/objectives"),
);
return {
workspaceId,
objectives: objectives.data,
objectivesError: objectives.error,
};

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { formatDate } from '$lib/workspace-api/http';
import { formatDate, workspaceRoute } from '$lib/workspace-api/http';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
@ -14,7 +14,7 @@
{#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}`}>
<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>

View File

@ -1,21 +1,22 @@
import { loadJson } from "$lib/workspace-api/http";
import type {
ObjectiveDetail,
ObjectiveListResponse,
} from "$lib/workspace-sidebar/types";
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 }) => {
export const load: PageLoad = async ({ fetch, params, parent }) => {
const layout = await parent();
const workspaceId = layout.workspace?.workspace_id ?? "";
const apiPath = (path: string) => workspaceApiPath(workspaceId, path);
const objectiveId = params.objectiveId;
const [objectives, objective] = await Promise.all([
loadJson<ObjectiveListResponse>(fetch, "/api/objectives"),
loadJson<ObjectiveListResponse>(fetch, apiPath("/objectives")),
loadJson<ObjectiveDetail>(
fetch,
`/api/objectives/${encodeURIComponent(objectiveId)}`,
apiPath(`/objectives/${encodeURIComponent(objectiveId)}`),
),
]);
return {
workspaceId,
objectiveId,
objectives: objectives.data,
objectivesError: objectives.error,

View File

@ -1,4 +1,4 @@
import { loadJson } from "$lib/workspace-api/http";
import { loadJson, workspaceApiPath } from "$lib/workspace-api/http";
import type {
RepositoryDetailResponse,
RepositoryLogResponse,
@ -6,20 +6,23 @@ import type {
} from "$lib/workspace-sidebar/types";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
export const load: PageLoad = async ({ fetch, params, parent }) => {
const layout = await parent();
const workspaceId = layout.workspace?.workspace_id ?? "";
const apiPath = (path: string) => workspaceApiPath(workspaceId, path);
const repositoryId = params.repositoryId;
const [repository, log, tickets] = await Promise.all([
loadJson<RepositoryDetailResponse>(
fetch,
`/api/repositories/${encodeURIComponent(repositoryId)}`,
apiPath(`/repositories/${encodeURIComponent(repositoryId)}`),
),
loadJson<RepositoryLogResponse>(
fetch,
`/api/repositories/${encodeURIComponent(repositoryId)}/log`,
apiPath(`/repositories/${encodeURIComponent(repositoryId)}/log`),
),
loadJson<RepositoryTicketsResponse>(
fetch,
`/api/repositories/${encodeURIComponent(repositoryId)}/tickets`,
apiPath(`/repositories/${encodeURIComponent(repositoryId)}/tickets`),
),
]);

View File

@ -3,6 +3,7 @@
projectConsole,
type ConsoleLine
} from '$lib/workspace-console/model';
import { workspaceApiPath } from '$lib/workspace-api/http';
import type {
ClientWorkerEventWsFrame,
Diagnostic,
@ -13,6 +14,7 @@
type Props = {
data: {
workspaceId: string;
runtimeId: string;
workerId: string;
};
@ -20,9 +22,14 @@
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);
@ -93,7 +100,7 @@
workerError = null;
try {
worker = await getJson<Worker>(
`/api/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}`
workerApiPath(`/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}`)
);
} catch (error) {
workerError = error instanceof Error ? error.message : String(error);
@ -105,7 +112,7 @@
transcriptError = null;
try {
transcript = await getJson<WorkerTranscriptProjection>(
`/api/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}/transcript?limit=200`
workerApiPath(`/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}/transcript?limit=200`)
);
} catch (error) {
transcriptError = error instanceof Error ? error.message : String(error);
@ -134,7 +141,7 @@
sendError = null;
try {
const result = await postJson<WorkerInputResult>(
`/api/runtimes/${encodeURIComponent(runtimeId)}/workers/${encodeURIComponent(workerId)}/input`,
workerApiPath(`/runtimes/${encodeURIComponent(runtimeId)}/workers/${encodeURIComponent(workerId)}/input`),
{ kind: 'user', content }
);
if (result.state === 'accepted') {
@ -157,11 +164,10 @@
}
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(
const wsPath = workerApiPath(`/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(
target.workerId
)}/events/ws`
);
)}/events/ws`);
const ws = new WebSocket(`${protocol}//${window.location.host}${wsPath}`);
ws.onopen = () => {
if (token === reloadToken) {

View File

@ -1,8 +1,10 @@
export function load(
{ params }: { params: { runtimeId: string; workerId: string } },
) {
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ params, parent }) => {
const layout = await parent();
return {
workspaceId: layout.workspace?.workspace_id ?? "",
runtimeId: params.runtimeId,
workerId: params.workerId,
};
}
};

View File

@ -1,5 +1,6 @@
<script lang="ts">
import type { PageProps } from "./$types";
import { workspaceApiPath, workspaceRoute } from "$lib/workspace-api/http";
import {
SETTINGS_PATTERNS,
SETTINGS_PERMISSION_NOTICE,
@ -21,8 +22,13 @@
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);
@ -40,13 +46,18 @@
});
$effect(() => {
if (!workspaceId) {
runtimeLoading = false;
return;
}
let cancelled = false;
async function loadRuntimeSettings() {
runtimeLoading = true;
runtimeError = null;
try {
const response = await fetch("/api/settings/runtime-connections");
const response = await fetch(settingsApiPath('/settings/runtime-connections'));
if (!response.ok) {
throw new Error(`runtime settings request failed (${response.status})`);
}
@ -77,7 +88,7 @@
mutationMessage = null;
mutationDiagnostics = [];
try {
const response = await fetch("/api/settings/runtime-connections/remotes", {
const response = await fetch(settingsApiPath('/settings/runtime-connections/remotes'), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
@ -105,7 +116,7 @@
mutationMessage = null;
mutationDiagnostics = [];
try {
const response = await fetch(`/api/settings/runtime-connections/remotes/${encodeURIComponent(runtimeId)}`, {
const response = await fetch(settingsApiPath(`/settings/runtime-connections/remotes/${encodeURIComponent(runtimeId)}`), {
method: "DELETE",
});
if (!response.ok) {
@ -126,7 +137,7 @@
async function testRemoteRuntime(runtimeId: string) {
testing = runtimeId;
try {
const response = await fetch(`/api/settings/runtime-connections/remotes/${encodeURIComponent(runtimeId)}/test`, {
const response = await fetch(settingsApiPath(`/settings/runtime-connections/remotes/${encodeURIComponent(runtimeId)}/test`), {
method: "POST",
});
if (!response.ok) {
@ -240,7 +251,7 @@
<section class="settings-nav-card" aria-label="Settings sections">
{#each SETTINGS_SECTIONS as section}
<a class="settings-nav-link" href={settingsSectionHref(section.id)}>
<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>

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

View 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,
};
};

View File

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

View 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,
};
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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}