feat: add merge request review authority
This commit is contained in:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user