feat(ticket): generalize Worker launch and durable resource claims
This commit is contained in:
@@ -75,6 +75,16 @@ pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[
|
|||||||
resolved_path: "profiles/job.dcdl",
|
resolved_path: "profiles/job.dcdl",
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
|
BuiltinProfileResource {
|
||||||
|
selector: Some("builtin:ticket-worker"),
|
||||||
|
path: "profiles/ticket-worker.dcdl",
|
||||||
|
source: include_str!("../../../resources/profiles/ticket-worker.dcdl"),
|
||||||
|
description: "General Ticket work with typed progress and completion tools.",
|
||||||
|
imports: &[BuiltinProfileImport {
|
||||||
|
specifier: "./default.dcdl",
|
||||||
|
resolved_path: DEFAULT_PATH,
|
||||||
|
}],
|
||||||
|
},
|
||||||
BuiltinProfileResource {
|
BuiltinProfileResource {
|
||||||
selector: Some("builtin:coder"),
|
selector: Some("builtin:coder"),
|
||||||
path: "profiles/coder.dcdl",
|
path: "profiles/coder.dcdl",
|
||||||
@@ -212,3 +222,48 @@ pub fn builtin_profile_entrypoints() -> impl Iterator<Item = &'static BuiltinPro
|
|||||||
.iter()
|
.iter()
|
||||||
.filter(|resource| resource.selector.is_some())
|
.filter(|resource| resource.selector.is_some())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ticket_worker_catalog_import_graph_includes_default_and_base() {
|
||||||
|
// This snapshot supplies the Runtime profile source archive. Follow its
|
||||||
|
// declared edges rather than resolving against every catalog source.
|
||||||
|
let catalog = builtin_profile_catalog_snapshot();
|
||||||
|
let entrypoint = catalog.entrypoints["builtin:ticket-worker"].clone();
|
||||||
|
assert_eq!(
|
||||||
|
catalog
|
||||||
|
.imports
|
||||||
|
.get(&format!("{entrypoint}\0./default.dcdl")),
|
||||||
|
Some(&DEFAULT_PATH.to_owned())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
catalog.imports.get(&format!("{DEFAULT_PATH}\0./base.dcdl")),
|
||||||
|
Some(&BASE_PATH.to_owned())
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut pending = vec![entrypoint.clone()];
|
||||||
|
let mut reachable = BTreeSet::new();
|
||||||
|
while let Some(path) = pending.pop() {
|
||||||
|
if !reachable.insert(path.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assert!(catalog.sources.contains_key(&path), "missing source {path}");
|
||||||
|
let prefix = format!("{path}\0");
|
||||||
|
pending.extend(
|
||||||
|
catalog
|
||||||
|
.imports
|
||||||
|
.iter()
|
||||||
|
.filter(|(request, _)| request.starts_with(&prefix))
|
||||||
|
.map(|(_, resolved)| resolved.clone()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
reachable,
|
||||||
|
BTreeSet::from([entrypoint, DEFAULT_PATH.to_owned(), BASE_PATH.to_owned()])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1279,6 +1279,78 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ticket_worker_profile_enables_work_reporting_without_authoring_or_integration() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let registry = ProfileDiscovery::with_sources(None, None)
|
||||||
|
.discover()
|
||||||
|
.unwrap();
|
||||||
|
let selector = ProfileSelector::parse_cli("builtin:ticket-worker");
|
||||||
|
assert_eq!(
|
||||||
|
registry.select(&selector).unwrap().qualified_name(),
|
||||||
|
"builtin:ticket-worker"
|
||||||
|
);
|
||||||
|
let manifest = ProfileResolver::new()
|
||||||
|
.with_workspace_base(tmp.path())
|
||||||
|
.resolve(
|
||||||
|
&selector,
|
||||||
|
ProfileResolveOptions::with_worker_name("ticket-worker"),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.manifest;
|
||||||
|
|
||||||
|
assert_eq!(manifest.engine.instruction, "role.ticket_worker");
|
||||||
|
// enabled supplies ShowTicket; thread supplies TicketComment,
|
||||||
|
// TicketWorkflowState and CompleteTicket without broad workflow access.
|
||||||
|
let feature = &manifest.feature;
|
||||||
|
assert!(feature.ticket.enabled);
|
||||||
|
assert!(feature.ticket.thread);
|
||||||
|
assert!(!feature.ticket.authoring);
|
||||||
|
assert!(!feature.ticket.intake);
|
||||||
|
assert!(!feature.ticket.workflow);
|
||||||
|
assert!(!feature.merge_request.show);
|
||||||
|
assert!(!feature.merge_request.open);
|
||||||
|
assert!(!feature.merge_request.review);
|
||||||
|
assert!(!feature.merge_request.readiness_check);
|
||||||
|
assert!(!feature.merge_request.complete);
|
||||||
|
assert!(!feature.orchestration.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ticket_worker_profile_inherits_default_without_flow_or_workdir_management() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let resolve = |selector| {
|
||||||
|
ProfileResolver::new()
|
||||||
|
.with_workspace_base(tmp.path())
|
||||||
|
.resolve(
|
||||||
|
&ProfileSelector::parse_cli(selector),
|
||||||
|
ProfileResolveOptions::with_worker_name("ticket-worker"),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.manifest
|
||||||
|
};
|
||||||
|
let default = resolve("builtin:default");
|
||||||
|
let worker = resolve("builtin:ticket-worker");
|
||||||
|
|
||||||
|
assert_eq!(worker.model, default.model);
|
||||||
|
assert_eq!(worker.scope.allow, default.scope.allow);
|
||||||
|
assert_eq!(worker.scope.deny, default.scope.deny);
|
||||||
|
assert_eq!(
|
||||||
|
worker.delegation_scope.allow,
|
||||||
|
default.delegation_scope.allow
|
||||||
|
);
|
||||||
|
assert_eq!(worker.delegation_scope.deny, default.delegation_scope.deny);
|
||||||
|
assert!(worker.feature.task.enabled);
|
||||||
|
assert!(worker.feature.web.enabled);
|
||||||
|
assert!(worker.feature.image.enabled);
|
||||||
|
assert!(worker.feature.sub_worker.enabled);
|
||||||
|
assert!(!worker.feature.flow.enabled);
|
||||||
|
assert!(!worker.feature.manage_workdir.enabled);
|
||||||
|
assert!(!worker.feature.worker.enabled);
|
||||||
|
assert!(!worker.feature.memory.profile.enabled);
|
||||||
|
assert!(!worker.feature.subjektiv.profile.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn builtin_workspace_profiles_cut_over_to_subjektiv_without_legacy_memory() {
|
fn builtin_workspace_profiles_cut_over_to_subjektiv_without_legacy_memory() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -7380,7 +7380,7 @@ pub enum TicketAssignmentPrincipal {
|
|||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum TicketAssignmentRole {
|
pub enum TicketAssignmentRole {
|
||||||
Orchestrator,
|
Orchestrator,
|
||||||
Coder,
|
Worker,
|
||||||
Owner,
|
Owner,
|
||||||
Contributor,
|
Contributor,
|
||||||
}
|
}
|
||||||
@@ -7416,7 +7416,7 @@ pub struct TicketActionEligibility {
|
|||||||
pub can_assign_orchestrator: bool,
|
pub can_assign_orchestrator: bool,
|
||||||
pub can_unassign_orchestrator: bool,
|
pub can_unassign_orchestrator: bool,
|
||||||
pub can_queue: bool,
|
pub can_queue: bool,
|
||||||
pub can_start_manual_coder: bool,
|
pub can_start_manual_worker: bool,
|
||||||
pub queue_tickets: Vec<String>,
|
pub queue_tickets: Vec<String>,
|
||||||
pub blockers: Vec<String>,
|
pub blockers: Vec<String>,
|
||||||
}
|
}
|
||||||
@@ -7554,7 +7554,7 @@ pub struct TicketQueryItem {
|
|||||||
pub evidence: TicketEvidenceSummary,
|
pub evidence: TicketEvidenceSummary,
|
||||||
pub merge_requests: Vec<TicketMergeRequestSummary>,
|
pub merge_requests: Vec<TicketMergeRequestSummary>,
|
||||||
pub merge_request: Option<TicketMergeRequestSummary>,
|
pub merge_request: Option<TicketMergeRequestSummary>,
|
||||||
pub current_coder: Option<TicketAssignmentSummary>,
|
pub current_worker: Option<TicketAssignmentSummary>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
@@ -7608,7 +7608,7 @@ pub struct TicketDetail {
|
|||||||
pub linked_objectives: Vec<ObjectiveLinkSummary>,
|
pub linked_objectives: Vec<ObjectiveLinkSummary>,
|
||||||
pub implementation_reports: Vec<TicketEvidenceEvent>,
|
pub implementation_reports: Vec<TicketEvidenceEvent>,
|
||||||
pub assignments: Vec<TicketRoleAssignmentSummary>,
|
pub assignments: Vec<TicketRoleAssignmentSummary>,
|
||||||
pub current_coder: Option<TicketAssignmentSummary>,
|
pub current_worker: Option<TicketAssignmentSummary>,
|
||||||
pub assignment_diagnostics: Vec<String>,
|
pub assignment_diagnostics: Vec<String>,
|
||||||
pub action_eligibility: TicketActionEligibility,
|
pub action_eligibility: TicketActionEligibility,
|
||||||
pub merge_requests: Vec<TicketMergeRequestSummary>,
|
pub merge_requests: Vec<TicketMergeRequestSummary>,
|
||||||
@@ -7705,6 +7705,19 @@ pub struct TicketOrchestrationPlanSearchRequest {
|
|||||||
pub kind: Option<ticket::OrchestrationPlanKind>,
|
pub kind: Option<ticket::OrchestrationPlanKind>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Selected existing live Workdir attachment, including its current connection
|
||||||
|
/// identity. The Workspace authority validates the selection against live
|
||||||
|
/// attachments; callers must select them explicitly, and use an empty list
|
||||||
|
/// only when the Worker has no live Workdirs.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct TicketWorkerAttachmentBinding {
|
||||||
|
pub alias: String,
|
||||||
|
pub working_directory_id: String,
|
||||||
|
pub connection_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
@@ -7712,6 +7725,8 @@ pub struct SetTicketRoleAssignmentRequest {
|
|||||||
pub operation_id: String,
|
pub operation_id: String,
|
||||||
pub principal: TicketAssignmentPrincipal,
|
pub principal: TicketAssignmentPrincipal,
|
||||||
pub expected_assignment_id: Option<String>,
|
pub expected_assignment_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub workdir_bindings: Vec<TicketWorkerAttachmentBinding>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
@@ -8350,7 +8365,7 @@ pub enum RuntimeWorkerSpawnIntent {
|
|||||||
pub enum RuntimeTicketWorkerRole {
|
pub enum RuntimeTicketWorkerRole {
|
||||||
Intake,
|
Intake,
|
||||||
Orchestrator,
|
Orchestrator,
|
||||||
Coder,
|
Worker,
|
||||||
Reviewer,
|
Reviewer,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11121,6 +11136,70 @@ mod skill_typescript_tests {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ticket_role_assignment_request_defaults_empty_bindings_and_requires_each_selected_connection()
|
||||||
|
{
|
||||||
|
let base = serde_json::json!({"operation_id":"claim", "principal":{"kind":"worker","runtime_id":"r","worker_id":"w"}, "expected_assignment_id":null});
|
||||||
|
let omitted: SetTicketRoleAssignmentRequest = serde_json::from_value(base.clone()).unwrap();
|
||||||
|
assert!(omitted.workdir_bindings.is_empty());
|
||||||
|
let mut selected = base;
|
||||||
|
selected["workdir_bindings"] = serde_json::json!([{"alias":"main","working_directory_id":"checkout","connection_id":"connection"}]);
|
||||||
|
let parsed: SetTicketRoleAssignmentRequest =
|
||||||
|
serde_json::from_value(selected.clone()).unwrap();
|
||||||
|
assert_eq!(serde_json::to_value(parsed).unwrap(), selected);
|
||||||
|
for key in ["alias", "working_directory_id", "connection_id"] {
|
||||||
|
let mut malformed = selected.clone();
|
||||||
|
malformed["workdir_bindings"][0]
|
||||||
|
.as_object_mut()
|
||||||
|
.unwrap()
|
||||||
|
.remove(key);
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<SetTicketRoleAssignmentRequest>(malformed).is_err(),
|
||||||
|
"{key}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ticket_worker_roles_and_manual_start_projection_use_worker_wire_names() {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(TicketAssignmentRole::Worker).unwrap(),
|
||||||
|
serde_json::json!("worker")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(RuntimeTicketWorkerRole::Worker).unwrap(),
|
||||||
|
serde_json::json!("worker")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<TicketAssignmentRole>(serde_json::json!("coder")).is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<RuntimeTicketWorkerRole>(serde_json::json!("coder")).is_err()
|
||||||
|
);
|
||||||
|
let eligibility = serde_json::json!({
|
||||||
|
"can_assign_orchestrator": false, "can_unassign_orchestrator": false,
|
||||||
|
"can_queue": true, "can_start_manual_worker": true, "queue_tickets": [], "blockers": []
|
||||||
|
});
|
||||||
|
let parsed: TicketActionEligibility = serde_json::from_value(eligibility.clone()).unwrap();
|
||||||
|
assert_eq!(serde_json::to_value(parsed).unwrap(), eligibility);
|
||||||
|
let mut legacy = eligibility;
|
||||||
|
legacy
|
||||||
|
.as_object_mut()
|
||||||
|
.unwrap()
|
||||||
|
.remove("can_start_manual_worker");
|
||||||
|
legacy["can_start_manual_coder"] = serde_json::json!(true);
|
||||||
|
assert!(serde_json::from_value::<TicketActionEligibility>(legacy).is_err());
|
||||||
|
for schema in [
|
||||||
|
schemars::schema_for!(TicketDetail),
|
||||||
|
schemars::schema_for!(TicketQueryItem),
|
||||||
|
] {
|
||||||
|
let schema = serde_json::to_value(schema).unwrap();
|
||||||
|
let properties = schema["properties"].as_object().unwrap();
|
||||||
|
assert!(properties.contains_key("current_worker"));
|
||||||
|
assert!(!properties.contains_key("current_coder"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn browser_ticket_targets_are_one_collection_authority() {
|
fn browser_ticket_targets_are_one_collection_authority() {
|
||||||
let edit = BrowserTicketTargetEdit::Set {
|
let edit = BrowserTicketTargetEdit::Set {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
pub enum TicketRole {
|
pub enum TicketRole {
|
||||||
Intake,
|
Intake,
|
||||||
Orchestrator,
|
Orchestrator,
|
||||||
Coder,
|
Worker,
|
||||||
Reviewer,
|
Reviewer,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ impl TicketRole {
|
|||||||
pub const ALL: [TicketRole; 4] = [
|
pub const ALL: [TicketRole; 4] = [
|
||||||
TicketRole::Intake,
|
TicketRole::Intake,
|
||||||
TicketRole::Orchestrator,
|
TicketRole::Orchestrator,
|
||||||
TicketRole::Coder,
|
TicketRole::Worker,
|
||||||
TicketRole::Reviewer,
|
TicketRole::Reviewer,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ impl TicketRole {
|
|||||||
match self {
|
match self {
|
||||||
Self::Intake => "intake",
|
Self::Intake => "intake",
|
||||||
Self::Orchestrator => "orchestrator",
|
Self::Orchestrator => "orchestrator",
|
||||||
Self::Coder => "coder",
|
Self::Worker => "worker",
|
||||||
Self::Reviewer => "reviewer",
|
Self::Reviewer => "reviewer",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,7 +42,7 @@ impl TicketRole {
|
|||||||
match value {
|
match value {
|
||||||
"intake" => Some(Self::Intake),
|
"intake" => Some(Self::Intake),
|
||||||
"orchestrator" => Some(Self::Orchestrator),
|
"orchestrator" => Some(Self::Orchestrator),
|
||||||
"coder" => Some(Self::Coder),
|
"worker" => Some(Self::Worker),
|
||||||
"reviewer" => Some(Self::Reviewer),
|
"reviewer" => Some(Self::Reviewer),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
@@ -52,7 +52,7 @@ impl TicketRole {
|
|||||||
match self {
|
match self {
|
||||||
Self::Intake => "builtin:intake",
|
Self::Intake => "builtin:intake",
|
||||||
Self::Orchestrator => "builtin:orchestrator",
|
Self::Orchestrator => "builtin:orchestrator",
|
||||||
Self::Coder => "builtin:coder",
|
Self::Worker => "builtin:default",
|
||||||
Self::Reviewer => "builtin:reviewer",
|
Self::Reviewer => "builtin:reviewer",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,4 +96,20 @@ mod tests {
|
|||||||
}
|
}
|
||||||
assert_eq!(TicketRole::parse("operator"), None);
|
assert_eq!(TicketRole::parse("operator"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_role_uses_worker_wire_name_and_default_profile() {
|
||||||
|
assert_eq!(TicketRole::parse("worker"), Some(TicketRole::Worker));
|
||||||
|
assert_eq!(TicketRole::parse("coder"), None);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&TicketRole::Worker).unwrap(),
|
||||||
|
"\"worker\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<TicketRole>("\"worker\"").unwrap(),
|
||||||
|
TicketRole::Worker
|
||||||
|
);
|
||||||
|
assert!(serde_json::from_str::<TicketRole>("\"coder\"").is_err());
|
||||||
|
assert_eq!(TicketRole::Worker.default_profile(), "builtin:default");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-11
@@ -2360,7 +2360,7 @@ impl SqliteTicketBackend {
|
|||||||
.event_attributes
|
.event_attributes
|
||||||
.get("source_actor_role")
|
.get("source_actor_role")
|
||||||
.map(String::as_str)
|
.map(String::as_str)
|
||||||
!= Some("coder")
|
!= Some("ticket_worker")
|
||||||
&& !self.event_attributes.contains_key("source_assignment_id")
|
&& !self.event_attributes.contains_key("source_assignment_id")
|
||||||
{
|
{
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -5678,6 +5678,41 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unassigned_worker_actor_is_distinct_from_ticket_worker_requiring_assignment() {
|
||||||
|
for role in ["worker", "ticket_worker"] {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let backend = backend(&tmp);
|
||||||
|
let (reference, ticket) = backend
|
||||||
|
.create_with_snapshot(NewTicket::new("Actor boundary"))
|
||||||
|
.unwrap();
|
||||||
|
let source = backend.clone().with_event_attributes(BTreeMap::from([
|
||||||
|
("source_actor_role".into(), role.into()),
|
||||||
|
("source_runtime_id".into(), "runtime".into()),
|
||||||
|
("source_worker_id".into(), "worker".into()),
|
||||||
|
]));
|
||||||
|
let result = source.complete(&reference.id, completion_request(&ticket, "complete"));
|
||||||
|
if role == "worker" {
|
||||||
|
let completed = result.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
completed
|
||||||
|
.events
|
||||||
|
.last()
|
||||||
|
.unwrap()
|
||||||
|
.attributes
|
||||||
|
.get("source_actor_role")
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("worker")
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
assert!(result.is_err());
|
||||||
|
let reread = backend.show(reference.id.into()).unwrap();
|
||||||
|
assert_eq!(reread.meta.workflow_state, ticket.meta.workflow_state);
|
||||||
|
assert_eq!(reread.events.len(), ticket.events.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn assignment_fence_accepts_exact_live_source_inside_state_and_close_transaction() {
|
fn assignment_fence_accepts_exact_live_source_inside_state_and_close_transaction() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
@@ -5686,7 +5721,7 @@ mod tests {
|
|||||||
.create_with_snapshot(NewTicket::new("Active source"))
|
.create_with_snapshot(NewTicket::new("Active source"))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
active_assignment_fixture(&backend, &reference.id);
|
active_assignment_fixture(&backend, &reference.id);
|
||||||
let source = backend.clone().with_event_attributes(assignment_source("coder"))
|
let source = backend.clone().with_event_attributes(assignment_source("ticket_worker"))
|
||||||
.with_mutation_hook(Arc::new(|conn, _| {
|
.with_mutation_hook(Arc::new(|conn, _| {
|
||||||
assert!(!conn.is_autocommit());
|
assert!(!conn.is_autocommit());
|
||||||
let count: i64 = conn.query_row("SELECT COUNT(*) FROM ticket_active_worker_assignments WHERE runtime_id = 'runtime-a' AND worker_id = 'worker-a' AND assignment_id = 'assignment-a'", [], |row| row.get(0)).map_err(sqlite_err)?;
|
let count: i64 = conn.query_row("SELECT COUNT(*) FROM ticket_active_worker_assignments WHERE runtime_id = 'runtime-a' AND worker_id = 'worker-a' AND assignment_id = 'assignment-a'", [], |row| row.get(0)).map_err(sqlite_err)?;
|
||||||
@@ -5735,7 +5770,7 @@ mod tests {
|
|||||||
"source_worker_id",
|
"source_worker_id",
|
||||||
"source_assignment_id",
|
"source_assignment_id",
|
||||||
] {
|
] {
|
||||||
let mut attrs = assignment_source("coder");
|
let mut attrs = assignment_source("ticket_worker");
|
||||||
attrs.insert(key.to_owned(), "other".to_owned());
|
attrs.insert(key.to_owned(), "other".to_owned());
|
||||||
let source = backend.clone().with_event_attributes(attrs);
|
let source = backend.clone().with_event_attributes(attrs);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -5763,7 +5798,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let source = backend
|
let source = backend
|
||||||
.clone()
|
.clone()
|
||||||
.with_event_attributes(assignment_source("coder"));
|
.with_event_attributes(assignment_source("ticket_worker"));
|
||||||
assert!(
|
assert!(
|
||||||
source
|
source
|
||||||
.complete(&other.id, completion_request(&other_ticket, "complete"))
|
.complete(&other.id, completion_request(&other_ticket, "complete"))
|
||||||
@@ -5777,7 +5812,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let source = other_workspace
|
let source = other_workspace
|
||||||
.clone()
|
.clone()
|
||||||
.with_event_attributes(assignment_source("coder"));
|
.with_event_attributes(assignment_source("ticket_worker"));
|
||||||
assert!(
|
assert!(
|
||||||
source
|
source
|
||||||
.complete(&other.id, completion_request(&other_ticket, "complete"))
|
.complete(&other.id, completion_request(&other_ticket, "complete"))
|
||||||
@@ -5798,7 +5833,7 @@ mod tests {
|
|||||||
"source_worker_id",
|
"source_worker_id",
|
||||||
"source_assignment_id",
|
"source_assignment_id",
|
||||||
] {
|
] {
|
||||||
let mut attrs = assignment_source("coder");
|
let mut attrs = assignment_source("ticket_worker");
|
||||||
attrs.remove(key);
|
attrs.remove(key);
|
||||||
let source = backend.clone().with_event_attributes(attrs);
|
let source = backend.clone().with_event_attributes(attrs);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -5809,7 +5844,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
let source = backend
|
let source = backend
|
||||||
.clone()
|
.clone()
|
||||||
.with_event_attributes(assignment_source("coder"));
|
.with_event_attributes(assignment_source("ticket_worker"));
|
||||||
assert!(
|
assert!(
|
||||||
source
|
source
|
||||||
.complete(&reference.id, completion_request(&ticket, "complete"))
|
.complete(&reference.id, completion_request(&ticket, "complete"))
|
||||||
@@ -5838,7 +5873,7 @@ mod tests {
|
|||||||
active_assignment_fixture(&backend, &reference.id);
|
active_assignment_fixture(&backend, &reference.id);
|
||||||
let source = backend
|
let source = backend
|
||||||
.clone()
|
.clone()
|
||||||
.with_event_attributes(assignment_source("coder"));
|
.with_event_attributes(assignment_source("ticket_worker"));
|
||||||
let request = completion_request(&ticket, "complete");
|
let request = completion_request(&ticket, "complete");
|
||||||
let completed = source.complete(&reference.id, request.clone()).unwrap();
|
let completed = source.complete(&reference.id, request.clone()).unwrap();
|
||||||
release_assignment_fixture(&backend);
|
release_assignment_fixture(&backend);
|
||||||
@@ -5906,7 +5941,7 @@ mod tests {
|
|||||||
active_assignment_fixture(&backend, &reference.id);
|
active_assignment_fixture(&backend, &reference.id);
|
||||||
let source = backend
|
let source = backend
|
||||||
.clone()
|
.clone()
|
||||||
.with_event_attributes(assignment_source("coder"));
|
.with_event_attributes(assignment_source("ticket_worker"));
|
||||||
let request = completion_request(&ticket, "complete");
|
let request = completion_request(&ticket, "complete");
|
||||||
let completed = source.complete(&reference.id, request.clone()).unwrap();
|
let completed = source.complete(&reference.id, request.clone()).unwrap();
|
||||||
release_assignment_fixture(&backend);
|
release_assignment_fixture(&backend);
|
||||||
@@ -5916,7 +5951,7 @@ mod tests {
|
|||||||
"source_assignment_id",
|
"source_assignment_id",
|
||||||
"source_actor_role",
|
"source_actor_role",
|
||||||
] {
|
] {
|
||||||
let mut attrs = assignment_source("coder");
|
let mut attrs = assignment_source("ticket_worker");
|
||||||
attrs.insert(key.to_owned(), "different-source".to_owned());
|
attrs.insert(key.to_owned(), "different-source".to_owned());
|
||||||
let other_source = backend.clone().with_event_attributes(attrs);
|
let other_source = backend.clone().with_event_attributes(attrs);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
@@ -6069,7 +6104,7 @@ mod tests {
|
|||||||
active_assignment_fixture(&backend, &reference.id);
|
active_assignment_fixture(&backend, &reference.id);
|
||||||
let source = backend
|
let source = backend
|
||||||
.clone()
|
.clone()
|
||||||
.with_event_attributes(assignment_source("coder"));
|
.with_event_attributes(assignment_source("ticket_worker"));
|
||||||
let request = completion_request(&ticket, "complete");
|
let request = completion_request(&ticket, "complete");
|
||||||
let completed = source.complete(&reference.id, request.clone()).unwrap();
|
let completed = source.complete(&reference.id, request.clone()).unwrap();
|
||||||
release_assignment_fixture(&backend);
|
release_assignment_fixture(&backend);
|
||||||
|
|||||||
@@ -368,11 +368,9 @@ fn apply_profile_launch_policy(
|
|||||||
apply_scope_launch_defaults(&mut manifest.scope, default_scope);
|
apply_scope_launch_defaults(&mut manifest.scope, default_scope);
|
||||||
manifest.delegation_scope = ScopeConfig::default();
|
manifest.delegation_scope = ScopeConfig::default();
|
||||||
}
|
}
|
||||||
Some(TicketRole::Coder) => {
|
// Work assignment is not a filesystem or delegation grant. The selected
|
||||||
let default_scope = workspace_scope(workspace_root, Permission::Write, &[]);
|
// Profile and explicit resource bindings remain the authority.
|
||||||
apply_scope_launch_defaults(&mut manifest.scope, default_scope);
|
Some(TicketRole::Worker) => {}
|
||||||
manifest.delegation_scope = ScopeConfig::default();
|
|
||||||
}
|
|
||||||
None => {
|
None => {
|
||||||
let worktree_root = workspace_root.join(".worktree");
|
let worktree_root = workspace_root.join(".worktree");
|
||||||
let default_scope = workspace_scope(
|
let default_scope = workspace_scope(
|
||||||
@@ -872,6 +870,41 @@ language = "override"
|
|||||||
assert_scope_contains(&manifest.scope.allow, &workspace, Permission::Write);
|
assert_scope_contains(&manifest.scope.allow, &workspace, Permission::Write);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ticket_worker_role_preserves_profile_scope_and_delegation_without_adding_grants() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let workspace = tmp.path().join("workspace");
|
||||||
|
let explicit_scope = ScopeConfig {
|
||||||
|
allow: vec![scope_rule(&workspace.join("documents"), Permission::Write)],
|
||||||
|
deny: vec![scope_rule(
|
||||||
|
&workspace.join("documents/private"),
|
||||||
|
Permission::Write,
|
||||||
|
)],
|
||||||
|
};
|
||||||
|
let explicit_delegation = ScopeConfig {
|
||||||
|
allow: vec![scope_rule(&workspace.join("reference"), Permission::Read)],
|
||||||
|
deny: vec![scope_rule(
|
||||||
|
&workspace.join("reference/private"),
|
||||||
|
Permission::Read,
|
||||||
|
)],
|
||||||
|
};
|
||||||
|
for (scope, delegation) in [
|
||||||
|
(ScopeConfig::default(), ScopeConfig::default()),
|
||||||
|
(explicit_scope, explicit_delegation),
|
||||||
|
] {
|
||||||
|
let (mut manifest, _) = load_builtin_default_manifest("ticket-worker").unwrap();
|
||||||
|
manifest.scope = scope.clone();
|
||||||
|
manifest.delegation_scope = delegation.clone();
|
||||||
|
|
||||||
|
apply_profile_launch_policy(&mut manifest, &workspace, Some("worker")).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(manifest.scope.allow, scope.allow);
|
||||||
|
assert_eq!(manifest.scope.deny, scope.deny);
|
||||||
|
assert_eq!(manifest.delegation_scope.allow, delegation.allow);
|
||||||
|
assert_eq!(manifest.delegation_scope.deny, delegation.deny);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn orchestrator_direct_startup_gets_read_root_and_worktree_delegation_from_launch_policy() {
|
fn orchestrator_direct_startup_gets_read_root_and_worktree_delegation_from_launch_policy() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -725,8 +725,8 @@ struct WorkerSpawnInput {
|
|||||||
/// Optional opaque Workspace singleton key. Prefixes are caller-owned and uninterpreted.
|
/// Optional opaque Workspace singleton key. Prefixes are caller-owned and uninterpreted.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
singleton_key: Option<String>,
|
singleton_key: Option<String>,
|
||||||
/// Optional inprogress Ticket already accepted by the Orchestrator. Set
|
/// Optional Ticket assignment. Backend validates claims and resource requirements;
|
||||||
/// this with one Flow segment to assign the new Coder atomically.
|
/// neither the profile nor an initial Flow segment grants review authority.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
ticket_id: Option<String>,
|
ticket_id: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -861,7 +861,7 @@ impl WorkerOperation {
|
|||||||
"List only known Runtime Workers and direct SubWorkers granted to the current Worker."
|
"List only known Runtime Workers and direct SubWorkers granted to the current Worker."
|
||||||
}
|
}
|
||||||
Self::Spawn => {
|
Self::Spawn => {
|
||||||
"Spawn a Backend/Runtime Worker session in one existing Workspace Workdir. The model-facing input remains singular and the shared lifecycle wraps it as the canonical attachment collection under the stable `workdir` alias. The Workdir id is authority; filesystem paths and Runtime URLs are not accepted. `initial_submit` carries the normal typed user submission. After the Orchestrator has committed a Ticket to `inprogress`, set `ticket_id` with a Flow segment in `initial_submit` to atomically assign the new Coder Worker; the operation id is derived from the durable tool call rather than model input."
|
"Spawn a Backend/Runtime Worker session in one existing Workspace Workdir. The model-facing input remains singular and the shared lifecycle wraps it as the canonical attachment collection under the stable `workdir` alias. The Workdir id is authority; filesystem paths and Runtime URLs are not accepted. `initial_submit` carries the normal typed user submission, with an optional Flow segment. Set `ticket_id` to atomically assign the Worker; Backend validates claims and resource requirements. Selecting a profile or Flow does not grant review authority. The operation id is derived from the durable tool call rather than model input."
|
||||||
}
|
}
|
||||||
Self::SendInput => {
|
Self::SendInput => {
|
||||||
"Start a fresh turn for an Idle Worker. Non-Idle Workers reject WorkerSendInput without queueing. Use WorkerNotify for advisory information during work already in progress. Direct SubWorkers also require Idle."
|
"Start a fresh turn for an Idle Worker. Non-Idle Workers reject WorkerSendInput without queueing. Use WorkerNotify for advisory information during work already in progress. Direct SubWorkers also require Idle."
|
||||||
@@ -1949,6 +1949,41 @@ mod tests {
|
|||||||
assert!(value.get("initial_text").is_none());
|
assert!(value.get("initial_text").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_lifecycle_spawn_serializes_ticket_assignment_without_workdirs_or_flow() {
|
||||||
|
let request = workspace_worker_create_request(WorkerLifecycleSpawnRequest {
|
||||||
|
runtime_id: "runtime-1".to_string(),
|
||||||
|
workdir_attachments: Vec::new(),
|
||||||
|
profile: "project:ticket-worker".to_string(),
|
||||||
|
singleton_key: None,
|
||||||
|
ticket_id: Some("T-719".to_string()),
|
||||||
|
operation_id: Some("spawn-ticket-worker:T-719:call-1".to_string()),
|
||||||
|
display_name: "Worker · T-719".to_string(),
|
||||||
|
initial_submit: vec![Segment::text("Investigate Ticket T-719.")],
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let value = serde_json::to_value(request).unwrap();
|
||||||
|
assert_eq!(value["workdir_attachments"], serde_json::json!([]));
|
||||||
|
assert_eq!(value["profile"], "project:ticket-worker");
|
||||||
|
assert_eq!(
|
||||||
|
value["ticket_assignment"],
|
||||||
|
serde_json::json!({
|
||||||
|
"ticket_id": "T-719",
|
||||||
|
"operation_id": "spawn-ticket-worker:T-719:call-1",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
value["control_operation_id"],
|
||||||
|
"spawn-ticket-worker:T-719:call-1"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
value["initial_submit"],
|
||||||
|
serde_json::json!([
|
||||||
|
{"kind": "text", "content": "Investigate Ticket T-719."}
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn worker_message_and_cancel_use_permission_fenced_control_routes() {
|
async fn worker_message_and_cancel_use_permission_fenced_control_routes() {
|
||||||
let client = Arc::new(RecordingWorkspaceClient::default());
|
let client = Arc::new(RecordingWorkspaceClient::default());
|
||||||
|
|||||||
@@ -20,9 +20,7 @@ use crate::feature::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
const FEATURE_ID: &str = "orchestration";
|
const FEATURE_ID: &str = "orchestration";
|
||||||
const TOOL_NAME: &str = "SpawnTicketCoder";
|
const TOOL_NAME: &str = "SpawnTicketWorker";
|
||||||
const CODER_PROFILE: &str = "builtin:coder";
|
|
||||||
const CODER_FLOW: &str = "builtin:coder-review";
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct OrchestrationFeature;
|
pub struct OrchestrationFeature;
|
||||||
@@ -37,15 +35,15 @@ impl FeatureModule for OrchestrationFeature {
|
|||||||
.with_description("Semantic Ticket orchestration operations.")
|
.with_description("Semantic Ticket orchestration operations.")
|
||||||
.with_service_requirement(ServiceRequirement::required(
|
.with_service_requirement(ServiceRequirement::required(
|
||||||
ServiceId::builtin(TICKET_SERVICE_ID),
|
ServiceId::builtin(TICKET_SERVICE_ID),
|
||||||
"SpawnTicketCoder requires current typed Ticket authority",
|
"SpawnTicketWorker requires current typed Ticket authority",
|
||||||
))
|
))
|
||||||
.with_service_requirement(ServiceRequirement::required(
|
.with_service_requirement(ServiceRequirement::required(
|
||||||
ServiceId::builtin(WORKER_LIFECYCLE_SERVICE_ID),
|
ServiceId::builtin(WORKER_LIFECYCLE_SERVICE_ID),
|
||||||
"SpawnTicketCoder requires Workspace Worker lifecycle authority",
|
"SpawnTicketWorker requires Workspace Worker lifecycle authority",
|
||||||
))
|
))
|
||||||
.with_tool(ToolDeclaration::new(
|
.with_tool(ToolDeclaration::new(
|
||||||
TOOL_NAME,
|
TOOL_NAME,
|
||||||
"Spawn and atomically assign a Coder Worker for a queued or already-inprogress Ticket. Select every required Workdir by stable alias; Backend authority derives each attachment's access from the Ticket targets. The guarded operation records queued acceptance only after spawn, initial input, assignment, and Workdir finalization. The profile, Flow, display name, assignment operation, and initial message are fixed by orchestration policy.",
|
"Spawn and atomically assign a configurable Worker for a Ticket. Supply a registered profile selector, an initial request, and optionally a Flow selector. Workdir attachments may be empty; Backend authority validates claims and derives each attachment's access from the Ticket targets. The guarded operation records acceptance only after spawn, initial input, assignment, and resource finalization. Selecting a profile or Flow does not grant review authority.",
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +66,7 @@ impl FeatureModule for OrchestrationFeature {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
struct SpawnTicketCoderWorkdirInput {
|
struct SpawnTicketWorkerWorkdirInput {
|
||||||
/// Stable Worker-local routing alias (for example `checkout` or `docs`).
|
/// Stable Worker-local routing alias (for example `checkout` or `docs`).
|
||||||
alias: String,
|
alias: String,
|
||||||
/// Workspace-authoritative Workdir id. Paths and URLs are not accepted.
|
/// Workspace-authoritative Workdir id. Paths and URLs are not accepted.
|
||||||
@@ -80,27 +78,35 @@ struct SpawnTicketCoderWorkdirInput {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
struct SpawnTicketCoderInput {
|
struct SpawnTicketWorkerInput {
|
||||||
ticket_id: String,
|
ticket_id: String,
|
||||||
runtime_id: String,
|
runtime_id: String,
|
||||||
/// Every Workdir required by the Ticket target set. Backend authority derives
|
/// Registered profile selector. Backend resolves the profile and validates claims.
|
||||||
/// effective read/write capabilities; callers cannot request capabilities.
|
profile: String,
|
||||||
workdir_attachments: Vec<SpawnTicketCoderWorkdirInput>,
|
/// Initial user request delivered through the normal typed submission path.
|
||||||
|
initial_request: String,
|
||||||
|
/// Optional Flow selector, delivered as a typed Flow segment before the request.
|
||||||
|
#[serde(default)]
|
||||||
|
flow: Option<String>,
|
||||||
|
/// Alias-keyed Workdir selections; omit or leave empty for a Worker without
|
||||||
|
/// Workdirs. Backend derives effective capabilities; callers cannot request them.
|
||||||
|
#[serde(default)]
|
||||||
|
workdir_attachments: Vec<SpawnTicketWorkerWorkdirInput>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SpawnTicketCoderTool {
|
struct SpawnTicketWorkerTool {
|
||||||
ticket_service: Arc<dyn TicketService>,
|
ticket_service: Arc<dyn TicketService>,
|
||||||
worker_service: Arc<dyn WorkerLifecycleService>,
|
worker_service: Arc<dyn WorkerLifecycleService>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Tool for SpawnTicketCoderTool {
|
impl Tool for SpawnTicketWorkerTool {
|
||||||
async fn execute(
|
async fn execute(
|
||||||
&self,
|
&self,
|
||||||
input_json: &str,
|
input_json: &str,
|
||||||
ctx: ToolExecutionContext,
|
ctx: ToolExecutionContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let input: SpawnTicketCoderInput = serde_json::from_str(input_json).map_err(|error| {
|
let input: SpawnTicketWorkerInput = serde_json::from_str(input_json).map_err(|error| {
|
||||||
ToolError::InvalidArgument(format!("invalid {TOOL_NAME} input: {error}"))
|
ToolError::InvalidArgument(format!("invalid {TOOL_NAME} input: {error}"))
|
||||||
})?;
|
})?;
|
||||||
let ticket_ref = authority_id(input.ticket_id, "ticket_id")?;
|
let ticket_ref = authority_id(input.ticket_id, "ticket_id")?;
|
||||||
@@ -108,35 +114,28 @@ impl Tool for SpawnTicketCoderTool {
|
|||||||
.ticket_service
|
.ticket_service
|
||||||
.ticket_handoff(&ticket_ref)
|
.ticket_handoff(&ticket_ref)
|
||||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||||
if !matches!(
|
|
||||||
ticket.workflow_state,
|
|
||||||
ticket::TicketWorkflowState::Queued | ticket::TicketWorkflowState::InProgress
|
|
||||||
) {
|
|
||||||
return Err(ToolError::ExecutionFailed(format!(
|
|
||||||
"Ticket {} must be queued or inprogress before spawning its Coder; current state is {}",
|
|
||||||
ticket.resource_key,
|
|
||||||
ticket.workflow_state.as_str()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let call_id = non_empty(ctx.call_id, "tool call_id")?;
|
let call_id = non_empty(ctx.call_id, "tool call_id")?;
|
||||||
let runtime_id = authority_id(input.runtime_id, "runtime_id")?;
|
let runtime_id = authority_id(input.runtime_id, "runtime_id")?;
|
||||||
|
let profile = non_empty(input.profile, "profile")?;
|
||||||
|
let initial_request = non_empty(input.initial_request, "initial_request")?;
|
||||||
|
let flow = input.flow.map(|flow| non_empty(flow, "flow")).transpose()?;
|
||||||
let workdir_attachments = validate_workdir_attachments(input.workdir_attachments)?;
|
let workdir_attachments = validate_workdir_attachments(input.workdir_attachments)?;
|
||||||
|
let mut initial_submit = Vec::new();
|
||||||
|
if let Some(selector) = flow {
|
||||||
|
initial_submit.push(Segment::Flow { selector });
|
||||||
|
}
|
||||||
|
initial_submit.push(Segment::text(initial_request));
|
||||||
let response = self
|
let response = self
|
||||||
.worker_service
|
.worker_service
|
||||||
.spawn(WorkerLifecycleSpawnRequest {
|
.spawn(WorkerLifecycleSpawnRequest {
|
||||||
runtime_id,
|
runtime_id,
|
||||||
workdir_attachments,
|
workdir_attachments,
|
||||||
profile: CODER_PROFILE.to_string(),
|
profile,
|
||||||
singleton_key: None,
|
singleton_key: None,
|
||||||
ticket_id: Some(ticket.id.clone()),
|
ticket_id: Some(ticket.id.clone()),
|
||||||
operation_id: Some(format!("spawn-ticket-coder:{}:{call_id}", ticket.id)),
|
operation_id: Some(format!("spawn-ticket-worker:{}:{call_id}", ticket.id)),
|
||||||
display_name: format!("Coder · {}", ticket.resource_key),
|
display_name: format!("Worker · {}", ticket.resource_key),
|
||||||
initial_submit: vec![
|
initial_submit,
|
||||||
Segment::Flow {
|
|
||||||
selector: CODER_FLOW.to_string(),
|
|
||||||
},
|
|
||||||
Segment::text(format!("Implement Ticket {}.", ticket.resource_key)),
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||||
@@ -147,7 +146,7 @@ impl Tool for SpawnTicketCoderTool {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
Ok(ToolOutput {
|
Ok(ToolOutput {
|
||||||
summary: format!("Spawned Coder for Ticket {}", ticket.resource_key),
|
summary: format!("Spawned Worker for Ticket {}", ticket.resource_key),
|
||||||
content: Some(response.body),
|
content: Some(response.body),
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
})
|
})
|
||||||
@@ -155,14 +154,8 @@ impl Tool for SpawnTicketCoderTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn validate_workdir_attachments(
|
fn validate_workdir_attachments(
|
||||||
attachments: Vec<SpawnTicketCoderWorkdirInput>,
|
attachments: Vec<SpawnTicketWorkerWorkdirInput>,
|
||||||
) -> Result<Vec<WorkerLifecycleWorkdirAttachment>, ToolError> {
|
) -> Result<Vec<WorkerLifecycleWorkdirAttachment>, ToolError> {
|
||||||
if attachments.is_empty() {
|
|
||||||
return Err(ToolError::InvalidArgument(
|
|
||||||
"workdir_attachments must contain at least one Workdir".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut aliases = BTreeSet::new();
|
let mut aliases = BTreeSet::new();
|
||||||
let mut workdir_ids = BTreeSet::new();
|
let mut workdir_ids = BTreeSet::new();
|
||||||
attachments
|
attachments
|
||||||
@@ -200,14 +193,14 @@ fn definition(
|
|||||||
worker_service: Arc<dyn WorkerLifecycleService>,
|
worker_service: Arc<dyn WorkerLifecycleService>,
|
||||||
) -> ToolDefinition {
|
) -> ToolDefinition {
|
||||||
Arc::new(move || {
|
Arc::new(move || {
|
||||||
let schema = serde_json::to_value(schemars::schema_for!(SpawnTicketCoderInput))
|
let schema = serde_json::to_value(schemars::schema_for!(SpawnTicketWorkerInput))
|
||||||
.unwrap_or_else(|_| serde_json::json!({}));
|
.unwrap_or_else(|_| serde_json::json!({}));
|
||||||
let meta = ToolMeta::new(TOOL_NAME)
|
let meta = ToolMeta::new(TOOL_NAME)
|
||||||
.description(
|
.description(
|
||||||
"Spawn and atomically assign a policy-configured Coder for a Ticket with every required alias-keyed Workdir attachment.",
|
"Spawn and atomically assign a configurable Ticket Worker with a registered profile, initial request, optional typed Flow, and zero or more alias-keyed Workdir attachments. Backend validates claims and resources; profile and Flow selection do not grant review authority.",
|
||||||
)
|
)
|
||||||
.input_schema(schema);
|
.input_schema(schema);
|
||||||
let tool: Arc<dyn Tool> = Arc::new(SpawnTicketCoderTool {
|
let tool: Arc<dyn Tool> = Arc::new(SpawnTicketWorkerTool {
|
||||||
ticket_service: ticket_service.clone(),
|
ticket_service: ticket_service.clone(),
|
||||||
worker_service: worker_service.clone(),
|
worker_service: worker_service.clone(),
|
||||||
});
|
});
|
||||||
@@ -254,32 +247,18 @@ mod tests {
|
|||||||
|
|
||||||
use ticket::{TicketError, TicketWorkflowState};
|
use ticket::{TicketError, TicketWorkflowState};
|
||||||
|
|
||||||
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
|
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::feature::builtin::ticket::TicketHandoff;
|
use crate::feature::builtin::ticket::TicketHandoff;
|
||||||
|
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
|
||||||
#[derive(Default)]
|
|
||||||
struct RecordingTicketService;
|
|
||||||
|
|
||||||
impl TicketService for RecordingTicketService {
|
|
||||||
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
|
||||||
assert_eq!(ticket_ref, "T-482");
|
|
||||||
Ok(TicketHandoff {
|
|
||||||
id: "00001KZXN51C7".to_string(),
|
|
||||||
resource_key: "T-482".to_string(),
|
|
||||||
workflow_state: TicketWorkflowState::Queued,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct FixedTicketService(TicketWorkflowState);
|
struct FixedTicketService(TicketWorkflowState);
|
||||||
|
|
||||||
impl TicketService for FixedTicketService {
|
impl TicketService for FixedTicketService {
|
||||||
fn ticket_handoff(&self, _ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
||||||
|
assert_eq!(ticket_ref, "T-719");
|
||||||
Ok(TicketHandoff {
|
Ok(TicketHandoff {
|
||||||
id: "00001KZXN51C7".to_string(),
|
id: "00001KZXN51C7".to_string(),
|
||||||
resource_key: "T-482".to_string(),
|
resource_key: "T-719".to_string(),
|
||||||
workflow_state: self.0,
|
workflow_state: self.0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -288,6 +267,7 @@ mod tests {
|
|||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct RecordingService {
|
struct RecordingService {
|
||||||
requests: Mutex<Vec<WorkerLifecycleSpawnRequest>>,
|
requests: Mutex<Vec<WorkerLifecycleSpawnRequest>>,
|
||||||
|
response: Option<WorkspaceResponse>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -297,193 +277,236 @@ mod tests {
|
|||||||
request: WorkerLifecycleSpawnRequest,
|
request: WorkerLifecycleSpawnRequest,
|
||||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||||
self.requests.lock().unwrap().push(request);
|
self.requests.lock().unwrap().push(request);
|
||||||
Ok(WorkspaceResponse {
|
Ok(self.response.clone().unwrap_or_else(|| WorkspaceResponse {
|
||||||
status: 200,
|
status: 200,
|
||||||
body: r#"{"worker_id":"42"}"#.to_string(),
|
body: r#"{"worker_id":"42"}"#.to_string(),
|
||||||
})
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn input() -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"ticket_id": "T-719",
|
||||||
|
"runtime_id": "runtime-1",
|
||||||
|
"profile": "project:ticket-worker",
|
||||||
|
"initial_request": "Investigate Ticket T-719."
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool(service: Arc<RecordingService>, state: TicketWorkflowState) -> SpawnTicketWorkerTool {
|
||||||
|
SpawnTicketWorkerTool {
|
||||||
|
ticket_service: Arc::new(FixedTicketService(state)),
|
||||||
|
worker_service: service,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn spawn_ticket_coder_fixes_profile_flow_assignment_and_message() {
|
async fn spawn_ticket_worker_forwards_selected_profile_flow_request_and_workdirs() {
|
||||||
let service = Arc::new(RecordingService::default());
|
let service = Arc::new(RecordingService::default());
|
||||||
let tool = SpawnTicketCoderTool {
|
let mut input = input();
|
||||||
ticket_service: Arc::new(RecordingTicketService),
|
input["flow"] = serde_json::json!("project:investigation");
|
||||||
worker_service: service.clone(),
|
input["workdir_attachments"] = serde_json::json!([
|
||||||
};
|
{
|
||||||
tool.execute(
|
"alias": "checkout",
|
||||||
&serde_json::json!({
|
"working_directory_id": "workdir-1",
|
||||||
"ticket_id": "T-482",
|
"relative_cwd": "crates/worker"
|
||||||
"runtime_id": "runtime-1",
|
},
|
||||||
"workdir_attachments": [
|
{"alias": "docs", "working_directory_id": "workdir-2"}
|
||||||
{
|
]);
|
||||||
"alias": "checkout",
|
let output = tool(service.clone(), TicketWorkflowState::Queued)
|
||||||
"working_directory_id": "workdir-1",
|
.execute(
|
||||||
"relative_cwd": "crates/yoi"
|
&input.to_string(),
|
||||||
},
|
ToolExecutionContext::new("call-7", "batch-1", 0),
|
||||||
{
|
)
|
||||||
"alias": "docs",
|
.await
|
||||||
"working_directory_id": "workdir-2"
|
.unwrap();
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
|
||||||
.to_string(),
|
|
||||||
ToolExecutionContext::new("call-7", "batch-1", 0),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let requests = service.requests.lock().unwrap();
|
let requests = service.requests.lock().unwrap();
|
||||||
|
assert_eq!(requests.len(), 1);
|
||||||
let request = &requests[0];
|
let request = &requests[0];
|
||||||
assert_eq!(request.profile, CODER_PROFILE);
|
assert_eq!(request.runtime_id, "runtime-1");
|
||||||
|
assert_eq!(request.profile, "project:ticket-worker");
|
||||||
assert_eq!(request.ticket_id.as_deref(), Some("00001KZXN51C7"));
|
assert_eq!(request.ticket_id.as_deref(), Some("00001KZXN51C7"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
request.operation_id.as_deref(),
|
request.operation_id.as_deref(),
|
||||||
Some("spawn-ticket-coder:00001KZXN51C7:call-7")
|
Some("spawn-ticket-worker:00001KZXN51C7:call-7")
|
||||||
);
|
);
|
||||||
assert_eq!(request.display_name, "Coder · T-482");
|
assert_eq!(request.display_name, "Worker · T-719");
|
||||||
assert_eq!(request.workdir_attachments.len(), 2);
|
assert_eq!(request.singleton_key, None);
|
||||||
assert_eq!(request.workdir_attachments[0].alias, "checkout");
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
request.workdir_attachments[0].working_directory_id,
|
request.workdir_attachments,
|
||||||
"workdir-1"
|
vec![
|
||||||
|
WorkerLifecycleWorkdirAttachment {
|
||||||
|
alias: "checkout".into(),
|
||||||
|
working_directory_id: "workdir-1".into(),
|
||||||
|
relative_cwd: Some("crates/worker".into()),
|
||||||
|
},
|
||||||
|
WorkerLifecycleWorkdirAttachment {
|
||||||
|
alias: "docs".into(),
|
||||||
|
working_directory_id: "workdir-2".into(),
|
||||||
|
relative_cwd: None,
|
||||||
|
},
|
||||||
|
]
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
request.workdir_attachments[0].relative_cwd.as_deref(),
|
|
||||||
Some("crates/yoi")
|
|
||||||
);
|
|
||||||
assert_eq!(request.workdir_attachments[1].alias, "docs");
|
|
||||||
assert_eq!(
|
|
||||||
request.workdir_attachments[1].working_directory_id,
|
|
||||||
"workdir-2"
|
|
||||||
);
|
|
||||||
assert_eq!(request.workdir_attachments[1].relative_cwd, None);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
request.initial_submit,
|
request.initial_submit,
|
||||||
vec![
|
vec![
|
||||||
Segment::Flow {
|
Segment::Flow {
|
||||||
selector: CODER_FLOW.to_string()
|
selector: "project:investigation".into()
|
||||||
},
|
},
|
||||||
Segment::text("Implement Ticket T-482.")
|
Segment::text("Investigate Ticket T-719."),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert!(!request.display_name.contains("00001KZXN51C7"));
|
assert_eq!(output.summary, "Spawned Worker for Ticket T-719");
|
||||||
assert!(request.initial_submit.iter().all(|segment| {
|
assert_eq!(output.content.as_deref(), Some(r#"{"worker_id":"42"}"#));
|
||||||
!Segment::flatten_to_text(std::slice::from_ref(segment)).contains("00001KZXN51C7")
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn spawn_ticket_coder_rejects_ineligible_ticket_before_worker_side_effect() {
|
async fn spawn_ticket_worker_allows_zero_workdirs_and_omitted_flow() {
|
||||||
let worker_service = Arc::new(RecordingService::default());
|
let service = Arc::new(RecordingService::default());
|
||||||
let tool = SpawnTicketCoderTool {
|
for explicit_empty in [false, true] {
|
||||||
ticket_service: Arc::new(FixedTicketService(TicketWorkflowState::Planning)),
|
let mut input = input();
|
||||||
worker_service: worker_service.clone(),
|
if explicit_empty {
|
||||||
};
|
input["workdir_attachments"] = serde_json::json!([]);
|
||||||
let error = tool
|
input["flow"] = serde_json::Value::Null;
|
||||||
|
}
|
||||||
|
tool(service.clone(), TicketWorkflowState::Queued)
|
||||||
|
.execute(
|
||||||
|
&input.to_string(),
|
||||||
|
ToolExecutionContext::new("call-empty", "batch-1", 0),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let requests = service.requests.lock().unwrap();
|
||||||
|
assert_eq!(requests.len(), 2);
|
||||||
|
for request in requests.iter() {
|
||||||
|
assert!(request.workdir_attachments.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
request.initial_submit,
|
||||||
|
vec![Segment::text("Investigate Ticket T-719.")]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn spawn_ticket_worker_defers_claim_eligibility_to_backend() {
|
||||||
|
let service = Arc::new(RecordingService {
|
||||||
|
response: Some(WorkspaceResponse {
|
||||||
|
status: 409,
|
||||||
|
body: "Backend rejected Ticket claim".into(),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let error = tool(service.clone(), TicketWorkflowState::Planning)
|
||||||
.execute(
|
.execute(
|
||||||
&serde_json::json!({
|
&input().to_string(),
|
||||||
"ticket_id": "00001KZXN51C7",
|
ToolExecutionContext::new("call-claim", "batch-1", 0),
|
||||||
"runtime_id": "runtime-1",
|
|
||||||
"workdir_attachments": [{
|
|
||||||
"alias": "checkout",
|
|
||||||
"working_directory_id": "workdir-1"
|
|
||||||
}]
|
|
||||||
})
|
|
||||||
.to_string(),
|
|
||||||
ToolExecutionContext::new("call-queued", "batch-1", 0),
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(error.to_string().contains("must be queued or inprogress"));
|
assert_eq!(service.requests.lock().unwrap().len(), 1);
|
||||||
assert!(worker_service.requests.lock().unwrap().is_empty());
|
assert!(
|
||||||
|
error
|
||||||
|
.to_string()
|
||||||
|
.contains("HTTP 409: Backend rejected Ticket claim")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn spawn_ticket_coder_rejects_invalid_workdir_collections_before_worker_side_effect() {
|
async fn spawn_ticket_worker_rejects_invalid_input_before_worker_side_effect() {
|
||||||
let worker_service = Arc::new(RecordingService::default());
|
let service = Arc::new(RecordingService::default());
|
||||||
let tool = SpawnTicketCoderTool {
|
let tool = tool(service.clone(), TicketWorkflowState::Queued);
|
||||||
ticket_service: Arc::new(FixedTicketService(TicketWorkflowState::Queued)),
|
|
||||||
worker_service: worker_service.clone(),
|
|
||||||
};
|
|
||||||
let cases = [
|
let cases = [
|
||||||
(
|
(
|
||||||
serde_json::json!({
|
"profile",
|
||||||
"ticket_id": "T-482",
|
serde_json::json!(" "),
|
||||||
"runtime_id": "runtime-1",
|
"profile must not be empty",
|
||||||
"workdir_attachments": []
|
|
||||||
}),
|
|
||||||
"must contain at least one Workdir",
|
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
serde_json::json!({
|
"initial_request",
|
||||||
"ticket_id": "T-482",
|
serde_json::json!(" \n "),
|
||||||
"runtime_id": "runtime-1",
|
"initial_request must not be empty",
|
||||||
"workdir_attachments": [
|
|
||||||
{"alias": "checkout", "working_directory_id": "workdir-1"},
|
|
||||||
{"alias": " checkout ", "working_directory_id": "workdir-2"}
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
"duplicate Workdir attachment alias `checkout`",
|
|
||||||
),
|
),
|
||||||
|
("flow", serde_json::json!(" "), "flow must not be empty"),
|
||||||
(
|
(
|
||||||
serde_json::json!({
|
"runtime_id",
|
||||||
"ticket_id": "T-482",
|
serde_json::json!("runtime/1"),
|
||||||
"runtime_id": "runtime-1",
|
|
||||||
"workdir_attachments": [
|
|
||||||
{"alias": "checkout", "working_directory_id": "workdir-1"},
|
|
||||||
{"alias": "docs", "working_directory_id": " workdir-1 "}
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
"duplicate Workdir attachment `workdir-1`",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
serde_json::json!({
|
|
||||||
"ticket_id": "T-482",
|
|
||||||
"runtime_id": "runtime-1",
|
|
||||||
"workdir_attachments": [
|
|
||||||
{"alias": "docs/path", "working_directory_id": "workdir-1"}
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
"workdir_attachments.alias must be an authority id",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
serde_json::json!({
|
|
||||||
"ticket_id": "T-482",
|
|
||||||
"runtime_id": "runtime-1",
|
|
||||||
"workdir_attachments": [
|
|
||||||
{"alias": "docs", "working_directory_id": "https://example.test/workdir"}
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
"workdir_attachments.working_directory_id must be an authority id",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
serde_json::json!({
|
|
||||||
"ticket_id": "T-482",
|
|
||||||
"runtime_id": "runtime/1",
|
|
||||||
"workdir_attachments": [
|
|
||||||
{"alias": "checkout", "working_directory_id": "workdir-1"}
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
"runtime_id must be an authority id",
|
"runtime_id must be an authority id",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
serde_json::json!({
|
"ticket_id",
|
||||||
"ticket_id": "T-482",
|
serde_json::json!("https://example.test/ticket"),
|
||||||
"runtime_id": "runtime-1",
|
"ticket_id must be an authority id",
|
||||||
"workdir_attachments": [
|
),
|
||||||
{
|
(
|
||||||
"alias": "checkout",
|
"workdir_attachments",
|
||||||
"working_directory_id": "workdir-1",
|
serde_json::json!([
|
||||||
"relative_cwd": "../outside"
|
{"alias": "checkout", "working_directory_id": "workdir-1"},
|
||||||
}
|
{"alias": " checkout ", "working_directory_id": "workdir-2"}
|
||||||
]
|
]),
|
||||||
}),
|
"duplicate Workdir attachment alias `checkout`",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"workdir_attachments",
|
||||||
|
serde_json::json!([
|
||||||
|
{"alias": "checkout", "working_directory_id": "workdir-1"},
|
||||||
|
{"alias": "docs", "working_directory_id": " workdir-1 "}
|
||||||
|
]),
|
||||||
|
"duplicate Workdir attachment `workdir-1`",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"workdir_attachments",
|
||||||
|
serde_json::json!([
|
||||||
|
{"alias": "docs/path", "working_directory_id": "workdir-1"}
|
||||||
|
]),
|
||||||
|
"workdir_attachments.alias must be an authority id",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"workdir_attachments",
|
||||||
|
serde_json::json!([
|
||||||
|
{"alias": "docs", "working_directory_id": "https://example.test/workdir"}
|
||||||
|
]),
|
||||||
|
"workdir_attachments.working_directory_id must be an authority id",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"workdir_attachments",
|
||||||
|
serde_json::json!([
|
||||||
|
{"alias": "checkout", "working_directory_id": "workdir-1", "relative_cwd": "../outside"}
|
||||||
|
]),
|
||||||
"relative_cwd must be a normalized relative path inside the Workdir",
|
"relative_cwd must be a normalized relative path inside the Workdir",
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"workdir_attachments",
|
||||||
|
serde_json::json!([
|
||||||
|
{"alias": "checkout", "working_directory_id": "workdir-1", "access": "write"}
|
||||||
|
]),
|
||||||
|
"unknown field `access`",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"review",
|
||||||
|
serde_json::json!({"ticket_id": "T-719", "merge_request_id": "MR-1"}),
|
||||||
|
"unknown field `review`",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"operation_id",
|
||||||
|
serde_json::json!("caller-claim"),
|
||||||
|
"unknown field `operation_id`",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"display_name",
|
||||||
|
serde_json::json!("Reviewer"),
|
||||||
|
"unknown field `display_name`",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"initial_submit",
|
||||||
|
serde_json::json!([]),
|
||||||
|
"unknown field `initial_submit`",
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
for (index, (field, value, expected)) in cases.into_iter().enumerate() {
|
||||||
for (index, (input, expected)) in cases.into_iter().enumerate() {
|
let mut input = input();
|
||||||
|
input[field] = value;
|
||||||
let error = tool
|
let error = tool
|
||||||
.execute(
|
.execute(
|
||||||
&input.to_string(),
|
&input.to_string(),
|
||||||
@@ -493,10 +516,27 @@ mod tests {
|
|||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
error.to_string().contains(expected),
|
error.to_string().contains(expected),
|
||||||
"unexpected error for case {index}: {error}"
|
"unexpected error for {field}: {error}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
assert!(worker_service.requests.lock().unwrap().is_empty());
|
for field in ["ticket_id", "runtime_id", "profile", "initial_request"] {
|
||||||
|
let mut input = input();
|
||||||
|
input.as_object_mut().unwrap().remove(field);
|
||||||
|
let error = tool
|
||||||
|
.execute(
|
||||||
|
&input.to_string(),
|
||||||
|
ToolExecutionContext::new("call-missing", "batch-validation", 0),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
error
|
||||||
|
.to_string()
|
||||||
|
.contains(&format!("missing field `{field}`")),
|
||||||
|
"{error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(service.requests.lock().unwrap().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -517,26 +557,38 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn input_surface_does_not_expose_profile_flow_or_assignment_controls() {
|
fn spawn_ticket_worker_schema_exposes_configuration_not_claim_or_review_grants() {
|
||||||
let schema = serde_json::to_string(&schemars::schema_for!(SpawnTicketCoderInput)).unwrap();
|
let definition = definition(
|
||||||
for field in [
|
Arc::new(FixedTicketService(TicketWorkflowState::Queued)),
|
||||||
"ticket_id",
|
Arc::new(RecordingService::default()),
|
||||||
"runtime_id",
|
);
|
||||||
"workdir_attachments",
|
let (meta, _) = definition();
|
||||||
"alias",
|
assert_eq!(meta.name, "SpawnTicketWorker");
|
||||||
"working_directory_id",
|
let schema = serde_json::to_value(schemars::schema_for!(SpawnTicketWorkerInput)).unwrap();
|
||||||
"relative_cwd",
|
assert_eq!(schema["additionalProperties"], false);
|
||||||
] {
|
let required = schema["required"].as_array().unwrap();
|
||||||
assert!(schema.contains(field));
|
for field in ["ticket_id", "runtime_id", "profile", "initial_request"] {
|
||||||
|
assert!(
|
||||||
|
required.contains(&serde_json::json!(field)),
|
||||||
|
"missing required field {field}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for field in ["flow", "workdir_attachments"] {
|
||||||
|
assert!(schema["properties"].get(field).is_some());
|
||||||
|
assert!(!required.contains(&serde_json::json!(field)));
|
||||||
}
|
}
|
||||||
for forbidden in [
|
for forbidden in [
|
||||||
"profile",
|
"review",
|
||||||
"selector",
|
|
||||||
"operation_id",
|
"operation_id",
|
||||||
"display_name",
|
"display_name",
|
||||||
"initial_submit",
|
"initial_submit",
|
||||||
|
"claim",
|
||||||
|
"access",
|
||||||
] {
|
] {
|
||||||
assert!(!schema.contains(forbidden), "schema leaked {forbidden}");
|
assert!(
|
||||||
|
schema["properties"].get(forbidden).is_none(),
|
||||||
|
"schema leaked {forbidden}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ struct ModelTicketQueryItem {
|
|||||||
workspace_action_priority: Option<String>,
|
workspace_action_priority: Option<String>,
|
||||||
matched_fields: Vec<String>,
|
matched_fields: Vec<String>,
|
||||||
snippet: Option<String>,
|
snippet: Option<String>,
|
||||||
current_coder: Option<ModelWorkerSummary>,
|
current_worker: Option<ModelWorkerSummary>,
|
||||||
linked_objectives: Vec<String>,
|
linked_objectives: Vec<String>,
|
||||||
relation_count: usize,
|
relation_count: usize,
|
||||||
blocker_count: usize,
|
blocker_count: usize,
|
||||||
@@ -47,7 +47,7 @@ pub(super) struct ModelTicketDetail {
|
|||||||
relations: ModelTicketRelations,
|
relations: ModelTicketRelations,
|
||||||
linked_objectives: Vec<ModelObjectiveSummary>,
|
linked_objectives: Vec<ModelObjectiveSummary>,
|
||||||
assignments: Vec<ModelAssignment>,
|
assignments: Vec<ModelAssignment>,
|
||||||
current_coder: Option<ModelWorkerSummary>,
|
current_worker: Option<ModelWorkerSummary>,
|
||||||
implementation_reports: Vec<ModelEvidenceEvent>,
|
implementation_reports: Vec<ModelEvidenceEvent>,
|
||||||
merge_requests: Vec<ModelMergeRequest>,
|
merge_requests: Vec<ModelMergeRequest>,
|
||||||
merge_request: Option<ModelMergeRequest>,
|
merge_request: Option<ModelMergeRequest>,
|
||||||
@@ -195,7 +195,7 @@ struct ModelTicketActions {
|
|||||||
can_assign_orchestrator: bool,
|
can_assign_orchestrator: bool,
|
||||||
can_unassign_orchestrator: bool,
|
can_unassign_orchestrator: bool,
|
||||||
can_queue: bool,
|
can_queue: bool,
|
||||||
can_start_manual_coder: bool,
|
can_start_manual_worker: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -244,8 +244,8 @@ fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, Stri
|
|||||||
workspace_action_priority: optional_string(item, "workspace_action_priority")?,
|
workspace_action_priority: optional_string(item, "workspace_action_priority")?,
|
||||||
matched_fields: string_array(item, "matched_fields")?,
|
matched_fields: string_array(item, "matched_fields")?,
|
||||||
snippet: optional_string(item, "snippet")?,
|
snippet: optional_string(item, "snippet")?,
|
||||||
current_coder: item
|
current_worker: item
|
||||||
.get("current_coder")
|
.get("current_worker")
|
||||||
.filter(|value| !value.is_null())
|
.filter(|value| !value.is_null())
|
||||||
.map(project_worker)
|
.map(project_worker)
|
||||||
.transpose()?,
|
.transpose()?,
|
||||||
@@ -272,14 +272,14 @@ fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, Stri
|
|||||||
|
|
||||||
pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, String> {
|
pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, String> {
|
||||||
let root = object(&value, "Ticket detail response")?;
|
let root = object(&value, "Ticket detail response")?;
|
||||||
let current_coder = root
|
let current_worker = root
|
||||||
.get("current_coder")
|
.get("current_worker")
|
||||||
.filter(|value| !value.is_null())
|
.filter(|value| !value.is_null())
|
||||||
.map(project_worker)
|
.map(project_worker)
|
||||||
.transpose()?;
|
.transpose()?;
|
||||||
let assignments = array_field(root, "assignments")?
|
let assignments = array_field(root, "assignments")?
|
||||||
.iter()
|
.iter()
|
||||||
.map(|assignment| project_assignment(assignment, current_coder.as_ref()))
|
.map(|assignment| project_assignment(assignment, current_worker.as_ref()))
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
Ok(ModelTicketDetail {
|
Ok(ModelTicketDetail {
|
||||||
@@ -302,7 +302,7 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
|
|||||||
.map(project_objective_summary)
|
.map(project_objective_summary)
|
||||||
.collect::<Result<Vec<_>, _>>()?,
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
assignments,
|
assignments,
|
||||||
current_coder,
|
current_worker,
|
||||||
implementation_reports: array_field(root, "implementation_reports")?
|
implementation_reports: array_field(root, "implementation_reports")?
|
||||||
.iter()
|
.iter()
|
||||||
.map(project_evidence_event)
|
.map(project_evidence_event)
|
||||||
@@ -483,13 +483,13 @@ fn project_ticket_summary(value: &Value) -> Result<ModelTicketSummary, String> {
|
|||||||
|
|
||||||
fn project_assignment(
|
fn project_assignment(
|
||||||
value: &Value,
|
value: &Value,
|
||||||
current_coder: Option<&ModelWorkerSummary>,
|
current_worker: Option<&ModelWorkerSummary>,
|
||||||
) -> Result<ModelAssignment, String> {
|
) -> Result<ModelAssignment, String> {
|
||||||
let assignment = object(value, "Ticket assignment")?;
|
let assignment = object(value, "Ticket assignment")?;
|
||||||
let principal = object_field(assignment, "principal")?;
|
let principal = object_field(assignment, "principal")?;
|
||||||
let kind = string_field(principal, "kind")?;
|
let kind = string_field(principal, "kind")?;
|
||||||
let principal = match kind.as_str() {
|
let principal = match kind.as_str() {
|
||||||
"worker" => current_coder
|
"worker" => current_worker
|
||||||
.map(|coder| coder.worker.clone())
|
.map(|coder| coder.worker.clone())
|
||||||
.ok_or_else(|| "Worker assignment is missing a Workspace key projection".to_string())?,
|
.ok_or_else(|| "Worker assignment is missing a Workspace key projection".to_string())?,
|
||||||
"workspace_agent" => format!("workspace-agent:{}", string_field(principal, "agent_key")?),
|
"workspace_agent" => format!("workspace-agent:{}", string_field(principal, "agent_key")?),
|
||||||
@@ -572,7 +572,7 @@ fn project_actions(value: &Value) -> Result<ModelTicketActions, String> {
|
|||||||
can_assign_orchestrator: bool_field(actions, "can_assign_orchestrator")?,
|
can_assign_orchestrator: bool_field(actions, "can_assign_orchestrator")?,
|
||||||
can_unassign_orchestrator: bool_field(actions, "can_unassign_orchestrator")?,
|
can_unassign_orchestrator: bool_field(actions, "can_unassign_orchestrator")?,
|
||||||
can_queue: bool_field(actions, "can_queue")?,
|
can_queue: bool_field(actions, "can_queue")?,
|
||||||
can_start_manual_coder: bool_field(actions, "can_start_manual_coder")?,
|
can_start_manual_worker: bool_field(actions, "can_start_manual_worker")?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -783,7 +783,7 @@ mod tests {
|
|||||||
"workspace_action_priority": "active_work",
|
"workspace_action_priority": "active_work",
|
||||||
"matched_fields": ["title"],
|
"matched_fields": ["title"],
|
||||||
"snippet": "Ticket",
|
"snippet": "Ticket",
|
||||||
"current_coder": {"runtime_id": "runtime-internal", "worker_id": "worker-internal", "worker_resource_key": "W-12"},
|
"current_worker": {"runtime_id": "runtime-internal", "worker_id": "worker-internal", "worker_resource_key": "W-12"},
|
||||||
"linked_objective_ids": ["00001OBJECTIVEINTERNAL"],
|
"linked_objective_ids": ["00001OBJECTIVEINTERNAL"],
|
||||||
"linked_objective_keys": ["O-6"],
|
"linked_objective_keys": ["O-6"],
|
||||||
"relation_count": 0,
|
"relation_count": 0,
|
||||||
@@ -870,7 +870,7 @@ mod tests {
|
|||||||
"linked_objectives": [],
|
"linked_objectives": [],
|
||||||
"implementation_reports": [],
|
"implementation_reports": [],
|
||||||
"assignments": [],
|
"assignments": [],
|
||||||
"current_coder": null,
|
"current_worker": null,
|
||||||
"merge_requests": [],
|
"merge_requests": [],
|
||||||
"merge_request": null,
|
"merge_request": null,
|
||||||
"evidence": {
|
"evidence": {
|
||||||
@@ -889,7 +889,7 @@ mod tests {
|
|||||||
"can_assign_orchestrator": true,
|
"can_assign_orchestrator": true,
|
||||||
"can_unassign_orchestrator": false,
|
"can_unassign_orchestrator": false,
|
||||||
"can_queue": false,
|
"can_queue": false,
|
||||||
"can_start_manual_coder": false
|
"can_start_manual_worker": false
|
||||||
},
|
},
|
||||||
"event_page": {"next_cursor": null, "has_more": false}
|
"event_page": {"next_cursor": null, "has_more": false}
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -750,9 +750,9 @@ impl SqliteWorkspaceAuthority {
|
|||||||
assigned_at: assignment.assigned_at,
|
assigned_at: assignment.assigned_at,
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let current_coder = role_assignments
|
let current_worker = role_assignments
|
||||||
.iter()
|
.iter()
|
||||||
.find(|assignment| assignment.role == TicketAssignmentRole::Coder)
|
.find(|assignment| assignment.role == TicketAssignmentRole::Worker)
|
||||||
.and_then(|assignment| {
|
.and_then(|assignment| {
|
||||||
assignment
|
assignment
|
||||||
.principal
|
.principal
|
||||||
@@ -779,9 +779,9 @@ impl SqliteWorkspaceAuthority {
|
|||||||
let has_orchestrator = active_role_assignments
|
let has_orchestrator = active_role_assignments
|
||||||
.iter()
|
.iter()
|
||||||
.any(|assignment| assignment.role == TicketAssignmentRole::Orchestrator);
|
.any(|assignment| assignment.role == TicketAssignmentRole::Orchestrator);
|
||||||
let has_coder = active_role_assignments
|
let has_worker = active_role_assignments
|
||||||
.iter()
|
.iter()
|
||||||
.any(|assignment| assignment.role == TicketAssignmentRole::Coder);
|
.any(|assignment| assignment.role == TicketAssignmentRole::Worker);
|
||||||
let targets = ticket
|
let targets = ticket
|
||||||
.meta
|
.meta
|
||||||
.targets
|
.targets
|
||||||
@@ -799,7 +799,6 @@ impl SqliteWorkspaceAuthority {
|
|||||||
})
|
})
|
||||||
.collect::<Result<Vec<_>>>()?;
|
.collect::<Result<Vec<_>>>()?;
|
||||||
let write_repository_keys = ticket_write_target_repository_keys(&targets);
|
let write_repository_keys = ticket_write_target_repository_keys(&targets);
|
||||||
let has_target = !write_repository_keys.is_empty();
|
|
||||||
let has_blockers = !ticket.relations.blockers.is_empty();
|
let has_blockers = !ticket.relations.blockers.is_empty();
|
||||||
let mut assignment_diagnostics = Vec::new();
|
let mut assignment_diagnostics = Vec::new();
|
||||||
if let Some(legacy_assignee) = ticket
|
if let Some(legacy_assignee) = ticket
|
||||||
@@ -818,17 +817,20 @@ impl SqliteWorkspaceAuthority {
|
|||||||
ticket.meta.workflow_state,
|
ticket.meta.workflow_state,
|
||||||
TicketWorkflowState::Planning | TicketWorkflowState::Ready
|
TicketWorkflowState::Planning | TicketWorkflowState::Ready
|
||||||
) && !has_orchestrator
|
) && !has_orchestrator
|
||||||
&& !has_coder,
|
&& !has_worker,
|
||||||
can_unassign_orchestrator: has_orchestrator
|
can_unassign_orchestrator: has_orchestrator
|
||||||
&& matches!(
|
&& matches!(
|
||||||
ticket.meta.workflow_state,
|
ticket.meta.workflow_state,
|
||||||
TicketWorkflowState::Planning | TicketWorkflowState::Ready
|
TicketWorkflowState::Planning | TicketWorkflowState::Ready
|
||||||
),
|
),
|
||||||
can_queue: true,
|
can_queue: true,
|
||||||
can_start_manual_coder: ticket.meta.workflow_state == TicketWorkflowState::Ready
|
can_start_manual_worker: matches!(
|
||||||
&& !has_orchestrator
|
ticket.meta.workflow_state,
|
||||||
&& !has_coder
|
TicketWorkflowState::Planning
|
||||||
&& has_target
|
| TicketWorkflowState::Ready
|
||||||
|
| TicketWorkflowState::Queued
|
||||||
|
| TicketWorkflowState::InProgress
|
||||||
|
) && !has_worker
|
||||||
&& !has_blockers,
|
&& !has_blockers,
|
||||||
queue_tickets: dependency_check.queue_tickets.clone(),
|
queue_tickets: dependency_check.queue_tickets.clone(),
|
||||||
blockers: action_blockers,
|
blockers: action_blockers,
|
||||||
@@ -960,7 +962,7 @@ impl SqliteWorkspaceAuthority {
|
|||||||
linked_objectives,
|
linked_objectives,
|
||||||
implementation_reports,
|
implementation_reports,
|
||||||
assignments,
|
assignments,
|
||||||
current_coder,
|
current_worker,
|
||||||
assignment_diagnostics,
|
assignment_diagnostics,
|
||||||
action_eligibility,
|
action_eligibility,
|
||||||
merge_requests,
|
merge_requests,
|
||||||
@@ -2353,7 +2355,7 @@ fn ticket_query_item(
|
|||||||
evidence: detail.evidence.clone(),
|
evidence: detail.evidence.clone(),
|
||||||
merge_requests: detail.merge_requests.clone(),
|
merge_requests: detail.merge_requests.clone(),
|
||||||
merge_request: detail.merge_request.clone(),
|
merge_request: detail.merge_request.clone(),
|
||||||
current_coder: detail.current_coder.clone(),
|
current_worker: detail.current_worker.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3193,6 +3195,143 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn manual_worker_eligibility_accepts_explicit_states_without_reviving_released_work() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("workspace.db");
|
||||||
|
let store = SqliteWorkspaceStore::open(&path).unwrap();
|
||||||
|
store
|
||||||
|
.upsert_workspace(&WorkspaceRecord {
|
||||||
|
workspace_id: "w".into(),
|
||||||
|
owner_account_id: "owner-account".into(),
|
||||||
|
display_name: "W".into(),
|
||||||
|
state: "active".into(),
|
||||||
|
created_at: "1".into(),
|
||||||
|
updated_at: "1".into(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let conn = rusqlite::Connection::open(&path).unwrap();
|
||||||
|
conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
|
||||||
|
store
|
||||||
|
.upsert_worker_registry(&crate::store::WorkerRegistryRecord {
|
||||||
|
workspace_id: "w".into(),
|
||||||
|
worker: worker_runtime::identity::RuntimeWorkerRef::new("runtime", "worker"),
|
||||||
|
display_name: "Worker".into(),
|
||||||
|
profile: Some("builtin:default".into()),
|
||||||
|
retention_state: "normal".into(),
|
||||||
|
transcript_ref: None,
|
||||||
|
session_ref: None,
|
||||||
|
summary_ref: None,
|
||||||
|
diagnostics_ref: None,
|
||||||
|
created_at: "1".into(),
|
||||||
|
updated_at: "1".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let backend = SqliteTicketBackend::open_verified(&path, "w").unwrap();
|
||||||
|
let ticket = backend
|
||||||
|
.create(ticket::NewTicket::new("Explicit work"))
|
||||||
|
.unwrap();
|
||||||
|
let authority = SqliteWorkspaceAuthority::new(&path, "w").unwrap();
|
||||||
|
for (state, allowed) in [
|
||||||
|
("planning", true),
|
||||||
|
("ready", true),
|
||||||
|
("queued", true),
|
||||||
|
("inprogress", true),
|
||||||
|
("done", false),
|
||||||
|
("closed", false),
|
||||||
|
] {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE typed_tickets SET workflow_state=?1 WHERE ticket_id=?2",
|
||||||
|
rusqlite::params![state, ticket.id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
authority
|
||||||
|
.ticket(&ticket.id)
|
||||||
|
.unwrap()
|
||||||
|
.action_eligibility
|
||||||
|
.can_start_manual_worker,
|
||||||
|
allowed,
|
||||||
|
"{state}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE typed_tickets SET workflow_state='planning' WHERE ticket_id=?1",
|
||||||
|
[&ticket.id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let record = crate::store::TicketRoleAssignmentRecord {
|
||||||
|
workspace_id: "w".into(),
|
||||||
|
ticket_id: ticket.id.clone(),
|
||||||
|
assignment_id: "assignment".into(),
|
||||||
|
role: TicketAssignmentRole::Worker,
|
||||||
|
principal: TicketAssignmentPrincipal::Worker {
|
||||||
|
runtime_id: "runtime".into(),
|
||||||
|
worker_id: "worker".into(),
|
||||||
|
},
|
||||||
|
assigned_by: "owner-account".into(),
|
||||||
|
assigned_at: "2".into(),
|
||||||
|
};
|
||||||
|
let orchestrator = crate::store::TicketRoleAssignmentRecord {
|
||||||
|
role: TicketAssignmentRole::Orchestrator,
|
||||||
|
assignment_id: "orchestrator".into(),
|
||||||
|
principal: TicketAssignmentPrincipal::WorkspaceAgent {
|
||||||
|
agent_key: "workspace-orchestrator".into(),
|
||||||
|
},
|
||||||
|
..record.clone()
|
||||||
|
};
|
||||||
|
store
|
||||||
|
.set_current_ticket_role_assignment(
|
||||||
|
&orchestrator,
|
||||||
|
None,
|
||||||
|
"orchestrator-event",
|
||||||
|
"orchestrator-op",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
authority
|
||||||
|
.ticket(&ticket.id)
|
||||||
|
.unwrap()
|
||||||
|
.action_eligibility
|
||||||
|
.can_start_manual_worker
|
||||||
|
);
|
||||||
|
store
|
||||||
|
.start_ticket_with_worker_assignment(&record, "event", "operation", None)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
!authority
|
||||||
|
.ticket(&ticket.id)
|
||||||
|
.unwrap()
|
||||||
|
.action_eligibility
|
||||||
|
.can_start_manual_worker
|
||||||
|
);
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE typed_tickets SET workflow_state='closed' WHERE ticket_id=?1",
|
||||||
|
[&ticket.id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE typed_tickets SET workflow_state='planning' WHERE ticket_id=?1",
|
||||||
|
[&ticket.id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let detail = authority.ticket(&ticket.id).unwrap();
|
||||||
|
assert!(detail.action_eligibility.can_start_manual_worker);
|
||||||
|
assert_eq!(detail.assignments.len(), 2);
|
||||||
|
let worker = detail
|
||||||
|
.assignments
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.role == "worker")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(worker.assignment_id, record.assignment_id);
|
||||||
|
assert_eq!(
|
||||||
|
detail.current_worker.unwrap().assignment_id,
|
||||||
|
record.assignment_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn sqlite_workspace_authority_reads_sqlite_records_without_filesystem_authority() {
|
async fn sqlite_workspace_authority_reads_sqlite_records_without_filesystem_authority() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -484,7 +484,7 @@ pub(crate) fn worker_spawn_idempotency(
|
|||||||
let Some(assignment) = request.ticket_assignment.as_ref() else {
|
let Some(assignment) = request.ticket_assignment.as_ref() else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let encoded = serde_json::to_vec(request)
|
let encoded = serde_json::to_vec(&(request, request.resolved_workdir_attachments.as_slice()))
|
||||||
.map_err(|error| format!("serialize Worker spawn idempotency input: {error}"))?;
|
.map_err(|error| format!("serialize Worker spawn idempotency input: {error}"))?;
|
||||||
Ok(Some((
|
Ok(Some((
|
||||||
assignment.operation_id.clone(),
|
assignment.operation_id.clone(),
|
||||||
@@ -578,7 +578,7 @@ pub enum WorkerSpawnIntent {
|
|||||||
pub enum TicketWorkerRole {
|
pub enum TicketWorkerRole {
|
||||||
Intake,
|
Intake,
|
||||||
Orchestrator,
|
Orchestrator,
|
||||||
Coder,
|
Worker,
|
||||||
Reviewer,
|
Reviewer,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6456,7 +6456,7 @@ fn worker_spawn_intent_label(intent: &WorkerSpawnIntent) -> &'static str {
|
|||||||
WorkerSpawnIntent::TicketRole { role, .. } => match role {
|
WorkerSpawnIntent::TicketRole { role, .. } => match role {
|
||||||
TicketWorkerRole::Intake => "ticket_intake",
|
TicketWorkerRole::Intake => "ticket_intake",
|
||||||
TicketWorkerRole::Orchestrator => "ticket_orchestrator",
|
TicketWorkerRole::Orchestrator => "ticket_orchestrator",
|
||||||
TicketWorkerRole::Coder => "ticket_coder",
|
TicketWorkerRole::Worker => "ticket_worker",
|
||||||
TicketWorkerRole::Reviewer => "ticket_reviewer",
|
TicketWorkerRole::Reviewer => "ticket_reviewer",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -6521,6 +6521,27 @@ mod tests {
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ticket_worker_spawn_intent_uses_worker_wire_role() {
|
||||||
|
let intent = WorkerSpawnIntent::TicketRole {
|
||||||
|
ticket_id: "ticket".into(),
|
||||||
|
role: TicketWorkerRole::Worker,
|
||||||
|
};
|
||||||
|
let encoded = json!({"kind": "ticket_role", "ticket_id": "ticket", "role": "worker"});
|
||||||
|
assert_eq!(serde_json::to_value(&intent).unwrap(), encoded);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_value::<WorkerSpawnIntent>(encoded).unwrap(),
|
||||||
|
intent
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<WorkerSpawnIntent>(
|
||||||
|
json!({"kind": "ticket_role", "ticket_id": "ticket", "role": "coder"})
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(worker_spawn_intent_label(&intent), "ticket_worker");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn remote_restore_transport_failure_requires_reconciliation() {
|
fn remote_restore_transport_failure_requires_reconciliation() {
|
||||||
let provider = RemoteWorkerRuntime::new(
|
let provider = RemoteWorkerRuntime::new(
|
||||||
@@ -7538,7 +7559,7 @@ mod tests {
|
|||||||
WorkerSpawnRequest {
|
WorkerSpawnRequest {
|
||||||
intent: WorkerSpawnIntent::TicketRole {
|
intent: WorkerSpawnIntent::TicketRole {
|
||||||
ticket_id: "00001KVZSGT0Q".to_string(),
|
ticket_id: "00001KVZSGT0Q".to_string(),
|
||||||
role: TicketWorkerRole::Coder,
|
role: TicketWorkerRole::Worker,
|
||||||
},
|
},
|
||||||
requested_worker_name: None,
|
requested_worker_name: None,
|
||||||
singleton_key: None,
|
singleton_key: None,
|
||||||
@@ -7827,7 +7848,7 @@ mod tests {
|
|||||||
WorkerSpawnRequest {
|
WorkerSpawnRequest {
|
||||||
intent: WorkerSpawnIntent::TicketRole {
|
intent: WorkerSpawnIntent::TicketRole {
|
||||||
ticket_id: "00001KVZSGT0Q".to_string(),
|
ticket_id: "00001KVZSGT0Q".to_string(),
|
||||||
role: TicketWorkerRole::Coder,
|
role: TicketWorkerRole::Worker,
|
||||||
},
|
},
|
||||||
requested_worker_name: Some("friendly-name-is-not-authority".to_string()),
|
requested_worker_name: Some("friendly-name-is-not-authority".to_string()),
|
||||||
singleton_key: None,
|
singleton_key: None,
|
||||||
@@ -7939,7 +7960,7 @@ mod tests {
|
|||||||
WorkerSpawnRequest {
|
WorkerSpawnRequest {
|
||||||
intent: WorkerSpawnIntent::TicketRole {
|
intent: WorkerSpawnIntent::TicketRole {
|
||||||
ticket_id: "00001KVZSGT0Q".to_string(),
|
ticket_id: "00001KVZSGT0Q".to_string(),
|
||||||
role: TicketWorkerRole::Coder,
|
role: TicketWorkerRole::Worker,
|
||||||
},
|
},
|
||||||
requested_worker_name: None,
|
requested_worker_name: None,
|
||||||
singleton_key: None,
|
singleton_key: None,
|
||||||
|
|||||||
@@ -335,8 +335,8 @@ CREATE TABLE ticket_assignment_operations (
|
|||||||
operation_id TEXT NOT NULL,
|
operation_id TEXT NOT NULL,
|
||||||
action TEXT NOT NULL CHECK (action IN ('assign', 'reassign', 'unassign')),
|
action TEXT NOT NULL CHECK (action IN ('assign', 'reassign', 'unassign')),
|
||||||
ticket_id TEXT NOT NULL,
|
ticket_id TEXT NOT NULL,
|
||||||
role TEXT NOT NULL DEFAULT 'coder'
|
role TEXT NOT NULL DEFAULT 'worker'
|
||||||
CHECK(role IN ('orchestrator', 'coder', 'owner', 'contributor')),
|
CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')),
|
||||||
principal_kind TEXT NOT NULL DEFAULT 'worker'
|
principal_kind TEXT NOT NULL DEFAULT 'worker'
|
||||||
CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')),
|
CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')),
|
||||||
principal_id TEXT,
|
principal_id TEXT,
|
||||||
@@ -346,8 +346,14 @@ CREATE TABLE ticket_assignment_operations (
|
|||||||
expected_assignment_id TEXT,
|
expected_assignment_id TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
request_fingerprint TEXT,
|
request_fingerprint TEXT,
|
||||||
|
claim_state TEXT NOT NULL DEFAULT 'committed'
|
||||||
|
CHECK(claim_state IN ('pending', 'failed', 'committed')),
|
||||||
|
failure_reason TEXT,
|
||||||
|
binding_recovery_json TEXT,
|
||||||
PRIMARY KEY (workspace_id, operation_id),
|
PRIMARY KEY (workspace_id, operation_id),
|
||||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||||
|
CHECK(claim_state = 'committed' OR assignment_id IS NULL),
|
||||||
|
CHECK((claim_state = 'failed') = (failure_reason IS NOT NULL))
|
||||||
);
|
);
|
||||||
CREATE TABLE ticket_assignment_ticket_tombstones (
|
CREATE TABLE ticket_assignment_ticket_tombstones (
|
||||||
workspace_id TEXT NOT NULL,
|
workspace_id TEXT NOT NULL,
|
||||||
@@ -367,8 +373,8 @@ CREATE TABLE ticket_assignment_worker_tombstones (
|
|||||||
CREATE TABLE ticket_current_worker_assignments (
|
CREATE TABLE ticket_current_worker_assignments (
|
||||||
workspace_id TEXT NOT NULL,
|
workspace_id TEXT NOT NULL,
|
||||||
ticket_id TEXT NOT NULL,
|
ticket_id TEXT NOT NULL,
|
||||||
role TEXT NOT NULL DEFAULT 'coder'
|
role TEXT NOT NULL DEFAULT 'worker'
|
||||||
CHECK(role IN ('orchestrator', 'coder', 'owner', 'contributor')),
|
CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')),
|
||||||
assignment_id TEXT NOT NULL,
|
assignment_id TEXT NOT NULL,
|
||||||
principal_kind TEXT NOT NULL DEFAULT 'worker'
|
principal_kind TEXT NOT NULL DEFAULT 'worker'
|
||||||
CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')),
|
CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')),
|
||||||
@@ -393,8 +399,8 @@ CREATE TABLE ticket_current_worker_assignments (
|
|||||||
CREATE TABLE ticket_worker_assignment_events (
|
CREATE TABLE ticket_worker_assignment_events (
|
||||||
workspace_id TEXT NOT NULL,
|
workspace_id TEXT NOT NULL,
|
||||||
ticket_id TEXT NOT NULL,
|
ticket_id TEXT NOT NULL,
|
||||||
role TEXT NOT NULL DEFAULT 'coder'
|
role TEXT NOT NULL DEFAULT 'worker'
|
||||||
CHECK(role IN ('orchestrator', 'coder', 'owner', 'contributor')),
|
CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')),
|
||||||
event_id TEXT NOT NULL,
|
event_id TEXT NOT NULL,
|
||||||
action TEXT NOT NULL CHECK (action IN ('assigned', 'reassigned', 'unassigned')),
|
action TEXT NOT NULL CHECK (action IN ('assigned', 'reassigned', 'unassigned')),
|
||||||
assignment_id TEXT,
|
assignment_id TEXT,
|
||||||
@@ -410,8 +416,8 @@ CREATE TABLE ticket_worker_assignments (
|
|||||||
workspace_id TEXT NOT NULL,
|
workspace_id TEXT NOT NULL,
|
||||||
ticket_id TEXT NOT NULL,
|
ticket_id TEXT NOT NULL,
|
||||||
assignment_id TEXT NOT NULL,
|
assignment_id TEXT NOT NULL,
|
||||||
role TEXT NOT NULL DEFAULT 'coder'
|
role TEXT NOT NULL DEFAULT 'worker'
|
||||||
CHECK(role IN ('orchestrator', 'coder', 'owner', 'contributor')),
|
CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')),
|
||||||
principal_kind TEXT NOT NULL DEFAULT 'worker'
|
principal_kind TEXT NOT NULL DEFAULT 'worker'
|
||||||
CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')),
|
CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')),
|
||||||
principal_id TEXT,
|
principal_id TEXT,
|
||||||
@@ -1215,7 +1221,7 @@ CREATE INDEX ticket_current_principal_idx
|
|||||||
ON ticket_current_worker_assignments(workspace_id, principal_kind, principal_id, runtime_id, worker_id);
|
ON ticket_current_worker_assignments(workspace_id, principal_kind, principal_id, runtime_id, worker_id);
|
||||||
CREATE UNIQUE INDEX ticket_current_singleton_role_idx
|
CREATE UNIQUE INDEX ticket_current_singleton_role_idx
|
||||||
ON ticket_current_worker_assignments(workspace_id, ticket_id, role)
|
ON ticket_current_worker_assignments(workspace_id, ticket_id, role)
|
||||||
WHERE role IN ('orchestrator', 'coder');
|
WHERE role IN ('orchestrator', 'worker');
|
||||||
CREATE INDEX typed_ticket_events_workspace_kind_ticket
|
CREATE INDEX typed_ticket_events_workspace_kind_ticket
|
||||||
ON typed_ticket_events(workspace_id, kind, ticket_id, event_index);
|
ON typed_ticket_events(workspace_id, kind, ticket_id, event_index);
|
||||||
CREATE UNIQUE INDEX ux_worker_workdir_attachment_reservation_id
|
CREATE UNIQUE INDEX ux_worker_workdir_attachment_reservation_id
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ pub struct WorkerRemovalPlanRequest {
|
|||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
pub enum WorkerRemovalBlocker {
|
pub enum WorkerRemovalBlocker {
|
||||||
Hold,
|
Hold,
|
||||||
|
PendingTicketClaim {
|
||||||
|
operation_id: String,
|
||||||
|
ticket_id: String,
|
||||||
|
},
|
||||||
UnfinishedWork {
|
UnfinishedWork {
|
||||||
assignment_id: String,
|
assignment_id: String,
|
||||||
ticket_id: String,
|
ticket_id: String,
|
||||||
@@ -232,6 +236,9 @@ impl SqliteWorkspaceStore {
|
|||||||
if let Some((assignment_id,ticket_id))=tx.query_row("SELECT a.assignment_id,a.ticket_id FROM ticket_active_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3",params![req.workspace_id,req.worker.runtime_id,req.worker.worker_id],|r|Ok((r.get(0)?,r.get(1)?))).optional()? {
|
if let Some((assignment_id,ticket_id))=tx.query_row("SELECT a.assignment_id,a.ticket_id FROM ticket_active_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3",params![req.workspace_id,req.worker.runtime_id,req.worker.worker_id],|r|Ok((r.get(0)?,r.get(1)?))).optional()? {
|
||||||
blockers.push(WorkerRemovalBlocker::UnfinishedWork{assignment_id,ticket_id});
|
blockers.push(WorkerRemovalBlocker::UnfinishedWork{assignment_id,ticket_id});
|
||||||
}
|
}
|
||||||
|
if let Some((operation_id,ticket_id))=tx.query_row("SELECT operation_id,ticket_id FROM ticket_assignment_operations WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND claim_state='pending' AND action IN ('assign','reassign')",params![req.workspace_id,req.worker.runtime_id,req.worker.worker_id],|r|Ok((r.get(0)?,r.get(1)?))).optional()? {
|
||||||
|
blockers.push(WorkerRemovalBlocker::PendingTicketClaim{operation_id,ticket_id});
|
||||||
|
}
|
||||||
let fp=fingerprint(req,&worker.updated_at,inv,&policy,&blockers)?;
|
let fp=fingerprint(req,&worker.updated_at,inv,&policy,&blockers)?;
|
||||||
let plan_id=stable("wrp",&fp); let operation_id=stable("wro",&fp);
|
let plan_id=stable("wrp",&fp); let operation_id=stable("wro",&fp);
|
||||||
let archive_id=(policy.session_disposition==SessionDisposition::Archive).then(||stable("wra",&fp));
|
let archive_id=(policy.session_disposition==SessionDisposition::Archive).then(||stable("wra",&fp));
|
||||||
@@ -276,7 +283,7 @@ impl SqliteWorkspaceStore {
|
|||||||
mark_stale(&tx,&plan,"hold added")?;tx.commit()?;
|
mark_stale(&tx,&plan,"hold added")?;tx.commit()?;
|
||||||
return Err(stale_error(&plan,"hold added"));
|
return Err(stale_error(&plan,"hold added"));
|
||||||
}
|
}
|
||||||
let assigned:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3)",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id],|r|r.get(0))?;
|
let assigned:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3 UNION ALL SELECT 1 FROM ticket_assignment_operations WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND claim_state='pending' AND action IN ('assign','reassign'))",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id],|r|r.get(0))?;
|
||||||
if assigned{
|
if assigned{
|
||||||
mark_stale(&tx,&plan,"unfinished work added")?;tx.commit()?;
|
mark_stale(&tx,&plan,"unfinished work added")?;tx.commit()?;
|
||||||
return Err(stale_error(&plan,"unfinished work added"));
|
return Err(stale_error(&plan,"unfinished work added"));
|
||||||
@@ -462,7 +469,7 @@ impl SqliteWorkspaceStore {
|
|||||||
let worker=load_worker(&tx,workspace_id,&plan.worker)?.ok_or_else(||StoreError::InvalidInput("Worker missing before commit".into()))?;
|
let worker=load_worker(&tx,workspace_id,&plan.worker)?.ok_or_else(||StoreError::InvalidInput("Worker missing before commit".into()))?;
|
||||||
if worker.updated_at!=plan.worker_revision{return Err(StoreError::InvalidInput(format!("stale:{}:Worker revision changed",plan.plan_id)));}
|
if worker.updated_at!=plan.worker_revision{return Err(StoreError::InvalidInput(format!("stale:{}:Worker revision changed",plan.plan_id)));}
|
||||||
if worker.retention_state=="pinned" { return Err(StoreError::InvalidInput(format!("stale:{}:hold added",plan.plan_id))); }
|
if worker.retention_state=="pinned" { return Err(StoreError::InvalidInput(format!("stale:{}:hold added",plan.plan_id))); }
|
||||||
let assigned:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3)",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id],|row|row.get(0))?;
|
let assigned:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3 UNION ALL SELECT 1 FROM ticket_assignment_operations WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND claim_state='pending' AND action IN ('assign','reassign'))",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id],|row|row.get(0))?;
|
||||||
if assigned { return Err(StoreError::InvalidInput(format!("stale:{}:unfinished work added",plan.plan_id))); }
|
if assigned { return Err(StoreError::InvalidInput(format!("stale:{}:unfinished work added",plan.plan_id))); }
|
||||||
let now=Utc::now().to_rfc3339();
|
let now=Utc::now().to_rfc3339();
|
||||||
if let Some(a)=&result.archive{
|
if let Some(a)=&result.archive{
|
||||||
@@ -1092,7 +1099,7 @@ fn parse_state(v: &str) -> rusqlite::Result<WorkerRemovalPlanState> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::store::{
|
use crate::store::{
|
||||||
ControlPlaneStore, TicketCoderAssignmentRecord, WorkerCatalogChange, WorkerRegistryRecord,
|
ControlPlaneStore, TicketWorkerAssignmentRecord, WorkerCatalogChange, WorkerRegistryRecord,
|
||||||
};
|
};
|
||||||
use worker_runtime::identity::WorkerId;
|
use worker_runtime::identity::WorkerId;
|
||||||
fn worker_id() -> WorkerId {
|
fn worker_id() -> WorkerId {
|
||||||
@@ -1431,7 +1438,7 @@ mod tests {
|
|||||||
s: &SqliteWorkspaceStore,
|
s: &SqliteWorkspaceStore,
|
||||||
assignment_id: &str,
|
assignment_id: &str,
|
||||||
) -> crate::Result<()> {
|
) -> crate::Result<()> {
|
||||||
let assignment = TicketCoderAssignmentRecord {
|
let assignment = TicketWorkerAssignmentRecord {
|
||||||
workspace_id: "w".into(),
|
workspace_id: "w".into(),
|
||||||
ticket_id: "ticket".into(),
|
ticket_id: "ticket".into(),
|
||||||
assignment_id: assignment_id.into(),
|
assignment_id: assignment_id.into(),
|
||||||
@@ -1439,7 +1446,7 @@ mod tests {
|
|||||||
assigned_by: "test".into(),
|
assigned_by: "test".into(),
|
||||||
assigned_at: "t".into(),
|
assigned_at: "t".into(),
|
||||||
};
|
};
|
||||||
s.set_current_ticket_coder_assignment(
|
s.set_current_ticket_worker_assignment(
|
||||||
&assignment,
|
&assignment,
|
||||||
None,
|
None,
|
||||||
&format!("event-{assignment_id}"),
|
&format!("event-{assignment_id}"),
|
||||||
@@ -1474,6 +1481,38 @@ mod tests {
|
|||||||
store
|
store
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pending_ticket_claim_blocks_removal_and_is_rechecked_after_planning() {
|
||||||
|
let s = setup();
|
||||||
|
let old = s.plan_worker_removal(&req(), &inv()).unwrap();
|
||||||
|
s.reserve_ticket_assignment_operation(
|
||||||
|
"w",
|
||||||
|
"claim",
|
||||||
|
"ticket",
|
||||||
|
"r",
|
||||||
|
Some(&worker_id().to_string()),
|
||||||
|
"intent",
|
||||||
|
"t",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let blocked = s.plan_worker_removal(&req(), &inv()).unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(&blocked.blockers[..], [WorkerRemovalBlocker::PendingTicketClaim{operation_id,ticket_id}] if operation_id=="claim" && ticket_id=="ticket")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
s.begin_worker_removal("w", &old.plan_id, &old.input_fingerprint)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
s.fail_ticket_assignment_operation("w", "claim", "No Runtime effects dispatched")
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
s.plan_worker_removal(&req(), &inv())
|
||||||
|
.unwrap()
|
||||||
|
.blockers
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn assignment_and_orphan_are_authoritative() {
|
fn assignment_and_orphan_are_authoritative() {
|
||||||
let s = setup();
|
let s = setup();
|
||||||
@@ -1758,7 +1797,7 @@ mod tests {
|
|||||||
let replaced = tx.execute(
|
let replaced = tx.execute(
|
||||||
"UPDATE ticket_current_worker_assignments
|
"UPDATE ticket_current_worker_assignments
|
||||||
SET assignment_id='new-assignment', updated_at='new'
|
SET assignment_id='new-assignment', updated_at='new'
|
||||||
WHERE workspace_id='w' AND ticket_id='ticket' AND role='coder'
|
WHERE workspace_id='w' AND ticket_id='ticket' AND role='worker'
|
||||||
AND assignment_id='assignment'
|
AND assignment_id='assignment'
|
||||||
AND EXISTS (
|
AND EXISTS (
|
||||||
SELECT 1 FROM ticket_assignment_work_releases
|
SELECT 1 FROM ticket_assignment_work_releases
|
||||||
@@ -2088,7 +2127,7 @@ mod tests {
|
|||||||
).map_err(StoreError::from)).unwrap();
|
).map_err(StoreError::from)).unwrap();
|
||||||
assert_eq!(revision, "rev1");
|
assert_eq!(revision, "rev1");
|
||||||
|
|
||||||
let assignment = TicketCoderAssignmentRecord {
|
let assignment = TicketWorkerAssignmentRecord {
|
||||||
workspace_id: "w".into(),
|
workspace_id: "w".into(),
|
||||||
ticket_id: "new-ticket".into(),
|
ticket_id: "new-ticket".into(),
|
||||||
assignment_id: "new-assignment".into(),
|
assignment_id: "new-assignment".into(),
|
||||||
@@ -2101,7 +2140,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert!(
|
assert!(
|
||||||
store
|
store
|
||||||
.set_current_ticket_coder_assignment(
|
.set_current_ticket_worker_assignment(
|
||||||
&assignment,
|
&assignment,
|
||||||
None,
|
None,
|
||||||
"event",
|
"event",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -74,8 +74,8 @@ impl EvidenceApiFixture {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let assignment_id = "evidence-assignment".to_string();
|
let assignment_id = "evidence-assignment".to_string();
|
||||||
api.store
|
api.store
|
||||||
.set_current_ticket_coder_assignment(
|
.set_current_ticket_worker_assignment(
|
||||||
&TicketCoderAssignmentRecord {
|
&TicketWorkerAssignmentRecord {
|
||||||
workspace_id: TEST_WORKSPACE_ID.into(),
|
workspace_id: TEST_WORKSPACE_ID.into(),
|
||||||
ticket_id: ticket_id.clone(),
|
ticket_id: ticket_id.clone(),
|
||||||
assignment_id: assignment_id.clone(),
|
assignment_id: assignment_id.clone(),
|
||||||
@@ -319,7 +319,7 @@ async fn done_merged_remote_ticket_uses_immutable_result_in_show_query_list_and_
|
|||||||
fixture
|
fixture
|
||||||
.api
|
.api
|
||||||
.store
|
.store
|
||||||
.get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id)
|
.get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.is_some()
|
.is_some()
|
||||||
);
|
);
|
||||||
@@ -512,7 +512,7 @@ async fn open_source_without_runtime_is_typed_unavailable_not_stale_after_rescop
|
|||||||
fixture
|
fixture
|
||||||
.api
|
.api
|
||||||
.store
|
.store
|
||||||
.get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id)
|
.get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.is_some()
|
.is_some()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,657 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
async fn claim(
|
||||||
|
fixture: &ManualCoderAssignmentFixture,
|
||||||
|
request: server_api::SetTicketRoleAssignmentRequest,
|
||||||
|
) -> ApiResult<Json<server_api::TicketRoleAssignmentMutationResponse>> {
|
||||||
|
scoped_set_ticket_assignment(
|
||||||
|
State(fixture.api.clone()),
|
||||||
|
AxumPath((
|
||||||
|
TEST_WORKSPACE_ID.into(),
|
||||||
|
fixture.ticket_id.clone(),
|
||||||
|
"worker".into(),
|
||||||
|
)),
|
||||||
|
Json(request),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn explicit_existing_worker_claim_accepts_no_resources_and_preserves_assignment_on_retry() {
|
||||||
|
let fixture = manual_worker_assignment_fixture().await;
|
||||||
|
for id in [&fixture.main_workdir_id, &fixture.docs_workdir_id] {
|
||||||
|
fixture
|
||||||
|
.api
|
||||||
|
.store
|
||||||
|
.detach_worker_workdir(
|
||||||
|
TEST_WORKSPACE_ID,
|
||||||
|
&fixture.worker,
|
||||||
|
Some(id),
|
||||||
|
TEST_CREATED_AT,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
sync_runtime_worker_workdir_attachments(&fixture.api, &fixture.worker).unwrap();
|
||||||
|
let backend = browser_ticket_backend(&fixture.api).unwrap();
|
||||||
|
backend
|
||||||
|
.edit_item(
|
||||||
|
fixture.ticket_id.clone().into(),
|
||||||
|
TicketItemEdit {
|
||||||
|
targets: Some(TicketTargetsEdit::Clear),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
backend
|
||||||
|
.set_workflow_state(
|
||||||
|
fixture.ticket_id.clone().into(),
|
||||||
|
TicketStateChange::new(
|
||||||
|
"ready",
|
||||||
|
"planning",
|
||||||
|
"Explicit investigation",
|
||||||
|
"No repository resources required",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let request = manual_worker_assignment_request(&fixture, "claim-without-resources");
|
||||||
|
assert!(request.workdir_bindings.is_empty());
|
||||||
|
let Json(first) = claim(&fixture, request.clone()).await.unwrap();
|
||||||
|
let Json(replay) = claim(&fixture, request).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
first.assignment.unwrap().assignment_id,
|
||||||
|
replay.assignment.unwrap().assignment_id
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fixture
|
||||||
|
.api
|
||||||
|
.authority
|
||||||
|
.ticket(&fixture.ticket_id)
|
||||||
|
.unwrap()
|
||||||
|
.state,
|
||||||
|
"inprogress"
|
||||||
|
);
|
||||||
|
assert_eq!(*fixture.runtime.restore_attempts.lock().unwrap(), 1);
|
||||||
|
assert!(
|
||||||
|
fixture
|
||||||
|
.runtime
|
||||||
|
.logical_attachments(&fixture.worker.worker_id)
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn explicit_existing_worker_claim_narrows_read_only_targets_without_orchestrator_conflict() {
|
||||||
|
let fixture = manual_worker_assignment_fixture().await;
|
||||||
|
let backend = browser_ticket_backend(&fixture.api).unwrap();
|
||||||
|
backend
|
||||||
|
.edit_item(
|
||||||
|
fixture.ticket_id.clone().into(),
|
||||||
|
TicketItemEdit {
|
||||||
|
targets: Some(TicketTargetsEdit::Set {
|
||||||
|
targets: vec![
|
||||||
|
test_ticket_target(
|
||||||
|
"test-repository",
|
||||||
|
"develop",
|
||||||
|
TicketTargetAccess::ReadOnly,
|
||||||
|
),
|
||||||
|
test_ticket_target("docs", "develop", TicketTargetAccess::ReadOnly),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assign_test_orchestrator(&fixture.api, &fixture.ticket_id);
|
||||||
|
let request = manual_worker_assignment_request(&fixture, "claim-read-only");
|
||||||
|
let _ = claim(&fixture, request).await.unwrap();
|
||||||
|
for link in fixture
|
||||||
|
.api
|
||||||
|
.store
|
||||||
|
.list_worker_workdir_links(TEST_WORKSPACE_ID, &fixture.worker)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|link| link.unlinked_at.is_none())
|
||||||
|
{
|
||||||
|
assert_eq!(
|
||||||
|
link.capabilities,
|
||||||
|
workdir::WorkdirSessionCapabilities::READ_ONLY
|
||||||
|
);
|
||||||
|
let workdir = fixture
|
||||||
|
.api
|
||||||
|
.store
|
||||||
|
.get_workdir_registry(TEST_WORKSPACE_ID, &link.workdir_id)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
effective_worker_workdir_capabilities(
|
||||||
|
&fixture.api,
|
||||||
|
&fixture.worker,
|
||||||
|
&workdir,
|
||||||
|
link.capabilities
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
workdir::WorkdirSessionCapabilities::READ_ONLY
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
fixture
|
||||||
|
.runtime
|
||||||
|
.logical_attachments(&fixture.worker.worker_id)
|
||||||
|
.iter()
|
||||||
|
.all(|a| a.capabilities == workdir::WorkdirSessionCapabilities::READ_ONLY)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
require_ticket_read_write_target(
|
||||||
|
&fixture.api,
|
||||||
|
&fixture.ticket_id,
|
||||||
|
&test_repository_id(&fixture.api),
|
||||||
|
"develop"
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn explicit_existing_worker_claim_requires_connections_instead_of_ambient_attachments() {
|
||||||
|
let fixture = manual_worker_assignment_fixture().await;
|
||||||
|
let mut request = manual_worker_assignment_request(&fixture, "claim-ambient-denied");
|
||||||
|
request.workdir_bindings.clear();
|
||||||
|
let response = claim(&fixture, request).await.unwrap_err().into_response();
|
||||||
|
let status = response.status();
|
||||||
|
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&bytes)
|
||||||
|
);
|
||||||
|
assert!(String::from_utf8_lossy(&bytes).contains("exact live Workdir connections"));
|
||||||
|
assert_eq!(*fixture.runtime.restore_attempts.lock().unwrap(), 0);
|
||||||
|
assert!(
|
||||||
|
fixture
|
||||||
|
.api
|
||||||
|
.store
|
||||||
|
.get_ticket_assignment_operation(TEST_WORKSPACE_ID, "claim-ambient-denied")
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ambiguous_existing_worker_restore_keeps_claim_pending_until_accepted_recovery() {
|
||||||
|
let fixture = manual_worker_assignment_fixture().await;
|
||||||
|
*fixture.runtime.next_restore_outcome.lock().unwrap() =
|
||||||
|
Some(server_api::WorkerRestoreState::ReconciliationRequired);
|
||||||
|
let request = manual_worker_assignment_request(&fixture, "claim-unknown");
|
||||||
|
assert!(claim(&fixture, request.clone()).await.is_err());
|
||||||
|
let operation = fixture
|
||||||
|
.api
|
||||||
|
.store
|
||||||
|
.get_ticket_assignment_operation(TEST_WORKSPACE_ID, "claim-unknown")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(operation.claim_state, "pending");
|
||||||
|
assert!(operation.assignment_id.is_none());
|
||||||
|
assert_eq!(operation.worker, Some(fixture.worker.clone()));
|
||||||
|
assert!(operation.request_fingerprint.is_some());
|
||||||
|
let recovery = fixture
|
||||||
|
.api
|
||||||
|
.config_store
|
||||||
|
.get_ticket_claim_recovery(TEST_WORKSPACE_ID, "claim-unknown")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
recovery.item_revision,
|
||||||
|
fixture
|
||||||
|
.api
|
||||||
|
.authority
|
||||||
|
.ticket(&fixture.ticket_id)
|
||||||
|
.unwrap()
|
||||||
|
.item_revision
|
||||||
|
);
|
||||||
|
assert_eq!(recovery.selected.len(), request.workdir_bindings.len());
|
||||||
|
for selected in &request.workdir_bindings {
|
||||||
|
assert!(recovery.selected.contains(selected));
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
recovery
|
||||||
|
.original_links
|
||||||
|
.iter()
|
||||||
|
.all(|link| link.capabilities == workdir::WorkdirSessionCapabilities::ALL)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
recovery
|
||||||
|
.effective_links
|
||||||
|
.iter()
|
||||||
|
.find(|link| link.alias == "docs")
|
||||||
|
.unwrap()
|
||||||
|
.capabilities,
|
||||||
|
workdir::WorkdirSessionCapabilities::READ_ONLY
|
||||||
|
);
|
||||||
|
// This fake proves the domain transition, not execution-level restore deduplication.
|
||||||
|
*fixture.runtime.next_restore_outcome.lock().unwrap() =
|
||||||
|
Some(server_api::WorkerRestoreState::ReconciliationRequired);
|
||||||
|
assert!(claim(&fixture, request.clone()).await.is_err());
|
||||||
|
let pending = fixture
|
||||||
|
.api
|
||||||
|
.store
|
||||||
|
.get_ticket_assignment_operation(TEST_WORKSPACE_ID, "claim-unknown")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(pending.claim_state, "pending");
|
||||||
|
assert!(pending.assignment_id.is_none());
|
||||||
|
assert_eq!(pending.request_fingerprint, operation.request_fingerprint);
|
||||||
|
assert_eq!(*fixture.runtime.restore_attempts.lock().unwrap(), 2);
|
||||||
|
assert!(
|
||||||
|
fixture
|
||||||
|
.api
|
||||||
|
.store
|
||||||
|
.reserve_ticket_assignment_operation(
|
||||||
|
TEST_WORKSPACE_ID,
|
||||||
|
"competing-launch",
|
||||||
|
&fixture.ticket_id,
|
||||||
|
&fixture.worker.runtime_id,
|
||||||
|
None,
|
||||||
|
"other-intent",
|
||||||
|
TEST_CREATED_AT
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let worker = WorkspaceWorker::resolve(
|
||||||
|
&fixture.api,
|
||||||
|
&fixture.worker.runtime_id,
|
||||||
|
&fixture.worker.worker_id,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let denied = worker
|
||||||
|
.input(
|
||||||
|
&WorkerOperationContext::Backend,
|
||||||
|
WorkerInputRequest {
|
||||||
|
kind: WorkerInputKind::User,
|
||||||
|
content: "Do not run while claim is unresolved".into(),
|
||||||
|
segments: None,
|
||||||
|
submission_request_id: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(denied.error.to_string().contains("pending Ticket claim"));
|
||||||
|
assert_eq!(
|
||||||
|
fixture
|
||||||
|
.api
|
||||||
|
.authority
|
||||||
|
.ticket(&fixture.ticket_id)
|
||||||
|
.unwrap()
|
||||||
|
.state,
|
||||||
|
"ready"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
fixture
|
||||||
|
.api
|
||||||
|
.store
|
||||||
|
.get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id)
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
|
||||||
|
let Json(accepted) = claim(&fixture, request.clone()).await.unwrap();
|
||||||
|
let assignment = accepted.assignment.unwrap();
|
||||||
|
let committed = fixture
|
||||||
|
.api
|
||||||
|
.store
|
||||||
|
.get_ticket_assignment_operation(TEST_WORKSPACE_ID, "claim-unknown")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(committed.claim_state, "committed");
|
||||||
|
assert_eq!(
|
||||||
|
committed.assignment_id,
|
||||||
|
Some(assignment.assignment_id.clone())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fixture
|
||||||
|
.api
|
||||||
|
.authority
|
||||||
|
.ticket(&fixture.ticket_id)
|
||||||
|
.unwrap()
|
||||||
|
.state,
|
||||||
|
"inprogress"
|
||||||
|
);
|
||||||
|
assert_eq!(*fixture.runtime.restore_attempts.lock().unwrap(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
fixture
|
||||||
|
.api
|
||||||
|
.config_store
|
||||||
|
.get_ticket_claim_recovery(TEST_WORKSPACE_ID, "claim-unknown")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap(),
|
||||||
|
recovery
|
||||||
|
);
|
||||||
|
let Json(replay) = claim(&fixture, request).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
replay.assignment.unwrap().assignment_id,
|
||||||
|
assignment.assignment_id
|
||||||
|
);
|
||||||
|
assert_eq!(*fixture.runtime.restore_attempts.lock().unwrap(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pending_manual_worker_claim_rejects_changed_bindings_without_restore() {
|
||||||
|
let fixture = manual_worker_assignment_fixture().await;
|
||||||
|
*fixture.runtime.next_restore_outcome.lock().unwrap() =
|
||||||
|
Some(server_api::WorkerRestoreState::ReconciliationRequired);
|
||||||
|
let mut request = manual_worker_assignment_request(&fixture, "claim-changed-bindings");
|
||||||
|
assert!(claim(&fixture, request.clone()).await.is_err());
|
||||||
|
let admitted = fixture
|
||||||
|
.api
|
||||||
|
.store
|
||||||
|
.get_ticket_assignment_operation(TEST_WORKSPACE_ID, &request.operation_id)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
request.workdir_bindings[0].connection_id = "changed-connection".into();
|
||||||
|
let response = claim(&fixture, request.clone())
|
||||||
|
.await
|
||||||
|
.unwrap_err()
|
||||||
|
.into_response();
|
||||||
|
let status = response.status();
|
||||||
|
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&bytes)
|
||||||
|
);
|
||||||
|
assert!(String::from_utf8_lossy(&bytes).contains("different input"));
|
||||||
|
let pending = fixture
|
||||||
|
.api
|
||||||
|
.store
|
||||||
|
.get_ticket_assignment_operation(TEST_WORKSPACE_ID, &request.operation_id)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(pending.claim_state, "pending");
|
||||||
|
assert!(pending.assignment_id.is_none());
|
||||||
|
assert_eq!(pending.request_fingerprint, admitted.request_fingerprint);
|
||||||
|
assert_eq!(*fixture.runtime.restore_attempts.lock().unwrap(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pending_manual_worker_claim_without_pinned_intent_rejects_replay_without_restore() {
|
||||||
|
let fixture = manual_worker_assignment_fixture().await;
|
||||||
|
*fixture.runtime.next_restore_outcome.lock().unwrap() =
|
||||||
|
Some(server_api::WorkerRestoreState::ReconciliationRequired);
|
||||||
|
let request = manual_worker_assignment_request(&fixture, "claim-missing-intent");
|
||||||
|
assert!(claim(&fixture, request.clone()).await.is_err());
|
||||||
|
// Simulate an interrupted/legacy pending receipt with no exact restore intent.
|
||||||
|
let request_id = format!("manual-worker-restore:{}", request.operation_id);
|
||||||
|
fixture.api.config_store.with_conn(|conn| {
|
||||||
|
let deleted = conn.execute(
|
||||||
|
"DELETE FROM worker_restore_intents WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND request_id=?4",
|
||||||
|
rusqlite::params![TEST_WORKSPACE_ID, fixture.worker.runtime_id, fixture.worker.worker_id, request_id],
|
||||||
|
)?;
|
||||||
|
assert_eq!(deleted, 1);
|
||||||
|
Ok(())
|
||||||
|
}).unwrap();
|
||||||
|
let response = claim(&fixture, request.clone())
|
||||||
|
.await
|
||||||
|
.unwrap_err()
|
||||||
|
.into_response();
|
||||||
|
let status = response.status();
|
||||||
|
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&bytes)
|
||||||
|
);
|
||||||
|
assert!(String::from_utf8_lossy(&bytes).contains("no pinned restore intent"));
|
||||||
|
let pending = fixture
|
||||||
|
.api
|
||||||
|
.store
|
||||||
|
.get_ticket_assignment_operation(TEST_WORKSPACE_ID, &request.operation_id)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(pending.claim_state, "pending");
|
||||||
|
assert!(pending.assignment_id.is_none());
|
||||||
|
assert_eq!(*fixture.runtime.restore_attempts.lock().unwrap(), 1);
|
||||||
|
assert!(
|
||||||
|
fixture
|
||||||
|
.api
|
||||||
|
.config_store
|
||||||
|
.get_internal_worker_restore_intent(TEST_WORKSPACE_ID, &fixture.worker, &request_id)
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pending_manual_worker_claim_reconciles_same_runtime_restore_and_commits_without_input() {
|
||||||
|
let workspace = tempfile::tempdir().unwrap();
|
||||||
|
let (api, execution) = test_api_with_recording_backend(workspace.path()).await;
|
||||||
|
execution.accept_restores();
|
||||||
|
let worker = spawn_ticket_check_source(&api, "manual-claim-reconciliation");
|
||||||
|
assert!(
|
||||||
|
api.store
|
||||||
|
.list_worker_workdir_links(TEST_WORKSPACE_ID, &worker)
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
let backend = browser_ticket_backend(&api).unwrap();
|
||||||
|
let mut input = ticket::NewTicket::new("Manual investigation without resources");
|
||||||
|
input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||||
|
let ticket_id = backend.create(input).unwrap().id;
|
||||||
|
backend
|
||||||
|
.edit_item(
|
||||||
|
ticket_id.clone().into(),
|
||||||
|
TicketItemEdit {
|
||||||
|
targets: Some(TicketTargetsEdit::Clear),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
backend
|
||||||
|
.set_workflow_state(
|
||||||
|
ticket_id.clone().into(),
|
||||||
|
TicketStateChange::new(
|
||||||
|
"ready",
|
||||||
|
"planning",
|
||||||
|
"Explicit investigation",
|
||||||
|
"No repository resources required",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let admitted_revision = api.authority.ticket(&ticket_id).unwrap().item_revision;
|
||||||
|
let request = server_api::SetTicketRoleAssignmentRequest {
|
||||||
|
operation_id: "claim-runtime-reconciliation".into(),
|
||||||
|
principal: server_api::TicketAssignmentPrincipal::Worker {
|
||||||
|
runtime_id: worker.runtime_id.clone(),
|
||||||
|
worker_id: worker.worker_id.clone(),
|
||||||
|
},
|
||||||
|
expected_assignment_id: None,
|
||||||
|
workdir_bindings: Vec::new(),
|
||||||
|
};
|
||||||
|
let submit_claim = || {
|
||||||
|
scoped_set_ticket_assignment(
|
||||||
|
State(api.clone()),
|
||||||
|
AxumPath((TEST_WORKSPACE_ID.into(), ticket_id.clone(), "worker".into())),
|
||||||
|
Json(request.clone()),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let uncertain_restore = || {
|
||||||
|
worker_runtime::execution::WorkerExecutionSpawnResult::Errored(
|
||||||
|
worker_runtime::execution::WorkerExecutionResult::rejected(
|
||||||
|
worker_runtime::execution::WorkerExecutionOperation::Restore,
|
||||||
|
"uncertain manual claim restore",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
*execution.restore_failure.lock().unwrap() = Some(uncertain_restore());
|
||||||
|
let response = submit_claim().await.unwrap_err().into_response();
|
||||||
|
let status = response.status();
|
||||||
|
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&bytes)
|
||||||
|
);
|
||||||
|
assert!(String::from_utf8_lossy(&bytes).contains("unknown"));
|
||||||
|
let pending = api
|
||||||
|
.store
|
||||||
|
.get_ticket_assignment_operation(TEST_WORKSPACE_ID, &request.operation_id)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(pending.claim_state, "pending");
|
||||||
|
assert!(pending.assignment_id.is_none());
|
||||||
|
assert_eq!(pending.worker, Some(worker.clone()));
|
||||||
|
assert!(pending.request_fingerprint.is_some());
|
||||||
|
assert_eq!(api.authority.ticket(&ticket_id).unwrap().state, "planning");
|
||||||
|
assert!(
|
||||||
|
api.store
|
||||||
|
.get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &ticket_id)
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
let recovery = api
|
||||||
|
.config_store
|
||||||
|
.get_ticket_claim_recovery(TEST_WORKSPACE_ID, &request.operation_id)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(recovery.item_revision, admitted_revision);
|
||||||
|
assert!(recovery.selected.is_empty());
|
||||||
|
assert!(recovery.original_links.is_empty());
|
||||||
|
assert!(recovery.effective_links.is_empty());
|
||||||
|
let request_id = format!("manual-worker-restore:{}", request.operation_id);
|
||||||
|
let pinned = api
|
||||||
|
.config_store
|
||||||
|
.get_internal_worker_restore_intent(TEST_WORKSPACE_ID, &worker, &request_id)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(pinned.request_id, request_id);
|
||||||
|
let original = execution.restore_operations.lock().unwrap()[0];
|
||||||
|
assert_eq!(
|
||||||
|
execution.restore_operations.lock().unwrap().as_slice(),
|
||||||
|
&[original]
|
||||||
|
);
|
||||||
|
assert!(execution.reconcile_operations.lock().unwrap().is_empty());
|
||||||
|
|
||||||
|
let handle = WorkspaceWorker::resolve(&api, &worker.runtime_id, &worker.worker_id).unwrap();
|
||||||
|
let denied = handle
|
||||||
|
.input(
|
||||||
|
&WorkerOperationContext::Backend,
|
||||||
|
WorkerInputRequest {
|
||||||
|
kind: WorkerInputKind::User,
|
||||||
|
content: "Do not submit while the manual claim is pending".into(),
|
||||||
|
segments: None,
|
||||||
|
submission_request_id: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(denied.error.to_string().contains("pending Ticket claim"));
|
||||||
|
|
||||||
|
*execution.restore_failure.lock().unwrap() = Some(uncertain_restore());
|
||||||
|
let response = submit_claim().await.unwrap_err().into_response();
|
||||||
|
let status = response.status();
|
||||||
|
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&bytes)
|
||||||
|
);
|
||||||
|
assert!(String::from_utf8_lossy(&bytes).contains("pending"));
|
||||||
|
let still_pending = api
|
||||||
|
.store
|
||||||
|
.get_ticket_assignment_operation(TEST_WORKSPACE_ID, &request.operation_id)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(still_pending.claim_state, "pending");
|
||||||
|
assert!(still_pending.assignment_id.is_none());
|
||||||
|
assert_eq!(
|
||||||
|
still_pending.request_fingerprint,
|
||||||
|
pending.request_fingerprint
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
api.authority.ticket(&ticket_id).unwrap().item_revision,
|
||||||
|
admitted_revision
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
api.config_store
|
||||||
|
.get_internal_worker_restore_intent(TEST_WORKSPACE_ID, &worker, &request_id)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap(),
|
||||||
|
pinned
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
execution.reconcile_operations.lock().unwrap().as_slice(),
|
||||||
|
&[original]
|
||||||
|
);
|
||||||
|
|
||||||
|
let Json(accepted) = submit_claim().await.unwrap();
|
||||||
|
let assignment = accepted.assignment.unwrap();
|
||||||
|
assert_eq!(assignment.principal, request.principal);
|
||||||
|
let committed = api
|
||||||
|
.store
|
||||||
|
.get_ticket_assignment_operation(TEST_WORKSPACE_ID, &request.operation_id)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(committed.claim_state, "committed");
|
||||||
|
assert_eq!(
|
||||||
|
committed.assignment_id,
|
||||||
|
Some(assignment.assignment_id.clone())
|
||||||
|
);
|
||||||
|
assert_eq!(committed.request_fingerprint, pending.request_fingerprint);
|
||||||
|
assert_eq!(
|
||||||
|
api.authority.ticket(&ticket_id).unwrap().state,
|
||||||
|
"inprogress"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
api.store
|
||||||
|
.get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &ticket_id)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.assignment_id,
|
||||||
|
assignment.assignment_id
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
api.config_store
|
||||||
|
.get_ticket_claim_recovery(TEST_WORKSPACE_ID, &request.operation_id)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap(),
|
||||||
|
recovery
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
api.store
|
||||||
|
.list_worker_workdir_links(TEST_WORKSPACE_ID, &worker)
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
// The executor logs reconcile_restore in restore_operations as well. There is
|
||||||
|
// exactly one fresh dispatch; both subsequent entries reconcile its same id.
|
||||||
|
assert_eq!(
|
||||||
|
execution.restore_operations.lock().unwrap().as_slice(),
|
||||||
|
&[original, original, original]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
execution.reconcile_operations.lock().unwrap().as_slice(),
|
||||||
|
&[original, original]
|
||||||
|
);
|
||||||
|
let Json(replay) = submit_claim().await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
replay.assignment.unwrap().assignment_id,
|
||||||
|
assignment.assignment_id
|
||||||
|
);
|
||||||
|
assert_eq!(execution.restore_operations.lock().unwrap().len(), 3);
|
||||||
|
assert_eq!(execution.reconcile_operations.lock().unwrap().len(), 2);
|
||||||
|
assert!(
|
||||||
|
!execution
|
||||||
|
.protocol_methods()
|
||||||
|
.iter()
|
||||||
|
.any(|(_, method)| matches!(
|
||||||
|
method,
|
||||||
|
protocol::Method::Submit { .. }
|
||||||
|
| protocol::Method::SubmitTracked { .. }
|
||||||
|
| protocol::Method::SubmitIfIdle { .. }
|
||||||
|
)),
|
||||||
|
"manual claims and denied input must not generate a Submit"
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -359,6 +359,8 @@ impl WorkspaceWorker {
|
|||||||
"send_input"
|
"send_input"
|
||||||
};
|
};
|
||||||
let _guard = self.authorize_operation(context, permission).await?;
|
let _guard = self.authorize_operation(context, permission).await?;
|
||||||
|
let session_lock = current_worker_session_lock(&self.api, &self.identity);
|
||||||
|
let _session_guard = session_lock.lock().await;
|
||||||
self.ensure_interactive()?;
|
self.ensure_interactive()?;
|
||||||
if matches!(context, WorkerOperationContext::WorkerControl { .. })
|
if matches!(context, WorkerOperationContext::WorkerControl { .. })
|
||||||
&& request.kind == WorkerInputKind::User
|
&& request.kind == WorkerInputKind::User
|
||||||
@@ -373,6 +375,19 @@ impl WorkspaceWorker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_interactive(&self) -> ApiResult<()> {
|
fn ensure_interactive(&self) -> ApiResult<()> {
|
||||||
|
if let Some(claim) = self
|
||||||
|
.api
|
||||||
|
.store
|
||||||
|
.get_pending_ticket_assignment_operation_for_worker(
|
||||||
|
self.api.workspace_id(),
|
||||||
|
&self.identity,
|
||||||
|
)?
|
||||||
|
{
|
||||||
|
return Err(Error::TicketAssignmentConflict(format!(
|
||||||
|
"Worker has pending Ticket claim {}; reconcile that exact operation before admitting input",
|
||||||
|
claim.operation_id,
|
||||||
|
)).into());
|
||||||
|
}
|
||||||
if self
|
if self
|
||||||
.api
|
.api
|
||||||
.store
|
.store
|
||||||
@@ -578,6 +593,8 @@ impl WorkspaceWorkerMethodSender {
|
|||||||
.worker
|
.worker
|
||||||
.authorize_operation(context, "send_input")
|
.authorize_operation(context, "send_input")
|
||||||
.await?;
|
.await?;
|
||||||
|
let session_lock = current_worker_session_lock(&self.worker.api, &self.worker.identity);
|
||||||
|
let _session_guard = session_lock.lock().await;
|
||||||
self.worker.ensure_interactive()?;
|
self.worker.ensure_interactive()?;
|
||||||
let WorkerOperationContext::Browser { source, .. } = context else {
|
let WorkerOperationContext::Browser { source, .. } = context else {
|
||||||
return Err(Error::WorkspacePermissionDenied(
|
return Err(Error::WorkspacePermissionDenied(
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ mod workspace_config_integration {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn workspace_config_signed_editor_routes_cannot_bypass_wip_authority() {
|
async fn workspace_config_signed_editor_routes_cannot_bypass_wip_authority() {
|
||||||
let mut fixture = manual_coder_assignment_fixture().await;
|
let mut fixture = manual_worker_assignment_fixture().await;
|
||||||
let identity = RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id).unwrap();
|
let identity = RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id).unwrap();
|
||||||
configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id);
|
configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id);
|
||||||
let api = &fixture.api;
|
let api = &fixture.api;
|
||||||
@@ -222,7 +222,7 @@ mod workspace_config_integration {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn workspace_config_entrypoint_support_is_exact_and_canonical_mutations_stay_guarded() {
|
async fn workspace_config_entrypoint_support_is_exact_and_canonical_mutations_stay_guarded() {
|
||||||
let fixture = manual_coder_assignment_fixture().await;
|
let fixture = manual_worker_assignment_fixture().await;
|
||||||
let api = &fixture.api;
|
let api = &fixture.api;
|
||||||
grant(api, &fixture.worker, Access::ReadWrite).await;
|
grant(api, &fixture.worker, Access::ReadWrite).await;
|
||||||
let attached = attach(api, &fixture.worker).await;
|
let attached = attach(api, &fixture.worker).await;
|
||||||
@@ -299,7 +299,7 @@ mod workspace_config_integration {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn workspace_config_grants_owner_scope_and_signed_routes() {
|
async fn workspace_config_grants_owner_scope_and_signed_routes() {
|
||||||
let mut fixture = manual_coder_assignment_fixture().await;
|
let mut fixture = manual_worker_assignment_fixture().await;
|
||||||
let identity = RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id).unwrap();
|
let identity = RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id).unwrap();
|
||||||
configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id);
|
configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id);
|
||||||
let api = &fixture.api;
|
let api = &fixture.api;
|
||||||
@@ -615,7 +615,7 @@ mod workspace_config_integration {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn workspace_config_readonly_revoke_and_conditional_lifetime_fences() {
|
async fn workspace_config_readonly_revoke_and_conditional_lifetime_fences() {
|
||||||
let fixture = manual_coder_assignment_fixture().await;
|
let fixture = manual_worker_assignment_fixture().await;
|
||||||
let api = &fixture.api;
|
let api = &fixture.api;
|
||||||
let read_grant = grant(api, &fixture.worker, Access::ReadOnly).await;
|
let read_grant = grant(api, &fixture.worker, Access::ReadOnly).await;
|
||||||
assert!(
|
assert!(
|
||||||
@@ -827,7 +827,7 @@ mod workspace_config_integration {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn workspace_config_two_workers_ui_cas_and_identical_virtual_replacement() {
|
async fn workspace_config_two_workers_ui_cas_and_identical_virtual_replacement() {
|
||||||
let fixture = manual_coder_assignment_fixture().await;
|
let fixture = manual_worker_assignment_fixture().await;
|
||||||
let api = &fixture.api;
|
let api = &fixture.api;
|
||||||
let second = second_worker(&fixture).await;
|
let second = second_worker(&fixture).await;
|
||||||
grant(api, &fixture.worker, Access::ReadWrite).await;
|
grant(api, &fixture.worker, Access::ReadWrite).await;
|
||||||
@@ -982,7 +982,7 @@ mod workspace_config_integration {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn workspace_config_invalid_bounds_and_failed_save_do_not_corrupt_tree() {
|
async fn workspace_config_invalid_bounds_and_failed_save_do_not_corrupt_tree() {
|
||||||
let fixture = manual_coder_assignment_fixture().await;
|
let fixture = manual_worker_assignment_fixture().await;
|
||||||
let api = &fixture.api;
|
let api = &fixture.api;
|
||||||
grant(api, &fixture.worker, Access::ReadWrite).await;
|
grant(api, &fixture.worker, Access::ReadWrite).await;
|
||||||
let attached = attach(api, &fixture.worker).await;
|
let attached = attach(api, &fixture.worker).await;
|
||||||
@@ -1114,7 +1114,7 @@ mod workspace_config_integration {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn workspace_config_operation_and_revoke_share_the_detach_session_lock() {
|
async fn workspace_config_operation_and_revoke_share_the_detach_session_lock() {
|
||||||
let fixture = manual_coder_assignment_fixture().await;
|
let fixture = manual_worker_assignment_fixture().await;
|
||||||
let api = &fixture.api;
|
let api = &fixture.api;
|
||||||
let granted = grant(api, &fixture.worker, Access::ReadWrite).await;
|
let granted = grant(api, &fixture.worker, Access::ReadWrite).await;
|
||||||
let attached = attach(api, &fixture.worker).await;
|
let attached = attach(api, &fixture.worker).await;
|
||||||
@@ -1168,7 +1168,7 @@ mod workspace_config_integration {
|
|||||||
}
|
}
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn workspace_config_grant_restart_source_immutability_and_owner_acceptance_guard() {
|
async fn workspace_config_grant_restart_source_immutability_and_owner_acceptance_guard() {
|
||||||
let fixture = manual_coder_assignment_fixture().await;
|
let fixture = manual_worker_assignment_fixture().await;
|
||||||
let api = &fixture.api;
|
let api = &fixture.api;
|
||||||
let granted = grant(api, &fixture.worker, Access::ReadWrite).await;
|
let granted = grant(api, &fixture.worker, Access::ReadWrite).await;
|
||||||
let attached = attach(api, &fixture.worker).await;
|
let attached = attach(api, &fixture.worker).await;
|
||||||
@@ -1255,7 +1255,7 @@ mod workspace_config_integration {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn workspace_config_auxiliary_secret_effects_are_unknown_not_false_rollback() {
|
async fn workspace_config_auxiliary_secret_effects_are_unknown_not_false_rollback() {
|
||||||
let fixture = manual_coder_assignment_fixture().await;
|
let fixture = manual_worker_assignment_fixture().await;
|
||||||
let api = &fixture.api;
|
let api = &fixture.api;
|
||||||
grant(api, &fixture.worker, Access::ReadWrite).await;
|
grant(api, &fixture.worker, Access::ReadWrite).await;
|
||||||
let attached = attach(api, &fixture.worker).await;
|
let attached = attach(api, &fixture.worker).await;
|
||||||
@@ -1324,7 +1324,7 @@ mod workspace_config_integration {
|
|||||||
}
|
}
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn workspace_config_observe_node_limit_is_complete_or_error_not_silent_truncation() {
|
async fn workspace_config_observe_node_limit_is_complete_or_error_not_silent_truncation() {
|
||||||
let fixture = manual_coder_assignment_fixture().await;
|
let fixture = manual_worker_assignment_fixture().await;
|
||||||
let api = &fixture.api;
|
let api = &fixture.api;
|
||||||
grant(api, &fixture.worker, Access::ReadWrite).await;
|
grant(api, &fixture.worker, Access::ReadWrite).await;
|
||||||
let attached = attach(api, &fixture.worker).await;
|
let attached = attach(api, &fixture.worker).await;
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ async fn assert_config_catalog(runtime: &worker::wip::WipRuntime, attached: &Att
|
|||||||
async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_import_and_unknown() {
|
async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_import_and_unknown() {
|
||||||
use worker::feature::builtin::workspace_config::wip::mount_workspace_config_wip;
|
use worker::feature::builtin::workspace_config::wip::mount_workspace_config_wip;
|
||||||
use worker::feature::builtin::workspace_config::{CONTENT_ROOT, WorkspaceConfigFeature};
|
use worker::feature::builtin::workspace_config::{CONTENT_ROOT, WorkspaceConfigFeature};
|
||||||
let mut fixture = manual_coder_assignment_fixture().await;
|
let mut fixture = manual_worker_assignment_fixture().await;
|
||||||
let identity = RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id).unwrap();
|
let identity = RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id).unwrap();
|
||||||
configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id);
|
configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id);
|
||||||
let granted = grant(&fixture.api, &fixture.worker, Access::ReadWrite).await;
|
let granted = grant(&fixture.api, &fixture.worker, Access::ReadWrite).await;
|
||||||
@@ -706,7 +706,7 @@ async fn workspace_config_actual_remote_and_embedded_clients_use_authenticated_w
|
|||||||
decode_runtime_request_source_claims,
|
decode_runtime_request_source_claims,
|
||||||
};
|
};
|
||||||
// Remote Runtime-owned HTTP adapter, including its real request signer.
|
// Remote Runtime-owned HTTP adapter, including its real request signer.
|
||||||
let mut fixture = manual_coder_assignment_fixture().await;
|
let mut fixture = manual_worker_assignment_fixture().await;
|
||||||
let identity = RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id).unwrap();
|
let identity = RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id).unwrap();
|
||||||
configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id);
|
configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id);
|
||||||
let claims = Arc::new(Mutex::new(Vec::new()));
|
let claims = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
|||||||
+1509
-246
File diff suppressed because it is too large
Load Diff
+35
-7
@@ -7657,7 +7657,7 @@
|
|||||||
"enum": [
|
"enum": [
|
||||||
"intake",
|
"intake",
|
||||||
"orchestrator",
|
"orchestrator",
|
||||||
"coder",
|
"worker",
|
||||||
"reviewer"
|
"reviewer"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -8871,6 +8871,13 @@
|
|||||||
},
|
},
|
||||||
"principal": {
|
"principal": {
|
||||||
"$ref": "#/components/schemas/TicketAssignmentPrincipal"
|
"$ref": "#/components/schemas/TicketAssignmentPrincipal"
|
||||||
|
},
|
||||||
|
"workdir_bindings": {
|
||||||
|
"default": [],
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/TicketWorkerAttachmentBinding"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -10098,7 +10105,7 @@
|
|||||||
"can_queue": {
|
"can_queue": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"can_start_manual_coder": {
|
"can_start_manual_worker": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"can_unassign_orchestrator": {
|
"can_unassign_orchestrator": {
|
||||||
@@ -10115,7 +10122,7 @@
|
|||||||
"can_assign_orchestrator",
|
"can_assign_orchestrator",
|
||||||
"can_unassign_orchestrator",
|
"can_unassign_orchestrator",
|
||||||
"can_queue",
|
"can_queue",
|
||||||
"can_start_manual_coder",
|
"can_start_manual_worker",
|
||||||
"queue_tickets",
|
"queue_tickets",
|
||||||
"blockers"
|
"blockers"
|
||||||
],
|
],
|
||||||
@@ -10195,7 +10202,7 @@
|
|||||||
"TicketAssignmentRole": {
|
"TicketAssignmentRole": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"orchestrator",
|
"orchestrator",
|
||||||
"coder",
|
"worker",
|
||||||
"owner",
|
"owner",
|
||||||
"contributor"
|
"contributor"
|
||||||
],
|
],
|
||||||
@@ -10360,7 +10367,7 @@
|
|||||||
"null"
|
"null"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"current_coder": {
|
"current_worker": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/TicketAssignmentSummary"
|
"$ref": "#/components/schemas/TicketAssignmentSummary"
|
||||||
@@ -11526,7 +11533,7 @@
|
|||||||
"null"
|
"null"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"current_coder": {
|
"current_worker": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/TicketAssignmentSummary"
|
"$ref": "#/components/schemas/TicketAssignmentSummary"
|
||||||
@@ -12423,6 +12430,27 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"TicketWorkerAttachmentBinding": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "Selected existing live Workdir attachment, including its current connection\nidentity. The Workspace authority validates the selection against live\nattachments; callers must select them explicitly, and use an empty list\nonly when the Worker has no live Workdirs.",
|
||||||
|
"properties": {
|
||||||
|
"alias": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"connection_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"working_directory_id": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"alias",
|
||||||
|
"working_directory_id",
|
||||||
|
"connection_id"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"TicketWorkflowState": {
|
"TicketWorkflowState": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"planning",
|
"planning",
|
||||||
@@ -15643,7 +15671,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Yoi Server API",
|
"title": "Yoi Server API",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"x-yoi-source-digest": "sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0"
|
"x-yoi-source-digest": "sha256:16796bde0da04973310a14f90cf6eac14b2580bbe184c7c12afde3b6225ce933"
|
||||||
},
|
},
|
||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import "./default.dcdl" // {
|
||||||
|
slug = "ticket-worker";
|
||||||
|
description = "General Ticket work with typed progress and completion tools.";
|
||||||
|
engine = {
|
||||||
|
instruction = "role.ticket_worker";
|
||||||
|
};
|
||||||
|
|
||||||
|
feature = {
|
||||||
|
ticket = {
|
||||||
|
enabled = true;
|
||||||
|
authoring = false;
|
||||||
|
thread = true;
|
||||||
|
intake = false;
|
||||||
|
workflow = false;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ commonWorkerOrchestration = import "./common/worker-orchestration.md";
|
|||||||
commonWorkspace = import "./common/workspace.md";
|
commonWorkspace = import "./common/workspace.md";
|
||||||
commonWriting = import "./common/writing.md";
|
commonWriting = import "./common/writing.md";
|
||||||
roleCoder = import "./role/coder.md";
|
roleCoder = import "./role/coder.md";
|
||||||
|
roleTicketWorker = import "./role/ticket-worker.md";
|
||||||
roleIntake = import "./role/intake.md";
|
roleIntake = import "./role/intake.md";
|
||||||
roleOrchestrator = import "./role/orchestrator.md";
|
roleOrchestrator = import "./role/orchestrator.md";
|
||||||
roleReviewer = import "./role/reviewer.md";
|
roleReviewer = import "./role/reviewer.md";
|
||||||
@@ -55,6 +56,7 @@ in
|
|||||||
};
|
};
|
||||||
role = {
|
role = {
|
||||||
coder = roleCoder.content;
|
coder = roleCoder.content;
|
||||||
|
ticket_worker = roleTicketWorker.content;
|
||||||
intake = roleIntake.content;
|
intake = roleIntake.content;
|
||||||
orchestrator = roleOrchestrator.content;
|
orchestrator = roleOrchestrator.content;
|
||||||
reviewer = roleReviewer.content;
|
reviewer = roleReviewer.content;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ You are the Ticket Orchestrator role.
|
|||||||
|
|
||||||
{% include "common.git" %}
|
{% include "common.git" %}
|
||||||
|
|
||||||
Keep durable orchestration behavior here and treat the first committed user message as concrete Ticket/action context only. Use typed Ticket tools and current repository state as authority. For an actionable `queued` Ticket, select exactly one current Workdir for every declared Ticket target and call `SpawnTicketCoder` once with the complete alias-keyed `workdir_attachments` collection; use stable repository-derived aliases and grant Backend-derived write capability to every `read_write` target. Never omit a target, attach an undeclared repository, or request attachment capabilities: Ticket access and Backend validation are authoritative. Call `SpawnTicketCoder` without first recording `inprogress`: the guarded Worker creation operation commits the fixed Coder profile/Flow, initial input, one current assignment, every Workdir finalization, and only then the authoritative `queued -> inprogress` acceptance. If any attachment, Runtime, assignment, or finalization step fails, leave the Ticket queued and do not report accepted implementation. After spawn, reread the Ticket and verify both `inprogress` and that its current assignment names that Coder before asking it to implement; never route implementation to an unassigned Coder. Route implementation work to sibling Coder Workers. The human `ready -> queued` transition delegates ordinary implementation, publication from any required `read_write` target work branch, guarded integration of the approved linked Merge Requests, and guarded Ticket completion to the Workspace Orchestrator by default. This queue delegation does not grant broader repository authority from launch prose. Stop only when the Ticket explicitly records a separate approval gate or completion requires a new decision outside the queued scope.
|
Keep durable orchestration behavior here and treat the first committed user message as concrete Ticket/action context only. Use typed Ticket tools and current repository state as authority. Explicitly start bounded Ticket work with `SpawnTicketWorker`, selecting a registered `profile`, an `initial_request`, an optional `flow`, and alias-keyed `workdir_attachments`. Work assignment is independent from the selected Profile: ordinary investigation can use `builtin:default` without a Flow or Workdir; Git implementation can select `builtin:coder` with `builtin:coder-review`. Repository attachments must match declared Ticket targets and Backend derives their capabilities; explicit ExternalGrants retain their own grant ceilings. Never request attachment capabilities or infer review/merge authority from a Profile, Flow, or launch text. Do not first record `inprogress`: the guarded operation accepts initial input, assignment, and attachment finalization before recording start. A state change alone does not launch a Worker. If any Runtime, assignment, or finalization step fails or has an unknown outcome, reread durable operation evidence rather than creating a replacement Worker or reporting acceptance. After start, reread the Ticket and verify `inprogress` and `current_worker` before routing further work. Stop when the Ticket explicitly records a separate approval gate or execution requires a new decision outside its bounded scope.
|
||||||
|
|
||||||
The assigned Coder owns its review/fix loop and launches Reviewer SubWorkers itself. Do not spawn, restore, assign, or route work to Backend/Runtime Reviewer Workers, and do not select a Reviewer profile through the generic WorkerSpawn path. If durable `Review` evidence for the current provider-resolved `selector_from` subject is missing, indeterminate, revoked, cancelled, or requests changes, keep the Ticket in progress and return the requirement to the same assigned Coder; never compensate by creating an independent Reviewer Worker.
|
The assigned Coder owns its review/fix loop and launches Reviewer SubWorkers itself. Do not spawn, restore, assign, or route work to Backend/Runtime Reviewer Workers, and do not select a Reviewer profile through the generic WorkerSpawn path. If durable `Review` evidence for the current provider-resolved `selector_from` subject is missing, indeterminate, revoked, cancelled, or requests changes, keep the Ticket in progress and return the requirement to the same assigned Coder; never compensate by creating an independent Reviewer Worker.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
You are the assigned Ticket Worker. Carry out the user's requested work and keep durable progress and completion evidence on the Ticket. Ticket work may be research, analysis, planning, documentation, or other non-code work; do not assume a repository, Workdir, Flow, git operation, Merge Request, or review cycle is required.
|
||||||
|
|
||||||
|
Read the current Ticket with `ShowTicket` before acting. Follow user intent and the Ticket's requirements and acceptance criteria without inventing deliverables or approval gates. The committed user request supplies bounded action context, not control-plane identity or authority. Use only explicitly granted resources and available typed tools. Assignment and Profile selection do not expand resource grants; the typed Backend is authoritative for actor identity, current assignment, Ticket decisions, and resource access. Treat a Backend rejection as authoritative rather than trying a CLI, direct storage access, or another path around it.
|
||||||
|
|
||||||
|
Use `TicketComment` for useful natural-language progress, results, blockers, and decisions that need attention. Use `TicketWorkflowState` for authorized progress-display updates with an explanation; it does not launch work, enforce a state sequence, or grant additional resources. When the requested work meets its acceptance criteria, use `CompleteTicket` with a natural-language reason describing the result and supporting evidence, and optional typed references when relevant. Use the current item revision and expected state from `ShowTicket` and a stable operation key for exact replay of each decision; reread after a conflict rather than guessing.
|
||||||
|
|
||||||
|
Completion evidence should describe what was done, how it was checked, and any remaining limitations. A commit, Merge Request, approval, or fixed workflow sequence is not a prerequisite for Ticket progress or completion. Do not claim work or validation that did not occur. Ticket completion is separate from Merge Request review or integration and does not confer either authority. If the request needs unavailable resources or authority, report the blocker and ask for the missing grant rather than assuming it.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0)
|
// Input contract: 0.1.0 (sha256:16796bde0da04973310a14f90cf6eac14b2580bbe184c7c12afde3b6225ce933)
|
||||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0)
|
// Input contract: 0.1.0 (sha256:16796bde0da04973310a14f90cf6eac14b2580bbe184c7c12afde3b6225ce933)
|
||||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0)
|
// Input contract: 0.1.0 (sha256:16796bde0da04973310a14f90cf6eac14b2580bbe184c7c12afde3b6225ce933)
|
||||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||||
@@ -109,7 +109,7 @@ export type RuntimeSourceSummary = { identity_authority: RuntimeIdentityAuthorit
|
|||||||
|
|
||||||
export type RuntimeSummary = { arch: string; diagnostics?: Array<Diagnostic>; host_ids?: Array<string>; kind: string; label: string; os: string; runtime_id: string; source: RuntimeSourceSummary; status: string; worker_creation_available: boolean; };
|
export type RuntimeSummary = { arch: string; diagnostics?: Array<Diagnostic>; host_ids?: Array<string>; kind: string; label: string; os: string; runtime_id: string; source: RuntimeSourceSummary; status: string; worker_creation_available: boolean; };
|
||||||
|
|
||||||
export type RuntimeTicketWorkerRole = "intake" | "orchestrator" | "coder" | "reviewer";
|
export type RuntimeTicketWorkerRole = "intake" | "orchestrator" | "worker" | "reviewer";
|
||||||
|
|
||||||
export type RuntimeTrustAuditAction = "created" | "replaced" | "reactivated" | "revoked";
|
export type RuntimeTrustAuditAction = "created" | "replaced" | "reactivated" | "revoked";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0)
|
// Input contract: 0.1.0 (sha256:16796bde0da04973310a14f90cf6eac14b2580bbe184c7c12afde3b6225ce933)
|
||||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||||
@@ -81,17 +81,17 @@ export type ReviewDecision = "approve" | "request_changes";
|
|||||||
|
|
||||||
export type ReviewFinding = { body: string; code?: string | null; line?: number | null; path?: string | null; severity: FindingSeverity; };
|
export type ReviewFinding = { body: string; code?: string | null; line?: number | null; path?: string | null; severity: FindingSeverity; };
|
||||||
|
|
||||||
export type SetTicketRoleAssignmentRequest = { expected_assignment_id?: string | null; operation_id: string; principal: TicketAssignmentPrincipal; };
|
export type SetTicketRoleAssignmentRequest = { expected_assignment_id?: string | null; operation_id: string; principal: TicketAssignmentPrincipal; workdir_bindings?: Array<TicketWorkerAttachmentBinding>; };
|
||||||
|
|
||||||
export type TicketActionEligibility = { blockers: Array<string>; can_assign_orchestrator: boolean; can_queue: boolean; can_start_manual_coder: boolean; can_unassign_orchestrator: boolean; queue_tickets: Array<string>; };
|
export type TicketActionEligibility = { blockers: Array<string>; can_assign_orchestrator: boolean; can_queue: boolean; can_start_manual_worker: boolean; can_unassign_orchestrator: boolean; queue_tickets: Array<string>; };
|
||||||
|
|
||||||
export type TicketAssignmentPrincipal = { account_id: string; kind: "user"; } | { kind: "worker"; runtime_id: string; worker_id: string; } | { agent_key: string; kind: "workspace_agent"; };
|
export type TicketAssignmentPrincipal = { account_id: string; kind: "user"; } | { kind: "worker"; runtime_id: string; worker_id: string; } | { agent_key: string; kind: "workspace_agent"; };
|
||||||
|
|
||||||
export type TicketAssignmentRole = "orchestrator" | "coder" | "owner" | "contributor";
|
export type TicketAssignmentRole = "orchestrator" | "worker" | "owner" | "contributor";
|
||||||
|
|
||||||
export type TicketAssignmentSummary = { assignment_id: string; runtime_id: string; worker_id: string; worker_resource_key?: string | null; };
|
export type TicketAssignmentSummary = { assignment_id: string; runtime_id: string; worker_id: string; worker_resource_key?: string | null; };
|
||||||
|
|
||||||
export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array<string>; assignment_diagnostics: Array<string>; assignments: Array<TicketRoleAssignmentSummary>; body: string; body_truncated: boolean; created_at?: string | null; current_coder?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array<TicketEventDetail>; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array<TicketEvidenceEvent>; item_revision: string; linked_objectives: Array<ObjectiveLinkSummary>; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array<TicketMergeRequestSummary>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array<string>; state: string; targets: Array<TicketTarget>; title: string; updated_at?: string | null; };
|
export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array<string>; assignment_diagnostics: Array<string>; assignments: Array<TicketRoleAssignmentSummary>; body: string; body_truncated: boolean; created_at?: string | null; current_worker?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array<TicketEventDetail>; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array<TicketEvidenceEvent>; item_revision: string; linked_objectives: Array<ObjectiveLinkSummary>; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array<TicketMergeRequestSummary>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array<string>; state: string; targets: Array<TicketTarget>; title: string; updated_at?: string | null; };
|
||||||
|
|
||||||
export type TicketDetailDerivedRelation = { at: string; author: string; forward_kind: string; inverse_kind: string; note?: string | null; source_resource_key?: string | null; source_ticket: string; };
|
export type TicketDetailDerivedRelation = { at: string; author: string; forward_kind: string; inverse_kind: string; note?: string | null; source_resource_key?: string | null; source_ticket: string; };
|
||||||
|
|
||||||
@@ -139,4 +139,6 @@ export type TicketTarget = { access: TicketTargetAccess; ref_selector?: string |
|
|||||||
|
|
||||||
export type TicketTargetAccess = "read_only" | "read_write";
|
export type TicketTargetAccess = "read_only" | "read_write";
|
||||||
|
|
||||||
|
export type TicketWorkerAttachmentBinding = { alias: string; connection_id: string; working_directory_id: string; };
|
||||||
|
|
||||||
export type TicketWorkflowState = "planning" | "ready" | "queued" | "in_progress" | "done" | "closed";
|
export type TicketWorkflowState = "planning" | "ready" | "queued" | "in_progress" | "done" | "closed";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0)
|
// Input contract: 0.1.0 (sha256:16796bde0da04973310a14f90cf6eac14b2580bbe184c7c12afde3b6225ce933)
|
||||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||||
|
|||||||
Reference in New Issue
Block a user