From f7b722246da0f32a86c1abf3de9af29a48ee7724 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 4 Oct 2026 08:40:30 +0900 Subject: [PATCH] feat: configure subject attachment on worker launch --- crates/client/src/backend_runtime.rs | 4 +- crates/client/src/workspace_product.rs | 1 + crates/server-api/src/lib.rs | 54 +-- crates/tui/src/backend_spawn.rs | 3 + .../src/feature/builtin/manage_worker.rs | 1 + .../workspace-server/src/profile_settings.rs | 17 + crates/workspace-server/src/server.rs | 355 ++++++++++++------ crates/workspace-server/src/subjektiv.rs | 42 +++ openapi/server-api.json | 188 +++------- .../memory_document_fixture_server.ts | 32 ++ .../memory_document_shell_test.ts | 45 ++- .../web-ux/scenarios/worker-launch-dark.json | 42 +++ .../web-ux/scenarios/worker-launch-light.json | 42 +++ .../src/lib/generated/companion-api.ts | 2 +- .../src/lib/generated/repository-api.ts | 2 +- .../src/lib/generated/runtime-api.ts | 2 +- web/workspace/src/lib/generated/ticket-api.ts | 2 +- .../src/lib/generated/workdir-api.ts | 2 +- .../src/lib/generated/worker-launch-api.ts | 12 +- .../src/lib/workspace/api/workers.test.ts | 24 +- .../src/lib/workspace/api/workers.ts | 32 +- .../workspace/sidebar/worker-launch.test.ts | 40 +- .../lib/workspace/sidebar/worker-launch.ts | 5 + .../src/lib/workspace/styles/workers.css | 70 ++++ .../w/[workspaceId]/workers/new/+page.svelte | 115 ++++++ 25 files changed, 831 insertions(+), 303 deletions(-) create mode 100644 tools/web-ux/scenarios/worker-launch-dark.json create mode 100644 tools/web-ux/scenarios/worker-launch-light.json diff --git a/crates/client/src/backend_runtime.rs b/crates/client/src/backend_runtime.rs index d3a0fed54..7b9fbd42a 100644 --- a/crates/client/src/backend_runtime.rs +++ b/crates/client/src/backend_runtime.rs @@ -819,7 +819,8 @@ mod tests { "profiles": [{ "id": "builtin:default", "label": "Default", - "description": "" + "description": "", + "feature_connections": { "subjektiv": false } }], "repositories": [], "working_directories": [], @@ -883,6 +884,7 @@ mod tests { ticket_assignment: None, initial_submit: Vec::new(), workdir_attachments: Vec::new(), + feature_connections: Default::default(), control_operation_id: None, }; diff --git a/crates/client/src/workspace_product.rs b/crates/client/src/workspace_product.rs index cda30d257..c65d1b4a2 100644 --- a/crates/client/src/workspace_product.rs +++ b/crates/client/src/workspace_product.rs @@ -532,6 +532,7 @@ impl BackendWorkspaceProductClient { content: format!("Please handle intake for Ticket {ticket_id}."), }], workdir_attachments: Vec::new(), + feature_connections: Default::default(), control_operation_id: None, }; let workspace_id = self.workspace_id.clone(); diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index d6c7d6535..1522c640b 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -249,7 +249,6 @@ impl_openapi_schema!( SubjektivMemoryQueryResponse, SubjektivMemoryReadResponse, SubjektivMemoryListRevisionsResponse, - SubjektivSubjectWorkerStartRequest, SubjektivStageCandidateRequest, SubjektivStageCandidateResponse, SubjektivRecordSessionRequest, @@ -1234,21 +1233,6 @@ pub trait ServerApi { #[path] workspace_id: String, #[path] subject_id: String, ) -> Result; - #[post( - "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/worker", - status = 200, - error_status = 400, - additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], - bearer_auth = true, - browser_auth = true, - )] - async fn subjektiv_subject_worker_start( - &self, - #[extension] context: ServerRequestContext, - #[path] workspace_id: String, - #[path] subject_id: String, - #[body] request: SubjektivSubjectWorkerStartRequest, - ) -> Result; #[post( "/api/w/{workspace_id}/subjektiv/staging", status = 200, @@ -4780,12 +4764,6 @@ pub struct SubjektivMemoryRevisionsQuery { pub cursor: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct SubjektivSubjectWorkerStartRequest { - pub worker: CreateWorkspaceWorkerRequest, -} - #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct SubjektivStageCandidateRequest { @@ -9489,6 +9467,14 @@ pub struct WorkerLaunchProfileCandidate { pub id: String, pub label: String, pub description: String, + pub feature_connections: WorkerLaunchFeatureConnectionOptions, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkerLaunchFeatureConnectionOptions { + pub subjektiv: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -9520,6 +9506,22 @@ pub struct CreateWorkspaceWorkerTicketAssignmentRequest { pub operation_id: String, } +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceWorkerFeatureConnectionsRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub subjektiv: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct SubjektivWorkerConnectionRequest { + pub subject_id: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] @@ -9537,6 +9539,9 @@ pub struct CreateWorkspaceWorkerRequest { pub initial_submit: Vec, #[serde(default)] pub workdir_attachments: Vec, + /// Explicit product Feature connections. Backend validation turns these into + /// trusted host attachments; Runtime and Profile inputs cannot self-attest them. + pub feature_connections: WorkspaceWorkerFeatureConnectionsRequest, /// Backend idempotency key used only for authenticated Worker-owned spawn/control. #[serde(default)] pub control_operation_id: Option, @@ -10703,11 +10708,14 @@ pub fn worker_launch_api_typescript() -> String { RuntimeWorkerWorkdirAttachmentSummary::decl(&config), WorkerLaunchWorkerSummary::decl(&config), WorkerLaunchRuntimeOption::decl(&config), + WorkerLaunchFeatureConnectionOptions::decl(&config), WorkerLaunchProfileCandidate::decl(&config), WorkingDirectoryRepositoryOption::decl(&config), WorkerLaunchOptionsResponse::decl(&config), BrowserWorkerWorkingDirectorySelection::decl(&config), CreateWorkspaceWorkerTicketAssignmentRequest::decl(&config), + SubjektivWorkerConnectionRequest::decl(&config), + WorkspaceWorkerFeatureConnectionsRequest::decl(&config), CreateWorkspaceWorkerRequest::decl(&config), BrowserCreateWorkerResponse::decl(&config), BrowserWorkspaceOrchestratorResponse::decl(&config), @@ -11748,6 +11756,7 @@ mod tests { ticket_assignment: None, initial_submit: Vec::new(), workdir_attachments: Vec::new(), + feature_connections: Default::default(), control_operation_id: None, }) .unwrap(); @@ -11761,6 +11770,7 @@ mod tests { "ticket_assignment": null, "initial_submit": [], "workdir_attachments": [], + "feature_connections": {}, "control_operation_id": null, }) ); diff --git a/crates/tui/src/backend_spawn.rs b/crates/tui/src/backend_spawn.rs index 40d4da58c..db7a22a35 100644 --- a/crates/tui/src/backend_spawn.rs +++ b/crates/tui/src/backend_spawn.rs @@ -167,6 +167,7 @@ fn request_from_selection(selection: Selection) -> BackendCreateWorkerRequest { initial_submit: Vec::new(), workdir_attachments: Vec::new(), ticket_assignment: None, + feature_connections: Default::default(), control_operation_id: None, } } @@ -431,11 +432,13 @@ mod tests { id: "builtin:default".to_string(), label: "Default".to_string(), description: String::new(), + feature_connections: Default::default(), }, BackendWorkerLaunchProfileCandidate { id: "builtin:coder".to_string(), label: "Coder".to_string(), description: "Ticket implementation".to_string(), + feature_connections: Default::default(), }, ], default_profile: Some("builtin:coder".to_string()), diff --git a/crates/worker/src/feature/builtin/manage_worker.rs b/crates/worker/src/feature/builtin/manage_worker.rs index 4155db86d..5c746f9b6 100644 --- a/crates/worker/src/feature/builtin/manage_worker.rs +++ b/crates/worker/src/feature/builtin/manage_worker.rs @@ -522,6 +522,7 @@ fn workspace_worker_create_request( relative_cwd: attachment.relative_cwd, }) .collect(), + feature_connections: Default::default(), control_operation_id: Some(control_operation_id), }) } diff --git a/crates/workspace-server/src/profile_settings.rs b/crates/workspace-server/src/profile_settings.rs index 0cc848b80..0e350cc42 100644 --- a/crates/workspace-server/src/profile_settings.rs +++ b/crates/workspace-server/src/profile_settings.rs @@ -447,6 +447,23 @@ pub fn build_virtual_profile_config_bundle_with_prompt_projection( Ok(Some(bundle)) } +pub(crate) fn resolve_profile_manifest_from_config_bundle( + bundle: &ConfigBundle, + selector: &str, +) -> Result { + let archive = bundle.profile_source_archive.as_ref().ok_or_else(|| { + Error::Config(format!( + "resolved Profile {selector:?} is missing its source archive" + )) + })?; + let verified = archive.verify().map_err(|error| { + Error::Config(format!("failed to verify Profile {selector:?}: {error}")) + })?; + verified + .resolve_profile(selector, Path::new("/"), "worker-launch-validation") + .map_err(|error| Error::Config(format!("failed to resolve Profile {selector:?}: {error}"))) +} + fn build_virtual_profile_archive( selector: &str, entry: &VirtualProfileEntry, diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 801e10e87..ebc92c143 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -5468,9 +5468,6 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::subjektiv_subject_retire( service.clone(), )) - .merge(server_api::server_api_axum::subjektiv_subject_worker_start( - service.clone(), - )) .merge(server_api::server_api_axum::subjektiv_stage_candidate( service.clone(), )) @@ -7235,27 +7232,6 @@ impl server_api::ServerApi for ServerApiContractService { .map_err(ApiError::into_repository_api_error) } - async fn subjektiv_subject_worker_start( - &self, - context: server_api::ServerRequestContext, - workspace_id: String, - subject_id: String, - request: server_api::SubjektivSubjectWorkerStartRequest, - ) -> std::result::Result { - scoped_start_subjektiv_subject_worker( - State(self.workspace_api()?.clone()), - AxumPath(ScopedSubjektivSubjectPath { - workspace_id, - subject_id, - }), - contract_request_headers(&context)?, - Json(request), - ) - .await - .map(|Json(response)| response) - .map_err(ApiError::into_repository_api_error) - } - async fn subjektiv_stage_candidate( &self, context: server_api::ServerRequestContext, @@ -16780,7 +16756,7 @@ fn resident_summary_output( } } -const SUBJEKTIV_SINGLETON_PREFIX: &str = "subjektiv:"; +const SUBJEKTIV_SINGLETON_PREFIX: &str = crate::subjektiv::SUBJECT_WORKER_SINGLETON_PREFIX; const SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX: &str = crate::hosts::SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX; const SUBJEKTIV_MEMORY_CONSOLIDATION_PROFILE: &str = "subjektiv-memory-consolidation"; @@ -16821,15 +16797,6 @@ fn subjektiv_scope_from_singleton_key(key: &str) -> ApiResult<(&str, SubjektivWo Ok((subject_id, authority)) } -fn subjektiv_singleton_key(subject_id: &str) -> ApiResult { - if subject_id.trim().is_empty() || subject_id.chars().any(char::is_control) { - return Err(Error::InvalidInput("subjektiv subject id is invalid".to_string()).into()); - } - let key = format!("{SUBJEKTIV_SINGLETON_PREFIX}{subject_id}"); - crate::store::validate_worker_singleton_key(&key)?; - Ok(key) -} - fn is_dedicated_subjektiv_consolidation_worker( worker: &InternalWorkerSummary, workspace_id: &str, @@ -16943,7 +16910,7 @@ fn subjektiv_subject_response( api: &WorkspaceApi, subject: crate::subjektiv::SubjectRecord, ) -> server_api::SubjektivSubjectResponse { - let current_worker = subjektiv_singleton_key(&subject.id) + let current_worker = crate::subjektiv::subject_worker_singleton_key(&subject.id) .ok() .and_then(|key| { api.store @@ -17199,36 +17166,6 @@ async fn scoped_retire_subjektiv_subject( Ok(Json(subjektiv_subject_response(&api, subject))) } -async fn scoped_start_subjektiv_subject_worker( - State(api): State, - AxumPath(path): AxumPath, - headers: HeaderMap, - Json(request): Json, -) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let subject = open_subjektiv_store(&api)? - .subject(&path.subject_id) - .map_err(|error| Error::Store(error.to_string()))? - .ok_or_else(|| Error::SubjektivSubjectNotFound(path.subject_id.clone()))?; - if subject.state != crate::subjektiv::SubjectState::Active { - return Err(Error::InvalidInput(format!( - "subjektiv subject `{}` is retired", - path.subject_id - )) - .into()); - } - let mut worker = request.worker; - if worker.singleton_key.is_some() { - return Err(Error::InvalidInput( - "subjektiv subject Worker start owns singleton selection; singleton_key must be omitted" - .to_string(), - ) - .into()); - } - worker.singleton_key = Some(subjektiv_singleton_key(&path.subject_id)?); - create_workspace_worker_inner(api, headers, worker, None, true).await -} - async fn scoped_stage_subjektiv_candidate( State(api): State, AxumPath(path): AxumPath, @@ -21529,14 +21466,9 @@ async fn spawn_known_worker( operation_id: scoped_worker_control_operation_id(&controller, &operation_id), input_fingerprint, }); - let response = create_workspace_worker_inner( - api.clone(), - headers, - request, - resolved_control_operation, - false, - ) - .await?; + let response = + create_workspace_worker_inner(api.clone(), headers, request, resolved_control_operation) + .await?; if let Err(error) = api .store .create_worker_control_grant(&WorkerControlGrantRecord { @@ -27110,7 +27042,7 @@ async fn create_workspace_worker( headers: HeaderMap, Json(request): Json, ) -> ApiResult> { - create_workspace_worker_inner(api, headers, request, None, false).await + create_workspace_worker_inner(api, headers, request, None).await } async fn create_workspace_worker_inner( @@ -27118,33 +27050,19 @@ async fn create_workspace_worker_inner( headers: HeaderMap, request: CreateWorkspaceWorkerRequest, resolved_control_operation: Option, - resolved_subjektiv_attached: bool, ) -> ApiResult> { let CreateWorkspaceWorkerRequest { runtime_id, display_name, - singleton_key, + mut singleton_key, profile, ticket_assignment, initial_submit, workdir_attachments, + feature_connections, control_operation_id: _, } = request; - if resolved_subjektiv_attached { - let singleton_key = singleton_key.as_deref().ok_or_else(|| { - Error::InvalidInput( - "subject-attached Worker launch requires a Backend-owned singleton key".to_string(), - ) - })?; - if !singleton_key.starts_with(SUBJEKTIV_SINGLETON_PREFIX) { - return Err(Error::InvalidInput( - "subject-attached Worker launch requires a subject singleton key".to_string(), - ) - .into()); - } - } else { - validate_caller_worker_singleton_key(singleton_key.as_deref())?; - } + let source = optional_worker_mutation_source(&api, &api.config.workspace_id, &headers)?; let config_state = api .config_store .load_workspace_config(&api.config.workspace_id)? @@ -27185,6 +27103,34 @@ async fn create_workspace_worker_inner( &profile, prompt_catalog.as_ref(), )?; + let resolved_subjektiv_attached = match feature_connections.subjektiv { + None => { + validate_caller_worker_singleton_key(singleton_key.as_deref())?; + false + } + Some(connection) => { + if singleton_key.is_some() { + return Err(Error::InvalidInput( + "singleton_key must be omitted when a subjektiv subject connection is requested" + .to_string(), + ) + .into()); + } + let profile_manifest = + crate::profile_settings::resolve_profile_manifest_from_config_bundle( + resolved_config_bundle.as_ref().ok_or_else(|| { + Error::Config("resolved Worker Profile bundle is missing".to_string()) + })?, + &profile, + )?; + let attachment = open_subjektiv_store(&api)? + .authorize_worker_attachment(&connection.subject_id, &profile_manifest) + .map_err(subjektiv_store_error)?; + crate::store::validate_worker_singleton_key(&attachment.singleton_key)?; + singleton_key = Some(attachment.singleton_key); + true + } + }; let display_name = sanitize_worker_display_name(&display_name).ok_or_else(|| { settings_bad_request( "invalid_worker_display_name", @@ -27195,7 +27141,6 @@ async fn create_workspace_worker_inner( return Err(Error::ReservedWorkerName(display_name).into()); } validate_worker_initial_submit(&initial_submit)?; - let source = optional_worker_mutation_source(&api, &api.config.workspace_id, &headers)?; reject_orchestrator_generic_flow_spawn( &api, source.as_ref(), @@ -29771,32 +29716,55 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> ApiResult>>()?; Ok(WorkerLaunchOptionsResponse { workspace_id: api.config.workspace_id.clone(), runtimes, - default_profile: profile_settings.default_profile, + default_profile: profile_projection.settings.default_profile.clone(), profiles, repositories: working_directory_repository_options(api), working_directories: available_working_directory_summaries(api) @@ -32729,6 +32697,7 @@ mod tests { ticket_assignment: None, initial_submit: Vec::new(), workdir_attachments: Vec::new(), + feature_connections: Default::default(), control_operation_id: None, }), ) @@ -33258,6 +33227,15 @@ mod tests { } let options = worker_launch_options_response(&api).unwrap(); + assert!( + options + .profiles + .iter() + .find(|profile| profile.id == "builtin:companion") + .unwrap() + .feature_connections + .subjektiv + ); let available = options .working_directories .iter() @@ -33306,6 +33284,7 @@ mod tests { relative_cwd: None, }, ], + feature_connections: Default::default(), control_operation_id: None, }), ) @@ -33543,6 +33522,7 @@ mod tests { ticket_assignment: None, initial_submit: Vec::new(), workdir_attachments: Vec::new(), + feature_connections: Default::default(), control_operation_id: None, }), ) @@ -36389,6 +36369,7 @@ mod tests { selector: "builtin:coder-review".to_string(), }], workdir_attachments: Vec::new(), + feature_connections: Default::default(), control_operation_id: None, }), ) @@ -36423,6 +36404,7 @@ mod tests { ticket_assignment: None, initial_submit: Vec::new(), workdir_attachments: Vec::new(), + feature_connections: Default::default(), control_operation_id: None, }), ) @@ -36458,6 +36440,7 @@ mod tests { ticket_assignment: None, initial_submit: Vec::new(), workdir_attachments: Vec::new(), + feature_connections: Default::default(), control_operation_id: None, }), ) @@ -36547,6 +36530,7 @@ mod tests { ticket_assignment: None, initial_submit: Vec::new(), workdir_attachments: Vec::new(), + feature_connections: Default::default(), control_operation_id: None, }), ) @@ -36805,6 +36789,7 @@ mod tests { ticket_assignment: None, initial_submit: Vec::new(), workdir_attachments: Vec::new(), + feature_connections: Default::default(), control_operation_id: None, }), ) @@ -36864,6 +36849,7 @@ mod tests { "working_directory_id": fixture.read_only_workdir_id, } ], + "feature_connections": {}, "control_operation_id": operation_id, }) } @@ -37287,6 +37273,7 @@ mod tests { ticket_assignment: None, initial_submit: Vec::new(), workdir_attachments: Vec::new(), + feature_connections: Default::default(), control_operation_id: None, }), ) @@ -37328,6 +37315,7 @@ mod tests { "working_directory_id": read_only_workdir_id, } ], + "feature_connections": {}, "control_operation_id": "guarded-strict-remote-success", }); assert!(payload.get("working_directory").is_none()); @@ -37580,6 +37568,7 @@ mod tests { ticket_assignment: None, initial_submit: Vec::new(), workdir_attachments: Vec::new(), + feature_connections: Default::default(), control_operation_id: None, }), ) @@ -37612,6 +37601,7 @@ mod tests { ticket_assignment: None, initial_submit: Vec::new(), workdir_attachments: Vec::new(), + feature_connections: Default::default(), control_operation_id: Some("control-spawn-retry".to_string()), }; @@ -37687,6 +37677,7 @@ mod tests { ticket_assignment: None, initial_submit: Vec::new(), workdir_attachments: Vec::new(), + feature_connections: Default::default(), control_operation_id: None, }; @@ -37845,6 +37836,7 @@ mod tests { ticket_assignment: None, initial_submit: Vec::new(), workdir_attachments: Vec::new(), + feature_connections: Default::default(), control_operation_id: None, }), ) @@ -37943,6 +37935,7 @@ mod tests { ticket_assignment: None, initial_submit: Vec::new(), workdir_attachments: Vec::new(), + feature_connections: Default::default(), control_operation_id: None, }; let Json(controller) = create_workspace_worker( @@ -38555,6 +38548,15 @@ mod tests { ); let memory_root = format!("{subject_root}/memories/{}", memory.id); + request_json_authenticated( + app.clone(), + "POST", + &format!("{subject_root}/worker"), + Some(serde_json::json!({})), + &owner_token, + StatusCode::METHOD_NOT_ALLOWED, + ) + .await; request_json( app.clone(), "GET", @@ -38915,6 +38917,107 @@ mod tests { assert_eq!(snapshot.memory_refs[0].revision, expected_ref.revision); } + #[tokio::test] + async fn ordinary_worker_launch_validates_optional_subjektiv_connection() { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let api = test_api(workspace.path()).await; + let store = open_subjektiv_store(&api).unwrap(); + let active = store + .create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap()) + .unwrap(); + let retired = store + .create_subject(crate::subjektiv::SubjectRole::new("retired").unwrap()) + .unwrap(); + store.retire_subject(&retired.id).unwrap(); + + let request = |subject_id: Option<&str>, profile: &str, singleton_key: Option<&str>| { + CreateWorkspaceWorkerRequest { + runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), + display_name: "Ordinary launch".to_string(), + singleton_key: singleton_key.map(ToOwned::to_owned), + profile: Some(profile.to_string()), + ticket_assignment: None, + initial_submit: Vec::new(), + workdir_attachments: Vec::new(), + feature_connections: server_api::WorkspaceWorkerFeatureConnectionsRequest { + subjektiv: subject_id.map(|subject_id| { + server_api::SubjektivWorkerConnectionRequest { + subject_id: subject_id.to_string(), + } + }), + }, + control_operation_id: None, + } + }; + + let Json(ordinary) = create_workspace_worker( + State(api.clone()), + HeaderMap::new(), + Json(request(None, "builtin:companion", None)), + ) + .await + .unwrap(); + assert!(ordinary.worker.singleton_key.is_none()); + + for (candidate, profile, singleton_key, expected_status) in [ + ( + "missing-subject", + "builtin:companion", + None, + StatusCode::NOT_FOUND, + ), + ( + retired.id.as_str(), + "builtin:companion", + None, + StatusCode::BAD_REQUEST, + ), + ( + active.id.as_str(), + "builtin:standalone", + None, + StatusCode::BAD_REQUEST, + ), + ( + active.id.as_str(), + "builtin:companion", + Some("caller-key"), + StatusCode::BAD_REQUEST, + ), + ] { + let response = create_workspace_worker( + State(api.clone()), + HeaderMap::new(), + Json(request(Some(candidate), profile, singleton_key)), + ) + .await + .unwrap_err() + .into_response(); + assert_eq!(response.status(), expected_status, "candidate={candidate}"); + } + + let Json(first) = create_workspace_worker( + State(api.clone()), + HeaderMap::new(), + Json(request(Some(&active.id), "builtin:companion", None)), + ) + .await + .unwrap(); + let Json(retried) = create_workspace_worker( + State(api.clone()), + HeaderMap::new(), + Json(request(Some(&active.id), "builtin:companion", None)), + ) + .await + .unwrap(); + assert_eq!(retried.worker_id, first.worker_id); + assert_eq!( + first.worker.singleton_key.as_deref(), + Some(format!("subjektiv:{}", active.id).as_str()) + ); + } + #[tokio::test] async fn subjektiv_subject_worker_and_staging_derive_scope_from_singleton_authority() { let workspace = tempfile::tempdir().unwrap(); @@ -38935,23 +39038,25 @@ mod tests { .unwrap() .create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap()) .unwrap(); - let request = server_api::SubjektivSubjectWorkerStartRequest { - worker: CreateWorkspaceWorkerRequest { - runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), - display_name: "Subject Worker".to_string(), - singleton_key: None, - profile: Some("builtin:companion".to_string()), - ticket_assignment: None, - initial_submit: Vec::new(), - workdir_attachments: Vec::new(), - control_operation_id: None, + let request = CreateWorkspaceWorkerRequest { + runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), + display_name: "Subject Worker".to_string(), + singleton_key: None, + profile: Some("builtin:companion".to_string()), + ticket_assignment: None, + initial_submit: Vec::new(), + workdir_attachments: Vec::new(), + feature_connections: server_api::WorkspaceWorkerFeatureConnectionsRequest { + subjektiv: Some(server_api::SubjektivWorkerConnectionRequest { + subject_id: subject.id.clone(), + }), }, + control_operation_id: None, }; - let Json(created) = scoped_start_subjektiv_subject_worker( + let Json(created) = scoped_create_workspace_worker( State(api.clone()), - AxumPath(ScopedSubjektivSubjectPath { + AxumPath(ScopedWorkspacePath { workspace_id: TEST_WORKSPACE_ID.to_string(), - subject_id: subject.id.clone(), }), HeaderMap::new(), Json(request), @@ -40259,6 +40364,7 @@ mod tests { ticket_assignment: None, initial_submit: Vec::new(), workdir_attachments: Vec::new(), + feature_connections: Default::default(), control_operation_id: None, }), ) @@ -40278,6 +40384,7 @@ mod tests { ticket_assignment: None, initial_submit: Vec::new(), workdir_attachments: Vec::new(), + feature_connections: Default::default(), control_operation_id: None, }), ) @@ -40362,6 +40469,7 @@ mod tests { ticket_assignment: None, initial_submit: Vec::new(), workdir_attachments: Vec::new(), + feature_connections: Default::default(), control_operation_id: None, }), ) @@ -45168,6 +45276,7 @@ mod tests { ticket_assignment: None, initial_submit: Vec::new(), workdir_attachments: Vec::new(), + feature_connections: Default::default(), control_operation_id: None, }), ) @@ -51507,7 +51616,8 @@ mod tests { "alias": "workdir", "working_directory_id": working_directory_id, "relative_cwd": "../escape" - }] + }], + "feature_connections": {} })), StatusCode::BAD_REQUEST, ) @@ -52207,7 +52317,8 @@ mod tests { serde_json::json!({ "runtime_id": "embedded-worker-runtime", "display_name": "", - "initial_submit": [] + "initial_submit": [], + "feature_connections": {} }), ) .await; @@ -52326,10 +52437,10 @@ mod tests { ticket_assignment: None, initial_submit: Vec::new(), workdir_attachments: Vec::new(), + feature_connections: Default::default(), control_operation_id: None, }, None, - false, ) .await .unwrap(); @@ -54746,7 +54857,7 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3); assert!(subjektiv_scope_from_singleton_key("subjektiv:").is_err()); assert!(subjektiv_scope_from_singleton_key("subjektiv: subject-42").is_err()); assert_eq!( - subjektiv_singleton_key("subject-42").unwrap(), + crate::subjektiv::subject_worker_singleton_key("subject-42").unwrap(), "subjektiv:subject-42" ); } diff --git a/crates/workspace-server/src/subjektiv.rs b/crates/workspace-server/src/subjektiv.rs index 1c66bc387..37fad4588 100644 --- a/crates/workspace-server/src/subjektiv.rs +++ b/crates/workspace-server/src/subjektiv.rs @@ -22,6 +22,7 @@ use crate::feature_storage::{ pub const SUBJEKTIV_SCHEMA_VERSION: u32 = 1; pub const SUBJEKTIV_FEATURE_ID: &str = "subjektiv"; +pub const SUBJECT_WORKER_SINGLETON_PREFIX: &str = "subjektiv:"; pub const MAX_STAGING_ANCHORS: usize = 10; /// Deterministic surface-generation policy. Input estimates use the repository's /// provider-independent UTF-8 byte estimate (`ceil(bytes / 4)`). The material @@ -45,6 +46,8 @@ pub enum SubjektivError { SubjectNotFound(String), #[error("subject `{0}` is retired")] SubjectRetired(String), + #[error("the selected Worker Profile does not enable subjektiv")] + ProfileDisabled, #[error("staging candidate `{0}` was not found for this subject")] CandidateNotFound(String), #[error("staging candidate `{0}` already exists with different content")] @@ -147,6 +150,23 @@ pub struct SubjectPage { pub has_more: bool, } +/// Backend-authorized launch material for one active subject. The singleton key +/// is derived by subjektiv and then acquired through Yoi's generic singleton path. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthorizedSubjectWorkerAttachment { + pub singleton_key: String, +} + +pub fn subject_worker_singleton_key(subject_id: &str) -> Result { + validate_label("subject id", subject_id)?; + if subject_id.trim() != subject_id { + return Err(SubjektivError::InvalidRecord( + "subject id must not contain surrounding whitespace".to_string(), + )); + } + Ok(format!("{SUBJECT_WORKER_SINGLETON_PREFIX}{subject_id}")) +} + /// Immutable host-recorded history that attributes one durable Session to the /// subject and Worker that produced it. This is archival provenance only: it is /// deliberately not a current-Worker link or execution ownership record. @@ -1091,6 +1111,28 @@ impl SubjektivStore { }) } + /// Validates an explicit product launch connection against this Workspace's + /// subject store and the already-resolved Profile policy. Subject knowledge + /// alone is insufficient: only an active local record produces host launch material. + pub fn authorize_worker_attachment( + &self, + subject_id: &str, + profile: &manifest::WorkerManifest, + ) -> Result { + if !profile.feature.subjektiv.profile.enabled { + return Err(SubjektivError::ProfileDisabled); + } + let subject = self + .subject(subject_id)? + .ok_or_else(|| SubjektivError::SubjectNotFound(subject_id.to_string()))?; + if subject.state != SubjectState::Active { + return Err(SubjektivError::SubjectRetired(subject_id.to_string())); + } + Ok(AuthorizedSubjectWorkerAttachment { + singleton_key: subject_worker_singleton_key(&subject.id)?, + }) + } + /// Lists the first deterministic bounded page of subjects. Ordering uses /// immutable creation time with the host-issued subject id as a total-order /// tie breaker. diff --git a/openapi/server-api.json b/openapi/server-api.json index d1b74ff09..1feb59279 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -1528,6 +1528,10 @@ "display_name": { "type": "string" }, + "feature_connections": { + "$ref": "#/components/schemas/WorkspaceWorkerFeatureConnectionsRequest", + "description": "Explicit product Feature connections. Backend validation turns these into\ntrusted host attachments; Runtime and Profile inputs cannot self-attest them." + }, "initial_submit": { "default": [], "items": { @@ -1574,7 +1578,8 @@ }, "required": [ "runtime_id", - "display_name" + "display_name", + "feature_connections" ], "type": "object" }, @@ -9428,15 +9433,15 @@ ], "type": "string" }, - "SubjektivSubjectWorkerStartRequest": { + "SubjektivWorkerConnectionRequest": { "additionalProperties": false, "properties": { - "worker": { - "$ref": "#/components/schemas/CreateWorkspaceWorkerRequest" + "subject_id": { + "type": "string" } }, "required": [ - "worker" + "subject_id" ], "type": "object" }, @@ -12069,6 +12074,18 @@ ], "type": "object" }, + "WorkerLaunchFeatureConnectionOptions": { + "additionalProperties": false, + "properties": { + "subjektiv": { + "type": "boolean" + } + }, + "required": [ + "subjektiv" + ], + "type": "object" + }, "WorkerLaunchOptionsResponse": { "additionalProperties": false, "properties": { @@ -12128,6 +12145,9 @@ "description": { "type": "string" }, + "feature_connections": { + "$ref": "#/components/schemas/WorkerLaunchFeatureConnectionOptions" + }, "id": { "type": "string" }, @@ -12138,7 +12158,8 @@ "required": [ "id", "label", - "description" + "description", + "feature_connections" ], "type": "object" }, @@ -14706,6 +14727,22 @@ ], "type": "object" }, + "WorkspaceWorkerFeatureConnectionsRequest": { + "additionalProperties": false, + "properties": { + "subjektiv": { + "anyOf": [ + { + "$ref": "#/components/schemas/SubjektivWorkerConnectionRequest" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, "WorkspaceWorkerSubject": { "oneOf": [ { @@ -14750,7 +14787,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098" + "x-yoi-source-digest": "sha256:cba261b44424ef69fde6339246ed891300b456ac3f8cc3b99103cb90e5fa32d6" }, "openapi": "3.1.0", "paths": { @@ -29642,143 +29679,6 @@ ] } }, - "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/worker": { - "post": { - "operationId": "subjektiv_subject_worker_start", - "parameters": [ - { - "explode": false, - "in": "path", - "name": "workspace_id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - }, - { - "explode": false, - "in": "path", - "name": "subject_id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SubjektivSubjectWorkerStartRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BrowserCreateWorkerResponse" - } - } - }, - "description": "Successful response" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "502": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - } - }, - "security": [ - { - "bearerAuth": [] - }, - { - "browserSession": [] - } - ] - } - }, "/api/w/{workspace_id}/tickets": { "get": { "operationId": "ticket_list", diff --git a/tools/web-ux/browser-tests/memory_document_fixture_server.ts b/tools/web-ux/browser-tests/memory_document_fixture_server.ts index 349033558..295abcca0 100644 --- a/tools/web-ux/browser-tests/memory_document_fixture_server.ts +++ b/tools/web-ux/browser-tests/memory_document_fixture_server.ts @@ -358,6 +358,38 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { if (url.pathname === `/api/w/${workspaceId}/working-directories`) { return json({ workspace_id: workspaceId, items: [], diagnostics: [] }); } + if (url.pathname === `/api/w/${workspaceId}/workers/launch-options`) { + return json({ + workspace_id: workspaceId, + runtimes: [{ + runtime_id: "embedded-worker-runtime", + display_name: "Embedded Runtime", + built_in: true, + worker_creation_available: true, + working_directory_required: false, + status: "active", + diagnostics: [], + }], + default_profile: "builtin:companion", + profiles: [ + { + id: "builtin:companion", + label: "Companion", + description: "General Workspace assistance with explicit subject Memory support.", + feature_connections: { subjektiv: true }, + }, + { + id: "builtin:standalone", + label: "Standalone", + description: "Independent Worker without Workspace subject Memory support.", + feature_connections: { subjektiv: false }, + }, + ], + repositories: [], + working_directories: [], + diagnostics: [], + }); + } const subjectsPath = `/api/w/${workspaceId}/subjektiv/subjects`; if (url.pathname === subjectsPath) { diff --git a/tools/web-ux/browser-tests/memory_document_shell_test.ts b/tools/web-ux/browser-tests/memory_document_shell_test.ts index 978d39a02..30456c93c 100644 --- a/tools/web-ux/browser-tests/memory_document_shell_test.ts +++ b/tools/web-ux/browser-tests/memory_document_shell_test.ts @@ -146,7 +146,7 @@ async function swipeMainToEnd(page: Page): Promise { throw new Error("touch input did not reach the end of the resident Memory surface"); } -Deno.test("production subject Memory shell renders exact states, safe Markdown, provenance, and one scroll owner", async () => { +Deno.test("production subject Memory and Worker launch shells preserve exact scoped behavior", async () => { const build = await new Deno.Command(Deno.execPath(), { args: ["task", "build"], cwd: workspaceRoot, @@ -407,6 +407,49 @@ Deno.test("production subject Memory shell renders exact states, safe Markdown, await page.close(); } await stateContext.close(); + + const launchContext = await browser.newContext({ viewport: { width: 768, height: 900 } }); + const launchPage = await launchContext.newPage(); + await launchPage.goto(`${baseUrl}/w/${workspaceId}/workers/new`); + await launchPage.getByRole("heading", { name: "New Worker", level: 1 }).waitFor(); + const subjectConnection = launchPage.locator( + '[data-worker-feature-connection="subjektiv"]', + ); + await subjectConnection.waitFor(); + const subjectSelect = launchPage.getByRole("combobox", { + name: "Subject Memory connection", + }); + assertEquals(await subjectSelect.locator('option[value="error-subject"]').count(), 0); + assertEquals(await subjectSelect.locator('option[value="paged-subject"]').count(), 0); + await launchPage.getByRole("button", { name: "Load more subjects" }).click(); + await subjectSelect.locator('option[value="paged-subject"]').waitFor({ state: "attached" }); + await subjectSelect.selectOption(subjectId); + assertEquals(await subjectSelect.inputValue(), subjectId); + assertEquals( + await subjectConnection.locator("code", { hasText: subjectId }).count(), + 1, + ); + + const profileSelect = launchPage.getByRole("combobox", { name: "Profile" }); + await profileSelect.selectOption("builtin:standalone"); + assertEquals(await subjectConnection.count(), 0); + await profileSelect.selectOption("builtin:companion"); + await subjectConnection.waitFor(); + assertEquals(await subjectSelect.inputValue(), ""); + + await subjectSelect.selectOption(subjectId); + const createRequestPromise = launchPage.waitForRequest((request) => + request.method() === "POST" && + new URL(request.url()).pathname === `/api/w/${workspaceId}/workers` + ); + await launchPage.getByRole("button", { name: "Start Worker" }).click(); + const createRequest = await createRequestPromise; + const createPayload = createRequest.postDataJSON(); + assertEquals(createPayload.profile, "builtin:companion"); + assertEquals(createPayload.feature_connections, { + subjektiv: { subject_id: subjectId }, + }); + await launchContext.close(); } finally { await browser.close(); } diff --git a/tools/web-ux/scenarios/worker-launch-dark.json b/tools/web-ux/scenarios/worker-launch-dark.json new file mode 100644 index 000000000..c091fbd3f --- /dev/null +++ b/tools/web-ux/scenarios/worker-launch-dark.json @@ -0,0 +1,42 @@ +{ + "schemaVersion": 1, + "id": "worker-launch-dark", + "title": "Ordinary Worker launch with optional subject connection — dark", + "baseUrl": "http://127.0.0.1:15288", + "colorScheme": "dark", + "reducedMotion": "reduce", + "personas": [{ + "id": "fixture", + "label": "Authorized Workspace member fixture", + "auth": { "kind": "anonymous" } + }], + "viewports": [ + { "label": "desktop", "width": 1440, "height": 900 }, + { "label": "narrow", "width": 768, "height": 900 }, + { "label": "mobile", "width": 390, "height": 844 }, + { "label": "compact", "width": 320, "height": 700 } + ], + "routes": [{ + "id": "representative", + "label": "Worker launch", + "path": "/w/memory-review/workers/new", + "goal": "Start an ordinary Worker with a compatible Profile and optionally connect an authorized existing subject.", + "dataState": "Compatible and incompatible Profiles with active, retired, long-identity, and paginated subjects.", + "ready": { "kind": "selector", "selector": "form.worker-launch-form" }, + "capturePoints": [{ "id": "initial", "label": "Worker launch form" }] + }], + "processes": [{ + "id": "worker-launch-fixture", + "command": "deno", + "cwd": "..", + "args": [ + "run", + "--allow-net", + "--allow-read", + "browser-tests/memory_document_fixture_server.ts", + "15288", + "../../web/workspace/build" + ], + "readyUrl": "http://127.0.0.1:15288/health" + }] +} diff --git a/tools/web-ux/scenarios/worker-launch-light.json b/tools/web-ux/scenarios/worker-launch-light.json new file mode 100644 index 000000000..25528519c --- /dev/null +++ b/tools/web-ux/scenarios/worker-launch-light.json @@ -0,0 +1,42 @@ +{ + "schemaVersion": 1, + "id": "worker-launch-light", + "title": "Ordinary Worker launch with optional subject connection — light", + "baseUrl": "http://127.0.0.1:15287", + "colorScheme": "light", + "reducedMotion": "reduce", + "personas": [{ + "id": "fixture", + "label": "Authorized Workspace member fixture", + "auth": { "kind": "anonymous" } + }], + "viewports": [ + { "label": "desktop", "width": 1440, "height": 900 }, + { "label": "narrow", "width": 768, "height": 900 }, + { "label": "mobile", "width": 390, "height": 844 }, + { "label": "compact", "width": 320, "height": 700 } + ], + "routes": [{ + "id": "representative", + "label": "Worker launch", + "path": "/w/memory-review/workers/new", + "goal": "Start an ordinary Worker with a compatible Profile and optionally connect an authorized existing subject.", + "dataState": "Compatible and incompatible Profiles with active, retired, long-identity, and paginated subjects.", + "ready": { "kind": "selector", "selector": "form.worker-launch-form" }, + "capturePoints": [{ "id": "initial", "label": "Worker launch form" }] + }], + "processes": [{ + "id": "worker-launch-fixture", + "command": "deno", + "cwd": "..", + "args": [ + "run", + "--allow-net", + "--allow-read", + "browser-tests/memory_document_fixture_server.ts", + "15287", + "../../web/workspace/build" + ], + "readyUrl": "http://127.0.0.1:15287/health" + }] +} diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index e791c828a..35a03dec1 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098) +// Input contract: 0.1.0 (sha256:cba261b44424ef69fde6339246ed891300b456ac3f8cc3b99103cb90e5fa32d6) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index ee61147a4..36aba65f0 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098) +// Input contract: 0.1.0 (sha256:cba261b44424ef69fde6339246ed891300b456ac3f8cc3b99103cb90e5fa32d6) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 905b323d2..241ccaca7 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098) +// Input contract: 0.1.0 (sha256:cba261b44424ef69fde6339246ed891300b456ac3f8cc3b99103cb90e5fa32d6) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index fbac54057..c76283c72 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098) +// Input contract: 0.1.0 (sha256:cba261b44424ef69fde6339246ed891300b456ac3f8cc3b99103cb90e5fa32d6) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts index 97b8c9c7c..452938640 100644 --- a/web/workspace/src/lib/generated/workdir-api.ts +++ b/web/workspace/src/lib/generated/workdir-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-workdir-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098) +// Input contract: 0.1.0 (sha256:cba261b44424ef69fde6339246ed891300b456ac3f8cc3b99103cb90e5fa32d6) // Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/workdir-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index ff2df6358..840852718 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098) +// Input contract: 0.1.0 (sha256:cba261b44424ef69fde6339246ed891300b456ac3f8cc3b99103cb90e5fa32d6) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check @@ -11,7 +11,7 @@ export type BrowserWorkerWorkingDirectorySelection = { alias: string; relative_c export type BrowserWorkspaceOrchestratorResponse = { diagnostics: Array; disposition: string; online: boolean; worker?: WorkerLaunchWorkerSummary | null; workspace_id: string; }; -export type CreateWorkspaceWorkerRequest = { control_operation_id?: string | null; display_name: string; initial_submit?: Array; profile?: string | null; runtime_id: string; singleton_key?: string | null; ticket_assignment?: CreateWorkspaceWorkerTicketAssignmentRequest | null; workdir_attachments?: Array; }; +export type CreateWorkspaceWorkerRequest = { control_operation_id?: string | null; display_name: string; feature_connections: WorkspaceWorkerFeatureConnectionsRequest; initial_submit?: Array; profile?: string | null; runtime_id: string; singleton_key?: string | null; ticket_assignment?: CreateWorkspaceWorkerTicketAssignmentRequest | null; workdir_attachments?: Array; }; export type CreateWorkspaceWorkerTicketAssignmentRequest = { operation_id: string; ticket_id: string; }; @@ -37,6 +37,8 @@ export type RuntimeWorkingDirectorySummary = { cleanliness?: string | null; clea export type Segment = { content: string; kind: "text"; } | { chars: number; content: string; id: number; kind: "paste"; lines: number; } | { artifact: PasteArtifactRef; kind: "paste_artifact"; } | { file: UploadedFileRef; kind: "uploaded_file"; } | { kind: "file_ref"; path: string; } | { kind: "flow"; selector: string; } | { kind: "unknown"; }; +export type SubjektivWorkerConnectionRequest = { subject_id: string; }; + export type SubscriptionWorkerAvailability = "observed" | "unavailable"; export type UploadedFileAvailability = "available" | "unavailable" | "integrity_failed"; @@ -47,9 +49,11 @@ export type WorkerBusyState = { kind: "run"; state: WorkerRunState; } | { kind: export type WorkerImplementationSummary = { display_hint: string; kind: string; }; +export type WorkerLaunchFeatureConnectionOptions = { subjektiv: boolean; }; + export type WorkerLaunchOptionsResponse = { default_profile?: string | null; diagnostics: Array; profiles: Array; repositories: Array; runtimes: Array; working_directories: Array; workspace_id: string; }; -export type WorkerLaunchProfileCandidate = { description: string; id: string; label: string; }; +export type WorkerLaunchProfileCandidate = { description: string; feature_connections: WorkerLaunchFeatureConnectionOptions; id: string; label: string; }; export type WorkerLaunchRuntimeOption = { built_in: boolean; diagnostics: Array; display_name: string; runtime_id: string; status: string; worker_creation_available: boolean; working_directory_required: boolean; }; @@ -84,3 +88,5 @@ export type WorkingDirectorySource = { kind: "repository"; repository_key: strin export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown"; export type WorkingDirectorySummary = { cleanliness?: string | null; cleanup_target?: WorkingDirectoryCleanupTarget | null; creation_ref?: string | null; creation_selector?: string | null; creation_tree?: string | null; current_ref?: string | null; current_selector?: string | null; current_tree?: string | null; display_name?: string | null; materializer_kind: WorkingDirectoryMaterializerKind; observed_at_epoch_seconds?: number | null; occupied_by?: WorkingDirectoryOccupancy | null; source: WorkingDirectorySource; status: WorkingDirectoryStatusKind; working_directory_id: string; }; + +export type WorkspaceWorkerFeatureConnectionsRequest = { subjektiv?: SubjektivWorkerConnectionRequest | null; }; diff --git a/web/workspace/src/lib/workspace/api/workers.test.ts b/web/workspace/src/lib/workspace/api/workers.test.ts index c4ca9bdd3..f76b0b404 100644 --- a/web/workspace/src/lib/workspace/api/workers.test.ts +++ b/web/workspace/src/lib/workspace/api/workers.test.ts @@ -166,7 +166,12 @@ Deno.test("Worker launch options parser accepts the generated wire shape", () => diagnostics: [], }], default_profile: null, - profiles: [{ id: "builtin:coder", label: "Coder", description: "Code" }], + profiles: [{ + id: "builtin:coder", + label: "Coder", + description: "Code", + feature_connections: { subjektiv: true }, + }], repositories: [{ repository_key: "main" }], working_directories: [], diagnostics: [], @@ -235,10 +240,27 @@ Deno.test("Worker create request parser requires the complete shared request", ( working_directory_id: "workdir-a", relative_cwd: null, }], + feature_connections: {}, control_operation_id: null, }; assertEquals(parseCreateWorkspaceWorkerRequest(request), request); + assertEquals( + parseCreateWorkspaceWorkerRequest({ + ...request, + feature_connections: { subjektiv: { subject_id: "subject-42" } }, + }).feature_connections, + { subjektiv: { subject_id: "subject-42" } }, + ); + assertThrows( + () => + parseCreateWorkspaceWorkerRequest({ + ...request, + resolved_subjektiv_attached: true, + }), + Error, + "unknown field resolved_subjektiv_attached", + ); assertThrows( () => parseCreateWorkspaceWorkerRequest({ diff --git a/web/workspace/src/lib/workspace/api/workers.ts b/web/workspace/src/lib/workspace/api/workers.ts index c31a50c44..71c76c9f3 100644 --- a/web/workspace/src/lib/workspace/api/workers.ts +++ b/web/workspace/src/lib/workspace/api/workers.ts @@ -21,6 +21,7 @@ import type { WorkerWorkdirAttachmentSummary, WorkerWorkspaceSummary, WorkingDirectoryRepositoryOption, + WorkspaceWorkerFeatureConnectionsRequest, } from "#lib/generated/worker-launch-api.ts"; import type { Segment } from "#lib/generated/protocol.ts"; import { @@ -148,16 +149,26 @@ function runtimeOption( }; } +function featureConnectionOptions(value: unknown, label: string) { + const item = record(value, label); + exact(item, ["subjektiv"], label); + return { subjektiv: boolean(item.subjektiv, `${label}.subjektiv`) }; +} + function profileCandidate( value: unknown, label: string, ): WorkerLaunchProfileCandidate { const item = record(value, label); - exact(item, ["id", "label", "description"], label); + exact(item, ["id", "label", "description", "feature_connections"], label); return { id: string(item.id, `${label}.id`), label: string(item.label, `${label}.label`), description: string(item.description, `${label}.description`), + feature_connections: featureConnectionOptions( + item.feature_connections, + `${label}.feature_connections`, + ), }; } @@ -700,6 +711,20 @@ function ticketAssignment( }; } +function featureConnectionsRequest( + value: unknown, + label: string, +): WorkspaceWorkerFeatureConnectionsRequest { + const item = record(value, label); + exact(item, ["subjektiv"], label); + const subjektiv = optional(item.subjektiv, `${label}.subjektiv`, (value, itemLabel) => { + const connection = record(value, itemLabel); + exact(connection, ["subject_id"], itemLabel); + return { subject_id: string(connection.subject_id, `${itemLabel}.subject_id`) }; + }); + return subjektiv === undefined ? {} : { subjektiv }; +} + function unsignedInteger(value: unknown, label: string): number { const parsed = number(value, label); if (!Number.isSafeInteger(parsed) || parsed < 0) { @@ -853,6 +878,7 @@ export function parseCreateWorkspaceWorkerRequest( "ticket_assignment", "initial_submit", "workdir_attachments", + "feature_connections", "control_operation_id", ], "Worker create request", @@ -870,6 +896,10 @@ export function parseCreateWorkspaceWorkerRequest( "workdir_attachments", workingDirectorySelection, ), + feature_connections: featureConnectionsRequest( + item.feature_connections, + "feature_connections", + ), control_operation_id: nullableString( item.control_operation_id, "control_operation_id", diff --git a/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts b/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts index 6cd8d19fd..b4747f7c7 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts @@ -64,7 +64,12 @@ Deno.test("defaultWorkerLaunchForm preserves an available Ticket role profile", ...options, profiles: [ ...options.profiles, - { id: "builtin:reviewer", label: "Reviewer", description: "review" }, + { + id: "builtin:reviewer", + label: "Reviewer", + description: "review", + feature_connections: { subjektiv: true }, + }, ], }; const form = defaultWorkerLaunchForm( @@ -195,10 +200,27 @@ Deno.test("buildCreateWorkspaceWorkerRequest emits multiple aliased attachments" relative_cwd: null, }, ], + feature_connections: {}, control_operation_id: null, }); }); +Deno.test("buildCreateWorkspaceWorkerRequest keeps subject attachment explicit and separate from Profile", () => { + const unattached = buildCreateWorkspaceWorkerRequest(emptyForm({ + profile: "builtin:companion", + })); + assertEquals(unattached.feature_connections, {}); + + const attached = buildCreateWorkspaceWorkerRequest(emptyForm({ + profile: "builtin:companion", + subjektiv_subject_id: " subject-42 ", + })); + assertEquals(attached.feature_connections, { + subjektiv: { subject_id: "subject-42" }, + }); + assertEquals(attached.profile, "builtin:companion"); +}); + Deno.test("buildCreateWorkspaceWorkerRequest validates attachment aliases and selections", () => { assertEquals( workerLaunchAttachmentError([attachment("invalid/alias", "wd-1-repo")]), @@ -268,6 +290,7 @@ Deno.test("buildCreateWorkspaceWorkerRequest emits an empty attachment list for ticket_assignment: null, initial_submit: [{ kind: "text", content: "chat" }], workdir_attachments: [], + feature_connections: {}, control_operation_id: null, }); }); @@ -280,6 +303,7 @@ function emptyForm( runtime_id: "", display_name: "", profile: "", + subjektiv_subject_id: "", initial_text: "", workdir_attachments: [], working_directory_repository_key: "", @@ -340,8 +364,18 @@ const options: WorkerLaunchOptionsResponse = { ], default_profile: "builtin:coder", profiles: [ - { id: "builtin:companion", label: "Companion", description: "chat" }, - { id: "builtin:coder", label: "Coder", description: "code" }, + { + id: "builtin:companion", + label: "Companion", + description: "chat", + feature_connections: { subjektiv: true }, + }, + { + id: "builtin:coder", + label: "Coder", + description: "code", + feature_connections: { subjektiv: true }, + }, ], repositories: [ { repository_key: "repo", default_selector: "HEAD" }, diff --git a/web/workspace/src/lib/workspace/sidebar/worker-launch.ts b/web/workspace/src/lib/workspace/sidebar/worker-launch.ts index 639ee36a0..66db63635 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-launch.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-launch.ts @@ -16,6 +16,7 @@ export type WorkerLaunchFormState = { runtime_id: string; display_name: string; profile: string; + subjektiv_subject_id: string; initial_text: string; workdir_attachments: WorkerLaunchAttachmentFormState[]; working_directory_repository_key: string; @@ -103,6 +104,7 @@ export function defaultWorkerLaunchForm( options?.profiles.some((candidate) => candidate.id === current.profile) ? current.profile : preferredProfile?.id || "", + subjektiv_subject_id: current.subjektiv_subject_id, initial_text: current.initial_text, workdir_attachments: workdirAttachments, working_directory_repository_key: @@ -179,6 +181,9 @@ export function buildCreateWorkspaceWorkerRequest( ? [{ kind: "text", content: form.initial_text }] : [], workdir_attachments: validatedAttachments(form.workdir_attachments), + feature_connections: form.subjektiv_subject_id.trim() + ? { subjektiv: { subject_id: form.subjektiv_subject_id.trim() } } + : {}, control_operation_id: null, }); } diff --git a/web/workspace/src/lib/workspace/styles/workers.css b/web/workspace/src/lib/workspace/styles/workers.css index 7fbeb1b28..1b5cdd8aa 100644 --- a/web/workspace/src/lib/workspace/styles/workers.css +++ b/web/workspace/src/lib/workspace/styles/workers.css @@ -184,6 +184,68 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); } + .worker-feature-connection { + display: grid; + gap: var(--space-3); + min-width: 0; + border-left: 2px solid var(--accent); + padding-left: var(--space-3); + } + .worker-feature-connection-heading { + display: flex; + align-items: start; + justify-content: space-between; + gap: var(--space-3); + } + .worker-feature-connection-heading > div { + display: grid; + gap: var(--space-1); + } + .worker-feature-connection-heading h3, + .worker-feature-connection-heading p, + .worker-feature-connection-selection, + .worker-feature-connection-state, + .worker-feature-connection-note { + margin: 0; + } + .worker-feature-connection-heading h3 { + color: var(--text-strong); + font-size: var(--font-size-body); + } + .worker-feature-connection-heading p, + .worker-feature-connection-note, + .worker-feature-connection-state { + color: var(--text-muted); + font-size: var(--font-size-compact); + } + .worker-feature-connection-heading > span { + flex: 0 0 auto; + color: var(--accent); + font-size: var(--font-size-compact); + font-weight: 700; + } + .worker-feature-connection-selection { + display: grid; + gap: var(--space-1); + min-width: 0; + color: var(--text-muted); + font-size: var(--font-size-compact); + } + .worker-feature-connection-selection code { + overflow-wrap: anywhere; + } + .worker-feature-connection-state { + display: flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; + } + .worker-feature-connection-state.is-error { + color: var(--danger); + } + .worker-subject-load-more { + justify-self: start; + } .worker-new-actions { display: flex; align-items: center; @@ -213,6 +275,14 @@ .worker-attachment-row { grid-template-columns: 1fr; } + .worker-feature-connection-heading { + align-items: stretch; + flex-direction: column; + gap: var(--space-2); + } + .worker-feature-connection-heading > span { + align-self: start; + } .worker-attachment-remove { justify-self: start; margin-bottom: 0; diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte index d1648fd46..826003ed6 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte @@ -11,6 +11,7 @@ parseWorkerApiError, parseWorkerLaunchOptionsResponse, } from '#lib/workspace/api/workers.ts'; + import { parseSubjektivSubjectListResponse } from '#lib/workspace/memory/api.ts'; import { formatCurrentWorkdirRevision } from '#lib/workspace/settings/workdir-revision.ts'; import { formatExternalGrantPermissionLevel } from '#lib/workspace/settings/workdir-permissions.ts'; import { @@ -24,6 +25,7 @@ WorkerLaunchOptionsResponse, WorkingDirectorySummary, } from '#lib/workspace/sidebar/types.ts'; + import type { SubjektivSubjectResponse } from '#lib/generated/memory-api.ts'; import type { PageProps } from './$types'; type DisplayError = { @@ -69,6 +71,14 @@ : 'builtin:coder' : '', ); + let subjektivSubjectId = $state(''); + let subjects = $state([]); + let subjectsLoading = $state(false); + let subjectsLoaded = $state(false); + let subjectsError = $state(null); + let subjectsNextCursor = $state(null); + let subjectsHaveMore = $state(false); + let previousProfile = $state(''); let initialText = $state(ticketContext?.initialInput ?? ''); let workdirAttachments = $state([]); let workingDirectoryDisplayName = $state(''); @@ -80,6 +90,10 @@ ); let isNewWorkingDirectorySelected = $derived(newWorkingDirectoryAttachmentIndex >= 0); let selectedRuntime = $derived(options?.runtimes.find((runtime) => runtime.runtime_id === runtimeId)); + let selectedProfile = $derived(options?.profiles.find((candidate) => candidate.id === profile)); + let subjektivConnectionAvailable = $derived(selectedProfile?.feature_connections.subjektiv === true); + let activeSubjects = $derived(subjects.filter((subject) => subject.state === 'active')); + let selectedSubject = $derived(activeSubjects.find((subject) => subject.id === subjektivSubjectId)); let selectedRuntimeAllowsNoWorkdir = $derived(selectedRuntime?.working_directory_required === false); let availableWorkingDirectories = $derived( selectedRuntimeAllowsNoWorkdir @@ -130,6 +144,16 @@ } }); + $effect(() => { + if (profile !== previousProfile) { + subjektivSubjectId = ''; + previousProfile = profile; + } + if (subjektivConnectionAvailable && !subjectsLoaded && !subjectsLoading) { + void loadSubjects(false); + } + }); + function nextAttachmentAlias(): string { const aliases = new Set(workdirAttachments.map((attachment) => attachment.alias.trim())); if (!aliases.has('workdir')) return 'workdir'; @@ -178,6 +202,7 @@ runtime_id: runtimeId, display_name: displayName, profile, + subjektiv_subject_id: subjektivSubjectId, initial_text: initialText, workdir_attachments: workdirAttachments, working_directory_repository_key: workingDirectoryRepositoryKey, @@ -186,6 +211,7 @@ runtimeId = form.runtime_id; displayName = form.display_name; profile = form.profile; + subjektivSubjectId = form.subjektiv_subject_id; const runtimeRequiresWorkdir = payload.runtimes.find((runtime) => runtime.runtime_id === form.runtime_id )?.working_directory_required !== false; @@ -215,6 +241,45 @@ } } + async function loadSubjects(append: boolean) { + if (!workspaceId || subjectsLoading) return; + const cursor = append ? subjectsNextCursor : null; + if (append && !cursor) return; + subjectsLoading = true; + subjectsLoaded = true; + subjectsError = null; + try { + const query = new URLSearchParams({ limit: '100' }); + if (cursor) query.set('cursor', cursor); + const response = await fetch(workerApiPath(`/subjektiv/subjects?${query}`)); + if (!response.ok) { + throw new Error(`subject list failed (${response.status})`); + } + const payload = parseSubjektivSubjectListResponse( + await readBoundedJson(response, 8 * 1024 * 1024), + ); + if (append) { + const byId = new Map(subjects.map((subject) => [subject.id, subject])); + for (const subject of payload.items) byId.set(subject.id, subject); + subjects = [...byId.values()]; + } else { + subjects = payload.items; + } + subjectsNextCursor = payload.next_cursor ?? null; + subjectsHaveMore = payload.has_more; + if ( + subjektivSubjectId && + !subjects.some((subject) => subject.id === subjektivSubjectId && subject.state === 'active') + ) { + subjektivSubjectId = ''; + } + } catch (err) { + subjectsError = err instanceof Error ? err.message : 'subject list failed'; + } finally { + subjectsLoading = false; + } + } + async function createWorkingDirectory() { const attachmentIndex = newWorkingDirectoryAttachmentIndex; if (attachmentIndex < 0) { @@ -304,6 +369,7 @@ runtime_id: runtimeId, display_name: displayName, profile, + subjektiv_subject_id: subjektivSubjectId, initial_text: initialText, workdir_attachments: workdirAttachments, working_directory_repository_key: workingDirectoryRepositoryKey, @@ -511,6 +577,55 @@ + + {#if subjektivConnectionAvailable} +
+
+
+

Subject Memory

+

Optionally connect this Worker to one existing active subject.

+
+ Optional +
+ + {#if selectedSubject} +

+ {selectedSubject.role} + {selectedSubject.id} + {#if selectedSubject.current_worker} + Current Worker: {selectedSubject.current_worker.display_name} + {/if} +

+ {/if} + {#if subjectsLoading && subjects.length === 0} +

Loading authorized subjects…

+ {:else if subjectsError} + + {:else if subjectsLoaded && activeSubjects.length === 0} +

No active subjects are available.

+ {/if} + {#if subjectsHaveMore} + + {/if} +

Profile enablement does not connect a subject by itself.

+
+ {/if}