diff --git a/crates/protocol/src/subscription.rs b/crates/protocol/src/subscription.rs index 150109f7..94ca2604 100644 --- a/crates/protocol/src/subscription.rs +++ b/crates/protocol/src/subscription.rs @@ -568,6 +568,10 @@ pub struct SubscriptionWorker { pub profile: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub repository_id: Option, + /// Workspace-facing Repository key. Runtime producers leave this unset and + /// Workspace Server projections replace `repository_id` with this field. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repository_key: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub working_directory_id: Option, } @@ -811,6 +815,7 @@ mod tests { display_name: Some(format!("Worker {value}")), profile: Some("builtin:coder".to_string()), repository_id: None, + repository_key: None, working_directory_id: None, } } diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index 1140f5ea..a355c547 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -489,6 +489,7 @@ pub struct NewTicket { pub workflow_state: Option, pub queued_by: Option, pub queued_at: Option, + #[serde(rename = "repository_key")] pub repository_id: Option, pub ref_selector: Option, } @@ -519,6 +520,7 @@ impl NewTicket { #[serde(tag = "action", rename_all = "snake_case")] pub enum TicketTargetEdit { Set { + #[serde(rename = "repository_key")] repository_id: String, ref_selector: Option, }, @@ -1610,6 +1612,7 @@ pub struct TicketMeta { pub workflow_state_explicit: bool, pub queued_by: Option, pub queued_at: Option, + #[serde(rename = "repository_key")] pub repository_id: Option, pub ref_selector: Option, pub raw: BTreeMap, diff --git a/crates/ticket/src/tool.rs b/crates/ticket/src/tool.rs index 62b1380d..2b853ae2 100644 --- a/crates/ticket/src/tool.rs +++ b/crates/ticket/src/tool.rs @@ -402,8 +402,8 @@ struct TicketCreateParams { queued_at: Option, /// Optional target Workspace repository id. #[serde(default)] - repository_id: Option, - /// Optional target Git ref selector. Requires `repository_id`. + repository_key: Option, + /// Optional target Git ref selector. Requires `repository_key`. #[serde(default)] ref_selector: Option, } @@ -944,7 +944,7 @@ impl Tool for TicketCreateTool { input.workflow_state = params.state.map(TicketWorkflowStateParam::into_state); input.queued_by = None; input.queued_at = params.queued_at; - input.repository_id = params.repository_id; + input.repository_id = params.repository_key; input.ref_selector = params.ref_selector; let created = self @@ -1173,7 +1173,7 @@ impl Tool for TicketMarkReadyTool { json!({ "ticket": ticket.meta.id, "state": ticket.meta.workflow_state.as_str(), - "repository_id": ticket.meta.repository_id, + "repository_key": ticket.meta.repository_id, "ref_selector": ticket.meta.ref_selector, "ok": true }), @@ -1206,7 +1206,7 @@ impl Tool for TicketIntakeReadyTool { json!({ "ticket": ticket.meta.id, "state": ticket.meta.workflow_state.as_str(), - "repository_id": ticket.meta.repository_id, + "repository_key": ticket.meta.repository_id, "ref_selector": ticket.meta.ref_selector, "ok": true }), @@ -1940,11 +1940,11 @@ mod tests { fn resolve_target( &self, _workspace_id: &str, - repository_id: Option<&str>, + repository_key: Option<&str>, ref_selector: Option<&str>, ) -> crate::Result { Ok(crate::ResolvedTicketTarget { - repository_id: repository_id.unwrap_or("main").to_owned(), + repository_id: repository_key.unwrap_or("main").to_owned(), ref_selector: ref_selector.unwrap_or("develop").to_owned(), }) } diff --git a/crates/tui/src/backend_worker_picker.rs b/crates/tui/src/backend_worker_picker.rs index 46e3f28a..54c7b6e4 100644 --- a/crates/tui/src/backend_worker_picker.rs +++ b/crates/tui/src/backend_worker_picker.rs @@ -370,7 +370,7 @@ fn working_directory_text(worker: &BackendWorkerSummary) -> String { let cleanliness = wd.cleanliness.as_deref().unwrap_or("unknown"); format!( "wd:{}:{} {} {}", - wd.repository_id, wd.working_directory_id, wd.status, cleanliness + wd.repository_key, wd.working_directory_id, wd.status, cleanliness ) } diff --git a/crates/workdir/src/workspace.rs b/crates/workdir/src/workspace.rs index 825e5b16..3872bc9d 100644 --- a/crates/workdir/src/workspace.rs +++ b/crates/workdir/src/workspace.rs @@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize}; pub use workspace_api::{ + RuntimeWorkingDirectoryCleanupTarget, RuntimeWorkingDirectorySummary, WorkingDirectoryCleanupTarget, WorkingDirectoryMaterializerKind as MaterializerKind, WorkingDirectoryOccupancy, WorkingDirectoryStatusKind, WorkingDirectorySummary, }; diff --git a/crates/worker-runtime/src/catalog.rs b/crates/worker-runtime/src/catalog.rs index 44453575..2e5d9b9f 100644 --- a/crates/worker-runtime/src/catalog.rs +++ b/crates/worker-runtime/src/catalog.rs @@ -92,9 +92,9 @@ pub struct WorkingDirectoryRepository { } pub use workdir::workspace::{ - MaterializerKind, WorkingDirectoryCleanupTarget, WorkingDirectoryCurrentObservation, + MaterializerKind, RuntimeWorkingDirectoryCleanupTarget as WorkingDirectoryCleanupTarget, + RuntimeWorkingDirectorySummary as WorkingDirectorySummary, WorkingDirectoryCurrentObservation, WorkingDirectoryOccupancy, WorkingDirectoryProvenance, WorkingDirectoryStatusKind, - WorkingDirectorySummary, }; #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index 7ac91a3f..6d3d997a 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -2402,6 +2402,7 @@ impl RuntimeState { display_name: worker.request.display_name.clone(), profile, repository_id, + repository_key: None, working_directory_id, }) } diff --git a/crates/worker/src/feature/builtin/manage_workdir.rs b/crates/worker/src/feature/builtin/manage_workdir.rs index 74419af6..f00093eb 100644 --- a/crates/worker/src/feature/builtin/manage_workdir.rs +++ b/crates/worker/src/feature/builtin/manage_workdir.rs @@ -423,12 +423,13 @@ impl WorkspaceHttpWorkdirBackend { .as_deref() .map(|value| validate_identity(value, CREATE_TOOL, "runtime_id")) .transpose()?; - let repository_id = validate_identity(&input.repository_id, CREATE_TOOL, "repository_id")?; + let repository_key = + validate_identity(&input.repository_key, CREATE_TOOL, "repository_key")?; let selector = validate_optional_selector(input.selector)?; let workspace_id = encode_path_segment(self.workspace_id()?); let request = WorkdirCreateRequest { runtime_id: runtime_id.map(str::to_string), - repository_id: repository_id.to_string(), + repository_key: repository_key.to_string(), selector, operation_id: Some(operation_id), }; @@ -682,10 +683,10 @@ fn create_schema() -> serde_json::Value { json!({ "type": "object", "additionalProperties": false, - "required": ["repository_id"], + "required": ["repository_key"], "properties": { "runtime_id": {"type": ["string", "null"], "minLength": 1}, - "repository_id": {"type": "string", "minLength": 1}, + "repository_key": {"type": "string", "minLength": 1}, "selector": {"type": ["string", "null"], "minLength": 1} } }) @@ -727,7 +728,7 @@ struct WorkdirListInput {} struct WorkdirCreateInput { #[serde(default)] runtime_id: Option, - repository_id: String, + repository_key: String, #[serde(default)] selector: Option, } @@ -840,14 +841,14 @@ mod tests { fn workdir_json(id: &str) -> serde_json::Value { json!({ "working_directory_id": id, - "repository_id": "main", + "repository_key": "main", "creation_selector": "refs/heads/main", "creation_ref": "0123456789abcdef", "materializer_kind": "local_git_worktree", "cleanup_target": { "kind": "git_worktree", "working_directory_id": id, - "repository_id": "main" + "repository_key": "main" }, "status": "active", "cleanliness": "clean", @@ -973,7 +974,7 @@ mod tests { #[test] fn schemas_expose_identities_without_paths_or_session_handles() { let create = create_schema(); - assert_eq!(create["required"], json!(["repository_id"])); + assert_eq!(create["required"], json!(["repository_key"])); assert_eq!( create["properties"]["runtime_id"]["type"], json!(["string", "null"]) @@ -1048,7 +1049,7 @@ mod tests { .create( WorkdirCreateInput { runtime_id: Some("runtime/one".to_string()), - repository_id: "main".to_string(), + repository_key: "main".to_string(), selector: Some("refs/heads/topic".to_string()), }, "call-create-1".to_string(), @@ -1089,7 +1090,7 @@ mod tests { assert_eq!(requests[1].method, WorkspaceRequestMethod::Post); let body: serde_json::Value = serde_json::from_str(requests[1].body.as_deref().unwrap()).unwrap(); - assert_eq!(body["repository_id"], "main"); + assert_eq!(body["repository_key"], "main"); assert_eq!(body["runtime_id"], "runtime/one"); assert_eq!(body["operation_id"], "call-create-1"); assert_eq!(body["selector"], "refs/heads/topic"); @@ -1380,7 +1381,7 @@ mod tests { .create( WorkdirCreateInput { runtime_id: None, - repository_id: "main".to_string(), + repository_key: "main".to_string(), selector: None, }, "call-default".to_string(), @@ -1405,7 +1406,7 @@ mod tests { .create( WorkdirCreateInput { runtime_id: Some(" ".to_string()), - repository_id: "main".to_string(), + repository_key: "main".to_string(), selector: None, }, "call-invalid".to_string(), diff --git a/crates/worker/src/feature/builtin/merge_request.rs b/crates/worker/src/feature/builtin/merge_request.rs index 96c78ac2..1752a42e 100644 --- a/crates/worker/src/feature/builtin/merge_request.rs +++ b/crates/worker/src/feature/builtin/merge_request.rs @@ -56,7 +56,7 @@ struct TicketInput { #[derive(Debug, Deserialize, JsonSchema)] struct OpenMergeRequestInput { ticket: String, - repository_id: String, + repository_key: String, selector_from: String, selector_to: String, #[serde(default)] @@ -178,7 +178,7 @@ impl Tool for MergeRequestTool { WorkspaceRequestMethod::Post, format!("/api/w/{ws}/tickets/{}/merge-request", v.ticket), Some( - json!({"repository_id":v.repository_id,"selector_from":v.selector_from,"selector_to":v.selector_to,"summary":v.summary}), + json!({"repository_key":v.repository_key,"selector_from":v.selector_from,"selector_to":v.selector_to,"summary":v.summary}), ), ) } diff --git a/crates/workspace-api/src/lib.rs b/crates/workspace-api/src/lib.rs index 54d43821..0948c843 100644 --- a/crates/workspace-api/src/lib.rs +++ b/crates/workspace-api/src/lib.rs @@ -533,7 +533,7 @@ impl std::fmt::Display for WorkingDirectoryStatusKind { pub struct WorkingDirectoryCleanupTarget { pub kind: String, pub working_directory_id: String, - pub repository_id: String, + pub repository_key: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -575,6 +575,51 @@ pub struct WorkingDirectoryOccupancy { pub linked_at: String, } +/// Runtime-internal Workdir cleanup authority. This transport intentionally +/// retains the Backend-generated Repository id and is never a Workspace public +/// projection. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RuntimeWorkingDirectoryCleanupTarget { + pub kind: String, + pub working_directory_id: String, + pub repository_id: String, +} + +/// Runtime-internal Workdir inventory transport. Workspace REST and model-facing +/// surfaces must project this through [`WorkingDirectorySummary`] so the UUID is +/// replaced with `repository_key`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RuntimeWorkingDirectorySummary { + pub working_directory_id: String, + pub repository_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub creation_selector: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub creation_ref: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub creation_tree: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_selector: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_ref: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_tree: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observed_at_epoch_seconds: Option, + pub materializer_kind: WorkingDirectoryMaterializerKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cleanup_target: Option, + pub status: WorkingDirectoryStatusKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cleanliness: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub primary_worker_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub occupied_by: Option, +} + /// Public, provider-neutral Workdir inventory projection. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] @@ -582,7 +627,7 @@ pub struct WorkingDirectoryOccupancy { #[serde(deny_unknown_fields)] pub struct WorkingDirectorySummary { pub working_directory_id: String, - pub repository_id: String, + pub repository_key: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub creation_selector: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -629,7 +674,7 @@ impl WorkingDirectorySummary { pub struct WorkingDirectoryCreateRequest { #[serde(default, skip_serializing_if = "Option::is_none")] pub runtime_id: Option, - pub repository_id: String, + pub repository_key: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub selector: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1228,7 +1273,7 @@ pub enum RepositoryAccessMode { #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct RepositorySshAccessBinding { - pub repository_id: String, + pub repository_key: String, pub credential_id: String, pub host_trust_id: String, pub access: RepositoryAccessMode, @@ -1438,8 +1483,7 @@ mod tests { let repositories = serde_json::json!({ "workspace_id": "workspace-test", "items": [{ - "id": "main", - "display_name": "main", + "repository_key": "main", "kind": "git", "provider": "git", "source": {"kind": "local_path", "uri": "/srv/project"}, @@ -1460,7 +1504,7 @@ mod tests { let stale = serde_json::json!({ "workspace_id": "workspace-test", "items": [{ - "repository_id": "main", + "repository_key": "main", "display_name": "main", "kind": "git", "provider": "git", @@ -1497,7 +1541,7 @@ mod tests { assert!(output.contains( "export type WorkspaceProfileSourceProvenance = \"project_profile_source_tree\"" )); - assert!(!output.contains("repository_id: string, display_name")); + assert!(!output.contains("repository_key: string, display_name")); } #[test] @@ -1754,7 +1798,7 @@ mod tests { #[test] fn workdir_create_request_preserves_optional_operation_fields() { - let payload = serde_json::json!({"repository_id": "main"}); + let payload = serde_json::json!({"repository_key": "main"}); let request = serde_json::from_value::(payload) .expect("optional create fields may be absent"); @@ -1763,13 +1807,13 @@ mod tests { assert_eq!(request.operation_id, None); let serialized = serde_json::to_value(request).expect("serialize create request"); - assert_eq!(serialized, serde_json::json!({"repository_id": "main"})); + assert_eq!(serialized, serde_json::json!({"repository_key": "main"})); } #[test] fn workdir_create_request_rejects_stale_or_incomplete_json() { let stale = serde_json::json!({ - "repository_id": "main", + "repository_key": "main", "selector": "develop", "path": "/tmp/workdir" }); @@ -1786,7 +1830,7 @@ mod tests { fn workdir_summary_omits_absent_optional_fields_on_the_wire() { let value = serde_json::to_value(WorkingDirectorySummary { working_directory_id: "workdir-1".into(), - repository_id: "main".into(), + repository_key: "main".into(), creation_selector: None, creation_ref: None, creation_tree: None, @@ -1830,7 +1874,7 @@ mod tests { "workspace_id": "workspace-test", "items": [{ "working_directory_id": "workdir-1", - "repository_id": "main", + "repository_key": "main", "materializer_kind": "runtime_git_cache", "status": "active", "occupied_by": { diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index c88ad058..93156958 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -918,13 +918,33 @@ impl SqliteWorkspaceAuthority { self.merge_revision_source .resolve_subject_ref(&request.repository_id, selector) }); - Some(merge_request_summary(request, current_subject_ref)) + let repository_key = self + .store + .get_repository(&self.workspace_id, &request.repository_id)? + .map(|repository| repository.repository_key) + .ok_or_else(|| Error::UnknownRepository(request.repository_id.clone()))?; + Some(merge_request_summary( + request, + repository_key, + current_subject_ref, + )) } Err(MergeRequestError::NotFound) => None, Err(error) => return Err(Error::Store(error.to_string())), }; + let repository_key = ticket + .meta + .repository_id + .as_deref() + .map(|repository_id| { + self.store + .get_repository(&self.workspace_id, repository_id)? + .map(|repository| repository.repository_key) + .ok_or_else(|| Error::UnknownRepository(repository_id.to_string())) + }) + .transpose()?; let evidence = ticket_evidence_summary( - ticket.meta.repository_id.as_deref(), + repository_key.as_deref(), &ticket.events, merge_request.as_ref(), ); @@ -980,7 +1000,7 @@ impl SqliteWorkspaceAuthority { item_revision, queued_by: ticket.meta.queued_by, queued_at: ticket.meta.queued_at, - repository_id: ticket.meta.repository_id, + repository_key, ref_selector: ticket.meta.ref_selector, risk_flags: ticket.meta.risk_flags, body, @@ -1742,6 +1762,7 @@ fn ticket_evidence_event(sequence: usize, event: &TicketEvent) -> TicketEvidence pub(crate) fn merge_request_summary( request: MergeRequest, + repository_key: String, current_subject_ref: Option, ) -> TicketMergeRequestSummary { let latest_review_request = request.thread.iter().rev().find_map(|event| match event { @@ -1790,7 +1811,7 @@ pub(crate) fn merge_request_summary( TicketMergeRequestSummary { merge_request_id: request.merge_request_id.clone(), - repository_id: request.repository_id.clone(), + repository_key, state, review_status, selector_from: request.selector_from.clone(), @@ -1835,7 +1856,7 @@ fn ticket_evidence_summary( let linked_merge_request = merge_request.filter(|request| { request.state == "open" && ticket_repository_id - .is_some_and(|repository_id| repository_id == request.repository_id) + .is_some_and(|repository_id| repository_id == request.repository_key) }); let has_merge_request = linked_merge_request.is_some(); let has_current_subject_ref = linked_merge_request.is_some_and(|request| { @@ -1886,7 +1907,7 @@ fn ticket_evidence_summary( Some(request) if request.state != "open" => missing.push("open_merge_request".to_string()), Some(request) if ticket_repository_id - .is_none_or(|repository_id| repository_id != request.repository_id) => + .is_none_or(|repository_id| repository_id != request.repository_key) => { missing.push("merge_request_repository".to_string()) } @@ -2823,6 +2844,7 @@ mod tests { fn merge_request_summary_uses_the_provider_resolved_current_subject() { let approved = merge_request_summary( reviewed_merge_request(ReviewDecision::Approve, false), + "main".to_string(), Some("commit-1".to_string()), ); assert_eq!(approved.review_status, "approved"); @@ -2834,6 +2856,7 @@ mod tests { let moved = merge_request_summary( reviewed_merge_request(ReviewDecision::Approve, false), + "main".to_string(), Some("commit-2".to_string()), ); assert_eq!(moved.review_status, "pending"); @@ -2846,6 +2869,7 @@ mod tests { fn ticket_readiness_requires_current_unrevoked_approval_without_a_report() { let approved = merge_request_summary( reviewed_merge_request(ReviewDecision::Approve, false), + "main".to_string(), Some("commit-1".to_string()), ); let evidence = ticket_evidence_summary(Some("main"), &[], Some(&approved)); @@ -2866,6 +2890,7 @@ mod tests { let revoked = merge_request_summary( reviewed_merge_request(ReviewDecision::Approve, true), + "main".to_string(), Some("commit-1".to_string()), ); let evidence = ticket_evidence_summary(Some("main"), &[], Some(&revoked)); @@ -2874,6 +2899,7 @@ mod tests { let changes = merge_request_summary( reviewed_merge_request(ReviewDecision::RequestChanges, false), + "main".to_string(), Some("commit-1".to_string()), ); let evidence = ticket_evidence_summary(Some("main"), &[], Some(&changes)); @@ -2883,8 +2909,11 @@ mod tests { #[test] fn ticket_readiness_fails_closed_for_missing_or_closed_current_merge_request() { - let unresolved = - merge_request_summary(reviewed_merge_request(ReviewDecision::Approve, false), None); + let unresolved = merge_request_summary( + reviewed_merge_request(ReviewDecision::Approve, false), + "main".to_string(), + None, + ); let evidence = ticket_evidence_summary(Some("main"), &[], Some(&unresolved)); assert!(!evidence.has_current_subject_ref); assert!(!evidence.has_commit); @@ -2892,7 +2921,11 @@ mod tests { let mut closed_request = reviewed_merge_request(ReviewDecision::Approve, false); closed_request.state = MergeRequestState::Closed; - let closed = merge_request_summary(closed_request, Some("commit-1".to_string())); + let closed = merge_request_summary( + closed_request, + "main".to_string(), + Some("commit-1".to_string()), + ); let evidence = ticket_evidence_summary(Some("main"), &[], Some(&closed)); assert!(!evidence.has_merge_request); assert!(!evidence.complete_for_integration); @@ -2903,6 +2936,7 @@ mod tests { fn ticket_readiness_requires_request_and_approval_after_substantive_rescope() { let approved = merge_request_summary( reviewed_merge_request(ReviewDecision::Approve, false), + "main".to_string(), Some("commit-1".to_string()), ); let fresh = ticket_evidence_summary( @@ -2946,6 +2980,7 @@ mod tests { fn ticket_query_filters_map_to_current_merge_request_evidence() { let approved_summary = merge_request_summary( reviewed_merge_request(ReviewDecision::Approve, false), + "main".to_string(), Some("commit-1".to_string()), ); let approved = ticket_evidence_summary(Some("main"), &[], Some(&approved_summary)); @@ -2962,6 +2997,7 @@ mod tests { let pending_summary = merge_request_summary( reviewed_merge_request(ReviewDecision::Approve, false), + "main".to_string(), Some("commit-2".to_string()), ); let pending = ticket_evidence_summary(Some("main"), &[], Some(&pending_summary)); diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index 3e06d842..5bdd083f 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -313,6 +313,7 @@ impl From for workspace_api::RuntimeSummary { pub(crate) fn workspace_worker_summary( summary: WorkerSummary, resource_key: String, + working_directory: Option, ) -> workspace_api::WorkerSummary { workspace_api::WorkerSummary { runtime_id: summary.worker.runtime_id, @@ -341,7 +342,7 @@ pub(crate) fn workspace_worker_summary( can_stop: summary.capabilities.can_stop, can_spawn_followup: summary.capabilities.can_spawn_followup, }, - working_directory: summary.working_directory, + working_directory, diagnostics: summary.diagnostics.into_iter().map(Into::into).collect(), } } @@ -402,10 +403,10 @@ pub struct RuntimeWorkingDirectoryResult { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct WorkerSpawnWorkingDirectoryRequest { - /// Safe configured Repository id. The host resolves this id to repository - /// authority from server-side config; browser callers cannot provide raw - /// source paths or runtime-internal storage paths. - pub repository_id: String, + /// Safe configured Repository key. The host resolves this key to internal + /// Repository authority; browser callers cannot provide Backend UUIDs, raw + /// source paths, or Runtime-internal storage paths. + pub repository_key: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub selector: Option, } diff --git a/crates/workspace-server/src/records.rs b/crates/workspace-server/src/records.rs index c5312fa3..6d0f1690 100644 --- a/crates/workspace-server/src/records.rs +++ b/crates/workspace-server/src/records.rs @@ -78,7 +78,7 @@ pub struct TicketDetail { pub item_revision: String, pub queued_by: Option, pub queued_at: Option, - pub repository_id: Option, + pub repository_key: Option, pub ref_selector: Option, pub risk_flags: Vec, pub body: String, @@ -301,7 +301,7 @@ pub struct TicketActionEligibility { #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketMergeRequestSummary { pub merge_request_id: String, - pub repository_id: String, + pub repository_key: String, pub state: String, pub review_status: String, pub selector_from: Option, diff --git a/crates/workspace-server/src/repositories.rs b/crates/workspace-server/src/repositories.rs index a294e7bd..0044f568 100644 --- a/crates/workspace-server/src/repositories.rs +++ b/crates/workspace-server/src/repositories.rs @@ -97,7 +97,14 @@ impl RepositoryRegistryReader { } } - pub fn summary( + pub fn summary(&self, id: &str) -> Result { + let repository = self + .find(id) + .ok_or_else(|| RepositoryLookupError::UnknownRepository { id: id.to_string() })?; + Ok(self.summary_for_config(repository)) + } + + pub fn summary_by_key( &self, repository_key: &str, ) -> Result { diff --git a/crates/workspace-server/src/repository_access.rs b/crates/workspace-server/src/repository_access.rs index d14ce95c..9f8fe10f 100644 --- a/crates/workspace-server/src/repository_access.rs +++ b/crates/workspace-server/src/repository_access.rs @@ -146,16 +146,17 @@ fn project_repository_access_evaluation( Error::InvalidInput(format!("invalid Repository access config: {error}")) })?; let mut bindings = Vec::with_capacity(config.repository_access.len()); - for (repository_id, access) in config.repository_access { - validate_identifier("repository_id", &repository_id)?; + for (repository_key, access) in config.repository_access { + workspace_api::validate_repository_key(&repository_key) + .map_err(|error| Error::InvalidInput(format!("invalid Repository key: {error}")))?; validate_identifier("credential_id", &access.ssh.credential)?; validate_identifier("host_trust_id", &access.ssh.host_trust)?; let repository = store - .get_repository(workspace_id, &repository_id)? - .ok_or_else(|| Error::InvalidInput(format!("unknown Repository `{repository_id}`")))?; + .get_repository_by_key(workspace_id, &repository_key)? + .ok_or_else(|| Error::InvalidInput(format!("unknown Repository `{repository_key}`")))?; if repository.source.kind != workspace_api::RepositorySourceKind::Ssh { return Err(Error::InvalidInput(format!( - "Repository `{repository_id}` is not an ssh:// Repository" + "Repository `{repository_key}` is not an ssh:// Repository" ))); } let credential = secrets @@ -182,34 +183,34 @@ fn project_repository_access_evaluation( })?; let uri = url::Url::parse(&repository.source.uri).map_err(|_| { Error::InvalidInput(format!( - "Repository `{repository_id}` has an invalid SSH URI" + "Repository `{repository_key}` has an invalid SSH URI" )) })?; if uri.scheme() != "ssh" || uri.username().is_empty() || uri.password().is_some() { return Err(Error::InvalidInput(format!( - "Repository `{repository_id}` must use ssh://user@host[:port]/path without credentials" + "Repository `{repository_key}` must use ssh://user@host[:port]/path without credentials" ))); } let hostname = uri.host_str().ok_or_else(|| { Error::InvalidInput(format!( - "Repository `{repository_id}` SSH URI has no hostname" + "Repository `{repository_key}` SSH URI has no hostname" )) })?; let port = uri.port().unwrap_or(22); if hostname != host_trust.hostname || port != host_trust.port { return Err(Error::InvalidInput(format!( - "Repository `{repository_id}` SSH host does not match host trust `{}`", + "Repository `{repository_key}` SSH host does not match host trust `{}`", access.ssh.host_trust ))); } bindings.push(RepositorySshAccessBinding { - repository_id, + repository_key, credential_id: access.ssh.credential, host_trust_id: access.ssh.host_trust, access: access.ssh.access, }); } - bindings.sort_by(|left, right| left.repository_id.cmp(&right.repository_id)); + bindings.sort_by(|left, right| left.repository_key.cmp(&right.repository_key)); Ok(RepositoryAccessProjection { workspace_id: workspace_id.to_string(), config_revision, @@ -1441,7 +1442,7 @@ fn credential_references( .bindings .iter() .filter(|binding| binding.credential_id == credential_id) - .map(|binding| binding.repository_id.clone()) + .map(|binding| binding.repository_key.clone()) .collect() } @@ -1453,7 +1454,7 @@ fn host_trust_references( .bindings .iter() .filter(|binding| binding.host_trust_id == host_trust_id) - .map(|binding| binding.repository_id.clone()) + .map(|binding| binding.repository_key.clone()) .collect() } @@ -1912,7 +1913,7 @@ mod tests { let projection = project_repository_access_state(&*store, &service, "workspace-a", &state).unwrap(); assert_eq!(projection.bindings.len(), 1); - assert_eq!(projection.bindings[0].repository_id, "remote"); + assert_eq!(projection.bindings[0].repository_key, "remote"); assert_eq!( projection.bindings[0].access, RepositoryAccessMode::ReadOnly @@ -1999,7 +2000,7 @@ mod tests { config_revision: 3, projection_digest: "sha256:test".to_string(), bindings: vec![RepositorySshAccessBinding { - repository_id: "main".to_string(), + repository_key: "main".to_string(), credential_id: "deploy".to_string(), host_trust_id: "host".to_string(), access: RepositoryAccessMode::ReadOnly, diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 7aef240a..54f52bc9 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -1976,6 +1976,22 @@ impl WorkspaceApi { .ok_or_else(|| ApiError::from(Error::UnknownRepository(repository_id.to_string()))) } + fn require_configured_workspace_repository_by_key( + &self, + repository_key: &str, + ) -> ApiResult { + workspace_api::validate_repository_key(repository_key).map_err(|error| { + ApiError::from(Error::InvalidInput(format!( + "invalid Repository key: {error}" + ))) + })?; + let repository = self + .store + .get_repository_by_key(&self.config.workspace_id, repository_key)? + .ok_or_else(|| ApiError::from(Error::UnknownRepository(repository_key.to_string())))?; + self.require_configured_workspace_repository(&repository.repository_id) + } + fn validate_worker_spawn_repository_scope( &self, request: &WorkerSpawnRequest, @@ -2013,14 +2029,16 @@ impl WorkspaceApi { // Workdir-less Ticket Workers cannot execute repository implementation. // Preserve that control-plane launch while still validating any persisted // target (including its Workspace ownership) when one exists. - if selected_repository_id.is_none() && ticket.repository_id.is_none() { + if selected_repository_id.is_none() && ticket.repository_key.is_none() { return Ok(()); } - let repository_id = ticket.repository_id.as_deref().ok_or_else(|| { + let repository_key = ticket.repository_key.as_deref().ok_or_else(|| { ApiError::from(Error::Config( "Ticket implementation target must be validated and persisted before spawning a Ticket Worker".to_owned(), )) })?; + let repository = self.require_configured_workspace_repository_by_key(repository_key)?; + let repository_id = repository.id.as_str(); let ref_selector = ticket.ref_selector.as_deref().ok_or_else(|| { ApiError::from(Error::Config( "Ticket implementation target selector must be validated and persisted before spawning a Ticket Worker".to_owned(), @@ -2036,7 +2054,7 @@ impl WorkspaceApi { })?; if selected_repository_id.as_deref() != Some(repository_id) { return Err(ApiError::from(Error::Config(format!( - "Ticket `{ticket_id}` targets repository `{repository_id}`, but the Worker launch resolves `{}`", + "Ticket `{ticket_id}` targets repository `{repository_key}`, but the Worker launch resolves `{}`", selected_repository_id.as_deref().unwrap_or("none") )))); } @@ -2459,14 +2477,14 @@ fn build_inner_router(api: WorkspaceApi) -> Router { "/api/w/{workspace_id}/repositories", get(scoped_list_repositories).post(scoped_create_repository), ) - .route("/api/repositories/{repository_id}", get(repository_detail)) + .route("/api/repositories/{repository_key}", get(repository_detail)) .route( - "/api/w/{workspace_id}/repositories/{repository_id}", + "/api/w/{workspace_id}/repositories/{repository_key}", get(scoped_repository_detail), ) - .route("/api/repositories/{repository_id}/log", get(repository_log)) + .route("/api/repositories/{repository_key}/log", get(repository_log)) .route( - "/api/w/{workspace_id}/repositories/{repository_id}/log", + "/api/w/{workspace_id}/repositories/{repository_key}/log", get(scoped_repository_log), ) .route("/api/hosts", get(list_hosts)) @@ -2929,7 +2947,7 @@ pub struct CleanupWorkdirCandidate { pub action: CleanupTargetKind, pub workdir_id: String, pub runtime_id: String, - pub repository_id: String, + pub repository_key: String, pub reason: String, pub blocking_reason: Option, pub linked_worker_ids: Vec, @@ -3024,8 +3042,7 @@ pub struct WorkerLaunchProfileCandidate { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkingDirectoryRepositoryOption { - pub id: String, - pub display_name: String, + pub repository_key: String, #[serde(skip_serializing_if = "Option::is_none")] pub default_selector: Option, } @@ -3174,7 +3191,7 @@ struct ScopedSkillPath { #[derive(Debug, Deserialize)] struct ScopedRepositoryPath { workspace_id: String, - repository_id: String, + repository_key: String, } #[derive(Debug, Deserialize)] @@ -4558,17 +4575,24 @@ async fn scoped_edit_ticket_item( Json(request): Json, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - if let Some(TicketTargetEdit::Set { repository_id, .. }) = request.target.as_ref() { - if api + let mut target = request.target; + if let Some(TicketTargetEdit::Set { repository_id, .. }) = target.as_mut() { + workspace_api::validate_repository_key(repository_id).map_err(|_| { + settings_bad_request( + "repository_key_invalid", + "Repository key must contain 1-64 lowercase ASCII letters, digits, or hyphens without a leading or trailing hyphen", + ) + })?; + *repository_id = api .store - .get_repository(&api.config.workspace_id, repository_id)? - .is_none() - { - return Err(settings_bad_request( - "unknown_ticket_repository", - "repository_id must identify a repository registered in this Workspace", - )); - } + .get_repository_by_key(&api.config.workspace_id, repository_id)? + .map(|repository| repository.repository_id) + .ok_or_else(|| { + settings_bad_request( + "unknown_ticket_repository", + "repository_key must identify a Repository registered in this Workspace", + ) + })?; } browser_ticket_backend(&api)? .edit_item( @@ -4590,7 +4614,7 @@ async fn scoped_edit_ticket_item( )); } }, - target: request.target, + target, author: request.author, }, ) @@ -4736,6 +4760,36 @@ fn reject_unguarded_ticket_completion(operation: &TicketBackendOperation) -> Res Ok(()) } +fn resolve_ticket_operation_repository_keys( + api: &WorkspaceApi, + workspace_id: &str, + operation: &mut TicketBackendOperation, +) -> ApiResult<()> { + let submitted_key = match operation { + TicketBackendOperation::Create { input } => input.repository_id.as_mut(), + TicketBackendOperation::EditItem { edit, .. } => match edit.target.as_mut() { + Some(ticket::TicketTargetEdit::Set { repository_id, .. }) => Some(repository_id), + _ => None, + }, + _ => None, + }; + let Some(repository_key) = submitted_key else { + return Ok(()); + }; + workspace_api::validate_repository_key(repository_key).map_err(|error| { + ApiError::from(Error::InvalidInput(format!( + "invalid Repository key: {error}" + ))) + })?; + let repository_id = api + .store + .get_repository_by_key(workspace_id, repository_key)? + .map(|repository| repository.repository_id) + .ok_or_else(|| ApiError::from(Error::UnknownRepository(repository_key.clone())))?; + *repository_key = repository_id; + Ok(()) +} + async fn execute_ticket_rest_operation( api: &WorkspaceApi, workspace_id: &str, @@ -4743,6 +4797,7 @@ async fn execute_ticket_rest_operation( mut operation: TicketBackendOperation, ) -> ApiResult { validate_workspace_scope(api, workspace_id)?; + resolve_ticket_operation_repository_keys(api, workspace_id, &mut operation)?; let mut backend = SqliteTicketBackend::open_verified( api.config.database_path.clone(), api.config.workspace_id.clone(), @@ -4844,7 +4899,7 @@ async fn execute_ticket_rest_operation( backend = backend.with_event_attributes(event_attributes); } - let result = execute_ticket_backend_operation(&backend, operation).map_err(Error::from)?; + let mut result = execute_ticket_backend_operation(&backend, operation).map_err(Error::from)?; if is_mutation { if let TicketBackendOperationResult::QueueOutcome(outcome) = &result { for ticket_id in &outcome.queued_tickets { @@ -4870,6 +4925,16 @@ async fn execute_ticket_rest_operation( ); } } + if let TicketBackendOperationResult::Ticket(ticket) = &mut result + && let Some(repository_id) = ticket.meta.repository_id.as_deref() + { + let repository_key = api + .store + .get_repository(workspace_id, repository_id)? + .map(|repository| repository.repository_key) + .ok_or_else(|| Error::UnknownRepository(repository_id.to_string()))?; + ticket.meta.repository_id = Some(repository_key); + } Ok(result) } @@ -5211,7 +5276,7 @@ async fn scoped_queue_ticket_record( #[derive(Debug, serde::Deserialize)] struct OpenMergeRequestRequest { - repository_id: String, + repository_key: String, selector_from: String, selector_to: String, #[serde(default)] @@ -5433,10 +5498,34 @@ fn resolve_workspace_worker_ticket_assignment( Ok(()) } +fn public_merge_request( + api: &WorkspaceApi, + workspace_id: &str, + mr: merge_request::MergeRequest, +) -> ApiResult { + let repository_key = api + .store + .get_repository(workspace_id, &mr.repository_id)? + .map(|repository| repository.repository_key) + .ok_or_else(|| ApiError::from(Error::UnknownRepository(mr.repository_id.clone())))?; + Ok(PublicMergeRequest { + workspace_id: mr.workspace_id, + merge_request_id: mr.merge_request_id, + repository_key, + state: mr.state, + selector_from: mr.selector_from, + selector_to: mr.selector_to, + ticket_ids: mr.ticket_ids, + created_at: mr.created_at, + updated_at: mr.updated_at, + thread: mr.thread, + }) +} + #[derive(Debug, serde::Deserialize)] struct MergeRequestListHttpQuery { state: Option, - repository_id: Option, + repository_key: Option, ticket_ref: Option, selector_from: Option, selector_to: Option, @@ -5461,12 +5550,26 @@ struct MergeRequestLinkedTicketResponse { #[derive(Debug, serde::Serialize)] struct MergeRequestDetailResponse { #[serde(flatten)] - merge_request: merge_request::MergeRequest, + merge_request: PublicMergeRequest, source: MergeRequestRefResponse, target: MergeRequestRefResponse, linked_tickets: Vec, } +#[derive(Debug, Clone, serde::Serialize)] +struct PublicMergeRequest { + workspace_id: String, + merge_request_id: String, + repository_key: String, + state: merge_request::MergeRequestState, + selector_from: Option, + selector_to: String, + ticket_ids: Vec, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, + thread: Vec, +} + async fn scoped_list_merge_requests( State(api): State, AxumPath(workspace_id): AxumPath, @@ -5492,12 +5595,22 @@ async fn scoped_list_merge_requests( )), }) .transpose()?; + let repository_id = query + .repository_key + .as_deref() + .map(|repository_key| { + api.store + .get_repository_by_key(&workspace_id, repository_key)? + .map(|repository| repository.repository_id) + .ok_or_else(|| Error::UnknownRepository(repository_key.to_string())) + }) + .transpose()?; let store = merge_request_store(&api, &workspace_id)?; let page = store.list( &workspace_id, &merge_request::MergeRequestListQuery { state, - repository_id: query.repository_id, + repository_id, ticket_id, selector_from: query.selector_from, selector_to: query.selector_to, @@ -5509,22 +5622,31 @@ async fn scoped_list_merge_requests( let items = page .items .into_iter() - .map(|merge_request| { + .map(|merge_request| -> ApiResult { let current_subject_ref = merge_request.selector_from.as_deref().and_then(|selector| { reader .observe_merge_target(&merge_request.repository_id, Some(selector)) .ok() .map(|observation| observation.commit) }); + let repository_key = api + .store + .get_repository(&workspace_id, &merge_request.repository_id)? + .map(|repository| repository.repository_key) + .ok_or_else(|| { + ApiError::from(Error::UnknownRepository( + merge_request.repository_id.clone(), + )) + })?; let ticket_ids = merge_request.ticket_ids.clone(); let thread_event_count = merge_request.thread.len(); - MergeRequestListItem { - summary: merge_request_summary(merge_request, current_subject_ref), + Ok(MergeRequestListItem { + summary: merge_request_summary(merge_request, repository_key, current_subject_ref), ticket_ids, thread_event_count, - } + }) }) - .collect(); + .collect::>>()?; Ok(Json(MergeRequestListResponse { items, next_cursor: page.next_cursor, @@ -5593,8 +5715,9 @@ async fn scoped_show_merge_request( }) }) .collect::>>()?; + let merge_request = public_merge_request(&api, &workspace_id, mr)?; Ok(Json(MergeRequestDetailResponse { - merge_request: mr, + merge_request, source, target, linked_tickets, @@ -5633,7 +5756,7 @@ async fn scoped_open_merge_request( headers: HeaderMap, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, Json(input): Json, -) -> ApiResult> { +) -> ApiResult> { let workspace_id = parse_workspace_id(&workspace_id)?; let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?; require_workspace_access(&workspace_id, &api)?; @@ -5655,7 +5778,9 @@ async fn scoped_open_merge_request( let ticket = browser_ticket_backend(&api)? .show(TicketIdOrSlug::Id(ticket_id.clone().into())) .map_err(Error::from)?; - if ticket.meta.repository_id.as_deref() != Some(input.repository_id.as_str()) + let repository = api.require_configured_workspace_repository_by_key(&input.repository_key)?; + let repository_id = repository.id; + if ticket.meta.repository_id.as_deref() != Some(repository_id.as_str()) || ticket.meta.ref_selector.as_deref() != Some(input.selector_to.as_str()) { return Err(Error::InvalidInput( @@ -5665,31 +5790,34 @@ async fn scoped_open_merge_request( } let reader = api.repository_reader(); reader - .observe_merge_target(&input.repository_id, Some(&input.selector_from)) + .observe_merge_target(&repository_id, Some(&input.selector_from)) .map_err(repository_merge_evidence_error)?; reader - .observe_merge_target(&input.repository_id, Some(&input.selector_to)) + .observe_merge_target(&repository_id, Some(&input.selector_to)) .map_err(repository_merge_evidence_error)?; - Ok(Json( - merge_request_store(&api, &workspace_id)?.open_merge_request( - merge_request::OpenMergeRequest { - merge_request_id: Uuid::now_v7().to_string(), - ticket_id, - repository_id: input.repository_id.clone(), - selector_from: input.selector_from, - selector_to: input.selector_to, - summary: input.summary, - auth: merge_request::MergeRequestAuth { - workspace_id, - repository_id: input.repository_id, - runtime_id: source.runtime_id, - worker_id: source.worker_id, - assignment_id: assignment.assignment_id, - }, - now: Utc::now(), + let merge_request = merge_request_store(&api, &workspace_id)?.open_merge_request( + merge_request::OpenMergeRequest { + merge_request_id: Uuid::now_v7().to_string(), + ticket_id, + repository_id: repository_id.clone(), + selector_from: input.selector_from, + selector_to: input.selector_to, + summary: input.summary, + auth: merge_request::MergeRequestAuth { + workspace_id: workspace_id.clone(), + repository_id, + runtime_id: source.runtime_id, + worker_id: source.worker_id, + assignment_id: assignment.assignment_id, }, - )?, - )) + now: Utc::now(), + }, + )?; + Ok(Json(public_merge_request( + &api, + &workspace_id, + merge_request, + )?)) } async fn scoped_merge_request_thread( @@ -5714,7 +5842,7 @@ async fn scoped_repair_merge_request_selector( headers: HeaderMap, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, Json(input): Json, -) -> ApiResult> { +) -> ApiResult> { let workspace_id = parse_workspace_id(&workspace_id)?; let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?; require_workspace_access(&workspace_id, &api)?; @@ -5730,20 +5858,19 @@ async fn scoped_repair_merge_request_selector( .observe_merge_target(&mr.repository_id, Some(&input.selector_from)) .map_err(repository_merge_evidence_error)? .commit; - Ok(Json(store.repair_selector_from( - merge_request::RepairSelectorFrom { - workspace_id, - ticket_id, - selector_from: input.selector_from, - resolved_subject_ref, - repaired_by: merge_request::WorkerIdentity { - runtime_id: "browser".into(), - worker_id: "authenticated-user".into(), - }, - reason: input.reason, - now: Utc::now(), + let repaired = store.repair_selector_from(merge_request::RepairSelectorFrom { + workspace_id: workspace_id.clone(), + ticket_id, + selector_from: input.selector_from, + resolved_subject_ref, + repaired_by: merge_request::WorkerIdentity { + runtime_id: "browser".into(), + worker_id: "authenticated-user".into(), }, - )?)) + reason: input.reason, + now: Utc::now(), + })?; + Ok(Json(public_merge_request(&api, &workspace_id, repaired)?)) } async fn scoped_register_reviewer_child_session( @@ -7982,7 +8109,7 @@ async fn scoped_repository_detail( AxumPath(path): AxumPath, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - repository_detail(State(api), AxumPath(path.repository_id)).await + repository_detail(State(api), AxumPath(path.repository_key)).await } async fn scoped_repository_log( @@ -7991,7 +8118,7 @@ async fn scoped_repository_log( Query(query): Query, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - repository_log(State(api), AxumPath(path.repository_id), Query(query)).await + repository_log(State(api), AxumPath(path.repository_key), Query(query)).await } async fn scoped_list_hosts( @@ -9197,7 +9324,7 @@ async fn create_workspace_working_directory( .as_ref() .map(|selector| selector.as_ref().to_string()); let request_fingerprint = crate::workdir_create_operations::request_fingerprint( - &request.repository_id, + &request.repository_key, selector.as_deref(), requested_runtime_id.as_deref(), &working_directory_request.repository.source_fingerprint, @@ -9249,7 +9376,7 @@ async fn create_workspace_working_directory( workspace_id: workspace_id.to_string(), operation_id: operation_id.clone(), request_fingerprint: request_fingerprint.clone(), - repository_id: request.repository_id.clone(), + repository_id: working_directory_request.repository.id.clone(), selector, requested_runtime_id, resolved_runtime_id, @@ -9277,7 +9404,7 @@ async fn create_workspace_working_directory( host_trust_revision: None, repository_access_mode: None, cache_generation: 0, - working_directory_id: next_backend_workdir_id(&request.repository_id), + working_directory_id: next_backend_workdir_id(&request.repository_key), state: "pending".to_string(), failure: None, created_at: now.clone(), @@ -9486,8 +9613,7 @@ async fn create_workspace_working_directory( None, &now_registry_timestamp(), )?; - let mut summary = working_directory.summary; - apply_workdir_occupancy_projection(api, &mut summary)?; + let summary = projected_workdir_summary_from_record(api, &record)?; Ok(( StatusCode::CREATED, Json(BrowserWorkingDirectoryCreateResponse { @@ -9511,8 +9637,7 @@ fn working_directory_detail_for_runtime( if let Some(working_directory) = result.working_directory { let record = workdir_record_from_summary(&api, runtime_id, &working_directory.summary); api.store.upsert_workdir_registry(&record)?; - let mut summary = working_directory.summary; - apply_workdir_occupancy_projection(&api, &mut summary)?; + let summary = projected_workdir_summary_from_record(&api, &record)?; return Ok(Json(BrowserWorkingDirectoryDetailResponse { workspace_id: api.config.workspace_id.clone(), runtime_id: runtime_id.to_string(), @@ -10088,12 +10213,17 @@ fn build_runtime_cleanup_plan( } else { None }; + let repository_key = api + .store + .get_repository(&api.config.workspace_id, &record.repository_id)? + .map(|repository| repository.repository_key) + .ok_or_else(|| Error::UnknownRepository(record.repository_id.clone()))?; workdir_candidates.push(CleanupWorkdirCandidate { target_id: format!("workdir:{}", record.workdir_id), action, workdir_id: record.workdir_id.clone(), runtime_id: record.runtime_id.clone(), - repository_id: record.repository_id.clone(), + repository_key, reason: if blocking_reason.is_some() { "Workdir cleanup is blocked until linked Worker state is safe".to_string() } else if file_status.is_record_only() { @@ -11632,7 +11762,10 @@ async fn repository_detail( "invalid Repository key: {error}" ))) })?; - let item = repository_lookup(api.live_repository_reader()?.summary(&repository_key))?; + let item = repository_lookup( + api.live_repository_reader()? + .summary_by_key(&repository_key), + )?; Ok(Json(RepositoryDetailResponse { workspace_id: api.config.workspace_id.clone(), item, @@ -11879,19 +12012,12 @@ fn configured_working_directory_request( api: &WorkspaceApi, request: &WorkerSpawnWorkingDirectoryRequest, ) -> Result { - if api - .store - .get_repository(&api.config.workspace_id, &request.repository_id)? - .is_none() - { - return Err(Error::UnknownRepository(request.repository_id.clone())); - } let repository = api .config .repositories .iter() - .find(|repository| repository.id == request.repository_id) - .ok_or_else(|| Error::UnknownRepository(request.repository_id.clone()))?; + .find(|repository| repository.repository_key == request.repository_key) + .ok_or_else(|| Error::UnknownRepository(request.repository_key.clone()))?; Ok(working_directory_request_from_repository( repository, request.selector.as_deref(), @@ -13596,7 +13722,20 @@ fn project_workspace_worker( summary.worker.worker_id )) })?; - Ok(workspace_worker_summary(summary, resource_key)) + let working_directory = summary + .working_directory + .as_ref() + .map(|working_directory| { + let record = + workdir_record_from_summary(api, &summary.worker.runtime_id, working_directory); + projected_workdir_summary_from_record(api, &record) + }) + .transpose()?; + Ok(workspace_worker_summary( + summary, + resource_key, + working_directory, + )) } fn project_observed_workspace_workers( @@ -14372,8 +14511,7 @@ fn working_directory_repository_options( .repositories .iter() .map(|repository| WorkingDirectoryRepositoryOption { - id: repository.id.clone(), - display_name: repository.repository_key.clone(), + repository_key: repository.repository_key.clone(), default_selector: repository.default_selector.clone(), }) .collect() @@ -14555,7 +14693,7 @@ fn merge_worker_registry_projection( .iter() .find(|workdir| workdir.workdir_id == link.workdir_id) .map(|workdir| { - let mut workdir_summary = workdir_summary_from_record(workdir); + let mut workdir_summary = runtime_workdir_summary_from_record(workdir); workdir_summary.occupied_by = Some(WorkingDirectoryOccupancy { runtime_id: record.worker.runtime_id.clone(), worker_id: record.worker.worker_id.clone(), @@ -14779,7 +14917,7 @@ fn sync_linked_workdir_after_worker_stop( fn workdir_record_from_summary( api: &WorkspaceApi, runtime_id: &str, - summary: &WorkingDirectorySummary, + summary: &worker_runtime::catalog::WorkingDirectorySummary, ) -> WorkdirRegistryRecord { let timestamp = now_registry_timestamp(); WorkdirRegistryRecord { @@ -14847,7 +14985,43 @@ fn preserve_workdir_identity_for_corrupted_summary( } } -fn workdir_summary_from_record(record: &WorkdirRegistryRecord) -> WorkingDirectorySummary { +fn runtime_workdir_summary_from_record( + record: &WorkdirRegistryRecord, +) -> worker_runtime::catalog::WorkingDirectorySummary { + let status = match record.materialization_status.as_str() { + "present" => WorkingDirectoryStatusKind::Active, + "pending" => WorkingDirectoryStatusKind::CleanupPending, + "corrupted" => WorkingDirectoryStatusKind::Corrupted, + "not_found" | "missing" => WorkingDirectoryStatusKind::NotFound, + _ => WorkingDirectoryStatusKind::Unknown, + }; + worker_runtime::catalog::WorkingDirectorySummary { + working_directory_id: record.workdir_id.clone(), + repository_id: record.repository_id.clone(), + creation_selector: record.creation_selector.clone(), + creation_ref: record.creation_ref.clone(), + creation_tree: record.creation_tree.clone(), + current_selector: record.current_selector.clone(), + current_ref: record.current_ref.clone(), + current_tree: record.current_tree.clone(), + observed_at_epoch_seconds: record.observed_at_epoch_seconds, + materializer_kind: MaterializerKind::RuntimeGitCache, + cleanup_target: Some(worker_runtime::catalog::WorkingDirectoryCleanupTarget { + kind: "runtime_git_cache_worktree".to_string(), + working_directory_id: record.workdir_id.clone(), + repository_id: record.repository_id.clone(), + }), + status, + cleanliness: Some(record.cleanliness.clone()), + primary_worker_id: None, + occupied_by: None, + } +} + +fn workdir_summary_from_record( + record: &WorkdirRegistryRecord, + repository_key: &str, +) -> WorkingDirectorySummary { let status = match record.materialization_status.as_str() { "present" => WorkingDirectoryStatusKind::Active, "pending" => WorkingDirectoryStatusKind::CleanupPending, @@ -14858,7 +15032,7 @@ fn workdir_summary_from_record(record: &WorkdirRegistryRecord) -> WorkingDirecto }; WorkingDirectorySummary { working_directory_id: record.workdir_id.clone(), - repository_id: record.repository_id.clone(), + repository_key: repository_key.to_string(), creation_selector: record.creation_selector.clone(), creation_ref: record.creation_ref.clone(), creation_tree: record.creation_tree.clone(), @@ -14870,7 +15044,7 @@ fn workdir_summary_from_record(record: &WorkdirRegistryRecord) -> WorkingDirecto cleanup_target: Some(WorkingDirectoryCleanupTarget { kind: "runtime_git_cache_worktree".to_string(), working_directory_id: record.workdir_id.clone(), - repository_id: record.repository_id.clone(), + repository_key: repository_key.to_string(), }), status, cleanliness: Some(record.cleanliness.clone()), @@ -14915,7 +15089,11 @@ fn projected_workdir_summary_from_record( api: &WorkspaceApi, record: &WorkdirRegistryRecord, ) -> Result { - let mut summary = workdir_summary_from_record(record); + let repository = api + .store + .get_repository(&record.workspace_id, &record.repository_id)? + .ok_or_else(|| Error::UnknownRepository(record.repository_id.clone()))?; + let mut summary = workdir_summary_from_record(record, &repository.repository_key); apply_workdir_occupancy_projection(api, &mut summary)?; Ok(summary) } @@ -15144,11 +15322,18 @@ fn authorize_repository_materialization( projection: &RepositoryAccessProjection, request: &mut WorkingDirectoryRequest, ) -> ApiResult<()> { + let repository_key = api + .config + .repositories + .iter() + .find(|repository| repository.id == request.repository.id) + .map(|repository| repository.repository_key.as_str()) + .ok_or_else(|| Error::UnknownRepository(request.repository.id.clone()))?; let ssh = if request.repository.source.kind == workspace_api::RepositorySourceKind::Ssh { let binding = projection .bindings .iter() - .find(|binding| binding.repository_id == request.repository.id) + .find(|binding| binding.repository_key == repository_key) .ok_or_else(|| { settings_bad_request( "working_directory_remote_repository_access_required", @@ -15255,7 +15440,7 @@ fn working_directory_request_for_browser( api: &WorkspaceApi, request: BrowserWorkingDirectoryCreateRequest, ) -> ApiResult { - let repository = api.require_configured_workspace_repository(&request.repository_id)?; + let repository = api.require_configured_workspace_repository_by_key(&request.repository_key)?; let selector = request .selector .or_else(|| repository.default_selector.clone()) @@ -17628,7 +17813,7 @@ mod tests { updated_at: "2".to_string(), }; - let projected = workdir_summary_from_record(&workdir); + let projected = workdir_summary_from_record(&workdir, "main"); assert_eq!(projected.status, WorkingDirectoryStatusKind::Active); let serialized = serde_json::to_string(&projected).unwrap(); @@ -18427,6 +18612,11 @@ mod tests { .await .unwrap(); assert_eq!(created.status(), StatusCode::CREATED); + let created_body: Value = + serde_json::from_slice(&to_bytes(created.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert_eq!(created_body["repository_key"], "documentation"); + assert!(created_body.get("repository_id").is_none()); let replayed = app .clone() .oneshot(create_repository(repository_request.clone())) @@ -18437,9 +18627,8 @@ mod tests { let conflict = app .clone() .oneshot(create_repository(serde_json::json!({ - "repository_id": "documentation", - "display_name": "Different registration", - "source": temp.path().join("documentation").display().to_string(), + "repository_key": "documentation", + "source": temp.path().join("different-documentation").display().to_string(), "default_ref": "main" }))) .await @@ -18459,8 +18648,7 @@ mod tests { .header(CONTENT_TYPE, "application/json") .body(Body::from( serde_json::json!({ - "repository_id": "foreign-owner", - "display_name": "Foreign owner", + "repository_key": "foreign-owner", "source": temp.path().join("foreign-owner").display().to_string() }) .to_string(), @@ -18854,7 +19042,7 @@ mod tests { "display_name": "Created Workspace", "repository": { "uri": repository, - "display_name": "Repository", + "repository_key": "repository", "default_ref": "HEAD" } }); @@ -19086,8 +19274,9 @@ mod tests { let api = test_api(dir.path()).await; let backend = browser_ticket_backend(&api).unwrap(); + let repository_id = test_repository_id(&api); let mut input = ticket::NewTicket::new("Validated target"); - input.repository_id = Some(TEST_REPOSITORY_ID.to_owned()); + input.repository_id = Some(repository_id.clone()); input.ref_selector = Some("develop".to_owned()); let ticket_ref = backend.create(input).unwrap(); let request = ticket::TicketMarkReady { @@ -19102,7 +19291,7 @@ mod tests { assert_eq!(ready.meta.workflow_state, TicketWorkflowState::Ready); assert_eq!( ready.meta.repository_id.as_deref(), - Some(TEST_REPOSITORY_ID) + Some(repository_id.as_str()) ); assert_eq!(ready.meta.ref_selector.as_deref(), Some("develop")); assert_eq!( @@ -19271,7 +19460,7 @@ mod tests { execution.take_inputs(); let mut input = ticket::NewTicket::new("Bounded notification"); - input.repository_id = Some(TEST_REPOSITORY_ID.to_owned()); + input.repository_id = Some(test_repository_id(&api)); input.ref_selector = Some("develop".to_owned()); let ticket = browser_ticket_backend(&api).unwrap().create(input).unwrap(); assign_test_orchestrator(&api, &ticket.id); @@ -20021,7 +20210,7 @@ mod tests { let backend = browser_ticket_backend(&api).unwrap(); let mut input = ticket::NewTicket::new("Queue role gate"); input.workflow_state = Some(TicketWorkflowState::Ready); - input.repository_id = Some(TEST_REPOSITORY_ID.to_string()); + input.repository_id = Some(test_repository_id(&api)); input.ref_selector = Some("develop".to_string()); let ticket = backend.create(input).unwrap(); let path = (TEST_WORKSPACE_ID.to_string(), ticket.id.clone()); @@ -20084,12 +20273,12 @@ mod tests { let backend = browser_ticket_backend(&api).unwrap(); let mut first_input = ticket::NewTicket::new("First cycle Ticket"); first_input.workflow_state = Some(TicketWorkflowState::Ready); - first_input.repository_id = Some(TEST_REPOSITORY_ID.to_string()); + first_input.repository_id = Some(test_repository_id(&api)); first_input.ref_selector = Some("develop".to_string()); let first = backend.create(first_input).unwrap(); let mut second_input = ticket::NewTicket::new("Second cycle Ticket"); second_input.workflow_state = Some(TicketWorkflowState::Ready); - second_input.repository_id = Some(TEST_REPOSITORY_ID.to_string()); + second_input.repository_id = Some(test_repository_id(&api)); second_input.ref_selector = Some("develop".to_string()); let second = backend.create(second_input).unwrap(); for (ticket_id, target) in [ @@ -20137,12 +20326,12 @@ mod tests { let backend = browser_ticket_backend(&api).unwrap(); let mut dependency_input = ticket::NewTicket::new("Invalid target dependency"); dependency_input.workflow_state = Some(TicketWorkflowState::Ready); - dependency_input.repository_id = Some(TEST_REPOSITORY_ID.to_string()); + dependency_input.repository_id = Some(test_repository_id(&api)); dependency_input.ref_selector = Some("missing-ref".to_string()); let dependency = backend.create(dependency_input).unwrap(); let mut root_input = ticket::NewTicket::new("Queue root"); root_input.workflow_state = Some(TicketWorkflowState::Ready); - root_input.repository_id = Some(TEST_REPOSITORY_ID.to_string()); + root_input.repository_id = Some(test_repository_id(&api)); root_input.ref_selector = Some("develop".to_string()); let root = backend.create(root_input).unwrap(); backend @@ -20191,12 +20380,12 @@ mod tests { let backend = browser_ticket_backend(&api).unwrap(); let mut dependency_input = ticket::NewTicket::new("Ready dependency"); dependency_input.workflow_state = Some(TicketWorkflowState::Ready); - dependency_input.repository_id = Some(TEST_REPOSITORY_ID.to_string()); + dependency_input.repository_id = Some(test_repository_id(&api)); dependency_input.ref_selector = Some("develop".to_string()); let dependency = backend.create(dependency_input).unwrap(); let mut root_input = ticket::NewTicket::new("Queue root"); root_input.workflow_state = Some(TicketWorkflowState::Ready); - root_input.repository_id = Some(TEST_REPOSITORY_ID.to_string()); + root_input.repository_id = Some(test_repository_id(&api)); root_input.ref_selector = Some("develop".to_string()); let root = backend.create(root_input).unwrap(); backend @@ -20550,7 +20739,7 @@ mod tests { let backend = browser_ticket_backend(&api).unwrap(); let mut input = ticket::NewTicket::new("Recover queued work"); input.workflow_state = Some(TicketWorkflowState::Queued); - input.repository_id = Some(TEST_REPOSITORY_ID.to_owned()); + input.repository_id = Some(test_repository_id(&api)); input.ref_selector = Some("HEAD".to_owned()); let ticket_ref = backend.create(input).unwrap(); assign_test_orchestrator(&api, &ticket_ref.id); @@ -21138,7 +21327,7 @@ mod tests { }; let mut related_input = ticket::NewTicket::new("Related Browser Ticket"); related_input.workflow_state = Some(TicketWorkflowState::Ready); - related_input.repository_id = Some(TEST_REPOSITORY_ID.to_string()); + related_input.repository_id = Some(test_repository_id(&api)); related_input.ref_selector = Some("develop".to_string()); let related_ticket_id = browser_ticket_backend(&api) .unwrap() @@ -21168,7 +21357,7 @@ mod tests { new_string: None, replace_all: false, target: Some(TicketTargetEdit::Set { - repository_id: "main".to_string(), + repository_id: "test-repository".to_string(), ref_selector: Some("develop".to_string()), }), author: Some("browser-user".to_string()), @@ -21178,7 +21367,7 @@ mod tests { .unwrap(); assert_eq!(edited.title, "Browser Ticket API edited"); assert_eq!(edited.body, "Updated from the Browser API."); - assert_eq!(edited.repository_id.as_deref(), Some("main")); + assert_eq!(edited.repository_key.as_deref(), Some("test-repository")); assert_eq!(edited.ref_selector.as_deref(), Some("develop")); assert!(edited.assignments.is_empty()); assert!(edited.assignment_diagnostics.is_empty()); @@ -21437,6 +21626,14 @@ mod tests { test_api_with_recording_backend(workspace_root).await.0 } + fn test_repository_id(api: &WorkspaceApi) -> String { + api.store + .get_repository_by_key(TEST_WORKSPACE_ID, "test-repository") + .unwrap() + .expect("test Repository exists") + .repository_id + } + fn set_test_default_runtime(api: &WorkspaceApi, runtime_id: &str) { let current = api .config_store @@ -22520,7 +22717,7 @@ mod tests { fn workdir_removal_result( state: WorkerOperationState, - summary: Option, + summary: Option, diagnostics: Vec, ) -> crate::hosts::RuntimeWorkingDirectoryResult { crate::hosts::RuntimeWorkingDirectoryResult { @@ -22534,14 +22731,17 @@ mod tests { fn reserve_removal_fixture( api: &WorkspaceApi, working_directory_id: &str, - ) -> (WorkdirRemovalOperation, WorkingDirectorySummary) { + ) -> ( + WorkdirRemovalOperation, + worker_runtime::catalog::WorkingDirectorySummary, + ) { seed_cleanup_workdir(api, working_directory_id, "present", "clean"); let record = api .store .get_workdir_registry(&api.config.workspace_id, working_directory_id) .unwrap() .unwrap(); - let summary = workdir_summary_from_record(&record); + let summary = runtime_workdir_summary_from_record(&record); let intent = workdir_removal_intent( &record, "account:owner", @@ -23955,11 +24155,12 @@ mod tests { init_clean_git_workspace(dir.path()); let api = test_api(dir.path()).await; let operation_id = "provider-rejection-classification"; + let repository_id = test_repository_id(&api); let repository = api - .require_configured_workspace_repository(TEST_REPOSITORY_ID) + .require_configured_workspace_repository(&repository_id) .unwrap(); let request_fingerprint = crate::workdir_create_operations::request_fingerprint( - TEST_REPOSITORY_ID, + &repository_id, Some("HEAD"), Some(EMBEDDED_WORKER_RUNTIME_ID), &repository.source_fingerprint, @@ -24057,7 +24258,7 @@ mod tests { "POST", &workspace_path, Some(serde_json::json!({ - "repository_id": TEST_REPOSITORY_ID, + "repository_key": "test-repository", "selector": "HEAD", })), StatusCode::BAD_REQUEST, @@ -24087,7 +24288,7 @@ mod tests { &format!("/api/w/{TEST_WORKSPACE_ID}/working-directories"), Some(serde_json::json!({ "runtime_id": "missing-runtime", - "repository_id": TEST_REPOSITORY_ID, + "repository_key": "test-repository", "selector": "HEAD", "operation_id": operation_id, })), @@ -24130,7 +24331,7 @@ mod tests { &format!("/api/w/{TEST_WORKSPACE_ID}/working-directories"), Some(serde_json::json!({ "runtime_id": "missing-runtime", - "repository_id": TEST_REPOSITORY_ID, + "repository_key": "test-repository", "selector": "HEAD", "operation_id": "stale-workdir-create", "path": "/tmp/legacy-workdir", @@ -24166,7 +24367,7 @@ mod tests { &format!("/api/w/{TEST_WORKSPACE_ID}/working-directories"), Some(serde_json::json!({ "runtime_id": EMBEDDED_WORKER_RUNTIME_ID, - "repository_id": "foreign-or-missing-repository", + "repository_key": "foreign-or-missing-repository", "selector": "HEAD", "operation_id": "missing-workdir-repository", })), @@ -24203,7 +24404,7 @@ mod tests { "POST", &format!("/api/w/{TEST_WORKSPACE_ID}/working-directories"), Some(serde_json::json!({ - "repository_id": TEST_REPOSITORY_ID, + "repository_key": "test-repository", "selector": "HEAD", "operation_id": operation_id, })), @@ -24222,7 +24423,7 @@ mod tests { "POST", &format!("/api/w/{TEST_WORKSPACE_ID}/working-directories"), Some(serde_json::json!({ - "repository_id": TEST_REPOSITORY_ID, + "repository_key": "test-repository", "selector": "HEAD", "operation_id": operation_id, })), @@ -24864,7 +25065,7 @@ mod tests { "value": "builtin:coder" }, "working_directory_request": { - "repository_id": TEST_REPOSITORY_ID, + "repository_key": "test-repository", "local_path": dir.path().display().to_string() } })), @@ -24903,7 +25104,7 @@ mod tests { "value": "builtin:coder" }, "working_directory_request": { - "repository_id": TEST_REPOSITORY_ID, + "repository_key": "test-repository", "selector": "HEAD" } })), @@ -25277,9 +25478,9 @@ mod tests { serde_json::from_value(repositories.clone()).unwrap(); assert_eq!( typed_repositories.items[0].repository_key, - TEST_REPOSITORY_ID + "test-repository" ); - assert_eq!(repositories["items"][0]["id"], TEST_REPOSITORY_ID); + assert!(repositories["items"][0].get("id").is_none()); assert_eq!(repositories["items"][0]["kind"], "git"); assert_eq!( repositories["items"][0]["record_authority"], @@ -25291,21 +25492,31 @@ mod tests { .contains("repository_git_unavailable") ); - let repository_detail = get_json(app.clone(), "/api/repositories/main").await; + let repository_detail = get_json(app.clone(), "/api/repositories/test-repository").await; let _: workspace_api::RepositoryDetailResponse = serde_json::from_value(repository_detail.clone()).unwrap(); - assert_eq!(repository_detail["item"]["id"], TEST_REPOSITORY_ID); + assert_eq!( + repository_detail["item"]["repository_key"], + "test-repository" + ); + assert!(repository_detail["item"].get("id").is_none()); let scoped_repository_detail = get_json( app.clone(), - &format!("/api/w/{TEST_WORKSPACE_ID}/repositories/main"), + &format!("/api/w/{TEST_WORKSPACE_ID}/repositories/test-repository"), ) .await; - assert_eq!(scoped_repository_detail["item"]["id"], TEST_REPOSITORY_ID); + assert_eq!( + scoped_repository_detail["item"]["repository_key"], + "test-repository" + ); + assert!(scoped_repository_detail["item"].get("id").is_none()); - let repository_log = get_json(app.clone(), "/api/repositories/main/log?limit=3").await; + let repository_log = + get_json(app.clone(), "/api/repositories/test-repository/log?limit=3").await; let _: workspace_api::RepositoryLogResponse = serde_json::from_value(repository_log.clone()).unwrap(); - assert_eq!(repository_log["repository_id"], TEST_REPOSITORY_ID); + assert_eq!(repository_log["repository_key"], "test-repository"); + assert!(repository_log.get("repository_id").is_none()); assert_eq!(repository_log["default_selector"], "HEAD"); assert_eq!(repository_log["limit"], 3); @@ -25313,7 +25524,7 @@ mod tests { .clone() .oneshot( Request::builder() - .uri("/api/repositories/main/tickets") + .uri("/api/repositories/test-repository/tickets") .body(Body::empty()) .unwrap(), ) @@ -25774,7 +25985,7 @@ mod tests { let unknown = request_json( app.clone(), "GET", - "/api/repositories/main/log", + "/api/repositories/test-repository/log", None, StatusCode::NOT_FOUND, ) @@ -26671,7 +26882,7 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3); workspace_id: TEST_WORKSPACE_ID.to_string(), items: vec![WorkingDirectorySummary { working_directory_id: "wd-1".to_string(), - repository_id: "main".to_string(), + repository_key: "main".to_string(), creation_selector: None, creation_ref: None, creation_tree: None, diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 3b6e6837..0f4bc29e 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -1298,6 +1298,16 @@ impl SqliteWorkspaceStore { repairs.push("create missing worker_diagnostics_archives table".to_string()); } + if current_schema_version < 50 && table_exists(&source, "repositories")? { + let repository_count: i64 = + source.query_row("SELECT COUNT(*) FROM repositories", [], |row| row.get(0))?; + if repository_count > 0 { + repairs.push(format!( + "replace {repository_count} legacy public Repository id(s) with UUIDv7 internal ids, retain each prior id as repository_key, and retire free-form Repository names" + )); + } + } + let mut candidate = Connection::open_in_memory()?; { let backup = Backup::new(&source, &mut candidate)?; @@ -12568,6 +12578,18 @@ INSERT INTO worker_registry ( '1', '1', 'local_path', '/repo-a', 1, 'sha256:a', 'unverified'), ('workspace-b', 'main', 'Legacy B', 'git', 'git', '/repo-b', 'develop', '1', '1', 'local_path', '/repo-b', 1, 'sha256:b', 'unverified'); + INSERT INTO typed_tickets ( + workspace_id, ticket_id, slug, title, status, kind, priority, body, + workflow_state, workflow_state_explicit, repository_id + ) VALUES + ('workspace-a', 'ticket-a', 'ticket-a', 'Ticket A', 'open', 'task', 'normal', '', 'planning', 1, 'main'), + ('workspace-b', 'ticket-b', 'ticket-b', 'Ticket B', 'open', 'task', 'normal', '', 'planning', 1, 'main'); + INSERT INTO merge_requests ( + workspace_id, merge_request_id, repository_id, state, + selector_from, selector_to, created_at, updated_at + ) VALUES + ('workspace-a', 'mr-a', 'main', 'open', 'work/a', 'develop', '1', '1'), + ('workspace-b', 'mr-b', 'main', 'open', 'work/b', 'develop', '1', '1'); INSERT INTO artifacts ( workspace_id, artifact_id, kind, uri, created_at, created_by_kind, created_by_key, created_by_display, repository_id @@ -12628,8 +12650,24 @@ INSERT INTO worker_registry ( |row| row.get(0), ) .unwrap(); + let ticket_repository_id: String = conn + .query_row( + "SELECT repository_id FROM typed_tickets WHERE workspace_id = ?1", + params![workspace_id], + |row| row.get(0), + ) + .unwrap(); + let merge_request_repository_id: String = conn + .query_row( + "SELECT repository_id FROM merge_requests WHERE workspace_id = ?1", + params![workspace_id], + |row| row.get(0), + ) + .unwrap(); assert_eq!(&artifact_repository_id, repository_id); assert_eq!(&workdir_repository_id, repository_id); + assert_eq!(&ticket_repository_id, repository_id); + assert_eq!(&merge_request_repository_id, repository_id); } assert!( table_columns(&conn, "repositories") diff --git a/crates/workspace-server/src/workspace_subscription.rs b/crates/workspace-server/src/workspace_subscription.rs index 7a7598d0..fe26f7f9 100644 --- a/crates/workspace-server/src/workspace_subscription.rs +++ b/crates/workspace-server/src/workspace_subscription.rs @@ -408,6 +408,9 @@ async fn run_workspace_workers( continue; }; worker.resource_key = Some(resource_key); + if !project_repository_key(&api, &mut worker) { + continue; + } let worker_ref = RuntimeWorkerRef::new(&runtime_id, worker.worker_id.as_str()); let revision = next_revision(&mut revisions, &worker_ref); worker.subject_revision = revision; @@ -507,11 +510,28 @@ fn install_snapshot( continue; }; worker.resource_key = Some(resource_key); + if !project_repository_key(api, &mut worker) { + continue; + } projected.insert(worker.worker_id.to_string(), worker); } workers.insert(runtime_id.to_string(), projected); } +fn project_repository_key(api: &WorkspaceApi, worker: &mut SubscriptionWorker) -> bool { + let Some(repository_id) = worker.repository_id.take() else { + return true; + }; + let Ok(Some(repository)) = api + .store + .get_repository(&api.config.workspace_id, &repository_id) + else { + return false; + }; + worker.repository_key = Some(repository.repository_key); + true +} + async fn send_event( outbound: &mpsc::Sender, subscription_id: &SubscriptionId, diff --git a/web/workspace/src/lib/generated/protocol.ts b/web/workspace/src/lib/generated/protocol.ts index 0bd9baf7..1d01dcba 100644 --- a/web/workspace/src/lib/generated/protocol.ts +++ b/web/workspace/src/lib/generated/protocol.ts @@ -192,7 +192,12 @@ resource_key?: string | null, /** * Producer-owned monotonic revision for this Worker subject. */ -subject_revision: number, state: SubscriptionWorkerState, has_running_internal_workers: boolean, workspace_id?: string | null, display_name?: string | null, profile?: string | null, repository_id?: string | null, working_directory_id?: SubscriptionWorkdirId | null, }; +subject_revision: number, state: SubscriptionWorkerState, has_running_internal_workers: boolean, workspace_id?: string | null, display_name?: string | null, profile?: string | null, repository_id?: string | null, +/** + * Workspace-facing Repository key. Runtime producers leave this unset and + * Workspace Server projections replace `repository_id` with this field. + */ +repository_key?: string | null, working_directory_id?: SubscriptionWorkdirId | null, }; export type SubscriptionWorkdir = { working_directory_id: SubscriptionWorkdirId, repository_id: string, state: string, primary_worker_id?: SubscriptionWorkerId | null, }; diff --git a/web/workspace/src/lib/generated/repository-access-api.ts b/web/workspace/src/lib/generated/repository-access-api.ts index fa2f9d44..8adeda20 100644 --- a/web/workspace/src/lib/generated/repository-access-api.ts +++ b/web/workspace/src/lib/generated/repository-access-api.ts @@ -65,7 +65,7 @@ export type DeleteRepositorySshHostTrustRequest = { export type RepositoryAccessMode = "read_only" | "read_write"; export type RepositorySshAccessBinding = { - repository_id: string; + repository_key: string; credential_id: string; host_trust_id: string; access: RepositoryAccessMode; diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index f9dae989..dc366555 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -23,7 +23,7 @@ export type TicketAssignmentPrincipalSummary = { "kind": "user", account_id: str export type TicketActionEligibility = { can_assign_orchestrator: boolean, can_unassign_orchestrator: boolean, can_queue: boolean, can_start_manual_coder: boolean, queue_tickets: Array, blockers: Array, }; -export type TicketMergeRequestSummary = { merge_request_id: string, repository_id: string, state: string, review_status: string, selector_from: string | null, selector_to: string, updated_at: string, current_subject_ref: string | null, review_subject_ref: string | null, review_requested_at: string | null, review_submitted_at: string | null, review_excerpt: string | null, }; +export type TicketMergeRequestSummary = { merge_request_id: string, repository_key: string, state: string, review_status: string, selector_from: string | null, selector_to: string, updated_at: string, current_subject_ref: string | null, review_subject_ref: string | null, review_requested_at: string | null, review_submitted_at: string | null, review_excerpt: string | null, }; export type MergeRequestListItem = { summary: TicketMergeRequestSummary, ticket_ids: Array, thread_event_count: number, }; @@ -49,4 +49,4 @@ export type TicketRelationNotice = { related_ticket: string, kind: string, messa export type TicketRelationView = { outgoing: Array, incoming: Array, blockers: Array, notices: Array, }; -export type TicketDetail = { id: string, resource_key: string, title: string, state: string, readiness: string | null, priority: string, created_at: string | null, updated_at: string | null, item_revision: string, queued_by: string | null, queued_at: string | null, repository_id: string | null, ref_selector: string | null, risk_flags: Array, body: string, body_truncated: boolean, event_count: number, events: Array, event_page: QueryPage, artifact_count: number, artifacts: Array, relations: TicketRelationView, linked_objectives: Array, implementation_reports: Array, assignments: Array, current_coder: TicketAssignmentSummary | null, assignment_diagnostics: Array, action_eligibility: TicketActionEligibility, merge_request: TicketMergeRequestSummary | null, evidence: TicketEvidenceSummary, resolution: string | null, record_source: string, }; +export type TicketDetail = { id: string, resource_key: string, title: string, state: string, readiness: string | null, priority: string, created_at: string | null, updated_at: string | null, item_revision: string, queued_by: string | null, queued_at: string | null, repository_key: string | null, ref_selector: string | null, risk_flags: Array, body: string, body_truncated: boolean, event_count: number, events: Array, event_page: QueryPage, artifact_count: number, artifacts: Array, relations: TicketRelationView, linked_objectives: Array, implementation_reports: Array, assignments: Array, current_coder: TicketAssignmentSummary | null, assignment_diagnostics: Array, action_eligibility: TicketActionEligibility, merge_request: TicketMergeRequestSummary | null, evidence: TicketEvidenceSummary, resolution: string | null, record_source: string, }; diff --git a/web/workspace/src/lib/generated/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts index ef6d4082..62ec5e9b 100644 --- a/web/workspace/src/lib/generated/workdir-api.ts +++ b/web/workspace/src/lib/generated/workdir-api.ts @@ -3,22 +3,77 @@ export type DiagnosticSeverity = "info" | "warning" | "error"; -export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, }; +export type Diagnostic = { + code: string; + severity: DiagnosticSeverity; + message: string; +}; -export type WorkingDirectoryMaterializerKind = "runtime_git_cache" | "local_git_worktree"; +export type WorkingDirectoryMaterializerKind = + | "runtime_git_cache" + | "local_git_worktree"; -export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown"; +export type WorkingDirectoryStatusKind = + | "active" + | "cleanup_pending" + | "corrupted" + | "not_found" + | "unknown"; -export type WorkingDirectoryCleanupTarget = { kind: string, working_directory_id: string, repository_id: string, }; +export type WorkingDirectoryCleanupTarget = { + kind: string; + working_directory_id: string; + repository_key: string; +}; -export type WorkingDirectoryOccupancy = { runtime_id: string, worker_id: string, display_name: string, linked_at: string, }; +export type WorkingDirectoryOccupancy = { + runtime_id: string; + worker_id: string; + display_name: string; + linked_at: string; +}; -export type WorkingDirectorySummary = { working_directory_id: string, repository_id: string, creation_selector?: string | null, creation_ref?: string | null, creation_tree?: string | null, current_selector?: string | null, current_ref?: string | null, current_tree?: string | null, observed_at_epoch_seconds?: number | null, materializer_kind: WorkingDirectoryMaterializerKind, cleanup_target?: WorkingDirectoryCleanupTarget | null, status: WorkingDirectoryStatusKind, cleanliness?: string | null, primary_worker_id?: string | null, occupied_by?: WorkingDirectoryOccupancy | null, }; +export type WorkingDirectorySummary = { + working_directory_id: string; + repository_key: string; + creation_selector?: string | null; + creation_ref?: string | null; + creation_tree?: string | null; + current_selector?: string | null; + current_ref?: string | null; + current_tree?: string | null; + observed_at_epoch_seconds?: number | null; + materializer_kind: WorkingDirectoryMaterializerKind; + cleanup_target?: WorkingDirectoryCleanupTarget | null; + status: WorkingDirectoryStatusKind; + cleanliness?: string | null; + primary_worker_id?: string | null; + occupied_by?: WorkingDirectoryOccupancy | null; +}; -export type WorkingDirectoryCreateRequest = { runtime_id?: string | null, repository_id: string, selector?: string | null, operation_id?: string | null, }; +export type WorkingDirectoryCreateRequest = { + runtime_id?: string | null; + repository_key: string; + selector?: string | null; + operation_id?: string | null; +}; -export type WorkingDirectoryListResponse = { workspace_id: string, items: Array, diagnostics: Array, }; +export type WorkingDirectoryListResponse = { + workspace_id: string; + items: Array; + diagnostics: Array; +}; -export type WorkingDirectoryDetailResponse = { workspace_id: string, runtime_id: string, item: WorkingDirectorySummary, diagnostics: Array, }; +export type WorkingDirectoryDetailResponse = { + workspace_id: string; + runtime_id: string; + item: WorkingDirectorySummary; + diagnostics: Array; +}; -export type WorkingDirectoryCreateResponse = { workspace_id: string, runtime_id: string, item: WorkingDirectorySummary, diagnostics: Array, }; +export type WorkingDirectoryCreateResponse = { + workspace_id: string; + runtime_id: string; + item: WorkingDirectorySummary; + diagnostics: Array; +}; diff --git a/web/workspace/src/lib/generated/workspace-api.ts b/web/workspace/src/lib/generated/workspace-api.ts index f54f9348..36c6c324 100644 --- a/web/workspace/src/lib/generated/workspace-api.ts +++ b/web/workspace/src/lib/generated/workspace-api.ts @@ -14,8 +14,7 @@ export type WorkspaceCatalogListResponse = Array; export type WorkspaceRepositoryRecord = { workspace_id: string; - repository_id: string; - name: string; + repository_key: string; kind: string; provider: string | null; source: RepositorySource; @@ -175,8 +174,7 @@ export type GitRepositorySummary = { }; export type RepositorySummary = { - id: string; - display_name: string; + repository_key: string; kind: string; provider: string; source: RepositorySource; @@ -216,7 +214,7 @@ export type RepositoryDetailResponse = { export type RepositoryLogResponse = { workspace_id: string; - repository_id: string; + repository_key: string; default_selector?: string | null; limit: number; items: Array; diff --git a/web/workspace/src/lib/workspace/api/merge-requests.ts b/web/workspace/src/lib/workspace/api/merge-requests.ts index db61cc7e..a8a68f37 100644 --- a/web/workspace/src/lib/workspace/api/merge-requests.ts +++ b/web/workspace/src/lib/workspace/api/merge-requests.ts @@ -19,7 +19,7 @@ export type MergeRequestThreadEvent = { export type MergeRequestRecord = { merge_request_id: string; workspace_id: string; - repository_id: string; + repository_key: string; selector_from: string | null; selector_to: string; ticket_ids: string[]; diff --git a/web/workspace/src/lib/workspace/api/repository-access.ts b/web/workspace/src/lib/workspace/api/repository-access.ts index 0a9f0d5d..cad14248 100644 --- a/web/workspace/src/lib/workspace/api/repository-access.ts +++ b/web/workspace/src/lib/workspace/api/repository-access.ts @@ -112,12 +112,12 @@ export function parseRepositoryAccessProjection( bindings.forEach((binding, index) => { const bindingPath = `${path}.bindings[${index}]`; const bindingRecord = readRecord(binding, bindingPath, [ - "repository_id", + "repository_key", "credential_id", "host_trust_id", "access", ]); - readString(bindingRecord, "repository_id", bindingPath); + readString(bindingRecord, "repository_key", bindingPath); readString(bindingRecord, "credential_id", bindingPath); readString(bindingRecord, "host_trust_id", bindingPath); const access = readString(bindingRecord, "access", bindingPath); diff --git a/web/workspace/src/lib/workspace/api/workdirs.ts b/web/workspace/src/lib/workspace/api/workdirs.ts index 4ca39ced..4381ab27 100644 --- a/web/workspace/src/lib/workspace/api/workdirs.ts +++ b/web/workspace/src/lib/workspace/api/workdirs.ts @@ -11,7 +11,7 @@ import type { const SUMMARY_KEYS = new Set([ "working_directory_id", - "repository_id", + "repository_key", "creation_selector", "creation_ref", "creation_tree", @@ -28,7 +28,7 @@ const SUMMARY_KEYS = new Set([ ]); const CREATE_REQUEST_KEYS = new Set([ "runtime_id", - "repository_id", + "repository_key", "selector", "operation_id", ]); @@ -36,7 +36,7 @@ const DIAGNOSTIC_KEYS = new Set(["code", "severity", "message"]); const CLEANUP_TARGET_KEYS = new Set([ "kind", "working_directory_id", - "repository_id", + "repository_key", ]); const OCCUPANCY_KEYS = new Set([ "runtime_id", @@ -81,7 +81,7 @@ export function validateWorkingDirectoryCreateRequest( "Workdir create request", ); const request: WorkingDirectoryCreateRequest = { - repository_id: stringField(record, "repository_id"), + repository_key: stringField(record, "repository_key"), }; assignOptionalString(request, record, "runtime_id"); assignOptionalString(request, record, "selector"); @@ -110,7 +110,7 @@ function parseSummary(value: unknown): WorkingDirectorySummary { const record = exactRecord(value, SUMMARY_KEYS, "Workdir summary"); const summary: WorkingDirectorySummary = { working_directory_id: stringField(record, "working_directory_id"), - repository_id: stringField(record, "repository_id"), + repository_key: stringField(record, "repository_key"), materializer_kind: enumField(record, "materializer_kind", [ "runtime_git_cache", "local_git_worktree", @@ -166,7 +166,7 @@ function parseCleanupTarget(value: unknown): WorkingDirectoryCleanupTarget { return { kind: stringField(record, "kind"), working_directory_id: stringField(record, "working_directory_id"), - repository_id: stringField(record, "repository_id"), + repository_key: stringField(record, "repository_key"), }; } diff --git a/web/workspace/src/lib/workspace/api/workspace-catalog.ts b/web/workspace/src/lib/workspace/api/workspace-catalog.ts index 7555ba52..320195eb 100644 --- a/web/workspace/src/lib/workspace/api/workspace-catalog.ts +++ b/web/workspace/src/lib/workspace/api/workspace-catalog.ts @@ -18,8 +18,8 @@ export type CreateWorkspaceRequest = { operation_key: string; display_name: string; repository: { + repository_key: string; uri: string; - display_name: string | null; default_ref: string | null; }; }; diff --git a/web/workspace/src/lib/workspace/api/workspace-model.ts b/web/workspace/src/lib/workspace/api/workspace-model.ts index 39fbd0c5..b6701aa0 100644 --- a/web/workspace/src/lib/workspace/api/workspace-model.ts +++ b/web/workspace/src/lib/workspace/api/workspace-model.ts @@ -170,8 +170,7 @@ function repositorySummary(value: unknown, path: string): RepositorySummary { exactKeys( item, [ - "id", - "display_name", + "repository_key", "kind", "provider", "source", @@ -200,8 +199,7 @@ function repositorySummary(value: unknown, path: string): RepositorySummary { repositoryDiagnostic(entry, `${path}.diagnostics[${index}]`) ); return { - id: string(item.id, `${path}.id`), - display_name: string(item.display_name, `${path}.display_name`), + repository_key: string(item.repository_key, `${path}.repository_key`), kind: string(item.kind, `${path}.kind`), provider: string(item.provider, `${path}.provider`), source: repositorySource(item.source, `${path}.source`), @@ -263,8 +261,7 @@ function workspaceRepositoryRecord( item, [ "workspace_id", - "repository_id", - "name", + "repository_key", "kind", "provider", "source", @@ -287,8 +284,7 @@ function workspaceRepositoryRecord( } return { workspace_id: string(item.workspace_id, `${path}.workspace_id`), - repository_id: string(item.repository_id, `${path}.repository_id`), - name: string(item.name, `${path}.name`), + repository_key: string(item.repository_key, `${path}.repository_key`), kind: string(item.kind, `${path}.kind`), provider: nullableString(item.provider, `${path}.provider`), source: repositorySource(item.source, `${path}.source`), @@ -573,7 +569,7 @@ export function parseRepositoryLogResponse( response, [ "workspace_id", - "repository_id", + "repository_key", "default_selector", "limit", "items", @@ -586,9 +582,9 @@ export function parseRepositoryLogResponse( response.workspace_id, "repository log response.workspace_id", ), - repository_id: string( - response.repository_id, - "repository log response.repository_id", + repository_key: string( + response.repository_key, + "repository log response.repository_key", ), default_selector: optionalNullableString( response.default_selector, diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index 9959e841..cd71662f 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -128,7 +128,7 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async () workersNav.includes("worker.display_name || worker.label") && workersNav.includes("worker-status-dot") && workersNav.includes("worker-status-spinner") && - workersNav.includes("worker.repository_id ?? '—'") && + workersNav.includes("worker.repository_key ?? '—'") && workersNav.includes("worker.working_directory_id ?? '—'") && !workersNav.includes('aria-disabled="true"'), "Workers sidebar should link to the Worker list page and show state indicators with repository/workdir metadata", @@ -207,13 +207,13 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as ); const repositoryLoad = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/repositories/[repositoryId]/+page.ts", + "./../../../routes/w/[workspaceId]/repositories/[repositoryKey]/+page.ts", import.meta.url, ), ); const repositoryPage = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/repositories/[repositoryId]/+page.svelte", + "./../../../routes/w/[workspaceId]/repositories/[repositoryKey]/+page.svelte", import.meta.url, ), ); diff --git a/web/workspace/src/lib/workspace/settings/workdir-revision.test.ts b/web/workspace/src/lib/workspace/settings/workdir-revision.test.ts index 75e9602a..1ab94c2b 100644 --- a/web/workspace/src/lib/workspace/settings/workdir-revision.test.ts +++ b/web/workspace/src/lib/workspace/settings/workdir-revision.test.ts @@ -17,7 +17,7 @@ function workdir( ): WorkingDirectorySummary { return { working_directory_id: "workdir-1", - repository_id: "repository-1", + repository_key: "repository-1", current_selector, current_ref, materializer_kind: "local_git_worktree", @@ -25,7 +25,7 @@ function workdir( cleanup_target: { kind: "local_git_worktree", working_directory_id: "workdir-1", - repository_id: "repository-1", + repository_key: "repository-1", }, }; } diff --git a/web/workspace/src/lib/workspace/sidebar/WorkersNavSection.svelte b/web/workspace/src/lib/workspace/sidebar/WorkersNavSection.svelte index e546350b..a954f46a 100644 --- a/web/workspace/src/lib/workspace/sidebar/WorkersNavSection.svelte +++ b/web/workspace/src/lib/workspace/sidebar/WorkersNavSection.svelte @@ -88,7 +88,7 @@ {worker.display_name || worker.label} - {worker.repository_id ?? '—'}・{worker.working_directory_id ?? '—'} + {worker.repository_key ?? '—'}・{worker.working_directory_id ?? '—'} diff --git a/web/workspace/src/lib/workspace/sidebar/types.ts b/web/workspace/src/lib/workspace/sidebar/types.ts index 51f4c34f..2076d719 100644 --- a/web/workspace/src/lib/workspace/sidebar/types.ts +++ b/web/workspace/src/lib/workspace/sidebar/types.ts @@ -118,8 +118,7 @@ export type WorkerLaunchProfileCandidate = { }; export type WorkingDirectoryRepositoryOption = { - id: string; - display_name: string; + repository_key: string; default_selector?: string | null; }; @@ -149,7 +148,7 @@ export type CleanupWorkdirCandidate = { action: CleanupTargetKind; workdir_id: string; runtime_id: string; - repository_id: string; + repository_key: string; reason: string; blocking_reason?: string | null; linked_worker_ids: string[]; 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 12c8d1ac..a52ce503 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts @@ -44,12 +44,12 @@ const options: WorkerLaunchOptionsResponse = { { id: "builtin:coder", label: "Coder", description: "code" }, ], repositories: [ - { id: "repo", display_name: "Repo", default_selector: "HEAD" }, + { repository_key: "repo", default_selector: "HEAD" }, ], working_directories: [ { working_directory_id: "wd-1-repo", - repository_id: "repo", + repository_key: "repo", creation_selector: "HEAD", creation_ref: "0123456789abcdef", current_selector: null, @@ -61,7 +61,7 @@ const options: WorkerLaunchOptionsResponse = { cleanup_target: { kind: "git_worktree", working_directory_id: "wd-1-repo", - repository_id: "repo", + repository_key: "repo", }, }, ], @@ -75,7 +75,7 @@ Deno.test("defaultWorkerLaunchForm uses the Backend-published Workspace default profile: "", initial_text: "hello", working_directory_id: "", - working_directory_repository_id: "", + working_directory_repository_key: "", working_directory_selector: "", relative_cwd: "", }); @@ -85,7 +85,7 @@ Deno.test("defaultWorkerLaunchForm uses the Backend-published Workspace default assertEquals(form.profile, "builtin:coder"); assertEquals(form.initial_text, "hello"); assertEquals(form.working_directory_id, "wd-1-repo"); - assertEquals(form.working_directory_repository_id, "repo"); + assertEquals(form.working_directory_repository_key, "repo"); assertEquals(form.working_directory_selector, "HEAD"); }); @@ -103,7 +103,7 @@ Deno.test("defaultWorkerLaunchForm preserves an available Ticket role profile", profile: "builtin:reviewer", initial_text: "Review the ticket.", working_directory_id: "", - working_directory_repository_id: "repo", + working_directory_repository_key: "repo", working_directory_selector: "HEAD", relative_cwd: "", }); @@ -133,7 +133,7 @@ Deno.test("defaultWorkerLaunchForm skips occupied working directories", () => { profile: "", initial_text: "hello", working_directory_id: "", - working_directory_repository_id: "", + working_directory_repository_key: "", working_directory_selector: "", relative_cwd: "", }, @@ -149,8 +149,7 @@ Deno.test("defaultWorkerLaunchForm preserves a Ticket repository target", () => repositories: [ ...options.repositories, { - id: "ticket-repo", - display_name: "Ticket repo", + repository_key: "ticket-repo", default_selector: "main", }, ], @@ -159,7 +158,7 @@ Deno.test("defaultWorkerLaunchForm preserves a Ticket repository target", () => { ...options.working_directories[0], working_directory_id: "ticket-workdir", - repository_id: "ticket-repo", + repository_key: "ticket-repo", creation_selector: "work/ticket", }, ], @@ -170,14 +169,14 @@ Deno.test("defaultWorkerLaunchForm preserves a Ticket repository target", () => profile: "builtin:coder", initial_text: "Work on a ticket.", working_directory_id: "", - working_directory_repository_id: "ticket-repo", + working_directory_repository_key: "ticket-repo", working_directory_selector: "work/ticket", relative_cwd: "", }, ); assertEquals(form.working_directory_id, "ticket-workdir"); - assertEquals(form.working_directory_repository_id, "ticket-repo"); + assertEquals(form.working_directory_repository_key, "ticket-repo"); assertEquals(form.working_directory_selector, "work/ticket"); }); @@ -188,7 +187,7 @@ Deno.test("buildCreateWorkspaceWorkerRequest sends working_directory id and rela profile: "builtin:coder", initial_text: "go", working_directory_id: "wd-1-repo", - working_directory_repository_id: "repo", + working_directory_repository_key: "repo", working_directory_selector: "main", relative_cwd: "crates/yoi", }); @@ -212,7 +211,7 @@ Deno.test("buildCreateWorkspaceWorkerRequest sends no initial segments for an em profile: "builtin:companion", initial_text: " ", working_directory_id: "", - working_directory_repository_id: "", + working_directory_repository_key: "", working_directory_selector: "", relative_cwd: "", }); @@ -227,7 +226,7 @@ Deno.test("buildCreateWorkspaceWorkerRequest omits working_directory for embedde profile: "builtin:companion", initial_text: "chat", working_directory_id: "", - working_directory_repository_id: "", + working_directory_repository_key: "", working_directory_selector: "", relative_cwd: "", }); diff --git a/web/workspace/src/lib/workspace/sidebar/worker-launch.ts b/web/workspace/src/lib/workspace/sidebar/worker-launch.ts index ba23c3e6..2926bc1e 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-launch.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-launch.ts @@ -11,7 +11,7 @@ export type WorkerLaunchFormState = { profile: string; initial_text: string; working_directory_id: string; - working_directory_repository_id: string; + working_directory_repository_key: string; working_directory_selector: string; relative_cwd: string; }; @@ -54,20 +54,20 @@ export function defaultWorkerLaunchForm( const preferredWorkingDirectory = workdirlessRuntime ? undefined : availableWorkingDirectories.find((directory) => - Boolean(current.working_directory_repository_id) && - directory.repository_id === current.working_directory_repository_id && + Boolean(current.working_directory_repository_key) && + directory.repository_key === current.working_directory_repository_key && (!current.working_directory_selector || (directory.current_selector ?? directory.creation_selector) === current.working_directory_selector) ) ?? availableWorkingDirectories.find((directory) => - Boolean(current.working_directory_repository_id) && - directory.repository_id === current.working_directory_repository_id - ) ?? (current.working_directory_repository_id + Boolean(current.working_directory_repository_key) && + directory.repository_key === current.working_directory_repository_key + ) ?? (current.working_directory_repository_key ? undefined : availableWorkingDirectories[0]); const preferredRepository = options?.repositories.find((repository) => - repository.id === current.working_directory_repository_id + repository.repository_key === current.working_directory_repository_key ) ?? options?.repositories[0]; @@ -86,8 +86,8 @@ export function defaultWorkerLaunchForm( ) ? current.working_directory_id : preferredWorkingDirectory?.working_directory_id || "", - working_directory_repository_id: current.working_directory_repository_id || - preferredRepository?.id || "", + working_directory_repository_key: current.working_directory_repository_key || + preferredRepository?.repository_key || "", working_directory_selector: current.working_directory_selector || preferredRepository?.default_selector || "HEAD", relative_cwd: current.relative_cwd, diff --git a/web/workspace/src/lib/workspace/sidebar/worker-subscription.ts b/web/workspace/src/lib/workspace/sidebar/worker-subscription.ts index 5baf4cc8..0a600dcf 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-subscription.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-subscription.ts @@ -9,7 +9,7 @@ import { compareWorkersForSidebar } from './workers'; import type { Worker } from './types'; export type SidebarWorker = Worker & { - repository_id: string | null; + repository_key: string | null; working_directory_id: string | null; has_running_internal_workers: boolean; }; @@ -97,7 +97,7 @@ function projectWorker(worker: SubscriptionWorker): SidebarWorker { can_stop: worker.state !== 'stopped' && worker.state !== 'cancelled', can_spawn_followup: false, }, - repository_id: worker.repository_id ?? null, + repository_key: worker.repository_key ?? null, working_directory_id: worker.working_directory_id ?? null, has_running_internal_workers: worker.has_running_internal_workers, working_directory: null, diff --git a/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts b/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts index 4a5ee65f..520f4e90 100644 --- a/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts +++ b/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts @@ -88,7 +88,7 @@ Deno.test("ticket worker launch uses the common Worker route and bounded Ticket const ticket = { id: "00001KYRRDVH9", title: "Ticket panel API", - repository_id: "main repo", + repository_key: "main repo", ref_selector: "work/ticket", } as TicketDetail; @@ -102,7 +102,7 @@ Deno.test("ticket worker launch uses the common Worker route and bounded Ticket assertEquals(url.pathname, "/w/workspace%20one/workers/new"); assertEquals(url.searchParams.get("ticketId"), ticket.id); assertEquals(url.searchParams.get("ticketRole"), "reviewer"); - assertEquals(url.searchParams.get("repositoryId"), "main repo"); + assertEquals(url.searchParams.get("repositoryKey"), "main repo"); assertEquals(url.searchParams.get("refSelector"), "work/ticket"); assertEquals( url.searchParams.get("initialInput"), diff --git a/web/workspace/src/lib/workspace/tickets/ticket-panel.ts b/web/workspace/src/lib/workspace/tickets/ticket-panel.ts index 38e9b99f..0df64ebc 100644 --- a/web/workspace/src/lib/workspace/tickets/ticket-panel.ts +++ b/web/workspace/src/lib/workspace/tickets/ticket-panel.ts @@ -120,7 +120,7 @@ export function ticketWorkerLaunchHref( workspaceId: string, ticket: Pick< TicketDetail, - "id" | "title" | "repository_id" | "ref_selector" + "id" | "title" | "repository_key" | "ref_selector" >, role: TicketWorkerRole, ): string { @@ -130,8 +130,8 @@ export function ticketWorkerLaunchHref( ticketRole: role, initialInput: ticketWorkerMessage(ticket.id, role), }); - if (ticket.repository_id) { - params.set("repositoryId", ticket.repository_id); + if (ticket.repository_key) { + params.set("repositoryKey", ticket.repository_key); } if (ticket.ref_selector) { params.set("refSelector", ticket.ref_selector); diff --git a/web/workspace/src/routes/+page.svelte b/web/workspace/src/routes/+page.svelte index 75bf8187..5811da70 100644 --- a/web/workspace/src/routes/+page.svelte +++ b/web/workspace/src/routes/+page.svelte @@ -17,8 +17,8 @@ let creating = $state(false); let creationError = $state(null); let displayName = $state(""); + let repositoryKey = $state(""); let repositoryUri = $state(""); - let repositoryName = $state("Main"); let defaultRef = $state(""); let lastSubmission = $state<{ signature: string; @@ -48,7 +48,7 @@ const normalized = { displayName: displayName.trim(), repositoryUri: repositoryUri.trim(), - repositoryName: repositoryName.trim(), + repositoryKey: repositoryKey.trim(), defaultRef: defaultRef.trim(), }; const signature = JSON.stringify(normalized); @@ -58,8 +58,8 @@ operation_key: createOperationKey(), display_name: normalized.displayName, repository: { + repository_key: normalized.repositoryKey, uri: normalized.repositoryUri, - display_name: normalized.repositoryName || null, default_ref: normalized.defaultRef || null, }, }; @@ -132,7 +132,7 @@ {workspace.workspace_id} {#if workspace.repositories[0]} - {workspace.repositories[0].display_name} + {workspace.repositories[0].repository_key} {workspace.repositories[0].default_selector ?? "repository default"} · {workspace.repositories[0].kind} @@ -169,8 +169,8 @@

- Repository {mergeRequest.repository_id} · {item.ticket_ids.length} linked Ticket{item.ticket_ids.length === 1 ? "" : "s"} · review {mergeRequest.review_status} + Repository {mergeRequest.repository_key} · {item.ticket_ids.length} linked Ticket{item.ticket_ids.length === 1 ? "" : "s"} · review {mergeRequest.review_status}

diff --git a/web/workspace/src/routes/w/[workspaceId]/merge-requests/[mergeRequestId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/merge-requests/[mergeRequestId]/+page.svelte index f8b86b83..90dcf708 100644 --- a/web/workspace/src/routes/w/[workspaceId]/merge-requests/[mergeRequestId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/merge-requests/[mergeRequestId]/+page.svelte @@ -48,7 +48,7 @@

Selectors

-
Repository
{mergeRequest.repository_id}
+
Repository
{mergeRequest.repository_key}
State
{mergeRequest.state}
Source selector
{mergeRequest.selector_from ?? "requires repair"}
Source revision
{mergeRequest.source.status}{mergeRequest.source.ref ? ` · ${mergeRequest.source.ref}` : ""}
diff --git a/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryKey]/+page.svelte similarity index 95% rename from web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryId]/+page.svelte rename to web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryKey]/+page.svelte index df07104d..b64e7c15 100644 --- a/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryKey]/+page.svelte @@ -6,7 +6,7 @@ - {data.repository?.item.display_name ?? data.repositoryId} · Repository + {data.repository?.item.repository_key ?? data.repositoryKey} · Repository
@@ -14,7 +14,7 @@ {#if data.repository}
-

{data.repository.item.display_name}

+

{data.repository.item.repository_key}

{data.repository.item.observed_status}
diff --git a/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryId]/+page.ts b/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryKey]/+page.ts similarity index 87% rename from web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryId]/+page.ts rename to web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryKey]/+page.ts index b423bbdb..ba4e8053 100644 --- a/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryId]/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryKey]/+page.ts @@ -7,20 +7,20 @@ import type { PageLoad } from "./$types"; export const load: PageLoad = async ({ fetch, params }) => { const workspaceId = params.workspaceId; - const repositoryId = params.repositoryId; + const repositoryKey = params.repositoryKey; const [repositoryResult, logResult] = await Promise.all([ loadJson( fetch, workspaceApiPath( workspaceId, - `/repositories/${encodeURIComponent(repositoryId)}`, + `/repositories/${encodeURIComponent(repositoryKey)}`, ), ), loadJson( fetch, workspaceApiPath( workspaceId, - `/repositories/${encodeURIComponent(repositoryId)}/log`, + `/repositories/${encodeURIComponent(repositoryKey)}/log`, ), ), ]); @@ -50,7 +50,7 @@ export const load: PageLoad = async ({ fetch, params }) => { } return { - repositoryId, + repositoryKey, repository, repositoryError, repositoryLog: log, diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/repositories/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/repositories/+page.svelte index 7b3f0b16..8e9e7c8d 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/repositories/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/repositories/+page.svelte @@ -16,8 +16,7 @@ return kind === 'ssh' || kind === 'http' || kind === 'https'; } let showAddRepository = $state(false); - let repositoryId = $state(''); - let displayName = $state(''); + let repositoryKey = $state(''); let source = $state(''); let defaultRef = $state(''); let pending = $state(false); @@ -39,15 +38,13 @@ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ - repository_id: repositoryId, - display_name: displayName, + repository_key: repositoryKey, source, default_ref: defaultRef || null, }), }); if (!response.ok) throw new Error(await responseError(response)); - repositoryId = ''; - displayName = ''; + repositoryKey = ''; source = ''; defaultRef = ''; showAddRepository = false; @@ -82,12 +79,9 @@

Add Repository

-
diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.ts b/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.ts index 9b6f6af0..53b3c101 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.ts @@ -11,7 +11,7 @@ export function load( ticketTitle: url.searchParams.get("ticketTitle") ?? ticketId, ticketRole, initialInput: url.searchParams.get("initialInput") ?? "", - repositoryId: url.searchParams.get("repositoryId") ?? "", + repositoryKey: url.searchParams.get("repositoryKey") ?? "", refSelector: url.searchParams.get("refSelector") ?? "HEAD", } : null, diff --git a/web/workspace/test/merge-request-status.test.ts b/web/workspace/test/merge-request-status.test.ts index 6436396f..89fd9b9c 100644 --- a/web/workspace/test/merge-request-status.test.ts +++ b/web/workspace/test/merge-request-status.test.ts @@ -25,7 +25,7 @@ function detail(thread: MergeRequestThreadEvent[]): MergeRequestDetail { return { merge_request_id: "MR-1", workspace_id: "W", - repository_id: "main", + repository_key: "main", ticket_ids: ["T-1"], selector_from: "work/ticket", selector_to: "develop", diff --git a/web/workspace/test/repositories/ui.test.ts b/web/workspace/test/repositories/ui.test.ts index e826b984..dcfd4ffc 100644 --- a/web/workspace/test/repositories/ui.test.ts +++ b/web/workspace/test/repositories/ui.test.ts @@ -25,8 +25,7 @@ test("Repository settings use the scoped list and typed create collection", () = "workspaceApiPath(params.workspaceId, \"/repositories\")", "workspaceApiPath(data.workspaceId, '/repositories')", "method: 'POST'", - "repository_id: repositoryId", - "display_name: displayName", + "repository_key: repositoryKey", "default_ref: defaultRef || null", "await invalidateAll()", ]) { diff --git a/web/workspace/test/repository-access/api.test.ts b/web/workspace/test/repository-access/api.test.ts index 7874dec5..ab30d94c 100644 --- a/web/workspace/test/repository-access/api.test.ts +++ b/web/workspace/test/repository-access/api.test.ts @@ -66,7 +66,7 @@ Deno.test("Repository Access parsers accept generated response contracts", () => config_revision: 4, projection_digest: "sha256:projection", bindings: [{ - repository_id: "main", + repository_key: "main", credential_id: "deploy-key", host_trust_id: "gitea", access: "read_only", @@ -77,7 +77,7 @@ Deno.test("Repository Access parsers accept generated response contracts", () => config_revision: 4, projection_digest: "sha256:projection", bindings: [{ - repository_id: "main", + repository_key: "main", credential_id: "deploy-key", host_trust_id: "gitea", access: "read_only", @@ -114,7 +114,7 @@ Deno.test("Repository Access parsers reject missing and wrong-typed fields", () config_revision: 4, projection_digest: "sha256:projection", bindings: [{ - repository_id: "main", + repository_key: "main", credential_id: "deploy-key", host_trust_id: "gitea", access: "admin", diff --git a/web/workspace/test/repository-access/ui.test.ts b/web/workspace/test/repository-access/ui.test.ts index 0cabf034..b47b88dd 100644 --- a/web/workspace/test/repository-access/ui.test.ts +++ b/web/workspace/test/repository-access/ui.test.ts @@ -56,7 +56,7 @@ test("Repository Access renders the shared access projection fields", () => { "accessProjection.config_revision", "accessProjection.projection_digest", "accessProjection.bindings", - "binding.repository_id", + "binding.repository_key", "binding.credential_id", "binding.host_trust_id", "binding.access", diff --git a/web/workspace/tests/workdir-api.test.ts b/web/workspace/tests/workdir-api.test.ts index ada83ce4..e047f0b6 100644 --- a/web/workspace/tests/workdir-api.test.ts +++ b/web/workspace/tests/workdir-api.test.ts @@ -10,7 +10,7 @@ import { const summary = { working_directory_id: "workdir-1", - repository_id: "main", + repository_key: "main", materializer_kind: "runtime_git_cache", status: "active", occupied_by: { @@ -60,7 +60,7 @@ Deno.test("Workdir REST validation rejects stale response JSON", () => { Deno.test("Workdir REST validation enforces create operation fields", () => { const request = validateWorkingDirectoryCreateRequest({ runtime_id: "arcadia", - repository_id: "main", + repository_key: "main", selector: "develop", operation_id: "operation-1", }); @@ -71,7 +71,7 @@ Deno.test("Workdir REST validation enforces create operation fields", () => { for ( const invalid of [ { runtime_id: "arcadia", operation_id: "operation-1" }, - { repository_id: "main", operation_key: "operation-1" }, + { repository_key: "main", operation_key: "operation-1" }, ] ) { let rejected = false; diff --git a/web/workspace/tests/workspace-catalog.test.ts b/web/workspace/tests/workspace-catalog.test.ts index b6b8bd4d..1839b2be 100644 --- a/web/workspace/tests/workspace-catalog.test.ts +++ b/web/workspace/tests/workspace-catalog.test.ts @@ -58,8 +58,7 @@ Deno.test("workspace catalog enriches each visible workspace without dropping si return Promise.resolve(Response.json({ workspace_id: "w-a", items: [{ - id: "main", - display_name: "Main", + repository_key: "main", kind: "git", provider: "git", source: { kind: "local_path", uri: "/srv/alpha" }, @@ -78,7 +77,7 @@ Deno.test("workspace catalog enriches each visible workspace without dropping si const items = await loadWorkspaceCatalog(fetcher as typeof fetch); assertEquals(items.length, 2); - assertEquals(items[0].repositories[0].id, "main"); + assertEquals(items[0].repositories[0].repository_key, "main"); assertEquals(items[1].repositories, []); assertEquals(typeof items[1].repository_error, "string"); }); @@ -89,8 +88,8 @@ Deno.test("workspace creation preserves caller-owned operation key across retry" operation_key: "web-create-1", display_name: "Alpha", repository: { + repository_key: "main", uri: "/srv/alpha", - display_name: "Main", default_ref: "develop", }, }; diff --git a/web/workspace/tests/workspace-model.test.ts b/web/workspace/tests/workspace-model.test.ts index 483ab08f..66efbae9 100644 --- a/web/workspace/tests/workspace-model.test.ts +++ b/web/workspace/tests/workspace-model.test.ts @@ -22,8 +22,7 @@ function assertThrows(operation: () => unknown, expected: string): void { const repositoryList = { workspace_id: "w-a", items: [{ - id: "main", - display_name: "Main", + repository_key: "main", kind: "git", provider: "git", source: { kind: "local_path", uri: "/srv/alpha" }, @@ -38,8 +37,8 @@ const repositoryList = { Deno.test("generated repository wrapper validates current Backend JSON", () => { const parsed = parseRepositoryListResponse(repositoryList); - if (parsed.items[0]?.id !== "main") { - throw new Error("repository id was not preserved"); + if (parsed.items[0]?.repository_key !== "main") { + throw new Error("repository key was not preserved"); } if (parsed.items[0]?.source.kind !== "local_path") { throw new Error("repository source kind was not preserved"); @@ -49,11 +48,11 @@ Deno.test("generated repository wrapper validates current Backend JSON", () => { Deno.test("stale repository aliases fail closed at the JSON boundary", () => { const stale = structuredClone(repositoryList) as Record; const items = stale.items as Array>; - items[0].repository_id = items[0].id; - delete items[0].id; + items[0].id = items[0].repository_key; + delete items[0].repository_key; assertThrows( () => parseRepositoryListResponse(stale), - ".repository_id is not part", + ".id is not part", ); }); @@ -129,7 +128,7 @@ Deno.test("Repository settings consume the validated shared wire shape", async ( for ( const token of [ "parseRepositoryListResponse", - "repository.id", + "repository.repository_key", "repository.observed_status", "sourceLabel(repository.source.kind)", "supportsRepositoryAccess(repository.source.kind)", @@ -141,8 +140,8 @@ Deno.test("Repository settings consume the validated shared wire shape", async ( } for ( const staleToken of [ - "repository.repository_id", - "repository.observed.status", + "repository.id", + "repository.display_name", "repository.source.kind === 'remote_git'", ] ) {