merge: workspace profile settings
# Conflicts: # .yoi/tickets/00001KX0DSMPT/item.md # .yoi/tickets/00001KX0DSMPT/thread.md
This commit is contained in:
commit
0bf3638c64
|
|
@ -392,6 +392,7 @@ fn spawn_companion_worker(runtime: &RuntimeRegistry) -> CompanionWorkerState {
|
|||
working_directory_request: None,
|
||||
resolved_working_directory_request: None,
|
||||
resolved_working_directory: None,
|
||||
resolved_config_bundle: None,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -307,6 +307,8 @@ pub struct WorkerSpawnRequest {
|
|||
pub resolved_working_directory_request: Option<WorkingDirectoryRequest>,
|
||||
#[serde(skip, default)]
|
||||
pub resolved_working_directory: Option<WorkingDirectoryClaim>,
|
||||
#[serde(skip, default)]
|
||||
pub resolved_config_bundle: Option<ConfigBundle>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
|
|
@ -1348,17 +1350,23 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
|||
.clone()
|
||||
.unwrap_or_else(|| embedded_profile_selector(&request.intent));
|
||||
let runtime_id = EmbeddedRuntimeId::new(self.runtime_id.clone());
|
||||
let config_bundle = match default_embedded_config_bundle(
|
||||
&profile,
|
||||
&self.host_id,
|
||||
runtime_id.as_ref(),
|
||||
&self.resource_broker,
|
||||
)
|
||||
.and_then(|bundle| {
|
||||
self.runtime
|
||||
.store_config_bundle(bundle)
|
||||
.map_err(|err| err.to_string())
|
||||
}) {
|
||||
let config_bundle = match request
|
||||
.resolved_config_bundle
|
||||
.clone()
|
||||
.map(Ok)
|
||||
.unwrap_or_else(|| {
|
||||
default_embedded_config_bundle(
|
||||
&profile,
|
||||
&self.host_id,
|
||||
runtime_id.as_ref(),
|
||||
&self.resource_broker,
|
||||
)
|
||||
})
|
||||
.and_then(|bundle| {
|
||||
self.runtime
|
||||
.store_config_bundle(bundle)
|
||||
.map_err(|err| err.to_string())
|
||||
}) {
|
||||
Ok(availability) => availability.reference,
|
||||
Err(error) => {
|
||||
diagnostics.push(diagnostic(
|
||||
|
|
@ -2048,12 +2056,18 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||
.clone()
|
||||
.unwrap_or_else(|| embedded_profile_selector(&request.intent));
|
||||
let runtime_id = EmbeddedRuntimeId::new(self.runtime_id.clone());
|
||||
let sync = match default_embedded_config_bundle(
|
||||
&profile,
|
||||
&self.host_id,
|
||||
runtime_id.as_ref(),
|
||||
&self.resource_broker,
|
||||
) {
|
||||
let sync = match request
|
||||
.resolved_config_bundle
|
||||
.clone()
|
||||
.map(Ok)
|
||||
.unwrap_or_else(|| {
|
||||
default_embedded_config_bundle(
|
||||
&profile,
|
||||
&self.host_id,
|
||||
runtime_id.as_ref(),
|
||||
&self.resource_broker,
|
||||
)
|
||||
}) {
|
||||
Ok(bundle) => self.sync_config_bundle(bundle),
|
||||
Err(error) => ConfigBundleSyncResult {
|
||||
state: WorkerOperationState::Rejected,
|
||||
|
|
@ -3398,6 +3412,7 @@ mod tests {
|
|||
working_directory_request: None,
|
||||
resolved_working_directory_request: None,
|
||||
resolved_working_directory: None,
|
||||
resolved_config_bundle: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3530,6 +3545,7 @@ mod tests {
|
|||
working_directory_request: None,
|
||||
resolved_working_directory_request: None,
|
||||
resolved_working_directory: None,
|
||||
resolved_config_bundle: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
|
@ -3632,6 +3648,7 @@ mod tests {
|
|||
working_directory_request: None,
|
||||
resolved_working_directory_request: None,
|
||||
resolved_working_directory: None,
|
||||
resolved_config_bundle: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
|
@ -3663,6 +3680,7 @@ mod tests {
|
|||
working_directory_request: None,
|
||||
resolved_working_directory_request: None,
|
||||
resolved_working_directory: None,
|
||||
resolved_config_bundle: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ pub mod config;
|
|||
pub mod hosts;
|
||||
pub mod identity;
|
||||
pub mod observation;
|
||||
pub mod profile_settings;
|
||||
pub mod records;
|
||||
pub mod repositories;
|
||||
pub mod resource_broker;
|
||||
|
|
|
|||
1478
crates/workspace-server/src/profile_settings.rs
Normal file
1478
crates/workspace-server/src/profile_settings.rs
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -6,7 +6,7 @@ use axum::extract::{Path as AxumPath, Query, State};
|
|||
use axum::http::header::{CONTENT_TYPE, LOCATION};
|
||||
use axum::http::{StatusCode, Uri};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{delete, get, post};
|
||||
use axum::routing::{delete, get, post, put};
|
||||
use axum::{Json, Router};
|
||||
use chrono::{SecondsFormat, Utc};
|
||||
use futures::StreamExt;
|
||||
|
|
@ -36,6 +36,11 @@ use crate::observation::{
|
|||
BackendObservationProxy, ClientWorkerEventWsFrame, ClientWorkerEventsWsQuery,
|
||||
ObservationProxyError, RuntimeObservationClient, RuntimeObservationSourceConfig,
|
||||
};
|
||||
use crate::profile_settings::{
|
||||
CreateWorkspaceProfileSourceRequest, DeleteWorkspaceProfileSourceRequest,
|
||||
UpdateWorkspaceMetadataRequest, UpdateWorkspaceProfileRegistryRequest,
|
||||
UpdateWorkspaceProfileSourceRequest,
|
||||
};
|
||||
use crate::records::{
|
||||
LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary,
|
||||
};
|
||||
|
|
@ -264,6 +269,24 @@ 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/w/{workspace_id}/settings/workspace",
|
||||
get(scoped_get_workspace_settings).put(scoped_update_workspace_settings),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/settings/profiles",
|
||||
get(scoped_get_profile_settings).post(scoped_create_profile_source),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/settings/profiles/registry",
|
||||
put(scoped_update_profile_registry),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/settings/profiles/{profile_source_id}",
|
||||
get(scoped_get_profile_source)
|
||||
.put(scoped_update_profile_source)
|
||||
.delete(scoped_delete_profile_source),
|
||||
)
|
||||
.route("/api/tickets", get(list_tickets))
|
||||
.route("/api/w/{workspace_id}/tickets", get(scoped_list_tickets))
|
||||
.route("/api/tickets/{id}", get(get_ticket))
|
||||
|
|
@ -814,6 +837,116 @@ async fn scoped_get_workspace(
|
|||
get_workspace(State(api)).await
|
||||
}
|
||||
|
||||
async fn scoped_get_workspace_settings(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
) -> ApiResult<Json<crate::profile_settings::WorkspaceMetadataSettingsResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
Ok(Json(crate::profile_settings::workspace_metadata_settings(
|
||||
&api.config.workspace_root,
|
||||
&api.config.workspace_id,
|
||||
&api.config.workspace_created_at,
|
||||
&api.config.workspace_display_name,
|
||||
)))
|
||||
}
|
||||
|
||||
async fn scoped_update_workspace_settings(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
Json(request): Json<UpdateWorkspaceMetadataRequest>,
|
||||
) -> ApiResult<Json<crate::profile_settings::WorkspaceMetadataMutationResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
let workspace =
|
||||
crate::profile_settings::update_workspace_metadata(&api.config.workspace_root, request)?;
|
||||
Ok(Json(
|
||||
crate::profile_settings::WorkspaceMetadataMutationResponse {
|
||||
workspace,
|
||||
diagnostics: vec![RuntimeDiagnostic {
|
||||
code: "workspace_metadata_updated".to_string(),
|
||||
severity: DiagnosticSeverity::Info,
|
||||
message: "Workspace display metadata was updated.".to_string(),
|
||||
}],
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
async fn scoped_get_profile_settings(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
) -> ApiResult<Json<crate::profile_settings::ProfileSettingsResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
Ok(Json(crate::profile_settings::load_profile_settings(
|
||||
&api.config.workspace_id,
|
||||
&api.config.workspace_root,
|
||||
)))
|
||||
}
|
||||
|
||||
async fn scoped_create_profile_source(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
Json(request): Json<CreateWorkspaceProfileSourceRequest>,
|
||||
) -> ApiResult<Json<crate::profile_settings::ProfileSettingsMutationResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
Ok(Json(crate::profile_settings::create_profile_source(
|
||||
&api.config.workspace_id,
|
||||
&api.config.workspace_root,
|
||||
request,
|
||||
)?))
|
||||
}
|
||||
|
||||
async fn scoped_update_profile_registry(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
Json(request): Json<UpdateWorkspaceProfileRegistryRequest>,
|
||||
) -> ApiResult<Json<crate::profile_settings::ProfileSettingsMutationResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
Ok(Json(crate::profile_settings::update_profile_registry(
|
||||
&api.config.workspace_id,
|
||||
&api.config.workspace_root,
|
||||
request,
|
||||
)?))
|
||||
}
|
||||
|
||||
async fn scoped_get_profile_source(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>,
|
||||
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceDetailResponse>> {
|
||||
validate_workspace_scope(&api, &workspace_id)?;
|
||||
Ok(Json(crate::profile_settings::read_profile_source(
|
||||
&api.config.workspace_id,
|
||||
&api.config.workspace_root,
|
||||
&profile_source_id,
|
||||
)?))
|
||||
}
|
||||
|
||||
async fn scoped_update_profile_source(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>,
|
||||
Json(request): Json<UpdateWorkspaceProfileSourceRequest>,
|
||||
) -> ApiResult<Json<crate::profile_settings::ProfileSettingsMutationResponse>> {
|
||||
validate_workspace_scope(&api, &workspace_id)?;
|
||||
Ok(Json(crate::profile_settings::update_profile_source(
|
||||
&api.config.workspace_id,
|
||||
&api.config.workspace_root,
|
||||
&profile_source_id,
|
||||
request,
|
||||
)?))
|
||||
}
|
||||
|
||||
async fn scoped_delete_profile_source(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>,
|
||||
Json(request): Json<DeleteWorkspaceProfileSourceRequest>,
|
||||
) -> ApiResult<Json<crate::profile_settings::ProfileSettingsMutationResponse>> {
|
||||
validate_workspace_scope(&api, &workspace_id)?;
|
||||
Ok(Json(crate::profile_settings::delete_profile_source(
|
||||
&api.config.workspace_id,
|
||||
&api.config.workspace_root,
|
||||
&profile_source_id,
|
||||
request,
|
||||
)?))
|
||||
}
|
||||
|
||||
async fn scoped_list_tickets(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
|
|
@ -1639,12 +1772,24 @@ async fn create_workspace_worker(
|
|||
State(api): State<WorkspaceApi>,
|
||||
Json(request): Json<BrowserCreateWorkerRequest>,
|
||||
) -> ApiResult<Json<BrowserCreateWorkerResponse>> {
|
||||
let profile_selector = profile_selector_for_candidate(&request.profile).ok_or_else(|| {
|
||||
settings_bad_request(
|
||||
"unsupported_worker_profile",
|
||||
"profile must be selected from Backend-published worker profile candidates",
|
||||
)
|
||||
})?;
|
||||
let profile_selector =
|
||||
profile_selector_for_candidate_with_root(&api.config.workspace_root, &request.profile)
|
||||
.ok_or_else(|| {
|
||||
settings_bad_request(
|
||||
"unsupported_worker_profile",
|
||||
"profile must be selected from Backend-published worker profile candidates",
|
||||
)
|
||||
})?;
|
||||
let resolved_config_bundle = if request.profile.starts_with("project:") {
|
||||
crate::profile_settings::build_workspace_profile_config_bundle(
|
||||
&api.config.workspace_root,
|
||||
&api.config.workspace_id,
|
||||
&api.config.workspace_created_at,
|
||||
&request.profile,
|
||||
)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let display_name = sanitize_worker_display_name(&request.display_name).ok_or_else(|| {
|
||||
settings_bad_request(
|
||||
"invalid_worker_display_name",
|
||||
|
|
@ -1702,6 +1847,7 @@ async fn create_workspace_worker(
|
|||
working_directory_request: None,
|
||||
resolved_working_directory_request: None,
|
||||
resolved_working_directory,
|
||||
resolved_config_bundle,
|
||||
},
|
||||
)
|
||||
.map_err(|err| err.into_error())?;
|
||||
|
|
@ -2753,7 +2899,7 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> WorkerLaunchOptionsResp
|
|||
WorkerLaunchOptionsResponse {
|
||||
workspace_id: api.config.workspace_id.clone(),
|
||||
runtimes,
|
||||
profiles: worker_profile_candidates(),
|
||||
profiles: worker_profile_candidates_for_root(&api.config.workspace_root),
|
||||
repositories: working_directory_repository_options(api),
|
||||
working_directories: working_directory_summaries(api).unwrap_or_default(),
|
||||
diagnostics: Vec::new(),
|
||||
|
|
@ -2823,8 +2969,16 @@ fn working_directory_request_for_browser(
|
|||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn worker_profile_candidates() -> Vec<WorkerLaunchProfileCandidate> {
|
||||
vec![
|
||||
worker_profile_candidates_for_root(Path::new("."))
|
||||
.into_iter()
|
||||
.filter(|candidate| candidate.id == "builtin:coder" || candidate.id == "runtime_default")
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn worker_profile_candidates_for_root(workspace_root: &Path) -> Vec<WorkerLaunchProfileCandidate> {
|
||||
let mut candidates = vec![
|
||||
WorkerLaunchProfileCandidate {
|
||||
id: "builtin:coder".to_string(),
|
||||
label: "Coding Worker".to_string(),
|
||||
|
|
@ -2835,14 +2989,37 @@ fn worker_profile_candidates() -> Vec<WorkerLaunchProfileCandidate> {
|
|||
label: "Runtime default".to_string(),
|
||||
description: "Use the selected Runtime's default profile.".to_string(),
|
||||
},
|
||||
]
|
||||
];
|
||||
candidates.extend(
|
||||
crate::profile_settings::project_profile_candidates(workspace_root)
|
||||
.into_iter()
|
||||
.filter(|profile| profile.diagnostics.is_empty())
|
||||
.map(|profile| WorkerLaunchProfileCandidate {
|
||||
id: profile.profile_id,
|
||||
label: profile.label,
|
||||
description: profile
|
||||
.description
|
||||
.unwrap_or_else(|| "Workspace Decodal profile source.".to_string()),
|
||||
}),
|
||||
);
|
||||
candidates
|
||||
}
|
||||
|
||||
fn profile_selector_for_candidate(profile: &str) -> Option<ProfileSelector> {
|
||||
match profile {
|
||||
"builtin:coder" => Some(ProfileSelector::Builtin("builtin:coder".to_string())),
|
||||
"runtime_default" => Some(ProfileSelector::RuntimeDefault),
|
||||
_ => None,
|
||||
crate::profile_settings::selector_for_builtin_candidate(profile)
|
||||
.filter(|_| matches!(profile, "builtin:coder" | "runtime_default"))
|
||||
}
|
||||
|
||||
fn profile_selector_for_candidate_with_root(
|
||||
workspace_root: &Path,
|
||||
profile: &str,
|
||||
) -> Option<ProfileSelector> {
|
||||
if profile_selector_for_candidate(profile).is_some() {
|
||||
profile_selector_for_candidate(profile)
|
||||
} else if crate::profile_settings::is_profile_candidate(workspace_root, profile) {
|
||||
crate::profile_settings::selector_for_builtin_candidate(profile)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3184,10 +3361,15 @@ struct ApiError {
|
|||
|
||||
impl From<Error> for ApiError {
|
||||
fn from(error: Error) -> Self {
|
||||
Self {
|
||||
error,
|
||||
diagnostics: Vec::new(),
|
||||
}
|
||||
let diagnostics = match &error {
|
||||
Error::RuntimeOperationFailed { code, message, .. } => vec![RuntimeDiagnostic {
|
||||
code: code.clone(),
|
||||
severity: DiagnosticSeverity::Error,
|
||||
message: sanitize_backend_error(message),
|
||||
}],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
Self { error, diagnostics }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3227,6 +3409,23 @@ impl IntoResponse for ApiError {
|
|||
Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_unsupported" => {
|
||||
StatusCode::NOT_IMPLEMENTED
|
||||
}
|
||||
Error::RuntimeOperationFailed { code, .. }
|
||||
if code == "profile_registry_revision_conflict"
|
||||
|| code == "profile_source_revision_conflict"
|
||||
|| code == "workspace_metadata_revision_conflict" =>
|
||||
{
|
||||
StatusCode::CONFLICT
|
||||
}
|
||||
Error::RuntimeOperationFailed { code, .. }
|
||||
if code == "unknown_profile_source" || code == "unknown_profile_selector" =>
|
||||
{
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
Error::RuntimeOperationFailed { code, .. }
|
||||
if code == "workspace_display_name_invalid" || code.starts_with("profile_") =>
|
||||
{
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
Error::RuntimeOperationFailed { code, .. } if code.ends_with("_blocked") => {
|
||||
StatusCode::CONFLICT
|
||||
}
|
||||
|
|
@ -3244,12 +3443,18 @@ impl IntoResponse for ApiError {
|
|||
Error::RuntimeOperationFailed { .. } => StatusCode::BAD_GATEWAY,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
let response_message = match &self.error {
|
||||
Error::RuntimeOperationFailed { code, message, .. } => {
|
||||
format!("{code}: {}", sanitize_backend_error(message))
|
||||
}
|
||||
_ => sanitize_backend_error(&self.error.to_string()),
|
||||
};
|
||||
(
|
||||
status,
|
||||
[(CONTENT_TYPE, "application/json")],
|
||||
Json(serde_json::json!({
|
||||
"error": status.canonical_reason().unwrap_or("error"),
|
||||
"message": self.error.to_string(),
|
||||
"message": response_message,
|
||||
"diagnostics": self.diagnostics,
|
||||
}))
|
||||
.to_string(),
|
||||
|
|
@ -3265,7 +3470,7 @@ mod tests {
|
|||
use axum::http::Request;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use std::{fs, sync::Arc};
|
||||
use tokio_tungstenite::connect_async;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tower::ServiceExt;
|
||||
|
|
@ -3322,6 +3527,210 @@ mod tests {
|
|||
assert!(!sanitized.contains("/home/example"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_settings_api_returns_typed_diagnostics_for_duplicate_selector() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let response = profile_settings_request(
|
||||
dir.path(),
|
||||
"POST",
|
||||
"/settings/profiles",
|
||||
json!({
|
||||
"name": "coder",
|
||||
"content": valid_profile_source("coder"),
|
||||
"registry_revision": "missing"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.0, StatusCode::BAD_REQUEST);
|
||||
assert_diagnostic(&response.1, "profile_selector_duplicate");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_settings_api_returns_typed_diagnostics_for_invalid_decodal() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let response = profile_settings_request(
|
||||
dir.path(),
|
||||
"POST",
|
||||
"/settings/profiles",
|
||||
json!({
|
||||
"name": "bad",
|
||||
"content": "not decodal",
|
||||
"registry_revision": "missing"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.0, StatusCode::BAD_REQUEST);
|
||||
assert!(
|
||||
diagnostic_codes(&response.1)
|
||||
.iter()
|
||||
.any(|code| code.starts_with("profile_source_"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_settings_api_returns_typed_diagnostic_for_invalid_registry_schema() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(dir.path().join(".yoi")).unwrap();
|
||||
fs::write(dir.path().join(".yoi/profiles.toml"), "[profile\n").unwrap();
|
||||
let response = profile_settings_request(
|
||||
dir.path(),
|
||||
"POST",
|
||||
"/settings/profiles",
|
||||
json!({
|
||||
"name": "schema",
|
||||
"content": valid_profile_source("schema"),
|
||||
"registry_revision": test_file_revision(&dir.path().join(".yoi/profiles.toml"))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.0, StatusCode::BAD_REQUEST);
|
||||
assert_diagnostic(&response.1, "profile_registry_schema_invalid");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_settings_api_returns_conflict_diagnostic_for_stale_revision() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let response = profile_settings_request(
|
||||
dir.path(),
|
||||
"POST",
|
||||
"/settings/profiles",
|
||||
json!({
|
||||
"name": "alpha",
|
||||
"content": valid_profile_source("alpha"),
|
||||
"registry_revision": "stale"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.0, StatusCode::CONFLICT);
|
||||
assert_diagnostic(&response.1, "profile_registry_revision_conflict");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_settings_api_returns_typed_diagnostic_for_too_large_source() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let response = profile_settings_request(
|
||||
dir.path(),
|
||||
"POST",
|
||||
"/settings/profiles",
|
||||
json!({
|
||||
"name": "large",
|
||||
"content": "x".repeat((256 * 1024) + 1),
|
||||
"registry_revision": "missing"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.0, StatusCode::BAD_REQUEST);
|
||||
assert_diagnostic(&response.1, "profile_source_too_large");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn profile_settings_api_redacts_symlink_escape_response() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(dir.path().join(".yoi/profiles")).unwrap();
|
||||
fs::write(dir.path().join(".yoi/profiles.toml"), "").unwrap();
|
||||
let outside = dir.path().join("outside.dcdl");
|
||||
fs::write(&outside, valid_profile_source("escape")).unwrap();
|
||||
std::os::unix::fs::symlink(&outside, dir.path().join(".yoi/profiles/escape.dcdl")).unwrap();
|
||||
let response = profile_settings_request(
|
||||
dir.path(),
|
||||
"PUT",
|
||||
"/settings/profiles/registry",
|
||||
json!({
|
||||
"registry_revision": test_file_revision(&dir.path().join(".yoi/profiles.toml")),
|
||||
"default_profile": null,
|
||||
"profiles": [{ "name": "escape", "profile_source_id": "project:escape" }]
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.0, StatusCode::BAD_REQUEST);
|
||||
assert_diagnostic(&response.1, "profile_source_symlink_escape");
|
||||
let rendered = response.1.to_string();
|
||||
assert!(!rendered.contains(dir.path().to_string_lossy().as_ref()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_launch_rejects_invalid_project_profile_candidate() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(dir.path().join(".yoi/profiles")).unwrap();
|
||||
fs::write(
|
||||
dir.path().join(".yoi/profiles.toml"),
|
||||
"[profile.bad]\npath = \"profiles/bad.dcdl\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(dir.path().join(".yoi/profiles/bad.dcdl"), "not decodal").unwrap();
|
||||
assert!(
|
||||
worker_profile_candidates_for_root(dir.path())
|
||||
.iter()
|
||||
.all(|candidate| candidate.id != "project:bad")
|
||||
);
|
||||
assert!(profile_selector_for_candidate_with_root(dir.path(), "project:bad").is_none());
|
||||
}
|
||||
|
||||
fn valid_profile_source(slug: &str) -> String {
|
||||
format!(
|
||||
r#"{{
|
||||
slug = "{slug}";
|
||||
description = "Test";
|
||||
scope = "workspace_read";
|
||||
}}"#
|
||||
)
|
||||
}
|
||||
|
||||
fn test_file_revision(path: &Path) -> String {
|
||||
let Ok(metadata) = fs::metadata(path) else {
|
||||
return "missing".to_string();
|
||||
};
|
||||
let modified = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|duration| duration.as_nanos())
|
||||
.unwrap_or_default();
|
||||
format!("rev:{modified}:{}", metadata.len())
|
||||
}
|
||||
|
||||
async fn profile_settings_request(
|
||||
workspace_root: &Path,
|
||||
method: &str,
|
||||
path: &str,
|
||||
body: Value,
|
||||
) -> (StatusCode, Value) {
|
||||
let app = test_app(workspace_root.to_path_buf()).await;
|
||||
let request = Request::builder()
|
||||
.method(method)
|
||||
.uri(format!("/api/w/{TEST_WORKSPACE_ID}{path}"))
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap();
|
||||
let response = app.oneshot(request).await.unwrap();
|
||||
let status = response.status();
|
||||
let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
|
||||
let json = serde_json::from_slice::<Value>(&bytes).unwrap();
|
||||
(status, json)
|
||||
}
|
||||
|
||||
fn diagnostic_codes(response: &Value) -> Vec<String> {
|
||||
response["diagnostics"]
|
||||
.as_array()
|
||||
.expect("diagnostics array")
|
||||
.iter()
|
||||
.map(|diagnostic| diagnostic["code"].as_str().unwrap().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn assert_diagnostic(response: &Value, code: &str) {
|
||||
let codes = diagnostic_codes(response);
|
||||
assert!(
|
||||
!codes.is_empty(),
|
||||
"diagnostics must not be empty: {response}"
|
||||
);
|
||||
assert!(
|
||||
codes.iter().any(|actual| actual == code),
|
||||
"missing {code}: {codes:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DeterministicExecutionBackend {
|
||||
contexts: std::sync::Mutex<
|
||||
|
|
@ -4599,6 +5008,7 @@ mod tests {
|
|||
working_directory_request: None,
|
||||
resolved_working_directory_request: None,
|
||||
resolved_working_directory: None,
|
||||
resolved_config_bundle: None,
|
||||
},
|
||||
)
|
||||
.expect("spawn worker");
|
||||
|
|
|
|||
|
|
@ -38,6 +38,31 @@ export async function loadJson<T>(
|
|||
}
|
||||
}
|
||||
|
||||
async function requireJson<T>(response: Response, path: string): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || `${path} request failed (${response.status})`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export async function workspaceApiJson<T>(path: string): Promise<T> {
|
||||
return requireJson<T>(await fetch(path), path);
|
||||
}
|
||||
|
||||
export async function workspaceApiJsonWithBody<T>(path: string, init: RequestInit): Promise<T> {
|
||||
return requireJson<T>(
|
||||
await fetch(path, {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
...init,
|
||||
}),
|
||||
path,
|
||||
);
|
||||
}
|
||||
|
||||
export function formatDate(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export type Diagnostic = {
|
|||
|
||||
export type SettingsSectionId =
|
||||
| "runtime-connections"
|
||||
| "profile-sources"
|
||||
| "backend-config"
|
||||
| "workspace-identity";
|
||||
|
||||
|
|
@ -84,6 +85,18 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
|||
"Test negotiation is an observation only; checked_at, health, compatibility, and capability results are not persisted to local config.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "profile-sources",
|
||||
label: "Profile Sources",
|
||||
status: "editable",
|
||||
summary:
|
||||
"Manage the workspace-scoped Decodal Profile registry and source files used by Backend-published launch profile discovery.",
|
||||
bullets: [
|
||||
"Selectors are source-qualified (builtin:* or project:*); raw profile source paths, archive content, archive digests, resource handles, and runtime tokens are not exposed.",
|
||||
"Profile source edits are validated through the Backend ProfileSourceArchive/Decodal boundary before they are persisted.",
|
||||
"Launch profile candidates refresh from the same Backend projection after registry or source updates.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "backend-config",
|
||||
label: "Backend Config",
|
||||
|
|
|
|||
81
web/workspace/src/lib/workspace-settings/profile-api.ts
Normal file
81
web/workspace/src/lib/workspace-settings/profile-api.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { workspaceApiJson, workspaceApiJsonWithBody } from "../workspace-api/http";
|
||||
import type {
|
||||
ProfileSettingsMutationResponse,
|
||||
ProfileSettingsResponse,
|
||||
WorkspaceMetadataMutationResponse,
|
||||
WorkspaceMetadataSettingsResponse,
|
||||
WorkspaceProfileSourceDetailResponse,
|
||||
} from "./profile-types";
|
||||
|
||||
export function fetchWorkspaceMetadataSettings(workspaceId: string): Promise<WorkspaceMetadataSettingsResponse> {
|
||||
return workspaceApiJson(`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`);
|
||||
}
|
||||
|
||||
export function updateWorkspaceMetadataSettings(
|
||||
workspaceId: string,
|
||||
request: { display_name: string; revision: string },
|
||||
): Promise<WorkspaceMetadataMutationResponse> {
|
||||
return workspaceApiJsonWithBody(`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchProfileSettings(workspaceId: string): Promise<ProfileSettingsResponse> {
|
||||
return workspaceApiJson(`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`);
|
||||
}
|
||||
|
||||
export function createProfileSource(
|
||||
workspaceId: string,
|
||||
request: { name: string; description?: string; content: string; registry_revision: string },
|
||||
): Promise<ProfileSettingsMutationResponse> {
|
||||
return workspaceApiJsonWithBody(`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateProfileRegistry(
|
||||
workspaceId: string,
|
||||
request: {
|
||||
registry_revision: string;
|
||||
default_profile?: string | null;
|
||||
profiles: Array<{ name: string; description?: string | null; profile_source_id?: string | null }>;
|
||||
},
|
||||
): Promise<ProfileSettingsMutationResponse> {
|
||||
return workspaceApiJsonWithBody(`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/registry`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchProfileSource(
|
||||
workspaceId: string,
|
||||
sourceId: string,
|
||||
): Promise<WorkspaceProfileSourceDetailResponse> {
|
||||
return workspaceApiJson(
|
||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/${encodeURIComponent(sourceId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function updateProfileSource(
|
||||
workspaceId: string,
|
||||
sourceId: string,
|
||||
request: { content: string; revision: string },
|
||||
): Promise<ProfileSettingsMutationResponse> {
|
||||
return workspaceApiJsonWithBody(
|
||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/${encodeURIComponent(sourceId)}`,
|
||||
{ method: "PUT", body: JSON.stringify(request) },
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteProfileSource(
|
||||
workspaceId: string,
|
||||
sourceId: string,
|
||||
request: { registry_revision: string; source_revision: string },
|
||||
): Promise<ProfileSettingsMutationResponse> {
|
||||
return workspaceApiJsonWithBody(
|
||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/${encodeURIComponent(sourceId)}`,
|
||||
{ method: "DELETE", body: JSON.stringify(request) },
|
||||
);
|
||||
}
|
||||
60
web/workspace/src/lib/workspace-settings/profile-types.ts
Normal file
60
web/workspace/src/lib/workspace-settings/profile-types.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import type { Diagnostic } from "./model";
|
||||
|
||||
export type WorkspaceMetadataSettingsResponse = {
|
||||
workspace_id: string;
|
||||
display_name: string;
|
||||
created_at: string;
|
||||
revision: string;
|
||||
source: string;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type WorkspaceMetadataMutationResponse = {
|
||||
workspace: WorkspaceMetadataSettingsResponse;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type WorkspaceProfileSummary = {
|
||||
profile_id: string;
|
||||
selector: string;
|
||||
label: string;
|
||||
source_kind: "builtin" | "project" | string;
|
||||
profile_source_id?: string | null;
|
||||
description?: string | null;
|
||||
editable: boolean;
|
||||
is_default: boolean;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type WorkspaceProfileSourceSummary = {
|
||||
profile_source_id: string;
|
||||
display_path: string;
|
||||
kind: "decodal" | string;
|
||||
editable: boolean;
|
||||
revision: string;
|
||||
size_bytes: number;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type ProfileSettingsResponse = {
|
||||
workspace_id: string;
|
||||
registry_revision: string;
|
||||
default_profile?: string | null;
|
||||
profiles: WorkspaceProfileSummary[];
|
||||
sources: WorkspaceProfileSourceSummary[];
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type WorkspaceProfileSourceDetailResponse = {
|
||||
workspace_id: string;
|
||||
profile: WorkspaceProfileSummary;
|
||||
source: WorkspaceProfileSourceSummary;
|
||||
content: string;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type ProfileSettingsMutationResponse = {
|
||||
workspace_id: string;
|
||||
settings: ProfileSettingsResponse;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
|
@ -13,6 +13,21 @@
|
|||
type RuntimeConnectionMutationResponse,
|
||||
type RuntimeConnectionSettingsResponse,
|
||||
} from "$lib/workspace-settings/model";
|
||||
import type {
|
||||
ProfileSettingsResponse,
|
||||
WorkspaceMetadataSettingsResponse,
|
||||
WorkspaceProfileSourceDetailResponse,
|
||||
} from "$lib/workspace-settings/profile-types";
|
||||
import {
|
||||
createProfileSource,
|
||||
deleteProfileSource,
|
||||
fetchProfileSettings,
|
||||
fetchProfileSource,
|
||||
fetchWorkspaceMetadataSettings,
|
||||
updateProfileRegistry,
|
||||
updateProfileSource,
|
||||
updateWorkspaceMetadataSettings,
|
||||
} from "$lib/workspace-settings/profile-api";
|
||||
|
||||
type RemoteAddForm = {
|
||||
runtime_id: string;
|
||||
|
|
@ -44,6 +59,19 @@
|
|||
display_name: "",
|
||||
endpoint: "",
|
||||
});
|
||||
let workspaceMetadata = $state<WorkspaceMetadataSettingsResponse | null>(null);
|
||||
let profileSettings = $state<ProfileSettingsResponse | null>(null);
|
||||
let selectedProfileSource = $state<WorkspaceProfileSourceDetailResponse | null>(null);
|
||||
let profileSourceContent = $state("");
|
||||
let newProfileName = $state("");
|
||||
let newProfileDescription = $state("");
|
||||
let newProfileContent = $state("{\n slug = \"workspace-profile\";\n description = \"Workspace profile\";\n scope = \"workspace_read\";\n}\n");
|
||||
let profileLoading = $state(true);
|
||||
let profileMessage = $state<string | null>(null);
|
||||
let profileDiagnostics = $state<Diagnostic[]>([]);
|
||||
let profileSubmitting = $state(false);
|
||||
let workspaceNameDraft = $state("");
|
||||
let defaultProfileDraft = $state("");
|
||||
|
||||
$effect(() => {
|
||||
if (!workspaceId) {
|
||||
|
|
@ -83,6 +111,179 @@
|
|||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!workspaceId) {
|
||||
profileLoading = false;
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
async function loadProfileAndWorkspaceSettings() {
|
||||
profileLoading = true;
|
||||
profileMessage = null;
|
||||
profileDiagnostics = [];
|
||||
try {
|
||||
const [workspace, profiles] = await Promise.all([
|
||||
fetchWorkspaceMetadataSettings(workspaceId),
|
||||
fetchProfileSettings(workspaceId),
|
||||
]);
|
||||
if (!cancelled) {
|
||||
workspaceMetadata = workspace;
|
||||
workspaceNameDraft = workspace.display_name;
|
||||
profileSettings = profiles;
|
||||
defaultProfileDraft = profiles.default_profile ?? "";
|
||||
profileDiagnostics = [...workspace.diagnostics, ...profiles.diagnostics];
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
profileMessage = err instanceof Error ? err.message : "profile settings request failed";
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
profileLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
loadProfileAndWorkspaceSettings();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
async function refreshProfileSettings() {
|
||||
profileSettings = await fetchProfileSettings(workspaceId);
|
||||
profileDiagnostics = profileSettings.diagnostics;
|
||||
}
|
||||
|
||||
async function submitWorkspaceName() {
|
||||
if (!workspaceMetadata) return;
|
||||
profileSubmitting = true;
|
||||
profileMessage = null;
|
||||
try {
|
||||
const response = await updateWorkspaceMetadataSettings(workspaceId, {
|
||||
display_name: workspaceNameDraft,
|
||||
revision: workspaceMetadata.revision,
|
||||
});
|
||||
workspaceMetadata = response.workspace;
|
||||
workspaceNameDraft = response.workspace.display_name;
|
||||
profileDiagnostics = response.diagnostics.concat(response.workspace.diagnostics);
|
||||
profileMessage = "Workspace display name updated.";
|
||||
} catch (err) {
|
||||
profileMessage = err instanceof Error ? err.message : "workspace update failed";
|
||||
} finally {
|
||||
profileSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitProfileRegistry() {
|
||||
if (!profileSettings) return;
|
||||
profileSubmitting = true;
|
||||
profileMessage = null;
|
||||
try {
|
||||
const response = await updateProfileRegistry(workspaceId, {
|
||||
registry_revision: profileSettings.registry_revision,
|
||||
default_profile: defaultProfileDraft || null,
|
||||
profiles: profileSettings.profiles
|
||||
.filter((profile) => profile.source_kind === "project")
|
||||
.map((profile) => ({
|
||||
name: profile.selector.replace(/^project:/, ""),
|
||||
description: profile.description ?? null,
|
||||
profile_source_id: profile.profile_source_id ?? null,
|
||||
})),
|
||||
});
|
||||
profileSettings = response.settings;
|
||||
defaultProfileDraft = response.settings.default_profile ?? "";
|
||||
profileDiagnostics = response.diagnostics.concat(response.settings.diagnostics);
|
||||
profileMessage = "Profile registry updated.";
|
||||
} catch (err) {
|
||||
profileMessage = err instanceof Error ? err.message : "profile registry update failed";
|
||||
} finally {
|
||||
profileSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitNewProfileSource() {
|
||||
if (!profileSettings) return;
|
||||
profileSubmitting = true;
|
||||
profileMessage = null;
|
||||
try {
|
||||
const response = await createProfileSource(workspaceId, {
|
||||
name: newProfileName,
|
||||
description: newProfileDescription || undefined,
|
||||
content: newProfileContent,
|
||||
registry_revision: profileSettings.registry_revision,
|
||||
});
|
||||
profileSettings = response.settings;
|
||||
defaultProfileDraft = response.settings.default_profile ?? "";
|
||||
profileDiagnostics = response.diagnostics.concat(response.settings.diagnostics);
|
||||
newProfileName = "";
|
||||
newProfileDescription = "";
|
||||
profileMessage = "Profile source created.";
|
||||
} catch (err) {
|
||||
profileMessage = err instanceof Error ? err.message : "profile source create failed";
|
||||
} finally {
|
||||
profileSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectProfileSource(sourceId: string) {
|
||||
profileSubmitting = true;
|
||||
profileMessage = null;
|
||||
try {
|
||||
selectedProfileSource = await fetchProfileSource(workspaceId, sourceId);
|
||||
profileSourceContent = selectedProfileSource.content;
|
||||
profileDiagnostics = selectedProfileSource.diagnostics.concat(
|
||||
selectedProfileSource.source.diagnostics,
|
||||
selectedProfileSource.profile.diagnostics,
|
||||
);
|
||||
} catch (err) {
|
||||
profileMessage = err instanceof Error ? err.message : "profile source load failed";
|
||||
} finally {
|
||||
profileSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitProfileSourceUpdate() {
|
||||
if (!selectedProfileSource) return;
|
||||
profileSubmitting = true;
|
||||
profileMessage = null;
|
||||
try {
|
||||
const response = await updateProfileSource(workspaceId, selectedProfileSource.source.profile_source_id, {
|
||||
content: profileSourceContent,
|
||||
revision: selectedProfileSource.source.revision,
|
||||
});
|
||||
profileSettings = response.settings;
|
||||
defaultProfileDraft = response.settings.default_profile ?? "";
|
||||
profileDiagnostics = response.diagnostics.concat(response.settings.diagnostics);
|
||||
await selectProfileSource(selectedProfileSource.source.profile_source_id);
|
||||
profileMessage = "Profile source updated.";
|
||||
} catch (err) {
|
||||
profileMessage = err instanceof Error ? err.message : "profile source update failed";
|
||||
} finally {
|
||||
profileSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitProfileSourceDelete() {
|
||||
if (!profileSettings || !selectedProfileSource) return;
|
||||
profileSubmitting = true;
|
||||
profileMessage = null;
|
||||
try {
|
||||
const response = await deleteProfileSource(workspaceId, selectedProfileSource.source.profile_source_id, {
|
||||
registry_revision: profileSettings.registry_revision,
|
||||
source_revision: selectedProfileSource.source.revision,
|
||||
});
|
||||
profileSettings = response.settings;
|
||||
selectedProfileSource = null;
|
||||
profileSourceContent = "";
|
||||
profileDiagnostics = response.diagnostics.concat(response.settings.diagnostics);
|
||||
profileMessage = "Profile source deleted.";
|
||||
} catch (err) {
|
||||
profileMessage = err instanceof Error ? err.message : "profile source delete failed";
|
||||
} finally {
|
||||
profileSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRemoteRuntime() {
|
||||
submitting = true;
|
||||
mutationMessage = null;
|
||||
|
|
@ -410,8 +611,109 @@
|
|||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="card profile-settings" id="profile-sources" aria-labelledby="profile-settings-title">
|
||||
<header class="settings-section-header">
|
||||
<div>
|
||||
<p class="eyebrow">editable</p>
|
||||
<h2 id="profile-settings-title">Profile Sources</h2>
|
||||
</div>
|
||||
<span class="badge success">Backend scoped</span>
|
||||
</header>
|
||||
<p>
|
||||
Workspace profile settings are surfaced as source-qualified selectors and Decodal source summaries. The browser never receives raw host paths, runtime endpoints, tokens, resource handles, archive content, or archive digests.
|
||||
</p>
|
||||
|
||||
{#if profileLoading}
|
||||
<p class="status-message">Loading profile settings…</p>
|
||||
{:else}
|
||||
<div class="grid settings-grid">
|
||||
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); submitWorkspaceName(); }}>
|
||||
<h3>Workspace display name</h3>
|
||||
<label>
|
||||
<span>Display name</span>
|
||||
<input bind:value={workspaceNameDraft} autocomplete="off" />
|
||||
</label>
|
||||
<p class="settings-note">Workspace id: <code>{workspaceMetadata?.workspace_id ?? workspaceId}</code></p>
|
||||
<button type="submit" disabled={profileSubmitting || !workspaceMetadata}>Save workspace name</button>
|
||||
</form>
|
||||
|
||||
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); submitProfileRegistry(); }}>
|
||||
<h3>Default launch profile</h3>
|
||||
<label>
|
||||
<span>Default selector</span>
|
||||
<select bind:value={defaultProfileDraft} disabled={!profileSettings}>
|
||||
<option value="">Runtime default</option>
|
||||
{#each profileSettings?.profiles ?? [] as profile}
|
||||
<option value={profile.selector}>{profile.label} ({profile.selector})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" disabled={profileSubmitting || !profileSettings}>Save profile registry</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="settings-table-wrapper">
|
||||
<h3>Discovered profiles</h3>
|
||||
<table class="settings-table">
|
||||
<thead>
|
||||
<tr><th>Selector</th><th>Label</th><th>Source</th><th>Status</th><th>Action</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each profileSettings?.profiles ?? [] as profile}
|
||||
<tr>
|
||||
<td><code>{profile.selector}</code></td>
|
||||
<td>{profile.label}</td>
|
||||
<td>{profile.source_kind}</td>
|
||||
<td>{profile.diagnostics.length === 0 ? "ok" : `${profile.diagnostics.length} diagnostics`}</td>
|
||||
<td>
|
||||
{#if profile.profile_source_id}
|
||||
<button type="button" onclick={() => selectProfileSource(profile.profile_source_id!)} disabled={profileSubmitting}>Open source</button>
|
||||
{:else}
|
||||
<span class="settings-note">builtin</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="grid settings-grid">
|
||||
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); submitNewProfileSource(); }}>
|
||||
<h3>Create project profile source</h3>
|
||||
<label><span>Name</span><input bind:value={newProfileName} placeholder="team-coder" autocomplete="off" /></label>
|
||||
<label><span>Description</span><input bind:value={newProfileDescription} placeholder="Optional summary" autocomplete="off" /></label>
|
||||
<label><span>Decodal source</span><textarea bind:value={newProfileContent} rows="8"></textarea></label>
|
||||
<button type="submit" disabled={profileSubmitting || !profileSettings}>Create source</button>
|
||||
</form>
|
||||
|
||||
{#if selectedProfileSource}
|
||||
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); submitProfileSourceUpdate(); }}>
|
||||
<h3>Edit {selectedProfileSource.profile.label}</h3>
|
||||
<p class="settings-note">Source id: <code>{selectedProfileSource.source.profile_source_id}</code>; display path: {selectedProfileSource.source.display_path}</p>
|
||||
<label><span>Decodal source</span><textarea bind:value={profileSourceContent} rows="14"></textarea></label>
|
||||
<div class="settings-actions">
|
||||
<button type="submit" disabled={profileSubmitting}>Validate & save source</button>
|
||||
<button type="button" class="danger" onclick={submitProfileSourceDelete} disabled={profileSubmitting}>Delete source</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<div class="settings-empty-state">
|
||||
<h3>No profile source selected</h3>
|
||||
<p>Open a project source from the discovered profile table to edit it.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if profileMessage}
|
||||
<p class="status-message">{profileMessage}</p>
|
||||
{/if}
|
||||
{@render DiagnosticsList({ diagnostics: profileDiagnostics })}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<div class="grid settings-grid">
|
||||
{#each SETTINGS_SECTIONS.filter((section) => section.id !== "runtime-connections") as section}
|
||||
{#each SETTINGS_SECTIONS.filter((section) => section.id !== "runtime-connections" && section.id !== "profile-sources") as section}
|
||||
<section class="card settings-section" id={section.id} aria-labelledby={`${section.id}-title`}>
|
||||
<header class="settings-section-header">
|
||||
<div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user