fix(worker): preserve Ticket WIP outcome semantics

This commit is contained in:
2026-10-04 02:38:55 +09:00
parent 31ee6b3c6a
commit 983c20c3f7
3 changed files with 172 additions and 41 deletions
+150 -35
View File
@@ -1604,6 +1604,9 @@ impl WipOperationHandler for TicketItemWipHandler {
&response,
tool.projection.mutating,
);
if tool.projection.mutating {
record_affected_ticket_mutations(&self.revisions, &self.ticket_reference, &response);
}
Ok(WipOperationOutput::native(
json_to_wip(&response).map_err(WipOperationError::OutcomeUnknown)?,
))
@@ -1652,9 +1655,6 @@ fn native_ticket_input(
}
let mut input = serde_json::Map::new();
for (name, value) in arguments {
if matches!(value, WipValue::Unit) {
continue;
}
input.insert(
name.clone(),
wip_to_json(value).map_err(|message| {
@@ -1702,7 +1702,7 @@ fn map_ticket_tool_error(error: ToolError) -> WipOperationError {
ProtocolErrorCode::InvalidArguments,
format!("{code}: {message}"),
),
ToolError::ExecutionFailed(message) | ToolError::Internal(message) => {
ToolError::ExecutionFailed(message) => {
if message.contains("HTTP status 401") || message.contains("HTTP status 403") {
protocol_failure(ProtocolErrorCode::PermissionDenied, message)
} else if message.contains("HTTP status 404") {
@@ -1716,6 +1716,7 @@ fn map_ticket_tool_error(error: ToolError) -> WipOperationError {
WipOperationError::OutcomeUnknown(message)
}
}
ToolError::Internal(message) => WipOperationError::OutcomeUnknown(message),
}
}
@@ -1753,6 +1754,7 @@ fn add_ticket_paths(response: &mut Value, collection_route: &str) {
fn ticket_reference(response: &Value) -> Option<&str> {
response
.get("ticket")
.or_else(|| response.get("ticket_id"))
.or_else(|| response.get("id"))
.or_else(|| response.get("meta").and_then(|meta| meta.get("id")))
.and_then(Value::as_str)
@@ -1774,35 +1776,49 @@ fn record_ticket_observation(
response: &Value,
mutation: bool,
) {
let canonical = ticket_reference(response)
.map(ToOwned::to_owned)
.or_else(|| {
bound_reference
.filter(|reference| is_canonical_ticket_resource_key(reference))
.map(ToOwned::to_owned)
});
let observed_canonical = ticket_reference(response).map(ToOwned::to_owned);
let mut state = revisions.lock().unwrap_or_else(|error| error.into_inner());
let canonical = canonical.or_else(|| {
bound_reference.and_then(|reference| {
state
.aliases_by_canonical
.iter()
.find(|(_, aliases)| aliases.contains(reference))
.map(|(canonical, _)| canonical.clone())
})
let existing_bound_group = bound_reference.and_then(|reference| {
state
.aliases_by_canonical
.iter()
.find(|(_, aliases)| aliases.contains(reference))
.map(|(canonical, _)| canonical.clone())
});
let Some(canonical) = canonical else {
let existing_canonical_group = observed_canonical.as_deref().and_then(|reference| {
state
.aliases_by_canonical
.iter()
.find(|(_, aliases)| aliases.contains(reference))
.map(|(canonical, _)| canonical.clone())
});
let Some(group_key) = observed_canonical
.clone()
.or(existing_canonical_group.clone())
.or(existing_bound_group.clone())
.or_else(|| bound_reference.map(ToOwned::to_owned))
else {
return;
};
let aliases = state
.aliases_by_canonical
.entry(canonical.clone())
.or_default();
aliases.insert(canonical.clone());
let mut aliases = BTreeSet::from([group_key.clone()]);
for prior_group in [existing_bound_group, existing_canonical_group]
.into_iter()
.flatten()
.chain(std::iter::once(group_key.clone()))
.collect::<BTreeSet<_>>()
{
if let Some(existing) = state.aliases_by_canonical.remove(&prior_group) {
aliases.extend(existing);
}
}
if let Some(canonical) = observed_canonical {
aliases.insert(canonical);
}
if let Some(reference) = bound_reference {
aliases.insert(reference.to_string());
}
let aliases = aliases.clone();
let revision = if mutation {
state.mutation_sequence = state.mutation_sequence.saturating_add(1);
format!(
@@ -1818,8 +1834,40 @@ fn record_ticket_observation(
.find_map(|alias| state.revisions.get(alias).cloned())
.unwrap_or_else(|| "observed".into())
};
for alias in aliases {
state.revisions.insert(alias, revision.clone());
for alias in &aliases {
state.revisions.insert(alias.clone(), revision.clone());
}
state.aliases_by_canonical.insert(group_key, aliases);
}
fn record_affected_ticket_mutations(
revisions: &TicketRevisions,
bound_reference: &str,
response: &Value,
) {
let mut subject_references = BTreeSet::from([bound_reference.to_string()]);
if let Some(reference) = ticket_reference(response) {
subject_references.insert(reference.to_string());
}
let mut affected = BTreeSet::new();
if let Some(target) = response
.get("target")
.and_then(Value::as_str)
.filter(|reference| is_ticket_route_reference(reference))
{
affected.insert(target.to_string());
}
if let Some(queued) = response.get("queued_tickets").and_then(Value::as_array) {
affected.extend(
queued
.iter()
.filter_map(Value::as_str)
.filter(|reference| is_ticket_route_reference(reference))
.map(ToOwned::to_owned),
);
}
for reference in affected.difference(&subject_references) {
record_ticket_observation(revisions, Some(reference), &Value::Null, true);
}
}
@@ -2094,7 +2142,17 @@ mod tests {
]);
let input = native_ticket_input(&arguments, Some("T-42"), Some("ticket"))
.unwrap_or_else(|_| panic!("route-bound Ticket input should project"));
assert_eq!(input, json!({"ticket": "T-42", "body": "comment"}));
assert_eq!(
input,
json!({"ticket": "T-42", "body": "comment", "reason": null})
);
let omitted = native_ticket_input(
&BTreeMap::from([("body".into(), WipValue::String("comment".into()))]),
Some("T-42"),
Some("ticket"),
)
.unwrap_or_else(|_| panic!("omitted optional Ticket input should project"));
assert_eq!(omitted, json!({"ticket": "T-42", "body": "comment"}));
let replaced = native_ticket_input(
&BTreeMap::from([("ticket".into(), WipValue::String("T-99".into()))]),
Some("T-42"),
@@ -2128,18 +2186,31 @@ mod tests {
#[test]
fn ticket_alias_validators_refresh_together_after_mutation() {
let revisions = Arc::new(Mutex::new(TicketRevisionState::default()));
record_ticket_observation(
&revisions,
Some("00001TICKET"),
&json!({"ticket": "T-42", "updated_at": "rev-1"}),
false,
);
let resolver = TicketItemResolver {
tools: HashMap::new(),
permissions: None,
collection_route: "/features/ticket/tickets".into(),
revisions: Arc::clone(&revisions),
};
let unobserved_internal = resolver.resolve("00001TICKET").unwrap().object.validator;
record_ticket_observation(
&revisions,
Some("00001TICKET"),
&json!({"ticket": "00001TICKET", "ok": true}),
true,
);
assert_ne!(
unobserved_internal,
resolver.resolve("00001TICKET").unwrap().object.validator,
"a first mutation through an internal-id route must stale that route"
);
record_ticket_observation(
&revisions,
Some("00001TICKET"),
&json!({"ticket": "T-42", "item_revision": "rev-1"}),
false,
);
let canonical_v1 = resolver.resolve("T-42").unwrap().object.validator;
let internal_v1 = resolver.resolve("00001TICKET").unwrap().object.validator;
record_ticket_observation(
@@ -2158,6 +2229,44 @@ mod tests {
);
}
#[test]
fn related_ticket_mutations_invalidate_target_and_queue_observations() {
let revisions = Arc::new(Mutex::new(TicketRevisionState::default()));
for ticket in ["T-42", "T-43", "T-44"] {
record_ticket_observation(
&revisions,
Some(ticket),
&json!({"ticket": ticket, "item_revision": "rev-1"}),
false,
);
}
let resolver = TicketItemResolver {
tools: HashMap::new(),
permissions: None,
collection_route: "/features/ticket/tickets".into(),
revisions: Arc::clone(&revisions),
};
let target_v1 = resolver.resolve("T-43").unwrap().object.validator;
let queued_v1 = resolver.resolve("T-44").unwrap().object.validator;
record_affected_ticket_mutations(
&revisions,
"T-42",
&json!({
"ticket": "T-42",
"target": "T-43",
"queued_tickets": ["T-42", "T-44"]
}),
);
assert_ne!(
target_v1,
resolver.resolve("T-43").unwrap().object.validator
);
assert_ne!(
queued_v1,
resolver.resolve("T-44").unwrap().object.validator
);
}
#[test]
fn native_ticket_errors_keep_deterministic_protocol_outcomes() {
for (status, code) in [
@@ -2177,6 +2286,12 @@ mod tests {
map_ticket_tool_error(ToolError::ExecutionFailed("transport disconnected".into())),
WipOperationError::OutcomeUnknown(_)
));
assert!(matches!(
map_ticket_tool_error(ToolError::Internal(
"TicketClose outcome unknown after mutation: HTTP status 404".into()
)),
WipOperationError::OutcomeUnknown(_)
));
}
#[test]