feat: add workspace profile settings

This commit is contained in:
Keisuke Hirata 2026-07-08 21:38:23 +09:00
parent c903beee69
commit 3eefd33334
No known key found for this signature in database
12 changed files with 1909 additions and 23 deletions

View File

@ -2,7 +2,7 @@
title: 'Add Workspace settings API and pages for Workspace/Profile editing'
state: 'inprogress'
created_at: '2026-07-08T08:34:01Z'
updated_at: '2026-07-08T11:56:36Z'
updated_at: '2026-07-08T12:37:56Z'
assignee: null
queued_by: 'workspace-panel'
queued_at: '2026-07-08T10:44:13Z'

View File

@ -152,4 +152,27 @@ Acceptance basis:
- Ticket scope is Workspace/Profile settings API and pages using already-merged Decodal/ProfileSourceArchive and resource-fetch boundaries。
- side effect はこの `queued -> inprogress` acceptance 後に、worktree-workflow に沿って implementation worktree を作成してから開始する。
---
<!-- event: implementation_report author: hare at: 2026-07-08T12:37:56Z -->
## Implementation report
Implemented workspace-scoped Workspace/Profile settings in the delegated worktree.
Summary:
- Added scoped Workspace settings API to read/update display metadata through the workspace identity record with revision checks.
- Added scoped Profile settings API for workspace Decodal Profile registry/source discovery, create/update/delete, default selector updates, typed diagnostics, revision conflict checks, safe source-root/path validation, and Decodal/ProfileSourceArchive validation.
- Added Backend launch-options integration so project profile candidates refresh from the same settings projection; project-profile spawns carry the resolved ConfigBundle through the Runtime resource-fetch/config boundary.
- Added Settings page UI for workspace display name, profile registry/default selector, profile source creation/edit/delete, and safe diagnostics without exposing raw host paths, secrets, runtime endpoints/tokens, socket/session/store paths, archive content/digest, resource handles, or runtime internals.
Validation:
- git diff --check
- cargo test -p yoi-workspace-server
- cargo check -p yoi
- cd web/workspace && deno task check && deno task test
- yoi ticket doctor
- nix build .#yoi --no-link
---

View File

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

View File

@ -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,12 +1350,18 @@ 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,
)
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)
@ -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();

View File

@ -8,6 +8,7 @@ pub mod companion;
pub mod config;
pub mod hosts;
pub mod identity;
pub mod profile_settings;
pub mod observation;
pub mod records;
pub mod repositories;

File diff suppressed because it is too large Load Diff

View File

@ -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,113 @@ 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 +1769,22 @@ 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(|| {
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 +1842,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 +2894,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 +2964,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 +2984,35 @@ 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
}
}
@ -4599,6 +4769,7 @@ mod tests {
working_directory_request: None,
resolved_working_directory_request: None,
resolved_working_directory: None,
resolved_config_bundle: None,
},
)
.expect("spawn worker");

View File

@ -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())) {

View File

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

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

View 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[];
};

View File

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