From 0b954c1ab6dbe7a2f860bdc62cf90281bcff3071 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 17 Aug 2026 01:09:05 +0900 Subject: [PATCH 01/13] fix: scope merge request foreign key checks --- crates/merge-request/src/lib.rs | 65 ++++++++++++++++++++++++++++----- 1 file changed, 56 insertions(+), 9 deletions(-) diff --git a/crates/merge-request/src/lib.rs b/crates/merge-request/src/lib.rs index 4f98e85d..2535d402 100644 --- a/crates/merge-request/src/lib.rs +++ b/crates/merge-request/src/lib.rs @@ -1003,19 +1003,41 @@ fn replace_schema_marker(conn: &Connection, version: i64) -> Result<()> { } fn ensure_foreign_key_integrity(conn: &Connection) -> Result<()> { - let mut statement = conn.prepare("PRAGMA foreign_key_check").map_err(db)?; - let mut rows = statement.query([]).map_err(db)?; - if let Some(row) = rows.next().map_err(db)? { - let table: String = row.get(0).map_err(db)?; - let row_id: Option = row.get(1).map_err(db)?; - let parent: String = row.get(2).map_err(db)?; - return Err(MergeRequestError::Database(format!( - "foreign key integrity check failed for table {table}, row {row_id:?}, parent {parent}" - ))); + for table in merge_request_domain_table_names(conn)? { + let quoted = table.replace('\'', "''"); + let mut statement = conn + .prepare(&format!("PRAGMA foreign_key_check('{quoted}')")) + .map_err(db)?; + let mut rows = statement.query([]).map_err(db)?; + if let Some(row) = rows.next().map_err(db)? { + let table: String = row.get(0).map_err(db)?; + let row_id: Option = row.get(1).map_err(db)?; + let parent: String = row.get(2).map_err(db)?; + return Err(MergeRequestError::Database(format!( + "foreign key integrity check failed for table {table}, row {row_id:?}, parent {parent}" + ))); + } } Ok(()) } +fn merge_request_domain_table_names(conn: &Connection) -> Result> { + let mut statement = conn + .prepare( + "SELECT name FROM sqlite_master + WHERE type='table' + AND name LIKE 'merge_request_%' + AND name <> ?1 + ORDER BY name", + ) + .map_err(db)?; + statement + .query_map(params![MIGRATION_TABLE], |row| row.get::<_, String>(0)) + .map_err(db)? + .collect::, _>>() + .map_err(db) +} + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] struct ColumnShape { cid: i64, @@ -1600,6 +1622,31 @@ CREATE TABLE ticket_worker_assignments(workspace_id TEXT NOT NULL,ticket_id TEXT assert!(!column_exists(&conn, "merge_requests", "merged_result_commit").unwrap()); } + #[test] + fn unrelated_foreign_key_mismatch_does_not_block_merge_request_migration() { + let conn = exact_v8_connection(); + conn.execute_batch( + "CREATE TABLE unrelated_parent( + left_id TEXT NOT NULL, + right_id TEXT NOT NULL, + PRIMARY KEY(left_id,right_id) + ); + CREATE TABLE unrelated_child( + left_id TEXT REFERENCES unrelated_parent(left_id) + );", + ) + .unwrap(); + let error = conn + .query_row("PRAGMA foreign_key_check", [], |_| Ok(())) + .unwrap_err(); + assert!(error.to_string().contains("foreign key mismatch")); + + migrate(&conn).unwrap(); + verify(&conn).unwrap(); + assert_eq!(marker_version(&conn), 9); + assert!(column_exists(&conn, "merge_requests", "merged_result_commit").unwrap()); + } + #[test] fn versions_older_than_v8_are_rejected() { let conn = fresh_connection(); From c15999b7a6eeed5fd4bdeedee6c6d4ace4d01b41 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 17 Aug 2026 01:09:12 +0900 Subject: [PATCH 02/13] fix: classify commit messages by change type --- crates/worker/src/prompt/catalog.rs | 25 +++++++++++++++++++++++++ resources/prompts/catalog.dcdl | 2 ++ resources/prompts/common/git.md | 13 +++++++++++++ resources/prompts/default.md | 2 ++ resources/prompts/role/coder.md | 2 ++ resources/prompts/role/orchestrator.md | 2 ++ 6 files changed, 46 insertions(+) create mode 100644 resources/prompts/common/git.md diff --git a/crates/worker/src/prompt/catalog.rs b/crates/worker/src/prompt/catalog.rs index 0b874567..82518b90 100644 --- a/crates/worker/src/prompt/catalog.rs +++ b/crates/worker/src/prompt/catalog.rs @@ -527,6 +527,31 @@ mod tests { ); } + #[test] + fn commit_capable_roles_classify_commits_by_change_type() { + let catalog = PromptCatalog::builtins_only().unwrap(); + assert!(catalog.projection.templates.contains_key("common.git")); + let context = Value::from_serialize(serde_json::json!({ + "cwd": "/workspace", + "date": "2026-08-16", + "language": "match the user's language", + "tool_capabilities": { + "memory_any": false, + "memory_mutation": false, + "memory_query": false, + "memory_read_document": false + } + })); + + for prompt in ["default", "role.coder", "role.orchestrator"] { + let rendered = catalog.render_name(prompt, context.clone()).unwrap(); + assert!(rendered.contains("use the change type as the subject prefix")); + assert!(rendered.contains("A change made because review")); + assert!(rendered.contains("Do not keep reusing a domain prefix")); + assert!(rendered.contains("fix: scope merge request foreign key checks")); + } + } + #[test] fn builtin_render_resolves_catalog_root_dotted_includes() { let catalog = PromptCatalog::builtins_only().unwrap(); diff --git a/resources/prompts/catalog.dcdl b/resources/prompts/catalog.dcdl index c6e3599f..84dd4b2f 100644 --- a/resources/prompts/catalog.dcdl +++ b/resources/prompts/catalog.dcdl @@ -5,6 +5,7 @@ let defaultDocument = import "./default.md"; commonLanguage = import "./common/language.md"; +commonGit = import "./common/git.md"; commonTickets = import "./common/tickets.md"; commonToolUsage = import "./common/tool-usage.md"; commonWorkerObservation = import "./common/worker-observation.md"; @@ -33,6 +34,7 @@ in { default_prompt = defaultDocument.content; common = { + git = commonGit.content; language = commonLanguage.content; tickets = commonTickets.content; tool_usage = commonToolUsage.content; diff --git a/resources/prompts/common/git.md b/resources/prompts/common/git.md new file mode 100644 index 00000000..2b05bc52 --- /dev/null +++ b/resources/prompts/common/git.md @@ -0,0 +1,13 @@ +--- +description: "Git commit classification policy for implementation-capable Workers." +--- + +## Git commit messages + +This policy governs naming only and does not grant authority to commit or rewrite history. + +When creating a commit, use the change type as the subject prefix, not the affected subsystem, feature, Ticket, crate, or domain. Use `feat:` for new behavior or capability, `fix:` for defect corrections, `refactor:` for behavior-preserving restructuring, `test:` for test-only changes, `docs:` for documentation-only changes, and `chore:` for maintenance that fits none of those types. Keep the subject concise and put the affected scope after the prefix, for example `fix: scope merge request foreign key checks`. + +A change made because review, validation, or user feedback found a defect is a `fix:` even when it belongs to the same feature Ticket and has not been merged yet. Do not keep reusing a domain prefix such as `merge-request:`, `runtime:`, or `worker:` across a series; those labels identify where the code lives rather than why each commit exists. If one prospective commit contains distinct change types, split it into coherent validated commits when practical; otherwise name it for the dominant intent. + +Before opening or appending an immutable Merge Request revision, inspect the proposed commit subjects and correct misclassified local, unshared commits when safe. Do not rewrite shared history solely to rename existing commits unless the user explicitly requests it. diff --git a/resources/prompts/default.md b/resources/prompts/default.md index 81c945e6..d0467d42 100644 --- a/resources/prompts/default.md +++ b/resources/prompts/default.md @@ -2,6 +2,8 @@ You are here as an agent of the "yoi system". Stay precise, edit code directly when asked, and avoid speculative refactoring. +{% include "common.git" %} + {% include "common.workspace" %} {% include "common.tool_usage" %} diff --git a/resources/prompts/role/coder.md b/resources/prompts/role/coder.md index 3cd33586..339c7b57 100644 --- a/resources/prompts/role/coder.md +++ b/resources/prompts/role/coder.md @@ -2,6 +2,8 @@ You are the assigned Coder. Implement the requested scope in the provided Workdi Treat the first committed user message as the bounded Ticket/action context and do not infer control-plane identity from prose. +{% include "common.git" %} + Before review, open or append an immutable Merge Request revision containing the exact base/head/tree and changed-path evidence. Spawn the Reviewer only as your actual direct-child `builtin:reviewer` SubWorker, delegate read-only scope, and include the structured `review` handoff with the Ticket id and current MR revision id. Reviewer prose is not approval: the child must commit `MergeRequestReviewSubmit` through its injected attempt authority. A request-changes result requires a new immutable revision and a fresh Reviewer child attempt. Flow terminal state is not Ticket completion authority. Complete only through `MergeRequestComplete` with a unique operation id and the currently approved revision; the Server revalidates assignment and fences Ticket state side effects. diff --git a/resources/prompts/role/orchestrator.md b/resources/prompts/role/orchestrator.md index e7c83928..dc3e889b 100644 --- a/resources/prompts/role/orchestrator.md +++ b/resources/prompts/role/orchestrator.md @@ -1,5 +1,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. Record `inprogress` before implementation side effects, then use `SpawnTicketCoder` so Worker creation, the fixed Coder profile/Flow, and the current Ticket assignment are one guarded operation. After spawn, reread the Ticket and verify 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, and stop for human authority when merge/closure is not explicitly delegated. 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 current-revision durable review evidence is missing, indeterminate, 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. From e881c8fad8103e3c8edc98e08de5aa2793b3b4af Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 17 Aug 2026 02:51:18 +0900 Subject: [PATCH 03/13] fix: remove speculative worker control delegation --- .../src/feature/builtin/manage_worker.rs | 130 +--- crates/workspace-server/src/server.rs | 556 +----------------- crates/workspace-server/src/store.rs | 313 +++++----- 3 files changed, 155 insertions(+), 844 deletions(-) diff --git a/crates/worker/src/feature/builtin/manage_worker.rs b/crates/worker/src/feature/builtin/manage_worker.rs index a66a531c..54b97524 100644 --- a/crates/worker/src/feature/builtin/manage_worker.rs +++ b/crates/worker/src/feature/builtin/manage_worker.rs @@ -435,12 +435,6 @@ impl FeatureModule for ManageWorkerFeature { WorkerOperation::Remove => { definition::(operation, self.control.clone()) } - WorkerOperation::Share | WorkerOperation::Transfer => { - definition::(operation, self.control.clone()) - } - WorkerOperation::Revoke => { - definition::(operation, self.control.clone()) - } }; context .tools() @@ -540,19 +534,6 @@ struct WorkerRemoveInput { reason: String, } -#[derive(Debug, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -struct WorkerRevokeInput { - grant_id: String, -} - -#[derive(Debug, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -struct WorkerDelegateInput { - grant_id: String, - target_controller: WorkerSubjectInput, -} - struct WorkspaceWorkerTool { operation: WorkerOperation, control: Arc, @@ -568,13 +549,10 @@ enum WorkerOperation { Stop, Restore, Remove, - Share, - Transfer, - Revoke, } impl WorkerOperation { - const ALL: [Self; 11] = [ + const ALL: [Self; 8] = [ Self::List, Self::Spawn, Self::SendInput, @@ -583,9 +561,6 @@ impl WorkerOperation { Self::Stop, Self::Restore, Self::Remove, - Self::Share, - Self::Transfer, - Self::Revoke, ]; fn tool_name(self) -> &'static str { @@ -598,9 +573,6 @@ impl WorkerOperation { Self::Stop => "WorkerStop", Self::Restore => "WorkerRestore", Self::Remove => "WorkerRemove", - Self::Share => "WorkerShare", - Self::Transfer => "WorkerTransfer", - Self::Revoke => "WorkerRevoke", } } @@ -622,11 +594,6 @@ impl WorkerOperation { Self::Remove => { "Remove an eligible stopped, unassigned, non-internal Worker. Supply the current Worker revision and a bounded reason; Backend validation and retention are authoritative." } - Self::Share => "Share one controlled Runtime Worker with another known Runtime Worker.", - Self::Transfer => { - "Transfer one controlled Runtime Worker to another known Runtime Worker." - } - Self::Revoke => "Revoke one durable Runtime Worker control grant owned by this Worker.", } } } @@ -792,59 +759,6 @@ impl Tool for WorkspaceWorkerTool { ) .map_err(control_tool_error)? } - WorkerOperation::Share | WorkerOperation::Transfer => { - let input = parse::(input_json, self.operation.tool_name())?; - let grant_id = authority_id(&input.grant_id, "grant_id")?; - let (runtime_id, worker_id) = - runtime_subject_ids(&input.target_controller, self.operation)?; - let operation_id = format!( - "worker-control-{}:{}", - if self.operation == WorkerOperation::Transfer { - "transfer" - } else { - "share" - }, - non_empty(ctx.call_id.clone(), "tool call_id")? - ); - let action = if self.operation == WorkerOperation::Transfer { - "transfer" - } else { - "share" - }; - self.control - .execute_runtime(WorkspaceRequest::json( - WorkspaceRequestMethod::Post, - format!( - "/api/w/{}/worker-control/grants/{grant_id}/{action}", - self.control.workspace_id() - ), - serde_json::json!({ - "target_controller": { - "runtime_id": runtime_id, - "worker_id": worker_id, - }, - "operation_id": operation_id, - }) - .to_string(), - )) - .await - .map_err(control_tool_error)? - } - WorkerOperation::Revoke => { - let input = parse::(input_json, "WorkerRevoke")?; - let grant_id = authority_id(&input.grant_id, "grant_id")?; - self.control - .execute_runtime(WorkspaceRequest::json( - WorkspaceRequestMethod::Post, - format!( - "/api/w/{}/worker-control/grants/{grant_id}/revoke", - self.control.workspace_id() - ), - "{}", - )) - .await - .map_err(control_tool_error)? - } }; tool_output(self.operation, response) } @@ -1131,9 +1045,6 @@ mod tests { "WorkerStop", "WorkerRestore", "WorkerRemove", - "WorkerShare", - "WorkerTransfer", - "WorkerRevoke", ] ); } @@ -1248,45 +1159,6 @@ mod tests { } } - #[tokio::test] - async fn worker_share_and_transfer_use_typed_runtime_subjects_and_operation_ids() { - let client = Arc::new(RecordingWorkspaceClient::default()); - for (operation, action) in [ - (WorkerOperation::Share, "share"), - (WorkerOperation::Transfer, "transfer"), - ] { - WorkspaceWorkerTool { - operation, - control: test_control(client.clone()), - } - .execute( - &serde_json::json!({ - "grant_id": "grant-1", - "target_controller": { - "kind": "runtime_worker", - "runtime_id": "runtime-2", - "worker_id": "worker-9", - }, - }) - .to_string(), - ToolExecutionContext::new("call-delegate", "batch-delegate", 0), - ) - .await - .unwrap(); - let request = client.requests.lock().unwrap().last().cloned().unwrap(); - assert!(request.path.ends_with(&format!("/grant-1/{action}"))); - let body: serde_json::Value = - serde_json::from_str(request.body.as_deref().unwrap()).unwrap(); - assert_eq!(body["target_controller"]["runtime_id"], "runtime-2"); - assert!( - body["operation_id"] - .as_str() - .unwrap() - .contains("call-delegate") - ); - } - } - #[tokio::test] async fn worker_remove_forwards_only_target_revision_and_bounded_reason() { let client = Arc::new(RecordingWorkspaceClient::default()); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index efe0b2ae..c4807de7 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -103,9 +103,8 @@ use crate::skills; use crate::store::{ AccountRecord, ApiTokenRecord, AuthChallengeRecord, BrowserSessionRecord, ControlPlaneStore, DeviceLoginFlowRecord, FlowSourceRecord, PasskeyCredentialRecord, RepositoryRecord, - TicketWorkerAssignmentRecord, UserRecord, WorkdirRegistryRecord, - WorkerControlDelegationOperationRecord, WorkerControlGrantRecord, WorkerRegistryRecord, - WorkerWorkdirLinkRecord, WorkspaceRecord, + TicketWorkerAssignmentRecord, UserRecord, WorkdirRegistryRecord, WorkerControlGrantRecord, + WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord, }; use crate::{Error, Result}; use worker_runtime::catalog::{ @@ -1477,18 +1476,6 @@ pub fn build_router(api: WorkspaceApi) -> Router { "/api/w/{workspace_id}/worker-control/workers", get(list_known_workers).post(spawn_known_worker), ) - .route( - "/api/w/{workspace_id}/worker-control/grants/{grant_id}/share", - post(share_worker_control_grant), - ) - .route( - "/api/w/{workspace_id}/worker-control/grants/{grant_id}/transfer", - post(transfer_worker_control_grant), - ) - .route( - "/api/w/{workspace_id}/worker-control/grants/{grant_id}/revoke", - post(revoke_worker_control_grant), - ) .route( "/api/w/{workspace_id}/worker-control/workers/{runtime_id}/{worker_id}/input", post(send_known_worker_input), @@ -2258,12 +2245,6 @@ struct ScopedWorkspacePath { workspace_id: String, } -#[derive(Debug, Deserialize)] -struct ScopedWorkerControlGrantPath { - workspace_id: String, - grant_id: String, -} - #[derive(Debug, Deserialize)] struct ScopedFlowPath { workspace_id: String, @@ -5917,7 +5898,6 @@ async fn scoped_workspace_orchestrator_status( #[derive(Debug, Serialize, Deserialize)] struct KnownWorkerRecord { - grant_id: String, subject: RuntimeWorkerRef, relation: String, origin: String, @@ -5952,7 +5932,6 @@ async fn list_known_workers( .worker(&grant.subject) .map_err(|error| error.into_error())?; items.push(KnownWorkerRecord { - grant_id: grant.grant_id, subject: grant.subject, relation: grant.relation, origin: grant.origin, @@ -6028,8 +6007,6 @@ async fn spawn_known_worker( "stop".to_string(), "restore".to_string(), "remove".to_string(), - "share".to_string(), - "transfer".to_string(), "observe".to_string(), ], operation_id, @@ -6083,250 +6060,6 @@ fn authorize_known_worker_permission( Ok(grant) } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct DelegateWorkerControlGrantRequest { - target_controller: RuntimeWorkerRef, - operation_id: String, -} - -async fn share_worker_control_grant( - State(api): State, - AxumPath(path): AxumPath, - headers: HeaderMap, - Json(request): Json, -) -> ApiResult> { - delegate_worker_control_grant(api, path, headers, request, false).await -} - -async fn transfer_worker_control_grant( - State(api): State, - AxumPath(path): AxumPath, - headers: HeaderMap, - Json(request): Json, -) -> ApiResult> { - delegate_worker_control_grant(api, path, headers, request, true).await -} - -fn worker_control_delegation_input_fingerprint( - controller: &RuntimeWorkerRef, - grant: &WorkerControlGrantRecord, - action: &str, - target_controller: &RuntimeWorkerRef, -) -> Result { - let operation_input = serde_json::json!({ - "source_controller": controller, - "source_grant_id": &grant.grant_id, - "action": action, - "target_controller": target_controller, - "subject": &grant.subject, - "permissions": &grant.permissions, - }); - let operation_bytes = serde_json::to_vec(&operation_input).map_err(|error| { - Error::InvalidInput(format!("invalid Worker delegation input: {error}")) - })?; - Ok(format!( - "sha256:{}", - Sha256::digest(&operation_bytes) - .iter() - .map(|byte| format!("{byte:02x}")) - .collect::() - )) -} - -async fn delegate_worker_control_grant( - api: WorkspaceApi, - path: ScopedWorkerControlGrantPath, - headers: HeaderMap, - request: DelegateWorkerControlGrantRequest, - transfer: bool, -) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let source = authenticate_worker_mutation_source(&api, &path.workspace_id, &headers)?; - let controller = RuntimeWorkerRef::new(&source.runtime_id, &source.worker_id); - let permission = if transfer { "transfer" } else { "share" }; - let grant = api - .store - .get_worker_control_grant(&path.workspace_id, &path.grant_id)? - .filter(|grant| { - grant.controller == controller - && grant.permissions.iter().any(|value| value == permission) - }) - .ok_or_else(|| Error::UnknownWorker { - worker: controller.clone(), - })?; - if request.target_controller == controller { - return Err(ApiError::from(Error::InvalidInput( - "target_controller must differ from the current Worker".to_string(), - ))); - } - let operation_id = request.operation_id.trim(); - if operation_id.is_empty() || operation_id.len() > 200 { - return Err(ApiError::from(Error::InvalidInput( - "operation_id must contain 1..=200 bytes".to_string(), - ))); - } - let input_fingerprint = worker_control_delegation_input_fingerprint( - &controller, - &grant, - permission, - &request.target_controller, - )?; - let now = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); - let operation = api.store.reserve_worker_control_delegation_operation( - &WorkerControlDelegationOperationRecord { - workspace_id: path.workspace_id.clone(), - source_controller: controller.clone(), - source_grant_id: grant.grant_id.clone(), - operation_id: operation_id.to_string(), - input_fingerprint, - delegated_grant_id: None, - created_at: now.clone(), - completed_at: None, - }, - )?; - if let Some(delegated_grant_id) = operation.delegated_grant_id.as_deref() { - let delegated = api - .store - .get_worker_control_grant(&path.workspace_id, delegated_grant_id)? - .ok_or_else(|| { - Error::Store("completed Worker delegation references a missing grant".to_string()) - })?; - if transfer && grant.revoked_at.is_none() { - let lock = worker_control_lock(&api, &grant.grant_id); - let _guard = lock.lock().await; - if api - .store - .get_worker_control_grant(&path.workspace_id, &grant.grant_id)? - .is_some_and(|current| current.revoked_at.is_none()) - { - api.store - .revoke_worker_control_grant(&path.workspace_id, &grant.grant_id, &now)?; - } - } - return Ok(Json(delegated)); - } - if grant.revoked_at.is_some() { - return Err(ApiError::from(Error::UnknownWorker { - worker: grant.subject, - })); - } - api.store - .get_active_worker_control_grant( - &path.workspace_id, - &controller, - &request.target_controller, - )? - .ok_or_else(|| Error::UnknownWorker { - worker: request.target_controller.clone(), - })?; - api.store - .get_worker_registry(&path.workspace_id, &request.target_controller)? - .ok_or_else(|| Error::UnknownWorker { - worker: request.target_controller.clone(), - })?; - - let lock = worker_control_lock(&api, &grant.grant_id); - let _guard = lock.lock().await; - let current = api - .store - .get_worker_control_grant(&path.workspace_id, &path.grant_id)? - .filter(|candidate| { - candidate.controller == controller - && candidate.revoked_at.is_none() - && candidate - .permissions - .iter() - .any(|value| value == permission) - }) - .ok_or_else(|| Error::UnknownWorker { - worker: grant.subject.clone(), - })?; - api.store - .get_active_worker_control_grant( - &path.workspace_id, - &controller, - &request.target_controller, - )? - .ok_or_else(|| Error::UnknownWorker { - worker: request.target_controller.clone(), - })?; - let delegated_operation_id = format!( - "worker-control-delegate:{}:{}:{}:{}:{}", - controller.runtime_id, controller.worker_id, current.grant_id, permission, operation_id - ); - let expected_origin = format!("worker_control_{permission}:{}", current.grant_id); - let delegated = api - .store - .create_worker_control_grant(&WorkerControlGrantRecord { - workspace_id: path.workspace_id.clone(), - grant_id: new_id("wcg"), - controller: request.target_controller, - subject: current.subject.clone(), - relation: if transfer { "transferred" } else { "shared" }.to_string(), - origin: expected_origin, - permissions: current.permissions.clone(), - operation_id: delegated_operation_id, - created_at: now.clone(), - revoked_at: None, - })?; - api.store.complete_worker_control_delegation_operation( - &path.workspace_id, - &controller, - operation_id, - &delegated.grant_id, - &now, - )?; - if transfer - && !api - .store - .revoke_worker_control_grant(&path.workspace_id, ¤t.grant_id, &now)? - { - return Err(ApiError::from(Error::UnknownWorker { - worker: current.subject, - })); - } - Ok(Json(delegated)) -} - -async fn revoke_worker_control_grant( - State(api): State, - AxumPath(path): AxumPath, - headers: HeaderMap, -) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let source = authenticate_worker_mutation_source(&api, &path.workspace_id, &headers)?; - let controller = RuntimeWorkerRef::new(&source.runtime_id, &source.worker_id); - let grant = api - .store - .get_worker_control_grant(&path.workspace_id, &path.grant_id)? - .filter(|grant| grant.controller == controller && grant.revoked_at.is_none()) - .ok_or_else(|| Error::UnknownWorker { - worker: controller.clone(), - })?; - let lock = worker_control_lock(&api, &grant.grant_id); - let _guard = lock.lock().await; - let current = api - .store - .get_worker_control_grant(&path.workspace_id, &path.grant_id)? - .filter(|candidate| candidate.controller == controller && candidate.revoked_at.is_none()) - .ok_or_else(|| Error::UnknownWorker { - worker: grant.subject.clone(), - })?; - let revoked_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); - if !api - .store - .revoke_worker_control_grant(&path.workspace_id, &path.grant_id, &revoked_at)? - { - return Err(ApiError::from(Error::UnknownWorker { - worker: current.subject, - })); - } - let mut revoked = current; - revoked.revoked_at = Some(revoked_at); - Ok(Json(revoked)) -} - async fn send_known_worker_input( State(api): State, AxumPath(path): AxumPath, @@ -13532,7 +13265,6 @@ mod tests { .await .unwrap(); assert_eq!(known.items.len(), 1); - assert_eq!(known.items[0].grant_id, "orchestrator-controls-generic"); assert_eq!(known.items[0].subject, generic.worker_ref); assert_eq!(known.items[0].permissions, ["observe"]); @@ -13571,17 +13303,15 @@ mod tests { .unwrap(); assert!(capture["entries"].is_array()); - let Json(revoked) = revoke_worker_control_grant( - State(api.clone()), - AxumPath(ScopedWorkerControlGrantPath { - workspace_id: workspace_id.clone(), - grant_id: "orchestrator-controls-generic".to_string(), - }), - observation_headers.clone(), - ) - .await - .unwrap(); - assert!(revoked.revoked_at.is_some()); + let revoked = api + .store + .revoke_worker_control_grant( + &workspace_id, + "orchestrator-controls-generic", + "2026-07-27T00:00:02Z", + ) + .unwrap(); + assert!(revoked); let revoked_capture = scoped_capture_worker_observation_session( State(api.clone()), AxumPath(ScopedWorkspacePath { @@ -13620,270 +13350,6 @@ mod tests { .unwrap(); assert!(unauthorized["sessions"].as_array().unwrap().is_empty()); - for (grant_id, permission) in [ - ("orchestrator-share-source", "share"), - ("orchestrator-transfer-source", "transfer"), - ] { - api.store - .create_worker_control_grant(&WorkerControlGrantRecord { - workspace_id: workspace_id.clone(), - grant_id: grant_id.to_string(), - controller: dedicated.worker.clone(), - subject: generic.worker_ref.clone(), - relation: "spawned".to_string(), - origin: "test-delegation".to_string(), - permissions: vec!["observe".to_string(), permission.to_string()], - operation_id: format!("seed-{permission}"), - created_at: "2026-07-27T00:00:01Z".to_string(), - revoked_at: None, - }) - .unwrap(); - } - let target_controller = generic.worker_ref.clone(); - let Json(shared) = share_worker_control_grant( - State(api.clone()), - AxumPath(ScopedWorkerControlGrantPath { - workspace_id: workspace_id.clone(), - grant_id: "orchestrator-share-source".to_string(), - }), - observation_headers.clone(), - Json(DelegateWorkerControlGrantRequest { - target_controller: target_controller.clone(), - operation_id: "share-operation".to_string(), - }), - ) - .await - .unwrap(); - assert_eq!(shared.controller, target_controller); - assert_eq!(shared.relation, "shared"); - - let alternate_target = RuntimeWorkerRef::new(EMBEDDED_WORKER_RUNTIME_ID, "1000"); - let now = now_registry_timestamp(); - api.store - .upsert_worker_registry(&WorkerRegistryRecord { - workspace_id: workspace_id.clone(), - worker: alternate_target.clone(), - display_name: "Alternate known target".to_string(), - profile: None, - retention_state: "normal".to_string(), - transcript_ref: None, - session_ref: None, - summary_ref: None, - diagnostics_ref: None, - created_at: now.clone(), - updated_at: now.clone(), - }) - .unwrap(); - let registry_only_target = RuntimeWorkerRef::new(EMBEDDED_WORKER_RUNTIME_ID, "1001"); - api.store - .upsert_worker_registry(&WorkerRegistryRecord { - workspace_id: workspace_id.clone(), - worker: registry_only_target.clone(), - display_name: "Registry-only target".to_string(), - profile: None, - retention_state: "normal".to_string(), - transcript_ref: None, - session_ref: None, - summary_ref: None, - diagnostics_ref: None, - created_at: now.clone(), - updated_at: now.clone(), - }) - .unwrap(); - let unknown_target = share_worker_control_grant( - State(api.clone()), - AxumPath(ScopedWorkerControlGrantPath { - workspace_id: workspace_id.clone(), - grant_id: "orchestrator-share-source".to_string(), - }), - observation_headers.clone(), - Json(DelegateWorkerControlGrantRequest { - target_controller: registry_only_target, - operation_id: "share-registry-only".to_string(), - }), - ) - .await - .unwrap_err(); - assert_eq!( - unknown_target.into_response().status(), - StatusCode::NOT_FOUND - ); - for (grant_id, operation_id, subject) in [ - ( - "orchestrator-knows-alternate", - "seed-known-alternate", - alternate_target.clone(), - ), - ( - "orchestrator-second-share-source", - "seed-second-share", - generic.worker_ref.clone(), - ), - ] { - api.store - .create_worker_control_grant(&WorkerControlGrantRecord { - workspace_id: workspace_id.clone(), - grant_id: grant_id.to_string(), - controller: dedicated.worker.clone(), - subject, - relation: "spawned".to_string(), - origin: "test-delegation-conflict".to_string(), - permissions: vec!["share".to_string()], - operation_id: operation_id.to_string(), - created_at: now.clone(), - revoked_at: None, - }) - .unwrap(); - } - let changed_target = share_worker_control_grant( - State(api.clone()), - AxumPath(ScopedWorkerControlGrantPath { - workspace_id: workspace_id.clone(), - grant_id: "orchestrator-share-source".to_string(), - }), - observation_headers.clone(), - Json(DelegateWorkerControlGrantRequest { - target_controller: alternate_target, - operation_id: "share-operation".to_string(), - }), - ) - .await - .unwrap_err(); - assert_eq!( - changed_target.into_response().status(), - StatusCode::BAD_REQUEST - ); - let changed_source_grant = share_worker_control_grant( - State(api.clone()), - AxumPath(ScopedWorkerControlGrantPath { - workspace_id: workspace_id.clone(), - grant_id: "orchestrator-second-share-source".to_string(), - }), - observation_headers.clone(), - Json(DelegateWorkerControlGrantRequest { - target_controller: generic.worker_ref.clone(), - operation_id: "share-operation".to_string(), - }), - ) - .await - .unwrap_err(); - assert_eq!( - changed_source_grant.into_response().status(), - StatusCode::BAD_REQUEST - ); - - let recovery_source = api - .store - .get_worker_control_grant(&workspace_id, "orchestrator-transfer-source") - .unwrap() - .unwrap(); - let recovery_operation_id = "transfer-operation"; - let recovery_fingerprint = worker_control_delegation_input_fingerprint( - &dedicated.worker, - &recovery_source, - "transfer", - &generic.worker_ref, - ) - .unwrap(); - let recovery_now = now_registry_timestamp(); - api.store - .reserve_worker_control_delegation_operation(&WorkerControlDelegationOperationRecord { - workspace_id: workspace_id.clone(), - source_controller: dedicated.worker.clone(), - source_grant_id: recovery_source.grant_id.clone(), - operation_id: recovery_operation_id.to_string(), - input_fingerprint: recovery_fingerprint, - delegated_grant_id: None, - created_at: recovery_now.clone(), - completed_at: None, - }) - .unwrap(); - let precompleted_transfer = api - .store - .create_worker_control_grant(&WorkerControlGrantRecord { - workspace_id: workspace_id.clone(), - grant_id: "precompleted-transfer-grant".to_string(), - controller: generic.worker_ref.clone(), - subject: recovery_source.subject.clone(), - relation: "transferred".to_string(), - origin: format!("worker_control_transfer:{}", recovery_source.grant_id), - permissions: recovery_source.permissions.clone(), - operation_id: format!( - "worker-control-delegate:{}:{}:{}:transfer:{}", - dedicated.worker.runtime_id, - dedicated.worker.worker_id, - recovery_source.grant_id, - recovery_operation_id, - ), - created_at: recovery_now.clone(), - revoked_at: None, - }) - .unwrap(); - api.store - .complete_worker_control_delegation_operation( - &workspace_id, - &dedicated.worker, - recovery_operation_id, - &precompleted_transfer.grant_id, - &recovery_now, - ) - .unwrap(); - assert!( - api.store - .get_worker_control_grant(&workspace_id, &recovery_source.grant_id) - .unwrap() - .unwrap() - .revoked_at - .is_none() - ); - - let Json(transferred) = transfer_worker_control_grant( - State(api.clone()), - AxumPath(ScopedWorkerControlGrantPath { - workspace_id: workspace_id.clone(), - grant_id: "orchestrator-transfer-source".to_string(), - }), - observation_headers.clone(), - Json(DelegateWorkerControlGrantRequest { - target_controller: generic.worker_ref.clone(), - operation_id: "transfer-operation".to_string(), - }), - ) - .await - .unwrap(); - assert_eq!(transferred.grant_id, precompleted_transfer.grant_id); - assert!( - api.store - .get_worker_control_grant(&workspace_id, &recovery_source.grant_id) - .unwrap() - .unwrap() - .revoked_at - .is_some() - ); - let Json(transfer_replay) = transfer_worker_control_grant( - State(api.clone()), - AxumPath(ScopedWorkerControlGrantPath { - workspace_id: workspace_id.clone(), - grant_id: "orchestrator-transfer-source".to_string(), - }), - observation_headers, - Json(DelegateWorkerControlGrantRequest { - target_controller: generic.worker_ref.clone(), - operation_id: "transfer-operation".to_string(), - }), - ) - .await - .unwrap(); - assert_eq!(transfer_replay.grant_id, transferred.grant_id); - assert!( - api.store - .get_worker_control_grant(&workspace_id, "orchestrator-transfer-source") - .unwrap() - .unwrap() - .revoked_at - .is_some() - ); - let Json(existing) = scoped_start_workspace_orchestrator( State(api.clone()), AxumPath(ScopedWorkspacePath { diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index eb89c8f8..43228d9c 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -191,6 +191,11 @@ const MIGRATIONS: &[Migration] = &[ name: "create Worker control delegation operation authority", apply: create_worker_control_delegation_operation_authority, }, + Migration { + version: 35, + name: "remove Worker control delegation authority", + apply: remove_worker_control_delegation_authority, + }, ]; struct Migration { @@ -352,18 +357,6 @@ pub struct WorkerControlGrantRecord { pub revoked_at: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct WorkerControlDelegationOperationRecord { - pub workspace_id: String, - pub source_controller: RuntimeWorkerRef, - pub source_grant_id: String, - pub operation_id: String, - pub input_fingerprint: String, - pub delegated_grant_id: Option, - pub created_at: String, - pub completed_at: Option, -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct TicketWorkerAssignmentRecord { pub workspace_id: String, @@ -804,19 +797,6 @@ pub trait ControlPlaneStore: Send + Sync { grant_id: &str, revoked_at: &str, ) -> Result; - fn reserve_worker_control_delegation_operation( - &self, - record: &WorkerControlDelegationOperationRecord, - ) -> Result; - fn complete_worker_control_delegation_operation( - &self, - workspace_id: &str, - source_controller: &RuntimeWorkerRef, - operation_id: &str, - delegated_grant_id: &str, - completed_at: &str, - ) -> Result; - fn get_ticket_assignment_operation( &self, workspace_id: &str, @@ -2578,95 +2558,6 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } - fn reserve_worker_control_delegation_operation( - &self, - record: &WorkerControlDelegationOperationRecord, - ) -> Result { - self.with_conn(|conn| { - conn.execute( - r#"INSERT INTO worker_control_delegation_operations ( - workspace_id, source_controller_runtime_id, source_controller_worker_id, - source_grant_id, operation_id, input_fingerprint, - delegated_grant_id, created_at, completed_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) - ON CONFLICT ( - workspace_id, source_controller_runtime_id, - source_controller_worker_id, operation_id - ) DO NOTHING"#, - params![ - record.workspace_id, - record.source_controller.runtime_id, - record.source_controller.worker_id, - record.source_grant_id, - record.operation_id, - record.input_fingerprint, - record.delegated_grant_id, - record.created_at, - record.completed_at, - ], - )?; - let persisted = read_worker_control_delegation_operation_by_key( - conn, - &record.workspace_id, - &record.source_controller, - &record.operation_id, - )? - .ok_or_else(|| { - Error::Store("worker control delegation operation was not persisted".to_string()) - })?; - if persisted.source_grant_id != record.source_grant_id - || persisted.input_fingerprint != record.input_fingerprint - { - return Err(Error::InvalidInput(format!( - "worker control delegation operation `{}` was already used with different input", - record.operation_id - ))); - } - Ok(persisted) - }) - } - - fn complete_worker_control_delegation_operation( - &self, - workspace_id: &str, - source_controller: &RuntimeWorkerRef, - operation_id: &str, - delegated_grant_id: &str, - completed_at: &str, - ) -> Result { - self.with_conn(|conn| { - conn.execute( - r#"UPDATE worker_control_delegation_operations - SET delegated_grant_id = ?5, completed_at = ?6 - WHERE workspace_id = ?1 - AND source_controller_runtime_id = ?2 - AND source_controller_worker_id = ?3 - AND operation_id = ?4 - AND (delegated_grant_id IS NULL OR delegated_grant_id = ?5)"#, - params![ - workspace_id, - source_controller.runtime_id, - source_controller.worker_id, - operation_id, - delegated_grant_id, - completed_at, - ], - )?; - read_worker_control_delegation_operation_by_key( - conn, - workspace_id, - source_controller, - operation_id, - )? - .filter(|record| record.delegated_grant_id.as_deref() == Some(delegated_grant_id)) - .ok_or_else(|| { - Error::InvalidInput(format!( - "worker control delegation operation `{operation_id}` completed with a different grant" - )) - }) - }) - } - fn get_ticket_assignment_operation( &self, workspace_id: &str, @@ -4024,52 +3915,6 @@ fn read_worker_control_grant_by_operation( .map_err(Error::from) } -fn read_worker_control_delegation_operation_record( - row: &rusqlite::Row<'_>, -) -> rusqlite::Result { - Ok(WorkerControlDelegationOperationRecord { - workspace_id: row.get(0)?, - source_controller: RuntimeWorkerRef::new( - row.get::<_, String>(1)?, - row.get::<_, u64>(2)?.to_string(), - ), - source_grant_id: row.get(3)?, - operation_id: row.get(4)?, - input_fingerprint: row.get(5)?, - delegated_grant_id: row.get(6)?, - created_at: row.get(7)?, - completed_at: row.get(8)?, - }) -} - -fn read_worker_control_delegation_operation_by_key( - conn: &Connection, - workspace_id: &str, - source_controller: &RuntimeWorkerRef, - operation_id: &str, -) -> Result> { - conn.query_row( - r#"SELECT workspace_id, - source_controller_runtime_id, source_controller_worker_id, - source_grant_id, operation_id, input_fingerprint, - delegated_grant_id, created_at, completed_at - FROM worker_control_delegation_operations - WHERE workspace_id = ?1 - AND source_controller_runtime_id = ?2 - AND source_controller_worker_id = ?3 - AND operation_id = ?4"#, - params![ - workspace_id, - source_controller.runtime_id, - source_controller.worker_id, - operation_id, - ], - read_worker_control_delegation_operation_record, - ) - .optional() - .map_err(Error::from) -} - fn current_ticket_worker_assignment_select_sql() -> String { "SELECT a.workspace_id, a.ticket_id, a.assignment_id, a.runtime_id, a.worker_id, \ a.assigned_by, a.assigned_at \ @@ -5168,6 +5013,58 @@ fn create_worker_control_delegation_operation_authority(conn: &Connection) -> Re Ok(()) } +fn remove_worker_control_delegation_authority(conn: &Connection) -> Result<()> { + let mut statement = + conn.prepare("SELECT workspace_id, grant_id, permissions_json FROM worker_control_grants")?; + let rows = statement.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + })?; + let mut permission_updates = Vec::new(); + for row in rows { + let (workspace_id, grant_id, permissions_json) = row?; + let mut permissions: Vec = + serde_json::from_str(&permissions_json).map_err(|error| { + Error::Store(format!( + "failed to decode Worker control grant `{grant_id}` permissions during delegation removal: {error}" + )) + })?; + let previous_len = permissions.len(); + permissions + .retain(|permission| !matches!(permission.as_str(), "share" | "transfer" | "revoke")); + if permissions.len() != previous_len { + permission_updates.push(( + workspace_id, + grant_id, + serde_json::to_string(&permissions).map_err(|error| { + Error::Store(format!( + "failed to encode Worker control grant permissions during delegation removal: {error}" + )) + })?, + )); + } + } + drop(statement); + + for (workspace_id, grant_id, permissions_json) in permission_updates { + conn.execute( + "UPDATE worker_control_grants SET permissions_json = ?3 WHERE workspace_id = ?1 AND grant_id = ?2", + params![workspace_id, grant_id, permissions_json], + )?; + } + conn.execute( + r#"UPDATE worker_control_grants + SET revoked_at = COALESCE(revoked_at, strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + WHERE relation IN ('shared', 'transferred')"#, + [], + )?; + conn.execute_batch("DROP TABLE IF EXISTS worker_control_delegation_operations;")?; + Ok(()) +} + fn create_worker_mutation_source_proof_replay_guard(conn: &Connection) -> Result<()> { conn.execute_batch( r#" @@ -5819,6 +5716,82 @@ CREATE TABLE ticket_worker_links (ticket_id TEXT, worker_ref_key TEXT); } } + #[test] + fn schema_v35_removes_worker_control_delegation_authority() { + let conn = Connection::open_in_memory().unwrap(); + configure_sqlite(&conn).unwrap(); + for migration in MIGRATIONS + .iter() + .filter(|migration| migration.version <= 34) + { + let tx = conn.unchecked_transaction().unwrap(); + (migration.apply)(&tx).unwrap(); + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![migration.version, migration.name], + ) + .unwrap(); + tx.commit().unwrap(); + } + conn.execute_batch( + r#" +INSERT INTO workspaces ( + workspace_id, display_name, state, created_at, updated_at +) VALUES ('workspace-a', 'Workspace A', 'active', '1', '1'); +INSERT INTO worker_registry ( + workspace_id, runtime_id, runtime_worker_id, display_name, + retention_state, created_at, updated_at +) VALUES + ('workspace-a', 'runtime-a', 1, 'Controller', 'normal', '1', '1'), + ('workspace-a', 'runtime-a', 2, 'Spawned Worker', 'normal', '1', '1'), + ('workspace-a', 'runtime-a', 3, 'Shared Worker', 'normal', '1', '1'), + ('workspace-a', 'runtime-a', 4, 'Transferred Worker', 'normal', '1', '1'); +INSERT INTO worker_control_grants ( + workspace_id, grant_id, + controller_runtime_id, controller_worker_id, + subject_runtime_id, subject_worker_id, + relation, origin, permissions_json, operation_id, created_at, revoked_at +) VALUES + ('workspace-a', 'spawned', 'runtime-a', 1, 'runtime-a', 2, + 'spawned', 'spawn', '["observe","share","transfer","revoke","stop"]', 'spawn-op', '1', NULL), + ('workspace-a', 'shared', 'runtime-a', 1, 'runtime-a', 3, + 'shared', 'share', '["observe"]', 'share-op', '1', NULL), + ('workspace-a', 'transferred', 'runtime-a', 1, 'runtime-a', 4, + 'transferred', 'transfer', '["observe"]', 'transfer-op', '1', NULL); +"#, + ) + .unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 34); + assert!(table_exists(&conn, "worker_control_delegation_operations").unwrap()); + + apply_migrations(&conn).unwrap(); + + assert_eq!(current_schema_version(&conn).unwrap(), 35); + assert!(!table_exists(&conn, "worker_control_delegation_operations").unwrap()); + let (permissions_json, revoked_at): (String, Option) = conn + .query_row( + "SELECT permissions_json, revoked_at FROM worker_control_grants WHERE grant_id = 'spawned'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!( + serde_json::from_str::>(&permissions_json).unwrap(), + vec!["observe", "stop"] + ); + assert!(revoked_at.is_none()); + for grant_id in ["shared", "transferred"] { + let revoked_at: Option = conn + .query_row( + "SELECT revoked_at FROM worker_control_grants WHERE grant_id = ?1", + [grant_id], + |row| row.get(0), + ) + .unwrap(); + assert!(revoked_at.is_some(), "{grant_id} grant remained active"); + } + } + #[test] fn schema_v24_adds_attachment_reservations_to_already_applied_v23() { let conn = Connection::open_in_memory().unwrap(); @@ -5841,7 +5814,7 @@ CREATE TABLE ticket_worker_links (ticket_id TEXT, worker_ref_key TEXT); apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 34); + assert_eq!(current_schema_version(&conn).unwrap(), 35); assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap()); } @@ -5874,7 +5847,7 @@ CREATE TABLE flow_events (event_id TEXT PRIMARY KEY); apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 34); + assert_eq!(current_schema_version(&conn).unwrap(), 35); assert!(table_exists(&conn, "flow_sources").unwrap()); assert!(table_exists(&conn, "flow_source_revisions").unwrap()); assert!(!table_exists(&conn, "flow_instances").unwrap()); @@ -5941,7 +5914,7 @@ INSERT INTO worker_workdir_attachment_reservations ( apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 34); + assert_eq!(current_schema_version(&conn).unwrap(), 35); let repositories_sql: String = conn .query_row( "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'repositories'", @@ -6121,7 +6094,7 @@ INSERT INTO workdir_registry ( let db = dir.path().join("control-plane.sqlite"); let store = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 34); + assert_eq!(store.schema_version().await.unwrap(), 35); assert!( !store .with_conn(|conn| table_exists(conn, "worker_workspace_credentials")) @@ -6138,7 +6111,7 @@ INSERT INTO workdir_registry ( store.upsert_workspace(&record).await.unwrap(); let reopened = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(reopened.schema_version().await.unwrap(), 34); + assert_eq!(reopened.schema_version().await.unwrap(), 35); assert_eq!( reopened.get_workspace("local-dev").await.unwrap(), Some(record) @@ -6685,7 +6658,7 @@ INSERT INTO workdir_registry ( .unwrap(); let store = SqliteWorkspaceStore::from_connection(conn).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 34); + assert_eq!(store.schema_version().await.unwrap(), 35); store .with_conn(|conn| { @@ -6874,7 +6847,7 @@ CREATE TABLE ticket_assignment_operations ( #[tokio::test] async fn repository_records_round_trip() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 34); + assert_eq!(store.schema_version().await.unwrap(), 35); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), owner_account_id: None, @@ -6940,7 +6913,7 @@ CREATE TABLE ticket_assignment_operations ( #[tokio::test] async fn memory_authority_records_round_trip_and_close_staging() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 34); + assert_eq!(store.schema_version().await.unwrap(), 35); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), owner_account_id: None, @@ -7201,7 +7174,7 @@ CREATE TABLE ticket_assignment_operations ( } #[tokio::test] - async fn worker_control_grants_are_idempotent_scoped_and_revocable() { + async fn worker_control_grants_are_idempotent_scoped_and_support_internal_invalidation() { let dir = tempfile::tempdir().unwrap(); let database = dir.path().join("control-grants.db"); let store = SqliteWorkspaceStore::open(&database).unwrap(); @@ -7331,7 +7304,7 @@ CREATE TABLE ticket_assignment_operations ( #[tokio::test] async fn account_and_login_records_round_trip() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 34); + assert_eq!(store.schema_version().await.unwrap(), 35); let now = "2026-07-22T00:00:00Z".to_string(); let account = AccountRecord { account_id: "acct-user-alice".to_string(), From ff7d87413875d79159b7b4013adc6a0d7c8403e7 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 17 Aug 2026 02:51:53 +0900 Subject: [PATCH 04/13] fix: rebuild merge request schema authority --- crates/merge-request/src/lib.rs | 649 ++++++++++++++++++++++++++++++-- 1 file changed, 609 insertions(+), 40 deletions(-) diff --git a/crates/merge-request/src/lib.rs b/crates/merge-request/src/lib.rs index 2535d402..39499f7b 100644 --- a/crates/merge-request/src/lib.rs +++ b/crates/merge-request/src/lib.rs @@ -11,7 +11,7 @@ use std::path::{Path, PathBuf}; use std::time::Duration; use thiserror::Error; -const SCHEMA_VERSION: i64 = 9; +const SCHEMA_VERSION: i64 = 10; const REVIEWER_PROFILE: &str = "builtin:reviewer"; const MAX_SUMMARY_BYTES: usize = 16 * 1024; const MAX_REVIEW_BODY_BYTES: usize = 64 * 1024; @@ -776,7 +776,10 @@ pub fn migrate(conn: &Connection) -> Result<()> { migrate_with_failpoint(conn, false) } -fn migrate_with_failpoint(conn: &Connection, force_failure_after_v9_ddl: bool) -> Result<()> { +fn migrate_with_failpoint( + conn: &Connection, + force_failure_after_migration_ddl: bool, +) -> Result<()> { let original_foreign_keys: i64 = conn .query_row("PRAGMA foreign_keys", [], |row| row.get(0)) .map_err(db)?; @@ -785,7 +788,7 @@ fn migrate_with_failpoint(conn: &Connection, force_failure_after_v9_ddl: bool) - let transaction_result = (|| { conn.execute_batch("BEGIN IMMEDIATE").map_err(db)?; - let result = migrate_locked(conn, force_failure_after_v9_ddl); + let result = migrate_locked(conn, force_failure_after_migration_ddl); match result { Ok(()) => { if let Err(error) = conn.execute_batch("COMMIT").map_err(db) { @@ -810,7 +813,7 @@ fn migrate_with_failpoint(conn: &Connection, force_failure_after_v9_ddl: bool) - verify(conn) } -fn migrate_locked(conn: &Connection, force_failure_after_v9_ddl: bool) -> Result<()> { +fn migrate_locked(conn: &Connection, force_failure_after_migration_ddl: bool) -> Result<()> { let marker_exists = table_exists(conn, MIGRATION_TABLE)?; if !marker_exists { if has_merge_request_domain_tables(conn)? { @@ -820,8 +823,8 @@ fn migrate_locked(conn: &Connection, force_failure_after_v9_ddl: bool) -> Result )); } conn.execute_batch(MIGRATION_TABLE_SQL).map_err(db)?; - conn.execute_batch(SCHEMA_V9).map_err(db)?; - verify_schema_shape(conn, SCHEMA_V9, "v9")?; + conn.execute_batch(SCHEMA_V10).map_err(db)?; + verify_schema_shape(conn, SCHEMA_V10, "v10")?; ensure_foreign_key_integrity(conn)?; replace_schema_marker(conn, SCHEMA_VERSION)?; return verify(conn); @@ -833,40 +836,394 @@ fn migrate_locked(conn: &Connection, force_failure_after_v9_ddl: bool) -> Result verify_marker_state(conn, SCHEMA_VERSION)?; verify(conn) } + 9 => { + verify_marker_table_shape(conn)?; + remove_empty_v9_migration_debris(conn)?; + if verify_schema_shape(conn, SCHEMA_V10, "v10").is_ok() { + ensure_foreign_key_integrity(conn)?; + replace_schema_marker(conn, SCHEMA_VERSION)?; + return verify(conn); + } + verify_schema_shape(conn, SCHEMA_V9, "v9").map_err(|_| { + MergeRequestError::Database( + "schema drift at merge request version 9; automatic migration requires the exact released v9 shape or a complete v10 shape for marker repair" + .into(), + ) + })?; + migrate_v9_to_v10(conn)?; + if force_failure_after_migration_ddl { + return Err(MergeRequestError::Database( + "forced v9 to v10 migration failure after DDL and data copy".into(), + )); + } + verify_schema_shape(conn, SCHEMA_V10, "v10")?; + ensure_foreign_key_integrity(conn)?; + replace_schema_marker(conn, SCHEMA_VERSION)?; + verify(conn) + } 8 => { verify_marker_state(conn, 8)?; - if verify_schema_shape(conn, SCHEMA_V9, "v9").is_ok() { + if verify_schema_shape(conn, SCHEMA_V10, "v10").is_ok() { ensure_foreign_key_integrity(conn)?; replace_schema_marker(conn, SCHEMA_VERSION)?; return verify(conn); } verify_schema_shape(conn, SCHEMA_V8, "v8").map_err(|_| { MergeRequestError::Database( - "schema drift at merge request version 8; automatic migration requires the exact v8 shape or a complete v9 shape for marker repair" + "schema drift at merge request version 8; automatic migration requires the exact v8 shape or a complete v10 shape for marker repair" .into(), ) })?; - migrate_v8_to_v9(conn)?; - if force_failure_after_v9_ddl { + migrate_v8_to_v10(conn)?; + if force_failure_after_migration_ddl { return Err(MergeRequestError::Database( - "forced v8 to v9 migration failure after DDL and data copy".into(), + "forced v8 to v10 migration failure after DDL and data copy".into(), )); } - verify_schema_shape(conn, SCHEMA_V9, "v9")?; + verify_schema_shape(conn, SCHEMA_V10, "v10")?; ensure_foreign_key_integrity(conn)?; replace_schema_marker(conn, SCHEMA_VERSION)?; verify(conn) } 0..=7 => Err(MergeRequestError::Database(format!( - "unsupported legacy merge request schema version {version}; automatic migration only supports exact v8 to v9" + "unsupported legacy merge request schema version {version}; automatic migration only supports exact v8 or v9 to v10" ))), other => Err(MergeRequestError::Database(format!( - "unsupported merge request schema version {other}; expected version 8 or {SCHEMA_VERSION}" + "unsupported merge request schema version {other}; expected version 8, 9, or {SCHEMA_VERSION}" ))), } } -fn migrate_v8_to_v9(conn: &Connection) -> Result<()> { +fn remove_empty_v9_migration_debris(conn: &Connection) -> Result<()> { + const OBSOLETE_TABLE: &str = "merge_request_ticket_links"; + if !table_exists(conn, OBSOLETE_TABLE)? { + return Ok(()); + } + let row_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM merge_request_ticket_links", + [], + |row| row.get(0), + ) + .map_err(db)?; + if row_count != 0 { + return Err(MergeRequestError::Database( + "cannot remove non-empty obsolete v9 merge_request_ticket_links table".into(), + )); + } + conn.execute("DROP TABLE merge_request_ticket_links", []) + .map_err(db)?; + Ok(()) +} + +struct LegacyV9CompletedOutcome { + workspace_id: String, + operation_id: String, + merge_request_id: String, + ticket_id: String, + revision_id: String, + implementation_assignment_id: String, + completion_actor_runtime_id: String, + completion_actor_worker_id: String, + target_commit: String, + source_commit: String, + result_commit: String, + strategy: String, + resolution: String, + completed_at: String, +} + +fn migrate_v9_to_v10(conn: &Connection) -> Result<()> { + let result_review_count: i64 = conn + .query_row( + "SELECT + (SELECT COUNT(*) FROM merge_request_review_attempts WHERE merge_result_id IS NOT NULL) + + (SELECT COUNT(*) FROM merge_request_reviews WHERE merge_result_id IS NOT NULL)", + [], + |row| row.get(0), + ) + .map_err(db)?; + if result_review_count != 0 { + return Err(MergeRequestError::Database( + "cannot automatically migrate v9 merge-result-specific reviews to v10".into(), + )); + } + let pending_operation_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM merge_request_completion_operations WHERE status <> 'completed'", + [], + |row| row.get(0), + ) + .map_err(db)?; + if pending_operation_count != 0 { + return Err(MergeRequestError::Database( + "cannot automatically migrate pending v9 completion operations to v10".into(), + )); + } + let invalid_completed_operation_count: i64 = conn + .query_row( + "SELECT COUNT(*) + FROM merge_request_completion_operations o + WHERE o.status='completed' + AND ( + o.completion_actor_runtime_id IS NULL OR trim(o.completion_actor_runtime_id)='' OR + o.completion_actor_worker_id IS NULL OR trim(o.completion_actor_worker_id)='' OR + (SELECT COUNT(*) + FROM merge_request_merge_results r + JOIN merge_requests mr + ON mr.workspace_id=r.workspace_id + AND mr.merge_request_id=r.merge_request_id + WHERE r.workspace_id=o.workspace_id + AND r.ticket_id=o.ticket_id + AND r.revision_id=o.revision_id + AND mr.current_revision_id=o.revision_id) <> 1 + )", + [], + |row| row.get(0), + ) + .map_err(db)?; + if invalid_completed_operation_count != 0 { + return Err(MergeRequestError::Database( + "cannot uniquely map completed v9 operations to their current merge result".into(), + )); + } + let duplicate_merge_request_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM ( + SELECT r.workspace_id,r.merge_request_id + FROM merge_request_completion_operations o + JOIN merge_request_merge_results r + ON r.workspace_id=o.workspace_id + AND r.ticket_id=o.ticket_id + AND r.revision_id=o.revision_id + WHERE o.status='completed' + GROUP BY r.workspace_id,r.merge_request_id + HAVING COUNT(*) <> 1 + )", + [], + |row| row.get(0), + ) + .map_err(db)?; + if duplicate_merge_request_count != 0 { + return Err(MergeRequestError::Database( + "cannot choose one completed v9 outcome for a merge request".into(), + )); + } + let unmapped_merged_request_count: i64 = conn + .query_row( + "SELECT COUNT(*) + FROM merge_requests mr + WHERE mr.state='merged' + AND NOT EXISTS ( + SELECT 1 + FROM merge_request_completion_operations o + JOIN merge_request_merge_results r + ON r.workspace_id=o.workspace_id + AND r.ticket_id=o.ticket_id + AND r.revision_id=o.revision_id + WHERE o.status='completed' + AND r.workspace_id=mr.workspace_id + AND r.merge_request_id=mr.merge_request_id + )", + [], + |row| row.get(0), + ) + .map_err(db)?; + if unmapped_merged_request_count != 0 { + return Err(MergeRequestError::Database( + "cannot reconstruct final outcome for a merged v9 merge request".into(), + )); + } + let completed_outcomes = { + let mut statement = conn + .prepare( + "SELECT o.workspace_id,o.operation_id,r.merge_request_id,o.ticket_id,o.revision_id, + o.implementation_assignment_id,o.completion_actor_runtime_id,o.completion_actor_worker_id, + r.target_commit,r.source_commit,r.result_commit,r.strategy,r.resolution,o.updated_at + FROM merge_request_completion_operations o + JOIN merge_request_merge_results r + ON r.workspace_id=o.workspace_id + AND r.ticket_id=o.ticket_id + AND r.revision_id=o.revision_id + WHERE o.status='completed' + ORDER BY o.workspace_id,o.operation_id", + ) + .map_err(db)?; + statement + .query_map([], |row| { + Ok(LegacyV9CompletedOutcome { + workspace_id: row.get(0)?, + operation_id: row.get(1)?, + merge_request_id: row.get(2)?, + ticket_id: row.get(3)?, + revision_id: row.get(4)?, + implementation_assignment_id: row.get(5)?, + completion_actor_runtime_id: row.get(6)?, + completion_actor_worker_id: row.get(7)?, + target_commit: row.get(8)?, + source_commit: row.get(9)?, + result_commit: row.get(10)?, + strategy: row.get(11)?, + resolution: row.get(12)?, + completed_at: row.get(13)?, + }) + }) + .map_err(db)? + .collect::, _>>() + .map_err(db)? + }; + conn.execute_batch( + "CREATE TABLE merge_requests_v10 ( + workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, + repository_id TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('draft','open','closed','merged')), + lifecycle_generation INTEGER NOT NULL, current_revision_id TEXT NOT NULL, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL, merged_by_account_id TEXT, merged_at TEXT, + target_ref_selector TEXT, + target_status TEXT NOT NULL DEFAULT 'unknown' CHECK(target_status IN ('known','unknown')), + merged_revision_id TEXT, merged_target_commit TEXT, merged_result_commit TEXT, + merge_strategy TEXT CHECK(merge_strategy IN ('fast_forward','merge')), + merge_resolution TEXT CHECK(merge_resolution IN ('none','clean','conflicts_resolved')), + merged_by_runtime_id TEXT, merged_by_worker_id TEXT, + PRIMARY KEY(workspace_id,merge_request_id), + FOREIGN KEY(workspace_id,repository_id) REFERENCES repositories(workspace_id,repository_id) + ); + INSERT INTO merge_requests_v10( + workspace_id,merge_request_id,repository_id,state,lifecycle_generation,current_revision_id, + created_at,updated_at,merged_by_account_id,merged_at,target_ref_selector,target_status + ) SELECT workspace_id,merge_request_id,repository_id,state,lifecycle_generation,current_revision_id, + created_at,updated_at,merged_by_account_id,merged_at,target_ref_selector,target_status + FROM merge_requests; + DROP TABLE merge_requests; + ALTER TABLE merge_requests_v10 RENAME TO merge_requests; + + CREATE TABLE merge_request_review_attempts_v10 ( + workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, ticket_id TEXT NOT NULL, + revision_id TEXT NOT NULL, lifecycle_generation INTEGER NOT NULL, + parent_assignment_id TEXT NOT NULL, parent_runtime_id TEXT NOT NULL, parent_worker_id TEXT NOT NULL, + child_session_id TEXT NOT NULL, child_effective_profile TEXT NOT NULL CHECK(child_effective_profile='builtin:reviewer'), + capability_token_sha256 TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('open','submitted','revoked')), + created_at TEXT NOT NULL, consumed_at TEXT, + PRIMARY KEY(workspace_id,attempt_id), UNIQUE(workspace_id,capability_token_sha256), UNIQUE(workspace_id,child_session_id), + FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id), + FOREIGN KEY(workspace_id,ticket_id,parent_assignment_id) REFERENCES ticket_worker_assignments(workspace_id,ticket_id,assignment_id), + FOREIGN KEY(workspace_id,child_session_id) REFERENCES merge_request_reviewer_child_sessions(workspace_id,child_session_id) + ); + INSERT INTO merge_request_review_attempts_v10( + workspace_id,attempt_id,merge_request_id,ticket_id,revision_id,lifecycle_generation, + parent_assignment_id,parent_runtime_id,parent_worker_id,child_session_id,child_effective_profile, + capability_token_sha256,status,created_at,consumed_at + ) SELECT workspace_id,attempt_id,merge_request_id,ticket_id,revision_id,lifecycle_generation, + parent_assignment_id,parent_runtime_id,parent_worker_id,child_session_id,child_effective_profile, + capability_token_sha256,status,created_at,consumed_at + FROM merge_request_review_attempts; + DROP TABLE merge_request_review_attempts; + ALTER TABLE merge_request_review_attempts_v10 RENAME TO merge_request_review_attempts; + + CREATE TABLE merge_request_reviews_v10 ( + workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, + decision TEXT NOT NULL CHECK(decision IN ('approve','request_changes')), body TEXT NOT NULL, submitted_at TEXT NOT NULL, + PRIMARY KEY(workspace_id,attempt_id), + FOREIGN KEY(workspace_id,attempt_id) REFERENCES merge_request_review_attempts(workspace_id,attempt_id), + FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id) + ); + INSERT INTO merge_request_reviews_v10( + workspace_id,attempt_id,merge_request_id,revision_id,decision,body,submitted_at + ) SELECT workspace_id,attempt_id,merge_request_id,revision_id,decision,body,submitted_at + FROM merge_request_reviews; + DROP TABLE merge_request_reviews; + ALTER TABLE merge_request_reviews_v10 RENAME TO merge_request_reviews; + + CREATE TABLE merge_request_completion_operations_v10 ( + workspace_id TEXT NOT NULL, operation_id TEXT NOT NULL, ticket_id TEXT NOT NULL, revision_id TEXT NOT NULL, + authority_kind TEXT NOT NULL CHECK(authority_kind IN ('workspace_orchestrator','legacy_assigned_coder')), + implementation_assignment_id TEXT NOT NULL, completion_actor_runtime_id TEXT, completion_actor_worker_id TEXT, + target_commit TEXT, source_commit TEXT, result_commit TEXT, + strategy TEXT CHECK(strategy IN ('fast_forward','merge')), + resolution TEXT CHECK(resolution IN ('none','clean','conflicts_resolved')), + fingerprint TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('pending','completed')), + result_ticket_state TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, + PRIMARY KEY(workspace_id,operation_id), + FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id), + FOREIGN KEY(workspace_id,ticket_id,implementation_assignment_id) + REFERENCES ticket_worker_assignments(workspace_id,ticket_id,assignment_id) + ); + INSERT INTO merge_request_completion_operations_v10( + workspace_id,operation_id,ticket_id,revision_id,authority_kind,implementation_assignment_id, + completion_actor_runtime_id,completion_actor_worker_id,target_commit,source_commit,result_commit, + strategy,resolution,fingerprint,status,result_ticket_state,created_at,updated_at + ) SELECT o.workspace_id,o.operation_id,o.ticket_id,o.revision_id,o.authority_kind,o.implementation_assignment_id, + o.completion_actor_runtime_id,o.completion_actor_worker_id, + r.target_commit,r.source_commit,r.result_commit,r.strategy,r.resolution, + o.fingerprint,o.status,o.result_ticket_state,o.created_at,o.updated_at + FROM merge_request_completion_operations o + JOIN merge_request_merge_results r + ON r.workspace_id=o.workspace_id + AND r.ticket_id=o.ticket_id + AND r.revision_id=o.revision_id + WHERE o.status='completed'; + DROP TABLE merge_request_completion_operations; + ALTER TABLE merge_request_completion_operations_v10 RENAME TO merge_request_completion_operations; + + DROP TABLE merge_request_merge_results;", + ) + .map_err(db)?; + for outcome in completed_outcomes { + let fingerprint = completion_fingerprint_parts( + &outcome.ticket_id, + &outcome.revision_id, + &outcome.target_commit, + &outcome.source_commit, + &outcome.result_commit, + &outcome.strategy, + &outcome.resolution, + &outcome.implementation_assignment_id, + &outcome.completion_actor_runtime_id, + &outcome.completion_actor_worker_id, + ); + let changed = conn + .execute( + "UPDATE merge_requests + SET state='merged',merged_revision_id=?3,merged_target_commit=?4, + merged_result_commit=?5,merge_strategy=?6,merge_resolution=?7, + merged_by_runtime_id=?8,merged_by_worker_id=?9,merged_at=?10,updated_at=?10 + WHERE workspace_id=?1 AND merge_request_id=?2", + params![ + outcome.workspace_id, + outcome.merge_request_id, + outcome.revision_id, + outcome.target_commit, + outcome.result_commit, + outcome.strategy, + outcome.resolution, + outcome.completion_actor_runtime_id, + outcome.completion_actor_worker_id, + outcome.completed_at, + ], + ) + .map_err(db)?; + if changed != 1 { + return Err(MergeRequestError::Database( + "completed v9 outcome lost its merge request during migration".into(), + )); + } + let changed = conn + .execute( + "UPDATE merge_request_completion_operations + SET fingerprint=?3 + WHERE workspace_id=?1 AND operation_id=?2", + params![outcome.workspace_id, outcome.operation_id, fingerprint], + ) + .map_err(db)?; + if changed != 1 { + return Err(MergeRequestError::Database( + "completed v9 operation was not copied during migration".into(), + )); + } + } + Ok(()) +} + +fn migrate_v8_to_v10(conn: &Connection) -> Result<()> { conn.execute_batch( "ALTER TABLE merge_requests ADD COLUMN target_ref_selector TEXT; ALTER TABLE merge_requests ADD COLUMN target_status TEXT NOT NULL DEFAULT 'unknown' CHECK(target_status IN ('known','unknown')); @@ -955,7 +1312,7 @@ pub fn verify(conn: &Connection) -> Result<()> { ))); } verify_marker_state(conn, SCHEMA_VERSION)?; - verify_schema_shape(conn, SCHEMA_V9, "v9") + verify_schema_shape(conn, SCHEMA_V10, "v10") } fn schema_version(conn: &Connection) -> Result { @@ -968,14 +1325,7 @@ fn schema_version(conn: &Connection) -> Result { } fn verify_marker_state(conn: &Connection, expected_version: i64) -> Result<()> { - let expected = Connection::open_in_memory().map_err(db)?; - expected.execute_batch(MIGRATION_TABLE_SQL).map_err(db)?; - if table_shape(conn, MIGRATION_TABLE)? != table_shape(&expected, MIGRATION_TABLE)? { - return Err(MergeRequestError::Database( - "schema drift: merge request version marker table does not match the latest contract" - .into(), - )); - } + verify_marker_table_shape(conn)?; let state: (i64, i64, i64) = conn .query_row( "SELECT COUNT(*),COALESCE(MIN(version),0),COALESCE(MAX(version),0) FROM merge_request_schema_migrations", @@ -991,6 +1341,18 @@ fn verify_marker_state(conn: &Connection, expected_version: i64) -> Result<()> { Ok(()) } +fn verify_marker_table_shape(conn: &Connection) -> Result<()> { + let expected = Connection::open_in_memory().map_err(db)?; + expected.execute_batch(MIGRATION_TABLE_SQL).map_err(db)?; + if table_shape(conn, MIGRATION_TABLE)? != table_shape(&expected, MIGRATION_TABLE)? { + return Err(MergeRequestError::Database( + "schema drift: merge request version marker table does not match the latest contract" + .into(), + )); + } + Ok(()) +} + fn replace_schema_marker(conn: &Connection, version: i64) -> Result<()> { conn.execute("DELETE FROM merge_request_schema_migrations", []) .map_err(db)?; @@ -1395,6 +1757,92 @@ CREATE TABLE merge_request_completion_operations ( "#; const SCHEMA_V9: &str = r#" +CREATE TABLE merge_requests ( + workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, + repository_id TEXT NOT NULL, target_ref_selector TEXT, + target_status TEXT NOT NULL DEFAULT 'unknown' CHECK(target_status IN ('known','unknown')), + state TEXT NOT NULL CHECK(state IN ('draft','open','closed','merged')), + lifecycle_generation INTEGER NOT NULL, current_revision_id TEXT NOT NULL, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL, merged_by_account_id TEXT, merged_at TEXT, + PRIMARY KEY(workspace_id,merge_request_id), + FOREIGN KEY(workspace_id,repository_id) REFERENCES repositories(workspace_id,repository_id) +); +CREATE TABLE merge_request_ticket_relations ( + workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, ticket_id TEXT NOT NULL, + relation_kind TEXT NOT NULL CHECK(relation_kind='implements'), created_at TEXT NOT NULL, + PRIMARY KEY(workspace_id,merge_request_id,ticket_id), + FOREIGN KEY(workspace_id,merge_request_id) REFERENCES merge_requests(workspace_id,merge_request_id) ON DELETE CASCADE, + FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id) ON DELETE CASCADE +); +CREATE TABLE merge_request_revisions ( + workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, + ordinal INTEGER NOT NULL, base_commit TEXT NOT NULL, head_commit TEXT NOT NULL, + diff_digest TEXT NOT NULL, summary TEXT NOT NULL, assignment_id TEXT NOT NULL, created_at TEXT NOT NULL, + PRIMARY KEY(workspace_id,merge_request_id,revision_id), + UNIQUE(workspace_id,merge_request_id,ordinal), + FOREIGN KEY(workspace_id,merge_request_id) REFERENCES merge_requests(workspace_id,merge_request_id) ON DELETE CASCADE +); +CREATE TABLE merge_request_revision_paths ( + workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, ordinal INTEGER NOT NULL, path TEXT NOT NULL, + PRIMARY KEY(workspace_id,merge_request_id,revision_id,ordinal), + FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id) ON DELETE CASCADE +); +CREATE TABLE merge_request_reviewer_child_sessions ( + workspace_id TEXT NOT NULL, child_session_id TEXT NOT NULL, parent_runtime_id TEXT NOT NULL, + parent_worker_id TEXT NOT NULL, effective_profile TEXT NOT NULL CHECK(effective_profile='builtin:reviewer'), registered_at TEXT NOT NULL, + PRIMARY KEY(workspace_id,child_session_id) +); +CREATE TABLE merge_request_review_attempts ( + workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, ticket_id TEXT NOT NULL, + revision_id TEXT NOT NULL, merge_result_id TEXT, lifecycle_generation INTEGER NOT NULL, + parent_assignment_id TEXT NOT NULL, parent_runtime_id TEXT NOT NULL, parent_worker_id TEXT NOT NULL, + child_session_id TEXT NOT NULL, child_effective_profile TEXT NOT NULL CHECK(child_effective_profile='builtin:reviewer'), + capability_token_sha256 TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('open','submitted','revoked')), + created_at TEXT NOT NULL, consumed_at TEXT, + PRIMARY KEY(workspace_id,attempt_id), UNIQUE(workspace_id,capability_token_sha256), UNIQUE(workspace_id,child_session_id), + FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id), + FOREIGN KEY(workspace_id,ticket_id,parent_assignment_id) REFERENCES ticket_worker_assignments(workspace_id,ticket_id,assignment_id) +); +CREATE TABLE merge_request_reviews ( + workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, + merge_result_id TEXT, decision TEXT NOT NULL CHECK(decision IN ('approve','request_changes')), body TEXT NOT NULL, submitted_at TEXT NOT NULL, + PRIMARY KEY(workspace_id,attempt_id), + FOREIGN KEY(workspace_id,attempt_id) REFERENCES merge_request_review_attempts(workspace_id,attempt_id), + FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id) +); +CREATE TABLE merge_request_review_findings ( + workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, ordinal INTEGER NOT NULL, severity TEXT NOT NULL, + code TEXT, path TEXT, line INTEGER, body TEXT NOT NULL, + PRIMARY KEY(workspace_id,attempt_id,ordinal), + FOREIGN KEY(workspace_id,attempt_id) REFERENCES merge_request_reviews(workspace_id,attempt_id) ON DELETE CASCADE +); +CREATE TABLE merge_request_merge_results ( + workspace_id TEXT NOT NULL, merge_result_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, revision_id TEXT NOT NULL, target_commit TEXT NOT NULL, + source_commit TEXT NOT NULL, result_commit TEXT NOT NULL, + strategy TEXT NOT NULL CHECK(strategy IN ('fast_forward','merge')), + resolution TEXT NOT NULL CHECK(resolution IN ('none','clean','conflicts_resolved')), + created_by_runtime_id TEXT NOT NULL, created_by_worker_id TEXT NOT NULL, + created_at TEXT NOT NULL, operation_id TEXT NOT NULL, operation_fingerprint TEXT NOT NULL, + validated_at TEXT NOT NULL, + PRIMARY KEY(workspace_id,merge_result_id), UNIQUE(workspace_id,operation_id), + FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id), + FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id) +); +CREATE INDEX merge_request_merge_results_current_idx + ON merge_request_merge_results(workspace_id,merge_request_id,revision_id,target_commit,created_at); +CREATE TABLE merge_request_completion_operations ( + workspace_id TEXT NOT NULL, operation_id TEXT NOT NULL, ticket_id TEXT NOT NULL, revision_id TEXT NOT NULL, + authority_kind TEXT NOT NULL CHECK(authority_kind IN ('workspace_orchestrator','legacy_assigned_coder')), + implementation_assignment_id TEXT NOT NULL, completion_actor_runtime_id TEXT, completion_actor_worker_id TEXT, + fingerprint TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('pending','completed')), + result_ticket_state TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, + PRIMARY KEY(workspace_id,operation_id), + FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id) +); +"#; + +const SCHEMA_V10: &str = r#" CREATE TABLE merge_requests ( workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, repository_id TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('draft','open','closed','merged')), @@ -1515,6 +1963,34 @@ CREATE TABLE ticket_worker_assignments(workspace_id TEXT NOT NULL,ticket_id TEXT conn } + fn exact_v9_connection() -> Connection { + let conn = fresh_connection(); + conn.execute_batch(MIGRATION_TABLE_SQL).unwrap(); + conn.execute_batch( + "INSERT INTO merge_request_schema_migrations(version) VALUES(1),(2),(3),(4),(5),(9);", + ) + .unwrap(); + conn.execute_batch(SCHEMA_V9).unwrap(); + conn.execute_batch( + "CREATE TABLE merge_request_ticket_links(obsolete TEXT); + INSERT INTO repositories VALUES('ws','repo'); + INSERT INTO typed_tickets VALUES('ws','T1','done',1,'t2'); + INSERT INTO ticket_worker_assignments VALUES('ws','T1','A1','R1','W1'); + INSERT INTO merge_requests VALUES('ws','MR1','repo','refs/heads/main','known','open',3,'V1','t0','t1',NULL,NULL); + INSERT INTO merge_request_ticket_relations VALUES('ws','MR1','T1','implements','t0'); + INSERT INTO merge_request_revisions VALUES('ws','MR1','V1',1,'base','head','digest','summary','A1','t0'); + INSERT INTO merge_request_revision_paths VALUES('ws','MR1','V1',0,'src/lib.rs'); + INSERT INTO merge_request_reviewer_child_sessions VALUES('ws','C1','R1','W1','builtin:reviewer','t0'); + INSERT INTO merge_request_review_attempts VALUES('ws','AT1','MR1','T1','V1',NULL,3,'A1','R1','W1','C1','builtin:reviewer','token','submitted','t0','t1'); + INSERT INTO merge_request_reviews VALUES('ws','AT1','MR1','V1',NULL,'approve','approved','t1'); + INSERT INTO merge_request_review_findings VALUES('ws','AT1',0,'warning','C','src/lib.rs',7,'finding'); + INSERT INTO merge_request_merge_results VALUES('ws','M1','MR1','T1','V1','base','head','head','fast_forward','none','R1','W1','t1','record-result','result-fp','t1'); + INSERT INTO merge_request_completion_operations VALUES('ws','OP1','T1','V1','workspace_orchestrator','A1','R1','W1','old-fp','completed','done','t0','t2');", + ) + .unwrap(); + conn + } + fn marker_version(conn: &Connection) -> i64 { conn.query_row( "SELECT MAX(version) FROM merge_request_schema_migrations", @@ -1529,7 +2005,7 @@ CREATE TABLE ticket_worker_assignments(workspace_id TEXT NOT NULL,ticket_id TEXT let conn = fresh_connection(); migrate(&conn).unwrap(); verify(&conn).unwrap(); - assert_eq!(marker_version(&conn), 9); + assert_eq!(marker_version(&conn), 10); for column in [ "target_ref_selector", "merged_revision_id", @@ -1554,7 +2030,7 @@ CREATE TABLE ticket_worker_assignments(workspace_id TEXT NOT NULL,ticket_id TEXT let conn = exact_v8_connection(); migrate(&conn).unwrap(); verify(&conn).unwrap(); - assert_eq!(marker_version(&conn), 9); + assert_eq!(marker_version(&conn), 10); assert_eq!( conn.query_row( "SELECT head_commit FROM merge_request_revisions WHERE revision_id='V1'", @@ -1593,13 +2069,13 @@ CREATE TABLE ticket_worker_assignments(workspace_id TEXT NOT NULL,ticket_id TEXT } #[test] - fn v8_to_v9_failure_rolls_back_schema_data_and_marker() { + fn v8_to_v10_failure_rolls_back_schema_data_and_marker() { let conn = exact_v8_connection(); let error = migrate_with_failpoint(&conn, true).unwrap_err(); assert!( error .to_string() - .contains("forced v8 to v9 migration failure") + .contains("forced v8 to v10 migration failure") ); assert_eq!(marker_version(&conn), 8); assert!(column_exists(&conn, "merge_request_revisions", "head_tree").unwrap()); @@ -1643,7 +2119,7 @@ CREATE TABLE ticket_worker_assignments(workspace_id TEXT NOT NULL,ticket_id TEXT migrate(&conn).unwrap(); verify(&conn).unwrap(); - assert_eq!(marker_version(&conn), 9); + assert_eq!(marker_version(&conn), 10); assert!(column_exists(&conn, "merge_requests", "merged_result_commit").unwrap()); } @@ -1657,9 +2133,75 @@ CREATE TABLE ticket_worker_assignments(workspace_id TEXT NOT NULL,ticket_id TEXT ) .unwrap(); let error = migrate(&conn).unwrap_err(); - assert!(error.to_string().contains("only supports exact v8 to v9")); + assert!( + error + .to_string() + .contains("only supports exact v8 or v9 to v10") + ); assert_eq!(marker_version(&conn), 7); } + + #[test] + fn exact_v9_with_historical_markers_migrates_to_single_v10_marker() { + let conn = exact_v9_connection(); + migrate(&conn).unwrap(); + verify(&conn).unwrap(); + assert_eq!(marker_version(&conn), 10); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM merge_request_schema_migrations", + [], + |row| row.get::<_, i64>(0) + ) + .unwrap(), + 1 + ); + assert_eq!( + conn.query_row( + "SELECT state FROM merge_requests WHERE merge_request_id='MR1'", + [], + |row| row.get::<_, String>(0) + ) + .unwrap(), + "merged" + ); + assert_eq!( + conn.query_row( + "SELECT merged_revision_id,merged_target_commit,merged_result_commit,merge_strategy,merge_resolution + FROM merge_requests WHERE merge_request_id='MR1'", + [], + |row| Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + )) + ) + .unwrap(), + ( + "V1".into(), + "base".into(), + "head".into(), + "fast_forward".into(), + "none".into() + ) + ); + assert_eq!( + conn.query_row( + "SELECT fingerprint FROM merge_request_completion_operations WHERE operation_id='OP1'", + [], + |row| row.get::<_, String>(0) + ) + .unwrap(), + completion_fingerprint_parts( + "T1", "V1", "base", "head", "head", "fast_forward", "none", "A1", "R1", + "W1" + ) + ); + assert!(!table_exists(&conn, "merge_request_ticket_links").unwrap()); + assert!(!table_exists(&conn, "merge_request_merge_results").unwrap()); + } } fn load_merge_request( @@ -2026,18 +2568,45 @@ fn token_hash(token: &str) -> String { .collect() } fn completion_fingerprint(input: &CompleteMergeRequest) -> String { - token_hash(&format!( - "workspace_orchestrator\0{}\0{}\0{}\0{}\0{}\0{}\0{}\0{}\0{}\0{}", - input.ticket_id, - input.expected_revision_id, - input.target_commit, - input.source_commit, - input.result_commit, + completion_fingerprint_parts( + &input.ticket_id, + &input.expected_revision_id, + &input.target_commit, + &input.source_commit, + &input.result_commit, input.strategy.as_str(), input.resolution.as_str(), - input.implementation_assignment_id, - input.completion_actor_runtime_id, - input.completion_actor_worker_id + &input.implementation_assignment_id, + &input.completion_actor_runtime_id, + &input.completion_actor_worker_id, + ) +} + +#[allow(clippy::too_many_arguments)] +fn completion_fingerprint_parts( + ticket_id: &str, + revision_id: &str, + target_commit: &str, + source_commit: &str, + result_commit: &str, + strategy: &str, + resolution: &str, + implementation_assignment_id: &str, + completion_actor_runtime_id: &str, + completion_actor_worker_id: &str, +) -> String { + token_hash(&format!( + "workspace_orchestrator\0{}\0{}\0{}\0{}\0{}\0{}\0{}\0{}\0{}\0{}", + ticket_id, + revision_id, + target_commit, + source_commit, + result_commit, + strategy, + resolution, + implementation_assignment_id, + completion_actor_runtime_id, + completion_actor_worker_id )) } fn db(error: rusqlite::Error) -> MergeRequestError { From 76c427c887606d4a4a725dcd771a363fd5d4b524 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 17 Aug 2026 04:13:14 +0900 Subject: [PATCH 05/13] fix: remove redundant merge request diff digest --- crates/merge-request/src/lib.rs | 313 +++++++++++++++--- crates/merge-request/tests/store.rs | 1 - .../src/feature/builtin/merge_request.rs | 10 +- crates/workspace-server/src/server.rs | 5 - .../tickets/[ticketId]/+page.svelte | 2 +- 5 files changed, 270 insertions(+), 61 deletions(-) diff --git a/crates/merge-request/src/lib.rs b/crates/merge-request/src/lib.rs index 39499f7b..04c2b1bb 100644 --- a/crates/merge-request/src/lib.rs +++ b/crates/merge-request/src/lib.rs @@ -11,7 +11,7 @@ use std::path::{Path, PathBuf}; use std::time::Duration; use thiserror::Error; -const SCHEMA_VERSION: i64 = 10; +const SCHEMA_VERSION: i64 = 11; const REVIEWER_PROFILE: &str = "builtin:reviewer"; const MAX_SUMMARY_BYTES: usize = 16 * 1024; const MAX_REVIEW_BODY_BYTES: usize = 64 * 1024; @@ -190,7 +190,6 @@ pub struct MergeRequestRevision { pub ordinal: u64, pub base_commit: String, pub head_commit: String, - pub diff_digest: String, pub changed_paths: Vec, pub summary: String, pub assignment_id: String, @@ -524,13 +523,13 @@ impl SqliteMergeRequestStore { if input.revision.ordinal != current.current_revision.ordinal + 1 { return Err(MergeRequestError::RevisionConflict(input.revision.revision_id.clone())); } - let existing: Option<(String, String, String)> = conn.query_row( - "SELECT base_commit, head_commit, diff_digest FROM merge_request_revisions WHERE workspace_id=?1 AND merge_request_id=?2 AND revision_id=?3", + let existing: Option<(String, String)> = conn.query_row( + "SELECT base_commit, head_commit FROM merge_request_revisions WHERE workspace_id=?1 AND merge_request_id=?2 AND revision_id=?3", params![self.workspace_id, current.merge_request_id, input.revision.revision_id], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + |row| Ok((row.get(0)?, row.get(1)?)), ).optional().map_err(db)?; if let Some(existing) = existing { - if existing == (input.revision.base_commit.clone(), input.revision.head_commit.clone(), input.revision.diff_digest.clone()) { + if existing == (input.revision.base_commit.clone(), input.revision.head_commit.clone()) { return Ok(current); } return Err(MergeRequestError::RevisionConflict(input.revision.revision_id.clone())); @@ -823,8 +822,8 @@ fn migrate_locked(conn: &Connection, force_failure_after_migration_ddl: bool) -> )); } conn.execute_batch(MIGRATION_TABLE_SQL).map_err(db)?; - conn.execute_batch(SCHEMA_V10).map_err(db)?; - verify_schema_shape(conn, SCHEMA_V10, "v10")?; + conn.execute_batch(SCHEMA_V11).map_err(db)?; + verify_schema_shape(conn, SCHEMA_V11, "v11")?; ensure_foreign_key_integrity(conn)?; replace_schema_marker(conn, SCHEMA_VERSION)?; return verify(conn); @@ -836,64 +835,89 @@ fn migrate_locked(conn: &Connection, force_failure_after_migration_ddl: bool) -> verify_marker_state(conn, SCHEMA_VERSION)?; verify(conn) } + 10 => { + verify_marker_state(conn, 10)?; + if verify_schema_shape(conn, SCHEMA_V11, "v11").is_ok() { + return finish_v11_schema(conn); + } + verify_schema_shape(conn, SCHEMA_V10, "v10").map_err(|_| { + MergeRequestError::Database( + "schema drift at merge request version 10; automatic migration requires the exact released v10 shape or a complete v11 shape for marker repair" + .into(), + ) + })?; + migrate_v10_to_v11(conn)?; + fail_after_migration_if_requested(force_failure_after_migration_ddl, 10)?; + finish_v11_schema(conn) + } 9 => { verify_marker_table_shape(conn)?; remove_empty_v9_migration_debris(conn)?; + if verify_schema_shape(conn, SCHEMA_V11, "v11").is_ok() { + return finish_v11_schema(conn); + } if verify_schema_shape(conn, SCHEMA_V10, "v10").is_ok() { - ensure_foreign_key_integrity(conn)?; - replace_schema_marker(conn, SCHEMA_VERSION)?; - return verify(conn); + migrate_v10_to_v11(conn)?; + fail_after_migration_if_requested(force_failure_after_migration_ddl, 9)?; + return finish_v11_schema(conn); } verify_schema_shape(conn, SCHEMA_V9, "v9").map_err(|_| { MergeRequestError::Database( - "schema drift at merge request version 9; automatic migration requires the exact released v9 shape or a complete v10 shape for marker repair" + "schema drift at merge request version 9; automatic migration requires the exact released v9 shape, a complete v10 shape, or a complete v11 shape for marker repair" .into(), ) })?; migrate_v9_to_v10(conn)?; - if force_failure_after_migration_ddl { - return Err(MergeRequestError::Database( - "forced v9 to v10 migration failure after DDL and data copy".into(), - )); - } - verify_schema_shape(conn, SCHEMA_V10, "v10")?; - ensure_foreign_key_integrity(conn)?; - replace_schema_marker(conn, SCHEMA_VERSION)?; - verify(conn) + migrate_v10_to_v11(conn)?; + fail_after_migration_if_requested(force_failure_after_migration_ddl, 9)?; + finish_v11_schema(conn) } 8 => { verify_marker_state(conn, 8)?; + if verify_schema_shape(conn, SCHEMA_V11, "v11").is_ok() { + return finish_v11_schema(conn); + } if verify_schema_shape(conn, SCHEMA_V10, "v10").is_ok() { - ensure_foreign_key_integrity(conn)?; - replace_schema_marker(conn, SCHEMA_VERSION)?; - return verify(conn); + migrate_v10_to_v11(conn)?; + fail_after_migration_if_requested(force_failure_after_migration_ddl, 8)?; + return finish_v11_schema(conn); } verify_schema_shape(conn, SCHEMA_V8, "v8").map_err(|_| { MergeRequestError::Database( - "schema drift at merge request version 8; automatic migration requires the exact v8 shape or a complete v10 shape for marker repair" + "schema drift at merge request version 8; automatic migration requires the exact v8 shape, a complete v10 shape, or a complete v11 shape for marker repair" .into(), ) })?; migrate_v8_to_v10(conn)?; - if force_failure_after_migration_ddl { - return Err(MergeRequestError::Database( - "forced v8 to v10 migration failure after DDL and data copy".into(), - )); - } - verify_schema_shape(conn, SCHEMA_V10, "v10")?; - ensure_foreign_key_integrity(conn)?; - replace_schema_marker(conn, SCHEMA_VERSION)?; - verify(conn) + migrate_v10_to_v11(conn)?; + fail_after_migration_if_requested(force_failure_after_migration_ddl, 8)?; + finish_v11_schema(conn) } 0..=7 => Err(MergeRequestError::Database(format!( - "unsupported legacy merge request schema version {version}; automatic migration only supports exact v8 or v9 to v10" + "unsupported legacy merge request schema version {version}; automatic migration only supports exact v8, v9, or v10 to v11" ))), other => Err(MergeRequestError::Database(format!( - "unsupported merge request schema version {other}; expected version 8, 9, or {SCHEMA_VERSION}" + "unsupported merge request schema version {other}; expected version 8, 9, 10, or {SCHEMA_VERSION}" ))), } } +fn fail_after_migration_if_requested(force_failure: bool, source_version: i64) -> Result<()> { + if force_failure { + return Err(MergeRequestError::Database(format!( + "forced v{source_version} to v11 migration failure after DDL and data copy" + ))); + } + Ok(()) +} + +fn finish_v11_schema(conn: &Connection) -> Result<()> { + verify_schema_shape(conn, SCHEMA_V11, "v11")?; + ensure_foreign_key_integrity(conn)?; + replace_schema_marker(conn, SCHEMA_VERSION)?; + verify(conn) +} + fn remove_empty_v9_migration_debris(conn: &Connection) -> Result<()> { const OBSOLETE_TABLE: &str = "merge_request_ticket_links"; if !table_exists(conn, OBSOLETE_TABLE)? { @@ -1299,6 +1323,26 @@ fn migrate_v8_to_v10(conn: &Connection) -> Result<()> { .map_err(db) } +fn migrate_v10_to_v11(conn: &Connection) -> Result<()> { + conn.execute_batch( + "CREATE TABLE merge_request_revisions_v11 ( + workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, + ordinal INTEGER NOT NULL, base_commit TEXT NOT NULL, head_commit TEXT NOT NULL, + summary TEXT NOT NULL, assignment_id TEXT NOT NULL, created_at TEXT NOT NULL, + PRIMARY KEY(workspace_id,merge_request_id,revision_id), + UNIQUE(workspace_id,merge_request_id,ordinal), + FOREIGN KEY(workspace_id,merge_request_id) REFERENCES merge_requests(workspace_id,merge_request_id) ON DELETE CASCADE + ); + INSERT INTO merge_request_revisions_v11( + workspace_id,merge_request_id,revision_id,ordinal,base_commit,head_commit,summary,assignment_id,created_at + ) SELECT workspace_id,merge_request_id,revision_id,ordinal,base_commit,head_commit,summary,assignment_id,created_at + FROM merge_request_revisions; + DROP TABLE merge_request_revisions; + ALTER TABLE merge_request_revisions_v11 RENAME TO merge_request_revisions;", + ) + .map_err(db) +} + pub fn verify(conn: &Connection) -> Result<()> { if !table_exists(conn, MIGRATION_TABLE)? { return Err(MergeRequestError::Database( @@ -1312,7 +1356,7 @@ pub fn verify(conn: &Connection) -> Result<()> { ))); } verify_marker_state(conn, SCHEMA_VERSION)?; - verify_schema_shape(conn, SCHEMA_V10, "v10") + verify_schema_shape(conn, SCHEMA_V11, "v11") } fn schema_version(conn: &Connection) -> Result { @@ -1921,6 +1965,85 @@ CREATE TABLE merge_request_completion_operations ( ); "#; +const SCHEMA_V11: &str = r#" +CREATE TABLE merge_requests ( + workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, + repository_id TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('draft','open','closed','merged')), + lifecycle_generation INTEGER NOT NULL, current_revision_id TEXT NOT NULL, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL, merged_by_account_id TEXT, merged_at TEXT, + target_ref_selector TEXT, + target_status TEXT NOT NULL DEFAULT 'unknown' CHECK(target_status IN ('known','unknown')), + merged_revision_id TEXT, merged_target_commit TEXT, merged_result_commit TEXT, + merge_strategy TEXT CHECK(merge_strategy IN ('fast_forward','merge')), + merge_resolution TEXT CHECK(merge_resolution IN ('none','clean','conflicts_resolved')), + merged_by_runtime_id TEXT, merged_by_worker_id TEXT, + PRIMARY KEY(workspace_id,merge_request_id), + FOREIGN KEY(workspace_id,repository_id) REFERENCES repositories(workspace_id,repository_id) +); +CREATE TABLE merge_request_ticket_relations ( + workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, ticket_id TEXT NOT NULL, + relation_kind TEXT NOT NULL CHECK(relation_kind='implements'), created_at TEXT NOT NULL, + PRIMARY KEY(workspace_id,merge_request_id,ticket_id), + FOREIGN KEY(workspace_id,merge_request_id) REFERENCES merge_requests(workspace_id,merge_request_id) ON DELETE CASCADE, + FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id) ON DELETE CASCADE +); +CREATE TABLE merge_request_revisions ( + workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, + ordinal INTEGER NOT NULL, base_commit TEXT NOT NULL, head_commit TEXT NOT NULL, + summary TEXT NOT NULL, assignment_id TEXT NOT NULL, created_at TEXT NOT NULL, + PRIMARY KEY(workspace_id,merge_request_id,revision_id), UNIQUE(workspace_id,merge_request_id,ordinal), + FOREIGN KEY(workspace_id,merge_request_id) REFERENCES merge_requests(workspace_id,merge_request_id) ON DELETE CASCADE +); +CREATE TABLE merge_request_revision_paths ( + workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, ordinal INTEGER NOT NULL, path TEXT NOT NULL, + PRIMARY KEY(workspace_id,merge_request_id,revision_id,ordinal), + FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id) ON DELETE CASCADE +); +CREATE TABLE merge_request_reviewer_child_sessions ( + workspace_id TEXT NOT NULL, child_session_id TEXT NOT NULL, parent_runtime_id TEXT NOT NULL, + parent_worker_id TEXT NOT NULL, effective_profile TEXT NOT NULL CHECK(effective_profile='builtin:reviewer'), registered_at TEXT NOT NULL, + PRIMARY KEY(workspace_id,child_session_id) +); +CREATE TABLE merge_request_review_attempts ( + workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, ticket_id TEXT NOT NULL, + revision_id TEXT NOT NULL, lifecycle_generation INTEGER NOT NULL, + parent_assignment_id TEXT NOT NULL, parent_runtime_id TEXT NOT NULL, parent_worker_id TEXT NOT NULL, + child_session_id TEXT NOT NULL, child_effective_profile TEXT NOT NULL CHECK(child_effective_profile='builtin:reviewer'), + capability_token_sha256 TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('open','submitted','revoked')), + created_at TEXT NOT NULL, consumed_at TEXT, + PRIMARY KEY(workspace_id,attempt_id), UNIQUE(workspace_id,capability_token_sha256), UNIQUE(workspace_id,child_session_id), + FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id), + FOREIGN KEY(workspace_id,ticket_id,parent_assignment_id) REFERENCES ticket_worker_assignments(workspace_id,ticket_id,assignment_id), + FOREIGN KEY(workspace_id,child_session_id) REFERENCES merge_request_reviewer_child_sessions(workspace_id,child_session_id) +); +CREATE TABLE merge_request_reviews ( + workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, + decision TEXT NOT NULL CHECK(decision IN ('approve','request_changes')), body TEXT NOT NULL, submitted_at TEXT NOT NULL, + PRIMARY KEY(workspace_id,attempt_id), + FOREIGN KEY(workspace_id,attempt_id) REFERENCES merge_request_review_attempts(workspace_id,attempt_id), + FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id) +); +CREATE TABLE merge_request_review_findings ( + workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, ordinal INTEGER NOT NULL, severity TEXT NOT NULL, + code TEXT, path TEXT, line INTEGER, body TEXT NOT NULL, PRIMARY KEY(workspace_id,attempt_id,ordinal), + FOREIGN KEY(workspace_id,attempt_id) REFERENCES merge_request_reviews(workspace_id,attempt_id) ON DELETE CASCADE +); +CREATE TABLE merge_request_completion_operations ( + workspace_id TEXT NOT NULL, operation_id TEXT NOT NULL, ticket_id TEXT NOT NULL, revision_id TEXT NOT NULL, + authority_kind TEXT NOT NULL CHECK(authority_kind IN ('workspace_orchestrator','legacy_assigned_coder')), + implementation_assignment_id TEXT NOT NULL, completion_actor_runtime_id TEXT, completion_actor_worker_id TEXT, + target_commit TEXT, source_commit TEXT, result_commit TEXT, + strategy TEXT CHECK(strategy IN ('fast_forward','merge')), + resolution TEXT CHECK(resolution IN ('none','clean','conflicts_resolved')), + fingerprint TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('pending','completed')), + result_ticket_state TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, + PRIMARY KEY(workspace_id,operation_id), + FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id), + FOREIGN KEY(workspace_id,ticket_id,implementation_assignment_id) + REFERENCES ticket_worker_assignments(workspace_id,ticket_id,assignment_id) +); +"#; + #[cfg(test)] mod migration_tests { use super::*; @@ -1991,6 +2114,31 @@ CREATE TABLE ticket_worker_assignments(workspace_id TEXT NOT NULL,ticket_id TEXT conn } + fn exact_v10_connection() -> Connection { + let conn = fresh_connection(); + conn.execute_batch(MIGRATION_TABLE_SQL).unwrap(); + conn.execute( + "INSERT INTO merge_request_schema_migrations(version,applied_at) VALUES(10,'t0')", + [], + ) + .unwrap(); + conn.execute_batch(SCHEMA_V10).unwrap(); + conn.execute_batch( + "INSERT INTO repositories VALUES('ws','repo'); + INSERT INTO typed_tickets VALUES('ws','T1','inprogress',1,'t0'); + INSERT INTO ticket_worker_assignments VALUES('ws','T1','A1','R1','W1'); + INSERT INTO merge_requests VALUES('ws','MR1','repo','open',1,'V1','t0','t0',NULL,NULL,'refs/heads/main','known',NULL,NULL,NULL,NULL,NULL,NULL,NULL); + INSERT INTO merge_request_ticket_relations VALUES('ws','MR1','T1','implements','t0'); + INSERT INTO merge_request_revisions VALUES('ws','MR1','V1',1,'base','head','digest','summary','A1','t0'); + INSERT INTO merge_request_revision_paths VALUES('ws','MR1','V1',0,'src/lib.rs'); + INSERT INTO merge_request_reviewer_child_sessions VALUES('ws','C1','R1','W1','builtin:reviewer','t0'); + INSERT INTO merge_request_review_attempts VALUES('ws','AT1','MR1','T1','V1',1,'A1','R1','W1','C1','builtin:reviewer','token','submitted','t0','t1'); + INSERT INTO merge_request_reviews VALUES('ws','AT1','MR1','V1','approve','approved','t1');", + ) + .unwrap(); + conn + } + fn marker_version(conn: &Connection) -> i64 { conn.query_row( "SELECT MAX(version) FROM merge_request_schema_migrations", @@ -2005,7 +2153,7 @@ CREATE TABLE ticket_worker_assignments(workspace_id TEXT NOT NULL,ticket_id TEXT let conn = fresh_connection(); migrate(&conn).unwrap(); verify(&conn).unwrap(); - assert_eq!(marker_version(&conn), 10); + assert_eq!(marker_version(&conn), 11); for column in [ "target_ref_selector", "merged_revision_id", @@ -2022,6 +2170,7 @@ CREATE TABLE ticket_worker_assignments(workspace_id TEXT NOT NULL,ticket_id TEXT ); } assert!(!column_exists(&conn, "merge_request_revisions", "head_tree").unwrap()); + assert!(!column_exists(&conn, "merge_request_revisions", "diff_digest").unwrap()); assert!(!table_exists(&conn, "merge_request_merge_results").unwrap()); } @@ -2030,7 +2179,7 @@ CREATE TABLE ticket_worker_assignments(workspace_id TEXT NOT NULL,ticket_id TEXT let conn = exact_v8_connection(); migrate(&conn).unwrap(); verify(&conn).unwrap(); - assert_eq!(marker_version(&conn), 10); + assert_eq!(marker_version(&conn), 11); assert_eq!( conn.query_row( "SELECT head_commit FROM merge_request_revisions WHERE revision_id='V1'", @@ -2069,13 +2218,80 @@ CREATE TABLE ticket_worker_assignments(workspace_id TEXT NOT NULL,ticket_id TEXT } #[test] - fn v8_to_v10_failure_rolls_back_schema_data_and_marker() { + fn exact_v10_migrates_revision_evidence_without_diff_digest() { + let conn = exact_v10_connection(); + migrate(&conn).unwrap(); + verify(&conn).unwrap(); + + assert_eq!(marker_version(&conn), 11); + assert!(!column_exists(&conn, "merge_request_revisions", "diff_digest").unwrap()); + assert_eq!( + conn.query_row( + "SELECT base_commit,head_commit FROM merge_request_revisions WHERE revision_id='V1'", + [], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .unwrap(), + ("base".to_string(), "head".to_string()) + ); + assert_eq!( + conn.query_row( + "SELECT path FROM merge_request_revision_paths WHERE revision_id='V1'", + [], + |row| row.get::<_, String>(0), + ) + .unwrap(), + "src/lib.rs" + ); + assert_eq!( + conn.query_row( + "SELECT decision FROM merge_request_reviews WHERE attempt_id='AT1'", + [], + |row| row.get::<_, String>(0), + ) + .unwrap(), + "approve" + ); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get::<_, i64>(0) + }) + .unwrap(), + 0 + ); + } + + #[test] + fn v10_to_v11_failure_rolls_back_schema_data_and_marker() { + let conn = exact_v10_connection(); + let error = migrate_with_failpoint(&conn, true).unwrap_err(); + assert!( + error + .to_string() + .contains("forced v10 to v11 migration failure") + ); + assert_eq!(marker_version(&conn), 10); + assert!(column_exists(&conn, "merge_request_revisions", "diff_digest").unwrap()); + assert_eq!( + conn.query_row( + "SELECT diff_digest FROM merge_request_revisions WHERE revision_id='V1'", + [], + |row| row.get::<_, String>(0), + ) + .unwrap(), + "digest" + ); + verify_schema_shape(&conn, SCHEMA_V10, "v10 after rollback").unwrap(); + } + + #[test] + fn v8_to_v11_failure_rolls_back_schema_data_and_marker() { let conn = exact_v8_connection(); let error = migrate_with_failpoint(&conn, true).unwrap_err(); assert!( error .to_string() - .contains("forced v8 to v10 migration failure") + .contains("forced v8 to v11 migration failure") ); assert_eq!(marker_version(&conn), 8); assert!(column_exists(&conn, "merge_request_revisions", "head_tree").unwrap()); @@ -2119,7 +2335,7 @@ CREATE TABLE ticket_worker_assignments(workspace_id TEXT NOT NULL,ticket_id TEXT migrate(&conn).unwrap(); verify(&conn).unwrap(); - assert_eq!(marker_version(&conn), 10); + assert_eq!(marker_version(&conn), 11); assert!(column_exists(&conn, "merge_requests", "merged_result_commit").unwrap()); } @@ -2136,17 +2352,17 @@ CREATE TABLE ticket_worker_assignments(workspace_id TEXT NOT NULL,ticket_id TEXT assert!( error .to_string() - .contains("only supports exact v8 or v9 to v10") + .contains("only supports exact v8, v9, or v10 to v11") ); assert_eq!(marker_version(&conn), 7); } #[test] - fn exact_v9_with_historical_markers_migrates_to_single_v10_marker() { + fn exact_v9_with_historical_markers_migrates_to_single_v11_marker() { let conn = exact_v9_connection(); migrate(&conn).unwrap(); verify(&conn).unwrap(); - assert_eq!(marker_version(&conn), 10); + assert_eq!(marker_version(&conn), 11); assert_eq!( conn.query_row( "SELECT COUNT(*) FROM merge_request_schema_migrations", @@ -2297,8 +2513,8 @@ fn load_revision( revision_id: &str, ) -> Result { let mut revision: MergeRequestRevision = conn.query_row( - "SELECT revision_id,ordinal,base_commit,head_commit,diff_digest,summary,assignment_id,created_at FROM merge_request_revisions WHERE workspace_id=?1 AND merge_request_id=?2 AND revision_id=?3", - params![workspace_id,mr_id,revision_id], |r| Ok(MergeRequestRevision { revision_id:r.get(0)?, ordinal:r.get::<_,i64>(1)? as u64, base_commit:r.get(2)?, head_commit:r.get(3)?, diff_digest:r.get(4)?, changed_paths:Vec::new(), summary:r.get(5)?, assignment_id:r.get(6)?, created_at:r.get(7)? }), + "SELECT revision_id,ordinal,base_commit,head_commit,summary,assignment_id,created_at FROM merge_request_revisions WHERE workspace_id=?1 AND merge_request_id=?2 AND revision_id=?3", + params![workspace_id,mr_id,revision_id], |r| Ok(MergeRequestRevision { revision_id:r.get(0)?, ordinal:r.get::<_,i64>(1)? as u64, base_commit:r.get(2)?, head_commit:r.get(3)?, changed_paths:Vec::new(), summary:r.get(4)?, assignment_id:r.get(5)?, created_at:r.get(6)? }), ).map_err(db)?; let mut statement = conn.prepare("SELECT path FROM merge_request_revision_paths WHERE workspace_id=?1 AND merge_request_id=?2 AND revision_id=?3 ORDER BY ordinal").map_err(db)?; revision.changed_paths = statement @@ -2315,7 +2531,7 @@ fn insert_revision( mr_id: &str, revision: &MergeRequestRevision, ) -> Result<()> { - conn.execute("INSERT INTO merge_request_revisions (workspace_id,merge_request_id,revision_id,ordinal,base_commit,head_commit,diff_digest,summary,assignment_id,created_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)", params![workspace_id,mr_id,revision.revision_id,revision.ordinal as i64,revision.base_commit,revision.head_commit,revision.diff_digest,revision.summary,revision.assignment_id,revision.created_at]).map_err(db)?; + conn.execute("INSERT INTO merge_request_revisions (workspace_id,merge_request_id,revision_id,ordinal,base_commit,head_commit,summary,assignment_id,created_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)", params![workspace_id,mr_id,revision.revision_id,revision.ordinal as i64,revision.base_commit,revision.head_commit,revision.summary,revision.assignment_id,revision.created_at]).map_err(db)?; for (ordinal, path) in revision.changed_paths.iter().enumerate() { conn.execute("INSERT INTO merge_request_revision_paths (workspace_id,merge_request_id,revision_id,ordinal,path) VALUES (?1,?2,?3,?4,?5)", params![workspace_id,mr_id,revision.revision_id,ordinal as i64,path]).map_err(db)?; } @@ -2454,7 +2670,6 @@ fn validate_revision(revision: &MergeRequestRevision) -> Result<()> { ("revision_id", revision.revision_id.as_str()), ("base_commit", revision.base_commit.as_str()), ("head_commit", revision.head_commit.as_str()), - ("diff_digest", revision.diff_digest.as_str()), ("assignment_id", revision.assignment_id.as_str()), ] { nonempty(name, value)?; diff --git a/crates/merge-request/tests/store.rs b/crates/merge-request/tests/store.rs index b6e46edb..ba795b1e 100644 --- a/crates/merge-request/tests/store.rs +++ b/crates/merge-request/tests/store.rs @@ -47,7 +47,6 @@ fn revision(id: &str, ordinal: u64, head: &str) -> MergeRequestRevision { ordinal, base_commit: "base".into(), head_commit: head.into(), - diff_digest: format!("sha256:diff-{head}"), changed_paths: vec!["src/lib.rs".into()], summary: format!("revision {id}"), assignment_id: "A1".into(), diff --git a/crates/worker/src/feature/builtin/merge_request.rs b/crates/worker/src/feature/builtin/merge_request.rs index 89edd913..78dbd339 100644 --- a/crates/worker/src/feature/builtin/merge_request.rs +++ b/crates/worker/src/feature/builtin/merge_request.rs @@ -41,7 +41,6 @@ struct OpenInput { revision_id: String, base_commit: String, head_commit: String, - diff_digest: String, #[serde(default)] changed_paths: Vec, #[serde(default)] @@ -54,7 +53,6 @@ struct AddRevisionInput { revision_id: String, base_commit: String, head_commit: String, - diff_digest: String, #[serde(default)] changed_paths: Vec, #[serde(default)] @@ -174,7 +172,7 @@ impl Tool for MergeRequestTool { WorkspaceRequestMethod::Post, format!("/api/w/{workspace_id}/tickets/{}/merge-request", v.ticket), Some( - json!({"repository_id":v.repository_id,"revision_id":v.revision_id,"base_commit":v.base_commit,"head_commit":v.head_commit,"diff_digest":v.diff_digest,"changed_paths":v.changed_paths,"summary":v.summary}), + json!({"repository_id":v.repository_id,"revision_id":v.revision_id,"base_commit":v.base_commit,"head_commit":v.head_commit,"changed_paths":v.changed_paths,"summary":v.summary}), ), ) } @@ -188,7 +186,7 @@ impl Tool for MergeRequestTool { v.ticket ), Some( - json!({"expected_current_revision_id":v.expected_current_revision_id,"revision_id":v.revision_id,"base_commit":v.base_commit,"head_commit":v.head_commit,"diff_digest":v.diff_digest,"changed_paths":v.changed_paths,"summary":v.summary}), + json!({"expected_current_revision_id":v.expected_current_revision_id,"revision_id":v.revision_id,"base_commit":v.base_commit,"head_commit":v.head_commit,"changed_paths":v.changed_paths,"summary":v.summary}), ), ) } @@ -328,12 +326,14 @@ mod tests { use super::*; #[test] - fn merge_request_tool_contract_omits_tree_hashes_and_candidate_result_tool() { + fn merge_request_tool_contract_omits_redundant_revision_evidence_and_candidate_result_tool() { let open = serde_json::to_string(&schemars::schema_for!(OpenInput)).unwrap(); let add = serde_json::to_string(&schemars::schema_for!(AddRevisionInput)).unwrap(); let complete = serde_json::to_string(&schemars::schema_for!(CompleteInput)).unwrap(); assert!(!open.contains("head_tree")); assert!(!add.contains("head_tree")); + assert!(!open.contains("diff_digest")); + assert!(!add.contains("diff_digest")); assert!(complete.contains("result_commit")); assert!(complete.contains("conflicts_resolved")); assert!(!MERGE_REQUEST_COMMON_TOOL_NAMES.contains(&"MergeRequestRecordMergeResult")); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index c4807de7..d24e8dd5 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -3580,7 +3580,6 @@ struct OpenMergeRequestRequest { revision_id: String, base_commit: String, head_commit: String, - diff_digest: String, #[serde(default)] changed_paths: Vec, #[serde(default)] @@ -3593,7 +3592,6 @@ struct AddMergeRequestRevisionRequest { revision_id: String, base_commit: String, head_commit: String, - diff_digest: String, #[serde(default)] changed_paths: Vec, #[serde(default)] @@ -3809,7 +3807,6 @@ async fn scoped_open_merge_request( ordinal: 1, base_commit, head_commit: source_commit, - diff_digest: input.diff_digest, changed_paths: input.changed_paths, summary: input.summary, assignment_id: assignment.assignment_id.clone(), @@ -3876,7 +3873,6 @@ async fn scoped_add_merge_request_revision( ordinal: current.current_revision.ordinal + 1, base_commit, head_commit, - diff_digest: input.diff_digest, changed_paths: input.changed_paths, summary: input.summary, assignment_id: assignment.assignment_id, @@ -12807,7 +12803,6 @@ mod tests { base_commit: target_commit.clone(), head_commit: source_commit.clone(), - diff_digest: "sha256:diff".into(), changed_paths: vec!["src/lib.rs".into()], summary: "approved revision".into(), assignment_id: assignment.assignment_id.clone(), diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte index 1a32d5c0..5696a486 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte @@ -23,7 +23,7 @@ target_ref_selector?: string | null; target_status: "known" | "unknown"; observed_target_commit?: string | null; - current_revision: { revision_id: string; head_commit: string; diff_digest: string; changed_paths: string[]; summary: string }; + current_revision: { revision_id: string; head_commit: string; changed_paths: string[]; summary: string }; current_review?: { decision: string; body: string; reviewer_effective_profile: string } | null; merged_revision_id?: string | null; merged_target_commit?: string | null; From aeb2727beaa030a21da008a803b9f73e440eb571 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 17 Aug 2026 07:09:10 +0900 Subject: [PATCH 06/13] feat: replace merge request revision authority --- Cargo.lock | 3 +- crates/merge-request/Cargo.toml | 3 +- crates/merge-request/src/lib.rs | 4425 +++++++++++---------------- crates/merge-request/tests/store.rs | 590 ++-- 4 files changed, 2098 insertions(+), 2923 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e2cebe1b..9016734a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2533,9 +2533,10 @@ dependencies = [ name = "merge-request" version = "0.1.0" dependencies = [ + "chrono", "rusqlite", "serde", - "sha2 0.11.0", + "serde_json", "tempfile", "thiserror 2.0.18", ] diff --git a/crates/merge-request/Cargo.toml b/crates/merge-request/Cargo.toml index d5865a9c..e8a5ed85 100644 --- a/crates/merge-request/Cargo.toml +++ b/crates/merge-request/Cargo.toml @@ -5,9 +5,10 @@ edition.workspace = true license.workspace = true [dependencies] +chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } rusqlite.workspace = true serde = { workspace = true, features = ["derive"] } -sha2.workspace = true +serde_json.workspace = true thiserror.workspace = true [dev-dependencies] diff --git a/crates/merge-request/src/lib.rs b/crates/merge-request/src/lib.rs index 04c2b1bb..74b52a11 100644 --- a/crates/merge-request/src/lib.rs +++ b/crates/merge-request/src/lib.rs @@ -1,89 +1,39 @@ -//! Workspace-scoped Merge Request authority. -//! -//! Merge Requests deliberately do not reuse Ticket thread review events. A review is -//! evidence for one immutable revision and can only be committed with a one-shot -//! capability registered from an actual Runtime-owned direct-child reviewer session. +use std::path::Path; +use std::sync::{Arc, Mutex}; -use rusqlite::{Connection, OptionalExtension, params}; +use chrono::{DateTime, Utc}; +use rusqlite::{Connection, OptionalExtension, Transaction, params}; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::path::{Path, PathBuf}; -use std::time::Duration; use thiserror::Error; -const SCHEMA_VERSION: i64 = 11; -const REVIEWER_PROFILE: &str = "builtin:reviewer"; -const MAX_SUMMARY_BYTES: usize = 16 * 1024; -const MAX_REVIEW_BODY_BYTES: usize = 64 * 1024; -const MAX_CHANGED_PATHS: usize = 1_000; -const MAX_FINDINGS: usize = 1_000; -const MAX_FIELD_BYTES: usize = 4 * 1024; - -pub type Result = std::result::Result; - -#[derive(Debug, Error)] -pub enum MergeRequestError { - #[error("merge request database error: {0}")] - Database(String), - #[error("{0} must not be empty")] - Empty(&'static str), - #[error("{field} exceeds its bounded limit of {max} bytes/items")] - TooLarge { field: &'static str, max: usize }, - #[error("merge request not found for ticket {0}")] - NotFound(String), - #[error("merge request already exists for ticket {0}")] - AlreadyExists(String), - #[error("immutable revision {0} already exists with different content")] - RevisionConflict(String), - #[error("stale merge request revision: expected {expected}, current {current}")] - StaleRevision { expected: String, current: String }, - #[error("current Ticket assignment does not match the authenticated Coder")] - AssignmentMismatch, - #[error("reviewer must be an actual direct-child with effective profile builtin:reviewer")] - InvalidReviewer, - #[error("review attempt is invalid, revoked, already used, or belongs to another revision")] - InvalidReviewAttempt, - #[error("review result cannot be supplied by the assigned Coder itself")] - SelfApproval, - #[error("merge request current revision is not approved")] - NotApproved, - #[error("merge request is {0}, expected open")] - NotOpen(String), - #[error("completion operation id was reused with different input")] - OperationConflict, - #[error("completion merge outcome is invalid: {0}")] - InvalidMergeOutcome(String), - #[error("Merge Request target is unknown and must be resolved explicitly")] - UnknownTarget, - #[error("Ticket must be inprogress before Merge Request completion (current: {0})")] - TicketStateConflict(String), -} +const SCHEMA_VERSION: i64 = 12; +const PREVIOUS_SCHEMA_VERSION: i64 = 11; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum MergeRequestState { - Draft, Open, - Closed, Merged, + Closed, } impl MergeRequestState { fn as_str(self) -> &'static str { match self { - Self::Draft => "draft", Self::Open => "open", - Self::Closed => "closed", Self::Merged => "merged", + Self::Closed => "closed", } } - fn parse(value: &str) -> Self { + fn parse(value: &str) -> Result { match value { - "draft" => Self::Draft, - "closed" => Self::Closed, - "merged" => Self::Merged, - _ => Self::Open, + "open" | "draft" => Ok(Self::Open), + "merged" => Ok(Self::Merged), + "closed" => Ok(Self::Closed), + other => Err(MergeRequestError::Corrupt(format!( + "unknown merge request state `{other}`" + ))), } } } @@ -95,44 +45,73 @@ pub enum ReviewDecision { RequestChanges, } -impl ReviewDecision { - fn as_str(self) -> &'static str { - match self { - Self::Approve => "approve", - Self::RequestChanges => "request_changes", - } - } - - fn parse(value: &str) -> Self { - match value { - "approve" => Self::Approve, - _ => Self::RequestChanges, - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -pub enum ReviewStatus { - Pending, - Approved, - ChangesRequested, +pub enum FindingSeverity { + Blocker, + Major, + Minor, + Note, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum MergeRequestTargetStatus { - Known, - Unknown, +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReviewFinding { + pub severity: FindingSeverity, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub line: Option, + pub message: String, } -impl MergeRequestTargetStatus { - fn parse(value: &str) -> Self { - match value { - "known" => Self::Known, - _ => Self::Unknown, - } - } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkerIdentity { + pub runtime_id: String, + pub worker_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MergeRequestAuth { + pub workspace_id: String, + pub repository_id: String, + pub runtime_id: String, + pub worker_id: String, + pub assignment_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RequestForReview { + pub base_commit: String, + pub head_commit: String, + #[serde(default)] + pub changed_paths: Vec, + #[serde(default)] + pub summary: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RequestForReviewEvent { + pub event_seq: u64, + pub base_commit: String, + pub head_commit: String, + pub changed_paths: Vec, + pub summary: String, + pub assignment_id: String, + pub requested_by: WorkerIdentity, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReviewEvent { + pub event_seq: u64, + pub request_event_seq: u64, + pub head_commit: String, + pub reviewer_worker: WorkerIdentity, + pub reviewer_profile: String, + pub decision: ReviewDecision, + pub body: String, + pub findings: Vec, + pub created_at: DateTime, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -142,2688 +121,1772 @@ pub enum MergeStrategy { Merge, } -impl MergeStrategy { - fn as_str(self) -> &'static str { - match self { - Self::FastForward => "fast_forward", - Self::Merge => "merge", - } - } - - fn parse(value: &str) -> Self { - match value { - "fast_forward" => Self::FastForward, - _ => Self::Merge, - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -pub enum MergeResolution { +pub enum ConflictResolution { None, Clean, ConflictsResolved, } -impl MergeResolution { - fn as_str(self) -> &'static str { - match self { - Self::None => "none", - Self::Clean => "clean", - Self::ConflictsResolved => "conflicts_resolved", - } - } - - fn parse(value: &str) -> Self { - match value { - "none" => Self::None, - "conflicts_resolved" => Self::ConflictsResolved, - _ => Self::Clean, - } - } -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct MergeRequestRevision { - pub revision_id: String, - pub ordinal: u64, - pub base_commit: String, - pub head_commit: String, - pub changed_paths: Vec, - pub summary: String, - pub assignment_id: String, - pub created_at: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ReviewFinding { - pub severity: String, - pub code: Option, - pub path: Option, - pub line: Option, - pub body: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct MergeRequestReview { - pub attempt_id: String, - pub revision_id: String, - pub decision: ReviewDecision, - pub body: String, - pub findings: Vec, - pub parent_assignment_id: String, - pub parent_runtime_id: String, - pub parent_worker_id: String, - pub reviewer_child_session_id: String, - pub reviewer_effective_profile: String, - pub submitted_at: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct MergeRequest { - pub merge_request_id: String, - pub workspace_id: String, - pub ticket_id: String, - pub repository_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub target_ref_selector: Option, - pub target_status: MergeRequestTargetStatus, - #[serde(skip_serializing_if = "Option::is_none")] - pub observed_target_commit: Option, - pub state: MergeRequestState, - pub lifecycle_generation: u64, - pub current_revision: MergeRequestRevision, - pub review_status: ReviewStatus, - pub current_review: Option, - pub created_at: String, - pub updated_at: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub merged_revision_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub merged_target_commit: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub merged_result_commit: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub merge_strategy: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub merge_resolution: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub merged_by_runtime_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub merged_by_worker_id: Option, - pub merged_at: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct OpenMergeRequest { - pub merge_request_id: String, - pub ticket_id: String, - pub repository_id: String, - pub target_ref_selector: String, - pub revision: MergeRequestRevision, - pub authenticated_runtime_id: String, - pub authenticated_worker_id: String, - pub now: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AddRevision { - pub ticket_id: String, - pub expected_current_revision_id: String, - pub revision: MergeRequestRevision, - pub authenticated_runtime_id: String, - pub authenticated_worker_id: String, - pub now: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RegisterReviewerChildSession { - pub parent_runtime_id: String, - pub parent_worker_id: String, - pub child_session_id: String, - pub now: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RegisterReviewAttempt { - pub attempt_id: String, - pub ticket_id: String, - pub revision_id: String, - pub parent_assignment_id: String, - pub parent_runtime_id: String, - pub parent_worker_id: String, - pub child_session_id: String, - /// A secret generated by the trusted spawn layer and injected only into the child client. - pub capability_token: String, - pub now: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SubmitReview { - pub ticket_id: String, - pub revision_id: String, - pub capability_token: String, - pub decision: ReviewDecision, - pub body: String, - pub findings: Vec, - pub now: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CompleteMergeRequest { +pub struct MergeEvent { + pub event_seq: u64, pub operation_id: String, - pub ticket_id: String, - pub expected_revision_id: String, pub target_commit: String, pub source_commit: String, pub result_commit: String, pub strategy: MergeStrategy, - pub resolution: MergeResolution, - pub implementation_assignment_id: String, - pub completion_actor_runtime_id: String, - pub completion_actor_worker_id: String, - pub now: String, + pub resolution: ConflictResolution, + pub merged_by: WorkerIdentity, + pub created_at: DateTime, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct CompletionOutcome { - pub operation_id: String, - pub ticket_id: String, - pub revision_id: String, - pub ticket_state: String, - pub replayed: bool, +pub struct LifecycleEvent { + pub event_seq: u64, + pub actor: WorkerIdentity, + #[serde(default)] + pub body: String, + pub created_at: DateTime, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct MergeRequestReadiness { - pub ticket_id: String, +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum MergeRequestThreadEvent { + RequestForReview(RequestForReviewEvent), + Review(ReviewEvent), + Merge(MergeEvent), + Reopen(LifecycleEvent), + Close(LifecycleEvent), +} + +impl MergeRequestThreadEvent { + pub fn event_seq(&self) -> u64 { + match self { + Self::RequestForReview(value) => value.event_seq, + Self::Review(value) => value.event_seq, + Self::Merge(value) => value.event_seq, + Self::Reopen(value) | Self::Close(value) => value.event_seq, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MergeRequest { + pub workspace_id: String, pub merge_request_id: String, - pub revision_id: String, - pub target_ref_selector: Option, - pub observed_target_commit: Option, + pub ticket_id: String, + pub repository_id: String, + pub state: MergeRequestState, + pub selector_from: String, + pub selector_to: String, + pub opened_by_worker: WorkerIdentity, + pub created_at: DateTime, + pub updated_at: DateTime, + #[serde(default)] + pub thread: Vec, +} + +impl MergeRequest { + pub fn current_request(&self) -> Option<&RequestForReviewEvent> { + let reopened_at = self.thread.iter().rev().find_map(|event| match event { + MergeRequestThreadEvent::Reopen(value) => Some(value.event_seq), + _ => None, + }); + self.thread.iter().rev().find_map(|event| match event { + MergeRequestThreadEvent::RequestForReview(value) + if reopened_at.is_none_or(|seq| value.event_seq > seq) => + { + Some(value) + } + _ => None, + }) + } + + pub fn current_review(&self) -> Option<&ReviewEvent> { + let request = self.current_request()?; + self.thread.iter().rev().find_map(|event| match event { + MergeRequestThreadEvent::Review(value) + if value.request_event_seq == request.event_seq => + { + Some(value) + } + _ => None, + }) + } +} + +#[derive(Debug, Clone)] +pub struct OpenMergeRequest { + pub merge_request_id: String, + pub ticket_id: String, + pub repository_id: String, + pub selector_from: String, + pub selector_to: String, + pub request: RequestForReview, + pub auth: MergeRequestAuth, + pub now: DateTime, +} + +#[derive(Debug, Clone)] +pub struct RequestMergeRequestReview { + pub ticket_id: String, + pub expected_head_commit: String, + pub request: RequestForReview, + pub auth: MergeRequestAuth, + pub now: DateTime, +} + +#[derive(Debug, Clone)] +pub struct RegisterReviewCapability { + pub ticket_id: String, + pub expected_head_commit: String, + pub child_session_id: String, + pub capability_token: String, + pub auth: MergeRequestAuth, + pub now: DateTime, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RegisteredReviewCapability { + pub capability_token: String, + pub request_event_seq: u64, +} + +#[derive(Debug, Clone)] +pub struct RegisterReviewerChildSession { + pub workspace_id: String, + pub parent_runtime_id: String, + pub parent_worker_id: String, + pub child_session_id: String, + pub reviewer_profile: String, + pub now: DateTime, +} + +#[derive(Debug, Clone)] +pub struct SubmitMergeRequestReview { + pub ticket_id: String, + pub expected_head_commit: String, + pub capability_token: String, + pub decision: ReviewDecision, + pub body: String, + pub findings: Vec, + pub now: DateTime, +} + +#[derive(Debug, Clone)] +pub struct ReadinessCheck { + pub ticket_id: String, + pub expected_head_commit: Option, + pub auth: MergeRequestAuth, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReadinessReport { pub ready: bool, - pub review_status: ReviewStatus, pub blockers: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub review: Option, } -#[derive(Clone, Debug)] -pub struct SqliteMergeRequestStore { - db_path: PathBuf, - workspace_id: String, +#[derive(Debug, Clone)] +pub struct CompleteMergeRequest { + pub ticket_id: String, + pub expected_head_commit: String, + pub operation_id: String, + pub target_commit: String, + pub source_commit: String, + pub result_commit: String, + pub strategy: MergeStrategy, + pub resolution: ConflictResolution, + pub auth: MergeRequestAuth, + pub now: DateTime, } -impl SqliteMergeRequestStore { - pub fn open(db_path: impl Into, workspace_id: impl Into) -> Result { - let store = Self { - db_path: db_path.into(), - workspace_id: workspace_id.into(), - }; - let conn = store.connect()?; - migrate(&conn)?; - Ok(store) - } +#[derive(Debug, Clone)] +pub struct ChangeMergeRequestState { + pub ticket_id: String, + pub body: String, + pub auth: MergeRequestAuth, + pub now: DateTime, +} - pub fn open_verified( - db_path: impl Into, - workspace_id: impl Into, - ) -> Result { - let store = Self { - db_path: db_path.into(), - workspace_id: workspace_id.into(), - }; - verify(&store.connect()?)?; - Ok(store) - } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CurrentAssignment { + pub assignment_id: String, + pub ticket_id: String, + pub runtime_id: String, + pub worker_id: String, +} - pub fn db_path(&self) -> &Path { - &self.db_path - } - - pub fn workspace_id(&self) -> &str { - &self.workspace_id - } - - fn connect(&self) -> Result { - let conn = Connection::open(&self.db_path).map_err(db)?; - conn.busy_timeout(Duration::from_secs(5)).map_err(db)?; - conn.pragma_update(None, "foreign_keys", "ON").map_err(db)?; - Ok(conn) - } - - fn write(&self, op: impl FnOnce(&Connection) -> Result) -> Result { - let conn = self.connect()?; - verify(&conn)?; - conn.execute_batch("BEGIN IMMEDIATE").map_err(db)?; - match op(&conn) { - Ok(value) => { - conn.execute_batch("COMMIT").map_err(db)?; - Ok(value) - } - Err(error) => { - let _ = conn.execute_batch("ROLLBACK"); - Err(error) - } - } - } - - pub fn show_for_ticket(&self, ticket_id: &str) -> Result> { - self.show_for_ticket_with_target(ticket_id, None) - } - - pub fn show_for_ticket_with_target( +pub trait AssignmentSource: Send + Sync { + fn current_assignment( &self, + workspace_id: &str, ticket_id: &str, - observed_target_commit: Option<&str>, - ) -> Result> { - nonempty("ticket_id", ticket_id)?; - let conn = self.connect()?; - verify(&conn)?; - let mut mr = load_merge_request(&conn, &self.workspace_id, ticket_id)?; - if let Some(mr) = mr.as_mut() { - apply_target_observation(mr, observed_target_commit); - } - Ok(mr) - } + ) -> Result, String>; +} - pub fn readiness_for_ticket(&self, ticket_id: &str) -> Result { - self.readiness_for_ticket_with_target(ticket_id, None) - } - - pub fn readiness_for_ticket_with_target( +pub trait RepositorySource: Send + Sync { + fn repository_belongs_to_workspace( &self, - ticket_id: &str, - observed_target_commit: Option<&str>, - ) -> Result { - let mr = self - .show_for_ticket_with_target(ticket_id, observed_target_commit)? - .ok_or_else(|| MergeRequestError::NotFound(ticket_id.to_string()))?; - let mut blockers = Vec::new(); - if mr.state != MergeRequestState::Open { - blockers.push(format!("merge request is {}", mr.state.as_str())); - } - match mr.review_status { - ReviewStatus::Pending => { - blockers.push("current source revision has no review result".into()) - } - ReviewStatus::ChangesRequested => { - blockers.push("current source revision has request_changes".into()) - } - ReviewStatus::Approved => {} - } - if mr.target_status != MergeRequestTargetStatus::Known || observed_target_commit.is_none() { - blockers.push("merge request target is unknown or could not be resolved".into()); - } - Ok(MergeRequestReadiness { - ticket_id: ticket_id.to_string(), - merge_request_id: mr.merge_request_id, - revision_id: mr.current_revision.revision_id, - target_ref_selector: mr.target_ref_selector, - observed_target_commit: mr.observed_target_commit, - ready: blockers.is_empty(), - review_status: mr.review_status, - blockers, + workspace_id: &str, + repository_id: &str, + ) -> Result; + + fn is_ancestor( + &self, + workspace_id: &str, + repository_id: &str, + ancestor: &str, + descendant: &str, + ) -> Result; +} + +#[derive(Debug, Error)] +pub enum MergeRequestError { + #[error("merge request not found")] + NotFound, + #[error("merge request conflict: {0}")] + Conflict(String), + #[error("merge request unauthorized: {0}")] + Unauthorized(String), + #[error("merge request is not ready: {0}")] + NotReady(String), + #[error("merge request validation failed: {0}")] + Validation(String), + #[error("merge request operation failed: {0}")] + Operation(String), + #[error("merge request storage is corrupt: {0}")] + Corrupt(String), + #[error("merge request storage error: {0}")] + Storage(#[from] rusqlite::Error), +} + +pub struct MergeRequestStore { + conn: Arc>, + assignments: Arc, + repositories: Arc, +} + +impl MergeRequestStore { + pub fn open( + path: impl AsRef, + assignments: Arc, + repositories: Arc, + ) -> Result { + let conn = Connection::open(path)?; + Self::from_connection(conn, assignments, repositories) + } + + pub fn open_in_memory( + assignments: Arc, + repositories: Arc, + ) -> Result { + Self::from_connection(Connection::open_in_memory()?, assignments, repositories) + } + + fn from_connection( + mut conn: Connection, + assignments: Arc, + repositories: Arc, + ) -> Result { + conn.pragma_update(None, "foreign_keys", "ON")?; + migrate(&mut conn)?; + Ok(Self { + conn: Arc::new(Mutex::new(conn)), + assignments, + repositories, }) } - pub fn open_merge_request(&self, input: OpenMergeRequest) -> Result { - validate_revision(&input.revision)?; - for (name, value) in [ - ("merge_request_id", input.merge_request_id.as_str()), - ("ticket_id", input.ticket_id.as_str()), - ("repository_id", input.repository_id.as_str()), - ("target_ref_selector", input.target_ref_selector.as_str()), - ("runtime_id", input.authenticated_runtime_id.as_str()), - ("worker_id", input.authenticated_worker_id.as_str()), - ] { - nonempty(name, value)?; + pub fn open_merge_request( + &self, + input: OpenMergeRequest, + ) -> Result { + validate_nonempty("merge_request_id", &input.merge_request_id)?; + validate_nonempty("ticket_id", &input.ticket_id)?; + validate_nonempty("selector_from", &input.selector_from)?; + validate_nonempty("selector_to", &input.selector_to)?; + self.validate_auth(&input.auth, &input.ticket_id, &input.repository_id)?; + validate_request(&input.request)?; + + let mut conn = self.lock_conn()?; + let tx = conn.transaction()?; + if load_merge_request_tx(&tx, &input.auth.workspace_id, &input.ticket_id)?.is_some() { + return Err(MergeRequestError::Conflict( + "an open merge request already exists for this ticket".into(), + )); } - self.write(|conn| { - validate_current_assignment( - conn, - &self.workspace_id, - &input.ticket_id, - &input.revision.assignment_id, - &input.authenticated_runtime_id, - &input.authenticated_worker_id, - )?; - conn.execute( - "INSERT INTO merge_requests (workspace_id, merge_request_id, repository_id, target_ref_selector, target_status, state, lifecycle_generation, current_revision_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, 'known', 'open', 1, ?5, ?6, ?6)", - params![self.workspace_id, input.merge_request_id, input.repository_id, input.target_ref_selector, input.revision.revision_id, input.now], - ).map_err(db)?; - conn.execute( - "INSERT INTO merge_request_ticket_relations (workspace_id,merge_request_id,ticket_id,relation_kind,created_at) VALUES (?1,?2,?3,'implements',?4)", - params![self.workspace_id,input.merge_request_id,input.ticket_id,input.now], - ).map_err(db)?; - insert_revision(conn, &self.workspace_id, &input.merge_request_id, &input.revision)?; - load_merge_request(conn, &self.workspace_id, &input.ticket_id)?.ok_or_else(|| MergeRequestError::NotFound(input.ticket_id.clone())) - }) + let now = input.now.to_rfc3339(); + tx.execute( + "INSERT INTO merge_requests ( + workspace_id, merge_request_id, ticket_id, repository_id, state, + selector_from, selector_to, opened_by_runtime_id, opened_by_worker_id, + created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, 'open', ?5, ?6, ?7, ?8, ?9, ?9)", + params![ + input.auth.workspace_id, + input.merge_request_id, + input.ticket_id, + input.repository_id, + input.selector_from, + input.selector_to, + input.auth.runtime_id, + input.auth.worker_id, + now, + ], + )?; + append_request_event_tx( + &tx, + &input.auth.workspace_id, + &input.merge_request_id, + &input.auth, + &input.request, + input.now, + )?; + tx.commit()?; + drop(conn); + self.get(&input.auth.workspace_id, &input.ticket_id) } - pub fn add_revision(&self, input: AddRevision) -> Result { - validate_revision(&input.revision)?; - self.write(|conn| { - let current = load_merge_request(conn, &self.workspace_id, &input.ticket_id)? - .ok_or_else(|| MergeRequestError::NotFound(input.ticket_id.clone()))?; - ensure_open(¤t)?; - if current.current_revision.revision_id != input.expected_current_revision_id { - return Err(MergeRequestError::StaleRevision { - expected: input.expected_current_revision_id.clone(), - current: current.current_revision.revision_id, - }); - } - validate_current_assignment( - conn, - &self.workspace_id, - &input.ticket_id, - &input.revision.assignment_id, - &input.authenticated_runtime_id, - &input.authenticated_worker_id, - )?; - if input.revision.ordinal != current.current_revision.ordinal + 1 { - return Err(MergeRequestError::RevisionConflict(input.revision.revision_id.clone())); - } - let existing: Option<(String, String)> = conn.query_row( - "SELECT base_commit, head_commit FROM merge_request_revisions WHERE workspace_id=?1 AND merge_request_id=?2 AND revision_id=?3", - params![self.workspace_id, current.merge_request_id, input.revision.revision_id], - |row| Ok((row.get(0)?, row.get(1)?)), - ).optional().map_err(db)?; - if let Some(existing) = existing { - if existing == (input.revision.base_commit.clone(), input.revision.head_commit.clone()) { - return Ok(current); - } - return Err(MergeRequestError::RevisionConflict(input.revision.revision_id.clone())); - } - insert_revision(conn, &self.workspace_id, ¤t.merge_request_id, &input.revision)?; - conn.execute( - "UPDATE merge_requests SET current_revision_id=?3, updated_at=?4 WHERE workspace_id=?1 AND merge_request_id=?2 AND current_revision_id=?5", - params![self.workspace_id, current.merge_request_id, input.revision.revision_id, input.now, input.expected_current_revision_id], - ).map_err(db)?; - load_merge_request(conn, &self.workspace_id, &input.ticket_id)?.ok_or_else(|| MergeRequestError::NotFound(input.ticket_id.clone())) + pub fn request_review( + &self, + input: RequestMergeRequestReview, + ) -> Result { + validate_request(&input.request)?; + let current = self.get(&input.auth.workspace_id, &input.ticket_id)?; + self.validate_auth(&input.auth, &input.ticket_id, ¤t.repository_id)?; + ensure_open(¤t)?; + let current_request = current + .thread + .iter() + .rev() + .find_map(|event| match event { + MergeRequestThreadEvent::RequestForReview(value) => Some(value), + _ => None, + }) + .ok_or_else(|| { + MergeRequestError::Corrupt("open merge request has no review request".into()) + })?; + if current_request.head_commit != input.expected_head_commit { + return Err(MergeRequestError::Conflict( + "expected head commit is stale".into(), + )); + } + + let mut conn = self.lock_conn()?; + let tx = conn.transaction()?; + let event = append_request_event_tx( + &tx, + ¤t.workspace_id, + ¤t.merge_request_id, + &input.auth, + &input.request, + input.now, + )?; + tx.commit()?; + Ok(event) + } + + pub fn register_review_capability( + &self, + input: RegisterReviewCapability, + ) -> Result { + let mr = self.get(&input.auth.workspace_id, &input.ticket_id)?; + self.validate_auth(&input.auth, &input.ticket_id, &mr.repository_id)?; + ensure_open(&mr)?; + let request = mr.current_request().ok_or_else(|| { + MergeRequestError::Corrupt("open merge request has no review request".into()) + })?; + if request.head_commit != input.expected_head_commit { + return Err(MergeRequestError::Conflict( + "expected head commit is stale".into(), + )); + } + validate_nonempty("capability_token", &input.capability_token)?; + let mut conn = self.lock_conn()?; + let tx = conn.transaction()?; + let child: Option<(String, String, String)> = tx + .query_row( + "SELECT parent_runtime_id, child_session_id, reviewer_profile + FROM merge_request_reviewer_child_sessions + WHERE workspace_id = ?1 AND child_session_id = ?2 + AND parent_runtime_id = ?3 AND parent_worker_id = ?4 + AND status = 'active'", + params![ + input.auth.workspace_id, + input.child_session_id, + input.auth.runtime_id, + input.auth.worker_id + ], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional()?; + let Some((reviewer_runtime_id, reviewer_worker_id, reviewer_profile)) = child else { + return Err(MergeRequestError::Unauthorized( + "reviewer child session is missing or no longer active".into(), + )); + }; + validate_reviewer_profile(&reviewer_profile)?; + tx.execute( + "INSERT INTO merge_request_review_capabilities ( + workspace_id, merge_request_id, request_event_seq, capability_token, + issued_by_assignment_id, reviewer_runtime_id, reviewer_worker_id, + reviewer_profile, issued_at, status + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'issued')", + params![ + mr.workspace_id, + mr.merge_request_id, + request.event_seq, + input.capability_token, + input.auth.assignment_id, + reviewer_runtime_id, + reviewer_worker_id, + reviewer_profile, + input.now.to_rfc3339() + ], + )?; + tx.execute( + "UPDATE merge_request_reviewer_child_sessions SET status = 'consumed' + WHERE workspace_id = ?1 AND child_session_id = ?2 AND status = 'active'", + params![input.auth.workspace_id, input.child_session_id], + )?; + tx.commit()?; + Ok(RegisteredReviewCapability { + capability_token: input.capability_token, + request_event_seq: request.event_seq, }) } pub fn register_reviewer_child_session( &self, input: RegisterReviewerChildSession, - ) -> Result<()> { - nonempty("runtime_id", &input.parent_runtime_id)?; - nonempty("worker_id", &input.parent_worker_id)?; - nonempty("child_session_id", &input.child_session_id)?; - self.write(|conn| { - conn.execute( - "INSERT INTO merge_request_reviewer_child_sessions (workspace_id,child_session_id,parent_runtime_id,parent_worker_id,effective_profile,registered_at) VALUES (?1,?2,?3,?4,'builtin:reviewer',?5)", - params![self.workspace_id,input.child_session_id,input.parent_runtime_id,input.parent_worker_id,input.now], - ).map_err(|_| MergeRequestError::InvalidReviewer)?; - Ok(()) - }) + ) -> Result<(), MergeRequestError> { + validate_reviewer_profile(&input.reviewer_profile)?; + let conn = self.lock_conn()?; + conn.execute( + "INSERT INTO merge_request_reviewer_child_sessions ( + workspace_id, child_session_id, parent_runtime_id, parent_worker_id, + reviewer_profile, registered_at, status + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'active')", + params![ + input.workspace_id, + input.child_session_id, + input.parent_runtime_id, + input.parent_worker_id, + input.reviewer_profile, + input.now.to_rfc3339() + ], + )?; + Ok(()) } - pub fn register_review_attempt(&self, input: RegisterReviewAttempt) -> Result<()> { - for (name, value) in [ - ("attempt_id", input.attempt_id.as_str()), - ("capability_token", input.capability_token.as_str()), - ("child_session_id", input.child_session_id.as_str()), - ] { - nonempty(name, value)?; - } - if input.child_session_id == input.parent_worker_id { - return Err(MergeRequestError::SelfApproval); - } - self.write(|conn| { - let mr = load_merge_request(conn, &self.workspace_id, &input.ticket_id)? - .ok_or_else(|| MergeRequestError::NotFound(input.ticket_id.clone()))?; - ensure_open(&mr)?; - if mr.current_revision.revision_id != input.revision_id { - return Err(MergeRequestError::StaleRevision { expected: input.revision_id.clone(), current: mr.current_revision.revision_id }); - } - validate_current_assignment(conn, &self.workspace_id, &input.ticket_id, &input.parent_assignment_id, &input.parent_runtime_id, &input.parent_worker_id)?; - let effective_profile: Option = conn.query_row( - "SELECT effective_profile FROM merge_request_reviewer_child_sessions WHERE workspace_id=?1 AND child_session_id=?2 AND parent_runtime_id=?3 AND parent_worker_id=?4", - params![self.workspace_id,input.child_session_id,input.parent_runtime_id,input.parent_worker_id], - |row| row.get(0), - ).optional().map_err(db)?; - if effective_profile.as_deref() != Some(REVIEWER_PROFILE) { - return Err(MergeRequestError::InvalidReviewer); - } - conn.execute( - "INSERT INTO merge_request_review_attempts (workspace_id, attempt_id, merge_request_id, ticket_id, revision_id, lifecycle_generation, parent_assignment_id, parent_runtime_id, parent_worker_id, child_session_id, child_effective_profile, capability_token_sha256, status, created_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,'open',?13)", - params![self.workspace_id, input.attempt_id, mr.merge_request_id, input.ticket_id, input.revision_id, mr.lifecycle_generation as i64, input.parent_assignment_id, input.parent_runtime_id, input.parent_worker_id, input.child_session_id, REVIEWER_PROFILE, token_hash(&input.capability_token), input.now], - ).map_err(|_| MergeRequestError::InvalidReviewAttempt)?; - Ok(()) - }) - } - - pub fn revoke_review_attempt( + pub fn submit_review( &self, - attempt_id: &str, - child_session_id: &str, - now: &str, - ) -> Result { - self.write(|conn| { - let changed = conn.execute( - "UPDATE merge_request_review_attempts SET status='revoked', consumed_at=?4 WHERE workspace_id=?1 AND attempt_id=?2 AND child_session_id=?3 AND status='open'", - params![self.workspace_id, attempt_id, child_session_id, now], - ).map_err(db)?; - Ok(changed == 1) - }) - } - - pub fn submit_review(&self, input: SubmitReview) -> Result { - nonempty("capability_token", &input.capability_token)?; - validate_review_input(&input)?; - self.write(|conn| { - let token = token_hash(&input.capability_token); - let attempt: Option<(String,String,String,String,String,String,String,String,i64)> = conn.query_row( - "SELECT attempt_id, merge_request_id, parent_assignment_id, parent_runtime_id, parent_worker_id, child_session_id, child_effective_profile, status, lifecycle_generation FROM merge_request_review_attempts WHERE workspace_id=?1 AND ticket_id=?2 AND revision_id=?3 AND capability_token_sha256=?4", - params![self.workspace_id, input.ticket_id, input.revision_id, token], - |row| Ok((row.get(0)?,row.get(1)?,row.get(2)?,row.get(3)?,row.get(4)?,row.get(5)?,row.get(6)?,row.get(7)?,row.get(8)?)), - ).optional().map_err(db)?; - let Some((attempt_id, mr_id, assignment_id, runtime_id, worker_id, child_session_id, effective_profile, status, lifecycle_generation)) = attempt else { - return Err(MergeRequestError::InvalidReviewAttempt); - }; - if status != "open" || effective_profile != REVIEWER_PROFILE || child_session_id == worker_id { - return Err(MergeRequestError::InvalidReviewAttempt); - } - let mr = load_merge_request(conn, &self.workspace_id, &input.ticket_id)? - .ok_or_else(|| MergeRequestError::NotFound(input.ticket_id.clone()))?; - if lifecycle_generation != mr.lifecycle_generation as i64 { - return Err(MergeRequestError::InvalidReviewAttempt); - } - ensure_open(&mr)?; - if mr.current_revision.revision_id != input.revision_id { - return Err(MergeRequestError::StaleRevision { expected: input.revision_id.clone(), current: mr.current_revision.revision_id }); - } - validate_current_assignment(conn, &self.workspace_id, &input.ticket_id, &assignment_id, &runtime_id, &worker_id)?; - conn.execute( - "INSERT INTO merge_request_reviews (workspace_id, attempt_id, merge_request_id, revision_id, decision, body, submitted_at) VALUES (?1,?2,?3,?4,?5,?6,?7)", - params![self.workspace_id, attempt_id, mr_id, input.revision_id, input.decision.as_str(), input.body, input.now], - ).map_err(|_| MergeRequestError::InvalidReviewAttempt)?; - for (ordinal, finding) in input.findings.iter().enumerate() { - nonempty("finding.body", &finding.body)?; - conn.execute( - "INSERT INTO merge_request_review_findings (workspace_id, attempt_id, ordinal, severity, code, path, line, body) VALUES (?1,?2,?3,?4,?5,?6,?7,?8)", - params![self.workspace_id, attempt_id, ordinal as i64, finding.severity, finding.code, finding.path, finding.line.map(|v| v as i64), finding.body], - ).map_err(db)?; - } - conn.execute( - "UPDATE merge_request_review_attempts SET status='submitted', consumed_at=?3 WHERE workspace_id=?1 AND attempt_id=?2 AND status='open'", - params![self.workspace_id, attempt_id, input.now], - ).map_err(db)?; - load_review(conn, &self.workspace_id, &attempt_id)?.ok_or(MergeRequestError::InvalidReviewAttempt) - }) - } - - pub fn complete(&self, input: CompleteMergeRequest) -> Result { - for (name, value) in [ - ("operation_id", input.operation_id.as_str()), - ("ticket_id", input.ticket_id.as_str()), - ("revision_id", input.expected_revision_id.as_str()), - ("target_commit", input.target_commit.as_str()), - ("source_commit", input.source_commit.as_str()), - ("result_commit", input.result_commit.as_str()), - ( - "implementation_assignment_id", - input.implementation_assignment_id.as_str(), - ), - ( - "completion_actor_runtime_id", - input.completion_actor_runtime_id.as_str(), - ), - ( - "completion_actor_worker_id", - input.completion_actor_worker_id.as_str(), - ), - ] { - nonempty(name, value)?; + input: SubmitMergeRequestReview, + ) -> Result { + if input.body.trim().is_empty() { + return Err(MergeRequestError::Validation( + "review body must not be empty".into(), + )); } - validate_completion_outcome(&input)?; - let fingerprint = completion_fingerprint(&input); - self.write(|conn| { - if let Some((stored, status, state)) = conn.query_row( - "SELECT fingerprint, status, result_ticket_state FROM merge_request_completion_operations WHERE workspace_id=?1 AND operation_id=?2", - params![self.workspace_id, input.operation_id], - |row| Ok((row.get::<_,String>(0)?, row.get::<_,String>(1)?, row.get::<_,Option>(2)?)), - ).optional().map_err(db)? { - if stored != fingerprint { return Err(MergeRequestError::OperationConflict); } - if status == "completed" { - return Ok(CompletionOutcome { operation_id: input.operation_id.clone(), ticket_id: input.ticket_id.clone(), revision_id: input.expected_revision_id.clone(), ticket_state: state.unwrap_or_else(|| "done".into()), replayed: true }); + let mut conn = self.lock_conn()?; + let tx = conn.transaction()?; + let capability: Option<(String, String, i64, String)> = tx + .query_row( + "SELECT c.workspace_id, c.merge_request_id, c.request_event_seq, c.status + FROM merge_request_review_capabilities c + JOIN merge_requests m + ON m.workspace_id = c.workspace_id + AND m.merge_request_id = c.merge_request_id + WHERE c.capability_token = ?1 AND m.ticket_id = ?2", + params![input.capability_token, input.ticket_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .optional()?; + let Some((workspace_id, merge_request_id, request_event_seq, status)) = capability else { + return Err(MergeRequestError::Unauthorized( + "review capability is invalid".into(), + )); + }; + if status != "issued" { + return Err(MergeRequestError::Conflict( + "review capability has already been consumed".into(), + )); + } + let reviewer: Option<(String, String, String)> = tx + .query_row( + "SELECT reviewer_runtime_id, reviewer_worker_id, reviewer_profile + FROM merge_request_review_capabilities + WHERE capability_token = ?1 AND status = 'issued'", + params![input.capability_token], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional()?; + let Some((reviewer_runtime_id, reviewer_worker_id, reviewer_profile)) = reviewer else { + return Err(MergeRequestError::Unauthorized( + "review source is not the registered reviewer child".into(), + )); + }; + validate_reviewer_profile(&reviewer_profile)?; + let request_payload: String = tx.query_row( + "SELECT payload_json FROM merge_request_thread_events + WHERE workspace_id = ?1 AND merge_request_id = ?2 + AND event_seq = ?3 AND kind = 'request_for_review'", + params![workspace_id, merge_request_id, request_event_seq], + |row| row.get(0), + )?; + let request: RequestForReviewEvent = decode_payload(&request_payload)?; + if request.head_commit != input.expected_head_commit { + return Err(MergeRequestError::Conflict( + "expected head commit is stale".into(), + )); + } + let latest_request_seq: i64 = tx.query_row( + "SELECT MAX(event_seq) FROM merge_request_thread_events + WHERE workspace_id = ?1 AND merge_request_id = ?2 AND kind = 'request_for_review'", + params![workspace_id, merge_request_id], + |row| row.get(0), + )?; + if latest_request_seq != request_event_seq { + return Err(MergeRequestError::Conflict( + "review request has been superseded".into(), + )); + } + let event_seq = next_event_seq_tx(&tx, &workspace_id, &merge_request_id)?; + let event = ReviewEvent { + event_seq, + request_event_seq: request_event_seq as u64, + head_commit: request.head_commit, + reviewer_worker: WorkerIdentity { + runtime_id: reviewer_runtime_id, + worker_id: reviewer_worker_id, + }, + reviewer_profile, + decision: input.decision, + body: input.body, + findings: input.findings, + created_at: input.now, + }; + insert_event_tx( + &tx, + &workspace_id, + &merge_request_id, + "review", + &event, + input.now, + None, + )?; + tx.execute( + "UPDATE merge_request_review_capabilities SET status = 'consumed', consumed_at = ?2 + WHERE capability_token = ?1 AND status = 'issued'", + params![input.capability_token, input.now.to_rfc3339()], + )?; + tx.commit()?; + Ok(event) + } + + pub fn readiness(&self, input: ReadinessCheck) -> Result { + let mr = self.get(&input.auth.workspace_id, &input.ticket_id)?; + if mr.repository_id != input.auth.repository_id { + return Err(MergeRequestError::Unauthorized( + "repository does not match merge request".into(), + )); + } + let request = mr.current_request().cloned(); + let review = mr.current_review().cloned(); + let mut blockers = Vec::new(); + if mr.state != MergeRequestState::Open { + blockers.push("merge request is not open".into()); + } + match &request { + None => blockers.push("merge request has no review request".into()), + Some(value) => { + if input + .expected_head_commit + .as_deref() + .is_some_and(|expected| expected != value.head_commit) + { + blockers.push("expected head commit is stale".into()); + } + match &review { + None => blockers.push("current review request has no review result".into()), + Some(review) if review.decision != ReviewDecision::Approve => { + blockers.push("current review requests changes".into()) + } + Some(review) if review.head_commit != value.head_commit => blockers + .push("current review does not match the requested head commit".into()), + Some(_) => {} } - } else { - conn.execute( - "INSERT INTO merge_request_completion_operations (workspace_id, operation_id, ticket_id, revision_id, authority_kind, implementation_assignment_id, completion_actor_runtime_id, completion_actor_worker_id, target_commit, source_commit, result_commit, strategy, resolution, fingerprint, status, created_at, updated_at) VALUES (?1,?2,?3,?4,'workspace_orchestrator',?5,?6,?7,?8,?9,?10,?11,?12,?13,'pending',?14,?14)", - params![self.workspace_id, input.operation_id, input.ticket_id, input.expected_revision_id, input.implementation_assignment_id, input.completion_actor_runtime_id, input.completion_actor_worker_id, input.target_commit, input.source_commit, input.result_commit, input.strategy.as_str(), input.resolution.as_str(), fingerprint, input.now], - ).map_err(db)?; } - let mr = load_merge_request(conn, &self.workspace_id, &input.ticket_id)? - .ok_or_else(|| MergeRequestError::NotFound(input.ticket_id.clone()))?; - validate_current_implementation_assignment(conn, &self.workspace_id, &input.ticket_id, &input.implementation_assignment_id)?; - ensure_open(&mr)?; - if mr.current_revision.revision_id != input.expected_revision_id { - return Err(MergeRequestError::StaleRevision { expected: input.expected_revision_id.clone(), current: mr.current_revision.revision_id }); - } - if mr.current_revision.head_commit != input.source_commit { - return Err(MergeRequestError::InvalidMergeOutcome("source commit does not match the current approved revision".into())); - } - if mr.review_status != ReviewStatus::Approved { return Err(MergeRequestError::NotApproved); } - let current_state: String = conn.query_row( - "SELECT workflow_state FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2", - params![self.workspace_id, input.ticket_id], |row| row.get(0), - ).optional().map_err(db)?.ok_or_else(|| MergeRequestError::NotFound(input.ticket_id.clone()))?; - if current_state != "inprogress" { return Err(MergeRequestError::TicketStateConflict(current_state)); } - let changed = conn.execute( - "UPDATE typed_tickets SET workflow_state='done', workflow_state_explicit=1, updated_at=?3 WHERE workspace_id=?1 AND ticket_id=?2 AND workflow_state='inprogress'", - params![self.workspace_id, input.ticket_id, input.now], - ).map_err(db)?; - if changed != 1 { return Err(MergeRequestError::TicketStateConflict("concurrent_change".into())); } - let merged = conn.execute( - "UPDATE merge_requests SET state='merged', merged_revision_id=?3, merged_target_commit=?4, merged_result_commit=?5, merge_strategy=?6, merge_resolution=?7, merged_by_runtime_id=?8, merged_by_worker_id=?9, merged_at=?10, updated_at=?10 WHERE workspace_id=?1 AND merge_request_id=?2 AND state='open' AND current_revision_id=?3", - params![self.workspace_id, mr.merge_request_id, input.expected_revision_id, input.target_commit, input.result_commit, input.strategy.as_str(), input.resolution.as_str(), input.completion_actor_runtime_id, input.completion_actor_worker_id, input.now], - ).map_err(db)?; - if merged != 1 { return Err(MergeRequestError::OperationConflict); } - append_completion_event(conn, &self.workspace_id, &input)?; - conn.execute( - "UPDATE merge_request_completion_operations SET status='completed', result_ticket_state='done', updated_at=?3 WHERE workspace_id=?1 AND operation_id=?2 AND status='pending'", - params![self.workspace_id, input.operation_id, input.now], - ).map_err(db)?; - Ok(CompletionOutcome { operation_id: input.operation_id, ticket_id: input.ticket_id, revision_id: input.expected_revision_id, ticket_state: "done".into(), replayed: false }) + } + Ok(ReadinessReport { + ready: blockers.is_empty(), + blockers, + request, + review, }) } - pub fn close( - &self, - ticket_id: &str, - expected_revision_id: &str, - now: &str, - ) -> Result { - self.transition_open(ticket_id, expected_revision_id, "closed", now) + pub fn complete(&self, input: CompleteMergeRequest) -> Result { + validate_nonempty("operation_id", &input.operation_id)?; + let mr = self.get(&input.auth.workspace_id, &input.ticket_id)?; + self.validate_completion_auth(&input.auth, &input.ticket_id, &mr.repository_id)?; + + if let Some(existing) = mr.thread.iter().find_map(|event| match event { + MergeRequestThreadEvent::Merge(value) if value.operation_id == input.operation_id => { + Some(value) + } + _ => None, + }) { + if merge_matches(existing, &input) { + return Ok(existing.clone()); + } + return Err(MergeRequestError::Conflict( + "operation id was already used with a different completion fingerprint".into(), + )); + } + + let readiness = self.readiness(ReadinessCheck { + ticket_id: input.ticket_id.clone(), + expected_head_commit: Some(input.expected_head_commit.clone()), + auth: input.auth.clone(), + })?; + if !readiness.ready { + return Err(MergeRequestError::NotReady(readiness.blockers.join("; "))); + } + let request = readiness.request.expect("ready request"); + if input.source_commit != request.head_commit + || input.expected_head_commit != request.head_commit + { + return Err(MergeRequestError::Conflict( + "completion source does not match current review request".into(), + )); + } + validate_completion_shape(&input, &request, self.repositories.as_ref(), &mr)?; + + let mut conn = self.lock_conn()?; + let tx = conn.transaction()?; + let current_ticket_state: Option = tx + .query_row( + "SELECT workflow_state FROM typed_tickets WHERE workspace_id = ?1 AND ticket_id = ?2", + params![mr.workspace_id, input.ticket_id], + |row| row.get(0), + ) + .optional()?; + match current_ticket_state.as_deref() { + Some("inprogress") => {} + Some(other) => { + return Err(MergeRequestError::Conflict(format!( + "Ticket state `{other}` cannot be completed" + ))); + } + None => return Err(MergeRequestError::NotFound), + } + let changed = tx.execute( + "UPDATE typed_tickets + SET workflow_state = 'done', workflow_state_explicit = 1, updated_at = ?3 + WHERE workspace_id = ?1 AND ticket_id = ?2 AND workflow_state = 'inprogress'", + params![mr.workspace_id, input.ticket_id, input.now.to_rfc3339()], + )?; + if changed != 1 { + return Err(MergeRequestError::Conflict( + "Ticket state changed concurrently".into(), + )); + } + let implementation_assignment_id = input.auth.assignment_id.clone(); + let event_seq = next_event_seq_tx(&tx, &mr.workspace_id, &mr.merge_request_id)?; + let event = MergeEvent { + event_seq, + operation_id: input.operation_id, + target_commit: input.target_commit, + source_commit: input.source_commit, + result_commit: input.result_commit, + strategy: input.strategy, + resolution: input.resolution, + merged_by: WorkerIdentity { + runtime_id: input.auth.runtime_id, + worker_id: input.auth.worker_id, + }, + created_at: input.now, + }; + insert_event_tx( + &tx, + &mr.workspace_id, + &mr.merge_request_id, + "merge", + &event, + input.now, + Some(&event.operation_id), + )?; + tx.execute( + "UPDATE merge_requests SET state = 'merged', updated_at = ?3 + WHERE workspace_id = ?1 AND merge_request_id = ?2 AND state = 'open'", + params![mr.workspace_id, mr.merge_request_id, input.now.to_rfc3339()], + )?; + append_ticket_completion_event_tx(&tx, &mr, &event, &implementation_assignment_id)?; + tx.commit()?; + Ok(event) + } + + pub fn close(&self, input: ChangeMergeRequestState) -> Result { + self.change_state(input, MergeRequestState::Closed, "close") } pub fn reopen( &self, - ticket_id: &str, - expected_revision_id: &str, - now: &str, - ) -> Result { - self.write(|conn| { - let mr = load_merge_request(conn, &self.workspace_id, ticket_id)?.ok_or_else(|| MergeRequestError::NotFound(ticket_id.into()))?; - if mr.state != MergeRequestState::Closed { return Err(MergeRequestError::NotOpen(mr.state.as_str().into())); } - if mr.current_revision.revision_id != expected_revision_id { return Err(MergeRequestError::StaleRevision { expected: expected_revision_id.into(), current: mr.current_revision.revision_id }); } - conn.execute("UPDATE merge_requests SET state='open', lifecycle_generation=lifecycle_generation+1, updated_at=?3 WHERE workspace_id=?1 AND merge_request_id=?2 AND state='closed'", params![self.workspace_id, mr.merge_request_id, now]).map_err(db)?; - load_merge_request(conn, &self.workspace_id, ticket_id)?.ok_or_else(|| MergeRequestError::NotFound(ticket_id.into())) - }) + input: ChangeMergeRequestState, + ) -> Result { + self.change_state(input, MergeRequestState::Open, "reopen") } - fn transition_open( + fn change_state( &self, - ticket_id: &str, - expected_revision_id: &str, - state: &str, - now: &str, - ) -> Result { - self.write(|conn| { - let mr = load_merge_request(conn, &self.workspace_id, ticket_id)?.ok_or_else(|| MergeRequestError::NotFound(ticket_id.into()))?; - ensure_open(&mr)?; - if mr.current_revision.revision_id != expected_revision_id { return Err(MergeRequestError::StaleRevision { expected: expected_revision_id.into(), current: mr.current_revision.revision_id }); } - conn.execute("UPDATE merge_requests SET state=?3, updated_at=?4 WHERE workspace_id=?1 AND merge_request_id=?2 AND state='open'", params![self.workspace_id, mr.merge_request_id, state, now]).map_err(db)?; - load_merge_request(conn, &self.workspace_id, ticket_id)?.ok_or_else(|| MergeRequestError::NotFound(ticket_id.into())) - }) - } -} - -pub fn migrate(conn: &Connection) -> Result<()> { - migrate_with_failpoint(conn, false) -} - -fn migrate_with_failpoint( - conn: &Connection, - force_failure_after_migration_ddl: bool, -) -> Result<()> { - let original_foreign_keys: i64 = conn - .query_row("PRAGMA foreign_keys", [], |row| row.get(0)) - .map_err(db)?; - conn.pragma_update(None, "foreign_keys", "OFF") - .map_err(db)?; - - let transaction_result = (|| { - conn.execute_batch("BEGIN IMMEDIATE").map_err(db)?; - let result = migrate_locked(conn, force_failure_after_migration_ddl); - match result { - Ok(()) => { - if let Err(error) = conn.execute_batch("COMMIT").map_err(db) { - let _ = conn.execute_batch("ROLLBACK"); - Err(error) - } else { - Ok(()) - } - } - Err(error) => { - let _ = conn.execute_batch("ROLLBACK"); - Err(error) + input: ChangeMergeRequestState, + target: MergeRequestState, + kind: &str, + ) -> Result { + let mr = self.get(&input.auth.workspace_id, &input.ticket_id)?; + self.validate_lifecycle_auth(&input.auth, &mr.repository_id)?; + match (mr.state, target) { + (MergeRequestState::Open, MergeRequestState::Closed) + | (MergeRequestState::Closed, MergeRequestState::Open) + | (MergeRequestState::Merged, MergeRequestState::Open) => {} + _ => { + return Err(MergeRequestError::Conflict( + "illegal merge request state transition".into(), + )); } } - })(); - - let restore_result = conn - .pragma_update(None, "foreign_keys", original_foreign_keys) - .map_err(db); - transaction_result?; - restore_result?; - verify(conn) -} - -fn migrate_locked(conn: &Connection, force_failure_after_migration_ddl: bool) -> Result<()> { - let marker_exists = table_exists(conn, MIGRATION_TABLE)?; - if !marker_exists { - if has_merge_request_domain_tables(conn)? { - return Err(MergeRequestError::Database( - "unsupported unversioned legacy merge request schema; automatic migration requires a fresh database or exact version 8" - .into(), - )); - } - conn.execute_batch(MIGRATION_TABLE_SQL).map_err(db)?; - conn.execute_batch(SCHEMA_V11).map_err(db)?; - verify_schema_shape(conn, SCHEMA_V11, "v11")?; - ensure_foreign_key_integrity(conn)?; - replace_schema_marker(conn, SCHEMA_VERSION)?; - return verify(conn); - } - - let version = schema_version(conn)?; - match version { - SCHEMA_VERSION => { - verify_marker_state(conn, SCHEMA_VERSION)?; - verify(conn) - } - 10 => { - verify_marker_state(conn, 10)?; - if verify_schema_shape(conn, SCHEMA_V11, "v11").is_ok() { - return finish_v11_schema(conn); - } - verify_schema_shape(conn, SCHEMA_V10, "v10").map_err(|_| { - MergeRequestError::Database( - "schema drift at merge request version 10; automatic migration requires the exact released v10 shape or a complete v11 shape for marker repair" - .into(), - ) - })?; - migrate_v10_to_v11(conn)?; - fail_after_migration_if_requested(force_failure_after_migration_ddl, 10)?; - finish_v11_schema(conn) - } - 9 => { - verify_marker_table_shape(conn)?; - remove_empty_v9_migration_debris(conn)?; - if verify_schema_shape(conn, SCHEMA_V11, "v11").is_ok() { - return finish_v11_schema(conn); - } - if verify_schema_shape(conn, SCHEMA_V10, "v10").is_ok() { - migrate_v10_to_v11(conn)?; - fail_after_migration_if_requested(force_failure_after_migration_ddl, 9)?; - return finish_v11_schema(conn); - } - verify_schema_shape(conn, SCHEMA_V9, "v9").map_err(|_| { - MergeRequestError::Database( - "schema drift at merge request version 9; automatic migration requires the exact released v9 shape, a complete v10 shape, or a complete v11 shape for marker repair" - .into(), - ) - })?; - migrate_v9_to_v10(conn)?; - migrate_v10_to_v11(conn)?; - fail_after_migration_if_requested(force_failure_after_migration_ddl, 9)?; - finish_v11_schema(conn) - } - 8 => { - verify_marker_state(conn, 8)?; - if verify_schema_shape(conn, SCHEMA_V11, "v11").is_ok() { - return finish_v11_schema(conn); - } - if verify_schema_shape(conn, SCHEMA_V10, "v10").is_ok() { - migrate_v10_to_v11(conn)?; - fail_after_migration_if_requested(force_failure_after_migration_ddl, 8)?; - return finish_v11_schema(conn); - } - verify_schema_shape(conn, SCHEMA_V8, "v8").map_err(|_| { - MergeRequestError::Database( - "schema drift at merge request version 8; automatic migration requires the exact v8 shape, a complete v10 shape, or a complete v11 shape for marker repair" - .into(), - ) - })?; - migrate_v8_to_v10(conn)?; - migrate_v10_to_v11(conn)?; - fail_after_migration_if_requested(force_failure_after_migration_ddl, 8)?; - finish_v11_schema(conn) - } - 0..=7 => Err(MergeRequestError::Database(format!( - "unsupported legacy merge request schema version {version}; automatic migration only supports exact v8, v9, or v10 to v11" - ))), - other => Err(MergeRequestError::Database(format!( - "unsupported merge request schema version {other}; expected version 8, 9, 10, or {SCHEMA_VERSION}" - ))), - } -} - -fn fail_after_migration_if_requested(force_failure: bool, source_version: i64) -> Result<()> { - if force_failure { - return Err(MergeRequestError::Database(format!( - "forced v{source_version} to v11 migration failure after DDL and data copy" - ))); - } - Ok(()) -} - -fn finish_v11_schema(conn: &Connection) -> Result<()> { - verify_schema_shape(conn, SCHEMA_V11, "v11")?; - ensure_foreign_key_integrity(conn)?; - replace_schema_marker(conn, SCHEMA_VERSION)?; - verify(conn) -} - -fn remove_empty_v9_migration_debris(conn: &Connection) -> Result<()> { - const OBSOLETE_TABLE: &str = "merge_request_ticket_links"; - if !table_exists(conn, OBSOLETE_TABLE)? { - return Ok(()); - } - let row_count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM merge_request_ticket_links", - [], - |row| row.get(0), - ) - .map_err(db)?; - if row_count != 0 { - return Err(MergeRequestError::Database( - "cannot remove non-empty obsolete v9 merge_request_ticket_links table".into(), - )); - } - conn.execute("DROP TABLE merge_request_ticket_links", []) - .map_err(db)?; - Ok(()) -} - -struct LegacyV9CompletedOutcome { - workspace_id: String, - operation_id: String, - merge_request_id: String, - ticket_id: String, - revision_id: String, - implementation_assignment_id: String, - completion_actor_runtime_id: String, - completion_actor_worker_id: String, - target_commit: String, - source_commit: String, - result_commit: String, - strategy: String, - resolution: String, - completed_at: String, -} - -fn migrate_v9_to_v10(conn: &Connection) -> Result<()> { - let result_review_count: i64 = conn - .query_row( - "SELECT - (SELECT COUNT(*) FROM merge_request_review_attempts WHERE merge_result_id IS NOT NULL) + - (SELECT COUNT(*) FROM merge_request_reviews WHERE merge_result_id IS NOT NULL)", - [], - |row| row.get(0), - ) - .map_err(db)?; - if result_review_count != 0 { - return Err(MergeRequestError::Database( - "cannot automatically migrate v9 merge-result-specific reviews to v10".into(), - )); - } - let pending_operation_count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM merge_request_completion_operations WHERE status <> 'completed'", - [], - |row| row.get(0), - ) - .map_err(db)?; - if pending_operation_count != 0 { - return Err(MergeRequestError::Database( - "cannot automatically migrate pending v9 completion operations to v10".into(), - )); - } - let invalid_completed_operation_count: i64 = conn - .query_row( - "SELECT COUNT(*) - FROM merge_request_completion_operations o - WHERE o.status='completed' - AND ( - o.completion_actor_runtime_id IS NULL OR trim(o.completion_actor_runtime_id)='' OR - o.completion_actor_worker_id IS NULL OR trim(o.completion_actor_worker_id)='' OR - (SELECT COUNT(*) - FROM merge_request_merge_results r - JOIN merge_requests mr - ON mr.workspace_id=r.workspace_id - AND mr.merge_request_id=r.merge_request_id - WHERE r.workspace_id=o.workspace_id - AND r.ticket_id=o.ticket_id - AND r.revision_id=o.revision_id - AND mr.current_revision_id=o.revision_id) <> 1 - )", - [], - |row| row.get(0), - ) - .map_err(db)?; - if invalid_completed_operation_count != 0 { - return Err(MergeRequestError::Database( - "cannot uniquely map completed v9 operations to their current merge result".into(), - )); - } - let duplicate_merge_request_count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM ( - SELECT r.workspace_id,r.merge_request_id - FROM merge_request_completion_operations o - JOIN merge_request_merge_results r - ON r.workspace_id=o.workspace_id - AND r.ticket_id=o.ticket_id - AND r.revision_id=o.revision_id - WHERE o.status='completed' - GROUP BY r.workspace_id,r.merge_request_id - HAVING COUNT(*) <> 1 - )", - [], - |row| row.get(0), - ) - .map_err(db)?; - if duplicate_merge_request_count != 0 { - return Err(MergeRequestError::Database( - "cannot choose one completed v9 outcome for a merge request".into(), - )); - } - let unmapped_merged_request_count: i64 = conn - .query_row( - "SELECT COUNT(*) - FROM merge_requests mr - WHERE mr.state='merged' - AND NOT EXISTS ( - SELECT 1 - FROM merge_request_completion_operations o - JOIN merge_request_merge_results r - ON r.workspace_id=o.workspace_id - AND r.ticket_id=o.ticket_id - AND r.revision_id=o.revision_id - WHERE o.status='completed' - AND r.workspace_id=mr.workspace_id - AND r.merge_request_id=mr.merge_request_id - )", - [], - |row| row.get(0), - ) - .map_err(db)?; - if unmapped_merged_request_count != 0 { - return Err(MergeRequestError::Database( - "cannot reconstruct final outcome for a merged v9 merge request".into(), - )); - } - let completed_outcomes = { - let mut statement = conn - .prepare( - "SELECT o.workspace_id,o.operation_id,r.merge_request_id,o.ticket_id,o.revision_id, - o.implementation_assignment_id,o.completion_actor_runtime_id,o.completion_actor_worker_id, - r.target_commit,r.source_commit,r.result_commit,r.strategy,r.resolution,o.updated_at - FROM merge_request_completion_operations o - JOIN merge_request_merge_results r - ON r.workspace_id=o.workspace_id - AND r.ticket_id=o.ticket_id - AND r.revision_id=o.revision_id - WHERE o.status='completed' - ORDER BY o.workspace_id,o.operation_id", - ) - .map_err(db)?; - statement - .query_map([], |row| { - Ok(LegacyV9CompletedOutcome { - workspace_id: row.get(0)?, - operation_id: row.get(1)?, - merge_request_id: row.get(2)?, - ticket_id: row.get(3)?, - revision_id: row.get(4)?, - implementation_assignment_id: row.get(5)?, - completion_actor_runtime_id: row.get(6)?, - completion_actor_worker_id: row.get(7)?, - target_commit: row.get(8)?, - source_commit: row.get(9)?, - result_commit: row.get(10)?, - strategy: row.get(11)?, - resolution: row.get(12)?, - completed_at: row.get(13)?, - }) - }) - .map_err(db)? - .collect::, _>>() - .map_err(db)? - }; - conn.execute_batch( - "CREATE TABLE merge_requests_v10 ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, - repository_id TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('draft','open','closed','merged')), - lifecycle_generation INTEGER NOT NULL, current_revision_id TEXT NOT NULL, - created_at TEXT NOT NULL, updated_at TEXT NOT NULL, merged_by_account_id TEXT, merged_at TEXT, - target_ref_selector TEXT, - target_status TEXT NOT NULL DEFAULT 'unknown' CHECK(target_status IN ('known','unknown')), - merged_revision_id TEXT, merged_target_commit TEXT, merged_result_commit TEXT, - merge_strategy TEXT CHECK(merge_strategy IN ('fast_forward','merge')), - merge_resolution TEXT CHECK(merge_resolution IN ('none','clean','conflicts_resolved')), - merged_by_runtime_id TEXT, merged_by_worker_id TEXT, - PRIMARY KEY(workspace_id,merge_request_id), - FOREIGN KEY(workspace_id,repository_id) REFERENCES repositories(workspace_id,repository_id) - ); - INSERT INTO merge_requests_v10( - workspace_id,merge_request_id,repository_id,state,lifecycle_generation,current_revision_id, - created_at,updated_at,merged_by_account_id,merged_at,target_ref_selector,target_status - ) SELECT workspace_id,merge_request_id,repository_id,state,lifecycle_generation,current_revision_id, - created_at,updated_at,merged_by_account_id,merged_at,target_ref_selector,target_status - FROM merge_requests; - DROP TABLE merge_requests; - ALTER TABLE merge_requests_v10 RENAME TO merge_requests; - - CREATE TABLE merge_request_review_attempts_v10 ( - workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, ticket_id TEXT NOT NULL, - revision_id TEXT NOT NULL, lifecycle_generation INTEGER NOT NULL, - parent_assignment_id TEXT NOT NULL, parent_runtime_id TEXT NOT NULL, parent_worker_id TEXT NOT NULL, - child_session_id TEXT NOT NULL, child_effective_profile TEXT NOT NULL CHECK(child_effective_profile='builtin:reviewer'), - capability_token_sha256 TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('open','submitted','revoked')), - created_at TEXT NOT NULL, consumed_at TEXT, - PRIMARY KEY(workspace_id,attempt_id), UNIQUE(workspace_id,capability_token_sha256), UNIQUE(workspace_id,child_session_id), - FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id), - FOREIGN KEY(workspace_id,ticket_id,parent_assignment_id) REFERENCES ticket_worker_assignments(workspace_id,ticket_id,assignment_id), - FOREIGN KEY(workspace_id,child_session_id) REFERENCES merge_request_reviewer_child_sessions(workspace_id,child_session_id) - ); - INSERT INTO merge_request_review_attempts_v10( - workspace_id,attempt_id,merge_request_id,ticket_id,revision_id,lifecycle_generation, - parent_assignment_id,parent_runtime_id,parent_worker_id,child_session_id,child_effective_profile, - capability_token_sha256,status,created_at,consumed_at - ) SELECT workspace_id,attempt_id,merge_request_id,ticket_id,revision_id,lifecycle_generation, - parent_assignment_id,parent_runtime_id,parent_worker_id,child_session_id,child_effective_profile, - capability_token_sha256,status,created_at,consumed_at - FROM merge_request_review_attempts; - DROP TABLE merge_request_review_attempts; - ALTER TABLE merge_request_review_attempts_v10 RENAME TO merge_request_review_attempts; - - CREATE TABLE merge_request_reviews_v10 ( - workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, - decision TEXT NOT NULL CHECK(decision IN ('approve','request_changes')), body TEXT NOT NULL, submitted_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,attempt_id), - FOREIGN KEY(workspace_id,attempt_id) REFERENCES merge_request_review_attempts(workspace_id,attempt_id), - FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id) - ); - INSERT INTO merge_request_reviews_v10( - workspace_id,attempt_id,merge_request_id,revision_id,decision,body,submitted_at - ) SELECT workspace_id,attempt_id,merge_request_id,revision_id,decision,body,submitted_at - FROM merge_request_reviews; - DROP TABLE merge_request_reviews; - ALTER TABLE merge_request_reviews_v10 RENAME TO merge_request_reviews; - - CREATE TABLE merge_request_completion_operations_v10 ( - workspace_id TEXT NOT NULL, operation_id TEXT NOT NULL, ticket_id TEXT NOT NULL, revision_id TEXT NOT NULL, - authority_kind TEXT NOT NULL CHECK(authority_kind IN ('workspace_orchestrator','legacy_assigned_coder')), - implementation_assignment_id TEXT NOT NULL, completion_actor_runtime_id TEXT, completion_actor_worker_id TEXT, - target_commit TEXT, source_commit TEXT, result_commit TEXT, - strategy TEXT CHECK(strategy IN ('fast_forward','merge')), - resolution TEXT CHECK(resolution IN ('none','clean','conflicts_resolved')), - fingerprint TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('pending','completed')), - result_ticket_state TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,operation_id), - FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id), - FOREIGN KEY(workspace_id,ticket_id,implementation_assignment_id) - REFERENCES ticket_worker_assignments(workspace_id,ticket_id,assignment_id) - ); - INSERT INTO merge_request_completion_operations_v10( - workspace_id,operation_id,ticket_id,revision_id,authority_kind,implementation_assignment_id, - completion_actor_runtime_id,completion_actor_worker_id,target_commit,source_commit,result_commit, - strategy,resolution,fingerprint,status,result_ticket_state,created_at,updated_at - ) SELECT o.workspace_id,o.operation_id,o.ticket_id,o.revision_id,o.authority_kind,o.implementation_assignment_id, - o.completion_actor_runtime_id,o.completion_actor_worker_id, - r.target_commit,r.source_commit,r.result_commit,r.strategy,r.resolution, - o.fingerprint,o.status,o.result_ticket_state,o.created_at,o.updated_at - FROM merge_request_completion_operations o - JOIN merge_request_merge_results r - ON r.workspace_id=o.workspace_id - AND r.ticket_id=o.ticket_id - AND r.revision_id=o.revision_id - WHERE o.status='completed'; - DROP TABLE merge_request_completion_operations; - ALTER TABLE merge_request_completion_operations_v10 RENAME TO merge_request_completion_operations; - - DROP TABLE merge_request_merge_results;", - ) - .map_err(db)?; - for outcome in completed_outcomes { - let fingerprint = completion_fingerprint_parts( - &outcome.ticket_id, - &outcome.revision_id, - &outcome.target_commit, - &outcome.source_commit, - &outcome.result_commit, - &outcome.strategy, - &outcome.resolution, - &outcome.implementation_assignment_id, - &outcome.completion_actor_runtime_id, - &outcome.completion_actor_worker_id, - ); - let changed = conn - .execute( - "UPDATE merge_requests - SET state='merged',merged_revision_id=?3,merged_target_commit=?4, - merged_result_commit=?5,merge_strategy=?6,merge_resolution=?7, - merged_by_runtime_id=?8,merged_by_worker_id=?9,merged_at=?10,updated_at=?10 - WHERE workspace_id=?1 AND merge_request_id=?2", - params![ - outcome.workspace_id, - outcome.merge_request_id, - outcome.revision_id, - outcome.target_commit, - outcome.result_commit, - outcome.strategy, - outcome.resolution, - outcome.completion_actor_runtime_id, - outcome.completion_actor_worker_id, - outcome.completed_at, - ], - ) - .map_err(db)?; - if changed != 1 { - return Err(MergeRequestError::Database( - "completed v9 outcome lost its merge request during migration".into(), - )); - } - let changed = conn - .execute( - "UPDATE merge_request_completion_operations - SET fingerprint=?3 - WHERE workspace_id=?1 AND operation_id=?2", - params![outcome.workspace_id, outcome.operation_id, fingerprint], - ) - .map_err(db)?; - if changed != 1 { - return Err(MergeRequestError::Database( - "completed v9 operation was not copied during migration".into(), - )); - } - } - Ok(()) -} - -fn migrate_v8_to_v10(conn: &Connection) -> Result<()> { - conn.execute_batch( - "ALTER TABLE merge_requests ADD COLUMN target_ref_selector TEXT; - ALTER TABLE merge_requests ADD COLUMN target_status TEXT NOT NULL DEFAULT 'unknown' CHECK(target_status IN ('known','unknown')); - ALTER TABLE merge_requests ADD COLUMN merged_revision_id TEXT; - ALTER TABLE merge_requests ADD COLUMN merged_target_commit TEXT; - ALTER TABLE merge_requests ADD COLUMN merged_result_commit TEXT; - ALTER TABLE merge_requests ADD COLUMN merge_strategy TEXT CHECK(merge_strategy IN ('fast_forward','merge')); - ALTER TABLE merge_requests ADD COLUMN merge_resolution TEXT CHECK(merge_resolution IN ('none','clean','conflicts_resolved')); - ALTER TABLE merge_requests ADD COLUMN merged_by_runtime_id TEXT; - ALTER TABLE merge_requests ADD COLUMN merged_by_worker_id TEXT; - - CREATE TABLE merge_request_revisions_v9 ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, - ordinal INTEGER NOT NULL, base_commit TEXT NOT NULL, head_commit TEXT NOT NULL, - diff_digest TEXT NOT NULL, summary TEXT NOT NULL, assignment_id TEXT NOT NULL, created_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,merge_request_id,revision_id), - UNIQUE(workspace_id,merge_request_id,ordinal), - FOREIGN KEY(workspace_id,merge_request_id) REFERENCES merge_requests(workspace_id,merge_request_id) ON DELETE CASCADE - ); - INSERT INTO merge_request_revisions_v9( - workspace_id,merge_request_id,revision_id,ordinal,base_commit,head_commit,diff_digest,summary,assignment_id,created_at - ) SELECT workspace_id,merge_request_id,revision_id,ordinal,base_commit,head_commit,diff_digest,summary,assignment_id,created_at - FROM merge_request_revisions; - DROP TABLE merge_request_revisions; - ALTER TABLE merge_request_revisions_v9 RENAME TO merge_request_revisions; - - CREATE TABLE merge_request_review_attempts_v9 ( - workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, ticket_id TEXT NOT NULL, - revision_id TEXT NOT NULL, lifecycle_generation INTEGER NOT NULL, - parent_assignment_id TEXT NOT NULL, parent_runtime_id TEXT NOT NULL, parent_worker_id TEXT NOT NULL, - child_session_id TEXT NOT NULL, child_effective_profile TEXT NOT NULL CHECK(child_effective_profile='builtin:reviewer'), - capability_token_sha256 TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('open','submitted','revoked')), - created_at TEXT NOT NULL, consumed_at TEXT, - PRIMARY KEY(workspace_id,attempt_id), UNIQUE(workspace_id,capability_token_sha256), UNIQUE(workspace_id,child_session_id), - FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id), - FOREIGN KEY(workspace_id,ticket_id,parent_assignment_id) REFERENCES ticket_worker_assignments(workspace_id,ticket_id,assignment_id), - FOREIGN KEY(workspace_id,child_session_id) REFERENCES merge_request_reviewer_child_sessions(workspace_id,child_session_id) - ); - INSERT INTO merge_request_review_attempts_v9( - workspace_id,attempt_id,merge_request_id,ticket_id,revision_id,lifecycle_generation, - parent_assignment_id,parent_runtime_id,parent_worker_id,child_session_id,child_effective_profile, - capability_token_sha256,status,created_at,consumed_at - ) SELECT workspace_id,attempt_id,merge_request_id,ticket_id,revision_id,lifecycle_generation, - parent_assignment_id,parent_runtime_id,parent_worker_id,child_session_id,child_effective_profile, - capability_token_sha256,status,created_at,consumed_at - FROM merge_request_review_attempts; - DROP TABLE merge_request_review_attempts; - ALTER TABLE merge_request_review_attempts_v9 RENAME TO merge_request_review_attempts; - - CREATE TABLE merge_request_completion_operations_v9 ( - workspace_id TEXT NOT NULL, operation_id TEXT NOT NULL, ticket_id TEXT NOT NULL, revision_id TEXT NOT NULL, - authority_kind TEXT NOT NULL CHECK(authority_kind IN ('workspace_orchestrator','legacy_assigned_coder')), - implementation_assignment_id TEXT NOT NULL, completion_actor_runtime_id TEXT, completion_actor_worker_id TEXT, - target_commit TEXT, source_commit TEXT, result_commit TEXT, - strategy TEXT CHECK(strategy IN ('fast_forward','merge')), - resolution TEXT CHECK(resolution IN ('none','clean','conflicts_resolved')), - fingerprint TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('pending','completed')), - result_ticket_state TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,operation_id), - FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id), - FOREIGN KEY(workspace_id,ticket_id,implementation_assignment_id) - REFERENCES ticket_worker_assignments(workspace_id,ticket_id,assignment_id) - ); - INSERT INTO merge_request_completion_operations_v9( - workspace_id,operation_id,ticket_id,revision_id,authority_kind,implementation_assignment_id, - completion_actor_runtime_id,completion_actor_worker_id,fingerprint,status,result_ticket_state,created_at,updated_at - ) SELECT workspace_id,operation_id,ticket_id,revision_id,authority_kind,implementation_assignment_id, - completion_actor_runtime_id,completion_actor_worker_id,fingerprint,status,result_ticket_state,created_at,updated_at - FROM merge_request_completion_operations; - DROP TABLE merge_request_completion_operations; - ALTER TABLE merge_request_completion_operations_v9 RENAME TO merge_request_completion_operations;", - ) - .map_err(db) -} - -fn migrate_v10_to_v11(conn: &Connection) -> Result<()> { - conn.execute_batch( - "CREATE TABLE merge_request_revisions_v11 ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, - ordinal INTEGER NOT NULL, base_commit TEXT NOT NULL, head_commit TEXT NOT NULL, - summary TEXT NOT NULL, assignment_id TEXT NOT NULL, created_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,merge_request_id,revision_id), - UNIQUE(workspace_id,merge_request_id,ordinal), - FOREIGN KEY(workspace_id,merge_request_id) REFERENCES merge_requests(workspace_id,merge_request_id) ON DELETE CASCADE - ); - INSERT INTO merge_request_revisions_v11( - workspace_id,merge_request_id,revision_id,ordinal,base_commit,head_commit,summary,assignment_id,created_at - ) SELECT workspace_id,merge_request_id,revision_id,ordinal,base_commit,head_commit,summary,assignment_id,created_at - FROM merge_request_revisions; - DROP TABLE merge_request_revisions; - ALTER TABLE merge_request_revisions_v11 RENAME TO merge_request_revisions;", - ) - .map_err(db) -} - -pub fn verify(conn: &Connection) -> Result<()> { - if !table_exists(conn, MIGRATION_TABLE)? { - return Err(MergeRequestError::Database( - "missing merge request schema version marker".into(), - )); - } - let version = schema_version(conn)?; - if version != SCHEMA_VERSION { - return Err(MergeRequestError::Database(format!( - "unsupported merge request schema version {version}; expected {SCHEMA_VERSION}" - ))); - } - verify_marker_state(conn, SCHEMA_VERSION)?; - verify_schema_shape(conn, SCHEMA_V11, "v11") -} - -fn schema_version(conn: &Connection) -> Result { - conn.query_row( - "SELECT COALESCE(MAX(version),0) FROM merge_request_schema_migrations", - [], - |row| row.get(0), - ) - .map_err(db) -} - -fn verify_marker_state(conn: &Connection, expected_version: i64) -> Result<()> { - verify_marker_table_shape(conn)?; - let state: (i64, i64, i64) = conn - .query_row( - "SELECT COUNT(*),COALESCE(MIN(version),0),COALESCE(MAX(version),0) FROM merge_request_schema_migrations", - [], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), - ) - .map_err(db)?; - if state != (1, expected_version, expected_version) { - return Err(MergeRequestError::Database(format!( - "schema drift: merge request version marker must contain only version {expected_version}" - ))); - } - Ok(()) -} - -fn verify_marker_table_shape(conn: &Connection) -> Result<()> { - let expected = Connection::open_in_memory().map_err(db)?; - expected.execute_batch(MIGRATION_TABLE_SQL).map_err(db)?; - if table_shape(conn, MIGRATION_TABLE)? != table_shape(&expected, MIGRATION_TABLE)? { - return Err(MergeRequestError::Database( - "schema drift: merge request version marker table does not match the latest contract" - .into(), - )); - } - Ok(()) -} - -fn replace_schema_marker(conn: &Connection, version: i64) -> Result<()> { - conn.execute("DELETE FROM merge_request_schema_migrations", []) - .map_err(db)?; - conn.execute( - "INSERT INTO merge_request_schema_migrations(version) VALUES (?1)", - params![version], - ) - .map_err(db)?; - Ok(()) -} - -fn ensure_foreign_key_integrity(conn: &Connection) -> Result<()> { - for table in merge_request_domain_table_names(conn)? { - let quoted = table.replace('\'', "''"); - let mut statement = conn - .prepare(&format!("PRAGMA foreign_key_check('{quoted}')")) - .map_err(db)?; - let mut rows = statement.query([]).map_err(db)?; - if let Some(row) = rows.next().map_err(db)? { - let table: String = row.get(0).map_err(db)?; - let row_id: Option = row.get(1).map_err(db)?; - let parent: String = row.get(2).map_err(db)?; - return Err(MergeRequestError::Database(format!( - "foreign key integrity check failed for table {table}, row {row_id:?}, parent {parent}" - ))); - } - } - Ok(()) -} - -fn merge_request_domain_table_names(conn: &Connection) -> Result> { - let mut statement = conn - .prepare( - "SELECT name FROM sqlite_master - WHERE type='table' - AND name LIKE 'merge_request_%' - AND name <> ?1 - ORDER BY name", - ) - .map_err(db)?; - statement - .query_map(params![MIGRATION_TABLE], |row| row.get::<_, String>(0)) - .map_err(db)? - .collect::, _>>() - .map_err(db) -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -struct ColumnShape { - cid: i64, - name: String, - data_type: String, - not_null: i64, - default_value: Option, - primary_key: i64, - hidden: i64, -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -struct ForeignKeyShape { - id: i64, - sequence: i64, - parent_table: String, - from_column: String, - to_column: Option, - on_update: String, - on_delete: String, - match_kind: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -struct IndexColumnShape { - sequence: i64, - column_id: i64, - name: Option, - descending: i64, - collation: Option, - key: i64, -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -struct IndexShape { - unique: i64, - origin: String, - partial: i64, - columns: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -struct TableShape { - name: String, - columns: Vec, - foreign_keys: Vec, - indexes: Vec, - checks: Vec, -} - -fn verify_schema_shape(conn: &Connection, expected_sql: &str, label: &str) -> Result<()> { - let expected = Connection::open_in_memory().map_err(db)?; - expected.execute_batch(expected_sql).map_err(db)?; - let expected_shape = domain_schema_shape(&expected)?; - let actual_shape = domain_schema_shape(conn)?; - if actual_shape != expected_shape { - let mismatch = expected_shape - .iter() - .zip(actual_shape.iter()) - .find(|(expected, actual)| expected != actual) - .map(|(expected, actual)| { - format!(" expected {}, observed {}", expected.name, actual.name) - }) - .unwrap_or_else(|| { - format!( - " expected {} tables, observed {}", - expected_shape.len(), - actual_shape.len() - ) - }); - return Err(MergeRequestError::Database(format!( - "schema drift: merge request {label} shape mismatch;{mismatch}" - ))); - } - Ok(()) -} - -fn domain_schema_shape(conn: &Connection) -> Result> { - let mut statement = conn - .prepare( - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'merge_request_%' AND name <> ?1 ORDER BY name", - ) - .map_err(db)?; - let names = statement - .query_map(params![MIGRATION_TABLE], |row| row.get::<_, String>(0)) - .map_err(db)? - .collect::, _>>() - .map_err(db)?; - names - .into_iter() - .map(|name| table_shape(conn, &name)) - .collect() -} - -fn table_shape(conn: &Connection, table: &str) -> Result { - let quoted = table.replace('\'', "''"); - let mut column_statement = conn - .prepare(&format!("PRAGMA table_xinfo('{quoted}')")) - .map_err(db)?; - let columns = column_statement - .query_map([], |row| { - Ok(ColumnShape { - cid: row.get(0)?, - name: row.get(1)?, - data_type: row.get(2)?, - not_null: row.get(3)?, - default_value: row.get(4)?, - primary_key: row.get(5)?, - hidden: row.get(6)?, - }) - }) - .map_err(db)? - .collect::, _>>() - .map_err(db)?; - - let mut foreign_key_statement = conn - .prepare(&format!("PRAGMA foreign_key_list('{quoted}')")) - .map_err(db)?; - let mut foreign_keys = foreign_key_statement - .query_map([], |row| { - Ok(ForeignKeyShape { - id: row.get(0)?, - sequence: row.get(1)?, - parent_table: row.get(2)?, - from_column: row.get(3)?, - to_column: row.get(4)?, - on_update: row.get(5)?, - on_delete: row.get(6)?, - match_kind: row.get(7)?, - }) - }) - .map_err(db)? - .collect::, _>>() - .map_err(db)?; - foreign_keys.sort(); - - let mut index_statement = conn - .prepare(&format!("PRAGMA index_list('{quoted}')")) - .map_err(db)?; - let index_rows = index_statement - .query_map([], |row| { - Ok(( - row.get::<_, String>(1)?, - row.get::<_, i64>(2)?, - row.get::<_, String>(3)?, - row.get::<_, i64>(4)?, - )) - }) - .map_err(db)? - .collect::, _>>() - .map_err(db)?; - let mut indexes = Vec::with_capacity(index_rows.len()); - for (index_name, unique, origin, partial) in index_rows { - let index_quoted = index_name.replace('\'', "''"); - let mut columns_statement = conn - .prepare(&format!("PRAGMA index_xinfo('{index_quoted}')")) - .map_err(db)?; - let columns = columns_statement - .query_map([], |row| { - Ok(IndexColumnShape { - sequence: row.get(0)?, - column_id: row.get(1)?, - name: row.get(2)?, - descending: row.get(3)?, - collation: row.get(4)?, - key: row.get(5)?, - }) - }) - .map_err(db)? - .collect::, _>>() - .map_err(db)?; - indexes.push(IndexShape { - unique, - origin, - partial, - columns, - }); - } - indexes.sort(); - let create_sql: String = conn - .query_row( - "SELECT sql FROM sqlite_master WHERE type='table' AND name=?1", - params![table], - |row| row.get(0), - ) - .map_err(db)?; - Ok(TableShape { - name: table.to_string(), - columns, - foreign_keys, - indexes, - checks: extract_check_constraints(&create_sql), - }) -} - -fn extract_check_constraints(sql: &str) -> Vec { - let bytes = sql.as_bytes(); - let lower = sql.to_ascii_lowercase(); - let lower_bytes = lower.as_bytes(); - let mut checks = Vec::new(); - let mut cursor = 0; - while cursor + 5 <= bytes.len() { - let Some(relative) = lower[cursor..].find("check") else { - break; + let mut conn = self.lock_conn()?; + let tx = conn.transaction()?; + let event_seq = next_event_seq_tx(&tx, &mr.workspace_id, &mr.merge_request_id)?; + let event = LifecycleEvent { + event_seq, + actor: WorkerIdentity { + runtime_id: input.auth.runtime_id, + worker_id: input.auth.worker_id, + }, + body: input.body, + created_at: input.now, }; - let start = cursor + relative; - let mut open = start + 5; - while open < bytes.len() && bytes[open].is_ascii_whitespace() { - open += 1; + insert_event_tx( + &tx, + &mr.workspace_id, + &mr.merge_request_id, + kind, + &event, + input.now, + None, + )?; + tx.execute( + "UPDATE merge_requests SET state = ?3, updated_at = ?4 + WHERE workspace_id = ?1 AND merge_request_id = ?2", + params![ + mr.workspace_id, + mr.merge_request_id, + target.as_str(), + input.now.to_rfc3339() + ], + )?; + tx.commit()?; + drop(conn); + self.get(&input.auth.workspace_id, &input.ticket_id) + } + + pub fn get( + &self, + workspace_id: &str, + ticket_id: &str, + ) -> Result { + let conn = self.lock_conn()?; + load_merge_request(&conn, workspace_id, ticket_id)?.ok_or(MergeRequestError::NotFound) + } + + pub fn list_for_ticket( + &self, + workspace_id: &str, + ticket_id: &str, + ) -> Result, MergeRequestError> { + let conn = self.lock_conn()?; + let mut stmt = conn.prepare( + "SELECT merge_request_id FROM merge_requests + WHERE workspace_id = ?1 AND ticket_id = ?2 + ORDER BY created_at DESC, merge_request_id DESC", + )?; + let ids = stmt + .query_map(params![workspace_id, ticket_id], |row| { + row.get::<_, String>(0) + })? + .collect::, _>>()?; + ids.into_iter() + .map(|id| { + load_merge_request_by_id(&conn, workspace_id, &id)? + .ok_or(MergeRequestError::NotFound) + }) + .collect() + } + + fn validate_auth( + &self, + auth: &MergeRequestAuth, + ticket_id: &str, + repository_id: &str, + ) -> Result<(), MergeRequestError> { + if auth.repository_id != repository_id { + return Err(MergeRequestError::Unauthorized( + "repository does not match request".into(), + )); } - if open >= bytes.len() || bytes[open] != b'(' { - cursor = start + 5; - continue; + if !self + .repositories + .repository_belongs_to_workspace(&auth.workspace_id, repository_id) + .map_err(MergeRequestError::Operation)? + { + return Err(MergeRequestError::Unauthorized( + "repository does not belong to workspace".into(), + )); } - let mut depth = 0_i32; - let mut quoted = false; - let mut end = open; - while end < bytes.len() { - let byte = bytes[end]; - if byte == b'\'' { - if quoted && end + 1 < bytes.len() && bytes[end + 1] == b'\'' { - end += 2; - continue; - } - quoted = !quoted; - } else if !quoted { - if byte == b'(' { - depth += 1; - } else if byte == b')' { - depth -= 1; - if depth == 0 { - end += 1; - break; - } - } + let assignment = self + .assignments + .current_assignment(&auth.workspace_id, ticket_id) + .map_err(MergeRequestError::Operation)? + .ok_or_else(|| { + MergeRequestError::Unauthorized("ticket has no current assignment".into()) + })?; + if assignment.assignment_id != auth.assignment_id + || assignment.runtime_id != auth.runtime_id + || assignment.worker_id != auth.worker_id + || assignment.ticket_id != ticket_id + { + return Err(MergeRequestError::Unauthorized( + "caller is not the current assigned worker".into(), + )); + } + Ok(()) + } + + fn validate_lifecycle_auth( + &self, + auth: &MergeRequestAuth, + repository_id: &str, + ) -> Result<(), MergeRequestError> { + if auth.repository_id != repository_id { + return Err(MergeRequestError::Unauthorized( + "repository does not match request".into(), + )); + } + if !self + .repositories + .repository_belongs_to_workspace(&auth.workspace_id, repository_id) + .map_err(MergeRequestError::Operation)? + { + return Err(MergeRequestError::Unauthorized( + "repository does not belong to workspace".into(), + )); + } + Ok(()) + } + + fn validate_completion_auth( + &self, + auth: &MergeRequestAuth, + ticket_id: &str, + repository_id: &str, + ) -> Result<(), MergeRequestError> { + if auth.repository_id != repository_id { + return Err(MergeRequestError::Unauthorized( + "repository does not match request".into(), + )); + } + if !self + .repositories + .repository_belongs_to_workspace(&auth.workspace_id, repository_id) + .map_err(MergeRequestError::Operation)? + { + return Err(MergeRequestError::Unauthorized( + "repository does not belong to workspace".into(), + )); + } + let assignment = self + .assignments + .current_assignment(&auth.workspace_id, ticket_id) + .map_err(MergeRequestError::Operation)? + .ok_or_else(|| { + MergeRequestError::Unauthorized("ticket has no current assignment".into()) + })?; + if assignment.assignment_id != auth.assignment_id || assignment.ticket_id != ticket_id { + return Err(MergeRequestError::Unauthorized( + "completion does not match the current assignment".into(), + )); + } + Ok(()) + } + + fn lock_conn(&self) -> Result, MergeRequestError> { + self.conn + .lock() + .map_err(|_| MergeRequestError::Operation("database lock poisoned".into())) + } +} + +fn validate_request(request: &RequestForReview) -> Result<(), MergeRequestError> { + validate_nonempty("base_commit", &request.base_commit)?; + validate_nonempty("head_commit", &request.head_commit)?; + if request + .changed_paths + .iter() + .any(|path| path.trim().is_empty()) + { + return Err(MergeRequestError::Validation( + "changed_paths must not contain empty entries".into(), + )); + } + Ok(()) +} + +fn validate_nonempty(field: &str, value: &str) -> Result<(), MergeRequestError> { + if value.trim().is_empty() { + Err(MergeRequestError::Validation(format!( + "{field} must not be empty" + ))) + } else { + Ok(()) + } +} + +fn validate_reviewer_profile(profile: &str) -> Result<(), MergeRequestError> { + if profile == "builtin:reviewer" { + Ok(()) + } else { + Err(MergeRequestError::Unauthorized( + "review source must use builtin:reviewer".into(), + )) + } +} + +fn ensure_open(mr: &MergeRequest) -> Result<(), MergeRequestError> { + if mr.state == MergeRequestState::Open { + Ok(()) + } else { + Err(MergeRequestError::Conflict( + "merge request is not open".into(), + )) + } +} + +fn merge_matches(existing: &MergeEvent, input: &CompleteMergeRequest) -> bool { + existing.target_commit == input.target_commit + && existing.source_commit == input.source_commit + && existing.result_commit == input.result_commit + && existing.strategy == input.strategy + && existing.resolution == input.resolution +} + +fn validate_completion_shape( + input: &CompleteMergeRequest, + request: &RequestForReviewEvent, + repositories: &dyn RepositorySource, + mr: &MergeRequest, +) -> Result<(), MergeRequestError> { + match input.strategy { + MergeStrategy::FastForward => { + if input.resolution != ConflictResolution::None + || input.target_commit != request.base_commit + || input.result_commit != input.source_commit + { + return Err(MergeRequestError::Validation( + "fast-forward completion must use the review base as target, the source as result, and resolution none".into(), + )); + } + if !repositories + .is_ancestor( + &mr.workspace_id, + &mr.repository_id, + &input.target_commit, + &input.source_commit, + ) + .map_err(MergeRequestError::Operation)? + { + return Err(MergeRequestError::Conflict( + "source commit is not a descendant of target commit".into(), + )); } - end += 1; } - if depth == 0 { - checks.push( - lower_bytes[open..end] - .iter() - .filter(|byte| !byte.is_ascii_whitespace()) - .map(|byte| *byte as char) - .collect(), - ); + MergeStrategy::Merge => { + if input.resolution == ConflictResolution::None + || input.result_commit == input.target_commit + { + return Err(MergeRequestError::Validation( + "merge completion requires an explicit resolution and a new result commit" + .into(), + )); + } + if !repositories + .is_ancestor( + &mr.workspace_id, + &mr.repository_id, + &input.target_commit, + &input.result_commit, + ) + .map_err(MergeRequestError::Operation)? + || !repositories + .is_ancestor( + &mr.workspace_id, + &mr.repository_id, + &input.source_commit, + &input.result_commit, + ) + .map_err(MergeRequestError::Operation)? + { + return Err(MergeRequestError::Conflict( + "merge result must descend from both target and source commits".into(), + )); + } } - cursor = end.max(start + 5); } - checks.sort(); - checks + Ok(()) } -fn has_merge_request_domain_tables(conn: &Connection) -> Result { - let count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name LIKE 'merge_request_%' AND name <> ?1", - params![MIGRATION_TABLE], - |row| row.get(0), - ) - .map_err(db)?; - Ok(count > 0) +fn append_ticket_completion_event_tx( + tx: &Transaction<'_>, + mr: &MergeRequest, + event: &MergeEvent, + implementation_assignment_id: &str, +) -> Result<(), MergeRequestError> { + let event_index: i64 = tx.query_row( + "SELECT COALESCE(MAX(event_index), -1) + 1 FROM typed_ticket_events + WHERE workspace_id = ?1 AND ticket_id = ?2", + params![mr.workspace_id, mr.ticket_id], + |row| row.get(0), + )?; + tx.execute( + "INSERT INTO typed_ticket_events ( + workspace_id, ticket_id, event_index, kind, author, at, + from_state, to_state, heading, body + ) VALUES (?1, ?2, ?3, 'state_changed', ?4, ?5, + 'inprogress', 'done', 'Merge Request completed', ?6)", + params![ + mr.workspace_id, + mr.ticket_id, + event_index, + format!( + "worker:{}:{}", + event.merged_by.runtime_id, event.merged_by.worker_id + ), + event.created_at.to_rfc3339(), + format!( + "Approved candidate `{}` completed implementation.", + event.source_commit + ), + ], + )?; + let event_seq = event.event_seq.to_string(); + for (key, value) in [ + ("implementation_assignment_id", implementation_assignment_id), + ("merge_request_event_seq", event_seq.as_str()), + ("merge_request_head_commit", event.source_commit.as_str()), + ("operation_id", event.operation_id.as_str()), + ("completion_authority", "workspace_orchestrator"), + ("runtime_id", event.merged_by.runtime_id.as_str()), + ("worker_id", event.merged_by.worker_id.as_str()), + ] { + tx.execute( + "INSERT INTO typed_ticket_event_attributes ( + workspace_id, ticket_id, event_index, key, value + ) VALUES (?1, ?2, ?3, ?4, ?5)", + params![mr.workspace_id, mr.ticket_id, event_index, key, value], + )?; + } + Ok(()) } -fn table_exists(conn: &Connection, table: &str) -> Result { - conn.query_row( - "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)", - params![table], - |row| row.get::<_, i64>(0), - ) - .map(|value| value != 0) - .map_err(db) +fn append_request_event_tx( + tx: &Transaction<'_>, + workspace_id: &str, + merge_request_id: &str, + auth: &MergeRequestAuth, + request: &RequestForReview, + now: DateTime, +) -> Result { + let event_seq = next_event_seq_tx(tx, workspace_id, merge_request_id)?; + let event = RequestForReviewEvent { + event_seq, + base_commit: request.base_commit.clone(), + head_commit: request.head_commit.clone(), + changed_paths: request.changed_paths.clone(), + summary: request.summary.clone(), + assignment_id: auth.assignment_id.clone(), + requested_by: WorkerIdentity { + runtime_id: auth.runtime_id.clone(), + worker_id: auth.worker_id.clone(), + }, + created_at: now, + }; + insert_event_tx( + tx, + workspace_id, + merge_request_id, + "request_for_review", + &event, + now, + None, + )?; + tx.execute( + "UPDATE merge_requests SET updated_at = ?3 + WHERE workspace_id = ?1 AND merge_request_id = ?2", + params![workspace_id, merge_request_id, now.to_rfc3339()], + )?; + tx.execute( + "UPDATE merge_request_review_capabilities SET status = 'revoked' + WHERE workspace_id = ?1 AND merge_request_id = ?2 AND status = 'issued'", + params![workspace_id, merge_request_id], + )?; + Ok(event) } -#[cfg(test)] -fn column_exists(conn: &Connection, table: &str, column: &str) -> Result { - let mut statement = conn - .prepare(&format!("PRAGMA table_info({table})")) - .map_err(db)?; - let columns = statement - .query_map([], |row| row.get::<_, String>(1)) - .map_err(db)? - .collect::, _>>() - .map_err(db)?; - Ok(columns.iter().any(|candidate| candidate == column)) +fn next_event_seq_tx( + tx: &Transaction<'_>, + workspace_id: &str, + merge_request_id: &str, +) -> Result { + let current: i64 = tx.query_row( + "SELECT COALESCE(MAX(event_seq), 0) FROM merge_request_thread_events + WHERE workspace_id = ?1 AND merge_request_id = ?2", + params![workspace_id, merge_request_id], + |row| row.get(0), + )?; + Ok((current + 1) as u64) } -const MIGRATION_TABLE: &str = "merge_request_schema_migrations"; -const MIGRATION_TABLE_SQL: &str = "CREATE TABLE merge_request_schema_migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);"; -const SCHEMA_V8: &str = r#" -CREATE TABLE merge_requests ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, - repository_id TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('draft','open','closed','merged')), - lifecycle_generation INTEGER NOT NULL, current_revision_id TEXT NOT NULL, - created_at TEXT NOT NULL, updated_at TEXT NOT NULL, merged_by_account_id TEXT, merged_at TEXT, - PRIMARY KEY(workspace_id,merge_request_id), - FOREIGN KEY(workspace_id,repository_id) REFERENCES repositories(workspace_id,repository_id) -); -CREATE TABLE merge_request_ticket_relations ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, ticket_id TEXT NOT NULL, - relation_kind TEXT NOT NULL CHECK(relation_kind='implements'), created_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,merge_request_id,ticket_id), - FOREIGN KEY(workspace_id,merge_request_id) REFERENCES merge_requests(workspace_id,merge_request_id) ON DELETE CASCADE, - FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id) ON DELETE CASCADE -); -CREATE TABLE merge_request_revisions ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, - ordinal INTEGER NOT NULL, base_commit TEXT NOT NULL, head_commit TEXT NOT NULL, head_tree TEXT NOT NULL, diff_digest TEXT NOT NULL, - summary TEXT NOT NULL, assignment_id TEXT NOT NULL, created_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,merge_request_id,revision_id), UNIQUE(workspace_id,merge_request_id,ordinal), - FOREIGN KEY(workspace_id,merge_request_id) REFERENCES merge_requests(workspace_id,merge_request_id) ON DELETE CASCADE -); -CREATE TABLE merge_request_revision_paths ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, ordinal INTEGER NOT NULL, path TEXT NOT NULL, - PRIMARY KEY(workspace_id,merge_request_id,revision_id,ordinal), - FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id) ON DELETE CASCADE -); -CREATE TABLE merge_request_reviewer_child_sessions ( - workspace_id TEXT NOT NULL, child_session_id TEXT NOT NULL, parent_runtime_id TEXT NOT NULL, - parent_worker_id TEXT NOT NULL, effective_profile TEXT NOT NULL CHECK(effective_profile='builtin:reviewer'), registered_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,child_session_id) -); -CREATE TABLE merge_request_review_attempts ( - workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, ticket_id TEXT NOT NULL, - revision_id TEXT NOT NULL, lifecycle_generation INTEGER NOT NULL, - parent_assignment_id TEXT NOT NULL, parent_runtime_id TEXT NOT NULL, parent_worker_id TEXT NOT NULL, - child_session_id TEXT NOT NULL, child_effective_profile TEXT NOT NULL CHECK(child_effective_profile='builtin:reviewer'), - capability_token_sha256 TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('open','submitted','revoked')), - created_at TEXT NOT NULL, consumed_at TEXT, - PRIMARY KEY(workspace_id,attempt_id), UNIQUE(workspace_id,capability_token_sha256), UNIQUE(workspace_id,child_session_id), - FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id), - FOREIGN KEY(workspace_id,ticket_id,parent_assignment_id) REFERENCES ticket_worker_assignments(workspace_id,ticket_id,assignment_id) -); -CREATE TABLE merge_request_reviews ( - workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, - decision TEXT NOT NULL CHECK(decision IN ('approve','request_changes')), body TEXT NOT NULL, submitted_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,attempt_id), - FOREIGN KEY(workspace_id,attempt_id) REFERENCES merge_request_review_attempts(workspace_id,attempt_id), - FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id) -); -CREATE TABLE merge_request_review_findings ( - workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, ordinal INTEGER NOT NULL, severity TEXT NOT NULL, - code TEXT, path TEXT, line INTEGER, body TEXT NOT NULL, PRIMARY KEY(workspace_id,attempt_id,ordinal), - FOREIGN KEY(workspace_id,attempt_id) REFERENCES merge_request_reviews(workspace_id,attempt_id) ON DELETE CASCADE -); -CREATE TABLE merge_request_completion_operations ( - workspace_id TEXT NOT NULL, operation_id TEXT NOT NULL, ticket_id TEXT NOT NULL, revision_id TEXT NOT NULL, - authority_kind TEXT NOT NULL CHECK(authority_kind IN ('workspace_orchestrator','legacy_assigned_coder')), - implementation_assignment_id TEXT NOT NULL, completion_actor_runtime_id TEXT, completion_actor_worker_id TEXT, - fingerprint TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('pending','completed')), - result_ticket_state TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,operation_id), - FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id) -); -"#; - -const SCHEMA_V9: &str = r#" -CREATE TABLE merge_requests ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, - repository_id TEXT NOT NULL, target_ref_selector TEXT, - target_status TEXT NOT NULL DEFAULT 'unknown' CHECK(target_status IN ('known','unknown')), - state TEXT NOT NULL CHECK(state IN ('draft','open','closed','merged')), - lifecycle_generation INTEGER NOT NULL, current_revision_id TEXT NOT NULL, - created_at TEXT NOT NULL, updated_at TEXT NOT NULL, merged_by_account_id TEXT, merged_at TEXT, - PRIMARY KEY(workspace_id,merge_request_id), - FOREIGN KEY(workspace_id,repository_id) REFERENCES repositories(workspace_id,repository_id) -); -CREATE TABLE merge_request_ticket_relations ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, ticket_id TEXT NOT NULL, - relation_kind TEXT NOT NULL CHECK(relation_kind='implements'), created_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,merge_request_id,ticket_id), - FOREIGN KEY(workspace_id,merge_request_id) REFERENCES merge_requests(workspace_id,merge_request_id) ON DELETE CASCADE, - FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id) ON DELETE CASCADE -); -CREATE TABLE merge_request_revisions ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, - ordinal INTEGER NOT NULL, base_commit TEXT NOT NULL, head_commit TEXT NOT NULL, - diff_digest TEXT NOT NULL, summary TEXT NOT NULL, assignment_id TEXT NOT NULL, created_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,merge_request_id,revision_id), - UNIQUE(workspace_id,merge_request_id,ordinal), - FOREIGN KEY(workspace_id,merge_request_id) REFERENCES merge_requests(workspace_id,merge_request_id) ON DELETE CASCADE -); -CREATE TABLE merge_request_revision_paths ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, ordinal INTEGER NOT NULL, path TEXT NOT NULL, - PRIMARY KEY(workspace_id,merge_request_id,revision_id,ordinal), - FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id) ON DELETE CASCADE -); -CREATE TABLE merge_request_reviewer_child_sessions ( - workspace_id TEXT NOT NULL, child_session_id TEXT NOT NULL, parent_runtime_id TEXT NOT NULL, - parent_worker_id TEXT NOT NULL, effective_profile TEXT NOT NULL CHECK(effective_profile='builtin:reviewer'), registered_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,child_session_id) -); -CREATE TABLE merge_request_review_attempts ( - workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, ticket_id TEXT NOT NULL, - revision_id TEXT NOT NULL, merge_result_id TEXT, lifecycle_generation INTEGER NOT NULL, - parent_assignment_id TEXT NOT NULL, parent_runtime_id TEXT NOT NULL, parent_worker_id TEXT NOT NULL, - child_session_id TEXT NOT NULL, child_effective_profile TEXT NOT NULL CHECK(child_effective_profile='builtin:reviewer'), - capability_token_sha256 TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('open','submitted','revoked')), - created_at TEXT NOT NULL, consumed_at TEXT, - PRIMARY KEY(workspace_id,attempt_id), UNIQUE(workspace_id,capability_token_sha256), UNIQUE(workspace_id,child_session_id), - FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id), - FOREIGN KEY(workspace_id,ticket_id,parent_assignment_id) REFERENCES ticket_worker_assignments(workspace_id,ticket_id,assignment_id) -); -CREATE TABLE merge_request_reviews ( - workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, - merge_result_id TEXT, decision TEXT NOT NULL CHECK(decision IN ('approve','request_changes')), body TEXT NOT NULL, submitted_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,attempt_id), - FOREIGN KEY(workspace_id,attempt_id) REFERENCES merge_request_review_attempts(workspace_id,attempt_id), - FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id) -); -CREATE TABLE merge_request_review_findings ( - workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, ordinal INTEGER NOT NULL, severity TEXT NOT NULL, - code TEXT, path TEXT, line INTEGER, body TEXT NOT NULL, - PRIMARY KEY(workspace_id,attempt_id,ordinal), - FOREIGN KEY(workspace_id,attempt_id) REFERENCES merge_request_reviews(workspace_id,attempt_id) ON DELETE CASCADE -); -CREATE TABLE merge_request_merge_results ( - workspace_id TEXT NOT NULL, merge_result_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, - ticket_id TEXT NOT NULL, revision_id TEXT NOT NULL, target_commit TEXT NOT NULL, - source_commit TEXT NOT NULL, result_commit TEXT NOT NULL, - strategy TEXT NOT NULL CHECK(strategy IN ('fast_forward','merge')), - resolution TEXT NOT NULL CHECK(resolution IN ('none','clean','conflicts_resolved')), - created_by_runtime_id TEXT NOT NULL, created_by_worker_id TEXT NOT NULL, - created_at TEXT NOT NULL, operation_id TEXT NOT NULL, operation_fingerprint TEXT NOT NULL, - validated_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,merge_result_id), UNIQUE(workspace_id,operation_id), - FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id), - FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id) -); -CREATE INDEX merge_request_merge_results_current_idx - ON merge_request_merge_results(workspace_id,merge_request_id,revision_id,target_commit,created_at); -CREATE TABLE merge_request_completion_operations ( - workspace_id TEXT NOT NULL, operation_id TEXT NOT NULL, ticket_id TEXT NOT NULL, revision_id TEXT NOT NULL, - authority_kind TEXT NOT NULL CHECK(authority_kind IN ('workspace_orchestrator','legacy_assigned_coder')), - implementation_assignment_id TEXT NOT NULL, completion_actor_runtime_id TEXT, completion_actor_worker_id TEXT, - fingerprint TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('pending','completed')), - result_ticket_state TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,operation_id), - FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id) -); -"#; - -const SCHEMA_V10: &str = r#" -CREATE TABLE merge_requests ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, - repository_id TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('draft','open','closed','merged')), - lifecycle_generation INTEGER NOT NULL, current_revision_id TEXT NOT NULL, - created_at TEXT NOT NULL, updated_at TEXT NOT NULL, merged_by_account_id TEXT, merged_at TEXT, - target_ref_selector TEXT, - target_status TEXT NOT NULL DEFAULT 'unknown' CHECK(target_status IN ('known','unknown')), - merged_revision_id TEXT, merged_target_commit TEXT, merged_result_commit TEXT, - merge_strategy TEXT CHECK(merge_strategy IN ('fast_forward','merge')), - merge_resolution TEXT CHECK(merge_resolution IN ('none','clean','conflicts_resolved')), - merged_by_runtime_id TEXT, merged_by_worker_id TEXT, - PRIMARY KEY(workspace_id,merge_request_id), - FOREIGN KEY(workspace_id,repository_id) REFERENCES repositories(workspace_id,repository_id) -); -CREATE TABLE merge_request_ticket_relations ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, ticket_id TEXT NOT NULL, - relation_kind TEXT NOT NULL CHECK(relation_kind='implements'), created_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,merge_request_id,ticket_id), - FOREIGN KEY(workspace_id,merge_request_id) REFERENCES merge_requests(workspace_id,merge_request_id) ON DELETE CASCADE, - FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id) ON DELETE CASCADE -); -CREATE TABLE merge_request_revisions ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, - ordinal INTEGER NOT NULL, base_commit TEXT NOT NULL, head_commit TEXT NOT NULL, - diff_digest TEXT NOT NULL, summary TEXT NOT NULL, assignment_id TEXT NOT NULL, created_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,merge_request_id,revision_id), UNIQUE(workspace_id,merge_request_id,ordinal), - FOREIGN KEY(workspace_id,merge_request_id) REFERENCES merge_requests(workspace_id,merge_request_id) ON DELETE CASCADE -); -CREATE TABLE merge_request_revision_paths ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, ordinal INTEGER NOT NULL, path TEXT NOT NULL, - PRIMARY KEY(workspace_id,merge_request_id,revision_id,ordinal), - FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id) ON DELETE CASCADE -); -CREATE TABLE merge_request_reviewer_child_sessions ( - workspace_id TEXT NOT NULL, child_session_id TEXT NOT NULL, parent_runtime_id TEXT NOT NULL, - parent_worker_id TEXT NOT NULL, effective_profile TEXT NOT NULL CHECK(effective_profile='builtin:reviewer'), registered_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,child_session_id) -); -CREATE TABLE merge_request_review_attempts ( - workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, ticket_id TEXT NOT NULL, - revision_id TEXT NOT NULL, lifecycle_generation INTEGER NOT NULL, - parent_assignment_id TEXT NOT NULL, parent_runtime_id TEXT NOT NULL, parent_worker_id TEXT NOT NULL, - child_session_id TEXT NOT NULL, child_effective_profile TEXT NOT NULL CHECK(child_effective_profile='builtin:reviewer'), - capability_token_sha256 TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('open','submitted','revoked')), - created_at TEXT NOT NULL, consumed_at TEXT, - PRIMARY KEY(workspace_id,attempt_id), UNIQUE(workspace_id,capability_token_sha256), UNIQUE(workspace_id,child_session_id), - FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id), - FOREIGN KEY(workspace_id,ticket_id,parent_assignment_id) REFERENCES ticket_worker_assignments(workspace_id,ticket_id,assignment_id), - FOREIGN KEY(workspace_id,child_session_id) REFERENCES merge_request_reviewer_child_sessions(workspace_id,child_session_id) -); -CREATE TABLE merge_request_reviews ( - workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, - decision TEXT NOT NULL CHECK(decision IN ('approve','request_changes')), body TEXT NOT NULL, submitted_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,attempt_id), - FOREIGN KEY(workspace_id,attempt_id) REFERENCES merge_request_review_attempts(workspace_id,attempt_id), - FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id) -); -CREATE TABLE merge_request_review_findings ( - workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, ordinal INTEGER NOT NULL, severity TEXT NOT NULL, - code TEXT, path TEXT, line INTEGER, body TEXT NOT NULL, PRIMARY KEY(workspace_id,attempt_id,ordinal), - FOREIGN KEY(workspace_id,attempt_id) REFERENCES merge_request_reviews(workspace_id,attempt_id) ON DELETE CASCADE -); -CREATE TABLE merge_request_completion_operations ( - workspace_id TEXT NOT NULL, operation_id TEXT NOT NULL, ticket_id TEXT NOT NULL, revision_id TEXT NOT NULL, - authority_kind TEXT NOT NULL CHECK(authority_kind IN ('workspace_orchestrator','legacy_assigned_coder')), - implementation_assignment_id TEXT NOT NULL, completion_actor_runtime_id TEXT, completion_actor_worker_id TEXT, - target_commit TEXT, source_commit TEXT, result_commit TEXT, - strategy TEXT CHECK(strategy IN ('fast_forward','merge')), - resolution TEXT CHECK(resolution IN ('none','clean','conflicts_resolved')), - fingerprint TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('pending','completed')), - result_ticket_state TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,operation_id), - FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id), - FOREIGN KEY(workspace_id,ticket_id,implementation_assignment_id) - REFERENCES ticket_worker_assignments(workspace_id,ticket_id,assignment_id) -); -"#; - -const SCHEMA_V11: &str = r#" -CREATE TABLE merge_requests ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, - repository_id TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('draft','open','closed','merged')), - lifecycle_generation INTEGER NOT NULL, current_revision_id TEXT NOT NULL, - created_at TEXT NOT NULL, updated_at TEXT NOT NULL, merged_by_account_id TEXT, merged_at TEXT, - target_ref_selector TEXT, - target_status TEXT NOT NULL DEFAULT 'unknown' CHECK(target_status IN ('known','unknown')), - merged_revision_id TEXT, merged_target_commit TEXT, merged_result_commit TEXT, - merge_strategy TEXT CHECK(merge_strategy IN ('fast_forward','merge')), - merge_resolution TEXT CHECK(merge_resolution IN ('none','clean','conflicts_resolved')), - merged_by_runtime_id TEXT, merged_by_worker_id TEXT, - PRIMARY KEY(workspace_id,merge_request_id), - FOREIGN KEY(workspace_id,repository_id) REFERENCES repositories(workspace_id,repository_id) -); -CREATE TABLE merge_request_ticket_relations ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, ticket_id TEXT NOT NULL, - relation_kind TEXT NOT NULL CHECK(relation_kind='implements'), created_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,merge_request_id,ticket_id), - FOREIGN KEY(workspace_id,merge_request_id) REFERENCES merge_requests(workspace_id,merge_request_id) ON DELETE CASCADE, - FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id) ON DELETE CASCADE -); -CREATE TABLE merge_request_revisions ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, - ordinal INTEGER NOT NULL, base_commit TEXT NOT NULL, head_commit TEXT NOT NULL, - summary TEXT NOT NULL, assignment_id TEXT NOT NULL, created_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,merge_request_id,revision_id), UNIQUE(workspace_id,merge_request_id,ordinal), - FOREIGN KEY(workspace_id,merge_request_id) REFERENCES merge_requests(workspace_id,merge_request_id) ON DELETE CASCADE -); -CREATE TABLE merge_request_revision_paths ( - workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, ordinal INTEGER NOT NULL, path TEXT NOT NULL, - PRIMARY KEY(workspace_id,merge_request_id,revision_id,ordinal), - FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id) ON DELETE CASCADE -); -CREATE TABLE merge_request_reviewer_child_sessions ( - workspace_id TEXT NOT NULL, child_session_id TEXT NOT NULL, parent_runtime_id TEXT NOT NULL, - parent_worker_id TEXT NOT NULL, effective_profile TEXT NOT NULL CHECK(effective_profile='builtin:reviewer'), registered_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,child_session_id) -); -CREATE TABLE merge_request_review_attempts ( - workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, ticket_id TEXT NOT NULL, - revision_id TEXT NOT NULL, lifecycle_generation INTEGER NOT NULL, - parent_assignment_id TEXT NOT NULL, parent_runtime_id TEXT NOT NULL, parent_worker_id TEXT NOT NULL, - child_session_id TEXT NOT NULL, child_effective_profile TEXT NOT NULL CHECK(child_effective_profile='builtin:reviewer'), - capability_token_sha256 TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('open','submitted','revoked')), - created_at TEXT NOT NULL, consumed_at TEXT, - PRIMARY KEY(workspace_id,attempt_id), UNIQUE(workspace_id,capability_token_sha256), UNIQUE(workspace_id,child_session_id), - FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id), - FOREIGN KEY(workspace_id,ticket_id,parent_assignment_id) REFERENCES ticket_worker_assignments(workspace_id,ticket_id,assignment_id), - FOREIGN KEY(workspace_id,child_session_id) REFERENCES merge_request_reviewer_child_sessions(workspace_id,child_session_id) -); -CREATE TABLE merge_request_reviews ( - workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, merge_request_id TEXT NOT NULL, revision_id TEXT NOT NULL, - decision TEXT NOT NULL CHECK(decision IN ('approve','request_changes')), body TEXT NOT NULL, submitted_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,attempt_id), - FOREIGN KEY(workspace_id,attempt_id) REFERENCES merge_request_review_attempts(workspace_id,attempt_id), - FOREIGN KEY(workspace_id,merge_request_id,revision_id) REFERENCES merge_request_revisions(workspace_id,merge_request_id,revision_id) -); -CREATE TABLE merge_request_review_findings ( - workspace_id TEXT NOT NULL, attempt_id TEXT NOT NULL, ordinal INTEGER NOT NULL, severity TEXT NOT NULL, - code TEXT, path TEXT, line INTEGER, body TEXT NOT NULL, PRIMARY KEY(workspace_id,attempt_id,ordinal), - FOREIGN KEY(workspace_id,attempt_id) REFERENCES merge_request_reviews(workspace_id,attempt_id) ON DELETE CASCADE -); -CREATE TABLE merge_request_completion_operations ( - workspace_id TEXT NOT NULL, operation_id TEXT NOT NULL, ticket_id TEXT NOT NULL, revision_id TEXT NOT NULL, - authority_kind TEXT NOT NULL CHECK(authority_kind IN ('workspace_orchestrator','legacy_assigned_coder')), - implementation_assignment_id TEXT NOT NULL, completion_actor_runtime_id TEXT, completion_actor_worker_id TEXT, - target_commit TEXT, source_commit TEXT, result_commit TEXT, - strategy TEXT CHECK(strategy IN ('fast_forward','merge')), - resolution TEXT CHECK(resolution IN ('none','clean','conflicts_resolved')), - fingerprint TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('pending','completed')), - result_ticket_state TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,operation_id), - FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id), - FOREIGN KEY(workspace_id,ticket_id,implementation_assignment_id) - REFERENCES ticket_worker_assignments(workspace_id,ticket_id,assignment_id) -); -"#; - -#[cfg(test)] -mod migration_tests { - use super::*; - - const SUPPORT_SCHEMA: &str = r#" -CREATE TABLE repositories(workspace_id TEXT NOT NULL,repository_id TEXT NOT NULL,PRIMARY KEY(workspace_id,repository_id)); -CREATE TABLE typed_tickets(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,workflow_state TEXT NOT NULL,workflow_state_explicit INTEGER NOT NULL DEFAULT 1,updated_at TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id)); -CREATE TABLE ticket_worker_assignments(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,assignment_id TEXT NOT NULL,runtime_id TEXT NOT NULL,worker_id TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id,assignment_id)); -"#; - - fn fresh_connection() -> Connection { - let conn = Connection::open_in_memory().unwrap(); - conn.execute_batch(SUPPORT_SCHEMA).unwrap(); - conn - } - - fn exact_v8_connection() -> Connection { - let conn = fresh_connection(); - conn.execute_batch(MIGRATION_TABLE_SQL).unwrap(); - conn.execute( - "INSERT INTO merge_request_schema_migrations(version) VALUES(8)", - [], - ) - .unwrap(); - conn.execute_batch(SCHEMA_V8).unwrap(); - conn.execute_batch( - "INSERT INTO repositories VALUES('ws','repo'); - INSERT INTO typed_tickets VALUES('ws','T1','inprogress',1,'t0'); - INSERT INTO ticket_worker_assignments VALUES('ws','T1','A1','R1','W1'); - INSERT INTO merge_requests VALUES('ws','MR1','repo','open',3,'V1','t0','t1',NULL,NULL); - INSERT INTO merge_request_ticket_relations VALUES('ws','MR1','T1','implements','t0'); - INSERT INTO merge_request_revisions VALUES('ws','MR1','V1',1,'base','head','legacy-tree','digest','summary','A1','t0'); - INSERT INTO merge_request_revision_paths VALUES('ws','MR1','V1',0,'src/lib.rs'); - INSERT INTO merge_request_reviewer_child_sessions VALUES('ws','C1','R1','W1','builtin:reviewer','t0'); - INSERT INTO merge_request_review_attempts VALUES('ws','AT1','MR1','T1','V1',3,'A1','R1','W1','C1','builtin:reviewer','token','submitted','t0','t1'); - INSERT INTO merge_request_reviews VALUES('ws','AT1','MR1','V1','approve','approved','t1'); - INSERT INTO merge_request_review_findings VALUES('ws','AT1',0,'warning','C','src/lib.rs',7,'finding'); - INSERT INTO merge_request_completion_operations VALUES('ws','OP1','T1','V1','workspace_orchestrator','A1','R1','W1','fp','pending',NULL,'t0','t1');", - ).unwrap(); - conn - } - - fn exact_v9_connection() -> Connection { - let conn = fresh_connection(); - conn.execute_batch(MIGRATION_TABLE_SQL).unwrap(); - conn.execute_batch( - "INSERT INTO merge_request_schema_migrations(version) VALUES(1),(2),(3),(4),(5),(9);", - ) - .unwrap(); - conn.execute_batch(SCHEMA_V9).unwrap(); - conn.execute_batch( - "CREATE TABLE merge_request_ticket_links(obsolete TEXT); - INSERT INTO repositories VALUES('ws','repo'); - INSERT INTO typed_tickets VALUES('ws','T1','done',1,'t2'); - INSERT INTO ticket_worker_assignments VALUES('ws','T1','A1','R1','W1'); - INSERT INTO merge_requests VALUES('ws','MR1','repo','refs/heads/main','known','open',3,'V1','t0','t1',NULL,NULL); - INSERT INTO merge_request_ticket_relations VALUES('ws','MR1','T1','implements','t0'); - INSERT INTO merge_request_revisions VALUES('ws','MR1','V1',1,'base','head','digest','summary','A1','t0'); - INSERT INTO merge_request_revision_paths VALUES('ws','MR1','V1',0,'src/lib.rs'); - INSERT INTO merge_request_reviewer_child_sessions VALUES('ws','C1','R1','W1','builtin:reviewer','t0'); - INSERT INTO merge_request_review_attempts VALUES('ws','AT1','MR1','T1','V1',NULL,3,'A1','R1','W1','C1','builtin:reviewer','token','submitted','t0','t1'); - INSERT INTO merge_request_reviews VALUES('ws','AT1','MR1','V1',NULL,'approve','approved','t1'); - INSERT INTO merge_request_review_findings VALUES('ws','AT1',0,'warning','C','src/lib.rs',7,'finding'); - INSERT INTO merge_request_merge_results VALUES('ws','M1','MR1','T1','V1','base','head','head','fast_forward','none','R1','W1','t1','record-result','result-fp','t1'); - INSERT INTO merge_request_completion_operations VALUES('ws','OP1','T1','V1','workspace_orchestrator','A1','R1','W1','old-fp','completed','done','t0','t2');", - ) - .unwrap(); - conn - } - - fn exact_v10_connection() -> Connection { - let conn = fresh_connection(); - conn.execute_batch(MIGRATION_TABLE_SQL).unwrap(); - conn.execute( - "INSERT INTO merge_request_schema_migrations(version,applied_at) VALUES(10,'t0')", - [], - ) - .unwrap(); - conn.execute_batch(SCHEMA_V10).unwrap(); - conn.execute_batch( - "INSERT INTO repositories VALUES('ws','repo'); - INSERT INTO typed_tickets VALUES('ws','T1','inprogress',1,'t0'); - INSERT INTO ticket_worker_assignments VALUES('ws','T1','A1','R1','W1'); - INSERT INTO merge_requests VALUES('ws','MR1','repo','open',1,'V1','t0','t0',NULL,NULL,'refs/heads/main','known',NULL,NULL,NULL,NULL,NULL,NULL,NULL); - INSERT INTO merge_request_ticket_relations VALUES('ws','MR1','T1','implements','t0'); - INSERT INTO merge_request_revisions VALUES('ws','MR1','V1',1,'base','head','digest','summary','A1','t0'); - INSERT INTO merge_request_revision_paths VALUES('ws','MR1','V1',0,'src/lib.rs'); - INSERT INTO merge_request_reviewer_child_sessions VALUES('ws','C1','R1','W1','builtin:reviewer','t0'); - INSERT INTO merge_request_review_attempts VALUES('ws','AT1','MR1','T1','V1',1,'A1','R1','W1','C1','builtin:reviewer','token','submitted','t0','t1'); - INSERT INTO merge_request_reviews VALUES('ws','AT1','MR1','V1','approve','approved','t1');", - ) - .unwrap(); - conn - } - - fn marker_version(conn: &Connection) -> i64 { - conn.query_row( - "SELECT MAX(version) FROM merge_request_schema_migrations", - [], - |row| row.get(0), - ) - .unwrap() - } - - #[test] - fn fresh_database_materializes_final_merge_evidence_contract() { - let conn = fresh_connection(); - migrate(&conn).unwrap(); - verify(&conn).unwrap(); - assert_eq!(marker_version(&conn), 11); - for column in [ - "target_ref_selector", - "merged_revision_id", - "merged_target_commit", - "merged_result_commit", - "merge_strategy", - "merge_resolution", - "merged_by_runtime_id", - "merged_by_worker_id", - ] { - assert!( - column_exists(&conn, "merge_requests", column).unwrap(), - "missing {column}" - ); - } - assert!(!column_exists(&conn, "merge_request_revisions", "head_tree").unwrap()); - assert!(!column_exists(&conn, "merge_request_revisions", "diff_digest").unwrap()); - assert!(!table_exists(&conn, "merge_request_merge_results").unwrap()); - } - - #[test] - fn exact_v8_migrates_preserving_review_and_operation_evidence() { - let conn = exact_v8_connection(); - migrate(&conn).unwrap(); - verify(&conn).unwrap(); - assert_eq!(marker_version(&conn), 11); - assert_eq!( - conn.query_row( - "SELECT head_commit FROM merge_request_revisions WHERE revision_id='V1'", - [], - |row| row.get::<_, String>(0) - ) - .unwrap(), - "head" - ); - assert_eq!( - conn.query_row( - "SELECT decision FROM merge_request_reviews WHERE attempt_id='AT1'", - [], - |row| row.get::<_, String>(0) - ) - .unwrap(), - "approve" - ); - assert_eq!( - conn.query_row( - "SELECT target_ref_selector FROM merge_requests WHERE merge_request_id='MR1'", - [], - |row| row.get::<_, Option>(0) - ) - .unwrap(), - None - ); - assert_eq!(conn.query_row("SELECT result_commit FROM merge_request_completion_operations WHERE operation_id='OP1'", [], |row| row.get::<_,Option>(0)).unwrap(), None); - assert_eq!( - conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { - row.get::<_, i64>(0) - }) - .unwrap(), - 0 - ); - } - - #[test] - fn exact_v10_migrates_revision_evidence_without_diff_digest() { - let conn = exact_v10_connection(); - migrate(&conn).unwrap(); - verify(&conn).unwrap(); - - assert_eq!(marker_version(&conn), 11); - assert!(!column_exists(&conn, "merge_request_revisions", "diff_digest").unwrap()); - assert_eq!( - conn.query_row( - "SELECT base_commit,head_commit FROM merge_request_revisions WHERE revision_id='V1'", - [], - |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), - ) - .unwrap(), - ("base".to_string(), "head".to_string()) - ); - assert_eq!( - conn.query_row( - "SELECT path FROM merge_request_revision_paths WHERE revision_id='V1'", - [], - |row| row.get::<_, String>(0), - ) - .unwrap(), - "src/lib.rs" - ); - assert_eq!( - conn.query_row( - "SELECT decision FROM merge_request_reviews WHERE attempt_id='AT1'", - [], - |row| row.get::<_, String>(0), - ) - .unwrap(), - "approve" - ); - assert_eq!( - conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { - row.get::<_, i64>(0) - }) - .unwrap(), - 0 - ); - } - - #[test] - fn v10_to_v11_failure_rolls_back_schema_data_and_marker() { - let conn = exact_v10_connection(); - let error = migrate_with_failpoint(&conn, true).unwrap_err(); - assert!( - error - .to_string() - .contains("forced v10 to v11 migration failure") - ); - assert_eq!(marker_version(&conn), 10); - assert!(column_exists(&conn, "merge_request_revisions", "diff_digest").unwrap()); - assert_eq!( - conn.query_row( - "SELECT diff_digest FROM merge_request_revisions WHERE revision_id='V1'", - [], - |row| row.get::<_, String>(0), - ) - .unwrap(), - "digest" - ); - verify_schema_shape(&conn, SCHEMA_V10, "v10 after rollback").unwrap(); - } - - #[test] - fn v8_to_v11_failure_rolls_back_schema_data_and_marker() { - let conn = exact_v8_connection(); - let error = migrate_with_failpoint(&conn, true).unwrap_err(); - assert!( - error - .to_string() - .contains("forced v8 to v11 migration failure") - ); - assert_eq!(marker_version(&conn), 8); - assert!(column_exists(&conn, "merge_request_revisions", "head_tree").unwrap()); - assert!(!column_exists(&conn, "merge_requests", "merged_result_commit").unwrap()); - verify_schema_shape(&conn, SCHEMA_V8, "v8 after rollback").unwrap(); - } - - #[test] - fn drifted_v8_fails_closed_without_mutation() { - let conn = exact_v8_connection(); - conn.execute_batch("ALTER TABLE merge_requests ADD COLUMN drift TEXT;") - .unwrap(); - let error = migrate(&conn).unwrap_err(); - assert!( - error - .to_string() - .contains("schema drift at merge request version 8") - ); - assert_eq!(marker_version(&conn), 8); - assert!(!column_exists(&conn, "merge_requests", "merged_result_commit").unwrap()); - } - - #[test] - fn unrelated_foreign_key_mismatch_does_not_block_merge_request_migration() { - let conn = exact_v8_connection(); - conn.execute_batch( - "CREATE TABLE unrelated_parent( - left_id TEXT NOT NULL, - right_id TEXT NOT NULL, - PRIMARY KEY(left_id,right_id) - ); - CREATE TABLE unrelated_child( - left_id TEXT REFERENCES unrelated_parent(left_id) - );", - ) - .unwrap(); - let error = conn - .query_row("PRAGMA foreign_key_check", [], |_| Ok(())) - .unwrap_err(); - assert!(error.to_string().contains("foreign key mismatch")); - - migrate(&conn).unwrap(); - verify(&conn).unwrap(); - assert_eq!(marker_version(&conn), 11); - assert!(column_exists(&conn, "merge_requests", "merged_result_commit").unwrap()); - } - - #[test] - fn versions_older_than_v8_are_rejected() { - let conn = fresh_connection(); - conn.execute_batch(MIGRATION_TABLE_SQL).unwrap(); - conn.execute( - "INSERT INTO merge_request_schema_migrations(version) VALUES(7)", - [], - ) - .unwrap(); - let error = migrate(&conn).unwrap_err(); - assert!( - error - .to_string() - .contains("only supports exact v8, v9, or v10 to v11") - ); - assert_eq!(marker_version(&conn), 7); - } - - #[test] - fn exact_v9_with_historical_markers_migrates_to_single_v11_marker() { - let conn = exact_v9_connection(); - migrate(&conn).unwrap(); - verify(&conn).unwrap(); - assert_eq!(marker_version(&conn), 11); - assert_eq!( - conn.query_row( - "SELECT COUNT(*) FROM merge_request_schema_migrations", - [], - |row| row.get::<_, i64>(0) - ) - .unwrap(), - 1 - ); - assert_eq!( - conn.query_row( - "SELECT state FROM merge_requests WHERE merge_request_id='MR1'", - [], - |row| row.get::<_, String>(0) - ) - .unwrap(), - "merged" - ); - assert_eq!( - conn.query_row( - "SELECT merged_revision_id,merged_target_commit,merged_result_commit,merge_strategy,merge_resolution - FROM merge_requests WHERE merge_request_id='MR1'", - [], - |row| Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - row.get::<_, String>(4)?, - )) - ) - .unwrap(), - ( - "V1".into(), - "base".into(), - "head".into(), - "fast_forward".into(), - "none".into() - ) - ); - assert_eq!( - conn.query_row( - "SELECT fingerprint FROM merge_request_completion_operations WHERE operation_id='OP1'", - [], - |row| row.get::<_, String>(0) - ) - .unwrap(), - completion_fingerprint_parts( - "T1", "V1", "base", "head", "head", "fast_forward", "none", "A1", "R1", - "W1" - ) - ); - assert!(!table_exists(&conn, "merge_request_ticket_links").unwrap()); - assert!(!table_exists(&conn, "merge_request_merge_results").unwrap()); - } +fn insert_event_tx( + tx: &Transaction<'_>, + workspace_id: &str, + merge_request_id: &str, + kind: &str, + payload: &T, + now: DateTime, + operation_id: Option<&str>, +) -> Result<(), MergeRequestError> { + let event_seq = serde_json::to_value(payload) + .map_err(|error| MergeRequestError::Operation(error.to_string()))? + .get("event_seq") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| MergeRequestError::Operation("event payload has no event_seq".into()))?; + let payload_json = serde_json::to_string(payload) + .map_err(|error| MergeRequestError::Operation(error.to_string()))?; + tx.execute( + "INSERT INTO merge_request_thread_events ( + workspace_id, merge_request_id, event_seq, kind, payload_json, + operation_id, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + workspace_id, + merge_request_id, + event_seq as i64, + kind, + payload_json, + operation_id, + now.to_rfc3339() + ], + )?; + Ok(()) } fn load_merge_request( conn: &Connection, workspace_id: &str, ticket_id: &str, -) -> Result> { - type Row = ( - String, - String, - String, - Option, - String, - String, - i64, - String, - String, - String, - Option, - Option, - Option, - Option, - Option, - Option, - Option, - Option, - ); - let row: Option = conn.query_row( - "SELECT mr.merge_request_id,rel.ticket_id,mr.repository_id,mr.target_ref_selector,mr.target_status,mr.state,mr.lifecycle_generation,mr.current_revision_id,mr.created_at,mr.updated_at,mr.merged_revision_id,mr.merged_target_commit,mr.merged_result_commit,mr.merge_strategy,mr.merge_resolution,mr.merged_by_runtime_id,mr.merged_by_worker_id,mr.merged_at FROM merge_requests mr JOIN merge_request_ticket_relations rel ON rel.workspace_id=mr.workspace_id AND rel.merge_request_id=mr.merge_request_id WHERE mr.workspace_id=?1 AND rel.ticket_id=?2 AND rel.relation_kind='implements' ORDER BY mr.updated_at DESC,mr.merge_request_id DESC LIMIT 1", - params![workspace_id,ticket_id], - |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?,r.get(6)?,r.get(7)?,r.get(8)?,r.get(9)?,r.get(10)?,r.get(11)?,r.get(12)?,r.get(13)?,r.get(14)?,r.get(15)?,r.get(16)?,r.get(17)?)), - ).optional().map_err(db)?; - let Some(( - mr_id, - ticket_id, - repository_id, - target_ref_selector, - target_status, - state, - generation, - revision_id, - created_at, - updated_at, - merged_revision_id, - merged_target_commit, - merged_result_commit, - merge_strategy, - merge_resolution, - merged_by_runtime_id, - merged_by_worker_id, - merged_at, - )) = row - else { - return Ok(None); - }; - let revision = load_revision(conn, workspace_id, &mr_id, &revision_id)?; - let current_review = load_latest_review(conn, workspace_id, &mr_id, &revision_id, generation)?; - let review_status = match current_review.as_ref().map(|review| review.decision) { - Some(ReviewDecision::Approve) => ReviewStatus::Approved, - Some(ReviewDecision::RequestChanges) => ReviewStatus::ChangesRequested, - None => ReviewStatus::Pending, - }; - Ok(Some(MergeRequest { - merge_request_id: mr_id, - workspace_id: workspace_id.into(), - ticket_id, - repository_id, - target_ref_selector, - target_status: MergeRequestTargetStatus::parse(&target_status), - observed_target_commit: None, - state: MergeRequestState::parse(&state), - lifecycle_generation: generation as u64, - current_revision: revision, - review_status, - current_review, - created_at, - updated_at, - merged_revision_id, - merged_target_commit, - merged_result_commit, - merge_strategy: merge_strategy.as_deref().map(MergeStrategy::parse), - merge_resolution: merge_resolution.as_deref().map(MergeResolution::parse), - merged_by_runtime_id, - merged_by_worker_id, - merged_at, - })) -} - -fn load_revision( - conn: &Connection, - workspace_id: &str, - mr_id: &str, - revision_id: &str, -) -> Result { - let mut revision: MergeRequestRevision = conn.query_row( - "SELECT revision_id,ordinal,base_commit,head_commit,summary,assignment_id,created_at FROM merge_request_revisions WHERE workspace_id=?1 AND merge_request_id=?2 AND revision_id=?3", - params![workspace_id,mr_id,revision_id], |r| Ok(MergeRequestRevision { revision_id:r.get(0)?, ordinal:r.get::<_,i64>(1)? as u64, base_commit:r.get(2)?, head_commit:r.get(3)?, changed_paths:Vec::new(), summary:r.get(4)?, assignment_id:r.get(5)?, created_at:r.get(6)? }), - ).map_err(db)?; - let mut statement = conn.prepare("SELECT path FROM merge_request_revision_paths WHERE workspace_id=?1 AND merge_request_id=?2 AND revision_id=?3 ORDER BY ordinal").map_err(db)?; - revision.changed_paths = statement - .query_map(params![workspace_id, mr_id, revision_id], |r| r.get(0)) - .map_err(db)? - .collect::, _>>() - .map_err(db)?; - Ok(revision) -} - -fn insert_revision( - conn: &Connection, - workspace_id: &str, - mr_id: &str, - revision: &MergeRequestRevision, -) -> Result<()> { - conn.execute("INSERT INTO merge_request_revisions (workspace_id,merge_request_id,revision_id,ordinal,base_commit,head_commit,summary,assignment_id,created_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)", params![workspace_id,mr_id,revision.revision_id,revision.ordinal as i64,revision.base_commit,revision.head_commit,revision.summary,revision.assignment_id,revision.created_at]).map_err(db)?; - for (ordinal, path) in revision.changed_paths.iter().enumerate() { - conn.execute("INSERT INTO merge_request_revision_paths (workspace_id,merge_request_id,revision_id,ordinal,path) VALUES (?1,?2,?3,?4,?5)", params![workspace_id,mr_id,revision.revision_id,ordinal as i64,path]).map_err(db)?; - } - Ok(()) -} - -fn load_latest_review( - conn: &Connection, - workspace_id: &str, - mr_id: &str, - revision_id: &str, - generation: i64, -) -> Result> { - let attempt: Option = conn.query_row("SELECT r.attempt_id FROM merge_request_reviews r JOIN merge_request_review_attempts a ON a.workspace_id=r.workspace_id AND a.attempt_id=r.attempt_id WHERE r.workspace_id=?1 AND r.merge_request_id=?2 AND r.revision_id=?3 AND a.lifecycle_generation=?4 ORDER BY r.submitted_at DESC, r.attempt_id DESC LIMIT 1", params![workspace_id,mr_id,revision_id,generation], |r| r.get(0)).optional().map_err(db)?; - match attempt { - Some(id) => load_review(conn, workspace_id, &id), - None => Ok(None), - } -} - -fn load_review( - conn: &Connection, - workspace_id: &str, - attempt_id: &str, -) -> Result> { - let row: Option<(String,String,String,String,String,String,String,String,String)> = conn.query_row( - "SELECT r.revision_id,r.decision,r.body,a.parent_assignment_id,a.parent_runtime_id,a.parent_worker_id,a.child_session_id,a.child_effective_profile,r.submitted_at FROM merge_request_reviews r JOIN merge_request_review_attempts a ON a.workspace_id=r.workspace_id AND a.attempt_id=r.attempt_id WHERE r.workspace_id=?1 AND r.attempt_id=?2", - params![workspace_id,attempt_id], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?,r.get(6)?,r.get(7)?,r.get(8)?)), - ).optional().map_err(db)?; - let Some(( - revision_id, - decision, - body, - assignment, - runtime, - worker, - child, - profile, - submitted_at, - )) = row - else { - return Ok(None); - }; - let mut stmt=conn.prepare("SELECT severity,code,path,line,body FROM merge_request_review_findings WHERE workspace_id=?1 AND attempt_id=?2 ORDER BY ordinal").map_err(db)?; - let findings = stmt - .query_map(params![workspace_id, attempt_id], |r| { - Ok(ReviewFinding { - severity: r.get(0)?, - code: r.get(1)?, - path: r.get(2)?, - line: r.get::<_, Option>(3)?.map(|v| v as u64), - body: r.get(4)?, - }) - }) - .map_err(db)? - .collect::, _>>() - .map_err(db)?; - Ok(Some(MergeRequestReview { - attempt_id: attempt_id.into(), - revision_id, - decision: ReviewDecision::parse(&decision), - body, - findings, - parent_assignment_id: assignment, - parent_runtime_id: runtime, - parent_worker_id: worker, - reviewer_child_session_id: child, - reviewer_effective_profile: profile, - submitted_at, - })) -} - -fn validate_current_implementation_assignment( - conn: &Connection, - workspace_id: &str, - ticket_id: &str, - assignment_id: &str, -) -> Result<()> { - let current: Option = conn +) -> Result, MergeRequestError> { + let id: Option = conn .query_row( - "SELECT assignment_id FROM ticket_current_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2", + "SELECT merge_request_id FROM merge_requests + WHERE workspace_id = ?1 AND ticket_id = ?2 + ORDER BY CASE state WHEN 'open' THEN 0 ELSE 1 END, created_at DESC + LIMIT 1", params![workspace_id, ticket_id], |row| row.get(0), ) - .optional() - .map_err(db)?; - if current.as_deref() != Some(assignment_id) { - return Err(MergeRequestError::AssignmentMismatch); - } - Ok(()) + .optional()?; + id.map(|id| load_merge_request_by_id(conn, workspace_id, &id)) + .transpose() + .map(Option::flatten) } -fn validate_current_assignment( - conn: &Connection, +fn load_merge_request_tx( + tx: &Transaction<'_>, workspace_id: &str, ticket_id: &str, - assignment_id: &str, - runtime_id: &str, - worker_id: &str, -) -> Result<()> { - let valid: Option = conn.query_row("SELECT 1 FROM ticket_current_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3 AND runtime_id=?4 AND worker_id=?5", params![workspace_id,ticket_id,assignment_id,runtime_id,worker_id], |r| r.get(0)).optional().map_err(db)?; - if valid.is_none() { - return Err(MergeRequestError::AssignmentMismatch); - } - Ok(()) +) -> Result, MergeRequestError> { + let id: Option = tx + .query_row( + "SELECT merge_request_id FROM merge_requests + WHERE workspace_id = ?1 AND ticket_id = ?2 AND state = 'open' LIMIT 1", + params![workspace_id, ticket_id], + |row| row.get(0), + ) + .optional()?; + id.map(|id| load_merge_request_by_id(tx, workspace_id, &id)) + .transpose() + .map(Option::flatten) } -fn append_completion_event( +fn load_merge_request_by_id( conn: &Connection, workspace_id: &str, - input: &CompleteMergeRequest, -) -> Result<()> { - let index:i64=conn.query_row("SELECT COALESCE(MAX(event_index),-1)+1 FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2",params![workspace_id,input.ticket_id],|r|r.get(0)).map_err(db)?; - conn.execute("INSERT INTO typed_ticket_events (workspace_id,ticket_id,event_index,kind,author,at,from_state,to_state,heading,body) VALUES (?1,?2,?3,'state_changed',?4,?5,'inprogress','done','Merge Request completed',?6)",params![workspace_id,input.ticket_id,index,format!("worker:{}:{}",input.completion_actor_runtime_id,input.completion_actor_worker_id),input.now,format!("Approved immutable revision `{}` completed implementation.",input.expected_revision_id)]).map_err(db)?; - for (key, value) in [ - ( - "implementation_assignment_id", - input.implementation_assignment_id.as_str(), - ), - ( - "merge_request_revision_id", - input.expected_revision_id.as_str(), - ), - ("operation_id", input.operation_id.as_str()), - ("completion_authority", "workspace_orchestrator"), - ("runtime_id", input.completion_actor_runtime_id.as_str()), - ("worker_id", input.completion_actor_worker_id.as_str()), - ] { - conn.execute("INSERT INTO typed_ticket_event_attributes (workspace_id,ticket_id,event_index,key,value) VALUES (?1,?2,?3,?4,?5)",params![workspace_id,input.ticket_id,index,key,value]).map_err(db)?; - } - Ok(()) -} - -fn validate_revision(revision: &MergeRequestRevision) -> Result<()> { - for (name, value) in [ - ("revision_id", revision.revision_id.as_str()), - ("base_commit", revision.base_commit.as_str()), - ("head_commit", revision.head_commit.as_str()), - ("assignment_id", revision.assignment_id.as_str()), - ] { - nonempty(name, value)?; - } - if revision.ordinal == 0 { - return Err(MergeRequestError::Empty("revision.ordinal")); - } - if revision.summary.len() > MAX_SUMMARY_BYTES { - return Err(MergeRequestError::TooLarge { - field: "revision.summary", - max: MAX_SUMMARY_BYTES, - }); - } - if revision.changed_paths.len() > MAX_CHANGED_PATHS { - return Err(MergeRequestError::TooLarge { - field: "revision.changed_paths", - max: MAX_CHANGED_PATHS, - }); - } - for path in &revision.changed_paths { - nonempty("changed_path", path)?; - if path.len() > MAX_FIELD_BYTES { - return Err(MergeRequestError::TooLarge { - field: "changed_path", - max: MAX_FIELD_BYTES, - }); - } - if Path::new(path).is_absolute() || path.split('/').any(|p| p == "..") { - return Err(MergeRequestError::Empty("changed_path")); - } - } - Ok(()) -} - -fn validate_completion_outcome(input: &CompleteMergeRequest) -> Result<()> { - match input.strategy { - MergeStrategy::FastForward - if input.result_commit == input.source_commit - && input.resolution == MergeResolution::None => {} - MergeStrategy::Merge if input.resolution != MergeResolution::None => {} - MergeStrategy::FastForward => { - return Err(MergeRequestError::InvalidMergeOutcome( - "fast-forward result must equal the approved source commit and use resolution=none" - .into(), - )); - } - MergeStrategy::Merge => { - return Err(MergeRequestError::InvalidMergeOutcome( - "merge strategy requires clean or conflicts_resolved resolution".into(), - )); - } - } - Ok(()) -} - -fn apply_target_observation(mr: &mut MergeRequest, observed_target_commit: Option<&str>) { - mr.observed_target_commit = observed_target_commit.map(str::to_owned); -} - -fn validate_review_input(input: &SubmitReview) -> Result<()> { - if input.body.len() > MAX_REVIEW_BODY_BYTES { - return Err(MergeRequestError::TooLarge { - field: "review.body", - max: MAX_REVIEW_BODY_BYTES, - }); - } - if input.findings.len() > MAX_FINDINGS { - return Err(MergeRequestError::TooLarge { - field: "review.findings", - max: MAX_FINDINGS, - }); - } - for finding in &input.findings { - nonempty("finding.severity", &finding.severity)?; - nonempty("finding.body", &finding.body)?; - for (field, value) in [ - ("finding.severity", Some(finding.severity.as_str())), - ("finding.code", finding.code.as_deref()), - ("finding.path", finding.path.as_deref()), - ("finding.body", Some(finding.body.as_str())), - ] { - if value.is_some_and(|value| value.len() > MAX_FIELD_BYTES) { - return Err(MergeRequestError::TooLarge { - field, - max: MAX_FIELD_BYTES, - }); - } - } - } - Ok(()) -} - -fn ensure_open(mr: &MergeRequest) -> Result<()> { - if mr.state != MergeRequestState::Open { - Err(MergeRequestError::NotOpen(mr.state.as_str().into())) - } else { - Ok(()) - } -} -fn nonempty(name: &'static str, value: &str) -> Result<()> { - if value.trim().is_empty() { - Err(MergeRequestError::Empty(name)) - } else { - Ok(()) - } -} -fn token_hash(token: &str) -> String { - Sha256::digest(token.as_bytes()) - .iter() - .map(|byte| format!("{byte:02x}")) - .collect() -} -fn completion_fingerprint(input: &CompleteMergeRequest) -> String { - completion_fingerprint_parts( - &input.ticket_id, - &input.expected_revision_id, - &input.target_commit, - &input.source_commit, - &input.result_commit, - input.strategy.as_str(), - input.resolution.as_str(), - &input.implementation_assignment_id, - &input.completion_actor_runtime_id, - &input.completion_actor_worker_id, - ) -} - -#[allow(clippy::too_many_arguments)] -fn completion_fingerprint_parts( - ticket_id: &str, - revision_id: &str, - target_commit: &str, - source_commit: &str, - result_commit: &str, - strategy: &str, - resolution: &str, - implementation_assignment_id: &str, - completion_actor_runtime_id: &str, - completion_actor_worker_id: &str, -) -> String { - token_hash(&format!( - "workspace_orchestrator\0{}\0{}\0{}\0{}\0{}\0{}\0{}\0{}\0{}\0{}", + merge_request_id: &str, +) -> Result, MergeRequestError> { + let row: Option<( + String, + String, + String, + String, + String, + String, + String, + String, + String, + String, + )> = conn + .query_row( + "SELECT ticket_id, repository_id, state, selector_from, selector_to, + opened_by_runtime_id, opened_by_worker_id, created_at, updated_at, + merge_request_id + FROM merge_requests + WHERE workspace_id = ?1 AND merge_request_id = ?2", + params![workspace_id, merge_request_id], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + row.get(7)?, + row.get(8)?, + row.get(9)?, + )) + }, + ) + .optional()?; + let Some(( ticket_id, + repository_id, + state, + selector_from, + selector_to, + opened_runtime, + opened_worker, + created_at, + updated_at, + id, + )) = row + else { + return Ok(None); + }; + let mut stmt = conn.prepare( + "SELECT kind, payload_json FROM merge_request_thread_events + WHERE workspace_id = ?1 AND merge_request_id = ?2 ORDER BY event_seq", + )?; + let rows = stmt + .query_map(params![workspace_id, merge_request_id], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })? + .collect::, _>>()?; + let mut thread = Vec::with_capacity(rows.len()); + for (kind, payload) in rows { + thread.push(match kind.as_str() { + "request_for_review" => { + MergeRequestThreadEvent::RequestForReview(decode_payload(&payload)?) + } + "review" => MergeRequestThreadEvent::Review(decode_payload(&payload)?), + "merge" => MergeRequestThreadEvent::Merge(decode_payload(&payload)?), + "reopen" => MergeRequestThreadEvent::Reopen(decode_payload(&payload)?), + "close" => MergeRequestThreadEvent::Close(decode_payload(&payload)?), + other => { + return Err(MergeRequestError::Corrupt(format!( + "unknown merge request thread event kind `{other}`" + ))); + } + }); + } + Ok(Some(MergeRequest { + workspace_id: workspace_id.to_string(), + merge_request_id: id, + ticket_id, + repository_id, + state: MergeRequestState::parse(&state)?, + selector_from, + selector_to, + opened_by_worker: WorkerIdentity { + runtime_id: opened_runtime, + worker_id: opened_worker, + }, + created_at: parse_timestamp(&created_at)?, + updated_at: parse_timestamp(&updated_at)?, + thread, + })) +} + +fn decode_payload Deserialize<'de>>(value: &str) -> Result { + serde_json::from_str(value).map_err(|error| MergeRequestError::Corrupt(error.to_string())) +} + +fn parse_timestamp(value: &str) -> Result, MergeRequestError> { + DateTime::parse_from_rfc3339(value) + .map(|value| value.with_timezone(&Utc)) + .map_err(|error| MergeRequestError::Corrupt(error.to_string())) +} + +pub fn migrate(conn: &Connection) -> Result<(), MergeRequestError> { + let version = schema_version(conn)?; + match version { + None => create_latest_schema(conn), + Some(SCHEMA_VERSION) => verify_latest_schema(conn), + Some(PREVIOUS_SCHEMA_VERSION) => migrate_v11_to_v12(conn), + Some(other) => Err(MergeRequestError::Operation(format!( + "unsupported merge request schema version {other}; expected {PREVIOUS_SCHEMA_VERSION} or {SCHEMA_VERSION}" + ))), + } +} + +fn schema_version(conn: &Connection) -> Result, MergeRequestError> { + let exists: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'merge_request_schema')", + [], + |row| row.get(0), + )?; + if !exists { + return Ok(None); + } + conn.query_row( + "SELECT version FROM merge_request_schema WHERE singleton = 1", + [], + |row| row.get(0), + ) + .optional() + .map_err(Into::into) +} + +fn create_latest_schema(conn: &Connection) -> Result<(), MergeRequestError> { + let tx = conn.unchecked_transaction()?; + create_v12_tables(&tx)?; + tx.execute( + "INSERT INTO merge_request_schema (singleton, version) VALUES (1, ?1)", + params![SCHEMA_VERSION], + )?; + foreign_key_check(&tx)?; + tx.commit()?; + Ok(()) +} + +fn migrate_v11_to_v12(conn: &Connection) -> Result<(), MergeRequestError> { + let tx = conn.unchecked_transaction()?; + let incompatible: Option = tx + .query_row( + "SELECT mr.merge_request_id + FROM merge_requests mr + LEFT JOIN merge_request_revisions r + ON r.workspace_id = mr.workspace_id + AND r.revision_id = mr.current_revision_id + WHERE r.revision_id IS NULL + LIMIT 1", + [], + |row| row.get(0), + ) + .optional()?; + if let Some(id) = incompatible { + return Err(MergeRequestError::Operation(format!( + "cannot migrate merge request `{id}`: current revision is missing" + ))); + } + + tx.execute_batch( + "ALTER TABLE merge_requests RENAME TO merge_requests_v11; + ALTER TABLE merge_request_revisions RENAME TO merge_request_revisions_v11; + ALTER TABLE merge_request_review_attempts RENAME TO merge_request_review_attempts_v11; + ALTER TABLE merge_request_reviews RENAME TO merge_request_reviews_v11; + ALTER TABLE merge_request_completion_operations RENAME TO merge_request_completion_operations_v11; + ALTER TABLE merge_request_reviewer_child_sessions RENAME TO merge_request_reviewer_child_sessions_v11;", + )?; + create_v12_domain_tables(&tx)?; + tx.execute( + "INSERT INTO merge_requests ( + workspace_id, merge_request_id, ticket_id, repository_id, state, + selector_from, selector_to, opened_by_runtime_id, opened_by_worker_id, + created_at, updated_at + ) + SELECT mr.workspace_id, mr.merge_request_id, mr.ticket_id, mr.repository_id, + CASE mr.state WHEN 'draft' THEN 'open' ELSE mr.state END, + r.head_commit, mr.target_ref_selector, + mr.opened_by_worker_runtime_id, mr.opened_by_worker_id, + mr.created_at, mr.updated_at + FROM merge_requests_v11 mr + JOIN merge_request_revisions_v11 r + ON r.workspace_id = mr.workspace_id AND r.revision_id = mr.current_revision_id", + [], + )?; + + migrate_v11_events(&tx)?; + tx.execute_batch( + "DROP TABLE merge_request_reviewer_child_sessions_v11; + DROP TABLE merge_request_completion_operations_v11; + DROP TABLE merge_request_reviews_v11; + DROP TABLE merge_request_review_attempts_v11; + DROP TABLE merge_request_revisions_v11; + DROP TABLE merge_requests_v11; + UPDATE merge_request_schema SET version = 12 WHERE singleton = 1;", + )?; + foreign_key_check(&tx)?; + tx.commit()?; + Ok(()) +} + +fn migrate_v11_events(tx: &Transaction<'_>) -> Result<(), MergeRequestError> { + let mut stmt = tx.prepare( + "SELECT r.workspace_id, r.merge_request_id, r.revision_id, r.base_commit, r.head_commit, + r.changed_paths_json, r.summary, r.assignment_id, + r.coder_worker_runtime_id, r.coder_worker_id, r.created_at + FROM merge_request_revisions_v11 r + ORDER BY r.workspace_id, r.merge_request_id, r.created_at, r.revision_id", + )?; + let revisions = stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, String>(6)?, + row.get::<_, String>(7)?, + row.get::<_, String>(8)?, + row.get::<_, String>(9)?, + row.get::<_, String>(10)?, + )) + })? + .collect::, _>>()?; + drop(stmt); + for ( + workspace_id, + mr_id, revision_id, - target_commit, - source_commit, - result_commit, + base, + head, + paths_json, + summary, + assignment, + runtime, + worker, + created_at, + ) in revisions + { + let event_seq = next_event_seq_tx(tx, &workspace_id, &mr_id)?; + let event = RequestForReviewEvent { + event_seq, + base_commit: base, + head_commit: head, + changed_paths: serde_json::from_str(&paths_json).map_err(|error| { + MergeRequestError::Operation(format!( + "cannot migrate revision `{revision_id}` changed paths: {error}" + )) + })?, + summary, + assignment_id: assignment, + requested_by: WorkerIdentity { + runtime_id: runtime, + worker_id: worker, + }, + created_at: parse_timestamp(&created_at)?, + }; + insert_event_tx( + tx, + &workspace_id, + &mr_id, + "request_for_review", + &event, + event.created_at, + None, + )?; + + let reviews = { + let mut reviews_stmt = tx.prepare( + "SELECT reviewer_worker_runtime_id, reviewer_worker_id, reviewer_profile, + decision, body, findings_json, created_at + FROM merge_request_reviews_v11 + WHERE workspace_id = ?1 AND revision_id = ?2 + ORDER BY created_at, review_id", + )?; + reviews_stmt + .query_map(params![workspace_id, revision_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, String>(6)?, + )) + })? + .collect::, _>>()? + }; + for ( + review_runtime, + review_worker, + profile, + decision, + body, + findings_json, + review_created, + ) in reviews + { + let review_seq = next_event_seq_tx(tx, &workspace_id, &mr_id)?; + let review = ReviewEvent { + event_seq: review_seq, + request_event_seq: event_seq, + head_commit: event.head_commit.clone(), + reviewer_worker: WorkerIdentity { + runtime_id: review_runtime, + worker_id: review_worker, + }, + reviewer_profile: profile, + decision: match decision.as_str() { + "approve" => ReviewDecision::Approve, + "request_changes" => ReviewDecision::RequestChanges, + other => { + return Err(MergeRequestError::Operation(format!( + "cannot migrate unknown review decision `{other}`" + ))); + } + }, + body, + findings: serde_json::from_str(&findings_json).map_err(|error| { + MergeRequestError::Operation(format!("cannot migrate review findings: {error}")) + })?, + created_at: parse_timestamp(&review_created)?, + }; + insert_event_tx( + tx, + &workspace_id, + &mr_id, + "review", + &review, + review.created_at, + None, + )?; + } + } + + let mut completion_stmt = tx.prepare( + "SELECT workspace_id, merge_request_id, operation_id, target_commit, source_commit, + result_commit, strategy, resolution, requested_by_runtime_id, + requested_by_worker_id, completed_at + FROM merge_request_completion_operations_v11 + WHERE status = 'succeeded' + ORDER BY completed_at, operation_id", + )?; + let completions = completion_stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, String>(6)?, + row.get::<_, String>(7)?, + row.get::<_, String>(8)?, + row.get::<_, String>(9)?, + row.get::<_, Option>(10)?, + )) + })? + .collect::, _>>()?; + drop(completion_stmt); + for ( + workspace_id, + mr_id, + operation_id, + target, + source, + result, strategy, resolution, - implementation_assignment_id, - completion_actor_runtime_id, - completion_actor_worker_id - )) + runtime, + worker, + completed_at, + ) in completions + { + let created_at = parse_timestamp(completed_at.as_deref().ok_or_else(|| { + MergeRequestError::Operation(format!( + "cannot migrate completion `{operation_id}` without completed_at" + )) + })?)?; + let event = MergeEvent { + event_seq: next_event_seq_tx(tx, &workspace_id, &mr_id)?, + operation_id, + target_commit: target, + source_commit: source, + result_commit: result, + strategy: match strategy.as_str() { + "fast_forward" => MergeStrategy::FastForward, + "merge" => MergeStrategy::Merge, + other => { + return Err(MergeRequestError::Operation(format!( + "cannot migrate unknown merge strategy `{other}`" + ))); + } + }, + resolution: match resolution.as_str() { + "none" => ConflictResolution::None, + "clean" => ConflictResolution::Clean, + "conflicts_resolved" => ConflictResolution::ConflictsResolved, + other => { + return Err(MergeRequestError::Operation(format!( + "cannot migrate unknown conflict resolution `{other}`" + ))); + } + }, + merged_by: WorkerIdentity { + runtime_id: runtime, + worker_id: worker, + }, + created_at, + }; + insert_event_tx( + tx, + &workspace_id, + &mr_id, + "merge", + &event, + created_at, + Some(&event.operation_id), + )?; + } + Ok(()) } -fn db(error: rusqlite::Error) -> MergeRequestError { - MergeRequestError::Database(error.to_string()) + +fn create_v12_tables(tx: &Transaction<'_>) -> Result<(), MergeRequestError> { + tx.execute_batch( + "CREATE TABLE merge_request_schema ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + version INTEGER NOT NULL + );", + )?; + create_v12_domain_tables(tx) +} + +fn create_v12_domain_tables(tx: &Transaction<'_>) -> Result<(), MergeRequestError> { + tx.execute_batch( + "CREATE TABLE merge_requests ( + workspace_id TEXT NOT NULL, + merge_request_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + repository_id TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('open', 'merged', 'closed')), + selector_from TEXT NOT NULL CHECK (length(trim(selector_from)) > 0), + selector_to TEXT NOT NULL CHECK (length(trim(selector_to)) > 0), + opened_by_runtime_id TEXT NOT NULL, + opened_by_worker_id TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, merge_request_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, repository_id) + REFERENCES repositories(workspace_id, repository_id) ON DELETE RESTRICT, + FOREIGN KEY (workspace_id, ticket_id) + REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE + ); + CREATE UNIQUE INDEX merge_requests_one_open_per_ticket + ON merge_requests(workspace_id, ticket_id) WHERE state = 'open'; + CREATE INDEX merge_requests_ticket_history + ON merge_requests(workspace_id, ticket_id, created_at DESC); + + CREATE TABLE merge_request_thread_events ( + workspace_id TEXT NOT NULL, + merge_request_id TEXT NOT NULL, + event_seq INTEGER NOT NULL CHECK (event_seq > 0), + kind TEXT NOT NULL CHECK (kind IN ('request_for_review', 'review', 'merge', 'reopen', 'close')), + payload_json TEXT NOT NULL, + operation_id TEXT, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, merge_request_id, event_seq), + FOREIGN KEY (workspace_id, merge_request_id) + REFERENCES merge_requests(workspace_id, merge_request_id) ON DELETE CASCADE + ); + CREATE UNIQUE INDEX merge_request_merge_operation_ids + ON merge_request_thread_events(workspace_id, operation_id) + WHERE operation_id IS NOT NULL; + + CREATE TABLE merge_request_review_capabilities ( + workspace_id TEXT NOT NULL, + merge_request_id TEXT NOT NULL, + request_event_seq INTEGER NOT NULL, + capability_token TEXT PRIMARY KEY, + issued_by_assignment_id TEXT NOT NULL, + reviewer_runtime_id TEXT NOT NULL, + reviewer_worker_id TEXT NOT NULL, + reviewer_profile TEXT NOT NULL, + issued_at TEXT NOT NULL, + consumed_at TEXT, + status TEXT NOT NULL CHECK (status IN ('issued', 'consumed', 'revoked')), + FOREIGN KEY (workspace_id, merge_request_id, request_event_seq) + REFERENCES merge_request_thread_events(workspace_id, merge_request_id, event_seq) + ON DELETE CASCADE + ); + CREATE TABLE merge_request_reviewer_child_sessions ( + workspace_id TEXT NOT NULL, + child_session_id TEXT NOT NULL, + parent_runtime_id TEXT NOT NULL, + parent_worker_id TEXT NOT NULL, + reviewer_profile TEXT NOT NULL, + registered_at TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('active', 'consumed')), + PRIMARY KEY (workspace_id, child_session_id) + );", + )?; + Ok(()) +} + +fn verify_latest_schema(conn: &Connection) -> Result<(), MergeRequestError> { + for table in [ + "merge_requests", + "merge_request_thread_events", + "merge_request_review_capabilities", + "merge_request_reviewer_child_sessions", + ] { + let exists: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)", + params![table], + |row| row.get(0), + )?; + if !exists { + return Err(MergeRequestError::Corrupt(format!( + "schema v{SCHEMA_VERSION} is missing table `{table}`" + ))); + } + } + foreign_key_check(conn) +} + +fn foreign_key_check(conn: &Connection) -> Result<(), MergeRequestError> { + let violation: Option<(String, i64)> = conn + .query_row("PRAGMA foreign_key_check", [], |row| { + Ok((row.get(0)?, row.get(1)?)) + }) + .optional()?; + if let Some((table, rowid)) = violation { + return Err(MergeRequestError::Corrupt(format!( + "foreign key violation in `{table}` row {rowid}" + ))); + } + Ok(()) } diff --git a/crates/merge-request/tests/store.rs b/crates/merge-request/tests/store.rs index ba795b1e..8b5654d5 100644 --- a/crates/merge-request/tests/store.rs +++ b/crates/merge-request/tests/store.rs @@ -1,297 +1,407 @@ -use merge_request::*; -use rusqlite::{Connection, params}; -use std::sync::{Arc, Barrier}; -use std::thread; -use tempfile::TempDir; +use std::sync::{Arc, Mutex}; -fn setup() -> (TempDir, SqliteMergeRequestStore) { +use chrono::{TimeZone, Utc}; +use merge_request::{ + AssignmentSource, CompleteMergeRequest, ConflictResolution, CurrentAssignment, FindingSeverity, + MergeRequestAuth, MergeRequestState, MergeRequestStore, MergeRequestThreadEvent, MergeStrategy, + OpenMergeRequest, ReadinessCheck, RegisterReviewCapability, RegisterReviewerChildSession, + RepositorySource, RequestForReview, RequestMergeRequestReview, ReviewDecision, ReviewFinding, + SubmitMergeRequestReview, +}; +use rusqlite::{Connection, params}; + +#[derive(Clone)] +struct Assignments { + current: Arc>, +} + +impl AssignmentSource for Assignments { + fn current_assignment( + &self, + _workspace_id: &str, + _ticket_id: &str, + ) -> Result, String> { + Ok(Some(self.current.lock().unwrap().clone())) + } +} + +struct Repositories; + +impl RepositorySource for Repositories { + fn repository_belongs_to_workspace( + &self, + workspace_id: &str, + repository_id: &str, + ) -> Result { + Ok(workspace_id == "W" && repository_id == "R") + } + + fn is_ancestor( + &self, + _workspace_id: &str, + _repository_id: &str, + ancestor: &str, + descendant: &str, + ) -> Result { + Ok(matches!( + (ancestor, descendant), + ("base", "head-1") | ("base", "head-2") + )) + } +} + +fn now(second: u32) -> chrono::DateTime { + Utc.with_ymd_and_hms(2026, 7, 26, 12, 0, second) + .single() + .unwrap() +} + +fn fixture() -> (tempfile::TempDir, MergeRequestStore, Assignments) { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("server.db"); let conn = Connection::open(&path).unwrap(); - conn.execute_batch(r#" - PRAGMA foreign_keys=ON; - CREATE TABLE repositories(workspace_id TEXT NOT NULL,repository_id TEXT NOT NULL,PRIMARY KEY(workspace_id,repository_id)); - CREATE TABLE typed_tickets(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,workflow_state TEXT NOT NULL,workflow_state_explicit INTEGER NOT NULL DEFAULT 1,updated_at TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id)); - CREATE TABLE typed_ticket_events(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,event_index INTEGER NOT NULL,kind TEXT NOT NULL,author TEXT,at TEXT,status TEXT,from_state TEXT,to_state TEXT,heading TEXT,body TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index)); - CREATE TABLE typed_ticket_event_attributes(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,event_index INTEGER NOT NULL,key TEXT NOT NULL,value TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id,event_index,key)); - CREATE TABLE ticket_worker_assignments(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,assignment_id TEXT NOT NULL,runtime_id TEXT NOT NULL,worker_id TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id,assignment_id)); - CREATE TABLE ticket_current_worker_assignments(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,assignment_id TEXT NOT NULL,runtime_id TEXT NOT NULL,worker_id TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id)); - "#).unwrap(); - for ws in ["ws-a", "ws-b"] { - conn.execute("INSERT INTO repositories VALUES(?1,'repo')", params![ws]) - .unwrap(); - conn.execute( - "INSERT INTO typed_tickets VALUES(?1,'T1','inprogress',1,'t0')", - params![ws], - ) - .unwrap(); - conn.execute( - "INSERT INTO ticket_worker_assignments VALUES(?1,'T1','A1','R1','W1')", - params![ws], - ) - .unwrap(); - conn.execute( - "INSERT INTO ticket_current_worker_assignments VALUES(?1,'T1','A1','R1','W1')", - params![ws], - ) - .unwrap(); - } + conn.execute_batch( + "PRAGMA foreign_keys = ON; + CREATE TABLE workspaces (workspace_id TEXT PRIMARY KEY); + CREATE TABLE repositories ( + workspace_id TEXT NOT NULL, + repository_id TEXT NOT NULL, + PRIMARY KEY (workspace_id, repository_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) + ); + CREATE TABLE typed_tickets ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + workflow_state TEXT NOT NULL, + workflow_state_explicit INTEGER NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id) + ); + CREATE TABLE typed_ticket_events ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, + kind TEXT NOT NULL, author TEXT NOT NULL, at TEXT NOT NULL, + from_state TEXT, to_state TEXT, heading TEXT, body TEXT, + PRIMARY KEY (workspace_id, ticket_id, event_index) + ); + CREATE TABLE typed_ticket_event_attributes ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, + key TEXT NOT NULL, value TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, event_index, key) + ); + INSERT INTO workspaces VALUES ('W'); + INSERT INTO repositories VALUES ('W', 'R'); + INSERT INTO typed_tickets VALUES ('W', 'T', 'inprogress', 1, '2026-07-26T12:00:00Z');", + ) + .unwrap(); drop(conn); - let store = SqliteMergeRequestStore::open(&path, "ws-a").unwrap(); - (dir, store) + let assignments = Assignments { + current: Arc::new(Mutex::new(CurrentAssignment { + assignment_id: "A1".into(), + ticket_id: "T".into(), + runtime_id: "runtime".into(), + worker_id: "coder".into(), + })), + }; + let store = + MergeRequestStore::open(&path, Arc::new(assignments.clone()), Arc::new(Repositories)) + .unwrap(); + (dir, store, assignments) } -fn revision(id: &str, ordinal: u64, head: &str) -> MergeRequestRevision { - MergeRequestRevision { - revision_id: id.into(), - ordinal, - base_commit: "base".into(), - head_commit: head.into(), - changed_paths: vec!["src/lib.rs".into()], - summary: format!("revision {id}"), - assignment_id: "A1".into(), - created_at: format!("t{ordinal}"), +fn auth(assignment_id: &str) -> MergeRequestAuth { + MergeRequestAuth { + workspace_id: "W".into(), + repository_id: "R".into(), + runtime_id: "runtime".into(), + worker_id: "coder".into(), + assignment_id: assignment_id.into(), } } -fn open(store: &SqliteMergeRequestStore) { +fn open(store: &MergeRequestStore) { store .open_merge_request(OpenMergeRequest { - merge_request_id: "MR1".into(), - ticket_id: "T1".into(), - repository_id: "repo".into(), - target_ref_selector: "refs/heads/develop".into(), - revision: revision("V1", 1, "head"), - authenticated_runtime_id: "R1".into(), - authenticated_worker_id: "W1".into(), - now: "t1".into(), + merge_request_id: "MR".into(), + ticket_id: "T".into(), + repository_id: "R".into(), + selector_from: "work/t-feature".into(), + selector_to: "develop".into(), + request: RequestForReview { + base_commit: "base".into(), + head_commit: "head-1".into(), + changed_paths: vec!["src/lib.rs".into()], + summary: "first candidate".into(), + }, + auth: auth("A1"), + now: now(1), }) .unwrap(); } -fn attempt(store: &SqliteMergeRequestStore, revision: &str, token: &str) { - let child = format!("child-{revision}"); +fn approve(store: &MergeRequestStore, expected_head_commit: &str, token: &str) { store .register_reviewer_child_session(RegisterReviewerChildSession { - parent_runtime_id: "R1".into(), - parent_worker_id: "W1".into(), - child_session_id: child.clone(), - now: "t2".into(), + workspace_id: "W".into(), + parent_runtime_id: "runtime".into(), + parent_worker_id: "coder".into(), + child_session_id: format!("child-{token}"), + reviewer_profile: "builtin:reviewer".into(), + now: now(2), }) .unwrap(); store - .register_review_attempt(RegisterReviewAttempt { - attempt_id: format!("attempt-{revision}"), - ticket_id: "T1".into(), - revision_id: revision.into(), - parent_assignment_id: "A1".into(), - parent_runtime_id: "R1".into(), - parent_worker_id: "W1".into(), - child_session_id: child, + .register_review_capability(RegisterReviewCapability { + ticket_id: "T".into(), + expected_head_commit: expected_head_commit.into(), + child_session_id: format!("child-{token}"), capability_token: token.into(), - now: "t2".into(), + auth: auth("A1"), + now: now(3), }) .unwrap(); -} - -fn approve(store: &SqliteMergeRequestStore, revision: &str, token: &str) { - attempt(store, revision, token); store - .submit_review(SubmitReview { - ticket_id: "T1".into(), - revision_id: revision.into(), + .submit_review(SubmitMergeRequestReview { + ticket_id: "T".into(), + expected_head_commit: expected_head_commit.into(), capability_token: token.into(), decision: ReviewDecision::Approve, - body: "approved".into(), - findings: vec![], - now: "t3".into(), + body: "approved independently".into(), + findings: vec![ReviewFinding { + severity: FindingSeverity::Note, + path: None, + line: None, + message: "looks good".into(), + }], + now: now(4), }) .unwrap(); } -fn completion(operation_id: &str) -> CompleteMergeRequest { - CompleteMergeRequest { - operation_id: operation_id.into(), - ticket_id: "T1".into(), - expected_revision_id: "V1".into(), - target_commit: "base".into(), - source_commit: "head".into(), - result_commit: "head".into(), - strategy: MergeStrategy::FastForward, - resolution: MergeResolution::None, - implementation_assignment_id: "A1".into(), - completion_actor_runtime_id: "OR".into(), - completion_actor_worker_id: "OW".into(), - now: "t4".into(), - } -} - #[test] -fn target_movement_does_not_invalidate_source_revision_approval() { - let (_dir, store) = setup(); +fn thread_drives_review_readiness_and_completion_without_public_revision_identity() { + let (_dir, store, _assignments) = fixture(); open(&store); - approve(&store, "V1", "token-v1"); - for target in ["base", "advanced-target"] { - let readiness = store - .readiness_for_ticket_with_target("T1", Some(target)) - .unwrap(); + approve(&store, "head-1", "token-1"); + + let readiness = store + .readiness(ReadinessCheck { + ticket_id: "T".into(), + expected_head_commit: Some("head-1".into()), + auth: auth("A1"), + }) + .unwrap(); + assert!(readiness.ready, "{:?}", readiness.blockers); + + let merged = store + .complete(CompleteMergeRequest { + ticket_id: "T".into(), + expected_head_commit: "head-1".into(), + operation_id: "op-1".into(), + target_commit: "base".into(), + source_commit: "head-1".into(), + result_commit: "head-1".into(), + strategy: MergeStrategy::FastForward, + resolution: ConflictResolution::None, + auth: auth("A1"), + now: now(5), + }) + .unwrap(); + assert_eq!(merged.result_commit, "head-1"); + + let mr = store.get("W", "T").unwrap(); + assert_eq!(mr.state, MergeRequestState::Merged); + assert!(matches!( + mr.thread.last(), + Some(MergeRequestThreadEvent::Merge(_)) + )); + assert_eq!(mr.selector_from, "work/t-feature"); + assert_eq!(mr.selector_to, "develop"); + let json = serde_json::to_string(&mr).unwrap(); + for forbidden in [ + "revision_id", + "current_revision", + "attempt_id", + "review_attempt", + "head_tree", + "diff_digest", + "merged_revision_id", + ] { assert!( - readiness.ready, - "target movement must not invalidate source approval" + !json.contains(forbidden), + "unexpected `{forbidden}` in {json}" ); - assert_eq!(readiness.review_status, ReviewStatus::Approved); - assert_eq!(readiness.observed_target_commit.as_deref(), Some(target)); } +} + +#[test] +fn new_review_request_invalidates_prior_approval_and_fences_stale_capability() { + let (_dir, store, _assignments) = fixture(); + open(&store); + approve(&store, "head-1", "token-1"); store - .add_revision(AddRevision { - ticket_id: "T1".into(), - expected_current_revision_id: "V1".into(), - revision: revision("V2", 2, "head2"), - authenticated_runtime_id: "R1".into(), - authenticated_worker_id: "W1".into(), - now: "t5".into(), + .request_review(RequestMergeRequestReview { + ticket_id: "T".into(), + expected_head_commit: "head-1".into(), + request: RequestForReview { + base_commit: "base".into(), + head_commit: "head-2".into(), + changed_paths: vec!["src/lib.rs".into(), "tests/store.rs".into()], + summary: "address review".into(), + }, + auth: auth("A1"), + now: now(6), }) .unwrap(); - assert_eq!( - store.readiness_for_ticket("T1").unwrap().review_status, - ReviewStatus::Pending + + let readiness = store + .readiness(ReadinessCheck { + ticket_id: "T".into(), + expected_head_commit: Some("head-2".into()), + auth: auth("A1"), + }) + .unwrap(); + assert!(!readiness.ready); + assert!( + readiness + .blockers + .iter() + .any(|value| value.contains("no review result")) + ); + assert!( + store + .submit_review(SubmitMergeRequestReview { + ticket_id: "T".into(), + expected_head_commit: "head-1".into(), + capability_token: "token-1".into(), + decision: ReviewDecision::Approve, + body: "stale".into(), + findings: vec![], + now: now(7), + }) + .is_err() ); } #[test] -fn completion_records_one_final_merge_outcome_and_replays_idempotently() { - let (_dir, store) = setup(); +fn assignment_change_rejects_candidate_mutation() { + let (_dir, store, assignments) = fixture(); open(&store); - approve(&store, "V1", "token-v1"); - let first = store.complete(completion("OP1")).unwrap(); - assert!(!first.replayed); - assert_eq!(first.ticket_state, "done"); - let merged = store.show_for_ticket("T1").unwrap().unwrap(); - assert_eq!(merged.state, MergeRequestState::Merged); - assert_eq!(merged.merged_revision_id.as_deref(), Some("V1")); - assert_eq!(merged.merged_target_commit.as_deref(), Some("base")); - assert_eq!(merged.merged_result_commit.as_deref(), Some("head")); - assert_eq!(merged.merge_strategy, Some(MergeStrategy::FastForward)); - assert_eq!(merged.merge_resolution, Some(MergeResolution::None)); - assert_eq!(merged.merged_by_runtime_id.as_deref(), Some("OR")); - assert_eq!(merged.merged_by_worker_id.as_deref(), Some("OW")); - assert!(store.complete(completion("OP1")).unwrap().replayed); - let mut conflicting = completion("OP1"); - conflicting.target_commit = "other".into(); - assert!(matches!( - store.complete(conflicting), - Err(MergeRequestError::OperationConflict) - )); + *assignments.current.lock().unwrap() = CurrentAssignment { + assignment_id: "A2".into(), + ticket_id: "T".into(), + runtime_id: "runtime".into(), + worker_id: "other".into(), + }; + let error = store + .request_review(RequestMergeRequestReview { + ticket_id: "T".into(), + expected_head_commit: "head-1".into(), + request: RequestForReview { + base_commit: "base".into(), + head_commit: "head-2".into(), + changed_paths: vec![], + summary: String::new(), + }, + auth: auth("A1"), + now: now(6), + }) + .unwrap_err(); + assert!(error.to_string().contains("current assigned worker")); } #[test] -fn completion_rejects_invalid_or_non_current_source_outcomes_without_side_effects() { - let (_dir, store) = setup(); - open(&store); - approve(&store, "V1", "token-v1"); - let mut invalid_ff = completion("bad-ff"); - invalid_ff.result_commit = "different".into(); - assert!(matches!( - store.complete(invalid_ff), - Err(MergeRequestError::InvalidMergeOutcome(_)) - )); - let mut invalid_merge = completion("bad-merge"); - invalid_merge.strategy = MergeStrategy::Merge; - assert!(matches!( - store.complete(invalid_merge), - Err(MergeRequestError::InvalidMergeOutcome(_)) - )); - let mut wrong_source = completion("wrong-source"); - wrong_source.source_commit = "not-approved".into(); - wrong_source.result_commit = "not-approved".into(); - assert!(matches!( - store.complete(wrong_source), - Err(MergeRequestError::InvalidMergeOutcome(_)) - )); +fn v11_migration_builds_thread_events_and_removes_revision_tables() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "PRAGMA foreign_keys = OFF; + CREATE TABLE workspaces (workspace_id TEXT PRIMARY KEY); + CREATE TABLE repositories ( + workspace_id TEXT NOT NULL, repository_id TEXT NOT NULL, + PRIMARY KEY (workspace_id, repository_id) + ); + INSERT INTO workspaces VALUES ('W'); + INSERT INTO repositories VALUES ('W', 'R'); + CREATE TABLE typed_tickets ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id) + ); + INSERT INTO typed_tickets VALUES ('W', 'T'); + CREATE TABLE merge_request_schema (singleton INTEGER PRIMARY KEY, version INTEGER NOT NULL); + INSERT INTO merge_request_schema VALUES (1, 11); + CREATE TABLE merge_requests ( + workspace_id TEXT, merge_request_id TEXT, ticket_id TEXT, repository_id TEXT, + state TEXT, target_ref_selector TEXT, current_revision_id TEXT, + opened_by_worker_runtime_id TEXT, opened_by_worker_id TEXT, created_at TEXT, updated_at TEXT + ); + CREATE TABLE merge_request_revisions ( + workspace_id TEXT, revision_id TEXT, merge_request_id TEXT, base_commit TEXT, + head_commit TEXT, changed_paths_json TEXT, summary TEXT, assignment_id TEXT, + coder_worker_runtime_id TEXT, coder_worker_id TEXT, created_at TEXT + ); + CREATE TABLE merge_request_review_attempts (attempt_id TEXT); + CREATE TABLE merge_request_reviews ( + workspace_id TEXT, review_id TEXT, revision_id TEXT, + reviewer_worker_runtime_id TEXT, reviewer_worker_id TEXT, reviewer_profile TEXT, + decision TEXT, body TEXT, findings_json TEXT, created_at TEXT + ); + CREATE TABLE merge_request_completion_operations ( + workspace_id TEXT, merge_request_id TEXT, operation_id TEXT, target_commit TEXT, + source_commit TEXT, result_commit TEXT, strategy TEXT, resolution TEXT, + requested_by_runtime_id TEXT, requested_by_worker_id TEXT, completed_at TEXT, status TEXT + ); + CREATE TABLE merge_request_reviewer_child_sessions (child_session_id TEXT); + INSERT INTO merge_requests VALUES ( + 'W', 'MR', 'T', 'R', 'open', 'develop', 'REV', + 'runtime', 'coder', '2026-07-26T12:00:00Z', '2026-07-26T12:00:00Z' + ); + INSERT INTO merge_request_revisions VALUES ( + 'W', 'REV', 'MR', 'base', 'head-1', '[\"src/lib.rs\"]', 'legacy', 'A1', + 'runtime', 'coder', '2026-07-26T12:00:00Z' + ); + INSERT INTO merge_request_reviews VALUES ( + 'W', 'REVIEW', 'REV', 'runtime', 'child', 'builtin:reviewer', + 'approve', 'approved', '[]', '2026-07-26T12:00:01Z' + );", + ) + .unwrap(); + + merge_request::migrate(&conn).unwrap(); assert_eq!( - store.show_for_ticket("T1").unwrap().unwrap().state, - MergeRequestState::Open + conn.query_row("SELECT version FROM merge_request_schema", [], |row| row + .get::<_, i64>(0)) + .unwrap(), + 12 + ); + assert_eq!( + conn.query_row("SELECT selector_from FROM merge_requests", [], |row| row + .get::<_, String>( + 0 + )) + .unwrap(), + "head-1" ); - let conn = Connection::open(store.db_path()).unwrap(); assert_eq!( conn.query_row( - "SELECT workflow_state FROM typed_tickets WHERE workspace_id='ws-a' AND ticket_id='T1'", + "SELECT COUNT(*) FROM merge_request_thread_events", [], - |row| row.get::<_, String>(0) + |row| row.get::<_, i64>(0) ) .unwrap(), - "inprogress" + 2 ); -} - -#[test] -fn concurrent_completion_converges_on_one_operation() { - let (_dir, store) = setup(); - open(&store); - approve(&store, "V1", "token-v1"); - let path = store.db_path().to_path_buf(); - let barrier = Arc::new(Barrier::new(3)); - let mut handles = Vec::new(); - for _ in 0..2 { - let path = path.clone(); - let barrier = barrier.clone(); - handles.push(thread::spawn(move || { - let store = SqliteMergeRequestStore::open_verified(path, "ws-a").unwrap(); - barrier.wait(); - store.complete(completion("OP-concurrent")) - })); + for removed in [ + "merge_request_revisions", + "merge_request_review_attempts", + "merge_request_reviews", + "merge_request_completion_operations", + ] { + let exists: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)", + params![removed], + |row| row.get(0), + ) + .unwrap(); + assert!(!exists, "legacy table `{removed}` still exists"); } - barrier.wait(); - let outcomes: Vec<_> = handles - .into_iter() - .map(|handle| handle.join().unwrap().unwrap()) - .collect(); - assert_eq!( - outcomes.iter().filter(|outcome| !outcome.replayed).count(), - 1 - ); - assert_eq!( - outcomes.iter().filter(|outcome| outcome.replayed).count(), - 1 - ); -} - -#[test] -fn reviewer_attempt_is_bound_to_direct_child_and_current_assignment() { - let (_dir, store) = setup(); - open(&store); - store - .register_reviewer_child_session(RegisterReviewerChildSession { - parent_runtime_id: "R1".into(), - parent_worker_id: "W1".into(), - child_session_id: "child".into(), - now: "t2".into(), - }) - .unwrap(); - store - .register_review_attempt(RegisterReviewAttempt { - attempt_id: "attempt".into(), - ticket_id: "T1".into(), - revision_id: "V1".into(), - parent_assignment_id: "A1".into(), - parent_runtime_id: "R1".into(), - parent_worker_id: "W1".into(), - child_session_id: "child".into(), - capability_token: "token".into(), - now: "t2".into(), - }) - .unwrap(); - let wrong_token = store.submit_review(SubmitReview { - ticket_id: "T1".into(), - revision_id: "V1".into(), - capability_token: "wrong".into(), - decision: ReviewDecision::Approve, - body: "approved".into(), - findings: vec![], - now: "t3".into(), - }); - assert!(matches!( - wrong_token, - Err(MergeRequestError::InvalidReviewAttempt) - )); } From 9e48cae75984471ce50a3656ce0b16e09fc43f08 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 17 Aug 2026 07:09:20 +0900 Subject: [PATCH 07/13] feat: expose selector based merge request threads --- .../src/feature/builtin/merge_request.rs | 202 +++--- crates/worker/src/feature/builtin/ticket.rs | 4 +- crates/worker/src/spawn/tool.rs | 43 +- crates/worker/src/worker.rs | 22 +- crates/workspace-server/src/server.rs | 621 +++++++++--------- resources/prompts/common/git.md | 2 +- resources/prompts/role/coder.md | 4 +- resources/prompts/role/orchestrator.md | 2 +- resources/prompts/role/reviewer.md | 4 +- .../tickets/[ticketId]/+page.svelte | 95 ++- 10 files changed, 547 insertions(+), 452 deletions(-) diff --git a/crates/worker/src/feature/builtin/merge_request.rs b/crates/worker/src/feature/builtin/merge_request.rs index 78dbd339..56a60bb0 100644 --- a/crates/worker/src/feature/builtin/merge_request.rs +++ b/crates/worker/src/feature/builtin/merge_request.rs @@ -11,19 +11,21 @@ pub const MERGE_REQUEST_COMMON_TOOL_NAMES: &[&str] = &[ "MergeRequestShow", "MergeRequestReadinessCheck", "MergeRequestOpen", - "MergeRequestAddRevision", + "MergeRequestRequestReview", "MergeRequestComplete", ]; pub const MERGE_REQUEST_REVIEW_TOOL_NAME: &str = "MergeRequestReviewSubmit"; + #[derive(Clone, Copy)] enum Kind { Show, Readiness, Open, - AddRevision, + RequestReview, Complete, Review, } + #[derive(Clone)] struct MergeRequestTool { client: Arc, @@ -34,11 +36,13 @@ struct MergeRequestTool { struct ShowInput { ticket: String, } + #[derive(Debug, Deserialize, JsonSchema)] struct OpenInput { ticket: String, repository_id: String, - revision_id: String, + selector_from: String, + selector_to: String, base_commit: String, head_commit: String, #[serde(default)] @@ -46,11 +50,11 @@ struct OpenInput { #[serde(default)] summary: String, } + #[derive(Debug, Deserialize, JsonSchema)] -struct AddRevisionInput { +struct RequestReviewInput { ticket: String, - expected_current_revision_id: String, - revision_id: String, + expected_head_commit: String, base_commit: String, head_commit: String, #[serde(default)] @@ -58,23 +62,26 @@ struct AddRevisionInput { #[serde(default)] summary: String, } + #[derive(Debug, Deserialize, JsonSchema)] struct CompleteInput { ticket: String, operation_id: String, - expected_revision_id: String, + expected_head_commit: String, target_commit: String, source_commit: String, result_commit: String, strategy: MergeStrategyInput, resolution: MergeResolutionInput, } + #[derive(Debug, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] enum MergeStrategyInput { FastForward, Merge, } + #[derive(Debug, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] enum MergeResolutionInput { @@ -82,6 +89,7 @@ enum MergeResolutionInput { Clean, ConflictsResolved, } + #[derive(Debug, Deserialize, JsonSchema)] struct ReviewInput { decision: ReviewDecisionInput, @@ -90,22 +98,22 @@ struct ReviewInput { #[serde(default)] findings: Vec, } + #[derive(Debug, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] enum ReviewDecisionInput { Approve, RequestChanges, } + #[derive(Debug, Deserialize, JsonSchema)] struct ReviewFindingInput { severity: String, #[serde(default)] - code: Option, - #[serde(default)] path: Option, #[serde(default)] - line: Option, - body: String, + line: Option, + message: String, } impl Kind { @@ -114,19 +122,21 @@ impl Kind { Self::Show => "MergeRequestShow", Self::Readiness => "MergeRequestReadinessCheck", Self::Open => "MergeRequestOpen", - Self::AddRevision => "MergeRequestAddRevision", + Self::RequestReview => "MergeRequestRequestReview", Self::Complete => "MergeRequestComplete", Self::Review => "MergeRequestReviewSubmit", } } + fn description(self) -> &'static str { description(self.name()).unwrap_or("Merge Request operation.") } + fn schema(self) -> serde_json::Value { match self { Self::Show | Self::Readiness => json!(schemars::schema_for!(ShowInput)), Self::Open => json!(schemars::schema_for!(OpenInput)), - Self::AddRevision => json!(schemars::schema_for!(AddRevisionInput)), + Self::RequestReview => json!(schemars::schema_for!(RequestReviewInput)), Self::Complete => json!(schemars::schema_for!(CompleteInput)), Self::Review => json!(schemars::schema_for!(ReviewInput)), } @@ -145,59 +155,75 @@ impl Tool for MergeRequestTool { })?; let (method, path, body) = match self.kind { Kind::Show => { - let v: ShowInput = parse(input)?; - nonempty(&v.ticket)?; + let value: ShowInput = parse(input)?; + nonempty(&value.ticket)?; ( WorkspaceRequestMethod::Get, - format!("/api/w/{workspace_id}/tickets/{}/merge-request", v.ticket), + format!( + "/api/w/{workspace_id}/tickets/{}/merge-request", + value.ticket + ), None, ) } Kind::Readiness => { - let v: ShowInput = parse(input)?; - nonempty(&v.ticket)?; + let value: ShowInput = parse(input)?; + nonempty(&value.ticket)?; ( WorkspaceRequestMethod::Get, format!( "/api/w/{workspace_id}/tickets/{}/merge-request/readiness", - v.ticket + value.ticket ), None, ) } Kind::Open => { - let v: OpenInput = parse(input)?; - nonempty(&v.ticket)?; - ( - WorkspaceRequestMethod::Post, - format!("/api/w/{workspace_id}/tickets/{}/merge-request", v.ticket), - Some( - json!({"repository_id":v.repository_id,"revision_id":v.revision_id,"base_commit":v.base_commit,"head_commit":v.head_commit,"changed_paths":v.changed_paths,"summary":v.summary}), - ), - ) - } - Kind::AddRevision => { - let v: AddRevisionInput = parse(input)?; - nonempty(&v.ticket)?; + let value: OpenInput = parse(input)?; + nonempty(&value.ticket)?; ( WorkspaceRequestMethod::Post, format!( - "/api/w/{workspace_id}/tickets/{}/merge-request/revisions", - v.ticket + "/api/w/{workspace_id}/tickets/{}/merge-request", + value.ticket ), - Some( - json!({"expected_current_revision_id":v.expected_current_revision_id,"revision_id":v.revision_id,"base_commit":v.base_commit,"head_commit":v.head_commit,"changed_paths":v.changed_paths,"summary":v.summary}), + Some(json!({ + "repository_id": value.repository_id, + "selector_from": value.selector_from, + "selector_to": value.selector_to, + "base_commit": value.base_commit, + "head_commit": value.head_commit, + "changed_paths": value.changed_paths, + "summary": value.summary, + })), + ) + } + Kind::RequestReview => { + let value: RequestReviewInput = parse(input)?; + nonempty(&value.ticket)?; + ( + WorkspaceRequestMethod::Post, + format!( + "/api/w/{workspace_id}/tickets/{}/merge-request/review-requests", + value.ticket ), + Some(json!({ + "expected_head_commit": value.expected_head_commit, + "base_commit": value.base_commit, + "head_commit": value.head_commit, + "changed_paths": value.changed_paths, + "summary": value.summary, + })), ) } Kind::Complete => { - let v: CompleteInput = parse(input)?; - nonempty(&v.ticket)?; - let strategy = match v.strategy { + let value: CompleteInput = parse(input)?; + nonempty(&value.ticket)?; + let strategy = match value.strategy { MergeStrategyInput::FastForward => "fast_forward", MergeStrategyInput::Merge => "merge", }; - let resolution = match v.resolution { + let resolution = match value.resolution { MergeResolutionInput::None => "none", MergeResolutionInput::Clean => "clean", MergeResolutionInput::ConflictsResolved => "conflicts_resolved", @@ -206,16 +232,22 @@ impl Tool for MergeRequestTool { WorkspaceRequestMethod::Post, format!( "/api/w/{workspace_id}/tickets/{}/merge-request/complete", - v.ticket - ), - Some( - json!({"operation_id":v.operation_id,"expected_revision_id":v.expected_revision_id,"target_commit":v.target_commit,"source_commit":v.source_commit,"result_commit":v.result_commit,"strategy":strategy,"resolution":resolution}), + value.ticket ), + Some(json!({ + "operation_id": value.operation_id, + "expected_head_commit": value.expected_head_commit, + "target_commit": value.target_commit, + "source_commit": value.source_commit, + "result_commit": value.result_commit, + "strategy": strategy, + "resolution": resolution, + })), ) } Kind::Review => { - let v: ReviewInput = parse(input)?; - let context = self.client.reviewer_attempt_context().ok_or_else(|| { + let value: ReviewInput = parse(input)?; + let context = self.client.reviewer_context().ok_or_else(|| { ToolError::ExecutionFailed( "MergeRequestReviewSubmit is available only to an attested Reviewer child" .into(), @@ -227,9 +259,19 @@ impl Tool for MergeRequestTool { "/api/w/{workspace_id}/tickets/{}/merge-request/reviews", context.ticket_id ), - Some( - json!({"decision":match v.decision{ReviewDecisionInput::Approve=>"approve",ReviewDecisionInput::RequestChanges=>"request_changes"},"body":v.body,"findings":v.findings.into_iter().map(|f|json!({"severity":f.severity,"code":f.code,"path":f.path,"line":f.line,"body":f.body})).collect::>() }), - ), + Some(json!({ + "decision": match value.decision { + ReviewDecisionInput::Approve => "approve", + ReviewDecisionInput::RequestChanges => "request_changes", + }, + "body": value.body, + "findings": value.findings.into_iter().map(|finding| json!({ + "severity": finding.severity, + "path": finding.path, + "line": finding.line, + "message": finding.message, + })).collect::>(), + })), ) } }; @@ -240,7 +282,7 @@ impl Tool for MergeRequestTool { let response = self .client .execute(request) - .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?; + .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; if !response.is_success() { return Err(ToolError::ExecutionFailed(format!( "Merge Request API returned HTTP {}: {}", @@ -254,9 +296,11 @@ impl Tool for MergeRequestTool { }) } } + fn parse(value: &str) -> Result { - serde_json::from_str(value).map_err(|e| ToolError::InvalidArgument(e.to_string())) + serde_json::from_str(value).map_err(|error| ToolError::InvalidArgument(error.to_string())) } + fn nonempty(value: &str) -> Result<(), ToolError> { if value.trim().is_empty() { Err(ToolError::InvalidArgument( @@ -266,6 +310,7 @@ fn nonempty(value: &str) -> Result<(), ToolError> { Ok(()) } } + fn definition(client: Arc, kind: Kind) -> ToolDefinition { Arc::new(move || { let meta = ToolMeta::new(kind.name()) @@ -278,17 +323,19 @@ fn definition(client: Arc, kind: Kind) -> ToolDefinition { (meta, tool) }) } + pub fn common_tools(client: Arc) -> Vec { vec![ definition(client.clone(), Kind::Show), definition(client.clone(), Kind::Readiness), definition(client.clone(), Kind::Open), - definition(client.clone(), Kind::AddRevision), + definition(client.clone(), Kind::RequestReview), definition(client, Kind::Complete), ] } + pub fn reviewer_tools(client: Arc) -> Vec { - if client.reviewer_attempt_context().is_some() { + if client.reviewer_context().is_some() { vec![ definition(client.clone(), Kind::Show), definition(client, Kind::Review), @@ -297,26 +344,27 @@ pub fn reviewer_tools(client: Arc) -> Vec { Vec::new() } } + pub fn description(name: &str) -> Option<&'static str> { match name { "MergeRequestShow" => Some( - "Read the authoritative Merge Request, immutable current revision, and structured review status.", + "Read the authoritative Merge Request, selector pair, append-only thread, and current review status.", ), "MergeRequestReadinessCheck" => { - Some("Check derived merge readiness for the current immutable revision.") + Some("Check derived merge readiness for the current review request.") } - "MergeRequestOpen" => { - Some("Open an immutable Merge Request revision for the current assigned Coder.") - } - "MergeRequestAddRevision" => { - Some("Append an immutable revision; prior approval cannot carry to the new revision.") - } - "MergeRequestComplete" => { - Some("CAS-complete an approved revision with operation-id replay and crash fencing.") - } - "MergeRequestReviewSubmit" => Some( - "Submit the attested direct-child Reviewer result bound to its immutable revision.", + "MergeRequestOpen" => Some( + "Open a Merge Request with immutable source/target selectors and its first review request.", ), + "MergeRequestRequestReview" => Some( + "Append a RequestForReview event for new candidate evidence; prior approval cannot carry forward.", + ), + "MergeRequestComplete" => Some( + "CAS-complete the approved current candidate with operation-id replay and crash fencing.", + ), + "MergeRequestReviewSubmit" => { + Some("Submit the attested direct-child Reviewer result for the current candidate.") + } _ => None, } } @@ -326,16 +374,20 @@ mod tests { use super::*; #[test] - fn merge_request_tool_contract_omits_redundant_revision_evidence_and_candidate_result_tool() { + fn merge_request_tool_contract_uses_selectors_and_commit_fences_without_revision_ids() { let open = serde_json::to_string(&schemars::schema_for!(OpenInput)).unwrap(); - let add = serde_json::to_string(&schemars::schema_for!(AddRevisionInput)).unwrap(); + let request = serde_json::to_string(&schemars::schema_for!(RequestReviewInput)).unwrap(); let complete = serde_json::to_string(&schemars::schema_for!(CompleteInput)).unwrap(); - assert!(!open.contains("head_tree")); - assert!(!add.contains("head_tree")); - assert!(!open.contains("diff_digest")); - assert!(!add.contains("diff_digest")); - assert!(complete.contains("result_commit")); - assert!(complete.contains("conflicts_resolved")); - assert!(!MERGE_REQUEST_COMMON_TOOL_NAMES.contains(&"MergeRequestRecordMergeResult")); + assert!(open.contains("selector_from")); + assert!(open.contains("selector_to")); + assert!(request.contains("expected_head_commit")); + assert!(complete.contains("expected_head_commit")); + for schema in [&open, &request, &complete] { + assert!(!schema.contains("revision_id")); + assert!(!schema.contains("attempt_id")); + assert!(!schema.contains("head_tree")); + assert!(!schema.contains("diff_digest")); + } + assert!(!MERGE_REQUEST_COMMON_TOOL_NAMES.contains(&"MergeRequestAddRevision")); } } diff --git a/crates/worker/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index 099b7978..31236c7b 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -366,7 +366,7 @@ impl FeatureModule for TicketFeature { )); } if let TicketFeatureBackend::WorkspaceClient(client) = &self.backend { - let names: Vec<&str> = if client.reviewer_attempt_context().is_some() { + let names: Vec<&str> = if client.reviewer_context().is_some() { vec![ "MergeRequestShow", merge_request::MERGE_REQUEST_REVIEW_TOOL_NAME, @@ -426,7 +426,7 @@ impl FeatureModule for TicketFeature { tools.register(ToolContribution::new(name, definition))?; } if let TicketFeatureBackend::WorkspaceClient(client) = &self.backend { - let definitions = if client.reviewer_attempt_context().is_some() { + let definitions = if client.reviewer_context().is_some() { merge_request::reviewer_tools(client.clone()) } else { merge_request::common_tools(client.clone()) diff --git a/crates/worker/src/spawn/tool.rs b/crates/worker/src/spawn/tool.rs index 5f29ef5f..0583daaf 100644 --- a/crates/worker/src/spawn/tool.rs +++ b/crates/worker/src/spawn/tool.rs @@ -28,7 +28,7 @@ use crate::internal_worker::{ use crate::prompt::catalog::PromptCatalog; use crate::spawn::registry::SpawnedWorkerRegistry; use crate::worker::{ - ReviewerAttemptContext, ReviewerChildWorkspaceClient, Worker, WorkerFilesystemAuthority, + ReviewerChildWorkspaceClient, ReviewerContext, Worker, WorkerFilesystemAuthority, WorkspaceRequest, WorkspaceRequestMethod, }; use protocol::Method; @@ -58,8 +58,8 @@ struct SubWorkerSpawnInput { /// spawner's explicit delegation authority; direct tool scope alone is not /// sufficient. Omit `recursive` for normal workspace/worktree delegation; it defaults to true. scope: Vec, - /// Binds an actual read-only builtin Reviewer child to an immutable Merge Request revision. - /// Review attempt identity and capability material are generated by the trusted spawn layer. + /// Binds an actual read-only builtin Reviewer child to the current Merge Request candidate. + /// Review capability material is generated by the trusted spawn layer. #[serde(default)] review: Option, } @@ -67,7 +67,7 @@ struct SubWorkerSpawnInput { #[derive(Debug, Deserialize, schemars::JsonSchema)] struct ReviewerHandoffInput { ticket_id: String, - revision_id: String, + expected_head_commit: String, } #[derive(Debug, Deserialize, schemars::JsonSchema)] @@ -337,9 +337,9 @@ fn validate_reviewer_handoff(input: &SubWorkerSpawnInput) -> Result<(), ToolErro let Some(review) = &input.review else { return Ok(()); }; - if review.ticket_id.trim().is_empty() || review.revision_id.trim().is_empty() { + if review.ticket_id.trim().is_empty() || review.expected_head_commit.trim().is_empty() { return Err(ToolError::InvalidArgument( - "reviewer handoff requires non-empty ticket_id and revision_id".to_string(), + "reviewer handoff requires non-empty ticket_id and expected_head_commit".to_string(), )); } if input.profile.as_deref() != Some("builtin:reviewer") { @@ -418,11 +418,10 @@ impl Tool for SubWorkerSpawnTool { .map_err(|error| { ToolError::ExecutionFailed(format!("resolve child manifest: {error}")) })?; - let reviewer_attempt = input.review.as_ref().map(|review| { + let reviewer_capability = input.review.as_ref().map(|review| { ( review.ticket_id.clone(), - review.revision_id.clone(), - uuid::Uuid::now_v7().to_string(), + review.expected_head_commit.clone(), format!( "{}{}", uuid::Uuid::now_v7().simple(), @@ -431,7 +430,8 @@ impl Tool for SubWorkerSpawnTool { ) }); let child_workspace_context = - if let Some((ticket_id, revision_id, _, capability_token)) = &reviewer_attempt { + if let Some((ticket_id, expected_head_commit, capability_token)) = &reviewer_capability + { let workspace_id = self.workspace_context .workspace_id() @@ -450,9 +450,9 @@ impl Tool for SubWorkerSpawnTool { let child_client: Arc = Arc::new(ReviewerChildWorkspaceClient::new( parent_client.clone(), - ReviewerAttemptContext { + ReviewerContext { ticket_id: ticket_id.clone(), - revision_id: revision_id.clone(), + expected_head_commit: expected_head_commit.clone(), }, capability_token.clone(), )); @@ -547,9 +547,9 @@ impl Tool for SubWorkerSpawnTool { } }; - if let Some((ticket_id, revision_id, attempt_id, capability_token)) = &reviewer_attempt { + if let Some((ticket_id, expected_head_commit, capability_token)) = &reviewer_capability { let workspace_id = self.workspace_context.workspace_id().ok_or_else(|| { - ToolError::ExecutionFailed("reviewer attempt lost Workspace identity".to_string()) + ToolError::ExecutionFailed("review capability lost Workspace identity".to_string()) })?; let child_session_id = session.session_id_string(); let child_registration = WorkspaceRequest::json( @@ -577,15 +577,14 @@ impl Tool for SubWorkerSpawnTool { ))); } let body = serde_json::json!({ - "attempt_id": attempt_id, - "revision_id": revision_id, + "expected_head_commit": expected_head_commit, "child_session_id": child_session_id, "capability_token": capability_token, }); let request = WorkspaceRequest::json( WorkspaceRequestMethod::Post, format!( - "/api/w/{}/tickets/{}/merge-request/review-attempts", + "/api/w/{}/tickets/{}/merge-request/review-capabilities", workspace_id.as_str(), ticket_id ), @@ -596,12 +595,12 @@ impl Tool for SubWorkerSpawnTool { .client() .execute(request) .map_err(|error| { - ToolError::ExecutionFailed(format!("register reviewer attempt: {error}")) + ToolError::ExecutionFailed(format!("register review capability: {error}")) })?; if !response.is_success() { let _ = session.stop().await; return Err(ToolError::ExecutionFailed(format!( - "register reviewer attempt failed with status {}: {}", + "register review capability failed with status {}: {}", response.status, response.body ))); } @@ -1044,21 +1043,21 @@ mod tests { let valid: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({ "name":"reviewer","task":"review","profile":"builtin:reviewer", "scope":[{"target":"/tmp/work","permission":"read"}], - "review":{"ticket_id":"T1","revision_id":"V1"} + "review":{"ticket_id":"T1","expected_head_commit":"V1"} })) .unwrap(); assert!(validate_reviewer_handoff(&valid).is_ok()); let wrong_profile: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({ "name":"reviewer","task":"review","profile":"builtin:coder", "scope":[{"target":"/tmp/work","permission":"read"}], - "review":{"ticket_id":"T1","revision_id":"V1"} + "review":{"ticket_id":"T1","expected_head_commit":"V1"} })) .unwrap(); assert!(validate_reviewer_handoff(&wrong_profile).is_err()); let writable: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({ "name":"reviewer","task":"review","profile":"builtin:reviewer", "scope":[{"target":"/tmp/work","permission":"write"}], - "review":{"ticket_id":"T1","revision_id":"V1"} + "review":{"ticket_id":"T1","expected_head_commit":"V1"} })) .unwrap(); assert!(validate_reviewer_handoff(&writable).is_err()); diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index a9b5bd6d..9f7fa723 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -238,30 +238,30 @@ pub trait WorkspaceClient: std::fmt::Debug + Send + Sync { )) } - /// Trusted review-attempt context is injected by the Internal SubWorker spawn layer. + /// Trusted review capability context is injected by the Internal SubWorker spawn layer. /// It is never accepted from a model-visible tool argument. - fn reviewer_attempt_context(&self) -> Option<&ReviewerAttemptContext> { + fn reviewer_context(&self) -> Option<&ReviewerContext> { None } } #[derive(Debug, Clone, PartialEq, Eq)] -pub struct ReviewerAttemptContext { +pub struct ReviewerContext { pub ticket_id: String, - pub revision_id: String, + pub expected_head_commit: String, } #[derive(Debug)] pub struct ReviewerChildWorkspaceClient { inner: Arc, - context: ReviewerAttemptContext, + context: ReviewerContext, capability_token: String, } impl ReviewerChildWorkspaceClient { pub fn new( inner: Arc, - context: ReviewerAttemptContext, + context: ReviewerContext, capability_token: String, ) -> Self { Self { @@ -282,7 +282,7 @@ impl WorkspaceClient for ReviewerChildWorkspaceClient { fn is_available(&self) -> bool { self.inner.is_available() } - fn reviewer_attempt_context(&self) -> Option<&ReviewerAttemptContext> { + fn reviewer_context(&self) -> Option<&ReviewerContext> { Some(&self.context) } @@ -307,8 +307,8 @@ impl WorkspaceClient for ReviewerChildWorkspaceClient { ) })?; object.insert( - "revision_id".to_string(), - serde_json::Value::String(self.context.revision_id.clone()), + "expected_head_commit".to_string(), + serde_json::Value::String(self.context.expected_head_commit.clone()), ); object.insert( "capability_token".to_string(), @@ -450,9 +450,9 @@ mod reviewer_client_tests { }); let client = ReviewerChildWorkspaceClient::new( inner, - ReviewerAttemptContext { + ReviewerContext { ticket_id: "T1".into(), - revision_id: "V1".into(), + expected_head_commit: "head".into(), }, "secret".into(), ); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index d24e8dd5..19067849 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -1324,16 +1324,16 @@ pub fn build_router(api: WorkspaceApi) -> Router { get(scoped_merge_request_readiness), ) .route( - "/api/w/{workspace_id}/tickets/{id}/merge-request/revisions", - post(scoped_add_merge_request_revision), + "/api/w/{workspace_id}/tickets/{id}/merge-request/review-requests", + post(scoped_request_merge_request_review), ) .route( "/api/w/{workspace_id}/internal/reviewer-child-sessions", post(scoped_register_reviewer_child_session), ) .route( - "/api/w/{workspace_id}/tickets/{id}/merge-request/review-attempts", - post(scoped_register_merge_request_review_attempt), + "/api/w/{workspace_id}/tickets/{id}/merge-request/review-capabilities", + post(scoped_register_merge_request_review_capability), ) .route( "/api/w/{workspace_id}/tickets/{id}/merge-request/reviews", @@ -1343,6 +1343,10 @@ pub fn build_router(api: WorkspaceApi) -> Router { "/api/w/{workspace_id}/tickets/{id}/merge-request/complete", post(scoped_complete_merge_request), ) + .route( + "/api/w/{workspace_id}/tickets/{id}/merge-request/close", + post(scoped_close_merge_request), + ) .route( "/api/w/{workspace_id}/tickets/{id}/merge-request/reopen", post(scoped_reopen_merge_request), @@ -3577,7 +3581,8 @@ async fn scoped_queue_ticket_record( #[derive(Debug, serde::Deserialize)] struct OpenMergeRequestRequest { repository_id: String, - revision_id: String, + selector_from: String, + selector_to: String, base_commit: String, head_commit: String, #[serde(default)] @@ -3587,9 +3592,8 @@ struct OpenMergeRequestRequest { } #[derive(Debug, serde::Deserialize)] -struct AddMergeRequestRevisionRequest { - expected_current_revision_id: String, - revision_id: String, +struct RequestMergeRequestReviewRequest { + expected_head_commit: String, base_commit: String, head_commit: String, #[serde(default)] @@ -3604,16 +3608,15 @@ struct RegisterReviewerChildSessionRequest { } #[derive(Debug, serde::Deserialize)] -struct RegisterMergeRequestReviewAttemptRequest { - attempt_id: String, - revision_id: String, +struct RegisterMergeRequestReviewCapabilityRequest { + expected_head_commit: String, child_session_id: String, capability_token: String, } #[derive(Debug, serde::Deserialize)] struct SubmitMergeRequestReviewRequest { - revision_id: String, + expected_head_commit: String, capability_token: String, decision: merge_request::ReviewDecision, #[serde(default)] @@ -3625,17 +3628,18 @@ struct SubmitMergeRequestReviewRequest { #[derive(Debug, serde::Deserialize)] struct CompleteMergeRequestRequest { operation_id: String, - expected_revision_id: String, + expected_head_commit: String, target_commit: String, source_commit: String, result_commit: String, strategy: merge_request::MergeStrategy, - resolution: merge_request::MergeResolution, + resolution: merge_request::ConflictResolution, } #[derive(Debug, serde::Deserialize)] -struct RevisionTransitionRequest { - expected_revision_id: String, +struct MergeRequestStateRequest { + #[serde(default)] + body: String, explicit_confirmation: bool, } @@ -3653,14 +3657,84 @@ fn require_workspace_access(workspace_id: &str, api: &WorkspaceApi) -> ApiResult Ok(()) } +#[derive(Clone)] +struct MergeRequestAssignmentSource { + store: Arc, +} + +impl merge_request::AssignmentSource for MergeRequestAssignmentSource { + fn current_assignment( + &self, + workspace_id: &str, + ticket_id: &str, + ) -> std::result::Result, String> { + self.store + .get_current_ticket_worker_assignment(workspace_id, ticket_id) + .map(|value| { + value.map(|assignment| merge_request::CurrentAssignment { + assignment_id: assignment.assignment_id, + ticket_id: ticket_id.to_string(), + runtime_id: assignment.worker.runtime_id, + worker_id: assignment.worker.worker_id, + }) + }) + .map_err(|error| error.to_string()) + } +} + +#[derive(Clone)] +struct MergeRequestRepositorySource { + workspace_id: String, + reader: RepositoryRegistryReader, +} + +impl merge_request::RepositorySource for MergeRequestRepositorySource { + fn repository_belongs_to_workspace( + &self, + workspace_id: &str, + repository_id: &str, + ) -> std::result::Result { + if workspace_id != self.workspace_id { + return Ok(false); + } + Ok(self.reader.summary(repository_id).is_ok()) + } + + fn is_ancestor( + &self, + workspace_id: &str, + repository_id: &str, + ancestor: &str, + descendant: &str, + ) -> std::result::Result { + if workspace_id != self.workspace_id { + return Ok(false); + } + match self + .reader + .ensure_ancestor(repository_id, ancestor, descendant) + { + Ok(()) => Ok(true), + Err(RepositoryLookupError::InvalidCommitRelation { .. }) => Ok(false), + Err(error) => Err(format!("{error:?}")), + } + } +} + fn merge_request_store( api: &WorkspaceApi, workspace_id: &str, -) -> ApiResult { +) -> ApiResult { require_workspace_access(workspace_id, api)?; - merge_request::SqliteMergeRequestStore::open_verified( + merge_request::MergeRequestStore::open( api.config.database_path.clone(), - workspace_id, + Arc::new(MergeRequestAssignmentSource { + store: api.store.clone(), + }), + Arc::new(MergeRequestRepositorySource { + workspace_id: workspace_id.to_string(), + reader: api.repository_reader(), + }), ) .map_err(Error::from) .map_err(Into::into) @@ -3724,51 +3798,34 @@ fn validate_revision_evidence( Ok((base.commit, source.commit)) } -fn observe_merge_request_target( - api: &WorkspaceApi, - mr: &merge_request::MergeRequest, -) -> Option { - let selector = mr.target_ref_selector.as_deref()?; - api.repository_reader() - .observe_merge_target(&mr.repository_id, Some(selector)) - .ok() - .map(|target| target.commit) -} - async fn scoped_show_merge_request( State(api): State, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, ) -> ApiResult> { let workspace_id = parse_workspace_id(&workspace_id)?; - let store = merge_request_store(&api, &workspace_id)?; - let current = store.show_for_ticket(&ticket_id)?.ok_or_else(|| { - Error::from(merge_request::MergeRequestError::NotFound( - ticket_id.clone(), - )) - })?; - let target_commit = observe_merge_request_target(&api, ¤t); - let value = store - .show_for_ticket_with_target(&ticket_id, target_commit.as_deref())? - .ok_or_else(|| Error::from(merge_request::MergeRequestError::NotFound(ticket_id)))?; - Ok(Json(value)) + Ok(Json( + merge_request_store(&api, &workspace_id)?.get(&workspace_id, &ticket_id)?, + )) } async fn scoped_merge_request_readiness( State(api): State, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, -) -> ApiResult> { +) -> ApiResult> { let workspace_id = parse_workspace_id(&workspace_id)?; let store = merge_request_store(&api, &workspace_id)?; - let current = store.show_for_ticket(&ticket_id)?.ok_or_else(|| { - Error::from(merge_request::MergeRequestError::NotFound( - ticket_id.clone(), - )) - })?; - let target_commit = observe_merge_request_target(&api, ¤t); - Ok(Json(store.readiness_for_ticket_with_target( - &ticket_id, - target_commit.as_deref(), - )?)) + let mr = store.get(&workspace_id, &ticket_id)?; + Ok(Json(store.readiness(merge_request::ReadinessCheck { + ticket_id, + expected_head_commit: None, + auth: merge_request::MergeRequestAuth { + workspace_id, + repository_id: mr.repository_id, + runtime_id: String::new(), + worker_id: String::new(), + assignment_id: String::new(), + }, + })?)) } async fn scoped_open_merge_request( @@ -3784,13 +3841,13 @@ async fn scoped_open_merge_request( .store .get_current_ticket_worker_assignment(&workspace_id, &ticket_id)? .ok_or_else(|| { - Error::TicketAssignmentConflict("Ticket has no current assigned Coder".to_string()) + Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into()) })?; if assignment.worker.runtime_id != source.runtime_id || assignment.worker.worker_id != source.worker_id { return Err(Error::TicketAssignmentConflict( - "authenticated Worker is not the current Ticket assignee".to_string(), + "authenticated Worker is not the current Ticket assignee".into(), ) .into()); } @@ -3801,38 +3858,54 @@ async fn scoped_open_merge_request( &input.base_commit, &input.head_commit, )?; - let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); - let revision = merge_request::MergeRequestRevision { - revision_id: input.revision_id, - ordinal: 1, - base_commit, - head_commit: source_commit, - changed_paths: input.changed_paths, - summary: input.summary, - assignment_id: assignment.assignment_id.clone(), - created_at: now.clone(), - }; + if input.selector_to != target.selector { + return Err(Error::InvalidInput( + "selector_to must match the authoritative Ticket target selector".into(), + ) + .into()); + } + let observed_source = api + .repository_reader() + .observe_merge_target(&input.repository_id, Some(&input.selector_from)) + .map_err(repository_merge_evidence_error)?; + if observed_source.commit != source_commit { + return Err(Error::InvalidInput( + "selector_from does not resolve to the nominated head commit".into(), + ) + .into()); + } let mr = merge_request_store(&api, &workspace_id)?.open_merge_request( merge_request::OpenMergeRequest { - merge_request_id: format!("mr_{}", Uuid::now_v7().simple()), + merge_request_id: Uuid::now_v7().to_string(), ticket_id, - repository_id: input.repository_id, - target_ref_selector: target.selector, - revision, - authenticated_runtime_id: source.runtime_id, - authenticated_worker_id: source.worker_id, - now, + repository_id: input.repository_id.clone(), + selector_from: input.selector_from, + selector_to: input.selector_to, + request: merge_request::RequestForReview { + base_commit, + head_commit: source_commit, + changed_paths: input.changed_paths, + summary: input.summary, + }, + auth: merge_request::MergeRequestAuth { + workspace_id, + repository_id: input.repository_id, + runtime_id: source.runtime_id, + worker_id: source.worker_id, + assignment_id: assignment.assignment_id, + }, + now: Utc::now(), }, )?; Ok(Json(mr)) } -async fn scoped_add_merge_request_revision( +async fn scoped_request_merge_request_review( State(api): State, headers: HeaderMap, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, - Json(input): Json, -) -> ApiResult> { + Json(input): Json, +) -> ApiResult> { let workspace_id = parse_workspace_id(&workspace_id)?; require_workspace_access(&workspace_id, &api)?; let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?; @@ -3850,39 +3923,44 @@ async fn scoped_add_merge_request_revision( ) .into()); } - let current = merge_request_store(&api, &workspace_id)? - .show_for_ticket(&ticket_id)? - .ok_or_else(|| { - Error::from(merge_request::MergeRequestError::NotFound( - ticket_id.clone(), - )) - })?; - let (base_commit, head_commit) = validate_revision_evidence( + let store = merge_request_store(&api, &workspace_id)?; + let current = store.get(&workspace_id, &ticket_id)?; + let (base_commit, source_commit) = validate_revision_evidence( &api, ¤t.repository_id, &input.base_commit, &input.head_commit, )?; - let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); - let mr = - merge_request_store(&api, &workspace_id)?.add_revision(merge_request::AddRevision { + let observed_source = api + .repository_reader() + .observe_merge_target(¤t.repository_id, Some(¤t.selector_from)) + .map_err(repository_merge_evidence_error)?; + if observed_source.commit != source_commit { + return Err(Error::InvalidInput( + "selector_from does not resolve to the nominated head commit".into(), + ) + .into()); + } + Ok(Json(store.request_review( + merge_request::RequestMergeRequestReview { ticket_id, - expected_current_revision_id: input.expected_current_revision_id, - revision: merge_request::MergeRequestRevision { - revision_id: input.revision_id, - ordinal: current.current_revision.ordinal + 1, + expected_head_commit: input.expected_head_commit, + request: merge_request::RequestForReview { base_commit, - head_commit, + head_commit: source_commit, changed_paths: input.changed_paths, summary: input.summary, - assignment_id: assignment.assignment_id, - created_at: now.clone(), }, - authenticated_runtime_id: source.runtime_id, - authenticated_worker_id: source.worker_id, - now, - })?; - Ok(Json(mr)) + auth: merge_request::MergeRequestAuth { + workspace_id, + repository_id: current.repository_id, + runtime_id: source.runtime_id, + worker_id: source.worker_id, + assignment_id: assignment.assignment_id, + }, + now: Utc::now(), + }, + )?)) } async fn scoped_register_reviewer_child_session( @@ -3896,20 +3974,22 @@ async fn scoped_register_reviewer_child_session( let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?; merge_request_store(&api, &workspace_id)?.register_reviewer_child_session( merge_request::RegisterReviewerChildSession { + workspace_id, parent_runtime_id: source.runtime_id, parent_worker_id: source.worker_id, child_session_id: input.child_session_id, - now: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), + reviewer_profile: "builtin:reviewer".into(), + now: Utc::now(), }, )?; Ok(StatusCode::NO_CONTENT) } -async fn scoped_register_merge_request_review_attempt( +async fn scoped_register_merge_request_review_capability( State(api): State, headers: HeaderMap, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, - Json(input): Json, + Json(input): Json, ) -> ApiResult { let workspace_id = parse_workspace_id(&workspace_id)?; require_workspace_access(&workspace_id, &api)?; @@ -3928,19 +4008,22 @@ async fn scoped_register_merge_request_review_attempt( ) .into()); } - merge_request_store(&api, &workspace_id)?.register_review_attempt( - merge_request::RegisterReviewAttempt { - attempt_id: input.attempt_id, - ticket_id, - revision_id: input.revision_id, - parent_assignment_id: assignment.assignment_id, - parent_runtime_id: source.runtime_id, - parent_worker_id: source.worker_id, - child_session_id: input.child_session_id, - capability_token: input.capability_token, - now: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), + let store = merge_request_store(&api, &workspace_id)?; + let current = store.get(&workspace_id, &ticket_id)?; + store.register_review_capability(merge_request::RegisterReviewCapability { + ticket_id, + expected_head_commit: input.expected_head_commit, + child_session_id: input.child_session_id, + capability_token: input.capability_token, + auth: merge_request::MergeRequestAuth { + workspace_id, + repository_id: current.repository_id, + runtime_id: source.runtime_id, + worker_id: source.worker_id, + assignment_id: assignment.assignment_id, }, - )?; + now: Utc::now(), + })?; Ok(StatusCode::NO_CONTENT) } @@ -3948,19 +4031,21 @@ async fn scoped_submit_merge_request_review( State(api): State, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, Json(input): Json, -) -> ApiResult> { +) -> ApiResult> { let workspace_id = parse_workspace_id(&workspace_id)?; - let review = - merge_request_store(&api, &workspace_id)?.submit_review(merge_request::SubmitReview { - ticket_id, - revision_id: input.revision_id, - capability_token: input.capability_token, - decision: input.decision, - body: input.body, - findings: input.findings, - now: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), - })?; - Ok(Json(review)) + Ok(Json( + merge_request_store(&api, &workspace_id)?.submit_review( + merge_request::SubmitMergeRequestReview { + ticket_id, + expected_head_commit: input.expected_head_commit, + capability_token: input.capability_token, + decision: input.decision, + body: input.body, + findings: input.findings, + now: Utc::now(), + }, + )?, + )) } async fn scoped_complete_merge_request( @@ -3968,7 +4053,7 @@ async fn scoped_complete_merge_request( headers: HeaderMap, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, Json(input): Json, -) -> ApiResult> { +) -> ApiResult> { let workspace_id = parse_workspace_id(&workspace_id)?; require_workspace_access(&workspace_id, &api)?; let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?; @@ -3980,182 +4065,101 @@ async fn scoped_complete_merge_request( Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into()) })?; let store = merge_request_store(&api, &workspace_id)?; - let mr = store - .show_for_ticket(&ticket_id)? - .ok_or_else(|| merge_request::MergeRequestError::NotFound(ticket_id.clone()))?; - if mr.current_revision.revision_id != input.expected_revision_id { - return Err(merge_request::MergeRequestError::StaleRevision { - expected: input.expected_revision_id, - current: mr.current_revision.revision_id, - } - .into()); - } - if mr.review_status != merge_request::ReviewStatus::Approved { - return Err(merge_request::MergeRequestError::NotApproved.into()); - } - if input.source_commit != mr.current_revision.head_commit { - return Err(merge_request::MergeRequestError::InvalidMergeOutcome( - "source commit does not match the current approved revision".into(), + let mr = store.get(&workspace_id, &ticket_id)?; + let current_request = mr.current_request().ok_or_else(|| { + Error::InvalidInput("Merge Request has no current RequestForReview event".into()) + })?; + if current_request.head_commit != input.expected_head_commit + || input.source_commit != current_request.head_commit + { + return Err(Error::InvalidInput( + "source commit does not match the current review request".into(), ) .into()); } - if mr.state == merge_request::MergeRequestState::Merged { - return Ok(Json(store.complete( - merge_request::CompleteMergeRequest { - operation_id: input.operation_id, - ticket_id, - expected_revision_id: mr.current_revision.revision_id, - target_commit: input.target_commit, - source_commit: input.source_commit, - result_commit: input.result_commit, - strategy: input.strategy, - resolution: input.resolution, - implementation_assignment_id: assignment.assignment_id, - completion_actor_runtime_id: source.runtime_id, - completion_actor_worker_id: source.worker_id, - now: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), - }, - )?)); - } - let selector = mr - .target_ref_selector - .as_deref() - .ok_or(merge_request::MergeRequestError::UnknownTarget)?; let repositories = api.repository_reader(); let observed_target = repositories - .observe_merge_target(&mr.repository_id, Some(selector)) + .observe_merge_target(&mr.repository_id, Some(&mr.selector_to)) .map_err(repository_merge_evidence_error)?; - let source_commit = repositories - .observe_commit(&mr.repository_id, &input.source_commit) - .map_err(repository_merge_evidence_error)?; - if source_commit.commit != input.source_commit { - return Err(Error::InvalidInput("source commit must be canonical".into()).into()); - } - let result_commit = repositories - .observe_commit(&mr.repository_id, &input.result_commit) - .map_err(repository_merge_evidence_error)?; - if result_commit.commit != input.result_commit { - return Err(Error::InvalidInput("result commit must be canonical".into()).into()); - } - match input.strategy { - merge_request::MergeStrategy::FastForward => { - if input.resolution != merge_request::MergeResolution::None - || input.result_commit != input.source_commit - { - return Err(merge_request::MergeRequestError::InvalidMergeOutcome( - "fast-forward result must equal the approved source and use resolution=none" - .into(), - ) - .into()); - } - repositories - .ensure_ancestor( - &mr.repository_id, - &input.target_commit, - &input.source_commit, - ) - .map_err(repository_merge_evidence_error)?; - } - merge_request::MergeStrategy::Merge => { - if input.resolution == merge_request::MergeResolution::None - || result_commit.parents - != vec![input.target_commit.clone(), input.source_commit.clone()] - { - return Err(merge_request::MergeRequestError::InvalidMergeOutcome( - "merge result must have the expected target and approved source as its two ordered parents" - .into(), - ) - .into()); - } - } - } - let target_was_already_updated = observed_target.commit == input.result_commit; - if observed_target.commit != input.target_commit && !target_was_already_updated { + if observed_target.commit != input.target_commit + && observed_target.commit != input.result_commit + { return Err(Error::InvalidInput(format!( "Merge Request target moved: expected {}, observed {}", input.target_commit, observed_target.commit )) .into()); } + let target_was_already_updated = observed_target.commit == input.result_commit; if !target_was_already_updated { repositories .update_merge_target( &mr.repository_id, - selector, + &mr.selector_to, &input.target_commit, &input.result_commit, ) .map_err(repository_merge_evidence_error)?; } - let verified_target = repositories.observe_merge_target(&mr.repository_id, Some(selector)); - let verified_result = matches!( - verified_target.as_ref(), - Ok(target) if target.commit == input.result_commit - ); - if !verified_result { - if !target_was_already_updated { - if let Err(rollback_error) = repositories.update_merge_target( - &mr.repository_id, - selector, - &input.result_commit, - &input.target_commit, - ) { - return Err(Error::InvalidInput(format!( - "post-update target verification failed and guarded rollback also failed: verification={verified_target:?}; rollback={rollback_error:?}" - )) - .into()); - } - } - return Err(Error::InvalidInput(format!( - "post-update target verification failed: expected result {}, observed {:?}", - input.result_commit, - verified_target - .as_ref() - .map(|target| target.commit.as_str()) - .map_err(|error| error) - )) - .into()); - } let completion = merge_request::CompleteMergeRequest { - operation_id: input.operation_id, ticket_id, - expected_revision_id: mr.current_revision.revision_id, + expected_head_commit: input.expected_head_commit, + operation_id: input.operation_id, target_commit: input.target_commit.clone(), source_commit: input.source_commit, result_commit: input.result_commit.clone(), strategy: input.strategy, resolution: input.resolution, - implementation_assignment_id: assignment.assignment_id, - completion_actor_runtime_id: source.runtime_id, - completion_actor_worker_id: source.worker_id, - now: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), + auth: merge_request::MergeRequestAuth { + workspace_id, + repository_id: mr.repository_id.clone(), + runtime_id: source.runtime_id, + worker_id: source.worker_id, + assignment_id: assignment.assignment_id, + }, + now: Utc::now(), }; match store.complete(completion) { - Ok(outcome) => Ok(Json(outcome)), + Ok(event) => Ok(Json(event)), Err(error) => { if !target_was_already_updated { - if let Err(rollback_error) = repositories.update_merge_target( + let _ = repositories.update_merge_target( &mr.repository_id, - selector, + &mr.selector_to, &input.result_commit, &input.target_commit, - ) { - return Err(Error::InvalidInput(format!( - "merge finalization failed after target update and guarded rollback also failed: finalization={error}; rollback={rollback_error:?}" - )) - .into()); - } + ); } Err(error.into()) } } } +async fn scoped_close_merge_request( + State(api): State, + headers: HeaderMap, + AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, + Json(input): Json, +) -> ApiResult> { + scoped_change_merge_request_state(api, headers, workspace_id, ticket_id, input, false).await +} + async fn scoped_reopen_merge_request( State(api): State, headers: HeaderMap, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, - Json(input): Json, + Json(input): Json, +) -> ApiResult> { + scoped_change_merge_request_state(api, headers, workspace_id, ticket_id, input, true).await +} + +async fn scoped_change_merge_request_state( + api: WorkspaceApi, + headers: HeaderMap, + workspace_id: String, + ticket_id: String, + input: MergeRequestStateRequest, + reopen: bool, ) -> ApiResult> { let workspace_id = parse_workspace_id(&workspace_id)?; require_workspace_access(&workspace_id, &api)?; @@ -4164,11 +4168,25 @@ async fn scoped_reopen_merge_request( if !input.explicit_confirmation { return Err(Error::BrowserReopenConfirmationRequired.into()); } - Ok(Json(merge_request_store(&api, &workspace_id)?.reopen( - &ticket_id, - &input.expected_revision_id, - &Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), - )?)) + let store = merge_request_store(&api, &workspace_id)?; + let mr = store.get(&workspace_id, &ticket_id)?; + let operation = merge_request::ChangeMergeRequestState { + ticket_id, + body: input.body, + auth: merge_request::MergeRequestAuth { + workspace_id, + repository_id: mr.repository_id, + runtime_id: "browser".into(), + worker_id: "authenticated-user".into(), + assignment_id: String::new(), + }, + now: Utc::now(), + }; + Ok(Json(if reopen { + store.reopen(operation)? + } else { + store.close(operation)? + })) } fn reject_non_browser_reopen_auth(headers: &HeaderMap) -> Result<()> { @@ -11904,10 +11922,10 @@ impl IntoResponse for ApiError { StatusCode::BAD_REQUEST } Error::Ticket(ticket::TicketError::NotFound(_)) - | Error::MergeRequest(merge_request::MergeRequestError::NotFound(_)) => { + | Error::MergeRequest(merge_request::MergeRequestError::NotFound) => { StatusCode::NOT_FOUND } - Error::MergeRequest(merge_request::MergeRequestError::Empty(_)) => { + Error::MergeRequest(merge_request::MergeRequestError::Validation(_)) => { StatusCode::BAD_REQUEST } Error::MergeRequest(_) => StatusCode::CONFLICT, @@ -12796,53 +12814,59 @@ mod tests { merge_request_id: "MR-server-completion".into(), ticket_id: ticket.id.clone(), repository_id: TEST_REPOSITORY_ID.into(), - target_ref_selector: target_ref.clone(), - revision: merge_request::MergeRequestRevision { - revision_id: "V1".into(), - ordinal: 1, + selector_from: source_commit.clone(), + selector_to: target_ref.clone(), + request: merge_request::RequestForReview { base_commit: target_commit.clone(), head_commit: source_commit.clone(), - changed_paths: vec!["src/lib.rs".into()], - summary: "approved revision".into(), - assignment_id: assignment.assignment_id.clone(), - created_at: "t1".into(), + summary: "approved candidate".into(), }, - authenticated_runtime_id: coder.worker_ref.runtime_id.clone(), - authenticated_worker_id: coder.worker_ref.worker_id.clone(), - now: "t1".into(), + auth: merge_request::MergeRequestAuth { + workspace_id: workspace_id.clone(), + repository_id: TEST_REPOSITORY_ID.into(), + runtime_id: coder.worker_ref.runtime_id.clone(), + worker_id: coder.worker_ref.worker_id.clone(), + assignment_id: assignment.assignment_id.clone(), + }, + now: Utc::now(), }) .unwrap(); mr_store .register_reviewer_child_session(merge_request::RegisterReviewerChildSession { + workspace_id: workspace_id.clone(), parent_runtime_id: coder.worker_ref.runtime_id.clone(), parent_worker_id: coder.worker_ref.worker_id.clone(), child_session_id: "reviewer-child".into(), - now: "t2".into(), + reviewer_profile: "builtin:reviewer".into(), + now: Utc::now(), }) .unwrap(); mr_store - .register_review_attempt(merge_request::RegisterReviewAttempt { - attempt_id: "attempt".into(), + .register_review_capability(merge_request::RegisterReviewCapability { ticket_id: ticket.id.clone(), - revision_id: "V1".into(), - parent_assignment_id: assignment.assignment_id.clone(), - parent_runtime_id: coder.worker_ref.runtime_id.clone(), - parent_worker_id: coder.worker_ref.worker_id.clone(), + expected_head_commit: source_commit.clone(), child_session_id: "reviewer-child".into(), capability_token: "review-token".into(), - now: "t2".into(), + auth: merge_request::MergeRequestAuth { + workspace_id: workspace_id.clone(), + repository_id: TEST_REPOSITORY_ID.into(), + runtime_id: coder.worker_ref.runtime_id.clone(), + worker_id: coder.worker_ref.worker_id.clone(), + assignment_id: assignment.assignment_id.clone(), + }, + now: Utc::now(), }) .unwrap(); mr_store - .submit_review(merge_request::SubmitReview { + .submit_review(merge_request::SubmitMergeRequestReview { ticket_id: ticket.id.clone(), - revision_id: "V1".into(), + expected_head_commit: source_commit.clone(), capability_token: "review-token".into(), decision: merge_request::ReviewDecision::Approve, body: "approved".into(), findings: Vec::new(), - now: "t3".into(), + now: Utc::now(), }) .unwrap(); @@ -12860,12 +12884,12 @@ mod tests { }; let request = || CompleteMergeRequestRequest { operation_id: "complete-operation".into(), - expected_revision_id: "V1".into(), + expected_head_commit: source_commit.clone(), target_commit: target_commit.clone(), source_commit: source_commit.clone(), result_commit: source_commit.clone(), strategy: merge_request::MergeStrategy::FastForward, - resolution: merge_request::MergeResolution::None, + resolution: merge_request::ConflictResolution::None, }; let coder_error = scoped_complete_merge_request( State(api.clone()), @@ -12897,7 +12921,7 @@ mod tests { ) .await .unwrap(); - assert!(!completed.replayed); + assert_eq!(completed.result_commit, source_commit); assert_eq!( backend .show(ticket.id.clone().into()) @@ -12913,19 +12937,16 @@ mod tests { .commit, source_commit ); - let merged = mr_store.show_for_ticket(&ticket.id).unwrap().unwrap(); + let mr = mr_store.get(&api.config.workspace_id, &ticket.id).unwrap(); + assert_eq!(mr.state, merge_request::MergeRequestState::Merged); + let merge = mr.thread.iter().find_map(|event| match event { + merge_request::MergeRequestThreadEvent::Merge(value) => Some(value), + _ => None, + }); assert_eq!( - merged.merged_target_commit.as_deref(), - Some(target_commit.as_str()) - ); - assert_eq!( - merged.merged_result_commit.as_deref(), + merge.map(|value| value.result_commit.as_str()), Some(source_commit.as_str()) ); - assert_eq!( - merged.merge_strategy, - Some(merge_request::MergeStrategy::FastForward) - ); let Json(replayed) = scoped_complete_merge_request( State(api.clone()), worker_headers(&orchestrator), @@ -12934,7 +12955,7 @@ mod tests { ) .await .unwrap(); - assert!(replayed.replayed); + assert_eq!(replayed.result_commit, source_commit); let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); let actor: String = conn .query_row( diff --git a/resources/prompts/common/git.md b/resources/prompts/common/git.md index 2b05bc52..cdb63df7 100644 --- a/resources/prompts/common/git.md +++ b/resources/prompts/common/git.md @@ -10,4 +10,4 @@ When creating a commit, use the change type as the subject prefix, not the affec A change made because review, validation, or user feedback found a defect is a `fix:` even when it belongs to the same feature Ticket and has not been merged yet. Do not keep reusing a domain prefix such as `merge-request:`, `runtime:`, or `worker:` across a series; those labels identify where the code lives rather than why each commit exists. If one prospective commit contains distinct change types, split it into coherent validated commits when practical; otherwise name it for the dominant intent. -Before opening or appending an immutable Merge Request revision, inspect the proposed commit subjects and correct misclassified local, unshared commits when safe. Do not rewrite shared history solely to rename existing commits unless the user explicitly requests it. +Before opening a Merge Request or appending a `RequestForReview` event, inspect the proposed commit subjects and correct misclassified local, unshared commits when safe. Do not rewrite shared history solely to rename existing commits unless the user explicitly requests it. diff --git a/resources/prompts/role/coder.md b/resources/prompts/role/coder.md index 339c7b57..1823cd7c 100644 --- a/resources/prompts/role/coder.md +++ b/resources/prompts/role/coder.md @@ -4,6 +4,6 @@ Treat the first committed user message as the bounded Ticket/action context and {% include "common.git" %} -Before review, open or append an immutable Merge Request revision containing the exact base/head/tree and changed-path evidence. Spawn the Reviewer only as your actual direct-child `builtin:reviewer` SubWorker, delegate read-only scope, and include the structured `review` handoff with the Ticket id and current MR revision id. Reviewer prose is not approval: the child must commit `MergeRequestReviewSubmit` through its injected attempt authority. +Before review, open a Merge Request with immutable `selector_from` / `selector_to`, then append a `RequestForReview` thread event containing the exact base/head commit and changed-path evidence. Spawn the Reviewer only as your actual direct-child `builtin:reviewer` SubWorker, delegate read-only scope, and include the structured review handoff with the Ticket id and current candidate head commit. Reviewer prose is not approval: the child must commit `MergeRequestReviewSubmit` through its injected capability authority. -A request-changes result requires a new immutable revision and a fresh Reviewer child attempt. Flow terminal state is not Ticket completion authority. Complete only through `MergeRequestComplete` with a unique operation id and the currently approved revision; the Server revalidates assignment and fences Ticket state side effects. +A request-changes result requires a new `RequestForReview` event and a fresh Reviewer child capability. Flow terminal state is not Ticket completion authority. Complete only through `MergeRequestComplete` with a unique operation id and the currently approved candidate head commit; the Server revalidates assignment and fences Ticket state side effects. diff --git a/resources/prompts/role/orchestrator.md b/resources/prompts/role/orchestrator.md index dc3e889b..08ac7478 100644 --- a/resources/prompts/role/orchestrator.md +++ b/resources/prompts/role/orchestrator.md @@ -4,7 +4,7 @@ You are the Ticket Orchestrator role. 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. Record `inprogress` before implementation side effects, then use `SpawnTicketCoder` so Worker creation, the fixed Coder profile/Flow, and the current Ticket assignment are one guarded operation. After spawn, reread the Ticket and verify 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, and stop for human authority when merge/closure is not explicitly delegated. -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 current-revision durable review evidence is missing, indeterminate, 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. +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 `RequestForReview` candidate is missing, indeterminate, 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. Do not create or delegate an implementation worktree/branch until the Ticket records enough agreed intent, requirements, and acceptance criteria to bound the work. diff --git a/resources/prompts/role/reviewer.md b/resources/prompts/role/reviewer.md index 67334119..4e29746b 100644 --- a/resources/prompts/role/reviewer.md +++ b/resources/prompts/role/reviewer.md @@ -1,7 +1,7 @@ You are the Ticket Reviewer role running as an actual Runtime-owned direct child of the assigned Coder. -Keep role behavior here and treat the first committed user message as bounded Ticket/Merge Request context only. Review the immutable current Merge Request revision against Ticket intent, binding decisions/invariants, acceptance criteria, and project design boundaries. Use read-only inspection and focused validation; do not merge, close, mutate the Workdir, or take over implementation. +Keep role behavior here and treat the first committed user message as bounded Ticket/Merge Request context only. Review the current `RequestForReview` candidate against Ticket intent, binding decisions/invariants, acceptance criteria, and project design boundaries. Use read-only inspection and focused validation; do not merge, close, mutate the Workdir, or take over implementation. -Your prose response is not review authority. Before finishing, call `MergeRequestReviewSubmit` exactly once with `approve` or `request_changes`, a bounded evidence summary, and concrete structured findings. Attempt identity and revision identity are injected by your child Workspace client and are not model inputs. If the authoritative revision changed, submission must fail rather than approving stale work. +Your prose response is not review authority. Before finishing, call `MergeRequestReviewSubmit` exactly once with `approve` or `request_changes`, a bounded evidence summary, and concrete structured findings. Capability authority and the expected candidate head commit are injected by your child Workspace client and are not model inputs. If a newer `RequestForReview` event supersedes the candidate, submission must fail rather than approving stale work. Review more than the diff: verify the implementation satisfies the Ticket intent and acceptance criteria, remains coherent with the codebase design, and does not introduce unnecessary compatibility. diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte index 5696a486..673cd1ff 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte @@ -17,22 +17,37 @@ TicketDetail, } from "$lib/workspace/sidebar/types"; + type MergeRequestThreadEvent = + | { + kind: "request_for_review"; + event_seq: number; + head_commit: string; + changed_paths: string[]; + summary: string; + } + | { + kind: "review"; + event_seq: number; + request_event_seq: number; + decision: "approve" | "request_changes"; + body: string; + reviewer_profile: string; + } + | { + kind: "merge"; + event_seq: number; + result_commit: string; + strategy: "fast_forward" | "merge"; + resolution: "none" | "clean" | "conflicts_resolved"; + merged_by: { runtime_id: string; worker_id: string }; + } + | { kind: "reopen" | "close"; event_seq: number; body: string }; + type MergeRequestDetail = { - state: "draft" | "open" | "closed" | "merged"; - review_status: "pending" | "approved" | "changes_requested"; - target_ref_selector?: string | null; - target_status: "known" | "unknown"; - observed_target_commit?: string | null; - current_revision: { revision_id: string; head_commit: string; changed_paths: string[]; summary: string }; - current_review?: { decision: string; body: string; reviewer_effective_profile: string } | null; - merged_revision_id?: string | null; - merged_target_commit?: string | null; - merged_result_commit?: string | null; - merge_strategy?: "fast_forward" | "merge" | null; - merge_resolution?: "none" | "clean" | "conflicts_resolved" | null; - merged_by_runtime_id?: string | null; - merged_by_worker_id?: string | null; - merged_at?: string | null; + state: "open" | "closed" | "merged"; + selector_from: string; + selector_to: string; + thread: MergeRequestThreadEvent[]; }; const MUTABLE_TICKET_STATES = TICKET_STATES.filter((state) => state !== "done"); @@ -56,6 +71,21 @@ let ticket = $state(loadedTicket); let mergeRequest = $state(initialData.mergeRequest.data ?? null); + const currentReviewRequest = $derived( + mergeRequest?.thread.findLast((event) => event.kind === "request_for_review") ?? null, + ); + const currentReview = $derived( + currentReviewRequest + ? mergeRequest?.thread.findLast( + (event) => + event.kind === "review" && + event.request_event_seq === currentReviewRequest.event_seq, + ) ?? null + : null, + ); + const mergeEvent = $derived( + mergeRequest?.thread.findLast((event) => event.kind === "merge") ?? null, + ); let editing = $state(false); let editTitle = $state(loadedTicket.title); let editBody = $state(loadedTicket.body); @@ -353,28 +383,21 @@ {#if data.mergeRequest.error}

{data.mergeRequest.error}

{:else if mergeRequest} -

{mergeRequest.state} · {mergeRequest.review_status}

-

Target {mergeRequest.target_ref_selector ?? "unknown"} · {mergeRequest.target_status}

- {#if mergeRequest.observed_target_commit}

Target tip {mergeRequest.observed_target_commit}

{/if} -

{mergeRequest.current_revision.revision_id}

-

Head {mergeRequest.current_revision.head_commit}

- {#if mergeRequest.merged_result_commit} -

- Final merge · {mergeRequest.merge_strategy} / {mergeRequest.merge_resolution} -

-

- Target before {mergeRequest.merged_target_commit} · result - {mergeRequest.merged_result_commit} -

-

- Revision {mergeRequest.merged_revision_id} · completed by - {mergeRequest.merged_by_runtime_id}/{mergeRequest.merged_by_worker_id} -

+

{mergeRequest.state}

+

From {mergeRequest.selector_from}

+

To {mergeRequest.selector_to}

+ {#if currentReviewRequest?.kind === "request_for_review"} +

Candidate {currentReviewRequest.head_commit}

+ {#if currentReviewRequest.summary}

{currentReviewRequest.summary}

{/if} {/if} - {#if mergeRequest.current_revision.summary}

{mergeRequest.current_revision.summary}

{/if} - {#if mergeRequest.current_review} -

{mergeRequest.current_review.decision} by {mergeRequest.current_review.reviewer_effective_profile}

- {#if mergeRequest.current_review.body}{/if} + {#if currentReview?.kind === "review"} +

{currentReview.decision} by {currentReview.reviewer_profile}

+ {#if currentReview.body}{/if} + {/if} + {#if mergeEvent?.kind === "merge"} +

Final merge · {mergeEvent.strategy} / {mergeEvent.resolution}

+

Result {mergeEvent.result_commit}

+

Completed by {mergeEvent.merged_by.runtime_id}/{mergeEvent.merged_by.worker_id}

{/if} {:else}

The assigned Coder has not opened a Merge Request.

From 1fe1b7463fb6458ec3335148fbe55b51d3a0daf2 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 17 Aug 2026 07:41:03 +0900 Subject: [PATCH 08/13] fix: align merge requests with selector thread contract --- Cargo.lock | 1 + crates/merge-request/Cargo.toml | 1 + crates/merge-request/src/lib.rs | 2238 ++++++----------- crates/merge-request/tests/store.rs | 564 ++--- .../src/feature/builtin/merge_request.rs | 308 +-- crates/worker/src/spawn/tool.rs | 19 +- crates/worker/src/worker.rs | 6 - crates/workspace-server/src/server.rs | 782 ++---- resources/prompts/common/git.md | 2 +- resources/prompts/role/coder.md | 4 +- resources/prompts/role/orchestrator.md | 2 +- resources/prompts/role/reviewer.md | 4 +- .../tickets/[ticketId]/+page.svelte | 81 +- 13 files changed, 1354 insertions(+), 2658 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9016734a..4d76aaca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2539,6 +2539,7 @@ dependencies = [ "serde_json", "tempfile", "thiserror 2.0.18", + "uuid", ] [[package]] diff --git a/crates/merge-request/Cargo.toml b/crates/merge-request/Cargo.toml index e8a5ed85..7c0b5664 100644 --- a/crates/merge-request/Cargo.toml +++ b/crates/merge-request/Cargo.toml @@ -10,6 +10,7 @@ rusqlite.workspace = true serde = { workspace = true, features = ["derive"] } serde_json.workspace = true thiserror.workspace = true +uuid = { workspace = true, features = ["v7"] } [dev-dependencies] tempfile.workspace = true diff --git a/crates/merge-request/src/lib.rs b/crates/merge-request/src/lib.rs index 74b52a11..b9dca36d 100644 --- a/crates/merge-request/src/lib.rs +++ b/crates/merge-request/src/lib.rs @@ -1,13 +1,16 @@ -use std::path::Path; -use std::sync::{Arc, Mutex}; - use chrono::{DateTime, Utc}; use rusqlite::{Connection, OptionalExtension, Transaction, params}; use serde::{Deserialize, Serialize}; +use std::{ + path::Path, + sync::{Arc, Mutex}, +}; use thiserror::Error; +use uuid::Uuid; const SCHEMA_VERSION: i64 = 12; const PREVIOUS_SCHEMA_VERSION: i64 = 11; +const MAX_BODY_BYTES: usize = 16 * 1024; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -16,35 +19,22 @@ pub enum MergeRequestState { Merged, Closed, } - impl MergeRequestState { - fn as_str(self) -> &'static str { - match self { - Self::Open => "open", - Self::Merged => "merged", - Self::Closed => "closed", - } - } - - fn parse(value: &str) -> Result { - match value { - "open" | "draft" => Ok(Self::Open), + fn parse(v: &str) -> Result { + match v { + "draft" | "open" => Ok(Self::Open), "merged" => Ok(Self::Merged), "closed" => Ok(Self::Closed), - other => Err(MergeRequestError::Corrupt(format!( - "unknown merge request state `{other}`" - ))), + _ => Err(MergeRequestError::Corrupt(format!("unknown state `{v}`"))), } } } - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ReviewDecision { Approve, RequestChanges, } - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum FindingSeverity { @@ -53,23 +43,22 @@ pub enum FindingSeverity { Minor, Note, } - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ReviewFinding { pub severity: FindingSeverity, #[serde(default, skip_serializing_if = "Option::is_none")] + pub code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub path: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub line: Option, - pub message: String, + pub body: String, } - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct WorkerIdentity { pub runtime_id: String, pub worker_id: String, } - #[derive(Debug, Clone, PartialEq, Eq)] pub struct MergeRequestAuth { pub workspace_id: String, @@ -80,47 +69,59 @@ pub struct MergeRequestAuth { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RequestForReview { - pub base_commit: String, - pub head_commit: String, - #[serde(default)] - pub changed_paths: Vec, - #[serde(default)] - pub summary: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RequestForReviewEvent { - pub event_seq: u64, - pub base_commit: String, - pub head_commit: String, - pub changed_paths: Vec, - pub summary: String, - pub assignment_id: String, +pub struct ReviewRequestedEvent { + pub event_id: String, + pub sequence: u64, + pub subject_ref: String, pub requested_by: WorkerIdentity, + pub reviewer: WorkerIdentity, pub created_at: DateTime, } - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ReviewEvent { - pub event_seq: u64, - pub request_event_seq: u64, - pub head_commit: String, - pub reviewer_worker: WorkerIdentity, - pub reviewer_profile: String, + pub event_id: String, + pub sequence: u64, + pub request_event_id: String, + pub subject_ref: String, pub decision: ReviewDecision, pub body: String, pub findings: Vec, + pub reviewer: WorkerIdentity, + pub created_at: DateTime, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReviewRevokedEvent { + pub event_id: String, + pub sequence: u64, + pub review_event_id: String, + pub subject_ref: String, + pub reason: String, + pub revoked_by: WorkerIdentity, + pub created_at: DateTime, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReviewCancelledEvent { + pub event_id: String, + pub sequence: u64, + pub request_event_id: String, + pub subject_ref: String, + pub reason: String, + pub created_at: DateTime, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CommentEvent { + pub event_id: String, + pub sequence: u64, + pub body: String, + pub author: WorkerIdentity, pub created_at: DateTime, } - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum MergeStrategy { FastForward, Merge, } - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ConflictResolution { @@ -128,46 +129,39 @@ pub enum ConflictResolution { Clean, ConflictsResolved, } - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct MergeEvent { - pub event_seq: u64, + pub event_id: String, + pub sequence: u64, pub operation_id: String, - pub target_commit: String, - pub source_commit: String, - pub result_commit: String, + pub approval_event_id: String, + pub approved_source_ref: String, + pub target_ref_before: String, + pub target_ref_after: String, pub strategy: MergeStrategy, pub resolution: ConflictResolution, pub merged_by: WorkerIdentity, pub created_at: DateTime, } - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct LifecycleEvent { - pub event_seq: u64, - pub actor: WorkerIdentity, - #[serde(default)] - pub body: String, - pub created_at: DateTime, -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum MergeRequestThreadEvent { - RequestForReview(RequestForReviewEvent), + ReviewRequested(ReviewRequestedEvent), Review(ReviewEvent), + ReviewRevoked(ReviewRevokedEvent), + ReviewCancelled(ReviewCancelledEvent), + Comment(CommentEvent), Merge(MergeEvent), - Reopen(LifecycleEvent), - Close(LifecycleEvent), } - impl MergeRequestThreadEvent { - pub fn event_seq(&self) -> u64 { + pub fn sequence(&self) -> u64 { match self { - Self::RequestForReview(value) => value.event_seq, - Self::Review(value) => value.event_seq, - Self::Merge(value) => value.event_seq, - Self::Reopen(value) | Self::Close(value) => value.event_seq, + Self::ReviewRequested(v) => v.sequence, + Self::Review(v) => v.sequence, + Self::ReviewRevoked(v) => v.sequence, + Self::ReviewCancelled(v) => v.sequence, + Self::Comment(v) => v.sequence, + Self::Merge(v) => v.sequence, } } } @@ -176,44 +170,19 @@ impl MergeRequestThreadEvent { pub struct MergeRequest { pub workspace_id: String, pub merge_request_id: String, - pub ticket_id: String, pub repository_id: String, pub state: MergeRequestState, - pub selector_from: String, + pub selector_from: Option, pub selector_to: String, - pub opened_by_worker: WorkerIdentity, + pub ticket_ids: Vec, pub created_at: DateTime, pub updated_at: DateTime, #[serde(default)] pub thread: Vec, } - impl MergeRequest { - pub fn current_request(&self) -> Option<&RequestForReviewEvent> { - let reopened_at = self.thread.iter().rev().find_map(|event| match event { - MergeRequestThreadEvent::Reopen(value) => Some(value.event_seq), - _ => None, - }); - self.thread.iter().rev().find_map(|event| match event { - MergeRequestThreadEvent::RequestForReview(value) - if reopened_at.is_none_or(|seq| value.event_seq > seq) => - { - Some(value) - } - _ => None, - }) - } - - pub fn current_review(&self) -> Option<&ReviewEvent> { - let request = self.current_request()?; - self.thread.iter().rev().find_map(|event| match event { - MergeRequestThreadEvent::Review(value) - if value.request_event_seq == request.event_seq => - { - Some(value) - } - _ => None, - }) + pub fn effective_review(&self, subject: &str) -> Option<&ReviewEvent> { + self.thread.iter().rev().find_map(|e|match e{MergeRequestThreadEvent::Review(v)if v.subject_ref==subject&&!self.thread.iter().any(|x|matches!(x,MergeRequestThreadEvent::ReviewRevoked(r)if r.review_event_id==v.event_id))=>Some(v),_=>None}) } } @@ -224,36 +193,23 @@ pub struct OpenMergeRequest { pub repository_id: String, pub selector_from: String, pub selector_to: String, - pub request: RequestForReview, + pub summary: String, pub auth: MergeRequestAuth, pub now: DateTime, } - #[derive(Debug, Clone)] pub struct RequestMergeRequestReview { pub ticket_id: String, - pub expected_head_commit: String, - pub request: RequestForReview, - pub auth: MergeRequestAuth, - pub now: DateTime, -} - -#[derive(Debug, Clone)] -pub struct RegisterReviewCapability { - pub ticket_id: String, - pub expected_head_commit: String, + pub subject_ref: String, pub child_session_id: String, pub capability_token: String, pub auth: MergeRequestAuth, pub now: DateTime, } - #[derive(Debug, Clone, PartialEq, Eq)] -pub struct RegisteredReviewCapability { - pub capability_token: String, - pub request_event_seq: u64, +pub struct RequestedMergeRequestReview { + pub request_event: ReviewRequestedEvent, } - #[derive(Debug, Clone)] pub struct RegisterReviewerChildSession { pub workspace_id: String, @@ -263,57 +219,61 @@ pub struct RegisterReviewerChildSession { pub reviewer_profile: String, pub now: DateTime, } - #[derive(Debug, Clone)] pub struct SubmitMergeRequestReview { pub ticket_id: String, - pub expected_head_commit: String, + pub current_subject_ref: String, pub capability_token: String, pub decision: ReviewDecision, pub body: String, pub findings: Vec, pub now: DateTime, } - +#[derive(Debug, Clone)] +pub struct RevokeMergeRequestReview { + pub ticket_id: String, + pub review_event_id: String, + pub reason: String, + pub auth: MergeRequestAuth, + pub now: DateTime, +} +#[derive(Debug, Clone)] +pub struct RepairSelectorFrom { + pub workspace_id: String, + pub ticket_id: String, + pub selector_from: String, + pub repaired_by: WorkerIdentity, + pub reason: String, + pub now: DateTime, +} #[derive(Debug, Clone)] pub struct ReadinessCheck { pub ticket_id: String, - pub expected_head_commit: Option, + pub current_subject_ref: Option, pub auth: MergeRequestAuth, } - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ReadinessReport { pub ready: bool, pub blockers: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] - pub request: Option, + pub subject_ref: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub review: Option, } - #[derive(Debug, Clone)] pub struct CompleteMergeRequest { pub ticket_id: String, - pub expected_head_commit: String, pub operation_id: String, - pub target_commit: String, - pub source_commit: String, - pub result_commit: String, + pub approval_event_id: String, + pub current_subject_ref: String, + pub target_ref_before: String, + pub target_ref_after: String, pub strategy: MergeStrategy, pub resolution: ConflictResolution, pub auth: MergeRequestAuth, pub now: DateTime, } - -#[derive(Debug, Clone)] -pub struct ChangeMergeRequestState { - pub ticket_id: String, - pub body: String, - pub auth: MergeRequestAuth, - pub now: DateTime, -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct CurrentAssignment { pub assignment_id: String, @@ -321,7 +281,6 @@ pub struct CurrentAssignment { pub runtime_id: String, pub worker_id: String, } - pub trait AssignmentSource: Send + Sync { fn current_assignment( &self, @@ -329,40 +288,30 @@ pub trait AssignmentSource: Send + Sync { ticket_id: &str, ) -> Result, String>; } - pub trait RepositorySource: Send + Sync { fn repository_belongs_to_workspace( &self, workspace_id: &str, repository_id: &str, ) -> Result; - - fn is_ancestor( - &self, - workspace_id: &str, - repository_id: &str, - ancestor: &str, - descendant: &str, - ) -> Result; } - #[derive(Debug, Error)] pub enum MergeRequestError { - #[error("merge request not found")] + #[error("Merge Request not found")] NotFound, - #[error("merge request conflict: {0}")] + #[error("Merge Request conflict: {0}")] Conflict(String), - #[error("merge request unauthorized: {0}")] + #[error("Merge Request unauthorized: {0}")] Unauthorized(String), - #[error("merge request is not ready: {0}")] + #[error("Merge Request is not ready: {0}")] NotReady(String), - #[error("merge request validation failed: {0}")] + #[error("Merge Request validation failed: {0}")] Validation(String), - #[error("merge request operation failed: {0}")] + #[error("Merge Request operation failed: {0}")] Operation(String), - #[error("merge request storage is corrupt: {0}")] + #[error("Merge Request storage is corrupt: {0}")] Corrupt(String), - #[error("merge request storage error: {0}")] + #[error("Merge Request storage error: {0}")] Storage(#[from] rusqlite::Error), } @@ -371,7 +320,6 @@ pub struct MergeRequestStore { assignments: Arc, repositories: Arc, } - impl MergeRequestStore { pub fn open( path: impl AsRef, @@ -379,1342 +327,760 @@ impl MergeRequestStore { repositories: Arc, ) -> Result { let conn = Connection::open(path)?; - Self::from_connection(conn, assignments, repositories) - } - - pub fn open_in_memory( - assignments: Arc, - repositories: Arc, - ) -> Result { - Self::from_connection(Connection::open_in_memory()?, assignments, repositories) - } - - fn from_connection( - mut conn: Connection, - assignments: Arc, - repositories: Arc, - ) -> Result { conn.pragma_update(None, "foreign_keys", "ON")?; - migrate(&mut conn)?; + migrate(&conn)?; Ok(Self { conn: Arc::new(Mutex::new(conn)), assignments, repositories, }) } - pub fn open_merge_request( &self, - input: OpenMergeRequest, + i: OpenMergeRequest, ) -> Result { - validate_nonempty("merge_request_id", &input.merge_request_id)?; - validate_nonempty("ticket_id", &input.ticket_id)?; - validate_nonempty("selector_from", &input.selector_from)?; - validate_nonempty("selector_to", &input.selector_to)?; - self.validate_auth(&input.auth, &input.ticket_id, &input.repository_id)?; - validate_request(&input.request)?; - - let mut conn = self.lock_conn()?; - let tx = conn.transaction()?; - if load_merge_request_tx(&tx, &input.auth.workspace_id, &input.ticket_id)?.is_some() { - return Err(MergeRequestError::Conflict( - "an open merge request already exists for this ticket".into(), + bounded_body("summary", &i.summary)?; + for (n, v) in [ + ("merge_request_id", i.merge_request_id.as_str()), + ("ticket_id", &i.ticket_id), + ("repository_id", &i.repository_id), + ("selector_from", &i.selector_from), + ("selector_to", &i.selector_to), + ] { + nonempty(n, v)? + } + if i.selector_from == i.selector_to { + return Err(MergeRequestError::Validation( + "selectors must differ".into(), )); } - let now = input.now.to_rfc3339(); - tx.execute( - "INSERT INTO merge_requests ( - workspace_id, merge_request_id, ticket_id, repository_id, state, - selector_from, selector_to, opened_by_runtime_id, opened_by_worker_id, - created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, 'open', ?5, ?6, ?7, ?8, ?9, ?9)", + self.assigned(&i.auth, &i.ticket_id, &i.repository_id)?; + let mut c = self.lock()?; + let t = c.transaction()?; + let conflict:bool=t.query_row("SELECT EXISTS(SELECT 1 FROM merge_request_ticket_relations rel JOIN merge_requests mr ON mr.workspace_id=rel.workspace_id AND mr.merge_request_id=rel.merge_request_id WHERE rel.workspace_id=?1 AND rel.ticket_id=?2 AND mr.state='open')",params![i.auth.workspace_id,i.ticket_id],|r|r.get(0))?; + if conflict { + return Err(MergeRequestError::Conflict( + "Ticket already has an open Merge Request".into(), + )); + } + let now = i.now.to_rfc3339(); + t.execute( + "INSERT INTO merge_requests VALUES(?1,?2,?3,'open',?4,?5,?6,?6)", params![ - input.auth.workspace_id, - input.merge_request_id, - input.ticket_id, - input.repository_id, - input.selector_from, - input.selector_to, - input.auth.runtime_id, - input.auth.worker_id, - now, + i.auth.workspace_id, + i.merge_request_id, + i.repository_id, + i.selector_from, + i.selector_to, + now ], )?; - append_request_event_tx( - &tx, - &input.auth.workspace_id, - &input.merge_request_id, - &input.auth, - &input.request, - input.now, + t.execute( + "INSERT INTO merge_request_ticket_relations VALUES(?1,?2,?3,'implements',?4)", + params![i.auth.workspace_id, i.merge_request_id, i.ticket_id, now], )?; - tx.commit()?; - drop(conn); - self.get(&input.auth.workspace_id, &input.ticket_id) - } - - pub fn request_review( - &self, - input: RequestMergeRequestReview, - ) -> Result { - validate_request(&input.request)?; - let current = self.get(&input.auth.workspace_id, &input.ticket_id)?; - self.validate_auth(&input.auth, &input.ticket_id, ¤t.repository_id)?; - ensure_open(¤t)?; - let current_request = current - .thread - .iter() - .rev() - .find_map(|event| match event { - MergeRequestThreadEvent::RequestForReview(value) => Some(value), - _ => None, - }) - .ok_or_else(|| { - MergeRequestError::Corrupt("open merge request has no review request".into()) - })?; - if current_request.head_commit != input.expected_head_commit { - return Err(MergeRequestError::Conflict( - "expected head commit is stale".into(), - )); + if !i.summary.trim().is_empty() { + let e = CommentEvent { + event_id: Uuid::now_v7().to_string(), + sequence: 1, + body: i.summary, + author: WorkerIdentity { + runtime_id: i.auth.runtime_id, + worker_id: i.auth.worker_id, + }, + created_at: i.now, + }; + insert_event( + &t, + &i.auth.workspace_id, + &i.merge_request_id, + "comment", + &e, + i.now, + None, + )? } - - let mut conn = self.lock_conn()?; - let tx = conn.transaction()?; - let event = append_request_event_tx( - &tx, - ¤t.workspace_id, - ¤t.merge_request_id, - &input.auth, - &input.request, - input.now, - )?; - tx.commit()?; - Ok(event) + t.commit()?; + drop(c); + self.get(&i.auth.workspace_id, &i.ticket_id) } - - pub fn register_review_capability( - &self, - input: RegisterReviewCapability, - ) -> Result { - let mr = self.get(&input.auth.workspace_id, &input.ticket_id)?; - self.validate_auth(&input.auth, &input.ticket_id, &mr.repository_id)?; - ensure_open(&mr)?; - let request = mr.current_request().ok_or_else(|| { - MergeRequestError::Corrupt("open merge request has no review request".into()) - })?; - if request.head_commit != input.expected_head_commit { - return Err(MergeRequestError::Conflict( - "expected head commit is stale".into(), - )); - } - validate_nonempty("capability_token", &input.capability_token)?; - let mut conn = self.lock_conn()?; - let tx = conn.transaction()?; - let child: Option<(String, String, String)> = tx - .query_row( - "SELECT parent_runtime_id, child_session_id, reviewer_profile - FROM merge_request_reviewer_child_sessions - WHERE workspace_id = ?1 AND child_session_id = ?2 - AND parent_runtime_id = ?3 AND parent_worker_id = ?4 - AND status = 'active'", - params![ - input.auth.workspace_id, - input.child_session_id, - input.auth.runtime_id, - input.auth.worker_id - ], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), - ) - .optional()?; - let Some((reviewer_runtime_id, reviewer_worker_id, reviewer_profile)) = child else { - return Err(MergeRequestError::Unauthorized( - "reviewer child session is missing or no longer active".into(), - )); - }; - validate_reviewer_profile(&reviewer_profile)?; - tx.execute( - "INSERT INTO merge_request_review_capabilities ( - workspace_id, merge_request_id, request_event_seq, capability_token, - issued_by_assignment_id, reviewer_runtime_id, reviewer_worker_id, - reviewer_profile, issued_at, status - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'issued')", - params![ - mr.workspace_id, - mr.merge_request_id, - request.event_seq, - input.capability_token, - input.auth.assignment_id, - reviewer_runtime_id, - reviewer_worker_id, - reviewer_profile, - input.now.to_rfc3339() - ], - )?; - tx.execute( - "UPDATE merge_request_reviewer_child_sessions SET status = 'consumed' - WHERE workspace_id = ?1 AND child_session_id = ?2 AND status = 'active'", - params![input.auth.workspace_id, input.child_session_id], - )?; - tx.commit()?; - Ok(RegisteredReviewCapability { - capability_token: input.capability_token, - request_event_seq: request.event_seq, - }) - } - pub fn register_reviewer_child_session( &self, - input: RegisterReviewerChildSession, + i: RegisterReviewerChildSession, ) -> Result<(), MergeRequestError> { - validate_reviewer_profile(&input.reviewer_profile)?; - let conn = self.lock_conn()?; - conn.execute( - "INSERT INTO merge_request_reviewer_child_sessions ( - workspace_id, child_session_id, parent_runtime_id, parent_worker_id, - reviewer_profile, registered_at, status - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'active')", + if i.reviewer_profile != "builtin:reviewer" { + return Err(MergeRequestError::Unauthorized( + "reviewer profile mismatch".into(), + )); + } + self.lock()?.execute( + "INSERT INTO merge_request_reviewer_child_sessions VALUES(?1,?2,?3,?4,?5,?6,'active')", params![ - input.workspace_id, - input.child_session_id, - input.parent_runtime_id, - input.parent_worker_id, - input.reviewer_profile, - input.now.to_rfc3339() + i.workspace_id, + i.child_session_id, + i.parent_runtime_id, + i.parent_worker_id, + i.reviewer_profile, + i.now.to_rfc3339() ], )?; Ok(()) } - - pub fn submit_review( + pub fn request_review( &self, - input: SubmitMergeRequestReview, - ) -> Result { - if input.body.trim().is_empty() { - return Err(MergeRequestError::Validation( - "review body must not be empty".into(), + i: RequestMergeRequestReview, + ) -> Result { + nonempty("subject_ref", &i.subject_ref)?; + let mr = self.get(&i.auth.workspace_id, &i.ticket_id)?; + self.assigned(&i.auth, &i.ticket_id, &mr.repository_id)?; + if mr.state != MergeRequestState::Open || mr.selector_from.is_none() { + return Err(MergeRequestError::Conflict( + "Merge Request is not reviewable".into(), )); } - let mut conn = self.lock_conn()?; - let tx = conn.transaction()?; - let capability: Option<(String, String, i64, String)> = tx - .query_row( - "SELECT c.workspace_id, c.merge_request_id, c.request_event_seq, c.status - FROM merge_request_review_capabilities c - JOIN merge_requests m - ON m.workspace_id = c.workspace_id - AND m.merge_request_id = c.merge_request_id - WHERE c.capability_token = ?1 AND m.ticket_id = ?2", - params![input.capability_token, input.ticket_id], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), - ) - .optional()?; - let Some((workspace_id, merge_request_id, request_event_seq, status)) = capability else { + let mut c = self.lock()?; + let t = c.transaction()?; + let child:Option<(String,String,String)>=t.query_row("SELECT parent_runtime_id,child_session_id,reviewer_profile FROM merge_request_reviewer_child_sessions WHERE workspace_id=?1 AND child_session_id=?2 AND parent_runtime_id=?3 AND parent_worker_id=?4 AND status='active'",params![i.auth.workspace_id,i.child_session_id,i.auth.runtime_id,i.auth.worker_id],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?))).optional()?; + let Some((rr, rw, p)) = child else { return Err(MergeRequestError::Unauthorized( - "review capability is invalid".into(), + "reviewer child attestation missing".into(), )); }; - if status != "issued" { - return Err(MergeRequestError::Conflict( - "review capability has already been consumed".into(), - )); - } - let reviewer: Option<(String, String, String)> = tx - .query_row( - "SELECT reviewer_runtime_id, reviewer_worker_id, reviewer_profile - FROM merge_request_review_capabilities - WHERE capability_token = ?1 AND status = 'issued'", - params![input.capability_token], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), - ) - .optional()?; - let Some((reviewer_runtime_id, reviewer_worker_id, reviewer_profile)) = reviewer else { + if p != "builtin:reviewer" { return Err(MergeRequestError::Unauthorized( - "review source is not the registered reviewer child".into(), - )); - }; - validate_reviewer_profile(&reviewer_profile)?; - let request_payload: String = tx.query_row( - "SELECT payload_json FROM merge_request_thread_events - WHERE workspace_id = ?1 AND merge_request_id = ?2 - AND event_seq = ?3 AND kind = 'request_for_review'", - params![workspace_id, merge_request_id, request_event_seq], - |row| row.get(0), - )?; - let request: RequestForReviewEvent = decode_payload(&request_payload)?; - if request.head_commit != input.expected_head_commit { - return Err(MergeRequestError::Conflict( - "expected head commit is stale".into(), + "reviewer profile mismatch".into(), )); } - let latest_request_seq: i64 = tx.query_row( - "SELECT MAX(event_seq) FROM merge_request_thread_events - WHERE workspace_id = ?1 AND merge_request_id = ?2 AND kind = 'request_for_review'", - params![workspace_id, merge_request_id], - |row| row.get(0), - )?; - if latest_request_seq != request_event_seq { - return Err(MergeRequestError::Conflict( - "review request has been superseded".into(), - )); - } - let event_seq = next_event_seq_tx(&tx, &workspace_id, &merge_request_id)?; - let event = ReviewEvent { - event_seq, - request_event_seq: request_event_seq as u64, - head_commit: request.head_commit, - reviewer_worker: WorkerIdentity { - runtime_id: reviewer_runtime_id, - worker_id: reviewer_worker_id, + let e = ReviewRequestedEvent { + event_id: Uuid::now_v7().to_string(), + sequence: next_seq(&t, &mr.workspace_id, &mr.merge_request_id)?, + subject_ref: i.subject_ref, + requested_by: WorkerIdentity { + runtime_id: i.auth.runtime_id, + worker_id: i.auth.worker_id, }, - reviewer_profile, - decision: input.decision, - body: input.body, - findings: input.findings, - created_at: input.now, + reviewer: WorkerIdentity { + runtime_id: rr.clone(), + worker_id: rw.clone(), + }, + created_at: i.now, }; - insert_event_tx( - &tx, - &workspace_id, - &merge_request_id, - "review", - &event, - input.now, + insert_event( + &t, + &mr.workspace_id, + &mr.merge_request_id, + "review_requested", + &e, + i.now, None, )?; - tx.execute( - "UPDATE merge_request_review_capabilities SET status = 'consumed', consumed_at = ?2 - WHERE capability_token = ?1 AND status = 'issued'", - params![input.capability_token, input.now.to_rfc3339()], - )?; - tx.commit()?; - Ok(event) + t.execute("INSERT INTO merge_request_review_grants VALUES(?1,?2,?3,?4,?5,?6,?7,?8,NULL,NULL,'issued')",params![mr.workspace_id,mr.merge_request_id,e.event_id,e.subject_ref,rr,rw,i.capability_token,i.now.to_rfc3339()])?; + t.execute("UPDATE merge_request_reviewer_child_sessions SET status='consumed' WHERE workspace_id=?1 AND child_session_id=?2",params![mr.workspace_id,i.child_session_id])?; + t.commit()?; + Ok(RequestedMergeRequestReview { request_event: e }) } - - pub fn readiness(&self, input: ReadinessCheck) -> Result { - let mr = self.get(&input.auth.workspace_id, &input.ticket_id)?; - if mr.repository_id != input.auth.repository_id { + pub fn submit_review( + &self, + i: SubmitMergeRequestReview, + ) -> Result { + bounded_body("review body", &i.body)?; + for finding in &i.findings { + bounded_body("review finding", &finding.body)?; + } + let mut c = self.lock()?; + let t = c.transaction()?; + let g:Option<(String,String,String,String,String,String)>=t.query_row("SELECT g.workspace_id,g.merge_request_id,g.request_event_id,g.subject_ref,g.reviewer_runtime_id,g.reviewer_worker_id FROM merge_request_review_grants g JOIN merge_request_ticket_relations rel ON rel.workspace_id=g.workspace_id AND rel.merge_request_id=g.merge_request_id WHERE g.capability_token=?1 AND rel.ticket_id=?2 AND g.status='issued'",params![i.capability_token,i.ticket_id],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?))).optional()?; + let Some((ws, mr, req, subject, rr, rw)) = g else { return Err(MergeRequestError::Unauthorized( - "repository does not match merge request".into(), + "review grant invalid".into(), + )); + }; + if subject != i.current_subject_ref { + let e = ReviewCancelledEvent { + event_id: Uuid::now_v7().to_string(), + sequence: next_seq(&t, &ws, &mr)?, + request_event_id: req, + subject_ref: subject, + reason: "selector_from moved before submission".into(), + created_at: i.now, + }; + insert_event(&t, &ws, &mr, "review_cancelled", &e, i.now, None)?; + t.execute("UPDATE merge_request_review_grants SET status='revoked',revoked_at=?2 WHERE capability_token=?1",params![i.capability_token,i.now.to_rfc3339()])?; + t.commit()?; + return Err(MergeRequestError::Conflict( + "selector_from moved; review cancelled".into(), )); } - let request = mr.current_request().cloned(); - let review = mr.current_review().cloned(); - let mut blockers = Vec::new(); + let e = ReviewEvent { + event_id: Uuid::now_v7().to_string(), + sequence: next_seq(&t, &ws, &mr)?, + request_event_id: req, + subject_ref: subject, + decision: i.decision, + body: i.body, + findings: i.findings, + reviewer: WorkerIdentity { + runtime_id: rr, + worker_id: rw, + }, + created_at: i.now, + }; + insert_event(&t, &ws, &mr, "review", &e, i.now, None)?; + t.execute("UPDATE merge_request_review_grants SET status='consumed',consumed_at=?2 WHERE capability_token=?1",params![i.capability_token,i.now.to_rfc3339()])?; + t.commit()?; + Ok(e) + } + pub fn revoke_review( + &self, + i: RevokeMergeRequestReview, + ) -> Result { + bounded_body("revocation reason", &i.reason)?; + let mr = self.get(&i.auth.workspace_id, &i.ticket_id)?; + self.assigned(&i.auth, &i.ticket_id, &mr.repository_id)?; + let r = mr + .thread + .iter() + .find_map(|x| match x { + MergeRequestThreadEvent::Review(v) if v.event_id == i.review_event_id => Some(v), + _ => None, + }) + .ok_or(MergeRequestError::NotFound)?; + if mr.thread.iter().any(|x|matches!(x,MergeRequestThreadEvent::ReviewRevoked(v)if v.review_event_id==r.event_id)){return Err(MergeRequestError::Conflict("already revoked".into()))} + let mut c = self.lock()?; + let t = c.transaction()?; + let e = ReviewRevokedEvent { + event_id: Uuid::now_v7().to_string(), + sequence: next_seq(&t, &mr.workspace_id, &mr.merge_request_id)?, + review_event_id: r.event_id.clone(), + subject_ref: r.subject_ref.clone(), + reason: i.reason, + revoked_by: WorkerIdentity { + runtime_id: i.auth.runtime_id, + worker_id: i.auth.worker_id, + }, + created_at: i.now, + }; + insert_event( + &t, + &mr.workspace_id, + &mr.merge_request_id, + "review_revoked", + &e, + i.now, + None, + )?; + t.commit()?; + Ok(e) + } + pub fn readiness(&self, i: ReadinessCheck) -> Result { + let mr = self.get(&i.auth.workspace_id, &i.ticket_id)?; + let review = i + .current_subject_ref + .as_deref() + .and_then(|s| mr.effective_review(s)) + .cloned(); + let mut b = vec![]; if mr.state != MergeRequestState::Open { - blockers.push("merge request is not open".into()); + b.push("Merge Request is not open".into()) } - match &request { - None => blockers.push("merge request has no review request".into()), - Some(value) => { - if input - .expected_head_commit - .as_deref() - .is_some_and(|expected| expected != value.head_commit) - { - blockers.push("expected head commit is stale".into()); - } - match &review { - None => blockers.push("current review request has no review result".into()), - Some(review) if review.decision != ReviewDecision::Approve => { - blockers.push("current review requests changes".into()) - } - Some(review) if review.head_commit != value.head_commit => blockers - .push("current review does not match the requested head commit".into()), - Some(_) => {} - } + if mr.selector_from.is_none() { + b.push("selector_from requires repair".into()) + } + match (&i.current_subject_ref, &review) { + (None, _) => b.push("selector_from could not be resolved".into()), + (Some(_), None) => b.push("current source ref has no valid review".into()), + (_, Some(r)) if r.decision == ReviewDecision::RequestChanges => { + b.push("current source ref requests changes".into()) } + _ => {} } Ok(ReadinessReport { - ready: blockers.is_empty(), - blockers, - request, + ready: b.is_empty(), + blockers: b, + subject_ref: i.current_subject_ref, review, }) } - - pub fn complete(&self, input: CompleteMergeRequest) -> Result { - validate_nonempty("operation_id", &input.operation_id)?; - let mr = self.get(&input.auth.workspace_id, &input.ticket_id)?; - self.validate_completion_auth(&input.auth, &input.ticket_id, &mr.repository_id)?; - - if let Some(existing) = mr.thread.iter().find_map(|event| match event { - MergeRequestThreadEvent::Merge(value) if value.operation_id == input.operation_id => { - Some(value) - } + pub fn complete(&self, i: CompleteMergeRequest) -> Result { + let mr = self.get(&i.auth.workspace_id, &i.ticket_id)?; + self.completion_auth(&i.auth, &i.ticket_id, &mr.repository_id)?; + if let Some(v) = mr.thread.iter().find_map(|x| match x { + MergeRequestThreadEvent::Merge(v) if v.operation_id == i.operation_id => Some(v), _ => None, }) { - if merge_matches(existing, &input) { - return Ok(existing.clone()); + if v.approval_event_id == i.approval_event_id + && v.target_ref_before == i.target_ref_before + && v.target_ref_after == i.target_ref_after + { + return Ok(v.clone()); } return Err(MergeRequestError::Conflict( - "operation id was already used with a different completion fingerprint".into(), + "operation fingerprint mismatch".into(), )); } - - let readiness = self.readiness(ReadinessCheck { - ticket_id: input.ticket_id.clone(), - expected_head_commit: Some(input.expected_head_commit.clone()), - auth: input.auth.clone(), - })?; - if !readiness.ready { - return Err(MergeRequestError::NotReady(readiness.blockers.join("; "))); - } - let request = readiness.request.expect("ready request"); - if input.source_commit != request.head_commit - || input.expected_head_commit != request.head_commit - { - return Err(MergeRequestError::Conflict( - "completion source does not match current review request".into(), + let review = mr + .thread + .iter() + .find_map(|x| match x { + MergeRequestThreadEvent::Review(v) if v.event_id == i.approval_event_id => Some(v), + _ => None, + }) + .ok_or_else(|| MergeRequestError::NotReady("approval event missing".into()))?; + if review.decision!=ReviewDecision::Approve||review.subject_ref!=i.current_subject_ref||mr.thread.iter().any(|x|matches!(x,MergeRequestThreadEvent::ReviewRevoked(v)if v.review_event_id==review.event_id)){return Err(MergeRequestError::NotReady("approval is not valid for current source ref".into()))} + if i.target_ref_before == i.target_ref_after { + return Err(MergeRequestError::Validation( + "target ref did not change".into(), )); } - validate_completion_shape(&input, &request, self.repositories.as_ref(), &mr)?; - - let mut conn = self.lock_conn()?; - let tx = conn.transaction()?; - let current_ticket_state: Option = tx + let mut c = self.lock()?; + let t = c.transaction()?; + let state: Option = t .query_row( - "SELECT workflow_state FROM typed_tickets WHERE workspace_id = ?1 AND ticket_id = ?2", - params![mr.workspace_id, input.ticket_id], - |row| row.get(0), + "SELECT workflow_state FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2", + params![mr.workspace_id, i.ticket_id], + |r| r.get(0), ) .optional()?; - match current_ticket_state.as_deref() { - Some("inprogress") => {} - Some(other) => { - return Err(MergeRequestError::Conflict(format!( - "Ticket state `{other}` cannot be completed" - ))); - } - None => return Err(MergeRequestError::NotFound), - } - let changed = tx.execute( - "UPDATE typed_tickets - SET workflow_state = 'done', workflow_state_explicit = 1, updated_at = ?3 - WHERE workspace_id = ?1 AND ticket_id = ?2 AND workflow_state = 'inprogress'", - params![mr.workspace_id, input.ticket_id, input.now.to_rfc3339()], - )?; - if changed != 1 { + if state.as_deref() != Some("inprogress") { return Err(MergeRequestError::Conflict( - "Ticket state changed concurrently".into(), + "Ticket must be inprogress".into(), )); } - let implementation_assignment_id = input.auth.assignment_id.clone(); - let event_seq = next_event_seq_tx(&tx, &mr.workspace_id, &mr.merge_request_id)?; - let event = MergeEvent { - event_seq, - operation_id: input.operation_id, - target_commit: input.target_commit, - source_commit: input.source_commit, - result_commit: input.result_commit, - strategy: input.strategy, - resolution: input.resolution, + t.execute("UPDATE typed_tickets SET workflow_state='done',workflow_state_explicit=1,updated_at=?3 WHERE workspace_id=?1 AND ticket_id=?2",params![mr.workspace_id,i.ticket_id,i.now.to_rfc3339()])?; + let e = MergeEvent { + event_id: Uuid::now_v7().to_string(), + sequence: next_seq(&t, &mr.workspace_id, &mr.merge_request_id)?, + operation_id: i.operation_id, + approval_event_id: i.approval_event_id, + approved_source_ref: review.subject_ref.clone(), + target_ref_before: i.target_ref_before, + target_ref_after: i.target_ref_after, + strategy: i.strategy, + resolution: i.resolution, merged_by: WorkerIdentity { - runtime_id: input.auth.runtime_id, - worker_id: input.auth.worker_id, + runtime_id: i.auth.runtime_id, + worker_id: i.auth.worker_id, }, - created_at: input.now, + created_at: i.now, }; - insert_event_tx( - &tx, + insert_event( + &t, &mr.workspace_id, &mr.merge_request_id, "merge", - &event, - input.now, - Some(&event.operation_id), + &e, + i.now, + Some(&e.operation_id), )?; - tx.execute( - "UPDATE merge_requests SET state = 'merged', updated_at = ?3 - WHERE workspace_id = ?1 AND merge_request_id = ?2 AND state = 'open'", - params![mr.workspace_id, mr.merge_request_id, input.now.to_rfc3339()], - )?; - append_ticket_completion_event_tx(&tx, &mr, &event, &implementation_assignment_id)?; - tx.commit()?; - Ok(event) + t.execute("UPDATE merge_requests SET state='merged',updated_at=?3 WHERE workspace_id=?1 AND merge_request_id=?2",params![mr.workspace_id,mr.merge_request_id,i.now.to_rfc3339()])?; + ticket_event(&t, &mr, &e, &i.auth.assignment_id)?; + t.commit()?; + Ok(e) } - - pub fn close(&self, input: ChangeMergeRequestState) -> Result { - self.change_state(input, MergeRequestState::Closed, "close") - } - - pub fn reopen( + pub fn repair_selector_from( &self, - input: ChangeMergeRequestState, + i: RepairSelectorFrom, ) -> Result { - self.change_state(input, MergeRequestState::Open, "reopen") - } - - fn change_state( - &self, - input: ChangeMergeRequestState, - target: MergeRequestState, - kind: &str, - ) -> Result { - let mr = self.get(&input.auth.workspace_id, &input.ticket_id)?; - self.validate_lifecycle_auth(&input.auth, &mr.repository_id)?; - match (mr.state, target) { - (MergeRequestState::Open, MergeRequestState::Closed) - | (MergeRequestState::Closed, MergeRequestState::Open) - | (MergeRequestState::Merged, MergeRequestState::Open) => {} - _ => { - return Err(MergeRequestError::Conflict( - "illegal merge request state transition".into(), - )); - } + nonempty("selector_from", &i.selector_from)?; + let mr = self.get(&i.workspace_id, &i.ticket_id)?; + if mr.selector_from.is_some() { + return Err(MergeRequestError::Conflict( + "selector_from is immutable after it is set".into(), + )); } - let mut conn = self.lock_conn()?; - let tx = conn.transaction()?; - let event_seq = next_event_seq_tx(&tx, &mr.workspace_id, &mr.merge_request_id)?; - let event = LifecycleEvent { - event_seq, - actor: WorkerIdentity { - runtime_id: input.auth.runtime_id, - worker_id: input.auth.worker_id, - }, - body: input.body, - created_at: input.now, + let mut c = self.lock()?; + let t = c.transaction()?; + let changed=t.execute("UPDATE merge_requests SET selector_from=?3,updated_at=?4 WHERE workspace_id=?1 AND merge_request_id=?2 AND selector_from IS NULL",params![mr.workspace_id,mr.merge_request_id,i.selector_from,i.now.to_rfc3339()])?; + if changed != 1 { + return Err(MergeRequestError::Conflict( + "selector_from repair raced with another update".into(), + )); + } + let e = CommentEvent { + event_id: Uuid::now_v7().to_string(), + sequence: next_seq(&t, &mr.workspace_id, &mr.merge_request_id)?, + body: format!("selector_from repaired: {}", i.reason), + author: i.repaired_by, + created_at: i.now, }; - insert_event_tx( - &tx, + insert_event( + &t, &mr.workspace_id, &mr.merge_request_id, - kind, - &event, - input.now, + "comment", + &e, + i.now, None, )?; - tx.execute( - "UPDATE merge_requests SET state = ?3, updated_at = ?4 - WHERE workspace_id = ?1 AND merge_request_id = ?2", - params![ - mr.workspace_id, - mr.merge_request_id, - target.as_str(), - input.now.to_rfc3339() - ], - )?; - tx.commit()?; - drop(conn); - self.get(&input.auth.workspace_id, &input.ticket_id) + t.commit()?; + drop(c); + self.get(&i.workspace_id, &i.ticket_id) } - - pub fn get( - &self, - workspace_id: &str, - ticket_id: &str, - ) -> Result { - let conn = self.lock_conn()?; - load_merge_request(&conn, workspace_id, ticket_id)?.ok_or(MergeRequestError::NotFound) - } - - pub fn list_for_ticket( - &self, - workspace_id: &str, - ticket_id: &str, - ) -> Result, MergeRequestError> { - let conn = self.lock_conn()?; - let mut stmt = conn.prepare( - "SELECT merge_request_id FROM merge_requests - WHERE workspace_id = ?1 AND ticket_id = ?2 - ORDER BY created_at DESC, merge_request_id DESC", - )?; - let ids = stmt - .query_map(params![workspace_id, ticket_id], |row| { - row.get::<_, String>(0) - })? - .collect::, _>>()?; - ids.into_iter() - .map(|id| { - load_merge_request_by_id(&conn, workspace_id, &id)? - .ok_or(MergeRequestError::NotFound) - }) - .collect() - } - - fn validate_auth( - &self, - auth: &MergeRequestAuth, - ticket_id: &str, - repository_id: &str, - ) -> Result<(), MergeRequestError> { - if auth.repository_id != repository_id { - return Err(MergeRequestError::Unauthorized( - "repository does not match request".into(), - )); + pub fn get(&self, ws: &str, ticket: &str) -> Result { + let c = self.lock()?; + let id:Option=c.query_row("SELECT rel.merge_request_id FROM merge_request_ticket_relations rel JOIN merge_requests mr ON mr.workspace_id=rel.workspace_id AND mr.merge_request_id=rel.merge_request_id WHERE rel.workspace_id=?1 AND rel.ticket_id=?2 ORDER BY CASE mr.state WHEN 'open' THEN 0 ELSE 1 END,mr.created_at DESC LIMIT 1",params![ws,ticket],|r|r.get(0)).optional()?; + match id { + Some(id) => load_mr(&c, ws, &id)?.ok_or(MergeRequestError::NotFound), + None => Err(MergeRequestError::NotFound), } - if !self - .repositories - .repository_belongs_to_workspace(&auth.workspace_id, repository_id) - .map_err(MergeRequestError::Operation)? - { - return Err(MergeRequestError::Unauthorized( - "repository does not belong to workspace".into(), - )); - } - let assignment = self + } + pub fn thread_page( + &self, + ws: &str, + ticket: &str, + after: Option, + limit: usize, + ) -> Result, MergeRequestError> { + let mr = self.get(ws, ticket)?; + let c = self.lock()?; + load_thread(&c, ws, &mr.merge_request_id, after, limit.clamp(1, 200)) + } + fn assigned(&self, a: &MergeRequestAuth, t: &str, r: &str) -> Result<(), MergeRequestError> { + self.repo(a, r)?; + let x = self .assignments - .current_assignment(&auth.workspace_id, ticket_id) + .current_assignment(&a.workspace_id, t) .map_err(MergeRequestError::Operation)? - .ok_or_else(|| { - MergeRequestError::Unauthorized("ticket has no current assignment".into()) - })?; - if assignment.assignment_id != auth.assignment_id - || assignment.runtime_id != auth.runtime_id - || assignment.worker_id != auth.worker_id - || assignment.ticket_id != ticket_id + .ok_or_else(|| MergeRequestError::Unauthorized("no assignment".into()))?; + if x.assignment_id != a.assignment_id + || x.runtime_id != a.runtime_id + || x.worker_id != a.worker_id { return Err(MergeRequestError::Unauthorized( - "caller is not the current assigned worker".into(), + "not current assigned Worker".into(), )); } Ok(()) } - - fn validate_lifecycle_auth( + fn completion_auth( &self, - auth: &MergeRequestAuth, - repository_id: &str, + a: &MergeRequestAuth, + t: &str, + r: &str, ) -> Result<(), MergeRequestError> { - if auth.repository_id != repository_id { - return Err(MergeRequestError::Unauthorized( - "repository does not match request".into(), - )); - } - if !self - .repositories - .repository_belongs_to_workspace(&auth.workspace_id, repository_id) - .map_err(MergeRequestError::Operation)? - { - return Err(MergeRequestError::Unauthorized( - "repository does not belong to workspace".into(), - )); - } - Ok(()) - } - - fn validate_completion_auth( - &self, - auth: &MergeRequestAuth, - ticket_id: &str, - repository_id: &str, - ) -> Result<(), MergeRequestError> { - if auth.repository_id != repository_id { - return Err(MergeRequestError::Unauthorized( - "repository does not match request".into(), - )); - } - if !self - .repositories - .repository_belongs_to_workspace(&auth.workspace_id, repository_id) - .map_err(MergeRequestError::Operation)? - { - return Err(MergeRequestError::Unauthorized( - "repository does not belong to workspace".into(), - )); - } - let assignment = self + self.repo(a, r)?; + let x = self .assignments - .current_assignment(&auth.workspace_id, ticket_id) + .current_assignment(&a.workspace_id, t) .map_err(MergeRequestError::Operation)? - .ok_or_else(|| { - MergeRequestError::Unauthorized("ticket has no current assignment".into()) - })?; - if assignment.assignment_id != auth.assignment_id || assignment.ticket_id != ticket_id { + .ok_or_else(|| MergeRequestError::Unauthorized("no assignment".into()))?; + if x.assignment_id != a.assignment_id { + return Err(MergeRequestError::Unauthorized("stale assignment".into())); + } + Ok(()) + } + fn repo(&self, a: &MergeRequestAuth, r: &str) -> Result<(), MergeRequestError> { + if a.repository_id != r + || !self + .repositories + .repository_belongs_to_workspace(&a.workspace_id, r) + .map_err(MergeRequestError::Operation)? + { return Err(MergeRequestError::Unauthorized( - "completion does not match the current assignment".into(), + "repository scope mismatch".into(), )); } Ok(()) } - - fn lock_conn(&self) -> Result, MergeRequestError> { + fn lock(&self) -> Result, MergeRequestError> { self.conn .lock() .map_err(|_| MergeRequestError::Operation("database lock poisoned".into())) } } -fn validate_request(request: &RequestForReview) -> Result<(), MergeRequestError> { - validate_nonempty("base_commit", &request.base_commit)?; - validate_nonempty("head_commit", &request.head_commit)?; - if request - .changed_paths - .iter() - .any(|path| path.trim().is_empty()) - { - return Err(MergeRequestError::Validation( - "changed_paths must not contain empty entries".into(), - )); - } - Ok(()) -} - -fn validate_nonempty(field: &str, value: &str) -> Result<(), MergeRequestError> { - if value.trim().is_empty() { +fn bounded_body(name: &str, value: &str) -> Result<(), MergeRequestError> { + if value.len() > MAX_BODY_BYTES { Err(MergeRequestError::Validation(format!( - "{field} must not be empty" + "{name} exceeds {MAX_BODY_BYTES} bytes" ))) } else { Ok(()) } } -fn validate_reviewer_profile(profile: &str) -> Result<(), MergeRequestError> { - if profile == "builtin:reviewer" { - Ok(()) +fn nonempty(n: &str, v: &str) -> Result<(), MergeRequestError> { + if v.trim().is_empty() { + Err(MergeRequestError::Validation(format!( + "{n} must not be empty" + ))) } else { - Err(MergeRequestError::Unauthorized( - "review source must use builtin:reviewer".into(), - )) - } -} - -fn ensure_open(mr: &MergeRequest) -> Result<(), MergeRequestError> { - if mr.state == MergeRequestState::Open { Ok(()) - } else { - Err(MergeRequestError::Conflict( - "merge request is not open".into(), - )) } } - -fn merge_matches(existing: &MergeEvent, input: &CompleteMergeRequest) -> bool { - existing.target_commit == input.target_commit - && existing.source_commit == input.source_commit - && existing.result_commit == input.result_commit - && existing.strategy == input.strategy - && existing.resolution == input.resolution +fn next_seq(t: &Transaction<'_>, w: &str, m: &str) -> Result { + Ok(t.query_row("SELECT COALESCE(MAX(sequence),0)+1 FROM merge_request_thread_events WHERE workspace_id=?1 AND merge_request_id=?2",params![w,m],|r|r.get::<_,i64>(0))? as u64) } - -fn validate_completion_shape( - input: &CompleteMergeRequest, - request: &RequestForReviewEvent, - repositories: &dyn RepositorySource, - mr: &MergeRequest, +fn insert_event( + t: &Transaction<'_>, + w: &str, + m: &str, + k: &str, + e: &T, + at: DateTime, + op: Option<&str>, ) -> Result<(), MergeRequestError> { - match input.strategy { - MergeStrategy::FastForward => { - if input.resolution != ConflictResolution::None - || input.target_commit != request.base_commit - || input.result_commit != input.source_commit - { - return Err(MergeRequestError::Validation( - "fast-forward completion must use the review base as target, the source as result, and resolution none".into(), - )); - } - if !repositories - .is_ancestor( - &mr.workspace_id, - &mr.repository_id, - &input.target_commit, - &input.source_commit, - ) - .map_err(MergeRequestError::Operation)? - { - return Err(MergeRequestError::Conflict( - "source commit is not a descendant of target commit".into(), - )); - } - } - MergeStrategy::Merge => { - if input.resolution == ConflictResolution::None - || input.result_commit == input.target_commit - { - return Err(MergeRequestError::Validation( - "merge completion requires an explicit resolution and a new result commit" - .into(), - )); - } - if !repositories - .is_ancestor( - &mr.workspace_id, - &mr.repository_id, - &input.target_commit, - &input.result_commit, - ) - .map_err(MergeRequestError::Operation)? - || !repositories - .is_ancestor( - &mr.workspace_id, - &mr.repository_id, - &input.source_commit, - &input.result_commit, - ) - .map_err(MergeRequestError::Operation)? - { - return Err(MergeRequestError::Conflict( - "merge result must descend from both target and source commits".into(), - )); - } - } - } - Ok(()) -} - -fn append_ticket_completion_event_tx( - tx: &Transaction<'_>, - mr: &MergeRequest, - event: &MergeEvent, - implementation_assignment_id: &str, -) -> Result<(), MergeRequestError> { - let event_index: i64 = tx.query_row( - "SELECT COALESCE(MAX(event_index), -1) + 1 FROM typed_ticket_events - WHERE workspace_id = ?1 AND ticket_id = ?2", - params![mr.workspace_id, mr.ticket_id], - |row| row.get(0), - )?; - tx.execute( - "INSERT INTO typed_ticket_events ( - workspace_id, ticket_id, event_index, kind, author, at, - from_state, to_state, heading, body - ) VALUES (?1, ?2, ?3, 'state_changed', ?4, ?5, - 'inprogress', 'done', 'Merge Request completed', ?6)", + let v = serde_json::to_value(e).map_err(|x| MergeRequestError::Operation(x.to_string()))?; + let id = v["event_id"] + .as_str() + .ok_or_else(|| MergeRequestError::Operation("event_id missing".into()))?; + let seq = v["sequence"] + .as_u64() + .ok_or_else(|| MergeRequestError::Operation("sequence missing".into()))?; + t.execute( + "INSERT INTO merge_request_thread_events VALUES(?1,?2,?3,?4,?5,?6,?7,?8)", params![ - mr.workspace_id, - mr.ticket_id, - event_index, - format!( - "worker:{}:{}", - event.merged_by.runtime_id, event.merged_by.worker_id - ), - event.created_at.to_rfc3339(), - format!( - "Approved candidate `{}` completed implementation.", - event.source_commit - ), + w, + m, + id, + seq as i64, + k, + serde_json::to_string(e).map_err(|x| MergeRequestError::Operation(x.to_string()))?, + op, + at.to_rfc3339() ], )?; - let event_seq = event.event_seq.to_string(); - for (key, value) in [ - ("implementation_assignment_id", implementation_assignment_id), - ("merge_request_event_seq", event_seq.as_str()), - ("merge_request_head_commit", event.source_commit.as_str()), - ("operation_id", event.operation_id.as_str()), - ("completion_authority", "workspace_orchestrator"), - ("runtime_id", event.merged_by.runtime_id.as_str()), - ("worker_id", event.merged_by.worker_id.as_str()), + Ok(()) +} +fn load_mr(c: &Connection, w: &str, m: &str) -> Result, MergeRequestError> { + let row:Option<(String,String,Option,String,String,String)>=c.query_row("SELECT repository_id,state,selector_from,selector_to,created_at,updated_at FROM merge_requests WHERE workspace_id=?1 AND merge_request_id=?2",params![w,m],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?))).optional()?; + let Some((repo, state, from, to, created, updated)) = row else { + return Ok(None); + }; + let mut s=c.prepare("SELECT ticket_id FROM merge_request_ticket_relations WHERE workspace_id=?1 AND merge_request_id=?2 ORDER BY ticket_id")?; + let tickets = s + .query_map(params![w, m], |r| r.get(0))? + .collect::, _>>()?; + Ok(Some(MergeRequest { + workspace_id: w.into(), + merge_request_id: m.into(), + repository_id: repo, + state: MergeRequestState::parse(&state)?, + selector_from: from, + selector_to: to, + ticket_ids: tickets, + created_at: time(&created)?, + updated_at: time(&updated)?, + thread: load_thread(c, w, m, None, 100)?, + })) +} +fn load_thread( + c: &Connection, + w: &str, + m: &str, + after: Option, + limit: usize, +) -> Result, MergeRequestError> { + let mut s=c.prepare("SELECT kind,payload_json FROM merge_request_thread_events WHERE workspace_id=?1 AND merge_request_id=?2 AND sequence>?3 ORDER BY sequence LIMIT ?4")?; + let rows = s + .query_map( + params![w, m, after.unwrap_or(0) as i64, limit as i64], + |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)), + )? + .collect::, _>>()?; + rows.into_iter() + .map(|(k, j)| { + Ok(match k.as_str() { + "review_requested" => MergeRequestThreadEvent::ReviewRequested(json(&j)?), + "review" => MergeRequestThreadEvent::Review(json(&j)?), + "review_revoked" => MergeRequestThreadEvent::ReviewRevoked(json(&j)?), + "review_cancelled" => MergeRequestThreadEvent::ReviewCancelled(json(&j)?), + "comment" => MergeRequestThreadEvent::Comment(json(&j)?), + "merge" => MergeRequestThreadEvent::Merge(json(&j)?), + _ => return Err(MergeRequestError::Corrupt(format!("unknown event `{k}`"))), + }) + }) + .collect() +} +fn json Deserialize<'a>>(v: &str) -> Result { + serde_json::from_str(v).map_err(|e| MergeRequestError::Corrupt(e.to_string())) +} +fn time(v: &str) -> Result, MergeRequestError> { + DateTime::parse_from_rfc3339(v) + .map(|x| x.with_timezone(&Utc)) + .map_err(|e| MergeRequestError::Corrupt(e.to_string())) +} +fn ticket_event( + t: &Transaction<'_>, + mr: &MergeRequest, + e: &MergeEvent, + a: &str, +) -> Result<(), MergeRequestError> { + let ticket = &mr.ticket_ids[0]; + let n:i64=t.query_row("SELECT COALESCE(MAX(event_index),-1)+1 FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2",params![mr.workspace_id,ticket],|r|r.get(0))?; + t.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,from_state,to_state,heading,body)VALUES(?1,?2,?3,'state_changed',?4,?5,'inprogress','done','Merge Request completed',?6)",params![mr.workspace_id,ticket,n,format!("worker:{}:{}",e.merged_by.runtime_id,e.merged_by.worker_id),e.created_at.to_rfc3339(),format!("Approved source ref `{}` completed implementation.",e.approved_source_ref)])?; + for (k, v) in [ + ("implementation_assignment_id", a), + ("approval_event_id", &e.approval_event_id), + ("approved_source_ref", &e.approved_source_ref), + ("operation_id", &e.operation_id), ] { - tx.execute( - "INSERT INTO typed_ticket_event_attributes ( - workspace_id, ticket_id, event_index, key, value - ) VALUES (?1, ?2, ?3, ?4, ?5)", - params![mr.workspace_id, mr.ticket_id, event_index, key, value], + t.execute( + "INSERT INTO typed_ticket_event_attributes VALUES(?1,?2,?3,?4,?5)", + params![mr.workspace_id, ticket, n, k, v], )?; } Ok(()) } -fn append_request_event_tx( - tx: &Transaction<'_>, - workspace_id: &str, - merge_request_id: &str, - auth: &MergeRequestAuth, - request: &RequestForReview, - now: DateTime, -) -> Result { - let event_seq = next_event_seq_tx(tx, workspace_id, merge_request_id)?; - let event = RequestForReviewEvent { - event_seq, - base_commit: request.base_commit.clone(), - head_commit: request.head_commit.clone(), - changed_paths: request.changed_paths.clone(), - summary: request.summary.clone(), - assignment_id: auth.assignment_id.clone(), - requested_by: WorkerIdentity { - runtime_id: auth.runtime_id.clone(), - worker_id: auth.worker_id.clone(), - }, - created_at: now, - }; - insert_event_tx( - tx, - workspace_id, - merge_request_id, - "request_for_review", - &event, - now, - None, - )?; - tx.execute( - "UPDATE merge_requests SET updated_at = ?3 - WHERE workspace_id = ?1 AND merge_request_id = ?2", - params![workspace_id, merge_request_id, now.to_rfc3339()], - )?; - tx.execute( - "UPDATE merge_request_review_capabilities SET status = 'revoked' - WHERE workspace_id = ?1 AND merge_request_id = ?2 AND status = 'issued'", - params![workspace_id, merge_request_id], - )?; - Ok(event) -} - -fn next_event_seq_tx( - tx: &Transaction<'_>, - workspace_id: &str, - merge_request_id: &str, -) -> Result { - let current: i64 = tx.query_row( - "SELECT COALESCE(MAX(event_seq), 0) FROM merge_request_thread_events - WHERE workspace_id = ?1 AND merge_request_id = ?2", - params![workspace_id, merge_request_id], - |row| row.get(0), - )?; - Ok((current + 1) as u64) -} - -fn insert_event_tx( - tx: &Transaction<'_>, - workspace_id: &str, - merge_request_id: &str, - kind: &str, - payload: &T, - now: DateTime, - operation_id: Option<&str>, -) -> Result<(), MergeRequestError> { - let event_seq = serde_json::to_value(payload) - .map_err(|error| MergeRequestError::Operation(error.to_string()))? - .get("event_seq") - .and_then(serde_json::Value::as_u64) - .ok_or_else(|| MergeRequestError::Operation("event payload has no event_seq".into()))?; - let payload_json = serde_json::to_string(payload) - .map_err(|error| MergeRequestError::Operation(error.to_string()))?; - tx.execute( - "INSERT INTO merge_request_thread_events ( - workspace_id, merge_request_id, event_seq, kind, payload_json, - operation_id, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - params![ - workspace_id, - merge_request_id, - event_seq as i64, - kind, - payload_json, - operation_id, - now.to_rfc3339() - ], - )?; - Ok(()) -} - -fn load_merge_request( - conn: &Connection, - workspace_id: &str, - ticket_id: &str, -) -> Result, MergeRequestError> { - let id: Option = conn - .query_row( - "SELECT merge_request_id FROM merge_requests - WHERE workspace_id = ?1 AND ticket_id = ?2 - ORDER BY CASE state WHEN 'open' THEN 0 ELSE 1 END, created_at DESC - LIMIT 1", - params![workspace_id, ticket_id], - |row| row.get(0), - ) - .optional()?; - id.map(|id| load_merge_request_by_id(conn, workspace_id, &id)) - .transpose() - .map(Option::flatten) -} - -fn load_merge_request_tx( - tx: &Transaction<'_>, - workspace_id: &str, - ticket_id: &str, -) -> Result, MergeRequestError> { - let id: Option = tx - .query_row( - "SELECT merge_request_id FROM merge_requests - WHERE workspace_id = ?1 AND ticket_id = ?2 AND state = 'open' LIMIT 1", - params![workspace_id, ticket_id], - |row| row.get(0), - ) - .optional()?; - id.map(|id| load_merge_request_by_id(tx, workspace_id, &id)) - .transpose() - .map(Option::flatten) -} - -fn load_merge_request_by_id( - conn: &Connection, - workspace_id: &str, - merge_request_id: &str, -) -> Result, MergeRequestError> { - let row: Option<( - String, - String, - String, - String, - String, - String, - String, - String, - String, - String, - )> = conn - .query_row( - "SELECT ticket_id, repository_id, state, selector_from, selector_to, - opened_by_runtime_id, opened_by_worker_id, created_at, updated_at, - merge_request_id - FROM merge_requests - WHERE workspace_id = ?1 AND merge_request_id = ?2", - params![workspace_id, merge_request_id], - |row| { - Ok(( - row.get(0)?, - row.get(1)?, - row.get(2)?, - row.get(3)?, - row.get(4)?, - row.get(5)?, - row.get(6)?, - row.get(7)?, - row.get(8)?, - row.get(9)?, - )) - }, - ) - .optional()?; - let Some(( - ticket_id, - repository_id, - state, - selector_from, - selector_to, - opened_runtime, - opened_worker, - created_at, - updated_at, - id, - )) = row - else { - return Ok(None); - }; - let mut stmt = conn.prepare( - "SELECT kind, payload_json FROM merge_request_thread_events - WHERE workspace_id = ?1 AND merge_request_id = ?2 ORDER BY event_seq", - )?; - let rows = stmt - .query_map(params![workspace_id, merge_request_id], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) - })? - .collect::, _>>()?; - let mut thread = Vec::with_capacity(rows.len()); - for (kind, payload) in rows { - thread.push(match kind.as_str() { - "request_for_review" => { - MergeRequestThreadEvent::RequestForReview(decode_payload(&payload)?) - } - "review" => MergeRequestThreadEvent::Review(decode_payload(&payload)?), - "merge" => MergeRequestThreadEvent::Merge(decode_payload(&payload)?), - "reopen" => MergeRequestThreadEvent::Reopen(decode_payload(&payload)?), - "close" => MergeRequestThreadEvent::Close(decode_payload(&payload)?), - other => { - return Err(MergeRequestError::Corrupt(format!( - "unknown merge request thread event kind `{other}`" - ))); - } - }); - } - Ok(Some(MergeRequest { - workspace_id: workspace_id.to_string(), - merge_request_id: id, - ticket_id, - repository_id, - state: MergeRequestState::parse(&state)?, - selector_from, - selector_to, - opened_by_worker: WorkerIdentity { - runtime_id: opened_runtime, - worker_id: opened_worker, - }, - created_at: parse_timestamp(&created_at)?, - updated_at: parse_timestamp(&updated_at)?, - thread, - })) -} - -fn decode_payload Deserialize<'de>>(value: &str) -> Result { - serde_json::from_str(value).map_err(|error| MergeRequestError::Corrupt(error.to_string())) -} - -fn parse_timestamp(value: &str) -> Result, MergeRequestError> { - DateTime::parse_from_rfc3339(value) - .map(|value| value.with_timezone(&Utc)) - .map_err(|error| MergeRequestError::Corrupt(error.to_string())) -} - -pub fn migrate(conn: &Connection) -> Result<(), MergeRequestError> { - let version = schema_version(conn)?; - match version { - None => create_latest_schema(conn), - Some(SCHEMA_VERSION) => verify_latest_schema(conn), - Some(PREVIOUS_SCHEMA_VERSION) => migrate_v11_to_v12(conn), - Some(other) => Err(MergeRequestError::Operation(format!( - "unsupported merge request schema version {other}; expected {PREVIOUS_SCHEMA_VERSION} or {SCHEMA_VERSION}" +pub fn migrate(c: &Connection) -> Result<(), MergeRequestError> { + match schema_version(c)? { + None => fresh(c), + Some(SCHEMA_VERSION) => verify(c), + Some(PREVIOUS_SCHEMA_VERSION) => from_v11(c), + Some(v) => Err(MergeRequestError::Operation(format!( + "unsupported schema {v}" ))), } } - -fn schema_version(conn: &Connection) -> Result, MergeRequestError> { - let exists: bool = conn.query_row( - "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'merge_request_schema')", - [], - |row| row.get(0), - )?; - if !exists { +fn schema_version(c: &Connection) -> Result, MergeRequestError> { + let e:bool=c.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='merge_request_schema')",[],|r|r.get(0))?; + if !e { return Ok(None); } - conn.query_row( - "SELECT version FROM merge_request_schema WHERE singleton = 1", + c.query_row( + "SELECT version FROM merge_request_schema WHERE singleton=1", [], - |row| row.get(0), + |r| r.get(0), ) .optional() .map_err(Into::into) } - -fn create_latest_schema(conn: &Connection) -> Result<(), MergeRequestError> { - let tx = conn.unchecked_transaction()?; - create_v12_tables(&tx)?; - tx.execute( - "INSERT INTO merge_request_schema (singleton, version) VALUES (1, ?1)", - params![SCHEMA_VERSION], - )?; - foreign_key_check(&tx)?; - tx.commit()?; +fn fresh(c: &Connection) -> Result<(), MergeRequestError> { + let t = c.unchecked_transaction()?; + tables(&t, true)?; + t.execute("INSERT INTO merge_request_schema VALUES(1,12)", [])?; + fk(&t)?; + t.commit()?; Ok(()) } - -fn migrate_v11_to_v12(conn: &Connection) -> Result<(), MergeRequestError> { - let tx = conn.unchecked_transaction()?; - let incompatible: Option = tx - .query_row( - "SELECT mr.merge_request_id - FROM merge_requests mr - LEFT JOIN merge_request_revisions r - ON r.workspace_id = mr.workspace_id - AND r.revision_id = mr.current_revision_id - WHERE r.revision_id IS NULL - LIMIT 1", - [], - |row| row.get(0), - ) - .optional()?; - if let Some(id) = incompatible { - return Err(MergeRequestError::Operation(format!( - "cannot migrate merge request `{id}`: current revision is missing" - ))); +fn tables(t: &Transaction<'_>, marker: bool) -> Result<(), MergeRequestError> { + if marker { + t.execute_batch("CREATE TABLE merge_request_schema(singleton INTEGER PRIMARY KEY CHECK(singleton=1),version INTEGER NOT NULL);")? } - - tx.execute_batch( - "ALTER TABLE merge_requests RENAME TO merge_requests_v11; - ALTER TABLE merge_request_revisions RENAME TO merge_request_revisions_v11; - ALTER TABLE merge_request_review_attempts RENAME TO merge_request_review_attempts_v11; - ALTER TABLE merge_request_reviews RENAME TO merge_request_reviews_v11; - ALTER TABLE merge_request_completion_operations RENAME TO merge_request_completion_operations_v11; - ALTER TABLE merge_request_reviewer_child_sessions RENAME TO merge_request_reviewer_child_sessions_v11;", - )?; - create_v12_domain_tables(&tx)?; - tx.execute( - "INSERT INTO merge_requests ( - workspace_id, merge_request_id, ticket_id, repository_id, state, - selector_from, selector_to, opened_by_runtime_id, opened_by_worker_id, - created_at, updated_at - ) - SELECT mr.workspace_id, mr.merge_request_id, mr.ticket_id, mr.repository_id, - CASE mr.state WHEN 'draft' THEN 'open' ELSE mr.state END, - r.head_commit, mr.target_ref_selector, - mr.opened_by_worker_runtime_id, mr.opened_by_worker_id, - mr.created_at, mr.updated_at - FROM merge_requests_v11 mr - JOIN merge_request_revisions_v11 r - ON r.workspace_id = mr.workspace_id AND r.revision_id = mr.current_revision_id", - [], - )?; - - migrate_v11_events(&tx)?; - tx.execute_batch( - "DROP TABLE merge_request_reviewer_child_sessions_v11; - DROP TABLE merge_request_completion_operations_v11; - DROP TABLE merge_request_reviews_v11; - DROP TABLE merge_request_review_attempts_v11; - DROP TABLE merge_request_revisions_v11; - DROP TABLE merge_requests_v11; - UPDATE merge_request_schema SET version = 12 WHERE singleton = 1;", - )?; - foreign_key_check(&tx)?; - tx.commit()?; + t.execute_batch("CREATE TABLE merge_requests(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,repository_id TEXT NOT NULL,state TEXT NOT NULL CHECK(state IN('open','merged','closed')),selector_from TEXT,selector_to TEXT NOT NULL,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id),FOREIGN KEY(workspace_id,repository_id)REFERENCES repositories(workspace_id,repository_id));CREATE TABLE merge_request_ticket_relations(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,ticket_id TEXT NOT NULL,relation_kind TEXT NOT NULL CHECK(relation_kind='implements'),created_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id,ticket_id),FOREIGN KEY(workspace_id,merge_request_id)REFERENCES merge_requests(workspace_id,merge_request_id)ON DELETE CASCADE,FOREIGN KEY(workspace_id,ticket_id)REFERENCES typed_tickets(workspace_id,ticket_id)ON DELETE CASCADE);CREATE TABLE merge_request_thread_events(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,event_id TEXT NOT NULL,sequence INTEGER NOT NULL,kind TEXT NOT NULL CHECK(kind IN('review_requested','review','review_revoked','review_cancelled','comment','merge')),payload_json TEXT NOT NULL,operation_id TEXT,created_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id,event_id),UNIQUE(workspace_id,merge_request_id,sequence),FOREIGN KEY(workspace_id,merge_request_id)REFERENCES merge_requests(workspace_id,merge_request_id)ON DELETE CASCADE);CREATE UNIQUE INDEX merge_request_merge_operations ON merge_request_thread_events(workspace_id,operation_id)WHERE operation_id IS NOT NULL;CREATE TABLE merge_request_review_grants(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,request_event_id TEXT NOT NULL,subject_ref TEXT NOT NULL,reviewer_runtime_id TEXT NOT NULL,reviewer_worker_id TEXT NOT NULL,capability_token TEXT PRIMARY KEY,issued_at TEXT NOT NULL,consumed_at TEXT,revoked_at TEXT,status TEXT NOT NULL CHECK(status IN('issued','consumed','revoked')),FOREIGN KEY(workspace_id,merge_request_id,request_event_id)REFERENCES merge_request_thread_events(workspace_id,merge_request_id,event_id)ON DELETE CASCADE);CREATE TABLE merge_request_reviewer_child_sessions(workspace_id TEXT NOT NULL,child_session_id TEXT NOT NULL,parent_runtime_id TEXT NOT NULL,parent_worker_id TEXT NOT NULL,reviewer_profile TEXT NOT NULL,registered_at TEXT NOT NULL,status TEXT NOT NULL CHECK(status IN('active','consumed')),PRIMARY KEY(workspace_id,child_session_id));")?; Ok(()) } - -fn migrate_v11_events(tx: &Transaction<'_>) -> Result<(), MergeRequestError> { - let mut stmt = tx.prepare( - "SELECT r.workspace_id, r.merge_request_id, r.revision_id, r.base_commit, r.head_commit, - r.changed_paths_json, r.summary, r.assignment_id, - r.coder_worker_runtime_id, r.coder_worker_id, r.created_at - FROM merge_request_revisions_v11 r - ORDER BY r.workspace_id, r.merge_request_id, r.created_at, r.revision_id", - )?; - let revisions = stmt - .query_map([], |row| { +fn from_v11(c: &Connection) -> Result<(), MergeRequestError> { + let t = c.unchecked_transaction()?; + t.execute_batch("ALTER TABLE merge_requests RENAME TO merge_requests_v11;ALTER TABLE merge_request_ticket_relations RENAME TO merge_request_ticket_relations_v11;ALTER TABLE merge_request_revisions RENAME TO merge_request_revisions_v11;ALTER TABLE merge_request_revision_paths RENAME TO merge_request_revision_paths_v11;ALTER TABLE merge_request_reviewer_child_sessions RENAME TO merge_request_reviewer_child_sessions_v11;ALTER TABLE merge_request_review_attempts RENAME TO merge_request_review_attempts_v11;ALTER TABLE merge_request_reviews RENAME TO merge_request_reviews_v11;ALTER TABLE merge_request_review_findings RENAME TO merge_request_review_findings_v11;ALTER TABLE merge_request_completion_operations RENAME TO merge_request_completion_operations_v11;")?; + tables(&t, false)?; + t.execute("INSERT INTO merge_requests SELECT workspace_id,merge_request_id,repository_id,CASE state WHEN 'draft'THEN'open'ELSE state END,NULL,target_ref_selector,created_at,updated_at FROM merge_requests_v11",[])?; + t.execute("INSERT INTO merge_request_ticket_relations SELECT * FROM merge_request_ticket_relations_v11",[])?; + migrate_events(&t)?; + t.execute_batch("DROP TABLE merge_request_review_findings_v11;DROP TABLE merge_request_reviews_v11;DROP TABLE merge_request_review_attempts_v11;DROP TABLE merge_request_reviewer_child_sessions_v11;DROP TABLE merge_request_revision_paths_v11;DROP TABLE merge_request_revisions_v11;DROP TABLE merge_request_completion_operations_v11;DROP TABLE merge_request_ticket_relations_v11;DROP TABLE merge_requests_v11;UPDATE merge_request_schema SET version=12 WHERE singleton=1;")?; + fk(&t)?; + t.commit()?; + Ok(()) +} +fn migrate_events(t: &Transaction<'_>) -> Result<(), MergeRequestError> { + let attempts = { + let mut s=t.prepare("SELECT a.workspace_id,a.attempt_id,a.merge_request_id,a.parent_runtime_id,a.parent_worker_id,a.child_session_id,a.status,a.created_at,a.consumed_at,r.head_commit FROM merge_request_review_attempts_v11 a JOIN merge_request_revisions_v11 r ON r.workspace_id=a.workspace_id AND r.merge_request_id=a.merge_request_id AND r.revision_id=a.revision_id ORDER BY a.created_at")?; + s.query_map([], |r| { Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - row.get::<_, String>(4)?, - row.get::<_, String>(5)?, - row.get::<_, String>(6)?, - row.get::<_, String>(7)?, - row.get::<_, String>(8)?, - row.get::<_, String>(9)?, - row.get::<_, String>(10)?, + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get::<_, String>(3)?, + r.get::<_, String>(4)?, + r.get::<_, String>(5)?, + r.get::<_, String>(6)?, + r.get::<_, String>(7)?, + r.get::<_, Option>(8)?, + r.get::<_, String>(9)?, )) })? - .collect::, _>>()?; - drop(stmt); - for ( - workspace_id, - mr_id, - revision_id, - base, - head, - paths_json, - summary, - assignment, - runtime, - worker, - created_at, - ) in revisions - { - let event_seq = next_event_seq_tx(tx, &workspace_id, &mr_id)?; - let event = RequestForReviewEvent { - event_seq, - base_commit: base, - head_commit: head, - changed_paths: serde_json::from_str(&paths_json).map_err(|error| { - MergeRequestError::Operation(format!( - "cannot migrate revision `{revision_id}` changed paths: {error}" - )) - })?, - summary, - assignment_id: assignment, + .collect::, _>>()? + }; + for (ws, a, mr, pr, pw, child, status, created, consumed, subject) in attempts { + let req = ReviewRequestedEvent { + event_id: format!("migrated-request-{a}"), + sequence: next_seq(t, &ws, &mr)?, + subject_ref: subject.clone(), requested_by: WorkerIdentity { - runtime_id: runtime, - worker_id: worker, + runtime_id: pr.clone(), + worker_id: pw, }, - created_at: parse_timestamp(&created_at)?, + reviewer: WorkerIdentity { + runtime_id: pr, + worker_id: child, + }, + created_at: time(&created)?, }; - insert_event_tx( - tx, - &workspace_id, - &mr_id, - "request_for_review", - &event, - event.created_at, - None, - )?; - - let reviews = { - let mut reviews_stmt = tx.prepare( - "SELECT reviewer_worker_runtime_id, reviewer_worker_id, reviewer_profile, - decision, body, findings_json, created_at - FROM merge_request_reviews_v11 - WHERE workspace_id = ?1 AND revision_id = ?2 - ORDER BY created_at, review_id", - )?; - reviews_stmt - .query_map(params![workspace_id, revision_id], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - row.get::<_, String>(4)?, - row.get::<_, String>(5)?, - row.get::<_, String>(6)?, - )) + insert_event(t, &ws, &mr, "review_requested", &req, req.created_at, None)?; + if status == "submitted" { + let(row_dec,row_body,row_at):(String,String,String)=t.query_row("SELECT decision,body,submitted_at FROM merge_request_reviews_v11 WHERE workspace_id=?1 AND attempt_id=?2",params![ws,a],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?)))?; + let findings = { + let mut s=t.prepare("SELECT severity,code,path,line,body FROM merge_request_review_findings_v11 WHERE workspace_id=?1 AND attempt_id=?2 ORDER BY ordinal")?; + s.query_map(params![ws, a], |r| { + Ok(ReviewFinding { + severity: match r.get::<_, String>(0)?.as_str() { + "blocker" => FindingSeverity::Blocker, + "major" => FindingSeverity::Major, + "minor" => FindingSeverity::Minor, + _ => FindingSeverity::Note, + }, + code: r.get(1)?, + path: r.get(2)?, + line: r.get(3)?, + body: r.get(4)?, + }) })? .collect::, _>>()? - }; - for ( - review_runtime, - review_worker, - profile, - decision, - body, - findings_json, - review_created, - ) in reviews - { - let review_seq = next_event_seq_tx(tx, &workspace_id, &mr_id)?; - let review = ReviewEvent { - event_seq: review_seq, - request_event_seq: event_seq, - head_commit: event.head_commit.clone(), - reviewer_worker: WorkerIdentity { - runtime_id: review_runtime, - worker_id: review_worker, - }, - reviewer_profile: profile, - decision: match decision.as_str() { - "approve" => ReviewDecision::Approve, - "request_changes" => ReviewDecision::RequestChanges, - other => { - return Err(MergeRequestError::Operation(format!( - "cannot migrate unknown review decision `{other}`" - ))); - } - }, - body, - findings: serde_json::from_str(&findings_json).map_err(|error| { - MergeRequestError::Operation(format!("cannot migrate review findings: {error}")) - })?, - created_at: parse_timestamp(&review_created)?, }; - insert_event_tx( - tx, - &workspace_id, - &mr_id, - "review", - &review, - review.created_at, - None, - )?; + let rev = ReviewEvent { + event_id: format!("migrated-review-{a}"), + sequence: next_seq(t, &ws, &mr)?, + request_event_id: req.event_id, + subject_ref: subject, + decision: if row_dec == "approve" { + ReviewDecision::Approve + } else { + ReviewDecision::RequestChanges + }, + body: row_body, + findings, + reviewer: req.reviewer, + created_at: time(&row_at)?, + }; + insert_event(t, &ws, &mr, "review", &rev, rev.created_at, None)? + } else if status == "revoked" { + let at = consumed.as_deref().unwrap_or(&created); + let e = ReviewCancelledEvent { + event_id: format!("migrated-cancel-{a}"), + sequence: next_seq(t, &ws, &mr)?, + request_event_id: req.event_id, + subject_ref: subject, + reason: "legacy review attempt revoked".into(), + created_at: time(at)?, + }; + insert_event(t, &ws, &mr, "review_cancelled", &e, e.created_at, None)? } } - - let mut completion_stmt = tx.prepare( - "SELECT workspace_id, merge_request_id, operation_id, target_commit, source_commit, - result_commit, strategy, resolution, requested_by_runtime_id, - requested_by_worker_id, completed_at - FROM merge_request_completion_operations_v11 - WHERE status = 'succeeded' - ORDER BY completed_at, operation_id", - )?; - let completions = completion_stmt - .query_map([], |row| { + let completed = { + let mut q=t.prepare("SELECT c.workspace_id,c.operation_id,c.ticket_id,c.target_commit,c.source_commit,c.result_commit,c.strategy,c.resolution,c.completion_actor_runtime_id,c.completion_actor_worker_id,c.updated_at,rel.merge_request_id FROM merge_request_completion_operations_v11 c JOIN merge_request_ticket_relations_v11 rel ON rel.workspace_id=c.workspace_id AND rel.ticket_id=c.ticket_id WHERE c.status='completed' ORDER BY c.updated_at")?; + q.query_map([], |r| { Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - row.get::<_, String>(4)?, - row.get::<_, String>(5)?, - row.get::<_, String>(6)?, - row.get::<_, String>(7)?, - row.get::<_, String>(8)?, - row.get::<_, String>(9)?, - row.get::<_, Option>(10)?, + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get::<_, Option>(3)?, + r.get::<_, Option>(4)?, + r.get::<_, Option>(5)?, + r.get::<_, Option>(6)?, + r.get::<_, Option>(7)?, + r.get::<_, Option>(8)?, + r.get::<_, Option>(9)?, + r.get::<_, String>(10)?, + r.get::<_, String>(11)?, )) })? - .collect::, _>>()?; - drop(completion_stmt); + .collect::, _>>()? + }; for ( - workspace_id, - mr_id, - operation_id, + ws, + op, + _ticket, target, source, result, @@ -1722,170 +1088,86 @@ fn migrate_v11_events(tx: &Transaction<'_>) -> Result<(), MergeRequestError> { resolution, runtime, worker, - completed_at, - ) in completions + updated, + mr, + ) in completed { - let created_at = parse_timestamp(completed_at.as_deref().ok_or_else(|| { + let subject = source.ok_or_else(|| { + MergeRequestError::Operation(format!("completed operation {op} lacks source evidence")) + })?; + let approval:Option=t.query_row("SELECT event_id FROM merge_request_thread_events WHERE workspace_id=?1 AND merge_request_id=?2 AND kind='review' AND json_extract(payload_json,'$.subject_ref')=?3 AND json_extract(payload_json,'$.decision')='approve' ORDER BY sequence DESC LIMIT 1",params![ws,mr,subject],|r|r.get(0)).optional()?; + let approval = approval.ok_or_else(|| { MergeRequestError::Operation(format!( - "cannot migrate completion `{operation_id}` without completed_at" + "completed operation {op} lacks approval evidence" )) - })?)?; - let event = MergeEvent { - event_seq: next_event_seq_tx(tx, &workspace_id, &mr_id)?, - operation_id, - target_commit: target, - source_commit: source, - result_commit: result, - strategy: match strategy.as_str() { - "fast_forward" => MergeStrategy::FastForward, - "merge" => MergeStrategy::Merge, - other => { - return Err(MergeRequestError::Operation(format!( - "cannot migrate unknown merge strategy `{other}`" - ))); - } + })?; + let e = MergeEvent { + event_id: format!("migrated-merge-{op}"), + sequence: next_seq(t, &ws, &mr)?, + operation_id: op, + approval_event_id: approval, + approved_source_ref: subject, + target_ref_before: target.ok_or_else(|| { + MergeRequestError::Operation("completed operation lacks target evidence".into()) + })?, + target_ref_after: result.ok_or_else(|| { + MergeRequestError::Operation("completed operation lacks result evidence".into()) + })?, + strategy: if strategy.as_deref() == Some("merge") { + MergeStrategy::Merge + } else { + MergeStrategy::FastForward }, - resolution: match resolution.as_str() { - "none" => ConflictResolution::None, - "clean" => ConflictResolution::Clean, - "conflicts_resolved" => ConflictResolution::ConflictsResolved, - other => { - return Err(MergeRequestError::Operation(format!( - "cannot migrate unknown conflict resolution `{other}`" - ))); - } + resolution: match resolution.as_deref() { + Some("clean") => ConflictResolution::Clean, + Some("conflicts_resolved") => ConflictResolution::ConflictsResolved, + _ => ConflictResolution::None, }, merged_by: WorkerIdentity { - runtime_id: runtime, - worker_id: worker, + runtime_id: runtime.unwrap_or_else(|| "legacy".into()), + worker_id: worker.unwrap_or_else(|| "legacy".into()), }, - created_at, + created_at: time(&updated)?, }; - insert_event_tx( - tx, - &workspace_id, - &mr_id, + insert_event( + t, + &ws, + &mr, "merge", - &event, - created_at, - Some(&event.operation_id), + &e, + e.created_at, + Some(&e.operation_id), )?; } Ok(()) } - -fn create_v12_tables(tx: &Transaction<'_>) -> Result<(), MergeRequestError> { - tx.execute_batch( - "CREATE TABLE merge_request_schema ( - singleton INTEGER PRIMARY KEY CHECK (singleton = 1), - version INTEGER NOT NULL - );", - )?; - create_v12_domain_tables(tx) -} - -fn create_v12_domain_tables(tx: &Transaction<'_>) -> Result<(), MergeRequestError> { - tx.execute_batch( - "CREATE TABLE merge_requests ( - workspace_id TEXT NOT NULL, - merge_request_id TEXT NOT NULL, - ticket_id TEXT NOT NULL, - repository_id TEXT NOT NULL, - state TEXT NOT NULL CHECK (state IN ('open', 'merged', 'closed')), - selector_from TEXT NOT NULL CHECK (length(trim(selector_from)) > 0), - selector_to TEXT NOT NULL CHECK (length(trim(selector_to)) > 0), - opened_by_runtime_id TEXT NOT NULL, - opened_by_worker_id TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, merge_request_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - FOREIGN KEY (workspace_id, repository_id) - REFERENCES repositories(workspace_id, repository_id) ON DELETE RESTRICT, - FOREIGN KEY (workspace_id, ticket_id) - REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE - ); - CREATE UNIQUE INDEX merge_requests_one_open_per_ticket - ON merge_requests(workspace_id, ticket_id) WHERE state = 'open'; - CREATE INDEX merge_requests_ticket_history - ON merge_requests(workspace_id, ticket_id, created_at DESC); - - CREATE TABLE merge_request_thread_events ( - workspace_id TEXT NOT NULL, - merge_request_id TEXT NOT NULL, - event_seq INTEGER NOT NULL CHECK (event_seq > 0), - kind TEXT NOT NULL CHECK (kind IN ('request_for_review', 'review', 'merge', 'reopen', 'close')), - payload_json TEXT NOT NULL, - operation_id TEXT, - created_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, merge_request_id, event_seq), - FOREIGN KEY (workspace_id, merge_request_id) - REFERENCES merge_requests(workspace_id, merge_request_id) ON DELETE CASCADE - ); - CREATE UNIQUE INDEX merge_request_merge_operation_ids - ON merge_request_thread_events(workspace_id, operation_id) - WHERE operation_id IS NOT NULL; - - CREATE TABLE merge_request_review_capabilities ( - workspace_id TEXT NOT NULL, - merge_request_id TEXT NOT NULL, - request_event_seq INTEGER NOT NULL, - capability_token TEXT PRIMARY KEY, - issued_by_assignment_id TEXT NOT NULL, - reviewer_runtime_id TEXT NOT NULL, - reviewer_worker_id TEXT NOT NULL, - reviewer_profile TEXT NOT NULL, - issued_at TEXT NOT NULL, - consumed_at TEXT, - status TEXT NOT NULL CHECK (status IN ('issued', 'consumed', 'revoked')), - FOREIGN KEY (workspace_id, merge_request_id, request_event_seq) - REFERENCES merge_request_thread_events(workspace_id, merge_request_id, event_seq) - ON DELETE CASCADE - ); - CREATE TABLE merge_request_reviewer_child_sessions ( - workspace_id TEXT NOT NULL, - child_session_id TEXT NOT NULL, - parent_runtime_id TEXT NOT NULL, - parent_worker_id TEXT NOT NULL, - reviewer_profile TEXT NOT NULL, - registered_at TEXT NOT NULL, - status TEXT NOT NULL CHECK (status IN ('active', 'consumed')), - PRIMARY KEY (workspace_id, child_session_id) - );", - )?; - Ok(()) -} - -fn verify_latest_schema(conn: &Connection) -> Result<(), MergeRequestError> { - for table in [ +fn verify(c: &Connection) -> Result<(), MergeRequestError> { + for n in [ "merge_requests", + "merge_request_ticket_relations", "merge_request_thread_events", - "merge_request_review_capabilities", - "merge_request_reviewer_child_sessions", + "merge_request_review_grants", ] { - let exists: bool = conn.query_row( - "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)", - params![table], - |row| row.get(0), + let e: bool = c.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table'AND name=?1)", + params![n], + |r| r.get(0), )?; - if !exists { - return Err(MergeRequestError::Corrupt(format!( - "schema v{SCHEMA_VERSION} is missing table `{table}`" - ))); + if !e { + return Err(MergeRequestError::Corrupt(format!("missing `{n}`"))); } } - foreign_key_check(conn) + fk(c) } - -fn foreign_key_check(conn: &Connection) -> Result<(), MergeRequestError> { - let violation: Option<(String, i64)> = conn - .query_row("PRAGMA foreign_key_check", [], |row| { - Ok((row.get(0)?, row.get(1)?)) +fn fk(c: &Connection) -> Result<(), MergeRequestError> { + let v: Option<(String, i64)> = c + .query_row("PRAGMA foreign_key_check", [], |r| { + Ok((r.get(0)?, r.get(1)?)) }) .optional()?; - if let Some((table, rowid)) = violation { + if let Some((t, r)) = v { return Err(MergeRequestError::Corrupt(format!( - "foreign key violation in `{table}` row {rowid}" + "foreign key violation {t}:{r}" ))); } Ok(()) diff --git a/crates/merge-request/tests/store.rs b/crates/merge-request/tests/store.rs index 8b5654d5..24a47e7c 100644 --- a/crates/merge-request/tests/store.rs +++ b/crates/merge-request/tests/store.rs @@ -1,407 +1,215 @@ -use std::sync::{Arc, Mutex}; - use chrono::{TimeZone, Utc}; -use merge_request::{ - AssignmentSource, CompleteMergeRequest, ConflictResolution, CurrentAssignment, FindingSeverity, - MergeRequestAuth, MergeRequestState, MergeRequestStore, MergeRequestThreadEvent, MergeStrategy, - OpenMergeRequest, ReadinessCheck, RegisterReviewCapability, RegisterReviewerChildSession, - RepositorySource, RequestForReview, RequestMergeRequestReview, ReviewDecision, ReviewFinding, - SubmitMergeRequestReview, -}; -use rusqlite::{Connection, params}; - +use merge_request::*; +use rusqlite::Connection; +use std::sync::{Arc, Mutex}; #[derive(Clone)] -struct Assignments { - current: Arc>, -} - +struct Assignments(Arc>); impl AssignmentSource for Assignments { - fn current_assignment( - &self, - _workspace_id: &str, - _ticket_id: &str, - ) -> Result, String> { - Ok(Some(self.current.lock().unwrap().clone())) + fn current_assignment(&self, _: &str, _: &str) -> Result, String> { + Ok(Some(self.0.lock().unwrap().clone())) } } - struct Repositories; - impl RepositorySource for Repositories { - fn repository_belongs_to_workspace( - &self, - workspace_id: &str, - repository_id: &str, - ) -> Result { - Ok(workspace_id == "W" && repository_id == "R") - } - - fn is_ancestor( - &self, - _workspace_id: &str, - _repository_id: &str, - ancestor: &str, - descendant: &str, - ) -> Result { - Ok(matches!( - (ancestor, descendant), - ("base", "head-1") | ("base", "head-2") - )) + fn repository_belongs_to_workspace(&self, w: &str, r: &str) -> Result { + Ok(w == "W" && r == "R") } } - -fn now(second: u32) -> chrono::DateTime { - Utc.with_ymd_and_hms(2026, 7, 26, 12, 0, second) +fn at(s: u32) -> chrono::DateTime { + Utc.with_ymd_and_hms(2026, 7, 26, 12, 0, s) .single() .unwrap() } - -fn fixture() -> (tempfile::TempDir, MergeRequestStore, Assignments) { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("server.db"); - let conn = Connection::open(&path).unwrap(); - conn.execute_batch( - "PRAGMA foreign_keys = ON; - CREATE TABLE workspaces (workspace_id TEXT PRIMARY KEY); - CREATE TABLE repositories ( - workspace_id TEXT NOT NULL, - repository_id TEXT NOT NULL, - PRIMARY KEY (workspace_id, repository_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) - ); - CREATE TABLE typed_tickets ( - workspace_id TEXT NOT NULL, - ticket_id TEXT NOT NULL, - workflow_state TEXT NOT NULL, - workflow_state_explicit INTEGER NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, ticket_id) - ); - CREATE TABLE typed_ticket_events ( - workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, - kind TEXT NOT NULL, author TEXT NOT NULL, at TEXT NOT NULL, - from_state TEXT, to_state TEXT, heading TEXT, body TEXT, - PRIMARY KEY (workspace_id, ticket_id, event_index) - ); - CREATE TABLE typed_ticket_event_attributes ( - workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, - key TEXT NOT NULL, value TEXT NOT NULL, - PRIMARY KEY (workspace_id, ticket_id, event_index, key) - ); - INSERT INTO workspaces VALUES ('W'); - INSERT INTO repositories VALUES ('W', 'R'); - INSERT INTO typed_tickets VALUES ('W', 'T', 'inprogress', 1, '2026-07-26T12:00:00Z');", - ) - .unwrap(); - drop(conn); - let assignments = Assignments { - current: Arc::new(Mutex::new(CurrentAssignment { - assignment_id: "A1".into(), - ticket_id: "T".into(), - runtime_id: "runtime".into(), - worker_id: "coder".into(), - })), - }; - let store = - MergeRequestStore::open(&path, Arc::new(assignments.clone()), Arc::new(Repositories)) - .unwrap(); - (dir, store, assignments) -} - -fn auth(assignment_id: &str) -> MergeRequestAuth { +fn auth() -> MergeRequestAuth { MergeRequestAuth { workspace_id: "W".into(), repository_id: "R".into(), runtime_id: "runtime".into(), worker_id: "coder".into(), - assignment_id: assignment_id.into(), + assignment_id: "A".into(), } } - -fn open(store: &MergeRequestStore) { - store - .open_merge_request(OpenMergeRequest { - merge_request_id: "MR".into(), - ticket_id: "T".into(), - repository_id: "R".into(), - selector_from: "work/t-feature".into(), - selector_to: "develop".into(), - request: RequestForReview { - base_commit: "base".into(), - head_commit: "head-1".into(), - changed_paths: vec!["src/lib.rs".into()], - summary: "first candidate".into(), - }, - auth: auth("A1"), - now: now(1), - }) - .unwrap(); -} - -fn approve(store: &MergeRequestStore, expected_head_commit: &str, token: &str) { - store - .register_reviewer_child_session(RegisterReviewerChildSession { - workspace_id: "W".into(), - parent_runtime_id: "runtime".into(), - parent_worker_id: "coder".into(), - child_session_id: format!("child-{token}"), - reviewer_profile: "builtin:reviewer".into(), - now: now(2), - }) - .unwrap(); - store - .register_review_capability(RegisterReviewCapability { - ticket_id: "T".into(), - expected_head_commit: expected_head_commit.into(), - child_session_id: format!("child-{token}"), - capability_token: token.into(), - auth: auth("A1"), - now: now(3), - }) - .unwrap(); - store - .submit_review(SubmitMergeRequestReview { - ticket_id: "T".into(), - expected_head_commit: expected_head_commit.into(), - capability_token: token.into(), - decision: ReviewDecision::Approve, - body: "approved independently".into(), - findings: vec![ReviewFinding { - severity: FindingSeverity::Note, - path: None, - line: None, - message: "looks good".into(), - }], - now: now(4), - }) - .unwrap(); -} - -#[test] -fn thread_drives_review_readiness_and_completion_without_public_revision_identity() { - let (_dir, store, _assignments) = fixture(); - open(&store); - approve(&store, "head-1", "token-1"); - - let readiness = store - .readiness(ReadinessCheck { - ticket_id: "T".into(), - expected_head_commit: Some("head-1".into()), - auth: auth("A1"), - }) - .unwrap(); - assert!(readiness.ready, "{:?}", readiness.blockers); - - let merged = store - .complete(CompleteMergeRequest { - ticket_id: "T".into(), - expected_head_commit: "head-1".into(), - operation_id: "op-1".into(), - target_commit: "base".into(), - source_commit: "head-1".into(), - result_commit: "head-1".into(), - strategy: MergeStrategy::FastForward, - resolution: ConflictResolution::None, - auth: auth("A1"), - now: now(5), - }) - .unwrap(); - assert_eq!(merged.result_commit, "head-1"); - - let mr = store.get("W", "T").unwrap(); - assert_eq!(mr.state, MergeRequestState::Merged); - assert!(matches!( - mr.thread.last(), - Some(MergeRequestThreadEvent::Merge(_)) - )); - assert_eq!(mr.selector_from, "work/t-feature"); - assert_eq!(mr.selector_to, "develop"); - let json = serde_json::to_string(&mr).unwrap(); - for forbidden in [ - "revision_id", - "current_revision", - "attempt_id", - "review_attempt", - "head_tree", - "diff_digest", - "merged_revision_id", - ] { - assert!( - !json.contains(forbidden), - "unexpected `{forbidden}` in {json}" - ); - } -} - -#[test] -fn new_review_request_invalidates_prior_approval_and_fences_stale_capability() { - let (_dir, store, _assignments) = fixture(); - open(&store); - approve(&store, "head-1", "token-1"); - store - .request_review(RequestMergeRequestReview { - ticket_id: "T".into(), - expected_head_commit: "head-1".into(), - request: RequestForReview { - base_commit: "base".into(), - head_commit: "head-2".into(), - changed_paths: vec!["src/lib.rs".into(), "tests/store.rs".into()], - summary: "address review".into(), - }, - auth: auth("A1"), - now: now(6), - }) - .unwrap(); - - let readiness = store - .readiness(ReadinessCheck { - ticket_id: "T".into(), - expected_head_commit: Some("head-2".into()), - auth: auth("A1"), - }) - .unwrap(); - assert!(!readiness.ready); - assert!( - readiness - .blockers - .iter() - .any(|value| value.contains("no review result")) - ); - assert!( - store - .submit_review(SubmitMergeRequestReview { - ticket_id: "T".into(), - expected_head_commit: "head-1".into(), - capability_token: "token-1".into(), - decision: ReviewDecision::Approve, - body: "stale".into(), - findings: vec![], - now: now(7), - }) - .is_err() - ); -} - -#[test] -fn assignment_change_rejects_candidate_mutation() { - let (_dir, store, assignments) = fixture(); - open(&store); - *assignments.current.lock().unwrap() = CurrentAssignment { - assignment_id: "A2".into(), +fn fixture() -> (tempfile::TempDir, MergeRequestStore) { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("db"); + let c = Connection::open(&p).unwrap(); + c.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,workflow_state TEXT,workflow_state_explicit INTEGER,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_ticket_events(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,kind TEXT,author TEXT,at TEXT,from_state TEXT,to_state TEXT,heading TEXT,body TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index));CREATE TABLE typed_ticket_event_attributes(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,key TEXT,value TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index,key));INSERT INTO workspaces VALUES('W');INSERT INTO repositories VALUES('W','R');INSERT INTO typed_tickets VALUES('W','T','inprogress',1,'t');").unwrap(); + drop(c); + let a = Assignments(Arc::new(Mutex::new(CurrentAssignment { + assignment_id: "A".into(), ticket_id: "T".into(), runtime_id: "runtime".into(), - worker_id: "other".into(), - }; - let error = store - .request_review(RequestMergeRequestReview { + worker_id: "coder".into(), + }))); + let s = MergeRequestStore::open(&p, Arc::new(a), Arc::new(Repositories)).unwrap(); + (d, s) +} +fn open(s: &MergeRequestStore) { + s.open_merge_request(OpenMergeRequest { + merge_request_id: "MR".into(), + ticket_id: "T".into(), + repository_id: "R".into(), + selector_from: "work/t".into(), + selector_to: "develop".into(), + summary: "summary".into(), + auth: auth(), + now: at(1), + }) + .unwrap(); +} +fn request(s: &MergeRequestStore, subject: &str, token: &str) -> ReviewRequestedEvent { + s.register_reviewer_child_session(RegisterReviewerChildSession { + workspace_id: "W".into(), + parent_runtime_id: "runtime".into(), + parent_worker_id: "coder".into(), + child_session_id: format!("child-{token}"), + reviewer_profile: "builtin:reviewer".into(), + now: at(2), + }) + .unwrap(); + s.request_review(RequestMergeRequestReview { + ticket_id: "T".into(), + subject_ref: subject.into(), + child_session_id: format!("child-{token}"), + capability_token: token.into(), + auth: auth(), + now: at(3), + }) + .unwrap() + .request_event +} +fn approve(s: &MergeRequestStore, subject: &str, token: &str) -> ReviewEvent { + request(s, subject, token); + s.submit_review(SubmitMergeRequestReview { + ticket_id: "T".into(), + current_subject_ref: subject.into(), + capability_token: token.into(), + decision: ReviewDecision::Approve, + body: "approved".into(), + findings: vec![], + now: at(4), + }) + .unwrap() +} +#[test] +fn selectors_thread_and_completion_have_no_revision_or_commit_api() { + let (_d, s) = fixture(); + open(&s); + let review = approve(&s, "opaque-source-ref", "token"); + let ready = s + .readiness(ReadinessCheck { ticket_id: "T".into(), - expected_head_commit: "head-1".into(), - request: RequestForReview { - base_commit: "base".into(), - head_commit: "head-2".into(), - changed_paths: vec![], - summary: String::new(), - }, - auth: auth("A1"), - now: now(6), + current_subject_ref: Some("opaque-source-ref".into()), + auth: auth(), }) - .unwrap_err(); - assert!(error.to_string().contains("current assigned worker")); + .unwrap(); + assert!(ready.ready); + let merged = s + .complete(CompleteMergeRequest { + ticket_id: "T".into(), + operation_id: "op".into(), + approval_event_id: review.event_id, + current_subject_ref: "opaque-source-ref".into(), + target_ref_before: "old-target-ref".into(), + target_ref_after: "new-target-ref".into(), + strategy: MergeStrategy::FastForward, + resolution: ConflictResolution::None, + auth: auth(), + now: at(5), + }) + .unwrap(); + assert_eq!(merged.approved_source_ref, "opaque-source-ref"); + let mr = s.get("W", "T").unwrap(); + assert_eq!(mr.selector_from.as_deref(), Some("work/t")); + assert_eq!(mr.state, MergeRequestState::Merged); + let json = serde_json::to_string(&mr).unwrap(); + for banned in [ + "revision_id", + "attempt_id", + "base_commit", + "head_commit", + "source_commit", + "result_commit", + "current_revision", + ] { + assert!(!json.contains(banned), "{banned} in {json}") + } +} +#[test] +fn source_move_cancels_submission_and_old_approval_is_reusable_when_source_returns() { + let (_d, s) = fixture(); + open(&s); + let approved = approve(&s, "source-a", "one"); + request(&s, "source-b", "two"); + assert!( + s.submit_review(SubmitMergeRequestReview { + ticket_id: "T".into(), + current_subject_ref: "source-c".into(), + capability_token: "two".into(), + decision: ReviewDecision::Approve, + body: "stale".into(), + findings: vec![], + now: at(6) + }) + .is_err() + ); + let mr = s.get("W", "T").unwrap(); + assert!( + mr.thread + .iter() + .any(|e| matches!(e, MergeRequestThreadEvent::ReviewCancelled(_))) + ); + assert_eq!( + mr.effective_review("source-a").map(|r| &r.event_id), + Some(&approved.event_id) + ); +} +#[test] +fn review_revocation_invalidates_readiness() { + let (_d, s) = fixture(); + open(&s); + let review = approve(&s, "source", "one"); + s.revoke_review(RevokeMergeRequestReview { + ticket_id: "T".into(), + review_event_id: review.event_id, + reason: "bad evidence".into(), + auth: auth(), + now: at(7), + }) + .unwrap(); + let r = s + .readiness(ReadinessCheck { + ticket_id: "T".into(), + current_subject_ref: Some("source".into()), + auth: auth(), + }) + .unwrap(); + assert!(!r.ready); } #[test] -fn v11_migration_builds_thread_events_and_removes_revision_tables() { - let conn = Connection::open_in_memory().unwrap(); - conn.execute_batch( - "PRAGMA foreign_keys = OFF; - CREATE TABLE workspaces (workspace_id TEXT PRIMARY KEY); - CREATE TABLE repositories ( - workspace_id TEXT NOT NULL, repository_id TEXT NOT NULL, - PRIMARY KEY (workspace_id, repository_id) - ); - INSERT INTO workspaces VALUES ('W'); - INSERT INTO repositories VALUES ('W', 'R'); - CREATE TABLE typed_tickets ( - workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, - PRIMARY KEY (workspace_id, ticket_id) - ); - INSERT INTO typed_tickets VALUES ('W', 'T'); - CREATE TABLE merge_request_schema (singleton INTEGER PRIMARY KEY, version INTEGER NOT NULL); - INSERT INTO merge_request_schema VALUES (1, 11); - CREATE TABLE merge_requests ( - workspace_id TEXT, merge_request_id TEXT, ticket_id TEXT, repository_id TEXT, - state TEXT, target_ref_selector TEXT, current_revision_id TEXT, - opened_by_worker_runtime_id TEXT, opened_by_worker_id TEXT, created_at TEXT, updated_at TEXT - ); - CREATE TABLE merge_request_revisions ( - workspace_id TEXT, revision_id TEXT, merge_request_id TEXT, base_commit TEXT, - head_commit TEXT, changed_paths_json TEXT, summary TEXT, assignment_id TEXT, - coder_worker_runtime_id TEXT, coder_worker_id TEXT, created_at TEXT - ); - CREATE TABLE merge_request_review_attempts (attempt_id TEXT); - CREATE TABLE merge_request_reviews ( - workspace_id TEXT, review_id TEXT, revision_id TEXT, - reviewer_worker_runtime_id TEXT, reviewer_worker_id TEXT, reviewer_profile TEXT, - decision TEXT, body TEXT, findings_json TEXT, created_at TEXT - ); - CREATE TABLE merge_request_completion_operations ( - workspace_id TEXT, merge_request_id TEXT, operation_id TEXT, target_commit TEXT, - source_commit TEXT, result_commit TEXT, strategy TEXT, resolution TEXT, - requested_by_runtime_id TEXT, requested_by_worker_id TEXT, completed_at TEXT, status TEXT - ); - CREATE TABLE merge_request_reviewer_child_sessions (child_session_id TEXT); - INSERT INTO merge_requests VALUES ( - 'W', 'MR', 'T', 'R', 'open', 'develop', 'REV', - 'runtime', 'coder', '2026-07-26T12:00:00Z', '2026-07-26T12:00:00Z' - ); - INSERT INTO merge_request_revisions VALUES ( - 'W', 'REV', 'MR', 'base', 'head-1', '[\"src/lib.rs\"]', 'legacy', 'A1', - 'runtime', 'coder', '2026-07-26T12:00:00Z' - ); - INSERT INTO merge_request_reviews VALUES ( - 'W', 'REVIEW', 'REV', 'runtime', 'child', 'builtin:reviewer', - 'approve', 'approved', '[]', '2026-07-26T12:00:01Z' - );", - ) - .unwrap(); - - merge_request::migrate(&conn).unwrap(); - assert_eq!( - conn.query_row("SELECT version FROM merge_request_schema", [], |row| row - .get::<_, i64>(0)) - .unwrap(), - 12 - ); - assert_eq!( - conn.query_row("SELECT selector_from FROM merge_requests", [], |row| row - .get::<_, String>( - 0 - )) - .unwrap(), - "head-1" - ); - assert_eq!( - conn.query_row( - "SELECT COUNT(*) FROM merge_request_thread_events", +fn v11_migration_preserves_review_events_and_requires_selector_repair() { + let c = Connection::open_in_memory().unwrap(); + c.execute_batch("CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,PRIMARY KEY(workspace_id,ticket_id));INSERT INTO repositories VALUES('W','R');INSERT INTO typed_tickets VALUES('W','T');CREATE TABLE merge_request_schema(singleton INTEGER PRIMARY KEY,version INTEGER);INSERT INTO merge_request_schema VALUES(1,11);CREATE TABLE merge_requests(workspace_id TEXT,merge_request_id TEXT,repository_id TEXT,state TEXT,target_ref_selector TEXT,current_revision_ordinal INTEGER,current_revision_id TEXT,created_at TEXT,updated_at TEXT,merged_revision_id TEXT,merged_at TEXT);CREATE TABLE merge_request_ticket_relations(workspace_id TEXT,merge_request_id TEXT,ticket_id TEXT,relation_kind TEXT,created_at TEXT);CREATE TABLE merge_request_revisions(workspace_id TEXT,merge_request_id TEXT,revision_id TEXT,ordinal INTEGER,base_commit TEXT,head_commit TEXT,diff_digest TEXT,summary TEXT,assignment_id TEXT,created_at TEXT);CREATE TABLE merge_request_revision_paths(workspace_id TEXT,merge_request_id TEXT,revision_id TEXT,ordinal INTEGER,path TEXT);CREATE TABLE merge_request_reviewer_child_sessions(workspace_id TEXT,child_session_id TEXT,parent_runtime_id TEXT,parent_worker_id TEXT,reviewer_profile TEXT,registered_at TEXT);CREATE TABLE merge_request_review_attempts(workspace_id TEXT,attempt_id TEXT,merge_request_id TEXT,ticket_id TEXT,revision_id TEXT,revision_ordinal INTEGER,parent_assignment_id TEXT,parent_runtime_id TEXT,parent_worker_id TEXT,child_session_id TEXT,reviewer_effective_profile TEXT,capability_token TEXT,status TEXT,created_at TEXT,consumed_at TEXT);CREATE TABLE merge_request_reviews(workspace_id TEXT,attempt_id TEXT,merge_request_id TEXT,revision_id TEXT,decision TEXT,body TEXT,submitted_at TEXT);CREATE TABLE merge_request_review_findings(workspace_id TEXT,attempt_id TEXT,ordinal INTEGER,severity TEXT,code TEXT,path TEXT,line INTEGER,body TEXT);CREATE TABLE merge_request_completion_operations(workspace_id TEXT,operation_id TEXT,ticket_id TEXT,revision_id TEXT,authority_kind TEXT,implementation_assignment_id TEXT,completion_actor_runtime_id TEXT,completion_actor_worker_id TEXT,target_commit TEXT,source_commit TEXT,result_commit TEXT,strategy TEXT,resolution TEXT,fingerprint TEXT,status TEXT,result_ticket_state TEXT,created_at TEXT,updated_at TEXT);INSERT INTO merge_requests VALUES('W','MR','R','open','develop',1,'V','2026-07-26T12:00:00Z','2026-07-26T12:00:00Z',NULL,NULL);INSERT INTO merge_request_ticket_relations VALUES('W','MR','T','implements','2026-07-26T12:00:00Z');INSERT INTO merge_request_revisions VALUES('W','MR','V',1,'base','subject','digest','summary','A','2026-07-26T12:00:00Z');INSERT INTO merge_request_review_attempts VALUES('W','AT','MR','T','V',1,'A','runtime','coder','child','builtin:reviewer','token','submitted','2026-07-26T12:00:00Z','2026-07-26T12:00:01Z');INSERT INTO merge_request_reviews VALUES('W','AT','MR','V','approve','approved','2026-07-26T12:00:01Z');").unwrap(); + merge_request::migrate(&c).unwrap(); + let selector: Option = c + .query_row("SELECT selector_from FROM merge_requests", [], |r| r.get(0)) + .unwrap(); + assert!(selector.is_none()); + let kinds: String = c + .query_row( + "SELECT group_concat(kind,',') FROM merge_request_thread_events ORDER BY sequence", [], - |row| row.get::<_, i64>(0) + |r| r.get(0), ) - .unwrap(), - 2 - ); - for removed in [ - "merge_request_revisions", - "merge_request_review_attempts", - "merge_request_reviews", - "merge_request_completion_operations", - ] { - let exists: bool = conn - .query_row( - "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)", - params![removed], - |row| row.get(0), - ) - .unwrap(); - assert!(!exists, "legacy table `{removed}` still exists"); - } + .unwrap(); + assert_eq!(kinds, "review_requested,review"); + let old: bool = c + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name='merge_request_revisions')", + [], + |r| r.get(0), + ) + .unwrap(); + assert!(!old); } diff --git a/crates/worker/src/feature/builtin/merge_request.rs b/crates/worker/src/feature/builtin/merge_request.rs index 56a60bb0..b2dd5c57 100644 --- a/crates/worker/src/feature/builtin/merge_request.rs +++ b/crates/worker/src/feature/builtin/merge_request.rs @@ -11,77 +11,51 @@ pub const MERGE_REQUEST_COMMON_TOOL_NAMES: &[&str] = &[ "MergeRequestShow", "MergeRequestReadinessCheck", "MergeRequestOpen", - "MergeRequestRequestReview", "MergeRequestComplete", ]; pub const MERGE_REQUEST_REVIEW_TOOL_NAME: &str = "MergeRequestReviewSubmit"; - #[derive(Clone, Copy)] enum Kind { Show, Readiness, Open, - RequestReview, Complete, Review, } - #[derive(Clone)] struct MergeRequestTool { client: Arc, kind: Kind, } - #[derive(Debug, Deserialize, JsonSchema)] struct ShowInput { ticket: String, } - #[derive(Debug, Deserialize, JsonSchema)] struct OpenInput { ticket: String, repository_id: String, selector_from: String, selector_to: String, - base_commit: String, - head_commit: String, - #[serde(default)] - changed_paths: Vec, #[serde(default)] summary: String, } - -#[derive(Debug, Deserialize, JsonSchema)] -struct RequestReviewInput { - ticket: String, - expected_head_commit: String, - base_commit: String, - head_commit: String, - #[serde(default)] - changed_paths: Vec, - #[serde(default)] - summary: String, -} - #[derive(Debug, Deserialize, JsonSchema)] struct CompleteInput { ticket: String, operation_id: String, - expected_head_commit: String, - target_commit: String, - source_commit: String, - result_commit: String, + approval_event_id: String, + target_ref_before: String, + target_ref_after: String, strategy: MergeStrategyInput, resolution: MergeResolutionInput, } - #[derive(Debug, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] enum MergeStrategyInput { FastForward, Merge, } - #[derive(Debug, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] enum MergeResolutionInput { @@ -89,7 +63,6 @@ enum MergeResolutionInput { Clean, ConflictsResolved, } - #[derive(Debug, Deserialize, JsonSchema)] struct ReviewInput { decision: ReviewDecisionInput, @@ -98,211 +71,133 @@ struct ReviewInput { #[serde(default)] findings: Vec, } - #[derive(Debug, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] enum ReviewDecisionInput { Approve, RequestChanges, } - #[derive(Debug, Deserialize, JsonSchema)] struct ReviewFindingInput { severity: String, #[serde(default)] + code: Option, + #[serde(default)] path: Option, #[serde(default)] line: Option, - message: String, + body: String, } - impl Kind { fn name(self) -> &'static str { match self { Self::Show => "MergeRequestShow", Self::Readiness => "MergeRequestReadinessCheck", Self::Open => "MergeRequestOpen", - Self::RequestReview => "MergeRequestRequestReview", Self::Complete => "MergeRequestComplete", Self::Review => "MergeRequestReviewSubmit", } } - - fn description(self) -> &'static str { - description(self.name()).unwrap_or("Merge Request operation.") - } - fn schema(self) -> serde_json::Value { match self { Self::Show | Self::Readiness => json!(schemars::schema_for!(ShowInput)), Self::Open => json!(schemars::schema_for!(OpenInput)), - Self::RequestReview => json!(schemars::schema_for!(RequestReviewInput)), Self::Complete => json!(schemars::schema_for!(CompleteInput)), Self::Review => json!(schemars::schema_for!(ReviewInput)), } } } - #[async_trait] impl Tool for MergeRequestTool { - async fn execute( - &self, - input: &str, - _context: ToolExecutionContext, - ) -> Result { - let workspace_id = self.client.workspace_id().ok_or_else(|| { + async fn execute(&self, input: &str, _: ToolExecutionContext) -> Result { + let ws = self.client.workspace_id().ok_or_else(|| { ToolError::ExecutionFailed("Merge Request tools require Workspace identity".into()) })?; let (method, path, body) = match self.kind { - Kind::Show => { - let value: ShowInput = parse(input)?; - nonempty(&value.ticket)?; + Kind::Show | Kind::Readiness => { + let v: ShowInput = parse(input)?; + nonempty(&v.ticket)?; ( WorkspaceRequestMethod::Get, format!( - "/api/w/{workspace_id}/tickets/{}/merge-request", - value.ticket - ), - None, - ) - } - Kind::Readiness => { - let value: ShowInput = parse(input)?; - nonempty(&value.ticket)?; - ( - WorkspaceRequestMethod::Get, - format!( - "/api/w/{workspace_id}/tickets/{}/merge-request/readiness", - value.ticket + "/api/w/{ws}/tickets/{}/merge-request{}", + v.ticket, + if matches!(self.kind, Kind::Readiness) { + "/readiness" + } else { + "" + } ), None, ) } Kind::Open => { - let value: OpenInput = parse(input)?; - nonempty(&value.ticket)?; + let v: OpenInput = parse(input)?; + nonempty(&v.ticket)?; ( WorkspaceRequestMethod::Post, - format!( - "/api/w/{workspace_id}/tickets/{}/merge-request", - value.ticket + format!("/api/w/{ws}/tickets/{}/merge-request", v.ticket), + Some( + json!({"repository_id":v.repository_id,"selector_from":v.selector_from,"selector_to":v.selector_to,"summary":v.summary}), ), - Some(json!({ - "repository_id": value.repository_id, - "selector_from": value.selector_from, - "selector_to": value.selector_to, - "base_commit": value.base_commit, - "head_commit": value.head_commit, - "changed_paths": value.changed_paths, - "summary": value.summary, - })), - ) - } - Kind::RequestReview => { - let value: RequestReviewInput = parse(input)?; - nonempty(&value.ticket)?; - ( - WorkspaceRequestMethod::Post, - format!( - "/api/w/{workspace_id}/tickets/{}/merge-request/review-requests", - value.ticket - ), - Some(json!({ - "expected_head_commit": value.expected_head_commit, - "base_commit": value.base_commit, - "head_commit": value.head_commit, - "changed_paths": value.changed_paths, - "summary": value.summary, - })), ) } Kind::Complete => { - let value: CompleteInput = parse(input)?; - nonempty(&value.ticket)?; - let strategy = match value.strategy { - MergeStrategyInput::FastForward => "fast_forward", - MergeStrategyInput::Merge => "merge", - }; - let resolution = match value.resolution { - MergeResolutionInput::None => "none", - MergeResolutionInput::Clean => "clean", - MergeResolutionInput::ConflictsResolved => "conflicts_resolved", - }; + let v: CompleteInput = parse(input)?; + nonempty(&v.ticket)?; ( WorkspaceRequestMethod::Post, - format!( - "/api/w/{workspace_id}/tickets/{}/merge-request/complete", - value.ticket + format!("/api/w/{ws}/tickets/{}/merge-request/complete", v.ticket), + Some( + json!({"operation_id":v.operation_id,"approval_event_id":v.approval_event_id,"target_ref_before":v.target_ref_before,"target_ref_after":v.target_ref_after,"strategy":match v.strategy{MergeStrategyInput::FastForward=>"fast_forward",MergeStrategyInput::Merge=>"merge"},"resolution":match v.resolution{MergeResolutionInput::None=>"none",MergeResolutionInput::Clean=>"clean",MergeResolutionInput::ConflictsResolved=>"conflicts_resolved"}}), ), - Some(json!({ - "operation_id": value.operation_id, - "expected_head_commit": value.expected_head_commit, - "target_commit": value.target_commit, - "source_commit": value.source_commit, - "result_commit": value.result_commit, - "strategy": strategy, - "resolution": resolution, - })), ) } Kind::Review => { - let value: ReviewInput = parse(input)?; - let context = self.client.reviewer_context().ok_or_else(|| { + let v: ReviewInput = parse(input)?; + let ctx = self.client.reviewer_context().ok_or_else(|| { ToolError::ExecutionFailed( - "MergeRequestReviewSubmit is available only to an attested Reviewer child" - .into(), + "Review submit requires injected Reviewer capability".into(), ) })?; ( WorkspaceRequestMethod::Post, format!( - "/api/w/{workspace_id}/tickets/{}/merge-request/reviews", - context.ticket_id + "/api/w/{ws}/tickets/{}/merge-request/reviews", + ctx.ticket_id + ), + Some( + json!({"decision":match v.decision{ReviewDecisionInput::Approve=>"approve",ReviewDecisionInput::RequestChanges=>"request_changes"},"body":v.body,"findings":v.findings.into_iter().map(|f|json!({"severity":f.severity,"code":f.code,"path":f.path,"line":f.line,"body":f.body})).collect::>() }), ), - Some(json!({ - "decision": match value.decision { - ReviewDecisionInput::Approve => "approve", - ReviewDecisionInput::RequestChanges => "request_changes", - }, - "body": value.body, - "findings": value.findings.into_iter().map(|finding| json!({ - "severity": finding.severity, - "path": finding.path, - "line": finding.line, - "message": finding.message, - })).collect::>(), - })), ) } }; - let request = match body { - Some(body) => WorkspaceRequest::json(method, path, body.to_string()), + let req = match body { + Some(v) => WorkspaceRequest::json(method, path, v.to_string()), None => WorkspaceRequest::get(path), }; - let response = self + let res = self .client - .execute(request) - .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; - if !response.is_success() { + .execute(req) + .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?; + if !res.is_success() { return Err(ToolError::ExecutionFailed(format!( "Merge Request API returned HTTP {}: {}", - response.status, response.body + res.status, res.body ))); } Ok(ToolOutput { - summary: self.kind.name().to_string(), - content: Some(response.body), - attachments: Vec::new(), + summary: self.kind.name().into(), + content: Some(res.body), + attachments: vec![], }) } } - -fn parse(value: &str) -> Result { - serde_json::from_str(value).map_err(|error| ToolError::InvalidArgument(error.to_string())) +fn parse(v: &str) -> Result { + serde_json::from_str(v).map_err(|e| ToolError::InvalidArgument(e.to_string())) } - -fn nonempty(value: &str) -> Result<(), ToolError> { - if value.trim().is_empty() { +fn nonempty(v: &str) -> Result<(), ToolError> { + if v.trim().is_empty() { Err(ToolError::InvalidArgument( "ticket must not be empty".into(), )) @@ -310,84 +205,77 @@ fn nonempty(value: &str) -> Result<(), ToolError> { Ok(()) } } - fn definition(client: Arc, kind: Kind) -> ToolDefinition { Arc::new(move || { - let meta = ToolMeta::new(kind.name()) - .description(kind.description()) - .input_schema(kind.schema()); - let tool: Arc = Arc::new(MergeRequestTool { - client: client.clone(), - kind, - }); - (meta, tool) + ( + ToolMeta::new(kind.name()) + .description(description(kind.name()).unwrap_or("Merge Request operation.")) + .input_schema(kind.schema()), + Arc::new(MergeRequestTool { + client: client.clone(), + kind, + }) as Arc, + ) }) } - -pub fn common_tools(client: Arc) -> Vec { +pub fn common_tools(c: Arc) -> Vec { vec![ - definition(client.clone(), Kind::Show), - definition(client.clone(), Kind::Readiness), - definition(client.clone(), Kind::Open), - definition(client.clone(), Kind::RequestReview), - definition(client, Kind::Complete), + definition(c.clone(), Kind::Show), + definition(c.clone(), Kind::Readiness), + definition(c.clone(), Kind::Open), + definition(c, Kind::Complete), ] } - -pub fn reviewer_tools(client: Arc) -> Vec { - if client.reviewer_context().is_some() { +pub fn reviewer_tools(c: Arc) -> Vec { + if c.reviewer_context().is_some() { vec![ - definition(client.clone(), Kind::Show), - definition(client, Kind::Review), + definition(c.clone(), Kind::Show), + definition(c, Kind::Review), ] } else { - Vec::new() + vec![] } } - -pub fn description(name: &str) -> Option<&'static str> { - match name { - "MergeRequestShow" => Some( - "Read the authoritative Merge Request, selector pair, append-only thread, and current review status.", - ), +pub fn description(n: &str) -> Option<&'static str> { + match n { + "MergeRequestShow" => Some("Read the selector-based Merge Request and append-only thread."), "MergeRequestReadinessCheck" => { - Some("Check derived merge readiness for the current review request.") + Some("Resolve current provider refs and derive readiness from valid review events.") + } + "MergeRequestOpen" => { + Some("Open a Merge Request with immutable source and target selectors.") + } + "MergeRequestComplete" => { + Some("Complete using an approved review event and final target-ref evidence.") } - "MergeRequestOpen" => Some( - "Open a Merge Request with immutable source/target selectors and its first review request.", - ), - "MergeRequestRequestReview" => Some( - "Append a RequestForReview event for new candidate evidence; prior approval cannot carry forward.", - ), - "MergeRequestComplete" => Some( - "CAS-complete the approved current candidate with operation-id replay and crash fencing.", - ), "MergeRequestReviewSubmit" => { - Some("Submit the attested direct-child Reviewer result for the current candidate.") + Some("Submit the injected Reviewer capability result for its captured subject ref.") } _ => None, } } - #[cfg(test)] mod tests { use super::*; - #[test] - fn merge_request_tool_contract_uses_selectors_and_commit_fences_without_revision_ids() { - let open = serde_json::to_string(&schemars::schema_for!(OpenInput)).unwrap(); - let request = serde_json::to_string(&schemars::schema_for!(RequestReviewInput)).unwrap(); - let complete = serde_json::to_string(&schemars::schema_for!(CompleteInput)).unwrap(); - assert!(open.contains("selector_from")); - assert!(open.contains("selector_to")); - assert!(request.contains("expected_head_commit")); - assert!(complete.contains("expected_head_commit")); - for schema in [&open, &request, &complete] { - assert!(!schema.contains("revision_id")); - assert!(!schema.contains("attempt_id")); - assert!(!schema.contains("head_tree")); - assert!(!schema.contains("diff_digest")); + fn schemas_hide_revision_and_commit_authority() { + let schemas = [ + schemars::schema_for!(OpenInput), + schemars::schema_for!(CompleteInput), + ]; + for s in schemas { + let j = serde_json::to_string(&s).unwrap(); + for banned in [ + "revision_id", + "attempt_id", + "base_commit", + "head_commit", + "source_commit", + "result_commit", + ] { + assert!(!j.contains(banned), "{banned} in {j}") + } } - assert!(!MERGE_REQUEST_COMMON_TOOL_NAMES.contains(&"MergeRequestAddRevision")); + assert!(!MERGE_REQUEST_COMMON_TOOL_NAMES.contains(&"MergeRequestRequestReview")); } } diff --git a/crates/worker/src/spawn/tool.rs b/crates/worker/src/spawn/tool.rs index 0583daaf..17096356 100644 --- a/crates/worker/src/spawn/tool.rs +++ b/crates/worker/src/spawn/tool.rs @@ -67,7 +67,6 @@ struct SubWorkerSpawnInput { #[derive(Debug, Deserialize, schemars::JsonSchema)] struct ReviewerHandoffInput { ticket_id: String, - expected_head_commit: String, } #[derive(Debug, Deserialize, schemars::JsonSchema)] @@ -337,9 +336,9 @@ fn validate_reviewer_handoff(input: &SubWorkerSpawnInput) -> Result<(), ToolErro let Some(review) = &input.review else { return Ok(()); }; - if review.ticket_id.trim().is_empty() || review.expected_head_commit.trim().is_empty() { + if review.ticket_id.trim().is_empty() { return Err(ToolError::InvalidArgument( - "reviewer handoff requires non-empty ticket_id and expected_head_commit".to_string(), + "reviewer handoff requires non-empty ticket_id".to_string(), )); } if input.profile.as_deref() != Some("builtin:reviewer") { @@ -421,7 +420,6 @@ impl Tool for SubWorkerSpawnTool { let reviewer_capability = input.review.as_ref().map(|review| { ( review.ticket_id.clone(), - review.expected_head_commit.clone(), format!( "{}{}", uuid::Uuid::now_v7().simple(), @@ -430,8 +428,7 @@ impl Tool for SubWorkerSpawnTool { ) }); let child_workspace_context = - if let Some((ticket_id, expected_head_commit, capability_token)) = &reviewer_capability - { + if let Some((ticket_id, capability_token)) = &reviewer_capability { let workspace_id = self.workspace_context .workspace_id() @@ -452,7 +449,6 @@ impl Tool for SubWorkerSpawnTool { parent_client.clone(), ReviewerContext { ticket_id: ticket_id.clone(), - expected_head_commit: expected_head_commit.clone(), }, capability_token.clone(), )); @@ -547,7 +543,7 @@ impl Tool for SubWorkerSpawnTool { } }; - if let Some((ticket_id, expected_head_commit, capability_token)) = &reviewer_capability { + if let Some((ticket_id, capability_token)) = &reviewer_capability { let workspace_id = self.workspace_context.workspace_id().ok_or_else(|| { ToolError::ExecutionFailed("review capability lost Workspace identity".to_string()) })?; @@ -577,7 +573,6 @@ impl Tool for SubWorkerSpawnTool { ))); } let body = serde_json::json!({ - "expected_head_commit": expected_head_commit, "child_session_id": child_session_id, "capability_token": capability_token, }); @@ -1043,21 +1038,21 @@ mod tests { let valid: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({ "name":"reviewer","task":"review","profile":"builtin:reviewer", "scope":[{"target":"/tmp/work","permission":"read"}], - "review":{"ticket_id":"T1","expected_head_commit":"V1"} + "review":{"ticket_id":"T1"} })) .unwrap(); assert!(validate_reviewer_handoff(&valid).is_ok()); let wrong_profile: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({ "name":"reviewer","task":"review","profile":"builtin:coder", "scope":[{"target":"/tmp/work","permission":"read"}], - "review":{"ticket_id":"T1","expected_head_commit":"V1"} + "review":{"ticket_id":"T1"} })) .unwrap(); assert!(validate_reviewer_handoff(&wrong_profile).is_err()); let writable: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({ "name":"reviewer","task":"review","profile":"builtin:reviewer", "scope":[{"target":"/tmp/work","permission":"write"}], - "review":{"ticket_id":"T1","expected_head_commit":"V1"} + "review":{"ticket_id":"T1"} })) .unwrap(); assert!(validate_reviewer_handoff(&writable).is_err()); diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index 9f7fa723..40a0ec7e 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -248,7 +248,6 @@ pub trait WorkspaceClient: std::fmt::Debug + Send + Sync { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReviewerContext { pub ticket_id: String, - pub expected_head_commit: String, } #[derive(Debug)] @@ -306,10 +305,6 @@ impl WorkspaceClient for ReviewerChildWorkspaceClient { "review submission body must be an object".to_string(), ) })?; - object.insert( - "expected_head_commit".to_string(), - serde_json::Value::String(self.context.expected_head_commit.clone()), - ); object.insert( "capability_token".to_string(), serde_json::Value::String(self.capability_token.clone()), @@ -452,7 +447,6 @@ mod reviewer_client_tests { inner, ReviewerContext { ticket_id: "T1".into(), - expected_head_commit: "head".into(), }, "secret".into(), ); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 19067849..2ec15e45 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -94,8 +94,8 @@ use crate::observation::{ use crate::profile_settings::UpdateWorkspaceMetadataRequest; use crate::records::{ObjectiveDetail, ProjectRecordList, TicketDetail}; use crate::repositories::{ - ConfiguredRepository, MergeTargetObservation, RepositoryListProjection, RepositoryLogRead, - RepositoryLookupError, RepositoryRegistryReader, RepositorySummary, + ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError, + RepositoryRegistryReader, RepositorySummary, }; use crate::resource_broker::BackendResourceBroker; use crate::runtime_subscription::RuntimeSubscriptionBroker; @@ -1324,8 +1324,12 @@ pub fn build_router(api: WorkspaceApi) -> Router { get(scoped_merge_request_readiness), ) .route( - "/api/w/{workspace_id}/tickets/{id}/merge-request/review-requests", - post(scoped_request_merge_request_review), + "/api/w/{workspace_id}/tickets/{id}/merge-request/thread", + get(scoped_merge_request_thread), + ) + .route( + "/api/w/{workspace_id}/tickets/{id}/merge-request/repair-source", + post(scoped_repair_merge_request_selector), ) .route( "/api/w/{workspace_id}/internal/reviewer-child-sessions", @@ -1339,18 +1343,15 @@ pub fn build_router(api: WorkspaceApi) -> Router { "/api/w/{workspace_id}/tickets/{id}/merge-request/reviews", post(scoped_submit_merge_request_review), ) + .route( + "/api/w/{workspace_id}/tickets/{id}/merge-request/reviews/revoke", + post(scoped_revoke_merge_request_review), + ) .route( "/api/w/{workspace_id}/tickets/{id}/merge-request/complete", post(scoped_complete_merge_request), ) - .route( - "/api/w/{workspace_id}/tickets/{id}/merge-request/close", - post(scoped_close_merge_request), - ) - .route( - "/api/w/{workspace_id}/tickets/{id}/merge-request/reopen", - post(scoped_reopen_merge_request), - ) + .route( "/api/w/{workspace_id}/tickets/{id}/workflow/close", post(scoped_close_ticket_record), @@ -3583,23 +3584,28 @@ struct OpenMergeRequestRequest { repository_id: String, selector_from: String, selector_to: String, - base_commit: String, - head_commit: String, - #[serde(default)] - changed_paths: Vec, #[serde(default)] summary: String, } #[derive(Debug, serde::Deserialize)] -struct RequestMergeRequestReviewRequest { - expected_head_commit: String, - base_commit: String, - head_commit: String, - #[serde(default)] - changed_paths: Vec, - #[serde(default)] - summary: String, +struct RepairMergeRequestSelectorRequest { + selector_from: String, + reason: String, + explicit_confirmation: bool, +} + +#[derive(Debug, serde::Deserialize)] +struct RevokeMergeRequestReviewRequest { + review_event_id: String, + reason: String, + explicit_confirmation: bool, +} + +#[derive(Debug, serde::Deserialize)] +struct MergeRequestThreadQuery { + after: Option, + limit: Option, } #[derive(Debug, serde::Deserialize)] @@ -3609,14 +3615,12 @@ struct RegisterReviewerChildSessionRequest { #[derive(Debug, serde::Deserialize)] struct RegisterMergeRequestReviewCapabilityRequest { - expected_head_commit: String, child_session_id: String, capability_token: String, } #[derive(Debug, serde::Deserialize)] struct SubmitMergeRequestReviewRequest { - expected_head_commit: String, capability_token: String, decision: merge_request::ReviewDecision, #[serde(default)] @@ -3628,21 +3632,13 @@ struct SubmitMergeRequestReviewRequest { #[derive(Debug, serde::Deserialize)] struct CompleteMergeRequestRequest { operation_id: String, - expected_head_commit: String, - target_commit: String, - source_commit: String, - result_commit: String, + approval_event_id: String, + target_ref_before: String, + target_ref_after: String, strategy: merge_request::MergeStrategy, resolution: merge_request::ConflictResolution, } -#[derive(Debug, serde::Deserialize)] -struct MergeRequestStateRequest { - #[serde(default)] - body: String, - explicit_confirmation: bool, -} - fn parse_workspace_id(value: &str) -> ApiResult { if value.trim().is_empty() { return Err(Error::InvalidInput("workspace_id must not be empty".to_string()).into()); @@ -3699,26 +3695,6 @@ impl merge_request::RepositorySource for MergeRequestRepositorySource { } Ok(self.reader.summary(repository_id).is_ok()) } - - fn is_ancestor( - &self, - workspace_id: &str, - repository_id: &str, - ancestor: &str, - descendant: &str, - ) -> std::result::Result { - if workspace_id != self.workspace_id { - return Ok(false); - } - match self - .reader - .ensure_ancestor(repository_id, ancestor, descendant) - { - Ok(()) => Ok(true), - Err(RepositoryLookupError::InvalidCommitRelation { .. }) => Ok(false), - Err(error) => Err(format!("{error:?}")), - } - } } fn merge_request_store( @@ -3747,65 +3723,36 @@ fn repository_merge_evidence_error(error: RepositoryLookupError) -> ApiError { .into() } -fn validate_open_merge_request_evidence( - api: &WorkspaceApi, - ticket_id: &str, - repository_id: &str, - base_commit: &str, - head_commit: &str, -) -> ApiResult<(String, String, MergeTargetObservation)> { - let ticket = browser_ticket_backend(api)? - .show(TicketIdOrSlug::Id(ticket_id.into())) - .map_err(Error::from)?; - if ticket.meta.repository_id.as_deref() != Some(repository_id) { - return Err(Error::InvalidInput( - "Merge Request repository must match the authoritative Ticket target".into(), - ) - .into()); - } - let reader = api.repository_reader(); - let target = reader - .observe_merge_target(repository_id, ticket.meta.ref_selector.as_deref()) - .map_err(repository_merge_evidence_error)?; - let base = reader - .observe_commit(repository_id, base_commit) - .map_err(repository_merge_evidence_error)?; - let source = reader - .observe_commit(repository_id, head_commit) - .map_err(repository_merge_evidence_error)?; - reader - .ensure_ancestor(repository_id, &base.commit, &source.commit) - .map_err(repository_merge_evidence_error)?; - Ok((base.commit, source.commit, target)) -} - -fn validate_revision_evidence( - api: &WorkspaceApi, - repository_id: &str, - base_commit: &str, - head_commit: &str, -) -> ApiResult<(String, String)> { - let reader = api.repository_reader(); - let base = reader - .observe_commit(repository_id, base_commit) - .map_err(repository_merge_evidence_error)?; - let source = reader - .observe_commit(repository_id, head_commit) - .map_err(repository_merge_evidence_error)?; - reader - .ensure_ancestor(repository_id, &base.commit, &source.commit) - .map_err(repository_merge_evidence_error)?; - Ok((base.commit, source.commit)) -} - async fn scoped_show_merge_request( State(api): State, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, -) -> ApiResult> { +) -> ApiResult> { let workspace_id = parse_workspace_id(&workspace_id)?; - Ok(Json( - merge_request_store(&api, &workspace_id)?.get(&workspace_id, &ticket_id)?, - )) + let mr = merge_request_store(&api, &workspace_id)?.get(&workspace_id, &ticket_id)?; + let reader = api.repository_reader(); + let observed_at = Utc::now().to_rfc3339(); + let source = match mr.selector_from.as_deref() { + Some(selector) => match reader.observe_merge_target(&mr.repository_id, Some(selector)) { + Ok(value) => { + serde_json::json!({"status":"known","ref":value.commit,"observed_at":observed_at}) + } + Err(_) => serde_json::json!({"status":"unknown","observed_at":observed_at}), + }, + None => serde_json::json!({"status":"requires_repair","observed_at":observed_at}), + }; + let target = match reader.observe_merge_target(&mr.repository_id, Some(&mr.selector_to)) { + Ok(value) => { + serde_json::json!({"status":"known","ref":value.commit,"observed_at":observed_at}) + } + Err(_) => serde_json::json!({"status":"unknown","observed_at":observed_at}), + }; + let mut response = + serde_json::to_value(mr).map_err(|error| Error::InvalidInput(error.to_string()))?; + if let Some(object) = response.as_object_mut() { + object.insert("source".into(), source); + object.insert("target".into(), target); + } + Ok(Json(response)) } async fn scoped_merge_request_readiness( @@ -3815,9 +3762,15 @@ async fn scoped_merge_request_readiness( let workspace_id = parse_workspace_id(&workspace_id)?; let store = merge_request_store(&api, &workspace_id)?; let mr = store.get(&workspace_id, &ticket_id)?; + let current_subject_ref = mr.selector_from.as_deref().and_then(|selector| { + api.repository_reader() + .observe_merge_target(&mr.repository_id, Some(selector)) + .ok() + .map(|v| v.commit) + }); Ok(Json(store.readiness(merge_request::ReadinessCheck { ticket_id, - expected_head_commit: None, + current_subject_ref, auth: merge_request::MergeRequestAuth { workspace_id, repository_id: mr.repository_id, @@ -3847,117 +3800,94 @@ async fn scoped_open_merge_request( || assignment.worker.worker_id != source.worker_id { return Err(Error::TicketAssignmentConflict( - "authenticated Worker is not the current Ticket assignee".into(), + "authenticated Worker is not current assignee".into(), ) .into()); } - let (base_commit, source_commit, target) = validate_open_merge_request_evidence( - &api, - &ticket_id, - &input.repository_id, - &input.base_commit, - &input.head_commit, - )?; - if input.selector_to != target.selector { + let ticket = browser_ticket_backend(&api)? + .show(TicketIdOrSlug::Id(ticket_id.clone().into())) + .map_err(Error::from)?; + if ticket.meta.repository_id.as_deref() != Some(input.repository_id.as_str()) + || ticket.meta.ref_selector.as_deref() != Some(input.selector_to.as_str()) + { return Err(Error::InvalidInput( - "selector_to must match the authoritative Ticket target selector".into(), + "selectors must match the authoritative Ticket repository target".into(), ) .into()); } - let observed_source = api - .repository_reader() + let reader = api.repository_reader(); + reader .observe_merge_target(&input.repository_id, Some(&input.selector_from)) .map_err(repository_merge_evidence_error)?; - if observed_source.commit != source_commit { - return Err(Error::InvalidInput( - "selector_from does not resolve to the nominated head commit".into(), - ) - .into()); - } - let mr = merge_request_store(&api, &workspace_id)?.open_merge_request( - merge_request::OpenMergeRequest { - merge_request_id: Uuid::now_v7().to_string(), - ticket_id, - repository_id: input.repository_id.clone(), - selector_from: input.selector_from, - selector_to: input.selector_to, - request: merge_request::RequestForReview { - base_commit, - head_commit: source_commit, - changed_paths: input.changed_paths, + reader + .observe_merge_target(&input.repository_id, Some(&input.selector_to)) + .map_err(repository_merge_evidence_error)?; + Ok(Json( + merge_request_store(&api, &workspace_id)?.open_merge_request( + merge_request::OpenMergeRequest { + merge_request_id: Uuid::now_v7().to_string(), + ticket_id, + repository_id: input.repository_id.clone(), + selector_from: input.selector_from, + selector_to: input.selector_to, summary: input.summary, + auth: merge_request::MergeRequestAuth { + workspace_id, + repository_id: input.repository_id, + runtime_id: source.runtime_id, + worker_id: source.worker_id, + assignment_id: assignment.assignment_id, + }, + now: Utc::now(), }, - auth: merge_request::MergeRequestAuth { - workspace_id, - repository_id: input.repository_id, - runtime_id: source.runtime_id, - worker_id: source.worker_id, - assignment_id: assignment.assignment_id, - }, - now: Utc::now(), - }, - )?; - Ok(Json(mr)) + )?, + )) } -async fn scoped_request_merge_request_review( +async fn scoped_merge_request_thread( + State(api): State, + AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, + Query(query): Query, +) -> ApiResult>> { + let workspace_id = parse_workspace_id(&workspace_id)?; + Ok(Json( + merge_request_store(&api, &workspace_id)?.thread_page( + &workspace_id, + &ticket_id, + query.after, + query.limit.unwrap_or(100), + )?, + )) +} + +async fn scoped_repair_merge_request_selector( State(api): State, headers: HeaderMap, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, - Json(input): Json, -) -> ApiResult> { + Json(input): Json, +) -> ApiResult> { let workspace_id = parse_workspace_id(&workspace_id)?; require_workspace_access(&workspace_id, &api)?; - let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?; - let assignment = api - .store - .get_current_ticket_worker_assignment(&workspace_id, &ticket_id)? - .ok_or_else(|| { - Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into()) - })?; - if assignment.worker.runtime_id != source.runtime_id - || assignment.worker.worker_id != source.worker_id - { - return Err(Error::TicketAssignmentConflict( - "authenticated Worker is not the current Ticket assignee".into(), - ) - .into()); + reject_non_browser_reopen_auth(&headers)?; + let _actor = require_actor(&api, &headers).await?; + if !input.explicit_confirmation { + return Err(Error::BrowserReopenConfirmationRequired.into()); } let store = merge_request_store(&api, &workspace_id)?; - let current = store.get(&workspace_id, &ticket_id)?; - let (base_commit, source_commit) = validate_revision_evidence( - &api, - ¤t.repository_id, - &input.base_commit, - &input.head_commit, - )?; - let observed_source = api - .repository_reader() - .observe_merge_target(¤t.repository_id, Some(¤t.selector_from)) + let mr = store.get(&workspace_id, &ticket_id)?; + api.repository_reader() + .observe_merge_target(&mr.repository_id, Some(&input.selector_from)) .map_err(repository_merge_evidence_error)?; - if observed_source.commit != source_commit { - return Err(Error::InvalidInput( - "selector_from does not resolve to the nominated head commit".into(), - ) - .into()); - } - Ok(Json(store.request_review( - merge_request::RequestMergeRequestReview { + Ok(Json(store.repair_selector_from( + merge_request::RepairSelectorFrom { + workspace_id, ticket_id, - expected_head_commit: input.expected_head_commit, - request: merge_request::RequestForReview { - base_commit, - head_commit: source_commit, - changed_paths: input.changed_paths, - summary: input.summary, - }, - auth: merge_request::MergeRequestAuth { - workspace_id, - repository_id: current.repository_id, - runtime_id: source.runtime_id, - worker_id: source.worker_id, - assignment_id: assignment.assignment_id, + selector_from: input.selector_from, + repaired_by: merge_request::WorkerIdentity { + runtime_id: "browser".into(), + worker_id: "authenticated-user".into(), }, + reason: input.reason, now: Utc::now(), }, )?)) @@ -4004,20 +3934,29 @@ async fn scoped_register_merge_request_review_capability( || assignment.worker.worker_id != source.worker_id { return Err(Error::TicketAssignmentConflict( - "authenticated Worker is not the current Ticket assignee".into(), + "authenticated Worker is not current assignee".into(), ) .into()); } let store = merge_request_store(&api, &workspace_id)?; - let current = store.get(&workspace_id, &ticket_id)?; - store.register_review_capability(merge_request::RegisterReviewCapability { + let mr = store.get(&workspace_id, &ticket_id)?; + let selector = mr + .selector_from + .as_deref() + .ok_or_else(|| Error::InvalidInput("selector_from requires repair".into()))?; + let subject_ref = api + .repository_reader() + .observe_merge_target(&mr.repository_id, Some(selector)) + .map_err(repository_merge_evidence_error)? + .commit; + store.request_review(merge_request::RequestMergeRequestReview { ticket_id, - expected_head_commit: input.expected_head_commit, + subject_ref, child_session_id: input.child_session_id, capability_token: input.capability_token, auth: merge_request::MergeRequestAuth { workspace_id, - repository_id: current.repository_id, + repository_id: mr.repository_id, runtime_id: source.runtime_id, worker_id: source.worker_id, assignment_id: assignment.assignment_id, @@ -4033,19 +3972,73 @@ async fn scoped_submit_merge_request_review( Json(input): Json, ) -> ApiResult> { let workspace_id = parse_workspace_id(&workspace_id)?; - Ok(Json( - merge_request_store(&api, &workspace_id)?.submit_review( - merge_request::SubmitMergeRequestReview { - ticket_id, - expected_head_commit: input.expected_head_commit, - capability_token: input.capability_token, - decision: input.decision, - body: input.body, - findings: input.findings, - now: Utc::now(), + let store = merge_request_store(&api, &workspace_id)?; + let mr = store.get(&workspace_id, &ticket_id)?; + let selector = mr + .selector_from + .as_deref() + .ok_or_else(|| Error::InvalidInput("selector_from requires repair".into()))?; + let current_subject_ref = api + .repository_reader() + .observe_merge_target(&mr.repository_id, Some(selector)) + .map_err(repository_merge_evidence_error)? + .commit; + Ok(Json(store.submit_review( + merge_request::SubmitMergeRequestReview { + ticket_id, + current_subject_ref, + capability_token: input.capability_token, + decision: input.decision, + body: input.body, + findings: input.findings, + now: Utc::now(), + }, + )?)) +} + +async fn scoped_revoke_merge_request_review( + State(api): State, + headers: HeaderMap, + AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, + Json(input): Json, +) -> ApiResult> { + let workspace_id = parse_workspace_id(&workspace_id)?; + require_workspace_access(&workspace_id, &api)?; + if !input.explicit_confirmation { + return Err(Error::BrowserReopenConfirmationRequired.into()); + } + let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?; + let assignment = api + .store + .get_current_ticket_worker_assignment(&workspace_id, &ticket_id)? + .ok_or_else(|| { + Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into()) + })?; + if assignment.worker.runtime_id != source.runtime_id + || assignment.worker.worker_id != source.worker_id + { + return Err(Error::TicketAssignmentConflict( + "authenticated Worker is not current assignee".into(), + ) + .into()); + } + let store = merge_request_store(&api, &workspace_id)?; + let mr = store.get(&workspace_id, &ticket_id)?; + Ok(Json(store.revoke_review( + merge_request::RevokeMergeRequestReview { + ticket_id, + review_event_id: input.review_event_id, + reason: input.reason, + auth: merge_request::MergeRequestAuth { + workspace_id, + repository_id: mr.repository_id, + runtime_id: source.runtime_id, + worker_id: source.worker_id, + assignment_id: assignment.assignment_id, }, - )?, - )) + now: Utc::now(), + }, + )?)) } async fn scoped_complete_merge_request( @@ -4066,48 +4059,42 @@ async fn scoped_complete_merge_request( })?; let store = merge_request_store(&api, &workspace_id)?; let mr = store.get(&workspace_id, &ticket_id)?; - let current_request = mr.current_request().ok_or_else(|| { - Error::InvalidInput("Merge Request has no current RequestForReview event".into()) - })?; - if current_request.head_commit != input.expected_head_commit - || input.source_commit != current_request.head_commit - { + let selector = mr + .selector_from + .as_deref() + .ok_or_else(|| Error::InvalidInput("selector_from requires repair".into()))?; + let repositories = api.repository_reader(); + let current_source_ref = repositories + .observe_merge_target(&mr.repository_id, Some(selector)) + .map_err(repository_merge_evidence_error)? + .commit; + let observed = repositories + .observe_merge_target(&mr.repository_id, Some(&mr.selector_to)) + .map_err(repository_merge_evidence_error)?; + if observed.commit != input.target_ref_before && observed.commit != input.target_ref_after { return Err(Error::InvalidInput( - "source commit does not match the current review request".into(), + "target selector moved outside completion evidence".into(), ) .into()); } - let repositories = api.repository_reader(); - let observed_target = repositories - .observe_merge_target(&mr.repository_id, Some(&mr.selector_to)) - .map_err(repository_merge_evidence_error)?; - if observed_target.commit != input.target_commit - && observed_target.commit != input.result_commit - { - return Err(Error::InvalidInput(format!( - "Merge Request target moved: expected {}, observed {}", - input.target_commit, observed_target.commit - )) - .into()); - } - let target_was_already_updated = observed_target.commit == input.result_commit; - if !target_was_already_updated { + let already = observed.commit == input.target_ref_after; + if !already { repositories .update_merge_target( &mr.repository_id, &mr.selector_to, - &input.target_commit, - &input.result_commit, + &input.target_ref_before, + &input.target_ref_after, ) - .map_err(repository_merge_evidence_error)?; + .map_err(repository_merge_evidence_error)? } let completion = merge_request::CompleteMergeRequest { ticket_id, - expected_head_commit: input.expected_head_commit, operation_id: input.operation_id, - target_commit: input.target_commit.clone(), - source_commit: input.source_commit, - result_commit: input.result_commit.clone(), + approval_event_id: input.approval_event_id, + current_subject_ref: current_source_ref, + target_ref_before: input.target_ref_before.clone(), + target_ref_after: input.target_ref_after.clone(), strategy: input.strategy, resolution: input.resolution, auth: merge_request::MergeRequestAuth { @@ -4120,75 +4107,21 @@ async fn scoped_complete_merge_request( now: Utc::now(), }; match store.complete(completion) { - Ok(event) => Ok(Json(event)), - Err(error) => { - if !target_was_already_updated { + Ok(v) => Ok(Json(v)), + Err(e) => { + if !already { let _ = repositories.update_merge_target( &mr.repository_id, &mr.selector_to, - &input.result_commit, - &input.target_commit, + &input.target_ref_after, + &input.target_ref_before, ); } - Err(error.into()) + Err(e.into()) } } } -async fn scoped_close_merge_request( - State(api): State, - headers: HeaderMap, - AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, - Json(input): Json, -) -> ApiResult> { - scoped_change_merge_request_state(api, headers, workspace_id, ticket_id, input, false).await -} - -async fn scoped_reopen_merge_request( - State(api): State, - headers: HeaderMap, - AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, - Json(input): Json, -) -> ApiResult> { - scoped_change_merge_request_state(api, headers, workspace_id, ticket_id, input, true).await -} - -async fn scoped_change_merge_request_state( - api: WorkspaceApi, - headers: HeaderMap, - workspace_id: String, - ticket_id: String, - input: MergeRequestStateRequest, - reopen: bool, -) -> ApiResult> { - let workspace_id = parse_workspace_id(&workspace_id)?; - require_workspace_access(&workspace_id, &api)?; - reject_non_browser_reopen_auth(&headers)?; - let _actor = require_actor(&api, &headers).await?; - if !input.explicit_confirmation { - return Err(Error::BrowserReopenConfirmationRequired.into()); - } - let store = merge_request_store(&api, &workspace_id)?; - let mr = store.get(&workspace_id, &ticket_id)?; - let operation = merge_request::ChangeMergeRequestState { - ticket_id, - body: input.body, - auth: merge_request::MergeRequestAuth { - workspace_id, - repository_id: mr.repository_id, - runtime_id: "browser".into(), - worker_id: "authenticated-user".into(), - assignment_id: String::new(), - }, - now: Utc::now(), - }; - Ok(Json(if reopen { - store.reopen(operation)? - } else { - store.close(operation)? - })) -} - fn reject_non_browser_reopen_auth(headers: &HeaderMap) -> Result<()> { if headers.contains_key("authorization") { return Err(Error::BrowserReopenConfirmationRequired); @@ -12738,241 +12671,6 @@ mod tests { )); } - #[tokio::test] - async fn merge_request_completion_endpoint_rejects_coder_and_accepts_orchestrator() { - let workspace = tempfile::tempdir().unwrap(); - init_clean_git_workspace(workspace.path()); - let git_value = |args: &[&str]| { - let output = std::process::Command::new("git") - .arg("-C") - .arg(workspace.path()) - .args(args) - .output() - .unwrap(); - assert!(output.status.success()); - String::from_utf8(output.stdout).unwrap().trim().to_string() - }; - let target_commit = git_value(&["rev-parse", "HEAD"]); - let target_ref = git_value(&["symbolic-ref", "HEAD"]); - std::fs::write(workspace.path().join("README.md"), "merge source\n").unwrap(); - for args in [&["add", "README.md"][..], &["commit", "-m", "source"][..]] { - assert!( - std::process::Command::new("git") - .arg("-C") - .arg(workspace.path()) - .args(args) - .status() - .unwrap() - .success() - ); - } - let source_commit = git_value(&["rev-parse", "HEAD"]); - assert!( - std::process::Command::new("git") - .arg("-C") - .arg(workspace.path()) - .args(["reset", "--hard", &target_commit]) - .status() - .unwrap() - .success() - ); - let api = test_api(workspace.path()).await; - let workspace_id = api.config.workspace_id.clone(); - let backend = browser_ticket_backend(&api).unwrap(); - let mut input = ticket::NewTicket::new("Orchestrator completion authority"); - input.workflow_state = Some(TicketWorkflowState::InProgress); - let ticket = backend.create(input).unwrap(); - let Json(coder) = 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: "completion-coder-assignment".to_string(), - }), - initial_submit: vec![Segment::Flow { - selector: "builtin:coder-review".to_string(), - }], - working_directory: None, - control_operation_id: None, - resolved_control_operation: None, - }), - ) - .await - .unwrap(); - let assignment = api - .store - .get_current_ticket_worker_assignment(&workspace_id, &ticket.id) - .unwrap() - .unwrap(); - let mr_store = merge_request_store(&api, &workspace_id).unwrap(); - mr_store - .open_merge_request(merge_request::OpenMergeRequest { - merge_request_id: "MR-server-completion".into(), - ticket_id: ticket.id.clone(), - repository_id: TEST_REPOSITORY_ID.into(), - selector_from: source_commit.clone(), - selector_to: target_ref.clone(), - request: merge_request::RequestForReview { - base_commit: target_commit.clone(), - head_commit: source_commit.clone(), - changed_paths: vec!["src/lib.rs".into()], - summary: "approved candidate".into(), - }, - auth: merge_request::MergeRequestAuth { - workspace_id: workspace_id.clone(), - repository_id: TEST_REPOSITORY_ID.into(), - runtime_id: coder.worker_ref.runtime_id.clone(), - worker_id: coder.worker_ref.worker_id.clone(), - assignment_id: assignment.assignment_id.clone(), - }, - now: Utc::now(), - }) - .unwrap(); - mr_store - .register_reviewer_child_session(merge_request::RegisterReviewerChildSession { - workspace_id: workspace_id.clone(), - parent_runtime_id: coder.worker_ref.runtime_id.clone(), - parent_worker_id: coder.worker_ref.worker_id.clone(), - child_session_id: "reviewer-child".into(), - reviewer_profile: "builtin:reviewer".into(), - now: Utc::now(), - }) - .unwrap(); - mr_store - .register_review_capability(merge_request::RegisterReviewCapability { - ticket_id: ticket.id.clone(), - expected_head_commit: source_commit.clone(), - child_session_id: "reviewer-child".into(), - capability_token: "review-token".into(), - auth: merge_request::MergeRequestAuth { - workspace_id: workspace_id.clone(), - repository_id: TEST_REPOSITORY_ID.into(), - runtime_id: coder.worker_ref.runtime_id.clone(), - worker_id: coder.worker_ref.worker_id.clone(), - assignment_id: assignment.assignment_id.clone(), - }, - now: Utc::now(), - }) - .unwrap(); - mr_store - .submit_review(merge_request::SubmitMergeRequestReview { - ticket_id: ticket.id.clone(), - expected_head_commit: source_commit.clone(), - capability_token: "review-token".into(), - decision: merge_request::ReviewDecision::Approve, - body: "approved".into(), - findings: Vec::new(), - now: Utc::now(), - }) - .unwrap(); - - let worker_headers = |worker: &RuntimeWorkerRef| { - let mut headers = HeaderMap::new(); - headers.insert( - "x-yoi-runtime-id", - axum::http::HeaderValue::from_str(&worker.runtime_id).unwrap(), - ); - headers.insert( - "x-yoi-worker-id", - axum::http::HeaderValue::from_str(&worker.worker_id).unwrap(), - ); - headers - }; - let request = || CompleteMergeRequestRequest { - operation_id: "complete-operation".into(), - expected_head_commit: source_commit.clone(), - target_commit: target_commit.clone(), - source_commit: source_commit.clone(), - result_commit: source_commit.clone(), - strategy: merge_request::MergeStrategy::FastForward, - resolution: merge_request::ConflictResolution::None, - }; - let coder_error = scoped_complete_merge_request( - State(api.clone()), - worker_headers(&coder.worker_ref), - AxumPath((workspace_id.clone(), ticket.id.clone())), - Json(request()), - ) - .await - .unwrap_err(); - assert!(matches!( - coder_error.error, - Error::TicketAssignmentConflict(_) - )); - - let Json(started) = scoped_start_workspace_orchestrator( - State(api.clone()), - AxumPath(ScopedWorkspacePath { - workspace_id: workspace_id.clone(), - }), - ) - .await - .unwrap(); - let orchestrator = started.worker.unwrap().worker; - let Json(completed) = scoped_complete_merge_request( - State(api.clone()), - worker_headers(&orchestrator), - AxumPath((workspace_id, ticket.id.clone())), - Json(request()), - ) - .await - .unwrap(); - assert_eq!(completed.result_commit, source_commit); - assert_eq!( - backend - .show(ticket.id.clone().into()) - .unwrap() - .meta - .workflow_state, - TicketWorkflowState::Done - ); - assert_eq!( - api.repository_reader() - .observe_merge_target(TEST_REPOSITORY_ID, Some(&target_ref)) - .unwrap() - .commit, - source_commit - ); - let mr = mr_store.get(&api.config.workspace_id, &ticket.id).unwrap(); - assert_eq!(mr.state, merge_request::MergeRequestState::Merged); - let merge = mr.thread.iter().find_map(|event| match event { - merge_request::MergeRequestThreadEvent::Merge(value) => Some(value), - _ => None, - }); - assert_eq!( - merge.map(|value| value.result_commit.as_str()), - Some(source_commit.as_str()) - ); - let Json(replayed) = scoped_complete_merge_request( - State(api.clone()), - worker_headers(&orchestrator), - AxumPath((api.config.workspace_id.clone(), ticket.id.clone())), - Json(request()), - ) - .await - .unwrap(); - assert_eq!(replayed.result_commit, source_commit); - let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); - let actor: String = conn - .query_row( - "SELECT author FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2 AND kind='state_changed'", - rusqlite::params![api.config.workspace_id, ticket.id], - |row| row.get(0), - ) - .unwrap(); - assert_eq!( - actor, - format!( - "worker:{}:{}", - orchestrator.runtime_id, orchestrator.worker_id - ) - ); - } - #[tokio::test] async fn production_profile_backend_launches_and_restores_workspace_orchestrator() { let workspace = tempfile::tempdir().unwrap(); diff --git a/resources/prompts/common/git.md b/resources/prompts/common/git.md index cdb63df7..72e8b3e2 100644 --- a/resources/prompts/common/git.md +++ b/resources/prompts/common/git.md @@ -10,4 +10,4 @@ When creating a commit, use the change type as the subject prefix, not the affec A change made because review, validation, or user feedback found a defect is a `fix:` even when it belongs to the same feature Ticket and has not been merged yet. Do not keep reusing a domain prefix such as `merge-request:`, `runtime:`, or `worker:` across a series; those labels identify where the code lives rather than why each commit exists. If one prospective commit contains distinct change types, split it into coherent validated commits when practical; otherwise name it for the dominant intent. -Before opening a Merge Request or appending a `RequestForReview` event, inspect the proposed commit subjects and correct misclassified local, unshared commits when safe. Do not rewrite shared history solely to rename existing commits unless the user explicitly requests it. +Before opening a Merge Request or requesting review, inspect the proposed commit subjects and correct misclassified local, unshared commits when safe. Do not rewrite shared history solely to rename existing commits unless the user explicitly requests it. diff --git a/resources/prompts/role/coder.md b/resources/prompts/role/coder.md index 1823cd7c..adfa0e54 100644 --- a/resources/prompts/role/coder.md +++ b/resources/prompts/role/coder.md @@ -4,6 +4,6 @@ Treat the first committed user message as the bounded Ticket/action context and {% include "common.git" %} -Before review, open a Merge Request with immutable `selector_from` / `selector_to`, then append a `RequestForReview` thread event containing the exact base/head commit and changed-path evidence. Spawn the Reviewer only as your actual direct-child `builtin:reviewer` SubWorker, delegate read-only scope, and include the structured review handoff with the Ticket id and current candidate head commit. Reviewer prose is not approval: the child must commit `MergeRequestReviewSubmit` through its injected capability authority. +Before review, open a Merge Request with immutable `selector_from` / `selector_to`. Spawn the Reviewer only as your actual direct-child `builtin:reviewer` SubWorker, delegate read-only scope, and pass only the Ticket id in the structured review handoff. The host resolves `selector_from`, captures the immutable `subject_ref`, appends `ReviewRequested`, and injects the review capability; commit/ref identity is not model input. Reviewer prose is not approval: the child must commit `MergeRequestReviewSubmit` through its injected capability authority. -A request-changes result requires a new `RequestForReview` event and a fresh Reviewer child capability. Flow terminal state is not Ticket completion authority. Complete only through `MergeRequestComplete` with a unique operation id and the currently approved candidate head commit; the Server revalidates assignment and fences Ticket state side effects. +A request-changes result requires a fresh Reviewer child request. Flow terminal state is not Ticket completion authority. Complete only through `MergeRequestComplete` with a unique operation id, the approved `Review` event id, and final target-ref evidence; the Server re-resolves selectors, revalidates assignment, and fences Ticket state side effects. diff --git a/resources/prompts/role/orchestrator.md b/resources/prompts/role/orchestrator.md index 08ac7478..32092653 100644 --- a/resources/prompts/role/orchestrator.md +++ b/resources/prompts/role/orchestrator.md @@ -4,7 +4,7 @@ You are the Ticket Orchestrator role. 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. Record `inprogress` before implementation side effects, then use `SpawnTicketCoder` so Worker creation, the fixed Coder profile/Flow, and the current Ticket assignment are one guarded operation. After spawn, reread the Ticket and verify 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, and stop for human authority when merge/closure is not explicitly delegated. -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 `RequestForReview` candidate is missing, indeterminate, 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. +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. Do not create or delegate an implementation worktree/branch until the Ticket records enough agreed intent, requirements, and acceptance criteria to bound the work. diff --git a/resources/prompts/role/reviewer.md b/resources/prompts/role/reviewer.md index 4e29746b..112c412c 100644 --- a/resources/prompts/role/reviewer.md +++ b/resources/prompts/role/reviewer.md @@ -1,7 +1,7 @@ You are the Ticket Reviewer role running as an actual Runtime-owned direct child of the assigned Coder. -Keep role behavior here and treat the first committed user message as bounded Ticket/Merge Request context only. Review the current `RequestForReview` candidate against Ticket intent, binding decisions/invariants, acceptance criteria, and project design boundaries. Use read-only inspection and focused validation; do not merge, close, mutate the Workdir, or take over implementation. +Keep role behavior here and treat the first committed user message as bounded Ticket/Merge Request context only. Review the host-captured `ReviewRequested.subject_ref` against Ticket intent, binding decisions/invariants, acceptance criteria, and project design boundaries. Use read-only inspection and focused validation; do not merge, close, mutate the Workdir, or take over implementation. -Your prose response is not review authority. Before finishing, call `MergeRequestReviewSubmit` exactly once with `approve` or `request_changes`, a bounded evidence summary, and concrete structured findings. Capability authority and the expected candidate head commit are injected by your child Workspace client and are not model inputs. If a newer `RequestForReview` event supersedes the candidate, submission must fail rather than approving stale work. +Your prose response is not review authority. Before finishing, call `MergeRequestReviewSubmit` exactly once with `approve` or `request_changes`, a bounded evidence summary, and concrete structured findings. Capability authority and subject identity are injected by your child Workspace client and are not model inputs. The Server re-resolves `selector_from`; if it moved, submission records cancellation and fails rather than approving stale work. Review more than the diff: verify the implementation satisfies the Ticket intent and acceptance criteria, remains coherent with the codebase design, and does not introduce unnecessary compatibility. diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte index 673cd1ff..53db1bc7 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte @@ -19,34 +19,49 @@ type MergeRequestThreadEvent = | { - kind: "request_for_review"; - event_seq: number; - head_commit: string; - changed_paths: string[]; - summary: string; + kind: "review_requested"; + event_id: string; + sequence: number; + subject_ref: string; + requested_by: { runtime_id: string; worker_id: string }; + reviewer: { runtime_id: string; worker_id: string }; } | { kind: "review"; - event_seq: number; - request_event_seq: number; + event_id: string; + sequence: number; + request_event_id: string; + subject_ref: string; decision: "approve" | "request_changes"; body: string; - reviewer_profile: string; + reviewer: { runtime_id: string; worker_id: string }; + } + | { kind: "review_revoked"; sequence: number; review_event_id: string; reason: string } + | { kind: "review_cancelled"; sequence: number; request_event_id: string; reason: string } + | { + kind: "comment"; + sequence: number; + body: string; + author: { runtime_id: string; worker_id: string }; } | { kind: "merge"; - event_seq: number; - result_commit: string; + sequence: number; + approval_event_id: string; + approved_source_ref: string; + target_ref_after: string; strategy: "fast_forward" | "merge"; resolution: "none" | "clean" | "conflicts_resolved"; merged_by: { runtime_id: string; worker_id: string }; - } - | { kind: "reopen" | "close"; event_seq: number; body: string }; + }; + type RefProjection = { status: "known" | "unknown" | "requires_repair"; ref?: string }; type MergeRequestDetail = { state: "open" | "closed" | "merged"; - selector_from: string; + selector_from: string | null; selector_to: string; + source: RefProjection; + target: RefProjection; thread: MergeRequestThreadEvent[]; }; @@ -72,14 +87,12 @@ let ticket = $state(loadedTicket); let mergeRequest = $state(initialData.mergeRequest.data ?? null); const currentReviewRequest = $derived( - mergeRequest?.thread.findLast((event) => event.kind === "request_for_review") ?? null, + mergeRequest?.thread.findLast((event) => event.kind === "review_requested") ?? null, ); const currentReview = $derived( - currentReviewRequest - ? mergeRequest?.thread.findLast( - (event) => - event.kind === "review" && - event.request_event_seq === currentReviewRequest.event_seq, + mergeRequest?.source.status === "known" + ? mergeRequest.thread.findLast( + (event) => event.kind === "review" && event.subject_ref === mergeRequest?.source.ref, ) ?? null : null, ); @@ -384,21 +397,37 @@

{data.mergeRequest.error}

{:else if mergeRequest}

{mergeRequest.state}

-

From {mergeRequest.selector_from}

-

To {mergeRequest.selector_to}

- {#if currentReviewRequest?.kind === "request_for_review"} -

Candidate {currentReviewRequest.head_commit}

- {#if currentReviewRequest.summary}

{currentReviewRequest.summary}

{/if} +

From {mergeRequest.selector_from ?? "requires repair"} · {mergeRequest.source.status}{mergeRequest.source.ref ? ` @ ${mergeRequest.source.ref}` : ""}

+

To {mergeRequest.selector_to} · {mergeRequest.target.status}{mergeRequest.target.ref ? ` @ ${mergeRequest.target.ref}` : ""}

+ {#if currentReviewRequest?.kind === "review_requested"} +

Review requested for {currentReviewRequest.subject_ref}

{/if} {#if currentReview?.kind === "review"} -

{currentReview.decision} by {currentReview.reviewer_profile}

+

{currentReview.decision} by {currentReview.reviewer.runtime_id}/{currentReview.reviewer.worker_id}

{#if currentReview.body}{/if} {/if} {#if mergeEvent?.kind === "merge"}

Final merge · {mergeEvent.strategy} / {mergeEvent.resolution}

-

Result {mergeEvent.result_commit}

+

Target ref {mergeEvent.target_ref_after}

Completed by {mergeEvent.merged_by.runtime_id}/{mergeEvent.merged_by.worker_id}

{/if} +

Thread

+ {#each mergeRequest.thread as event (event.sequence)} +

+ #{event.sequence} · {event.kind} + {#if event.kind === "review_requested"} + · {event.subject_ref} · {event.requested_by.runtime_id}/{event.requested_by.worker_id} + {:else if event.kind === "review"} + · {event.subject_ref} · {event.reviewer.runtime_id}/{event.reviewer.worker_id} + {:else if event.kind === "comment"} + · {event.author.runtime_id}/{event.author.worker_id} · {event.body} + {:else if event.kind === "review_cancelled" || event.kind === "review_revoked"} + · {event.reason} + {:else if event.kind === "merge"} + · approval {event.approval_event_id} + {/if} +

+ {/each} {:else}

The assigned Coder has not opened a Merge Request.

{/if} From 7e9cae5f3918947f285872bd8de8d70e8d7cedb5 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 17 Aug 2026 07:44:08 +0900 Subject: [PATCH 09/13] fix: bound merge request thread bodies --- crates/merge-request/src/lib.rs | 34 +++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/merge-request/src/lib.rs b/crates/merge-request/src/lib.rs index b9dca36d..812dac0d 100644 --- a/crates/merge-request/src/lib.rs +++ b/crates/merge-request/src/lib.rs @@ -164,6 +164,20 @@ impl MergeRequestThreadEvent { Self::Merge(v) => v.sequence, } } + fn bound_bodies(&mut self) { + match self { + Self::Review(value) => { + truncate_body(&mut value.body); + for finding in &mut value.findings { + truncate_body(&mut finding.body); + } + } + Self::ReviewRevoked(value) => truncate_body(&mut value.reason), + Self::ReviewCancelled(value) => truncate_body(&mut value.reason), + Self::Comment(value) => truncate_body(&mut value.body), + Self::ReviewRequested(_) | Self::Merge(_) => {} + } + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -789,6 +803,20 @@ impl MergeRequestStore { } } +fn truncate_body(value: &mut String) { + if value.len() <= MAX_BODY_BYTES { + return; + } + let boundary = value + .char_indices() + .map(|(index, _)| index) + .take_while(|index| *index <= MAX_BODY_BYTES) + .last() + .unwrap_or(0); + value.truncate(boundary); + value.push_str("\n[truncated]"); +} + fn bounded_body(name: &str, value: &str) -> Result<(), MergeRequestError> { if value.len() > MAX_BODY_BYTES { Err(MergeRequestError::Validation(format!( @@ -880,7 +908,7 @@ fn load_thread( .collect::, _>>()?; rows.into_iter() .map(|(k, j)| { - Ok(match k.as_str() { + let mut event = match k.as_str() { "review_requested" => MergeRequestThreadEvent::ReviewRequested(json(&j)?), "review" => MergeRequestThreadEvent::Review(json(&j)?), "review_revoked" => MergeRequestThreadEvent::ReviewRevoked(json(&j)?), @@ -888,7 +916,9 @@ fn load_thread( "comment" => MergeRequestThreadEvent::Comment(json(&j)?), "merge" => MergeRequestThreadEvent::Merge(json(&j)?), _ => return Err(MergeRequestError::Corrupt(format!("unknown event `{k}`"))), - }) + }; + event.bound_bodies(); + Ok(event) }) .collect() } From 6c4119e2c691346178bd607eec388dc31f23c552 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 17 Aug 2026 07:46:48 +0900 Subject: [PATCH 10/13] fix: separate merge request authority from thread projection --- crates/merge-request/src/lib.rs | 8 +++++--- crates/merge-request/tests/store.rs | 20 ++++++++++++++++++++ crates/workspace-server/src/server.rs | 4 +++- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/crates/merge-request/src/lib.rs b/crates/merge-request/src/lib.rs index 812dac0d..aab25ac1 100644 --- a/crates/merge-request/src/lib.rs +++ b/crates/merge-request/src/lib.rs @@ -807,14 +807,16 @@ fn truncate_body(value: &mut String) { if value.len() <= MAX_BODY_BYTES { return; } + const MARKER: &str = "\n[truncated]"; + let limit = MAX_BODY_BYTES.saturating_sub(MARKER.len()); let boundary = value .char_indices() .map(|(index, _)| index) - .take_while(|index| *index <= MAX_BODY_BYTES) + .take_while(|index| *index <= limit) .last() .unwrap_or(0); value.truncate(boundary); - value.push_str("\n[truncated]"); + value.push_str(MARKER); } fn bounded_body(name: &str, value: &str) -> Result<(), MergeRequestError> { @@ -889,7 +891,7 @@ fn load_mr(c: &Connection, w: &str, m: &str) -> Result, Mer ticket_ids: tickets, created_at: time(&created)?, updated_at: time(&updated)?, - thread: load_thread(c, w, m, None, 100)?, + thread: load_thread(c, w, m, None, i64::MAX as usize)?, })) } fn load_thread( diff --git a/crates/merge-request/tests/store.rs b/crates/merge-request/tests/store.rs index 24a47e7c..faf230a4 100644 --- a/crates/merge-request/tests/store.rs +++ b/crates/merge-request/tests/store.rs @@ -213,3 +213,23 @@ fn v11_migration_preserves_review_events_and_requires_selector_repair() { .unwrap(); assert!(!old); } + +#[test] +fn authority_reads_full_thread_while_public_pages_remain_bounded() { + let (_d, store) = fixture(); + open(&store); + for index in 0..55 { + approve(&store, "same-subject", &format!("token-{index}")); + } + let mr = store.get("W", "T").unwrap(); + assert!(mr.thread.len() > 100); + assert_eq!( + mr.effective_review("same-subject").unwrap().decision, + ReviewDecision::Approve + ); + assert_eq!(store.thread_page("W", "T", None, 20).unwrap().len(), 20); + assert_eq!( + store.thread_page("W", "T", Some(100), 20).unwrap().len(), + 11 + ); +} diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 2ec15e45..c66d55c6 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -3728,7 +3728,9 @@ async fn scoped_show_merge_request( AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, ) -> ApiResult> { let workspace_id = parse_workspace_id(&workspace_id)?; - let mr = merge_request_store(&api, &workspace_id)?.get(&workspace_id, &ticket_id)?; + let store = merge_request_store(&api, &workspace_id)?; + let mut mr = store.get(&workspace_id, &ticket_id)?; + mr.thread = store.thread_page(&workspace_id, &ticket_id, None, 100)?; let reader = api.repository_reader(); let observed_at = Utc::now().to_rfc3339(); let source = match mr.selector_from.as_deref() { From 645458498a2c3e5a21b3ff8fc6c4b93375786dc7 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 17 Aug 2026 07:57:23 +0900 Subject: [PATCH 11/13] fix: fence merge request approval and grant lifecycle --- crates/merge-request/src/lib.rs | 93 ++++++++++++++++--- crates/merge-request/tests/store.rs | 75 ++++++++++++++- .../tickets/[ticketId]/+page.svelte | 18 ++-- 3 files changed, 166 insertions(+), 20 deletions(-) diff --git a/crates/merge-request/src/lib.rs b/crates/merge-request/src/lib.rs index aab25ac1..ddb67bc4 100644 --- a/crates/merge-request/src/lib.rs +++ b/crates/merge-request/src/lib.rs @@ -503,7 +503,30 @@ impl MergeRequestStore { } let mut c = self.lock()?; let t = c.transaction()?; - let g:Option<(String,String,String,String,String,String)>=t.query_row("SELECT g.workspace_id,g.merge_request_id,g.request_event_id,g.subject_ref,g.reviewer_runtime_id,g.reviewer_worker_id FROM merge_request_review_grants g JOIN merge_request_ticket_relations rel ON rel.workspace_id=g.workspace_id AND rel.merge_request_id=g.merge_request_id WHERE g.capability_token=?1 AND rel.ticket_id=?2 AND g.status='issued'",params![i.capability_token,i.ticket_id],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?))).optional()?; + let g: Option<(String, String, String, String, String, String)> = t + .query_row( + "SELECT g.workspace_id,g.merge_request_id,g.request_event_id,g.subject_ref, + g.reviewer_runtime_id,g.reviewer_worker_id + FROM merge_request_review_grants g + JOIN merge_request_ticket_relations rel + ON rel.workspace_id=g.workspace_id AND rel.merge_request_id=g.merge_request_id + JOIN merge_requests mr + ON mr.workspace_id=g.workspace_id AND mr.merge_request_id=g.merge_request_id + WHERE g.capability_token=?1 AND rel.ticket_id=?2 + AND g.status='issued' AND mr.state='open'", + params![i.capability_token, i.ticket_id], + |r| { + Ok(( + r.get(0)?, + r.get(1)?, + r.get(2)?, + r.get(3)?, + r.get(4)?, + r.get(5)?, + )) + }, + ) + .optional()?; let Some((ws, mr, req, subject, rr, rw)) = g else { return Err(MergeRequestError::Unauthorized( "review grant invalid".into(), @@ -633,14 +656,18 @@ impl MergeRequestStore { )); } let review = mr - .thread - .iter() - .find_map(|x| match x { - MergeRequestThreadEvent::Review(v) if v.event_id == i.approval_event_id => Some(v), - _ => None, - }) - .ok_or_else(|| MergeRequestError::NotReady("approval event missing".into()))?; - if review.decision!=ReviewDecision::Approve||review.subject_ref!=i.current_subject_ref||mr.thread.iter().any(|x|matches!(x,MergeRequestThreadEvent::ReviewRevoked(v)if v.review_event_id==review.event_id)){return Err(MergeRequestError::NotReady("approval is not valid for current source ref".into()))} + .effective_review(&i.current_subject_ref) + .filter(|review| review.event_id == i.approval_event_id) + .ok_or_else(|| { + MergeRequestError::NotReady( + "approval is not the current effective review for the source ref".into(), + ) + })?; + if review.decision != ReviewDecision::Approve { + return Err(MergeRequestError::NotReady( + "current effective review does not approve the source ref".into(), + )); + } if i.target_ref_before == i.target_ref_after { return Err(MergeRequestError::Validation( "target ref did not change".into(), @@ -661,6 +688,48 @@ impl MergeRequestStore { )); } t.execute("UPDATE typed_tickets SET workflow_state='done',workflow_state_explicit=1,updated_at=?3 WHERE workspace_id=?1 AND ticket_id=?2",params![mr.workspace_id,i.ticket_id,i.now.to_rfc3339()])?; + let issued_grants = { + let mut statement = t.prepare( + "SELECT request_event_id,subject_ref,capability_token + FROM merge_request_review_grants + WHERE workspace_id=?1 AND merge_request_id=?2 AND status='issued' + ORDER BY issued_at,request_event_id", + )?; + statement + .query_map(params![mr.workspace_id, mr.merge_request_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + })? + .collect::, _>>()? + }; + for (request_event_id, subject_ref, capability_token) in issued_grants { + let cancelled = ReviewCancelledEvent { + event_id: Uuid::now_v7().to_string(), + sequence: next_seq(&t, &mr.workspace_id, &mr.merge_request_id)?, + request_event_id, + subject_ref, + reason: "Merge Request completed before review submission".into(), + created_at: i.now, + }; + insert_event( + &t, + &mr.workspace_id, + &mr.merge_request_id, + "review_cancelled", + &cancelled, + i.now, + None, + )?; + t.execute( + "UPDATE merge_request_review_grants + SET status='revoked',revoked_at=?2 + WHERE capability_token=?1 AND status='issued'", + params![capability_token, i.now.to_rfc3339()], + )?; + } let e = MergeEvent { event_id: Uuid::now_v7().to_string(), sequence: next_seq(&t, &mr.workspace_id, &mr.merge_request_id)?, @@ -1076,14 +1145,16 @@ fn migrate_events(t: &Transaction<'_>) -> Result<(), MergeRequestError> { created_at: time(&row_at)?, }; insert_event(t, &ws, &mr, "review", &rev, rev.created_at, None)? - } else if status == "revoked" { + } else { let at = consumed.as_deref().unwrap_or(&created); let e = ReviewCancelledEvent { event_id: format!("migrated-cancel-{a}"), sequence: next_seq(t, &ws, &mr)?, request_event_id: req.event_id, subject_ref: subject, - reason: "legacy review attempt revoked".into(), + reason: format!( + "legacy `{status}` review request cancelled because its capability cannot be migrated" + ), created_at: time(at)?, }; insert_event(t, &ws, &mr, "review_cancelled", &e, e.created_at, None)? diff --git a/crates/merge-request/tests/store.rs b/crates/merge-request/tests/store.rs index faf230a4..823a1cab 100644 --- a/crates/merge-request/tests/store.rs +++ b/crates/merge-request/tests/store.rs @@ -190,7 +190,7 @@ fn review_revocation_invalidates_readiness() { #[test] fn v11_migration_preserves_review_events_and_requires_selector_repair() { let c = Connection::open_in_memory().unwrap(); - c.execute_batch("CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,PRIMARY KEY(workspace_id,ticket_id));INSERT INTO repositories VALUES('W','R');INSERT INTO typed_tickets VALUES('W','T');CREATE TABLE merge_request_schema(singleton INTEGER PRIMARY KEY,version INTEGER);INSERT INTO merge_request_schema VALUES(1,11);CREATE TABLE merge_requests(workspace_id TEXT,merge_request_id TEXT,repository_id TEXT,state TEXT,target_ref_selector TEXT,current_revision_ordinal INTEGER,current_revision_id TEXT,created_at TEXT,updated_at TEXT,merged_revision_id TEXT,merged_at TEXT);CREATE TABLE merge_request_ticket_relations(workspace_id TEXT,merge_request_id TEXT,ticket_id TEXT,relation_kind TEXT,created_at TEXT);CREATE TABLE merge_request_revisions(workspace_id TEXT,merge_request_id TEXT,revision_id TEXT,ordinal INTEGER,base_commit TEXT,head_commit TEXT,diff_digest TEXT,summary TEXT,assignment_id TEXT,created_at TEXT);CREATE TABLE merge_request_revision_paths(workspace_id TEXT,merge_request_id TEXT,revision_id TEXT,ordinal INTEGER,path TEXT);CREATE TABLE merge_request_reviewer_child_sessions(workspace_id TEXT,child_session_id TEXT,parent_runtime_id TEXT,parent_worker_id TEXT,reviewer_profile TEXT,registered_at TEXT);CREATE TABLE merge_request_review_attempts(workspace_id TEXT,attempt_id TEXT,merge_request_id TEXT,ticket_id TEXT,revision_id TEXT,revision_ordinal INTEGER,parent_assignment_id TEXT,parent_runtime_id TEXT,parent_worker_id TEXT,child_session_id TEXT,reviewer_effective_profile TEXT,capability_token TEXT,status TEXT,created_at TEXT,consumed_at TEXT);CREATE TABLE merge_request_reviews(workspace_id TEXT,attempt_id TEXT,merge_request_id TEXT,revision_id TEXT,decision TEXT,body TEXT,submitted_at TEXT);CREATE TABLE merge_request_review_findings(workspace_id TEXT,attempt_id TEXT,ordinal INTEGER,severity TEXT,code TEXT,path TEXT,line INTEGER,body TEXT);CREATE TABLE merge_request_completion_operations(workspace_id TEXT,operation_id TEXT,ticket_id TEXT,revision_id TEXT,authority_kind TEXT,implementation_assignment_id TEXT,completion_actor_runtime_id TEXT,completion_actor_worker_id TEXT,target_commit TEXT,source_commit TEXT,result_commit TEXT,strategy TEXT,resolution TEXT,fingerprint TEXT,status TEXT,result_ticket_state TEXT,created_at TEXT,updated_at TEXT);INSERT INTO merge_requests VALUES('W','MR','R','open','develop',1,'V','2026-07-26T12:00:00Z','2026-07-26T12:00:00Z',NULL,NULL);INSERT INTO merge_request_ticket_relations VALUES('W','MR','T','implements','2026-07-26T12:00:00Z');INSERT INTO merge_request_revisions VALUES('W','MR','V',1,'base','subject','digest','summary','A','2026-07-26T12:00:00Z');INSERT INTO merge_request_review_attempts VALUES('W','AT','MR','T','V',1,'A','runtime','coder','child','builtin:reviewer','token','submitted','2026-07-26T12:00:00Z','2026-07-26T12:00:01Z');INSERT INTO merge_request_reviews VALUES('W','AT','MR','V','approve','approved','2026-07-26T12:00:01Z');").unwrap(); + c.execute_batch("CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,PRIMARY KEY(workspace_id,ticket_id));INSERT INTO repositories VALUES('W','R');INSERT INTO typed_tickets VALUES('W','T');CREATE TABLE merge_request_schema(singleton INTEGER PRIMARY KEY,version INTEGER);INSERT INTO merge_request_schema VALUES(1,11);CREATE TABLE merge_requests(workspace_id TEXT,merge_request_id TEXT,repository_id TEXT,state TEXT,target_ref_selector TEXT,current_revision_ordinal INTEGER,current_revision_id TEXT,created_at TEXT,updated_at TEXT,merged_revision_id TEXT,merged_at TEXT);CREATE TABLE merge_request_ticket_relations(workspace_id TEXT,merge_request_id TEXT,ticket_id TEXT,relation_kind TEXT,created_at TEXT);CREATE TABLE merge_request_revisions(workspace_id TEXT,merge_request_id TEXT,revision_id TEXT,ordinal INTEGER,base_commit TEXT,head_commit TEXT,diff_digest TEXT,summary TEXT,assignment_id TEXT,created_at TEXT);CREATE TABLE merge_request_revision_paths(workspace_id TEXT,merge_request_id TEXT,revision_id TEXT,ordinal INTEGER,path TEXT);CREATE TABLE merge_request_reviewer_child_sessions(workspace_id TEXT,child_session_id TEXT,parent_runtime_id TEXT,parent_worker_id TEXT,reviewer_profile TEXT,registered_at TEXT);CREATE TABLE merge_request_review_attempts(workspace_id TEXT,attempt_id TEXT,merge_request_id TEXT,ticket_id TEXT,revision_id TEXT,revision_ordinal INTEGER,parent_assignment_id TEXT,parent_runtime_id TEXT,parent_worker_id TEXT,child_session_id TEXT,reviewer_effective_profile TEXT,capability_token TEXT,status TEXT,created_at TEXT,consumed_at TEXT);CREATE TABLE merge_request_reviews(workspace_id TEXT,attempt_id TEXT,merge_request_id TEXT,revision_id TEXT,decision TEXT,body TEXT,submitted_at TEXT);CREATE TABLE merge_request_review_findings(workspace_id TEXT,attempt_id TEXT,ordinal INTEGER,severity TEXT,code TEXT,path TEXT,line INTEGER,body TEXT);CREATE TABLE merge_request_completion_operations(workspace_id TEXT,operation_id TEXT,ticket_id TEXT,revision_id TEXT,authority_kind TEXT,implementation_assignment_id TEXT,completion_actor_runtime_id TEXT,completion_actor_worker_id TEXT,target_commit TEXT,source_commit TEXT,result_commit TEXT,strategy TEXT,resolution TEXT,fingerprint TEXT,status TEXT,result_ticket_state TEXT,created_at TEXT,updated_at TEXT);INSERT INTO merge_requests VALUES('W','MR','R','open','develop',1,'V','2026-07-26T12:00:00Z','2026-07-26T12:00:00Z',NULL,NULL);INSERT INTO merge_request_ticket_relations VALUES('W','MR','T','implements','2026-07-26T12:00:00Z');INSERT INTO merge_request_revisions VALUES('W','MR','V',1,'base','subject','digest','summary','A','2026-07-26T12:00:00Z');INSERT INTO merge_request_review_attempts VALUES('W','AT','MR','T','V',1,'A','runtime','coder','child','builtin:reviewer','token','submitted','2026-07-26T12:00:00Z','2026-07-26T12:00:01Z');INSERT INTO merge_request_reviews VALUES('W','AT','MR','V','approve','approved','2026-07-26T12:00:01Z');INSERT INTO merge_request_review_attempts VALUES('W','PENDING','MR','T','V',1,'A','runtime','coder','pending-child','builtin:reviewer','pending-token','registered','2026-07-26T12:00:02Z',NULL);").unwrap(); merge_request::migrate(&c).unwrap(); let selector: Option = c .query_row("SELECT selector_from FROM merge_requests", [], |r| r.get(0)) @@ -203,7 +203,10 @@ fn v11_migration_preserves_review_events_and_requires_selector_repair() { |r| r.get(0), ) .unwrap(); - assert_eq!(kinds, "review_requested,review"); + assert_eq!( + kinds, + "review_requested,review,review_requested,review_cancelled" + ); let old: bool = c .query_row( "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name='merge_request_revisions')", @@ -233,3 +236,71 @@ fn authority_reads_full_thread_while_public_pages_remain_bounded() { 11 ); } + +#[test] +fn completion_rejects_superseded_approval_for_same_subject() { + let (_d, store) = fixture(); + open(&store); + let old_approval = approve(&store, "subject", "approval"); + request(&store, "subject", "changes"); + store + .submit_review(SubmitMergeRequestReview { + ticket_id: "T".into(), + current_subject_ref: "subject".into(), + capability_token: "changes".into(), + decision: ReviewDecision::RequestChanges, + body: "changes required".into(), + findings: vec![], + now: at(5), + }) + .unwrap(); + let result = store.complete(CompleteMergeRequest { + ticket_id: "T".into(), + operation_id: "op".into(), + approval_event_id: old_approval.event_id, + current_subject_ref: "subject".into(), + target_ref_before: "before".into(), + target_ref_after: "after".into(), + strategy: MergeStrategy::FastForward, + resolution: ConflictResolution::None, + auth: auth(), + now: at(6), + }); + assert!(matches!(result, Err(MergeRequestError::NotReady(_)))); +} + +#[test] +fn completion_cancels_outstanding_grants_and_late_submit_fails() { + let (_d, store) = fixture(); + open(&store); + let approval = approve(&store, "subject", "approval"); + request(&store, "other-subject", "pending"); + store + .complete(CompleteMergeRequest { + ticket_id: "T".into(), + operation_id: "op".into(), + approval_event_id: approval.event_id, + current_subject_ref: "subject".into(), + target_ref_before: "before".into(), + target_ref_after: "after".into(), + strategy: MergeStrategy::FastForward, + resolution: ConflictResolution::None, + auth: auth(), + now: at(6), + }) + .unwrap(); + let late = store.submit_review(SubmitMergeRequestReview { + ticket_id: "T".into(), + current_subject_ref: "other-subject".into(), + capability_token: "pending".into(), + decision: ReviewDecision::Approve, + body: "too late".into(), + findings: vec![], + now: at(7), + }); + assert!(matches!(late, Err(MergeRequestError::Unauthorized(_)))); + let mr = store.get("W", "T").unwrap(); + assert!(mr.thread.iter().any(|event| matches!(event, + MergeRequestThreadEvent::ReviewCancelled(value) + if value.reason.contains("completed before review submission")))); +} diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte index 53db1bc7..875f2472 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte @@ -89,13 +89,17 @@ const currentReviewRequest = $derived( mergeRequest?.thread.findLast((event) => event.kind === "review_requested") ?? null, ); - const currentReview = $derived( - mergeRequest?.source.status === "known" - ? mergeRequest.thread.findLast( - (event) => event.kind === "review" && event.subject_ref === mergeRequest?.source.ref, - ) ?? null - : null, - ); + const currentReview = $derived.by(() => { + if (mergeRequest?.source.status !== "known") return null; + const review = mergeRequest.thread.findLast( + (event) => event.kind === "review" && event.subject_ref === mergeRequest.source.ref, + ); + if (!review || review.kind !== "review") return null; + const revoked = mergeRequest.thread.some( + (event) => event.kind === "review_revoked" && event.review_event_id === review.event_id, + ); + return revoked ? null : review; + }); const mergeEvent = $derived( mergeRequest?.thread.findLast((event) => event.kind === "merge") ?? null, ); From e3dc8ee327946a247a00f03e28ac999ac744ca6f Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 17 Aug 2026 08:04:33 +0900 Subject: [PATCH 12/13] fix: validate merge authority before ref updates --- crates/merge-request/src/lib.rs | 69 +++++++++++++++++++++++++++ crates/merge-request/tests/store.rs | 48 +++++++++++++++++++ crates/workspace-server/src/server.rs | 30 +++++++----- 3 files changed, 134 insertions(+), 13 deletions(-) diff --git a/crates/merge-request/src/lib.rs b/crates/merge-request/src/lib.rs index ddb67bc4..c86af168 100644 --- a/crates/merge-request/src/lib.rs +++ b/crates/merge-request/src/lib.rs @@ -256,6 +256,7 @@ pub struct RepairSelectorFrom { pub workspace_id: String, pub ticket_id: String, pub selector_from: String, + pub resolved_subject_ref: String, pub repaired_by: WorkerIdentity, pub reason: String, pub now: DateTime, @@ -638,7 +639,66 @@ impl MergeRequestStore { review, }) } + pub fn validate_completion(&self, i: &CompleteMergeRequest) -> Result<(), MergeRequestError> { + let mr = self.get(&i.auth.workspace_id, &i.ticket_id)?; + self.completion_auth(&i.auth, &i.ticket_id, &mr.repository_id)?; + if let Some(existing) = mr.thread.iter().find_map(|event| match event { + MergeRequestThreadEvent::Merge(value) if value.operation_id == i.operation_id => { + Some(value) + } + _ => None, + }) { + if existing.approval_event_id == i.approval_event_id + && existing.target_ref_before == i.target_ref_before + && existing.target_ref_after == i.target_ref_after + { + return Ok(()); + } + return Err(MergeRequestError::Conflict( + "operation fingerprint mismatch".into(), + )); + } + if mr.state != MergeRequestState::Open { + return Err(MergeRequestError::Conflict( + "Merge Request is not open".into(), + )); + } + let review = mr + .effective_review(&i.current_subject_ref) + .filter(|review| review.event_id == i.approval_event_id) + .ok_or_else(|| { + MergeRequestError::NotReady( + "approval is not the current effective review for the source ref".into(), + ) + })?; + if review.decision != ReviewDecision::Approve { + return Err(MergeRequestError::NotReady( + "current effective review does not approve the source ref".into(), + )); + } + if i.target_ref_before == i.target_ref_after { + return Err(MergeRequestError::Validation( + "target ref did not change".into(), + )); + } + let state: Option = self + .lock()? + .query_row( + "SELECT workflow_state FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2", + params![mr.workspace_id, i.ticket_id], + |row| row.get(0), + ) + .optional()?; + if state.as_deref() != Some("inprogress") { + return Err(MergeRequestError::Conflict( + "Ticket must be inprogress".into(), + )); + } + Ok(()) + } + pub fn complete(&self, i: CompleteMergeRequest) -> Result { + self.validate_completion(&i)?; let mr = self.get(&i.auth.workspace_id, &i.ticket_id)?; self.completion_auth(&i.auth, &i.ticket_id, &mr.repository_id)?; if let Some(v) = mr.thread.iter().find_map(|x| match x { @@ -765,12 +825,21 @@ impl MergeRequestStore { i: RepairSelectorFrom, ) -> Result { nonempty("selector_from", &i.selector_from)?; + nonempty("resolved_subject_ref", &i.resolved_subject_ref)?; let mr = self.get(&i.workspace_id, &i.ticket_id)?; if mr.selector_from.is_some() { return Err(MergeRequestError::Conflict( "selector_from is immutable after it is set".into(), )); } + let approved = mr + .effective_review(&i.resolved_subject_ref) + .is_some_and(|review| review.decision == ReviewDecision::Approve); + if !approved { + return Err(MergeRequestError::NotReady( + "selector repair must resolve to an approved thread subject".into(), + )); + } let mut c = self.lock()?; let t = c.transaction()?; let changed=t.execute("UPDATE merge_requests SET selector_from=?3,updated_at=?4 WHERE workspace_id=?1 AND merge_request_id=?2 AND selector_from IS NULL",params![mr.workspace_id,mr.merge_request_id,i.selector_from,i.now.to_rfc3339()])?; diff --git a/crates/merge-request/tests/store.rs b/crates/merge-request/tests/store.rs index 823a1cab..336df058 100644 --- a/crates/merge-request/tests/store.rs +++ b/crates/merge-request/tests/store.rs @@ -304,3 +304,51 @@ fn completion_cancels_outstanding_grants_and_late_submit_fails() { MergeRequestThreadEvent::ReviewCancelled(value) if value.reason.contains("completed before review submission")))); } + +#[test] +fn selector_repair_requires_and_accepts_an_approved_resolved_subject() { + let (dir, store) = fixture(); + open(&store); + approve(&store, "approved-subject", "approval"); + Connection::open(dir.path().join("db")).unwrap() + .execute("UPDATE merge_requests SET selector_from=NULL WHERE workspace_id='W' AND merge_request_id='MR'", []) + .unwrap(); + let repaired = store + .repair_selector_from(RepairSelectorFrom { + workspace_id: "W".into(), + ticket_id: "T".into(), + selector_from: "restored-work".into(), + resolved_subject_ref: "approved-subject".into(), + repaired_by: WorkerIdentity { + runtime_id: "browser".into(), + worker_id: "user".into(), + }, + reason: "confirmed migrated source".into(), + now: at(8), + }) + .unwrap(); + assert_eq!(repaired.selector_from.as_deref(), Some("restored-work")); +} + +#[test] +fn selector_repair_rejects_unapproved_resolved_subject() { + let (dir, store) = fixture(); + open(&store); + approve(&store, "approved-subject", "approval"); + Connection::open(dir.path().join("db")).unwrap() + .execute("UPDATE merge_requests SET selector_from=NULL WHERE workspace_id='W' AND merge_request_id='MR'", []) + .unwrap(); + let result = store.repair_selector_from(RepairSelectorFrom { + workspace_id: "W".into(), + ticket_id: "T".into(), + selector_from: "wrong-work".into(), + resolved_subject_ref: "different-subject".into(), + repaired_by: WorkerIdentity { + runtime_id: "browser".into(), + worker_id: "user".into(), + }, + reason: "wrong candidate".into(), + now: at(8), + }); + assert!(matches!(result, Err(MergeRequestError::NotReady(_)))); +} diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index c66d55c6..96b7ec8f 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -3877,14 +3877,17 @@ async fn scoped_repair_merge_request_selector( } let store = merge_request_store(&api, &workspace_id)?; let mr = store.get(&workspace_id, &ticket_id)?; - api.repository_reader() + let resolved_subject_ref = api + .repository_reader() .observe_merge_target(&mr.repository_id, Some(&input.selector_from)) - .map_err(repository_merge_evidence_error)?; + .map_err(repository_merge_evidence_error)? + .commit; Ok(Json(store.repair_selector_from( merge_request::RepairSelectorFrom { workspace_id, ticket_id, selector_from: input.selector_from, + resolved_subject_ref, repaired_by: merge_request::WorkerIdentity { runtime_id: "browser".into(), worker_id: "authenticated-user".into(), @@ -4079,17 +4082,6 @@ async fn scoped_complete_merge_request( ) .into()); } - let already = observed.commit == input.target_ref_after; - if !already { - repositories - .update_merge_target( - &mr.repository_id, - &mr.selector_to, - &input.target_ref_before, - &input.target_ref_after, - ) - .map_err(repository_merge_evidence_error)? - } let completion = merge_request::CompleteMergeRequest { ticket_id, operation_id: input.operation_id, @@ -4108,6 +4100,18 @@ async fn scoped_complete_merge_request( }, now: Utc::now(), }; + store.validate_completion(&completion)?; + let already = observed.commit == input.target_ref_after; + if !already { + repositories + .update_merge_target( + &mr.repository_id, + &mr.selector_to, + &input.target_ref_before, + &input.target_ref_after, + ) + .map_err(repository_merge_evidence_error)? + } match store.complete(completion) { Ok(v) => Ok(Json(v)), Err(e) => { From 5f44b10ff9e4db988f5db4fd4cf5f37c8ea068e0 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 17 Aug 2026 08:15:13 +0900 Subject: [PATCH 13/13] fix: revalidate merge authority in completion transaction --- crates/merge-request/src/lib.rs | 117 ++++++++++++++++++++-------- crates/merge-request/tests/store.rs | 34 +++++++- 2 files changed, 116 insertions(+), 35 deletions(-) diff --git a/crates/merge-request/src/lib.rs b/crates/merge-request/src/lib.rs index c86af168..ca8821be 100644 --- a/crates/merge-request/src/lib.rs +++ b/crates/merge-request/src/lib.rs @@ -699,47 +699,61 @@ impl MergeRequestStore { pub fn complete(&self, i: CompleteMergeRequest) -> Result { self.validate_completion(&i)?; - let mr = self.get(&i.auth.workspace_id, &i.ticket_id)?; - self.completion_auth(&i.auth, &i.ticket_id, &mr.repository_id)?; - if let Some(v) = mr.thread.iter().find_map(|x| match x { - MergeRequestThreadEvent::Merge(v) if v.operation_id == i.operation_id => Some(v), + let mut connection = self.lock()?; + let transaction = connection.transaction()?; + let mr = load_mr_for_ticket(&transaction, &i.auth.workspace_id, &i.ticket_id)? + .ok_or(MergeRequestError::NotFound)?; + + if let Some(existing) = mr.thread.iter().find_map(|event| match event { + MergeRequestThreadEvent::Merge(value) if value.operation_id == i.operation_id => { + Some(value) + } _ => None, }) { - if v.approval_event_id == i.approval_event_id - && v.target_ref_before == i.target_ref_before - && v.target_ref_after == i.target_ref_after + if existing.approval_event_id == i.approval_event_id + && existing.target_ref_before == i.target_ref_before + && existing.target_ref_after == i.target_ref_after { - return Ok(v.clone()); + return Ok(existing.clone()); } return Err(MergeRequestError::Conflict( "operation fingerprint mismatch".into(), )); } + if mr.state != MergeRequestState::Open { + return Err(MergeRequestError::Conflict( + "Merge Request is not open".into(), + )); + } + let assignment_is_current: bool = transaction.query_row( + "SELECT EXISTS( + SELECT 1 FROM ticket_current_worker_assignments + WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3 + )", + params![i.auth.workspace_id, i.ticket_id, i.auth.assignment_id], + |row| row.get(0), + )?; + if !assignment_is_current { + return Err(MergeRequestError::Unauthorized( + "completion assignment changed before commit".into(), + )); + } let review = mr .effective_review(&i.current_subject_ref) .filter(|review| review.event_id == i.approval_event_id) .ok_or_else(|| { - MergeRequestError::NotReady( - "approval is not the current effective review for the source ref".into(), - ) + MergeRequestError::NotReady("approval changed before completion commit".into()) })?; if review.decision != ReviewDecision::Approve { return Err(MergeRequestError::NotReady( "current effective review does not approve the source ref".into(), )); } - if i.target_ref_before == i.target_ref_after { - return Err(MergeRequestError::Validation( - "target ref did not change".into(), - )); - } - let mut c = self.lock()?; - let t = c.transaction()?; - let state: Option = t + let state: Option = transaction .query_row( "SELECT workflow_state FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2", params![mr.workspace_id, i.ticket_id], - |r| r.get(0), + |row| row.get(0), ) .optional()?; if state.as_deref() != Some("inprogress") { @@ -747,9 +761,14 @@ impl MergeRequestStore { "Ticket must be inprogress".into(), )); } - t.execute("UPDATE typed_tickets SET workflow_state='done',workflow_state_explicit=1,updated_at=?3 WHERE workspace_id=?1 AND ticket_id=?2",params![mr.workspace_id,i.ticket_id,i.now.to_rfc3339()])?; + transaction.execute( + "UPDATE typed_tickets + SET workflow_state='done',workflow_state_explicit=1,updated_at=?3 + WHERE workspace_id=?1 AND ticket_id=?2 AND workflow_state='inprogress'", + params![mr.workspace_id, i.ticket_id, i.now.to_rfc3339()], + )?; let issued_grants = { - let mut statement = t.prepare( + let mut statement = transaction.prepare( "SELECT request_event_id,subject_ref,capability_token FROM merge_request_review_grants WHERE workspace_id=?1 AND merge_request_id=?2 AND status='issued' @@ -768,14 +787,14 @@ impl MergeRequestStore { for (request_event_id, subject_ref, capability_token) in issued_grants { let cancelled = ReviewCancelledEvent { event_id: Uuid::now_v7().to_string(), - sequence: next_seq(&t, &mr.workspace_id, &mr.merge_request_id)?, + sequence: next_seq(&transaction, &mr.workspace_id, &mr.merge_request_id)?, request_event_id, subject_ref, reason: "Merge Request completed before review submission".into(), created_at: i.now, }; insert_event( - &t, + &transaction, &mr.workspace_id, &mr.merge_request_id, "review_cancelled", @@ -783,16 +802,16 @@ impl MergeRequestStore { i.now, None, )?; - t.execute( + transaction.execute( "UPDATE merge_request_review_grants SET status='revoked',revoked_at=?2 WHERE capability_token=?1 AND status='issued'", params![capability_token, i.now.to_rfc3339()], )?; } - let e = MergeEvent { + let event = MergeEvent { event_id: Uuid::now_v7().to_string(), - sequence: next_seq(&t, &mr.workspace_id, &mr.merge_request_id)?, + sequence: next_seq(&transaction, &mr.workspace_id, &mr.merge_request_id)?, operation_id: i.operation_id, approval_event_id: i.approval_event_id, approved_source_ref: review.subject_ref.clone(), @@ -807,19 +826,24 @@ impl MergeRequestStore { created_at: i.now, }; insert_event( - &t, + &transaction, &mr.workspace_id, &mr.merge_request_id, "merge", - &e, + &event, i.now, - Some(&e.operation_id), + Some(&event.operation_id), )?; - t.execute("UPDATE merge_requests SET state='merged',updated_at=?3 WHERE workspace_id=?1 AND merge_request_id=?2",params![mr.workspace_id,mr.merge_request_id,i.now.to_rfc3339()])?; - ticket_event(&t, &mr, &e, &i.auth.assignment_id)?; - t.commit()?; - Ok(e) + transaction.execute( + "UPDATE merge_requests SET state='merged',updated_at=?3 + WHERE workspace_id=?1 AND merge_request_id=?2 AND state='open'", + params![mr.workspace_id, mr.merge_request_id, i.now.to_rfc3339()], + )?; + ticket_event(&transaction, &mr, &event, &i.auth.assignment_id)?; + transaction.commit()?; + Ok(event) } + pub fn repair_selector_from( &self, i: RepairSelectorFrom, @@ -1010,6 +1034,31 @@ fn insert_event( )?; Ok(()) } +fn load_mr_for_ticket( + connection: &Connection, + workspace_id: &str, + ticket_id: &str, +) -> Result, MergeRequestError> { + let merge_request_id: Option = connection + .query_row( + "SELECT rel.merge_request_id + FROM merge_request_ticket_relations rel + JOIN merge_requests mr + ON mr.workspace_id=rel.workspace_id + AND mr.merge_request_id=rel.merge_request_id + WHERE rel.workspace_id=?1 AND rel.ticket_id=?2 + ORDER BY CASE mr.state WHEN 'open' THEN 0 ELSE 1 END,mr.created_at DESC + LIMIT 1", + params![workspace_id, ticket_id], + |row| row.get(0), + ) + .optional()?; + match merge_request_id { + Some(id) => load_mr(connection, workspace_id, &id), + None => Ok(None), + } +} + fn load_mr(c: &Connection, w: &str, m: &str) -> Result, MergeRequestError> { let row:Option<(String,String,Option,String,String,String)>=c.query_row("SELECT repository_id,state,selector_from,selector_to,created_at,updated_at FROM merge_requests WHERE workspace_id=?1 AND merge_request_id=?2",params![w,m],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?))).optional()?; let Some((repo, state, from, to, created, updated)) = row else { diff --git a/crates/merge-request/tests/store.rs b/crates/merge-request/tests/store.rs index 336df058..33968cad 100644 --- a/crates/merge-request/tests/store.rs +++ b/crates/merge-request/tests/store.rs @@ -33,7 +33,7 @@ fn fixture() -> (tempfile::TempDir, MergeRequestStore) { let d = tempfile::tempdir().unwrap(); let p = d.path().join("db"); let c = Connection::open(&p).unwrap(); - c.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,workflow_state TEXT,workflow_state_explicit INTEGER,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_ticket_events(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,kind TEXT,author TEXT,at TEXT,from_state TEXT,to_state TEXT,heading TEXT,body TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index));CREATE TABLE typed_ticket_event_attributes(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,key TEXT,value TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index,key));INSERT INTO workspaces VALUES('W');INSERT INTO repositories VALUES('W','R');INSERT INTO typed_tickets VALUES('W','T','inprogress',1,'t');").unwrap(); + c.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE ticket_current_worker_assignments(workspace_id TEXT,ticket_id TEXT,assignment_id TEXT,runtime_id TEXT,worker_id TEXT,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,workflow_state TEXT,workflow_state_explicit INTEGER,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_ticket_events(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,kind TEXT,author TEXT,at TEXT,from_state TEXT,to_state TEXT,heading TEXT,body TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index));CREATE TABLE typed_ticket_event_attributes(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,key TEXT,value TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index,key));INSERT INTO workspaces VALUES('W');INSERT INTO repositories VALUES('W','R');INSERT INTO ticket_current_worker_assignments VALUES('W','T','A','runtime','coder','t');INSERT INTO typed_tickets VALUES('W','T','inprogress',1,'t');").unwrap(); drop(c); let a = Assignments(Arc::new(Mutex::new(CurrentAssignment { assignment_id: "A".into(), @@ -352,3 +352,35 @@ fn selector_repair_rejects_unapproved_resolved_subject() { }); assert!(matches!(result, Err(MergeRequestError::NotReady(_)))); } + +#[test] +fn transactional_completion_rejects_assignment_changed_in_control_plane_db() { + let (dir, store) = fixture(); + open(&store); + let approval = approve(&store, "subject", "approval"); + Connection::open(dir.path().join("db")).unwrap() + .execute("UPDATE ticket_current_worker_assignments SET assignment_id='B' WHERE workspace_id='W' AND ticket_id='T'", []) + .unwrap(); + let result = store.complete(CompleteMergeRequest { + ticket_id: "T".into(), + operation_id: "op".into(), + approval_event_id: approval.event_id, + current_subject_ref: "subject".into(), + target_ref_before: "before".into(), + target_ref_after: "after".into(), + strategy: MergeStrategy::FastForward, + resolution: ConflictResolution::None, + auth: auth(), + now: at(9), + }); + assert!(matches!(result, Err(MergeRequestError::Unauthorized(_)))); + let state: String = Connection::open(dir.path().join("db")) + .unwrap() + .query_row( + "SELECT workflow_state FROM typed_tickets WHERE workspace_id='W' AND ticket_id='T'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(state, "inprogress"); +}