worker: add typed feature services for coder spawn
This commit is contained in:
@@ -20,7 +20,7 @@ use crate::{
|
||||
CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
|
||||
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConfig, MemoryFeatureConfig, ScopeConfig,
|
||||
SessionConfig, SkillsConfig, TicketFeatureConfig, ToolOutputLimits, ToolPermissionConfig,
|
||||
ToolPermissionRule, WebConfig, WorkerManifest, WorkerMeta,
|
||||
ToolPermissionRule, WebConfig, WorkerFeatureConfig, WorkerManifest, WorkerMeta,
|
||||
};
|
||||
|
||||
/// Partial-form Worker manifest. Every field is optional; one or more
|
||||
@@ -89,7 +89,7 @@ pub struct FeatureConfigPartial {
|
||||
#[serde(default)]
|
||||
pub flow: Option<FeatureFlagConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub worker: Option<FeatureFlagConfigPartial>,
|
||||
pub worker: Option<WorkerFeatureConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub objective: Option<FeatureFlagConfigPartial>,
|
||||
#[serde(default)]
|
||||
@@ -97,6 +97,8 @@ pub struct FeatureConfigPartial {
|
||||
#[serde(default)]
|
||||
pub ticket: Option<TicketFeatureConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub orchestration: Option<FeatureFlagConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub plugins: Option<FeatureFlagConfigPartial>,
|
||||
}
|
||||
|
||||
@@ -113,7 +115,7 @@ impl FeatureConfigPartial {
|
||||
FeatureFlagConfigPartial::merge,
|
||||
),
|
||||
flow: merge_option(self.flow, other.flow, FeatureFlagConfigPartial::merge),
|
||||
worker: merge_option(self.worker, other.worker, FeatureFlagConfigPartial::merge),
|
||||
worker: merge_option(self.worker, other.worker, WorkerFeatureConfigPartial::merge),
|
||||
objective: merge_option(
|
||||
self.objective,
|
||||
other.objective,
|
||||
@@ -125,6 +127,11 @@ impl FeatureConfigPartial {
|
||||
FeatureFlagConfigPartial::merge,
|
||||
),
|
||||
ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge),
|
||||
orchestration: merge_option(
|
||||
self.orchestration,
|
||||
other.orchestration,
|
||||
FeatureFlagConfigPartial::merge,
|
||||
),
|
||||
plugins: merge_option(self.plugins, other.plugins, FeatureFlagConfigPartial::merge),
|
||||
}
|
||||
}
|
||||
@@ -144,6 +151,32 @@ impl FeatureFlagConfigPartial {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct WorkerFeatureConfigPartial {
|
||||
#[serde(default)]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub direct_spawn: Option<bool>,
|
||||
}
|
||||
|
||||
impl WorkerFeatureConfigPartial {
|
||||
fn merge(self, other: Self) -> Self {
|
||||
Self {
|
||||
enabled: other.enabled.or(self.enabled),
|
||||
direct_spawn: other.direct_spawn.or(self.direct_spawn),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WorkerFeatureConfigPartial> for WorkerFeatureConfig {
|
||||
fn from(value: WorkerFeatureConfigPartial) -> Self {
|
||||
Self {
|
||||
enabled: value.enabled.unwrap_or(false),
|
||||
direct_spawn: value.direct_spawn.unwrap_or(true),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct MemoryFeatureConfigPartial {
|
||||
#[serde(default)]
|
||||
@@ -168,7 +201,7 @@ pub struct TicketFeatureConfigPartial {
|
||||
pub authoring: Option<bool>,
|
||||
pub thread: Option<bool>,
|
||||
pub intake: Option<bool>,
|
||||
pub orchestration_control: Option<bool>,
|
||||
pub workflow: Option<bool>,
|
||||
}
|
||||
|
||||
impl TicketFeatureConfigPartial {
|
||||
@@ -178,7 +211,7 @@ impl TicketFeatureConfigPartial {
|
||||
authoring: other.authoring.or(self.authoring),
|
||||
thread: other.thread.or(self.thread),
|
||||
intake: other.intake.or(self.intake),
|
||||
orchestration_control: other.orchestration_control.or(self.orchestration_control),
|
||||
workflow: other.workflow.or(self.workflow),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,7 +233,7 @@ impl From<FeatureConfigPartial> for FeatureConfig {
|
||||
flow: value.flow.map(FeatureFlagConfig::from).unwrap_or_default(),
|
||||
worker: value
|
||||
.worker
|
||||
.map(FeatureFlagConfig::from)
|
||||
.map(WorkerFeatureConfig::from)
|
||||
.unwrap_or_default(),
|
||||
objective: value
|
||||
.objective
|
||||
@@ -214,6 +247,10 @@ impl From<FeatureConfigPartial> for FeatureConfig {
|
||||
.ticket
|
||||
.map(TicketFeatureConfig::from)
|
||||
.unwrap_or_default(),
|
||||
orchestration: value
|
||||
.orchestration
|
||||
.map(FeatureFlagConfig::from)
|
||||
.unwrap_or_default(),
|
||||
plugins: value
|
||||
.plugins
|
||||
.map(FeatureFlagConfig::from)
|
||||
@@ -238,6 +275,15 @@ impl From<FeatureFlagConfig> for FeatureFlagConfigPartial {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WorkerFeatureConfig> for WorkerFeatureConfigPartial {
|
||||
fn from(value: WorkerFeatureConfig) -> Self {
|
||||
Self {
|
||||
enabled: Some(value.enabled),
|
||||
direct_spawn: Some(value.direct_spawn),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MemoryFeatureConfigPartial> for MemoryFeatureConfig {
|
||||
fn from(value: MemoryFeatureConfigPartial) -> Self {
|
||||
Self {
|
||||
@@ -263,7 +309,7 @@ impl From<TicketFeatureConfigPartial> for TicketFeatureConfig {
|
||||
authoring: value.authoring.unwrap_or_default(),
|
||||
thread: value.thread.unwrap_or_default(),
|
||||
intake: value.intake.unwrap_or_default(),
|
||||
orchestration_control: value.orchestration_control.unwrap_or_default(),
|
||||
workflow: value.workflow.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -275,7 +321,7 @@ impl From<TicketFeatureConfig> for TicketFeatureConfigPartial {
|
||||
authoring: Some(value.authoring),
|
||||
thread: Some(value.thread),
|
||||
intake: Some(value.intake),
|
||||
orchestration_control: Some(value.orchestration_control),
|
||||
workflow: Some(value.workflow),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -293,6 +339,7 @@ impl From<FeatureConfig> for FeatureConfigPartial {
|
||||
objective: Some(value.objective.into()),
|
||||
manage_workdir: Some(value.manage_workdir.into()),
|
||||
ticket: Some(value.ticket.into()),
|
||||
orchestration: Some(value.orchestration.into()),
|
||||
plugins: Some(value.plugins.into()),
|
||||
}
|
||||
}
|
||||
@@ -1866,7 +1913,10 @@ enabled = true
|
||||
authoring = false
|
||||
thread = false
|
||||
intake = false
|
||||
orchestration_control = false
|
||||
workflow = false
|
||||
|
||||
[feature.orchestration]
|
||||
enabled = false
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -1900,7 +1950,8 @@ orchestration_control = false
|
||||
assert!(!manifest.feature.ticket.authoring);
|
||||
assert!(!manifest.feature.ticket.thread);
|
||||
assert!(!manifest.feature.ticket.intake);
|
||||
assert!(!manifest.feature.ticket.orchestration_control);
|
||||
assert!(!manifest.feature.ticket.workflow);
|
||||
assert!(!manifest.feature.orchestration.enabled);
|
||||
assert!(!manifest.feature.memory.enabled);
|
||||
assert!(!manifest.feature.memory.staging);
|
||||
assert!(!manifest.feature.objective.enabled);
|
||||
@@ -1921,7 +1972,10 @@ enabled = true
|
||||
authoring = false
|
||||
thread = false
|
||||
intake = false
|
||||
orchestration_control = false
|
||||
workflow = false
|
||||
|
||||
[feature.orchestration]
|
||||
enabled = false
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -1929,7 +1983,10 @@ orchestration_control = false
|
||||
r#"
|
||||
[feature.ticket]
|
||||
thread = true
|
||||
orchestration_control = true
|
||||
workflow = true
|
||||
|
||||
[feature.orchestration]
|
||||
enabled = true
|
||||
|
||||
[feature.memory]
|
||||
staging = true
|
||||
@@ -1977,7 +2034,8 @@ enabled = true
|
||||
assert!(!manifest.feature.ticket.authoring);
|
||||
assert!(manifest.feature.ticket.thread);
|
||||
assert!(!manifest.feature.ticket.intake);
|
||||
assert!(manifest.feature.ticket.orchestration_control);
|
||||
assert!(manifest.feature.ticket.workflow);
|
||||
assert!(manifest.feature.orchestration.enabled);
|
||||
assert!(manifest.feature.objective.enabled);
|
||||
assert!(manifest.feature.web.enabled);
|
||||
assert!(!manifest.feature.sub_worker.enabled);
|
||||
|
||||
@@ -117,7 +117,7 @@ pub struct FeatureConfig {
|
||||
#[serde(default)]
|
||||
pub flow: FeatureFlagConfig,
|
||||
#[serde(default)]
|
||||
pub worker: FeatureFlagConfig,
|
||||
pub worker: WorkerFeatureConfig,
|
||||
#[serde(default)]
|
||||
pub objective: FeatureFlagConfig,
|
||||
#[serde(default)]
|
||||
@@ -125,6 +125,8 @@ pub struct FeatureConfig {
|
||||
#[serde(default)]
|
||||
pub ticket: TicketFeatureConfig,
|
||||
#[serde(default)]
|
||||
pub orchestration: FeatureFlagConfig,
|
||||
#[serde(default)]
|
||||
pub plugins: FeatureFlagConfig,
|
||||
}
|
||||
|
||||
@@ -137,10 +139,11 @@ impl Default for FeatureConfig {
|
||||
image: FeatureFlagConfig::disabled(),
|
||||
sub_worker: FeatureFlagConfig::disabled(),
|
||||
flow: FeatureFlagConfig::disabled(),
|
||||
worker: FeatureFlagConfig::disabled(),
|
||||
worker: WorkerFeatureConfig::disabled(),
|
||||
objective: FeatureFlagConfig::disabled(),
|
||||
manage_workdir: FeatureFlagConfig::disabled(),
|
||||
ticket: TicketFeatureConfig::default(),
|
||||
orchestration: FeatureFlagConfig::disabled(),
|
||||
plugins: FeatureFlagConfig::disabled(),
|
||||
}
|
||||
}
|
||||
@@ -168,6 +171,42 @@ impl Default for FeatureFlagConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WorkerFeatureConfig {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// Exposes the generic WorkerSpawn tool. Stateful lifecycle services remain
|
||||
/// available to dependent semantic features when this is false.
|
||||
#[serde(default = "default_true")]
|
||||
pub direct_spawn: bool,
|
||||
}
|
||||
|
||||
impl WorkerFeatureConfig {
|
||||
pub const fn disabled() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
direct_spawn: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn enabled() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
direct_spawn: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WorkerFeatureConfig {
|
||||
fn default() -> Self {
|
||||
Self::disabled()
|
||||
}
|
||||
}
|
||||
|
||||
const fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct MemoryFeatureConfig {
|
||||
#[serde(default)]
|
||||
@@ -210,7 +249,7 @@ pub struct TicketFeatureConfig {
|
||||
#[serde(default)]
|
||||
pub intake: bool,
|
||||
#[serde(default)]
|
||||
pub orchestration_control: bool,
|
||||
pub workflow: bool,
|
||||
}
|
||||
|
||||
/// External Agent Skills (`SKILL.md`) ingest configuration. Skills are
|
||||
|
||||
@@ -1002,16 +1002,19 @@ fn apply_role_profile(
|
||||
value["feature"]["image"] = serde_json::json!({ "enabled": true });
|
||||
value["feature"]["sub_worker"] = serde_json::json!({ "enabled": sub_worker });
|
||||
value["feature"]["flow"] = serde_json::json!({ "enabled": slug == "coder" });
|
||||
value["feature"]["worker"] =
|
||||
serde_json::json!({ "enabled": matches!(slug, "companion" | "orchestrator") });
|
||||
value["feature"]["worker"] = serde_json::json!({
|
||||
"enabled": matches!(slug, "companion" | "orchestrator"),
|
||||
"direct_spawn": slug != "orchestrator"
|
||||
});
|
||||
value["feature"]["manage_workdir"] = serde_json::json!({ "enabled": slug == "orchestrator" });
|
||||
value["feature"]["orchestration"] = serde_json::json!({ "enabled": slug == "orchestrator" });
|
||||
let ticket = match slug {
|
||||
"companion" => serde_json::json!({ "enabled": true, "authoring": true, "thread": true }),
|
||||
"intake" => {
|
||||
serde_json::json!({ "enabled": true, "authoring": true, "thread": true, "intake": true })
|
||||
}
|
||||
"orchestrator" => {
|
||||
serde_json::json!({ "enabled": true, "thread": true, "orchestration_control": true })
|
||||
serde_json::json!({ "enabled": true, "thread": true, "workflow": true })
|
||||
}
|
||||
"coder" => serde_json::json!({ "enabled": true, "thread": true }),
|
||||
"reviewer" => serde_json::json!({ "enabled": true, "thread": true }),
|
||||
@@ -1474,7 +1477,7 @@ mod tests {
|
||||
assert!(companion.feature.ticket.thread);
|
||||
assert!(companion.feature.objective.enabled);
|
||||
assert!(!companion.feature.ticket.intake);
|
||||
assert!(!companion.feature.ticket.orchestration_control);
|
||||
assert!(!companion.feature.orchestration.enabled);
|
||||
assert_eq!(
|
||||
companion.compaction.as_ref().unwrap().threshold,
|
||||
Some(240000)
|
||||
@@ -1503,7 +1506,7 @@ mod tests {
|
||||
assert!(intake.feature.objective.enabled);
|
||||
assert!(!intake.feature.manage_workdir.enabled);
|
||||
assert!(intake.feature.ticket.intake);
|
||||
assert!(!intake.feature.ticket.orchestration_control);
|
||||
assert!(!intake.feature.orchestration.enabled);
|
||||
assert!(intake.scope.allow.is_empty());
|
||||
assert!(intake.delegation_scope.allow.is_empty());
|
||||
assert_eq!(intake.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
|
||||
@@ -1514,6 +1517,7 @@ mod tests {
|
||||
assert!(orchestrator.feature.task.enabled);
|
||||
assert!(!orchestrator.feature.sub_worker.enabled);
|
||||
assert!(orchestrator.feature.worker.enabled);
|
||||
assert!(!orchestrator.feature.worker.direct_spawn);
|
||||
assert!(orchestrator.feature.ticket.enabled);
|
||||
assert!(orchestrator.feature.ticket.enabled);
|
||||
assert!(!orchestrator.feature.ticket.authoring);
|
||||
@@ -1521,7 +1525,8 @@ mod tests {
|
||||
assert!(orchestrator.feature.objective.enabled);
|
||||
assert!(orchestrator.feature.manage_workdir.enabled);
|
||||
assert!(!orchestrator.feature.ticket.intake);
|
||||
assert!(orchestrator.feature.ticket.orchestration_control);
|
||||
assert!(orchestrator.feature.ticket.workflow);
|
||||
assert!(orchestrator.feature.orchestration.enabled);
|
||||
assert!(orchestrator.scope.allow.is_empty());
|
||||
assert!(orchestrator.delegation_scope.allow.is_empty());
|
||||
assert_eq!(
|
||||
@@ -1548,7 +1553,7 @@ mod tests {
|
||||
assert!(coder.feature.objective.enabled);
|
||||
assert!(!coder.feature.manage_workdir.enabled);
|
||||
assert!(!coder.feature.ticket.intake);
|
||||
assert!(!coder.feature.ticket.orchestration_control);
|
||||
assert!(!coder.feature.orchestration.enabled);
|
||||
let reviewer = resolve("reviewer");
|
||||
assert!(reviewer.feature.task.enabled);
|
||||
assert!(!reviewer.feature.sub_worker.enabled);
|
||||
@@ -1561,7 +1566,7 @@ mod tests {
|
||||
assert!(reviewer.feature.objective.enabled);
|
||||
assert!(!reviewer.feature.manage_workdir.enabled);
|
||||
assert!(!reviewer.feature.ticket.intake);
|
||||
assert!(!reviewer.feature.ticket.orchestration_control);
|
||||
assert!(!reviewer.feature.orchestration.enabled);
|
||||
assert!(reviewer.scope.allow.is_empty());
|
||||
assert!(reviewer.delegation_scope.allow.is_empty());
|
||||
assert_eq!(reviewer.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
|
||||
@@ -1574,7 +1579,7 @@ mod tests {
|
||||
let prompt = include_str!("../../../resources/prompts/role/orchestrator.md");
|
||||
|
||||
assert!(prompt.contains("assigned Coder owns its review/fix loop"));
|
||||
assert!(prompt.contains("spawn the implementation Coder with `WorkerSpawn.ticket_id`"));
|
||||
assert!(prompt.contains("then use `SpawnTicketCoder`"));
|
||||
assert!(prompt.contains("verify its current assignment names that Coder"));
|
||||
assert!(prompt.contains("never route implementation to an unassigned Coder"));
|
||||
assert!(prompt.contains(
|
||||
@@ -1728,7 +1733,8 @@ enabled = true
|
||||
authoring = false
|
||||
thread = false
|
||||
intake = false
|
||||
orchestration_control = false
|
||||
[feature.orchestration]
|
||||
enabled = false
|
||||
"#,
|
||||
);
|
||||
let workspace = tmp.path().join("workspace");
|
||||
@@ -1749,7 +1755,7 @@ orchestration_control = false
|
||||
assert!(!resolved.manifest.feature.ticket.authoring);
|
||||
assert!(!resolved.manifest.feature.ticket.thread);
|
||||
assert!(!resolved.manifest.feature.ticket.intake);
|
||||
assert!(!resolved.manifest.feature.ticket.orchestration_control);
|
||||
assert!(!resolved.manifest.feature.orchestration.enabled);
|
||||
assert_eq!(
|
||||
resolved.manifest.delegation_scope.allow[0].target,
|
||||
workspace
|
||||
@@ -1836,7 +1842,7 @@ worker_context_max_tokens = 68000
|
||||
assert!(resolved.manifest.feature.ticket.authoring);
|
||||
assert!(resolved.manifest.feature.ticket.thread);
|
||||
assert!(!resolved.manifest.feature.ticket.intake);
|
||||
assert!(!resolved.manifest.feature.ticket.orchestration_control);
|
||||
assert!(!resolved.manifest.feature.orchestration.enabled);
|
||||
assert_eq!(
|
||||
resolved.profile.as_ref().unwrap().name.as_deref(),
|
||||
Some("companion")
|
||||
|
||||
@@ -748,7 +748,7 @@ where
|
||||
authoring: feature_config.ticket.authoring,
|
||||
thread: feature_config.ticket.thread,
|
||||
intake: feature_config.ticket.intake,
|
||||
orchestration_control: feature_config.ticket.orchestration_control,
|
||||
workflow: feature_config.ticket.workflow,
|
||||
};
|
||||
// Ticket tools are typed operations over the current workspace Ticket backend.
|
||||
// Workspace access must be authority-bound to the Backend Workspace API; the
|
||||
@@ -800,9 +800,16 @@ where
|
||||
));
|
||||
}
|
||||
feature_registry.add_module(
|
||||
crate::feature::builtin::manage_worker::manage_worker_feature(workspace_client),
|
||||
crate::feature::builtin::manage_worker::manage_worker_feature(
|
||||
workspace_client,
|
||||
feature_config.worker.direct_spawn,
|
||||
),
|
||||
);
|
||||
}
|
||||
if feature_config.orchestration.enabled {
|
||||
feature_registry
|
||||
.add_module(crate::feature::builtin::orchestration::orchestration_feature());
|
||||
}
|
||||
for module in crate::feature::plugin::plugin_tool_features_if_enabled(
|
||||
feature_config.plugins.enabled,
|
||||
&worker.manifest().plugins,
|
||||
@@ -937,7 +944,16 @@ where
|
||||
);
|
||||
}
|
||||
}
|
||||
let _feature_install_report = worker.install_features(feature_registry);
|
||||
let feature_install_report = worker.install_features(feature_registry);
|
||||
if feature_install_report.has_errors() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"Worker feature installation failed: {}",
|
||||
feature_install_report.error_message()
|
||||
),
|
||||
));
|
||||
}
|
||||
if let Some(tracker) = tracker {
|
||||
worker.attach_tracker(tracker);
|
||||
}
|
||||
|
||||
+235
-49
@@ -11,6 +11,7 @@
|
||||
//! [`crate::hook::HookRegistryBuilder`], and provider output is represented as
|
||||
//! ordinary feature reports/diagnostics instead of a separate authority layer.
|
||||
|
||||
use std::any::{Any, type_name};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
@@ -497,8 +498,8 @@ impl ServiceRequirement {
|
||||
}
|
||||
}
|
||||
|
||||
/// Contribution service registry skeleton used during feature installation.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
/// Typed concrete services installed by stateful Feature modules.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct FeatureServiceRegistry {
|
||||
providers: HashMap<ServiceId, FeatureServiceProvider>,
|
||||
}
|
||||
@@ -512,11 +513,32 @@ impl FeatureServiceRegistry {
|
||||
self.providers.contains_key(id)
|
||||
}
|
||||
|
||||
fn register_provider(
|
||||
pub fn service<T>(&self, id: &ServiceId) -> Result<Arc<T>, FeatureInstallError>
|
||||
where
|
||||
T: ?Sized + Send + Sync + 'static,
|
||||
{
|
||||
let provider = self.providers.get(id).ok_or_else(|| {
|
||||
FeatureInstallError::InvalidDescriptor(format!(
|
||||
"feature service provider is unavailable: {id}"
|
||||
))
|
||||
})?;
|
||||
provider.service::<T>().ok_or_else(|| {
|
||||
FeatureInstallError::InvalidDescriptor(format!(
|
||||
"feature service {id} has incompatible concrete type; requested {}",
|
||||
type_name::<T>()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn register_provider<T>(
|
||||
&mut self,
|
||||
feature_id: FeatureId,
|
||||
declaration: ServiceDeclaration,
|
||||
) -> Result<(), FeatureInstallError> {
|
||||
instance: Arc<T>,
|
||||
) -> Result<(), FeatureInstallError>
|
||||
where
|
||||
T: ?Sized + Send + Sync + 'static,
|
||||
{
|
||||
if let Some(existing) = self.providers.get(&declaration.id) {
|
||||
return Err(FeatureInstallError::DuplicateService {
|
||||
service: declaration.id.to_string(),
|
||||
@@ -529,19 +551,38 @@ impl FeatureServiceRegistry {
|
||||
FeatureServiceProvider {
|
||||
feature_id,
|
||||
declaration,
|
||||
instance: Arc::new(instance),
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider metadata for one service declaration.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
/// Provider metadata and concrete instance for one service declaration.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FeatureServiceProvider {
|
||||
pub feature_id: FeatureId,
|
||||
pub declaration: ServiceDeclaration,
|
||||
instance: Arc<dyn Any + Send + Sync>,
|
||||
}
|
||||
|
||||
impl FeatureServiceProvider {
|
||||
fn service<T>(&self) -> Option<Arc<T>>
|
||||
where
|
||||
T: ?Sized + Send + Sync + 'static,
|
||||
{
|
||||
self.instance.downcast_ref::<Arc<T>>().map(Arc::clone)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for FeatureServiceProvider {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.feature_id == other.feature_id && self.declaration == other.declaration
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for FeatureServiceProvider {}
|
||||
|
||||
/// Feature descriptor advertised before installation.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FeatureDescriptor {
|
||||
@@ -742,6 +783,7 @@ struct FeatureContributionDeclarations {
|
||||
instructions: HashSet<FeatureInstructionId>,
|
||||
background_tasks: HashSet<String>,
|
||||
provided_services: HashSet<(ServiceId, String)>,
|
||||
required_services: HashSet<ServiceId>,
|
||||
protocol_providers: HashSet<ProviderId>,
|
||||
}
|
||||
|
||||
@@ -773,6 +815,11 @@ impl FeatureContributionDeclarations {
|
||||
.iter()
|
||||
.map(|service| (service.id.clone(), service.version.clone()))
|
||||
.collect(),
|
||||
required_services: descriptor
|
||||
.requires_services
|
||||
.iter()
|
||||
.map(|service| service.id.clone())
|
||||
.collect(),
|
||||
protocol_providers: descriptor
|
||||
.protocol_providers
|
||||
.iter()
|
||||
@@ -1099,7 +1146,7 @@ impl BackgroundTaskRegistrar<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Service registrar for descriptor/report-only provider metadata.
|
||||
/// Service registrar for concrete typed Feature state.
|
||||
pub struct FeatureServiceRegistrar<'a> {
|
||||
feature_id: &'a FeatureId,
|
||||
declarations: &'a FeatureContributionDeclarations,
|
||||
@@ -1108,7 +1155,14 @@ pub struct FeatureServiceRegistrar<'a> {
|
||||
}
|
||||
|
||||
impl FeatureServiceRegistrar<'_> {
|
||||
pub fn provide(&mut self, declaration: ServiceDeclaration) -> Result<(), FeatureInstallError> {
|
||||
pub fn provide<T>(
|
||||
&mut self,
|
||||
declaration: ServiceDeclaration,
|
||||
instance: Arc<T>,
|
||||
) -> Result<(), FeatureInstallError>
|
||||
where
|
||||
T: ?Sized + Send + Sync + 'static,
|
||||
{
|
||||
if !self.declarations.contains_provided_service(&declaration) {
|
||||
return Err(reject_undeclared_contribution(
|
||||
self.feature_id,
|
||||
@@ -1117,19 +1171,28 @@ impl FeatureServiceRegistrar<'_> {
|
||||
declaration.id.to_string(),
|
||||
));
|
||||
}
|
||||
if self
|
||||
.report
|
||||
.provided_services
|
||||
.iter()
|
||||
.any(|service| service.id == declaration.id && service.version == declaration.version)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
self.service_registry
|
||||
.register_provider(self.feature_id.clone(), declaration.clone())?;
|
||||
self.service_registry.register_provider(
|
||||
self.feature_id.clone(),
|
||||
declaration.clone(),
|
||||
instance,
|
||||
)?;
|
||||
self.report.provided_services.push(declaration);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn require<T>(&self, service: &ServiceId) -> Result<Arc<T>, FeatureInstallError>
|
||||
where
|
||||
T: ?Sized + Send + Sync + 'static,
|
||||
{
|
||||
if !self.declarations.required_services.contains(service) {
|
||||
return Err(FeatureInstallError::UndeclaredContribution {
|
||||
kind: FeatureContributionKind::Service,
|
||||
name: service.to_string(),
|
||||
feature: self.feature_id.to_string(),
|
||||
});
|
||||
}
|
||||
self.service_registry.service(service)
|
||||
}
|
||||
}
|
||||
|
||||
/// Registrar for startup-discovered protocol-backed provider contributions.
|
||||
@@ -1248,8 +1311,11 @@ impl ProtocolProviderRegistrar<'_> {
|
||||
.iter()
|
||||
.any(|provided| provided.id == service.id && provided.version == service.version)
|
||||
{
|
||||
self.service_registry
|
||||
.register_provider(self.feature_id.clone(), service.clone())?;
|
||||
self.service_registry.register_provider(
|
||||
self.feature_id.clone(),
|
||||
service.clone(),
|
||||
Arc::new(()),
|
||||
)?;
|
||||
self.report.provided_services.push(service);
|
||||
}
|
||||
}
|
||||
@@ -1360,13 +1426,36 @@ impl FeatureInstallContext<'_> {
|
||||
}
|
||||
|
||||
/// Aggregate install output for a registry installation.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct FeatureRegistryInstallReport {
|
||||
pub reports: Vec<FeatureInstallReport>,
|
||||
pub services: FeatureServiceRegistry,
|
||||
}
|
||||
|
||||
impl FeatureRegistryInstallReport {
|
||||
pub fn has_errors(&self) -> bool {
|
||||
self.reports.iter().any(|report| {
|
||||
report
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.severity == FeatureDiagnosticSeverity::Error)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn error_message(&self) -> String {
|
||||
self.reports
|
||||
.iter()
|
||||
.flat_map(|report| {
|
||||
report
|
||||
.diagnostics
|
||||
.iter()
|
||||
.filter(|diagnostic| diagnostic.severity == FeatureDiagnosticSeverity::Error)
|
||||
.map(move |diagnostic| format!("{}: {}", report.feature_id, diagnostic.message))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
}
|
||||
|
||||
pub fn installed_tool_names(&self) -> Vec<String> {
|
||||
self.reports
|
||||
.iter()
|
||||
@@ -1453,7 +1542,9 @@ impl FeatureRegistryBuilder {
|
||||
hook_builder,
|
||||
registered_tool_names,
|
||||
);
|
||||
worker.register_tools(pending_tools);
|
||||
if !report.has_errors() {
|
||||
worker.register_tools(pending_tools);
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
@@ -1481,7 +1572,32 @@ impl FeatureRegistryBuilder {
|
||||
let mut reports = Vec::with_capacity(self.modules.len());
|
||||
let mut seen_features = HashSet::new();
|
||||
|
||||
for (module, descriptor) in self.modules.into_iter().zip(descriptors.into_iter()) {
|
||||
let mut pending_modules: Vec<_> = self.modules.into_iter().zip(descriptors).collect();
|
||||
let mut ordered_modules = Vec::with_capacity(pending_modules.len());
|
||||
let mut declared_services = HashSet::new();
|
||||
while !pending_modules.is_empty() {
|
||||
let next = pending_modules
|
||||
.iter()
|
||||
.position(|(_, descriptor)| {
|
||||
descriptor
|
||||
.requires_services
|
||||
.iter()
|
||||
.filter(|requirement| requirement.required)
|
||||
.all(|requirement| declared_services.contains(&requirement.id))
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let entry = pending_modules.remove(next);
|
||||
declared_services.extend(
|
||||
entry
|
||||
.1
|
||||
.provides_services
|
||||
.iter()
|
||||
.map(|service| service.id.clone()),
|
||||
);
|
||||
ordered_modules.push(entry);
|
||||
}
|
||||
|
||||
for (module, descriptor) in ordered_modules {
|
||||
let declarations = FeatureContributionDeclarations::from_descriptor(&descriptor);
|
||||
let mut report = FeatureInstallReport::new(&descriptor);
|
||||
|
||||
@@ -1538,22 +1654,6 @@ impl FeatureRegistryBuilder {
|
||||
report.declared_background_tasks.push(background_task);
|
||||
}
|
||||
|
||||
for service in descriptor.provides_services.iter().cloned() {
|
||||
match service_registry.register_provider(descriptor.id.clone(), service.clone()) {
|
||||
Ok(()) => report.provided_services.push(service),
|
||||
Err(error) => {
|
||||
report
|
||||
.diagnostics
|
||||
.push(FeatureDiagnostic::error(error.to_string()));
|
||||
report.mark_skipped(
|
||||
FeatureContributionKind::Service,
|
||||
service.id.to_string(),
|
||||
error.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let install_result = {
|
||||
let mut context = FeatureInstallContext {
|
||||
feature_id: &descriptor.id,
|
||||
@@ -2096,8 +2196,11 @@ mod tests {
|
||||
|
||||
fn install(
|
||||
&self,
|
||||
_context: &mut FeatureInstallContext<'_>,
|
||||
context: &mut FeatureInstallContext<'_>,
|
||||
) -> Result<(), FeatureInstallError> {
|
||||
for service in self.descriptor.provides_services.iter().cloned() {
|
||||
context.services().provide(service, Arc::new(()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -2171,11 +2274,10 @@ mod tests {
|
||||
&self,
|
||||
context: &mut FeatureInstallContext<'_>,
|
||||
) -> Result<(), FeatureInstallError> {
|
||||
context.services().provide(ServiceDeclaration::new(
|
||||
self.service.clone(),
|
||||
"1",
|
||||
"runtime service provider",
|
||||
))
|
||||
context.services().provide(
|
||||
ServiceDeclaration::new(self.service.clone(), "1", "runtime service provider"),
|
||||
Arc::new(()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2311,16 +2413,26 @@ mod tests {
|
||||
report.reports[1].resolved_service_requirements[0].id,
|
||||
service
|
||||
);
|
||||
assert!(!report.reports[2].installed);
|
||||
let missing_report = report
|
||||
.reports
|
||||
.iter()
|
||||
.find(|feature| feature.feature_id == FeatureId::builtin("missing"))
|
||||
.unwrap();
|
||||
assert!(!missing_report.installed);
|
||||
assert!(
|
||||
report.reports[2]
|
||||
missing_report
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.message.contains("required service requirement"))
|
||||
);
|
||||
assert!(report.reports[3].installed);
|
||||
let optional_report = report
|
||||
.reports
|
||||
.iter()
|
||||
.find(|feature| feature.feature_id == FeatureId::builtin("optional"))
|
||||
.unwrap();
|
||||
assert!(optional_report.installed);
|
||||
assert_eq!(
|
||||
report.reports[3].skipped[0].kind,
|
||||
optional_report.skipped[0].kind,
|
||||
FeatureContributionKind::Service
|
||||
);
|
||||
}
|
||||
@@ -2364,6 +2476,80 @@ mod tests {
|
||||
assert!(report.reports[0].skipped.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_registry_keeps_typed_concrete_instance() {
|
||||
trait CounterService: Send + Sync {
|
||||
fn value(&self) -> usize;
|
||||
}
|
||||
struct Counter(usize);
|
||||
impl CounterService for Counter {
|
||||
fn value(&self) -> usize {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
let mut registry = FeatureServiceRegistry::default();
|
||||
let service_id = ServiceId::builtin("typed-counter");
|
||||
let service: Arc<dyn CounterService> = Arc::new(Counter(7));
|
||||
registry
|
||||
.register_provider(
|
||||
FeatureId::builtin("provider"),
|
||||
ServiceDeclaration::new(service_id.clone(), "1", "typed counter"),
|
||||
service,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
registry
|
||||
.service::<dyn CounterService>(&service_id)
|
||||
.unwrap()
|
||||
.value(),
|
||||
7
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_dependencies_install_in_provider_order() {
|
||||
let service = ServiceId::builtin("ordered-service");
|
||||
let provider = FeatureDescriptor::builtin("provider", "Provider").with_provided_service(
|
||||
ServiceDeclaration::new(service.clone(), "1", "ordered service"),
|
||||
);
|
||||
let consumer = FeatureDescriptor::builtin("consumer", "Consumer").with_service_requirement(
|
||||
ServiceRequirement::required(service, "consumer depends on provider"),
|
||||
);
|
||||
let mut hook_builder = HookRegistryBuilder::default();
|
||||
let mut pending_tools = Vec::new();
|
||||
let report = FeatureRegistryBuilder::new()
|
||||
.with_module(ServiceFeature {
|
||||
descriptor: consumer,
|
||||
})
|
||||
.with_module(ServiceFeature {
|
||||
descriptor: provider,
|
||||
})
|
||||
.install_into_pending(&mut pending_tools, &mut hook_builder);
|
||||
assert!(!report.has_errors(), "{}", report.error_message());
|
||||
assert_eq!(report.reports[0].feature_id, FeatureId::builtin("provider"));
|
||||
assert_eq!(report.reports[1].feature_id, FeatureId::builtin("consumer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_required_service_is_fatal() {
|
||||
let descriptor = FeatureDescriptor::builtin("consumer", "Consumer")
|
||||
.with_service_requirement(ServiceRequirement::required(
|
||||
ServiceId::builtin("missing-service"),
|
||||
"must fail closed",
|
||||
));
|
||||
let mut hook_builder = HookRegistryBuilder::default();
|
||||
let mut pending_tools = Vec::new();
|
||||
let report = FeatureRegistryBuilder::new()
|
||||
.with_module(ServiceFeature { descriptor })
|
||||
.install_into_pending(&mut pending_tools, &mut hook_builder);
|
||||
assert!(report.has_errors());
|
||||
assert!(
|
||||
report
|
||||
.error_message()
|
||||
.contains("required service requirement")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_internal_task_feature_descriptor_has_exact_tools_hooks() {
|
||||
let descriptor = builtin::task_tools_feature().descriptor();
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod memory;
|
||||
pub mod memory_extract;
|
||||
pub mod merge_request;
|
||||
pub mod objective;
|
||||
pub mod orchestration;
|
||||
pub mod session_explore;
|
||||
pub mod task;
|
||||
pub mod ticket;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -724,11 +724,14 @@ impl FeatureModule for PluginToolFeature {
|
||||
if instance.is_none() {
|
||||
instance = Some(self.ensure_instance()?);
|
||||
}
|
||||
context.services().provide(ServiceDeclaration::new(
|
||||
plugin_service_id(&self.record, &service.name),
|
||||
self.record.manifest.version.clone(),
|
||||
service.description.clone(),
|
||||
))?;
|
||||
context.services().provide(
|
||||
ServiceDeclaration::new(
|
||||
plugin_service_id(&self.record, &service.name),
|
||||
self.record.manifest.version.clone(),
|
||||
service.description.clone(),
|
||||
),
|
||||
Arc::new(instance.as_ref().expect("instance initialized").clone()),
|
||||
)?;
|
||||
exposed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user