feat: add merge request review authority

This commit is contained in:
2026-08-11 20:21:37 +09:00
parent 7f0d025312
commit 1814c35701
29 changed files with 3069 additions and 555 deletions
+169 -2
View File
@@ -27,7 +27,10 @@ use crate::internal_worker::{
};
use crate::prompt::catalog::PromptCatalog;
use crate::spawn::registry::SpawnedWorkerRegistry;
use crate::worker::{Worker, WorkerFilesystemAuthority};
use crate::worker::{
ReviewerAttemptContext, ReviewerChildWorkspaceClient, Worker, WorkerFilesystemAuthority,
WorkspaceRequest, WorkspaceRequestMethod,
};
use protocol::Method;
#[derive(Debug, Deserialize, schemars::JsonSchema)]
@@ -55,6 +58,16 @@ 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<ScopeRuleInput>,
/// 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.
#[serde(default)]
review: Option<ReviewerHandoffInput>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct ReviewerHandoffInput {
ticket_id: String,
revision_id: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
@@ -320,6 +333,32 @@ impl SubWorkerSpawnTool {
}
}
fn validate_reviewer_handoff(input: &SubWorkerSpawnInput) -> Result<(), ToolError> {
let Some(review) = &input.review else {
return Ok(());
};
if review.ticket_id.trim().is_empty() || review.revision_id.trim().is_empty() {
return Err(ToolError::InvalidArgument(
"reviewer handoff requires non-empty ticket_id and revision_id".to_string(),
));
}
if input.profile.as_deref() != Some("builtin:reviewer") {
return Err(ToolError::InvalidArgument(
"reviewer handoff requires the explicit effective profile builtin:reviewer".to_string(),
));
}
if input
.scope
.iter()
.any(|rule| matches!(rule.permission, PermissionInput::Write))
{
return Err(ToolError::InvalidArgument(
"Merge Request Reviewer SubWorkers must have read-only delegated scope".to_string(),
));
}
Ok(())
}
#[async_trait]
impl Tool for SubWorkerSpawnTool {
async fn execute(
@@ -340,6 +379,7 @@ impl Tool for SubWorkerSpawnTool {
input.name
)));
}
validate_reviewer_handoff(&input)?;
let name_reservation = self
.registry
.reserve_internal_name(input.name.clone())
@@ -378,6 +418,48 @@ impl Tool for SubWorkerSpawnTool {
.map_err(|error| {
ToolError::ExecutionFailed(format!("resolve child manifest: {error}"))
})?;
let reviewer_attempt = input.review.as_ref().map(|review| {
(
review.ticket_id.clone(),
review.revision_id.clone(),
uuid::Uuid::now_v7().to_string(),
format!(
"{}{}",
uuid::Uuid::now_v7().simple(),
uuid::Uuid::now_v7().simple()
),
)
});
let child_workspace_context =
if let Some((ticket_id, revision_id, _, capability_token)) = &reviewer_attempt {
let workspace_id =
self.workspace_context
.workspace_id()
.cloned()
.ok_or_else(|| {
ToolError::InvalidArgument(
"reviewer handoff requires Workspace identity".to_string(),
)
})?;
let parent_client = self.workspace_context.client_handle();
if !parent_client.is_available() {
return Err(ToolError::InvalidArgument(
"reviewer handoff requires Workspace API authority".to_string(),
));
}
let child_client: Arc<dyn crate::worker::WorkspaceClient> =
Arc::new(ReviewerChildWorkspaceClient::new(
parent_client.clone(),
ReviewerAttemptContext {
ticket_id: ticket_id.clone(),
revision_id: revision_id.clone(),
},
capability_token.clone(),
));
crate::worker::WorkerWorkspaceContext::with_client(Some(workspace_id), child_client)
} else {
self.workspace_context.clone()
};
let store = EphemeralSessionStore::default();
let filesystem_authority =
WorkerFilesystemAuthority::local(self.workspace_root.clone(), child_cwd.clone());
@@ -385,7 +467,7 @@ impl Tool for SubWorkerSpawnTool {
child_manifest,
store.clone(),
self.prompt_loader.clone(),
self.workspace_context.clone(),
child_workspace_context,
filesystem_authority,
self.internal_client_override
.as_ref()
@@ -465,6 +547,66 @@ impl Tool for SubWorkerSpawnTool {
}
};
if let Some((ticket_id, revision_id, attempt_id, capability_token)) = &reviewer_attempt {
let workspace_id = self.workspace_context.workspace_id().ok_or_else(|| {
ToolError::ExecutionFailed("reviewer attempt lost Workspace identity".to_string())
})?;
let child_session_id = session.session_id_string();
let child_registration = WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!(
"/api/w/{}/internal/reviewer-child-sessions",
workspace_id.as_str()
),
serde_json::json!({"child_session_id": child_session_id}).to_string(),
);
let child_response = self
.workspace_context
.client()
.execute(child_registration)
.map_err(|error| {
ToolError::ExecutionFailed(format!(
"register Runtime-owned Reviewer child session: {error}"
))
})?;
if !child_response.is_success() {
let _ = session.stop().await;
return Err(ToolError::ExecutionFailed(format!(
"register Runtime-owned Reviewer child session failed with status {}: {}",
child_response.status, child_response.body
)));
}
let body = serde_json::json!({
"attempt_id": attempt_id,
"revision_id": revision_id,
"child_session_id": child_session_id,
"capability_token": capability_token,
});
let request = WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!(
"/api/w/{}/tickets/{}/merge-request/review-attempts",
workspace_id.as_str(),
ticket_id
),
body.to_string(),
);
let response = self
.workspace_context
.client()
.execute(request)
.map_err(|error| {
ToolError::ExecutionFailed(format!("register reviewer attempt: {error}"))
})?;
if !response.is_success() {
let _ = session.stop().await;
return Err(ToolError::ExecutionFailed(format!(
"register reviewer attempt failed with status {}: {}",
response.status, response.body
)));
}
}
let record = crate::spawn::registry::InternalSpawnedWorkerRecord::new(
input.name.clone(),
scope_allow,
@@ -899,6 +1041,31 @@ mod tests {
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceResponse,
};
#[test]
fn reviewer_handoff_requires_explicit_builtin_profile_and_read_only_scope() {
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"}
}))
.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"}
}))
.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"}
}))
.unwrap();
assert!(validate_reviewer_handoff(&writable).is_err());
}
fn abs_rule(path: &Path, permission: Permission) -> ScopeRule {
ScopeRule {
target: path.to_path_buf(),