worker: add typed feature services for coder spawn

This commit is contained in:
2026-08-14 00:02:13 +09:00
parent e47eca53a2
commit f9d328f2db
12 changed files with 962 additions and 163 deletions
@@ -12,34 +12,116 @@ use serde::{Deserialize, Serialize};
use protocol::Segment;
use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
ToolDeclaration,
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule,
ServiceDeclaration, ServiceId, ToolContribution, ToolDeclaration,
};
use crate::worker::{
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
WorkspaceResponse,
};
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
const FEATURE_ID: &str = "worker";
const FEATURE_NAME: &str = "Worker";
const FEATURE_DESCRIPTION: &str =
"Workspace-authority tools for managing Workdir-bound Backend/Runtime Worker sessions.";
pub const WORKER_LIFECYCLE_SERVICE_ID: &str = "worker.lifecycle";
const WORKER_LIFECYCLE_SERVICE_VERSION: &str = "1";
#[async_trait]
pub trait WorkerLifecycleService: Send + Sync {
async fn spawn(
&self,
request: WorkerLifecycleSpawnRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError>;
}
#[derive(Debug, Clone)]
pub struct WorkerLifecycleSpawnRequest {
pub runtime_id: String,
pub working_directory_id: String,
pub relative_cwd: Option<String>,
pub profile: String,
pub ticket_id: Option<String>,
pub operation_id: Option<String>,
pub display_name: String,
pub initial_submit: Vec<Segment>,
}
struct WorkspaceWorkerLifecycleService {
client: Arc<dyn WorkspaceClient>,
workspace_id: String,
}
#[async_trait]
impl WorkerLifecycleService for WorkspaceWorkerLifecycleService {
async fn spawn(
&self,
request: WorkerLifecycleSpawnRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
let ticket_assignment = match (request.ticket_id, request.operation_id) {
(Some(ticket_id), Some(operation_id)) => Some(WorkerSpawnTicketAssignmentRequest {
ticket_id,
operation_id,
}),
(None, None) => None,
_ => {
return Err(WorkspaceClientError::Request(
"ticket_id and operation_id must be provided together".to_string(),
));
}
};
let body = WorkerSpawnRequest {
runtime_id: request.runtime_id,
display_name: request.display_name,
profile: request.profile,
ticket_assignment,
initial_submit: request.initial_submit,
working_directory: WorkerWorkingDirectorySelection {
working_directory_id: request.working_directory_id,
relative_cwd: request.relative_cwd,
},
};
self.client.execute(WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("/api/w/{}/workers", self.workspace_id),
serde_json::to_string(&body)
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?,
))
}
}
#[derive(Clone, Debug)]
pub struct ManageWorkerFeature {
client: Arc<dyn WorkspaceClient>,
direct_spawn: bool,
}
pub fn manage_worker_feature(client: Arc<dyn WorkspaceClient>) -> ManageWorkerFeature {
ManageWorkerFeature { client }
pub fn manage_worker_feature(
client: Arc<dyn WorkspaceClient>,
direct_spawn: bool,
) -> ManageWorkerFeature {
ManageWorkerFeature {
client,
direct_spawn,
}
}
impl FeatureModule for ManageWorkerFeature {
fn descriptor(&self) -> FeatureDescriptor {
let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME)
.with_description(FEATURE_DESCRIPTION);
for operation in WorkerOperation::ALL {
descriptor = descriptor.with_tool(ToolDeclaration::new(
operation.tool_name(),
operation.description(),
.with_description(FEATURE_DESCRIPTION)
.with_provided_service(ServiceDeclaration::new(
ServiceId::builtin(WORKER_LIFECYCLE_SERVICE_ID),
WORKER_LIFECYCLE_SERVICE_VERSION,
"Workspace-authoritative Worker lifecycle operations",
));
for operation in WorkerOperation::ALL {
if operation != WorkerOperation::Spawn || self.direct_spawn {
descriptor = descriptor.with_tool(ToolDeclaration::new(
operation.tool_name(),
operation.description(),
));
}
}
descriptor
}
@@ -55,7 +137,23 @@ impl FeatureModule for ManageWorkerFeature {
)
})?
.to_string();
let lifecycle: Arc<dyn WorkerLifecycleService> =
Arc::new(WorkspaceWorkerLifecycleService {
client: self.client.clone(),
workspace_id: workspace_id.clone(),
});
context.services().provide(
ServiceDeclaration::new(
ServiceId::builtin(WORKER_LIFECYCLE_SERVICE_ID),
WORKER_LIFECYCLE_SERVICE_VERSION,
"Workspace-authoritative Worker lifecycle operations",
),
lifecycle,
)?;
for operation in WorkerOperation::ALL {
if operation == WorkerOperation::Spawn && !self.direct_spawn {
continue;
}
let definition = match operation {
WorkerOperation::List => definition::<WorkerListInput>(
operation,
@@ -170,7 +268,7 @@ struct WorkspaceWorkerTool {
workspace_id: String,
}
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WorkerOperation {
List,
Spawn,
@@ -254,27 +352,24 @@ impl Tool for WorkspaceWorkerTool {
}
WorkerOperation::Spawn => {
let input = parse::<WorkerSpawnInput>(input_json, "WorkerSpawn")?;
let ticket_assignment = input
let ticket_id = input
.ticket_id
.map(|ticket_id| authority_id(&ticket_id, "ticket_id"))
.transpose()?;
let operation_id = ticket_id
.as_ref()
.map(|ticket_id| {
let ticket_id = authority_id(&ticket_id, "ticket_id")?;
let call_id = non_empty(ctx.call_id.clone(), "tool call_id")?;
Ok::<_, ToolError>(WorkerSpawnTicketAssignmentRequest {
operation_id: format!("worker-spawn:{ticket_id}:{call_id}"),
ticket_id,
})
Ok::<_, ToolError>(format!("worker-spawn:{ticket_id}:{call_id}"))
})
.transpose()?;
let request = WorkerSpawnRequest {
runtime_id: authority_id(&input.runtime_id, "runtime_id")?,
display_name: input
.display_name
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "Workspace Worker".to_string()),
profile: non_empty(input.profile, "profile")?,
ticket_assignment,
initial_submit: input.initial_submit,
working_directory: WorkerWorkingDirectorySelection {
let lifecycle = WorkspaceWorkerLifecycleService {
client: self.client.clone(),
workspace_id: self.workspace_id.clone(),
};
let response = lifecycle
.spawn(WorkerLifecycleSpawnRequest {
runtime_id: authority_id(&input.runtime_id, "runtime_id")?,
working_directory_id: authority_id(
&input.working_directory_id,
"working_directory_id",
@@ -283,14 +378,18 @@ impl Tool for WorkspaceWorkerTool {
.relative_cwd
.map(|value| validate_relative_cwd(&value))
.transpose()?,
},
};
WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("/api/w/{}/workers", self.workspace_id),
serde_json::to_string(&request)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?,
)
profile: non_empty(input.profile, "profile")?,
ticket_id,
operation_id,
display_name: input
.display_name
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "Workspace Worker".to_string()),
initial_submit: input.initial_submit,
})
.await
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
return tool_output(self.operation, response);
}
WorkerOperation::Stop => {
let input = parse::<WorkerStopInput>(input_json, "WorkerStop")?;
@@ -325,20 +424,27 @@ impl Tool for WorkspaceWorkerTool {
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?
}
};
if !response.is_success() {
return Err(ToolError::ExecutionFailed(format!(
"Workspace Worker operation returned HTTP {}: {}",
response.status, response.body
)));
}
Ok(ToolOutput {
summary: format!("{} completed", self.operation.tool_name()),
content: Some(response.body),
attachments: Vec::new(),
})
tool_output(self.operation, response)
}
}
fn tool_output(
operation: WorkerOperation,
response: WorkspaceResponse,
) -> Result<ToolOutput, ToolError> {
if !response.is_success() {
return Err(ToolError::ExecutionFailed(format!(
"Workspace Worker operation returned HTTP {}: {}",
response.status, response.body
)));
}
Ok(ToolOutput {
summary: format!("{} completed", operation.tool_name()),
content: Some(response.body),
attachments: Vec::new(),
})
}
fn definition<I: JsonSchema + 'static>(
operation: WorkerOperation,
client: Arc<dyn WorkspaceClient>,
@@ -500,6 +606,23 @@ mod tests {
assert!(body.get("initial_text").is_none());
}
#[test]
fn worker_service_can_remain_enabled_without_direct_spawn_surface() {
let client = Arc::new(RecordingWorkspaceClient::default());
let descriptor = manage_worker_feature(client, false).descriptor();
let tools: Vec<_> = descriptor
.tools
.iter()
.map(|tool| tool.name.as_str())
.collect();
assert!(!tools.contains(&"WorkerSpawn"));
assert!(tools.contains(&"WorkerList"));
assert_eq!(
descriptor.provides_services[0].id,
ServiceId::builtin(WORKER_LIFECYCLE_SERVICE_ID)
);
}
#[test]
fn worker_tool_family_is_distinct_from_sub_worker_tools() {
assert_eq!(
@@ -0,0 +1,340 @@
//! Semantic Ticket orchestration tools backed by Feature Services.
use std::sync::Arc;
use async_trait::async_trait;
use llm_engine::tool::{
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput,
};
use protocol::Segment;
use schemars::JsonSchema;
use serde::Deserialize;
use super::manage_worker::{
WORKER_LIFECYCLE_SERVICE_ID, WorkerLifecycleService, WorkerLifecycleSpawnRequest,
};
use super::ticket::{TICKET_SERVICE_ID, TicketService};
use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ServiceId,
ServiceRequirement, ToolContribution, ToolDeclaration,
};
const FEATURE_ID: &str = "orchestration";
const TOOL_NAME: &str = "SpawnTicketCoder";
const CODER_PROFILE: &str = "builtin:coder";
const CODER_FLOW: &str = "builtin:coder-review";
#[derive(Debug, Default)]
pub struct OrchestrationFeature;
pub fn orchestration_feature() -> OrchestrationFeature {
OrchestrationFeature
}
impl FeatureModule for OrchestrationFeature {
fn descriptor(&self) -> FeatureDescriptor {
FeatureDescriptor::builtin(FEATURE_ID, "Orchestration")
.with_description("Semantic Ticket orchestration operations.")
.with_service_requirement(ServiceRequirement::required(
ServiceId::builtin(TICKET_SERVICE_ID),
"SpawnTicketCoder requires current typed Ticket authority",
))
.with_service_requirement(ServiceRequirement::required(
ServiceId::builtin(WORKER_LIFECYCLE_SERVICE_ID),
"SpawnTicketCoder requires Workspace Worker lifecycle authority",
))
.with_tool(ToolDeclaration::new(
TOOL_NAME,
"Spawn and atomically assign a Coder Worker for an inprogress Ticket. The profile, Flow, display name, assignment operation, and initial message are fixed by orchestration policy.",
))
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
let ticket_service = context
.services()
.require::<dyn TicketService>(&ServiceId::builtin(TICKET_SERVICE_ID))?;
let worker_service =
context
.services()
.require::<dyn WorkerLifecycleService>(&ServiceId::builtin(
WORKER_LIFECYCLE_SERVICE_ID,
))?;
context.tools().register(ToolContribution::new(
TOOL_NAME,
definition(ticket_service, worker_service),
))
}
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct SpawnTicketCoderInput {
ticket_id: String,
runtime_id: String,
working_directory_id: String,
#[serde(default)]
relative_cwd: Option<String>,
}
struct SpawnTicketCoderTool {
ticket_service: Arc<dyn TicketService>,
worker_service: Arc<dyn WorkerLifecycleService>,
}
#[async_trait]
impl Tool for SpawnTicketCoderTool {
async fn execute(
&self,
input_json: &str,
ctx: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let input: SpawnTicketCoderInput = serde_json::from_str(input_json).map_err(|error| {
ToolError::InvalidArgument(format!("invalid {TOOL_NAME} input: {error}"))
})?;
let ticket_id = authority_id(input.ticket_id, "ticket_id")?;
let workflow_state = self
.ticket_service
.workflow_state(&ticket_id)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if workflow_state != ticket::TicketWorkflowState::InProgress {
return Err(ToolError::ExecutionFailed(format!(
"Ticket {ticket_id} must be inprogress before spawning its Coder; current state is {}",
workflow_state.as_str()
)));
}
let call_id = non_empty(ctx.call_id, "tool call_id")?;
let relative_cwd = input.relative_cwd.map(validate_relative_cwd).transpose()?;
let response = self
.worker_service
.spawn(WorkerLifecycleSpawnRequest {
runtime_id: authority_id(input.runtime_id, "runtime_id")?,
working_directory_id: authority_id(
input.working_directory_id,
"working_directory_id",
)?,
relative_cwd,
profile: CODER_PROFILE.to_string(),
ticket_id: Some(ticket_id.clone()),
operation_id: Some(format!("spawn-ticket-coder:{ticket_id}:{call_id}")),
display_name: format!("Coder · {ticket_id}"),
initial_submit: vec![
Segment::Flow {
selector: CODER_FLOW.to_string(),
},
Segment::text(format!("Implement Ticket {ticket_id}.")),
],
})
.await
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if !response.is_success() {
return Err(ToolError::ExecutionFailed(format!(
"Workspace Worker operation returned HTTP {}: {}",
response.status, response.body
)));
}
Ok(ToolOutput {
summary: format!("Spawned Coder for Ticket {ticket_id}"),
content: Some(response.body),
attachments: Vec::new(),
})
}
}
fn definition(
ticket_service: Arc<dyn TicketService>,
worker_service: Arc<dyn WorkerLifecycleService>,
) -> ToolDefinition {
Arc::new(move || {
let schema = serde_json::to_value(schemars::schema_for!(SpawnTicketCoderInput))
.unwrap_or_else(|_| serde_json::json!({}));
let meta = ToolMeta::new(TOOL_NAME)
.description("Spawn and atomically assign a policy-configured Coder for a Ticket.")
.input_schema(schema);
let tool: Arc<dyn Tool> = Arc::new(SpawnTicketCoderTool {
ticket_service: ticket_service.clone(),
worker_service: worker_service.clone(),
});
(meta, tool)
})
}
fn authority_id(value: String, field: &str) -> Result<String, ToolError> {
let value = non_empty(value, field)?;
if value.contains('/') || value.contains('?') || value.contains('#') {
return Err(ToolError::InvalidArgument(format!(
"{field} must be an authority id, not a path or URL"
)));
}
Ok(value)
}
fn non_empty(value: String, field: &str) -> Result<String, ToolError> {
let value = value.trim().to_string();
if value.is_empty() {
return Err(ToolError::InvalidArgument(format!(
"{field} must not be empty"
)));
}
Ok(value)
}
fn validate_relative_cwd(value: String) -> Result<String, ToolError> {
let value = value.trim();
if value.is_empty()
|| value.starts_with('/')
|| value.split('/').any(|part| matches!(part, "" | "." | ".."))
{
return Err(ToolError::InvalidArgument(
"relative_cwd must be a normalized relative path inside the Workdir".to_string(),
));
}
Ok(value.to_string())
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use ticket::{TicketError, TicketWorkflowState};
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
use super::*;
#[derive(Default)]
struct RecordingTicketService;
impl TicketService for RecordingTicketService {
fn workflow_state(&self, _ticket_id: &str) -> Result<TicketWorkflowState, TicketError> {
Ok(TicketWorkflowState::InProgress)
}
}
struct FixedTicketService(TicketWorkflowState);
impl TicketService for FixedTicketService {
fn workflow_state(&self, _ticket_id: &str) -> Result<TicketWorkflowState, TicketError> {
Ok(self.0)
}
}
#[derive(Default)]
struct RecordingService {
requests: Mutex<Vec<WorkerLifecycleSpawnRequest>>,
}
#[async_trait]
impl WorkerLifecycleService for RecordingService {
async fn spawn(
&self,
request: WorkerLifecycleSpawnRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.requests.lock().unwrap().push(request);
Ok(WorkspaceResponse {
status: 200,
body: r#"{"worker_id":"42"}"#.to_string(),
})
}
}
#[tokio::test]
async fn spawn_ticket_coder_fixes_profile_flow_assignment_and_message() {
let service = Arc::new(RecordingService::default());
let tool = SpawnTicketCoderTool {
ticket_service: Arc::new(RecordingTicketService),
worker_service: service.clone(),
};
tool.execute(
&serde_json::json!({
"ticket_id": "00001KZXN51C7",
"runtime_id": "runtime-1",
"working_directory_id": "workdir-1"
})
.to_string(),
ToolExecutionContext::new("call-7", "batch-1", 0),
)
.await
.unwrap();
let requests = service.requests.lock().unwrap();
let request = &requests[0];
assert_eq!(request.profile, CODER_PROFILE);
assert_eq!(request.ticket_id.as_deref(), Some("00001KZXN51C7"));
assert_eq!(
request.operation_id.as_deref(),
Some("spawn-ticket-coder:00001KZXN51C7:call-7")
);
assert_eq!(request.display_name, "Coder · 00001KZXN51C7");
assert_eq!(
request.initial_submit,
vec![
Segment::Flow {
selector: CODER_FLOW.to_string()
},
Segment::text("Implement Ticket 00001KZXN51C7.")
]
);
}
#[tokio::test]
async fn spawn_ticket_coder_rejects_ticket_before_worker_side_effect() {
let worker_service = Arc::new(RecordingService::default());
let tool = SpawnTicketCoderTool {
ticket_service: Arc::new(FixedTicketService(TicketWorkflowState::Queued)),
worker_service: worker_service.clone(),
};
let error = tool
.execute(
&serde_json::json!({
"ticket_id": "00001KZXN51C7",
"runtime_id": "runtime-1",
"working_directory_id": "workdir-1"
})
.to_string(),
ToolExecutionContext::new("call-queued", "batch-1", 0),
)
.await
.unwrap_err();
assert!(error.to_string().contains("must be inprogress"));
assert!(worker_service.requests.lock().unwrap().is_empty());
}
#[test]
fn orchestration_descriptor_requires_ticket_and_worker_services() {
let descriptor = orchestration_feature().descriptor();
let required: Vec<_> = descriptor
.requires_services
.iter()
.map(|requirement| requirement.id.clone())
.collect();
assert_eq!(
required,
vec![
ServiceId::builtin(TICKET_SERVICE_ID),
ServiceId::builtin(WORKER_LIFECYCLE_SERVICE_ID),
]
);
}
#[test]
fn input_surface_does_not_expose_profile_flow_or_assignment_controls() {
let schema = serde_json::to_string(&schemars::schema_for!(SpawnTicketCoderInput)).unwrap();
for field in [
"ticket_id",
"runtime_id",
"working_directory_id",
"relative_cwd",
] {
assert!(schema.contains(field));
}
for forbidden in [
"profile",
"selector",
"operation_id",
"display_name",
"initial_submit",
] {
assert!(!schema.contains(forbidden), "schema leaked {forbidden}");
}
}
}
+55 -29
View File
@@ -15,7 +15,7 @@ use ticket::{
Ticket, TicketBackend, TicketBackendOperation, TicketBackendOperationResult,
TicketDoctorReport, TicketError, TicketIdOrSlug, TicketIntakeSummary, TicketListQuery,
TicketRef, TicketRelation, TicketRelationKind, TicketRelationView, TicketStateChange,
TicketSummary,
TicketSummary, TicketWorkflowState,
config::{DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TicketConfig},
tool::{TICKET_TOOL_NAMES, TicketToolBackend, ticket_tool_description, ticket_tools},
};
@@ -24,7 +24,7 @@ use super::merge_request;
use crate::feature::{
FeatureDescriptor, FeatureDiagnostic, FeatureInstallContext, FeatureInstallError,
FeatureInstructionContribution, FeatureInstructionDeclaration, FeatureInstructionId,
FeatureModule, ToolContribution, ToolDeclaration,
FeatureModule, ServiceDeclaration, ServiceId, ToolContribution, ToolDeclaration,
};
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
@@ -34,6 +34,24 @@ const FEATURE_DESCRIPTION: &str = "Typed local Ticket work-item operations over
The tools operate through the ticket crate backend and do not grant generic filesystem write scope.";
const TICKET_WORKFLOW_INSTRUCTION_ID: &str = "ticket.workflow";
const TICKET_WORKFLOW_PROMPT_REF: &str = "$yoi/common/tickets";
pub const TICKET_SERVICE_ID: &str = "ticket.authority";
const TICKET_SERVICE_VERSION: &str = "1";
pub trait TicketService: Send + Sync {
fn workflow_state(&self, ticket_id: &str) -> Result<TicketWorkflowState, TicketError>;
}
struct BackendTicketService {
backend: TicketToolBackend,
}
impl TicketService for BackendTicketService {
fn workflow_state(&self, ticket_id: &str) -> Result<TicketWorkflowState, TicketError> {
self.backend
.show(ticket_id.into())
.map(|ticket| ticket.meta.workflow_state)
}
}
fn ticket_workflow_instruction() -> FeatureInstructionDeclaration {
FeatureInstructionDeclaration::new(
@@ -49,7 +67,7 @@ pub struct TicketFeatureAccess {
pub authoring: bool,
pub thread: bool,
pub intake: bool,
pub orchestration_control: bool,
pub workflow: bool,
}
impl TicketFeatureAccess {
@@ -58,7 +76,7 @@ impl TicketFeatureAccess {
authoring: false,
thread: false,
intake: false,
orchestration_control: false,
workflow: false,
}
}
@@ -67,7 +85,7 @@ impl TicketFeatureAccess {
authoring: true,
thread: true,
intake: false,
orchestration_control: false,
workflow: false,
}
}
@@ -76,16 +94,16 @@ impl TicketFeatureAccess {
authoring: true,
thread: true,
intake: true,
orchestration_control: false,
workflow: false,
}
}
pub const fn orchestration_control() -> Self {
pub const fn workflow() -> Self {
Self {
authoring: false,
thread: true,
intake: false,
orchestration_control: true,
workflow: true,
}
}
@@ -94,7 +112,7 @@ impl TicketFeatureAccess {
authoring: false,
thread: true,
intake: false,
orchestration_control: false,
workflow: false,
}
}
@@ -103,7 +121,7 @@ impl TicketFeatureAccess {
authoring: false,
thread: false,
intake: false,
orchestration_control: false,
workflow: false,
}
}
@@ -120,8 +138,7 @@ impl TicketFeatureAccess {
|| (self.authoring && AUTHORING_TOOL_NAMES.contains(&name))
|| (self.thread && THREAD_TOOL_NAMES.contains(&name))
|| (self.intake && INTAKE_TOOL_NAMES.contains(&name))
|| (self.orchestration_control
&& ORCHESTRATION_CONTROL_ADDITIONAL_TOOL_NAMES.contains(&name))
|| (self.workflow && WORKFLOW_ADDITIONAL_TOOL_NAMES.contains(&name))
}
}
@@ -163,7 +180,7 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
];
#[cfg(test)]
const ORCHESTRATION_CONTROL_TOOL_NAMES: &[&str] = &[
const WORKFLOW_TOOL_NAMES: &[&str] = &[
"TicketList",
"TicketShow",
"TicketComment",
@@ -177,7 +194,7 @@ const ORCHESTRATION_CONTROL_TOOL_NAMES: &[&str] = &[
"TicketOrchestrationPlanQuery",
];
const ORCHESTRATION_CONTROL_ADDITIONAL_TOOL_NAMES: &[&str] = &[
const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[
"TicketWorkflowState",
"TicketClose",
"TicketRelationRecord",
@@ -331,7 +348,12 @@ impl FeatureModule for TicketFeature {
fn descriptor(&self) -> FeatureDescriptor {
let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME)
.with_description(FEATURE_DESCRIPTION)
.with_instruction(ticket_workflow_instruction());
.with_instruction(ticket_workflow_instruction())
.with_provided_service(ServiceDeclaration::new(
ServiceId::builtin(TICKET_SERVICE_ID),
TICKET_SERVICE_VERSION,
"Current typed Ticket authority",
));
let enabled_tool_names = self.enabled_tool_names();
for name in enabled_tool_names {
descriptor = descriptor.with_tool(ToolDeclaration::new(
@@ -370,6 +392,17 @@ impl FeatureModule for TicketFeature {
let Some(backend) = self.tool_backend(context) else {
return Ok(());
};
let ticket_service: Arc<dyn TicketService> = Arc::new(BackendTicketService {
backend: backend.clone(),
});
context.services().provide(
ServiceDeclaration::new(
ServiceId::builtin(TICKET_SERVICE_ID),
TICKET_SERVICE_VERSION,
"Current typed Ticket authority",
),
ticket_service,
)?;
context
.instructions()
.register(FeatureInstructionContribution::new(
@@ -1018,24 +1051,19 @@ mod tests {
}
#[test]
fn orchestration_control_descriptor_declares_orchestration_tools() {
fn workflow_descriptor_declares_workflow_tools() {
let temp = TempDir::new().unwrap();
let feature = ticket_tools_feature_with_access(
temp.path(),
TicketFeatureAccess::orchestration_control(),
);
let feature =
ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::workflow());
let descriptor = feature.descriptor();
assert_eq!(
feature.access(),
TicketFeatureAccess::orchestration_control()
);
assert_eq!(feature.access(), TicketFeatureAccess::workflow());
assert_eq!(
descriptor
.tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>(),
ORCHESTRATION_CONTROL_TOOL_NAMES
WORKFLOW_TOOL_NAMES
);
}
@@ -1058,10 +1086,8 @@ mod tests {
assert!(workspace_tools.contains(&"TicketQueue"));
assert!(!workspace_tools.contains(&"TicketWorkflowState"));
let orchestration = ticket_tools_feature_with_access(
temp.path(),
TicketFeatureAccess::orchestration_control(),
);
let orchestration =
ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::workflow());
let orchestration_descriptor = orchestration.descriptor();
let orchestration_tools = orchestration_descriptor
.tools