refactor: own Worker and Ticket service contracts outside providers
Move shared control/lifecycle/observation/Ticket contracts into feature::contracts. Consumers and host bindings import the neutral owner; providers retain current authorization, explicit combined routing and persisted composition. Document stable semantic IDs and exact compatibility epochs; connect consumer requirements to the existing planner without a new resolver. Exercise consumer installation/dispatch with contract-only test providers, atomic rejection paths, and parsed-source architecture guards including negative fixtures. Validation: worker and worker-runtime normal tests and doctests passed; root cargo check, rustdoc with warnings denied, fmt, diff and state-contract names passed. Dependent Server authorization tests require separate stale native-v1 fixture updates captured next.
This commit is contained in:
Generated
+1
@@ -6604,6 +6604,7 @@ dependencies = [
|
||||
"session-store",
|
||||
"sha2 0.11.0",
|
||||
"subjektiv",
|
||||
"syn 2.0.117",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"ticket",
|
||||
|
||||
@@ -144,9 +144,11 @@ use workdir::{
|
||||
use worker::WorkerController;
|
||||
use worker::feature::builtin::manage_workdir::WorkspaceAttachedWorkdirSession;
|
||||
use worker::feature::builtin::{
|
||||
CompositeWorkerObservationProvider, WorkerObservationError, WorkerObservationProvider,
|
||||
WorkerObservationSubject, WorkerObservationSubjectRef, WorkerSessionCapture,
|
||||
WorkspaceClientWorkerObservationProvider,
|
||||
CompositeWorkerObservationProvider, WorkspaceClientWorkerObservationProvider,
|
||||
};
|
||||
use worker::feature::contracts::{
|
||||
WorkerObservationError, WorkerObservationProvider, WorkerObservationSubject,
|
||||
WorkerObservationSubjectRef, WorkerSessionCapture,
|
||||
};
|
||||
#[cfg(feature = "ws-server")]
|
||||
use worker::ipc::protocol_session::{WorkerProtocolSessionStreams, live_log_entry_event};
|
||||
|
||||
@@ -64,3 +64,4 @@ dotenv = "0.15.0"
|
||||
futures = { workspace = true }
|
||||
serial_test = "3.4.0"
|
||||
tempfile = { workspace = true }
|
||||
syn = { version = "2", features = ["full", "visit"] }
|
||||
|
||||
@@ -311,7 +311,7 @@ pub struct RestoredControllerBindings {
|
||||
pub backend_job: Option<crate::BackendJobExecutionBinding>,
|
||||
pub workdir_sessions: std::sync::Arc<workdir::WorkdirSessionRouter>,
|
||||
pub observation_provider:
|
||||
Option<std::sync::Arc<dyn crate::feature::builtin::WorkerObservationProvider>>,
|
||||
Option<std::sync::Arc<dyn crate::feature::contracts::WorkerObservationProvider>>,
|
||||
pub subjektiv_attached: bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -2083,7 +2083,7 @@ where
|
||||
}
|
||||
|
||||
let mut observation_providers: Vec<
|
||||
Arc<dyn crate::feature::builtin::worker_observation::WorkerObservationProvider>,
|
||||
Arc<dyn crate::feature::contracts::WorkerObservationProvider>,
|
||||
> = Vec::new();
|
||||
|
||||
// Worker-orchestration tools derive child filesystem authority from the
|
||||
|
||||
@@ -514,6 +514,11 @@ impl ServiceRequirement {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_version(mut self, version: ServiceVersionReq) -> Self {
|
||||
self.version = version;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn optional(id: ServiceId, reason: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id,
|
||||
@@ -2372,6 +2377,7 @@ pub use invocation::{
|
||||
FeatureInvocationHandlerError, FeatureInvocationRegistry, InvocationContributionRegistrar,
|
||||
};
|
||||
pub mod builtin;
|
||||
pub mod contracts;
|
||||
pub mod mcp;
|
||||
pub(crate) mod session;
|
||||
|
||||
|
||||
@@ -47,7 +47,6 @@ pub mod workspace_worker_discovery;
|
||||
pub use task::{TaskFeature, task_tools_feature};
|
||||
pub use ticket::{TicketFeature, TicketFeatureAccess, ticket_tools_feature};
|
||||
pub use worker_observation::{
|
||||
CompositeWorkerObservationProvider, WorkerObservationError, WorkerObservationFeature,
|
||||
WorkerObservationProvider, WorkerObservationSubject, WorkerObservationSubjectRef,
|
||||
WorkerSessionCapture, WorkspaceClientWorkerObservationProvider,
|
||||
CompositeWorkerObservationProvider, WorkerObservationFeature,
|
||||
WorkspaceClientWorkerObservationProvider,
|
||||
};
|
||||
|
||||
@@ -14,6 +14,12 @@ use server_api::{
|
||||
CreateWorkspaceWorkerTicketAssignmentRequest,
|
||||
};
|
||||
|
||||
use crate::feature::contracts::{
|
||||
WORKER_CONTROL_SERVICE_ID, WORKER_CONTROL_SERVICE_VERSION, WORKER_LIFECYCLE_SERVICE_ID,
|
||||
WORKER_LIFECYCLE_SERVICE_VERSION, WorkerControlInputKind, WorkerControlService,
|
||||
WorkerControlStopKind, WorkerControlSubject, WorkerLifecycleService,
|
||||
WorkerLifecycleSpawnRequest, WorkerLifecycleWorkdirAttachment, WorkerObservationSubjectRef,
|
||||
};
|
||||
use crate::feature::{
|
||||
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule,
|
||||
ServiceDeclaration, ServiceId, ToolContribution, ToolDeclaration,
|
||||
@@ -25,78 +31,8 @@ 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";
|
||||
pub const WORKER_CONTROL_SERVICE_ID: &str = "worker.control";
|
||||
const WORKER_LIFECYCLE_SERVICE_VERSION: &str = "1";
|
||||
const INITIAL_WORKDIR_ALIAS: &str = "workdir";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum WorkerControlSubject {
|
||||
RuntimeWorker {
|
||||
runtime_id: String,
|
||||
worker_id: String,
|
||||
},
|
||||
SubWorker {
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WorkerControlInputKind {
|
||||
User,
|
||||
Notify,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WorkerControlStopKind {
|
||||
Cancel,
|
||||
Stop,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait WorkerControlService: Send + Sync {
|
||||
fn workspace_id(&self) -> &str;
|
||||
async fn list_workers(&self) -> Result<WorkspaceResponse, WorkspaceClientError>;
|
||||
async fn send_input(
|
||||
&self,
|
||||
subject: WorkerControlSubject,
|
||||
content: String,
|
||||
kind: WorkerControlInputKind,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError>;
|
||||
async fn stop_worker(
|
||||
&self,
|
||||
subject: WorkerControlSubject,
|
||||
kind: WorkerControlStopKind,
|
||||
reason: Option<String>,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError>;
|
||||
async fn spawn_worker(
|
||||
&self,
|
||||
request: WorkerLifecycleSpawnRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError>;
|
||||
async fn restore_worker(
|
||||
&self,
|
||||
runtime_id: String,
|
||||
worker_id: String,
|
||||
request: server_api::WorkerRestoreRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
let _ = (runtime_id, worker_id, request);
|
||||
Err(WorkspaceClientError::Unavailable(
|
||||
"Runtime Worker restore is unavailable".to_string(),
|
||||
))
|
||||
}
|
||||
fn remove_runtime_worker(
|
||||
&self,
|
||||
runtime_id: &str,
|
||||
worker_id: &str,
|
||||
reason: &str,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError>;
|
||||
async fn ensure_permission(
|
||||
&self,
|
||||
subject: &super::worker_observation::WorkerObservationSubjectRef,
|
||||
permission: &str,
|
||||
) -> Result<(), WorkspaceClientError>;
|
||||
}
|
||||
|
||||
struct WorkspaceWorkerControlService {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
workspace_id: String,
|
||||
@@ -296,9 +232,7 @@ impl WorkerControlService for WorkspaceWorkerControlService {
|
||||
));
|
||||
}
|
||||
self.ensure_permission(
|
||||
&super::worker_observation::WorkerObservationSubjectRef::SubWorker {
|
||||
name: name.clone(),
|
||||
},
|
||||
&WorkerObservationSubjectRef::SubWorker { name: name.clone() },
|
||||
"send_input",
|
||||
)
|
||||
.await?;
|
||||
@@ -377,9 +311,7 @@ impl WorkerControlService for WorkspaceWorkerControlService {
|
||||
));
|
||||
}
|
||||
self.ensure_permission(
|
||||
&super::worker_observation::WorkerObservationSubjectRef::SubWorker {
|
||||
name: name.clone(),
|
||||
},
|
||||
&WorkerObservationSubjectRef::SubWorker { name: name.clone() },
|
||||
"stop",
|
||||
)
|
||||
.await?;
|
||||
@@ -456,11 +388,11 @@ impl WorkerControlService for WorkspaceWorkerControlService {
|
||||
|
||||
async fn ensure_permission(
|
||||
&self,
|
||||
subject: &super::worker_observation::WorkerObservationSubjectRef,
|
||||
subject: &WorkerObservationSubjectRef,
|
||||
permission: &str,
|
||||
) -> Result<(), WorkspaceClientError> {
|
||||
match subject {
|
||||
super::worker_observation::WorkerObservationSubjectRef::SubWorker { name } => {
|
||||
WorkerObservationSubjectRef::SubWorker { name } => {
|
||||
let known = self
|
||||
.registry
|
||||
.as_ref()
|
||||
@@ -474,7 +406,7 @@ impl WorkerControlService for WorkspaceWorkerControlService {
|
||||
))
|
||||
}
|
||||
}
|
||||
super::worker_observation::WorkerObservationSubjectRef::RuntimeWorker {
|
||||
WorkerObservationSubjectRef::RuntimeWorker {
|
||||
runtime_id,
|
||||
worker_id,
|
||||
} => {
|
||||
@@ -533,43 +465,6 @@ impl WorkerControlService for WorkspaceWorkerControlService {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait WorkerLifecycleService: Send + Sync {
|
||||
async fn spawn(
|
||||
&self,
|
||||
request: WorkerLifecycleSpawnRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorkerLifecycleWorkdirAttachment {
|
||||
/// Stable Worker-local routing alias preserved into the canonical create request.
|
||||
pub alias: String,
|
||||
/// Workspace-authoritative Workdir id preserved into the canonical create request.
|
||||
pub working_directory_id: String,
|
||||
/// Optional normalized initial cwd relative to this Workdir.
|
||||
pub relative_cwd: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorkerLifecycleSpawnRequest {
|
||||
/// Observed delegation required for Orchestrator Ticket starts.
|
||||
pub orchestrator_assignment_id: Option<String>,
|
||||
|
||||
pub runtime_id: String,
|
||||
/// Ordered alias-keyed selections forwarded as canonical Workdir attachments.
|
||||
pub workdir_attachments: Vec<WorkerLifecycleWorkdirAttachment>,
|
||||
pub profile: String,
|
||||
pub singleton_key: Option<String>,
|
||||
pub ticket_id: Option<String>,
|
||||
/// Required for ordinary and Ticket spawn; exact retries reuse this ID and full input.
|
||||
pub operation_id: Option<String>,
|
||||
/// Explicit requested authority. Profile selection alone grants nothing.
|
||||
pub execution_grant: Option<server_api::authorization::WorkerDelegationRequest>,
|
||||
pub display_name: String,
|
||||
pub initial_submit: Vec<Segment>,
|
||||
}
|
||||
|
||||
struct WorkspaceWorkerLifecycleService {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
}
|
||||
@@ -702,7 +597,7 @@ impl FeatureModule for SubWorkerControlFeature {
|
||||
)
|
||||
.with_provided_service(ServiceDeclaration::new(
|
||||
ServiceId::builtin(WORKER_CONTROL_SERVICE_ID),
|
||||
WORKER_LIFECYCLE_SERVICE_VERSION,
|
||||
WORKER_CONTROL_SERVICE_VERSION,
|
||||
"Parent-owned SubWorker discovery and control operations",
|
||||
));
|
||||
for operation in SUB_WORKER_CONTROL_OPERATIONS {
|
||||
@@ -721,7 +616,7 @@ impl FeatureModule for SubWorkerControlFeature {
|
||||
context.services().provide(
|
||||
ServiceDeclaration::new(
|
||||
ServiceId::builtin(WORKER_CONTROL_SERVICE_ID),
|
||||
WORKER_LIFECYCLE_SERVICE_VERSION,
|
||||
WORKER_CONTROL_SERVICE_VERSION,
|
||||
"Parent-owned SubWorker discovery and control operations",
|
||||
),
|
||||
control.clone(),
|
||||
@@ -755,7 +650,7 @@ impl FeatureModule for ManageWorkerFeature {
|
||||
))
|
||||
.with_provided_service(ServiceDeclaration::new(
|
||||
ServiceId::builtin(WORKER_CONTROL_SERVICE_ID),
|
||||
WORKER_LIFECYCLE_SERVICE_VERSION,
|
||||
WORKER_CONTROL_SERVICE_VERSION,
|
||||
"Known-Worker discovery and permission-fenced control operations",
|
||||
));
|
||||
for operation in WorkerOperation::ALL {
|
||||
@@ -790,7 +685,7 @@ impl FeatureModule for ManageWorkerFeature {
|
||||
context.services().provide(
|
||||
ServiceDeclaration::new(
|
||||
ServiceId::builtin(WORKER_CONTROL_SERVICE_ID),
|
||||
WORKER_LIFECYCLE_SERVICE_VERSION,
|
||||
WORKER_CONTROL_SERVICE_VERSION,
|
||||
"Known-Worker discovery and permission-fenced control operations",
|
||||
),
|
||||
self.control.clone(),
|
||||
@@ -1570,7 +1465,7 @@ mod tests {
|
||||
|
||||
async fn ensure_permission(
|
||||
&self,
|
||||
_subject: &crate::feature::builtin::WorkerObservationSubjectRef,
|
||||
_subject: &crate::feature::contracts::WorkerObservationSubjectRef,
|
||||
_permission: &str,
|
||||
) -> Result<(), WorkspaceClientError> {
|
||||
Ok(())
|
||||
@@ -1942,7 +1837,7 @@ mod tests {
|
||||
});
|
||||
let permission_error = control
|
||||
.ensure_permission(
|
||||
&crate::feature::builtin::WorkerObservationSubjectRef::RuntimeWorker {
|
||||
&crate::feature::contracts::WorkerObservationSubjectRef::RuntimeWorker {
|
||||
runtime_id: "runtime-1".to_string(),
|
||||
worker_id: "worker-1".to_string(),
|
||||
},
|
||||
|
||||
@@ -9,14 +9,14 @@ use protocol::Segment;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::manage_worker::{
|
||||
WORKER_LIFECYCLE_SERVICE_ID, WorkerLifecycleService, WorkerLifecycleSpawnRequest,
|
||||
use crate::feature::contracts::{
|
||||
TICKET_SERVICE_ID, TICKET_SERVICE_VERSION, TicketService, WORKER_LIFECYCLE_SERVICE_ID,
|
||||
WORKER_LIFECYCLE_SERVICE_VERSION, WorkerLifecycleService, WorkerLifecycleSpawnRequest,
|
||||
WorkerLifecycleWorkdirAttachment,
|
||||
};
|
||||
use super::ticket::{TICKET_SERVICE_ID, TicketService};
|
||||
use crate::feature::{
|
||||
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ServiceId,
|
||||
ServiceRequirement, ToolContribution, ToolDeclaration,
|
||||
ServiceRequirement, ServiceVersionReq, ToolContribution, ToolDeclaration,
|
||||
};
|
||||
|
||||
const FEATURE_ID: &str = "orchestration";
|
||||
@@ -34,14 +34,20 @@ 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),
|
||||
"SpawnTicketWorker requires current typed Ticket authority",
|
||||
))
|
||||
.with_service_requirement(ServiceRequirement::required(
|
||||
ServiceId::builtin(WORKER_LIFECYCLE_SERVICE_ID),
|
||||
"SpawnTicketWorker requires Workspace Worker lifecycle authority",
|
||||
))
|
||||
.with_service_requirement(
|
||||
ServiceRequirement::required(
|
||||
ServiceId::builtin(TICKET_SERVICE_ID),
|
||||
"SpawnTicketWorker requires current typed Ticket authority",
|
||||
)
|
||||
.with_version(ServiceVersionReq::exact(TICKET_SERVICE_VERSION)),
|
||||
)
|
||||
.with_service_requirement(
|
||||
ServiceRequirement::required(
|
||||
ServiceId::builtin(WORKER_LIFECYCLE_SERVICE_ID),
|
||||
"SpawnTicketWorker requires Workspace Worker lifecycle authority",
|
||||
)
|
||||
.with_version(ServiceVersionReq::exact(WORKER_LIFECYCLE_SERVICE_VERSION)),
|
||||
)
|
||||
.with_tool(ToolDeclaration::new(TOOL_NAME, TOOL_DESCRIPTION))
|
||||
}
|
||||
|
||||
@@ -275,7 +281,7 @@ mod tests {
|
||||
use ticket::{TicketError, TicketState};
|
||||
|
||||
use super::*;
|
||||
use crate::feature::builtin::ticket::TicketHandoff;
|
||||
use crate::feature::contracts::TicketHandoff;
|
||||
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
|
||||
|
||||
struct FixedTicketService(TicketState);
|
||||
|
||||
@@ -46,6 +46,10 @@ mod native;
|
||||
mod notification_diagnostics;
|
||||
mod tags;
|
||||
|
||||
use crate::feature::contracts::{
|
||||
TICKET_SERVICE_ID, TICKET_SERVICE_VERSION, TicketHandoff, TicketService,
|
||||
};
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -539,29 +543,6 @@ const FEATURE_NAME: &str = "Ticket tools";
|
||||
const FEATURE_DESCRIPTION: &str = "Intent-led Ticket reads and authorized decisions through the Workspace API; no universal MR/approval gate, and no Backend judgment of natural-language satisfaction.";
|
||||
const TICKET_WORKFLOW_INSTRUCTION_ID: &str = "ticket.workflow";
|
||||
const TICKET_WORKFLOW_PROMPT_REF: &str = "common.tickets";
|
||||
pub const TICKET_SERVICE_ID: &str = "ticket.authority";
|
||||
const TICKET_SERVICE_VERSION: &str = "1";
|
||||
|
||||
pub trait TicketService: Send + Sync {
|
||||
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError>;
|
||||
|
||||
/// Typed creation without a synthetic Tool context. The default keeps legacy
|
||||
/// handoff-only fixture implementations fail-closed; production installs
|
||||
/// provide creation through the shared authorized backend dispatch.
|
||||
fn create(&self, _input: NewTicket) -> TicketResult<TicketRef> {
|
||||
Err(TicketError::Conflict(
|
||||
"Ticket creation service is unavailable".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TicketHandoff {
|
||||
pub id: String,
|
||||
pub resource_key: String,
|
||||
pub workflow_state: TicketState,
|
||||
}
|
||||
|
||||
fn is_canonical_ticket_resource_key(resource_key: &str) -> bool {
|
||||
resource_key.strip_prefix("T-").is_some_and(|sequence| {
|
||||
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
|
||||
|
||||
@@ -18,14 +18,17 @@ use agen::Item;
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use async_trait::async_trait;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use session_store::LogEntry;
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::manage_worker::{WORKER_CONTROL_SERVICE_ID, WorkerControlService};
|
||||
use crate::feature::contracts::{
|
||||
WORKER_CONTROL_SERVICE_ID, WORKER_CONTROL_SERVICE_VERSION, WorkerControlService,
|
||||
WorkerObservationError, WorkerObservationProvider, WorkerObservationSubject,
|
||||
WorkerObservationSubjectRef, WorkerSessionCapture,
|
||||
};
|
||||
use crate::feature::{
|
||||
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureInstructionContribution,
|
||||
FeatureInstructionDeclaration, FeatureInstructionId, FeatureModule, ServiceId,
|
||||
ServiceRequirement, ToolContribution, ToolDeclaration,
|
||||
ServiceRequirement, ServiceVersionReq, ToolContribution, ToolDeclaration,
|
||||
};
|
||||
use crate::session_capture::{
|
||||
ReadDetail, ReadOptions, ReadSelector, ReferenceKind, SearchOptions, SessionCapture,
|
||||
@@ -49,69 +52,6 @@ fn observation_instruction() -> FeatureInstructionDeclaration {
|
||||
.expect("static worker-observation instruction declaration is valid")
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
|
||||
)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum WorkerObservationSubjectRef {
|
||||
RuntimeWorker {
|
||||
runtime_id: String,
|
||||
worker_id: String,
|
||||
},
|
||||
SubWorker {
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkerObservationSubject {
|
||||
pub subject: WorkerObservationSubjectRef,
|
||||
pub display_name: String,
|
||||
pub relation: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorkerSessionCapture {
|
||||
pub segment_id: String,
|
||||
pub session: protocol::SessionSnapshot,
|
||||
}
|
||||
|
||||
impl WorkerSessionCapture {
|
||||
pub fn from_log_entries(
|
||||
segment_id: impl Into<String>,
|
||||
log_entries: &[LogEntry],
|
||||
) -> Result<Self, String> {
|
||||
Ok(Self {
|
||||
segment_id: segment_id.into(),
|
||||
session: session_store::public_snapshot::project_current_session_snapshot(log_entries),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum WorkerObservationError {
|
||||
#[error("worker session was not found or is not accessible")]
|
||||
NotFound,
|
||||
#[error("worker session observation failed: {0}")]
|
||||
Unavailable(String),
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait WorkerObservationProvider: Send + Sync {
|
||||
/// Returns only subjects already authorized for the current Worker.
|
||||
async fn list_worker_sessions(
|
||||
&self,
|
||||
) -> Result<Vec<WorkerObservationSubject>, WorkerObservationError>;
|
||||
|
||||
/// Reauthorizes and captures the latest committed session for one subject.
|
||||
/// Unauthorized and missing subjects must both return `NotFound`.
|
||||
async fn capture_worker_session(
|
||||
&self,
|
||||
subject: &WorkerObservationSubjectRef,
|
||||
) -> Result<WorkerSessionCapture, WorkerObservationError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WorkspaceWorkerObservationListResponse {
|
||||
sessions: Vec<WorkerObservationSubject>,
|
||||
@@ -290,7 +230,7 @@ impl FeatureModule for WorkerObservationFeature {
|
||||
.with_service_requirement(ServiceRequirement::required(
|
||||
ServiceId::builtin(WORKER_CONTROL_SERVICE_ID),
|
||||
"Worker observation extends the known-Worker control authority",
|
||||
))
|
||||
).with_version(ServiceVersionReq::exact(WORKER_CONTROL_SERVICE_VERSION)))
|
||||
.with_instruction(observation_instruction())
|
||||
.with_tool(ToolDeclaration::new(
|
||||
"ViewSessionOverview",
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Provider-independent contracts used by Worker Features and trusted host composition.
|
||||
//!
|
||||
//! Service IDs identify a capability's semantics, not a Feature, backend, Rust type name, or
|
||||
//! TypeId. Versions are exact compatibility epochs checked by the existing install planner;
|
||||
//! they are not crate versions or semver ranges. Compatible implementation changes retain the
|
||||
//! ID/epoch. Changes to required methods, request/result meaning, authorization guarantees, or
|
||||
//! subject routing that break consumers increment the affected epoch and update providers and
|
||||
//! requirements together. A different capability needs a different ID, not provider priority.
|
||||
//! Rust downcasts only validate an in-process binding and are not a public compatibility ABI.
|
||||
//!
|
||||
//! Trusted composition selects the exact-one control provider, including the explicit combined
|
||||
//! Workspace/SubWorker implementation. Acquiring a handle does not grant authority: providers
|
||||
//! must revalidate current authority, Workspace ownership and direct-child constraints at each
|
||||
//! operation. Observation tools bound public projections; providers reauthorize every capture.
|
||||
//! These Rust contracts are not a Plugin/guest ABI; an external boundary needs an explicit adapter.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use protocol::Segment;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use session_store::LogEntry;
|
||||
use ticket::{NewTicket, Result as TicketResult, TicketError, TicketRef, TicketState};
|
||||
|
||||
// Reuse the Host-neutral response/error vocabulary rather than redesigning transport DTOs.
|
||||
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
|
||||
|
||||
pub const WORKER_CONTROL_SERVICE_ID: &str = "worker.control";
|
||||
pub const WORKER_CONTROL_SERVICE_VERSION: &str = "1";
|
||||
pub const WORKER_LIFECYCLE_SERVICE_ID: &str = "worker.lifecycle";
|
||||
pub const WORKER_LIFECYCLE_SERVICE_VERSION: &str = "1";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum WorkerControlSubject {
|
||||
RuntimeWorker {
|
||||
runtime_id: String,
|
||||
worker_id: String,
|
||||
},
|
||||
SubWorker {
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WorkerControlInputKind {
|
||||
User,
|
||||
Notify,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WorkerControlStopKind {
|
||||
Cancel,
|
||||
Stop,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait WorkerControlService: Send + Sync {
|
||||
fn workspace_id(&self) -> &str;
|
||||
async fn list_workers(&self) -> Result<WorkspaceResponse, WorkspaceClientError>;
|
||||
async fn send_input(
|
||||
&self,
|
||||
subject: WorkerControlSubject,
|
||||
content: String,
|
||||
kind: WorkerControlInputKind,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError>;
|
||||
async fn stop_worker(
|
||||
&self,
|
||||
subject: WorkerControlSubject,
|
||||
kind: WorkerControlStopKind,
|
||||
reason: Option<String>,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError>;
|
||||
async fn spawn_worker(
|
||||
&self,
|
||||
request: WorkerLifecycleSpawnRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError>;
|
||||
async fn restore_worker(
|
||||
&self,
|
||||
runtime_id: String,
|
||||
worker_id: String,
|
||||
request: server_api::WorkerRestoreRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
let _ = (runtime_id, worker_id, request);
|
||||
Err(WorkspaceClientError::Unavailable(
|
||||
"Runtime Worker restore is unavailable".to_string(),
|
||||
))
|
||||
}
|
||||
fn remove_runtime_worker(
|
||||
&self,
|
||||
runtime_id: &str,
|
||||
worker_id: &str,
|
||||
reason: &str,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError>;
|
||||
async fn ensure_permission(
|
||||
&self,
|
||||
subject: &WorkerObservationSubjectRef,
|
||||
permission: &str,
|
||||
) -> Result<(), WorkspaceClientError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait WorkerLifecycleService: Send + Sync {
|
||||
async fn spawn(
|
||||
&self,
|
||||
request: WorkerLifecycleSpawnRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorkerLifecycleWorkdirAttachment {
|
||||
/// Stable Worker-local routing alias preserved into the canonical create request.
|
||||
pub alias: String,
|
||||
/// Workspace-authoritative Workdir id preserved into the canonical create request.
|
||||
pub working_directory_id: String,
|
||||
/// Optional normalized initial cwd relative to this Workdir.
|
||||
pub relative_cwd: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorkerLifecycleSpawnRequest {
|
||||
/// Observed delegation required for Orchestrator Ticket starts.
|
||||
pub orchestrator_assignment_id: Option<String>,
|
||||
|
||||
pub runtime_id: String,
|
||||
/// Ordered alias-keyed selections forwarded as canonical Workdir attachments.
|
||||
pub workdir_attachments: Vec<WorkerLifecycleWorkdirAttachment>,
|
||||
pub profile: String,
|
||||
pub singleton_key: Option<String>,
|
||||
pub ticket_id: Option<String>,
|
||||
/// Required for ordinary and Ticket spawn; exact retries reuse this ID and full input.
|
||||
pub operation_id: Option<String>,
|
||||
/// Explicit requested authority. Profile selection alone grants nothing.
|
||||
pub execution_grant: Option<server_api::authorization::WorkerDelegationRequest>,
|
||||
pub display_name: String,
|
||||
pub initial_submit: Vec<Segment>,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
|
||||
)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum WorkerObservationSubjectRef {
|
||||
RuntimeWorker {
|
||||
runtime_id: String,
|
||||
worker_id: String,
|
||||
},
|
||||
SubWorker {
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkerObservationSubject {
|
||||
pub subject: WorkerObservationSubjectRef,
|
||||
pub display_name: String,
|
||||
pub relation: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorkerSessionCapture {
|
||||
pub segment_id: String,
|
||||
pub session: protocol::SessionSnapshot,
|
||||
}
|
||||
|
||||
impl WorkerSessionCapture {
|
||||
pub fn from_log_entries(
|
||||
segment_id: impl Into<String>,
|
||||
log_entries: &[LogEntry],
|
||||
) -> Result<Self, String> {
|
||||
Ok(Self {
|
||||
segment_id: segment_id.into(),
|
||||
session: session_store::public_snapshot::project_current_session_snapshot(log_entries),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum WorkerObservationError {
|
||||
#[error("worker session was not found or is not accessible")]
|
||||
NotFound,
|
||||
#[error("worker session observation failed: {0}")]
|
||||
Unavailable(String),
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait WorkerObservationProvider: Send + Sync {
|
||||
/// Returns only subjects already authorized for the current Worker.
|
||||
async fn list_worker_sessions(
|
||||
&self,
|
||||
) -> Result<Vec<WorkerObservationSubject>, WorkerObservationError>;
|
||||
|
||||
/// Reauthorizes and captures the latest committed session for one subject.
|
||||
/// Unauthorized and missing subjects must both return `NotFound`.
|
||||
async fn capture_worker_session(
|
||||
&self,
|
||||
subject: &WorkerObservationSubjectRef,
|
||||
) -> Result<WorkerSessionCapture, WorkerObservationError>;
|
||||
}
|
||||
|
||||
pub const TICKET_SERVICE_ID: &str = "ticket.authority";
|
||||
pub const TICKET_SERVICE_VERSION: &str = "1";
|
||||
|
||||
pub trait TicketService: Send + Sync {
|
||||
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError>;
|
||||
|
||||
/// Typed creation without a synthetic Tool context. The default keeps legacy
|
||||
/// handoff-only fixture implementations fail-closed; production installs
|
||||
/// provide creation through the shared authorized backend dispatch.
|
||||
fn create(&self, _input: NewTicket) -> TicketResult<TicketRef> {
|
||||
Err(TicketError::Conflict(
|
||||
"Ticket creation service is unavailable".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TicketHandoff {
|
||||
pub id: String,
|
||||
pub resource_key: String,
|
||||
pub workflow_state: TicketState,
|
||||
}
|
||||
#[cfg(test)]
|
||||
#[path = "contracts_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,378 @@
|
||||
//! Component coverage: real consumer installation, planner and Tool dispatch with contract-only
|
||||
//! test providers. No concrete provider constructor, Workspace client or spawned-worker registry is
|
||||
//! passed to a consumer. Production authority/routing is covered by the providers' own suites.
|
||||
|
||||
use std::sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
use super::*;
|
||||
use crate::feature::builtin::{
|
||||
orchestration::orchestration_feature, worker_observation::WorkerObservationFeature,
|
||||
};
|
||||
use crate::feature::{
|
||||
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, FeaturePlanError,
|
||||
FeatureRegistryBuilder, ServiceDeclaration, ServiceId, ServiceRequirement,
|
||||
};
|
||||
use crate::hook::HookRegistryBuilder;
|
||||
use agen::tool::{Tool, ToolDefinition, ToolExecutionContext};
|
||||
|
||||
struct TestServices {
|
||||
revoked: AtomicBool,
|
||||
captures: AtomicUsize,
|
||||
spawns: Mutex<Vec<WorkerLifecycleSpawnRequest>>,
|
||||
}
|
||||
|
||||
impl Default for TestServices {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
revoked: AtomicBool::new(false),
|
||||
captures: AtomicUsize::new(0),
|
||||
spawns: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn response() -> WorkspaceResponse {
|
||||
WorkspaceResponse {
|
||||
status: 200,
|
||||
body: r#"{"worker_id":"test-worker"}"#.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WorkerLifecycleService for TestServices {
|
||||
async fn spawn(
|
||||
&self,
|
||||
request: WorkerLifecycleSpawnRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.spawns.lock().unwrap().push(request);
|
||||
Ok(response())
|
||||
}
|
||||
}
|
||||
|
||||
impl TicketService for TestServices {
|
||||
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
||||
assert_eq!(ticket_ref, "T-788");
|
||||
Ok(TicketHandoff {
|
||||
id: "ticket-internal-id".into(),
|
||||
resource_key: ticket_ref.into(),
|
||||
workflow_state: TicketState::Open,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WorkerControlService for TestServices {
|
||||
fn workspace_id(&self) -> &str {
|
||||
"test-workspace"
|
||||
}
|
||||
async fn list_workers(&self) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
unreachable!("not an observation operation")
|
||||
}
|
||||
async fn send_input(
|
||||
&self,
|
||||
_: WorkerControlSubject,
|
||||
_: String,
|
||||
_: WorkerControlInputKind,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
unreachable!("not a consumer operation")
|
||||
}
|
||||
async fn stop_worker(
|
||||
&self,
|
||||
_: WorkerControlSubject,
|
||||
_: WorkerControlStopKind,
|
||||
_: Option<String>,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
unreachable!("not a consumer operation")
|
||||
}
|
||||
async fn spawn_worker(
|
||||
&self,
|
||||
_: WorkerLifecycleSpawnRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
unreachable!("orchestration must use lifecycle")
|
||||
}
|
||||
fn remove_runtime_worker(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &str,
|
||||
_: &str,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
unreachable!("not a consumer operation")
|
||||
}
|
||||
async fn ensure_permission(
|
||||
&self,
|
||||
subject: &WorkerObservationSubjectRef,
|
||||
permission: &str,
|
||||
) -> Result<(), WorkspaceClientError> {
|
||||
assert_eq!(permission, "observe");
|
||||
if !self.revoked.load(Ordering::SeqCst)
|
||||
&& matches!(subject, WorkerObservationSubjectRef::SubWorker { name } if name == "child")
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(WorkspaceClientError::Request("not granted".into()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WorkerObservationProvider for TestServices {
|
||||
async fn list_worker_sessions(
|
||||
&self,
|
||||
) -> Result<Vec<WorkerObservationSubject>, WorkerObservationError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn capture_worker_session(
|
||||
&self,
|
||||
_: &WorkerObservationSubjectRef,
|
||||
) -> Result<WorkerSessionCapture, WorkerObservationError> {
|
||||
self.captures.fetch_add(1, Ordering::SeqCst);
|
||||
WorkerSessionCapture::from_log_entries("test-segment", &[])
|
||||
.map_err(WorkerObservationError::Unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
struct TestProvider {
|
||||
id: &'static str,
|
||||
services: Arc<TestServices>,
|
||||
declarations: Vec<ServiceDeclaration>,
|
||||
requirements: Vec<ServiceRequirement>,
|
||||
undeclared: bool,
|
||||
installs: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl TestProvider {
|
||||
fn new(id: &'static str, services: Arc<TestServices>) -> Self {
|
||||
Self {
|
||||
id,
|
||||
services,
|
||||
declarations: vec![
|
||||
ServiceDeclaration::new(
|
||||
ServiceId::builtin(WORKER_CONTROL_SERVICE_ID),
|
||||
WORKER_CONTROL_SERVICE_VERSION,
|
||||
"test control",
|
||||
),
|
||||
ServiceDeclaration::new(
|
||||
ServiceId::builtin(WORKER_LIFECYCLE_SERVICE_ID),
|
||||
WORKER_LIFECYCLE_SERVICE_VERSION,
|
||||
"test lifecycle",
|
||||
),
|
||||
ServiceDeclaration::new(
|
||||
ServiceId::builtin(TICKET_SERVICE_ID),
|
||||
TICKET_SERVICE_VERSION,
|
||||
"test Ticket",
|
||||
),
|
||||
],
|
||||
requirements: Vec::new(),
|
||||
undeclared: false,
|
||||
installs: Arc::new(AtomicUsize::new(0)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FeatureModule for TestProvider {
|
||||
fn descriptor(&self) -> FeatureDescriptor {
|
||||
let mut descriptor = FeatureDescriptor::builtin(self.id, "Contract test provider");
|
||||
descriptor.provides_services = self.declarations.clone();
|
||||
descriptor.requires_services = self.requirements.clone();
|
||||
descriptor
|
||||
}
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
self.installs.fetch_add(1, Ordering::SeqCst);
|
||||
for declaration in &self.declarations {
|
||||
match declaration.id.as_str() {
|
||||
"builtin:worker.control" => context.services().provide(
|
||||
declaration.clone(),
|
||||
self.services.clone() as Arc<dyn WorkerControlService>,
|
||||
)?,
|
||||
"builtin:worker.lifecycle" => context.services().provide(
|
||||
declaration.clone(),
|
||||
self.services.clone() as Arc<dyn WorkerLifecycleService>,
|
||||
)?,
|
||||
"builtin:ticket.authority" => context.services().provide(
|
||||
declaration.clone(),
|
||||
self.services.clone() as Arc<dyn TicketService>,
|
||||
)?,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
if self.undeclared {
|
||||
context.services().provide(
|
||||
ServiceDeclaration::new(ServiceId::builtin("hidden"), "1", "undeclared"),
|
||||
Arc::new(()),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn consumers(services: Arc<TestServices>) -> FeatureRegistryBuilder {
|
||||
FeatureRegistryBuilder::new()
|
||||
.with_module(orchestration_feature())
|
||||
.with_module(WorkerObservationFeature::new(services))
|
||||
}
|
||||
|
||||
fn tool(tools: &[ToolDefinition], name: &str) -> Arc<dyn Tool> {
|
||||
tools
|
||||
.iter()
|
||||
.map(|factory| factory())
|
||||
.find(|(meta, _)| meta.name == name)
|
||||
.unwrap()
|
||||
.1
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consumers_install_and_execute_with_contract_only_test_provider() {
|
||||
let services = Arc::new(TestServices::default());
|
||||
let mut tools = Vec::new();
|
||||
let report = consumers(services.clone())
|
||||
.with_module(TestProvider::new("test-provider", services.clone()))
|
||||
.install_into_pending(&mut tools, &mut HookRegistryBuilder::default());
|
||||
assert!(!report.has_errors(), "{report:?}");
|
||||
let result = tool(&tools, "SpawnTicketWorker").execute(
|
||||
r#"{"ticket_id":"T-788","runtime_id":"runtime-test","profile":"builtin:coder","initial_request":"implement","orchestrator_assignment_id":"delegation-test"}"#,
|
||||
ToolExecutionContext::new("spawn-call", "batch", 0),
|
||||
).await.unwrap();
|
||||
assert_eq!(result.summary, "Spawned Worker for Ticket T-788");
|
||||
{
|
||||
let requests = services.spawns.lock().unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(requests[0].ticket_id.as_deref(), Some("ticket-internal-id"));
|
||||
assert_eq!(
|
||||
requests[0].orchestrator_assignment_id.as_deref(),
|
||||
Some("delegation-test")
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0].operation_id.as_deref(),
|
||||
Some("spawn-ticket-worker:ticket-internal-id:spawn-call")
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0].initial_submit,
|
||||
vec![
|
||||
Segment::Flow {
|
||||
selector: "builtin:coder-review".into()
|
||||
},
|
||||
Segment::text("implement")
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
let overview = tool(&tools, "ViewSessionOverview");
|
||||
let granted = r#"{"subject":{"kind":"sub_worker","name":"child"}}"#;
|
||||
overview
|
||||
.execute(granted, ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(services.captures.load(Ordering::SeqCst), 1);
|
||||
services.revoked.store(true, Ordering::SeqCst);
|
||||
for denied in [
|
||||
granted,
|
||||
r#"{"subject":{"kind":"sub_worker","name":"non-child"}}"#,
|
||||
] {
|
||||
let error = overview
|
||||
.execute(denied, ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("not found or is not accessible"));
|
||||
}
|
||||
assert_eq!(
|
||||
services.captures.load(Ordering::SeqCst),
|
||||
1,
|
||||
"revocation must fence the existing handle before capture"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumer_plan_rejections_leave_no_partial_installation() {
|
||||
for condition in [
|
||||
"missing",
|
||||
"duplicate",
|
||||
"control-version",
|
||||
"lifecycle-version",
|
||||
"ticket-version",
|
||||
"cycle",
|
||||
"undeclared",
|
||||
] {
|
||||
let services = Arc::new(TestServices::default());
|
||||
let mut provider = TestProvider::new("test-provider", services.clone());
|
||||
let installs = provider.installs.clone();
|
||||
let mut builder = consumers(services.clone());
|
||||
match condition {
|
||||
"missing" => {}
|
||||
"duplicate" => {
|
||||
builder.add_module(TestProvider::new("duplicate-provider", services.clone()));
|
||||
}
|
||||
"control-version" => provider.declarations[0].version = "incompatible".into(),
|
||||
"lifecycle-version" => provider.declarations[1].version = "incompatible".into(),
|
||||
"ticket-version" => provider.declarations[2].version = "incompatible".into(),
|
||||
"cycle" => {
|
||||
provider.requirements.push(ServiceRequirement::required(
|
||||
ServiceId::builtin("consumer-output"),
|
||||
"cycle",
|
||||
));
|
||||
struct CyclicConsumer;
|
||||
impl FeatureModule for CyclicConsumer {
|
||||
fn descriptor(&self) -> FeatureDescriptor {
|
||||
FeatureDescriptor::builtin("cyclic-consumer", "Cycle")
|
||||
.with_service_requirement(ServiceRequirement::required(
|
||||
ServiceId::builtin(WORKER_CONTROL_SERVICE_ID),
|
||||
"control",
|
||||
))
|
||||
.with_provided_service(ServiceDeclaration::new(
|
||||
ServiceId::builtin("consumer-output"),
|
||||
"1",
|
||||
"output",
|
||||
))
|
||||
}
|
||||
fn install(
|
||||
&self,
|
||||
_: &mut FeatureInstallContext<'_>,
|
||||
) -> Result<(), FeatureInstallError> {
|
||||
panic!("cycle must fail preflight")
|
||||
}
|
||||
}
|
||||
builder.add_module(CyclicConsumer);
|
||||
}
|
||||
"undeclared" => provider.undeclared = true,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
if condition != "missing" {
|
||||
builder.add_module(provider);
|
||||
}
|
||||
let mut tools = Vec::new();
|
||||
let report = builder.install_into_pending(&mut tools, &mut HookRegistryBuilder::default());
|
||||
assert!(report.has_errors(), "{condition}: {report:?}");
|
||||
assert!(tools.is_empty(), "{condition}");
|
||||
assert!(report.services.providers().is_empty(), "{condition}");
|
||||
assert!(
|
||||
report.reports.iter().all(|entry| !entry.installed),
|
||||
"{condition}"
|
||||
);
|
||||
match (condition, report.plan_error) {
|
||||
("missing", Some(FeaturePlanError::MissingServiceProvider { .. }))
|
||||
| ("duplicate", Some(FeaturePlanError::AmbiguousServiceProvider { .. }))
|
||||
| (
|
||||
"control-version" | "lifecycle-version" | "ticket-version",
|
||||
Some(FeaturePlanError::ServiceVersionMismatch { .. }),
|
||||
)
|
||||
| ("cycle", Some(FeaturePlanError::ServiceDependencyCycle { .. })) => {
|
||||
assert_eq!(installs.load(Ordering::SeqCst), 0)
|
||||
}
|
||||
("undeclared", None) => {
|
||||
assert_eq!(installs.load(Ordering::SeqCst), 1);
|
||||
assert!(
|
||||
report
|
||||
.reports
|
||||
.iter()
|
||||
.flat_map(|entry| &entry.diagnostics)
|
||||
.any(|diagnostic| diagnostic
|
||||
.message
|
||||
.contains("undeclared Service contribution"))
|
||||
);
|
||||
}
|
||||
(_, error) => panic!("{condition}: unexpected plan error {error:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1528,16 +1528,16 @@ enabled = false
|
||||
crate::feature::builtin::worker_observation::SpawnedSubWorkerObservationProvider::new(
|
||||
registry.clone(),
|
||||
);
|
||||
let observed_child =
|
||||
crate::feature::builtin::worker_observation::WorkerObservationSubjectRef::SubWorker {
|
||||
name: "reviewer-child".to_string(),
|
||||
};
|
||||
let first_capture = crate::feature::builtin::worker_observation::WorkerObservationProvider::capture_worker_session(
|
||||
&observation,
|
||||
&observed_child,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let observed_child = crate::feature::contracts::WorkerObservationSubjectRef::SubWorker {
|
||||
name: "reviewer-child".to_string(),
|
||||
};
|
||||
let first_capture =
|
||||
crate::feature::contracts::WorkerObservationProvider::capture_worker_session(
|
||||
&observation,
|
||||
&observed_child,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
serde_json::to_string(&first_capture.session)
|
||||
.unwrap()
|
||||
@@ -1554,12 +1554,13 @@ enabled = false
|
||||
crate::internal_worker::InternalWorkerSessionStatus::Idle
|
||||
);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
let latest_capture = crate::feature::builtin::worker_observation::WorkerObservationProvider::capture_worker_session(
|
||||
&observation,
|
||||
&observed_child,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let latest_capture =
|
||||
crate::feature::contracts::WorkerObservationProvider::capture_worker_session(
|
||||
&observation,
|
||||
&observed_child,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(latest_capture.session.entries.len() > first_capture.session.entries.len());
|
||||
|
||||
fail_requests.store(true, Ordering::SeqCst);
|
||||
|
||||
@@ -53,8 +53,9 @@ use crate::compact::telemetry::{
|
||||
};
|
||||
use crate::compact::usage_tracker::{UsageTracker, persist_pending_usage};
|
||||
use crate::feature::background::{BackgroundTaskRewriteGuard, FeatureBackgroundTaskRegistry};
|
||||
use crate::feature::builtin::TaskFeature;
|
||||
use crate::feature::builtin::memory::WorkspaceMemoryBackendError;
|
||||
use crate::feature::builtin::{TaskFeature, WorkerObservationProvider};
|
||||
use crate::feature::contracts::WorkerObservationProvider;
|
||||
use crate::feature::session::{
|
||||
CommittedRunExit, CommittedSessionCapture, CommittedSessionCaptureHandle, FeatureSessionError,
|
||||
SessionExtensionHandle,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Source dependency regression for the neutral contracts and representative consumers. Parse Rust
|
||||
//! syntax so grouped/renamed imports and fully qualified type/expression paths are checked alike.
|
||||
|
||||
use syn::visit::Visit;
|
||||
|
||||
#[derive(Default)]
|
||||
struct ProviderDependencies(Vec<String>);
|
||||
|
||||
impl ProviderDependencies {
|
||||
fn check(&mut self, path: &[String]) {
|
||||
// These modules are provider/composition namespaces. `super::*` inside a consumer's own
|
||||
// unit tests is local access, but importing/aliasing the parent namespace is not.
|
||||
if path.windows(2).any(|pair| pair == ["feature", "builtin"])
|
||||
|| path.first().is_some_and(|segment| segment == "builtin")
|
||||
|| (path.first().is_some_and(|segment| segment == "super")
|
||||
&& path.get(1).is_none_or(|segment| segment != "*"))
|
||||
{
|
||||
self.0.push(path.join("::"));
|
||||
}
|
||||
}
|
||||
|
||||
fn use_tree(&mut self, prefix: &mut Vec<String>, tree: &syn::UseTree) {
|
||||
match tree {
|
||||
syn::UseTree::Path(path) => {
|
||||
prefix.push(path.ident.to_string());
|
||||
self.use_tree(prefix, &path.tree);
|
||||
prefix.pop();
|
||||
}
|
||||
syn::UseTree::Group(group) => {
|
||||
for item in &group.items {
|
||||
self.use_tree(prefix, item);
|
||||
}
|
||||
}
|
||||
syn::UseTree::Name(name) => {
|
||||
prefix.push(name.ident.to_string());
|
||||
self.check(prefix);
|
||||
prefix.pop();
|
||||
}
|
||||
syn::UseTree::Rename(rename) => {
|
||||
prefix.push(rename.ident.to_string());
|
||||
self.check(prefix);
|
||||
prefix.pop();
|
||||
}
|
||||
syn::UseTree::Glob(_) => {
|
||||
prefix.push("*".into());
|
||||
self.check(prefix);
|
||||
prefix.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'ast> Visit<'ast> for ProviderDependencies {
|
||||
fn visit_item_use(&mut self, item: &'ast syn::ItemUse) {
|
||||
self.use_tree(&mut Vec::new(), &item.tree);
|
||||
}
|
||||
|
||||
fn visit_path(&mut self, path: &'ast syn::Path) {
|
||||
self.check(
|
||||
&path
|
||||
.segments
|
||||
.iter()
|
||||
.map(|segment| segment.ident.to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
syn::visit::visit_path(self, path);
|
||||
}
|
||||
}
|
||||
|
||||
fn dependencies(source: &str) -> Vec<String> {
|
||||
let file = syn::parse_file(source).expect("valid Rust source");
|
||||
let mut visitor = ProviderDependencies::default();
|
||||
visitor.visit_file(&file);
|
||||
visitor.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contracts_and_consumers_do_not_depend_on_provider_namespaces() {
|
||||
for (name, source) in [
|
||||
("contracts", include_str!("../src/feature/contracts.rs")),
|
||||
(
|
||||
"orchestration",
|
||||
include_str!("../src/feature/builtin/orchestration.rs"),
|
||||
),
|
||||
(
|
||||
"worker_observation",
|
||||
include_str!("../src/feature/builtin/worker_observation.rs"),
|
||||
),
|
||||
] {
|
||||
assert_eq!(dependencies(source), Vec::<String>::new(), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn architecture_guard_detects_imports_reexports_aliases_and_qualified_provider_paths() {
|
||||
for source in [
|
||||
"use super::manage_worker::WorkerControlService;",
|
||||
"pub use super::ticket::TicketService;",
|
||||
"use crate::feature::{builtin::{manage_worker as provider, ticket::*}};",
|
||||
"use crate::feature::builtin as providers;",
|
||||
"use super as providers;",
|
||||
"type Contract = dyn crate::feature::builtin::manage_worker::WorkerControlService;",
|
||||
"fn consumer() { crate::feature::builtin::manage_worker::manage_worker_feature(); }",
|
||||
] {
|
||||
assert!(!dependencies(source).is_empty(), "guard missed {source}");
|
||||
}
|
||||
assert!(dependencies("use ticket::TicketError; use crate::feature::contracts::TicketService; mod tests { use super::*; }").is_empty());
|
||||
}
|
||||
@@ -1015,7 +1015,7 @@ async fn ticket_worker_host_with_service(
|
||||
) -> (
|
||||
worker::Worker<UnusedModel, session_store::FsStore>,
|
||||
bool,
|
||||
Option<Arc<dyn worker::feature::builtin::ticket::TicketService>>,
|
||||
Option<Arc<dyn worker::feature::contracts::TicketService>>,
|
||||
) {
|
||||
let manifest = worker::WorkerManifest::from_toml(
|
||||
r#"
|
||||
@@ -1060,9 +1060,9 @@ async fn ticket_worker_host_with_service(
|
||||
Some(
|
||||
report
|
||||
.services
|
||||
.service::<dyn worker::feature::builtin::ticket::TicketService>(
|
||||
.service::<dyn worker::feature::contracts::TicketService>(
|
||||
&worker::feature::ServiceId::builtin(
|
||||
worker::feature::builtin::ticket::TICKET_SERVICE_ID,
|
||||
worker::feature::contracts::TICKET_SERVICE_ID,
|
||||
),
|
||||
)
|
||||
.unwrap(),
|
||||
|
||||
Reference in New Issue
Block a user