From 9ccf2fbc3b0460776689bc845678c77a54302f27 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 23 Sep 2026 23:11:37 +0900 Subject: [PATCH 1/2] feat: support permissioned multi-repository ticket targets --- crates/runtime-api/src/lib.rs | 51 +- crates/server-api/src/lib.rs | 66 +- .../src/repository_openapi_typescript.rs | 46 + crates/ticket/src/latest_schema.sql | 13 +- crates/ticket/src/lib.rs | 689 +++++-- crates/ticket/src/sqlite_schema.rs | 309 ++- crates/ticket/src/tool.rs | 91 +- crates/workdir/src/lib.rs | 6 + crates/workdir/src/scope.rs | 10 + crates/worker-runtime/src/catalog.rs | 7 +- crates/worker-runtime/src/fs_store.rs | 38 +- crates/worker-runtime/src/runtime.rs | 67 +- crates/worker-runtime/src/worker_backend.rs | 120 +- .../src/feature/builtin/manage_worker.rs | 86 +- .../src/feature/builtin/orchestration.rs | 216 +- crates/workspace-server/src/authority.rs | 113 +- crates/workspace-server/src/hosts.rs | 1 + crates/workspace-server/src/server.rs | 1764 +++++++++-------- crates/workspace-server/src/store.rs | 998 +++++++++- .../src/workspace_deletion.rs | 1 + openapi/server-api.json | 124 +- resources/prompts/role/coder.md | 2 +- resources/prompts/role/orchestrator.md | 2 +- .../src/lib/generated/companion-api.ts | 2 +- .../src/lib/generated/repository-api.ts | 2 +- .../src/lib/generated/runtime-api.ts | 2 +- web/workspace/src/lib/generated/ticket-api.ts | 18 +- .../src/lib/generated/worker-launch-api.ts | 2 +- .../lib/workspace/api/ticket-browser.test.ts | 103 + .../src/lib/workspace/api/ticket-browser.ts | 50 +- .../src/lib/workspace/styles/tickets.css | 22 + .../workspace/tickets/ticket-panel.test.ts | 66 +- .../src/lib/workspace/tickets/ticket-panel.ts | 21 +- .../w/[workspaceId]/tickets/+page.svelte | 141 +- .../routes/w/[workspaceId]/tickets/+page.ts | 32 +- .../tickets/[ticketId]/+page.svelte | 172 +- 36 files changed, 4093 insertions(+), 1360 deletions(-) diff --git a/crates/runtime-api/src/lib.rs b/crates/runtime-api/src/lib.rs index 9520f37ec..f8364c2ce 100644 --- a/crates/runtime-api/src/lib.rs +++ b/crates/runtime-api/src/lib.rs @@ -357,7 +357,9 @@ pub struct LogicalWorkdirAttachment { } fn default_workdir_session_capabilities() -> workdir::WorkdirSessionCapabilities { - workdir::WorkdirSessionCapabilities::ALL + // Capability-less wire and persisted records must not silently regain write + // or command authority when read by a newer Runtime. + workdir::WorkdirSessionCapabilities::READ_ONLY } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -367,6 +369,9 @@ pub struct WorkingDirectoryAttachmentClaim { pub working_directory_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub relative_cwd: Option, + /// Backend-authoritative maximum capabilities for the attachment session. + #[serde(default = "default_workdir_session_capabilities")] + pub capabilities: workdir::WorkdirSessionCapabilities, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -956,6 +961,50 @@ mod tests { RuntimeApiError::new(status, "test_error", "test error") } + #[test] + fn capability_less_attachment_claims_default_to_read_only() { + let claim: WorkingDirectoryAttachmentClaim = serde_json::from_value(serde_json::json!({ + "alias": "docs", + "working_directory_id": "workdir-docs" + })) + .unwrap(); + assert_eq!( + claim.capabilities, + workdir::WorkdirSessionCapabilities::READ_ONLY + ); + + let logical: LogicalWorkdirAttachment = serde_json::from_value(serde_json::json!({ + "alias": "docs", + "working_directory_id": "workdir-docs" + })) + .unwrap(); + assert_eq!( + logical.capabilities, + workdir::WorkdirSessionCapabilities::READ_ONLY + ); + } + + #[test] + fn attachment_claim_serializes_backend_authored_capabilities() { + let claim = WorkingDirectoryAttachmentClaim { + alias: workdir::WorkdirAttachmentAlias::new("checkout").unwrap(), + working_directory_id: "workdir-main".to_string(), + relative_cwd: None, + capabilities: workdir::WorkdirSessionCapabilities::ALL, + }; + let value = serde_json::to_value(&claim).unwrap(); + assert_eq!( + value.get("capabilities"), + Some(&serde_json::to_value(workdir::WorkdirSessionCapabilities::ALL).unwrap()) + ); + assert_eq!( + serde_json::from_value::(value) + .unwrap() + .capabilities, + workdir::WorkdirSessionCapabilities::ALL + ); + } + impl RuntimeApi for RoundTripService { async fn ping( &self, diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index e423987ae..883707b29 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -5918,8 +5918,8 @@ pub struct TicketDetail { pub item_revision: String, pub queued_by: Option, pub queued_at: Option, - pub repository_key: Option, - pub ref_selector: Option, + #[cfg_attr(feature = "typescript", ts(type = "Array"))] + pub targets: Vec, pub risk_flags: Vec, pub body: String, pub body_truncated: bool, @@ -6081,8 +6081,8 @@ pub struct TicketRoleAssignmentMutationResponse { #[cfg_attr(feature = "typescript", ts(tag = "action", rename_all = "snake_case"))] pub enum BrowserTicketTargetEdit { Set { - repository_key: String, - ref_selector: Option, + #[cfg_attr(feature = "typescript", ts(type = "Array"))] + targets: Vec, }, Clear, } @@ -9269,6 +9269,64 @@ mod skill_typescript_tests { mod tests { use super::*; + #[test] + fn browser_ticket_targets_are_one_collection_authority() { + let edit = BrowserTicketTargetEdit::Set { + targets: vec![ + ticket::TicketTarget { + repository_key: "main".to_string(), + ref_selector: Some("develop".to_string()), + access: ticket::TicketTargetAccess::ReadWrite, + }, + ticket::TicketTarget { + repository_key: "docs".to_string(), + ref_selector: None, + access: ticket::TicketTargetAccess::ReadOnly, + }, + ], + }; + assert_eq!( + serde_json::to_value(edit).unwrap(), + serde_json::json!({ + "action": "set", + "targets": [ + { + "repository_key": "main", + "ref_selector": "develop", + "access": "read_write" + }, + { + "repository_key": "docs", + "access": "read_only" + } + ] + }) + ); + + assert!( + serde_json::from_value::(serde_json::json!({ + "action": "set", + "repository_key": "main", + "ref_selector": "develop" + })) + .is_err(), + "the removed singular target fields must not remain an accepted authority" + ); + assert!( + serde_json::from_value::(serde_json::json!({ + "target": { + "action": "set", + "targets": [{ + "repository_key": "main", + "ref_selector": "develop", + "access": "read_write" + }] + } + })) + .is_ok() + ); + } + #[test] fn websocket_operations_preserve_routes_and_directional_frame_authorities() { use api_macros::{TransportMetadata, WebSocketOperation}; diff --git a/crates/server-api/src/repository_openapi_typescript.rs b/crates/server-api/src/repository_openapi_typescript.rs index bccad1da2..a561bf71c 100644 --- a/crates/server-api/src/repository_openapi_typescript.rs +++ b/crates/server-api/src/repository_openapi_typescript.rs @@ -430,6 +430,13 @@ fn validate_ticket_browser_operations( request: None, successes: &[("200", "TicketListResponse")], }, + OperationContract { + path: "/api/w/{workspace_id}/tickets", + method: "post", + operation_id: "ticket_create_record", + request: Some("NewTicket"), + successes: &[("200", "TicketRef")], + }, OperationContract { path: "/api/w/{workspace_id}/tickets/{id}", method: "get", @@ -1837,6 +1844,45 @@ mod tests { const TICKET_BROWSER_TYPESCRIPT: &str = include_str!("../../../web/workspace/src/lib/generated/ticket-api.ts"); + #[test] + fn ticket_browser_generator_projects_target_collections_without_singular_authority() { + let openapi = crate::canonical_openapi_document() + .expect("canonical OpenAPI contract must build") + .to_json() + .expect("canonical OpenAPI contract must serialize"); + let output = generate_ticket_browser_typescript(&openapi).unwrap(); + assert!(output.contains( + "export type TicketTarget = { access: TicketTargetAccess; ref_selector?: string | null; repository_key: string; };" + )); + assert!( + output.contains("export type TicketTargetAccess = \"read_only\" | \"read_write\";") + ); + assert!(output.contains( + "export type BrowserTicketTargetEdit = { action: \"set\"; targets: Array; } | { action: \"clear\"; };" + )); + let edit = output + .lines() + .find(|line| line.starts_with("export type BrowserEditTicketRequest =")) + .expect("BrowserEditTicketRequest declaration"); + assert!(edit.contains("target?: BrowserTicketTargetEdit | null;")); + let create = output + .lines() + .find(|line| line.starts_with("export type NewTicket =")) + .expect("NewTicket declaration"); + assert!(create.contains("targets?: Array;")); + assert!(!create.contains("repository_key")); + assert!(!create.contains("ref_selector")); + assert!(!create.contains(" target?:")); + let detail = output + .lines() + .find(|line| line.starts_with("export type TicketDetail =")) + .expect("TicketDetail declaration"); + assert!(detail.contains("targets: Array;")); + assert!(!detail.contains("repository_key")); + assert!(!detail.contains("ref_selector")); + assert!(!detail.contains(" target:")); + } + #[test] fn checked_in_ticket_browser_typescript_is_current() { assert_eq!( diff --git a/crates/ticket/src/latest_schema.sql b/crates/ticket/src/latest_schema.sql index 942dc3c8b..a06926101 100644 --- a/crates/ticket/src/latest_schema.sql +++ b/crates/ticket/src/latest_schema.sql @@ -84,9 +84,20 @@ CREATE TABLE typed_tickets ( workflow_state_explicit INTEGER NOT NULL, queued_by TEXT, queued_at TEXT, - resolution TEXT, repository_id TEXT, ref_selector TEXT, + resolution TEXT, PRIMARY KEY (workspace_id, ticket_id) ); +CREATE TABLE typed_ticket_targets ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + ordinal INTEGER NOT NULL, + repository_key TEXT NOT NULL, + ref_selector TEXT, + access TEXT NOT NULL CHECK (access IN ('read_only', 'read_write')), + PRIMARY KEY (workspace_id, ticket_id, ordinal), + UNIQUE (workspace_id, ticket_id, repository_key), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); CREATE TABLE "workspace_resource_key_counters" ( workspace_id TEXT NOT NULL, resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')), diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index 3dba32a05..0ce6dc7e0 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -76,13 +76,17 @@ pub enum TicketError { #[error("ticket target selector is required for repository `{0}`")] MissingTargetSelector(String), #[error( - "ticket target selector `{selector}` is invalid for repository `{repository_id}`: {reason}" + "ticket target selector `{selector}` is invalid for repository `{repository_key}`: {reason}" )] InvalidTargetSelector { - repository_id: String, + repository_key: String, selector: String, reason: String, }, + #[error("ticket target repository `{0}` appears more than once")] + DuplicateTargetRepository(String), + #[error("ticket requires exactly one read_write target, found {0}")] + InvalidReadWriteTargetCount(usize), #[error("ticket target authority is unavailable")] TargetAuthorityUnavailable, #[error("stale ticket workflow state: expected `{expected}`, found `{actual}`")] @@ -501,9 +505,8 @@ 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, + #[serde(default)] + pub targets: Vec, } impl NewTicket { @@ -522,27 +525,68 @@ impl NewTicket { workflow_state: None, queued_by: None, queued_at: None, - repository_id: None, - ref_selector: None, + targets: Vec::new(), + } + } +} + +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, + schemars::JsonSchema, +)] +#[serde(rename_all = "snake_case")] +pub enum TicketTargetAccess { + ReadOnly, + ReadWrite, +} + +impl TicketTargetAccess { + pub fn as_str(self) -> &'static str { + match self { + Self::ReadOnly => "read_only", + Self::ReadWrite => "read_write", + } + } + + fn parse(value: &str) -> Option { + match value { + "read_only" => Some(Self::ReadOnly), + "read_write" => Some(Self::ReadWrite), + _ => None, } } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TicketTarget { + pub repository_key: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ref_selector: Option, + pub access: TicketTargetAccess, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(tag = "action", rename_all = "snake_case")] -pub enum TicketTargetEdit { - Set { - #[serde(rename = "repository_key")] - repository_id: String, - ref_selector: Option, - }, +pub enum TicketTargetsEdit { + Set { targets: Vec }, Clear, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ResolvedTicketTarget { - pub repository_id: String, + pub repository_key: String, pub ref_selector: String, + pub access: TicketTargetAccess, } /// Workspace-owned authority used to resolve and validate implementation targets. @@ -554,7 +598,7 @@ pub trait TicketTargetAuthority: Send + Sync { fn resolve_target( &self, workspace_id: &str, - repository_id: Option<&str>, + repository_key: &str, ref_selector: Option<&str>, ) -> Result; } @@ -570,50 +614,83 @@ pub struct TicketMarkReady { pub intake_summary: Option, } -impl TicketTargetEdit { +impl TicketTargetsEdit { fn validate(&self) -> Result<()> { - if let Self::Set { - repository_id, - ref_selector, - } = self - { - validate_ticket_target(Some(repository_id), ref_selector.as_deref())?; + if let Self::Set { targets } = self { + validate_ticket_targets(targets)?; } Ok(()) } } -fn validate_ticket_target(repository_id: Option<&str>, ref_selector: Option<&str>) -> Result<()> { - let Some(repository_id) = repository_id else { - if ref_selector.is_some() { - return Err(TicketError::Conflict( - "ref_selector requires repository_id".to_string(), +fn validate_ticket_targets(targets: &[TicketTarget]) -> Result<()> { + let mut repositories = BTreeSet::new(); + for target in targets { + validate_required_event_value("repository_key", &target.repository_key)?; + if let Some(ref_selector) = target.ref_selector.as_deref() { + validate_required_event_value("ref_selector", ref_selector)?; + } + if !repositories.insert(target.repository_key.as_str()) { + return Err(TicketError::DuplicateTargetRepository( + target.repository_key.clone(), )); } - return Ok(()); - }; - validate_required_event_value("repository_id", repository_id)?; - if let Some(ref_selector) = ref_selector { - validate_required_event_value("ref_selector", ref_selector)?; } Ok(()) } -fn resolve_ready_target( +fn resolve_ready_targets( authority: Option<&Arc>, workspace_id: &str, - ticket: &Ticket, -) -> Result { - authority - .ok_or(TicketError::TargetAuthorityUnavailable)? - .resolve_target( + targets: &[TicketTarget], +) -> Result> { + validate_ticket_targets(targets)?; + let read_write_count = targets + .iter() + .filter(|target| target.access == TicketTargetAccess::ReadWrite) + .count(); + if read_write_count != 1 { + return Err(TicketError::InvalidReadWriteTargetCount(read_write_count)); + } + let authority = authority.ok_or(TicketError::TargetAuthorityUnavailable)?; + let mut resolved = Vec::with_capacity(targets.len()); + let mut repositories = BTreeSet::new(); + for target in targets { + let mut canonical = authority.resolve_target( workspace_id, - ticket.meta.repository_id.as_deref(), - ticket.meta.ref_selector.as_deref(), - ) + &target.repository_key, + target.ref_selector.as_deref(), + )?; + validate_required_event_value("repository_key", &canonical.repository_key)?; + validate_required_event_value("ref_selector", &canonical.ref_selector)?; + canonical.access = target.access; + if !repositories.insert(canonical.repository_key.clone()) { + return Err(TicketError::DuplicateTargetRepository( + canonical.repository_key, + )); + } + resolved.push(canonical); + } + Ok(resolved) } -fn mark_ready_fingerprint( +fn canonical_ticket_targets(targets: &[ResolvedTicketTarget]) -> Vec { + targets + .iter() + .map(|target| TicketTarget { + repository_key: target.repository_key.clone(), + ref_selector: Some(target.ref_selector.clone()), + access: target.access, + }) + .collect() +} + +fn encoded_resolved_targets(targets: &[ResolvedTicketTarget]) -> Result { + serde_json::to_string(targets) + .map_err(|error| TicketError::Conflict(format!("failed to encode Ticket targets: {error}"))) +} + +fn mark_ready_fingerprint_v1( ticket: &Ticket, request: &TicketMarkReady, target: &ResolvedTicketTarget, @@ -622,7 +699,7 @@ fn mark_ready_fingerprint( digest.update(b"ticket.mark-ready.v1\0"); digest.update(ticket.meta.id.as_str().as_bytes()); digest.update(b"\0planning\0"); - digest.update(target.repository_id.as_bytes()); + digest.update(target.repository_key.as_bytes()); digest.update(b"\0"); digest.update(target.ref_selector.as_bytes()); digest.update(b"\0"); @@ -646,6 +723,43 @@ fn mark_ready_fingerprint( .collect() } +fn mark_ready_fingerprint( + ticket: &Ticket, + request: &TicketMarkReady, + targets: &[ResolvedTicketTarget], +) -> String { + let mut digest = Sha256::new(); + digest.update(b"ticket.mark-ready.v2\0"); + digest.update(ticket.meta.id.as_str().as_bytes()); + digest.update(b"\0planning\0"); + for target in targets { + digest.update(target.repository_key.as_bytes()); + digest.update(b"\0"); + digest.update(target.ref_selector.as_bytes()); + digest.update(b"\0"); + digest.update(target.access.as_str().as_bytes()); + digest.update(b"\0"); + } + if let Some(reason) = request.reason.as_deref() { + digest.update(reason.as_bytes()); + } + if let Some(summary) = request.intake_summary.as_ref() { + digest.update(b"\0intake-summary\0"); + digest.update(summary.body.as_str().as_bytes()); + for reference in &summary.references { + digest.update(b"\0"); + digest.update(reference.kind.as_bytes()); + digest.update(b":"); + digest.update(reference.target.as_bytes()); + } + } + digest + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + fn validate_mark_ready_replay(ticket: &Ticket, request: &TicketMarkReady) -> Result { let Some(event) = ticket .events @@ -654,31 +768,36 @@ fn validate_mark_ready_replay(ticket: &Ticket, request: &TicketMarkReady) -> Res else { return Ok(false); }; - let target = ResolvedTicketTarget { - repository_id: event - .attributes - .get("repository_id") - .cloned() - .ok_or_else(|| { - TicketError::Conflict("mark-ready event is missing repository_id".to_owned()) - })?, - ref_selector: event - .attributes - .get("ref_selector") - .cloned() - .ok_or_else(|| { - TicketError::Conflict("mark-ready event is missing ref_selector".to_owned()) - })?, + let targets = event + .attributes + .get("targets") + .ok_or_else(|| TicketError::Conflict("mark-ready event is missing targets".to_owned())) + .and_then(|targets| { + serde_json::from_str::>(targets).map_err(|error| { + TicketError::Conflict(format!("mark-ready event has invalid targets: {error}")) + }) + })?; + let fingerprint = match event + .attributes + .get("fingerprint_version") + .map(String::as_str) + .unwrap_or("1") + { + "1" if targets.len() == 1 => mark_ready_fingerprint_v1(ticket, request, &targets[0]), + "2" => mark_ready_fingerprint(ticket, request, &targets), + version => { + return Err(TicketError::Conflict(format!( + "mark-ready event has unsupported fingerprint version {version:?}" + ))); + } }; - let fingerprint = mark_ready_fingerprint(ticket, request, &target); if event.attributes.get("request_fingerprint") != Some(&fingerprint) { return Err(TicketError::OperationFingerprintMismatch { operation_key: request.operation_key.clone(), }); } if ticket.meta.workflow_state != TicketWorkflowState::Ready - || ticket.meta.repository_id.as_deref() != Some(target.repository_id.as_str()) - || ticket.meta.ref_selector.as_deref() != Some(target.ref_selector.as_str()) + || ticket.meta.targets != canonical_ticket_targets(&targets) { return Err(TicketError::StaleWorkflowState { expected: TicketWorkflowState::Ready.as_str().to_owned(), @@ -719,7 +838,7 @@ pub struct TicketItemEdit { pub body: Option, #[serde(default)] pub body_replacement: Option, - pub target: Option, + pub targets: Option, pub author: Option, } @@ -755,8 +874,8 @@ impl TicketItemEdit { if let Some(replacement) = &self.body_replacement { replacement.validate()?; } - if let Some(target) = &self.target { - target.validate()?; + if let Some(targets) = &self.targets { + targets.validate()?; } Ok(()) @@ -766,7 +885,7 @@ impl TicketItemEdit { self.title.is_some() || self.body.is_some() || self.body_replacement.is_some() - || self.target.is_some() + || self.targets.is_some() } } @@ -1640,9 +1759,8 @@ 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, + #[serde(default)] + pub targets: Vec, pub raw: BTreeMap, } @@ -2552,10 +2670,11 @@ impl SqliteTicketBackend { fn insert_ticket(&self, conn: &Connection, ticket: &Ticket) -> Result<()> { conn.execute(r#"INSERT INTO typed_tickets - (workspace_id, ticket_id, slug, title, status, kind, priority, body, created_at, updated_at, assignee, readiness, workflow_state, workflow_state_explicit, queued_by, queued_at, resolution, repository_id, ref_selector) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)"#, - params![self.workspace_id, ticket.meta.id, ticket.meta.slug, ticket.meta.title, ticket.meta.status.as_str(), ticket.meta.kind, ticket.meta.priority, ticket.document.body.as_str(), ticket.meta.created_at, ticket.meta.updated_at, ticket.meta.assignee, ticket.meta.readiness, ticket.meta.workflow_state.as_str(), if ticket.meta.workflow_state_explicit { 1 } else { 0 }, ticket.meta.queued_by, ticket.meta.queued_at, ticket.resolution.as_ref().map(|body| body.as_str()), ticket.meta.repository_id, ticket.meta.ref_selector] + (workspace_id, ticket_id, slug, title, status, kind, priority, body, created_at, updated_at, assignee, readiness, workflow_state, workflow_state_explicit, queued_by, queued_at, resolution) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)"#, + params![self.workspace_id, ticket.meta.id, ticket.meta.slug, ticket.meta.title, ticket.meta.status.as_str(), ticket.meta.kind, ticket.meta.priority, ticket.document.body.as_str(), ticket.meta.created_at, ticket.meta.updated_at, ticket.meta.assignee, ticket.meta.readiness, ticket.meta.workflow_state.as_str(), if ticket.meta.workflow_state_explicit { 1 } else { 0 }, ticket.meta.queued_by, ticket.meta.queued_at, ticket.resolution.as_ref().map(|body| body.as_str())] ).map_err(sqlite_err)?; + self.replace_ticket_targets(conn, &ticket.meta.id, &ticket.meta.targets)?; self.insert_ordered_values( conn, "typed_ticket_labels", @@ -2582,6 +2701,67 @@ impl SqliteTicketBackend { Ok(()) } + fn replace_ticket_targets( + &self, + conn: &Connection, + ticket_id: &str, + targets: &[TicketTarget], + ) -> Result<()> { + validate_ticket_targets(targets)?; + conn.execute( + "DELETE FROM typed_ticket_targets WHERE workspace_id = ?1 AND ticket_id = ?2", + params![self.workspace_id, ticket_id], + ) + .map_err(sqlite_err)?; + for (ordinal, target) in targets.iter().enumerate() { + conn.execute( + "INSERT INTO typed_ticket_targets + (workspace_id, ticket_id, ordinal, repository_key, ref_selector, access) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + self.workspace_id, + ticket_id, + ordinal as i64, + target.repository_key, + target.ref_selector, + target.access.as_str(), + ], + ) + .map_err(sqlite_err)?; + } + Ok(()) + } + + fn load_ticket_targets(&self, conn: &Connection, ticket_id: &str) -> Result> { + let mut statement = conn + .prepare( + "SELECT repository_key, ref_selector, access + FROM typed_ticket_targets + WHERE workspace_id = ?1 AND ticket_id = ?2 + ORDER BY ordinal ASC", + ) + .map_err(sqlite_err)?; + let rows = statement + .query_map(params![self.workspace_id, ticket_id], |row| { + let access = row.get::<_, String>(2)?; + let access = TicketTargetAccess::parse(&access).ok_or_else(|| { + rusqlite::Error::FromSqlConversionFailure( + 2, + rusqlite::types::Type::Text, + format!("invalid Ticket target access {access:?}").into(), + ) + })?; + Ok(TicketTarget { + repository_key: row.get(0)?, + ref_selector: row.get(1)?, + access, + }) + }) + .map_err(sqlite_err)?; + rows.collect::, _>>() + .map_err(sqlite_err) + } + fn insert_ordered_values( &self, conn: &Connection, @@ -2623,8 +2803,7 @@ impl SqliteTicketBackend { workflow_state_explicit: row.get::<_, i64>(13)? != 0, queued_by: row.get(14)?, queued_at: row.get(15)?, - repository_id: row.get(16)?, - ref_selector: row.get(17)?, + targets: Vec::new(), raw: BTreeMap::new(), }) } @@ -2632,9 +2811,10 @@ impl SqliteTicketBackend { fn load_ticket(&self, conn: &Connection, ticket_id: &str) -> Result { #[cfg(test)] self.full_ticket_load_count.fetch_add(1, Ordering::SeqCst); - let (mut meta, body, resolution): (TicketMeta, String, Option) = conn.query_row(r#"SELECT ticket_id, slug, title, status, kind, priority, created_at, updated_at, assignee, readiness, body, resolution, workflow_state, workflow_state_explicit, queued_by, queued_at, repository_id, ref_selector FROM typed_tickets WHERE workspace_id = ?1 AND ticket_id = ?2"#, + let (mut meta, body, resolution): (TicketMeta, String, Option) = conn.query_row(r#"SELECT ticket_id, slug, title, status, kind, priority, created_at, updated_at, assignee, readiness, body, resolution, workflow_state, workflow_state_explicit, queued_by, queued_at FROM typed_tickets WHERE workspace_id = ?1 AND ticket_id = ?2"#, params![self.workspace_id, ticket_id], |row| Ok((Self::ticket_meta_from_row(row)?, row.get(10)?, row.get(11)?))).optional().map_err(sqlite_err)?.ok_or_else(|| TicketError::NotFound(ticket_id.to_string()))?; meta.resource_key = Self::resource_key_for(conn, &self.workspace_id, ticket_id)?; + meta.targets = self.load_ticket_targets(conn, ticket_id)?; meta.labels = self.load_ordered_values(conn, "typed_ticket_labels", "label", ticket_id)?; meta.risk_flags = self.load_ordered_values(conn, "typed_ticket_risk_flags", "risk_flag", ticket_id)?; @@ -2799,7 +2979,7 @@ impl SqliteTicketBackend { conn: &Connection, filter: TicketListQuery, ) -> Result> { - let mut stmt = conn.prepare(r#"SELECT ticket_id, slug, title, status, kind, priority, created_at, updated_at, assignee, readiness, body, resolution, workflow_state, workflow_state_explicit, queued_by, queued_at, repository_id, ref_selector FROM typed_tickets WHERE workspace_id = ?1 ORDER BY ticket_id ASC"#).map_err(sqlite_err)?; + let mut stmt = conn.prepare(r#"SELECT ticket_id, slug, title, status, kind, priority, created_at, updated_at, assignee, readiness, body, resolution, workflow_state, workflow_state_explicit, queued_by, queued_at FROM typed_tickets WHERE workspace_id = ?1 ORDER BY ticket_id ASC"#).map_err(sqlite_err)?; let rows = stmt .query_map(params![self.workspace_id], Self::ticket_meta_from_row) .map_err(sqlite_err)?; @@ -2913,10 +3093,7 @@ impl TicketBackend for SqliteTicketBackend { "ticket title must not be empty".to_string(), )); } - validate_ticket_target( - input.repository_id.as_deref(), - input.ref_selector.as_deref(), - )?; + validate_ticket_targets(&input.targets)?; let base_millis = unix_epoch_millis_now().map_err(|err| { TicketError::Conflict(format!("failed to read ticket id timestamp: {err}")) })?; @@ -2953,8 +3130,7 @@ impl TicketBackend for SqliteTicketBackend { workflow_state_explicit: true, queued_by: input.queued_by, queued_at: input.queued_at, - repository_id: input.repository_id, - ref_selector: input.ref_selector, + targets: input.targets, raw: BTreeMap::new(), }; let body = if input.body.as_str() == DEFAULT_TICKET_BODY { @@ -3005,7 +3181,7 @@ impl TicketBackend for SqliteTicketBackend { edit.validate_body_edit_request()?; if !edit.has_changes() { return Err(TicketError::Conflict( - "TicketEditItem requires at least one of title, body, body_replacement, or target" + "TicketEditItem requires at least one of title, body, body_replacement, or targets" .to_string(), )); } @@ -3016,7 +3192,7 @@ impl TicketBackend for SqliteTicketBackend { validate_required_event_value("author", author)?; } let ticket_id = self.resolve_ticket_id(conn, id)?; - if edit.target.is_some() { + if edit.targets.is_some() { let current = self.load_ticket(conn, &ticket_id)?.meta.workflow_state; if current != TicketWorkflowState::Planning { return Err(TicketError::Conflict(format!( @@ -3052,19 +3228,13 @@ impl TicketBackend for SqliteTicketBackend { if let Some(body) = updated_body.as_ref() { conn.execute("UPDATE typed_tickets SET body = ?3, updated_at = ?4 WHERE workspace_id = ?1 AND ticket_id = ?2", params![self.workspace_id, ticket_id, body.as_str(), now]).map_err(sqlite_err)?; } - if let Some(target) = edit.target.as_ref() { - let (repository_id, ref_selector) = match target { - TicketTargetEdit::Set { - repository_id, - ref_selector, - } => (Some(repository_id.as_str()), ref_selector.as_deref()), - TicketTargetEdit::Clear => (None, None), + if let Some(targets) = edit.targets.as_ref() { + let targets = match targets { + TicketTargetsEdit::Set { targets } => targets.as_slice(), + TicketTargetsEdit::Clear => &[], }; - conn.execute( - "UPDATE typed_tickets SET repository_id = ?3, ref_selector = ?4, updated_at = ?5 WHERE workspace_id = ?1 AND ticket_id = ?2", - params![self.workspace_id, ticket_id, repository_id, ref_selector, now], - ) - .map_err(sqlite_err)?; + self.replace_ticket_targets(conn, &ticket_id, targets)?; + self.touch_ticket(conn, &ticket_id, &now)?; } let mut changes = Vec::new(); @@ -3074,8 +3244,8 @@ impl TicketBackend for SqliteTicketBackend { if !matches!(body_edit_audit, TicketBodyEditAudit::None) { changes.push("body"); } - if edit.target.is_some() { - changes.push("target"); + if edit.targets.is_some() { + changes.push("targets"); } let mut attributes = BTreeMap::new(); attributes.insert("changes".to_string(), changes.join(",")); @@ -3123,10 +3293,10 @@ impl TicketBackend for SqliteTicketBackend { let target_error = queue_tickets.iter().find_map(|candidate| { self.load_ticket(conn, candidate) .and_then(|ticket| { - resolve_ready_target( + resolve_ready_targets( self.target_authority.as_ref(), &self.workspace_id, - &ticket, + &ticket.meta.targets, ) }) .err() @@ -3285,18 +3455,31 @@ impl TicketBackend for SqliteTicketBackend { if validate_mark_ready_replay(&ticket, &request)? { return Ok(ticket); } - let target = resolve_ready_target( - self.target_authority.as_ref(), - &self.workspace_id, - &ticket, - )?; - let fingerprint = mark_ready_fingerprint(&ticket, &request, &target); if ticket.meta.workflow_state != TicketWorkflowState::Planning { return Err(TicketError::StaleWorkflowState { expected: TicketWorkflowState::Planning.as_str().to_owned(), actual: ticket.meta.workflow_state.as_str().to_owned(), }); } + let targets = resolve_ready_targets( + self.target_authority.as_ref(), + &self.workspace_id, + &ticket.meta.targets, + )?; + let fingerprint = mark_ready_fingerprint(&ticket, &request, &targets); + let encoded_targets = encoded_resolved_targets(&targets)?; + let target_summary = targets + .iter() + .map(|target| { + format!( + "{}@{} ({})", + target.repository_key, + target.ref_selector, + target.access.as_str() + ) + }) + .collect::>() + .join(", "); let reason = request .reason .as_deref() @@ -3341,23 +3524,32 @@ impl TicketBackend for SqliteTicketBackend { state_field: Some("state".to_owned()), heading: Some(TicketEventKind::StateChanged.heading()), body: MarkdownText::new(format!( - "Implementation target `{}` at selector `{}` was validated and the Ticket was marked ready.", - target.repository_id, target.ref_selector + "Implementation targets [{target_summary}] were validated and the Ticket was marked ready." )), references: Vec::new(), attributes: BTreeMap::from([ ("operation_key".to_owned(), request.operation_key), ("request_fingerprint".to_owned(), fingerprint), - ("repository_id".to_owned(), target.repository_id.clone()), - ("ref_selector".to_owned(), target.ref_selector.clone()), + ("fingerprint_version".to_owned(), "2".to_owned()), + ("targets".to_owned(), encoded_targets), ]), }, )?; - conn.execute( - "UPDATE typed_tickets SET workflow_state = 'ready', workflow_state_explicit = 1, repository_id = ?3, ref_selector = ?4, updated_at = ?5 WHERE workspace_id = ?1 AND ticket_id = ?2 AND workflow_state = 'planning'", - params![self.workspace_id, ticket_id, target.repository_id, target.ref_selector, at], + let updated = conn.execute( + "UPDATE typed_tickets SET workflow_state = 'ready', workflow_state_explicit = 1, updated_at = ?3 WHERE workspace_id = ?1 AND ticket_id = ?2 AND workflow_state = 'planning'", + params![self.workspace_id, ticket_id, at], ) .map_err(sqlite_err)?; + if updated != 1 { + return Err(TicketError::Conflict( + "Ticket changed while ready targets were being persisted".to_owned(), + )); + } + self.replace_ticket_targets( + conn, + &ticket_id, + &canonical_ticket_targets(&targets), + )?; self.load_ticket(conn, &ticket_id) }) } @@ -3389,33 +3581,37 @@ impl TicketBackend for SqliteTicketBackend { } } - let mut targets = Vec::with_capacity(queued_tickets.len()); + let mut resolved_targets = Vec::with_capacity(queued_tickets.len()); for ticket_id in &queued_tickets { let ticket = self.load_ticket(conn, ticket_id)?; - let target = resolve_ready_target( + let targets = resolve_ready_targets( self.target_authority.as_ref(), &self.workspace_id, - &ticket, + &ticket.meta.targets, )?; - targets.push((ticket_id.clone(), target)); + resolved_targets.push((ticket_id.clone(), targets)); } let at = now_utc(); - for (ticket_id, target) in targets { + for (ticket_id, targets) in resolved_targets { let updated = conn.execute( - "UPDATE typed_tickets SET workflow_state = 'queued', workflow_state_explicit = 1, queued_by = ?3, queued_at = ?4, repository_id = ?5, ref_selector = ?6, updated_at = ?4 WHERE workspace_id = ?1 AND ticket_id = ?2 AND workflow_state = 'ready'", - params![self.workspace_id, ticket_id, queued_by, at, target.repository_id, target.ref_selector], + "UPDATE typed_tickets SET workflow_state = 'queued', workflow_state_explicit = 1, queued_by = ?3, queued_at = ?4, updated_at = ?4 WHERE workspace_id = ?1 AND ticket_id = ?2 AND workflow_state = 'ready'", + params![self.workspace_id, ticket_id, queued_by, at], ).map_err(sqlite_err)?; if updated != 1 { return Err(TicketError::Conflict(format!( "Ticket {ticket_id} changed while the dependency queue plan was being applied" ))); } + self.replace_ticket_targets( + conn, + &ticket_id, + &canonical_ticket_targets(&targets), + )?; let mut attributes = BTreeMap::from([ ("queued_by".to_owned(), queued_by.to_owned()), ("queued_at".to_owned(), at.clone()), - ("repository_id".to_owned(), target.repository_id), - ("ref_selector".to_owned(), target.ref_selector), + ("targets".to_owned(), encoded_resolved_targets(&targets)?), ("queue_root_ticket".to_owned(), requested_ticket.clone()), ]); if let Some(assignment_id) = self @@ -4331,22 +4527,38 @@ mod tests { fn resolve_target( &self, _workspace_id: &str, - repository_id: Option<&str>, + repository_key: &str, ref_selector: Option<&str>, ) -> Result { - let repository_id = repository_id.unwrap_or("main"); - if repository_id == "unknown" { + if repository_key == "unknown" { return Err(TicketError::UnknownTargetRepository( - repository_id.to_owned(), + repository_key.to_owned(), )); } Ok(ResolvedTicketTarget { - repository_id: repository_id.to_owned(), + repository_key: repository_key.to_owned(), ref_selector: ref_selector.unwrap_or("develop").to_owned(), + access: TicketTargetAccess::ReadOnly, }) } } + fn ticket_target( + repository_key: &str, + ref_selector: Option<&str>, + access: TicketTargetAccess, + ) -> TicketTarget { + TicketTarget { + repository_key: repository_key.to_owned(), + ref_selector: ref_selector.map(str::to_owned), + access, + } + } + + fn write_target(repository_key: &str) -> TicketTarget { + ticket_target(repository_key, None, TicketTargetAccess::ReadWrite) + } + fn backend(dir: &TempDir) -> SqliteTicketBackend { SqliteTicketBackend::open(dir.path().join("tickets.db"), "workspace-test") .unwrap() @@ -4355,30 +4567,46 @@ mod tests { fn assert_ticket_target_edit_semantics(backend: &B) { let mut input = NewTicket::new("Target Ticket"); - input.repository_id = Some("main".to_string()); - input.ref_selector = Some("feature/api".to_string()); + input.targets = vec![ticket_target( + "main", + Some("feature/api"), + TicketTargetAccess::ReadWrite, + )]; let created = backend.create(input).unwrap(); let ticket = backend .show(TicketIdOrSlug::Id(created.id.clone())) .unwrap(); - assert_eq!(ticket.meta.repository_id.as_deref(), Some("main")); - assert_eq!(ticket.meta.ref_selector.as_deref(), Some("feature/api")); + assert_eq!( + ticket.meta.targets, + vec![ticket_target( + "main", + Some("feature/api"), + TicketTargetAccess::ReadWrite, + )] + ); let edited = backend .edit_item( TicketIdOrSlug::Id(created.id.clone()), TicketItemEdit { - target: Some(TicketTargetEdit::Set { - repository_id: "secondary".to_string(), - ref_selector: None, + targets: Some(TicketTargetsEdit::Set { + targets: vec![ + write_target("secondary"), + ticket_target("docs", Some("main"), TicketTargetAccess::ReadOnly), + ], }), author: Some("tester".to_string()), ..Default::default() }, ) .unwrap(); - assert_eq!(edited.meta.repository_id.as_deref(), Some("secondary")); - assert_eq!(edited.meta.ref_selector, None); + assert_eq!( + edited.meta.targets, + vec![ + write_target("secondary"), + ticket_target("docs", Some("main"), TicketTargetAccess::ReadOnly), + ] + ); let edit_event = edited .events .iter() @@ -4387,20 +4615,19 @@ mod tests { .expect("item_edit event"); assert_eq!( edit_event.attributes.get("changes"), - Some(&"target".to_string()) + Some(&"targets".to_string()) ); let cleared = backend .edit_item( TicketIdOrSlug::Id(created.id), TicketItemEdit { - target: Some(TicketTargetEdit::Clear), + targets: Some(TicketTargetsEdit::Clear), ..Default::default() }, ) .unwrap(); - assert_eq!(cleared.meta.repository_id, None); - assert_eq!(cleared.meta.ref_selector, None); + assert!(cleared.meta.targets.is_empty()); } fn assert_partial_body_replacement_semantics(backend: &B) { @@ -4876,11 +5103,11 @@ mod tests { .with_target_authority(Arc::new(TestTargetAuthority)); let mut root_input = NewTicket::new("Ready root"); root_input.workflow_state = Some(TicketWorkflowState::Ready); - root_input.repository_id = Some("main".to_string()); + root_input.targets = vec![write_target("main")]; let root = backend.create(root_input).unwrap(); let mut middle_input = NewTicket::new("Queued middle"); middle_input.workflow_state = Some(TicketWorkflowState::Queued); - middle_input.repository_id = Some("main".to_string()); + middle_input.targets = vec![write_target("main")]; let middle = backend.create(middle_input).unwrap(); let leaf = backend.create(NewTicket::new("Planning leaf")).unwrap(); for (ticket, target) in [ @@ -4941,8 +5168,11 @@ mod tests { .unwrap(); let mut sqlite_input = NewTicket::new("SQLite ready Ticket"); sqlite_input.workflow_state = Some(TicketWorkflowState::Ready); - sqlite_input.repository_id = Some("main".to_string()); - sqlite_input.ref_selector = Some("develop".to_string()); + sqlite_input.targets = vec![ticket_target( + "main", + Some("develop"), + TicketTargetAccess::ReadWrite, + )]; let sqlite_ticket = sqlite.create(sqlite_input).unwrap(); let sqlite_check = sqlite .dependency_check(TicketIdOrSlug::Id(sqlite_ticket.id.clone())) @@ -4970,11 +5200,11 @@ mod tests { .with_target_authority(Arc::new(TestTargetAuthority)); let mut root_input = NewTicket::new("Ready root"); root_input.workflow_state = Some(TicketWorkflowState::Ready); - root_input.repository_id = Some("main".to_string()); + root_input.targets = vec![write_target("main")]; let root = backend.create(root_input).unwrap(); let mut done_input = NewTicket::new("Done dependency"); done_input.workflow_state = Some(TicketWorkflowState::Done); - done_input.repository_id = Some("main".to_string()); + done_input.targets = vec![write_target("main")]; let done = backend.create(done_input).unwrap(); for (ticket_id, target) in [ (root.id.clone(), done.id.clone()), @@ -5023,15 +5253,15 @@ mod tests { .with_target_authority(Arc::new(TestTargetAuthority)); let mut first_input = NewTicket::new("First ready Ticket"); first_input.workflow_state = Some(TicketWorkflowState::Ready); - first_input.repository_id = Some("main".to_string()); + first_input.targets = vec![write_target("main")]; let first = backend.create(first_input).unwrap(); let mut second_input = NewTicket::new("Second ready Ticket"); second_input.workflow_state = Some(TicketWorkflowState::Ready); - second_input.repository_id = Some("main".to_string()); + second_input.targets = vec![write_target("main")]; let second = backend.create(second_input).unwrap(); let mut third_input = NewTicket::new("Third ready Ticket"); third_input.workflow_state = Some(TicketWorkflowState::Ready); - third_input.repository_id = Some("main".to_string()); + third_input.targets = vec![write_target("main")]; let third = backend.create(third_input).unwrap(); for (ticket, target) in [ (first.id.clone(), second.id.clone()), @@ -5105,11 +5335,11 @@ mod tests { .with_target_authority(Arc::new(TestTargetAuthority)); let mut dependency_input = NewTicket::new("Invalid ready dependency"); dependency_input.workflow_state = Some(TicketWorkflowState::Ready); - dependency_input.repository_id = Some("unknown".to_string()); + dependency_input.targets = vec![write_target("unknown")]; let dependency = backend.create(dependency_input).unwrap(); let mut root_input = NewTicket::new("Queue root"); root_input.workflow_state = Some(TicketWorkflowState::Ready); - root_input.repository_id = Some("main".to_string()); + root_input.targets = vec![write_target("main")]; let root = backend.create(root_input).unwrap(); backend .add_ticket_relation( @@ -5158,11 +5388,11 @@ mod tests { .unwrap() .with_target_authority(Arc::new(TestTargetAuthority)); let mut dependency = NewTicket::new("Dependency"); - dependency.repository_id = Some("main".to_owned()); + dependency.targets = vec![write_target("main")]; dependency.workflow_state = Some(TicketWorkflowState::Ready); let dependency = backend.create(dependency).unwrap(); let mut implementation = NewTicket::new("Implementation"); - implementation.repository_id = Some("main".to_owned()); + implementation.targets = vec![write_target("main")]; let implementation = backend.create(implementation).unwrap(); backend .add_ticket_relation( @@ -5187,7 +5417,14 @@ mod tests { request.clone(), ) .unwrap(); - assert_eq!(ready.meta.ref_selector.as_deref(), Some("develop")); + assert_eq!( + ready.meta.targets, + vec![ticket_target( + "main", + Some("develop"), + TicketTargetAccess::ReadWrite, + )] + ); let replay = backend .mark_ready(TicketIdOrSlug::Id(implementation.id.clone()), request) .unwrap(); @@ -5620,8 +5857,11 @@ mod tests { let mut ready_input = NewTicket::new("Ready Workflow"); ready_input.workflow_state = Some(TicketWorkflowState::Ready); - ready_input.repository_id = Some("main".to_owned()); - ready_input.ref_selector = Some("develop".to_owned()); + ready_input.targets = vec![ticket_target( + "main", + Some("develop"), + TicketTargetAccess::ReadWrite, + )]; let ready = backend.create(ready_input).unwrap(); backend .queue_ready(TicketIdOrSlug::Id(ready.id.clone()), "workspace-panel") @@ -5641,6 +5881,15 @@ mod tests { assert_eq!(event.from.as_deref(), Some("ready")); assert_eq!(event.to.as_deref(), Some("queued")); assert_eq!(event.reason.as_deref(), Some("queued")); + let event_targets = serde_json::from_str::>( + event.attributes.get("targets").expect("canonical targets"), + ) + .unwrap(); + assert_eq!(event_targets.len(), 1); + assert_eq!(event_targets[0].repository_key, "main"); + assert_eq!(event_targets[0].access, TicketTargetAccess::ReadWrite); + assert!(!event.attributes.contains_key("repository_id")); + assert!(!event.attributes.contains_key("ref_selector")); } #[test] @@ -5669,7 +5918,10 @@ mod tests { let tmp = TempDir::new().unwrap(); let backend = backend(&tmp); let mut input = NewTicket::new("Planning Ready"); - input.repository_id = Some("main".to_owned()); + input.targets = vec![ + write_target("main"), + ticket_target("docs", None, TicketTargetAccess::ReadOnly), + ]; let ticket = backend.create(input).unwrap(); let request = TicketMarkReady { operation_key: "ready-op-1".to_owned(), @@ -5685,9 +5937,37 @@ mod tests { .mark_ready(TicketIdOrSlug::Id(ticket.id.clone()), request) .unwrap(); assert_eq!(first.meta.workflow_state, TicketWorkflowState::Ready); - assert_eq!(first.meta.repository_id.as_deref(), Some("main")); - assert_eq!(first.meta.ref_selector.as_deref(), Some("develop")); + assert_eq!( + first.meta.targets, + vec![ + ticket_target("main", Some("develop"), TicketTargetAccess::ReadWrite,), + ticket_target("docs", Some("develop"), TicketTargetAccess::ReadOnly), + ] + ); assert_eq!(first.events, second.events); + let ready_event = first + .events + .iter() + .find(|event| event.attributes.contains_key("operation_key")) + .unwrap(); + let event_targets = serde_json::from_str::>( + ready_event.attributes.get("targets").unwrap(), + ) + .unwrap(); + assert_eq!(event_targets.len(), 2); + assert_eq!(event_targets[0].repository_key, "main"); + assert_eq!(event_targets[0].access, TicketTargetAccess::ReadWrite); + assert_eq!(event_targets[1].repository_key, "docs"); + assert_eq!(event_targets[1].access, TicketTargetAccess::ReadOnly); + assert_eq!( + ready_event + .attributes + .get("fingerprint_version") + .map(String::as_str), + Some("2") + ); + assert!(!ready_event.attributes.contains_key("repository_id")); + assert!(!ready_event.attributes.contains_key("ref_selector")); assert_eq!( first .events @@ -5714,6 +5994,97 @@ mod tests { )); } + #[test] + fn target_validation_rejects_duplicates_and_invalid_write_layouts_without_state_change() { + let tmp = TempDir::new().unwrap(); + let backend = backend(&tmp); + + let mut duplicate = NewTicket::new("Duplicate targets"); + duplicate.targets = vec![ + write_target("main"), + ticket_target("main", Some("docs"), TicketTargetAccess::ReadOnly), + ]; + assert!(matches!( + backend.create(duplicate), + Err(TicketError::DuplicateTargetRepository(repository)) if repository == "main" + )); + + for (name, targets, expected_count) in [ + ( + "No write target", + vec![ticket_target("docs", None, TicketTargetAccess::ReadOnly)], + 0, + ), + ( + "Multiple write targets", + vec![write_target("main"), write_target("docs")], + 2, + ), + ] { + let mut input = NewTicket::new(name); + input.targets = targets; + let ticket = backend.create(input).unwrap(); + let error = backend + .mark_ready( + TicketIdOrSlug::Id(ticket.id.clone()), + TicketMarkReady { + operation_key: format!("ready-{expected_count}"), + reason: None, + author: None, + intake_summary: None, + }, + ) + .unwrap_err(); + assert!(matches!( + error, + TicketError::InvalidReadWriteTargetCount(count) if count == expected_count + )); + let unchanged = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap(); + assert_eq!(unchanged.meta.workflow_state, TicketWorkflowState::Planning); + assert!( + unchanged + .events + .iter() + .all(|event| !event.attributes.contains_key("operation_key")) + ); + } + } + + #[test] + fn targets_are_locked_after_planning_and_queue_revalidates_every_target() { + let tmp = TempDir::new().unwrap(); + let backend = backend(&tmp); + let mut ready_input = NewTicket::new("Ready with unknown reference target"); + ready_input.workflow_state = Some(TicketWorkflowState::Ready); + ready_input.targets = vec![ + ticket_target("main", Some("develop"), TicketTargetAccess::ReadWrite), + ticket_target("unknown", Some("main"), TicketTargetAccess::ReadOnly), + ]; + let ticket = backend.create(ready_input).unwrap(); + + let edit_error = backend + .edit_item( + TicketIdOrSlug::Id(ticket.id.clone()), + TicketItemEdit { + targets: Some(TicketTargetsEdit::Clear), + ..Default::default() + }, + ) + .unwrap_err(); + assert!(edit_error.to_string().contains("locked after planning")); + + let queue_error = backend + .queue_ready(TicketIdOrSlug::Id(ticket.id.clone()), "orchestrator") + .unwrap_err(); + assert!(matches!( + queue_error, + TicketError::UnknownTargetRepository(repository) if repository == "unknown" + )); + let unchanged = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap(); + assert_eq!(unchanged.meta.workflow_state, TicketWorkflowState::Ready); + assert!(unchanged.meta.queued_at.is_none()); + } + #[test] fn close_sets_state_closed() { let tmp = TempDir::new().unwrap(); @@ -5794,6 +6165,7 @@ mod tests { let backend = backend(&tmp); let mut waiter_input = NewTicket::new("Ready After Queued Dependency"); waiter_input.workflow_state = Some(TicketWorkflowState::Ready); + waiter_input.targets = vec![write_target("main")]; let waiter = backend.create(waiter_input).unwrap(); let mut dependency_input = NewTicket::new("Queued Dependency"); dependency_input.workflow_state = Some(TicketWorkflowState::Queued); @@ -5819,6 +6191,7 @@ mod tests { let mut incoming_input = NewTicket::new("Ready After Inprogress Blocker"); incoming_input.workflow_state = Some(TicketWorkflowState::Ready); + incoming_input.targets = vec![write_target("main")]; let incoming = backend.create(incoming_input).unwrap(); let mut blocker_input = NewTicket::new("Inprogress Blocker"); blocker_input.workflow_state = Some(TicketWorkflowState::InProgress); diff --git a/crates/ticket/src/sqlite_schema.rs b/crates/ticket/src/sqlite_schema.rs index d95e5cc15..6b8f3c990 100644 --- a/crates/ticket/src/sqlite_schema.rs +++ b/crates/ticket/src/sqlite_schema.rs @@ -7,7 +7,9 @@ use crate::{Result, TicketError, sqlite_err}; const MIGRATION_TABLE: &str = "ticket_schema_migrations"; const MAX_SCHEMA_DIAGNOSTICS: usize = 32; -const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 6; +const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 7; +const PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION: i64 = 6; +const BASELINE_MIGRATION_NAME: &str = "ticket schema baseline"; #[derive(Clone, Copy)] struct Migration { @@ -18,7 +20,7 @@ struct Migration { const MIGRATIONS: &[Migration] = &[Migration { version: LATEST_SQLITE_TICKET_SCHEMA_VERSION, - name: "ticket schema baseline", + name: BASELINE_MIGRATION_NAME, apply: create_latest_ticket_schema, }]; @@ -40,6 +42,7 @@ struct ExpectedForeignKey { const OWNED_TABLES: &[&str] = &[ "typed_tickets", + "typed_ticket_targets", "typed_ticket_labels", "typed_ticket_risk_flags", "typed_ticket_raw_frontmatter", @@ -91,8 +94,15 @@ const TICKET_COLUMNS: &[ExpectedColumn] = &[ column("queued_by", "TEXT", false, 0), column("queued_at", "TEXT", false, 0), column("resolution", "TEXT", false, 0), - column("repository_id", "TEXT", false, 0), +]; + +const TARGET_COLUMNS: &[ExpectedColumn] = &[ + column("workspace_id", "TEXT", true, 1), + column("ticket_id", "TEXT", true, 2), + column("ordinal", "INTEGER", true, 3), + column("repository_key", "TEXT", true, 0), column("ref_selector", "TEXT", false, 0), + column("access", "TEXT", true, 0), ]; const LABEL_COLUMNS: &[ExpectedColumn] = &[ @@ -273,6 +283,30 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> { ], ) .map_err(sqlite_err)?; + } else if applied + == BTreeMap::from([( + PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION, + BASELINE_MIGRATION_NAME.to_string(), + )]) + { + migrate_v6_to_v7(connection)?; + connection + .execute( + "DELETE FROM ticket_schema_migrations WHERE version = ?1", + params![PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION], + ) + .map_err(sqlite_err)?; + connection + .execute( + "INSERT INTO ticket_schema_migrations (version, name, applied_at) + VALUES (?1, ?2, ?3)", + params![ + LATEST_SQLITE_TICKET_SCHEMA_VERSION, + BASELINE_MIGRATION_NAME, + chrono::Utc::now().to_rfc3339() + ], + ) + .map_err(sqlite_err)?; } else { validate_applied_migrations(&applied)?; } @@ -321,6 +355,7 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> { for (table, columns, foreign_keys) in [ ("typed_tickets", TICKET_COLUMNS, TICKET_FOREIGN_KEYS), + ("typed_ticket_targets", TARGET_COLUMNS, CHILD_FOREIGN_KEYS), ("typed_ticket_labels", LABEL_COLUMNS, CHILD_FOREIGN_KEYS), ( "typed_ticket_risk_flags", @@ -362,6 +397,14 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> { collect_table_diagnostics(connection, table, columns, foreign_keys, &mut diagnostics); } + collect_index_diagnostics( + connection, + "typed_ticket_targets", + None, + true, + &["workspace_id", "ticket_id", "repository_key"], + &mut diagnostics, + ); collect_column_diagnostics( connection, "workspace_resource_keys", @@ -431,6 +474,144 @@ fn create_latest_ticket_schema(connection: &Connection) -> Result<()> { .map_err(sqlite_err) } +fn migrate_v6_to_v7(connection: &Connection) -> Result<()> { + connection + .execute_batch( + "CREATE TABLE typed_ticket_targets ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + ordinal INTEGER NOT NULL, + repository_key TEXT NOT NULL, + ref_selector TEXT, + access TEXT NOT NULL CHECK (access IN ('read_only', 'read_write')), + PRIMARY KEY (workspace_id, ticket_id, ordinal), + UNIQUE (workspace_id, ticket_id, repository_key), + FOREIGN KEY (workspace_id, ticket_id) + REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE + );", + ) + .map_err(sqlite_err)?; + + let legacy_targets = { + let mut statement = connection + .prepare( + "SELECT workspace_id, ticket_id, repository_id, ref_selector + FROM typed_tickets + WHERE repository_id IS NOT NULL OR ref_selector IS NOT NULL + ORDER BY workspace_id, ticket_id", + ) + .map_err(sqlite_err)?; + let rows = statement + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + )) + }) + .map_err(sqlite_err)?; + rows.collect::, _>>() + .map_err(sqlite_err)? + }; + for (workspace_id, ticket_id, repository_key, ref_selector) in legacy_targets { + let repository_key = repository_key.ok_or_else(|| { + TicketError::Sqlite(format!( + "legacy Ticket {ticket_id:?} in Workspace {workspace_id:?} has ref_selector without repository_id" + )) + })?; + connection + .execute( + "INSERT INTO typed_ticket_targets + (workspace_id, ticket_id, ordinal, repository_key, ref_selector, access) + VALUES (?1, ?2, 0, ?3, ?4, 'read_write')", + params![workspace_id, ticket_id, repository_key, ref_selector], + ) + .map_err(sqlite_err)?; + } + + let legacy_event_targets = { + let mut statement = connection + .prepare( + "SELECT repository.workspace_id, repository.ticket_id, repository.event_index, + repository.value, selector.value + FROM typed_ticket_event_attributes AS repository + JOIN typed_ticket_event_attributes AS selector + ON selector.workspace_id = repository.workspace_id + AND selector.ticket_id = repository.ticket_id + AND selector.event_index = repository.event_index + AND selector.key = 'ref_selector' + WHERE repository.key = 'repository_id' + ORDER BY repository.workspace_id, repository.ticket_id, repository.event_index", + ) + .map_err(sqlite_err)?; + let rows = statement + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + )) + }) + .map_err(sqlite_err)?; + rows.collect::, _>>() + .map_err(sqlite_err)? + }; + for (workspace_id, ticket_id, event_index, repository_key, ref_selector) in legacy_event_targets + { + let targets = serde_json::to_string(&[serde_json::json!({ + "repository_key": repository_key, + "ref_selector": ref_selector, + "access": "read_write", + })]) + .map_err(sqlite_err)?; + connection + .execute( + "INSERT OR IGNORE INTO typed_ticket_event_attributes + (workspace_id, ticket_id, event_index, key, value) + VALUES (?1, ?2, ?3, 'targets', ?4)", + params![workspace_id, ticket_id, event_index, targets], + ) + .map_err(sqlite_err)?; + connection + .execute( + "INSERT OR IGNORE INTO typed_ticket_event_attributes + (workspace_id, ticket_id, event_index, key, value) + SELECT ?1, ?2, ?3, 'fingerprint_version', '1' + WHERE EXISTS ( + SELECT 1 FROM typed_ticket_event_attributes + WHERE workspace_id = ?1 AND ticket_id = ?2 AND event_index = ?3 + AND key = 'request_fingerprint' + )", + params![workspace_id, ticket_id, event_index], + ) + .map_err(sqlite_err)?; + } + connection + .execute( + "DELETE FROM typed_ticket_event_attributes AS legacy + WHERE legacy.key IN ('repository_id', 'ref_selector') + AND EXISTS ( + SELECT 1 FROM typed_ticket_event_attributes AS canonical + WHERE canonical.workspace_id = legacy.workspace_id + AND canonical.ticket_id = legacy.ticket_id + AND canonical.event_index = legacy.event_index + AND canonical.key = 'targets' + )", + [], + ) + .map_err(sqlite_err)?; + + connection + .execute_batch( + "ALTER TABLE typed_tickets DROP COLUMN repository_id; + ALTER TABLE typed_tickets DROP COLUMN ref_selector;", + ) + .map_err(sqlite_err) +} + fn load_applied_migrations(connection: &Connection) -> Result> { let mut statement = connection .prepare("SELECT version, name FROM ticket_schema_migrations ORDER BY version") @@ -842,6 +1023,124 @@ mod tests { assert_eq!(load_applied_migrations(&connection).unwrap(), versions); } + #[test] + fn migrates_single_target_rows_to_ordered_read_write_targets() { + let connection = Connection::open_in_memory().unwrap(); + create_latest_ticket_schema(&connection).unwrap(); + connection + .execute_batch( + "DROP TABLE typed_ticket_targets; + ALTER TABLE typed_tickets ADD COLUMN repository_id TEXT; + ALTER TABLE typed_tickets ADD COLUMN ref_selector TEXT; + CREATE TABLE ticket_schema_migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL + ); + INSERT INTO ticket_schema_migrations (version, name, applied_at) + VALUES (6, 'ticket schema baseline', '2026-09-23T00:00:00Z'); + INSERT INTO typed_tickets ( + workspace_id, ticket_id, slug, title, status, kind, priority, body, + workflow_state, workflow_state_explicit, repository_id, ref_selector + ) VALUES ( + 'workspace-1', 'ticket-1', 'ticket-1', 'Migrated', 'open', 'task', 'P2', + 'body', 'ready', 1, 'main', 'develop' + ); + INSERT INTO typed_ticket_events ( + workspace_id, ticket_id, event_index, kind, body + ) VALUES ( + 'workspace-1', 'ticket-1', 0, 'state_changed', 'ready' + ); + INSERT INTO typed_ticket_event_attributes ( + workspace_id, ticket_id, event_index, key, value + ) VALUES + ('workspace-1', 'ticket-1', 0, 'repository_id', 'main'), + ('workspace-1', 'ticket-1', 0, 'ref_selector', 'develop'), + ('workspace-1', 'ticket-1', 0, 'request_fingerprint', 'legacy');", + ) + .unwrap(); + + migrate_sqlite_ticket_schema(&connection).unwrap(); + verify_sqlite_ticket_schema(&connection).unwrap(); + + let target = connection + .query_row( + "SELECT ordinal, repository_key, ref_selector, access + FROM typed_ticket_targets + WHERE workspace_id = 'workspace-1' AND ticket_id = 'ticket-1'", + [], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, String>(3)?, + )) + }, + ) + .unwrap(); + assert_eq!( + target, + ( + 0, + "main".to_owned(), + Some("develop".to_owned()), + "read_write".to_owned() + ) + ); + let columns = load_columns(&connection, "typed_tickets").unwrap(); + assert!( + columns + .iter() + .all(|column| { column.name != "repository_id" && column.name != "ref_selector" }) + ); + let encoded_targets: String = connection + .query_row( + "SELECT value FROM typed_ticket_event_attributes + WHERE workspace_id = 'workspace-1' AND ticket_id = 'ticket-1' + AND event_index = 0 AND key = 'targets'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + serde_json::from_str::(&encoded_targets).unwrap(), + serde_json::json!([{ + "repository_key": "main", + "ref_selector": "develop", + "access": "read_write" + }]) + ); + let migrated_attributes = { + let mut statement = connection + .prepare( + "SELECT key, value FROM typed_ticket_event_attributes + WHERE workspace_id = 'workspace-1' AND ticket_id = 'ticket-1' + AND event_index = 0 ORDER BY key", + ) + .unwrap(); + statement + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .unwrap() + .collect::, _>>() + .unwrap() + }; + assert_eq!( + migrated_attributes + .get("fingerprint_version") + .map(String::as_str), + Some("1") + ); + assert!(!migrated_attributes.contains_key("repository_id")); + assert!(!migrated_attributes.contains_key("ref_selector")); + assert_eq!( + load_applied_migrations(&connection).unwrap(), + BTreeMap::from([(7, BASELINE_MIGRATION_NAME.to_owned())]) + ); + } + #[test] fn rejects_unknown_future_migration_history_without_changing_schema() { let connection = Connection::open_in_memory().unwrap(); @@ -856,7 +1155,7 @@ mod tests { let error = migrate_sqlite_ticket_schema(&connection).unwrap_err(); assert!(error.to_string().contains( - "migration history must contain only the canonical version 6 baseline marker" + "migration history must contain only the canonical version 7 baseline marker" )); assert_eq!(load_applied_migrations(&connection).unwrap().len(), 2); } @@ -878,7 +1177,7 @@ mod tests { let error = migrate_sqlite_ticket_schema(&connection).unwrap_err(); assert!(error.to_string().contains( - "migration history must contain only the canonical version 6 baseline marker" + "migration history must contain only the canonical version 7 baseline marker" )); assert!(!table_exists(&connection, "typed_tickets").unwrap()); } diff --git a/crates/ticket/src/tool.rs b/crates/ticket/src/tool.rs index 5acbec2ee..d04b6d97f 100644 --- a/crates/ticket/src/tool.rs +++ b/crates/ticket/src/tool.rs @@ -134,11 +134,11 @@ const DECISION_DESCRIPTION: &str = "Append a typed Ticket decision event. `body` const IMPLEMENTATION_REPORT_DESCRIPTION: &str = "Append a typed Ticket implementation_report event. `body` is Markdown."; const MARK_READY_DESCRIPTION: &str = "Mark a planning Ticket ready through the typed Ticket backend. \ -The backend atomically validates and normalizes the persisted repository/ref target, records one typed \ -state_changed event, and transitions planning -> ready. `reason` is optional."; +The backend atomically validates and normalizes every persisted repository target, requires exactly one \ +read_write target, records one typed state_changed event, and transitions planning -> ready. `reason` is optional."; const INTAKE_READY_DESCRIPTION: &str = "Record a bounded intake summary and mark a planning Ticket ready. \ -The backend applies the same target validation and lock as TicketMarkReady and commits the summary, \ -state_changed event, effective target, and planning -> ready transition atomically."; +The backend applies the same targets validation and lock as TicketMarkReady and commits the summary, \ +state_changed event, effective targets, and planning -> ready transition atomically."; const QUEUE_DESCRIPTION: &str = "Queue a ready Ticket for Orchestrator routing through the typed \ Ticket backend. The backend rejects transitive planning dependencies and cycles, atomically queues the \ requested Ticket plus every transitive ready dependency, and leaves queued or in-progress dependencies unchanged."; @@ -397,12 +397,9 @@ struct TicketCreateParams { /// Optional queued_at frontmatter value. #[serde(default)] queued_at: Option, - /// Optional target Workspace repository id. + /// Ordered implementation targets. Selectors may be omitted while planning. #[serde(default)] - repository_key: Option, - /// Optional target Git ref selector. Requires `repository_key`. - #[serde(default)] - ref_selector: Option, + targets: Vec, } #[derive(Debug, Deserialize, schemars::JsonSchema)] @@ -424,9 +421,9 @@ struct TicketEditItemParams { /// Replace every occurrence of `old_string`; by default exactly one occurrence is required. #[serde(default)] replace_all: bool, - /// Optional target repository/ref update. + /// Optional ordered targets replacement or clear operation. #[serde(default)] - target: Option, + targets: Option, } #[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)] @@ -941,8 +938,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_key; - input.ref_selector = params.ref_selector; + input.targets = params.targets; let created = self .backend @@ -986,7 +982,7 @@ impl Tool for TicketEditItemTool { title: params.title, body: params.body.map(MarkdownText::new), body_replacement, - target: params.target, + targets: params.targets, author: None, }; let ticket = self @@ -1170,8 +1166,7 @@ impl Tool for TicketMarkReadyTool { json!({ "ticket": ticket.meta.id, "state": ticket.meta.workflow_state.as_str(), - "repository_key": ticket.meta.repository_id, - "ref_selector": ticket.meta.ref_selector, + "targets": ticket.meta.targets, "ok": true }), )) @@ -1203,8 +1198,7 @@ impl Tool for TicketIntakeReadyTool { json!({ "ticket": ticket.meta.id, "state": ticket.meta.workflow_state.as_str(), - "repository_key": ticket.meta.repository_id, - "ref_selector": ticket.meta.ref_selector, + "targets": ticket.meta.targets, "ok": true }), )) @@ -1719,6 +1713,7 @@ fn ticket_json( "risk_flags": ticket.meta.risk_flags, "queued_by": ticket.meta.queued_by, "queued_at": ticket.meta.queued_at, + "targets": ticket.meta.targets, }, "body": truncate_text(ticket.document.body.as_str(), body_max_bytes), "events": { @@ -1937,16 +1932,25 @@ mod tests { fn resolve_target( &self, _workspace_id: &str, - repository_key: Option<&str>, + repository_key: &str, ref_selector: Option<&str>, ) -> crate::Result { Ok(crate::ResolvedTicketTarget { - repository_id: repository_key.unwrap_or("main").to_owned(), + repository_key: repository_key.to_owned(), ref_selector: ref_selector.unwrap_or("develop").to_owned(), + access: crate::TicketTargetAccess::ReadOnly, }) } } + fn write_target(repository_key: &str) -> crate::TicketTarget { + crate::TicketTarget { + repository_key: repository_key.to_owned(), + ref_selector: None, + access: crate::TicketTargetAccess::ReadWrite, + } + } + fn backend(temp: &TempDir) -> crate::SqliteTicketBackend { sqlite_backend(temp) } @@ -2076,7 +2080,18 @@ mod tests { .execute( &json!({ "title": "Tool Created", - "body": "## Background\n\nCreated by tool.\n" + "body": "## Background\n\nCreated by tool.\n", + "targets": [ + { + "repository_key": "main", + "ref_selector": "develop", + "access": "read_write" + }, + { + "repository_key": "docs", + "access": "read_only" + } + ] }) .to_string(), Default::default(), @@ -2114,6 +2129,11 @@ mod tests { .unwrap(); assert!(shown.summary.contains(&id)); let shown_content = shown.content.unwrap(); + let shown_json: Value = serde_json::from_str(&shown_content).unwrap(); + assert_eq!(shown_json["meta"]["targets"][0]["repository_key"], "main"); + assert_eq!(shown_json["meta"]["targets"][0]["access"], "read_write"); + assert_eq!(shown_json["meta"]["targets"][1]["repository_key"], "docs"); + assert_eq!(shown_json["meta"]["targets"][1]["access"], "read_only"); assert!(shown_content.contains("Created by tool")); assert!(!shown_content.contains("legacy_ticket")); assert!(!shown_content.contains("needs_preflight")); @@ -2574,10 +2594,10 @@ mod tests { let temp = TempDir::new().unwrap(); let inner = sqlite_backend(&temp); let mut dependency_input = NewTicket::new("Dependency"); - dependency_input.repository_id = Some("main".to_string()); + dependency_input.targets = vec![write_target("main")]; let dependency = inner.create(dependency_input).unwrap(); let mut target_input = NewTicket::new("Target"); - target_input.repository_id = Some("main".to_string()); + target_input.targets = vec![write_target("main")]; let target = inner.create(target_input).unwrap(); inner .add_ticket_relation( @@ -2669,7 +2689,7 @@ mod tests { let temp = TempDir::new().unwrap(); let backend = backend(&temp); let mut input = NewTicket::new("Workflow Tool"); - input.repository_id = Some("main".to_owned()); + input.targets = vec![write_target("main")]; let created = backend.create(input).unwrap(); let intake_ready = tool_by_name(backend.clone(), "TicketMarkReady"); let workflow = tool_by_name(backend.clone(), "TicketWorkflowState"); @@ -2752,7 +2772,7 @@ mod tests { let temp = TempDir::new().unwrap(); let backend = backend(&temp); let mut input = NewTicket::new("Intake Workflow"); - input.repository_id = Some("main".to_owned()); + input.targets = vec![write_target("main")]; let created = backend.create(input).unwrap(); tool_by_name(backend.clone(), "TicketIntakeReady") .execute( @@ -2768,7 +2788,10 @@ mod tests { .unwrap(); let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap(); assert_eq!(record.meta.workflow_state, TicketWorkflowState::Ready); - assert_eq!(record.meta.ref_selector.as_deref(), Some("develop")); + assert_eq!( + record.meta.targets[0].ref_selector.as_deref(), + Some("develop") + ); assert_eq!( record .events @@ -3088,6 +3111,15 @@ mod tests { assert!(!create_schema.contains("needs_preflight")); assert!(!create_schema.contains("action_required")); assert!(!create_schema.contains("attention_required")); + let create_properties = input_schema("TicketCreate")["properties"] + .as_object() + .unwrap() + .clone(); + assert!(create_properties.contains_key("targets")); + assert!(!create_properties.contains_key("repository_key")); + assert!(!create_properties.contains_key("ref_selector")); + assert!(create_schema.contains("read_only")); + assert!(create_schema.contains("read_write")); let plan_record_schema = tools .iter() .map(|definition| definition().0) @@ -3115,6 +3147,13 @@ mod tests { assert!(edit_schema.contains("old_string")); assert!(edit_schema.contains("new_string")); assert!(edit_schema.contains("replace_all")); + assert!(edit_schema.contains("targets")); + assert!( + !input_schema("TicketEditItem")["properties"] + .as_object() + .unwrap() + .contains_key("target") + ); for name in [ "TicketCreate", "TicketEditItem", diff --git a/crates/workdir/src/lib.rs b/crates/workdir/src/lib.rs index e162b8d9e..62b613d45 100644 --- a/crates/workdir/src/lib.rs +++ b/crates/workdir/src/lib.rs @@ -140,6 +140,12 @@ impl WorkdirSessionCapabilities { } } + pub const fn intersection(self, other: Self) -> Self { + Self { + bits: self.bits & other.bits, + } + } + pub const fn supports(self, capability: WorkdirSessionCapability) -> bool { let bit = match capability { WorkdirSessionCapability::Read => Self::READ, diff --git a/crates/workdir/src/scope.rs b/crates/workdir/src/scope.rs index fb5aabe74..570f1e9e1 100644 --- a/crates/workdir/src/scope.rs +++ b/crates/workdir/src/scope.rs @@ -84,6 +84,16 @@ impl WorkdirToolBroker { /// Own the parent Worker's active session and mediate every scoped child operation. pub fn new(source: WorkdirSessionHandle) -> Self { let capabilities = source.capabilities(); + Self::with_capabilities(source, capabilities) + } + + /// Own a session behind an additional Backend-authored capability ceiling. + /// The ceiling can only attenuate provider capabilities; it can never add one. + pub fn with_capabilities( + source: WorkdirSessionHandle, + capabilities: WorkdirSessionCapabilities, + ) -> Self { + let capabilities = source.capabilities().intersection(capabilities); let (command_events, _) = broadcast::channel(64); let authority = Arc::new(ScopedWorkdirSession { source, diff --git a/crates/worker-runtime/src/catalog.rs b/crates/worker-runtime/src/catalog.rs index 4970b1a84..40bb79f65 100644 --- a/crates/worker-runtime/src/catalog.rs +++ b/crates/worker-runtime/src/catalog.rs @@ -209,7 +209,9 @@ pub struct LogicalWorkdirAttachment { } fn default_workdir_session_capabilities() -> workdir::WorkdirSessionCapabilities { - workdir::WorkdirSessionCapabilities::ALL + // Capability-less wire and persisted records fail closed instead of + // silently regaining write or command authority. + workdir::WorkdirSessionCapabilities::READ_ONLY } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -218,6 +220,9 @@ pub struct WorkingDirectoryAttachmentClaim { pub working_directory_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub relative_cwd: Option, + /// Backend-authoritative maximum capabilities for the attachment session. + #[serde(default = "default_workdir_session_capabilities")] + pub capabilities: workdir::WorkdirSessionCapabilities, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/worker-runtime/src/fs_store.rs b/crates/worker-runtime/src/fs_store.rs index b97101201..7d783f757 100644 --- a/crates/worker-runtime/src/fs_store.rs +++ b/crates/worker-runtime/src/fs_store.rs @@ -1801,6 +1801,11 @@ impl<'de> Deserialize<'de> for WorkerExecutionRecord { alias, working_directory_id: claim.working_directory_id, relative_cwd: claim.relative_cwd, + // The removed singular field represented a full local + // Workdir binding. Preserve that known legacy contract + // explicitly; missing capabilities on the current claim + // shape default to read-only instead. + capabilities: workdir::WorkdirSessionCapabilities::ALL, }); } } @@ -2474,10 +2479,41 @@ mod tests { assert_eq!(attachment.alias.as_str(), "workdir"); assert_eq!(attachment.working_directory_id, "wd-legacy"); assert_eq!(attachment.relative_cwd.as_deref(), Some("crates/yoi")); + assert_eq!( + attachment.capabilities, + workdir::WorkdirSessionCapabilities::ALL + ); let serialized = serde_json::to_value(execution).unwrap(); assert!(serialized["request"].get("working_directory").is_none()); } + #[test] + fn current_attachment_claim_without_capabilities_defaults_to_read_only() { + let worker_id = WorkerId::now_v7(); + let mut request = schema_v7_worker_document(worker_id)["request"].clone(); + request["workdir_attachments"] = serde_json::json!([{ + "alias": "docs", + "working_directory_id": "wd-docs" + }]); + let execution: WorkerExecutionRecord = serde_json::from_value(serde_json::json!({ + "schema_version": SCHEMA_VERSION, + "request": request, + "binding": {}, + "restore_intent": "automatic" + })) + .unwrap(); + + assert_eq!( + execution.request.workdir_attachments[0].capabilities, + workdir::WorkdirSessionCapabilities::READ_ONLY + ); + let serialized = serde_json::to_value(execution).unwrap(); + assert_eq!( + serialized["request"]["workdir_attachments"][0]["capabilities"], + serde_json::to_value(workdir::WorkdirSessionCapabilities::READ_ONLY).unwrap() + ); + } + #[test] fn worker_identity_round_trips_logical_workdir_attachments() { let worker_id = WorkerId::now_v7(); @@ -2512,7 +2548,7 @@ mod tests { [LogicalWorkdirAttachment { alias: workdir::WorkdirAttachmentAlias::new("checkout").unwrap(), working_directory_id: "remote-workdir".to_string(), - capabilities: workdir::WorkdirSessionCapabilities::ALL, + capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY, }] ); } diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index 87c61f140..8fbca772f 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -2215,8 +2215,13 @@ impl Runtime { let _ = worker.apply_worker_state(snapshot); } worker.restore_intent = restore_intent_for_status(worker.status); + let claims = worker + .request + .as_ref() + .map(|request| request.workdir_attachments.as_slice()) + .unwrap_or_default(); worker.logical_workdir_attachments = - logical_workdir_attachments_from_statuses(&workdir_attachments); + logical_workdir_attachments_from_statuses(&workdir_attachments, claims); worker.workdir_attachments = workdir_attachments; worker.detail() }; @@ -2867,8 +2872,13 @@ impl Runtime { candidate.execution_bound = true; candidate.status = WorkerStatus::Idle; candidate.restore_intent = WorkerRestoreIntent::Explicit; + let claims = candidate + .request + .as_ref() + .map(|request| request.workdir_attachments.as_slice()) + .unwrap_or_default(); candidate.logical_workdir_attachments = - logical_workdir_attachments_from_statuses(&workdir_attachments); + logical_workdir_attachments_from_statuses(&workdir_attachments, claims); candidate.workdir_attachments = workdir_attachments; state.workers.insert(worker_ref.worker_id, candidate); state.publish_worker_upsert(worker_ref.worker_id)?; @@ -3213,7 +3223,11 @@ impl RuntimeState { } }; let logical_workdir_attachments = if worker.logical_workdir_attachments.is_empty() { - logical_workdir_attachments_from_statuses(&worker.workdir_attachments) + let claims = request + .as_ref() + .map(|request| request.workdir_attachments.as_slice()) + .unwrap_or_default(); + logical_workdir_attachments_from_statuses(&worker.workdir_attachments, claims) } else { worker.logical_workdir_attachments }; @@ -4283,17 +4297,29 @@ fn requested_workdir_ids(request: &CreateWorkerRequest) -> Vec<&str> { fn logical_workdir_attachments_from_statuses( attachments: &[crate::catalog::WorkingDirectoryAttachmentStatus], + claims: &[crate::catalog::WorkingDirectoryAttachmentClaim], ) -> Vec { attachments .iter() - .map(|attachment| LogicalWorkdirAttachment { - alias: attachment.alias.clone(), - working_directory_id: attachment - .working_directory - .summary - .working_directory_id - .clone(), - capabilities: workdir::WorkdirSessionCapabilities::ALL, + .map(|attachment| { + let working_directory_id = &attachment.working_directory.summary.working_directory_id; + let capabilities = match claims.iter().find(|claim| claim.alias == attachment.alias) { + Some(claim) if claim.working_directory_id == *working_directory_id => { + claim.capabilities + } + // An alias/id mismatch is invalid at the operation boundary. If + // corrupted persisted state reaches this compatibility path, + // fail closed rather than silently restoring write authority. + Some(_) => workdir::WorkdirSessionCapabilities::READ_ONLY, + // Runtime-materialized attachment requests predate capability + // claims and retain their existing full local-session behavior. + None => workdir::WorkdirSessionCapabilities::ALL, + }; + LogicalWorkdirAttachment { + alias: attachment.alias.clone(), + working_directory_id: working_directory_id.clone(), + capabilities, + } }) .collect() } @@ -6584,6 +6610,7 @@ mod tests { alias: workdir::WorkdirAttachmentAlias::new("workdir").unwrap(), working_directory_id: "workdir-idempotent".to_string(), relative_cwd: None, + capabilities: workdir::WorkdirSessionCapabilities::ALL, }]; let first = runtime.create_worker(request.clone()).unwrap(); @@ -6974,7 +7001,13 @@ mod tests { .unwrap(), }], }); - let request = task_request("uncertain create cleanup"); + let mut request = task_request("uncertain create cleanup"); + request.workdir_attachments = vec![WorkingDirectoryAttachmentClaim { + alias: workdir::WorkdirAttachmentAlias::new("checkout").unwrap(), + working_directory_id: "wd-uncertain".to_string(), + relative_cwd: None, + capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY, + }]; let worker_ref = WorkerRef::new(request.worker_id); let error = runtime.create_worker(request).unwrap_err(); @@ -6986,6 +7019,16 @@ mod tests { let retained = runtime.worker_detail(&worker_ref).unwrap(); assert_eq!(retained.status, WorkerStatus::Idle); assert!(!retained.execution_metadata_available); + assert_eq!( + runtime + .lock() + .unwrap() + .worker(&worker_ref) + .unwrap() + .logical_workdir_attachments[0] + .capabilities, + workdir::WorkdirSessionCapabilities::READ_ONLY + ); assert!(matches!( runtime.delete_worker(&worker_ref), Err(RuntimeError::InvalidRequest(_)) diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index 69483ae82..3aff8ed61 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -755,11 +755,25 @@ async fn fetch_workspace_config_http( Err("Workspace Config fetch requires the worker-runtime http-server feature".to_string()) } +fn initial_workdir_session_capabilities( + alias: &WorkdirAttachmentAlias, + claims: &[crate::catalog::WorkingDirectoryAttachmentClaim], +) -> WorkdirSessionCapabilities { + claims + .iter() + .find(|claim| &claim.alias == alias) + .map(|claim| claim.capabilities) + // New Runtime materialization requests have no claim and retain their + // established full local-session behavior. + .unwrap_or(WorkdirSessionCapabilities::ALL) +} + fn runtime_local_workdir_session( workdir_id: &str, root: &Path, cwd: &Path, scope: manifest::SharedScope, + capabilities: WorkdirSessionCapabilities, command_environment: std::collections::BTreeMap, resources: Vec>, ) -> WorkdirSessionHandle { @@ -768,7 +782,7 @@ fn runtime_local_workdir_session( root.to_path_buf(), cwd.to_path_buf(), scope, - WorkdirSessionCapabilities::ALL, + capabilities, command_environment, resources, )) @@ -776,6 +790,7 @@ fn runtime_local_workdir_session( fn runtime_local_workdir_router( attachments: &BTreeMap, + capabilities: &BTreeMap, scope: manifest::SharedScope, ) -> Result, String> { let router = Arc::new(WorkdirSessionRouter::new()); @@ -788,6 +803,10 @@ fn runtime_local_workdir_router( binding.root(), binding.cwd(), scope.clone(), + capabilities + .get(alias) + .copied() + .unwrap_or(WorkdirSessionCapabilities::READ_ONLY), binding.command_environment(), binding.session_resources(), ), @@ -800,10 +819,25 @@ fn runtime_local_workdir_router( fn restored_workdir_router( local_attachments: &BTreeMap, logical_attachments: &[crate::catalog::LogicalWorkdirAttachment], + claims: &[crate::catalog::WorkingDirectoryAttachmentClaim], scope: manifest::SharedScope, workspace_client: Arc, ) -> Result, String> { - let router = runtime_local_workdir_router(local_attachments, scope)?; + let mut local_capabilities = local_attachments + .keys() + .map(|alias| { + ( + alias.clone(), + initial_workdir_session_capabilities(alias, claims), + ) + }) + .collect::>(); + for attachment in logical_attachments { + if local_attachments.contains_key(&attachment.alias) { + local_capabilities.insert(attachment.alias.clone(), attachment.capabilities); + } + } + let router = runtime_local_workdir_router(local_attachments, &local_capabilities, scope)?; for attachment in logical_attachments { if let Some(local) = local_attachments.get(&attachment.alias) { if local.working_directory.id != attachment.working_directory_id { @@ -1068,8 +1102,22 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { })?; let worker = prepared.worker_mut(); validate_worker_memory_settings(worker.manifest(), &request.request)?; + let workdir_capabilities = request + .workdir_attachments + .keys() + .map(|alias| { + ( + alias.clone(), + initial_workdir_session_capabilities( + alias, + &request.request.workdir_attachments, + ), + ) + }) + .collect::>(); worker.bind_workdir_sessions(runtime_local_workdir_router( &request.workdir_attachments, + &workdir_capabilities, worker.scope().clone(), )?); if let (Some(runtime_id), Some(workspace_id)) = @@ -1300,6 +1348,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { let workdir_sessions = restored_workdir_router( &request.workdir_attachments, &request.logical_workdir_attachments, + &request.request.workdir_attachments, worker.scope().clone(), worker.workspace_client_handle(), )?; @@ -2177,6 +2226,7 @@ where binding.root(), binding.cwd(), manifest::SharedScope::new(scope), + WorkdirSessionCapabilities::ALL, binding.command_environment(), binding.session_resources(), )) @@ -2979,6 +3029,7 @@ mod tests { working_directory_id: "remote-workdir".to_string(), capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY, }], + &[], scope, client, ) @@ -2992,6 +3043,37 @@ mod tests { ); } + #[test] + fn restored_router_preserves_read_only_capabilities_for_local_attachment() { + let runtime_base = tempfile::tempdir().unwrap(); + let repo = create_clean_repo(); + let materializer = RuntimeGitMaterializer::new(runtime_base.path()); + let worker_ref = WorkerRef::new(crate::identity::WorkerId::now_v7()); + let binding = materializer + .materialize(&worker_ref, &working_directory_request(repo.path())) + .unwrap(); + let alias = WorkdirAttachmentAlias::new("docs").unwrap(); + let workdir_id = binding.working_directory.id.clone(); + let scope = manifest::SharedScope::new(manifest::Scope::writable(binding.root()).unwrap()); + let attachments = BTreeMap::from([(alias.clone(), binding)]); + let logical = [LogicalWorkdirAttachment { + alias: alias.clone(), + working_directory_id: workdir_id, + capabilities: WorkdirSessionCapabilities::READ_ONLY, + }]; + let client = WorkerWorkspaceContext::local_filesystem(Some( + WorkspaceId::new("workspace-a").unwrap(), + )) + .client_handle(); + + let router = restored_workdir_router(&attachments, &logical, &[], scope, client).unwrap(); + + assert_eq!( + router.session(&alias).unwrap().capabilities(), + WorkdirSessionCapabilities::READ_ONLY + ); + } + #[test] fn profile_factory_routes_workspace_requests_by_workspace_id() { let profiles = tempfile::tempdir().unwrap(); @@ -3949,13 +4031,35 @@ mod tests { } #[test] - fn restore_opens_a_fresh_session_for_the_same_workdir_identity() { + fn initial_claim_capabilities_override_full_materialization_default() { + let checkout = WorkdirAttachmentAlias::new("checkout").unwrap(); + let docs = WorkdirAttachmentAlias::new("docs").unwrap(); + let claims = [WorkingDirectoryAttachmentClaim { + alias: docs.clone(), + working_directory_id: "workdir-docs".to_string(), + relative_cwd: None, + capabilities: WorkdirSessionCapabilities::READ_ONLY, + }]; + + assert_eq!( + initial_workdir_session_capabilities(&docs, &claims), + WorkdirSessionCapabilities::READ_ONLY + ); + assert_eq!( + initial_workdir_session_capabilities(&checkout, &claims), + WorkdirSessionCapabilities::ALL + ); + } + + #[test] + fn restore_opens_a_fresh_session_with_the_same_read_only_capabilities() { let root = tempfile::tempdir().unwrap(); let spawned = runtime_local_workdir_session( "working-directory-42", root.path(), root.path(), manifest::SharedScope::new(Scope::writable(root.path()).unwrap()), + WorkdirSessionCapabilities::READ_ONLY, Default::default(), Vec::new(), ); @@ -3964,12 +4068,21 @@ mod tests { root.path(), root.path(), manifest::SharedScope::new(Scope::writable(root.path()).unwrap()), + WorkdirSessionCapabilities::READ_ONLY, Default::default(), Vec::new(), ); assert_eq!(spawned.workdir().id().as_str(), "working-directory-42"); assert_eq!(restored.workdir().id().as_str(), "working-directory-42"); + assert_eq!( + spawned.capabilities(), + WorkdirSessionCapabilities::READ_ONLY + ); + assert_eq!( + restored.capabilities(), + WorkdirSessionCapabilities::READ_ONLY + ); assert!(!Arc::ptr_eq(&spawned, &restored)); } @@ -4924,6 +5037,7 @@ mod tests { alias: workdir::WorkdirAttachmentAlias::new("workdir").unwrap(), working_directory_id: workdir_id.clone(), relative_cwd: None, + capabilities: workdir::WorkdirSessionCapabilities::ALL, }]; let error = runtime.create_worker(request).unwrap_err(); diff --git a/crates/worker/src/feature/builtin/manage_worker.rs b/crates/worker/src/feature/builtin/manage_worker.rs index f9cc88202..180fe93d1 100644 --- a/crates/worker/src/feature/builtin/manage_worker.rs +++ b/crates/worker/src/feature/builtin/manage_worker.rs @@ -458,11 +458,21 @@ pub trait WorkerLifecycleService: Send + Sync { ) -> Result; } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkerLifecycleWorkdirAttachment { + /// Stable Worker-local routing alias preserved into the canonical create request. + pub alias: String, + /// Workspace-authoritative Workdir id preserved into the canonical create request. + pub working_directory_id: String, + /// Optional normalized initial cwd relative to this Workdir. + pub relative_cwd: Option, +} + #[derive(Debug, Clone)] pub struct WorkerLifecycleSpawnRequest { pub runtime_id: String, - pub working_directory_id: String, - pub relative_cwd: Option, + /// Ordered alias-keyed selections forwarded as canonical Workdir attachments. + pub workdir_attachments: Vec, pub profile: String, pub ticket_id: Option, pub operation_id: Option, @@ -501,11 +511,15 @@ fn workspace_worker_create_request( profile: Some(request.profile), ticket_assignment, initial_submit: request.initial_submit, - workdir_attachments: vec![BrowserWorkerWorkingDirectorySelection { - alias: INITIAL_WORKDIR_ALIAS.to_string(), - working_directory_id: request.working_directory_id, - relative_cwd: request.relative_cwd, - }], + workdir_attachments: request + .workdir_attachments + .into_iter() + .map(|attachment| BrowserWorkerWorkingDirectorySelection { + alias: attachment.alias, + working_directory_id: attachment.working_directory_id, + relative_cwd: attachment.relative_cwd, + }) + .collect(), control_operation_id: Some(control_operation_id), }) } @@ -698,6 +712,8 @@ struct WorkerListInput {} #[serde(deny_unknown_fields)] struct WorkerSpawnInput { runtime_id: String, + /// Singular Workdir selection wrapped into the shared attachment collection + /// under the stable `workdir` alias. working_directory_id: String, profile: String, /// Optional inprogress Ticket already accepted by the Orchestrator. Set @@ -831,7 +847,7 @@ impl WorkerOperation { "List only known Runtime Workers and direct SubWorkers granted to the current Worker." } Self::Spawn => { - "Spawn a Backend/Runtime Worker session in an existing Workspace Workdir. The Workdir id is authority; filesystem paths and Runtime URLs are not accepted. `initial_submit` carries the normal typed user submission. After the Orchestrator has committed a Ticket to `inprogress`, set `ticket_id` with a Flow segment in `initial_submit` to atomically assign the new Coder Worker; the operation id is derived from the durable tool call rather than model input." + "Spawn a Backend/Runtime Worker session in one existing Workspace Workdir. The model-facing input remains singular and the shared lifecycle wraps it as the canonical attachment collection under the stable `workdir` alias. The Workdir id is authority; filesystem paths and Runtime URLs are not accepted. `initial_submit` carries the normal typed user submission. After the Orchestrator has committed a Ticket to `inprogress`, set `ticket_id` with a Flow segment in `initial_submit` to atomically assign the new Coder Worker; the operation id is derived from the durable tool call rather than model input." } Self::SendInput => "Send user input to a known Runtime Worker when allowed.", Self::Notify => "Send an advisory notification to a known Runtime Worker when allowed.", @@ -878,14 +894,17 @@ impl Tool for WorkspaceWorkerTool { self.control .spawn_worker(WorkerLifecycleSpawnRequest { runtime_id: authority_id(&input.runtime_id, "runtime_id")?, - working_directory_id: authority_id( - &input.working_directory_id, - "working_directory_id", - )?, - relative_cwd: input - .relative_cwd - .map(|value| validate_relative_cwd(&value)) - .transpose()?, + workdir_attachments: vec![WorkerLifecycleWorkdirAttachment { + alias: INITIAL_WORKDIR_ALIAS.to_string(), + working_directory_id: authority_id( + &input.working_directory_id, + "working_directory_id", + )?, + relative_cwd: input + .relative_cwd + .map(|value| validate_relative_cwd(&value)) + .transpose()?, + }], profile: non_empty(input.profile, "profile")?, ticket_id, operation_id, @@ -1390,6 +1409,7 @@ mod tests { &serde_json::json!({ "runtime_id": "runtime-1", "working_directory_id": "workdir-1", + "relative_cwd": "crates/worker", "profile": "builtin:coder", "ticket_id": "00001KZ9E0DBS", "initial_submit": [ @@ -1435,7 +1455,7 @@ mod tests { ); assert_eq!( body["workdir_attachments"][0]["relative_cwd"], - serde_json::Value::Null + "crates/worker" ); assert!(body.get("working_directory").is_none()); assert!(body.get("initial_text").is_none()); @@ -1709,11 +1729,21 @@ mod tests { } #[test] - fn worker_spawn_request_uses_canonical_initial_attachment_contract() { + fn worker_lifecycle_spawn_request_preserves_canonical_attachment_collection() { let request = workspace_worker_create_request(WorkerLifecycleSpawnRequest { runtime_id: "runtime-1".to_string(), - working_directory_id: "wd-1".to_string(), - relative_cwd: Some("repo".to_string()), + workdir_attachments: vec![ + WorkerLifecycleWorkdirAttachment { + alias: "checkout".to_string(), + working_directory_id: "wd-1".to_string(), + relative_cwd: Some("repo".to_string()), + }, + WorkerLifecycleWorkdirAttachment { + alias: "docs".to_string(), + working_directory_id: "wd-2".to_string(), + relative_cwd: None, + }, + ], profile: "builtin:coder".to_string(), ticket_id: None, operation_id: None, @@ -1728,16 +1758,22 @@ mod tests { .unwrap(); let value = serde_json::to_value(request).unwrap(); assert_eq!(value["runtime_id"], "runtime-1"); - assert_eq!(value["workdir_attachments"].as_array().unwrap().len(), 1); - assert_eq!( - value["workdir_attachments"][0]["alias"], - INITIAL_WORKDIR_ALIAS - ); + assert_eq!(value["workdir_attachments"].as_array().unwrap().len(), 2); + assert_eq!(value["workdir_attachments"][0]["alias"], "checkout"); assert_eq!( value["workdir_attachments"][0]["working_directory_id"], "wd-1" ); assert_eq!(value["workdir_attachments"][0]["relative_cwd"], "repo"); + assert_eq!(value["workdir_attachments"][1]["alias"], "docs"); + assert_eq!( + value["workdir_attachments"][1]["working_directory_id"], + "wd-2" + ); + assert_eq!( + value["workdir_attachments"][1]["relative_cwd"], + serde_json::Value::Null + ); assert!(value.get("working_directory").is_none()); assert!(value.get("cwd").is_none()); assert!(value.get("runtime_url").is_none()); diff --git a/crates/worker/src/feature/builtin/orchestration.rs b/crates/worker/src/feature/builtin/orchestration.rs index a16858705..953284271 100644 --- a/crates/worker/src/feature/builtin/orchestration.rs +++ b/crates/worker/src/feature/builtin/orchestration.rs @@ -1,5 +1,6 @@ //! Semantic Ticket orchestration tools backed by Feature Services. +use std::collections::BTreeSet; use std::sync::Arc; use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput}; @@ -10,6 +11,7 @@ use serde::Deserialize; use super::manage_worker::{ WORKER_LIFECYCLE_SERVICE_ID, WorkerLifecycleService, WorkerLifecycleSpawnRequest, + WorkerLifecycleWorkdirAttachment, }; use super::ticket::{TICKET_SERVICE_ID, TicketService}; use crate::feature::{ @@ -43,7 +45,7 @@ impl FeatureModule for OrchestrationFeature { )) .with_tool(ToolDeclaration::new( TOOL_NAME, - "Spawn and atomically assign a Coder Worker for a queued or already-inprogress Ticket. The guarded operation records queued acceptance only after spawn, initial input, assignment, and Workdir finalization. The profile, Flow, display name, assignment operation, and initial message are fixed by orchestration policy.", + "Spawn and atomically assign a Coder Worker for a queued or already-inprogress Ticket. Select every required Workdir by stable alias; Backend authority derives each attachment's access from the Ticket targets. The guarded operation records queued acceptance only after spawn, initial input, assignment, and Workdir finalization. The profile, Flow, display name, assignment operation, and initial message are fixed by orchestration policy.", )) } @@ -64,14 +66,26 @@ impl FeatureModule for OrchestrationFeature { } } +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct SpawnTicketCoderWorkdirInput { + /// Stable Worker-local routing alias (for example `checkout` or `docs`). + alias: String, + /// Workspace-authoritative Workdir id. Paths and URLs are not accepted. + working_directory_id: String, + /// Optional normalized path inside this Workdir for the Worker's initial cwd. + #[serde(default)] + relative_cwd: Option, +} + #[derive(Debug, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] struct SpawnTicketCoderInput { ticket_id: String, runtime_id: String, - working_directory_id: String, - #[serde(default)] - relative_cwd: Option, + /// Every Workdir required by the Ticket target set. Backend authority derives + /// effective read/write capabilities; callers cannot request capabilities. + workdir_attachments: Vec, } struct SpawnTicketCoderTool { @@ -105,16 +119,13 @@ impl Tool for SpawnTicketCoderTool { ))); } let call_id = non_empty(ctx.call_id, "tool call_id")?; - let relative_cwd = input.relative_cwd.map(validate_relative_cwd).transpose()?; + let runtime_id = authority_id(input.runtime_id, "runtime_id")?; + let workdir_attachments = validate_workdir_attachments(input.workdir_attachments)?; let response = self .worker_service .spawn(WorkerLifecycleSpawnRequest { - runtime_id: authority_id(input.runtime_id, "runtime_id")?, - working_directory_id: authority_id( - input.working_directory_id, - "working_directory_id", - )?, - relative_cwd, + runtime_id, + workdir_attachments, profile: CODER_PROFILE.to_string(), ticket_id: Some(ticket.id.clone()), operation_id: Some(format!("spawn-ticket-coder:{}:{call_id}", ticket.id)), @@ -142,6 +153,47 @@ impl Tool for SpawnTicketCoderTool { } } +fn validate_workdir_attachments( + attachments: Vec, +) -> Result, ToolError> { + if attachments.is_empty() { + return Err(ToolError::InvalidArgument( + "workdir_attachments must contain at least one Workdir".to_string(), + )); + } + + let mut aliases = BTreeSet::new(); + let mut workdir_ids = BTreeSet::new(); + attachments + .into_iter() + .map(|attachment| { + let alias = authority_id(attachment.alias, "workdir_attachments.alias")?; + if !aliases.insert(alias.clone()) { + return Err(ToolError::InvalidArgument(format!( + "duplicate Workdir attachment alias `{alias}`" + ))); + } + let working_directory_id = authority_id( + attachment.working_directory_id, + "workdir_attachments.working_directory_id", + )?; + if !workdir_ids.insert(working_directory_id.clone()) { + return Err(ToolError::InvalidArgument(format!( + "duplicate Workdir attachment `{working_directory_id}`" + ))); + } + Ok(WorkerLifecycleWorkdirAttachment { + alias, + working_directory_id, + relative_cwd: attachment + .relative_cwd + .map(validate_relative_cwd) + .transpose()?, + }) + }) + .collect() +} + fn definition( ticket_service: Arc, worker_service: Arc, @@ -150,7 +202,9 @@ fn definition( let schema = serde_json::to_value(schemars::schema_for!(SpawnTicketCoderInput)) .unwrap_or_else(|_| serde_json::json!({})); let meta = ToolMeta::new(TOOL_NAME) - .description("Spawn and atomically assign a policy-configured Coder for a Ticket.") + .description( + "Spawn and atomically assign a policy-configured Coder for a Ticket with every required alias-keyed Workdir attachment.", + ) .input_schema(schema); let tool: Arc = Arc::new(SpawnTicketCoderTool { ticket_service: ticket_service.clone(), @@ -260,8 +314,17 @@ mod tests { &serde_json::json!({ "ticket_id": "T-482", "runtime_id": "runtime-1", - "working_directory_id": "workdir-1", - "relative_cwd": "crates/yoi" + "workdir_attachments": [ + { + "alias": "checkout", + "working_directory_id": "workdir-1", + "relative_cwd": "crates/yoi" + }, + { + "alias": "docs", + "working_directory_id": "workdir-2" + } + ] }) .to_string(), ToolExecutionContext::new("call-7", "batch-1", 0), @@ -278,8 +341,22 @@ mod tests { Some("spawn-ticket-coder:00001KZXN51C7:call-7") ); assert_eq!(request.display_name, "Coder · T-482"); - assert_eq!(request.working_directory_id, "workdir-1"); - assert_eq!(request.relative_cwd.as_deref(), Some("crates/yoi")); + assert_eq!(request.workdir_attachments.len(), 2); + assert_eq!(request.workdir_attachments[0].alias, "checkout"); + assert_eq!( + request.workdir_attachments[0].working_directory_id, + "workdir-1" + ); + assert_eq!( + request.workdir_attachments[0].relative_cwd.as_deref(), + Some("crates/yoi") + ); + assert_eq!(request.workdir_attachments[1].alias, "docs"); + assert_eq!( + request.workdir_attachments[1].working_directory_id, + "workdir-2" + ); + assert_eq!(request.workdir_attachments[1].relative_cwd, None); assert_eq!( request.initial_submit, vec![ @@ -307,7 +384,10 @@ mod tests { &serde_json::json!({ "ticket_id": "00001KZXN51C7", "runtime_id": "runtime-1", - "working_directory_id": "workdir-1" + "workdir_attachments": [{ + "alias": "checkout", + "working_directory_id": "workdir-1" + }] }) .to_string(), ToolExecutionContext::new("call-queued", "batch-1", 0), @@ -318,6 +398,106 @@ mod tests { assert!(worker_service.requests.lock().unwrap().is_empty()); } + #[tokio::test] + async fn spawn_ticket_coder_rejects_invalid_workdir_collections_before_worker_side_effect() { + let worker_service = Arc::new(RecordingService::default()); + let tool = SpawnTicketCoderTool { + ticket_service: Arc::new(FixedTicketService(TicketWorkflowState::Queued)), + worker_service: worker_service.clone(), + }; + let cases = [ + ( + serde_json::json!({ + "ticket_id": "T-482", + "runtime_id": "runtime-1", + "workdir_attachments": [] + }), + "must contain at least one Workdir", + ), + ( + serde_json::json!({ + "ticket_id": "T-482", + "runtime_id": "runtime-1", + "workdir_attachments": [ + {"alias": "checkout", "working_directory_id": "workdir-1"}, + {"alias": " checkout ", "working_directory_id": "workdir-2"} + ] + }), + "duplicate Workdir attachment alias `checkout`", + ), + ( + serde_json::json!({ + "ticket_id": "T-482", + "runtime_id": "runtime-1", + "workdir_attachments": [ + {"alias": "checkout", "working_directory_id": "workdir-1"}, + {"alias": "docs", "working_directory_id": " workdir-1 "} + ] + }), + "duplicate Workdir attachment `workdir-1`", + ), + ( + serde_json::json!({ + "ticket_id": "T-482", + "runtime_id": "runtime-1", + "workdir_attachments": [ + {"alias": "docs/path", "working_directory_id": "workdir-1"} + ] + }), + "workdir_attachments.alias must be an authority id", + ), + ( + serde_json::json!({ + "ticket_id": "T-482", + "runtime_id": "runtime-1", + "workdir_attachments": [ + {"alias": "docs", "working_directory_id": "https://example.test/workdir"} + ] + }), + "workdir_attachments.working_directory_id must be an authority id", + ), + ( + serde_json::json!({ + "ticket_id": "T-482", + "runtime_id": "runtime/1", + "workdir_attachments": [ + {"alias": "checkout", "working_directory_id": "workdir-1"} + ] + }), + "runtime_id must be an authority id", + ), + ( + serde_json::json!({ + "ticket_id": "T-482", + "runtime_id": "runtime-1", + "workdir_attachments": [ + { + "alias": "checkout", + "working_directory_id": "workdir-1", + "relative_cwd": "../outside" + } + ] + }), + "relative_cwd must be a normalized relative path inside the Workdir", + ), + ]; + + for (index, (input, expected)) in cases.into_iter().enumerate() { + let error = tool + .execute( + &input.to_string(), + ToolExecutionContext::new(format!("call-{index}"), "batch-validation", index), + ) + .await + .unwrap_err(); + assert!( + error.to_string().contains(expected), + "unexpected error for case {index}: {error}" + ); + } + assert!(worker_service.requests.lock().unwrap().is_empty()); + } + #[test] fn orchestration_descriptor_requires_ticket_and_worker_services() { let descriptor = orchestration_feature().descriptor(); @@ -341,6 +521,8 @@ mod tests { for field in [ "ticket_id", "runtime_id", + "workdir_attachments", + "alias", "working_directory_id", "relative_cwd", ] { diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index 7d2c370c1..23740b988 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -840,7 +840,24 @@ impl SqliteWorkspaceAuthority { let has_coder = role_assignments .iter() .any(|assignment| assignment.role == TicketAssignmentRole::Coder); - let has_target = ticket.meta.repository_id.is_some() && ticket.meta.ref_selector.is_some(); + let targets = ticket + .meta + .targets + .iter() + .map(|target| { + let repository = self + .store + .get_repository_by_key(&self.workspace_id, &target.repository_key)? + .ok_or_else(|| Error::UnknownRepository(target.repository_key.clone()))?; + Ok::<_, Error>(ticket::TicketTarget { + repository_key: repository.repository_key, + ref_selector: target.ref_selector.clone(), + access: target.access, + }) + }) + .collect::>>()?; + let write_repository_key = ticket_write_target_repository_key(&targets); + let has_target = write_repository_key.is_some(); let has_blockers = !ticket.relations.blockers.is_empty(); let mut queue_assignment_blockers = Vec::new(); for ticket_id in &dependency_check.queue_tickets { @@ -932,22 +949,8 @@ impl SqliteWorkspaceAuthority { 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( - repository_key.as_deref(), - &ticket.events, - merge_request.as_ref(), - ); + let evidence = + ticket_evidence_summary(write_repository_key, &ticket.events, merge_request.as_ref()); let item_revision = ticket .events .iter() @@ -1000,8 +1003,7 @@ impl SqliteWorkspaceAuthority { item_revision, queued_by: ticket.meta.queued_by, queued_at: ticket.meta.queued_at, - repository_key, - ref_selector: ticket.meta.ref_selector, + targets, risk_flags: ticket.meta.risk_flags, body, body_truncated, @@ -1837,7 +1839,7 @@ fn substantive_item_edit(event: &TicketEvent) -> bool { changes .split(',') .map(str::trim) - .any(|field| matches!(field, "title" | "body" | "target")) + .any(|field| matches!(field, "title" | "body" | "target" | "targets")) } fn event_timestamp(event: &TicketEvent) -> Option> { @@ -1848,15 +1850,26 @@ fn event_timestamp(event: &TicketEvent) -> Option> { .map(|value| value.with_timezone(&Utc)) } +fn ticket_write_target_repository_key(targets: &[ticket::TicketTarget]) -> Option<&str> { + let mut write_targets = targets + .iter() + .filter(|target| target.access == ticket::TicketTargetAccess::ReadWrite); + let write_target = write_targets.next()?; + write_targets + .next() + .is_none() + .then_some(write_target.repository_key.as_str()) +} + fn ticket_evidence_summary( - ticket_repository_id: Option<&str>, + ticket_write_repository_key: Option<&str>, events: &[TicketEvent], merge_request: Option<&TicketMergeRequestSummary>, ) -> TicketEvidenceSummary { let linked_merge_request = merge_request.filter(|request| { request.state == "open" - && ticket_repository_id - .is_some_and(|repository_id| repository_id == request.repository_key) + && ticket_write_repository_key + .is_some_and(|repository_key| repository_key == request.repository_key) }); let has_merge_request = linked_merge_request.is_some(); let has_current_subject_ref = linked_merge_request.is_some_and(|request| { @@ -1906,8 +1919,8 @@ fn ticket_evidence_summary( None => missing.push("merge_request".to_string()), 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_key) => + if ticket_write_repository_key + .is_none_or(|repository_key| repository_key != request.repository_key) => { missing.push("merge_request_repository".to_string()) } @@ -2865,6 +2878,54 @@ mod tests { assert_eq!(moved.review_submitted_at, None); } + #[test] + fn ticket_target_projection_requires_exactly_one_write_target() { + let target = |repository_key: &str, access| ticket::TicketTarget { + repository_key: repository_key.to_string(), + ref_selector: Some("develop".to_string()), + access, + }; + + assert_eq!(ticket_write_target_repository_key(&[]), None); + assert_eq!( + ticket_write_target_repository_key(&[target( + "docs", + ticket::TicketTargetAccess::ReadOnly, + )]), + None, + ); + + let one_write = [ + target("main", ticket::TicketTargetAccess::ReadWrite), + target("docs", ticket::TicketTargetAccess::ReadOnly), + ]; + assert_eq!(ticket_write_target_repository_key(&one_write), Some("main"),); + + let two_writes = [ + target("main", ticket::TicketTargetAccess::ReadWrite), + target("docs", ticket::TicketTargetAccess::ReadWrite), + ]; + assert_eq!(ticket_write_target_repository_key(&two_writes), None); + } + + #[test] + fn ticket_evidence_is_tied_only_to_the_write_target() { + let read_only_request = merge_request_summary( + reviewed_merge_request(ReviewDecision::Approve, false), + "docs".to_string(), + Some("commit-1".to_string()), + ); + let evidence = ticket_evidence_summary(Some("main"), &[], Some(&read_only_request)); + + assert!(!evidence.has_merge_request); + assert!(!evidence.complete_for_integration); + assert!( + evidence + .missing + .contains(&"merge_request_repository".to_string()) + ); + } + #[test] fn ticket_readiness_requires_current_unrevoked_approval_without_a_report() { let approved = merge_request_summary( @@ -3013,7 +3074,7 @@ mod tests { let stale_events = vec![ticket_event( "item_edit", "2026-01-01T00:05:00Z", - Some("target"), + Some("targets"), )]; let stale = ticket_evidence_summary(Some("main"), &stale_events, Some(&approved_summary)); assert!(ticket_attention_matches( diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index e08194c3c..1629f73c7 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -6857,6 +6857,7 @@ mod tests { alias: workdir::WorkdirAttachmentAlias::new("workdir").unwrap(), working_directory_id: "workdir-1".to_string(), relative_cwd: Some("crates/yoi".to_string()), + capabilities: workdir::WorkdirSessionCapabilities::ALL, }]; let profile_source = profile_source_archive_source(&request, &request.profile).expect("profile source"); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index a690d6219..a753a7239 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -87,7 +87,8 @@ use server_api::{TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_P use sha2::{Digest, Sha256}; use ticket::{ MarkdownText, NewTicketEvent, TicketBackend, TicketBodyReplacement, TicketEventKind, - TicketIdOrSlug, TicketItemEdit, TicketStateChange, TicketTargetEdit, TicketWorkflowState, + TicketIdOrSlug, TicketItemEdit, TicketStateChange, TicketTargetAccess, TicketTargetsEdit, + TicketWorkflowState, }; use ticket::{ SqliteTicketBackend, TicketBackendOperation, TicketBackendOperationResult, @@ -3037,7 +3038,7 @@ impl WorkspaceApi { runtime_id: &str, mut request: WorkerSpawnRequest, ) -> ApiResult { - self.validate_worker_spawn_repository_scope(&request)?; + self.validate_worker_spawn_repository_scope(runtime_id, &mut request)?; let workspace_api = self.workspace_api_ref(runtime_id); request.resolved_workspace_api = Some(workspace_api.clone()); for access in take_new_workdir_repository_access(&mut request)? { @@ -3070,6 +3071,7 @@ impl WorkspaceApi { attachment.alias.to_string(), attachment.working_directory_id.clone(), Uuid::new_v4().to_string(), + attachment.capabilities, ) }) .collect::>(); @@ -3114,8 +3116,13 @@ impl WorkspaceApi { assignment: None, spawned_workdir_ids: &spawned_workdir_ids, }; - let mut reserved_attachments: Vec<(String, String, String)> = Vec::new(); - for (alias, workdir_id, reservation_id) in &attachment_reservations { + let mut reserved_attachments: Vec<( + String, + String, + String, + workdir::WorkdirSessionCapabilities, + )> = Vec::new(); + for (alias, workdir_id, reservation_id, capabilities) in &attachment_reservations { if let Err(error) = self.store.reserve_worker_workdir_attachment( &self.config.workspace_id, workdir_id, @@ -3147,7 +3154,12 @@ impl WorkspaceApi { ); return Err(ApiError::with_diagnostics(error, diagnostics)); } - reserved_attachments.push((alias.clone(), workdir_id.clone(), reservation_id.clone())); + reserved_attachments.push(( + alias.clone(), + workdir_id.clone(), + reservation_id.clone(), + *capabilities, + )); } let mut result = match self .runtime @@ -3307,12 +3319,13 @@ impl WorkspaceApi { return Err(api_error_with_additional_diagnostics(error, diagnostics)); } - for (alias, workdir_id, reservation_id) in &reserved_attachments { + for (alias, workdir_id, reservation_id, capabilities) in &reserved_attachments { let attachment = WorkerWorkdirLinkRecord { workspace_id: self.config.workspace_id.clone(), worker: worker_ref.clone(), workdir_id: workdir_id.clone(), alias: alias.clone(), + capabilities: *capabilities, linked_at: now_registry_timestamp(), unlinked_at: None, }; @@ -3467,13 +3480,8 @@ impl WorkspaceApi { &self, repository_id: &str, ) -> ApiResult { - self.require_workspace_repository(repository_id)?; - self.config - .repositories - .iter() - .find(|repository| repository.id == repository_id) - .cloned() - .ok_or_else(|| ApiError::from(Error::UnknownRepository(repository_id.to_string()))) + configured_repository_from_record(self.require_workspace_repository(repository_id)?) + .map_err(ApiError::from) } fn require_configured_workspace_repository_by_key( @@ -3494,23 +3502,55 @@ impl WorkspaceApi { fn validate_worker_spawn_repository_scope( &self, - request: &WorkerSpawnRequest, + runtime_id: &str, + request: &mut WorkerSpawnRequest, ) -> ApiResult<()> { - let mut selected_repositories = Vec::new(); for attachment in &request.resolved_workdir_attachment_requests { - let working_directory = &attachment.working_directory; - let repository_id = working_directory.repository.id.as_str(); - self.require_workspace_repository(repository_id)?; - selected_repositories.push(( - repository_id.to_string(), - working_directory - .repository - .selector - .as_deref() - .map(str::to_owned), - )); + self.require_workspace_repository(&attachment.working_directory.repository.id)?; } - for claim in &request.resolved_workdir_attachments { + let WorkerSpawnIntent::TicketRole { ticket_id, .. } = &request.intent else { + for claim in &mut request.resolved_workdir_attachments { + let workdir = self + .store + .get_workdir_registry(&self.config.workspace_id, &claim.working_directory_id)? + .ok_or_else(|| { + ApiError::from(Error::Config(format!( + "unknown working directory `{}` in this Workspace", + claim.working_directory_id + ))) + })?; + claim.capabilities = workdir_source_capabilities(&workdir); + } + return Ok(()); + }; + + if !request.resolved_workdir_attachment_requests.is_empty() { + return Err(Error::InvalidInput( + "Ticket-role spawn requires existing Workspace Workdir attachments so Backend authority can bind each persisted target capability before Runtime side effects" + .to_string(), + ) + .into()); + } + + let targets = validated_ticket_implementation_targets(self, ticket_id)?; + let mut aliases = HashSet::new(); + let mut workdir_ids = HashSet::new(); + let mut covered_repositories = HashSet::new(); + for claim in &mut request.resolved_workdir_attachments { + if !aliases.insert(claim.alias.as_str().to_string()) { + return Err(Error::InvalidInput(format!( + "Ticket `{ticket_id}` Worker attachment alias `{}` appears more than once", + claim.alias + )) + .into()); + } + if !workdir_ids.insert(claim.working_directory_id.clone()) { + return Err(Error::InvalidInput(format!( + "Ticket `{ticket_id}` Workdir `{}` appears more than once", + claim.working_directory_id + )) + .into()); + } let workdir = self .store .get_workdir_registry(&self.config.workspace_id, &claim.working_directory_id)? @@ -3520,58 +3560,236 @@ impl WorkspaceApi { claim.working_directory_id ))) })?; - if let WorkdirRegistrySource::Repository { repository_id, .. } = workdir.source { - self.require_workspace_repository(&repository_id)?; - selected_repositories.push((repository_id, workdir.creation_selector)); - } - } - - if let WorkerSpawnIntent::TicketRole { ticket_id, .. } = &request.intent { - let ticket = self.authority.ticket(ticket_id)?; - // 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_repositories.is_empty() && ticket.repository_key.is_none() { - return Ok(()); - } - 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 (workdir_runtime_id, repository_id) = match &workdir.source { + WorkdirRegistrySource::Repository { + runtime_id, + repository_id, + } => (runtime_id, repository_id), + WorkdirRegistrySource::ExternalGrant { .. } => { + return Err(Error::InvalidInput(format!( + "Ticket `{ticket_id}` attachment `{}` is an ExternalGrant and cannot satisfy a persisted Repository target", + claim.alias + )) + .into()); + } + }; + if workdir_runtime_id != runtime_id { + return Err(Error::InvalidInput(format!( + "Ticket `{ticket_id}` attachment `{}` belongs to Runtime `{workdir_runtime_id}`, not requested Runtime `{runtime_id}`", + claim.alias )) - })?; - 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(), - )) - })?; - self.require_workspace_repository(repository_id)?; - self.repository_reader() - .observe_merge_target(repository_id, Some(ref_selector)) - .map_err(|error| { - ApiError::from(Error::Config(format!( - "Ticket implementation target is no longer resolvable: {error:?}" + .into()); + } + let target = targets + .iter() + .find(|target| target.repository_id == *repository_id) + .ok_or_else(|| { + ApiError::from(Error::InvalidInput(format!( + "Ticket `{ticket_id}` attachment `{}` resolves undeclared Repository `{repository_id}`", + claim.alias ))) })?; - for (selected_repository_id, selected_ref_selector) in &selected_repositories { - if selected_repository_id != repository_id { - return Err(ApiError::from(Error::Config(format!( - "Ticket `{ticket_id}` targets Repository `{repository_key}`, but a Worker attachment resolves a different Repository" - )))); - } - if selected_ref_selector.as_deref() != Some(ref_selector) { - return Err(ApiError::from(Error::Config(format!( - "Ticket `{ticket_id}` targets selector `{ref_selector}`, but a Worker attachment resolves `{}`", - selected_ref_selector.as_deref().unwrap_or("none") - )))); - } + if workdir.creation_selector.as_deref() != Some(target.ref_selector.as_str()) { + return Err(Error::InvalidInput(format!( + "Ticket `{ticket_id}` target selector `{}` does not match attachment `{}` selector `{}`", + target.ref_selector, + claim.alias, + workdir.creation_selector.as_deref().unwrap_or("none") + )) + .into()); } + if !covered_repositories.insert(repository_id.clone()) { + return Err(Error::InvalidInput(format!( + "Ticket `{ticket_id}` target Repository `{}` has more than one Worker attachment", + target.repository_key + )) + .into()); + } + claim.capabilities = target + .capabilities + .intersection(workdir_source_capabilities(&workdir)); + } + + let missing = targets + .iter() + .filter(|target| !covered_repositories.contains(&target.repository_id)) + .map(|target| target.repository_key.as_str()) + .collect::>(); + if !missing.is_empty() { + return Err(Error::InvalidInput(format!( + "Ticket `{ticket_id}` is missing one-to-one Workdir attachment coverage for target(s): {}", + missing.join(", ") + )) + .into()); + } + if request.resolved_workdir_attachments.len() != targets.len() { + return Err(Error::InvalidInput(format!( + "Ticket `{ticket_id}` requires exactly {} Workdir attachment(s), found {}", + targets.len(), + request.resolved_workdir_attachments.len() + )) + .into()); } Ok(()) } } +#[derive(Clone, Debug, PartialEq, Eq)] +struct ValidatedTicketImplementationTarget { + repository_id: String, + repository_key: String, + ref_selector: String, + access: TicketTargetAccess, + capabilities: workdir::WorkdirSessionCapabilities, +} + +fn workdir_source_capabilities( + workdir: &WorkdirRegistryRecord, +) -> workdir::WorkdirSessionCapabilities { + match &workdir.source { + WorkdirRegistrySource::Repository { .. } => workdir::WorkdirSessionCapabilities::ALL, + WorkdirRegistrySource::ExternalGrant { .. } => { + workdir::WorkdirSessionCapabilities::READ_ONLY + } + } +} + +fn validated_ticket_implementation_targets( + api: &WorkspaceApi, + ticket_id: &str, +) -> ApiResult> { + let ticket = browser_ticket_backend(api)? + .show(TicketIdOrSlug::Id(ticket_id.to_string())) + .map_err(Error::from)?; + if ticket.meta.targets.is_empty() { + return Err(Error::InvalidInput(format!( + "Ticket `{ticket_id}` has no persisted implementation targets" + )) + .into()); + } + let read_write_count = ticket + .meta + .targets + .iter() + .filter(|target| target.access == TicketTargetAccess::ReadWrite) + .count(); + if read_write_count != 1 { + return Err(Error::InvalidInput(format!( + "Ticket `{ticket_id}` requires exactly one read_write target, found {read_write_count}" + )) + .into()); + } + + let mut repository_ids = HashSet::new(); + ticket + .meta + .targets + .into_iter() + .map(|target| { + let repository_key = target.repository_key; + let repository = api + .store + .get_repository_by_key(&api.config.workspace_id, &repository_key)? + .ok_or_else(|| ApiError::from(Error::UnknownRepository(repository_key.clone())))?; + let repository_id = repository.repository_id.clone(); + if !repository_ids.insert(repository_id.clone()) { + return Err(Error::InvalidInput(format!( + "Ticket `{ticket_id}` target Repository `{repository_key}` appears more than once" + )) + .into()); + } + let ref_selector = target + .ref_selector + .filter(|selector| !selector.trim().is_empty()) + .ok_or_else(|| { + ApiError::from(Error::InvalidInput(format!( + "Ticket `{ticket_id}` target Repository `{}` has no persisted selector", + repository.repository_key + ))) + })?; + api.live_repository_reader()? + .observe_merge_target(&repository_id, Some(&ref_selector)) + .map_err(|error| { + ApiError::from(Error::InvalidInput(format!( + "Ticket `{ticket_id}` target Repository `{}` selector `{ref_selector}` is not resolvable: {error:?}", + repository.repository_key + ))) + })?; + let capabilities = match target.access { + TicketTargetAccess::ReadOnly => { + workdir::WorkdirSessionCapabilities::READ_ONLY + } + TicketTargetAccess::ReadWrite => workdir::WorkdirSessionCapabilities::ALL, + }; + Ok(ValidatedTicketImplementationTarget { + repository_id, + repository_key: repository.repository_key, + ref_selector, + access: target.access, + capabilities, + }) + }) + .collect() +} + +fn require_ticket_read_write_target( + api: &WorkspaceApi, + ticket_id: &str, + repository_id: &str, + selector: &str, +) -> ApiResult { + let write_target = validated_ticket_implementation_targets(api, ticket_id)? + .into_iter() + .find(|target| target.access == TicketTargetAccess::ReadWrite) + .expect("validated Ticket target collection has exactly one read_write target"); + if write_target.repository_id != repository_id || write_target.ref_selector != selector { + return Err(Error::InvalidInput( + "selectors must match the authoritative Ticket read_write repository target".into(), + ) + .into()); + } + Ok(write_target) +} + +fn ticket_target_capabilities_for_workdir( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, + workdir: &WorkdirRegistryRecord, +) -> Result { + let source_capabilities = workdir_source_capabilities(workdir); + let Some(assignment) = api + .store + .get_current_ticket_role_assignment_for_worker(&api.config.workspace_id, worker)? + .filter(|assignment| assignment.role == TicketAssignmentRole::Coder) + else { + return Ok(source_capabilities); + }; + let WorkdirRegistrySource::Repository { repository_id, .. } = &workdir.source else { + return Err(Error::InvalidInput(format!( + "assigned Ticket Coder Workdir `{}` is not a Repository target", + workdir.workdir_id + ))); + }; + let targets = validated_ticket_implementation_targets(api, &assignment.ticket_id) + .map_err(|error| error.error)?; + let target = targets + .into_iter() + .find(|target| target.repository_id == *repository_id) + .ok_or_else(|| { + Error::InvalidInput(format!( + "Workdir `{}` Repository is not declared by assigned Ticket `{}`", + workdir.workdir_id, assignment.ticket_id + )) + })?; + if workdir.creation_selector.as_deref() != Some(target.ref_selector.as_str()) { + return Err(Error::InvalidInput(format!( + "Workdir `{}` selector does not match assigned Ticket `{}` target selector", + workdir.workdir_id, assignment.ticket_id + ))); + } + Ok(target.capabilities.intersection(source_capabilities)) +} + fn import_configured_repositories( store: &dyn ControlPlaneStore, config: &ServerConfig, @@ -10080,6 +10298,16 @@ fn validate_ticket_assignment_spawn( "Ticket-assigned Coder spawn requires one Flow segment in initial_submit".to_string(), )); } + if !request.workdir_attachment_requests.is_empty() + || !request.resolved_workdir_attachment_requests.is_empty() + { + return Err(Error::TicketAssignmentConflict( + "Ticket-assigned Coder spawn requires existing Workspace Workdir attachments; new materialization requests cannot carry persisted Ticket target capabilities" + .to_string(), + )); + } + validated_ticket_implementation_targets(api, &assignment.ticket_id) + .map_err(|error| Error::TicketAssignmentConflict(error.error.to_string()))?; validate_ticket_assignment_state(api, assignment)?; if let Some(current) = api @@ -10284,42 +10512,46 @@ impl ticket::TicketTargetAuthority for WorkspaceTicketTargetAuthority { fn resolve_target( &self, workspace_id: &str, - repository_id: Option<&str>, + repository_key: &str, ref_selector: Option<&str>, ) -> ticket::Result { if workspace_id != self.api.config.workspace_id { return Err(ticket::TicketError::UnknownTargetRepository( - repository_id.unwrap_or_default().to_owned(), + repository_key.to_owned(), )); } - let repository_id = repository_id - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or(ticket::TicketError::MissingTargetRepository)?; + let repository_key = repository_key.trim(); + if repository_key.is_empty() { + return Err(ticket::TicketError::MissingTargetRepository); + } let repository = self .api .store - .get_repository(workspace_id, repository_id) + .get_repository_by_key(workspace_id, repository_key) .map_err(|error| ticket::TicketError::Conflict(error.to_string()))? .ok_or_else(|| { - ticket::TicketError::UnknownTargetRepository(repository_id.to_owned()) + ticket::TicketError::UnknownTargetRepository(repository_key.to_owned()) })?; let selector = ref_selector .map(str::trim) .filter(|value| !value.is_empty()) .or(repository.default_ref.as_deref()) - .ok_or_else(|| ticket::TicketError::MissingTargetSelector(repository_id.to_owned()))?; + .ok_or_else(|| { + ticket::TicketError::MissingTargetSelector(repository.repository_key.clone()) + })?; self.api - .repository_reader() - .observe_merge_target(repository_id, Some(selector)) + .live_repository_reader() + .map_err(|error| ticket::TicketError::Conflict(error.to_string()))? + .observe_merge_target(&repository.repository_id, Some(selector)) .map_err(|error| ticket::TicketError::InvalidTargetSelector { - repository_id: repository_id.to_owned(), + repository_key: repository.repository_key.clone(), selector: selector.to_owned(), reason: format!("{error:?}"), })?; Ok(ticket::ResolvedTicketTarget { - repository_id: repository_id.to_owned(), + repository_key: repository.repository_key, ref_selector: selector.to_owned(), + access: TicketTargetAccess::ReadOnly, }) } } @@ -10349,33 +10581,21 @@ async fn scoped_edit_ticket_item( Json(request): Json, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - let mut target = request.target.map(|target| match target { - server_api::BrowserTicketTargetEdit::Set { - repository_key, - ref_selector, - } => TicketTargetEdit::Set { - repository_id: repository_key, - ref_selector, + let mut targets = request.target.map(|target| match target { + server_api::BrowserTicketTargetEdit::Set { targets } => TicketTargetsEdit::Set { + targets: targets + .into_iter() + .map(|target| ticket::TicketTarget { + repository_key: target.repository_key, + ref_selector: target.ref_selector, + access: target.access, + }) + .collect(), }, - server_api::BrowserTicketTargetEdit::Clear => TicketTargetEdit::Clear, + server_api::BrowserTicketTargetEdit::Clear => TicketTargetsEdit::Clear, }); - if let Some(TicketTargetEdit::Set { repository_id, .. }) = target.as_mut() { - server_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_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", - ) - })?; + if let Some(TicketTargetsEdit::Set { targets }) = targets.as_mut() { + validate_ticket_target_repository_keys(&api, &path.workspace_id, targets)?; } browser_ticket_backend(&api)? .edit_item( @@ -10397,7 +10617,7 @@ async fn scoped_edit_ticket_item( )); } }, - target, + targets, author: request.author, }, ) @@ -10559,36 +10779,54 @@ fn reject_unguarded_ticket_completion(operation: &TicketBackendOperation) -> Res Ok(()) } -fn resolve_ticket_operation_repository_keys( +fn validate_ticket_target_repository_keys( api: &WorkspaceApi, workspace_id: &str, - operation: &mut TicketBackendOperation, + targets: &[ticket::TicketTarget], ) -> 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(()); - }; - server_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; + let mut repositories = HashSet::new(); + for target in targets { + server_api::validate_repository_key(&target.repository_key).map_err(|error| { + ApiError::from(Error::InvalidInput(format!( + "invalid Repository key: {error}" + ))) + })?; + let repository = api + .store + .get_repository_by_key(workspace_id, &target.repository_key)? + .ok_or_else(|| { + ApiError::from(Error::UnknownRepository(target.repository_key.clone())) + })?; + if !repositories.insert(repository.repository_id) { + return Err(Error::InvalidInput(format!( + "Ticket target Repository `{}` appears more than once", + target.repository_key + )) + .into()); + } + } Ok(()) } +fn validate_ticket_operation_repository_keys( + api: &WorkspaceApi, + workspace_id: &str, + operation: &TicketBackendOperation, +) -> ApiResult<()> { + match operation { + TicketBackendOperation::Create { input } => { + validate_ticket_target_repository_keys(api, workspace_id, &input.targets) + } + TicketBackendOperation::EditItem { edit, .. } => match edit.targets.as_ref() { + Some(TicketTargetsEdit::Set { targets }) => { + validate_ticket_target_repository_keys(api, workspace_id, targets) + } + Some(TicketTargetsEdit::Clear) | None => Ok(()), + }, + _ => Ok(()), + } +} + async fn execute_ticket_rest_operation( api: &WorkspaceApi, workspace_id: &str, @@ -10596,7 +10834,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)?; + validate_ticket_operation_repository_keys(api, workspace_id, &operation)?; let mut backend = SqliteTicketBackend::open_verified( api.config.database_path.clone(), api.config.workspace_id.clone(), @@ -10698,7 +10936,7 @@ async fn execute_ticket_rest_operation( backend = backend.with_event_attributes(event_attributes); } - let mut result = execute_ticket_backend_operation(&backend, operation).map_err(Error::from)?; + let 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 { @@ -10724,16 +10962,6 @@ 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) } @@ -12002,19 +12230,9 @@ async fn scoped_open_merge_request( ) .into()); } - let ticket = browser_ticket_backend(&api)? - .show(TicketIdOrSlug::Id(ticket_id.clone().into())) - .map_err(Error::from)?; 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( - "selectors must match the authoritative Ticket repository target".into(), - ) - .into()); - } + require_ticket_read_write_target(&api, &ticket_id, &repository_id, &input.selector_to)?; let source_observation = observe_published_source_ref( &api, &workspace_id, @@ -12604,16 +12822,16 @@ fn validate_ticket_repository_operation( api: &WorkspaceApi, operation: &TicketBackendOperation, ) -> ApiResult<()> { - let repository_id = match operation { - TicketBackendOperation::Create { input } => input.repository_id.as_deref(), - TicketBackendOperation::EditItem { edit, .. } => match edit.target.as_ref() { - Some(TicketTargetEdit::Set { repository_id, .. }) => Some(repository_id.as_str()), - _ => None, + let targets: &[ticket::TicketTarget] = match operation { + TicketBackendOperation::Create { input } => &input.targets, + TicketBackendOperation::EditItem { edit, .. } => match edit.targets.as_ref() { + Some(TicketTargetsEdit::Set { targets }) => targets, + Some(TicketTargetsEdit::Clear) | None => &[], }, - _ => None, + _ => &[], }; - if let Some(repository_id) = repository_id { - api.require_workspace_repository(repository_id)?; + for target in targets { + api.require_configured_workspace_repository_by_key(&target.repository_key)?; } Ok(()) } @@ -13026,6 +13244,12 @@ async fn open_current_worker_workdir_session_locked( link.workdir_id ), })?; + let effective_capabilities = link + .capabilities + .intersection(workdir_source_capabilities(&workdir)) + .intersection(ticket_target_capabilities_for_workdir( + api, worker, &workdir, + )?); if let WorkdirRegistrySource::ExternalGrant { grant_id } = &workdir.source { close_current_worker_attachment_session_locked(api, worker).await?; let mut grant = api @@ -13072,7 +13296,7 @@ async fn open_current_worker_workdir_session_locked( code: "external_workdir_provider_offline".to_string(), message: "External Workdir provider connection is unavailable".to_string(), })?; - let session: WorkdirSessionHandle = Arc::new(ExternalProviderWorkdirSession::new( + let source: WorkdirSessionHandle = Arc::new(ExternalProviderWorkdirSession::new( connection, Some(ExternalProviderAuditContext { store: api.store.clone(), @@ -13081,6 +13305,8 @@ async fn open_current_worker_workdir_session_locked( attachment_alias: link.alias.clone(), }), )); + let session = workdir::WorkdirToolBroker::with_capabilities(source, effective_capabilities) + .tool_session(); let replaced = api .workdir_sessions .lock() @@ -13143,11 +13369,13 @@ async fn open_current_worker_workdir_session_locked( close_current_worker_attachment_session_locked(api, worker).await?; } let owner_worker_id = runtime_local_owner_worker_id(worker, runtime_id); - let session = api + let source = api .runtime .open_workdir_session(runtime_id, &workdir.workdir_id, owner_worker_id) .await .map_err(|error| error.into_error())?; + let session = workdir::WorkdirToolBroker::with_capabilities(source, effective_capabilities) + .tool_session(); let replaced = api .workdir_sessions .lock() @@ -13227,7 +13455,7 @@ fn sync_runtime_worker_workdir_attachments( .list_worker_workdir_links(&api.config.workspace_id, worker)? .into_iter() .filter(|link| link.unlinked_at.is_none()) - .map(|link| { + .map(|mut link| { let workdir = api .store .get_workdir_registry(&api.config.workspace_id, &link.workdir_id)? @@ -13237,14 +13465,16 @@ fn sync_runtime_worker_workdir_attachments( link.workdir_id )) })?; - let capabilities = match workdir.source { - WorkdirRegistrySource::Repository { .. } => { - workdir::WorkdirSessionCapabilities::ALL - } - WorkdirRegistrySource::ExternalGrant { .. } => { - workdir::WorkdirSessionCapabilities::READ_ONLY - } - }; + let capabilities = link + .capabilities + .intersection(workdir_source_capabilities(&workdir)) + .intersection(ticket_target_capabilities_for_workdir( + api, worker, &workdir, + )?); + if capabilities != link.capabilities { + link.capabilities = capabilities; + api.store.attach_worker_workdir(&link)?; + } Ok(LogicalWorkdirAttachment { alias: workdir::WorkdirAttachmentAlias::new(link.alias) .map_err(|error| Error::InvalidInput(error.to_string()))?, @@ -13320,8 +13550,8 @@ async fn scoped_attach_current_worker_workdir( )) .into()); } - let capabilities = match &workdir.source { - WorkdirRegistrySource::Repository { .. } => workdir::WorkdirSessionCapabilities::ALL, + match &workdir.source { + WorkdirRegistrySource::Repository { .. } => {} WorkdirRegistrySource::ExternalGrant { grant_id } => { let grant = api .store @@ -13353,9 +13583,9 @@ async fn scoped_attach_current_worker_workdir( } .into()); } - workdir::WorkdirSessionCapabilities::READ_ONLY } - }; + } + let capabilities = ticket_target_capabilities_for_workdir(&api, &worker, &workdir)?; let session_lock = current_worker_session_lock(&api, &worker); let _session_guard = session_lock.lock().await; if !api @@ -13383,6 +13613,12 @@ async fn scoped_attach_current_worker_workdir( )) .into()); } + let effective_capabilities = existing.capabilities.intersection(capabilities); + if effective_capabilities != existing.capabilities { + let mut attenuated = existing.clone(); + attenuated.capabilities = effective_capabilities; + api.store.attach_worker_workdir(&attenuated)?; + } sync_runtime_worker_workdir_attachments(&api, &worker)?; api.worker_projection .refresh(&worker) @@ -13391,7 +13627,7 @@ async fn scoped_attach_current_worker_workdir( workspace_id: api.config.workspace_id.clone(), alias: alias.to_string(), working_directory_id: workdir_id.to_string(), - capabilities, + capabilities: effective_capabilities, attached: true, })); } @@ -13401,6 +13637,7 @@ async fn scoped_attach_current_worker_workdir( worker: worker.clone(), workdir_id: workdir_id.to_string(), alias: alias.to_string(), + capabilities, linked_at: Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), unlinked_at: None, })?; @@ -20796,6 +21033,7 @@ async fn create_workspace_worker_inner( alias, working_directory_id: selection.working_directory_id, relative_cwd: selection.relative_cwd, + capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY, }) }) .collect::>>()?; @@ -21049,6 +21287,7 @@ fn finalize_spawned_worker_workdir_attachments( worker_record, attachment.alias.as_str(), &summary.working_directory_id, + workdir_source_capabilities(&workdir_record), None, ), )?; @@ -21536,10 +21775,10 @@ fn api_error_with_additional_diagnostics( fn release_worker_workdir_attachment_reservations( api: &WorkspaceApi, - reservations: &[(String, String, String)], + reservations: &[(String, String, String, workdir::WorkdirSessionCapabilities)], ) -> Vec { let mut diagnostics = Vec::new(); - for (_, workdir_id, reservation_id) in reservations { + for (_, workdir_id, reservation_id, _) in reservations { if let Err(error) = api.store.release_worker_workdir_attachment_reservation( &api.config.workspace_id, workdir_id, @@ -21565,7 +21804,7 @@ fn compensate_failed_workspace_worker_create( failure_phase: &str, worker: Option<&InternalWorkerSummary>, context: &WorkerSpawnCompensationContext<'_>, - attachment_reservations: &[(String, String, String)], + attachment_reservations: &[(String, String, String, workdir::WorkdirSessionCapabilities)], ) -> Vec { let mut diagnostics = Vec::new(); let reserved_worker_ref = @@ -23563,11 +23802,14 @@ fn sync_worker_observation( && link.alias == attachment.alias.as_str() && link.workdir_id == working_directory.working_directory_id }) { + let capabilities = + ticket_target_capabilities_for_workdir(api, &record.worker, &workdir_record)?; link_worker_to_workdir( api, &record, attachment.alias.as_str(), &working_directory.working_directory_id, + capabilities, None, )?; } @@ -24001,6 +24243,7 @@ fn link_worker_to_workdir( worker_record: &WorkerRegistryRecord, alias: &str, workdir_id: &str, + capabilities: workdir::WorkdirSessionCapabilities, reservation_id: Option<&str>, ) -> ApiResult<()> { let timestamp = now_registry_timestamp(); @@ -24009,6 +24252,7 @@ fn link_worker_to_workdir( worker: worker_record.worker.clone(), workdir_id: workdir_id.to_string(), alias: alias.to_string(), + capabilities, linked_at: timestamp, unlinked_at: None, }; @@ -24953,6 +25197,12 @@ impl From for ApiError { ticket::TicketError::InvalidTargetSelector { .. } => { "ticket_target_selector_invalid" } + ticket::TicketError::DuplicateTargetRepository(_) => { + "ticket_target_repository_duplicate" + } + ticket::TicketError::InvalidReadWriteTargetCount(_) => { + "ticket_target_read_write_count_invalid" + } ticket::TicketError::TargetAuthorityUnavailable => { "ticket_target_authority_unavailable" } @@ -25390,6 +25640,7 @@ mod tests { worker: worker.clone(), workdir_id: grant.workdir_id.clone(), alias: "expired-external".to_string(), + capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY, linked_at: created_at, unlinked_at: None, }; @@ -25803,6 +26054,7 @@ mod tests { worker: worker.clone(), workdir_id: "analysis-repository".to_string(), alias: "repository".to_string(), + capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY, linked_at: TEST_CREATED_AT.to_string(), unlinked_at: None, }; @@ -25811,13 +26063,23 @@ mod tests { worker: worker.clone(), workdir_id: grant.working_directory_id.clone(), alias: "session-logs".to_string(), + capabilities: workdir::WorkdirSessionCapabilities::ALL, linked_at: TEST_CREATED_AT.to_string(), unlinked_at: None, }; api.store.attach_worker_workdir(&repository_link).unwrap(); api.store.attach_worker_workdir(&external_link).unwrap(); sync_runtime_worker_workdir_attachments(&api, &worker).unwrap(); - + let links = api + .store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &worker) + .unwrap(); + assert_eq!(links.len(), 2); + assert!( + links.iter().all(|link| { + link.capabilities == workdir::WorkdirSessionCapabilities::READ_ONLY + }) + ); let provider_root = tempfile::tempdir().unwrap(); fs::write(provider_root.path().join("events.jsonl"), b"one\ntwo\n").unwrap(); let local_session = workdir::LocalWorkdirSession::external_read_only( @@ -27363,6 +27625,7 @@ mod tests { worker: worker.worker.clone(), workdir_id: workdir.workdir_id.clone(), alias: "attachment".to_string(), + capabilities: workdir::WorkdirSessionCapabilities::ALL, linked_at: "4".to_string(), unlinked_at: None, }; @@ -27729,7 +27992,7 @@ mod tests { .unwrap(); let mut create_input = ticket::NewTicket::new("Foreign repository target"); - create_input.repository_id = Some("foreign".to_string()); + set_test_ticket_target(&mut create_input, "foreign", "HEAD"); assert!( validate_ticket_repository_operation( &api, @@ -27764,7 +28027,7 @@ mod tests { let mut foreign_repository = api.config.repositories[0].clone(); foreign_repository.id = "foreign".to_string(); - let workdir_flow_launch = WorkerSpawnRequest { + let mut workdir_flow_launch = WorkerSpawnRequest { requested_worker_name: Some("cross-workspace-workdir".to_string()), intent: WorkerSpawnIntent::WorkspaceCoding, acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted { @@ -27792,7 +28055,7 @@ mod tests { resolved_control_operation: None, }; assert!( - api.validate_worker_spawn_repository_scope(&workdir_flow_launch) + api.validate_worker_spawn_repository_scope("runtime-1", &mut workdir_flow_launch) .is_err() ); @@ -28067,53 +28330,41 @@ mod tests { #[tokio::test] async fn workspace_worker_endpoint_finalizes_ticket_assignment() { - let workspace = tempfile::tempdir().unwrap(); - init_clean_git_workspace(workspace.path()); - let api = test_api(workspace.path()).await; - let backend = browser_ticket_backend(&api).unwrap(); - let mut input = ticket::NewTicket::new("Assigned Ticket"); - input.workflow_state = Some(TicketWorkflowState::Queued); - let ticket = backend.create(input).unwrap(); - assign_test_orchestrator(&api, &ticket.id); - let response = create_workspace_worker( - State(api.clone()), - HeaderMap::new(), - Json(CreateWorkspaceWorkerRequest { - runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), - display_name: "Assigned Coder".to_string(), - profile: Some("builtin:coder".to_string()), - ticket_assignment: Some(CreateWorkspaceWorkerTicketAssignmentRequest { - ticket_id: ticket.id.clone(), - operation_id: "workspace-endpoint-assignment".to_string(), - }), - initial_submit: vec![Segment::Flow { - selector: "builtin:coder-review".to_string(), - }], - workdir_attachments: Vec::new(), - control_operation_id: None, - }), - ) - .await - .unwrap() - .0; + let fixture = guarded_spawn_fixture().await; + let operation_id = "workspace-endpoint-assignment"; + let response = + post_guarded_spawn(&fixture, guarded_spawn_payload(&fixture, operation_id)).await; + let status = response.status(); + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + status, + StatusCode::OK, + "unexpected guarded spawn response: {}", + String::from_utf8_lossy(&bytes) + ); + let response: BrowserCreateWorkerResponse = serde_json::from_slice(&bytes).unwrap(); assert_eq!( - api.authority.ticket(&ticket.id).unwrap().state, + fixture + .api + .authority + .ticket(&fixture.ticket_id) + .unwrap() + .state, TicketWorkflowState::InProgress.as_str() ); - let current = api + let current = fixture + .api .store - .get_current_ticket_coder_assignment(&api.config.workspace_id, &ticket.id) + .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id) .unwrap() .unwrap(); let response_ref = RuntimeWorkerRef::new(&response.runtime_id, &response.worker_id); assert_eq!(current.worker, response_ref); - let operation = api + let operation = fixture + .api .store - .get_ticket_assignment_operation( - &api.config.workspace_id, - "workspace-endpoint-assignment", - ) + .get_ticket_assignment_operation(TEST_WORKSPACE_ID, operation_id) .unwrap() .unwrap(); assert_eq!(operation.assignment_id, Some(current.assignment_id)); @@ -28248,6 +28499,7 @@ mod tests { worker: worker.clone(), workdir_id: workdir_id.to_string(), alias: "checkout".to_string(), + capabilities: workdir::WorkdirSessionCapabilities::ALL, linked_at: TEST_CREATED_AT.to_string(), unlinked_at: None, }) @@ -28502,41 +28754,51 @@ mod tests { controller: RuntimeWorkerRef, ticket_id: String, workdir_id: String, + read_only_workdir_id: String, } async fn guarded_spawn_fixture() -> GuardedSpawnFixture { let workspace = tempfile::tempdir().unwrap(); init_clean_git_workspace(workspace.path()); - let api = test_api(workspace.path()).await; + let api = test_api_with_docs_repository(workspace.path()).await; register_test_runtime(&api, WorkdirlessFixtureRuntime::RUNTIME_ID).await; let runtime = WorkdirlessFixtureRuntime::default(); let workdir_id = "guarded-existing-workdir".to_string(); + let read_only_workdir_id = "guarded-docs-workdir".to_string(); let repository_id = test_repository_id(&api); + let docs_repository_id = test_repository_id_by_key(&api, "docs"); + assert_ne!(repository_id, docs_repository_id); runtime.set_workdir_repository(&workdir_id, &repository_id); + runtime.set_workdir_repository(&read_only_workdir_id, &docs_repository_id); api.runtime.register_or_replace(runtime.clone()); let now = now_registry_timestamp(); - api.store - .upsert_workdir_registry(&WorkdirRegistryRecord { - workspace_id: TEST_WORKSPACE_ID.to_string(), - workdir_id: workdir_id.clone(), - display_name: None, - source: WorkdirRegistrySource::Repository { - runtime_id: WorkdirlessFixtureRuntime::RUNTIME_ID.to_string(), - repository_id: repository_id.clone(), - }, - creation_selector: Some("develop".to_string()), - creation_ref: None, - creation_tree: None, - current_selector: Some("develop".to_string()), - current_ref: None, - current_tree: None, - observed_at_epoch_seconds: None, - materialization_status: "present".to_string(), - cleanliness: "clean".to_string(), - created_at: now.clone(), - updated_at: now, - }) - .unwrap(); + for (workdir_id, repository_id) in [ + (&workdir_id, &repository_id), + (&read_only_workdir_id, &docs_repository_id), + ] { + api.store + .upsert_workdir_registry(&WorkdirRegistryRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + workdir_id: workdir_id.clone(), + display_name: None, + source: WorkdirRegistrySource::Repository { + runtime_id: WorkdirlessFixtureRuntime::RUNTIME_ID.to_string(), + repository_id: repository_id.clone(), + }, + creation_selector: Some("develop".to_string()), + creation_ref: None, + creation_tree: None, + current_selector: Some("develop".to_string()), + current_ref: None, + current_tree: None, + observed_at_epoch_seconds: None, + materialization_status: "present".to_string(), + cleanliness: "clean".to_string(), + created_at: now.clone(), + updated_at: now.clone(), + }) + .unwrap(); + } let Json(controller) = create_workspace_worker( State(api.clone()), HeaderMap::new(), @@ -28555,13 +28817,21 @@ mod tests { let controller = RuntimeWorkerRef::new(controller.runtime_id, controller.worker_id); let mut input = ticket::NewTicket::new("Guarded attachment spawn"); input.workflow_state = Some(TicketWorkflowState::Queued); - input.repository_id = Some(repository_id); - input.ref_selector = Some("develop".to_string()); + input.targets = vec![ + test_ticket_target("test-repository", "develop", TicketTargetAccess::ReadWrite), + test_ticket_target("docs", "develop", TicketTargetAccess::ReadOnly), + ]; let ticket_id = browser_ticket_backend(&api) .unwrap() .create(input) .unwrap() .id; + assert_eq!( + validated_ticket_implementation_targets(&api, &ticket_id) + .unwrap() + .len(), + 2 + ); assign_test_orchestrator(&api, &ticket_id); GuardedSpawnFixture { _workspace: workspace, @@ -28570,6 +28840,7 @@ mod tests { controller, ticket_id, workdir_id, + read_only_workdir_id, } } @@ -28586,11 +28857,17 @@ mod tests { { "kind": "flow", "selector": "builtin:coder-review" }, { "kind": "text", "content": "Implement guarded attachment Ticket." }, ], - "workdir_attachments": [{ - "alias": "workdir", - "working_directory_id": fixture.workdir_id, - "relative_cwd": "crates/yoi", - }], + "workdir_attachments": [ + { + "alias": "workdir", + "working_directory_id": fixture.workdir_id, + "relative_cwd": "crates/yoi", + }, + { + "alias": "docs", + "working_directory_id": fixture.read_only_workdir_id, + } + ], "control_operation_id": operation_id, }) } @@ -28599,7 +28876,7 @@ mod tests { #[derive(Clone)] struct StrictRemoteSpawnState { - workdir_status: worker_runtime::catalog::WorkingDirectoryStatus, + workdir_statuses: Arc>, create_requests: Arc>>, } @@ -28624,18 +28901,36 @@ mod tests { AxumPath(working_directory_id): AxumPath, ) -> Json { assert_strict_remote_request_headers(&headers); - assert_eq!( - state.workdir_status.summary.working_directory_id, - working_directory_id - ); - Json(json!({ "working_directory": state.workdir_status })) + let workdir_status = state + .workdir_statuses + .iter() + .find(|status| status.summary.working_directory_id == working_directory_id) + .cloned() + .expect("strict Runtime received an unknown Workdir request"); + Json(json!({ "working_directory": workdir_status })) } fn strict_remote_worker_response( state: &StrictRemoteSpawnState, request: &runtime_api::CreateWorkerRequest, ) -> Value { - let attachment = &request.workdir_attachments[0]; + let workdir_attachments = request + .workdir_attachments + .iter() + .map(|attachment| { + let workdir_status = state + .workdir_statuses + .iter() + .find(|status| { + status.summary.working_directory_id == attachment.working_directory_id + }) + .expect("strict Runtime response requires a registered Workdir"); + json!({ + "alias": attachment.alias, + "working_directory": workdir_status, + }) + }) + .collect::>(); let profile_source = match &request.profile_source { runtime_api::ProfileSourceArchiveSource::Embedded { archive } => { archive.reference.clone() @@ -28649,10 +28944,7 @@ mod tests { "status": "idle", "execution_metadata_available": true, "workspace_id": TEST_WORKSPACE_ID, - "workdir_attachments": [{ - "alias": attachment.alias, - "working_directory": state.workdir_status, - }], + "workdir_attachments": workdir_attachments, "profile": request.profile, "display_name": request.display_name, "profile_source": profile_source, @@ -28675,14 +28967,32 @@ mod tests { let request: runtime_api::CreateWorkerRequest = serde_json::from_value(raw.clone()).unwrap(); assert!(request.workdir_attachment_requests.is_empty()); - assert_eq!(request.workdir_attachments.len(), 1); - let attachment = &request.workdir_attachments[0]; - assert_eq!(attachment.alias.as_str(), "workdir"); + assert_eq!(request.workdir_attachments.len(), 2); + let writable = request + .workdir_attachments + .iter() + .find(|attachment| attachment.alias.as_str() == "workdir") + .unwrap(); + assert_eq!(writable.relative_cwd.as_deref(), Some("crates/yoi")); assert_eq!( - attachment.working_directory_id, - state.workdir_status.summary.working_directory_id + writable.capabilities, + workdir::WorkdirSessionCapabilities::ALL ); - assert_eq!(attachment.relative_cwd.as_deref(), Some("crates/yoi")); + let read_only = request + .workdir_attachments + .iter() + .find(|attachment| attachment.alias.as_str() == "docs") + .unwrap(); + assert_eq!(read_only.relative_cwd, None); + assert_eq!( + read_only.capabilities, + workdir::WorkdirSessionCapabilities::READ_ONLY + ); + for attachment in &request.workdir_attachments { + assert!(state.workdir_statuses.iter().any(|status| { + status.summary.working_directory_id == attachment.working_directory_id + })); + } state .create_requests .lock() @@ -28757,14 +29067,20 @@ mod tests { guarded_spawn_artifact_counts(&fixture.api, &fixture.workdir_id), (0, 0, 0) ); - assert!( - fixture - .api - .store - .list_workdir_worker_links(TEST_WORKSPACE_ID, &fixture.workdir_id) - .unwrap() - .is_empty() + assert_eq!( + guarded_spawn_artifact_counts(&fixture.api, &fixture.read_only_workdir_id), + (0, 0, 0) ); + for workdir_id in [&fixture.workdir_id, &fixture.read_only_workdir_id] { + assert!( + fixture + .api + .store + .list_workdir_worker_links(TEST_WORKSPACE_ID, workdir_id) + .unwrap() + .is_empty() + ); + } assert!( fixture .api @@ -28793,7 +29109,7 @@ mod tests { } #[tokio::test] - async fn guarded_spawn_http_boundary_preserves_canonical_initial_attachment() { + async fn guarded_spawn_http_boundary_preserves_multi_target_capabilities() { let fixture = guarded_spawn_fixture().await; let payload = guarded_spawn_payload(&fixture, "guarded-success"); assert!(payload.get("working_directory").is_none()); @@ -28808,53 +29124,96 @@ mod tests { String::from_utf8_lossy(&bytes) ); let body: BrowserCreateWorkerResponse = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(body.worker.workdir_attachments.len(), 1); - assert_eq!(body.worker.workdir_attachments[0].alias, "workdir"); - assert_eq!( - body.worker.workdir_attachments[0] - .working_directory - .working_directory_id, - fixture.workdir_id - ); + assert_eq!(body.worker.workdir_attachments.len(), 2); + assert!(body.worker.workdir_attachments.iter().any(|attachment| { + attachment.alias == "workdir" + && attachment.working_directory.working_directory_id == fixture.workdir_id + })); + assert!(body.worker.workdir_attachments.iter().any(|attachment| { + attachment.alias == "docs" + && attachment.working_directory.working_directory_id == fixture.read_only_workdir_id + })); let requests = fixture.runtime.spawn_requests(); assert_eq!(requests.len(), 1); - assert_eq!(requests[0].resolved_workdir_attachments.len(), 1); - let attachment = &requests[0].resolved_workdir_attachments[0]; - assert_eq!(attachment.alias.as_str(), "workdir"); - assert_eq!(attachment.working_directory_id, fixture.workdir_id); - assert_eq!(attachment.relative_cwd.as_deref(), Some("crates/yoi")); - assert!( - fixture - .api - .store - .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id) - .unwrap() - .is_some() - ); + assert_eq!(requests[0].resolved_workdir_attachments.len(), 2); + let writable = requests[0] + .resolved_workdir_attachments + .iter() + .find(|attachment| attachment.alias.as_str() == "workdir") + .unwrap(); + assert_eq!(writable.working_directory_id, fixture.workdir_id); + assert_eq!(writable.relative_cwd.as_deref(), Some("crates/yoi")); assert_eq!( - fixture - .api - .store - .list_workdir_worker_links(TEST_WORKSPACE_ID, &fixture.workdir_id) - .unwrap() - .len(), - 1 + writable.capabilities, + workdir::WorkdirSessionCapabilities::ALL ); + let read_only = requests[0] + .resolved_workdir_attachments + .iter() + .find(|attachment| attachment.alias.as_str() == "docs") + .unwrap(); + assert_eq!(read_only.working_directory_id, fixture.read_only_workdir_id); + assert_eq!( + read_only.capabilities, + workdir::WorkdirSessionCapabilities::READ_ONLY + ); + let worker = fixture + .api + .store + .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id) + .unwrap() + .unwrap() + .worker; + let links = fixture + .api + .store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &worker) + .unwrap(); + assert_eq!(links.len(), 2); + assert!(links.iter().any(|link| { + link.workdir_id == fixture.workdir_id + && link.capabilities == workdir::WorkdirSessionCapabilities::ALL + })); + assert!(links.iter().any(|link| { + link.workdir_id == fixture.read_only_workdir_id + && link.capabilities == workdir::WorkdirSessionCapabilities::READ_ONLY + })); + } + + #[tokio::test] + async fn guarded_spawn_rejects_missing_target_before_runtime_side_effects() { + let fixture = guarded_spawn_fixture().await; + let mut payload = guarded_spawn_payload(&fixture, "guarded-missing-target"); + payload["workdir_attachments"] + .as_array_mut() + .unwrap() + .retain(|attachment| attachment["alias"] != "docs"); + + let response = post_guarded_spawn(&fixture, payload).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert!(fixture.runtime.spawn_requests().is_empty()); + assert_guarded_spawn_rolled_back(&fixture); } #[tokio::test(flavor = "multi_thread")] async fn guarded_spawn_crosses_strict_remote_runtime_http_boundary() { let workspace = tempfile::tempdir().unwrap(); init_clean_git_workspace(workspace.path()); - let api = test_api(workspace.path()).await; + let api = test_api_with_docs_repository(workspace.path()).await; register_test_runtime(&api, STRICT_REMOTE_RUNTIME_ID).await; let workdir_id = "guarded-strict-remote-workdir".to_string(); + let read_only_workdir_id = "guarded-strict-remote-docs-workdir".to_string(); let repository_id = test_repository_id(&api); + let docs_repository_id = test_repository_id_by_key(&api, "docs"); + assert_ne!(repository_id, docs_repository_id); let mut workdir_status = WorkdirlessFixtureRuntime::workdir_status(&workdir_id); workdir_status.summary.repository_id = repository_id.clone(); + let mut read_only_workdir_status = + WorkdirlessFixtureRuntime::workdir_status(&read_only_workdir_id); + read_only_workdir_status.summary.repository_id = docs_repository_id.clone(); let strict_state = StrictRemoteSpawnState { - workdir_status, + workdir_statuses: Arc::new(vec![workdir_status, read_only_workdir_status]), create_requests: Arc::new(Mutex::new(Vec::new())), }; let runtime_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -28894,28 +29253,33 @@ mod tests { .unwrap(), ); let now = now_registry_timestamp(); - api.store - .upsert_workdir_registry(&WorkdirRegistryRecord { - workspace_id: TEST_WORKSPACE_ID.to_string(), - workdir_id: workdir_id.clone(), - display_name: None, - source: WorkdirRegistrySource::Repository { - runtime_id: STRICT_REMOTE_RUNTIME_ID.to_string(), - repository_id: repository_id.clone(), - }, - creation_selector: Some("develop".to_string()), - creation_ref: None, - creation_tree: None, - current_selector: Some("develop".to_string()), - current_ref: None, - current_tree: None, - observed_at_epoch_seconds: None, - materialization_status: "present".to_string(), - cleanliness: "clean".to_string(), - created_at: now.clone(), - updated_at: now, - }) - .unwrap(); + for (workdir_id, repository_id) in [ + (&workdir_id, &repository_id), + (&read_only_workdir_id, &docs_repository_id), + ] { + api.store + .upsert_workdir_registry(&WorkdirRegistryRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + workdir_id: workdir_id.clone(), + display_name: None, + source: WorkdirRegistrySource::Repository { + runtime_id: STRICT_REMOTE_RUNTIME_ID.to_string(), + repository_id: repository_id.clone(), + }, + creation_selector: Some("develop".to_string()), + creation_ref: None, + creation_tree: None, + current_selector: Some("develop".to_string()), + current_ref: None, + current_tree: None, + observed_at_epoch_seconds: None, + materialization_status: "present".to_string(), + cleanliness: "clean".to_string(), + created_at: now.clone(), + updated_at: now.clone(), + }) + .unwrap(); + } let Json(controller) = create_workspace_worker( State(api.clone()), HeaderMap::new(), @@ -28934,8 +29298,10 @@ mod tests { let controller = RuntimeWorkerRef::new(controller.runtime_id, controller.worker_id); let mut ticket = ticket::NewTicket::new("Strict remote guarded attachment spawn"); ticket.workflow_state = Some(TicketWorkflowState::Queued); - ticket.repository_id = Some(repository_id); - ticket.ref_selector = Some("develop".to_string()); + ticket.targets = vec![ + test_ticket_target("test-repository", "develop", TicketTargetAccess::ReadWrite), + test_ticket_target("docs", "develop", TicketTargetAccess::ReadOnly), + ]; let ticket_id = browser_ticket_backend(&api) .unwrap() .create(ticket) @@ -28954,11 +29320,17 @@ mod tests { { "kind": "flow", "selector": "builtin:coder-review" }, { "kind": "text", "content": "Implement strict remote attachment Ticket." }, ], - "workdir_attachments": [{ - "alias": "workdir", - "working_directory_id": workdir_id, - "relative_cwd": "crates/yoi", - }], + "workdir_attachments": [ + { + "alias": "workdir", + "working_directory_id": workdir_id, + "relative_cwd": "crates/yoi", + }, + { + "alias": "docs", + "working_directory_id": read_only_workdir_id, + } + ], "control_operation_id": "guarded-strict-remote-success", }); assert!(payload.get("working_directory").is_none()); @@ -28986,14 +29358,15 @@ mod tests { ); let body: BrowserCreateWorkerResponse = serde_json::from_slice(&bytes).unwrap(); assert_eq!(body.worker.runtime_id, STRICT_REMOTE_RUNTIME_ID); - assert_eq!(body.worker.workdir_attachments.len(), 1); - assert_eq!(body.worker.workdir_attachments[0].alias.as_str(), "workdir"); - assert_eq!( - body.worker.workdir_attachments[0] - .working_directory - .working_directory_id, - workdir_id - ); + assert_eq!(body.worker.workdir_attachments.len(), 2); + assert!(body.worker.workdir_attachments.iter().any(|attachment| { + attachment.alias.as_str() == "workdir" + && attachment.working_directory.working_directory_id == workdir_id + })); + assert!(body.worker.workdir_attachments.iter().any(|attachment| { + attachment.alias.as_str() == "docs" + && attachment.working_directory.working_directory_id == read_only_workdir_id + })); let requests = strict_state .create_requests .lock() @@ -29002,22 +29375,28 @@ mod tests { assert!(requests[0].get("working_directory").is_none()); assert_eq!( requests[0]["workdir_attachments"].as_array().unwrap().len(), - 1 + 2 ); drop(requests); - assert!( - api.store - .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &ticket_id) - .unwrap() - .is_some() - ); - assert_eq!( - api.store - .list_workdir_worker_links(TEST_WORKSPACE_ID, &workdir_id) - .unwrap() - .len(), - 1 - ); + let worker = api + .store + .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &ticket_id) + .unwrap() + .unwrap() + .worker; + let links = api + .store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &worker) + .unwrap(); + assert_eq!(links.len(), 2); + assert!(links.iter().any(|link| { + link.workdir_id == workdir_id + && link.capabilities == workdir::WorkdirSessionCapabilities::ALL + })); + assert!(links.iter().any(|link| { + link.workdir_id == read_only_workdir_id + && link.capabilities == workdir::WorkdirSessionCapabilities::READ_ONLY + })); runtime_server.abort(); } @@ -29493,6 +29872,7 @@ mod tests { worker: RuntimeWorkerRef::new(EMBEDDED_WORKER_RUNTIME_ID, "7"), workdir_id: "managed".to_string(), alias: "attachment".to_string(), + capabilities: workdir::WorkdirSessionCapabilities::ALL, linked_at: "3".to_string(), unlinked_at: None, }) @@ -32317,10 +32697,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 repository_key = "test-repository"; let mut input = ticket::NewTicket::new("Validated target"); - input.repository_id = Some(repository_id.clone()); - input.ref_selector = Some("develop".to_owned()); + set_test_ticket_target(&mut input, repository_key, "develop"); let ticket_ref = backend.create(input).unwrap(); let request = ticket::TicketMarkReady { operation_key: "ready-server-test".to_owned(), @@ -32333,10 +32712,13 @@ mod tests { .unwrap(); assert_eq!(ready.meta.workflow_state, TicketWorkflowState::Ready); assert_eq!( - ready.meta.repository_id.as_deref(), - Some(repository_id.as_str()) + ready.meta.targets, + vec![test_ticket_target( + repository_key, + "develop", + TicketTargetAccess::ReadWrite, + )] ); - assert_eq!(ready.meta.ref_selector.as_deref(), Some("develop")); assert_eq!( backend .mark_ready(TicketIdOrSlug::Id(ticket_ref.id.clone()), request) @@ -32351,9 +32733,12 @@ mod tests { backend.edit_item( TicketIdOrSlug::Id(ticket_ref.id.clone()), ticket::TicketItemEdit { - target: Some(ticket::TicketTargetEdit::Set { - repository_id: TEST_REPOSITORY_ID.to_owned(), - ref_selector: Some("other".to_owned()), + targets: Some(ticket::TicketTargetsEdit::Set { + targets: vec![test_ticket_target( + "test-repository", + "other", + TicketTargetAccess::ReadWrite, + )], }), ..Default::default() }, @@ -32362,7 +32747,7 @@ mod tests { )); let mut missing = ticket::NewTicket::new("Missing target"); - missing.repository_id = Some("unknown".to_owned()); + set_test_ticket_target(&mut missing, "unknown", "develop"); assert!(backend.create(missing).is_err()); assert!(matches!( backend.set_workflow_state( @@ -32373,6 +32758,29 @@ mod tests { )); } + #[tokio::test] + async fn merge_request_target_rejects_read_only_repository() { + let dir = tempfile::tempdir().unwrap(); + init_clean_git_workspace(dir.path()); + let api = test_api_with_docs_repository(dir.path()).await; + let writable_repository_id = test_repository_id(&api); + let docs_repository_id = test_repository_id_by_key(&api, "docs"); + let mut input = ticket::NewTicket::new("Read-only MR target"); + input.workflow_state = Some(TicketWorkflowState::Queued); + input.targets = vec![ + test_ticket_target("test-repository", "develop", TicketTargetAccess::ReadWrite), + test_ticket_target("docs", "develop", TicketTargetAccess::ReadOnly), + ]; + let ticket = browser_ticket_backend(&api).unwrap().create(input).unwrap(); + + require_ticket_read_write_target(&api, &ticket.id, &writable_repository_id, "develop") + .unwrap(); + let error = + require_ticket_read_write_target(&api, &ticket.id, &docs_repository_id, "develop") + .unwrap_err(); + assert_eq!(error.into_response().status(), StatusCode::BAD_REQUEST); + } + #[test] fn worker_source_actor_roles_use_canonical_vocabulary() { assert_eq!(worker_source_actor_role(true, false), "coder"); @@ -32504,8 +32912,7 @@ mod tests { execution.take_inputs(); let mut input = ticket::NewTicket::new("Bounded notification"); - input.repository_id = Some(test_repository_id(&api)); - input.ref_selector = Some("develop".to_owned()); + set_test_ticket_target(&mut input, "test-repository", "develop"); let ticket = browser_ticket_backend(&api).unwrap().create(input).unwrap(); assign_test_orchestrator(&api, &ticket.id); let ticket_id = TicketIdOrSlug::Id(ticket.id.clone()); @@ -33254,8 +33661,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(&api)); - input.ref_selector = Some("develop".to_string()); + set_test_ticket_target(&mut input, "test-repository", "develop"); let ticket = backend.create(input).unwrap(); let path = (TEST_WORKSPACE_ID.to_string(), ticket.id.clone()); @@ -33317,13 +33723,11 @@ 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(&api)); - first_input.ref_selector = Some("develop".to_string()); + set_test_ticket_target(&mut first_input, "test-repository", "develop"); 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(&api)); - second_input.ref_selector = Some("develop".to_string()); + set_test_ticket_target(&mut second_input, "test-repository", "develop"); let second = backend.create(second_input).unwrap(); for (ticket_id, target) in [ (first.id.clone(), second.id.clone()), @@ -33370,13 +33774,11 @@ 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(&api)); - dependency_input.ref_selector = Some("missing-ref".to_string()); + set_test_ticket_target(&mut dependency_input, "test-repository", "missing-ref"); 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(&api)); - root_input.ref_selector = Some("develop".to_string()); + set_test_ticket_target(&mut root_input, "test-repository", "develop"); let root = backend.create(root_input).unwrap(); backend .add_ticket_relation( @@ -33424,13 +33826,11 @@ 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(&api)); - dependency_input.ref_selector = Some("develop".to_string()); + set_test_ticket_target(&mut dependency_input, "test-repository", "develop"); 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(&api)); - root_input.ref_selector = Some("develop".to_string()); + set_test_ticket_target(&mut root_input, "test-repository", "develop"); let root = backend.create(root_input).unwrap(); backend .add_ticket_relation( @@ -33783,8 +34183,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(&api)); - input.ref_selector = Some("HEAD".to_owned()); + set_test_ticket_target(&mut input, "test-repository", "HEAD"); let ticket_ref = backend.create(input).unwrap(); assign_test_orchestrator(&api, &ticket_ref.id); *api.orchestrator_attention_fingerprint.lock().unwrap() = Some(ticket_ref.id.clone()); @@ -33908,18 +34307,20 @@ mod tests { } #[tokio::test] - async fn worker_spawn_and_restore_assignment_operations_are_idempotent() { + async fn runtime_worker_ticket_assignment_rejects_missing_target_attachments() { let dir = tempfile::tempdir().unwrap(); + init_clean_git_workspace(dir.path()); let api = test_api(dir.path()).await; let backend = browser_ticket_backend(&api).unwrap(); - let mut first_ticket_input = ticket::NewTicket::new("Spawn assignment"); - first_ticket_input.workflow_state = Some(TicketWorkflowState::Queued); - let first_ticket = backend.create(first_ticket_input).unwrap(); - assign_test_orchestrator(&api, &first_ticket.id); + let mut input = ticket::NewTicket::new("Unsafe direct Runtime assignment"); + input.workflow_state = Some(TicketWorkflowState::Queued); + set_test_ticket_target(&mut input, "test-repository", "develop"); + let ticket = backend.create(input).unwrap(); + assign_test_orchestrator(&api, &ticket.id); let request = server_api::RuntimeWorkerSpawnRequest { requested_worker_name: Some("assigned-spawn".to_string()), intent: server_api::RuntimeWorkerSpawnIntent::TicketRole { - ticket_id: first_ticket.id.clone(), + ticket_id: ticket.id.clone(), role: server_api::RuntimeTicketWorkerRole::Coder, }, acceptance: server_api::RuntimeWorkerSpawnAcceptanceRequirement::RunAccepted { @@ -33927,8 +34328,8 @@ mod tests { }, profile: server_api::RuntimeProfileSelector::Builtin("builtin:coder".to_string()), ticket_assignment: Some(server_api::RuntimeWorkerTicketAssignmentRequest { - ticket_id: first_ticket.id.clone(), - operation_id: "spawn-assignment-operation".to_string(), + ticket_id: ticket.id.clone(), + operation_id: "unsafe-runtime-assignment".to_string(), }), initial_submit: vec![ serde_json::to_value(Segment::Flow { @@ -33938,495 +34339,28 @@ mod tests { ], workdir_attachment_requests: Vec::new(), }; - let Json(first) = scoped_create_runtime_worker( + + let error = scoped_create_runtime_worker( State(api.clone()), AxumPath(ScopedRuntimePath { workspace_id: TEST_WORKSPACE_ID.to_string(), runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), }), - Json(request.clone()), - ) - .await - .unwrap(); - let first_worker = first.worker.unwrap(); - let first_worker_ref = - RuntimeWorkerRef::new(&first_worker.runtime_id, &first_worker.worker_id); - let Json(projected) = scoped_list_ticket_assignments( - State(api.clone()), - AxumPath(ScopedRecordPath { - workspace_id: TEST_WORKSPACE_ID.to_string(), - id: first_ticket.id.clone(), - }), - ) - .await - .unwrap(); - assert!(matches!( - &projected.assignments[0].principal, - server_api::TicketAssignmentPrincipal::Worker { worker_id, .. } - if worker_id == &first_worker.worker_id - )); - assert_eq!( - backend - .show(first_ticket.id.clone().into()) - .unwrap() - .meta - .workflow_state, - TicketWorkflowState::InProgress - ); - let Json(retried) = scoped_create_runtime_worker( - State(api.clone()), - AxumPath(ScopedRuntimePath { - workspace_id: TEST_WORKSPACE_ID.to_string(), - runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), - }), - Json(request.clone()), - ) - .await - .unwrap(); - assert_eq!(retried.worker.unwrap().worker_id, first_worker.worker_id); - assert_eq!( - api.store - .list_ticket_coder_assignment_events(TEST_WORKSPACE_ID, &first_ticket.id, 10,) - .unwrap() - .len(), - 1 - ); - - let current = api - .store - .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &first_ticket.id) - .unwrap() - .unwrap(); - api.store - .clear_current_ticket_worker_assignment( - TEST_WORKSPACE_ID, - &first_ticket.id, - Some(¤t.assignment_id), - "spawn-unassign-operation", - "spawn-unassign-event", - "test-user", - TEST_CREATED_AT, - ) - .unwrap(); - api.runtime - .stop_worker( - &first_worker_ref, - WorkerLifecycleRequest { - reason: Some("restore assignment test".to_string()), - ticket_assignment: None, - }, - ) - .unwrap(); - let mut second_ticket_input = ticket::NewTicket::new("Restore assignment"); - second_ticket_input.workflow_state = Some(TicketWorkflowState::Queued); - let second_ticket = backend.create(second_ticket_input).unwrap(); - assign_test_orchestrator(&api, &second_ticket.id); - let _ = scoped_restore_runtime_worker( - State(api.clone()), - AxumPath(ScopedRuntimeWorkerPath { - workspace_id: TEST_WORKSPACE_ID.to_string(), - worker: RuntimeWorkerRef::new( - EMBEDDED_WORKER_RUNTIME_ID, - first_worker.worker_id.clone(), - ), - }), - Query(RestoreTicketAssignmentQuery { - ticket_id: Some(second_ticket.id.clone()), - assignment_operation_id: Some("restore-assignment-operation".to_string()), - }), - ) - .await - .unwrap(); - let Json(retried_restore) = scoped_restore_runtime_worker( - State(api.clone()), - AxumPath(ScopedRuntimeWorkerPath { - workspace_id: TEST_WORKSPACE_ID.to_string(), - worker: RuntimeWorkerRef::new( - EMBEDDED_WORKER_RUNTIME_ID, - first_worker.worker_id.clone(), - ), - }), - Query(RestoreTicketAssignmentQuery { - ticket_id: Some(second_ticket.id.clone()), - assignment_operation_id: Some("restore-assignment-operation".to_string()), - }), - ) - .await - .unwrap(); - assert_eq!(retried_restore.worker_id, first_worker.worker_id); - assert_eq!( - retried_restore.result.state, - server_api::WorkerRestoreState::Accepted - ); - let restored_assignment = api - .store - .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &second_ticket.id) - .unwrap() - .unwrap(); - assert_eq!(restored_assignment.worker.worker_id, first_worker.worker_id); - assert_eq!( - backend - .show(second_ticket.id.clone().into()) - .unwrap() - .meta - .workflow_state, - TicketWorkflowState::InProgress - ); - - api.store - .clear_current_ticket_worker_assignment( - TEST_WORKSPACE_ID, - &second_ticket.id, - Some(&restored_assignment.assignment_id), - "restore-clear-operation", - "restore-clear-event", - "test", - TEST_CREATED_AT, - ) - .unwrap(); - let pending_api_request = server_api::RuntimeWorkerSpawnRequest { - intent: server_api::RuntimeWorkerSpawnIntent::TicketRole { - ticket_id: second_ticket.id.clone(), - role: server_api::RuntimeTicketWorkerRole::Coder, - }, - ticket_assignment: Some(server_api::RuntimeWorkerTicketAssignmentRequest { - ticket_id: second_ticket.id.clone(), - operation_id: "pending-spawn-operation".to_string(), - }), - ..request - }; - let mut pending_request = - worker_spawn_request_from_api(pending_api_request.clone()).unwrap(); - pending_request.resolved_workspace_api = - Some(test_worker_workspace_api(EMBEDDED_WORKER_RUNTIME_ID)); - let (_, pending_fingerprint) = crate::hosts::worker_spawn_idempotency(&pending_request) - .unwrap() - .unwrap(); - api.store - .reserve_ticket_assignment_operation( - TEST_WORKSPACE_ID, - "pending-spawn-operation", - &second_ticket.id, - EMBEDDED_WORKER_RUNTIME_ID, - None, - &pending_fingerprint, - TEST_CREATED_AT, - ) - .unwrap(); - let current_memory_settings = api - .config_store - .get_workspace_memory_settings(TEST_WORKSPACE_ID) - .unwrap(); - let reservation = api - .config_store - .reserve_worker_create( - TEST_WORKSPACE_ID, - EMBEDDED_WORKER_RUNTIME_ID, - "pending-spawn-operation", - &pending_fingerprint, - ¤t_memory_settings, - ) - .unwrap(); - pending_request.resolved_memory_settings = Some(reservation.memory_settings.clone()); - let spawned_before_backend_failure = api - .runtime - .spawn_worker( - EMBEDDED_WORKER_RUNTIME_ID, - WorkerCreateBinding { - worker_id: reservation.worker_id, - create_fingerprint: reservation.create_fingerprint, - }, - pending_request.clone(), - ) - .unwrap() - .worker - .unwrap(); - assert!( - api.store - .get_ticket_assignment_operation(TEST_WORKSPACE_ID, "pending-spawn-operation") - .unwrap() - .is_some_and(|operation| operation.worker.is_none()) - ); - let worker_count_before_retry = api.runtime.list_workers(100).items.len(); - let Json(reconciled) = scoped_create_runtime_worker( - State(api.clone()), - AxumPath(ScopedRuntimePath { - workspace_id: TEST_WORKSPACE_ID.to_string(), - runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), - }), - Json(pending_api_request), - ) - .await - .unwrap(); - assert_eq!( - reconciled.worker.unwrap().worker_id, - spawned_before_backend_failure.worker.worker_id - ); - assert_eq!( - api.runtime.list_workers(100).items.len(), - worker_count_before_retry, - "retrying a reserved lifecycle operation must not spawn another Worker" - ); - assert!( - api.store - .get_ticket_assignment_operation(TEST_WORKSPACE_ID, "pending-spawn-operation") - .unwrap() - .and_then(|operation| operation.assignment_id) - .is_some() - ); - } - - #[tokio::test] - async fn worker_spawn_finalize_failure_reports_stage_and_compensation_outcomes() { - let dir = tempfile::tempdir().unwrap(); - let api = test_api(dir.path()).await; - register_test_runtime(&api, WorkdirlessFixtureRuntime::RUNTIME_ID).await; - api.runtime - .register_or_replace(WorkdirlessFixtureRuntime::default()); - let mut ticket_input = ticket::NewTicket::new("Compensation test Ticket"); - ticket_input.workflow_state = Some(TicketWorkflowState::InProgress); - let ticket_id = browser_ticket_backend(&api) - .unwrap() - .create(ticket_input) - .unwrap() - .id; - assign_test_orchestrator(&api, &ticket_id); - let assignment = crate::hosts::WorkerTicketAssignmentRequest { - ticket_id: ticket_id.clone(), - operation_id: "compensation-test-operation".to_string(), - }; - let request = server_api::RuntimeWorkerSpawnRequest { - intent: server_api::RuntimeWorkerSpawnIntent::TicketRole { - ticket_id: ticket_id.clone(), - role: server_api::RuntimeTicketWorkerRole::Coder, - }, - requested_worker_name: Some("Compensation test Worker".to_string()), - acceptance: server_api::RuntimeWorkerSpawnAcceptanceRequirement::RunAccepted { - expected_segments: 1, - }, - profile: server_api::RuntimeProfileSelector::Builtin("builtin:coder".to_string()), - ticket_assignment: Some(server_api::RuntimeWorkerTicketAssignmentRequest { - ticket_id: assignment.ticket_id.clone(), - operation_id: assignment.operation_id.clone(), - }), - initial_submit: vec![ - serde_json::to_value(Segment::Flow { - selector: "builtin:coder-review".to_string(), - }) - .unwrap(), - ], - workdir_attachment_requests: Vec::new(), - }; - let Json(created) = scoped_create_runtime_worker( - State(api.clone()), - AxumPath(ScopedRuntimePath { - workspace_id: TEST_WORKSPACE_ID.to_string(), - runtime_id: WorkdirlessFixtureRuntime::RUNTIME_ID.to_string(), - }), Json(request), ) .await - .unwrap(); - let created_worker = created.worker.unwrap(); - let worker_ref = - RuntimeWorkerRef::new(&created_worker.runtime_id, &created_worker.worker_id); - let mut worker = api.runtime.worker(&worker_ref).unwrap(); - assert!( - api.store - .get_worker_registry(TEST_WORKSPACE_ID, &worker.worker) - .unwrap() - .is_some() - ); - - let spawned_workdir_ids = vec![ - "spawned-workdir-left".to_string(), - "spawned-workdir-right".to_string(), - ]; - for workdir_id in &spawned_workdir_ids { - seed_cleanup_workdir_for_runtime( - &api, - workdir_id, - WorkdirlessFixtureRuntime::RUNTIME_ID, - "present", - "clean", - ); - } - worker.workdir_attachments = spawned_workdir_ids - .iter() - .enumerate() - .map(|(index, workdir_id)| { - let mut working_directory = WorkdirlessFixtureRuntime::workdir_status(workdir_id); - if index == 1 { - working_directory.summary.display_name = - Some("inject-registry-upsert-failure".to_string()); - } - WorkingDirectoryAttachmentStatus { - alias: workdir::WorkdirAttachmentAlias::new(if index == 0 { - "left" - } else { - "right" - }) - .unwrap(), - working_directory, - } - }) - .collect(); - let worker_record = api - .store - .get_worker_registry(TEST_WORKSPACE_ID, &worker.worker) - .unwrap() - .unwrap(); - rusqlite::Connection::open(&api.config.database_path) - .unwrap() - .execute_batch( - r#" - CREATE TABLE spawn_finalize_attachment_audit(alias TEXT NOT NULL); - CREATE TRIGGER audit_spawn_finalize_attachment - AFTER INSERT ON worker_workdir_links - BEGIN - INSERT INTO spawn_finalize_attachment_audit(alias) VALUES (NEW.alias); - END; - CREATE TRIGGER fail_second_spawn_workdir_upsert - BEFORE UPDATE ON workdir_registry - WHEN NEW.display_name = 'inject-registry-upsert-failure' - BEGIN - SELECT RAISE(ABORT, 'injected second Workdir registry failure'); - END; - "#, - ) - .unwrap(); - - let context = WorkerSpawnCompensationContext { - assignment: Some(&assignment), - spawned_workdir_ids: &spawned_workdir_ids, - }; - let error = - finalize_spawned_worker_workdir_attachments(&api, &worker, &worker_record, &context) - .unwrap_err(); - assert!(matches!( - error.error, - Error::RuntimeOperationFailed { ref code, .. } - if code == "worker_spawn_finalize_workdir_registry_failed" - )); - assert!(error.diagnostics.iter().any(|diagnostic| { - diagnostic.code == "worker_spawn_finalize_workdir_registry_failed" - && diagnostic.message.contains(&worker.worker.runtime_id) - && diagnostic.message.contains(&worker.worker.worker_id) - })); - let audit_connection = rusqlite::Connection::open(&api.config.database_path).unwrap(); - let mut audit_statement = audit_connection - .prepare("SELECT alias FROM spawn_finalize_attachment_audit ORDER BY rowid") - .unwrap(); - let finalized_aliases = audit_statement - .query_map([], |row| row.get::<_, String>(0)) - .unwrap() - .collect::, _>>() - .unwrap(); - assert_eq!(finalized_aliases, ["left"]); - assert!( - error - .diagnostics - .iter() - .any(|diagnostic| diagnostic.code == "worker_spawn_compensated"), - "{:?}", - error.diagnostics - ); - assert!(matches!( - api.runtime.worker(&worker.worker), - Err(RuntimeRegistryError::UnknownWorker { .. }) - )); - assert!( - api.store - .get_worker_registry(TEST_WORKSPACE_ID, &worker.worker) - .unwrap() - .is_none() - ); - assert!( - api.store - .get_ticket_assignment_operation(TEST_WORKSPACE_ID, &assignment.operation_id) - .unwrap() - .is_none() - ); - assert!( - api.store - .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &ticket_id) - .unwrap() - .is_none() - ); - - for workdir_id in &spawned_workdir_ids { - assert!( - api.store - .get_workdir_registry(TEST_WORKSPACE_ID, workdir_id) - .unwrap() - .is_none(), - "spawn compensation must clean every created Workdir: {workdir_id}" - ); - } - - let missing_summary_workdir_ids = vec![ - "missing-summary-workdir-left".to_string(), - "missing-summary-workdir-right".to_string(), - ]; - for workdir_id in &missing_summary_workdir_ids { - seed_cleanup_workdir_for_runtime( - &api, - workdir_id, - WorkdirlessFixtureRuntime::RUNTIME_ID, - "present", - "clean", - ); - } - let missing_summary_context = WorkerSpawnCompensationContext { - assignment: None, - spawned_workdir_ids: &missing_summary_workdir_ids, - }; - let reservation_worker_id = WorkerId::now_v7(); - let _ = compensate_failed_workspace_worker_create( - &api, - EMBEDDED_WORKER_RUNTIME_ID, - reservation_worker_id, - "missing-summary-fingerprint", - "runtime_spawn_missing_summary", - None, - &missing_summary_context, - &[], - ); - for workdir_id in &missing_summary_workdir_ids { - assert!( - api.store - .get_workdir_registry(TEST_WORKSPACE_ID, workdir_id) - .unwrap() - .is_none(), - "missing-summary compensation must clean every created Workdir: {workdir_id}" - ); - } - - let mut residual_worker = worker.clone(); - residual_worker.worker.runtime_id = "missing-runtime".to_string(); - let residual_context = WorkerSpawnCompensationContext { - assignment: None, - spawned_workdir_ids: &[], - }; - let residual_error = finalize_worker_spawn_stage::<()>( - &api, - &residual_worker, - &residual_context, - WorkerSpawnFinalizeStage::WorkerRegistry, - Err(Error::RegistryInconsistency("injected registry failure".to_string()).into()), - ) .unwrap_err(); - assert!(residual_error.diagnostics.iter().any(|diagnostic| { - diagnostic.code == "worker_spawn_compensation_runtime_delete_failed" - && diagnostic.message.contains("missing-runtime") - })); + assert_eq!(error.into_response().status(), StatusCode::BAD_REQUEST); assert!( - !residual_error - .diagnostics - .iter() - .any(|diagnostic| diagnostic.code == "worker_spawn_compensated") + api.store + .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &ticket.id) + .unwrap() + .is_none() + ); + assert_eq!( + backend.show(ticket.id.into()).unwrap().meta.workflow_state, + TicketWorkflowState::Queued ); - assert!(residual_error.error.to_string().contains("residual state")); } #[tokio::test] @@ -34476,8 +34410,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(&api)); - related_input.ref_selector = Some("develop".to_string()); + set_test_ticket_target(&mut related_input, "test-repository", "develop"); let related_ticket_id = browser_ticket_backend(&api) .unwrap() .create(related_input) @@ -34506,8 +34439,11 @@ mod tests { new_string: None, replace_all: false, target: Some(server_api::BrowserTicketTargetEdit::Set { - repository_key: "test-repository".to_string(), - ref_selector: Some("develop".to_string()), + targets: vec![test_ticket_target( + "test-repository", + "develop", + TicketTargetAccess::ReadWrite, + )], }), author: Some("browser-user".to_string()), }), @@ -34516,8 +34452,14 @@ mod tests { .unwrap(); assert_eq!(edited.title, "Browser Ticket API edited"); assert_eq!(edited.body, "Updated from the Browser API."); - assert_eq!(edited.repository_key.as_deref(), Some("test-repository")); - assert_eq!(edited.ref_selector.as_deref(), Some("develop")); + assert_eq!( + edited.targets, + vec![test_ticket_target( + "test-repository", + "develop", + TicketTargetAccess::ReadWrite, + )] + ); assert!(edited.assignments.is_empty()); assert!(edited.assignment_diagnostics.is_empty()); assert_eq!(edited.relations.outgoing.len(), 1); @@ -34941,6 +34883,35 @@ mod tests { test_api_with_recording_backend(workspace_root).await.0 } + async fn test_api_with_docs_repository(workspace_root: impl Into) -> WorkspaceApi { + let workspace_root = workspace_root.into(); + let docs_root = workspace_root.join("docs-repository"); + fs::create_dir_all(&docs_root).unwrap(); + init_clean_git_workspace(&docs_root); + let mut config = test_server_config(&workspace_root); + let mut docs = config.repositories[0].clone(); + let docs_source = server_api::RepositorySource { + kind: server_api::RepositorySourceKind::LocalPath, + uri: docs_root.display().to_string(), + }; + docs.id = "docs".to_string(); + docs.repository_key = "docs".to_string(); + docs.source_fingerprint = + crate::repository_source::repository_source_fingerprint(&docs_source); + docs.source = docs_source; + docs.path = Some(docs_root); + docs.default_selector = Some("develop".to_string()); + config.repositories.push(docs); + let store = SqliteWorkspaceStore::open(config.database_path.clone()).unwrap(); + WorkspaceApi::new_with_execution_backend( + config, + Arc::new(store), + Arc::new(DeterministicExecutionBackend::default()), + ) + .await + .unwrap() + } + fn test_owner_actor() -> RequestActor { RequestActor { user_id: "owner-user".to_string(), @@ -34952,13 +34923,41 @@ mod tests { } fn test_repository_id(api: &WorkspaceApi) -> String { + test_repository_id_by_key(api, "test-repository") + } + + fn test_repository_id_by_key(api: &WorkspaceApi, repository_key: &str) -> String { api.store - .get_repository_by_key(TEST_WORKSPACE_ID, "test-repository") + .get_repository_by_key(TEST_WORKSPACE_ID, repository_key) .unwrap() .expect("test Repository exists") .repository_id } + fn test_ticket_target( + repository_key: impl Into, + ref_selector: impl Into, + access: TicketTargetAccess, + ) -> ticket::TicketTarget { + ticket::TicketTarget { + repository_key: repository_key.into(), + ref_selector: Some(ref_selector.into()), + access, + } + } + + fn set_test_ticket_target( + input: &mut ticket::NewTicket, + repository_key: impl Into, + ref_selector: impl Into, + ) { + input.targets = vec![test_ticket_target( + repository_key, + ref_selector, + TicketTargetAccess::ReadWrite, + )]; + } + fn set_test_default_runtime(api: &WorkspaceApi, runtime_id: &str) { let current = api .config_store @@ -36119,6 +36118,7 @@ mod tests { worker: RuntimeWorkerRef::new("runtime-test", runtime_worker_id.to_string()), workdir_id: workdir_id.to_string(), alias: "attachment".to_string(), + capabilities: workdir::WorkdirSessionCapabilities::ALL, linked_at: now_registry_timestamp(), unlinked_at: None, }) @@ -40394,6 +40394,7 @@ mod tests { worker: RuntimeWorkerRef::new("embedded-worker-runtime", &worker_id), workdir_id: workdir_id.to_string(), alias: "attachment".to_string(), + capabilities: workdir::WorkdirSessionCapabilities::ALL, linked_at: "2".to_string(), unlinked_at: None, }) @@ -40790,6 +40791,7 @@ mod tests { worker: RuntimeWorkerRef::new(EMBEDDED_WORKER_RUNTIME_ID, &worker_id), workdir_id: workdir_id.to_string(), alias: "attachment".to_string(), + capabilities: workdir::WorkdirSessionCapabilities::ALL, linked_at: "1".to_string(), unlinked_at: None, }) diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index c27422561..3fc00c173 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -21,7 +21,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore; use crate::{Error, Result}; const OLDEST_SCHEMA_VERSION: i64 = 50; -const LATEST_SCHEMA_VERSION: i64 = 65; +const LATEST_SCHEMA_VERSION: i64 = 66; const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline"; const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings"; const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit"; @@ -46,6 +46,10 @@ const MULTI_WORKDIR_ATTACHMENTS_MIGRATION_NAME: &str = const EXTERNAL_WORKDIR_GRANTS_MIGRATION_NAME: &str = "typed client-hosted External Workdir grants"; const EXTERNAL_WORKDIR_CLEANUP_MIGRATION_NAME: &str = "durable External Workdir expiry cleanup state"; +const TICKET_TARGETS_AND_WORKDIR_CAPABILITIES_MIGRATION_NAME: &str = + "Ticket target collection and Workdir attachment capabilities"; +const TICKET_SCHEMA_VERSION_WITH_TARGETS: i64 = 7; +const TICKET_SCHEMA_BASELINE_NAME: &str = "ticket schema baseline"; const WORKER_REGISTRY_PROJECTION_SCHEMA: &str = r#" CREATE TABLE IF NOT EXISTS worker_registry_observations ( workspace_id TEXT NOT NULL, @@ -166,6 +170,11 @@ const MIGRATIONS: &[Migration] = &[ name: EXTERNAL_WORKDIR_CLEANUP_MIGRATION_NAME, apply: migrate_external_workdir_cleanup_v64_to_v65, }, + Migration { + version: 66, + name: TICKET_TARGETS_AND_WORKDIR_CAPABILITIES_MIGRATION_NAME, + apply: migrate_ticket_targets_and_workdir_capabilities_v65_to_v66, + }, ]; #[derive(Clone, Copy)] @@ -873,6 +882,7 @@ pub struct WorkerWorkdirLinkRecord { pub worker: RuntimeWorkerRef, pub workdir_id: String, pub alias: String, + pub capabilities: workdir::WorkdirSessionCapabilities, pub linked_at: String, pub unlinked_at: Option, } @@ -6848,23 +6858,29 @@ impl ControlPlaneStore for SqliteWorkspaceStore { ))); } - let (state, repository_id, ref_selector): (String, Option, Option) = tx - .query_row( - "SELECT workflow_state, repository_id, ref_selector FROM typed_tickets - WHERE workspace_id = ?1 AND ticket_id = ?2", - params![record.workspace_id, record.ticket_id], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), - )?; + let state: String = tx.query_row( + "SELECT workflow_state FROM typed_tickets + WHERE workspace_id = ?1 AND ticket_id = ?2", + params![record.workspace_id, record.ticket_id], + |row| row.get(0), + )?; if state != "ready" { return Err(Error::TicketAssignmentConflict(format!( "manual Coder assignment requires ready Ticket; current state is `{state}`" ))); } - if repository_id.as_deref().is_none_or(str::is_empty) - || ref_selector.as_deref().is_none_or(str::is_empty) - { + let (target_count, read_write_count): (i64, i64) = tx.query_row( + "SELECT COUNT(*), + COALESCE(SUM(CASE WHEN access = 'read_write' THEN 1 ELSE 0 END), 0) + FROM typed_ticket_targets + WHERE workspace_id = ?1 AND ticket_id = ?2", + params![record.workspace_id, record.ticket_id], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + if target_count == 0 || read_write_count != 1 { return Err(Error::TicketAssignmentConflict( - "manual Coder assignment requires a valid repository/ref target".to_string(), + "manual Coder assignment requires exactly one read_write repository target" + .to_string(), )); } let unresolved_blockers: i64 = tx.query_row( @@ -8034,9 +8050,10 @@ impl ControlPlaneStore for SqliteWorkspaceStore { } tx.execute( r#"INSERT INTO worker_workdir_links ( - workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL) + workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL) ON CONFLICT(workspace_id, worker_id, workdir_id, alias) DO UPDATE SET + capabilities = excluded.capabilities, linked_at = excluded.linked_at, unlinked_at = NULL"#, params![ @@ -8045,6 +8062,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { record.worker.worker_id, record.workdir_id, record.alias, + encode_workdir_link_capabilities(record.capabilities)?, record.linked_at, ], ) @@ -8118,7 +8136,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { } let active_for_alias = tx .query_row( - r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at + r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at FROM worker_workdir_links WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 AND alias = ?4 AND unlinked_at IS NULL"#, @@ -8131,8 +8149,26 @@ impl ControlPlaneStore for SqliteWorkspaceStore { read_worker_workdir_link_record, ) .optional()?; - if let Some(active) = active_for_alias { + if let Some(mut active) = active_for_alias { if active.workdir_id == record.workdir_id { + let capabilities = active.capabilities.intersection(record.capabilities); + if capabilities != active.capabilities { + tx.execute( + r#"UPDATE worker_workdir_links + SET capabilities = ?1 + WHERE workspace_id = ?2 AND runtime_id = ?3 AND worker_id = ?4 + AND workdir_id = ?5 AND alias = ?6 AND unlinked_at IS NULL"#, + params![ + encode_workdir_link_capabilities(capabilities)?, + record.workspace_id, + record.worker.runtime_id, + record.worker.worker_id, + record.workdir_id, + record.alias, + ], + )?; + active.capabilities = capabilities; + } tx.commit()?; return Ok(active); } @@ -8143,7 +8179,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { } let active_for_workdir = tx .query_row( - r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at + r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at FROM worker_workdir_links WHERE workspace_id = ?1 AND workdir_id = ?2 AND unlinked_at IS NULL"#, params![record.workspace_id, record.workdir_id], @@ -8158,9 +8194,10 @@ impl ControlPlaneStore for SqliteWorkspaceStore { } let write = tx.execute( r#"INSERT INTO worker_workdir_links ( - workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL) + workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL) ON CONFLICT(workspace_id, worker_id, workdir_id, alias) DO UPDATE SET + capabilities = excluded.capabilities, linked_at = excluded.linked_at, unlinked_at = NULL"#, params![ @@ -8169,6 +8206,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { record.worker.worker_id, record.workdir_id, record.alias, + encode_workdir_link_capabilities(record.capabilities)?, record.linked_at, ], ); @@ -8200,7 +8238,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { )?; let active = tx .query_row( - r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at + r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at FROM worker_workdir_links WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 AND (?4 IS NULL OR workdir_id = ?4) AND unlinked_at IS NULL"#, @@ -8264,7 +8302,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { ) -> Result> { self.with_conn(|conn| { let mut stmt = conn.prepare( - r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at + r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at FROM worker_workdir_links WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 AND unlinked_at IS NULL ORDER BY linked_at DESC"#, @@ -8285,7 +8323,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { ) -> Result> { self.with_conn(|conn| { let mut stmt = conn.prepare( - r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at + r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at FROM worker_workdir_links WHERE workspace_id = ?1 AND workdir_id = ?2 AND unlinked_at IS NULL ORDER BY linked_at DESC"#, @@ -8306,7 +8344,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { ) -> Result> { self.with_conn(|conn| { conn.query_row( - r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at + r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at FROM worker_workdir_links WHERE workspace_id = ?1 AND workdir_id = ?2 ORDER BY linked_at DESC, rowid DESC @@ -8993,16 +9031,47 @@ fn read_device_login_flow_record( }) } +fn encode_workdir_link_capabilities( + capabilities: workdir::WorkdirSessionCapabilities, +) -> Result<&'static str> { + if capabilities == workdir::WorkdirSessionCapabilities::ALL { + Ok("all") + } else if capabilities == workdir::WorkdirSessionCapabilities::READ_ONLY { + Ok("read_only") + } else { + Err(Error::InvalidInput( + "Workdir attachment capabilities must be exactly all or read_only".to_string(), + )) + } +} + +fn decode_workdir_link_capabilities( + value: &str, + column: usize, +) -> rusqlite::Result { + match value { + "all" => Ok(workdir::WorkdirSessionCapabilities::ALL), + "read_only" => Ok(workdir::WorkdirSessionCapabilities::READ_ONLY), + _ => Err(rusqlite::Error::FromSqlConversionFailure( + column, + rusqlite::types::Type::Text, + format!("invalid Worker Workdir link capabilities {value:?}").into(), + )), + } +} + fn read_worker_workdir_link_record( row: &rusqlite::Row<'_>, ) -> rusqlite::Result { + let capabilities = decode_workdir_link_capabilities(&row.get::<_, String>(5)?, 5)?; Ok(WorkerWorkdirLinkRecord { workspace_id: row.get(0)?, worker: RuntimeWorkerRef::new(row.get::<_, String>(1)?, row.get::<_, String>(2)?), workdir_id: row.get(3)?, alias: row.get(4)?, - linked_at: row.get(5)?, - unlinked_at: row.get(6)?, + capabilities, + linked_at: row.get(6)?, + unlinked_at: row.get(7)?, }) } @@ -9913,13 +9982,27 @@ fn read_workdir_registry_record( fn prepare_connection(conn: &Connection) -> Result<()> { configure_sqlite(conn)?; - ticket::migrate_sqlite_ticket_schema(conn) - .map_err(|error| Error::Store(format!("Ticket schema verification failed: {error}")))?; - merge_request::migrate(conn).map_err(|error| { - Error::Store(format!("Merge Request schema verification failed: {error}")) - })?; - apply_migrations(conn) - .map_err(|error| Error::Store(format!("workspace schema migration failed: {error}")))?; + let workspace_schema_version = current_schema_version(conn)?; + if workspace_schema_version == 0 { + ticket::migrate_sqlite_ticket_schema(conn) + .map_err(|error| Error::Store(format!("Ticket schema verification failed: {error}")))?; + merge_request::migrate(conn).map_err(|error| { + Error::Store(format!("Merge Request schema verification failed: {error}")) + })?; + apply_migrations(conn) + .map_err(|error| Error::Store(format!("workspace schema migration failed: {error}")))?; + } else { + // Workspace migrations own cross-domain table rebuilds. Apply them before + // component verification so the Ticket crate sees its new canonical target + // schema and migration marker rather than rejecting the legacy singular shape. + apply_migrations(conn) + .map_err(|error| Error::Store(format!("workspace schema migration failed: {error}")))?; + ticket::migrate_sqlite_ticket_schema(conn) + .map_err(|error| Error::Store(format!("Ticket schema verification failed: {error}")))?; + merge_request::migrate(conn).map_err(|error| { + Error::Store(format!("Merge Request schema verification failed: {error}")) + })?; + } validate_workspace_resource_references(conn)?; verify_workspace_resource_constraints(conn) } @@ -11354,8 +11437,499 @@ fn migrate_external_workdir_cleanup_v64_to_v65(conn: &Connection) -> Result<()> Ok(()) } +fn migrate_ticket_targets_and_workdir_capabilities_v65_to_v66(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if current != 65 { + return Err(Error::Store(format!( + "expected schema version 65 before {TICKET_TARGETS_AND_WORKDIR_CAPABILITIES_MIGRATION_NAME} migration, found {current}" + ))); + } + apply_ticket_targets_and_workdir_capabilities_schema(conn) +} + +fn apply_ticket_targets_and_workdir_capabilities_schema(conn: &Connection) -> Result<()> { + let foreign_keys_enabled = + conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))? != 0; + let legacy_alter_table_enabled = + conn.query_row("PRAGMA legacy_alter_table", [], |row| row.get::<_, i64>(0))? != 0; + if foreign_keys_enabled { + conn.execute_batch("PRAGMA foreign_keys = OFF;")?; + } + if !legacy_alter_table_enabled { + conn.execute_batch("PRAGMA legacy_alter_table = ON;")?; + } + + let result = (|| { + let transaction = + rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + let db: &Connection = &transaction; + let ticket_columns = table_columns(db, "typed_tickets")? + .into_iter() + .collect::>(); + let has_repository_id = ticket_columns.contains("repository_id"); + let has_ref_selector = ticket_columns.contains("ref_selector"); + if has_repository_id != has_ref_selector { + return Err(Error::Store( + "typed_tickets has only one legacy singular target column".to_string(), + )); + } + + let ticket_migration_history = { + let mut statement = + db.prepare("SELECT version, name FROM ticket_schema_migrations ORDER BY version")?; + statement + .query_map([], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + })? + .collect::>>()? + }; + let expected_ticket_history = vec![( + TICKET_SCHEMA_VERSION_WITH_TARGETS - 1, + TICKET_SCHEMA_BASELINE_NAME.to_string(), + )]; + if !has_repository_id || ticket_migration_history != expected_ticket_history { + return Err(Error::Store(format!( + "schema-65 Ticket authority is not the canonical version {} singular-target baseline: columns={ticket_columns:?}, history={ticket_migration_history:?}", + TICKET_SCHEMA_VERSION_WITH_TARGETS - 1 + ))); + } + + if has_repository_id { + if table_exists(db, "typed_ticket_targets")? { + return Err(Error::Store( + "legacy singular Ticket targets coexist with typed_ticket_targets".to_string(), + )); + } + let incomplete_targets: i64 = db.query_row( + "SELECT COUNT(*) FROM typed_tickets + WHERE (repository_id IS NULL AND ref_selector IS NOT NULL) + OR (repository_id IS NOT NULL AND trim(repository_id) = '') + OR (ref_selector IS NOT NULL AND trim(ref_selector) = '') + OR (repository_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM repositories AS repository + WHERE repository.workspace_id = typed_tickets.workspace_id + AND repository.repository_id = typed_tickets.repository_id + ))", + [], + |row| row.get(0), + )?; + if incomplete_targets != 0 { + return Err(Error::Store(format!( + "cannot migrate {incomplete_targets} Ticket(s) with incomplete singular targets" + ))); + } + let mixed_version_attributes: i64 = db.query_row( + "SELECT COUNT(*) FROM typed_ticket_event_attributes + WHERE key IN ('targets', 'fingerprint_version')", + [], + |row| row.get(0), + )?; + if mixed_version_attributes != 0 { + return Err(Error::Store(format!( + "cannot migrate schema-65 Ticket evidence containing {mixed_version_attributes} canonical target attribute(s)" + ))); + } + let incomplete_event_targets: i64 = db.query_row( + "SELECT COUNT(*) + FROM typed_ticket_event_attributes AS attribute + WHERE attribute.key IN ('repository_id', 'ref_selector') + AND NOT EXISTS ( + SELECT 1 FROM typed_ticket_event_attributes AS counterpart + WHERE counterpart.workspace_id = attribute.workspace_id + AND counterpart.ticket_id = attribute.ticket_id + AND counterpart.event_index = attribute.event_index + AND counterpart.key = CASE attribute.key + WHEN 'repository_id' THEN 'ref_selector' + ELSE 'repository_id' + END + )", + [], + |row| row.get(0), + )?; + if incomplete_event_targets != 0 { + return Err(Error::Store(format!( + "cannot migrate {incomplete_event_targets} incomplete legacy Ticket target evidence attribute(s)" + ))); + } + let legacy_event_targets = { + let mut statement = db.prepare( + "SELECT attribute.workspace_id, attribute.ticket_id, attribute.event_index, + repository.repository_key, selector.value + FROM typed_ticket_event_attributes AS attribute + LEFT JOIN typed_ticket_event_attributes AS selector + ON selector.workspace_id = attribute.workspace_id + AND selector.ticket_id = attribute.ticket_id + AND selector.event_index = attribute.event_index + AND selector.key = 'ref_selector' + LEFT JOIN repositories AS repository + ON repository.workspace_id = attribute.workspace_id + AND repository.repository_id = attribute.value + WHERE attribute.key = 'repository_id' + ORDER BY attribute.workspace_id, attribute.ticket_id, attribute.event_index", + )?; + statement + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, Option>(4)?, + )) + })? + .collect::>>()? + }; + for (_, ticket_id, _, repository_key, ref_selector) in &legacy_event_targets { + if repository_key.is_none() || ref_selector.is_none() { + return Err(Error::Store(format!( + "cannot migrate legacy target evidence for Ticket {ticket_id:?}" + ))); + } + } + db.execute_batch( + r#" + CREATE TEMP TABLE typed_ticket_targets_v66 AS + SELECT ticket.workspace_id, ticket.ticket_id, 0 AS ordinal, + repository.repository_key, + ticket.ref_selector, 'read_write' AS access + FROM typed_tickets AS ticket + JOIN repositories AS repository + ON repository.workspace_id = ticket.workspace_id + AND repository.repository_id = ticket.repository_id + WHERE ticket.repository_id IS NOT NULL; + ALTER TABLE typed_tickets RENAME TO typed_tickets_legacy_v66; + CREATE TABLE typed_tickets ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + slug TEXT NOT NULL, + title TEXT NOT NULL, + status TEXT NOT NULL, + kind TEXT NOT NULL, + priority TEXT NOT NULL, + body TEXT NOT NULL, + created_at TEXT, + updated_at TEXT, + assignee TEXT, + readiness TEXT, + workflow_state TEXT NOT NULL, + workflow_state_explicit INTEGER NOT NULL, + queued_by TEXT, + queued_at TEXT, + resolution TEXT, + PRIMARY KEY (workspace_id, ticket_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); + INSERT INTO typed_tickets ( + workspace_id, ticket_id, slug, title, status, kind, priority, body, + created_at, updated_at, assignee, readiness, workflow_state, + workflow_state_explicit, queued_by, queued_at, resolution + ) + SELECT workspace_id, ticket_id, slug, title, status, kind, priority, body, + created_at, updated_at, assignee, readiness, workflow_state, + workflow_state_explicit, queued_by, queued_at, resolution + FROM typed_tickets_legacy_v66; + DROP TABLE typed_tickets_legacy_v66; + CREATE INDEX idx_typed_tickets_workspace_state_updated + ON typed_tickets(workspace_id, workflow_state, updated_at DESC, ticket_id); + CREATE INDEX idx_typed_tickets_workspace_updated + ON typed_tickets(workspace_id, updated_at DESC, ticket_id); + CREATE TRIGGER ticket_assignment_ticket_parent_tombstone + BEFORE DELETE ON typed_tickets + WHEN EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = OLD.workspace_id + AND assignment.ticket_id = OLD.ticket_id + ) + BEGIN + INSERT OR IGNORE INTO ticket_assignment_ticket_tombstones ( + workspace_id, ticket_id, deleted_at + ) VALUES (OLD.workspace_id, OLD.ticket_id, CURRENT_TIMESTAMP); + END; + CREATE TABLE typed_ticket_targets ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + ordinal INTEGER NOT NULL, + repository_key TEXT NOT NULL, + ref_selector TEXT, + access TEXT NOT NULL CHECK (access IN ('read_only', 'read_write')), + PRIMARY KEY (workspace_id, ticket_id, ordinal), + UNIQUE (workspace_id, ticket_id, repository_key), + FOREIGN KEY (workspace_id, ticket_id) + REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, repository_key) + REFERENCES repositories(workspace_id, repository_key) ON DELETE RESTRICT + ); + INSERT INTO typed_ticket_targets ( + workspace_id, ticket_id, ordinal, repository_key, ref_selector, access + ) + SELECT workspace_id, ticket_id, ordinal, repository_key, ref_selector, access + FROM typed_ticket_targets_v66; + DROP TABLE typed_ticket_targets_v66; + CREATE INDEX typed_ticket_targets_workspace_repository + ON typed_ticket_targets(workspace_id, repository_key, ticket_id); + "#, + )?; + for (workspace_id, ticket_id, event_index, repository_key, ref_selector) in + legacy_event_targets + { + let repository_key = repository_key.ok_or_else(|| { + Error::Store(format!( + "legacy target evidence for Ticket {ticket_id:?} has no repository key" + )) + })?; + let ref_selector = ref_selector.ok_or_else(|| { + Error::Store(format!( + "legacy target evidence for Ticket {ticket_id:?} has no ref selector" + )) + })?; + let targets = serde_json::to_string(&[serde_json::json!({ + "repository_key": repository_key, + "ref_selector": ref_selector, + "access": "read_write", + })]) + .map_err(|error| { + Error::Store(format!( + "serialize migrated Ticket target evidence: {error}" + )) + })?; + db.execute( + "INSERT INTO typed_ticket_event_attributes ( + workspace_id, ticket_id, event_index, key, value + ) VALUES (?1, ?2, ?3, 'targets', ?4)", + params![workspace_id, ticket_id, event_index, targets], + )?; + db.execute( + "INSERT INTO typed_ticket_event_attributes ( + workspace_id, ticket_id, event_index, key, value + ) VALUES (?1, ?2, ?3, 'fingerprint_version', '1')", + params![workspace_id, ticket_id, event_index], + )?; + } + db.execute( + "DELETE FROM typed_ticket_event_attributes + WHERE key IN ('repository_id', 'ref_selector')", + [], + )?; + } + + if column_exists(db, "worker_workdir_links", "capabilities")? { + return Err(Error::Store( + "schema-65 Worker Workdir links already contain schema-66 capabilities".to_string(), + )); + } + let unmappable_links: i64 = db.query_row( + "SELECT COUNT(*) + FROM worker_workdir_links AS link + LEFT JOIN workdir_registry AS registry + ON registry.workspace_id = link.workspace_id + AND registry.workdir_id = link.workdir_id + WHERE registry.workdir_id IS NULL + OR registry.source_kind NOT IN ('repository', 'external_grant')", + [], + |row| row.get(0), + )?; + if unmappable_links != 0 { + return Err(Error::Store(format!( + "cannot migrate {unmappable_links} Workdir attachment link(s) without a supported registered source" + ))); + } + db.execute_batch( + r#" + CREATE TEMP TABLE worker_workdir_links_v66 AS + SELECT link.workspace_id, link.runtime_id, link.worker_id, + link.workdir_id, link.alias, + CASE registry.source_kind + WHEN 'repository' THEN 'all' + WHEN 'external_grant' THEN 'read_only' + END AS capabilities, + link.linked_at, link.unlinked_at + FROM worker_workdir_links AS link + JOIN workdir_registry AS registry + ON registry.workspace_id = link.workspace_id + AND registry.workdir_id = link.workdir_id; + ALTER TABLE worker_workdir_links RENAME TO worker_workdir_links_legacy_v66; + CREATE TABLE worker_workdir_links ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + alias TEXT NOT NULL, + capabilities TEXT NOT NULL CHECK (capabilities IN ('all', 'read_only')), + linked_at TEXT NOT NULL, + unlinked_at TEXT, + PRIMARY KEY (workspace_id, worker_id, workdir_id, alias), + FOREIGN KEY (workspace_id, worker_id) + REFERENCES worker_registry(workspace_id, worker_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, workdir_id) + REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE + ); + INSERT INTO worker_workdir_links ( + workspace_id, runtime_id, worker_id, workdir_id, alias, + capabilities, linked_at, unlinked_at + ) + SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, + capabilities, linked_at, unlinked_at + FROM worker_workdir_links_v66; + DROP TABLE worker_workdir_links_legacy_v66; + DROP TABLE worker_workdir_links_v66; + CREATE UNIQUE INDEX worker_workdir_links_active_workdir_unique + ON worker_workdir_links(workspace_id, workdir_id) WHERE unlinked_at IS NULL; + CREATE UNIQUE INDEX worker_workdir_links_active_alias_unique + ON worker_workdir_links(workspace_id, worker_id, alias) WHERE unlinked_at IS NULL; + CREATE INDEX worker_workdir_links_workdir + ON worker_workdir_links(workspace_id, workdir_id); + CREATE TRIGGER workdir_attachment_insert_blocked_by_runtime_removal + BEFORE INSERT ON worker_workdir_links FOR EACH ROW + WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') + ) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + CREATE TRIGGER workdir_attachment_update_blocked_by_runtime_removal + BEFORE UPDATE ON worker_workdir_links FOR EACH ROW + WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') + ) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + "#, + )?; + + let invalid_capabilities: i64 = db.query_row( + "SELECT COUNT(*) FROM worker_workdir_links + WHERE capabilities IS NULL OR capabilities NOT IN ('all', 'read_only')", + [], + |row| row.get(0), + )?; + if invalid_capabilities != 0 { + return Err(Error::Store(format!( + "schema-66 migration produced {invalid_capabilities} invalid Workdir attachment capability row(s)" + ))); + } + + db.execute("DELETE FROM ticket_schema_migrations", [])?; + db.execute( + "INSERT INTO ticket_schema_migrations (version, name, applied_at) + VALUES (?1, ?2, CURRENT_TIMESTAMP)", + params![ + TICKET_SCHEMA_VERSION_WITH_TARGETS, + TICKET_SCHEMA_BASELINE_NAME + ], + )?; + ticket::verify_sqlite_ticket_schema(db).map_err(|error| { + Error::Store(format!( + "schema-66 Ticket schema verification failed: {error}" + )) + })?; + + let foreign_key_violations = + db.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get::<_, i64>(0) + })?; + if foreign_key_violations != 0 { + return Err(Error::Store(format!( + "schema-66 migration left {foreign_key_violations} foreign-key violation(s)" + ))); + } + db.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![ + 66_i64, + TICKET_TARGETS_AND_WORKDIR_CAPABILITIES_MIGRATION_NAME + ], + )?; + transaction.commit()?; + Ok(()) + })(); + + let restore_result = match (legacy_alter_table_enabled, foreign_keys_enabled) { + (false, true) => { + conn.execute_batch("PRAGMA legacy_alter_table = OFF; PRAGMA foreign_keys = ON;") + } + (false, false) => conn.execute_batch("PRAGMA legacy_alter_table = OFF;"), + (true, true) => conn.execute_batch("PRAGMA foreign_keys = ON;"), + (true, false) => Ok(()), + } + .map_err(Error::from); + match (result, restore_result) { + (Err(error), _) => Err(error), + (Ok(()), Err(error)) => Err(error), + (Ok(()), Ok(())) => Ok(()), + } +} + fn create_latest_workspace_schema(conn: &Connection) -> Result<()> { + conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?; conn.execute_batch(include_str!("latest_schema.sql"))?; + conn.execute_batch( + r#" + ALTER TABLE typed_tickets RENAME TO typed_tickets_legacy_v66; + CREATE TABLE typed_tickets ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + slug TEXT NOT NULL, + title TEXT NOT NULL, + status TEXT NOT NULL, + kind TEXT NOT NULL, + priority TEXT NOT NULL, + body TEXT NOT NULL, + created_at TEXT, + updated_at TEXT, + assignee TEXT, + readiness TEXT, + workflow_state TEXT NOT NULL, + workflow_state_explicit INTEGER NOT NULL, + queued_by TEXT, + queued_at TEXT, + resolution TEXT, + PRIMARY KEY (workspace_id, ticket_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); + DROP TABLE typed_tickets_legacy_v66; + CREATE INDEX idx_typed_tickets_workspace_state_updated + ON typed_tickets(workspace_id, workflow_state, updated_at DESC, ticket_id); + CREATE INDEX idx_typed_tickets_workspace_updated + ON typed_tickets(workspace_id, updated_at DESC, ticket_id); + CREATE TRIGGER ticket_assignment_ticket_parent_tombstone + BEFORE DELETE ON typed_tickets + WHEN EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = OLD.workspace_id + AND assignment.ticket_id = OLD.ticket_id + ) + BEGIN + INSERT OR IGNORE INTO ticket_assignment_ticket_tombstones ( + workspace_id, ticket_id, deleted_at + ) VALUES (OLD.workspace_id, OLD.ticket_id, CURRENT_TIMESTAMP); + END; + CREATE TABLE typed_ticket_targets ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + ordinal INTEGER NOT NULL, + repository_key TEXT NOT NULL, + ref_selector TEXT, + access TEXT NOT NULL CHECK (access IN ('read_only', 'read_write')), + PRIMARY KEY (workspace_id, ticket_id, ordinal), + UNIQUE (workspace_id, ticket_id, repository_key), + FOREIGN KEY (workspace_id, ticket_id) + REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, repository_key) + REFERENCES repositories(workspace_id, repository_key) ON DELETE RESTRICT + ); + CREATE INDEX typed_ticket_targets_workspace_repository + ON typed_ticket_targets(workspace_id, repository_key, ticket_id); + ALTER TABLE worker_workdir_links + ADD COLUMN capabilities TEXT NOT NULL + CHECK (capabilities IN ('all', 'read_only')); + "#, + )?; + ticket::verify_sqlite_ticket_schema(conn).map_err(|error| { + Error::Store(format!( + "fresh Workspace Ticket schema verification failed: {error}" + )) + })?; Ok(()) } @@ -11446,6 +12020,22 @@ fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result Result Result<()> { let plan = workspace_schema_migration_plan(conn)?; if plan.current_schema_version == 0 { - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - create_latest_workspace_schema(&tx)?; - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![LATEST_SCHEMA_VERSION, SCHEMA_BASELINE_NAME], - )?; - tx.commit()?; + let foreign_keys_enabled = + conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))? != 0; + let legacy_alter_table_enabled = + conn.query_row("PRAGMA legacy_alter_table", [], |row| row.get::<_, i64>(0))? != 0; + if foreign_keys_enabled { + conn.execute_batch("PRAGMA foreign_keys = OFF;")?; + } + if !legacy_alter_table_enabled { + conn.execute_batch("PRAGMA legacy_alter_table = ON;")?; + } + let create_result = (|| { + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + create_latest_workspace_schema(&tx)?; + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![LATEST_SCHEMA_VERSION, SCHEMA_BASELINE_NAME], + )?; + tx.commit()?; + Ok(()) + })(); + let restore_result = match (legacy_alter_table_enabled, foreign_keys_enabled) { + (false, true) => { + conn.execute_batch("PRAGMA legacy_alter_table = OFF; PRAGMA foreign_keys = ON;") + } + (false, false) => conn.execute_batch("PRAGMA legacy_alter_table = OFF;"), + (true, true) => conn.execute_batch("PRAGMA foreign_keys = ON;"), + (true, false) => Ok(()), + } + .map_err(Error::from); + match (create_result, restore_result) { + (Err(error), _) => return Err(error), + (Ok(()), Err(error)) => return Err(error), + (Ok(()), Ok(())) => {} + } } else { for step in &plan.migrations { let migration = MIGRATIONS @@ -11988,6 +12605,19 @@ fn migrate_workdir_credential_candidate_snapshots_v58_to_v59(conn: &Connection) mod tests { use super::*; + fn downgrade_schema_66_ticket_and_workdir_authority(conn: &Connection) -> Result<()> { + conn.execute_batch( + "DROP TABLE typed_ticket_targets; + ALTER TABLE typed_tickets ADD COLUMN repository_id TEXT; + ALTER TABLE typed_tickets ADD COLUMN ref_selector TEXT; + ALTER TABLE worker_workdir_links DROP COLUMN capabilities; + DELETE FROM ticket_schema_migrations; + INSERT INTO ticket_schema_migrations(version, name, applied_at) + VALUES (6, 'ticket schema baseline', CURRENT_TIMESTAMP);", + )?; + Ok(()) + } + #[tokio::test] async fn external_workdir_grant_persists_typed_source_and_fences_generations() { let store = SqliteWorkspaceStore::in_memory().unwrap(); @@ -12693,6 +13323,7 @@ mod tests { let store = SqliteWorkspaceStore::open(&path).unwrap(); store .with_conn(|conn| { + downgrade_schema_66_ticket_and_workdir_authority(conn)?; conn.execute_batch( "DROP TRIGGER runtime_binding_insert_blocked_by_removal; \ DROP TRIGGER runtime_binding_update_blocked_by_removal; \ @@ -12761,6 +13392,7 @@ mod tests { drop(SqliteWorkspaceStore::open(&path).unwrap()); { let conn = Connection::open(&path).unwrap(); + downgrade_schema_66_ticket_and_workdir_authority(&conn).unwrap(); conn.execute_batch( "ALTER TABLE worker_removal_operations \ ADD COLUMN run_generation INTEGER NOT NULL DEFAULT 0; \ @@ -12843,6 +13475,219 @@ mod tests { .unwrap(); } + #[test] + fn schema_v65_migrates_ticket_targets_and_workdir_capabilities() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("server.db"); + drop(SqliteWorkspaceStore::open(&path).unwrap()); + let conn = Connection::open(&path).unwrap(); + configure_sqlite(&conn).unwrap(); + conn.execute_batch( + r#" + PRAGMA foreign_keys = OFF; + DROP TABLE typed_ticket_targets; + ALTER TABLE typed_tickets ADD COLUMN repository_id TEXT; + ALTER TABLE typed_tickets ADD COLUMN ref_selector TEXT; + DROP INDEX worker_workdir_links_active_workdir_unique; + DROP INDEX worker_workdir_links_active_alias_unique; + DROP INDEX worker_workdir_links_workdir; + CREATE TABLE worker_workdir_links_v65 ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + alias TEXT NOT NULL, + linked_at TEXT NOT NULL, + unlinked_at TEXT, + PRIMARY KEY (workspace_id, worker_id, workdir_id, alias), + FOREIGN KEY (workspace_id, worker_id) + REFERENCES worker_registry(workspace_id, worker_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, workdir_id) + REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE + ); + INSERT INTO worker_workdir_links_v65 ( + workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at + ) + SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at + FROM worker_workdir_links; + DROP TABLE worker_workdir_links; + ALTER TABLE worker_workdir_links_v65 RENAME TO worker_workdir_links; + CREATE UNIQUE INDEX worker_workdir_links_active_workdir_unique + ON worker_workdir_links(workspace_id, workdir_id) WHERE unlinked_at IS NULL; + CREATE UNIQUE INDEX worker_workdir_links_active_alias_unique + ON worker_workdir_links(workspace_id, worker_id, alias) WHERE unlinked_at IS NULL; + CREATE INDEX worker_workdir_links_workdir + ON worker_workdir_links(workspace_id, workdir_id); + DELETE FROM __yoi_schema_migrations; + INSERT INTO __yoi_schema_migrations(version, name) + VALUES (65, 'workspace schema baseline'); + DELETE FROM ticket_schema_migrations; + INSERT INTO ticket_schema_migrations(version, name, applied_at) + VALUES (6, 'ticket schema baseline', '1'); + + INSERT INTO accounts(account_id, kind, handle, display_name, created_at, updated_at) + VALUES ('owner', 'user', 'owner', 'Owner', '1', '1'); + INSERT INTO workspaces( + workspace_id, owner_account_id, display_name, state, created_at, updated_at + ) VALUES ('workspace-a', 'owner', 'Workspace A', 'active', '1', '1'); + INSERT INTO repositories( + workspace_id, repository_id, repository_key, kind, provider, uri, default_ref, + created_at, updated_at, source_kind, source_uri, source_revision, + source_fingerprint, observed_status, observed_at + ) VALUES + ('workspace-a', 'repo-main', 'main', 'git', 'git', '/main', 'develop', + '1', '1', 'local_path', '/main', 1, 'sha256:main', 'unverified', NULL); + INSERT INTO typed_tickets( + workspace_id, ticket_id, slug, title, status, kind, priority, body, + created_at, updated_at, assignee, readiness, workflow_state, + workflow_state_explicit, queued_by, queued_at, resolution, + repository_id, ref_selector + ) VALUES ( + 'workspace-a', 'ticket-a', 'ticket-a', 'Ticket A', 'open', 'task', 'P2', '', + '1', '1', NULL, NULL, 'planning', 1, NULL, NULL, NULL, + 'repo-main', 'develop' + ); + INSERT INTO typed_ticket_events( + workspace_id, ticket_id, event_index, kind, author, at, status, + from_state, to_state, reason, state_field, heading, body + ) VALUES ( + 'workspace-a', 'ticket-a', 0, 'state_changed', 'user', '1', NULL, + 'planning', 'ready', NULL, 'state', 'State changed', '' + ); + INSERT INTO typed_ticket_event_attributes( + workspace_id, ticket_id, event_index, key, value + ) VALUES + ('workspace-a', 'ticket-a', 0, 'repository_id', 'repo-main'), + ('workspace-a', 'ticket-a', 0, 'ref_selector', 'develop'); + INSERT INTO worker_registry( + workspace_id, worker_id, runtime_id, display_name, profile, retention_state, + transcript_ref, session_ref, summary_ref, diagnostics_ref, created_at, updated_at + ) VALUES + ('workspace-a', 'worker-repository', 'runtime-a', 'Repository Worker', NULL, + 'normal', NULL, NULL, NULL, NULL, '1', '1'), + ('workspace-a', 'worker-external', 'runtime-a', 'External Worker', NULL, + 'normal', NULL, NULL, NULL, NULL, '1', '1'); + INSERT INTO workdir_registry( + workspace_id, workdir_id, display_name, source_kind, runtime_id, repository_id, + external_grant_id, creation_selector, creation_ref, materialization_status, + cleanliness, created_at, updated_at, current_selector, current_ref, + creation_tree, current_tree, observed_at_epoch_seconds + ) VALUES + ('workspace-a', 'workdir-repository', 'Repository', 'repository', 'runtime-a', + 'repo-main', NULL, 'develop', 'abc', 'present', 'clean', '1', '1', + 'develop', 'abc', NULL, NULL, NULL), + ('workspace-a', 'workdir-external', 'External', 'external_grant', NULL, + NULL, 'grant-a', NULL, NULL, 'present', 'unknown', '1', '1', + NULL, NULL, NULL, NULL, NULL); + INSERT INTO external_workdir_grants( + grant_id, workspace_id, workdir_id, provider_instance_id, display_name, + permissions, created_by, created_at, expires_at, generation, status, updated_at, + cleanup_state, cleanup_error, cleanup_updated_at + ) VALUES ( + 'grant-a', 'workspace-a', 'workdir-external', 'provider-a', 'External', + 'read_only', 'owner', '1', '9', 1, 'online', '1', + 'not_required', NULL, NULL + ); + INSERT INTO worker_workdir_links( + workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at + ) VALUES + ('workspace-a', 'runtime-a', 'worker-repository', 'workdir-repository', + 'checkout', '1', NULL), + ('workspace-a', 'runtime-a', 'worker-external', 'workdir-external', + 'external', '1', NULL); + PRAGMA foreign_keys = ON; + "#, + ) + .unwrap(); + + migrate_ticket_targets_and_workdir_capabilities_v65_to_v66(&conn).unwrap(); + + assert_eq!(current_schema_version(&conn).unwrap(), 66); + ticket::verify_sqlite_ticket_schema(&conn).unwrap(); + assert!(!column_exists(&conn, "typed_tickets", "repository_id").unwrap()); + assert!(!column_exists(&conn, "typed_tickets", "ref_selector").unwrap()); + assert_eq!( + conn.query_row( + "SELECT repository_key || ':' || ref_selector || ':' || access + FROM typed_ticket_targets + WHERE workspace_id = 'workspace-a' AND ticket_id = 'ticket-a' AND ordinal = 0", + [], + |row| row.get::<_, String>(0), + ) + .unwrap(), + "main:develop:read_write" + ); + let target_evidence: String = conn + .query_row( + "SELECT value FROM typed_ticket_event_attributes + WHERE workspace_id = 'workspace-a' AND ticket_id = 'ticket-a' + AND event_index = 0 AND key = 'targets'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + serde_json::from_str::(&target_evidence).unwrap(), + serde_json::json!([{ + "repository_key": "main", + "ref_selector": "develop", + "access": "read_write" + }]) + ); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM typed_ticket_event_attributes + WHERE workspace_id = 'workspace-a' AND ticket_id = 'ticket-a' + AND event_index = 0 AND key IN ('repository_id', 'ref_selector')", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 0 + ); + assert_eq!( + conn.query_row( + "SELECT value FROM typed_ticket_event_attributes + WHERE workspace_id = 'workspace-a' AND ticket_id = 'ticket-a' + AND event_index = 0 AND key = 'fingerprint_version'", + [], + |row| row.get::<_, String>(0), + ) + .unwrap(), + "1" + ); + assert_eq!( + conn.query_row( + "SELECT version || ':' || name FROM ticket_schema_migrations", + [], + |row| row.get::<_, String>(0), + ) + .unwrap(), + "7:ticket schema baseline" + ); + assert_eq!( + conn.query_row( + "SELECT group_concat(workdir_id || ':' || capabilities, ',') + FROM (SELECT workdir_id, capabilities FROM worker_workdir_links ORDER BY workdir_id)", + [], + |row| row.get::<_, String>(0), + ) + .unwrap(), + "workdir-external:read_only,workdir-repository:all" + ); + assert_eq!( + conn.query_row("PRAGMA quick_check", [], |row| row.get::<_, String>(0)) + .unwrap(), + "ok" + ); + let foreign_key_failures: i64 = conn + .query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(foreign_key_failures, 0); + } + #[test] fn current_schema_accepts_every_retained_canonical_provenance() { for baseline_version in OLDEST_SCHEMA_VERSION..=LATEST_SCHEMA_VERSION { @@ -12888,6 +13733,10 @@ mod tests { )] ); ticket::verify_sqlite_ticket_schema(conn)?; + assert!(table_exists(conn, "typed_ticket_targets")?); + assert!(!column_exists(conn, "typed_tickets", "repository_id")?); + assert!(!column_exists(conn, "typed_tickets", "ref_selector")?); + assert!(column_exists(conn, "worker_workdir_links", "capabilities")?); merge_request::migrate(conn)?; let foreign_key_failures: i64 = conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { @@ -12904,7 +13753,12 @@ mod tests { configure_sqlite(&conn).unwrap(); ticket::migrate_sqlite_ticket_schema(&conn).unwrap(); merge_request::migrate(&conn).unwrap(); + conn.execute_batch("PRAGMA foreign_keys = OFF; PRAGMA legacy_alter_table = ON;") + .unwrap(); create_latest_workspace_schema(&conn).unwrap(); + downgrade_schema_66_ticket_and_workdir_authority(&conn).unwrap(); + conn.execute_batch("PRAGMA legacy_alter_table = OFF; PRAGMA foreign_keys = ON;") + .unwrap(); conn.execute_batch( r#" DROP TABLE workdir_create_credential_revision_retentions; @@ -13099,6 +13953,10 @@ mod tests { version: 65, name: EXTERNAL_WORKDIR_CLEANUP_MIGRATION_NAME.to_string(), }, + WorkspaceSchemaMigrationStep { + version: 66, + name: TICKET_TARGETS_AND_WORKDIR_CAPABILITIES_MIGRATION_NAME.to_string(), + }, ] ); @@ -13153,6 +14011,10 @@ mod tests { (63, MULTI_WORKDIR_ATTACHMENTS_MIGRATION_NAME.to_string()), (64, EXTERNAL_WORKDIR_GRANTS_MIGRATION_NAME.to_string()), (65, EXTERNAL_WORKDIR_CLEANUP_MIGRATION_NAME.to_string()), + ( + 66, + TICKET_TARGETS_AND_WORKDIR_CAPABILITIES_MIGRATION_NAME.to_string(), + ), ] ); assert!(!table_exists(conn, "trusted_runtime_records")?); @@ -13223,7 +14085,7 @@ mod tests { .iter() .map(|migration| migration.version) .collect::>(), - vec![52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65] + vec![52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66] ); SqliteWorkspaceStore::migrate_database(&path).unwrap(); let conn = Connection::open(&path).unwrap(); @@ -13231,8 +14093,9 @@ mod tests { current_schema_version(&conn).unwrap(), LATEST_SCHEMA_VERSION ); - assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 16); + assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 17); assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap()); + assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap()); assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap()); } @@ -14370,7 +15233,11 @@ mod tests { ) .unwrap(); let mut input = ticket::NewTicket::new("Foreign repository"); - input.repository_id = Some("main".to_string()); + input.targets = vec![ticket::TicketTarget { + repository_key: "main".to_string(), + ref_selector: None, + access: ticket::TicketTargetAccess::ReadWrite, + }]; let error = ticket::TicketBackend::create(&backend, input).unwrap_err(); assert!( error.to_string().contains("FOREIGN KEY constraint failed"), @@ -15227,8 +16094,11 @@ INSERT INTO worker_registry ( let mut input = ticket::NewTicket::new("Role assignment"); input.body = ticket::MarkdownText::new("test"); input.workflow_state = Some(ticket::TicketWorkflowState::Ready); - input.repository_id = Some("main".to_string()); - input.ref_selector = Some("develop".to_string()); + input.targets = vec![ticket::TicketTarget { + repository_key: "main".to_string(), + ref_selector: Some("develop".to_string()), + access: ticket::TicketTargetAccess::ReadWrite, + }]; let ticket = ticket::TicketBackend::create(&backend, input).unwrap(); let orchestrator = TicketRoleAssignmentRecord { @@ -15991,6 +16861,7 @@ INSERT INTO worker_registry ( worker: worker.worker.clone(), workdir_id: workdir.workdir_id.clone(), alias: "checkout".to_string(), + capabilities: workdir::WorkdirSessionCapabilities::ALL, linked_at: "4".to_string(), unlinked_at: None, }; @@ -16021,6 +16892,19 @@ INSERT INTO worker_registry ( link ); assert_eq!(store.attach_worker_workdir(&link).unwrap(), link); + let downgraded_link = WorkerWorkdirLinkRecord { + capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY, + ..link.clone() + }; + assert_eq!( + store.attach_worker_workdir(&downgraded_link).unwrap(), + downgraded_link + ); + assert_eq!( + store.attach_worker_workdir(&link).unwrap(), + downgraded_link, + "reattaching an existing link cannot expand its persisted capability ceiling" + ); assert_eq!( store @@ -16042,7 +16926,7 @@ INSERT INTO worker_registry ( store .list_worker_workdir_links("local-dev", &worker.worker) .unwrap(), - vec![link.clone()] + vec![downgraded_link.clone()] ); let worker_conflict = WorkerWorkdirLinkRecord { @@ -16065,8 +16949,17 @@ INSERT INTO worker_registry ( )); let second_attachment = WorkerWorkdirLinkRecord { alias: "docs".to_string(), + capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY, ..worker_conflict }; + let invalid_capabilities = WorkerWorkdirLinkRecord { + capabilities: workdir::WorkdirSessionCapabilities::EMPTY, + ..second_attachment.clone() + }; + assert!(matches!( + store.attach_worker_workdir(&invalid_capabilities), + Err(Error::InvalidInput(_)) + )); assert_eq!( store.attach_worker_workdir(&second_attachment).unwrap(), second_attachment @@ -16114,6 +17007,21 @@ INSERT INTO worker_registry ( store.attach_worker_workdir(&workdir_conflict).unwrap(), workdir_conflict ); + + drop(store); + let reopened = SqliteWorkspaceStore::open(&db).unwrap(); + assert_eq!( + reopened + .list_worker_workdir_links("local-dev", &worker.worker) + .unwrap(), + vec![second_attachment] + ); + assert_eq!( + reopened + .list_worker_workdir_links("local-dev", &second_worker.worker) + .unwrap(), + vec![workdir_conflict] + ); } #[tokio::test] diff --git a/crates/workspace-server/src/workspace_deletion.rs b/crates/workspace-server/src/workspace_deletion.rs index e09c9355a..fb6fe3f03 100644 --- a/crates/workspace-server/src/workspace_deletion.rs +++ b/crates/workspace-server/src/workspace_deletion.rs @@ -56,6 +56,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[ "typed_ticket_raw_frontmatter", "typed_ticket_relations", "typed_ticket_risk_flags", + "typed_ticket_targets", "typed_tickets", "workdir_create_credential_candidates", "workdir_create_credential_revision_retentions", diff --git a/openapi/server-api.json b/openapi/server-api.json index b3e54e2de..fb2c77fa3 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -408,19 +408,16 @@ "const": "set", "type": "string" }, - "ref_selector": { - "type": [ - "string", - "null" - ] - }, - "repository_key": { - "type": "string" + "targets": { + "items": { + "$ref": "#/components/schemas/TicketTarget" + }, + "type": "array" } }, "required": [ "action", - "repository_key" + "targets" ], "type": "object" }, @@ -4254,18 +4251,6 @@ "null" ] }, - "ref_selector": { - "type": [ - "string", - "null" - ] - }, - "repository_key": { - "type": [ - "string", - "null" - ] - }, "risk_flags": { "items": { "type": "string" @@ -4278,6 +4263,13 @@ "null" ] }, + "targets": { + "default": [], + "items": { + "$ref": "#/components/schemas/TicketTarget" + }, + "type": "array" + }, "title": { "type": "string" }, @@ -8974,21 +8966,9 @@ "record_source": { "type": "string" }, - "ref_selector": { - "type": [ - "string", - "null" - ] - }, "relations": { "$ref": "#/components/schemas/TicketDetailRelationView" }, - "repository_key": { - "type": [ - "string", - "null" - ] - }, "resolution": { "type": [ "string", @@ -9007,6 +8987,12 @@ "state": { "type": "string" }, + "targets": { + "items": { + "$ref": "#/components/schemas/TicketTarget" + }, + "type": "array" + }, "title": { "type": "string" }, @@ -9024,6 +9010,7 @@ "state", "priority", "item_revision", + "targets", "risk_flags", "body", "body_truncated", @@ -9659,10 +9646,10 @@ ], "default": null }, - "target": { + "targets": { "anyOf": [ { - "$ref": "#/components/schemas/TicketTargetEdit" + "$ref": "#/components/schemas/TicketTargetsEdit" }, { "type": "null" @@ -9951,18 +9938,6 @@ "null" ] }, - "ref_selector": { - "type": [ - "string", - "null" - ] - }, - "repository_key": { - "type": [ - "string", - "null" - ] - }, "resource_key": { "type": [ "string", @@ -9981,6 +9956,13 @@ "status": { "$ref": "#/components/schemas/ExtensibleTicketStatus" }, + "targets": { + "default": [], + "items": { + "$ref": "#/components/schemas/TicketTarget" + }, + "type": "array" + }, "title": { "type": "string" }, @@ -10825,7 +10807,36 @@ }, "type": "object" }, - "TicketTargetEdit": { + "TicketTarget": { + "additionalProperties": false, + "properties": { + "access": { + "$ref": "#/components/schemas/TicketTargetAccess" + }, + "ref_selector": { + "type": [ + "string", + "null" + ] + }, + "repository_key": { + "type": "string" + } + }, + "required": [ + "repository_key", + "access" + ], + "type": "object" + }, + "TicketTargetAccess": { + "enum": [ + "read_only", + "read_write" + ], + "type": "string" + }, + "TicketTargetsEdit": { "oneOf": [ { "properties": { @@ -10833,19 +10844,16 @@ "const": "set", "type": "string" }, - "ref_selector": { - "type": [ - "string", - "null" - ] - }, - "repository_key": { - "type": "string" + "targets": { + "items": { + "$ref": "#/components/schemas/TicketTarget" + }, + "type": "array" } }, "required": [ "action", - "repository_key" + "targets" ], "type": "object" }, @@ -13893,7 +13901,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:7a9d11f50cc41eca55a6dded4b8f55aa6d23b1beb2dc69b0a9b6c8349efc894f" + "x-yoi-source-digest": "sha256:80be292e32441730f7de890e64836f4ca9655576d655e60e6d44daf5227a1b1c" }, "openapi": "3.1.0", "paths": { diff --git a/resources/prompts/role/coder.md b/resources/prompts/role/coder.md index d803355bd..a184160d6 100644 --- a/resources/prompts/role/coder.md +++ b/resources/prompts/role/coder.md @@ -4,6 +4,6 @@ Use the existing Merge Request as the routine authority for review requests, ver Do not add a Ticket comment for each review or fix iteration. Add a Ticket comment only when a blocker or decision requires Orchestrator attention, or once after approval to hand off the final implementation and validation evidence. -Treat the first committed user message as the bounded Ticket/action context and do not infer control-plane identity from prose. +Treat the first committed user message as the bounded Ticket/action context and do not infer control-plane identity from prose. When multiple Workdirs are attached, route every Workdir tool call by its stable alias. Only the Ticket's sole `read_write` target is an implementation/publish/Merge Request repository; `read_only` targets are reference material and must never be modified or used as a command target. Treat a Backend capability rejection as authority rather than attempting another path to write. Before opening a Merge Request, publish only the committed Ticket work branch with a normal non-force push and verify that the Ticket repository remote resolves it to the exact local `HEAD`; a local branch name or dirty Workdir is not immutable review evidence. Do not push the target branch, tags, or unrelated refs, and never force-push. diff --git a/resources/prompts/role/orchestrator.md b/resources/prompts/role/orchestrator.md index d5053a9cb..10d8ba079 100644 --- a/resources/prompts/role/orchestrator.md +++ b/resources/prompts/role/orchestrator.md @@ -2,7 +2,7 @@ You are the Ticket Orchestrator role. {% include "common.git" %} -Keep durable orchestration behavior here and treat the first committed user message as concrete Ticket/action context only. Use typed Ticket tools and current repository state as authority. For an actionable `queued` Ticket, call `SpawnTicketCoder` without first recording `inprogress`: the guarded Worker creation operation commits the fixed Coder profile/Flow, initial input, current assignment, Workdir finalization, and only then the authoritative `queued -> inprogress` acceptance. If spawn or finalization fails, leave the Ticket queued and do not report accepted implementation. After spawn, reread the Ticket and verify both `inprogress` and that its current assignment names that Coder before asking it to implement; never route implementation to an unassigned Coder. Route implementation work to sibling Coder Workers. The human `ready -> queued` transition delegates ordinary implementation, publication of the Ticket source work branch, guarded integration of the current approved Merge Request, recording completion, and closing the Ticket to the Workspace Orchestrator by default; do not wait for a second merge confirmation. This queue delegation does not grant broader repository authority from launch prose. Stop only when the Ticket explicitly records a separate approval gate or completion requires a new decision outside the queued scope. +Keep durable orchestration behavior here and treat the first committed user message as concrete Ticket/action context only. Use typed Ticket tools and current repository state as authority. For an actionable `queued` Ticket, select exactly one current Workdir for every declared Ticket target and call `SpawnTicketCoder` once with the complete alias-keyed `workdir_attachments` collection; use stable semantic aliases such as `checkout` for the sole `read_write` target and repository-derived aliases for `read_only` targets. Never omit a target, attach an undeclared repository, or request attachment capabilities: Ticket access and Backend validation are authoritative. Call `SpawnTicketCoder` without first recording `inprogress`: the guarded Worker creation operation commits the fixed Coder profile/Flow, initial input, one current assignment, every Workdir finalization, and only then the authoritative `queued -> inprogress` acceptance. If any attachment, Runtime, assignment, or finalization step fails, leave the Ticket queued and do not report accepted implementation. After spawn, reread the Ticket and verify both `inprogress` and that its current assignment names that Coder before asking it to implement; never route implementation to an unassigned Coder. Route implementation work to sibling Coder Workers. The human `ready -> queued` transition delegates ordinary implementation, publication from the sole `read_write` target's Ticket work branch, guarded integration of the current approved Merge Request, recording completion, and closing the Ticket to the Workspace Orchestrator by default; do not wait for a second merge confirmation. This queue delegation does not grant broader repository authority from launch prose. Stop only when the Ticket explicitly records a separate approval gate or completion requires a new decision outside the queued scope. The assigned Coder owns its review/fix loop and launches Reviewer SubWorkers itself. Do not spawn, restore, assign, or route work to Backend/Runtime Reviewer Workers, and do not select a Reviewer profile through the generic WorkerSpawn path. If durable `Review` evidence for the current provider-resolved `selector_from` subject is missing, indeterminate, revoked, cancelled, or requests changes, keep the Ticket in progress and return the requirement to the same assigned Coder; never compensate by creating an independent Reviewer Worker. diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index 0e9ff5ee1..e6cfb44d9 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:7a9d11f50cc41eca55a6dded4b8f55aa6d23b1beb2dc69b0a9b6c8349efc894f) +// Input contract: 0.1.0 (sha256:80be292e32441730f7de890e64836f4ca9655576d655e60e6d44daf5227a1b1c) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index e3281a8bb..e6a5d5591 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:7a9d11f50cc41eca55a6dded4b8f55aa6d23b1beb2dc69b0a9b6c8349efc894f) +// Input contract: 0.1.0 (sha256:80be292e32441730f7de890e64836f4ca9655576d655e60e6d44daf5227a1b1c) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 0ddc4c37b..feeaadae9 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:7a9d11f50cc41eca55a6dded4b8f55aa6d23b1beb2dc69b0a9b6c8349efc894f) +// Input contract: 0.1.0 (sha256:80be292e32441730f7de890e64836f4ca9655576d655e60e6d44daf5227a1b1c) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 3c2c65901..7f9514be6 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:7a9d11f50cc41eca55a6dded4b8f55aa6d23b1beb2dc69b0a9b6c8349efc894f) +// Input contract: 0.1.0 (sha256:80be292e32441730f7de890e64836f4ca9655576d655e60e6d44daf5227a1b1c) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check @@ -13,7 +13,7 @@ export type BrowserEditTicketRequest = { author?: string | null; body?: string | export type BrowserQueueTicketRequest = { [key: string]: never; }; -export type BrowserTicketTargetEdit = { action: "set"; ref_selector?: string | null; repository_key: string; } | { action: "clear"; }; +export type BrowserTicketTargetEdit = { action: "set"; targets: Array; } | { action: "clear"; }; export type BrowserTicketThreadRole = "comment" | "plan" | "decision" | "implementation_report"; @@ -55,6 +55,8 @@ export type MergeRequestWorkerIdentity = { runtime_id: string; worker_id: string export type MergeStrategy = "fast_forward" | "merge"; +export type NewTicket = { assignee?: string | null; author?: string | null; body: MarkdownText; kind: string; labels: Array; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; risk_flags: Array; slug?: string | null; targets?: Array; title: string; workflow_state?: TicketWorkflowState | null; }; + export type ObjectiveDetail = { body: string; body_truncated: boolean; created_at?: string | null; event_page: QueryPage; events: Array; id: string; linked_ticket_summaries: Array; linked_tickets: Array; record_source: string; resource_key: string; resources: Array; revision: string; state: string; title: string; updated_at?: string | null; }; export type ObjectiveEventDetail = { body?: string | null; created_at: string; event_ref: string; kind: string; }; @@ -87,7 +89,7 @@ export type TicketAssignmentRole = "orchestrator" | "coder" | "owner" | "contrib export type TicketAssignmentSummary = { assignment_id: string; runtime_id: string; worker_id: string; worker_resource_key?: string | null; }; -export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array; assignment_diagnostics: Array; assignments: Array; body: string; body_truncated: boolean; created_at?: string | null; current_coder?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array; item_revision: string; linked_objectives: Array; merge_request?: TicketMergeRequestSummary | null; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; ref_selector?: string | null; relations: TicketDetailRelationView; repository_key?: string | null; resolution?: string | null; resource_key: string; risk_flags: Array; state: string; title: string; updated_at?: string | null; }; +export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array; assignment_diagnostics: Array; assignments: Array; body: string; body_truncated: boolean; created_at?: string | null; current_coder?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array; item_revision: string; linked_objectives: Array; merge_request?: TicketMergeRequestSummary | null; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array; state: string; targets: Array; title: string; updated_at?: string | null; }; export type TicketDetailDerivedRelation = { at: string; author: string; forward_kind: string; inverse_kind: string; note?: string | null; source_resource_key?: string | null; source_ticket: string; }; @@ -117,6 +119,8 @@ export type TicketMergeRequestSummary = { current_subject_ref?: string | null; m export type TicketQueueOutcome = { queued_tickets: Array; requested_ticket: string; }; +export type TicketRef = { id: string; resource_key?: string | null; slug: string; status: TicketStatus; }; + export type TicketReference = { kind: string; target: string; }; export type TicketRoleAssignmentMutationResponse = { assignment?: TicketRoleAssignmentRecord | null; ticket_id: string; workspace_id: string; }; @@ -124,3 +128,11 @@ export type TicketRoleAssignmentMutationResponse = { assignment?: TicketRoleAssi export type TicketRoleAssignmentRecord = { assigned_at: string; assigned_by: string; assignment_id: string; principal: TicketAssignmentPrincipal; role: TicketAssignmentRole; ticket_id: string; workspace_id: string; }; export type TicketRoleAssignmentSummary = { assigned_at: string; assigned_by: string; assignment_id: string; principal: TicketAssignmentPrincipal; role: string; }; + +export type TicketStatus = "Open" | "Closed"; + +export type TicketTarget = { access: TicketTargetAccess; ref_selector?: string | null; repository_key: string; }; + +export type TicketTargetAccess = "read_only" | "read_write"; + +export type TicketWorkflowState = "planning" | "ready" | "queued" | "in_progress" | "done" | "closed"; diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index fd53da9d9..79b39f7b6 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:7a9d11f50cc41eca55a6dded4b8f55aa6d23b1beb2dc69b0a9b6c8349efc894f) +// Input contract: 0.1.0 (sha256:80be292e32441730f7de890e64836f4ca9655576d655e60e6d44daf5227a1b1c) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check diff --git a/web/workspace/src/lib/workspace/api/ticket-browser.test.ts b/web/workspace/src/lib/workspace/api/ticket-browser.test.ts index 0b78f303b..aa6ef8279 100644 --- a/web/workspace/src/lib/workspace/api/ticket-browser.test.ts +++ b/web/workspace/src/lib/workspace/api/ticket-browser.test.ts @@ -5,7 +5,9 @@ declare const Deno: { import { parseMergeRequestDetailResponse, parseObjectiveListResponse, + parseTicketDetail, parseTicketListResponse, + parseTicketRecordRef, } from "./ticket-browser.ts"; function assertEquals(actual: unknown, expected: unknown): void { @@ -78,6 +80,107 @@ Deno.test("Ticket Browser parser rejects unknown fields and out-of-range limits" ); }); +Deno.test("Ticket Browser parser accepts target collections and rejects singular target authority", () => { + const fixture = { + action_eligibility: { + blockers: [], + can_assign_orchestrator: false, + can_queue: false, + can_start_manual_coder: false, + can_unassign_orchestrator: false, + queue_tickets: [], + }, + artifact_count: 0, + artifacts: [], + assignment_diagnostics: [], + assignments: [], + body: "Body", + body_truncated: false, + current_coder: null, + event_count: 0, + event_page: { ...page, returned: 0 }, + events: [], + evidence: { + approved_current_subject: false, + complete_for_integration: false, + has_commit: false, + has_current_subject_ref: false, + has_merge_request: false, + has_review_request: false, + missing: [], + review_after_rescope: false, + review_status: null, + unresolved_request_changes: false, + }, + id: "ticket-id", + implementation_reports: [], + item_revision: "revision-1", + linked_objectives: [], + merge_request: null, + priority: "P2", + queued_at: null, + queued_by: null, + readiness: null, + record_source: "sqlite", + relations: { blockers: [], incoming: [], notices: [], outgoing: [] }, + resolution: null, + resource_key: "T-1", + risk_flags: [], + state: "planning", + targets: [ + { + repository_key: "docs", + ref_selector: "main", + access: "read_only", + }, + { + repository_key: "main", + ref_selector: "develop", + access: "read_write", + }, + ], + title: "Ticket", + }; + + const parsed = parseTicketDetail(fixture); + assertEquals(parsed.targets.length, 2); + assertEquals(parsed.targets[0]?.repository_key, "docs"); + assertEquals(parsed.targets[0]?.access, "read_only"); + assertEquals(parsed.targets[1]?.repository_key, "main"); + assertEquals(parsed.targets[1]?.access, "read_write"); + assertThrows( + () => + parseTicketDetail({ + ...fixture, + targets: [{ ...fixture.targets[0], access: "write" }], + }), + "targets[0].access is invalid", + ); + assertThrows( + () => + parseTicketDetail({ + ...fixture, + repository_key: "main", + ref_selector: "develop", + }), + "unknown field repository_key", + ); +}); + +Deno.test("Ticket Browser parser validates Ticket create responses", () => { + const parsed = parseTicketRecordRef({ + id: "ticket-id", + resource_key: "T-1", + slug: "ticket", + status: "Open", + }); + assertEquals(parsed.resource_key, "T-1"); + assertThrows( + () => parseTicketRecordRef({ ...parsed, status: "open" }), + "status is invalid", + ); +}); + Deno.test("Ticket Browser parser accepts the generated Objective list shape", () => { const parsed = parseObjectiveListResponse({ workspace_id: "workspace-a", diff --git a/web/workspace/src/lib/workspace/api/ticket-browser.ts b/web/workspace/src/lib/workspace/api/ticket-browser.ts index 56cadc7ed..4e0b38ac8 100644 --- a/web/workspace/src/lib/workspace/api/ticket-browser.ts +++ b/web/workspace/src/lib/workspace/api/ticket-browser.ts @@ -32,9 +32,11 @@ import type { TicketListResponse, TicketMergeRequestSummary, TicketQueueOutcome, + TicketRef, TicketRoleAssignmentMutationResponse, TicketRoleAssignmentRecord, TicketRoleAssignmentSummary, + TicketTarget, } from "$lib/generated/ticket-api.ts"; export const TICKET_BROWSER_API_MAX_RESPONSE_BYTES = 4 * 1024 * 1024; @@ -671,6 +673,23 @@ function parseObjectiveLink(value: unknown, label: string) { }; } +function parseTicketTarget(value: unknown, label: string): TicketTarget { + const item = object(value, label); + exact(item, ["access", "ref_selector", "repository_key"], label); + const access = string(item.access, `${label}.access`); + if (access !== "read_only" && access !== "read_write") { + throw new Error(`${label}.access is invalid`); + } + return { + access, + ref_selector: optionalNullableString( + item.ref_selector, + `${label}.ref_selector`, + ), + repository_key: string(item.repository_key, `${label}.repository_key`), + }; +} + export function parseTicketDetail(value: unknown): TicketDetail { const label = "Ticket detail"; const item = object(value, label); @@ -698,13 +717,12 @@ export function parseTicketDetail(value: unknown): TicketDetail { "queued_by", "readiness", "record_source", - "ref_selector", "relations", - "repository_key", "resolution", "resource_key", "risk_flags", "state", + "targets", "title", "updated_at", ], label); @@ -764,24 +782,36 @@ export function parseTicketDetail(value: unknown): TicketDetail { queued_by: optionalNullableString(item.queued_by, `${label}.queued_by`), readiness: optionalNullableString(item.readiness, `${label}.readiness`), record_source: string(item.record_source, `${label}.record_source`), - ref_selector: optionalNullableString( - item.ref_selector, - `${label}.ref_selector`, - ), relations: parseRelationView(item.relations, `${label}.relations`), - repository_key: optionalNullableString( - item.repository_key, - `${label}.repository_key`, - ), resolution: optionalNullableString(item.resolution, `${label}.resolution`), resource_key: string(item.resource_key, `${label}.resource_key`), risk_flags: strings(item.risk_flags, `${label}.risk_flags`), state: string(item.state, `${label}.state`), + targets: array(item.targets, `${label}.targets`, parseTicketTarget), title: string(item.title, `${label}.title`), updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`), }; } +export function parseTicketRecordRef(value: unknown): TicketRef { + const label = "Ticket create response"; + const item = object(value, label); + exact(item, ["id", "resource_key", "slug", "status"], label); + const status = string(item.status, `${label}.status`); + if (status !== "Open" && status !== "Closed") { + throw new Error(`${label}.status is invalid`); + } + return { + id: string(item.id, `${label}.id`), + resource_key: optionalNullableString( + item.resource_key, + `${label}.resource_key`, + ), + slug: string(item.slug, `${label}.slug`), + status, + }; +} + export function parseTicketQueueOutcome(value: unknown): TicketQueueOutcome { const item = object(value, "Ticket queue outcome"); exact(item, ["queued_tickets", "requested_ticket"], "Ticket queue outcome"); diff --git a/web/workspace/src/lib/workspace/styles/tickets.css b/web/workspace/src/lib/workspace/styles/tickets.css index 8790b87be..2ceeb0954 100644 --- a/web/workspace/src/lib/workspace/styles/tickets.css +++ b/web/workspace/src/lib/workspace/styles/tickets.css @@ -342,6 +342,27 @@ display: grid; gap: 0.75rem; } + .ticket-target-list, .ticket-target-row { + display: grid; + gap: 0.75rem; + } + .ticket-target-row { + min-width: 0; + margin: 0; + border: 0; + border-top: 1px solid var(--line); + padding: var(--space-3) 0 0; + } + .ticket-target-row:first-child { + border-top: 0; + padding-top: 0; + } + .ticket-target-row legend { + color: var(--text-strong); + font-size: var(--font-size-compact); + font-weight: 700; + padding: 0; + } .ticket-editor label, .ticket-control-form label { display: grid; gap: 0.3rem; @@ -349,6 +370,7 @@ font-size: var(--font-size-body); } .ticket-editor input, + .ticket-editor select, .ticket-editor textarea, .ticket-control-form input, .ticket-control-form select, 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 25226c099..5908f7d51 100644 --- a/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts +++ b/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts @@ -88,8 +88,18 @@ Deno.test("ticket worker launch uses the common Worker route and bounded Ticket const ticket = { id: "00001KYRRDVH9", title: "Ticket panel API", - repository_key: "main repo", - ref_selector: "work/ticket", + targets: [ + { + repository_key: "docs", + ref_selector: "main", + access: "read_only", + }, + { + repository_key: "main repo", + ref_selector: "work/ticket", + access: "read_write", + }, + ], } as TicketDetail; assertEquals( @@ -110,6 +120,38 @@ Deno.test("ticket worker launch uses the common Worker route and bounded Ticket ); }); +Deno.test("ticket worker launch never selects a read_only or ambiguous write target", () => { + const readOnly = { + id: "ticket-read-only", + title: "Read only", + targets: [{ + repository_key: "docs", + ref_selector: "main", + access: "read_only", + }], + } as TicketDetail; + const readOnlyUrl = new URL( + ticketWorkerLaunchHref("workspace", readOnly, "coder"), + "https://example.test", + ); + assertEquals(readOnlyUrl.searchParams.has("repositoryKey"), false); + assertEquals(readOnlyUrl.searchParams.has("refSelector"), false); + + const ambiguous = { + ...readOnly, + targets: [ + { repository_key: "main", ref_selector: "develop", access: "read_write" }, + { repository_key: "docs", ref_selector: "main", access: "read_write" }, + ], + } as TicketDetail; + const ambiguousUrl = new URL( + ticketWorkerLaunchHref("workspace", ambiguous, "coder"), + "https://example.test", + ); + assertEquals(ambiguousUrl.searchParams.has("repositoryKey"), false); + assertEquals(ambiguousUrl.searchParams.has("refSelector"), false); +}); + Deno.test("ticket detail uses server-derived role assignment actions", async () => { const source = await Deno.readTextFile( new URL( @@ -138,6 +180,26 @@ Deno.test("ticket detail uses server-derived role assignment actions", async () assertEquals(source.includes('kind: "workspace_agent"'), true); assertEquals(source.includes('kind: "worker"'), true); assertEquals(source.includes("ticket.assignee"), false); + assertEquals(source.includes('action: "set", targets'), true); + assertEquals(source.includes("updatedTicket.targets"), true); + assertEquals(source.includes("ticket.repository_key"), false); + assertEquals(source.includes("ticket.ref_selector"), false); +}); + +Deno.test("Ticket authoring creates the target collection without singular authority", async () => { + const source = await Deno.readTextFile( + new URL( + "../../../routes/w/[workspaceId]/tickets/+page.svelte", + import.meta.url, + ), + ); + + assertEquals(source.includes("const request: NewTicket"), true); + assertEquals(source.includes("targets: normalizedCreateTargets()"), true); + assertEquals(source.includes('value="read_write"'), true); + assertEquals(source.includes('value="read_only"'), true); + assertEquals(source.includes("repository_key: create"), false); + assertEquals(source.includes("ref_selector: create"), false); }); Deno.test("ticket detail keeps the operation rail outside main content", async () => { diff --git a/web/workspace/src/lib/workspace/tickets/ticket-panel.ts b/web/workspace/src/lib/workspace/tickets/ticket-panel.ts index 0ead20777..113a60604 100644 --- a/web/workspace/src/lib/workspace/tickets/ticket-panel.ts +++ b/web/workspace/src/lib/workspace/tickets/ticket-panel.ts @@ -107,23 +107,26 @@ export function ticketWorkerMessage( export function ticketWorkerLaunchHref( workspaceId: string, - ticket: Pick< - TicketDetail, - "id" | "title" | "repository_key" | "ref_selector" - >, + ticket: Pick, role: TicketWorkerRole, ): string { + const implementationTargets = ticket.targets.filter((target) => + target.access === "read_write" + ); + const implementationTarget = implementationTargets.length === 1 + ? implementationTargets[0] + : null; const params = new URLSearchParams({ ticketId: ticket.id, ticketTitle: ticket.title, ticketRole: role, initialInput: ticketWorkerMessage(ticket.id, role), }); - if (ticket.repository_key) { - params.set("repositoryKey", ticket.repository_key); - } - if (ticket.ref_selector) { - params.set("refSelector", ticket.ref_selector); + if (implementationTarget) { + params.set("repositoryKey", implementationTarget.repository_key); + if (implementationTarget.ref_selector) { + params.set("refSelector", implementationTarget.ref_selector); + } } return `/w/${ encodeURIComponent(workspaceId) diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte index c8062c384..f3d9275f8 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte @@ -1,15 +1,25 @@