feat(ticket): validate parent links across signed API, Tools, WIP and Web
This commit is contained in:
@@ -3537,7 +3537,7 @@ pub trait ServerApi {
|
|||||||
#[path] id: String,
|
#[path] id: String,
|
||||||
) -> Result<TicketParentResponse, RepositoryApiError>;
|
) -> Result<TicketParentResponse, RepositoryApiError>;
|
||||||
|
|
||||||
#[put("/api/w/{workspace_id}/tickets/{id}/parent", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
|
#[put("/api/w/{workspace_id}/tickets/{id}/parent", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
|
||||||
async fn ticket_parent_set(
|
async fn ticket_parent_set(
|
||||||
&self,
|
&self,
|
||||||
#[extension] context: ServerRequestContext,
|
#[extension] context: ServerRequestContext,
|
||||||
@@ -12558,7 +12558,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let eligibility = serde_json::json!({
|
let eligibility = serde_json::json!({
|
||||||
"can_assign_orchestrator": false, "can_unassign_orchestrator": false,
|
"can_assign_orchestrator": false, "can_unassign_orchestrator": false,
|
||||||
"can_queue": true, "can_start_manual_worker": true, "queue_tickets": [], "blockers": []
|
"can_queue": true, "can_start_manual_worker": true, "can_set_parent": false, "queue_tickets": [], "blockers": []
|
||||||
});
|
});
|
||||||
let parsed: TicketActionEligibility = serde_json::from_value(eligibility.clone()).unwrap();
|
let parsed: TicketActionEligibility = serde_json::from_value(eligibility.clone()).unwrap();
|
||||||
assert_eq!(serde_json::to_value(parsed).unwrap(), eligibility);
|
assert_eq!(serde_json::to_value(parsed).unwrap(), eligibility);
|
||||||
|
|||||||
@@ -417,7 +417,7 @@ fn migration_preserves_original_records_and_only_reconciles_current_fenced_deleg
|
|||||||
|r| r.get::<_, i64>(0)
|
|r| r.get::<_, i64>(0)
|
||||||
)
|
)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
15
|
16
|
||||||
);
|
);
|
||||||
let value = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
|
let value = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ mod tests;
|
|||||||
|
|
||||||
pub(crate) const SCHEMA_SQL: &str = include_str!("parent_links.sql");
|
pub(crate) const SCHEMA_SQL: &str = include_str!("parent_links.sql");
|
||||||
|
|
||||||
|
// Schemars' required validator unwraps Option unless schema generation stays explicit.
|
||||||
|
// Wire presence and nullability are independent: null is an observed domain value.
|
||||||
|
fn nullable_schema<T: schemars::JsonSchema>(
|
||||||
|
generator: &mut schemars::SchemaGenerator,
|
||||||
|
) -> schemars::Schema {
|
||||||
|
<Option<T> as schemars::JsonSchema>::json_schema(generator)
|
||||||
|
}
|
||||||
|
|
||||||
fn deserialize_observed_parent<'de, D: serde::Deserializer<'de>>(
|
fn deserialize_observed_parent<'de, D: serde::Deserializer<'de>>(
|
||||||
deserializer: D,
|
deserializer: D,
|
||||||
) -> std::result::Result<Option<String>, D::Error> {
|
) -> std::result::Result<Option<String>, D::Error> {
|
||||||
@@ -39,10 +47,10 @@ fn deserialize_desired_parent<'de, D: serde::Deserializer<'de>>(
|
|||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct TicketParentObservation {
|
pub struct TicketParentObservation {
|
||||||
#[serde(deserialize_with = "deserialize_observed_parent")]
|
#[serde(deserialize_with = "deserialize_observed_parent")]
|
||||||
#[schemars(required)]
|
#[schemars(required, schema_with = "nullable_schema::<String>")]
|
||||||
pub parent_ticket_id: Option<String>,
|
pub parent_ticket_id: Option<String>,
|
||||||
#[serde(deserialize_with = "deserialize_observed_parent")]
|
#[serde(deserialize_with = "deserialize_observed_parent")]
|
||||||
#[schemars(required)]
|
#[schemars(required, schema_with = "nullable_schema::<String>")]
|
||||||
pub last_operation_key: Option<String>,
|
pub last_operation_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +60,7 @@ pub struct TicketParentSetRequest {
|
|||||||
pub expected: TicketParentObservation,
|
pub expected: TicketParentObservation,
|
||||||
/// Null unlinks; references are resolved only inside the scoped write transaction.
|
/// Null unlinks; references are resolved only inside the scoped write transaction.
|
||||||
#[serde(deserialize_with = "deserialize_desired_parent")]
|
#[serde(deserialize_with = "deserialize_desired_parent")]
|
||||||
#[schemars(required)]
|
#[schemars(required, schema_with = "nullable_schema::<String>")]
|
||||||
pub parent: Option<String>,
|
pub parent: Option<String>,
|
||||||
pub operation_key: String,
|
pub operation_key: String,
|
||||||
}
|
}
|
||||||
@@ -61,6 +69,7 @@ pub struct TicketParentSetRequest {
|
|||||||
pub struct TicketParentLink {
|
pub struct TicketParentLink {
|
||||||
pub ticket_id: String,
|
pub ticket_id: String,
|
||||||
pub observation: TicketParentObservation,
|
pub observation: TicketParentObservation,
|
||||||
|
#[schemars(required, schema_with = "nullable_schema::<TicketLinkSummary>")]
|
||||||
pub parent: Option<TicketLinkSummary>,
|
pub parent: Option<TicketLinkSummary>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,13 +80,14 @@ pub struct TicketLinkSummary {
|
|||||||
pub resource_key: String,
|
pub resource_key: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub state: TicketState,
|
pub state: TicketState,
|
||||||
|
#[schemars(required, schema_with = "nullable_schema::<TicketClosureReason>")]
|
||||||
pub closure_reason: Option<TicketClosureReason>,
|
pub closure_reason: Option<TicketClosureReason>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||||
pub enum TicketParentFilter {
|
pub enum TicketParentFilter {
|
||||||
None,
|
None {},
|
||||||
Parent { ticket: String },
|
Parent { ticket: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +114,7 @@ pub struct TicketChildrenRequest {
|
|||||||
pub struct TicketChildrenPage {
|
pub struct TicketChildrenPage {
|
||||||
pub parent_ticket_id: String,
|
pub parent_ticket_id: String,
|
||||||
pub items: Vec<TicketLinkSummary>,
|
pub items: Vec<TicketLinkSummary>,
|
||||||
|
#[schemars(required, schema_with = "nullable_schema::<String>")]
|
||||||
pub next_cursor: Option<String>,
|
pub next_cursor: Option<String>,
|
||||||
pub has_more: bool,
|
pub has_more: bool,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -336,6 +336,24 @@ fn parent_wire_contract_requires_explicit_nullable_observation_and_desired_paren
|
|||||||
}
|
}
|
||||||
let input = r#"{"expected":{"parent_ticket_id":null,"last_operation_key":null},"parent":null,"operation_key":"key"}"#;
|
let input = r#"{"expected":{"parent_ticket_id":null,"last_operation_key":null},"parent":null,"operation_key":"key"}"#;
|
||||||
assert!(serde_json::from_str::<TicketParentSetRequest>(input).is_ok());
|
assert!(serde_json::from_str::<TicketParentSetRequest>(input).is_ok());
|
||||||
|
let schema = serde_json::to_value(schemars::schema_for!(TicketParentSetRequest)).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
schema["properties"]["parent"]["type"],
|
||||||
|
serde_json::json!(["string", "null"])
|
||||||
|
);
|
||||||
|
let observation = &schema["$defs"]["TicketParentObservation"];
|
||||||
|
for field in ["parent_ticket_id", "last_operation_key"] {
|
||||||
|
assert!(
|
||||||
|
observation["required"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.contains(&serde_json::json!(field))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
observation["properties"][field]["type"],
|
||||||
|
serde_json::json!(["string", "null"])
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn outbox_schema_preimage() -> Connection {
|
fn outbox_schema_preimage() -> Connection {
|
||||||
|
|||||||
@@ -1061,7 +1061,7 @@ fn bounded_content13_reopens_after_failed_subject_upgrade_then_migrates_atomical
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
migrate_sqlite_ticket_schema(&conn).unwrap();
|
migrate_sqlite_ticket_schema(&conn).unwrap();
|
||||||
verify_sqlite_ticket_schema(&conn).unwrap();
|
verify_sqlite_ticket_schema(&conn).unwrap();
|
||||||
assert_eq!(max_component(&conn), 15);
|
assert_eq!(max_component(&conn), 16);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
&rows(&conn, "ticket_schema_migrations")[..receipts.len()],
|
&rows(&conn, "ticket_schema_migrations")[..receipts.len()],
|
||||||
receipts.as_slice()
|
receipts.as_slice()
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ fn schema12_migrates_body_authors_archived_history_and_stable_references_then_re
|
|||||||
|r| r.get::<_, i64>(0)
|
|r| r.get::<_, i64>(0)
|
||||||
)
|
)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
15
|
16
|
||||||
);
|
);
|
||||||
backend
|
backend
|
||||||
.add_event("known".into(), comment("Next", "author"))
|
.add_event("known".into(), comment("Next", "author"))
|
||||||
|
|||||||
@@ -597,9 +597,9 @@ fn verifier_rejects_weakened_fact_constraints_trigger_and_migration_history() {
|
|||||||
assert!(migrate_sqlite_ticket_schema(&f.conn).is_err(), "{from}");
|
assert!(migrate_sqlite_ticket_schema(&f.conn).is_err(), "{from}");
|
||||||
}
|
}
|
||||||
for mutation in [
|
for mutation in [
|
||||||
"UPDATE ticket_schema_migrations SET name='unrecognized' WHERE version=15",
|
"UPDATE ticket_schema_migrations SET name='unrecognized' WHERE version=16",
|
||||||
"INSERT INTO ticket_schema_migrations VALUES(12,'Canonical Ticket posts','invalid extra history')",
|
"INSERT INTO ticket_schema_migrations VALUES(12,'Canonical Ticket posts','invalid extra history')",
|
||||||
"UPDATE ticket_schema_migrations SET version=16 WHERE version=15",
|
"UPDATE ticket_schema_migrations SET version=17 WHERE version=16",
|
||||||
] {
|
] {
|
||||||
let f = Fixture::new();
|
let f = Fixture::new();
|
||||||
f.conn.execute_batch(mutation).unwrap();
|
f.conn.execute_batch(mutation).unwrap();
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ async fn inspect_open_entry_read_and_linked_ticket_use_published_contract_withou
|
|||||||
for ticket_first in [true, false] {
|
for ticket_first in [true, false] {
|
||||||
let detail = json!({"resource_key":"T-1", "title":"Intent", "body":"Requirements", "body_truncated":false,
|
let detail = json!({"resource_key":"T-1", "title":"Intent", "body":"Requirements", "body_truncated":false,
|
||||||
"content_digest":"observed", "state":"open", "targets":[], "events":[], "linked_objectives":[],
|
"content_digest":"observed", "state":"open", "targets":[], "events":[], "linked_objectives":[],
|
||||||
|
"parent_link":{"ticket_id":"T-1","observation":{"parent_ticket_id":null,"last_operation_key":null},"parent":null},
|
||||||
"initial_post":{"sequence":0,"event_ref":"initial","is_initial":true,"kind":"comment","body":"Requirements","body_truncated":false,"body_digest":"a".repeat(64),"editable":true,"author":null,"at":null,"edited_at":null,"edited_by":null},
|
"initial_post":{"sequence":0,"event_ref":"initial","is_initial":true,"kind":"comment","body":"Requirements","body_truncated":false,"body_digest":"a".repeat(64),"editable":true,"author":null,"at":null,"edited_at":null,"edited_by":null},
|
||||||
"closure_operation_key":null,"current_closure":null,"legacy_handoff":null,"closure_history":[],
|
"closure_operation_key":null,"current_closure":null,"legacy_handoff":null,"closure_history":[],
|
||||||
"assignment_tracking":[],"tags":[],"assignments":[],"implementation_reports":[],"merge_requests":[]});
|
"assignment_tracking":[],"tags":[],"assignments":[],"implementation_reports":[],"merge_requests":[]});
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ fn ticket_detail() -> crate::worker::WorkspaceResponse {
|
|||||||
response(json!({
|
response(json!({
|
||||||
"resource_key":"T-7","title":"Linked","body":"Requirements","body_truncated":false,
|
"resource_key":"T-7","title":"Linked","body":"Requirements","body_truncated":false,
|
||||||
"content_digest":"observed","state":"open","targets":[],"events":[],"linked_objectives":[],
|
"content_digest":"observed","state":"open","targets":[],"events":[],"linked_objectives":[],
|
||||||
|
"parent_link":{"ticket_id":"T-7","observation":{"parent_ticket_id":null,"last_operation_key":null},"parent":null},
|
||||||
"initial_post":{"sequence":0,"event_ref":"initial","is_initial":true,"kind":"comment",
|
"initial_post":{"sequence":0,"event_ref":"initial","is_initial":true,"kind":"comment",
|
||||||
"body":"Requirements","body_truncated":false,"body_digest":"a".repeat(64),
|
"body":"Requirements","body_truncated":false,"body_digest":"a".repeat(64),
|
||||||
"editable":true,"author":null,"at":null,"edited_at":null,"edited_by":null},
|
"editable":true,"author":null,"at":null,"edited_at":null,"edited_by":null},
|
||||||
|
|||||||
@@ -300,7 +300,8 @@ impl WorkspaceTicketReadTool {
|
|||||||
self.client.as_ref(),
|
self.client.as_ref(),
|
||||||
"ticket.relation.query",
|
"ticket.relation.query",
|
||||||
&TicketIdOrSlug::from(ticket.as_str()),
|
&TicketIdOrSlug::from(ticket.as_str()),
|
||||||
).map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
)
|
||||||
|
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||||
}
|
}
|
||||||
for (name, ids) in [
|
for (name, ids) in [
|
||||||
("tag_ids", &input.tag_ids),
|
("tag_ids", &input.tag_ids),
|
||||||
@@ -338,7 +339,7 @@ impl WorkspaceTicketReadTool {
|
|||||||
authorize_ticket_item(self.client.as_ref(), "ticket.read", &id)
|
authorize_ticket_item(self.client.as_ref(), "ticket.read", &id)
|
||||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||||
authorize_ticket_item(self.client.as_ref(), "ticket.relation.read", &id)
|
authorize_ticket_item(self.client.as_ref(), "ticket.relation.read", &id)
|
||||||
},
|
}
|
||||||
None => authorize_ticket_operation(self.client.as_ref(), "ticket.query"),
|
None => authorize_ticket_operation(self.client.as_ref(), "ticket.query"),
|
||||||
}
|
}
|
||||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||||
@@ -713,9 +714,11 @@ impl TicketFeatureAccess {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.chain(std::iter::once(notification_diagnostics::NAME))
|
.chain(std::iter::once(notification_diagnostics::NAME))
|
||||||
.chain(parent_links::TOOL_NAMES.iter().copied().filter(|name| {
|
.chain(
|
||||||
*name == "TicketChildrenQuery" || self.authoring || self.workflow
|
parent_links::TOOL_NAMES.iter().copied().filter(|name| {
|
||||||
}))
|
*name == "TicketChildrenQuery" || self.authoring || self.workflow
|
||||||
|
}),
|
||||||
|
)
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1143,8 +1146,10 @@ impl WorkspaceHttpTicketBackend {
|
|||||||
TicketBackendOperation::ParentLink { id } => {
|
TicketBackendOperation::ParentLink { id } => {
|
||||||
authorize_ticket_item(client.as_ref(), "ticket.relation.read", &id)?;
|
authorize_ticket_item(client.as_ref(), "ticket.relation.read", &id)?;
|
||||||
let link: ticket::TicketParentLink = Self::request_unprojected(
|
let link: ticket::TicketParentLink = Self::request_unprojected(
|
||||||
client.clone(), WorkspaceRequestMethod::Get,
|
client.clone(),
|
||||||
format!("{base}/{}/parent", Self::ticket_path(&id)), None,
|
WorkspaceRequestMethod::Get,
|
||||||
|
format!("{base}/{}/parent", Self::ticket_path(&id)),
|
||||||
|
None,
|
||||||
)?;
|
)?;
|
||||||
parent_links::authorize_link(client.as_ref(), &link)?;
|
parent_links::authorize_link(client.as_ref(), &link)?;
|
||||||
Ok(TicketBackendOperationResult::ParentLink(link))
|
Ok(TicketBackendOperationResult::ParentLink(link))
|
||||||
@@ -1153,8 +1158,10 @@ impl WorkspaceHttpTicketBackend {
|
|||||||
parent_links::validate_set(&request)?;
|
parent_links::validate_set(&request)?;
|
||||||
parent_links::authorize_set(client.clone(), &base, &id, &request)?;
|
parent_links::authorize_set(client.clone(), &base, &id, &request)?;
|
||||||
let link: ticket::TicketParentLink = Self::request_unprojected(
|
let link: ticket::TicketParentLink = Self::request_unprojected(
|
||||||
client, WorkspaceRequestMethod::Put,
|
client,
|
||||||
format!("{base}/{}/parent", Self::ticket_path(&id)), Some(json!(request)),
|
WorkspaceRequestMethod::Put,
|
||||||
|
format!("{base}/{}/parent", Self::ticket_path(&id)),
|
||||||
|
Some(json!(request)),
|
||||||
)?;
|
)?;
|
||||||
// Return the exact typed receipt, not a follow-up observation; CAS IDs stay intact.
|
// Return the exact typed receipt, not a follow-up observation; CAS IDs stay intact.
|
||||||
Ok(TicketBackendOperationResult::ParentLink(link))
|
Ok(TicketBackendOperationResult::ParentLink(link))
|
||||||
@@ -1163,10 +1170,15 @@ impl WorkspaceHttpTicketBackend {
|
|||||||
let endpoint = parent_links::children_endpoint(&base, &id, &request)?;
|
let endpoint = parent_links::children_endpoint(&base, &id, &request)?;
|
||||||
authorize_ticket_item(client.as_ref(), "ticket.relation.read", &id)?;
|
authorize_ticket_item(client.as_ref(), "ticket.relation.read", &id)?;
|
||||||
let page: ticket::TicketChildrenPage = Self::request_unprojected(
|
let page: ticket::TicketChildrenPage = Self::request_unprojected(
|
||||||
client.clone(), WorkspaceRequestMethod::Get, endpoint, None,
|
client.clone(),
|
||||||
|
WorkspaceRequestMethod::Get,
|
||||||
|
endpoint,
|
||||||
|
None,
|
||||||
)?;
|
)?;
|
||||||
if page.items.len() > request.limit.unwrap_or(50) {
|
if page.items.len() > request.limit.unwrap_or(50) {
|
||||||
return Err(TicketError::Conflict("Ticket children response exceeds requested bound".into()));
|
return Err(TicketError::Conflict(
|
||||||
|
"Ticket children response exceeds requested bound".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
authorize_ticket_item(
|
authorize_ticket_item(
|
||||||
client.as_ref(),
|
client.as_ref(),
|
||||||
@@ -1174,7 +1186,11 @@ impl WorkspaceHttpTicketBackend {
|
|||||||
&TicketIdOrSlug::from(page.parent_ticket_id.as_str()),
|
&TicketIdOrSlug::from(page.parent_ticket_id.as_str()),
|
||||||
)?;
|
)?;
|
||||||
for child in &page.items {
|
for child in &page.items {
|
||||||
authorize_ticket_item(client.as_ref(), "ticket.relation.read", &TicketIdOrSlug::from(child.id.as_str()))?;
|
authorize_ticket_item(
|
||||||
|
client.as_ref(),
|
||||||
|
"ticket.relation.read",
|
||||||
|
&TicketIdOrSlug::from(child.id.as_str()),
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
Ok(TicketBackendOperationResult::ChildrenPage(page))
|
Ok(TicketBackendOperationResult::ChildrenPage(page))
|
||||||
}
|
}
|
||||||
@@ -1537,15 +1553,32 @@ macro_rules! expect_ticket_result {
|
|||||||
|
|
||||||
impl TicketBackend for WorkspaceHttpTicketBackend {
|
impl TicketBackend for WorkspaceHttpTicketBackend {
|
||||||
fn get_ticket_parent(&self, id: TicketIdOrSlug) -> TicketResult<ticket::TicketParentLink> {
|
fn get_ticket_parent(&self, id: TicketIdOrSlug) -> TicketResult<ticket::TicketParentLink> {
|
||||||
expect_ticket_result!(self.invoke(TicketBackendOperation::ParentLink { id }), TicketBackendOperationResult::ParentLink)
|
expect_ticket_result!(
|
||||||
|
self.invoke(TicketBackendOperation::ParentLink { id }),
|
||||||
|
TicketBackendOperationResult::ParentLink
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_ticket_parent(&self, id: TicketIdOrSlug, request: ticket::TicketParentSetRequest) -> TicketResult<ticket::TicketParentLink> {
|
fn set_ticket_parent(
|
||||||
expect_ticket_result!(self.invoke(TicketBackendOperation::SetParentLink { id, request }), TicketBackendOperationResult::ParentLink)
|
&self,
|
||||||
|
id: TicketIdOrSlug,
|
||||||
|
request: ticket::TicketParentSetRequest,
|
||||||
|
) -> TicketResult<ticket::TicketParentLink> {
|
||||||
|
expect_ticket_result!(
|
||||||
|
self.invoke(TicketBackendOperation::SetParentLink { id, request }),
|
||||||
|
TicketBackendOperationResult::ParentLink
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn query_ticket_children(&self, id: TicketIdOrSlug, request: ticket::TicketChildrenRequest) -> TicketResult<ticket::TicketChildrenPage> {
|
fn query_ticket_children(
|
||||||
expect_ticket_result!(self.invoke(TicketBackendOperation::ChildrenPage { id, request }), TicketBackendOperationResult::ChildrenPage)
|
&self,
|
||||||
|
id: TicketIdOrSlug,
|
||||||
|
request: ticket::TicketChildrenRequest,
|
||||||
|
) -> TicketResult<ticket::TicketChildrenPage> {
|
||||||
|
expect_ticket_result!(
|
||||||
|
self.invoke(TicketBackendOperation::ChildrenPage { id, request }),
|
||||||
|
TicketBackendOperationResult::ChildrenPage
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn edit_post_with_snapshots(
|
fn edit_post_with_snapshots(
|
||||||
@@ -2049,7 +2082,10 @@ pub(crate) fn enabled_ticket_definitions(
|
|||||||
if allowed.contains(¬ification_diagnostics::NAME) {
|
if allowed.contains(¬ification_diagnostics::NAME) {
|
||||||
definitions.push(notification_diagnostics::definition(client.clone()));
|
definitions.push(notification_diagnostics::definition(client.clone()));
|
||||||
}
|
}
|
||||||
for kind in [parent_links::ParentToolKind::Set, parent_links::ParentToolKind::Children] {
|
for kind in [
|
||||||
|
parent_links::ParentToolKind::Set,
|
||||||
|
parent_links::ParentToolKind::Children,
|
||||||
|
] {
|
||||||
if allowed.contains(&kind.name()) {
|
if allowed.contains(&kind.name()) {
|
||||||
definitions.push(parent_links::definition(client.clone(), kind));
|
definitions.push(parent_links::definition(client.clone(), kind));
|
||||||
}
|
}
|
||||||
@@ -2320,8 +2356,11 @@ impl WipOperationHandler for TicketItemWipHandler {
|
|||||||
// Old-parent CAS IDs may not have a locally observed canonical alias. A receipt
|
// Old-parent CAS IDs may not have a locally observed canonical alias. A receipt
|
||||||
// is not a fresh representation; conservatively invalidate rather than leave a
|
// is not a fresh representation; conservatively invalidate rather than leave a
|
||||||
// previously read parent/children view attested by a stale route validator.
|
// previously read parent/children view attested by a stale route validator.
|
||||||
self.observations.lock().unwrap_or_else(|error| error.into_inner())
|
self.observations
|
||||||
.content_digests.clear();
|
.lock()
|
||||||
|
.unwrap_or_else(|error| error.into_inner())
|
||||||
|
.content_digests
|
||||||
|
.clear();
|
||||||
}
|
}
|
||||||
// Partial pages and classification receipts are not full item observations.
|
// Partial pages and classification receipts are not full item observations.
|
||||||
if !matches!(operation, "tags_set" | "children_query") {
|
if !matches!(operation, "tags_set" | "children_query") {
|
||||||
@@ -2353,7 +2392,8 @@ async fn execute_native_ticket_tool(
|
|||||||
context: WipCallContext,
|
context: WipCallContext,
|
||||||
) -> Result<Value, WipOperationError> {
|
) -> Result<Value, WipOperationError> {
|
||||||
authorize_native_ticket(permissions, &tool.name, &input)?;
|
authorize_native_ticket(permissions, &tool.name, &input)?;
|
||||||
if let Some(value) = parent_links::execute_value(tool.client.clone(), &tool.name, input.clone()) {
|
if let Some(value) = parent_links::execute_value(tool.client.clone(), &tool.name, input.clone())
|
||||||
|
{
|
||||||
return value.map_err(map_ticket_tool_error);
|
return value.map_err(map_ticket_tool_error);
|
||||||
}
|
}
|
||||||
if let Some(value) = assignment_public::execute_native_value(
|
if let Some(value) = assignment_public::execute_native_value(
|
||||||
@@ -2454,7 +2494,9 @@ async fn execute_ticket_domain(
|
|||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
if let Some(result) = parent_links::execute_value(client.clone(), name, command.clone()) {
|
if let Some(result) = parent_links::execute_value(client.clone(), name, command.clone()) {
|
||||||
return result.map(|value| ToolOutput {
|
return result.map(|value| ToolOutput {
|
||||||
summary: name.into(), content: Some(value.to_string()), attachments: Vec::new(),
|
summary: name.into(),
|
||||||
|
content: Some(value.to_string()),
|
||||||
|
attachments: Vec::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if name == "QueryTicket" || name == "ShowTicket" {
|
if name == "QueryTicket" || name == "ShowTicket" {
|
||||||
|
|||||||
@@ -219,10 +219,20 @@ pub(super) fn contract(op: &str) -> Contract {
|
|||||||
),
|
),
|
||||||
rename("related", "related_ticket_id", false, ticket_ref()),
|
rename("related", "related_ticket_id", false, ticket_ref()),
|
||||||
p("relation_kind", false, relation()),
|
p("relation_kind", false, relation()),
|
||||||
p("parent", false, union("kind", vec![
|
p(
|
||||||
case("none", None),
|
"parent",
|
||||||
case("parent", Some(record(vec![p("ticket", true, ticket_ref())]))),
|
false,
|
||||||
])),
|
union(
|
||||||
|
"kind",
|
||||||
|
vec![
|
||||||
|
case("none", None),
|
||||||
|
case(
|
||||||
|
"parent",
|
||||||
|
Some(record(vec![p("ticket", true, ticket_ref())])),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
p("tag_ids", false, strings()),
|
p("tag_ids", false, strings()),
|
||||||
p("tag_ids_any", false, strings()),
|
p("tag_ids_any", false, strings()),
|
||||||
p("tag_ids_none", false, strings()),
|
p("tag_ids_none", false, strings()),
|
||||||
@@ -274,10 +284,14 @@ pub(super) fn contract(op: &str) -> Contract {
|
|||||||
"parent_set",
|
"parent_set",
|
||||||
parent_links::description("TicketParentSet"),
|
parent_links::description("TicketParentSet"),
|
||||||
vec![
|
vec![
|
||||||
p("expected", true, record(vec![
|
p(
|
||||||
p("parent_ticket_id", true, nullable(Shape::String)),
|
"expected",
|
||||||
p("last_operation_key", true, nullable(Shape::String)),
|
true,
|
||||||
])),
|
record(vec![
|
||||||
|
p("parent_ticket_id", true, nullable(Shape::String)),
|
||||||
|
p("last_operation_key", true, nullable(Shape::String)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
p("parent", true, nullable(ticket_ref())),
|
p("parent", true, nullable(ticket_ref())),
|
||||||
p("operation_key", true, Shape::String),
|
p("operation_key", true, Shape::String),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -22,12 +22,16 @@ struct SetInput {
|
|||||||
expected: ticket::TicketParentObservation,
|
expected: ticket::TicketParentObservation,
|
||||||
/// Desired parent reference, or explicit null to unlink. Never omit.
|
/// Desired parent reference, or explicit null to unlink. Never omit.
|
||||||
#[serde(deserialize_with = "deserialize_required_parent")]
|
#[serde(deserialize_with = "deserialize_required_parent")]
|
||||||
#[schemars(required)]
|
#[schemars(required, schema_with = "nullable_string_schema")]
|
||||||
parent: Option<String>,
|
parent: Option<String>,
|
||||||
/// Stable exact-replay key; retain the full original selectors and input on retry.
|
/// Stable exact-replay key; retain the full original selectors and input on retry.
|
||||||
operation_key: String,
|
operation_key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn nullable_string_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
|
||||||
|
Option::<String>::json_schema(generator)
|
||||||
|
}
|
||||||
|
|
||||||
fn deserialize_required_parent<'de, D: serde::Deserializer<'de>>(
|
fn deserialize_required_parent<'de, D: serde::Deserializer<'de>>(
|
||||||
deserializer: D,
|
deserializer: D,
|
||||||
) -> Result<Option<String>, D::Error> {
|
) -> Result<Option<String>, D::Error> {
|
||||||
@@ -63,8 +67,12 @@ impl ParentToolKind {
|
|||||||
|
|
||||||
pub(super) fn description(name: &str) -> &'static str {
|
pub(super) fn description(name: &str) -> &'static str {
|
||||||
match name {
|
match name {
|
||||||
"TicketParentSet" => "Atomically set, replace or unlink this Ticket's parent. Reread ShowTicket.parent_link and supply its complete expected observation, including explicit nullable parent_ticket_id and last_operation_key; the retained key fences ABA even after unlink. parent must be a Ticket reference or explicit null. Exact replay retains the same operation_key, selectors and full input and returns a historical receipt, not a fresh observation. On stale/CAS conflict reread and reconcile; on unknown outcome reconcile saved authority and retry only the exact original request, never automatically mint a new key. Requires relation.read on child and desired parent metadata before mutation, independently of mutation grants. Setting requires relation.record on child and desired parent. Unlinking, or replacing an existing old parent with a different resolved canonical ID, additionally requires relation.remove on child and expected old parent when present. First attachment and the same canonical parent require no remove grant. Links do not grant resources, inherit authority, change case/assignment, start Workers or imply parent fulfillment.",
|
"TicketParentSet" => {
|
||||||
"TicketChildrenQuery" => "Read a bounded stable page of this Ticket's direct children, with case state and closure reason (closed is not necessarily success). Defaults to 50, maximum 100; has_more/next_cursor explicitly mark truncation. Continue with the opaque cursor and unchanged parent/scope/limit. Requires relation.read on the addressed parent and actual returned child Tickets; collection permission grants no linked-Ticket authority. No recursive expansion, inherited grants, case/assignment changes or execution rights.",
|
"Atomically set, replace or unlink this Ticket's parent. Reread ShowTicket.parent_link and supply its complete expected observation, including explicit nullable parent_ticket_id and last_operation_key; the retained key fences ABA even after unlink. parent must be a Ticket reference or explicit null. Exact replay retains the same operation_key, selectors and full input and returns a historical receipt, not a fresh observation. On stale/CAS conflict reread and reconcile; on unknown outcome reconcile saved authority and retry only the exact original request, never automatically mint a new key. Requires relation.read on child and desired parent metadata before mutation, independently of mutation grants. Setting requires relation.record on child and desired parent. Unlinking, or replacing an existing old parent with a different resolved canonical ID, additionally requires relation.remove on child and expected old parent when present. First attachment and the same canonical parent require no remove grant. Links do not grant resources, inherit authority, change case/assignment, start Workers or imply parent fulfillment."
|
||||||
|
}
|
||||||
|
"TicketChildrenQuery" => {
|
||||||
|
"Read a bounded stable page of this Ticket's direct children, with case state and closure reason (closed is not necessarily success). Defaults to 50, maximum 100; has_more/next_cursor explicitly mark truncation. Continue with the opaque cursor and unchanged parent/scope/limit. Requires relation.read on the addressed parent and actual returned child Tickets; collection permission grants no linked-Ticket authority. No recursive expansion, inherited grants, case/assignment changes or execution rights."
|
||||||
|
}
|
||||||
_ => unreachable!("unknown parent link tool"),
|
_ => unreachable!("unknown parent link tool"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,11 +118,16 @@ fn resolve_id(
|
|||||||
let summary: ticket::TicketLinkSummary = WorkspaceHttpTicketBackend::request_unprojected(
|
let summary: ticket::TicketLinkSummary = WorkspaceHttpTicketBackend::request_unprojected(
|
||||||
client,
|
client,
|
||||||
WorkspaceRequestMethod::Get,
|
WorkspaceRequestMethod::Get,
|
||||||
format!("{base}/{}/link-summary", WorkspaceHttpTicketBackend::ticket_path(reference)),
|
format!(
|
||||||
|
"{base}/{}/link-summary",
|
||||||
|
WorkspaceHttpTicketBackend::ticket_path(reference),
|
||||||
|
),
|
||||||
None,
|
None,
|
||||||
)?;
|
)?;
|
||||||
if summary.id.is_empty() {
|
if summary.id.is_empty() {
|
||||||
return Err(TicketError::Conflict("resolved Ticket identity is unavailable".into()));
|
return Err(TicketError::Conflict(
|
||||||
|
"resolved Ticket identity is unavailable".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok(summary.id)
|
Ok(summary.id)
|
||||||
}
|
}
|
||||||
@@ -189,9 +202,9 @@ pub(super) fn children_endpoint(
|
|||||||
if let Some(cursor) = &request.cursor {
|
if let Some(cursor) = &request.cursor {
|
||||||
required("cursor", cursor)?;
|
required("cursor", cursor)?;
|
||||||
endpoint.push_str("&cursor=");
|
endpoint.push_str("&cursor=");
|
||||||
endpoint.push_str(&WorkspaceHttpTicketBackend::ticket_path(&TicketIdOrSlug::Id(
|
endpoint.push_str(&WorkspaceHttpTicketBackend::ticket_path(
|
||||||
cursor.clone(),
|
&TicketIdOrSlug::Id(cursor.clone()),
|
||||||
)));
|
));
|
||||||
}
|
}
|
||||||
Ok(endpoint)
|
Ok(endpoint)
|
||||||
}
|
}
|
||||||
@@ -215,25 +228,21 @@ pub(super) fn authorize_link(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
pub(super) fn project_detail(
|
||||||
struct DetailProjection {
|
client: &dyn WorkspaceClient,
|
||||||
#[serde(flatten)]
|
value: Value,
|
||||||
detail: super::super::resource_projection::ModelTicketDetail,
|
) -> Result<Value, ToolError> {
|
||||||
parent_link: ticket::TicketParentLink,
|
let parent_link: ticket::TicketParentLink =
|
||||||
}
|
serde_json::from_value(value.get("parent_link").cloned().ok_or_else(|| {
|
||||||
|
|
||||||
pub(super) fn project_detail(client: &dyn WorkspaceClient, value: Value) -> Result<Value, ToolError> {
|
|
||||||
let parent_link: ticket::TicketParentLink = serde_json::from_value(
|
|
||||||
value.get("parent_link").cloned().ok_or_else(|| {
|
|
||||||
ToolError::ExecutionFailed("Ticket detail is missing parent_link".into())
|
ToolError::ExecutionFailed("Ticket detail is missing parent_link".into())
|
||||||
})?,
|
})?)
|
||||||
)
|
.map_err(|error| {
|
||||||
.map_err(|error| ToolError::ExecutionFailed(format!("decode Ticket parent_link: {error}")))?;
|
ToolError::ExecutionFailed(format!("decode Ticket parent_link: {error}"))
|
||||||
|
})?;
|
||||||
authorize_link(client, &parent_link)
|
authorize_link(client, &parent_link)
|
||||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||||
let detail = project_ticket_detail(value).map_err(ToolError::ExecutionFailed)?;
|
let detail = project_ticket_detail(value).map_err(ToolError::ExecutionFailed)?;
|
||||||
serde_json::to_value(DetailProjection { detail, parent_link })
|
serde_json::to_value(detail).map_err(|error| ToolError::Internal(error.to_string()))
|
||||||
.map_err(|error| ToolError::Internal(error.to_string()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -294,7 +303,9 @@ pub(super) fn execute_value(
|
|||||||
};
|
};
|
||||||
let id = TicketIdOrSlug::from(input.ticket.as_str());
|
let id = TicketIdOrSlug::from(input.ticket.as_str());
|
||||||
children_endpoint("", &id, &request).map_err(invalid)?;
|
children_endpoint("", &id, &request).map_err(invalid)?;
|
||||||
let result = backend.query_ticket_children(id, request).map_err(failure)?;
|
let result = backend
|
||||||
|
.query_ticket_children(id, request)
|
||||||
|
.map_err(failure)?;
|
||||||
Ok(json!(result))
|
Ok(json!(result))
|
||||||
}
|
}
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
//! Component boundary: production Tool/native conversion, runtime authorization and HTTP adapter;
|
//! Component boundary: production Tool/native conversion, runtime authorization and HTTP adapter;
|
||||||
//! the Workspace HTTP endpoint is a fixture, not evidence of server authorization or persistence.
|
//! the Workspace HTTP endpoint is a fixture, not evidence of server authorization or persistence.
|
||||||
use super::*;
|
use super::*;
|
||||||
use assignment_tracking::authorization_tests::{AuthorizedClient, ticket};
|
|
||||||
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
|
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
|
||||||
|
use assignment_tracking::authorization_tests::{AuthorizedClient, ticket};
|
||||||
use server_api::authorization::PermissionResource;
|
use server_api::authorization::PermissionResource;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -25,23 +25,43 @@ impl Client {
|
|||||||
self.authority.grant(operation, ticket(id));
|
self.authority.grant(operation, ticket(id));
|
||||||
}
|
}
|
||||||
fn writes(&self) -> Vec<WorkspaceRequest> {
|
fn writes(&self) -> Vec<WorkspaceRequest> {
|
||||||
self.requests.lock().unwrap().iter()
|
self.requests
|
||||||
.filter(|request| request.method == WorkspaceRequestMethod::Put).cloned().collect()
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter(|request| request.method == WorkspaceRequestMethod::Put)
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
impl WorkspaceClient for Client {
|
impl WorkspaceClient for Client {
|
||||||
fn workspace_id(&self) -> Option<&str> { Some("workspace") }
|
fn workspace_id(&self) -> Option<&str> {
|
||||||
fn kind(&self) -> &str { "parent-link-boundary-fixture" }
|
Some("workspace")
|
||||||
fn is_available(&self) -> bool { true }
|
}
|
||||||
fn execute(&self, request: WorkspaceRequest) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
fn kind(&self) -> &str {
|
||||||
|
"parent-link-boundary-fixture"
|
||||||
|
}
|
||||||
|
fn is_available(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn execute(
|
||||||
|
&self,
|
||||||
|
request: WorkspaceRequest,
|
||||||
|
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||||
if request.path == "/api/w/workspace/authorization/check" {
|
if request.path == "/api/w/workspace/authorization/check" {
|
||||||
return self.authority.execute(request);
|
return self.authority.execute(request);
|
||||||
}
|
}
|
||||||
let path = request.path.clone();
|
let path = request.path.clone();
|
||||||
let resolving = request.method == WorkspaceRequestMethod::Get && path.ends_with("/link-summary");
|
let resolving =
|
||||||
|
request.method == WorkspaceRequestMethod::Get && path.ends_with("/link-summary");
|
||||||
self.requests.lock().unwrap().push(request);
|
self.requests.lock().unwrap().push(request);
|
||||||
let response = if resolving {
|
let response = if resolving {
|
||||||
let reference = path.strip_suffix("/link-summary").unwrap().rsplit('/').next().unwrap();
|
let reference = path
|
||||||
|
.strip_suffix("/link-summary")
|
||||||
|
.unwrap()
|
||||||
|
.rsplit('/')
|
||||||
|
.next()
|
||||||
|
.unwrap();
|
||||||
let id = match reference {
|
let id = match reference {
|
||||||
"T-1" => "internalchild",
|
"T-1" => "internalchild",
|
||||||
"T-2" => "internalold",
|
"T-2" => "internalold",
|
||||||
@@ -58,63 +78,132 @@ impl WorkspaceClient for Client {
|
|||||||
} else {
|
} else {
|
||||||
self.response.clone()
|
self.response.clone()
|
||||||
};
|
};
|
||||||
Ok(WorkspaceResponse { status: if resolving { 200 } else { self.status }, body: response.to_string() })
|
Ok(WorkspaceResponse {
|
||||||
|
status: if resolving { 200 } else { self.status },
|
||||||
|
body: response.to_string(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn summary(id: &str, key: &str) -> Value {
|
fn summary(id: &str, key: &str) -> Value {
|
||||||
json!({"id":id,"resource_key":key,"title":"ordinary Ticket","state":"closed","closure_reason":"not_planned"})
|
json!({
|
||||||
|
"id": id,
|
||||||
|
"resource_key": key,
|
||||||
|
"title": "ordinary Ticket",
|
||||||
|
"state": "closed",
|
||||||
|
"closure_reason": "not_planned"
|
||||||
|
})
|
||||||
}
|
}
|
||||||
fn link() -> Value {
|
fn link() -> Value {
|
||||||
json!({"ticket_id":"internalchild","observation":{"parent_ticket_id":"internalold","last_operation_key":"before"},"parent":summary("internalold", "T-2")})
|
json!({
|
||||||
|
"ticket_id": "internalchild",
|
||||||
|
"observation": {"parent_ticket_id": "internalold", "last_operation_key": "before"},
|
||||||
|
"parent": summary("internalold", "T-2")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
fn set_input(parent: Value) -> Value {
|
fn set_input(parent: Value) -> Value {
|
||||||
json!({"ticket":"internalchild","expected":{"parent_ticket_id":"internalold","last_operation_key":"before"},"parent":parent,"operation_key":"same-key"})
|
json!({
|
||||||
|
"ticket": "internalchild",
|
||||||
|
"expected": {"parent_ticket_id": "internalold", "last_operation_key": "before"},
|
||||||
|
"parent": parent,
|
||||||
|
"operation_key": "same-key"
|
||||||
|
})
|
||||||
}
|
}
|
||||||
fn arguments(value: Value) -> BTreeMap<String, WipValue> {
|
fn arguments(value: Value) -> BTreeMap<String, WipValue> {
|
||||||
match json_to_wip(&value).unwrap() {
|
match json_to_wip(&value).unwrap_or_else(|_| panic!("invalid WIP fixture arguments: {value}")) {
|
||||||
WipValue::Record(fields) => fields,
|
WipValue::Record(fields) => fields,
|
||||||
_ => panic!("expected record"),
|
_ => panic!("expected record"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn handler(client: Arc<Client>) -> TicketItemWipHandler {
|
fn handler(client: Arc<Client>) -> TicketItemWipHandler {
|
||||||
TicketItemWipHandler {
|
TicketItemWipHandler {
|
||||||
tools: operation_map(native_ticket_tools(client, TicketFeatureAccess::workflow()).unwrap()
|
tools: operation_map(
|
||||||
.into_iter().filter(|tool| tool.projection.surface == NativeTicketSurface::Item).collect()),
|
native_ticket_tools(client, TicketFeatureAccess::workflow())
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|tool| tool.projection.surface == NativeTicketSurface::Item)
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
permissions: None,
|
permissions: None,
|
||||||
ticket_reference: "internalchild".into(),
|
ticket_reference: "internalchild".into(),
|
||||||
collection_route: "/cases".into(),
|
collection_route: "/cases".into(),
|
||||||
merge_request_route: String::new(), objective_route: String::new(),
|
merge_request_route: String::new(),
|
||||||
|
objective_route: String::new(),
|
||||||
observations: Arc::new(Mutex::new(TicketObservationState::default())),
|
observations: Arc::new(Mutex::new(TicketObservationState::default())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn context() -> WipCallContext {
|
fn context() -> WipCallContext {
|
||||||
WipCallContext { result_namespaces: Default::default(), execution: ToolExecutionContext::direct(), security_context: "worker".into() }
|
WipCallContext {
|
||||||
|
result_namespaces: Default::default(),
|
||||||
|
execution: ToolExecutionContext::direct(),
|
||||||
|
security_context: "worker".into(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fn native_set(parent: Value) -> BTreeMap<String, WipValue> {
|
fn native_set(parent: Value) -> BTreeMap<String, WipValue> {
|
||||||
arguments(json!({"expected":{"parent_ticket_id":{"$case":"value","value":"internalold"},"last_operation_key":{"$case":"value","value":"before"}},"parent":parent,"operation_key":"same-key"}))
|
arguments(json!({
|
||||||
|
"expected": {
|
||||||
|
"parent_ticket_id": {"$case": "value", "value": "internalold"},
|
||||||
|
"last_operation_key": {"$case": "value", "value": "before"}
|
||||||
|
},
|
||||||
|
"parent": parent,
|
||||||
|
"operation_key": "same-key"
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn set_schema_and_json_require_explicit_nullable_observations_and_desired_parent() {
|
fn set_schema_and_json_require_explicit_nullable_observations_and_desired_parent() {
|
||||||
|
let schema = ParentToolKind::Set.schema();
|
||||||
|
let validator = jsonschema::validator_for(&schema).unwrap();
|
||||||
let input = set_input(Value::Null);
|
let input = set_input(Value::Null);
|
||||||
assert!(serde_json::from_value::<SetInput>(input.clone()).is_ok());
|
assert!(serde_json::from_value::<SetInput>(input.clone()).is_ok());
|
||||||
for path in ["parent", "expected.parent_ticket_id", "expected.last_operation_key"] {
|
assert!(validator.is_valid(&input));
|
||||||
|
for path in [
|
||||||
|
"parent",
|
||||||
|
"expected.parent_ticket_id",
|
||||||
|
"expected.last_operation_key",
|
||||||
|
] {
|
||||||
let mut missing = input.clone();
|
let mut missing = input.clone();
|
||||||
if let Some(field) = path.strip_prefix("expected.") {
|
if let Some(field) = path.strip_prefix("expected.") {
|
||||||
missing["expected"].as_object_mut().unwrap().remove(field);
|
missing["expected"].as_object_mut().unwrap().remove(field);
|
||||||
} else {
|
} else {
|
||||||
missing.as_object_mut().unwrap().remove(path);
|
missing.as_object_mut().unwrap().remove(path);
|
||||||
}
|
}
|
||||||
assert!(serde_json::from_value::<SetInput>(missing).is_err(), "{path}");
|
assert!(
|
||||||
|
serde_json::from_value::<SetInput>(missing.clone()).is_err(),
|
||||||
|
"{path}"
|
||||||
|
);
|
||||||
|
assert!(!validator.is_valid(&missing), "schema must require {path}");
|
||||||
}
|
}
|
||||||
let nulls = json!({"ticket":"T-1","expected":{"parent_ticket_id":null,"last_operation_key":null},"parent":null,"operation_key":"k"});
|
let nulls = json!({
|
||||||
assert!(serde_json::from_value::<SetInput>(nulls).is_ok());
|
"ticket": "T-1",
|
||||||
let schema = ParentToolKind::Set.schema();
|
"expected": {"parent_ticket_id": null, "last_operation_key": null},
|
||||||
assert!(schema["required"].as_array().unwrap().contains(&json!("parent")));
|
"parent": null,
|
||||||
|
"operation_key": "k"
|
||||||
|
});
|
||||||
|
assert!(serde_json::from_value::<SetInput>(nulls.clone()).is_ok());
|
||||||
|
assert!(validator.is_valid(&nulls));
|
||||||
|
let linked = set_input(json!("T-3"));
|
||||||
|
assert!(serde_json::from_value::<SetInput>(linked.clone()).is_ok());
|
||||||
|
assert!(validator.is_valid(&linked));
|
||||||
|
for parent in [json!(42), json!(false), json!({}), json!([])] {
|
||||||
|
let malformed = set_input(parent);
|
||||||
|
assert!(serde_json::from_value::<SetInput>(malformed.clone()).is_err());
|
||||||
|
assert!(!validator.is_valid(&malformed));
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
schema["required"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.contains(&json!("parent"))
|
||||||
|
);
|
||||||
let observation = &schema["$defs"]["TicketParentObservation"];
|
let observation = &schema["$defs"]["TicketParentObservation"];
|
||||||
for field in ["parent_ticket_id", "last_operation_key"] {
|
for field in ["parent_ticket_id", "last_operation_key"] {
|
||||||
assert!(observation["required"].as_array().unwrap().contains(&json!(field)));
|
assert!(
|
||||||
|
observation["required"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.contains(&json!(field))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,15 +211,22 @@ fn set_schema_and_json_require_explicit_nullable_observations_and_desired_parent
|
|||||||
async fn reparent_requires_record_on_child_new_and_remove_on_child_old_in_tool_and_wip() {
|
async fn reparent_requires_record_on_child_new_and_remove_on_child_old_in_tool_and_wip() {
|
||||||
let client = Client::new(link());
|
let client = Client::new(link());
|
||||||
for operation in ["ticket.relation.record", "ticket.relation.remove"] {
|
for operation in ["ticket.relation.record", "ticket.relation.remove"] {
|
||||||
client.authority.grant(operation, PermissionResource::Workspace {
|
client.authority.grant(
|
||||||
workspace_id: "workspace".into(),
|
operation,
|
||||||
});
|
PermissionResource::Workspace {
|
||||||
|
workspace_id: "workspace".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let (_, tool) = definition(client.clone(), ParentToolKind::Set)();
|
let (_, tool) = definition(client.clone(), ParentToolKind::Set)();
|
||||||
let input = set_input(json!("T-3"));
|
let input = set_input(json!("T-3"));
|
||||||
let wip = handler(client.clone());
|
let wip = handler(client.clone());
|
||||||
let args = native_set(json!({"$case":"value","value":"/cases/T-3"}));
|
let args = native_set(json!({"$case":"value","value":"/cases/T-3"}));
|
||||||
assert!(tool.execute(&input.to_string(), ToolExecutionContext::direct()).await.is_err());
|
assert!(
|
||||||
|
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
assert!(wip.call("parent_set", &args, context()).await.is_err());
|
assert!(wip.call("parent_set", &args, context()).await.is_err());
|
||||||
assert!(client.requests.lock().unwrap().is_empty());
|
assert!(client.requests.lock().unwrap().is_empty());
|
||||||
|
|
||||||
@@ -142,23 +238,42 @@ async fn reparent_requires_record_on_child_new_and_remove_on_child_old_in_tool_a
|
|||||||
}
|
}
|
||||||
// Even record authority on all three endpoints cannot detach the old edge.
|
// Even record authority on all three endpoints cannot detach the old edge.
|
||||||
client.grant("ticket.relation.record", "internalold");
|
client.grant("ticket.relation.record", "internalold");
|
||||||
assert!(tool.execute(&input.to_string(), ToolExecutionContext::direct()).await.is_err());
|
assert!(
|
||||||
|
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
assert!(wip.call("parent_set", &args, context()).await.is_err());
|
assert!(wip.call("parent_set", &args, context()).await.is_err());
|
||||||
assert!(client.writes().is_empty());
|
assert!(client.writes().is_empty());
|
||||||
client.grant("ticket.relation.remove", "internalchild");
|
client.grant("ticket.relation.remove", "internalchild");
|
||||||
assert!(tool.execute(&input.to_string(), ToolExecutionContext::direct()).await.is_err());
|
assert!(
|
||||||
|
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
assert!(wip.call("parent_set", &args, context()).await.is_err());
|
assert!(wip.call("parent_set", &args, context()).await.is_err());
|
||||||
assert!(client.writes().is_empty());
|
assert!(client.writes().is_empty());
|
||||||
client.grant("ticket.relation.remove", "internalold");
|
client.grant("ticket.relation.remove", "internalold");
|
||||||
let result = tool.execute(&input.to_string(), ToolExecutionContext::direct()).await.unwrap();
|
let result = tool
|
||||||
assert_eq!(serde_json::from_str::<Value>(result.content.as_deref().unwrap()).unwrap(), link());
|
.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||||
wip.call("parent_set", &args, context()).await.unwrap();
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Value>(result.content.as_deref().unwrap()).unwrap(),
|
||||||
|
link()
|
||||||
|
);
|
||||||
|
wip.call("parent_set", &args, context())
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| panic!("WIP parent_set rejected authorized mutation"));
|
||||||
let writes = client.writes();
|
let writes = client.writes();
|
||||||
assert_eq!(writes.len(), 2);
|
assert_eq!(writes.len(), 2);
|
||||||
for write in writes {
|
for write in writes {
|
||||||
assert_eq!(write.path, "/api/w/workspace/tickets/internalchild/parent");
|
assert_eq!(write.path, "/api/w/workspace/tickets/internalchild/parent");
|
||||||
let actual: Value = serde_json::from_str(write.body.as_deref().unwrap()).unwrap();
|
let actual: Value = serde_json::from_str(write.body.as_deref().unwrap()).unwrap();
|
||||||
assert_eq!(actual, json!({"expected":input["expected"],"parent":"T-3","operation_key":"same-key"}));
|
assert_eq!(
|
||||||
|
actual,
|
||||||
|
json!({"expected":input["expected"],"parent":"T-3","operation_key":"same-key"})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
client.authority.grants.lock().unwrap().clear();
|
client.authority.grants.lock().unwrap().clear();
|
||||||
assert!(wip.call("parent_set", &args, context()).await.is_err());
|
assert!(wip.call("parent_set", &args, context()).await.is_err());
|
||||||
@@ -178,19 +293,44 @@ async fn reparent_does_not_require_record_on_old_or_remove_on_new() {
|
|||||||
client.grant("ticket.relation.remove", id);
|
client.grant("ticket.relation.remove", id);
|
||||||
}
|
}
|
||||||
let (_, tool) = definition(client.clone(), ParentToolKind::Set)();
|
let (_, tool) = definition(client.clone(), ParentToolKind::Set)();
|
||||||
tool.execute(&set_input(json!("T-3")).to_string(), ToolExecutionContext::direct()).await.unwrap();
|
tool.execute(
|
||||||
handler(client.clone()).call("parent_set", &native_set(json!({"$case":"value","value":"/cases/T-3"})), context()).await.unwrap();
|
&set_input(json!("T-3")).to_string(),
|
||||||
|
ToolExecutionContext::direct(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
handler(client.clone())
|
||||||
|
.call(
|
||||||
|
"parent_set",
|
||||||
|
&native_set(json!({"$case":"value","value":"/cases/T-3"})),
|
||||||
|
context(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| panic!("WIP parent_set rejected authorized parent reference"));
|
||||||
assert_eq!(client.writes().len(), 2);
|
assert_eq!(client.writes().len(), 2);
|
||||||
let checks = client.authority.authorizations.lock().unwrap();
|
let checks = client.authority.authorizations.lock().unwrap();
|
||||||
assert!(!checks.iter().any(|check| check.operation == "ticket.relation.record" && check.resource == ticket("internalold")));
|
assert!(!checks.iter().any(|check| {
|
||||||
assert!(!checks.iter().any(|check| check.operation == "ticket.relation.remove" && check.resource == ticket("internalnew")));
|
check.operation == "ticket.relation.record" && check.resource == ticket("internalold")
|
||||||
|
}));
|
||||||
|
assert!(!checks.iter().any(|check| {
|
||||||
|
check.operation == "ticket.relation.remove" && check.resource == ticket("internalnew")
|
||||||
|
}));
|
||||||
assert!(checks.iter().all(|check| check.operation != "ticket.read"));
|
assert!(checks.iter().all(|check| check.operation != "ticket.read"));
|
||||||
assert!(!checks.iter().any(|check| check.operation == "ticket.relation.read" && check.resource == ticket("internalold")));
|
assert!(!checks.iter().any(|check| {
|
||||||
|
check.operation == "ticket.relation.read" && check.resource == ticket("internalold")
|
||||||
|
}));
|
||||||
drop(checks);
|
drop(checks);
|
||||||
let requests = client.requests.lock().unwrap();
|
let requests = client.requests.lock().unwrap();
|
||||||
let lookups = requests.iter().filter(|request| request.method == WorkspaceRequestMethod::Get).collect::<Vec<_>>();
|
let lookups = requests
|
||||||
|
.iter()
|
||||||
|
.filter(|request| request.method == WorkspaceRequestMethod::Get)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
assert_eq!(lookups.len(), 2);
|
assert_eq!(lookups.len(), 2);
|
||||||
assert!(lookups.iter().all(|request| request.path == "/api/w/workspace/tickets/T-3/link-summary"));
|
assert!(
|
||||||
|
lookups
|
||||||
|
.iter()
|
||||||
|
.all(|request| request.path == "/api/w/workspace/tickets/T-3/link-summary")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -202,18 +342,47 @@ async fn same_parent_alias_is_compared_after_resolution_without_remove_or_wire_r
|
|||||||
}
|
}
|
||||||
let input = set_input(json!("T-2"));
|
let input = set_input(json!("T-2"));
|
||||||
let (_, tool) = definition(client.clone(), ParentToolKind::Set)();
|
let (_, tool) = definition(client.clone(), ParentToolKind::Set)();
|
||||||
tool.execute(&input.to_string(), ToolExecutionContext::direct()).await.unwrap();
|
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||||
handler(client.clone()).call("parent_set", &native_set(json!({"$case":"value","value":"/cases/T-2"})), context()).await.unwrap();
|
.await
|
||||||
|
.unwrap();
|
||||||
|
handler(client.clone())
|
||||||
|
.call(
|
||||||
|
"parent_set",
|
||||||
|
&native_set(json!({"$case":"value","value":"/cases/T-2"})),
|
||||||
|
context(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| panic!("WIP parent_set rejected authorized parent reference"));
|
||||||
assert_eq!(client.writes().len(), 2);
|
assert_eq!(client.writes().len(), 2);
|
||||||
assert!(client.authority.authorizations.lock().unwrap().iter().all(|check| check.operation != "ticket.relation.remove" && check.operation != "ticket.read"));
|
assert!(
|
||||||
|
client
|
||||||
|
.authority
|
||||||
|
.authorizations
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.all(|check| {
|
||||||
|
check.operation != "ticket.relation.remove" && check.operation != "ticket.read"
|
||||||
|
})
|
||||||
|
);
|
||||||
let requests = client.requests.lock().unwrap();
|
let requests = client.requests.lock().unwrap();
|
||||||
let lookups = requests.iter().filter(|request| request.method == WorkspaceRequestMethod::Get).collect::<Vec<_>>();
|
let lookups = requests
|
||||||
|
.iter()
|
||||||
|
.filter(|request| request.method == WorkspaceRequestMethod::Get)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
assert_eq!(lookups.len(), 2);
|
assert_eq!(lookups.len(), 2);
|
||||||
assert!(lookups.iter().all(|request| request.path == "/api/w/workspace/tickets/T-2/link-summary"));
|
assert!(
|
||||||
|
lookups
|
||||||
|
.iter()
|
||||||
|
.all(|request| request.path == "/api/w/workspace/tickets/T-2/link-summary")
|
||||||
|
);
|
||||||
drop(requests);
|
drop(requests);
|
||||||
for write in client.writes() {
|
for write in client.writes() {
|
||||||
let actual: Value = serde_json::from_str(write.body.as_deref().unwrap()).unwrap();
|
let actual: Value = serde_json::from_str(write.body.as_deref().unwrap()).unwrap();
|
||||||
assert_eq!(actual, json!({"expected":input["expected"],"parent":"T-2","operation_key":"same-key"}));
|
assert_eq!(
|
||||||
|
actual,
|
||||||
|
json!({"expected":input["expected"],"parent":"T-2","operation_key":"same-key"})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,30 +392,62 @@ async fn initial_attach_needs_only_record_and_metadata_read_without_remove_or_bo
|
|||||||
let (_, tool) = definition(client.clone(), ParentToolKind::Set)();
|
let (_, tool) = definition(client.clone(), ParentToolKind::Set)();
|
||||||
let mut input = set_input(json!("T-3"));
|
let mut input = set_input(json!("T-3"));
|
||||||
input["expected"] = json!({"parent_ticket_id":null,"last_operation_key":null});
|
input["expected"] = json!({"parent_ticket_id":null,"last_operation_key":null});
|
||||||
let args = arguments(json!({"expected":{"parent_ticket_id":{"$case":"null"},"last_operation_key":{"$case":"null"}},"parent":{"$case":"value","value":"/cases/T-3"},"operation_key":"same-key"}));
|
let args = arguments(json!({
|
||||||
|
"expected": {
|
||||||
|
"parent_ticket_id": {"$case": "null"},
|
||||||
|
"last_operation_key": {"$case": "null"}
|
||||||
|
},
|
||||||
|
"parent": {"$case": "value", "value": "/cases/T-3"},
|
||||||
|
"operation_key": "same-key"
|
||||||
|
}));
|
||||||
for id in ["internalchild", "T-3"] {
|
for id in ["internalchild", "T-3"] {
|
||||||
client.grant("ticket.relation.record", id);
|
client.grant("ticket.relation.record", id);
|
||||||
}
|
}
|
||||||
let wip = handler(client.clone());
|
let wip = handler(client.clone());
|
||||||
// Record grants never imply metadata read. Missing either endpoint rejects before PUT.
|
// Record grants never imply metadata read. Missing either endpoint rejects before PUT.
|
||||||
assert!(tool.execute(&input.to_string(), ToolExecutionContext::direct()).await.is_err());
|
assert!(
|
||||||
|
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
assert!(wip.call("parent_set", &args, context()).await.is_err());
|
assert!(wip.call("parent_set", &args, context()).await.is_err());
|
||||||
assert!(client.requests.lock().unwrap().is_empty());
|
assert!(client.requests.lock().unwrap().is_empty());
|
||||||
client.grant("ticket.relation.read", "internalchild");
|
client.grant("ticket.relation.read", "internalchild");
|
||||||
assert!(tool.execute(&input.to_string(), ToolExecutionContext::direct()).await.is_err());
|
assert!(
|
||||||
|
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
assert!(wip.call("parent_set", &args, context()).await.is_err());
|
assert!(wip.call("parent_set", &args, context()).await.is_err());
|
||||||
assert!(client.requests.lock().unwrap().is_empty());
|
assert!(client.requests.lock().unwrap().is_empty());
|
||||||
client.grant("ticket.relation.read", "T-3");
|
client.grant("ticket.relation.read", "T-3");
|
||||||
tool.execute(&input.to_string(), ToolExecutionContext::direct()).await.unwrap();
|
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||||
wip.call("parent_set", &args, context()).await.unwrap();
|
.await
|
||||||
|
.unwrap();
|
||||||
|
wip.call("parent_set", &args, context())
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| panic!("WIP parent_set rejected authorized mutation"));
|
||||||
assert_eq!(client.writes().len(), 2);
|
assert_eq!(client.writes().len(), 2);
|
||||||
assert!(client.requests.lock().unwrap().iter().all(|request| request.method == WorkspaceRequestMethod::Put));
|
assert!(
|
||||||
|
client
|
||||||
|
.requests
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.all(|request| request.method == WorkspaceRequestMethod::Put)
|
||||||
|
);
|
||||||
let checks = client.authority.authorizations.lock().unwrap();
|
let checks = client.authority.authorizations.lock().unwrap();
|
||||||
assert!(checks.iter().all(|check| matches!(check.operation.as_str(), "ticket.relation.record" | "ticket.relation.read")));
|
assert!(checks.iter().all(|check| matches!(
|
||||||
|
check.operation.as_str(),
|
||||||
|
"ticket.relation.record" | "ticket.relation.read"
|
||||||
|
)));
|
||||||
drop(checks);
|
drop(checks);
|
||||||
for write in client.writes() {
|
for write in client.writes() {
|
||||||
let body: Value = serde_json::from_str(write.body.as_deref().unwrap()).unwrap();
|
let body: Value = serde_json::from_str(write.body.as_deref().unwrap()).unwrap();
|
||||||
assert_eq!(body, json!({"expected":input["expected"],"parent":"T-3","operation_key":"same-key"}));
|
assert_eq!(
|
||||||
|
body,
|
||||||
|
json!({"expected":input["expected"],"parent":"T-3","operation_key":"same-key"})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,43 +461,111 @@ async fn unlink_needs_metadata_read_on_child_and_remove_on_child_old_only() {
|
|||||||
for id in ["internalchild", "internalold"] {
|
for id in ["internalchild", "internalold"] {
|
||||||
client.grant("ticket.relation.remove", id);
|
client.grant("ticket.relation.remove", id);
|
||||||
}
|
}
|
||||||
assert!(tool.execute(&input.to_string(), ToolExecutionContext::direct()).await.is_err());
|
assert!(
|
||||||
|
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
assert!(wip.call("parent_set", &args, context()).await.is_err());
|
assert!(wip.call("parent_set", &args, context()).await.is_err());
|
||||||
assert!(client.requests.lock().unwrap().is_empty());
|
assert!(client.requests.lock().unwrap().is_empty());
|
||||||
client.grant("ticket.relation.read", "internalchild");
|
client.grant("ticket.relation.read", "internalchild");
|
||||||
tool.execute(&input.to_string(), ToolExecutionContext::direct()).await.unwrap();
|
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||||
wip.call("parent_set", &args, context()).await.unwrap();
|
.await
|
||||||
|
.unwrap();
|
||||||
|
wip.call("parent_set", &args, context())
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| panic!("WIP parent_set rejected authorized mutation"));
|
||||||
assert_eq!(client.writes().len(), 2);
|
assert_eq!(client.writes().len(), 2);
|
||||||
assert!(client.requests.lock().unwrap().iter().all(|request| request.method == WorkspaceRequestMethod::Put));
|
assert!(
|
||||||
|
client
|
||||||
|
.requests
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.all(|request| request.method == WorkspaceRequestMethod::Put)
|
||||||
|
);
|
||||||
let checks = client.authority.authorizations.lock().unwrap();
|
let checks = client.authority.authorizations.lock().unwrap();
|
||||||
assert!(checks.iter().all(|check| matches!(check.operation.as_str(), "ticket.relation.remove" | "ticket.relation.read")));
|
assert!(checks.iter().all(|check| matches!(
|
||||||
assert!(!checks.iter().any(|check| check.operation == "ticket.relation.read" && check.resource == ticket("internalold")));
|
check.operation.as_str(),
|
||||||
|
"ticket.relation.remove" | "ticket.relation.read"
|
||||||
|
)));
|
||||||
|
assert!(!checks.iter().any(|check| {
|
||||||
|
check.operation == "ticket.relation.read" && check.resource == ticket("internalold")
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn children_transport_bounds_and_opaque_cursor_are_identical_on_tool_and_wip() {
|
async fn children_transport_bounds_and_opaque_cursor_are_identical_on_tool_and_wip() {
|
||||||
let page = json!({"parent_ticket_id":"internalchild","items":[summary("internalold", "T-2")],"next_cursor":"next/+&=opaque","has_more":true});
|
let page = json!({
|
||||||
|
"parent_ticket_id": "internalchild",
|
||||||
|
"items": [summary("internalold", "T-2")],
|
||||||
|
"next_cursor": "next/+&=opaque",
|
||||||
|
"has_more": true
|
||||||
|
});
|
||||||
let client = Client::new(page.clone());
|
let client = Client::new(page.clone());
|
||||||
let (_, tool) = definition(client.clone(), ParentToolKind::Children)();
|
let (_, tool) = definition(client.clone(), ParentToolKind::Children)();
|
||||||
let input = json!({"ticket":"internalchild","cursor":"page/+&=opaque","limit":1});
|
let input = json!({"ticket":"internalchild","cursor":"page/+&=opaque","limit":1});
|
||||||
assert!(tool.execute(&input.to_string(), ToolExecutionContext::direct()).await.is_err());
|
assert!(
|
||||||
|
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
assert!(client.requests.lock().unwrap().is_empty());
|
assert!(client.requests.lock().unwrap().is_empty());
|
||||||
client.grant("ticket.relation.read", "internalchild");
|
client.grant("ticket.relation.read", "internalchild");
|
||||||
// Parent access alone cannot disclose an unauthorized child.
|
// Parent access alone cannot disclose an unauthorized child.
|
||||||
assert!(tool.execute(&input.to_string(), ToolExecutionContext::direct()).await.is_err());
|
assert!(
|
||||||
|
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
client.grant("ticket.relation.read", "internalold");
|
client.grant("ticket.relation.read", "internalold");
|
||||||
let result = tool.execute(&input.to_string(), ToolExecutionContext::direct()).await.unwrap();
|
let result = tool
|
||||||
assert_eq!(serde_json::from_str::<Value>(result.content.as_deref().unwrap()).unwrap(), page);
|
.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||||
let projected = results::project("children_query", page, Some("internalchild"), "/cases", "", "").unwrap();
|
.await
|
||||||
let WipValue::Record(fields) = projected else { panic!("children page") };
|
.unwrap();
|
||||||
assert_eq!(fields.get("entry"), Some(&WipValue::String("/cases/internalchild".into())));
|
assert_eq!(
|
||||||
assert_eq!(fields.get("next_cursor"), Some(&WipValue::String("next/+&=opaque".into())));
|
serde_json::from_str::<Value>(result.content.as_deref().unwrap()).unwrap(),
|
||||||
|
page
|
||||||
|
);
|
||||||
|
let projected = results::project(
|
||||||
|
"children_query",
|
||||||
|
page,
|
||||||
|
Some("internalchild"),
|
||||||
|
"/cases",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|_| panic!("WIP children_query projection failed"));
|
||||||
|
let WipValue::Record(fields) = projected else {
|
||||||
|
panic!("children page")
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
fields.get("entry"),
|
||||||
|
Some(&WipValue::String("/cases/internalchild".into()))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fields.get("next_cursor"),
|
||||||
|
Some(&WipValue::String("next/+&=opaque".into()))
|
||||||
|
);
|
||||||
assert_eq!(fields.get("has_more"), Some(&WipValue::Boolean(true)));
|
assert_eq!(fields.get("has_more"), Some(&WipValue::Boolean(true)));
|
||||||
let Some(WipValue::List(items)) = fields.get("items") else { panic!("children") };
|
let Some(WipValue::List(items)) = fields.get("items") else {
|
||||||
let WipValue::Record(child) = &items[0] else { panic!("child") };
|
panic!("children")
|
||||||
assert_eq!(child.get("entry"), Some(&WipValue::String("/cases/T-2".into())));
|
};
|
||||||
|
let WipValue::Record(child) = &items[0] else {
|
||||||
|
panic!("child")
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
child.get("entry"),
|
||||||
|
Some(&WipValue::String("/cases/T-2".into()))
|
||||||
|
);
|
||||||
let wip = handler(client.clone());
|
let wip = handler(client.clone());
|
||||||
wip.call("children_query", &arguments(json!({"page":{"cursor":"page/+&=opaque","limit":1}})), context()).await.unwrap();
|
wip.call(
|
||||||
|
"children_query",
|
||||||
|
&arguments(json!({"page":{"cursor":"page/+&=opaque","limit":1}})),
|
||||||
|
context(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| panic!("WIP children_query rejected authorized page request"));
|
||||||
assert!(wip.observations.lock().unwrap().content_digests.is_empty());
|
assert!(wip.observations.lock().unwrap().content_digests.is_empty());
|
||||||
let requests = client.requests.lock().unwrap();
|
let requests = client.requests.lock().unwrap();
|
||||||
assert!(requests.iter().all(|r| r.method == WorkspaceRequestMethod::Get
|
assert!(requests.iter().all(|r| r.method == WorkspaceRequestMethod::Get
|
||||||
@@ -304,51 +573,148 @@ async fn children_transport_bounds_and_opaque_cursor_are_identical_on_tool_and_w
|
|||||||
drop(requests);
|
drop(requests);
|
||||||
for limit in [0, 101] {
|
for limit in [0, 101] {
|
||||||
let before = client.requests.lock().unwrap().len();
|
let before = client.requests.lock().unwrap().len();
|
||||||
assert!(tool.execute(&json!({"ticket":"internalchild","limit":limit}).to_string(), ToolExecutionContext::direct()).await.is_err());
|
assert!(
|
||||||
|
tool.execute(
|
||||||
|
&json!({"ticket":"internalchild","limit":limit}).to_string(),
|
||||||
|
ToolExecutionContext::direct(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
assert_eq!(client.requests.lock().unwrap().len(), before);
|
assert_eq!(client.requests.lock().unwrap().len(), before);
|
||||||
}
|
}
|
||||||
let backend = WorkspaceHttpTicketBackend::new(client.clone());
|
let backend = WorkspaceHttpTicketBackend::new(client.clone());
|
||||||
backend.query_ticket_children(TicketIdOrSlug::from("internalchild"), ticket::TicketChildrenRequest { cursor: None, limit: None }).unwrap();
|
backend
|
||||||
assert!(client.requests.lock().unwrap().last().unwrap().path.ends_with("/children?limit=50"));
|
.query_ticket_children(
|
||||||
|
TicketIdOrSlug::from("internalchild"),
|
||||||
|
ticket::TicketChildrenRequest {
|
||||||
|
cursor: None,
|
||||||
|
limit: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
client
|
||||||
|
.requests
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.last()
|
||||||
|
.unwrap()
|
||||||
|
.path
|
||||||
|
.ends_with("/children?limit=50")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn children_read_checks_returned_parent_identity_not_only_requested_alias() {
|
fn children_read_checks_returned_parent_identity_not_only_requested_alias() {
|
||||||
let client = Client::new(json!({"parent_ticket_id":"internalchild","items":[],"next_cursor":null,"has_more":false}));
|
let client = Client::new(json!({
|
||||||
|
"parent_ticket_id": "internalchild", "items": [], "next_cursor": null, "has_more": false
|
||||||
|
}));
|
||||||
client.grant("ticket.relation.read", "T-1");
|
client.grant("ticket.relation.read", "T-1");
|
||||||
let backend = WorkspaceHttpTicketBackend::new(client.clone());
|
let backend = WorkspaceHttpTicketBackend::new(client.clone());
|
||||||
let page = ticket::TicketChildrenRequest { cursor: None, limit: Some(1) };
|
let page = ticket::TicketChildrenRequest {
|
||||||
assert!(backend.query_ticket_children(TicketIdOrSlug::from("T-1"), page.clone()).is_err());
|
cursor: None,
|
||||||
|
limit: Some(1),
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
backend
|
||||||
|
.query_ticket_children(TicketIdOrSlug::from("T-1"), page.clone())
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
client.grant("ticket.relation.read", "internalchild");
|
client.grant("ticket.relation.read", "internalchild");
|
||||||
backend.query_ticket_children(TicketIdOrSlug::from("T-1"), page).unwrap();
|
backend
|
||||||
|
.query_ticket_children(TicketIdOrSlug::from("T-1"), page)
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn children_adapter_rejects_oversized_server_page_without_silent_truncation() {
|
fn children_adapter_rejects_oversized_server_page_without_silent_truncation() {
|
||||||
let client = Client::new(json!({"parent_ticket_id":"T-1","items":[summary("T-2", "T-2"),summary("T-3", "T-3")],"next_cursor":null,"has_more":false}));
|
let client = Client::new(json!({
|
||||||
|
"parent_ticket_id": "T-1",
|
||||||
|
"items": [summary("T-2", "T-2"), summary("T-3", "T-3")],
|
||||||
|
"next_cursor": null,
|
||||||
|
"has_more": false
|
||||||
|
}));
|
||||||
client.grant("ticket.relation.read", "T-1");
|
client.grant("ticket.relation.read", "T-1");
|
||||||
let error = WorkspaceHttpTicketBackend::new(client).query_ticket_children(
|
let error = WorkspaceHttpTicketBackend::new(client)
|
||||||
TicketIdOrSlug::from("T-1"), ticket::TicketChildrenRequest { cursor: None, limit: Some(1) },
|
.query_ticket_children(
|
||||||
).unwrap_err();
|
TicketIdOrSlug::from("T-1"),
|
||||||
|
ticket::TicketChildrenRequest {
|
||||||
|
cursor: None,
|
||||||
|
limit: Some(1),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
assert!(error.to_string().contains("bound"));
|
assert!(error.to_string().contains("bound"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn native_parent_refs_are_scoped_and_observation_null_is_not_omission() {
|
fn native_parent_refs_are_scoped_and_observation_null_is_not_omission() {
|
||||||
for parent in ["T-3", "/tickets/T-3", "/cases/T-3/children", "/cases/O-3"] {
|
for parent in ["T-3", "/tickets/T-3", "/cases/T-3/children", "/cases/O-3"] {
|
||||||
assert!(native::input("parent_set", &native_set(json!({"$case":"value","value":parent})), Some("T-1"), Some("ticket"), "/cases", "", "").is_err());
|
assert!(
|
||||||
|
native::input(
|
||||||
|
"parent_set",
|
||||||
|
&native_set(json!({"$case":"value","value":parent})),
|
||||||
|
Some("T-1"),
|
||||||
|
Some("ticket"),
|
||||||
|
"/cases",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let args = native_set(json!({"$case":"null"}));
|
let args = native_set(json!({"$case":"null"}));
|
||||||
let command = native::input("parent_set", &args, Some("T-1"), Some("ticket"), "/cases", "", "").unwrap();
|
let command = native::input(
|
||||||
|
"parent_set",
|
||||||
|
&args,
|
||||||
|
Some("T-1"),
|
||||||
|
Some("ticket"),
|
||||||
|
"/cases",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|_| panic!("native parent_set rejected explicit null parent"));
|
||||||
assert_eq!(command["parent"], Value::Null);
|
assert_eq!(command["parent"], Value::Null);
|
||||||
for field in ["parent_ticket_id", "last_operation_key"] {
|
for field in ["parent_ticket_id", "last_operation_key"] {
|
||||||
let mut args = args.clone();
|
let mut args = args.clone();
|
||||||
if let Some(WipValue::Record(expected)) = args.get_mut("expected") { expected.remove(field); }
|
if let Some(WipValue::Record(expected)) = args.get_mut("expected") {
|
||||||
assert!(native::input("parent_set", &args, Some("T-1"), Some("ticket"), "/cases", "", "").is_err());
|
expected.remove(field);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
native::input(
|
||||||
|
"parent_set",
|
||||||
|
&args,
|
||||||
|
Some("T-1"),
|
||||||
|
Some("ticket"),
|
||||||
|
"/cases",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let filtered = native::input("query", &arguments(json!({"filter":{"parent":{"$case":"parent","value":{"ticket":"/cases/T-3"}}}})), None, None, "/cases", "", "").unwrap();
|
let filtered = native::input(
|
||||||
|
"query",
|
||||||
|
&arguments(json!({"filter":{"parent":{"$case":"parent","value":{"ticket":"/cases/T-3"}}}})),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"/cases",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|_| panic!("native query rejected scoped parent filter"));
|
||||||
assert_eq!(filtered["parent"], json!({"kind":"parent","ticket":"T-3"}));
|
assert_eq!(filtered["parent"], json!({"kind":"parent","ticket":"T-3"}));
|
||||||
let roots = native::input("query", &arguments(json!({"filter":{"parent":{"$case":"none"}}})), None, None, "/cases", "", "").unwrap();
|
let roots = native::input(
|
||||||
|
"query",
|
||||||
|
&arguments(json!({"filter":{"parent":{"$case":"none"}}})),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"/cases",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|_| panic!("native query rejected root-only parent filter"));
|
||||||
assert_eq!(roots["parent"], json!({"kind":"none"}));
|
assert_eq!(roots["parent"], json!({"kind":"none"}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,12 +723,28 @@ fn parent_read_preserves_cas_ids_and_requires_actual_parent_resource() {
|
|||||||
let client = Client::new(link());
|
let client = Client::new(link());
|
||||||
client.grant("ticket.relation.read", "internalchild");
|
client.grant("ticket.relation.read", "internalchild");
|
||||||
let backend = WorkspaceHttpTicketBackend::new(client.clone());
|
let backend = WorkspaceHttpTicketBackend::new(client.clone());
|
||||||
assert!(backend.get_ticket_parent(TicketIdOrSlug::from("internalchild")).is_err());
|
assert!(
|
||||||
|
backend
|
||||||
|
.get_ticket_parent(TicketIdOrSlug::from("internalchild"))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
client.grant("ticket.relation.read", "internalold");
|
client.grant("ticket.relation.read", "internalold");
|
||||||
let result = backend.get_ticket_parent(TicketIdOrSlug::from("internalchild")).unwrap();
|
let result = backend
|
||||||
assert_eq!(result.observation.parent_ticket_id.as_deref(), Some("internalold"));
|
.get_ticket_parent(TicketIdOrSlug::from("internalchild"))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result.observation.parent_ticket_id.as_deref(),
|
||||||
|
Some("internalold")
|
||||||
|
);
|
||||||
assert_eq!(result.parent.unwrap().id, "internalold");
|
assert_eq!(result.parent.unwrap().id, "internalold");
|
||||||
assert!(client.requests.lock().unwrap().iter().all(|r| r.path.ends_with("/internalchild/parent")));
|
assert!(
|
||||||
|
client
|
||||||
|
.requests
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.all(|r| r.path.ends_with("/internalchild/parent"))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -373,26 +755,68 @@ async fn show_and_wip_read_expose_parent_observation_and_lazy_navigation() {
|
|||||||
let client = Client::new(detail);
|
let client = Client::new(detail);
|
||||||
client.grant("ticket.read", "internalchild");
|
client.grant("ticket.read", "internalchild");
|
||||||
client.grant("ticket.relation.read", "internalchild");
|
client.grant("ticket.relation.read", "internalchild");
|
||||||
let (_, tool) = workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Show)();
|
let (_, tool) =
|
||||||
assert!(tool.execute(r#"{"id":"internalchild"}"#, ToolExecutionContext::direct()).await.is_err());
|
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Show)();
|
||||||
|
assert!(
|
||||||
|
tool.execute(r#"{"id":"internalchild"}"#, ToolExecutionContext::direct())
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
client.grant("ticket.relation.read", "internalold");
|
client.grant("ticket.relation.read", "internalold");
|
||||||
let output = tool.execute(r#"{"id":"internalchild"}"#, ToolExecutionContext::direct()).await.unwrap();
|
let output = tool
|
||||||
|
.execute(r#"{"id":"internalchild"}"#, ToolExecutionContext::direct())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let value: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
|
let value: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
|
||||||
assert_eq!(value["parent_link"], link());
|
assert_eq!(value["parent_link"], link());
|
||||||
assert_eq!(value["parent_link"]["parent"]["closure_reason"], "not_planned");
|
assert_eq!(
|
||||||
handler(client.clone()).call("read", &BTreeMap::new(), context()).await.unwrap();
|
value["parent_link"]["parent"]["closure_reason"],
|
||||||
let projected = results::project("read", value, Some("internalchild"), "/cases", "", "").unwrap();
|
"not_planned"
|
||||||
let WipValue::Record(fields) = projected else { panic!("envelope") };
|
);
|
||||||
let Some(WipValue::Record(links)) = fields.get("links") else { panic!("links") };
|
handler(client.clone())
|
||||||
let Some(WipValue::List(tickets)) = links.get("tickets") else { panic!("tickets") };
|
.call("read", &BTreeMap::new(), context())
|
||||||
assert!(tickets.iter().any(|item| matches!(item, WipValue::Record(fields) if fields.get("entry") == Some(&WipValue::String("/cases/T-2".into())))));
|
.await
|
||||||
|
.unwrap_or_else(|_| panic!("WIP read rejected authorized parent metadata"));
|
||||||
|
let projected = results::project("read", value, Some("internalchild"), "/cases", "", "")
|
||||||
|
.unwrap_or_else(|_| panic!("WIP read projection failed for parent metadata"));
|
||||||
|
let WipValue::Record(fields) = projected else {
|
||||||
|
panic!("envelope")
|
||||||
|
};
|
||||||
|
let Some(WipValue::Record(links)) = fields.get("links") else {
|
||||||
|
panic!("links")
|
||||||
|
};
|
||||||
|
let Some(WipValue::List(tickets)) = links.get("tickets") else {
|
||||||
|
panic!("tickets")
|
||||||
|
};
|
||||||
|
assert!(tickets.iter().any(|item| {
|
||||||
|
matches!(
|
||||||
|
item,
|
||||||
|
WipValue::Record(fields)
|
||||||
|
if fields.get("entry") == Some(&WipValue::String("/cases/T-2".into()))
|
||||||
|
)
|
||||||
|
}));
|
||||||
// Navigation does not eagerly read the parent.
|
// Navigation does not eagerly read the parent.
|
||||||
assert!(client.requests.lock().unwrap().iter().all(|r| r.path.ends_with("/internalchild/show")));
|
assert!(
|
||||||
|
client
|
||||||
|
.requests
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.all(|r| r.path.ends_with("/internalchild/show"))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn show_rejects_missing_null_or_malformed_parent_link_without_inventing_observation() {
|
async fn show_rejects_missing_null_or_malformed_parent_link_without_inventing_observation() {
|
||||||
for parent_link in [None, Some(Value::Null), Some(json!({"ticket_id":"internalchild","observation":{"parent_ticket_id":null},"parent":null}))] {
|
for parent_link in [
|
||||||
|
None,
|
||||||
|
Some(Value::Null),
|
||||||
|
Some(json!({
|
||||||
|
"ticket_id": "internalchild",
|
||||||
|
"observation": {"parent_ticket_id": null},
|
||||||
|
"parent": null
|
||||||
|
})),
|
||||||
|
] {
|
||||||
let mut detail = super::super::tests::target_detail_fixture(json!([]));
|
let mut detail = super::super::tests::target_detail_fixture(json!([]));
|
||||||
if let Some(parent_link) = parent_link {
|
if let Some(parent_link) = parent_link {
|
||||||
detail["parent_link"] = parent_link;
|
detail["parent_link"] = parent_link;
|
||||||
@@ -402,26 +826,52 @@ async fn show_rejects_missing_null_or_malformed_parent_link_without_inventing_ob
|
|||||||
let client = Client::new(detail);
|
let client = Client::new(detail);
|
||||||
client.grant("ticket.read", "internalchild");
|
client.grant("ticket.read", "internalchild");
|
||||||
client.grant("ticket.relation.read", "internalchild");
|
client.grant("ticket.relation.read", "internalchild");
|
||||||
let (_, tool) = workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Show)();
|
let (_, tool) =
|
||||||
let error = tool.execute(r#"{"id":"internalchild"}"#, ToolExecutionContext::direct()).await.unwrap_err();
|
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Show)();
|
||||||
|
let error = tool
|
||||||
|
.execute(r#"{"id":"internalchild"}"#, ToolExecutionContext::direct())
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
assert!(error.to_string().contains("parent_link"));
|
assert!(error.to_string().contains("parent_link"));
|
||||||
assert!(handler(client).call("read", &BTreeMap::new(), context()).await.is_err());
|
assert!(
|
||||||
|
handler(client)
|
||||||
|
.call("read", &BTreeMap::new(), context())
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn parent_query_filter_is_forwarded_unchanged_and_requires_target_not_collection_only() {
|
async fn parent_query_filter_is_forwarded_unchanged_and_requires_target_not_collection_only() {
|
||||||
for parent in [json!({"kind":"none"}), json!({"kind":"parent","ticket":"T-3"})] {
|
for parent in [
|
||||||
let client = Client::new(json!({"tickets":[],"next_cursor":null,"has_more":false}));
|
json!({"kind":"none"}),
|
||||||
client.authority.grant("ticket.query", PermissionResource::Workspace { workspace_id: "workspace".into() });
|
json!({"kind":"parent","ticket":"T-3"}),
|
||||||
let (_, tool) = workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)();
|
] {
|
||||||
|
let client = Client::new(json!({
|
||||||
|
"items": [], "page": {"next_cursor": null, "has_more": false}
|
||||||
|
}));
|
||||||
|
client.authority.grant(
|
||||||
|
"ticket.query",
|
||||||
|
PermissionResource::Workspace {
|
||||||
|
workspace_id: "workspace".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let (_, tool) =
|
||||||
|
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)();
|
||||||
let input = json!({"parent":parent,"cursor":"opaque/+&","limit":7});
|
let input = json!({"parent":parent,"cursor":"opaque/+&","limit":7});
|
||||||
if parent["kind"] == "parent" {
|
if parent["kind"] == "parent" {
|
||||||
assert!(tool.execute(&input.to_string(), ToolExecutionContext::direct()).await.is_err());
|
assert!(
|
||||||
|
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
assert!(client.requests.lock().unwrap().is_empty());
|
assert!(client.requests.lock().unwrap().is_empty());
|
||||||
client.grant("ticket.relation.query", "T-3");
|
client.grant("ticket.relation.query", "T-3");
|
||||||
}
|
}
|
||||||
tool.execute(&input.to_string(), ToolExecutionContext::direct()).await.unwrap();
|
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let requests = client.requests.lock().unwrap();
|
let requests = client.requests.lock().unwrap();
|
||||||
assert_eq!(requests.len(), 1);
|
assert_eq!(requests.len(), 1);
|
||||||
assert_eq!(requests[0].path, "/api/w/workspace/tickets/query");
|
assert_eq!(requests[0].path, "/api/w/workspace/tickets/query");
|
||||||
@@ -436,8 +886,10 @@ async fn parent_query_filter_is_forwarded_unchanged_and_requires_target_not_coll
|
|||||||
async fn failed_parent_write_never_refreshes_cas_or_retries_as_a_new_operation() {
|
async fn failed_parent_write_never_refreshes_cas_or_retries_as_a_new_operation() {
|
||||||
for status in [409, 503] {
|
for status in [409, 503] {
|
||||||
let client = Arc::new(Client {
|
let client = Arc::new(Client {
|
||||||
authority: AuthorizedClient::new(json!(null)), requests: Mutex::new(Vec::new()),
|
authority: AuthorizedClient::new(json!(null)),
|
||||||
response: json!({"message":"reconcile current parent"}), status,
|
requests: Mutex::new(Vec::new()),
|
||||||
|
response: json!({"message":"reconcile current parent"}),
|
||||||
|
status,
|
||||||
});
|
});
|
||||||
for id in ["internalchild", "internalold"] {
|
for id in ["internalchild", "internalold"] {
|
||||||
client.grant("ticket.relation.remove", id);
|
client.grant("ticket.relation.remove", id);
|
||||||
@@ -446,7 +898,11 @@ async fn failed_parent_write_never_refreshes_cas_or_retries_as_a_new_operation()
|
|||||||
let (_, tool) = definition(client.clone(), ParentToolKind::Set)();
|
let (_, tool) = definition(client.clone(), ParentToolKind::Set)();
|
||||||
let input = set_input(Value::Null);
|
let input = set_input(Value::Null);
|
||||||
for attempt in 1..=2 {
|
for attempt in 1..=2 {
|
||||||
assert!(tool.execute(&input.to_string(), ToolExecutionContext::direct()).await.is_err());
|
assert!(
|
||||||
|
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
assert_eq!(client.writes().len(), attempt); // no hidden mutation retry
|
assert_eq!(client.writes().len(), attempt); // no hidden mutation retry
|
||||||
}
|
}
|
||||||
let writes = client.writes();
|
let writes = client.writes();
|
||||||
|
|||||||
@@ -360,9 +360,13 @@ pub(super) fn project(
|
|||||||
fields.insert("next_cursor".into(), WipValue::String(cursor));
|
fields.insert("next_cursor".into(), WipValue::String(cursor));
|
||||||
}
|
}
|
||||||
if children {
|
if children {
|
||||||
let parent = bound.filter(|id| ticket_leaf(id))
|
let parent = bound
|
||||||
|
.filter(|id| ticket_leaf(id))
|
||||||
.ok_or_else(|| failure("missing children parent subject"))?;
|
.ok_or_else(|| failure("missing children parent subject"))?;
|
||||||
fields.insert("entry".into(), WipValue::String(format!("{ticket_root}/{parent}")));
|
fields.insert(
|
||||||
|
"entry".into(),
|
||||||
|
WipValue::String(format!("{ticket_root}/{parent}")),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(WipValue::Record(fields))
|
Ok(WipValue::Record(fields))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ async fn ticket_inspect_contract_drives_query_read_and_comment_without_tool_sche
|
|||||||
let detail = json!({
|
let detail = json!({
|
||||||
"resource_key":"T-42", "title":"Intent", "body":"Requirements", "body_truncated":false,
|
"resource_key":"T-42", "title":"Intent", "body":"Requirements", "body_truncated":false,
|
||||||
"content_digest":"observed", "state":"open", "targets":[], "events":[], "linked_objectives":[],
|
"content_digest":"observed", "state":"open", "targets":[], "events":[], "linked_objectives":[],
|
||||||
|
"parent_link":{"ticket_id":"T-42","observation":{"parent_ticket_id":null,"last_operation_key":null},"parent":null},
|
||||||
"initial_post":{"sequence":0,"event_ref":"initial","is_initial":true,"kind":"comment","body":"Requirements","body_truncated":false,"body_digest":"a".repeat(64),"editable":true,"author":null,"at":null,"edited_at":null,"edited_by":null},
|
"initial_post":{"sequence":0,"event_ref":"initial","is_initial":true,"kind":"comment","body":"Requirements","body_truncated":false,"body_digest":"a".repeat(64),"editable":true,"author":null,"at":null,"edited_at":null,"edited_by":null},
|
||||||
"closure_operation_key":null,"current_closure":null,"legacy_handoff":null,"closure_history":[],
|
"closure_operation_key":null,"current_closure":null,"legacy_handoff":null,"closure_history":[],
|
||||||
"assignment_tracking":[],"tags":[],"assignments":[],"implementation_reports":[],"merge_requests":[]
|
"assignment_tracking":[],"tags":[],"assignments":[],"implementation_reports":[],"merge_requests":[]
|
||||||
@@ -30,6 +31,8 @@ async fn ticket_inspect_contract_drives_query_read_and_comment_without_tool_sche
|
|||||||
.with_authorizations([
|
.with_authorizations([
|
||||||
expected_authorization("ticket.query", "workspace", None),
|
expected_authorization("ticket.query", "workspace", None),
|
||||||
expected_authorization("ticket.read", "ticket", Some(("ticket_id", "T-42"))),
|
expected_authorization("ticket.read", "ticket", Some(("ticket_id", "T-42"))),
|
||||||
|
expected_authorization("ticket.relation.read", "ticket", Some(("ticket_id", "T-42"))),
|
||||||
|
expected_authorization("ticket.relation.read", "ticket", Some(("ticket_id", "T-42"))),
|
||||||
expected_authorization("ticket.event.add", "ticket", Some(("ticket_id", "T-42"))),
|
expected_authorization("ticket.event.add", "ticket", Some(("ticket_id", "T-42"))),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
@@ -569,6 +572,7 @@ fn ticket_result_detail(key: &str) -> Json {
|
|||||||
json!({
|
json!({
|
||||||
"resource_key":key,"title":"Intent","body":"/cases/T-999 is untrusted text", "body_truncated":false,
|
"resource_key":key,"title":"Intent","body":"/cases/T-999 is untrusted text", "body_truncated":false,
|
||||||
"content_digest":"observed","state":"open","targets":[],"events":[],"linked_objectives":[],
|
"content_digest":"observed","state":"open","targets":[],"events":[],"linked_objectives":[],
|
||||||
|
"parent_link":{"ticket_id":key,"observation":{"parent_ticket_id":null,"last_operation_key":null},"parent":null},
|
||||||
"initial_post":{"sequence":0,"event_ref":"initial","is_initial":true,"kind":"comment","body":"Requirements","body_truncated":false,"body_digest":"a".repeat(64),"editable":true,"author":null,"at":null,"edited_at":null,"edited_by":null},
|
"initial_post":{"sequence":0,"event_ref":"initial","is_initial":true,"kind":"comment","body":"Requirements","body_truncated":false,"body_digest":"a".repeat(64),"editable":true,"author":null,"at":null,"edited_at":null,"edited_by":null},
|
||||||
"closure_operation_key":null,"current_closure":null,"legacy_handoff":null,"closure_history":[],
|
"closure_operation_key":null,"current_closure":null,"legacy_handoff":null,"closure_history":[],
|
||||||
"assignment_tracking":[],"tags":[],"assignments":[],"implementation_reports":[],"merge_requests":[]
|
"assignment_tracking":[],"tags":[],"assignments":[],"implementation_reports":[],"merge_requests":[]
|
||||||
@@ -630,6 +634,13 @@ async fn ticket_typed_results_drive_model_query_create_read_and_all_related_doma
|
|||||||
detail["linked_objectives"] =
|
detail["linked_objectives"] =
|
||||||
json!([{"resource_key":"O-3","title":"Objective","state":"active"}]);
|
json!([{"resource_key":"O-3","title":"Objective","state":"active"}]);
|
||||||
detail["relations"] = json!({"outgoing":[{"target_resource_key":"T-7","kind":"related","note":null,"at":null}],"incoming":[],"blockers":[],"notices":[]});
|
detail["relations"] = json!({"outgoing":[{"target_resource_key":"T-7","kind":"related","note":null,"at":null}],"incoming":[],"blockers":[],"notices":[]});
|
||||||
|
detail["parent_link"] = json!({
|
||||||
|
"ticket_id":"T-42",
|
||||||
|
"observation":{"parent_ticket_id":"actualparent","last_operation_key":"parent-set"},
|
||||||
|
"parent":{"id":"actualparent","resource_key":"T-7","title":"Intent","state":"open","closure_reason":null}
|
||||||
|
});
|
||||||
|
let mut parent_detail = ticket_result_detail("T-7");
|
||||||
|
parent_detail["parent_link"]["ticket_id"] = json!("actualparent");
|
||||||
detail["merge_requests"] = json!([{"merge_request_id":"MR-1","repository_key":"main","state":"open","selector_from":"work/source","selector_to":"develop","review_status":"pending","subject_ref":null,"source_ref_observation":{"status":"not_required"}}]);
|
detail["merge_requests"] = json!([{"merge_request_id":"MR-1","repository_key":"main","state":"open","selector_from":"work/source","selector_to":"develop","review_status":"pending","subject_ref":null,"source_ref_observation":{"status":"not_required"}}]);
|
||||||
let query_item = json!({"resource_key":"T-42","title":"Intent","state":"open","closure_operation_key":null,"current_closure":null,"legacy_handoff":null,"assignment_tracking":[],"tags":[],"matched_fields":[],"linked_objective_keys":["O-3"],"relation_count":1,"blocker_count":0,"unresolved_blocker_count":0,"unresolved_review_count":0,"merge_requests":detail["merge_requests"], "snippet":"/cases/T-999"});
|
let query_item = json!({"resource_key":"T-42","title":"Intent","state":"open","closure_operation_key":null,"current_closure":null,"legacy_handoff":null,"assignment_tracking":[],"tags":[],"matched_fields":[],"linked_objective_keys":["O-3"],"relation_count":1,"blocker_count":0,"unresolved_blocker_count":0,"unresolved_review_count":0,"merge_requests":detail["merge_requests"], "snippet":"/cases/T-999"});
|
||||||
let mut second_item = query_item.clone();
|
let mut second_item = query_item.clone();
|
||||||
@@ -642,7 +653,7 @@ async fn ticket_typed_results_drive_model_query_create_read_and_all_related_doma
|
|||||||
response(json!({"items":[query_item, second_item],"page":{"next_cursor":"opaque-next","has_more":true}})),
|
response(json!({"items":[query_item, second_item],"page":{"next_cursor":"opaque-next","has_more":true}})),
|
||||||
response(json!({"items":[],"page":{"next_cursor":null,"has_more":false}})),
|
response(json!({"items":[],"page":{"next_cursor":null,"has_more":false}})),
|
||||||
response(detail),
|
response(detail),
|
||||||
response(ticket_result_detail("T-7")),
|
response(parent_detail),
|
||||||
objective_detail_response("objective-observed"),
|
objective_detail_response("objective-observed"),
|
||||||
response(json!({"workspace_id":"workspace","merge_request_id":"MR-1","repository_key":"main","state":"open","selector_from":"work/source","selector_to":"develop","ticket_ids":[],"thread":[]})),
|
response(json!({"workspace_id":"workspace","merge_request_id":"MR-1","repository_key":"main","state":"open","selector_from":"work/source","selector_to":"develop","ticket_ids":[],"thread":[]})),
|
||||||
response(json!({"id":"internal123","resource_key":"T-9","slug":"new","status":"Open"})),
|
response(json!({"id":"internal123","resource_key":"T-9","slug":"new","status":"Open"})),
|
||||||
@@ -650,7 +661,12 @@ async fn ticket_typed_results_drive_model_query_create_read_and_all_related_doma
|
|||||||
expected_authorization("ticket.query","workspace",None),
|
expected_authorization("ticket.query","workspace",None),
|
||||||
expected_authorization("ticket.query","workspace",None),
|
expected_authorization("ticket.query","workspace",None),
|
||||||
expected_authorization("ticket.read","ticket",Some(("ticket_id","T-42"))),
|
expected_authorization("ticket.read","ticket",Some(("ticket_id","T-42"))),
|
||||||
|
expected_authorization("ticket.relation.read","ticket",Some(("ticket_id","T-42"))),
|
||||||
|
expected_authorization("ticket.relation.read","ticket",Some(("ticket_id","T-42"))),
|
||||||
|
expected_authorization("ticket.relation.read","ticket",Some(("ticket_id","actualparent"))),
|
||||||
expected_authorization("ticket.read","ticket",Some(("ticket_id","T-7"))),
|
expected_authorization("ticket.read","ticket",Some(("ticket_id","T-7"))),
|
||||||
|
expected_authorization("ticket.relation.read","ticket",Some(("ticket_id","T-7"))),
|
||||||
|
expected_authorization("ticket.relation.read","ticket",Some(("ticket_id","actualparent"))),
|
||||||
expected_authorization("objective.read","objective",Some(("objective_id","O-3"))),
|
expected_authorization("objective.read","objective",Some(("objective_id","O-3"))),
|
||||||
expected_authorization("merge_request.read","merge_request",Some(("merge_request_id","MR-1"))),
|
expected_authorization("merge_request.read","merge_request",Some(("merge_request_id","MR-1"))),
|
||||||
expected_authorization("ticket.create","workspace",None),
|
expected_authorization("ticket.create","workspace",None),
|
||||||
@@ -752,6 +768,12 @@ async fn ticket_typed_results_drive_model_query_create_read_and_all_related_doma
|
|||||||
ticket_result_model_invoke(&tools, &selected, "read", json!({"page":{"limit":1}}))
|
ticket_result_model_invoke(&tools, &selected, "read", json!({"page":{"limit":1}}))
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(read["entry"], query["items"][0]["entry"]);
|
assert_eq!(read["entry"], query["items"][0]["entry"]);
|
||||||
|
assert_eq!(read["data"]["parent_link"]["parent"]["id"], "actualparent");
|
||||||
|
assert_eq!(
|
||||||
|
read["links"]["tickets"],
|
||||||
|
json!([{"id":"T-7","entry":"/cases/T-7"}])
|
||||||
|
);
|
||||||
|
assert_eq!(client.requests().len(), 3, "parent navigation remains lazy");
|
||||||
for domain in ["tickets", "objectives", "merge_requests"] {
|
for domain in ["tickets", "objectives", "merge_requests"] {
|
||||||
let related =
|
let related =
|
||||||
ticket_result_model_inspect(&tools, &read["links"][domain][0]["entry"]).await;
|
ticket_result_model_inspect(&tools, &read["links"][domain][0]["entry"]).await;
|
||||||
@@ -806,7 +828,9 @@ async fn ticket_successful_create_is_never_replayed_after_selected_deleted_or_de
|
|||||||
.with_authorizations([
|
.with_authorizations([
|
||||||
expected_authorization("ticket.create", "workspace", None),
|
expected_authorization("ticket.create", "workspace", None),
|
||||||
expected_authorization("ticket.read", "ticket", Some(("ticket_id", "T-9"))),
|
expected_authorization("ticket.read", "ticket", Some(("ticket_id", "T-9"))),
|
||||||
|
expected_authorization("ticket.relation.read", "ticket", Some(("ticket_id", "T-9"))),
|
||||||
expected_authorization("ticket.read", "ticket", Some(("ticket_id", "T-9"))),
|
expected_authorization("ticket.read", "ticket", Some(("ticket_id", "T-9"))),
|
||||||
|
expected_authorization("ticket.relation.read", "ticket", Some(("ticket_id", "T-9"))),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
let mut registry = WipMountRegistry::new();
|
let mut registry = WipMountRegistry::new();
|
||||||
@@ -980,11 +1004,11 @@ async fn host_overwrites_forged_result_context_and_omits_unpublished_cross_domai
|
|||||||
status: 200,
|
status: 200,
|
||||||
body: detail.to_string(),
|
body: detail.to_string(),
|
||||||
}])
|
}])
|
||||||
.with_authorizations([expected_authorization(
|
.with_authorizations([
|
||||||
"ticket.read",
|
expected_authorization("ticket.read", "ticket", Some(("ticket_id", "T-42"))),
|
||||||
"ticket",
|
expected_authorization("ticket.relation.read", "ticket", Some(("ticket_id", "T-42"))),
|
||||||
Some(("ticket_id", "T-42")),
|
expected_authorization("ticket.relation.read", "ticket", Some(("ticket_id", "T-42"))),
|
||||||
)]),
|
]),
|
||||||
);
|
);
|
||||||
let mut registry = WipMountRegistry::new();
|
let mut registry = WipMountRegistry::new();
|
||||||
let route = registry.allocate_namespace("ticket", "cases").unwrap();
|
let route = registry.allocate_namespace("ticket", "cases").unwrap();
|
||||||
|
|||||||
@@ -526,7 +526,8 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip
|
|||||||
111,
|
111,
|
||||||
"Typed Plugin Ticket extension data and atomic mutation receipts".into()
|
"Typed Plugin Ticket extension data and atomic mutation receipts".into()
|
||||||
),
|
),
|
||||||
(112, "Ticket Service outbox and fenced consumers".into())
|
(112, "Ticket Service outbox and fenced consumers".into()),
|
||||||
|
(113, "Ticket work-decomposition parent links".into())
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
for (table, rows) in retained.iter().zip(original.iter()) {
|
for (table, rows) in retained.iter().zip(original.iter()) {
|
||||||
|
|||||||
@@ -461,7 +461,7 @@ impl SqliteWorkspaceAuthority {
|
|||||||
};
|
};
|
||||||
if let Some(parent) = &query.parent {
|
if let Some(parent) = &query.parent {
|
||||||
match parent {
|
match parent {
|
||||||
ticket::TicketParentFilter::None => predicates.push("NOT EXISTS (SELECT 1 FROM typed_ticket_parent_links p WHERE p.workspace_id=t.workspace_id AND p.ticket_id=t.ticket_id AND p.parent_ticket_id IS NOT NULL)".into()),
|
ticket::TicketParentFilter::None {} => predicates.push("NOT EXISTS (SELECT 1 FROM typed_ticket_parent_links p WHERE p.workspace_id=t.workspace_id AND p.ticket_id=t.ticket_id AND p.parent_ticket_id IS NOT NULL)".into()),
|
||||||
ticket::TicketParentFilter::Parent { ticket } => {
|
ticket::TicketParentFilter::Parent { ticket } => {
|
||||||
let parent = bind(SqlValue::Text(ticket.clone()));
|
let parent = bind(SqlValue::Text(ticket.clone()));
|
||||||
predicates.push(format!("EXISTS (SELECT 1 FROM typed_ticket_parent_links p WHERE p.workspace_id=t.workspace_id AND p.ticket_id=t.ticket_id AND p.parent_ticket_id={parent})"));
|
predicates.push(format!("EXISTS (SELECT 1 FROM typed_ticket_parent_links p WHERE p.workspace_id=t.workspace_id AND p.ticket_id=t.ticket_id AND p.parent_ticket_id={parent})"));
|
||||||
@@ -2417,7 +2417,7 @@ fn ticket_matches_query(
|
|||||||
if let Some(parent) = &query.parent {
|
if let Some(parent) = &query.parent {
|
||||||
let actual = detail.parent_link.observation.parent_ticket_id.as_deref();
|
let actual = detail.parent_link.observation.parent_ticket_id.as_deref();
|
||||||
match parent {
|
match parent {
|
||||||
ticket::TicketParentFilter::None if actual.is_some() => return false,
|
ticket::TicketParentFilter::None {} if actual.is_some() => return false,
|
||||||
ticket::TicketParentFilter::Parent { ticket } if actual != Some(ticket.as_str()) => {
|
ticket::TicketParentFilter::Parent { ticket } if actual != Some(ticket.as_str()) => {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ fn plugin_catalog_migration_matches_fresh_schema_from_oldest_retained_and_105()
|
|||||||
// Plugin catalog remains migration 106 even when startup applies later steps.
|
// Plugin catalog remains migration 106 even when startup applies later steps.
|
||||||
assert_eq!(after.iter().filter(|(version, _)| *version == 106).cloned().collect::<Vec<_>>(),
|
assert_eq!(after.iter().filter(|(version, _)| *version == 106).cloned().collect::<Vec<_>>(),
|
||||||
vec![(106, crate::plugin_catalog::MIGRATION_NAME.into())]);
|
vec![(106, crate::plugin_catalog::MIGRATION_NAME.into())]);
|
||||||
assert_eq!(after.last().unwrap(), &(112, "Ticket Service outbox and fenced consumers".into()));
|
assert_eq!(after.last().unwrap(), &(113, "Ticket work-decomposition parent links".into()));
|
||||||
assert_eq!(current_schema_version(c)?, LATEST_SCHEMA_VERSION);
|
assert_eq!(current_schema_version(c)?, LATEST_SCHEMA_VERSION);
|
||||||
assert_eq!(c.query_row("SELECT display_name FROM workspaces WHERE workspace_id='plugin-workspace'",[],|r|r.get::<_,String>(0))?,"Plugins");
|
assert_eq!(c.query_row("SELECT display_name FROM workspaces WHERE workspace_id='plugin-workspace'",[],|r|r.get::<_,String>(0))?,"Plugins");
|
||||||
assert_eq!(c.query_row("SELECT count(*) FROM pragma_foreign_key_check",[],|r|r.get::<_,i64>(0))?,0);
|
assert_eq!(c.query_row("SELECT count(*) FROM pragma_foreign_key_check",[],|r|r.get::<_,i64>(0))?,0);
|
||||||
@@ -82,6 +82,7 @@ fn plugin_catalog_migration_marker_failure_rolls_back_then_retries_without_chang
|
|||||||
"Typed Plugin Ticket extension data and atomic mutation receipts".into(),
|
"Typed Plugin Ticket extension data and atomic mutation receipts".into(),
|
||||||
));
|
));
|
||||||
expected.push((112, "Ticket Service outbox and fenced consumers".into()));
|
expected.push((112, "Ticket Service outbox and fenced consumers".into()));
|
||||||
|
expected.push((113, "Ticket work-decomposition parent links".into()));
|
||||||
assert_eq!(workspace_schema_migration_history(c)?, expected);
|
assert_eq!(workspace_schema_migration_history(c)?, expected);
|
||||||
crate::plugin_catalog::verify_with_data(c)?;
|
crate::plugin_catalog::verify_with_data(c)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ fn plugin_receipt_tail_migrates_true_predecessor_reopens_and_matches_fresh_schem
|
|||||||
assert!(!table_exists(&conn, "plugin_invocations").unwrap());
|
assert!(!table_exists(&conn, "plugin_invocations").unwrap());
|
||||||
let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap();
|
let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap();
|
||||||
assert_eq!(plan.current_schema_version, 108);
|
assert_eq!(plan.current_schema_version, 108);
|
||||||
assert_eq!(plan.migrations.iter().map(|step| step.version).collect::<Vec<_>>(), [109, 110, 111, 112]);
|
assert_eq!(plan.migrations.iter().map(|step| step.version).collect::<Vec<_>>(), [109, 110, 111, 112, 113]);
|
||||||
assert_eq!(current_schema_version(&conn).unwrap(), 108);
|
assert_eq!(current_schema_version(&conn).unwrap(), 108);
|
||||||
drop(conn);
|
drop(conn);
|
||||||
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ fn plugin_plan_migration_marker_failure_preserves_catalog_then_retries_and_match
|
|||||||
"Typed Plugin Ticket extension data and atomic mutation receipts".into(),
|
"Typed Plugin Ticket extension data and atomic mutation receipts".into(),
|
||||||
));
|
));
|
||||||
expected.push((112, "Ticket Service outbox and fenced consumers".into()));
|
expected.push((112, "Ticket Service outbox and fenced consumers".into()));
|
||||||
|
expected.push((113, "Ticket work-decomposition parent links".into()));
|
||||||
assert_eq!(workspace_schema_migration_history(conn)?, expected);
|
assert_eq!(workspace_schema_migration_history(conn)?, expected);
|
||||||
subject_assignment_migration::verify(conn)?;
|
subject_assignment_migration::verify(conn)?;
|
||||||
crate::plugin_plan::verify_plan_schema(conn)?;
|
crate::plugin_plan::verify_plan_schema(conn)?;
|
||||||
|
|||||||
@@ -128,6 +128,10 @@ mod schema_convergence {
|
|||||||
version: 112,
|
version: 112,
|
||||||
name: "Ticket Service outbox and fenced consumers".into(),
|
name: "Ticket Service outbox and fenced consumers".into(),
|
||||||
},
|
},
|
||||||
|
WorkspaceSchemaMigrationStep {
|
||||||
|
version: 113,
|
||||||
|
name: "Ticket work-decomposition parent links".into(),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -151,6 +155,7 @@ mod schema_convergence {
|
|||||||
expected.push((110, "Worker Plugin launch selection associations".into()));
|
expected.push((110, "Worker Plugin launch selection associations".into()));
|
||||||
expected.push((111, "Typed Plugin Ticket extension data and atomic mutation receipts".into()));
|
expected.push((111, "Typed Plugin Ticket extension data and atomic mutation receipts".into()));
|
||||||
expected.push((112, "Ticket Service outbox and fenced consumers".into()));
|
expected.push((112, "Ticket Service outbox and fenced consumers".into()));
|
||||||
|
expected.push((113, "Ticket work-decomposition parent links".into()));
|
||||||
assert_eq!(workspace_schema_migration_history(conn)?, expected);
|
assert_eq!(workspace_schema_migration_history(conn)?, expected);
|
||||||
for table in ["passkey_credentials", "passkey_metadata", "passkey_addition_receipts", "backend_jobs", "backend_job_attempts"] {
|
for table in ["passkey_credentials", "passkey_metadata", "passkey_addition_receipts", "backend_jobs", "backend_job_attempts"] {
|
||||||
assert_eq!(state_contract_schema_signature(conn, table), fresh.with_conn(|c| Ok(state_contract_schema_signature(c, table)))?, "{table}");
|
assert_eq!(state_contract_schema_signature(conn, table), fresh.with_conn(|c| Ok(state_contract_schema_signature(c, table)))?, "{table}");
|
||||||
|
|||||||
@@ -7948,6 +7948,9 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou
|
|||||||
.merge(server_api::server_api_axum::workspace_ticket_tag_mutate(
|
.merge(server_api::server_api_axum::workspace_ticket_tag_mutate(
|
||||||
service.clone(),
|
service.clone(),
|
||||||
))
|
))
|
||||||
|
.merge(server_api::server_api_axum::ticket_link_summary_get(
|
||||||
|
service.clone(),
|
||||||
|
))
|
||||||
.merge(server_api::server_api_axum::ticket_parent_get(
|
.merge(server_api::server_api_axum::ticket_parent_get(
|
||||||
service.clone(),
|
service.clone(),
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -718,6 +718,7 @@ async fn connected_chat_delegation(scenario: Scenario) {
|
|||||||
let other_assignment = assigned["assignment"]["assignment_id"].clone();
|
let other_assignment = assigned["assignment"]["assignment_id"].clone();
|
||||||
for (id, name) in [
|
for (id, name) in [
|
||||||
(&ticket, "ticket.read"),
|
(&ticket, "ticket.read"),
|
||||||
|
(&ticket, "ticket.relation.read"),
|
||||||
(&ticket, "ticket.assignment.assign"),
|
(&ticket, "ticket.assignment.assign"),
|
||||||
(&ticket, "ticket.implementation_result.read"),
|
(&ticket, "ticket.implementation_result.read"),
|
||||||
(&other_ticket, "ticket.assignment.assign"),
|
(&other_ticket, "ticket.assignment.assign"),
|
||||||
@@ -730,7 +731,11 @@ async fn connected_chat_delegation(scenario: Scenario) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for name in ["ticket.read", "ticket.implementation_result.write"] {
|
for name in [
|
||||||
|
"ticket.read",
|
||||||
|
"ticket.relation.read",
|
||||||
|
"ticket.implementation_result.write",
|
||||||
|
] {
|
||||||
child_permissions.push(ScopedPermission {
|
child_permissions.push(ScopedPermission {
|
||||||
operation: name.into(),
|
operation: name.into(),
|
||||||
resource: PermissionResource::Ticket {
|
resource: PermissionResource::Ticket {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ mod chat_tests;
|
|||||||
mod subject_job_model_supply;
|
mod subject_job_model_supply;
|
||||||
#[path = "subject_permission_management_tests.rs"]
|
#[path = "subject_permission_management_tests.rs"]
|
||||||
mod subject_permission_management;
|
mod subject_permission_management;
|
||||||
|
#[path = "ticket_parent_connected_tests.rs"]
|
||||||
|
mod ticket_parent_connected;
|
||||||
#[path = "ticket_worker_messaging_connected_tests.rs"]
|
#[path = "ticket_worker_messaging_connected_tests.rs"]
|
||||||
mod ticket_worker_messaging_connected;
|
mod ticket_worker_messaging_connected;
|
||||||
#[path = "worker_messaging_connected_tests.rs"]
|
#[path = "worker_messaging_connected_tests.rs"]
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
//! Normal Tool and native Inspect/Invoke -> signed production router -> Subject
|
||||||
|
//! authorization -> real SQLite. Reuses the shared workdirless Runtime fixture;
|
||||||
|
//! only external execution and one transport response are substituted.
|
||||||
|
use super::*;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct LosingResponse {
|
||||||
|
inner: Arc<RouterClient>,
|
||||||
|
lose: AtomicBool,
|
||||||
|
}
|
||||||
|
impl WorkspaceClient for LosingResponse {
|
||||||
|
fn workspace_id(&self) -> Option<&str> {
|
||||||
|
self.inner.workspace_id()
|
||||||
|
}
|
||||||
|
fn kind(&self) -> &str {
|
||||||
|
"signed-parent-response-loss"
|
||||||
|
}
|
||||||
|
fn is_available(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn execute(
|
||||||
|
&self,
|
||||||
|
request: WorkspaceRequest,
|
||||||
|
) -> std::result::Result<WorkspaceResponse, WorkspaceClientError> {
|
||||||
|
let write =
|
||||||
|
request.method == WorkspaceRequestMethod::Put && request.path.ends_with("/parent");
|
||||||
|
let response = self.inner.execute(request)?;
|
||||||
|
if write && response.status == 200 && self.lose.swap(false, Ordering::SeqCst) {
|
||||||
|
return Err(WorkspaceClientError::Request(
|
||||||
|
"injected response loss after commit".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn grant(operation: &str, id: &str) -> ScopedPermission {
|
||||||
|
ScopedPermission {
|
||||||
|
operation: operation.into(),
|
||||||
|
resource: PermissionResource::Ticket {
|
||||||
|
workspace_id: TEST_WORKSPACE_ID.into(),
|
||||||
|
ticket_id: id.into(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn nullable(value: Option<&str>) -> Value {
|
||||||
|
match value {
|
||||||
|
Some(value) => json!({"$case":"value","value":value}),
|
||||||
|
None => json!({"$case":"null"}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
struct Surface {
|
||||||
|
native: bool,
|
||||||
|
set: Arc<dyn Tool>,
|
||||||
|
children: Arc<dyn Tool>,
|
||||||
|
inspect: Option<Arc<dyn Tool>>,
|
||||||
|
}
|
||||||
|
impl Surface {
|
||||||
|
async fn reference(&self, path: &str) -> std::result::Result<Value, agen::tool::ToolError> {
|
||||||
|
let description = value(
|
||||||
|
self.inspect
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.execute(
|
||||||
|
&json!({"path":path,"refresh":true}).to_string(),
|
||||||
|
ToolExecutionContext::direct(),
|
||||||
|
)
|
||||||
|
.await?,
|
||||||
|
);
|
||||||
|
Ok(description["interfaces"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|item| item["reference"]["name"] == "yoi.ticket/item/v2")
|
||||||
|
.expect("Ticket item interface")["reference"]
|
||||||
|
.clone())
|
||||||
|
}
|
||||||
|
async fn invoke(
|
||||||
|
&self,
|
||||||
|
child: &str,
|
||||||
|
expected: &ticket::TicketParentObservation,
|
||||||
|
parent: Option<&str>,
|
||||||
|
key: &str,
|
||||||
|
) -> std::result::Result<ToolOutput, agen::tool::ToolError> {
|
||||||
|
let input = if self.native {
|
||||||
|
let path = format!("/tickets/{child}");
|
||||||
|
let parent_path = parent.map(|parent| format!("/tickets/{parent}"));
|
||||||
|
json!({"path":path,"interface":self.reference(&path).await?,"operation":"parent_set","arguments":{
|
||||||
|
"expected":{"parent_ticket_id":nullable(expected.parent_ticket_id.as_deref()),"last_operation_key":nullable(expected.last_operation_key.as_deref())},
|
||||||
|
"parent":nullable(parent_path.as_deref()),"operation_key":key
|
||||||
|
}})
|
||||||
|
} else {
|
||||||
|
json!({"ticket":child,"expected":expected,"parent":parent,"operation_key":key})
|
||||||
|
};
|
||||||
|
self.set
|
||||||
|
.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
async fn children(
|
||||||
|
&self,
|
||||||
|
parent: &str,
|
||||||
|
) -> std::result::Result<ToolOutput, agen::tool::ToolError> {
|
||||||
|
let input = if self.native {
|
||||||
|
let path = format!("/tickets/{parent}");
|
||||||
|
json!({"path":path,"interface":self.reference(&path).await?,"operation":"children_query","arguments":{"page":{"limit":1}}})
|
||||||
|
} else {
|
||||||
|
json!({"ticket":parent,"limit":1})
|
||||||
|
};
|
||||||
|
self.children
|
||||||
|
.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
async fn tool_and_native_parent_mutations_share_metadata_only_authority_and_durable_replay() {
|
||||||
|
for native in [false, true] {
|
||||||
|
let f = Fixture::new().await;
|
||||||
|
let backend = browser_ticket_backend(&f.api).unwrap();
|
||||||
|
let a = backend.create(NewTicket::new("A private body")).unwrap().id;
|
||||||
|
let b = backend.create(NewTicket::new("B private body")).unwrap().id;
|
||||||
|
let child = backend
|
||||||
|
.create(NewTicket::new("Child private body"))
|
||||||
|
.unwrap()
|
||||||
|
.id;
|
||||||
|
let foreign = backend
|
||||||
|
.create(NewTicket::new("Unauthorized Ticket"))
|
||||||
|
.unwrap()
|
||||||
|
.id;
|
||||||
|
let key = |id: &str| backend.show(id.into()).unwrap().meta.resource_key.unwrap();
|
||||||
|
let child_key = key(&child);
|
||||||
|
let a_key = key(&a);
|
||||||
|
let b_key = key(&b);
|
||||||
|
let permissions = || {
|
||||||
|
vec![
|
||||||
|
grant("ticket.relation.read", &child),
|
||||||
|
grant("ticket.relation.read", &a),
|
||||||
|
grant("ticket.relation.read", &b),
|
||||||
|
grant("ticket.relation.record", &child),
|
||||||
|
grant("ticket.relation.record", &a),
|
||||||
|
grant("ticket.relation.record", &b),
|
||||||
|
]
|
||||||
|
};
|
||||||
|
f.assign(permissions(), vec![]).await;
|
||||||
|
assert_eq!(
|
||||||
|
f.check(&f.source, &grant("ticket.read", &a)).await.0,
|
||||||
|
StatusCode::FORBIDDEN
|
||||||
|
);
|
||||||
|
let client = Arc::new(LosingResponse {
|
||||||
|
inner: f.client(&f.source),
|
||||||
|
lose: AtomicBool::new(false),
|
||||||
|
});
|
||||||
|
let (mut host, installed) =
|
||||||
|
ticket_worker_host(&f._dir.path().join("parent-host"), client.clone()).await;
|
||||||
|
assert!(installed);
|
||||||
|
if native {
|
||||||
|
let mut mounts = worker::wip::WipMountRegistry::new();
|
||||||
|
let route = mounts.allocate_namespace("ticket", "tickets").unwrap();
|
||||||
|
worker::feature::builtin::ticket::mount_workspace_http_ticket_wip(
|
||||||
|
&mut mounts,
|
||||||
|
client.clone(),
|
||||||
|
TicketFeatureAccess::workspace_authoring(),
|
||||||
|
None,
|
||||||
|
&route,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
worker::wip::install_wip_mode_with_mounts(
|
||||||
|
host.engine_mut(),
|
||||||
|
None,
|
||||||
|
"parent-contract".into(),
|
||||||
|
mounts,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let tools = host.engine().tool_server_handle();
|
||||||
|
tools.flush_pending();
|
||||||
|
let surface = Surface {
|
||||||
|
native,
|
||||||
|
set: tools
|
||||||
|
.get_tool(if native { "Invoke" } else { "TicketParentSet" })
|
||||||
|
.unwrap()
|
||||||
|
.1,
|
||||||
|
children: tools
|
||||||
|
.get_tool(if native {
|
||||||
|
"Invoke"
|
||||||
|
} else {
|
||||||
|
"TicketChildrenQuery"
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
.1,
|
||||||
|
inspect: native.then(|| tools.get_tool("Inspect").unwrap().1),
|
||||||
|
};
|
||||||
|
let empty = Default::default();
|
||||||
|
surface
|
||||||
|
.invoke(&child_key, &empty, Some(&a_key), "attach")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let first = backend
|
||||||
|
.get_ticket_parent(child.clone().into())
|
||||||
|
.unwrap()
|
||||||
|
.observation;
|
||||||
|
assert_eq!(first.parent_ticket_id.as_deref(), Some(a.as_str()));
|
||||||
|
surface
|
||||||
|
.invoke(&child_key, &first, Some(&a_key), "same-alias")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
backend
|
||||||
|
.get_ticket_parent(child.clone().into())
|
||||||
|
.unwrap()
|
||||||
|
.observation,
|
||||||
|
first
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
surface
|
||||||
|
.invoke(&child_key, &first, Some(&b_key), "reparent")
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
backend
|
||||||
|
.get_ticket_parent(child.clone().into())
|
||||||
|
.unwrap()
|
||||||
|
.observation,
|
||||||
|
first
|
||||||
|
);
|
||||||
|
let mut all = permissions();
|
||||||
|
all.extend([
|
||||||
|
grant("ticket.relation.remove", &child),
|
||||||
|
grant("ticket.relation.remove", &a),
|
||||||
|
grant("ticket.relation.remove", &b),
|
||||||
|
]);
|
||||||
|
f.assign(all, vec![]).await;
|
||||||
|
client.lose.store(true, Ordering::SeqCst);
|
||||||
|
assert!(
|
||||||
|
surface
|
||||||
|
.invoke(&child_key, &first, Some(&b_key), "reparent")
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let moved = backend
|
||||||
|
.get_ticket_parent(child.clone().into())
|
||||||
|
.unwrap()
|
||||||
|
.observation;
|
||||||
|
assert_eq!(moved.parent_ticket_id.as_deref(), Some(b.as_str()));
|
||||||
|
surface
|
||||||
|
.invoke(&child_key, &first, Some(&b_key), "reparent")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
backend
|
||||||
|
.get_ticket_parent(child.clone().into())
|
||||||
|
.unwrap()
|
||||||
|
.observation,
|
||||||
|
moved
|
||||||
|
);
|
||||||
|
surface.children(&b_key).await.unwrap();
|
||||||
|
assert!(
|
||||||
|
surface
|
||||||
|
.invoke(&child_key, &first, None, "stale")
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
surface
|
||||||
|
.invoke(&child_key, &moved, None, "unlink")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
backend
|
||||||
|
.get_ticket_parent(child.clone().into())
|
||||||
|
.unwrap()
|
||||||
|
.observation
|
||||||
|
.parent_ticket_id,
|
||||||
|
None
|
||||||
|
);
|
||||||
|
let conn = rusqlite::Connection::open(&f.api.config.database_path).unwrap();
|
||||||
|
let events: i64 = conn.query_row("SELECT COUNT(*) FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2 AND kind='parent_changed'", rusqlite::params![TEST_WORKSPACE_ID,child], |row| row.get(0)).unwrap();
|
||||||
|
assert_eq!(events, 3, "no-op and exact replay add no event");
|
||||||
|
assert!(
|
||||||
|
client
|
||||||
|
.inner
|
||||||
|
.requests
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter(|(method, _)| method == "GET")
|
||||||
|
.all(|(_, path)| path.ends_with("/link-summary")
|
||||||
|
|| path.contains("/children?limit=1")),
|
||||||
|
"identity lookup must never fetch Ticket bodies or ancestors"
|
||||||
|
);
|
||||||
|
for path in [
|
||||||
|
format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{foreign}/link-summary"),
|
||||||
|
format!("/api/w/another-workspace/tickets/{a}/link-summary"),
|
||||||
|
] {
|
||||||
|
let (status, body) = response(
|
||||||
|
&f.api,
|
||||||
|
runtime_source_request(
|
||||||
|
&f.identity,
|
||||||
|
Some(&f.source.worker_id),
|
||||||
|
"GET",
|
||||||
|
&path,
|
||||||
|
vec![],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(!status.is_success(), "foreign/resource denial: {body}");
|
||||||
|
assert!(body.get("title").is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,7 @@ fn grant_test_assignment_ticket(api: &WorkspaceApi, subject_id: &str, ticket_id:
|
|||||||
ticket_id,
|
ticket_id,
|
||||||
&[
|
&[
|
||||||
"ticket.read",
|
"ticket.read",
|
||||||
|
"ticket.relation.read",
|
||||||
"ticket.assignment.query",
|
"ticket.assignment.query",
|
||||||
"ticket.assignment.history",
|
"ticket.assignment.history",
|
||||||
"ticket.implementation_result.write",
|
"ticket.implementation_result.write",
|
||||||
|
|||||||
@@ -216,16 +216,20 @@ async fn signed_parent_routes_reject_missing_wire_and_scoped_cursor_without_part
|
|||||||
json!({"expected":{},"parent":null,"operation_key":"bad"}),
|
json!({"expected":{},"parent":null,"operation_key":"bad"}),
|
||||||
json!({"expected":{"parent_ticket_id":null,"last_operation_key":null},"operation_key":"bad"}),
|
json!({"expected":{"parent_ticket_id":null,"last_operation_key":null},"operation_key":"bad"}),
|
||||||
] {
|
] {
|
||||||
runtime_tag_json(
|
let response = app
|
||||||
app.clone(),
|
.clone()
|
||||||
&identity,
|
.oneshot(runtime_source_request(
|
||||||
"linker",
|
&identity,
|
||||||
"PUT",
|
Some("linker"),
|
||||||
&path,
|
"PUT",
|
||||||
body,
|
&path,
|
||||||
StatusCode::BAD_REQUEST,
|
serde_json::to_vec(&body).unwrap(),
|
||||||
)
|
))
|
||||||
.await;
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
let rejection = to_bytes(response.into_body(), 16_384).await.unwrap();
|
||||||
|
assert!(String::from_utf8_lossy(&rejection).contains("missing field"));
|
||||||
}
|
}
|
||||||
let page_path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{parent}/children?limit=1");
|
let page_path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{parent}/children?limit=1");
|
||||||
let page = runtime_tag_json(
|
let page = runtime_tag_json(
|
||||||
|
|||||||
@@ -473,7 +473,7 @@ async fn reviewer_reads_saved_related_tickets_by_key_and_id_and_pages_their_thre
|
|||||||
&api,
|
&api,
|
||||||
&ensure_test_assignment_subject(&api),
|
&ensure_test_assignment_subject(&api),
|
||||||
&related.id,
|
&related.id,
|
||||||
&["ticket.read"],
|
&["ticket.read", "ticket.relation.read"],
|
||||||
);
|
);
|
||||||
for (reference, expected_key, expected_title, expected_body) in [
|
for (reference, expected_key, expected_title, expected_body) in [
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -19837,6 +19837,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
WorkspaceSchemaMigrationStep {version:110, name:"Worker Plugin launch selection associations".into()},
|
WorkspaceSchemaMigrationStep {version:110, name:"Worker Plugin launch selection associations".into()},
|
||||||
WorkspaceSchemaMigrationStep {version:111, name:"Typed Plugin Ticket extension data and atomic mutation receipts".into()},
|
WorkspaceSchemaMigrationStep {version:111, name:"Typed Plugin Ticket extension data and atomic mutation receipts".into()},
|
||||||
WorkspaceSchemaMigrationStep {version:112, name:"Ticket Service outbox and fenced consumers".into()},
|
WorkspaceSchemaMigrationStep {version:112, name:"Ticket Service outbox and fenced consumers".into()},
|
||||||
|
WorkspaceSchemaMigrationStep {version:113, name:"Ticket work-decomposition parent links".into()},
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -19959,6 +19960,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
(110, "Worker Plugin launch selection associations".to_string()),
|
(110, "Worker Plugin launch selection associations".to_string()),
|
||||||
(111, "Typed Plugin Ticket extension data and atomic mutation receipts".to_string()),
|
(111, "Typed Plugin Ticket extension data and atomic mutation receipts".to_string()),
|
||||||
(112, "Ticket Service outbox and fenced consumers".to_string()),
|
(112, "Ticket Service outbox and fenced consumers".to_string()),
|
||||||
|
(113, "Ticket work-decomposition parent links".to_string()),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
||||||
@@ -23252,7 +23254,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
(109, "Durable Plugin Host invocations and HTTPS effect receipts".into()),
|
(109, "Durable Plugin Host invocations and HTTPS effect receipts".into()),
|
||||||
(110, "Worker Plugin launch selection associations".into()),
|
(110, "Worker Plugin launch selection associations".into()),
|
||||||
(111, "Typed Plugin Ticket extension data and atomic mutation receipts".into()),
|
(111, "Typed Plugin Ticket extension data and atomic mutation receipts".into()),
|
||||||
(112, "Ticket Service outbox and fenced consumers".into())]);
|
(112, "Ticket Service outbox and fenced consumers".into()),
|
||||||
|
(113, "Ticket work-decomposition parent links".into())]);
|
||||||
for (table, rows) in unchanged.iter().zip(before.iter()) {
|
for (table, rows) in unchanged.iter().zip(before.iter()) {
|
||||||
assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}");
|
assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1072,11 +1072,30 @@ mod subject_assignment_migration_tests {
|
|||||||
assert_eq!(history.len(), host.len() + expected.len());
|
assert_eq!(history.len(), host.len() + expected.len());
|
||||||
let history = component_history(conn);
|
let history = component_history(conn);
|
||||||
assert_eq!(&history[..component.len()], component, "owner receipt prefix is preserved");
|
assert_eq!(&history[..component.len()], component, "owner receipt prefix is preserved");
|
||||||
assert_eq!(history.len(), component.len() + 2);
|
assert_eq!(history.len(), component.len() + 3);
|
||||||
let tail: Vec<(i64, String, String)> = conn.prepare("SELECT version,name,applied_at FROM ticket_schema_migrations WHERE version>=14 ORDER BY version").unwrap()
|
let tail: Vec<(i64, String, String)> = conn.prepare("SELECT version,name,applied_at FROM ticket_schema_migrations WHERE version>=14 ORDER BY version").unwrap()
|
||||||
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))).unwrap().collect::<rusqlite::Result<_>>().unwrap();
|
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))).unwrap().collect::<rusqlite::Result<_>>().unwrap();
|
||||||
assert_eq!(tail.iter().map(|(v,name,_)| (*v,name.as_str())).collect::<Vec<_>>(), [(14,"Subject Ticket responsibility"), (15,"Ticket Service transactional outbox")]);
|
assert_eq!(
|
||||||
|
tail.iter()
|
||||||
|
.map(|(v, name, _)| (*v, name.as_str()))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
[
|
||||||
|
(14, "Subject Ticket responsibility"),
|
||||||
|
(15, "Ticket Service transactional outbox"),
|
||||||
|
(16, "Ticket work-decomposition parent links"),
|
||||||
|
]
|
||||||
|
);
|
||||||
assert!(tail.iter().all(|(_,_,at)| !at.is_empty()));
|
assert!(tail.iter().all(|(_,_,at)| !at.is_empty()));
|
||||||
|
for table in ["typed_ticket_parent_links", "typed_ticket_parent_operations"] {
|
||||||
|
assert_eq!(
|
||||||
|
conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| {
|
||||||
|
r.get::<_, i64>(0)
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
0,
|
||||||
|
"migration must not create parent links or parent-operation receipts in {table}"
|
||||||
|
);
|
||||||
|
}
|
||||||
ticket::verify_sqlite_ticket_schema(conn).unwrap();
|
ticket::verify_sqlite_ticket_schema(conn).unwrap();
|
||||||
crate::plugin_catalog::verify_with_data(conn).unwrap();
|
crate::plugin_catalog::verify_with_data(conn).unwrap();
|
||||||
plugin_plan::verify_plan_schema(conn).unwrap();
|
plugin_plan::verify_plan_schema(conn).unwrap();
|
||||||
@@ -1142,6 +1161,7 @@ mod subject_assignment_migration_tests {
|
|||||||
"Typed Plugin Ticket extension data and atomic mutation receipts".into(),
|
"Typed Plugin Ticket extension data and atomic mutation receipts".into(),
|
||||||
));
|
));
|
||||||
tail.push((112, "Ticket Service outbox and fenced consumers".into()));
|
tail.push((112, "Ticket Service outbox and fenced consumers".into()));
|
||||||
|
tail.push((113, "Ticket work-decomposition parent links".into()));
|
||||||
tail
|
tail
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ fn page_sql(
|
|||||||
};
|
};
|
||||||
if let Some(parent) = &query.parent {
|
if let Some(parent) = &query.parent {
|
||||||
match parent {
|
match parent {
|
||||||
ticket::TicketParentFilter::None => predicates.push("NOT EXISTS (SELECT 1 FROM typed_ticket_parent_links p WHERE p.workspace_id=t.workspace_id AND p.ticket_id=t.ticket_id AND p.parent_ticket_id IS NOT NULL)".into()),
|
ticket::TicketParentFilter::None {} => predicates.push("NOT EXISTS (SELECT 1 FROM typed_ticket_parent_links p WHERE p.workspace_id=t.workspace_id AND p.ticket_id=t.ticket_id AND p.parent_ticket_id IS NOT NULL)".into()),
|
||||||
ticket::TicketParentFilter::Parent { ticket } => {
|
ticket::TicketParentFilter::Parent { ticket } => {
|
||||||
let parent = bind(ticket.clone());
|
let parent = bind(ticket.clone());
|
||||||
predicates.push(format!("EXISTS (SELECT 1 FROM typed_ticket_parent_links p WHERE p.workspace_id=t.workspace_id AND p.ticket_id=t.ticket_id AND p.parent_ticket_id={parent})"));
|
predicates.push(format!("EXISTS (SELECT 1 FROM typed_ticket_parent_links p WHERE p.workspace_id=t.workspace_id AND p.ticket_id=t.ticket_id AND p.parent_ticket_id={parent})"));
|
||||||
|
|||||||
@@ -700,7 +700,7 @@ async fn parent_filters_match_browse_and_authoritative_queries_and_bind_cursor_s
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let mut roots = ids(&f.run(TicketBrowseQueryRequest {
|
let mut roots = ids(&f.run(TicketBrowseQueryRequest {
|
||||||
parent: Some(ticket::TicketParentFilter::None),
|
parent: Some(ticket::TicketParentFilter::None {}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}));
|
}));
|
||||||
roots.sort();
|
roots.sort();
|
||||||
|
|||||||
@@ -83,13 +83,14 @@ fn ticket_posts_schema93_preserves_initial_body_creator_comments_and_archived_au
|
|||||||
assert_eq!(stored_fixture_rows(conn,"typed_ticket_event_references"),old_references);
|
assert_eq!(stored_fixture_rows(conn,"typed_ticket_event_references"),old_references);
|
||||||
let markers = stored_fixture_rows(conn,"ticket_schema_migrations");
|
let markers = stored_fixture_rows(conn,"ticket_schema_migrations");
|
||||||
assert_eq!(&markers[..old_markers.len()],old_markers.as_slice());
|
assert_eq!(&markers[..old_markers.len()],old_markers.as_slice());
|
||||||
assert_eq!(markers.len(),old_markers.len()+4);
|
assert_eq!(markers.len(),old_markers.len()+5);
|
||||||
let new_markers: Vec<(i64,String)> = conn.prepare("SELECT version,name FROM ticket_schema_migrations WHERE version>11 ORDER BY version")?.query_map([],|r|Ok((r.get(0)?,r.get(1)?)))?.collect::<rusqlite::Result<_>>()?;
|
let new_markers: Vec<(i64,String)> = conn.prepare("SELECT version,name FROM ticket_schema_migrations WHERE version>11 ORDER BY version")?.query_map([],|r|Ok((r.get(0)?,r.get(1)?)))?.collect::<rusqlite::Result<_>>()?;
|
||||||
assert_eq!(new_markers,vec![
|
assert_eq!(new_markers,vec![
|
||||||
(12,"Canonical Ticket posts".into()),
|
(12,"Canonical Ticket posts".into()),
|
||||||
(13,"Ticket content change facts".into()),
|
(13,"Ticket content change facts".into()),
|
||||||
(14,"Subject Ticket responsibility".into()),
|
(14,"Subject Ticket responsibility".into()),
|
||||||
(15,"Ticket Service transactional outbox".into()),
|
(15,"Ticket Service transactional outbox".into()),
|
||||||
|
(16,"Ticket work-decomposition parent links".into()),
|
||||||
]);
|
]);
|
||||||
assert_eq!(conn.query_row("SELECT COUNT(*) FROM typed_ticket_service_outbox",[],|r|r.get::<_,i64>(0))?,0,"migration does not invent Service delivery facts");
|
assert_eq!(conn.query_row("SELECT COUNT(*) FROM typed_ticket_service_outbox",[],|r|r.get::<_,i64>(0))?,0,"migration does not invent Service delivery facts");
|
||||||
assert_eq!(conn.query_row("SELECT COUNT(*) FROM typed_ticket_content_changes",[],|r|r.get::<_,i64>(0))?,0,"migration does not invent content change facts");
|
assert_eq!(conn.query_row("SELECT COUNT(*) FROM typed_ticket_content_changes",[],|r|r.get::<_,i64>(0))?,0,"migration does not invent content change facts");
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ fn assert_retained_classification_upgrade(workspace_version: i64, component: i64
|
|||||||
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||||
store.with_conn(|conn| {
|
store.with_conn(|conn| {
|
||||||
assert_eq!(current_schema_version(conn)?,LATEST_SCHEMA_VERSION);
|
assert_eq!(current_schema_version(conn)?,LATEST_SCHEMA_VERSION);
|
||||||
assert_eq!(conn.query_row("SELECT MAX(version) FROM ticket_schema_migrations",[],|row|row.get::<_,i64>(0))?,15);
|
assert_eq!(conn.query_row("SELECT MAX(version) FROM ticket_schema_migrations",[],|row|row.get::<_,i64>(0))?,16);
|
||||||
assert_eq!(fixture_auth_rows(conn),auth_before);
|
assert_eq!(fixture_auth_rows(conn),auth_before);
|
||||||
for (table, rows) in preserved_tables.iter().zip(&preserved) {
|
for (table, rows) in preserved_tables.iter().zip(&preserved) {
|
||||||
let mut migrated = stored_fixture_rows(conn, table);
|
let mut migrated = stored_fixture_rows(conn, table);
|
||||||
@@ -500,7 +500,7 @@ fn workspace90_cases9_add_classification_without_reinterpreting_reasoned_history
|
|||||||
[],
|
[],
|
||||||
|row| row.get::<_, i64>(0)
|
|row| row.get::<_, i64>(0)
|
||||||
)?,
|
)?,
|
||||||
15
|
16
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
stored_fixture_rows(conn, "typed_ticket_case_history"),
|
stored_fixture_rows(conn, "typed_ticket_case_history"),
|
||||||
|
|||||||
+661
-1
@@ -15754,6 +15754,10 @@
|
|||||||
"can_queue": {
|
"can_queue": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"can_set_parent": {
|
||||||
|
"description": "Request-bound browser owner eligibility; never a grant for a later mutation.",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"can_start_manual_worker": {
|
"can_start_manual_worker": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -15768,6 +15772,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
"can_set_parent",
|
||||||
"can_assign_orchestrator",
|
"can_assign_orchestrator",
|
||||||
"can_unassign_orchestrator",
|
"can_unassign_orchestrator",
|
||||||
"can_queue",
|
"can_queue",
|
||||||
@@ -16647,6 +16652,16 @@
|
|||||||
"null"
|
"null"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"parent": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/TicketParentFilter"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"query": {
|
"query": {
|
||||||
"type": [
|
"type": [
|
||||||
"string",
|
"string",
|
||||||
@@ -16836,6 +16851,57 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"TicketChildrenPage": {
|
||||||
|
"properties": {
|
||||||
|
"has_more": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/TicketLinkSummary"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"next_cursor": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"parent_ticket_id": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"parent_ticket_id",
|
||||||
|
"items",
|
||||||
|
"next_cursor",
|
||||||
|
"has_more"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"TicketChildrenRequest": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "Live keyset page: ascending immutable child ID, strictly after its cursor.\nConcurrent inserts/reparents may change later pages; this is not a tree snapshot.\nThe cursor is bound to the Workspace and parent and rejected in another scope.",
|
||||||
|
"properties": {
|
||||||
|
"cursor": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"format": "uint",
|
||||||
|
"maximum": 100,
|
||||||
|
"minimum": 1,
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"TicketClosureJudgment": {
|
"TicketClosureJudgment": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"at": {
|
"at": {
|
||||||
@@ -17082,6 +17148,9 @@
|
|||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
|
"parent_link": {
|
||||||
|
"$ref": "#/components/schemas/TicketParentLink"
|
||||||
|
},
|
||||||
"priority": {
|
"priority": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -17150,6 +17219,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
"parent_link",
|
||||||
"initial_post",
|
"initial_post",
|
||||||
"id",
|
"id",
|
||||||
"tags",
|
"tags",
|
||||||
@@ -17643,6 +17713,7 @@
|
|||||||
"implementation_report",
|
"implementation_report",
|
||||||
"state_changed",
|
"state_changed",
|
||||||
"tags_changed",
|
"tags_changed",
|
||||||
|
"parent_changed",
|
||||||
"intake_summary",
|
"intake_summary",
|
||||||
"status_changed",
|
"status_changed",
|
||||||
"close"
|
"close"
|
||||||
@@ -17969,6 +18040,41 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"TicketLinkSummary": {
|
||||||
|
"description": "A bounded navigation projection, never a progress/fulfillment aggregate.",
|
||||||
|
"properties": {
|
||||||
|
"closure_reason": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/TicketClosureReason"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"resource_key": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"$ref": "#/components/schemas/TicketState"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"resource_key",
|
||||||
|
"title",
|
||||||
|
"state",
|
||||||
|
"closure_reason"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"TicketListHttpQuery": {
|
"TicketListHttpQuery": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -18377,6 +18483,113 @@
|
|||||||
},
|
},
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"TicketParentFilter": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"const": "none",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"kind"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"const": "parent",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"ticket": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"kind",
|
||||||
|
"ticket"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"TicketParentLink": {
|
||||||
|
"properties": {
|
||||||
|
"observation": {
|
||||||
|
"$ref": "#/components/schemas/TicketParentObservation"
|
||||||
|
},
|
||||||
|
"parent": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/TicketLinkSummary"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ticket_id": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"ticket_id",
|
||||||
|
"observation",
|
||||||
|
"parent"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"TicketParentObservation": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "CAS observation. Both nullable fields must be explicitly observed on the wire.\nA never-mutated Ticket has null/null; unlink preserves a non-null operation key.",
|
||||||
|
"properties": {
|
||||||
|
"last_operation_key": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"parent_ticket_id": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"parent_ticket_id",
|
||||||
|
"last_operation_key"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"TicketParentSetRequest": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"expected": {
|
||||||
|
"$ref": "#/components/schemas/TicketParentObservation"
|
||||||
|
},
|
||||||
|
"operation_key": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"parent": {
|
||||||
|
"description": "Null unlinks; references are resolved only inside the scoped write transaction.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"expected",
|
||||||
|
"parent",
|
||||||
|
"operation_key"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"TicketPostEdit": {
|
"TicketPostEdit": {
|
||||||
"description": "A compare-and-swap edit of a real comment post. The host supplies authenticated author.",
|
"description": "A compare-and-swap edit of a real comment post. The host supplies authenticated author.",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -18687,6 +18900,17 @@
|
|||||||
"null"
|
"null"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"parent": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/TicketParentFilter"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Work-decomposition filter; absent means all Tickets, none means no direct parent."
|
||||||
|
},
|
||||||
"query": {
|
"query": {
|
||||||
"type": [
|
"type": [
|
||||||
"string",
|
"string",
|
||||||
@@ -22725,7 +22949,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Yoi Server API",
|
"title": "Yoi Server API",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"x-yoi-source-digest": "sha256:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c"
|
"x-yoi-source-digest": "sha256:728339d743ff6c83b9096f9a9454d34314f89a61d157b4391f8114fe5d104dcb"
|
||||||
},
|
},
|
||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"paths": {
|
"paths": {
|
||||||
@@ -44204,6 +44428,123 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/w/{workspace_id}/tickets/{id}/children": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "ticket_children_query",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"explode": false,
|
||||||
|
"in": "path",
|
||||||
|
"name": "workspace_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/string"
|
||||||
|
},
|
||||||
|
"style": "simple"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"explode": false,
|
||||||
|
"in": "path",
|
||||||
|
"name": "id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/string"
|
||||||
|
},
|
||||||
|
"style": "simple"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"explode": true,
|
||||||
|
"in": "query",
|
||||||
|
"name": "request",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/TicketChildrenRequest"
|
||||||
|
},
|
||||||
|
"style": "form"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/TicketChildrenPage"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Successful response"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"browserSession": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/w/{workspace_id}/tickets/{id}/dependency-check": {
|
"/api/w/{workspace_id}/tickets/{id}/dependency-check": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "ticket_dependency_check",
|
"operationId": "ticket_dependency_check",
|
||||||
@@ -44762,6 +45103,103 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/w/{workspace_id}/tickets/{id}/link-summary": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "ticket_link_summary_get",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"explode": false,
|
||||||
|
"in": "path",
|
||||||
|
"name": "workspace_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/string"
|
||||||
|
},
|
||||||
|
"style": "simple"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"explode": false,
|
||||||
|
"in": "path",
|
||||||
|
"name": "id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/string"
|
||||||
|
},
|
||||||
|
"style": "simple"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/TicketLinkSummary"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Successful response"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"browserSession": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/w/{workspace_id}/tickets/{id}/orchestration-plans": {
|
"/api/w/{workspace_id}/tickets/{id}/orchestration-plans": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "ticket_orchestration_plan_record",
|
"operationId": "ticket_orchestration_plan_record",
|
||||||
@@ -44879,6 +45317,228 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/w/{workspace_id}/tickets/{id}/parent": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "ticket_parent_get",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"explode": false,
|
||||||
|
"in": "path",
|
||||||
|
"name": "workspace_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/string"
|
||||||
|
},
|
||||||
|
"style": "simple"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"explode": false,
|
||||||
|
"in": "path",
|
||||||
|
"name": "id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/string"
|
||||||
|
},
|
||||||
|
"style": "simple"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/TicketParentLink"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Successful response"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"browserSession": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
|
"operationId": "ticket_parent_set",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"explode": false,
|
||||||
|
"in": "path",
|
||||||
|
"name": "workspace_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/string"
|
||||||
|
},
|
||||||
|
"style": "simple"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"explode": false,
|
||||||
|
"in": "path",
|
||||||
|
"name": "id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/string"
|
||||||
|
},
|
||||||
|
"style": "simple"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/TicketParentSetRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/TicketParentLink"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Successful response"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"browserSession": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/w/{workspace_id}/tickets/{id}/posts": {
|
"/api/w/{workspace_id}/tickets/{id}/posts": {
|
||||||
"patch": {
|
"patch": {
|
||||||
"operationId": "ticket_record_post_edit",
|
"operationId": "ticket_record_post_edit",
|
||||||
|
|||||||
@@ -371,6 +371,7 @@ function projectedTicket(key: string, workspaceId: string) {
|
|||||||
value.events = value.events.map((post) => ({ ...post, editable: false }));
|
value.events = value.events.map((post) => ({ ...post, editable: false }));
|
||||||
value.action_eligibility = {
|
value.action_eligibility = {
|
||||||
blockers: ["Read-only fixture"],
|
blockers: ["Read-only fixture"],
|
||||||
|
can_set_parent: false,
|
||||||
can_assign_orchestrator: false,
|
can_assign_orchestrator: false,
|
||||||
can_unassign_orchestrator: false,
|
can_unassign_orchestrator: false,
|
||||||
can_start_manual_worker: false,
|
can_start_manual_worker: false,
|
||||||
@@ -427,6 +428,12 @@ function workerSummary(workerId: string, state: string) {
|
|||||||
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
if (url.pathname === "/health") return new Response("ok");
|
if (url.pathname === "/health") return new Response("ok");
|
||||||
|
if (url.pathname === "/api/auth/whoami") return Response.json({
|
||||||
|
authentication_reference: "fixture-authentication",
|
||||||
|
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||||
|
actor: { user_id: "fixture-user", account_id: "fixture-owner", handle: "alice", display_name: "Alice", auth_method: "browser_session" },
|
||||||
|
});
|
||||||
|
if (url.pathname === "/api/hosting/access") return Response.json({ is_admin: false });
|
||||||
if (url.pathname === "/fixture-state") return Response.json(bodies);
|
if (url.pathname === "/fixture-state") return Response.json(bodies);
|
||||||
if (url.pathname === "/fixture-snapshot") {
|
if (url.pathname === "/fixture-snapshot") {
|
||||||
return Response.json({
|
return Response.json({
|
||||||
@@ -482,6 +489,7 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
|||||||
manage_secrets: false,
|
manage_secrets: false,
|
||||||
manage_runtimes: false,
|
manage_runtimes: false,
|
||||||
delete_workspace: false,
|
delete_workspace: false,
|
||||||
|
delete_subjects: false,
|
||||||
},
|
},
|
||||||
extension_points: {
|
extension_points: {
|
||||||
store: "fixture",
|
store: "fixture",
|
||||||
|
|||||||
@@ -0,0 +1,452 @@
|
|||||||
|
import { assert, assertEquals } from "jsr:@std/assert@1.0.19";
|
||||||
|
import { chromium, type Page } from "npm:playwright@1.59.1";
|
||||||
|
import { loadScenario } from "../src/scenario.ts";
|
||||||
|
import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts";
|
||||||
|
|
||||||
|
const captureDiagnostics = new WeakMap<
|
||||||
|
Page,
|
||||||
|
{
|
||||||
|
consoleErrors: string[];
|
||||||
|
requestFailures: string[];
|
||||||
|
documentStatuses: number[];
|
||||||
|
httpFailures: string[];
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
// Production static shell + synthetic API: browser interaction/transport and
|
||||||
|
// rendering only. Real SQLite/Subject authorization is tested in the server suite.
|
||||||
|
Deno.test("Ticket hierarchy browser: reparent, retained retry, conflict, paging and readonly navigation", async () => {
|
||||||
|
const scenarioPath =
|
||||||
|
new URL("../scenarios/tickets.json", import.meta.url).pathname;
|
||||||
|
const scenario = await loadScenario(scenarioPath);
|
||||||
|
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
|
||||||
|
const port = (listener.addr as Deno.NetAddr).port;
|
||||||
|
listener.close();
|
||||||
|
const base = `http://127.0.0.1:${port}`;
|
||||||
|
const baseline = Deno.env.get("TICKET_PARENT_BASELINE");
|
||||||
|
const artifacts =
|
||||||
|
new URL("../../../target/web-ux/t804/", import.meta.url).pathname;
|
||||||
|
await Deno.mkdir(artifacts, { recursive: true });
|
||||||
|
scenario.processes![0].args![4] = String(port);
|
||||||
|
if (baseline) scenario.processes![0].args![5] = baseline;
|
||||||
|
scenario.processes![0].readyUrl = `${base}/health`;
|
||||||
|
const processes = await startOwnedProcesses(
|
||||||
|
scenario.processes!,
|
||||||
|
scenarioPath,
|
||||||
|
`${artifacts}/logs`,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const evidence: unknown[] = [];
|
||||||
|
try {
|
||||||
|
for (
|
||||||
|
const [width, theme] of [[1440, "light"], [768, "light"], [
|
||||||
|
390,
|
||||||
|
"dark",
|
||||||
|
]] as const
|
||||||
|
) {
|
||||||
|
const page = await browser.newPage({
|
||||||
|
viewport: { width, height: 900 },
|
||||||
|
colorScheme: theme,
|
||||||
|
});
|
||||||
|
const errors: string[] = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
|
const diagnostics = {
|
||||||
|
consoleErrors: [] as string[],
|
||||||
|
requestFailures: [] as string[],
|
||||||
|
documentStatuses: [] as number[],
|
||||||
|
httpFailures: [] as string[],
|
||||||
|
};
|
||||||
|
captureDiagnostics.set(page, diagnostics);
|
||||||
|
page.on("console", (message) => {
|
||||||
|
if (message.type() === "error") {
|
||||||
|
diagnostics.consoleErrors.push(message.text());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
page.on(
|
||||||
|
"requestfailed",
|
||||||
|
(request) =>
|
||||||
|
diagnostics.requestFailures.push(
|
||||||
|
`${
|
||||||
|
new URL(request.url()).pathname
|
||||||
|
}: ${request.failure()?.errorText}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
page.on("response", (response) => {
|
||||||
|
if (response.status() >= 400) {
|
||||||
|
diagnostics.httpFailures.push(
|
||||||
|
`${new URL(response.url()).pathname}: ${response.status()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.request().resourceType() === "document") {
|
||||||
|
diagnostics.documentStatuses.push(response.status());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const wire = await (await fetch(`${base}/api/w/tickets/tickets/T-1`))
|
||||||
|
.json();
|
||||||
|
wire.action_eligibility.can_set_parent = true;
|
||||||
|
const parent = {
|
||||||
|
id: "internal-T-2",
|
||||||
|
resource_key: "T-2",
|
||||||
|
title: "Design the data migration and recovery boundary",
|
||||||
|
state: "open",
|
||||||
|
closure_reason: null,
|
||||||
|
};
|
||||||
|
const other = {
|
||||||
|
...parent,
|
||||||
|
id: "internal-T-3",
|
||||||
|
resource_key: "T-3",
|
||||||
|
title: "Independent delivery follow-up",
|
||||||
|
};
|
||||||
|
let link = {
|
||||||
|
ticket_id: wire.id,
|
||||||
|
observation: {
|
||||||
|
parent_ticket_id: parent.id as string | null,
|
||||||
|
last_operation_key: "initial" as string | null,
|
||||||
|
},
|
||||||
|
parent: parent as typeof parent | null,
|
||||||
|
};
|
||||||
|
const writes: string[] = [];
|
||||||
|
const receipts = new Map<string, typeof link>();
|
||||||
|
let lose = true;
|
||||||
|
let conflict = false;
|
||||||
|
let readonly = false;
|
||||||
|
await page.route("**/api/w/tickets/tickets/**", async (route) => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
const addressed = decodeURIComponent(url.pathname.split("/").at(-2)!);
|
||||||
|
const atOther = addressed === other.id ||
|
||||||
|
addressed === other.resource_key;
|
||||||
|
if (atOther && url.pathname.endsWith("/parent")) {
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
ticket_id: other.id,
|
||||||
|
observation: { parent_ticket_id: null, last_operation_key: null },
|
||||||
|
parent: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (atOther && url.pathname.endsWith("/children")) {
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
parent_ticket_id: other.id,
|
||||||
|
items: [{
|
||||||
|
id: wire.id,
|
||||||
|
resource_key: wire.resource_key,
|
||||||
|
title: wire.title,
|
||||||
|
state: wire.state,
|
||||||
|
closure_reason: null,
|
||||||
|
}],
|
||||||
|
has_more: false,
|
||||||
|
next_cursor: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.pathname.endsWith("/link-summary")) {
|
||||||
|
return route.fulfill({ json: other });
|
||||||
|
}
|
||||||
|
if (url.pathname.endsWith("/children")) {
|
||||||
|
const next = url.searchParams.has("cursor");
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
parent_ticket_id: wire.id,
|
||||||
|
items: [{
|
||||||
|
...parent,
|
||||||
|
id: next ? "child-2" : "child-1",
|
||||||
|
resource_key: next ? "T-12" : "T-11",
|
||||||
|
title: next
|
||||||
|
? "Follow-up deliberately not planned"
|
||||||
|
: "A child closed as duplicate, not success",
|
||||||
|
state: "closed",
|
||||||
|
closure_reason: next ? "not_planned" : "duplicate",
|
||||||
|
}],
|
||||||
|
has_more: !next,
|
||||||
|
next_cursor: next ? null : "opaque-next",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.pathname.endsWith("/parent")) {
|
||||||
|
if (route.request().method() !== "PUT") {
|
||||||
|
return route.fulfill({ json: link });
|
||||||
|
}
|
||||||
|
const body = route.request().postData()!;
|
||||||
|
writes.push(body);
|
||||||
|
const input = JSON.parse(body);
|
||||||
|
if (conflict) {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 409,
|
||||||
|
json: { message: "stale parent observation" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!receipts.has(input.operation_key)) {
|
||||||
|
assertEquals(input.expected, link.observation);
|
||||||
|
link = {
|
||||||
|
ticket_id: wire.id,
|
||||||
|
observation: {
|
||||||
|
parent_ticket_id: input.parent,
|
||||||
|
last_operation_key: input.operation_key,
|
||||||
|
},
|
||||||
|
parent: input.parent === null ? null : other,
|
||||||
|
};
|
||||||
|
receipts.set(input.operation_key, structuredClone(link));
|
||||||
|
}
|
||||||
|
if (lose) {
|
||||||
|
lose = false;
|
||||||
|
return route.abort("failed");
|
||||||
|
}
|
||||||
|
return route.fulfill({ json: receipts.get(input.operation_key) });
|
||||||
|
}
|
||||||
|
if (/\/tickets\/T-1(?:-|$)/.test(url.pathname)) {
|
||||||
|
const response = {
|
||||||
|
...wire,
|
||||||
|
parent_link: link,
|
||||||
|
action_eligibility: {
|
||||||
|
...wire.action_eligibility,
|
||||||
|
can_set_parent: !readonly,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (baseline) {
|
||||||
|
Reflect.deleteProperty(response, "parent_link");
|
||||||
|
Reflect.deleteProperty(
|
||||||
|
response.action_eligibility,
|
||||||
|
"can_set_parent",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return route.fulfill({ json: response });
|
||||||
|
}
|
||||||
|
return route.continue();
|
||||||
|
});
|
||||||
|
await page.route("**/api/auth/whoami", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: {
|
||||||
|
authentication_reference: "fixture-authentication",
|
||||||
|
session: {
|
||||||
|
kind: "authenticated",
|
||||||
|
expires_at: "2099-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
actor: {
|
||||||
|
user_id: "fixture-user",
|
||||||
|
account_id: "fixture-owner",
|
||||||
|
handle: "alice",
|
||||||
|
display_name: "Alice",
|
||||||
|
auth_method: "browser_session",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
await page.route(
|
||||||
|
"**/api/hosting/access",
|
||||||
|
(route) => route.fulfill({ json: { is_admin: false } }),
|
||||||
|
);
|
||||||
|
await page.goto(`${base}/w/tickets/tickets/T-1`);
|
||||||
|
await page.locator(".ticket-timeline").waitFor().catch(async (error) => {
|
||||||
|
console.error(await page.locator("body").innerText(), errors);
|
||||||
|
await page.screenshot({
|
||||||
|
path: `${artifacts}/failure.png`,
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
if (baseline) {
|
||||||
|
await capture(
|
||||||
|
page,
|
||||||
|
`${artifacts}/before-${width}-${theme}.png`,
|
||||||
|
evidence,
|
||||||
|
errors,
|
||||||
|
);
|
||||||
|
await page.close();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const hierarchy = page.getByRole("region", {
|
||||||
|
name: "Parent and children",
|
||||||
|
});
|
||||||
|
await hierarchy.getByRole("link", { name: /T-2 Design/ }).waitFor();
|
||||||
|
assert((await hierarchy.innerText()).includes("closed · duplicate"));
|
||||||
|
await hierarchy.scrollIntoViewIfNeeded();
|
||||||
|
await capture(
|
||||||
|
page,
|
||||||
|
`${artifacts}/after-${width}-${theme}.png`,
|
||||||
|
evidence,
|
||||||
|
errors,
|
||||||
|
);
|
||||||
|
await hierarchy.getByText("Change or unlink parent", { exact: true })
|
||||||
|
.click();
|
||||||
|
await hierarchy.getByLabel("Ticket reference", { exact: true }).fill(
|
||||||
|
"T-3",
|
||||||
|
);
|
||||||
|
await hierarchy.getByRole("button", {
|
||||||
|
name: "Find Ticket by reference",
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.click();
|
||||||
|
await hierarchy.getByRole("radio", { name: /T-3/ }).check();
|
||||||
|
await hierarchy.getByRole("button", {
|
||||||
|
name: "Set selected parent",
|
||||||
|
exact: true,
|
||||||
|
}).click();
|
||||||
|
const retry = hierarchy.getByRole("button", {
|
||||||
|
name: "Retry exact parent operation",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await retry.waitFor();
|
||||||
|
await hierarchy.getByRole("alert").filter({
|
||||||
|
hasText: /Outcome unavailable or unknown/,
|
||||||
|
})
|
||||||
|
.waitFor();
|
||||||
|
assert(
|
||||||
|
await hierarchy.getByRole("button", {
|
||||||
|
name: "Set selected parent",
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.isDisabled(),
|
||||||
|
);
|
||||||
|
await capture(
|
||||||
|
page,
|
||||||
|
`${artifacts}/unknown-${width}-${theme}.png`,
|
||||||
|
evidence,
|
||||||
|
errors,
|
||||||
|
);
|
||||||
|
await retry.click();
|
||||||
|
await hierarchy.getByText(
|
||||||
|
"Parent operation saved. Current parent is read separately.",
|
||||||
|
{
|
||||||
|
exact: true,
|
||||||
|
},
|
||||||
|
).waitFor();
|
||||||
|
assertEquals(writes[1], writes[0]);
|
||||||
|
await hierarchy.getByRole("link", { name: /T-3 Independent/ }).waitFor();
|
||||||
|
await hierarchy.getByText("Change or unlink parent", { exact: true })
|
||||||
|
.click();
|
||||||
|
await hierarchy.getByRole("button", {
|
||||||
|
name: "Unlink parent",
|
||||||
|
exact: true,
|
||||||
|
}).click();
|
||||||
|
await hierarchy.getByText("No parent.", { exact: true }).waitFor();
|
||||||
|
assertEquals(link.observation.parent_ticket_id, null);
|
||||||
|
await hierarchy.getByText("Set parent", { exact: true }).click();
|
||||||
|
await hierarchy.getByLabel("Ticket reference", { exact: true }).fill(
|
||||||
|
"T-3",
|
||||||
|
);
|
||||||
|
await hierarchy.getByRole("button", {
|
||||||
|
name: "Find Ticket by reference",
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.click();
|
||||||
|
await hierarchy.getByRole("radio", { name: /T-3/ }).check();
|
||||||
|
await hierarchy.getByRole("button", {
|
||||||
|
name: "Set selected parent",
|
||||||
|
exact: true,
|
||||||
|
}).click();
|
||||||
|
await hierarchy.getByRole("link", { name: /T-3 Independent/ }).waitFor();
|
||||||
|
await hierarchy.getByRole("button", {
|
||||||
|
name: "Load more children",
|
||||||
|
exact: true,
|
||||||
|
}).click();
|
||||||
|
await hierarchy.getByRole("link", { name: /T-12/ }).waitFor();
|
||||||
|
conflict = true;
|
||||||
|
await hierarchy.getByText("Change or unlink parent", { exact: true })
|
||||||
|
.click();
|
||||||
|
await hierarchy.getByRole("button", {
|
||||||
|
name: "Unlink parent",
|
||||||
|
exact: true,
|
||||||
|
}).click();
|
||||||
|
await hierarchy.getByRole("alert").filter({
|
||||||
|
hasText: /Parent update conflict/,
|
||||||
|
}).waitFor();
|
||||||
|
assertEquals(link.observation.parent_ticket_id, other.id);
|
||||||
|
await capture(
|
||||||
|
page,
|
||||||
|
`${artifacts}/conflict-${width}-${theme}.png`,
|
||||||
|
evidence,
|
||||||
|
errors,
|
||||||
|
);
|
||||||
|
readonly = true;
|
||||||
|
await page.reload();
|
||||||
|
await hierarchy.getByRole("link", { name: /T-3 Independent/ }).waitFor();
|
||||||
|
assertEquals(
|
||||||
|
await hierarchy.getByRole("button", {
|
||||||
|
name: "Unlink parent",
|
||||||
|
exact: true,
|
||||||
|
}).count(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
await hierarchy.getByRole("button", {
|
||||||
|
name: "Retry exact parent operation",
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.count(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const href = await hierarchy.getByRole("link", {
|
||||||
|
name: /T-3 Independent/,
|
||||||
|
}).getAttribute(
|
||||||
|
"href",
|
||||||
|
);
|
||||||
|
assert(href?.includes("/tickets/T-3"));
|
||||||
|
await capture(
|
||||||
|
page,
|
||||||
|
`${artifacts}/readonly-${width}-${theme}.png`,
|
||||||
|
evidence,
|
||||||
|
errors,
|
||||||
|
);
|
||||||
|
await hierarchy.getByRole("link", { name: /T-3 Independent/ }).click();
|
||||||
|
await page.waitForURL((url) => url.pathname.includes("/tickets/T-3"));
|
||||||
|
await hierarchy.getByText("No parent.", { exact: true }).waitFor();
|
||||||
|
await hierarchy.getByRole("link", { name: new RegExp(wire.resource_key) })
|
||||||
|
.click();
|
||||||
|
await page.waitForURL((url) =>
|
||||||
|
url.pathname.includes(`/tickets/${wire.resource_key}`)
|
||||||
|
);
|
||||||
|
await hierarchy.getByRole("link", { name: /T-3 Independent/ }).waitFor();
|
||||||
|
assertEquals(errors, []);
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await Deno.writeTextFile(
|
||||||
|
`${artifacts}/${baseline ? "before" : "after"}-review-context.json`,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
boundary:
|
||||||
|
"synthetic API, production static shell; not Backend authorization evidence",
|
||||||
|
captures: evidence,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await browser.close();
|
||||||
|
await stopOwnedProcesses(processes);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function capture(
|
||||||
|
page: Page,
|
||||||
|
path: string,
|
||||||
|
evidence: unknown[],
|
||||||
|
errors: string[],
|
||||||
|
) {
|
||||||
|
const heading = page.getByRole("heading", {
|
||||||
|
name: "Parent and children",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
if (await heading.count()) {
|
||||||
|
await heading.evaluate((element) =>
|
||||||
|
element.scrollIntoView({ block: "start", behavior: "instant" })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await page.screenshot({ path, fullPage: true });
|
||||||
|
const hash = await crypto.subtle.digest("SHA-256", await Deno.readFile(path));
|
||||||
|
evidence.push({
|
||||||
|
path,
|
||||||
|
url: page.url(),
|
||||||
|
status: await page.title(),
|
||||||
|
viewport: page.viewportSize(),
|
||||||
|
visibleAlerts: await page.getByRole("alert").allTextContents(),
|
||||||
|
pageErrors: [...errors],
|
||||||
|
...structuredClone(captureDiagnostics.get(page)),
|
||||||
|
aria: await page.locator("body").ariaSnapshot(),
|
||||||
|
sha256: Array.from(
|
||||||
|
new Uint8Array(hash),
|
||||||
|
(b) => b.toString(16).padStart(2, "0"),
|
||||||
|
).join(""),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
"web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts",
|
"web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts",
|
||||||
"check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts",
|
"check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts",
|
||||||
"test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests",
|
"test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests",
|
||||||
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/deferred_navigation_test.ts browser-tests/ticket_shell_test.ts browser-tests/ticket_principal_test.ts browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/journal_browser_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/console_image_visibility_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/workdir_removal_shell_test.ts browser-tests/console_pending_shell_test.ts browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts"
|
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/deferred_navigation_test.ts browser-tests/ticket_shell_test.ts browser-tests/ticket_parent_test.ts browser-tests/ticket_principal_test.ts browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/journal_browser_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/console_image_visibility_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/workdir_removal_shell_test.ts browser-tests/console_pending_shell_test.ts browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts"
|
||||||
},
|
},
|
||||||
"fmt": {
|
"fmt": {
|
||||||
"lineWidth": 100,
|
"lineWidth": 100,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"dev": "deno run -A npm:vite@8.3.2 dev",
|
"dev": "deno run -A npm:vite@8.3.2 dev",
|
||||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||||
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
|
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
|
||||||
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts src/lib/workspace/memory/permissions.test.ts tests/subject-delete.test.ts tests/journal-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts",
|
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-parent.test.ts src/lib/workspace/tickets/ticket-parent-controller.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts src/lib/workspace/memory/permissions.test.ts tests/subject-delete.test.ts tests/journal-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts",
|
||||||
"test:journal:backend-contract": "cd ../.. && mkdir -p target && cargo test -p yoi-workspace-server --lib journal_browser_web_contract -- --nocapture > target/journal-browser-contract.log && cd web/workspace && deno run --allow-read=../../target/journal-browser-contract.log test/journal-backend-contract.ts ../../target/journal-browser-contract.log",
|
"test:journal:backend-contract": "cd ../.. && mkdir -p target && cargo test -p yoi-workspace-server --lib journal_browser_web_contract -- --nocapture > target/journal-browser-contract.log && cd web/workspace && deno run --allow-read=../../target/journal-browser-contract.log test/journal-backend-contract.ts ../../target/journal-browser-contract.log",
|
||||||
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
|
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
|
||||||
"test:browser:activation": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/activation-browser/activation-shell.browser.test.ts",
|
"test:browser:activation": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/activation-browser/activation-shell.browser.test.ts",
|
||||||
|
|||||||
@@ -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:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c)
|
// Input contract: 0.1.0 (sha256:728339d743ff6c83b9096f9a9454d34314f89a61d157b4391f8114fe5d104dcb)
|
||||||
// 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:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c)
|
// Input contract: 0.1.0 (sha256:728339d743ff6c83b9096f9a9454d34314f89a61d157b4391f8114fe5d104dcb)
|
||||||
// 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:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c)
|
// Input contract: 0.1.0 (sha256:728339d743ff6c83b9096f9a9454d34314f89a61d157b4391f8114fe5d104dcb)
|
||||||
// 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
|
||||||
|
|||||||
@@ -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:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c)
|
// Input contract: 0.1.0 (sha256:728339d743ff6c83b9096f9a9454d34314f89a61d157b4391f8114fe5d104dcb)
|
||||||
// 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
|
||||||
@@ -95,7 +95,7 @@ export type ReviewFinding = { body: string; code?: string | null; line?: number
|
|||||||
|
|
||||||
export type SetTicketRoleAssignmentRequest = { expected_assignment_id?: string | null; operation_id: string; orchestrator_assignment_id?: string | null; principal: TicketAssignmentPrincipal; workdir_bindings?: Array<TicketWorkerAttachmentBinding>; };
|
export type SetTicketRoleAssignmentRequest = { expected_assignment_id?: string | null; operation_id: string; orchestrator_assignment_id?: string | null; principal: TicketAssignmentPrincipal; workdir_bindings?: Array<TicketWorkerAttachmentBinding>; };
|
||||||
|
|
||||||
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 TicketActionEligibility = { blockers: Array<string>; can_assign_orchestrator: boolean; can_queue: boolean; can_set_parent: boolean; can_start_manual_worker: boolean; can_unassign_orchestrator: boolean; queue_tickets: Array<string>; };
|
||||||
|
|
||||||
export type TicketAssignmentAnswer = { assignment_id: string; body: string; expected_last_event_id: string; operation_key: string; };
|
export type TicketAssignmentAnswer = { assignment_id: string; body: string; expected_last_event_id: string; operation_key: string; };
|
||||||
|
|
||||||
@@ -139,7 +139,7 @@ export type TicketAttention = "unresolved_review" | "missing_commit" | "blocked"
|
|||||||
|
|
||||||
export type TicketBrowseQueryItem = { assignment_tracking: Array<TicketAssignmentTrackingProjection>; current_closure?: TicketClosureJudgment | null; id: string; resource_key: string; state: BrowserTicketState; tags: Array<WorkspaceTag>; title: string; updated_at?: string | null; };
|
export type TicketBrowseQueryItem = { assignment_tracking: Array<TicketAssignmentTrackingProjection>; current_closure?: TicketClosureJudgment | null; id: string; resource_key: string; state: BrowserTicketState; tags: Array<WorkspaceTag>; title: string; updated_at?: string | null; };
|
||||||
|
|
||||||
export type TicketBrowseQueryRequest = { assigned_principal?: TicketAssignmentPrincipalSnapshot | null; closure_reason?: TicketClosureReason | null; cursor?: string | null; handling_status?: TicketAssignmentTrackingStatus | null; limit?: number & { readonly "__yoiIntegerRange": "0..1000"; } | null; query?: string | null; sort?: string | null; states?: Array<TicketState>; tag_ids?: Array<string>; };
|
export type TicketBrowseQueryRequest = { assigned_principal?: TicketAssignmentPrincipalSnapshot | null; closure_reason?: TicketClosureReason | null; cursor?: string | null; handling_status?: TicketAssignmentTrackingStatus | null; limit?: number & { readonly "__yoiIntegerRange": "0..1000"; } | null; parent?: TicketParentFilter | null; query?: string | null; sort?: string | null; states?: Array<TicketState>; tag_ids?: Array<string>; };
|
||||||
|
|
||||||
export type TicketBrowseQueryResponse = { items: Array<TicketBrowseQueryItem>; page: QueryPage; record_authority: string; };
|
export type TicketBrowseQueryResponse = { items: Array<TicketBrowseQueryItem>; page: QueryPage; record_authority: string; };
|
||||||
|
|
||||||
@@ -149,11 +149,13 @@ export type TicketCaseHistoryEntry = { action: TicketCaseAction; at: string; aut
|
|||||||
|
|
||||||
export type TicketCaseUpdateRequest = { action: TicketCaseAction; expected_closure_operation_key: string | null; expected_content_digest: string; expected_state: TicketState; explanation: string; operation_key: string; references?: Array<TicketReference>; };
|
export type TicketCaseUpdateRequest = { action: TicketCaseAction; expected_closure_operation_key: string | null; expected_content_digest: string; expected_state: TicketState; explanation: string; operation_key: string; references?: Array<TicketReference>; };
|
||||||
|
|
||||||
|
export type TicketChildrenPage = { has_more: boolean; items: Array<TicketLinkSummary>; next_cursor: string | null; parent_ticket_id: string; };
|
||||||
|
|
||||||
export type TicketClosureJudgment = { at: string; author: string; explanation: string; operation_key: string; reason: TicketClosureReason; references: Array<TicketReference>; };
|
export type TicketClosureJudgment = { at: string; author: string; explanation: string; operation_key: string; reason: TicketClosureReason; references: Array<TicketReference>; };
|
||||||
|
|
||||||
export type TicketClosureReason = "completed" | "not_planned" | "duplicate" | "unknown" | { other: string; };
|
export type TicketClosureReason = "completed" | "not_planned" | "duplicate" | "unknown" | { other: string; };
|
||||||
|
|
||||||
export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array<string>; assignment_diagnostics: Array<string>; assignment_tracking: Array<TicketAssignmentTrackingProjection>; assignments: Array<TicketRoleAssignmentSummary>; body: string; body_truncated: boolean; closure_history: Array<TicketCaseHistoryEntry>; closure_operation_key?: string | null; content_digest: string; created_at?: string | null; created_by?: string | null; current_closure?: TicketClosureJudgment | null; current_worker?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array<TicketEventDetail>; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array<TicketEvidenceEvent>; initial_post: TicketEventDetail; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; 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: BrowserTicketState; tags: Array<WorkspaceTag>; 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>; assignment_tracking: Array<TicketAssignmentTrackingProjection>; assignments: Array<TicketRoleAssignmentSummary>; body: string; body_truncated: boolean; closure_history: Array<TicketCaseHistoryEntry>; closure_operation_key?: string | null; content_digest: string; created_at?: string | null; created_by?: string | null; current_closure?: TicketClosureJudgment | null; current_worker?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array<TicketEventDetail>; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array<TicketEvidenceEvent>; initial_post: TicketEventDetail; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; linked_objectives: Array<ObjectiveLinkSummary>; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array<TicketMergeRequestSummary>; parent_link: TicketParentLink; 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: BrowserTicketState; tags: Array<WorkspaceTag>; 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; };
|
||||||
|
|
||||||
@@ -181,6 +183,8 @@ export type TicketIntakeSummary = { author?: string | null; body: MarkdownText;
|
|||||||
|
|
||||||
export type TicketLegacyWorkflowSnapshot = { queued_at?: string | null; queued_by?: string | null; resolution?: string | null; status: string; workflow_state: string; workflow_state_explicit: boolean; };
|
export type TicketLegacyWorkflowSnapshot = { queued_at?: string | null; queued_by?: string | null; resolution?: string | null; status: string; workflow_state: string; workflow_state_explicit: boolean; };
|
||||||
|
|
||||||
|
export type TicketLinkSummary = { closure_reason: TicketClosureReason | null; id: string; resource_key: string; state: TicketState; title: string; };
|
||||||
|
|
||||||
export type TicketListItemSummary = { closure_operation_key?: string | null; current_closure?: TicketClosureJudgment | null; id: string; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; priority: string; queued_at?: string | null; queued_by?: string | null; record_source: string; resource_key: string; state: BrowserTicketState; tags?: Array<WorkspaceTag>; title: string; updated_at?: string | null; workspace_action_priority: string; };
|
export type TicketListItemSummary = { closure_operation_key?: string | null; current_closure?: TicketClosureJudgment | null; id: string; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; priority: string; queued_at?: string | null; queued_by?: string | null; record_source: string; resource_key: string; state: BrowserTicketState; tags?: Array<WorkspaceTag>; title: string; updated_at?: string | null; workspace_action_priority: string; };
|
||||||
|
|
||||||
export type TicketListResponse = { invalid_records: Array<InvalidProjectRecord>; items: Array<TicketListItemSummary>; limit: number & { readonly "__yoiIntegerRange": "0..1000"; }; page: QueryPage; record_authority: string; workspace_id: string; };
|
export type TicketListResponse = { invalid_records: Array<InvalidProjectRecord>; items: Array<TicketListItemSummary>; limit: number & { readonly "__yoiIntegerRange": "0..1000"; }; page: QueryPage; record_authority: string; workspace_id: string; };
|
||||||
@@ -189,11 +193,19 @@ export type TicketMarkReadyRequest = { intake_summary?: TicketIntakeSummary | nu
|
|||||||
|
|
||||||
export type TicketMergeRequestSummary = { current_subject_ref?: string | null; integration_evidence_error?: string | null; merge_request_id: string; repository_key: string; review_excerpt?: string | null; review_requested_at?: string | null; review_status: string; review_subject_ref?: string | null; review_submitted_at?: string | null; selector_from?: string | null; selector_to: string; source_ref_observation: TicketSourceRefObservation; state: string; updated_at: string; };
|
export type TicketMergeRequestSummary = { current_subject_ref?: string | null; integration_evidence_error?: string | null; merge_request_id: string; repository_key: string; review_excerpt?: string | null; review_requested_at?: string | null; review_status: string; review_subject_ref?: string | null; review_submitted_at?: string | null; selector_from?: string | null; selector_to: string; source_ref_observation: TicketSourceRefObservation; state: string; updated_at: string; };
|
||||||
|
|
||||||
|
export type TicketParentFilter = { kind: "none"; } | { kind: "parent"; ticket: string; };
|
||||||
|
|
||||||
|
export type TicketParentLink = { observation: TicketParentObservation; parent: TicketLinkSummary | null; ticket_id: string; };
|
||||||
|
|
||||||
|
export type TicketParentObservation = { last_operation_key: string | null; parent_ticket_id: string | null; };
|
||||||
|
|
||||||
|
export type TicketParentSetRequest = { expected: TicketParentObservation; operation_key: string; parent: string | null; };
|
||||||
|
|
||||||
export type TicketPostEditRequest = { body: string; expected_body_digest: string; };
|
export type TicketPostEditRequest = { body: string; expected_body_digest: string; };
|
||||||
|
|
||||||
export type TicketQueryItem = { assignment_tracking: Array<TicketAssignmentTrackingProjection>; blocker_count: number; closure_operation_key?: string | null; content_digest: string; created_at?: string | null; current_closure?: TicketClosureJudgment | null; current_worker?: TicketAssignmentSummary | null; evidence: TicketEvidenceSummary; id: string; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; linked_objective_ids: Array<string>; linked_objective_keys: Array<string>; matched_fields: Array<string>; matching_event?: TicketEvidenceEvent | null; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array<TicketMergeRequestSummary>; priority: string; readiness?: string | null; relation_count: number; resource_key: string; snippet?: string | null; state: BrowserTicketState; tags: Array<WorkspaceTag>; title: string; unresolved_blocker_count: number; unresolved_review_count: number; updated_at?: string | null; workspace_action_priority: string; };
|
export type TicketQueryItem = { assignment_tracking: Array<TicketAssignmentTrackingProjection>; blocker_count: number; closure_operation_key?: string | null; content_digest: string; created_at?: string | null; current_closure?: TicketClosureJudgment | null; current_worker?: TicketAssignmentSummary | null; evidence: TicketEvidenceSummary; id: string; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; linked_objective_ids: Array<string>; linked_objective_keys: Array<string>; matched_fields: Array<string>; matching_event?: TicketEvidenceEvent | null; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array<TicketMergeRequestSummary>; priority: string; readiness?: string | null; relation_count: number; resource_key: string; snippet?: string | null; state: BrowserTicketState; tags: Array<WorkspaceTag>; title: string; unresolved_blocker_count: number; unresolved_review_count: number; updated_at?: string | null; workspace_action_priority: string; };
|
||||||
|
|
||||||
export type TicketQueryRequest = { assigned_principal?: TicketAssignmentPrincipalSnapshot | null; attention?: Array<TicketAttention>; closure_reason?: TicketClosureReason | null; cursor?: string | null; event_kinds?: Array<string>; evidence?: Array<string>; handling_status?: TicketAssignmentTrackingStatus | null; limit?: number & { readonly "__yoiIntegerRange": "0..1000"; } | null; linked_objective_id?: string | null; query?: string | null; related_ticket_id?: string | null; relation_kind?: string | null; review_status?: string | null; sort?: string | null; states?: Array<TicketState>; tag_ids?: Array<string>; tag_ids_any?: Array<string>; tag_ids_none?: Array<string>; updated_after?: string | null; updated_before?: string | null; };
|
export type TicketQueryRequest = { assigned_principal?: TicketAssignmentPrincipalSnapshot | null; attention?: Array<TicketAttention>; closure_reason?: TicketClosureReason | null; cursor?: string | null; event_kinds?: Array<string>; evidence?: Array<string>; handling_status?: TicketAssignmentTrackingStatus | null; limit?: number & { readonly "__yoiIntegerRange": "0..1000"; } | null; linked_objective_id?: string | null; parent?: TicketParentFilter | null; query?: string | null; related_ticket_id?: string | null; relation_kind?: string | null; review_status?: string | null; sort?: string | null; states?: Array<TicketState>; tag_ids?: Array<string>; tag_ids_any?: Array<string>; tag_ids_none?: Array<string>; updated_after?: string | null; updated_before?: string | null; };
|
||||||
|
|
||||||
export type TicketQueryResponse = { items: Array<TicketQueryItem>; page: QueryPage; record_authority: string; };
|
export type TicketQueryResponse = { items: Array<TicketQueryItem>; page: QueryPage; record_authority: string; };
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:2ac605f7ea70d0c725f9993a5c59c4a8c4266cf69184902db7b3ea7fd5932224)
|
// Input contract: 0.1.0 (sha256:728339d743ff6c83b9096f9a9454d34314f89a61d157b4391f8114fe5d104dcb)
|
||||||
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
||||||
|
|||||||
@@ -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:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c)
|
// Input contract: 0.1.0 (sha256:728339d743ff6c83b9096f9a9454d34314f89a61d157b4391f8114fe5d104dcb)
|
||||||
// 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
|
||||||
|
|||||||
@@ -317,7 +317,11 @@ function ticketDetailFixture() {
|
|||||||
unresolved_request_changes: false,
|
unresolved_request_changes: false,
|
||||||
},
|
},
|
||||||
id: "ticket-id",
|
id: "ticket-id",
|
||||||
parent_link: { ticket_id: "ticket-id", observation: { parent_ticket_id: null, last_operation_key: null }, parent: null },
|
parent_link: {
|
||||||
|
ticket_id: "ticket-id",
|
||||||
|
observation: { parent_ticket_id: null, last_operation_key: null },
|
||||||
|
parent: null,
|
||||||
|
},
|
||||||
implementation_reports: [],
|
implementation_reports: [],
|
||||||
content_digest: "a".repeat(64),
|
content_digest: "a".repeat(64),
|
||||||
linked_objectives: [],
|
linked_objectives: [],
|
||||||
|
|||||||
@@ -252,17 +252,34 @@ export function parseTicketParentLink(
|
|||||||
const item = object(value, label);
|
const item = object(value, label);
|
||||||
exact(item, ["ticket_id", "observation", "parent"], label);
|
exact(item, ["ticket_id", "observation", "parent"], label);
|
||||||
const observation = object(item.observation, `${label}.observation`);
|
const observation = object(item.observation, `${label}.observation`);
|
||||||
exact(observation, ["parent_ticket_id", "last_operation_key"], `${label}.observation`);
|
exact(
|
||||||
|
observation,
|
||||||
|
["parent_ticket_id", "last_operation_key"],
|
||||||
|
`${label}.observation`,
|
||||||
|
);
|
||||||
const result: TicketParentLink = {
|
const result: TicketParentLink = {
|
||||||
ticket_id: string(item.ticket_id, `${label}.ticket_id`),
|
ticket_id: string(item.ticket_id, `${label}.ticket_id`),
|
||||||
observation: {
|
observation: {
|
||||||
parent_ticket_id: nullableString(observation.parent_ticket_id, `${label}.observation.parent_ticket_id`),
|
parent_ticket_id: nullableString(
|
||||||
last_operation_key: nullableString(observation.last_operation_key, `${label}.observation.last_operation_key`),
|
observation.parent_ticket_id,
|
||||||
|
`${label}.observation.parent_ticket_id`,
|
||||||
|
),
|
||||||
|
last_operation_key: nullableString(
|
||||||
|
observation.last_operation_key,
|
||||||
|
`${label}.observation.last_operation_key`,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
parent: item.parent === null ? null : parseTicketLinkSummary(item.parent, `${label}.parent`),
|
parent: item.parent === null
|
||||||
|
? null
|
||||||
|
: parseTicketLinkSummary(item.parent, `${label}.parent`),
|
||||||
};
|
};
|
||||||
if (result.parent !== null && result.parent.id !== result.observation.parent_ticket_id) {
|
if (
|
||||||
throw new TicketBrowserContractError(`${label}.parent does not match observation`);
|
result.parent !== null &&
|
||||||
|
result.parent.id !== result.observation.parent_ticket_id
|
||||||
|
) {
|
||||||
|
throw new TicketBrowserContractError(
|
||||||
|
`${label}.parent does not match observation`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -272,14 +289,19 @@ export function parseTicketChildrenPage(value: unknown): TicketChildrenPage {
|
|||||||
const item = object(value, label);
|
const item = object(value, label);
|
||||||
exact(item, ["parent_ticket_id", "items", "next_cursor", "has_more"], label);
|
exact(item, ["parent_ticket_id", "items", "next_cursor", "has_more"], label);
|
||||||
const items = array(item.items, `${label}.items`, parseTicketLinkSummary);
|
const items = array(item.items, `${label}.items`, parseTicketLinkSummary);
|
||||||
if (items.length > 100) throw new TicketBrowserContractError(`${label} exceeds its page limit`);
|
if (items.length > 100) {
|
||||||
|
throw new TicketBrowserContractError(`${label} exceeds its page limit`);
|
||||||
|
}
|
||||||
const result: TicketChildrenPage = {
|
const result: TicketChildrenPage = {
|
||||||
parent_ticket_id: string(item.parent_ticket_id, `${label}.parent_ticket_id`),
|
parent_ticket_id: string(item.parent_ticket_id, `${label}.parent_ticket_id`),
|
||||||
items,
|
items,
|
||||||
next_cursor: nullableString(item.next_cursor, `${label}.next_cursor`),
|
next_cursor: nullableString(item.next_cursor, `${label}.next_cursor`),
|
||||||
has_more: boolean(item.has_more, `${label}.has_more`),
|
has_more: boolean(item.has_more, `${label}.has_more`),
|
||||||
};
|
};
|
||||||
if (result.has_more !== (result.next_cursor !== null && result.next_cursor.length > 0)) {
|
if (
|
||||||
|
result.has_more !==
|
||||||
|
(result.next_cursor !== null && result.next_cursor.length > 0)
|
||||||
|
) {
|
||||||
throw new TicketBrowserContractError(`${label} has inconsistent pagination`);
|
throw new TicketBrowserContractError(`${label} has inconsistent pagination`);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -1215,9 +1237,14 @@ export function parseTicketDetail(value: unknown): TicketDetail {
|
|||||||
"title",
|
"title",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
], label);
|
], label);
|
||||||
const parentLink = parseTicketParentLink(item.parent_link, `${label}.parent_link`);
|
const parentLink = parseTicketParentLink(
|
||||||
|
item.parent_link,
|
||||||
|
`${label}.parent_link`,
|
||||||
|
);
|
||||||
if (parentLink.ticket_id !== item.id) {
|
if (parentLink.ticket_id !== item.id) {
|
||||||
throw new TicketBrowserContractError(`${label}.parent_link has the wrong Ticket identity`);
|
throw new TicketBrowserContractError(
|
||||||
|
`${label}.parent_link has the wrong Ticket identity`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...caseProjection(item, label),
|
...caseProjection(item, label),
|
||||||
|
|||||||
@@ -1,14 +1,32 @@
|
|||||||
import { parseTicketChildrenPage, parseTicketLinkSummary, parseTicketParentLink } from "./ticket-browser.ts";
|
import {
|
||||||
|
parseTicketChildrenPage,
|
||||||
|
parseTicketLinkSummary,
|
||||||
|
parseTicketParentLink,
|
||||||
|
} from "./ticket-browser.ts";
|
||||||
import { createTicketParentClient } from "./ticket-parent.ts";
|
import { createTicketParentClient } from "./ticket-parent.ts";
|
||||||
|
|
||||||
declare const Deno: { test(name: string, fn: () => Promise<void> | void): void };
|
declare const Deno: {
|
||||||
function assert(value: unknown): asserts value { if (!value) throw new Error("Assertion failed"); }
|
test(name: string, fn: () => Promise<void> | void): void;
|
||||||
|
};
|
||||||
|
function assert(value: unknown): asserts value {
|
||||||
|
if (!value) throw new Error("Assertion failed");
|
||||||
|
}
|
||||||
function rejects(fn: () => unknown): void {
|
function rejects(fn: () => unknown): void {
|
||||||
let rejected = false;
|
let rejected = false;
|
||||||
try { fn(); } catch { rejected = true; }
|
try {
|
||||||
|
fn();
|
||||||
|
} catch {
|
||||||
|
rejected = true;
|
||||||
|
}
|
||||||
assert(rejected);
|
assert(rejected);
|
||||||
}
|
}
|
||||||
const parent = { id: "parent-id", resource_key: "T-8", title: "Parent", state: "closed", closure_reason: "not_planned" };
|
const parent = {
|
||||||
|
id: "parent-id",
|
||||||
|
resource_key: "T-8",
|
||||||
|
title: "Parent",
|
||||||
|
state: "closed",
|
||||||
|
closure_reason: "not_planned",
|
||||||
|
};
|
||||||
Deno.test("reference resolution reads only link-summary and preserves metadata closure reason", async () => {
|
Deno.test("reference resolution reads only link-summary and preserves metadata closure reason", async () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
const paths: string[] = [];
|
const paths: string[] = [];
|
||||||
@@ -21,11 +39,20 @@ Deno.test("reference resolution reads only link-summary and preserves metadata c
|
|||||||
for (const reference of ["T-8", "canonical/id"]) {
|
for (const reference of ["T-8", "canonical/id"]) {
|
||||||
const result = await client.resolve("workspace one", reference);
|
const result = await client.resolve("workspace one", reference);
|
||||||
assert(result.id === parent.id && result.resource_key === "T-8");
|
assert(result.id === parent.id && result.resource_key === "T-8");
|
||||||
assert(result.state === "closed" && result.closure_reason === "not_planned");
|
assert(
|
||||||
assert(paths.at(-1) === `/api/w/workspace%20one/tickets/${encodeURIComponent(reference)}/link-summary`);
|
result.state === "closed" && result.closure_reason === "not_planned",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
paths.at(-1) ===
|
||||||
|
`/api/w/workspace%20one/tickets/${
|
||||||
|
encodeURIComponent(reference)
|
||||||
|
}/link-summary`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
assert(paths.length === 2);
|
assert(paths.length === 2);
|
||||||
} finally { globalThis.fetch = originalFetch; }
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
Deno.test("reference resolution denial never falls back to Ticket detail", async () => {
|
Deno.test("reference resolution denial never falls back to Ticket detail", async () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
@@ -36,35 +63,84 @@ Deno.test("reference resolution denial never falls back to Ticket detail", async
|
|||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
let denied = false;
|
let denied = false;
|
||||||
try { await createTicketParentClient().resolve("ws", "T-8"); }
|
try {
|
||||||
catch (cause) { denied = cause instanceof Error && cause.message.includes("(403)"); }
|
await createTicketParentClient().resolve("ws", "T-8");
|
||||||
|
} catch (cause) {
|
||||||
|
denied = cause instanceof Error && cause.message.includes("(403)");
|
||||||
|
}
|
||||||
assert(denied);
|
assert(denied);
|
||||||
assert(paths.length === 1 && paths[0] === "/api/w/ws/tickets/T-8/link-summary");
|
assert(
|
||||||
} finally { globalThis.fetch = originalFetch; }
|
paths.length === 1 && paths[0] === "/api/w/ws/tickets/T-8/link-summary",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("parent JSON preserves closure reasons and the full ABA observation", () => {
|
Deno.test("parent JSON preserves closure reasons and the full ABA observation", () => {
|
||||||
const result = parseTicketParentLink({ ticket_id: "child-id", observation: { parent_ticket_id: "parent-id", last_operation_key: "set-1" }, parent });
|
const result = parseTicketParentLink({
|
||||||
|
ticket_id: "child-id",
|
||||||
|
observation: { parent_ticket_id: "parent-id", last_operation_key: "set-1" },
|
||||||
|
parent,
|
||||||
|
});
|
||||||
assert(result.parent?.closure_reason === "not_planned");
|
assert(result.parent?.closure_reason === "not_planned");
|
||||||
assert(result.observation.last_operation_key === "set-1");
|
assert(result.observation.last_operation_key === "set-1");
|
||||||
const detached = parseTicketParentLink({ ticket_id: "child-id", observation: { parent_ticket_id: null, last_operation_key: "unlink-2" }, parent: null });
|
const detached = parseTicketParentLink({
|
||||||
|
ticket_id: "child-id",
|
||||||
|
observation: { parent_ticket_id: null, last_operation_key: "unlink-2" },
|
||||||
|
parent: null,
|
||||||
|
});
|
||||||
assert(detached.observation.last_operation_key === "unlink-2");
|
assert(detached.observation.last_operation_key === "unlink-2");
|
||||||
assert(parseTicketLinkSummary({ ...parent, closure_reason: { other: "Research returned" } }).closure_reason !== null);
|
assert(
|
||||||
|
parseTicketLinkSummary({
|
||||||
|
...parent,
|
||||||
|
closure_reason: { other: "Research returned" },
|
||||||
|
}).closure_reason !== null,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
Deno.test("parent JSON rejects stale shapes mismatched identities and invalid case semantics", () => {
|
Deno.test("parent JSON rejects stale shapes mismatched identities and invalid case semantics", () => {
|
||||||
for (const value of [
|
for (
|
||||||
{ ticket_id: "child-id", parent },
|
const value of [
|
||||||
{ ticket_id: "child-id", observation: { parent_ticket_id: "different", last_operation_key: null }, parent },
|
{ ticket_id: "child-id", parent },
|
||||||
{ ticket_id: "child-id", observation: { parent_ticket_id: null }, parent: null },
|
{
|
||||||
{ ticket_id: "child-id", observation: { parent_ticket_id: null, last_operation_key: null, permission: true }, parent: null },
|
ticket_id: "child-id",
|
||||||
]) rejects(() => parseTicketParentLink(value));
|
observation: {
|
||||||
|
parent_ticket_id: "different",
|
||||||
|
last_operation_key: null,
|
||||||
|
},
|
||||||
|
parent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ticket_id: "child-id",
|
||||||
|
observation: { parent_ticket_id: null },
|
||||||
|
parent: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ticket_id: "child-id",
|
||||||
|
observation: {
|
||||||
|
parent_ticket_id: null,
|
||||||
|
last_operation_key: null,
|
||||||
|
permission: true,
|
||||||
|
},
|
||||||
|
parent: null,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
) rejects(() => parseTicketParentLink(value));
|
||||||
rejects(() => parseTicketLinkSummary({ ...parent, state: "done" }));
|
rejects(() => parseTicketLinkSummary({ ...parent, state: "done" }));
|
||||||
rejects(() => parseTicketLinkSummary({ ...parent, closure_reason: "success" }));
|
rejects(() =>
|
||||||
|
parseTicketLinkSummary({ ...parent, closure_reason: "success" })
|
||||||
|
);
|
||||||
});
|
});
|
||||||
Deno.test("children JSON enforces bounded pages and usable continuation", () => {
|
Deno.test("children JSON enforces bounded pages and usable continuation", () => {
|
||||||
const page = { parent_ticket_id: "parent-id", items: [parent], next_cursor: "opaque+cursor", has_more: true };
|
const page = {
|
||||||
|
parent_ticket_id: "parent-id",
|
||||||
|
items: [parent],
|
||||||
|
next_cursor: "opaque+cursor",
|
||||||
|
has_more: true,
|
||||||
|
};
|
||||||
assert(parseTicketChildrenPage(page).next_cursor === "opaque+cursor");
|
assert(parseTicketChildrenPage(page).next_cursor === "opaque+cursor");
|
||||||
rejects(() => parseTicketChildrenPage({ ...page, items: Array(101).fill(parent) }));
|
rejects(() =>
|
||||||
|
parseTicketChildrenPage({ ...page, items: Array(101).fill(parent) })
|
||||||
|
);
|
||||||
rejects(() => parseTicketChildrenPage({ ...page, next_cursor: null }));
|
rejects(() => parseTicketChildrenPage({ ...page, next_cursor: null }));
|
||||||
rejects(() => parseTicketChildrenPage({ ...page, has_more: false }));
|
rejects(() => parseTicketChildrenPage({ ...page, has_more: false }));
|
||||||
rejects(() => parseTicketChildrenPage({ ...page, next_cursor: "" }));
|
rejects(() => parseTicketChildrenPage({ ...page, next_cursor: "" }));
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import type {
|
|||||||
TicketParentLink,
|
TicketParentLink,
|
||||||
TicketParentSetRequest,
|
TicketParentSetRequest,
|
||||||
} from "#lib/generated/ticket-api.ts";
|
} from "#lib/generated/ticket-api.ts";
|
||||||
import { workspaceApiJson, workspaceApiJsonWithBody, workspaceApiPath } from "./http.ts";
|
import {
|
||||||
|
workspaceApiJson,
|
||||||
|
workspaceApiJsonWithBody,
|
||||||
|
workspaceApiPath,
|
||||||
|
} from "./http.ts";
|
||||||
import {
|
import {
|
||||||
parseTicketBrowseQueryResponse,
|
parseTicketBrowseQueryResponse,
|
||||||
parseTicketChildrenPage,
|
parseTicketChildrenPage,
|
||||||
@@ -13,7 +17,10 @@ import {
|
|||||||
parseTicketParentLink,
|
parseTicketParentLink,
|
||||||
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
|
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
|
||||||
} from "./ticket-browser.ts";
|
} from "./ticket-browser.ts";
|
||||||
import { observeOperation, type ObservedOperation } from "./ticket-operations.ts";
|
import {
|
||||||
|
type ObservedOperation,
|
||||||
|
observeOperation,
|
||||||
|
} from "./ticket-operations.ts";
|
||||||
|
|
||||||
export type ParentCandidates = {
|
export type ParentCandidates = {
|
||||||
items: TicketLinkSummary[];
|
items: TicketLinkSummary[];
|
||||||
@@ -21,14 +28,21 @@ export type ParentCandidates = {
|
|||||||
};
|
};
|
||||||
export type TicketParentClient = {
|
export type TicketParentClient = {
|
||||||
parent(workspaceId: string, ticketId: string): Promise<TicketParentLink>;
|
parent(workspaceId: string, ticketId: string): Promise<TicketParentLink>;
|
||||||
children(workspaceId: string, ticketId: string, cursor: string | null): Promise<TicketChildrenPage>;
|
children(
|
||||||
|
workspaceId: string,
|
||||||
|
ticketId: string,
|
||||||
|
cursor: string | null,
|
||||||
|
): Promise<TicketChildrenPage>;
|
||||||
search(workspaceId: string, query: string): Promise<ParentCandidates>;
|
search(workspaceId: string, query: string): Promise<ParentCandidates>;
|
||||||
resolve(workspaceId: string, reference: string): Promise<TicketLinkSummary>;
|
resolve(workspaceId: string, reference: string): Promise<TicketLinkSummary>;
|
||||||
set(operation: ObservedOperation): Promise<TicketParentLink>;
|
set(operation: ObservedOperation): Promise<TicketParentLink>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function ticketPath(workspaceId: string, ticketId: string): string {
|
function ticketPath(workspaceId: string, ticketId: string): string {
|
||||||
return workspaceApiPath(workspaceId, `/tickets/${encodeURIComponent(ticketId)}`);
|
return workspaceApiPath(
|
||||||
|
workspaceId,
|
||||||
|
`/tickets/${encodeURIComponent(ticketId)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parentOperation(
|
export function parentOperation(
|
||||||
@@ -42,43 +56,67 @@ export function parentOperation(
|
|||||||
parent,
|
parent,
|
||||||
operation_key: operationKey,
|
operation_key: operationKey,
|
||||||
};
|
};
|
||||||
return observeOperation(`${ticketPath(workspaceId, link.ticket_id)}/parent`, "PUT", request);
|
return observeOperation(
|
||||||
|
`${ticketPath(workspaceId, link.ticket_id)}/parent`,
|
||||||
|
"PUT",
|
||||||
|
request,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTicketParentClient(): TicketParentClient {
|
export function createTicketParentClient(): TicketParentClient {
|
||||||
return {
|
return {
|
||||||
parent: (workspaceId, ticketId) => workspaceApiJson(
|
parent: (workspaceId, ticketId) =>
|
||||||
`${ticketPath(workspaceId, ticketId)}/parent`, parseTicketParentLink,
|
workspaceApiJson(
|
||||||
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
|
`${ticketPath(workspaceId, ticketId)}/parent`,
|
||||||
),
|
parseTicketParentLink,
|
||||||
|
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
|
||||||
|
),
|
||||||
children: (workspaceId, ticketId, cursor) => {
|
children: (workspaceId, ticketId, cursor) => {
|
||||||
const query = new URLSearchParams({ limit: "50" });
|
const query = new URLSearchParams({ limit: "50" });
|
||||||
if (cursor !== null) query.set("cursor", cursor);
|
if (cursor !== null) query.set("cursor", cursor);
|
||||||
return workspaceApiJson(`${ticketPath(workspaceId, ticketId)}/children?${query}`,
|
return workspaceApiJson(
|
||||||
parseTicketChildrenPage, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
|
`${ticketPath(workspaceId, ticketId)}/children?${query}`,
|
||||||
|
parseTicketChildrenPage,
|
||||||
|
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
search: async (workspaceId, query) => {
|
search: async (workspaceId, query) => {
|
||||||
const request: TicketBrowseQueryRequest = {
|
const request: TicketBrowseQueryRequest = {
|
||||||
query, states: [], limit: 20 as TicketBrowseQueryRequest["limit"],
|
query,
|
||||||
sort: "updated_desc", cursor: null,
|
states: [],
|
||||||
|
limit: 20 as TicketBrowseQueryRequest["limit"],
|
||||||
|
sort: "updated_desc",
|
||||||
|
cursor: null,
|
||||||
};
|
};
|
||||||
const page = await workspaceApiJsonWithBody(workspaceApiPath(workspaceId, "/tickets/browse"),
|
const page = await workspaceApiJsonWithBody(
|
||||||
|
workspaceApiPath(workspaceId, "/tickets/browse"),
|
||||||
{ method: "POST", body: JSON.stringify(request) },
|
{ method: "POST", body: JSON.stringify(request) },
|
||||||
parseTicketBrowseQueryResponse, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
|
parseTicketBrowseQueryResponse,
|
||||||
|
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
items: page.items.map((ticket) => ({
|
items: page.items.map((ticket) => ({
|
||||||
id: ticket.id, resource_key: ticket.resource_key, title: ticket.title,
|
id: ticket.id,
|
||||||
state: ticket.state, closure_reason: ticket.current_closure?.reason ?? null,
|
resource_key: ticket.resource_key,
|
||||||
|
title: ticket.title,
|
||||||
|
state: ticket.state,
|
||||||
|
closure_reason: ticket.current_closure?.reason ?? null,
|
||||||
})),
|
})),
|
||||||
hasMore: page.page.has_more || page.page.source_truncated,
|
hasMore: page.page.has_more || page.page.source_truncated,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
resolve: (workspaceId, reference) => workspaceApiJson(
|
resolve: (workspaceId, reference) =>
|
||||||
`${ticketPath(workspaceId, reference)}/link-summary`, parseTicketLinkSummary,
|
workspaceApiJson(
|
||||||
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
|
`${ticketPath(workspaceId, reference)}/link-summary`,
|
||||||
),
|
parseTicketLinkSummary,
|
||||||
set: (operation) => workspaceApiJsonWithBody(operation.path,
|
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
|
||||||
{ method: operation.method, body: operation.body }, parseTicketParentLink,
|
),
|
||||||
TICKET_BROWSER_API_MAX_RESPONSE_BYTES),
|
set: (operation) =>
|
||||||
|
workspaceApiJsonWithBody(
|
||||||
|
operation.path,
|
||||||
|
{ method: operation.method, body: operation.body },
|
||||||
|
parseTicketParentLink,
|
||||||
|
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,11 @@ export function fixtureDetail(key = "T-101") {
|
|||||||
const { workspace_action_priority: _, ...ticket } = summary;
|
const { workspace_action_priority: _, ...ticket } = summary;
|
||||||
return {
|
return {
|
||||||
...ticket,
|
...ticket,
|
||||||
parent_link: { ticket_id: ticket.id, observation: { parent_ticket_id: null, last_operation_key: null }, parent: null },
|
parent_link: {
|
||||||
|
ticket_id: ticket.id,
|
||||||
|
observation: { parent_ticket_id: null, last_operation_key: null },
|
||||||
|
parent: null,
|
||||||
|
},
|
||||||
closure_history: [],
|
closure_history: [],
|
||||||
content_digest: "a".repeat(64),
|
content_digest: "a".repeat(64),
|
||||||
created_at: updated(1),
|
created_at: updated(1),
|
||||||
|
|||||||
@@ -5,52 +5,156 @@ import type { TicketDetail } from "#lib/generated/ticket-api.ts";
|
|||||||
import { fixtureDetail } from "#lib/workspace/home/dashboard.test-fixtures.ts";
|
import { fixtureDetail } from "#lib/workspace/home/dashboard.test-fixtures.ts";
|
||||||
import TicketHierarchy from "./TicketHierarchy.svelte";
|
import TicketHierarchy from "./TicketHierarchy.svelte";
|
||||||
|
|
||||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals(); sessionStorage.clear(); });
|
afterEach(() => {
|
||||||
const parent = { id: "parent-id", resource_key: "T-8", title: "Parent work", state: "closed", closure_reason: "not_planned" } as const;
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
sessionStorage.clear();
|
||||||
|
});
|
||||||
|
const parent = {
|
||||||
|
id: "parent-id",
|
||||||
|
resource_key: "T-8",
|
||||||
|
title: "Parent work",
|
||||||
|
state: "closed",
|
||||||
|
closure_reason: "not_planned",
|
||||||
|
} as const;
|
||||||
function detail(canSetParent = false): TicketDetail {
|
function detail(canSetParent = false): TicketDetail {
|
||||||
const ticket = fixtureDetail("T-1");
|
const ticket = fixtureDetail("T-1");
|
||||||
return { ...ticket, action_eligibility: { ...ticket.action_eligibility, can_set_parent: canSetParent },
|
return {
|
||||||
parent_link: { ticket_id: ticket.id, observation: { parent_ticket_id: parent.id, last_operation_key: "parent-1" }, parent } };
|
...ticket,
|
||||||
|
action_eligibility: {
|
||||||
|
...ticket.action_eligibility,
|
||||||
|
can_set_parent: canSetParent,
|
||||||
|
},
|
||||||
|
parent_link: {
|
||||||
|
ticket_id: ticket.id,
|
||||||
|
observation: {
|
||||||
|
parent_ticket_id: parent.id,
|
||||||
|
last_operation_key: "parent-1",
|
||||||
|
},
|
||||||
|
parent,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
function reads(ticket: TicketDetail, path: string): Response {
|
function reads(ticket: TicketDetail, path: string): Response {
|
||||||
if (path.endsWith("/parent")) return Response.json(ticket.parent_link);
|
if (path.endsWith("/parent")) return Response.json(ticket.parent_link);
|
||||||
if (path.includes("/children?")) return Response.json({ parent_ticket_id: ticket.id, items: [], next_cursor: null, has_more: false });
|
if (path.includes("/children?")) {
|
||||||
|
return Response.json({
|
||||||
|
parent_ticket_id: ticket.id,
|
||||||
|
items: [],
|
||||||
|
next_cursor: null,
|
||||||
|
has_more: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
throw new Error(`Unexpected request ${path}`);
|
throw new Error(`Unexpected request ${path}`);
|
||||||
}
|
}
|
||||||
test("read-only hierarchy preserves canonical navigation and non-success closure reason without mutation actions", async () => {
|
test("read-only hierarchy preserves canonical navigation and non-success closure reason without mutation actions", async () => {
|
||||||
const ticket = detail();
|
const ticket = detail();
|
||||||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => reads(ticket, String(input))));
|
vi.stubGlobal(
|
||||||
const view = render(TicketHierarchy, { workspaceId: "ws", routeTicketId: ticket.id, ticket });
|
"fetch",
|
||||||
await waitFor(() => expect(view.queryByText("Reading parent…")).toBeNull());
|
vi.fn(async (input: RequestInfo | URL) => reads(ticket, String(input))),
|
||||||
const link = view.getByRole("link", { name: /T-8 Parent work closed · not_planned/ });
|
);
|
||||||
|
const view = render(TicketHierarchy, {
|
||||||
|
workspaceId: "ws",
|
||||||
|
routeTicketId: ticket.id,
|
||||||
|
ticket,
|
||||||
|
});
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(view.getByText("No direct children.")).toBeTruthy()
|
||||||
|
);
|
||||||
|
const link = view.getByRole("link", {
|
||||||
|
name: /T-8 Parent work closed · not_planned/,
|
||||||
|
});
|
||||||
expect(link.getAttribute("href")).toBe("/w/ws/tickets/T-8-parent-work");
|
expect(link.getAttribute("href")).toBe("/w/ws/tickets/T-8-parent-work");
|
||||||
expect(view.queryByText("Change or unlink parent")).toBeNull();
|
expect(view.queryByText("Change or unlink parent")).toBeNull();
|
||||||
expect(view.queryByRole("button", { name: "Unlink parent" })).toBeNull();
|
expect(view.queryByRole("button", { name: "Unlink parent" })).toBeNull();
|
||||||
expect(view.getByText("No direct children.")).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
test("existing Ticket search selection sends observed CAS body and keeps exact retry after response loss", async () => {
|
test("existing Ticket search selection sends observed CAS body and keeps exact retry after response loss", async () => {
|
||||||
const ticket = detail(true);
|
const ticket = detail(true);
|
||||||
const bodies: string[] = [];
|
const bodies: string[] = [];
|
||||||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
vi.stubGlobal(
|
||||||
const path = String(input);
|
"fetch",
|
||||||
if (init?.method === "PUT") { bodies.push(String(init.body)); throw new TypeError("response lost"); }
|
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
if (path.endsWith("/tickets/browse")) return Response.json({ record_authority: "sqlite", items: [{ id: "candidate-id", resource_key: "T-9", title: "Existing work", state: "open", tags: [], assignment_tracking: [], current_closure: null }], page: { has_more: false, next_cursor: null, returned: 1, limit: 20, sort: "updated_desc", source_truncated: false } });
|
const path = String(input);
|
||||||
return reads(ticket, path);
|
if (init?.method === "PUT") {
|
||||||
}));
|
bodies.push(String(init.body));
|
||||||
const view = render(TicketHierarchy, { workspaceId: "ws", routeTicketId: ticket.id, ticket });
|
throw new TypeError("response lost");
|
||||||
await waitFor(() => expect(view.getByText("Change or unlink parent")).toBeTruthy());
|
}
|
||||||
|
if (path.endsWith("/tickets/browse")) {
|
||||||
|
return Response.json({
|
||||||
|
record_authority: "sqlite",
|
||||||
|
items: [{
|
||||||
|
id: "candidate-id",
|
||||||
|
resource_key: "T-9",
|
||||||
|
title: "Existing work",
|
||||||
|
state: "open",
|
||||||
|
tags: [],
|
||||||
|
assignment_tracking: [],
|
||||||
|
current_closure: null,
|
||||||
|
}],
|
||||||
|
page: {
|
||||||
|
has_more: false,
|
||||||
|
next_cursor: null,
|
||||||
|
returned: 1,
|
||||||
|
limit: 20,
|
||||||
|
sort: "updated_desc",
|
||||||
|
source_truncated: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return reads(ticket, path);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const view = render(TicketHierarchy, {
|
||||||
|
workspaceId: "ws",
|
||||||
|
routeTicketId: ticket.id,
|
||||||
|
ticket,
|
||||||
|
});
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(view.getByText("Change or unlink parent")).toBeTruthy()
|
||||||
|
);
|
||||||
await fireEvent.click(view.getByText("Change or unlink parent"));
|
await fireEvent.click(view.getByText("Change or unlink parent"));
|
||||||
await fireEvent.input(view.getByLabelText("Search existing Tickets"), { target: { value: "Existing" } });
|
await fireEvent.input(view.getByLabelText("Search existing Tickets"), {
|
||||||
|
target: { value: "Existing" },
|
||||||
|
});
|
||||||
await fireEvent.click(view.getByRole("button", { name: "Search Tickets" }));
|
await fireEvent.click(view.getByRole("button", { name: "Search Tickets" }));
|
||||||
await waitFor(() => expect(view.getByRole("radio", { name: /T-9 Existing work/ })).toBeTruthy());
|
await waitFor(() =>
|
||||||
|
expect(view.getByRole("radio", { name: /T-9 Existing work/ })).toBeTruthy()
|
||||||
|
);
|
||||||
await fireEvent.click(view.getByRole("radio", { name: /T-9 Existing work/ }));
|
await fireEvent.click(view.getByRole("radio", { name: /T-9 Existing work/ }));
|
||||||
await fireEvent.click(view.getByRole("button", { name: "Set selected parent" }));
|
await fireEvent.click(
|
||||||
await waitFor(() => expect(view.getByText("Unresolved parent operation retained. New changes are blocked.")).toBeTruthy());
|
view.getByRole("button", { name: "Set selected parent" }),
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(view.getByText(/Outcome unavailable or unknown/)).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
(view.getByRole("button", {
|
||||||
|
name: "Retry exact parent operation",
|
||||||
|
}) as HTMLButtonElement).disabled,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
view.getByText(
|
||||||
|
"Unresolved parent operation retained. New changes are blocked.",
|
||||||
|
),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
(view.getByRole("button", {
|
||||||
|
name: "Set selected parent",
|
||||||
|
}) as HTMLButtonElement).disabled,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
(view.getByRole("button", {
|
||||||
|
name: "Unlink parent",
|
||||||
|
}) as HTMLButtonElement).disabled,
|
||||||
|
).toBe(true);
|
||||||
const body = JSON.parse(bodies[0]);
|
const body = JSON.parse(bodies[0]);
|
||||||
expect(body.expected).toEqual(ticket.parent_link.observation);
|
expect(body.expected).toEqual(ticket.parent_link.observation);
|
||||||
expect(body.parent).toBe("candidate-id");
|
expect(body.parent).toBe("candidate-id");
|
||||||
expect(typeof body.operation_key).toBe("string");
|
expect(typeof body.operation_key).toBe("string");
|
||||||
await fireEvent.click(view.getByRole("button", { name: "Retry exact parent operation" }));
|
await fireEvent.click(
|
||||||
|
view.getByRole("button", { name: "Retry exact parent operation" }),
|
||||||
|
);
|
||||||
await waitFor(() => expect(bodies).toHaveLength(2));
|
await waitFor(() => expect(bodies).toHaveLength(2));
|
||||||
expect(bodies[1]).toBe(bodies[0]);
|
expect(bodies[1]).toBe(bodies[0]);
|
||||||
expect(view.queryByText(/Parent operation saved\./)).toBeNull();
|
expect(view.queryByText(/Parent operation saved\./)).toBeNull();
|
||||||
|
|||||||
@@ -1,26 +1,53 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, untrack } from "svelte";
|
import { onDestroy, onMount, untrack } from "svelte";
|
||||||
import { browser } from "$app/environment";
|
import type {
|
||||||
import type { TicketClosureReason, TicketDetail } from "#lib/generated/ticket-api.ts";
|
TicketClosureReason,
|
||||||
|
TicketDetail,
|
||||||
|
} from "#lib/generated/ticket-api.ts";
|
||||||
import { createTicketParentClient } from "#lib/workspace/api/ticket-parent.ts";
|
import { createTicketParentClient } from "#lib/workspace/api/ticket-parent.ts";
|
||||||
import { ticketHref } from "#lib/workspace/resource-links.ts";
|
import { ticketHref } from "#lib/workspace/resource-links.ts";
|
||||||
import { MAX_VISIBLE_CHILDREN, TicketParentController } from "./ticket-parent-controller.ts";
|
import {
|
||||||
|
MAX_VISIBLE_CHILDREN,
|
||||||
|
TicketParentController,
|
||||||
|
} from "./ticket-parent-controller.ts";
|
||||||
|
|
||||||
let { workspaceId, routeTicketId, ticket }: { workspaceId: string; routeTicketId: string; ticket: TicketDetail } = $props();
|
let { workspaceId, routeTicketId, ticket }: {
|
||||||
const controller = new TicketParentController(createTicketParentClient(), browser ? {
|
workspaceId: string;
|
||||||
getItem: (key) => sessionStorage.getItem(key),
|
routeTicketId: string;
|
||||||
setItem: (key, value) => sessionStorage.setItem(key, value),
|
ticket: TicketDetail;
|
||||||
removeItem: (key) => sessionStorage.removeItem(key),
|
} = $props();
|
||||||
} : undefined);
|
const controller = new TicketParentController(
|
||||||
|
createTicketParentClient(),
|
||||||
|
{
|
||||||
|
getItem: (key) => sessionStorage.getItem(key),
|
||||||
|
setItem: (key, value) => sessionStorage.setItem(key, value),
|
||||||
|
removeItem: (key) => sessionStorage.removeItem(key),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let mounted = $state(false);
|
||||||
|
onMount(() => {
|
||||||
|
mounted = true;
|
||||||
|
});
|
||||||
let query = $state("");
|
let query = $state("");
|
||||||
let reference = $state("");
|
let reference = $state("");
|
||||||
let selectedId = $state("");
|
let selectedId = $state("");
|
||||||
let searched = $state(false);
|
let searched = $state(false);
|
||||||
const selected = $derived($controller.candidates.find((item) => item.id === selectedId) ?? null);
|
const selected = $derived(
|
||||||
const editBlocked = $derived($controller.saving || $controller.pending !== null || $controller.loadingParent || $controller.parentError !== null || $controller.retentionError !== null);
|
$controller.candidates.find((item) => item.id === selectedId) ?? null,
|
||||||
const childrenSearch = $derived(`/w/${encodeURIComponent(workspaceId)}/tickets?${new URLSearchParams({ q: `parent:${ticket.resource_key}` })}`);
|
);
|
||||||
|
const editBlocked = $derived(
|
||||||
|
$controller.saving || $controller.pending !== null ||
|
||||||
|
$controller.loadingParent || $controller.parentError !== null ||
|
||||||
|
$controller.retentionError !== null,
|
||||||
|
);
|
||||||
|
const childrenSearch = $derived(
|
||||||
|
`/w/${encodeURIComponent(workspaceId)}/tickets?${
|
||||||
|
new URLSearchParams({ q: `parent:${ticket.resource_key}` })
|
||||||
|
}`,
|
||||||
|
);
|
||||||
let selectedIdentity = "";
|
let selectedIdentity = "";
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
if (!mounted) return;
|
||||||
const ws = workspaceId;
|
const ws = workspaceId;
|
||||||
const id = routeTicketId;
|
const id = routeTicketId;
|
||||||
const loadedId = ticket.id;
|
const loadedId = ticket.id;
|
||||||
@@ -28,12 +55,26 @@
|
|||||||
const identity = JSON.stringify([ws, id, loadedId]);
|
const identity = JSON.stringify([ws, id, loadedId]);
|
||||||
if (identity === selectedIdentity) return;
|
if (identity === selectedIdentity) return;
|
||||||
selectedIdentity = identity;
|
selectedIdentity = identity;
|
||||||
query = ""; reference = ""; selectedId = ""; searched = false;
|
query = "";
|
||||||
if (loadedId !== id) { controller.suspend(); return; }
|
reference = "";
|
||||||
void controller.select(ws, id, ticket.parent_link, ticket.action_eligibility.can_set_parent);
|
selectedId = "";
|
||||||
|
searched = false;
|
||||||
|
if (loadedId !== id) {
|
||||||
|
controller.suspend();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void controller.select(
|
||||||
|
ws,
|
||||||
|
id,
|
||||||
|
ticket.parent_link,
|
||||||
|
ticket.action_eligibility.can_set_parent,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
$effect(() => { controller.setEligibility(ticket.action_eligibility.can_set_parent); });
|
$effect(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
controller.setEligibility(ticket.action_eligibility.can_set_parent);
|
||||||
|
});
|
||||||
onDestroy(() => controller.dispose());
|
onDestroy(() => controller.dispose());
|
||||||
|
|
||||||
function closureLabel(reason: TicketClosureReason | null): string {
|
function closureLabel(reason: TicketClosureReason | null): string {
|
||||||
@@ -41,111 +82,304 @@
|
|||||||
return typeof reason === "string" ? reason : reason.other;
|
return typeof reason === "string" ? reason : reason.other;
|
||||||
}
|
}
|
||||||
function search(byReference = false): void {
|
function search(byReference = false): void {
|
||||||
selectedId = ""; searched = true;
|
selectedId = "";
|
||||||
|
searched = true;
|
||||||
void controller.search(byReference ? reference : query, byReference);
|
void controller.search(byReference ? reference : query, byReference);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $controller.workspaceId === workspaceId && $controller.ticketId === routeTicketId && ticket.id === routeTicketId}
|
{#if $controller.workspaceId === workspaceId && $controller.ticketId === routeTicketId && ticket.id === routeTicketId}
|
||||||
<section class="ticket-detail-section ticket-hierarchy" aria-label="Parent and children">
|
<section
|
||||||
<div class="ticket-section-heading"><h2>Parent and children</h2></div>
|
class="ticket-detail-section ticket-hierarchy"
|
||||||
<h3>Parent</h3>
|
aria-label="Parent and children"
|
||||||
{#if $controller.parent?.parent}
|
>
|
||||||
{@const parent = $controller.parent.parent}
|
<div class="ticket-section-heading"><h2>Parent and children</h2></div>
|
||||||
<a class="hierarchy-link" href={ticketHref(workspaceId, parent)}>
|
<h3>Parent</h3>
|
||||||
<strong>{parent.resource_key}</strong> {parent.title}
|
{#if $controller.parent?.parent}
|
||||||
<span>{parent.state}{parent.state === "closed" ? ` · ${closureLabel(parent.closure_reason)}` : ""}</span>
|
{@const parent = $controller.parent.parent}
|
||||||
</a>
|
<a class="hierarchy-link" href={ticketHref(workspaceId, parent)}>
|
||||||
{:else if $controller.parent?.observation.parent_ticket_id}
|
<strong>{parent.resource_key}</strong> {parent.title}
|
||||||
<p>Parent details unavailable.</p>
|
<span>{parent.state}{parent.state === "closed"
|
||||||
{:else if $controller.parent}
|
? ` · ${closureLabel(parent.closure_reason)}`
|
||||||
<p class="workspace-empty-copy">No parent.</p>
|
: ""}</span>
|
||||||
{/if}
|
</a>
|
||||||
{#if $controller.loadingParent}<p role="status">Reading parent…</p>{/if}
|
{:else if $controller.parent?.observation.parent_ticket_id}
|
||||||
{#if $controller.parentError}<p class="workspace-callout is-error" role="alert">{$controller.parentError}</p>{/if}
|
<p>Parent details unavailable.</p>
|
||||||
<button class="workspace-secondary-button" type="button" disabled={$controller.loadingParent || $controller.saving} onclick={() => void controller.readParent()}>Reread parent</button>
|
{:else if $controller.parent}
|
||||||
{#if $controller.retentionError}<p class="workspace-callout is-error" role="alert">{$controller.retentionError}</p>{/if}
|
<p class="workspace-empty-copy">No parent.</p>
|
||||||
{#if $controller.operationError}<p class="workspace-callout is-error" role="alert">{$controller.operationError}</p>{/if}
|
|
||||||
{#if $controller.saved}<p role="status">Parent operation saved. Current parent is read separately.</p>{/if}
|
|
||||||
{#if $controller.pending}
|
|
||||||
<p>Unresolved parent operation retained. New changes are blocked.</p>
|
|
||||||
{#if $controller.canSetParent}
|
|
||||||
<button class="workspace-secondary-button" type="button" disabled={$controller.saving || $controller.loadingParent} onclick={() => void controller.retry()}>Retry exact parent operation</button>
|
|
||||||
{/if}
|
{/if}
|
||||||
<button class="workspace-secondary-button" type="button" disabled={$controller.saving || $controller.loadingParent} onclick={() => void controller.readParent()}>Reread and reconcile parent</button>
|
{#if $controller.loadingParent}
|
||||||
{#if $controller.pending.rejected && $controller.reconciled}
|
<p role="status">Reading parent…</p>
|
||||||
<button class="workspace-secondary-button" type="button" disabled={$controller.saving} onclick={() => controller.useRefreshedObservation()}>Use refreshed parent observation</button>
|
|
||||||
{:else if $controller.reconciled}
|
|
||||||
<p>The latest observation does not confirm this operation. Its original key and body remain available for exact retry; a different parent alone does not prove failure.</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
<details><summary>Retained parent request</summary><pre>{$controller.pending.operation.body}</pre></details>
|
{#if $controller.parentError}
|
||||||
{/if}
|
<p class="workspace-callout is-error" role="alert">
|
||||||
{#if $controller.canSetParent && !$controller.loadingParent && !$controller.parentError && $controller.parent}
|
{$controller.parentError}
|
||||||
<details>
|
</p>
|
||||||
<summary>{$controller.parent.observation.parent_ticket_id ? "Change or unlink parent" : "Set parent"}</summary>
|
{/if}
|
||||||
<form class="ticket-control-form" onsubmit={(event) => { event.preventDefault(); search(); }}>
|
<button
|
||||||
<label>Search existing Tickets<input bind:value={query} disabled={editBlocked || $controller.searching} required /></label>
|
class="workspace-secondary-button"
|
||||||
<button class="workspace-secondary-button" type="submit" disabled={editBlocked || $controller.searching || !query.trim()}>Search Tickets</button>
|
type="button"
|
||||||
</form>
|
disabled={$controller.loadingParent || $controller.saving}
|
||||||
<form class="ticket-control-form" onsubmit={(event) => { event.preventDefault(); search(true); }}>
|
onclick={() => void controller.readParent()}
|
||||||
<label>Ticket reference<input bind:value={reference} placeholder="T-804" disabled={editBlocked || $controller.searching} required /></label>
|
>Reread parent</button>
|
||||||
<button class="workspace-secondary-button" type="submit" disabled={editBlocked || $controller.searching || !reference.trim()}>Find Ticket by reference</button>
|
{#if $controller.retentionError}
|
||||||
</form>
|
<p class="workspace-callout is-error" role="alert">
|
||||||
{#if $controller.searching}<p role="status">Finding Tickets…</p>{/if}
|
{$controller.retentionError}
|
||||||
{#if $controller.searchError}<p class="workspace-callout is-error" role="alert">{$controller.searchError}</p>{/if}
|
</p>
|
||||||
{#if $controller.candidates.length > 0}
|
{/if}
|
||||||
<fieldset disabled={editBlocked}>
|
{#if $controller.operationError}
|
||||||
<legend>Choose existing parent</legend>
|
<p class="workspace-callout is-error" role="alert">
|
||||||
{#each $controller.candidates as candidate (candidate.id)}
|
{$controller.operationError}
|
||||||
<label class="parent-candidate">
|
</p>
|
||||||
<input type="radio" name="parent-ticket" value={candidate.id} bind:group={selectedId} />
|
{/if}
|
||||||
<span><strong>{candidate.resource_key}</strong> {candidate.title}<small>{candidate.state}{candidate.state === "closed" ? ` · ${closureLabel(candidate.closure_reason)}` : ""}</small></span>
|
{#if $controller.saved}
|
||||||
</label>
|
<p role="status">
|
||||||
{/each}
|
Parent operation saved. Current parent is read separately.
|
||||||
</fieldset>
|
</p>
|
||||||
<button class="workspace-secondary-button" type="button" disabled={editBlocked || !selected} onclick={() => selected && void controller.change(selected, crypto.randomUUID())}>Set selected parent</button>
|
{/if}
|
||||||
{:else if searched && !$controller.searching && !$controller.searchError}
|
{#if $controller.pending}
|
||||||
<p class="workspace-empty-copy">No other matching Tickets.</p>
|
<p>Unresolved parent operation retained. New changes are blocked.</p>
|
||||||
|
{#if $controller.canSetParent}
|
||||||
|
<button
|
||||||
|
class="workspace-secondary-button"
|
||||||
|
type="button"
|
||||||
|
disabled={$controller.saving || $controller.loadingParent}
|
||||||
|
onclick={() => void controller.retry()}
|
||||||
|
>Retry exact parent operation</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $controller.candidatesTruncated}<p>More matches are omitted. Refine the search or use an exact Ticket reference.</p>{/if}
|
<button
|
||||||
{#if $controller.parent.observation.parent_ticket_id}
|
class="workspace-secondary-button"
|
||||||
<button class="workspace-secondary-button" type="button" disabled={editBlocked} onclick={() => void controller.change(null, crypto.randomUUID())}>Unlink parent</button>
|
type="button"
|
||||||
|
disabled={$controller.saving || $controller.loadingParent}
|
||||||
|
onclick={() => void controller.readParent()}
|
||||||
|
>Reread and reconcile parent</button>
|
||||||
|
{#if $controller.pending.rejected && $controller.reconciled}
|
||||||
|
<button
|
||||||
|
class="workspace-secondary-button"
|
||||||
|
type="button"
|
||||||
|
disabled={$controller.saving}
|
||||||
|
onclick={() => controller.useRefreshedObservation()}
|
||||||
|
>Use refreshed parent observation</button>
|
||||||
|
{:else if $controller.reconciled}
|
||||||
|
<p>
|
||||||
|
The latest observation does not confirm this operation. Its original key
|
||||||
|
and body remain available for exact retry; a different parent alone does
|
||||||
|
not prove failure.
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</details>
|
<details>
|
||||||
{/if}
|
<summary>Retained parent request</summary>
|
||||||
|
<pre>{$controller.pending.operation.body}</pre>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
{#if $controller.canSetParent && !$controller.loadingParent && !$controller.parentError && $controller.parent}
|
||||||
|
<details>
|
||||||
|
<summary>{$controller.parent.observation.parent_ticket_id
|
||||||
|
? "Change or unlink parent"
|
||||||
|
: "Set parent"}</summary>
|
||||||
|
<form
|
||||||
|
class="ticket-control-form"
|
||||||
|
onsubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
search();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
Search existing Tickets
|
||||||
|
<input
|
||||||
|
bind:value={query}
|
||||||
|
disabled={editBlocked || $controller.searching}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
class="workspace-secondary-button"
|
||||||
|
type="submit"
|
||||||
|
disabled={editBlocked || $controller.searching || !query.trim()}
|
||||||
|
>Search Tickets</button>
|
||||||
|
</form>
|
||||||
|
<form
|
||||||
|
class="ticket-control-form"
|
||||||
|
onsubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
search(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
Ticket reference
|
||||||
|
<input
|
||||||
|
bind:value={reference}
|
||||||
|
placeholder="T-804"
|
||||||
|
disabled={editBlocked || $controller.searching}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
class="workspace-secondary-button"
|
||||||
|
type="submit"
|
||||||
|
disabled={editBlocked || $controller.searching || !reference.trim()}
|
||||||
|
>Find Ticket by reference</button>
|
||||||
|
</form>
|
||||||
|
{#if $controller.searching}
|
||||||
|
<p role="status">Finding Tickets…</p>
|
||||||
|
{/if}
|
||||||
|
{#if $controller.searchError}
|
||||||
|
<p class="workspace-callout is-error" role="alert">
|
||||||
|
{$controller.searchError}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if $controller.candidates.length > 0}
|
||||||
|
<fieldset disabled={editBlocked}>
|
||||||
|
<legend>Choose existing parent</legend>
|
||||||
|
{#each $controller.candidates as candidate (candidate.id)}
|
||||||
|
<label class="parent-candidate">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="parent-ticket"
|
||||||
|
value={candidate.id}
|
||||||
|
bind:group={selectedId}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>{candidate.resource_key}</strong> {candidate.title}
|
||||||
|
<small>{candidate.state}{candidate.state === "closed"
|
||||||
|
? ` · ${closureLabel(candidate.closure_reason)}`
|
||||||
|
: ""}</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</fieldset>
|
||||||
|
<button
|
||||||
|
class="workspace-secondary-button"
|
||||||
|
type="button"
|
||||||
|
disabled={editBlocked || !selected}
|
||||||
|
onclick={() =>
|
||||||
|
selected && void controller.change(selected, crypto.randomUUID())}
|
||||||
|
>Set selected parent</button>
|
||||||
|
{:else if searched && !$controller.searching && !$controller.searchError}
|
||||||
|
<p class="workspace-empty-copy">No other matching Tickets.</p>
|
||||||
|
{/if}
|
||||||
|
{#if $controller.candidatesTruncated}
|
||||||
|
<p>
|
||||||
|
More matches are omitted. Refine the search or use an exact Ticket
|
||||||
|
reference.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if $controller.parent.observation.parent_ticket_id}
|
||||||
|
<button
|
||||||
|
class="workspace-secondary-button"
|
||||||
|
type="button"
|
||||||
|
disabled={editBlocked}
|
||||||
|
onclick={() => void controller.change(null, crypto.randomUUID())}
|
||||||
|
>Unlink parent</button>
|
||||||
|
{/if}
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<h3>Direct children</h3>
|
<h3>Direct children</h3>
|
||||||
{#if $controller.children}
|
{#if $controller.children}
|
||||||
<ul class="hierarchy-children">
|
<ul class="hierarchy-children">
|
||||||
{#each $controller.children.items as child (child.id)}
|
{#each $controller.children.items as child (child.id)}
|
||||||
<li><a class="hierarchy-link" href={ticketHref(workspaceId, child)}><strong>{child.resource_key}</strong> {child.title}<span>{child.state}{child.state === "closed" ? ` · ${closureLabel(child.closure_reason)}` : ""}</span></a></li>
|
<li>
|
||||||
{:else}<li class="workspace-empty-copy">No direct children.</li>{/each}
|
<a class="hierarchy-link" href={ticketHref(workspaceId, child)}>
|
||||||
</ul>
|
<strong>{child.resource_key}</strong> {child.title}
|
||||||
{#if $controller.children.has_more}
|
<span>{child.state}{child.state === "closed"
|
||||||
<p>Showing {$controller.children.items.length} direct children; more are omitted.</p>
|
? ` · ${closureLabel(child.closure_reason)}`
|
||||||
{#if $controller.children.items.length < MAX_VISIBLE_CHILDREN}
|
: ""}</span>
|
||||||
<button class="workspace-secondary-button" type="button" disabled={$controller.loadingChildren} onclick={() => void controller.readChildren(true)}>Load more children</button>
|
</a>
|
||||||
|
</li>
|
||||||
|
{:else}
|
||||||
|
<li class="workspace-empty-copy">No direct children.</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{#if $controller.children.has_more}
|
||||||
|
<p>
|
||||||
|
Showing {$controller.children.items.length} direct children; more are
|
||||||
|
omitted.
|
||||||
|
</p>
|
||||||
|
{#if $controller.children.items.length < MAX_VISIBLE_CHILDREN}
|
||||||
|
<button
|
||||||
|
class="workspace-secondary-button"
|
||||||
|
type="button"
|
||||||
|
disabled={$controller.loadingChildren}
|
||||||
|
onclick={() => void controller.readChildren(true)}
|
||||||
|
>Load more children</button>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{#if $controller.loadingChildren}
|
||||||
{#if $controller.loadingChildren}<p role="status">Reading children…</p>{/if}
|
<p role="status">Reading children…</p>
|
||||||
{#if $controller.childrenError}<p class="workspace-callout is-error" role="alert">{$controller.childrenError}</p>{/if}
|
{/if}
|
||||||
<div class="hierarchy-actions">
|
{#if $controller.childrenError}
|
||||||
<button class="workspace-secondary-button" type="button" disabled={$controller.loadingChildren} onclick={() => void controller.readChildren()}>Refresh children</button>
|
<p class="workspace-callout is-error" role="alert">
|
||||||
<a href={childrenSearch}>View children in Ticket search</a>
|
{$controller.childrenError}
|
||||||
</div>
|
</p>
|
||||||
</section>
|
{/if}
|
||||||
|
<div class="hierarchy-actions">
|
||||||
|
<button
|
||||||
|
class="workspace-secondary-button"
|
||||||
|
type="button"
|
||||||
|
disabled={$controller.loadingChildren}
|
||||||
|
onclick={() => void controller.readChildren()}
|
||||||
|
>Refresh children</button>
|
||||||
|
<a href={childrenSearch}>View children in Ticket search</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.ticket-hierarchy { min-width: 0; overflow-wrap: anywhere; }
|
.ticket-hierarchy {
|
||||||
h3 { font-size: var(--font-size-body); line-height: var(--line-height-body); margin-block: var(--space-4) var(--space-2); }
|
min-width: 0;
|
||||||
.hierarchy-link { display: block; padding-block: var(--space-2); }
|
overflow-wrap: anywhere;
|
||||||
.hierarchy-link span, .parent-candidate small { display: block; color: var(--text-muted); font-size: var(--font-size-compact); line-height: var(--line-height-compact); }
|
}
|
||||||
.hierarchy-children { list-style: none; padding: 0; margin-block: var(--space-2); }
|
h3 {
|
||||||
.hierarchy-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3); }
|
font-size: var(--font-size-body);
|
||||||
fieldset { min-width: 0; border: 0; padding: 0; margin-block: var(--space-3); }
|
line-height: var(--line-height-body);
|
||||||
.parent-candidate { display: flex; align-items: start; gap: var(--space-2); margin-block: var(--space-2); }
|
margin-block: var(--space-4) var(--space-2);
|
||||||
.parent-candidate input { width: auto; flex: none; }
|
}
|
||||||
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
.hierarchy-link {
|
||||||
|
display: block;
|
||||||
|
padding-block: var(--space-2);
|
||||||
|
}
|
||||||
|
.hierarchy-link strong,
|
||||||
|
.parent-candidate strong {
|
||||||
|
margin-inline-end: var(--space-2);
|
||||||
|
}
|
||||||
|
.hierarchy-link span,
|
||||||
|
.parent-candidate small {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-size-compact);
|
||||||
|
line-height: var(--line-height-compact);
|
||||||
|
}
|
||||||
|
.hierarchy-children {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin-block: var(--space-2);
|
||||||
|
}
|
||||||
|
.hierarchy-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
fieldset {
|
||||||
|
min-width: 0;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin-block: var(--space-3);
|
||||||
|
}
|
||||||
|
.parent-candidate {
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-block: var(--space-2);
|
||||||
|
}
|
||||||
|
.parent-candidate input {
|
||||||
|
width: auto;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,28 +1,72 @@
|
|||||||
import type { TicketChildrenPage, TicketLinkSummary, TicketParentLink } from "#lib/generated/ticket-api.ts";
|
import type {
|
||||||
|
TicketChildrenPage,
|
||||||
|
TicketLinkSummary,
|
||||||
|
TicketParentLink,
|
||||||
|
} from "#lib/generated/ticket-api.ts";
|
||||||
import type { TicketParentClient } from "#lib/workspace/api/ticket-parent.ts";
|
import type { TicketParentClient } from "#lib/workspace/api/ticket-parent.ts";
|
||||||
import { TicketParentController, type TicketParentState } from "./ticket-parent-controller.ts";
|
import {
|
||||||
|
TicketParentController,
|
||||||
|
type TicketParentState,
|
||||||
|
} from "./ticket-parent-controller.ts";
|
||||||
|
|
||||||
declare const Deno: { test(name: string, fn: () => Promise<void>): void };
|
declare const Deno: { test(name: string, fn: () => Promise<void>): void };
|
||||||
function assert(value: unknown): asserts value { if (!value) throw new Error("Assertion failed"); }
|
function assert(value: unknown): void {
|
||||||
|
if (!value) throw new Error("Assertion failed");
|
||||||
|
}
|
||||||
function deferred<T>() {
|
function deferred<T>() {
|
||||||
let resolve!: (value: T) => void;
|
let resolve!: (value: T) => void;
|
||||||
let reject!: (cause: unknown) => void;
|
let reject!: (cause: unknown) => void;
|
||||||
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
|
const promise = new Promise<T>((yes, no) => {
|
||||||
|
resolve = yes;
|
||||||
|
reject = no;
|
||||||
|
});
|
||||||
return { promise, resolve, reject };
|
return { promise, resolve, reject };
|
||||||
}
|
}
|
||||||
const summary = (id: string): TicketLinkSummary => ({ id, resource_key: `T-${id}`, title: `Ticket ${id}`, state: "open", closure_reason: null });
|
const summary = (id: string): TicketLinkSummary => ({
|
||||||
const link = (id: string, parent: string | null = null, key: string | null = null): TicketParentLink => ({ ticket_id: id, observation: { parent_ticket_id: parent, last_operation_key: key }, parent: parent ? summary(parent) : null });
|
id,
|
||||||
const page = (id: string): TicketChildrenPage => ({ parent_ticket_id: id, items: [], next_cursor: null, has_more: false });
|
resource_key: `T-${id}`,
|
||||||
function harness(overrides: Partial<TicketParentClient> = {}, storage?: Pick<Storage, "getItem" | "setItem" | "removeItem">) {
|
title: `Ticket ${id}`,
|
||||||
|
state: "open",
|
||||||
|
closure_reason: null,
|
||||||
|
});
|
||||||
|
const link = (
|
||||||
|
id: string,
|
||||||
|
parent: string | null = null,
|
||||||
|
key: string | null = null,
|
||||||
|
): TicketParentLink => ({
|
||||||
|
ticket_id: id,
|
||||||
|
observation: { parent_ticket_id: parent, last_operation_key: key },
|
||||||
|
parent: parent ? summary(parent) : null,
|
||||||
|
});
|
||||||
|
const page = (id: string): TicketChildrenPage => ({
|
||||||
|
parent_ticket_id: id,
|
||||||
|
items: [],
|
||||||
|
next_cursor: null,
|
||||||
|
has_more: false,
|
||||||
|
});
|
||||||
|
function harness(
|
||||||
|
overrides: Partial<TicketParentClient> = {},
|
||||||
|
storage?: Pick<Storage, "getItem" | "setItem" | "removeItem">,
|
||||||
|
) {
|
||||||
const client: TicketParentClient = {
|
const client: TicketParentClient = {
|
||||||
parent: async (_, id) => link(id), children: async (_, id) => page(id),
|
parent: async (_, id) => link(id),
|
||||||
|
children: async (_, id) => page(id),
|
||||||
search: async () => ({ items: [summary("8")], hasMore: false }),
|
search: async () => ({ items: [summary("8")], hasMore: false }),
|
||||||
resolve: async () => summary("8"), set: async () => link("1", "8", "set-1"), ...overrides,
|
resolve: async () => summary("8"),
|
||||||
|
set: async () => link("1", "8", "set-1"),
|
||||||
|
...overrides,
|
||||||
};
|
};
|
||||||
const controller = new TicketParentController(client, storage);
|
const controller = new TicketParentController(client, storage);
|
||||||
let state!: TicketParentState;
|
let state!: TicketParentState;
|
||||||
controller.subscribe((value) => { state = value; });
|
controller.subscribe((value) => {
|
||||||
return { controller, get state() { return state; } };
|
state = value;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
controller,
|
||||||
|
get state() {
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
Deno.test("same canonical parent and already-unlinked changes do not send or retain an operation", async () => {
|
Deno.test("same canonical parent and already-unlinked changes do not send or retain an operation", async () => {
|
||||||
for (const currentParent of ["8", null]) {
|
for (const currentParent of ["8", null]) {
|
||||||
@@ -31,34 +75,118 @@ Deno.test("same canonical parent and already-unlinked changes do not send or ret
|
|||||||
const observed = link("1", currentParent, "previous-operation");
|
const observed = link("1", currentParent, "previous-operation");
|
||||||
const h = harness({
|
const h = harness({
|
||||||
parent: async () => observed,
|
parent: async () => observed,
|
||||||
set: async () => { ++sends; return observed; },
|
set: async () => {
|
||||||
|
++sends;
|
||||||
|
return observed;
|
||||||
|
},
|
||||||
}, {
|
}, {
|
||||||
getItem: () => null,
|
getItem: () => null,
|
||||||
setItem: () => { ++retainedWrites; },
|
setItem: () => {
|
||||||
|
++retainedWrites;
|
||||||
|
},
|
||||||
removeItem: () => {},
|
removeItem: () => {},
|
||||||
});
|
});
|
||||||
await h.controller.select("ws", "1", observed, true);
|
await h.controller.select("ws", "1", observed, true);
|
||||||
await h.controller.change(currentParent === null ? null : { ...summary(currentParent), title: "Updated title" }, "unused-new-key");
|
await h.controller.change(
|
||||||
|
currentParent === null
|
||||||
|
? null
|
||||||
|
: { ...summary(currentParent), title: "Updated title" },
|
||||||
|
"unused-new-key",
|
||||||
|
);
|
||||||
assert(sends === 0 && retainedWrites === 0);
|
assert(sends === 0 && retainedWrites === 0);
|
||||||
assert(!h.state.pending && !h.state.saving && !h.state.saved && !h.state.operationError);
|
assert(
|
||||||
|
!h.state.pending && !h.state.saving && !h.state.saved &&
|
||||||
|
!h.state.operationError,
|
||||||
|
);
|
||||||
assert(h.state.parent?.observation.parent_ticket_id === currentParent);
|
assert(h.state.parent?.observation.parent_ticket_id === currentParent);
|
||||||
assert(h.state.parent?.observation.last_operation_key === "previous-operation");
|
assert(
|
||||||
|
h.state.parent?.observation.last_operation_key === "previous-operation",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
Deno.test("no-op choices during an unknown save preserve the full request and leave exact retry available", async () => {
|
||||||
|
for (const currentParent of ["8", null]) {
|
||||||
|
let observed = link("1", currentParent, "previous-operation");
|
||||||
|
const entries = new Map<string, string>();
|
||||||
|
const sent: { path: string; method: string; body?: string }[] = [];
|
||||||
|
let writes = 0;
|
||||||
|
const h = harness({
|
||||||
|
parent: async () => observed,
|
||||||
|
set: async (operation) => {
|
||||||
|
sent.push({ ...operation });
|
||||||
|
if (sent.length === 1) throw new TypeError("response lost");
|
||||||
|
observed = link("1", "9", "original-key");
|
||||||
|
return observed;
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
getItem: (key) => entries.get(key) ?? null,
|
||||||
|
setItem: (key, value) => {
|
||||||
|
++writes;
|
||||||
|
entries.set(key, value);
|
||||||
|
},
|
||||||
|
removeItem: (key) => {
|
||||||
|
entries.delete(key);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await h.controller.select("ws", "1", observed, true);
|
||||||
|
await h.controller.change(summary("9"), "original-key");
|
||||||
|
const retained = JSON.stringify([...entries]);
|
||||||
|
const pending = JSON.stringify(h.state.pending);
|
||||||
|
const error = h.state.operationError;
|
||||||
|
const retainedWrites = writes;
|
||||||
|
assert(h.state.pending?.key === "original-key" && !h.state.saved);
|
||||||
|
const originalBody = JSON.parse(sent[0].body!);
|
||||||
|
assert(originalBody.expected.parent_ticket_id === currentParent);
|
||||||
|
assert(originalBody.expected.last_operation_key === "previous-operation");
|
||||||
|
await h.controller.change(
|
||||||
|
currentParent === null ? null : summary(currentParent),
|
||||||
|
"unused-no-op-key",
|
||||||
|
);
|
||||||
|
assert(sent.length === 1 && writes === retainedWrites);
|
||||||
|
assert(JSON.stringify([...entries]) === retained);
|
||||||
|
assert(JSON.stringify(h.state.pending) === pending);
|
||||||
|
assert(h.state.operationError === error && !h.state.saved);
|
||||||
|
|
||||||
|
// desiredが現在の親と一致しても、別keyの再読では先行要求の成否を確定しない。
|
||||||
|
observed = link("1", "9", "intervening-operation");
|
||||||
|
await h.controller.readParent();
|
||||||
|
await h.controller.change(summary("9"), "unused-same-parent-key");
|
||||||
|
h.controller.useRefreshedObservation();
|
||||||
|
assert(sent.length === 1 && writes === retainedWrites);
|
||||||
|
assert(JSON.stringify([...entries]) === retained);
|
||||||
|
assert(JSON.stringify(h.state.pending) === pending);
|
||||||
|
assert(!h.state.saved && !h.state.saving);
|
||||||
|
|
||||||
|
// retryはchangeのno-op抑止を通さず、元のexpected/key/bodyを完全一致で送る。
|
||||||
|
await h.controller.retry();
|
||||||
|
assert(sent.length === 2);
|
||||||
|
assert(JSON.stringify(sent[1]) === JSON.stringify(sent[0]));
|
||||||
|
assert(entries.size === 0 && !h.state.pending && h.state.saved);
|
||||||
|
assert(h.state.parent?.observation.last_operation_key === "original-key");
|
||||||
|
}
|
||||||
|
});
|
||||||
Deno.test("unknown parent save retains exact key body and expected observation across navigation", async () => {
|
Deno.test("unknown parent save retains exact key body and expected observation across navigation", async () => {
|
||||||
const sent: string[] = [];
|
const sent: string[] = [];
|
||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
const h = harness({ set: async (operation) => {
|
const h = harness({
|
||||||
sent.push(operation.body!);
|
set: async (operation) => {
|
||||||
if (++attempt === 1) throw new TypeError("response lost");
|
sent.push(operation.body!);
|
||||||
return link("1", "8", "set-1");
|
if (++attempt === 1) throw new TypeError("response lost");
|
||||||
} });
|
return link("1", "8", "set-1");
|
||||||
await h.controller.select("ws", "1", link("1", null, "unlink-previous"), true);
|
},
|
||||||
|
});
|
||||||
|
await h.controller.select(
|
||||||
|
"ws",
|
||||||
|
"1",
|
||||||
|
link("1", null, "unlink-previous"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
await h.controller.change(summary("8"), "set-1");
|
await h.controller.change(summary("8"), "set-1");
|
||||||
assert(h.state.pending && !h.state.saved);
|
assert(h.state.pending && !h.state.saved);
|
||||||
const body = JSON.parse(sent[0]);
|
const body = JSON.parse(sent[0]);
|
||||||
assert(body.operation_key === "set-1" && body.expected.last_operation_key === null);
|
assert(
|
||||||
|
body.operation_key === "set-1" && body.expected.last_operation_key === null,
|
||||||
|
);
|
||||||
await h.controller.select("ws", "2", link("2"), true);
|
await h.controller.select("ws", "2", link("2"), true);
|
||||||
await h.controller.select("ws", "1", link("1"), true);
|
await h.controller.select("ws", "1", link("1"), true);
|
||||||
assert(h.state.pending && h.state.reconciled);
|
assert(h.state.pending && h.state.reconciled);
|
||||||
@@ -74,7 +202,11 @@ Deno.test("unknown parent save retains exact key body and expected observation a
|
|||||||
});
|
});
|
||||||
Deno.test("permission and CAS rejections never report success or automatically rebase", async () => {
|
Deno.test("permission and CAS rejections never report success or automatically rebase", async () => {
|
||||||
for (const status of [403, 409]) {
|
for (const status of [403, 409]) {
|
||||||
const h = harness({ set: async () => { throw new Error(`request failed (${status})`); } });
|
const h = harness({
|
||||||
|
set: async () => {
|
||||||
|
throw new Error(`request failed (${status})`);
|
||||||
|
},
|
||||||
|
});
|
||||||
await h.controller.select("ws", "1", link("1"), true);
|
await h.controller.select("ws", "1", link("1"), true);
|
||||||
await h.controller.change(summary("8"), `rejected-${status}`);
|
await h.controller.change(summary("8"), `rejected-${status}`);
|
||||||
assert(h.state.pending?.rejected && !h.state.saved);
|
assert(h.state.pending?.rejected && !h.state.saved);
|
||||||
@@ -88,7 +220,12 @@ Deno.test("permission and CAS rejections never report success or automatically r
|
|||||||
});
|
});
|
||||||
Deno.test("rejection after an ambiguous send cannot discard that earlier unknown outcome", async () => {
|
Deno.test("rejection after an ambiguous send cannot discard that earlier unknown outcome", async () => {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
const h = harness({ set: async () => { if (++count === 1) throw new TypeError("lost"); throw new Error("request failed (403)"); } });
|
const h = harness({
|
||||||
|
set: async () => {
|
||||||
|
if (++count === 1) throw new TypeError("lost");
|
||||||
|
throw new Error("request failed (403)");
|
||||||
|
},
|
||||||
|
});
|
||||||
await h.controller.select("ws", "1", link("1"), true);
|
await h.controller.select("ws", "1", link("1"), true);
|
||||||
await h.controller.change(summary("8"), "same-key");
|
await h.controller.change(summary("8"), "same-key");
|
||||||
await h.controller.retry();
|
await h.controller.retry();
|
||||||
@@ -99,11 +236,16 @@ Deno.test("rejection after an ambiguous send cannot discard that earlier unknown
|
|||||||
Deno.test("parent children and search responses from an old A epoch cannot overwrite A after ABA navigation", async () => {
|
Deno.test("parent children and search responses from an old A epoch cannot overwrite A after ABA navigation", async () => {
|
||||||
const oldParent = deferred<TicketParentLink>();
|
const oldParent = deferred<TicketParentLink>();
|
||||||
const oldChildren = deferred<TicketChildrenPage>();
|
const oldChildren = deferred<TicketChildrenPage>();
|
||||||
const oldSearch = deferred<{ items: TicketLinkSummary[]; hasMore: boolean }>();
|
const oldSearch = deferred<
|
||||||
|
{ items: TicketLinkSummary[]; hasMore: boolean }
|
||||||
|
>();
|
||||||
let reads = 0;
|
let reads = 0;
|
||||||
const h = harness({ parent: async (_, id) => ++reads === 1 ? oldParent.promise : link(id),
|
const h = harness({
|
||||||
children: async (_, id) => id === "1" && reads === 1 ? oldChildren.promise : page(id),
|
parent: async (_, id) => ++reads === 1 ? oldParent.promise : link(id),
|
||||||
search: async () => oldSearch.promise });
|
children: async (_, id) =>
|
||||||
|
id === "1" && reads === 1 ? oldChildren.promise : page(id),
|
||||||
|
search: async () => oldSearch.promise,
|
||||||
|
});
|
||||||
const first = h.controller.select("ws", "1", link("1"), true);
|
const first = h.controller.select("ws", "1", link("1"), true);
|
||||||
const searching = h.controller.search("old");
|
const searching = h.controller.search("old");
|
||||||
await h.controller.select("ws", "2", link("2"), true);
|
await h.controller.select("ws", "2", link("2"), true);
|
||||||
@@ -111,10 +253,15 @@ Deno.test("parent children and search responses from an old A epoch cannot overw
|
|||||||
oldParent.resolve(link("1", "8", "old"));
|
oldParent.resolve(link("1", "8", "old"));
|
||||||
oldChildren.resolve({ ...page("1"), items: [summary("9")] });
|
oldChildren.resolve({ ...page("1"), items: [summary("9")] });
|
||||||
oldSearch.resolve({ items: [summary("8")], hasMore: true });
|
oldSearch.resolve({ items: [summary("8")], hasMore: true });
|
||||||
await first; await searching;
|
await first;
|
||||||
|
await searching;
|
||||||
assert(h.state.parent?.observation.parent_ticket_id === null);
|
assert(h.state.parent?.observation.parent_ticket_id === null);
|
||||||
assert(h.state.children?.items.length === 0 && h.state.candidates.length === 0);
|
assert(
|
||||||
assert(!h.state.loadingParent && !h.state.loadingChildren && !h.state.searching);
|
h.state.children?.items.length === 0 && h.state.candidates.length === 0,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!h.state.loadingParent && !h.state.loadingChildren && !h.state.searching,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
Deno.test("old mutation success does not change a new route or clear its retained unknown attempt", async () => {
|
Deno.test("old mutation success does not change a new route or clear its retained unknown attempt", async () => {
|
||||||
const saving = deferred<TicketParentLink>();
|
const saving = deferred<TicketParentLink>();
|
||||||
@@ -122,51 +269,98 @@ Deno.test("old mutation success does not change a new route or clear its retaine
|
|||||||
await h.controller.select("ws", "1", link("1"), true);
|
await h.controller.select("ws", "1", link("1"), true);
|
||||||
const operation = h.controller.change(summary("8"), "old-save");
|
const operation = h.controller.change(summary("8"), "old-save");
|
||||||
await h.controller.select("ws", "2", link("2"), true);
|
await h.controller.select("ws", "2", link("2"), true);
|
||||||
saving.resolve(link("1", "8", "old-save")); await operation;
|
saving.resolve(link("1", "8", "old-save"));
|
||||||
|
await operation;
|
||||||
assert(h.state.ticketId === "2" && !h.state.saved && !h.state.pending);
|
assert(h.state.ticketId === "2" && !h.state.saved && !h.state.pending);
|
||||||
await h.controller.select("ws", "1", link("1"), true);
|
await h.controller.select("ws", "1", link("1"), true);
|
||||||
assert(h.state.pending?.key === "old-save" && !h.state.saved);
|
assert(h.state.pending?.key === "old-save" && !h.state.saved);
|
||||||
});
|
});
|
||||||
Deno.test("children continuation stays scoped and preserves rows when the next page is rejected", async () => {
|
Deno.test("children continuation stays scoped and preserves rows when the next page is rejected", async () => {
|
||||||
const cursors: (string | null)[] = [];
|
const cursors: (string | null)[] = [];
|
||||||
const h = harness({ children: async (_, id, cursor) => {
|
const h = harness({
|
||||||
cursors.push(cursor);
|
children: async (_, id, cursor) => {
|
||||||
return cursor === null ? { ...page(id), items: [summary("8")], next_cursor: "opaque", has_more: true }
|
cursors.push(cursor);
|
||||||
: { ...page("wrong-parent"), items: [summary("9")] };
|
return cursor === null
|
||||||
} });
|
? {
|
||||||
|
...page(id),
|
||||||
|
items: [summary("8")],
|
||||||
|
next_cursor: "opaque",
|
||||||
|
has_more: true,
|
||||||
|
}
|
||||||
|
: { ...page("wrong-parent"), items: [summary("9")] };
|
||||||
|
},
|
||||||
|
});
|
||||||
await h.controller.select("ws", "1", link("1"), true);
|
await h.controller.select("ws", "1", link("1"), true);
|
||||||
await h.controller.readChildren(true);
|
await h.controller.readChildren(true);
|
||||||
assert(cursors[0] === null && cursors[1] === "opaque");
|
assert(cursors[0] === null && cursors[1] === "opaque");
|
||||||
assert(h.state.children?.items[0].id === "8" && h.state.childrenError !== null);
|
assert(
|
||||||
|
h.state.children?.items[0].id === "8" && h.state.childrenError !== null,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
Deno.test("tab-session retained request survives component remount without rebasing or automatic resend", async () => {
|
Deno.test("tab-session retained request survives component remount without rebasing or automatic resend", async () => {
|
||||||
const entries = new Map<string, string>();
|
const entries = new Map<string, string>();
|
||||||
const storage = { getItem: (key: string) => entries.get(key) ?? null, setItem: (key: string, value: string) => { entries.set(key, value); }, removeItem: (key: string) => { entries.delete(key); } };
|
const storage = {
|
||||||
|
getItem: (key: string) => entries.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => {
|
||||||
|
entries.set(key, value);
|
||||||
|
},
|
||||||
|
removeItem: (key: string) => {
|
||||||
|
entries.delete(key);
|
||||||
|
},
|
||||||
|
};
|
||||||
const sent: string[] = [];
|
const sent: string[] = [];
|
||||||
const first = harness({ set: async (operation) => { sent.push(operation.body!); throw new TypeError("lost"); } }, storage);
|
const first = harness({
|
||||||
|
set: async (operation) => {
|
||||||
|
sent.push(operation.body!);
|
||||||
|
throw new TypeError("lost");
|
||||||
|
},
|
||||||
|
}, storage);
|
||||||
await first.controller.select("ws", "1", link("1"), true);
|
await first.controller.select("ws", "1", link("1"), true);
|
||||||
await first.controller.change(summary("8"), "tab-save");
|
await first.controller.change(summary("8"), "tab-save");
|
||||||
first.controller.dispose();
|
first.controller.dispose();
|
||||||
const next = harness({ set: async (operation) => { sent.push(operation.body!); return link("1", "8", "tab-save"); } }, storage);
|
const next = harness({
|
||||||
|
set: async (operation) => {
|
||||||
|
sent.push(operation.body!);
|
||||||
|
return link("1", "8", "tab-save");
|
||||||
|
},
|
||||||
|
}, storage);
|
||||||
await next.controller.select("ws", "1", link("1"), true);
|
await next.controller.select("ws", "1", link("1"), true);
|
||||||
assert(next.state.pending?.key === "tab-save" && sent.length === 1);
|
assert(next.state.pending?.key === "tab-save" && sent.length === 1);
|
||||||
|
await next.controller.change(null, "unused-unlink-key");
|
||||||
|
assert(next.state.pending?.key === "tab-save" && sent.length === 1);
|
||||||
await next.controller.retry();
|
await next.controller.retry();
|
||||||
assert(sent[0] === sent[1] && !next.state.pending && entries.size === 0);
|
assert(sent[0] === sent[1] && !next.state.pending && entries.size === 0);
|
||||||
});
|
});
|
||||||
Deno.test("unreadable request retention fails closed before any parent mutation", async () => {
|
Deno.test("unreadable request retention fails closed before any parent mutation", async () => {
|
||||||
let sends = 0;
|
let sends = 0;
|
||||||
const h = harness({ set: async () => { ++sends; return link("1"); } }, {
|
const h = harness({
|
||||||
getItem: () => "corrupt", setItem: () => {}, removeItem: () => {},
|
set: async () => {
|
||||||
|
++sends;
|
||||||
|
return link("1");
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
getItem: () => "corrupt",
|
||||||
|
setItem: () => {},
|
||||||
|
removeItem: () => {},
|
||||||
});
|
});
|
||||||
await h.controller.select("ws", "1", link("1"), true);
|
await h.controller.select("ws", "1", link("1"), true);
|
||||||
await h.controller.change(summary("8"), "do-not-send");
|
await h.controller.change(summary("8"), "do-not-send");
|
||||||
assert(h.state.retentionError && sends === 0);
|
assert(h.state.retentionError && sends === 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("ineligible parent actions cannot search or mutate even with visible links", async () => {
|
Deno.test("ineligible parent actions cannot search or mutate even with visible links", async () => {
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
const h = harness({ search: async () => { ++calls; return { items: [], hasMore: false }; }, set: async () => { ++calls; return link("1"); } });
|
const h = harness({
|
||||||
|
search: async () => {
|
||||||
|
++calls;
|
||||||
|
return { items: [], hasMore: false };
|
||||||
|
},
|
||||||
|
set: async () => {
|
||||||
|
++calls;
|
||||||
|
return link("1");
|
||||||
|
},
|
||||||
|
});
|
||||||
await h.controller.select("ws", "1", link("1", "8"), false);
|
await h.controller.select("ws", "1", link("1", "8"), false);
|
||||||
await h.controller.search("anything"); await h.controller.change(null, "forbidden");
|
await h.controller.search("anything");
|
||||||
|
await h.controller.change(null, "forbidden");
|
||||||
assert(calls === 0 && !h.state.pending && !h.state.saved);
|
assert(calls === 0 && !h.state.pending && !h.state.saved);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import type { TicketChildrenPage, TicketLinkSummary, TicketParentLink, TicketParentSetRequest } from "#lib/generated/ticket-api.ts";
|
import type {
|
||||||
|
TicketChildrenPage,
|
||||||
|
TicketLinkSummary,
|
||||||
|
TicketParentLink,
|
||||||
|
TicketParentSetRequest,
|
||||||
|
} from "#lib/generated/ticket-api.ts";
|
||||||
import type { ObservedOperation } from "#lib/workspace/api/ticket-operations.ts";
|
import type { ObservedOperation } from "#lib/workspace/api/ticket-operations.ts";
|
||||||
import { parentOperation, type TicketParentClient } from "#lib/workspace/api/ticket-parent.ts";
|
import {
|
||||||
|
parentOperation,
|
||||||
|
type TicketParentClient,
|
||||||
|
} from "#lib/workspace/api/ticket-parent.ts";
|
||||||
|
|
||||||
export const MAX_VISIBLE_CHILDREN = 500;
|
export const MAX_VISIBLE_CHILDREN = 500;
|
||||||
type Attempt = { operation: ObservedOperation; key: string; rejected: boolean };
|
type Attempt = { operation: ObservedOperation; key: string; rejected: boolean };
|
||||||
@@ -26,19 +34,43 @@ export type TicketParentState = {
|
|||||||
saved: boolean;
|
saved: boolean;
|
||||||
};
|
};
|
||||||
const empty = (): TicketParentState => ({
|
const empty = (): TicketParentState => ({
|
||||||
workspaceId: "", ticketId: "", canSetParent: false, parent: null, children: null,
|
workspaceId: "",
|
||||||
candidates: [], candidatesTruncated: false, loadingParent: false, loadingChildren: false,
|
ticketId: "",
|
||||||
searching: false, saving: false, parentError: null, childrenError: null,
|
canSetParent: false,
|
||||||
searchError: null, operationError: null, retentionError: null, pending: null, reconciled: false, saved: false,
|
parent: null,
|
||||||
|
children: null,
|
||||||
|
candidates: [],
|
||||||
|
candidatesTruncated: false,
|
||||||
|
loadingParent: false,
|
||||||
|
loadingChildren: false,
|
||||||
|
searching: false,
|
||||||
|
saving: false,
|
||||||
|
parentError: null,
|
||||||
|
childrenError: null,
|
||||||
|
searchError: null,
|
||||||
|
operationError: null,
|
||||||
|
retentionError: null,
|
||||||
|
pending: null,
|
||||||
|
reconciled: false,
|
||||||
|
saved: false,
|
||||||
});
|
});
|
||||||
function rejected(cause: unknown): boolean {
|
function rejected(cause: unknown): boolean {
|
||||||
return cause instanceof Error && /\((400|401|403|404|409|422)\)/.test(cause.message);
|
return cause instanceof Error &&
|
||||||
|
/\((400|401|403|404|409|422)\)/.test(cause.message);
|
||||||
}
|
}
|
||||||
function failure(cause: unknown): string {
|
function failure(cause: unknown): string {
|
||||||
const message = cause instanceof Error ? cause.message : "Request could not be completed.";
|
const message = cause instanceof Error
|
||||||
if (/\(401\)|\(403\)/.test(message)) return "Permission denied. No parent change is confirmed.";
|
? cause.message
|
||||||
if (/\(409\)/.test(message)) return "Parent update conflict. Reread the parent before choosing a new change.";
|
: "Request could not be completed.";
|
||||||
if (rejected(cause)) return "Parent request rejected. Reread the parent before trying a new change.";
|
if (/\(401\)|\(403\)/.test(message)) {
|
||||||
|
return "Permission denied. No parent change is confirmed.";
|
||||||
|
}
|
||||||
|
if (/\(409\)/.test(message)) {
|
||||||
|
return "Parent update conflict. Reread the parent before choosing a new change.";
|
||||||
|
}
|
||||||
|
if (rejected(cause)) {
|
||||||
|
return "Parent request rejected. Reread the parent before trying a new change.";
|
||||||
|
}
|
||||||
return "Outcome unavailable or unknown. No success is inferred; retain the exact request for retry.";
|
return "Outcome unavailable or unknown. No success is inferred; retain the exact request for retry.";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,17 +81,26 @@ export class TicketParentController {
|
|||||||
private epoch = 0;
|
private epoch = 0;
|
||||||
private searchEpoch = 0;
|
private searchEpoch = 0;
|
||||||
private identity = "";
|
private identity = "";
|
||||||
constructor(private client: TicketParentClient, private storage?: Pick<Storage, "getItem" | "setItem" | "removeItem">) {}
|
constructor(
|
||||||
private storageKey(): string { return `yoi.ticket-parent-attempt:${this.identity}`; }
|
private client: TicketParentClient,
|
||||||
|
private storage?: Pick<Storage, "getItem" | "setItem" | "removeItem">,
|
||||||
|
) {}
|
||||||
|
private storageKey(): string {
|
||||||
|
return `yoi.ticket-parent-attempt:${this.identity}`;
|
||||||
|
}
|
||||||
private retain(attempt: Attempt | null): boolean {
|
private retain(attempt: Attempt | null): boolean {
|
||||||
try {
|
try {
|
||||||
if (attempt) this.storage?.setItem(this.storageKey(), JSON.stringify(attempt));
|
if (attempt) {
|
||||||
else this.storage?.removeItem(this.storageKey());
|
this.storage?.setItem(this.storageKey(), JSON.stringify(attempt));
|
||||||
|
} else this.storage?.removeItem(this.storageKey());
|
||||||
if (attempt) this.attempts.set(this.identity, attempt);
|
if (attempt) this.attempts.set(this.identity, attempt);
|
||||||
else this.attempts.delete(this.identity);
|
else this.attempts.delete(this.identity);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
this.patch({ retentionError: "Parent request retention is unavailable. Keep this page open; new changes are blocked." });
|
this.patch({
|
||||||
|
retentionError:
|
||||||
|
"Parent request retention is unavailable. Keep this page open; new changes are blocked.",
|
||||||
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,43 +111,92 @@ export class TicketParentController {
|
|||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
if (raw.length > 16_384) throw new Error("Invalid retained request");
|
if (raw.length > 16_384) throw new Error("Invalid retained request");
|
||||||
const value = JSON.parse(raw);
|
const value = JSON.parse(raw);
|
||||||
if (!value || typeof value !== "object" || typeof value.operation?.body !== "string" || typeof value.rejected !== "boolean") throw new Error("Invalid retained request");
|
if (
|
||||||
|
!value || typeof value !== "object" ||
|
||||||
|
typeof value.operation?.body !== "string" ||
|
||||||
|
typeof value.rejected !== "boolean"
|
||||||
|
) throw new Error("Invalid retained request");
|
||||||
const body = JSON.parse(value.operation.body);
|
const body = JSON.parse(value.operation.body);
|
||||||
const nullable = (item: unknown) => item === null || typeof item === "string";
|
const nullable = (item: unknown) =>
|
||||||
if (!body || typeof body !== "object" || typeof body.operation_key !== "string" || !body.operation_key || !nullable(body.parent) || !body.expected || !nullable(body.expected.parent_ticket_id) || !nullable(body.expected.last_operation_key)) throw new Error("Invalid retained request");
|
item === null || typeof item === "string";
|
||||||
|
if (
|
||||||
|
!body || typeof body !== "object" ||
|
||||||
|
typeof body.operation_key !== "string" || !body.operation_key ||
|
||||||
|
!nullable(body.parent) || !body.expected ||
|
||||||
|
!nullable(body.expected.parent_ticket_id) ||
|
||||||
|
!nullable(body.expected.last_operation_key)
|
||||||
|
) throw new Error("Invalid retained request");
|
||||||
const request: TicketParentSetRequest = {
|
const request: TicketParentSetRequest = {
|
||||||
expected: { parent_ticket_id: body.expected.parent_ticket_id, last_operation_key: body.expected.last_operation_key },
|
expected: {
|
||||||
parent: body.parent, operation_key: body.operation_key,
|
parent_ticket_id: body.expected.parent_ticket_id,
|
||||||
|
last_operation_key: body.expected.last_operation_key,
|
||||||
|
},
|
||||||
|
parent: body.parent,
|
||||||
|
operation_key: body.operation_key,
|
||||||
|
};
|
||||||
|
const operation = parentOperation(
|
||||||
|
workspaceId,
|
||||||
|
{ ticket_id: ticketId, observation: request.expected, parent: null },
|
||||||
|
request.parent,
|
||||||
|
request.operation_key,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
operation.path !== value.operation.path ||
|
||||||
|
operation.method !== value.operation.method ||
|
||||||
|
operation.body !== value.operation.body ||
|
||||||
|
value.key !== request.operation_key
|
||||||
|
) throw new Error("Invalid retained request");
|
||||||
|
const attempt: Attempt = {
|
||||||
|
operation,
|
||||||
|
key: request.operation_key,
|
||||||
|
rejected: value.rejected,
|
||||||
};
|
};
|
||||||
const operation = parentOperation(workspaceId, { ticket_id: ticketId, observation: request.expected, parent: null }, request.parent, request.operation_key);
|
|
||||||
if (operation.path !== value.operation.path || operation.method !== value.operation.method || operation.body !== value.operation.body || value.key !== request.operation_key) throw new Error("Invalid retained request");
|
|
||||||
const attempt: Attempt = { operation, key: request.operation_key, rejected: value.rejected };
|
|
||||||
this.attempts.set(this.identity, attempt);
|
this.attempts.set(this.identity, attempt);
|
||||||
if (!attempt.rejected) this.unknownKeys.add(attempt.key);
|
if (!attempt.rejected) this.unknownKeys.add(attempt.key);
|
||||||
return attempt;
|
return attempt;
|
||||||
}
|
}
|
||||||
subscribe = (listener: (state: TicketParentState) => void): (() => void) => {
|
subscribe = (listener: (state: TicketParentState) => void): () => void => {
|
||||||
this.listeners.add(listener); listener(this.value);
|
this.listeners.add(listener);
|
||||||
return () => { this.listeners.delete(listener); };
|
listener(this.value);
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
};
|
||||||
};
|
};
|
||||||
private patch(update: Partial<TicketParentState>): void {
|
private patch(update: Partial<TicketParentState>): void {
|
||||||
this.value = { ...this.value, ...update };
|
this.value = { ...this.value, ...update };
|
||||||
for (const listener of this.listeners) listener(this.value);
|
for (const listener of this.listeners) listener(this.value);
|
||||||
}
|
}
|
||||||
async select(workspaceId: string, ticketId: string, initial: TicketParentLink, canSetParent: boolean): Promise<void> {
|
async select(
|
||||||
++this.epoch; ++this.searchEpoch;
|
workspaceId: string,
|
||||||
|
ticketId: string,
|
||||||
|
initial: TicketParentLink,
|
||||||
|
canSetParent: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
++this.epoch;
|
||||||
|
++this.searchEpoch;
|
||||||
this.identity = JSON.stringify([workspaceId, ticketId]);
|
this.identity = JSON.stringify([workspaceId, ticketId]);
|
||||||
this.value = empty();
|
this.value = empty();
|
||||||
let pending: Attempt | null = null;
|
let pending: Attempt | null = null;
|
||||||
let retentionError: string | null = null;
|
let retentionError: string | null = null;
|
||||||
try { pending = this.restore(workspaceId, ticketId); }
|
try {
|
||||||
catch { retentionError = "Retained parent request cannot be read. New changes are blocked; do not discard an unknown operation."; }
|
pending = this.restore(workspaceId, ticketId);
|
||||||
this.patch({ workspaceId, ticketId, parent: initial.ticket_id === ticketId ? initial : null,
|
} catch {
|
||||||
canSetParent, pending, retentionError });
|
retentionError =
|
||||||
|
"Retained parent request cannot be read. New changes are blocked; do not discard an unknown operation.";
|
||||||
|
}
|
||||||
|
this.patch({
|
||||||
|
workspaceId,
|
||||||
|
ticketId,
|
||||||
|
parent: initial.ticket_id === ticketId ? initial : null,
|
||||||
|
canSetParent,
|
||||||
|
pending,
|
||||||
|
retentionError,
|
||||||
|
});
|
||||||
await Promise.all([this.readParent(), this.readChildren()]);
|
await Promise.all([this.readParent(), this.readChildren()]);
|
||||||
}
|
}
|
||||||
suspend(): void {
|
suspend(): void {
|
||||||
++this.epoch; ++this.searchEpoch;
|
++this.epoch;
|
||||||
|
++this.searchEpoch;
|
||||||
this.identity = "";
|
this.identity = "";
|
||||||
this.value = empty();
|
this.value = empty();
|
||||||
this.patch({});
|
this.patch({});
|
||||||
@@ -125,94 +215,175 @@ export class TicketParentController {
|
|||||||
try {
|
try {
|
||||||
const link = await this.client.parent(workspaceId, ticketId);
|
const link = await this.client.parent(workspaceId, ticketId);
|
||||||
if (epoch !== this.epoch) return;
|
if (epoch !== this.epoch) return;
|
||||||
if (link.ticket_id !== ticketId) throw new Error("Wrong parent response identity");
|
if (link.ticket_id !== ticketId) {
|
||||||
|
throw new Error("Wrong parent response identity");
|
||||||
|
}
|
||||||
const pending = this.value.pending;
|
const pending = this.value.pending;
|
||||||
const saved = pending !== null && link.observation.last_operation_key === pending.key;
|
const saved = pending !== null &&
|
||||||
|
link.observation.last_operation_key === pending.key;
|
||||||
if (saved) this.retain(null);
|
if (saved) this.retain(null);
|
||||||
this.patch({ parent: link, reconciled: true,
|
this.patch({
|
||||||
...(saved ? { pending: null, saved: true, operationError: null } : {}) });
|
parent: link,
|
||||||
|
reconciled: true,
|
||||||
|
...(saved ? { pending: null, saved: true, operationError: null } : {}),
|
||||||
|
});
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (epoch === this.epoch) this.patch({ parentError: failure(cause),
|
if (epoch === this.epoch) {
|
||||||
...(cause instanceof Error && /\(401\)|\(403\)/.test(cause.message) ? { canSetParent: false } : {}) });
|
this.patch({
|
||||||
|
parentError: failure(cause),
|
||||||
|
...(cause instanceof Error && /\(401\)|\(403\)/.test(cause.message)
|
||||||
|
? { canSetParent: false }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (epoch === this.epoch) this.patch({ loadingParent: false });
|
||||||
}
|
}
|
||||||
finally { if (epoch === this.epoch) this.patch({ loadingParent: false }); }
|
|
||||||
}
|
}
|
||||||
async readChildren(more = false): Promise<void> {
|
async readChildren(more = false): Promise<void> {
|
||||||
if (this.value.loadingChildren) return;
|
if (this.value.loadingChildren) return;
|
||||||
const previous = this.value.children;
|
const previous = this.value.children;
|
||||||
if (more && (!previous?.has_more || !previous.next_cursor || previous.items.length >= MAX_VISIBLE_CHILDREN)) return;
|
let cursor: string | null = null;
|
||||||
|
if (more) {
|
||||||
|
const nextCursor = previous?.next_cursor;
|
||||||
|
if (
|
||||||
|
!previous?.has_more || typeof nextCursor !== "string" ||
|
||||||
|
nextCursor.length === 0 || previous.items.length >= MAX_VISIBLE_CHILDREN
|
||||||
|
) return;
|
||||||
|
cursor = nextCursor;
|
||||||
|
}
|
||||||
const epoch = this.epoch;
|
const epoch = this.epoch;
|
||||||
const { workspaceId, ticketId } = this.value;
|
const { workspaceId, ticketId } = this.value;
|
||||||
const cursor = more ? previous!.next_cursor : null;
|
|
||||||
this.patch({ loadingChildren: true, childrenError: null });
|
this.patch({ loadingChildren: true, childrenError: null });
|
||||||
try {
|
try {
|
||||||
const page = await this.client.children(workspaceId, ticketId, cursor);
|
const page = await this.client.children(workspaceId, ticketId, cursor);
|
||||||
if (epoch !== this.epoch) return;
|
if (epoch !== this.epoch) return;
|
||||||
if (page.parent_ticket_id !== ticketId || page.items.length > 50 || (page.has_more && page.next_cursor === cursor)) {
|
if (
|
||||||
|
page.parent_ticket_id !== ticketId || page.items.length > 50 ||
|
||||||
|
(page.has_more && page.next_cursor === cursor)
|
||||||
|
) {
|
||||||
throw new Error("Invalid children response identity or cursor");
|
throw new Error("Invalid children response identity or cursor");
|
||||||
}
|
}
|
||||||
const items = more && previous
|
const items = more && previous
|
||||||
? [...new Map([...previous.items, ...page.items].map((item) => [item.id, item])).values()]
|
? [
|
||||||
|
...new Map(
|
||||||
|
[...previous.items, ...page.items].map((item) => [item.id, item]),
|
||||||
|
).values(),
|
||||||
|
]
|
||||||
: page.items;
|
: page.items;
|
||||||
this.patch({ children: { ...page, items } });
|
this.patch({ children: { ...page, items } });
|
||||||
} catch (cause) { if (epoch === this.epoch) this.patch({ childrenError: failure(cause) }); }
|
} catch (cause) {
|
||||||
finally { if (epoch === this.epoch) this.patch({ loadingChildren: false }); }
|
if (epoch === this.epoch) this.patch({ childrenError: failure(cause) });
|
||||||
|
} finally {
|
||||||
|
if (epoch === this.epoch) this.patch({ loadingChildren: false });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async search(input: string, reference = false): Promise<void> {
|
async search(input: string, reference = false): Promise<void> {
|
||||||
if (!this.value.canSetParent || this.value.pending || !input.trim()) return;
|
if (!this.value.canSetParent || this.value.pending || !input.trim()) return;
|
||||||
const epoch = this.epoch;
|
const epoch = this.epoch;
|
||||||
const searchEpoch = ++this.searchEpoch;
|
const searchEpoch = ++this.searchEpoch;
|
||||||
const { workspaceId, ticketId } = this.value;
|
const { workspaceId, ticketId } = this.value;
|
||||||
this.patch({ searching: true, candidates: [], candidatesTruncated: false, searchError: null });
|
this.patch({
|
||||||
|
searching: true,
|
||||||
|
candidates: [],
|
||||||
|
candidatesTruncated: false,
|
||||||
|
searchError: null,
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const result = reference
|
const result = reference
|
||||||
? { items: [await this.client.resolve(workspaceId, input.trim())], hasMore: false }
|
? {
|
||||||
|
items: [await this.client.resolve(workspaceId, input.trim())],
|
||||||
|
hasMore: false,
|
||||||
|
}
|
||||||
: await this.client.search(workspaceId, input.trim());
|
: await this.client.search(workspaceId, input.trim());
|
||||||
if (epoch !== this.epoch || searchEpoch !== this.searchEpoch) return;
|
if (epoch !== this.epoch || searchEpoch !== this.searchEpoch) return;
|
||||||
this.patch({ candidates: result.items.filter((item) => item.id !== ticketId), candidatesTruncated: result.hasMore });
|
this.patch({
|
||||||
|
candidates: result.items.filter((item) => item.id !== ticketId),
|
||||||
|
candidatesTruncated: result.hasMore,
|
||||||
|
});
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (epoch === this.epoch && searchEpoch === this.searchEpoch) this.patch({ searchError: failure(cause) });
|
if (epoch === this.epoch && searchEpoch === this.searchEpoch) {
|
||||||
|
this.patch({ searchError: failure(cause) });
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (epoch === this.epoch && searchEpoch === this.searchEpoch) this.patch({ searching: false });
|
if (epoch === this.epoch && searchEpoch === this.searchEpoch) {
|
||||||
|
this.patch({ searching: false });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async change(parent: TicketLinkSummary | null, operationKey: string): Promise<void> {
|
async change(
|
||||||
|
parent: TicketLinkSummary | null,
|
||||||
|
operationKey: string,
|
||||||
|
): Promise<void> {
|
||||||
const state = this.value;
|
const state = this.value;
|
||||||
if (!state.canSetParent || state.retentionError || !state.parent || state.parentError || state.loadingParent || state.saving || state.pending || parent?.id === state.ticketId) return;
|
if (
|
||||||
|
!state.canSetParent || state.retentionError || !state.parent ||
|
||||||
|
state.parentError || state.loadingParent || state.saving ||
|
||||||
|
state.pending ||
|
||||||
|
parent?.id === state.ticketId
|
||||||
|
) return;
|
||||||
const desired = parent?.id ?? null;
|
const desired = parent?.id ?? null;
|
||||||
if (desired === state.parent.observation.parent_ticket_id) return;
|
if (desired === state.parent.observation.parent_ticket_id) return;
|
||||||
const attempt: Attempt = {
|
const attempt: Attempt = {
|
||||||
operation: parentOperation(state.workspaceId, state.parent, desired, operationKey),
|
operation: parentOperation(
|
||||||
key: operationKey, rejected: false,
|
state.workspaceId,
|
||||||
|
state.parent,
|
||||||
|
desired,
|
||||||
|
operationKey,
|
||||||
|
),
|
||||||
|
key: operationKey,
|
||||||
|
rejected: false,
|
||||||
};
|
};
|
||||||
await this.run(attempt);
|
await this.run(attempt);
|
||||||
}
|
}
|
||||||
async retry(): Promise<void> {
|
async retry(): Promise<void> {
|
||||||
if (this.value.pending && this.value.canSetParent) await this.run(this.value.pending);
|
if (this.value.pending && this.value.canSetParent) {
|
||||||
|
await this.run(this.value.pending);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
private async run(attempt: Attempt): Promise<void> {
|
private async run(attempt: Attempt): Promise<void> {
|
||||||
if (this.value.saving || this.value.loadingParent || this.value.retentionError) return;
|
if (
|
||||||
|
this.value.saving || this.value.loadingParent || this.value.retentionError
|
||||||
|
) return;
|
||||||
if (!this.retain({ ...attempt, rejected: false })) return;
|
if (!this.retain({ ...attempt, rejected: false })) return;
|
||||||
const epoch = this.epoch;
|
const epoch = this.epoch;
|
||||||
const previouslyUnknown = this.unknownKeys.has(attempt.key);
|
const previouslyUnknown = this.unknownKeys.has(attempt.key);
|
||||||
this.unknownKeys.add(attempt.key);
|
this.unknownKeys.add(attempt.key);
|
||||||
++this.searchEpoch;
|
++this.searchEpoch;
|
||||||
this.patch({ saving: true, searching: false, pending: attempt, saved: false, operationError: null, reconciled: false });
|
this.patch({
|
||||||
|
saving: true,
|
||||||
|
searching: false,
|
||||||
|
pending: attempt,
|
||||||
|
saved: false,
|
||||||
|
operationError: null,
|
||||||
|
reconciled: false,
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const result = await this.client.set(attempt.operation);
|
const result = await this.client.set(attempt.operation);
|
||||||
if (epoch !== this.epoch) return;
|
if (epoch !== this.epoch) return;
|
||||||
if (result.ticket_id !== this.value.ticketId || result.observation.last_operation_key !== attempt.key) throw new Error("Wrong mutation response identity");
|
if (
|
||||||
|
result.ticket_id !== this.value.ticketId ||
|
||||||
|
result.observation.last_operation_key !== attempt.key
|
||||||
|
) throw new Error("Wrong mutation response identity");
|
||||||
this.retain(null);
|
this.retain(null);
|
||||||
this.unknownKeys.delete(attempt.key);
|
this.unknownKeys.delete(attempt.key);
|
||||||
this.patch({ pending: null, saved: true, candidates: [] });
|
this.patch({ pending: null, saved: true, candidates: [] });
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (epoch === this.epoch) {
|
if (epoch === this.epoch) {
|
||||||
// 後の拒否応答では、先行する応答消失時の保存有無を否定できない。
|
// 後の拒否応答では、先行する応答消失時の保存有無を否定できない。
|
||||||
const updated = { ...attempt, rejected: rejected(cause) && !previouslyUnknown };
|
const updated = {
|
||||||
|
...attempt,
|
||||||
|
rejected: rejected(cause) && !previouslyUnknown,
|
||||||
|
};
|
||||||
if (updated.rejected) this.unknownKeys.delete(attempt.key);
|
if (updated.rejected) this.unknownKeys.delete(attempt.key);
|
||||||
this.retain(updated);
|
this.retain(updated);
|
||||||
this.patch({ pending: updated, operationError: failure(cause),
|
this.patch({
|
||||||
...(cause instanceof Error && /\(401\)|\(403\)/.test(cause.message) ? { canSetParent: false } : {}) });
|
pending: updated,
|
||||||
|
operationError: failure(cause),
|
||||||
|
...(cause instanceof Error && /\(401\)|\(403\)/.test(cause.message)
|
||||||
|
? { canSetParent: false }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (epoch === this.epoch) this.patch({ saving: false });
|
if (epoch === this.epoch) this.patch({ saving: false });
|
||||||
@@ -222,9 +393,16 @@ export class TicketParentController {
|
|||||||
}
|
}
|
||||||
private unknownKeys = new Set<string>();
|
private unknownKeys = new Set<string>();
|
||||||
useRefreshedObservation(): void {
|
useRefreshedObservation(): void {
|
||||||
if (this.value.saving || !this.value.reconciled || !this.value.pending?.rejected) return;
|
if (
|
||||||
|
this.value.saving || !this.value.reconciled ||
|
||||||
|
!this.value.pending?.rejected
|
||||||
|
) return;
|
||||||
if (!this.retain(null)) return;
|
if (!this.retain(null)) return;
|
||||||
this.patch({ pending: null, operationError: null, saved: false });
|
this.patch({ pending: null, operationError: null, saved: false });
|
||||||
}
|
}
|
||||||
dispose(): void { ++this.epoch; ++this.searchEpoch; this.listeners.clear(); }
|
dispose(): void {
|
||||||
|
++this.epoch;
|
||||||
|
++this.searchEpoch;
|
||||||
|
this.listeners.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,15 +62,26 @@ const tags: readonly WorkspaceTag[] = Object.freeze([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
Deno.test("parent filters compose with standard filters without leaking into free text", () => {
|
Deno.test("parent filters compose with standard filters without leaking into free text", () => {
|
||||||
const request = compileTicketSearch('parent:T-804 state:closed reason:duplicate tag:Ready assignee:orchestrator retain words', tags);
|
const request = compileTicketSearch(
|
||||||
|
"parent:T-804 state:closed reason:duplicate tag:Ready assignee:orchestrator retain words",
|
||||||
|
tags,
|
||||||
|
);
|
||||||
assertEquals(request.parent, { kind: "parent", ticket: "T-804" });
|
assertEquals(request.parent, { kind: "parent", ticket: "T-804" });
|
||||||
assertEquals(request.states, ["closed"]);
|
assertEquals(request.states, ["closed"]);
|
||||||
assertEquals(request.closure_reason, "duplicate");
|
assertEquals(request.closure_reason, "duplicate");
|
||||||
assertEquals(request.tag_ids, ["tag-1"]);
|
assertEquals(request.tag_ids, ["tag-1"]);
|
||||||
assertEquals(request.assigned_principal, { kind: "workspace_agent", agent_key: "workspace-orchestrator" });
|
assertEquals(request.assigned_principal, {
|
||||||
|
kind: "workspace_agent",
|
||||||
|
agent_key: "workspace-orchestrator",
|
||||||
|
});
|
||||||
assertEquals(request.query, "retain words");
|
assertEquals(request.query, "retain words");
|
||||||
assertEquals(compileTicketSearch("parent:none parent:none", tags).parent, { kind: "none" });
|
assertEquals(compileTicketSearch("parent:none parent:none", tags).parent, {
|
||||||
assertEquals(compileTicketSearch('"parent:none" other:field', tags).query, "parent:none other:field");
|
kind: "none",
|
||||||
|
});
|
||||||
|
assertEquals(
|
||||||
|
compileTicketSearch('"parent:none" other:field', tags).query,
|
||||||
|
"parent:none other:field",
|
||||||
|
);
|
||||||
assertEquals(compileTicketSearch("ordinary text", tags).parent, undefined);
|
assertEquals(compileTicketSearch("ordinary text", tags).parent, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -166,7 +166,9 @@ export function compileTicketSearch(
|
|||||||
break;
|
break;
|
||||||
case "parent": {
|
case "parent": {
|
||||||
if (value !== "none" && !/^T-[1-9][0-9]*$/.test(value)) {
|
if (value !== "none" && !/^T-[1-9][0-9]*$/.test(value)) {
|
||||||
throw new Error("Invalid parent: value; use none or a Ticket key such as T-804.");
|
throw new Error(
|
||||||
|
"Invalid parent: value; use none or a Ticket key such as T-804.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const parent: TicketParentFilter = value === "none"
|
const parent: TicketParentFilter = value === "none"
|
||||||
? { kind: "none" }
|
? { kind: "none" }
|
||||||
|
|||||||
@@ -309,7 +309,10 @@
|
|||||||
<summary>Search syntax</summary>
|
<summary>Search syntax</summary>
|
||||||
<p>Combine text with <code>state:open</code> or <code>state:closed</code>. Omit state to search both.</p>
|
<p>Combine text with <code>state:open</code> or <code>state:closed</code>. Omit state to search both.</p>
|
||||||
<p><code>tag:"UI bug"</code> · <code>assignee:subject:SUBJECT_ID</code> · <code>reason:completed</code></p>
|
<p><code>tag:"UI bug"</code> · <code>assignee:subject:SUBJECT_ID</code> · <code>reason:completed</code></p>
|
||||||
<p><code>parent:none</code> finds Tickets without a parent; <code>parent:T-804</code> finds direct children.</p>
|
<p>
|
||||||
|
<code>parent:none</code> finds Tickets without a parent;
|
||||||
|
<code>parent:T-804</code> finds direct children.
|
||||||
|
</p>
|
||||||
<p>Other principals: <code>assignee:user/account-id</code> or <code>assignee:worker/runtime-id/worker-id</code>.
|
<p>Other principals: <code>assignee:user/account-id</code> or <code>assignee:worker/runtime-id/worker-id</code>.
|
||||||
Custom close reason: <code>reason:"Superseded plan"</code>.</p>
|
Custom close reason: <code>reason:"Superseded plan"</code>.</p>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -590,7 +590,11 @@
|
|||||||
{/key}
|
{/key}
|
||||||
|
|
||||||
<BevelLine decorative />
|
<BevelLine decorative />
|
||||||
<TicketHierarchy workspaceId={data.workspaceId} routeTicketId={data.ticketId} {ticket} />
|
<TicketHierarchy
|
||||||
|
workspaceId={data.workspaceId}
|
||||||
|
routeTicketId={data.ticketId}
|
||||||
|
{ticket}
|
||||||
|
/>
|
||||||
<BevelLine decorative />
|
<BevelLine decorative />
|
||||||
<section class="ticket-detail-section ticket-relations">
|
<section class="ticket-detail-section ticket-relations">
|
||||||
<div class="ticket-section-heading">
|
<div class="ticket-section-heading">
|
||||||
|
|||||||
Reference in New Issue
Block a user