Merge remote-tracking branch 'origin/develop' into work/t-808-subject-chat

# Conflicts:
#	openapi/server-api.json
#	web/workspace/deno.json
#	web/workspace/src/lib/generated/companion-api.ts
#	web/workspace/src/lib/generated/repository-api.ts
#	web/workspace/src/lib/generated/runtime-api.ts
#	web/workspace/src/lib/generated/ticket-api.ts
#	web/workspace/src/lib/generated/worker-launch-api.ts
This commit is contained in:
2026-10-31 15:54:20 +09:00
72 changed files with 7337 additions and 90 deletions
+55
View File
@@ -92,6 +92,20 @@ impl BackendWorkspaceProductClient {
.map_err(|error| crate::backend_workspace::server_client_error(&self.api, error)) .map_err(|error| crate::backend_workspace::server_client_error(&self.api, error))
} }
pub fn read_ticket_link_summary(
&self,
reference: &str,
) -> Result<ticket::TicketLinkSummary, BackendWorkspaceClientError> {
let workspace_id = self.workspace_id.clone();
let reference = reference.to_owned();
self.generated(move |client| async move {
client
.ticket_link_summary_get(workspace_id, reference)
.await
.map(|response| response.0)
})
}
pub fn list_workspace_tags( pub fn list_workspace_tags(
&self, &self,
request: &ticket::TagListRequest, request: &ticket::TagListRequest,
@@ -602,6 +616,47 @@ impl BackendWorkspaceProductClient {
} }
impl TicketBackend for BackendWorkspaceProductClient { impl TicketBackend for BackendWorkspaceProductClient {
fn get_ticket_parent(&self, id: TicketIdOrSlug) -> ticket::Result<ticket::TicketParentLink> {
let workspace_id = self.workspace_id.clone();
let id = ticket_reference(&id);
self.generated(move |client| async move {
client
.ticket_parent_get(workspace_id, id)
.await
.map(|response| response.0)
})
.map_err(ticket_client_error)
}
fn set_ticket_parent(
&self,
id: TicketIdOrSlug,
request: ticket::TicketParentSetRequest,
) -> ticket::Result<ticket::TicketParentLink> {
let workspace_id = self.workspace_id.clone();
let id = ticket_reference(&id);
self.generated(move |client| async move {
client
.ticket_parent_set(workspace_id, id, server_api::TicketParentRequest(request))
.await
.map(|response| response.0)
})
.map_err(ticket_client_error)
}
fn query_ticket_children(
&self,
id: TicketIdOrSlug,
request: ticket::TicketChildrenRequest,
) -> ticket::Result<ticket::TicketChildrenPage> {
let workspace_id = self.workspace_id.clone();
let id = ticket_reference(&id);
self.generated(move |client| async move {
client
.ticket_children_query(workspace_id, id, server_api::TicketChildrenQuery(request))
.await
.map(|response| response.0)
})
.map_err(ticket_client_error)
}
fn edit_post_with_snapshots( fn edit_post_with_snapshots(
&self, &self,
id: TicketIdOrSlug, id: TicketIdOrSlug,
@@ -312,17 +312,17 @@ pub fn subject_permission_operations() -> Vec<PermissionOperationDescriptor> {
( (
"ticket.relation.read", "ticket.relation.read",
"Read Ticket relations", "Read Ticket relations",
"Read selected Ticket relations.", "Read selected Ticket relation and bounded parent/children link metadata; grants no access to relative Ticket bodies.",
), ),
( (
"ticket.relation.record", "ticket.relation.record",
"Record Ticket relation", "Record Ticket relation",
"Record an explicit relation from this Ticket.", "Record an explicit relation or parent link on the authorized endpoints; reparenting also requires removal authority for the old link.",
), ),
( (
"ticket.relation.remove", "ticket.relation.remove",
"Remove Ticket relation", "Remove Ticket relation",
"Remove an exact relation from this Ticket.", "Remove an exact relation or observed parent link on the authorized endpoints.",
), ),
( (
"ticket.orchestration_plan.record", "ticket.orchestration_plan.record",
+106 -1
View File
@@ -506,6 +506,11 @@ impl_openapi_schema!(
WorkspaceTicketTagListRequest, WorkspaceTicketTagListRequest,
WorkspaceTicketTagMutationRequest, WorkspaceTicketTagMutationRequest,
TicketTagsSetRequest, TicketTagsSetRequest,
TicketLinkSummaryResponse,
TicketParentRequest,
TicketParentResponse,
TicketChildrenQuery,
TicketChildrenResponse,
WorkspaceTicketTagList, WorkspaceTicketTagList,
WorkspaceTicketTag, WorkspaceTicketTag,
RuntimeManagementApiError, RuntimeManagementApiError,
@@ -3565,6 +3570,40 @@ pub trait ServerApi {
#[body] request: TicketTagsSetRequest, #[body] request: TicketTagsSetRequest,
) -> Result<WorkspaceTicketTagList, RepositoryApiError>; ) -> Result<WorkspaceTicketTagList, RepositoryApiError>;
#[get("/api/w/{workspace_id}/tickets/{id}/link-summary", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_link_summary_get(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
) -> Result<TicketLinkSummaryResponse, RepositoryApiError>;
#[get("/api/w/{workspace_id}/tickets/{id}/parent", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_parent_get(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
) -> Result<TicketParentResponse, RepositoryApiError>;
#[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(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
#[body] request: TicketParentRequest,
) -> Result<TicketParentResponse, RepositoryApiError>;
#[get("/api/w/{workspace_id}/tickets/{id}/children", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_children_query(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
#[query] request: TicketChildrenQuery,
) -> Result<TicketChildrenResponse, RepositoryApiError>;
/// Lightweight browser list; deliberately excludes live MR evidence and external observations. /// Lightweight browser list; deliberately excludes live MR evidence and external observations.
#[post("/api/w/{workspace_id}/tickets/browse", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] #[post("/api/w/{workspace_id}/tickets/browse", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_browse( async fn ticket_browse(
@@ -3582,6 +3621,7 @@ pub trait ServerApi {
#[get("/api/tickets/{id}", status = 200, error_status = 404, additional_error_statuses = [400, 500], bearer_auth = true, browser_auth = true)] #[get("/api/tickets/{id}", status = 200, error_status = 404, additional_error_statuses = [400, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_get_alias( async fn ticket_get_alias(
&self, &self,
#[extension] context: ServerRequestContext,
#[path] id: String, #[path] id: String,
) -> Result<TicketDetail, RepositoryApiError>; ) -> Result<TicketDetail, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/default-intake-ready-body", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] #[post("/api/w/{workspace_id}/tickets/default-intake-ready-body", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
@@ -3917,6 +3957,7 @@ pub trait ServerApi {
#[get("/api/w/{workspace_id}/tickets/{id}", status = 200, error_status = 404, additional_error_statuses = [400, 401, 403, 500], bearer_auth = true, browser_auth = true)] #[get("/api/w/{workspace_id}/tickets/{id}", status = 200, error_status = 404, additional_error_statuses = [400, 401, 403, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_get( async fn ticket_get(
&self, &self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String, #[path] workspace_id: String,
#[path] id: String, #[path] id: String,
) -> Result<TicketDetail, RepositoryApiError>; ) -> Result<TicketDetail, RepositoryApiError>;
@@ -3950,6 +3991,7 @@ pub trait ServerApi {
#[post("/api/w/{workspace_id}/tickets/{id}/show", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] #[post("/api/w/{workspace_id}/tickets/{id}/show", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_show( async fn ticket_show(
&self, &self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String, #[path] workspace_id: String,
#[path] id: String, #[path] id: String,
#[body] request: TicketShowRequest, #[body] request: TicketShowRequest,
@@ -3985,6 +4027,7 @@ pub trait ServerApi {
#[post("/api/w/{workspace_id}/tickets/{id}/implementation-cancellations", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] #[post("/api/w/{workspace_id}/tickets/{id}/implementation-cancellations", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_implementation_cancel( async fn ticket_implementation_cancel(
&self, &self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String, #[path] workspace_id: String,
#[path] id: String, #[path] id: String,
#[body] request: CancelTicketImplementationRequest, #[body] request: CancelTicketImplementationRequest,
@@ -8370,6 +8413,8 @@ pub struct TicketRoleAssignmentSummary {
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct TicketActionEligibility { pub struct TicketActionEligibility {
/// Request-bound browser owner eligibility; never a grant for a later mutation.
pub can_set_parent: bool,
pub can_assign_orchestrator: bool, pub can_assign_orchestrator: bool,
pub can_unassign_orchestrator: bool, pub can_unassign_orchestrator: bool,
pub can_queue: bool, pub can_queue: bool,
@@ -8485,6 +8530,9 @@ impl TicketAttention {
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct TicketQueryRequest { pub struct TicketQueryRequest {
/// Work-decomposition filter; absent means all Tickets, none means no direct parent.
#[cfg_attr(feature = "typescript", ts(type = "TicketParentFilter | null"))]
pub parent: Option<ticket::TicketParentFilter>,
/// Exact reason of the current closed-case judgment; reopened/history-only reasons do not match. /// Exact reason of the current closed-case judgment; reopened/history-only reasons do not match.
#[cfg_attr(feature = "typescript", ts(type = "TicketClosureReason | null"))] #[cfg_attr(feature = "typescript", ts(type = "TicketClosureReason | null"))]
pub closure_reason: Option<ticket::TicketClosureReason>, pub closure_reason: Option<ticket::TicketClosureReason>,
@@ -8607,6 +8655,8 @@ pub struct TicketShowRequest {
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct TicketDetail { pub struct TicketDetail {
#[cfg_attr(feature = "typescript", ts(type = "TicketParentLink"))]
pub parent_link: ticket::TicketParentLink,
pub created_by: Option<String>, pub created_by: Option<String>,
pub initial_post: TicketEventDetail, pub initial_post: TicketEventDetail,
pub id: String, pub id: String,
@@ -8858,6 +8908,11 @@ transparent_ticket_dto!(
ticket::WorkspaceTagMutation ticket::WorkspaceTagMutation
); );
transparent_ticket_dto!(TicketTagsSetRequest, ticket::TicketTagSetRequest); transparent_ticket_dto!(TicketTagsSetRequest, ticket::TicketTagSetRequest);
transparent_ticket_dto!(TicketLinkSummaryResponse, ticket::TicketLinkSummary);
transparent_ticket_dto!(TicketParentRequest, ticket::TicketParentSetRequest);
transparent_ticket_dto!(TicketParentResponse, ticket::TicketParentLink);
transparent_ticket_dto!(TicketChildrenQuery, ticket::TicketChildrenRequest);
transparent_ticket_dto!(TicketChildrenResponse, ticket::TicketChildrenPage);
transparent_ticket_dto!(WorkspaceTicketTagList, Vec<ticket::WorkspaceTag>); transparent_ticket_dto!(WorkspaceTicketTagList, Vec<ticket::WorkspaceTag>);
transparent_ticket_dto!(WorkspaceTicketTag, ticket::WorkspaceTag); transparent_ticket_dto!(WorkspaceTicketTag, ticket::WorkspaceTag);
transparent_ticket_dto!(TicketRecordRef, ticket::TicketRef); transparent_ticket_dto!(TicketRecordRef, ticket::TicketRef);
@@ -12421,6 +12476,56 @@ mod tests {
} }
} }
#[test]
fn parent_routes_require_explicit_observation_and_use_typed_bounded_contracts() {
for (name, method, path) in [
(
"ticket_parent_get",
HttpMethod::Get,
"/api/w/{workspace_id}/tickets/{id}/parent",
),
(
"ticket_parent_set",
HttpMethod::Put,
"/api/w/{workspace_id}/tickets/{id}/parent",
),
(
"ticket_children_query",
HttpMethod::Get,
"/api/w/{workspace_id}/tickets/{id}/children",
),
] {
let operation = ServerApiMetadata::OPERATIONS
.iter()
.find(|operation| operation.operation_id == name)
.unwrap();
assert_eq!(operation.method, method);
assert_eq!(operation.path, path);
}
let wire = serde_json::json!({"expected":{"parent_ticket_id":null,"last_operation_key":null},"parent":null,"operation_key":"unlink"});
assert!(serde_json::from_value::<TicketParentRequest>(wire.clone()).is_ok());
for field in ["parent_ticket_id", "last_operation_key"] {
let mut missing = wire.clone();
missing["expected"].as_object_mut().unwrap().remove(field);
assert!(serde_json::from_value::<TicketParentRequest>(missing).is_err());
}
let mut missing = wire;
missing.as_object_mut().unwrap().remove("parent");
assert!(serde_json::from_value::<TicketParentRequest>(missing).is_err());
assert!(
serde_json::from_value::<TicketQueryRequest>(
serde_json::json!({"parent":{"kind":"none"}})
)
.is_ok()
);
assert!(
serde_json::from_value::<TicketQueryRequest>(
serde_json::json!({"parent":{"kind":"none","ticket":"T-1"}})
)
.is_err()
);
}
#[test] #[test]
fn ticket_tag_operations_are_typed_workspace_routes() { fn ticket_tag_operations_are_typed_workspace_routes() {
for (name, method, path) in [ for (name, method, path) in [
@@ -12518,7 +12623,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);
@@ -438,6 +438,34 @@ fn validate_ticket_browser_operations(
paths: &Map<String, Value>, paths: &Map<String, Value>,
) -> Result<BTreeSet<String>, GenerationError> { ) -> Result<BTreeSet<String>, GenerationError> {
let operations = [ let operations = [
OperationContract {
path: "/api/w/{workspace_id}/tickets/{id}/link-summary",
method: "get",
operation_id: "ticket_link_summary_get",
request: None,
successes: &[("200", "TicketLinkSummary")],
},
OperationContract {
path: "/api/w/{workspace_id}/tickets/{id}/parent",
method: "get",
operation_id: "ticket_parent_get",
request: None,
successes: &[("200", "TicketParentLink")],
},
OperationContract {
path: "/api/w/{workspace_id}/tickets/{id}/parent",
method: "put",
operation_id: "ticket_parent_set",
request: Some("TicketParentSetRequest"),
successes: &[("200", "TicketParentLink")],
},
OperationContract {
path: "/api/w/{workspace_id}/tickets/{id}/children",
method: "get",
operation_id: "ticket_children_query",
request: None,
successes: &[("200", "TicketChildrenPage")],
},
OperationContract { OperationContract {
path: "/api/w/{workspace_id}/ticket-tags", path: "/api/w/{workspace_id}/ticket-tags",
method: "get", method: "get",
+2
View File
@@ -7,6 +7,8 @@ use serde::{Deserialize, Serialize};
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct TicketBrowseQueryRequest { pub struct TicketBrowseQueryRequest {
#[cfg_attr(feature = "typescript", ts(type = "TicketParentFilter | null"))]
pub parent: Option<ticket::TicketParentFilter>,
pub query: Option<String>, pub query: Option<String>,
#[serde(default)] #[serde(default)]
#[cfg_attr(feature = "typescript", ts(type = "Array<BrowserTicketState>"))] #[cfg_attr(feature = "typescript", ts(type = "Array<BrowserTicketState>"))]
@@ -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()
+59
View File
@@ -30,6 +30,11 @@ pub use assignment_answer::*;
mod assignment_tracking; mod assignment_tracking;
pub mod config; pub mod config;
mod content_changes; mod content_changes;
mod parent_links;
pub use parent_links::{
TicketChildrenPage, TicketChildrenRequest, TicketLinkSummary, TicketParentFilter,
TicketParentLink, TicketParentObservation, TicketParentSetRequest,
};
pub mod service_outbox; pub mod service_outbox;
mod sqlite_schema; mod sqlite_schema;
mod subject_assignment; mod subject_assignment;
@@ -372,6 +377,7 @@ pub enum TicketEventKind {
ImplementationReport, ImplementationReport,
StateChanged, StateChanged,
TagsChanged, TagsChanged,
ParentChanged,
IntakeSummary, IntakeSummary,
StatusChanged, StatusChanged,
Close, Close,
@@ -388,6 +394,7 @@ impl TicketEventKind {
Self::ImplementationReport => "implementation_report", Self::ImplementationReport => "implementation_report",
Self::StateChanged => "state_changed", Self::StateChanged => "state_changed",
Self::TagsChanged => "tags_changed", Self::TagsChanged => "tags_changed",
Self::ParentChanged => "parent_changed",
Self::IntakeSummary => "intake_summary", Self::IntakeSummary => "intake_summary",
Self::StatusChanged => "status_changed", Self::StatusChanged => "status_changed",
Self::Close => "close", Self::Close => "close",
@@ -404,6 +411,7 @@ impl TicketEventKind {
Self::ImplementationReport => "Implementation report".to_string(), Self::ImplementationReport => "Implementation report".to_string(),
Self::StateChanged => "State changed".to_string(), Self::StateChanged => "State changed".to_string(),
Self::TagsChanged => "Tags changed".to_string(), Self::TagsChanged => "Tags changed".to_string(),
Self::ParentChanged => "Parent changed".to_string(),
Self::IntakeSummary => "Intake summary".to_string(), Self::IntakeSummary => "Intake summary".to_string(),
Self::StatusChanged => "Status changed".to_string(), Self::StatusChanged => "Status changed".to_string(),
Self::Close => "Closed".to_string(), Self::Close => "Closed".to_string(),
@@ -423,6 +431,7 @@ impl From<&str> for TicketEventKind {
"review" => Self::Comment, "review" => Self::Comment,
"state_changed" => Self::StateChanged, "state_changed" => Self::StateChanged,
"tags_changed" => Self::TagsChanged, "tags_changed" => Self::TagsChanged,
"parent_changed" => Self::ParentChanged,
"intake_summary" => Self::IntakeSummary, "intake_summary" => Self::IntakeSummary,
"status_changed" => Self::StatusChanged, "status_changed" => Self::StatusChanged,
"close" | "closed" => Self::Close, "close" | "closed" => Self::Close,
@@ -1777,6 +1786,17 @@ pub struct TicketPostEdit {
} }
pub trait TicketBackend { pub trait TicketBackend {
fn get_ticket_parent(&self, id: TicketIdOrSlug) -> Result<TicketParentLink>;
fn set_ticket_parent(
&self,
id: TicketIdOrSlug,
request: TicketParentSetRequest,
) -> Result<TicketParentLink>;
fn query_ticket_children(
&self,
id: TicketIdOrSlug,
request: TicketChildrenRequest,
) -> Result<TicketChildrenPage>;
fn edit_post_with_snapshots( fn edit_post_with_snapshots(
&self, &self,
id: TicketIdOrSlug, id: TicketIdOrSlug,
@@ -1862,6 +1882,17 @@ pub trait TicketBackend {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "operation", rename_all = "snake_case")] #[serde(tag = "operation", rename_all = "snake_case")]
pub enum TicketBackendOperation { pub enum TicketBackendOperation {
ParentLink {
id: TicketIdOrSlug,
},
SetParentLink {
id: TicketIdOrSlug,
request: TicketParentSetRequest,
},
ChildrenPage {
id: TicketIdOrSlug,
request: TicketChildrenRequest,
},
ListWorkspaceTags { ListWorkspaceTags {
request: TagListRequest, request: TagListRequest,
}, },
@@ -1951,6 +1982,8 @@ pub enum TicketBackendOperation {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "result", content = "value", rename_all = "snake_case")] #[serde(tag = "result", content = "value", rename_all = "snake_case")]
pub enum TicketBackendOperationResult { pub enum TicketBackendOperationResult {
ParentLink(TicketParentLink),
ChildrenPage(TicketChildrenPage),
WorkspaceTags(Vec<WorkspaceTag>), WorkspaceTags(Vec<WorkspaceTag>),
WorkspaceTag(WorkspaceTag), WorkspaceTag(WorkspaceTag),
Text(String), Text(String),
@@ -1977,6 +2010,15 @@ where
B: TicketBackend + ?Sized, B: TicketBackend + ?Sized,
{ {
Ok(match operation { Ok(match operation {
TicketBackendOperation::ParentLink { id } => {
TicketBackendOperationResult::ParentLink(backend.get_ticket_parent(id)?)
}
TicketBackendOperation::SetParentLink { id, request } => {
TicketBackendOperationResult::ParentLink(backend.set_ticket_parent(id, request)?)
}
TicketBackendOperation::ChildrenPage { id, request } => {
TicketBackendOperationResult::ChildrenPage(backend.query_ticket_children(id, request)?)
}
TicketBackendOperation::ListWorkspaceTags { request } => { TicketBackendOperation::ListWorkspaceTags { request } => {
TicketBackendOperationResult::WorkspaceTags(backend.list_workspace_tags(request)?) TicketBackendOperationResult::WorkspaceTags(backend.list_workspace_tags(request)?)
} }
@@ -3110,6 +3152,23 @@ fn finish_sqlite_transaction<R>(conn: &Connection, result: Result<R>) -> Result<
} }
impl TicketBackend for SqliteTicketBackend { impl TicketBackend for SqliteTicketBackend {
fn get_ticket_parent(&self, id: TicketIdOrSlug) -> Result<TicketParentLink> {
self.parent_link(id)
}
fn set_ticket_parent(
&self,
id: TicketIdOrSlug,
request: TicketParentSetRequest,
) -> Result<TicketParentLink> {
self.set_parent_link(id, request)
}
fn query_ticket_children(
&self,
id: TicketIdOrSlug,
request: TicketChildrenRequest,
) -> Result<TicketChildrenPage> {
self.children_page(id, request)
}
fn edit_post_with_snapshots( fn edit_post_with_snapshots(
&self, &self,
id: TicketIdOrSlug, id: TicketIdOrSlug,
+424
View File
@@ -0,0 +1,424 @@
//! Work decomposition between ordinary Tickets, independent of dependencies, case
//! judgments, assignments and grants. Only the child's parent link is authoritative;
//! children are a bounded reverse query, never another independently maintained list.
//!
//! A null parent retains its last mutation key. Parent identity alone cannot fence
//! A -> B -> A or link -> unlink races. Exact replay binds the original selector,
//! complete request and authenticated identity, returning the historical receipt,
//! not fresh observation. Reconcile uncertain outcomes by rereading and retaining
//! the same key/input for an exact retry; never mint a new operation blindly.
//!
//! All writers use SQLite's immediate transaction before CAS and the entire ancestor
//! walk. Serializing only the child row would permit concurrent reciprocal links.
//! The walk has no depth cutoff; a corrupt pre-existing cycle also fails closed.
//! Authorization belongs to the Host and must run before these storage methods.
use super::*;
#[cfg(test)]
#[path = "parent_links_tests.rs"]
mod tests;
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>>(
deserializer: D,
) -> std::result::Result<Option<String>, D::Error> {
Option::<String>::deserialize(deserializer)
}
fn deserialize_desired_parent<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Option<String>, D::Error> {
Option::<String>::deserialize(deserializer)
}
/// CAS observation. Both nullable fields must be explicitly observed on the wire.
/// A never-mutated Ticket has null/null; unlink preserves a non-null operation key.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TicketParentObservation {
#[serde(deserialize_with = "deserialize_observed_parent")]
#[schemars(required, schema_with = "nullable_schema::<String>")]
pub parent_ticket_id: Option<String>,
#[serde(deserialize_with = "deserialize_observed_parent")]
#[schemars(required, schema_with = "nullable_schema::<String>")]
pub last_operation_key: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TicketParentSetRequest {
pub expected: TicketParentObservation,
/// Null unlinks; references are resolved only inside the scoped write transaction.
#[serde(deserialize_with = "deserialize_desired_parent")]
#[schemars(required, schema_with = "nullable_schema::<String>")]
pub parent: Option<String>,
pub operation_key: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct TicketParentLink {
pub ticket_id: String,
pub observation: TicketParentObservation,
#[schemars(required, schema_with = "nullable_schema::<TicketLinkSummary>")]
pub parent: Option<TicketLinkSummary>,
}
/// A bounded navigation projection, never a progress/fulfillment aggregate.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct TicketLinkSummary {
pub id: String,
pub resource_key: String,
pub title: String,
pub state: TicketState,
#[schemars(required, schema_with = "nullable_schema::<TicketClosureReason>")]
pub closure_reason: Option<TicketClosureReason>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum TicketParentFilter {
None {},
Parent { ticket: String },
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct ChildrenCursor {
workspace_id: String,
parent_ticket_id: String,
after: String,
}
/// Live keyset page: ascending immutable child ID, strictly after its cursor.
/// Concurrent inserts/reparents may change later pages; this is not a tree snapshot.
/// The cursor is bound to the Workspace and parent and rejected in another scope.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TicketChildrenRequest {
pub cursor: Option<String>,
#[schemars(range(min = 1, max = 100))]
pub limit: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct TicketChildrenPage {
pub parent_ticket_id: String,
pub items: Vec<TicketLinkSummary>,
#[schemars(required, schema_with = "nullable_schema::<String>")]
pub next_cursor: Option<String>,
pub has_more: bool,
}
impl SqliteTicketBackend {
/// Read only this Ticket's link metadata, without its body or relatives.
/// Callers authorize the addressed Ticket before exposing the projection.
pub fn read_link_summary(&self, id: TicketIdOrSlug) -> Result<TicketLinkSummary> {
self.with_parent_read(|conn| {
let id = self.resolve_parent_link_ticket(conn, id)?;
self.load_link_summary(conn, &id)
})
}
fn with_parent_read<R>(&self, op: impl FnOnce(&Connection) -> Result<R>) -> Result<R> {
let conn = self.open_connection()?;
conn.execute_batch("BEGIN").map_err(sqlite_err)?;
finish_sqlite_transaction(&conn, op(&conn))
}
fn load_link_summary(&self, conn: &Connection, id: &str) -> Result<TicketLinkSummary> {
let (title, state, reason): (String, String, Option<String>) = conn
.query_row(
"SELECT t.title,t.workflow_state,
CASE WHEN t.workflow_state='closed' THEN (
SELECT json_extract(h.judgment_json,'$.action.reason')
FROM typed_ticket_case_metadata m JOIN typed_ticket_case_history h
ON h.workspace_id=m.workspace_id AND h.ticket_id=m.ticket_id
AND h.operation_key=m.closure_operation_key
WHERE m.workspace_id=t.workspace_id AND m.ticket_id=t.ticket_id
) ELSE NULL END
FROM typed_tickets t WHERE t.workspace_id=?1 AND t.ticket_id=?2",
params![self.workspace_id, id],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.optional()
.map_err(sqlite_err)?
.ok_or_else(|| TicketError::NotFound(id.into()))?;
// SQLite returns JSON strings without quotes but returns object reasons as
// JSON text. Interpret exactly the persisted closure reason, never closed=success.
let closure_reason = reason
.map(|reason| {
let value = if reason.starts_with('{') {
serde_json::from_str(&reason).map_err(sqlite_err)?
} else {
serde_json::Value::String(reason)
};
serde_json::from_value(value).map_err(sqlite_err)
})
.transpose()?;
let state = match state.as_str() {
"open" => TicketState::Open,
"closed" => TicketState::Closed,
_ => return Err(TicketError::Conflict("invalid Ticket case state".into())),
};
let resource_key = Self::resource_key_for(conn, &self.workspace_id, id)?
.ok_or_else(|| TicketError::Conflict("linked Ticket has no canonical key".into()))?;
Ok(TicketLinkSummary {
id: id.into(),
resource_key,
title,
state,
closure_reason,
})
}
fn parent_projection(
&self,
conn: &Connection,
ticket_id: String,
observation: TicketParentObservation,
) -> Result<TicketParentLink> {
let parent = observation
.parent_ticket_id
.as_deref()
.map(|id| self.load_link_summary(conn, id))
.transpose()?;
Ok(TicketParentLink {
ticket_id,
observation,
parent,
})
}
fn resolve_parent_link_ticket(&self, conn: &Connection, id: TicketIdOrSlug) -> Result<String> {
let ticket_id = self.resolve_ticket_id(conn, id)?;
if !self.ticket_exists(conn, &ticket_id)? {
return Err(TicketError::NotFound(ticket_id));
}
Ok(ticket_id)
}
fn load_parent_observation(
&self,
conn: &Connection,
ticket_id: &str,
) -> Result<TicketParentObservation> {
conn.query_row(
"SELECT parent_ticket_id, last_operation_key FROM typed_ticket_parent_links
WHERE workspace_id=?1 AND ticket_id=?2",
params![self.workspace_id, ticket_id],
|row| {
Ok(TicketParentObservation {
parent_ticket_id: row.get(0)?,
last_operation_key: Some(row.get(1)?),
})
},
)
.optional()
.map_err(sqlite_err)
.map(Option::unwrap_or_default)
}
pub(crate) fn parent_link(&self, id: TicketIdOrSlug) -> Result<TicketParentLink> {
self.with_parent_read(|conn| {
let ticket_id = self.resolve_parent_link_ticket(conn, id)?;
let observation = self.load_parent_observation(conn, &ticket_id)?;
self.parent_projection(conn, ticket_id, observation)
})
}
pub(crate) fn children_page(
&self,
parent: TicketIdOrSlug,
request: TicketChildrenRequest,
) -> Result<TicketChildrenPage> {
let limit = request.limit.unwrap_or(50);
if !(1..=100).contains(&limit) {
return Err(TicketError::Conflict(
"children limit must be 1..=100".into(),
));
}
self.with_parent_read(|conn| {
let parent_ticket_id = self.resolve_parent_link_ticket(conn, parent)?;
let after = request
.cursor
.as_deref()
.map(|cursor| {
if cursor.len() > 4096 {
return Err(TicketError::Conflict("invalid children cursor".into()));
}
let cursor: ChildrenCursor = serde_json::from_str(cursor)
.map_err(|_| TicketError::Conflict("invalid children cursor".into()))?;
if cursor.workspace_id != self.workspace_id
|| cursor.parent_ticket_id != parent_ticket_id
{
return Err(TicketError::Conflict(
"children cursor belongs to another Workspace or parent".into(),
));
}
Ok(cursor.after)
})
.transpose()?;
let mut stmt = conn
.prepare(
"SELECT ticket_id FROM typed_ticket_parent_links
WHERE workspace_id=?1 AND parent_ticket_id=?2
AND (?3 IS NULL OR ticket_id>?3) ORDER BY ticket_id LIMIT ?4",
)
.map_err(sqlite_err)?;
let mut ticket_ids = stmt
.query_map(
params![
self.workspace_id,
parent_ticket_id,
after,
(limit + 1) as i64
],
|row| row.get::<_, String>(0),
)
.map_err(sqlite_err)?
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(sqlite_err)?;
let has_more = ticket_ids.len() > limit;
ticket_ids.truncate(limit);
let next_cursor = if has_more {
Some(
serde_json::to_string(&ChildrenCursor {
workspace_id: self.workspace_id.clone(),
parent_ticket_id: parent_ticket_id.clone(),
after: ticket_ids.last().expect("nonempty bounded page").clone(),
})
.map_err(sqlite_err)?,
)
} else {
None
};
let items = ticket_ids
.iter()
.map(|id| self.load_link_summary(conn, id))
.collect::<Result<Vec<_>>>()?;
Ok(TicketChildrenPage {
parent_ticket_id,
items,
next_cursor,
has_more,
})
})
}
fn require_acyclic_parent(
&self,
conn: &Connection,
child: &str,
parent: Option<&str>,
) -> Result<()> {
let mut ancestor = parent.map(str::to_owned);
let mut visited = BTreeSet::new();
let mut stmt = conn
.prepare(
"SELECT parent_ticket_id FROM typed_ticket_parent_links
WHERE workspace_id=?1 AND ticket_id=?2",
)
.map_err(sqlite_err)?;
while let Some(id) = ancestor {
if id == child || !visited.insert(id.clone()) {
return Err(TicketError::Conflict(
"Ticket parent link would contain a cycle".into(),
));
}
ancestor = stmt
.query_row(params![self.workspace_id, id], |row| {
row.get::<_, Option<String>>(0)
})
.optional()
.map_err(sqlite_err)?
.flatten();
}
Ok(())
}
pub(crate) fn set_parent_link(
&self,
id: TicketIdOrSlug,
request: TicketParentSetRequest,
) -> Result<TicketParentLink> {
validate_required_event_value("operation_key", &request.operation_key)?;
self.with_write(|conn| {
// Mutable assignment/role context is audit data, not replay identity.
let identity: BTreeMap<_, _> = self.event_attributes.iter().filter(|(key, _)| {
matches!(key.as_str(), "source_account_id" | "source_user_id" | "source_runtime_id" | "source_worker_id")
}).collect();
let input = serde_json::to_string(&(&id, &request, identity)).map_err(sqlite_err)?;
let saved: Option<(String, String)> = conn.query_row(
"SELECT input_json,result_json FROM typed_ticket_parent_operations
WHERE workspace_id=?1 AND operation_key=?2",
params![self.workspace_id, request.operation_key],
|row| Ok((row.get(0)?, row.get(1)?)),
).optional().map_err(sqlite_err)?;
if let Some((original, result)) = saved {
return if original == input {
serde_json::from_str(&result).map_err(sqlite_err)
} else {
Err(TicketError::OperationFingerprintMismatch { operation_key: request.operation_key.clone() })
};
}
let ticket_id = self.resolve_parent_link_ticket(conn, id)?;
self.require_active_source_assignment(conn, &ticket_id)?;
let before = self.load_parent_observation(conn, &ticket_id)?;
if before != request.expected {
return Err(TicketError::Conflict("stale Ticket parent observation; reread before editing".into()));
}
let parent_ticket_id = request.parent.clone().map(|parent| self.resolve_parent_link_ticket(conn, TicketIdOrSlug::Query(parent))).transpose()?;
self.require_acyclic_parent(conn, &ticket_id, parent_ticket_id.as_deref())?;
let changed = before.parent_ticket_id != parent_ticket_id;
let observation = if changed {
TicketParentObservation { parent_ticket_id, last_operation_key: Some(request.operation_key.clone()) }
} else {
before.clone()
};
let at = now_utc();
if changed {
conn.execute(
"INSERT INTO typed_ticket_parent_links (workspace_id,ticket_id,parent_ticket_id,last_operation_key)
VALUES (?1,?2,?3,?4) ON CONFLICT (workspace_id,ticket_id) DO UPDATE
SET parent_ticket_id=excluded.parent_ticket_id,last_operation_key=excluded.last_operation_key",
params![self.workspace_id, ticket_id, observation.parent_ticket_id, request.operation_key],
).map_err(sqlite_err)?;
self.insert_event(conn, &ticket_id, &TicketEvent {
event_ref: String::new(), is_initial: false, edited_at: None, edited_by: None,
kind: TicketEventKind::ParentChanged,
author: Some(default_author()), at: Some(at.clone()), status: None,
from: None, to: None, reason: None, state_field: None,
heading: Some("Parent changed".into()), body: MarkdownText::new("Ticket parent changed"),
references: Vec::new(),
attributes: BTreeMap::from([
("operation_key".into(), request.operation_key.clone()),
("parent_before".into(), serde_json::to_string(&before).map_err(sqlite_err)?),
("parent_after".into(), serde_json::to_string(&observation).map_err(sqlite_err)?),
]),
})?;
self.touch_ticket(conn, &ticket_id, &at)?;
}
let result = self.parent_projection(conn, ticket_id, observation)?;
conn.execute(
"INSERT INTO typed_ticket_parent_operations
(workspace_id,operation_key,ticket_id,input_json,before_json,result_json,source_context_json,at)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8)",
params![self.workspace_id, request.operation_key, result.ticket_id, input,
serde_json::to_string(&before).map_err(sqlite_err)?,
serde_json::to_string(&result).map_err(sqlite_err)?,
serde_json::to_string(&self.event_attributes).map_err(sqlite_err)?, at],
).map_err(sqlite_err)?;
Ok(result)
})
}
}
+27
View File
@@ -0,0 +1,27 @@
CREATE TABLE typed_ticket_parent_links (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
parent_ticket_id TEXT,
last_operation_key TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id),
FOREIGN KEY (workspace_id, ticket_id)
REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE,
FOREIGN KEY (workspace_id, parent_ticket_id)
REFERENCES typed_tickets(workspace_id, ticket_id),
CHECK (parent_ticket_id IS NULL OR parent_ticket_id <> ticket_id),
CHECK (length(last_operation_key) > 0)
);
CREATE INDEX typed_ticket_parent_links_children
ON typed_ticket_parent_links(workspace_id, parent_ticket_id, ticket_id);
CREATE TABLE typed_ticket_parent_operations (
workspace_id TEXT NOT NULL,
operation_key TEXT NOT NULL,
ticket_id TEXT NOT NULL,
input_json TEXT NOT NULL,
before_json TEXT NOT NULL,
result_json TEXT NOT NULL,
source_context_json TEXT NOT NULL,
at TEXT NOT NULL,
PRIMARY KEY (workspace_id, operation_key),
CHECK (length(operation_key) > 0)
);
+593
View File
@@ -0,0 +1,593 @@
//! Real SQLite component tests. Authentication and public adapters are outside this
//! suite; it exercises the owning storage transaction and rereads durable effects.
use super::*;
use tempfile::tempdir;
fn create(backend: &SqliteTicketBackend, title: &str) -> String {
backend.create(NewTicket::new(title)).unwrap().id
}
fn observe(backend: &SqliteTicketBackend, child: &str) -> TicketParentObservation {
backend
.parent_link(TicketIdOrSlug::Id(child.into()))
.unwrap()
.observation
}
fn request(
backend: &SqliteTicketBackend,
child: &str,
parent: Option<&str>,
key: &str,
) -> TicketParentSetRequest {
TicketParentSetRequest {
expected: observe(backend, child),
parent: parent.map(str::to_owned),
operation_key: key.into(),
}
}
fn children(backend: &SqliteTicketBackend, parent: &str) -> Vec<String> {
backend
.children_page(parent.to_owned().into(), TicketChildrenRequest::default())
.unwrap()
.items
.into_iter()
.map(|item| item.id)
.collect()
}
fn counts(backend: &SqliteTicketBackend, child: &str) -> (i64, i64, i64) {
let conn = backend.open_connection().unwrap();
let count = |table: &str, column: &str| {
conn.query_row(
&format!("SELECT COUNT(*) FROM {table} WHERE workspace_id=?1 AND {column}=?2"),
params![backend.workspace_id, child],
|row| row.get::<_, i64>(0),
)
.unwrap()
};
(
count("typed_ticket_parent_operations", "ticket_id"),
count("typed_ticket_events", "ticket_id"),
count("typed_ticket_service_outbox", "ticket_id"),
)
}
#[test]
fn reparent_and_unlink_persist_one_forward_authority_without_changing_requirements() {
let dir = tempdir().unwrap();
let backend = SqliteTicketBackend::open(dir.path().join("parents.db"), "ws").unwrap();
let a = create(&backend, "A");
let b = create(&backend, "B");
let c = create(&backend, "C");
let d = create(&backend, "D");
let before = backend.show(b.clone().into()).unwrap();
backend
.set_parent_link(b.clone().into(), request(&backend, &b, Some(&a), "b-a"))
.unwrap();
backend
.set_parent_link(c.clone().into(), request(&backend, &c, Some(&b), "c-b"))
.unwrap();
assert_eq!(children(&backend, &a), vec![b.clone()]);
assert_eq!(children(&backend, &b), vec![c.clone()]);
backend
.set_parent_link(b.clone().into(), request(&backend, &b, Some(&d), "b-d"))
.unwrap();
assert!(children(&backend, &a).is_empty());
assert_eq!(children(&backend, &d), vec![b.clone()]);
let saved = backend
.set_parent_link(b.clone().into(), request(&backend, &b, None, "unlink"))
.unwrap();
assert_eq!(saved.observation.parent_ticket_id, None);
assert_eq!(
saved.observation.last_operation_key.as_deref(),
Some("unlink")
);
let reopened = SqliteTicketBackend::open_verified(backend.db_path(), "ws").unwrap();
assert_eq!(observe(&reopened, &b), saved.observation);
assert!(children(&reopened, &d).is_empty());
assert_eq!(children(&reopened, &b), vec![c]);
let after = reopened.show(b.into()).unwrap();
assert_eq!(before.document, after.document);
assert_eq!(before.meta.workflow_state, after.meta.workflow_state);
assert_eq!(before.meta.targets, after.meta.targets);
assert_eq!(before.relations, after.relations);
assert_eq!(before.current_closure, after.current_closure);
}
#[test]
fn parent_cas_rejects_aba_and_replay_returns_historical_receipt_without_new_effects() {
let dir = tempdir().unwrap();
let backend = SqliteTicketBackend::open(dir.path().join("parents.db"), "ws").unwrap();
let a = create(&backend, "A");
let b = create(&backend, "B");
let first = request(&backend, &b, Some(&a), "first");
let saved = backend
.set_parent_link(b.clone().into(), first.clone())
.unwrap();
let stale = request(&backend, &b, None, "stale");
backend
.set_parent_link(b.clone().into(), request(&backend, &b, None, "unlink"))
.unwrap();
backend
.set_parent_link(b.clone().into(), request(&backend, &b, Some(&a), "again"))
.unwrap();
let before = counts(&backend, &b);
assert!(matches!(
backend.set_parent_link(b.clone().into(), stale),
Err(TicketError::Conflict(_))
));
// Simulate losing the first response and retrying after subsequent changes.
assert_eq!(
backend
.set_parent_link(b.clone().into(), first.clone())
.unwrap(),
saved
);
assert_eq!(
observe(&backend, &b).last_operation_key.as_deref(),
Some("again")
);
let changed_input = TicketParentSetRequest {
parent: None,
..first.clone()
};
assert!(matches!(
backend.set_parent_link(b.clone().into(), changed_input),
Err(TicketError::OperationFingerprintMismatch { .. })
));
let other_actor = backend.clone().with_event_attributes(BTreeMap::from([(
"source_worker_id".into(),
"other".into(),
)]));
assert!(matches!(
other_actor.set_parent_link(b.clone().into(), first),
Err(TicketError::OperationFingerprintMismatch { .. })
));
assert_eq!(counts(&backend, &b), before);
}
#[test]
fn invalid_parent_rejects_self_missing_foreign_and_indirect_cycles_atomically() {
let dir = tempdir().unwrap();
let path = dir.path().join("parents.db");
let backend = SqliteTicketBackend::open(&path, "ws").unwrap();
let foreign = SqliteTicketBackend::open(&path, "foreign").unwrap();
let a = create(&backend, "A");
let b = create(&backend, "B");
let c = create(&backend, "C");
let foreign_id = create(&foreign, "Foreign");
backend
.set_parent_link(b.clone().into(), request(&backend, &b, Some(&a), "b-a"))
.unwrap();
backend
.set_parent_link(c.clone().into(), request(&backend, &c, Some(&b), "c-b"))
.unwrap();
let before = counts(&backend, &a);
for (i, invalid) in [a.as_str(), "missing", foreign_id.as_str(), c.as_str()]
.into_iter()
.enumerate()
{
assert!(
backend
.set_parent_link(
a.clone().into(),
request(&backend, &a, Some(invalid), &format!("invalid-{i}"))
)
.is_err()
);
assert_eq!(observe(&backend, &a), TicketParentObservation::default());
assert_eq!(counts(&backend, &a), before);
}
}
#[test]
fn deep_ancestor_walk_does_not_treat_a_depth_cutoff_as_acyclic() {
let dir = tempdir().unwrap();
let backend = SqliteTicketBackend::open(dir.path().join("parents.db"), "ws").unwrap();
let root = create(&backend, "Root");
// A large persisted ancestry fixture avoids thousands of unrelated creation
// transactions. The mutation and entire ancestor validation remain production.
backend.with_write(|conn| {
let mut previous = root.clone();
for depth in 0..1200 {
let id = format!("deep-{depth:04}");
conn.execute(
"INSERT INTO typed_tickets (workspace_id,ticket_id,slug,title,status,kind,priority,workflow_state,workflow_state_explicit)
VALUES ('ws',?1,?1,'Deep','open','task','P2','open',1)", [&id],
).map_err(sqlite_err)?;
conn.execute(
"INSERT INTO typed_ticket_parent_links VALUES ('ws',?1,?2,?1)", params![id, previous],
).map_err(sqlite_err)?;
previous = id;
}
Ok(())
}).unwrap();
assert!(matches!(
backend.set_parent_link(
root.clone().into(),
request(&backend, &root, Some("deep-1199"), "cycle")
),
Err(TicketError::Conflict(_))
));
assert_eq!(observe(&backend, &root), TicketParentObservation::default());
}
#[test]
fn reciprocal_parallel_reparents_cannot_both_commit() {
let dir = tempdir().unwrap();
let backend = SqliteTicketBackend::open(dir.path().join("parents.db"), "ws").unwrap();
let a = create(&backend, "A");
let b = create(&backend, "B");
let barrier = Arc::new(std::sync::Barrier::new(2));
let tasks = [(a.clone(), b.clone(), "a-b"), (b.clone(), a.clone(), "b-a")].map(
|(child, parent, key)| {
let backend = backend.clone();
let barrier = barrier.clone();
let input = request(&backend, &child, Some(&parent), key);
std::thread::spawn(move || {
barrier.wait();
backend.set_parent_link(child.into(), input)
})
},
);
let results = tasks.map(|task| task.join().unwrap());
assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
let links = [observe(&backend, &a), observe(&backend, &b)];
assert_eq!(
links
.iter()
.filter(|link| link.parent_ticket_id.is_some())
.count(),
1
);
}
#[test]
fn audit_outbox_failure_rolls_back_link_and_receipt_together() {
let dir = tempdir().unwrap();
let backend = SqliteTicketBackend::open(dir.path().join("parents.db"), "ws").unwrap();
let a = create(&backend, "A");
let b = create(&backend, "B");
let before = counts(&backend, &b);
let failing = backend.clone().with_mutation_hook(Arc::new(|_, _| {
Err(TicketError::Conflict("injected domain hook failure".into()))
}));
let input = request(&backend, &b, Some(&a), "link");
assert!(
failing
.set_parent_link(b.clone().into(), input.clone())
.is_err()
);
assert_eq!(observe(&backend, &b), TicketParentObservation::default());
assert_eq!(counts(&backend, &b), before);
backend
.set_parent_link(b.clone().into(), input.clone())
.unwrap();
let after = counts(&backend, &b);
assert_eq!(after, (before.0 + 1, before.1 + 1, before.2 + 1));
backend.set_parent_link(b.clone().into(), input).unwrap();
assert_eq!(counts(&backend, &b), after);
}
#[test]
fn children_keyset_pages_are_bounded_sorted_and_not_duplicated() {
let dir = tempdir().unwrap();
let backend = SqliteTicketBackend::open(dir.path().join("parents.db"), "ws").unwrap();
let parent = create(&backend, "Parent");
let mut expected = Vec::new();
for i in 0..5 {
let child = create(&backend, &format!("Child {i}"));
backend
.set_parent_link(
child.clone().into(),
request(&backend, &child, Some(&parent), &format!("link-{i}")),
)
.unwrap();
expected.push(child);
}
expected.sort();
let mut cursor = None;
let mut actual = Vec::new();
loop {
let page = backend
.children_page(
parent.clone().into(),
TicketChildrenRequest {
cursor,
limit: Some(2),
},
)
.unwrap();
assert!(page.items.len() <= 2);
assert_eq!(page.has_more, page.next_cursor.is_some());
actual.extend(page.items.into_iter().map(|item| item.id));
cursor = page.next_cursor;
if !page.has_more {
break;
}
}
assert_eq!(actual, expected);
for limit in [0, 101] {
assert!(
backend
.children_page(
parent.clone().into(),
TicketChildrenRequest {
cursor: None,
limit: Some(limit)
}
)
.is_err()
);
}
}
#[test]
fn parent_wire_contract_requires_explicit_nullable_observation_and_desired_parent() {
for input in [
r#"{"expected":{},"parent":null,"operation_key":"key"}"#,
r#"{"expected":{"parent_ticket_id":null},"parent":null,"operation_key":"key"}"#,
r#"{"expected":{"parent_ticket_id":null,"last_operation_key":null},"operation_key":"key"}"#,
] {
assert!(serde_json::from_str::<TicketParentSetRequest>(input).is_err());
}
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());
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 {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch("PRAGMA foreign_keys=ON; BEGIN IMMEDIATE")
.unwrap();
migrate_historical_sqlite_ticket_schema_in_transaction(
&conn,
HistoricalTicketSchemaTarget::Outbox15,
)
.unwrap();
conn.execute_batch("COMMIT").unwrap();
conn
}
#[test]
fn parent_migration_preserves_existing_relations_and_receipts_and_defaults_to_no_parent() {
let conn = outbox_schema_preimage();
conn.execute_batch(
"INSERT INTO typed_tickets (workspace_id,ticket_id,slug,title,status,kind,priority,workflow_state,workflow_state_explicit)
VALUES ('ws','a','a','A','open','task','P2','open',1), ('ws','b','b','B','open','task','P2','open',1);
INSERT INTO typed_ticket_relations VALUES ('ws','a','related','b','kept','actor','at');",
).unwrap();
let old_receipt: (String, String) = conn
.query_row(
"SELECT name,applied_at FROM ticket_schema_migrations WHERE version=15",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap();
migrate_sqlite_ticket_schema(&conn).unwrap();
migrate_sqlite_ticket_schema(&conn).unwrap();
verify_sqlite_ticket_schema(&conn).unwrap();
let receipt: (String, String) = conn
.query_row(
"SELECT name,applied_at FROM ticket_schema_migrations WHERE version=15",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap();
assert_eq!(receipt, old_receipt);
let parent_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM typed_ticket_parent_links",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(parent_count, 0);
let relation: (String, String) = conn
.query_row(
"SELECT kind,note FROM typed_ticket_relations WHERE ticket_id='a'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap();
assert_eq!(relation, ("related".into(), "kept".into()));
let fresh = Connection::open_in_memory().unwrap();
migrate_sqlite_ticket_schema(&fresh).unwrap();
let shape = |db: &Connection| {
let mut stmt = db.prepare("SELECT name,sql FROM sqlite_master WHERE name LIKE 'typed_ticket_parent_%' AND sql IS NOT NULL ORDER BY name").unwrap();
stmt.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.unwrap()
.collect::<std::result::Result<Vec<_>, _>>()
.unwrap()
};
assert_eq!(shape(&conn), shape(&fresh));
}
#[test]
fn parent_migration_failure_rolls_back_ddl_and_retains_original_schema_receipt() {
let conn = outbox_schema_preimage();
conn.execute_batch("CREATE TABLE typed_ticket_parent_operations (unexpected TEXT)")
.unwrap();
assert!(migrate_sqlite_ticket_schema(&conn).is_err());
let parent_tables: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE name='typed_ticket_parent_links'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(parent_tables, 0);
let version: i64 = conn
.query_row(
"SELECT MAX(version) FROM ticket_schema_migrations",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(version, 15);
verify_historical_sqlite_ticket_schema(&conn, HistoricalTicketSchemaTarget::Outbox15).unwrap();
}
#[test]
fn simultaneous_changes_to_one_child_cannot_overwrite_the_same_observation() {
let dir = tempdir().unwrap();
let backend = SqliteTicketBackend::open(dir.path().join("parents.db"), "ws").unwrap();
let child = create(&backend, "Child");
let a = create(&backend, "A");
let b = create(&backend, "B");
let barrier = Arc::new(std::sync::Barrier::new(2));
let tasks = [(a, "a"), (b, "b")].map(|(parent, key)| {
let input = request(&backend, &child, Some(&parent), key);
let backend = backend.clone();
let child = child.clone();
let barrier = barrier.clone();
std::thread::spawn(move || {
barrier.wait();
backend.set_parent_link(child.into(), input)
})
});
let results = tasks.map(|task| task.join().unwrap());
assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
let winner = results
.into_iter()
.find_map(std::result::Result::ok)
.unwrap();
assert_eq!(observe(&backend, &child), winner.observation);
}
#[test]
fn children_cursor_rejects_other_parent_workspace_and_malformed_input() {
let dir = tempdir().unwrap();
let path = dir.path().join("parents.db");
let backend = SqliteTicketBackend::open(&path, "ws").unwrap();
let parent = create(&backend, "Parent");
let other = create(&backend, "Other parent");
for i in 0..2 {
let child = create(&backend, &format!("Child {i}"));
backend
.set_parent_link(
child.clone().into(),
request(&backend, &child, Some(&parent), &format!("link-{i}")),
)
.unwrap();
}
let cursor = backend
.children_page(
parent.clone().into(),
TicketChildrenRequest {
cursor: None,
limit: Some(1),
},
)
.unwrap()
.next_cursor
.unwrap();
let foreign = SqliteTicketBackend::open(&path, "foreign").unwrap();
let foreign_parent = create(&foreign, "Foreign parent");
for (store, target) in [(&backend, other), (&foreign, foreign_parent)] {
assert!(
store
.children_page(
target.into(),
TicketChildrenRequest {
cursor: Some(cursor.clone()),
limit: Some(1)
}
)
.is_err()
);
}
for invalid in ["not-json".to_owned(), "x".repeat(4097)] {
assert!(
backend
.children_page(
parent.clone().into(),
TicketChildrenRequest {
cursor: Some(invalid),
limit: Some(1)
}
)
.is_err()
);
}
}
#[test]
fn closed_child_projection_preserves_reason_and_does_not_close_parent_or_remove_dependencies() {
let dir = tempdir().unwrap();
let backend = SqliteTicketBackend::open(dir.path().join("parents.db"), "ws").unwrap();
let parent = create(&backend, "Parent");
let child = create(&backend, "Child");
backend
.add_ticket_relation(
child.clone().into(),
NewTicketRelation {
kind: TicketRelationKind::DependsOn,
target: parent.clone(),
note: None,
author: None,
},
)
.unwrap();
let before = backend.show(child.clone().into()).unwrap();
backend
.set_parent_link(
child.clone().into(),
request(&backend, &child, Some(&parent), "link"),
)
.unwrap();
backend
.update_case(
&child,
TicketCaseUpdate {
expected_content_digest: ticket_content_digest(&before),
expected_state: TicketState::Open,
expected_closure_operation_key: None,
operation_key: "not-planned".into(),
action: TicketCaseAction::Close {
reason: TicketClosureReason::NotPlanned,
},
explanation: "Not proceeding".into(),
references: vec![],
author: Some("case-actor".into()),
},
)
.unwrap();
let page = backend
.children_page(parent.clone().into(), TicketChildrenRequest::default())
.unwrap();
assert_eq!(page.items[0].state, TicketState::Closed);
assert_eq!(
page.items[0].closure_reason,
Some(TicketClosureReason::NotPlanned)
);
let after = backend.show(child.into()).unwrap();
assert_eq!(after.relations, before.relations);
assert_eq!(after.meta.targets, before.meta.targets);
assert_eq!(
backend.show(parent.into()).unwrap().meta.workflow_state,
TicketState::Open
);
}
+79 -10
View File
@@ -20,7 +20,7 @@ use crate::{Result, TicketError, sqlite_err};
const MIGRATION_TABLE: &str = "ticket_schema_migrations"; const MIGRATION_TABLE: &str = "ticket_schema_migrations";
const MAX_SCHEMA_DIAGNOSTICS: usize = 32; const MAX_SCHEMA_DIAGNOSTICS: usize = 32;
const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 15; const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 16;
const BASELINE_MIGRATION_NAME: &str = "ticket schema baseline"; const BASELINE_MIGRATION_NAME: &str = "ticket schema baseline";
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@@ -52,6 +52,8 @@ const OWNED_TABLES: &[&str] = &[
"typed_ticket_event_references", "typed_ticket_event_references",
"typed_ticket_event_attributes", "typed_ticket_event_attributes",
"typed_ticket_relations", "typed_ticket_relations",
"typed_ticket_parent_links",
"typed_ticket_parent_operations",
"typed_ticket_orchestration_plans", "typed_ticket_orchestration_plans",
"typed_ticket_artifacts", "typed_ticket_artifacts",
"typed_ticket_case_metadata", "typed_ticket_case_metadata",
@@ -336,6 +338,8 @@ pub enum HistoricalTicketSchemaTarget {
Content13, Content13,
/// Subject responsibility, before Service outbox facts. /// Subject responsibility, before Service outbox facts.
Subjects14, Subjects14,
/// Service outbox, before work-decomposition parent links.
Outbox15,
} }
impl HistoricalTicketSchemaTarget { impl HistoricalTicketSchemaTarget {
fn shape(self) -> TicketSchemaShape { fn shape(self) -> TicketSchemaShape {
@@ -345,6 +349,7 @@ impl HistoricalTicketSchemaTarget {
Self::Posts12 => TicketSchemaShape::Posts12, Self::Posts12 => TicketSchemaShape::Posts12,
Self::Content13 => TicketSchemaShape::ContentChanges13, Self::Content13 => TicketSchemaShape::ContentChanges13,
Self::Subjects14 => TicketSchemaShape::Subjects14, Self::Subjects14 => TicketSchemaShape::Subjects14,
Self::Outbox15 => TicketSchemaShape::Outbox15,
} }
} }
} }
@@ -370,7 +375,7 @@ pub fn migrate_historical_sqlite_ticket_schema_in_transaction(
/// A savepoint rolls back this component on failure without committing host work. /// A savepoint rolls back this component on failure without committing host work.
/// Already-current schemas are verified, never repaired or backfilled. /// Already-current schemas are verified, never repaired or backfilled.
pub fn migrate_sqlite_ticket_schema_in_transaction(connection: &Connection) -> Result<()> { pub fn migrate_sqlite_ticket_schema_in_transaction(connection: &Connection) -> Result<()> {
migrate_ticket_schema_to_in_transaction(connection, TicketSchemaShape::Outbox15) migrate_ticket_schema_to_in_transaction(connection, TicketSchemaShape::ParentLinks16)
} }
fn migrate_ticket_schema_to_in_transaction( fn migrate_ticket_schema_to_in_transaction(
@@ -438,7 +443,8 @@ fn migrate_ticket_schema_to_in_transaction(
| TicketSchemaShape::Posts12 | TicketSchemaShape::Posts12
| TicketSchemaShape::ContentChanges13 | TicketSchemaShape::ContentChanges13
| TicketSchemaShape::Subjects14 | TicketSchemaShape::Subjects14
| TicketSchemaShape::Outbox15 => {} | TicketSchemaShape::Outbox15
| TicketSchemaShape::ParentLinks16 => {}
} }
if version < 10 { if version < 10 {
append_component_receipt(connection, 10)?; append_component_receipt(connection, 10)?;
@@ -475,6 +481,7 @@ enum TicketSchemaShape {
ContentChanges13, ContentChanges13,
Subjects14, Subjects14,
Outbox15, Outbox15,
ParentLinks16,
} }
impl TicketSchemaShape { impl TicketSchemaShape {
fn from_version(version: i64) -> Self { fn from_version(version: i64) -> Self {
@@ -488,6 +495,7 @@ impl TicketSchemaShape {
13 => Self::ContentChanges13, 13 => Self::ContentChanges13,
14 => Self::Subjects14, 14 => Self::Subjects14,
15 => Self::Outbox15, 15 => Self::Outbox15,
16 => Self::ParentLinks16,
_ => unreachable!(), _ => unreachable!(),
} }
} }
@@ -502,6 +510,7 @@ impl TicketSchemaShape {
Self::ContentChanges13 => 13, Self::ContentChanges13 => 13,
Self::Subjects14 => 14, Self::Subjects14 => 14,
Self::Outbox15 => 15, Self::Outbox15 => 15,
Self::ParentLinks16 => 16,
} }
} }
fn tags(self) -> bool { fn tags(self) -> bool {
@@ -514,6 +523,7 @@ impl TicketSchemaShape {
| Self::ContentChanges13 | Self::ContentChanges13
| Self::Subjects14 | Self::Subjects14
| Self::Outbox15 | Self::Outbox15
| Self::ParentLinks16
) )
} }
fn cases(self) -> bool { fn cases(self) -> bool {
@@ -526,6 +536,7 @@ impl TicketSchemaShape {
| Self::ContentChanges13 | Self::ContentChanges13
| Self::Subjects14 | Self::Subjects14
| Self::Outbox15 | Self::Outbox15
| Self::ParentLinks16
) )
} }
} }
@@ -548,7 +559,7 @@ pub fn verify_historical_sqlite_ticket_schema(
/// Verifies the current Ticket-owned SQLite schema without executing DDL. /// Verifies the current Ticket-owned SQLite schema without executing DDL.
pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> { pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
verify_ticket_schema_shape(connection, TicketSchemaShape::Outbox15) verify_ticket_schema_shape(connection, TicketSchemaShape::ParentLinks16)
} }
/// Retained Workspace schema-66 migration produces the pre-classification v7 /// Retained Workspace schema-66 migration produces the pre-classification v7
@@ -668,6 +679,7 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape)
| TicketSchemaShape::ContentChanges13 | TicketSchemaShape::ContentChanges13
| TicketSchemaShape::Subjects14 | TicketSchemaShape::Subjects14
| TicketSchemaShape::Outbox15 | TicketSchemaShape::Outbox15
| TicketSchemaShape::ParentLinks16
) { ) {
if table == "typed_tickets" { if table == "typed_tickets" {
columns.retain(|c| c.name != "body"); columns.retain(|c| c.name != "body");
@@ -886,6 +898,14 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape)
); );
} }
for table in OWNED_TABLES { for table in OWNED_TABLES {
if !matches!(shape, TicketSchemaShape::ParentLinks16)
&& matches!(
*table,
"typed_ticket_parent_links" | "typed_ticket_parent_operations"
)
{
continue;
}
if !shape.tags() if !shape.tags()
&& matches!( && matches!(
*table, *table,
@@ -914,12 +934,15 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape)
| TicketSchemaShape::ContentChanges13 | TicketSchemaShape::ContentChanges13
| TicketSchemaShape::Subjects14 | TicketSchemaShape::Subjects14
| TicketSchemaShape::Outbox15 | TicketSchemaShape::Outbox15
| TicketSchemaShape::ParentLinks16
) { ) {
if let Err(error) = crate::assignment_tracking::verify_assignment_tracking_schema( if let Err(error) = crate::assignment_tracking::verify_assignment_tracking_schema(
connection, connection,
matches!( matches!(
shape, shape,
TicketSchemaShape::Subjects14 | TicketSchemaShape::Outbox15 TicketSchemaShape::Subjects14
| TicketSchemaShape::Outbox15
| TicketSchemaShape::ParentLinks16
), ),
) { ) {
push_diagnostic(&mut diagnostics, error.to_string()); push_diagnostic(&mut diagnostics, error.to_string());
@@ -931,6 +954,7 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape)
| TicketSchemaShape::ContentChanges13 | TicketSchemaShape::ContentChanges13
| TicketSchemaShape::Subjects14 | TicketSchemaShape::Subjects14
| TicketSchemaShape::Outbox15 | TicketSchemaShape::Outbox15
| TicketSchemaShape::ParentLinks16
) { ) {
collect_index_diagnostics( collect_index_diagnostics(
connection, connection,
@@ -946,12 +970,19 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape)
TicketSchemaShape::ContentChanges13 TicketSchemaShape::ContentChanges13
| TicketSchemaShape::Subjects14 | TicketSchemaShape::Subjects14
| TicketSchemaShape::Outbox15 | TicketSchemaShape::Outbox15
| TicketSchemaShape::ParentLinks16
) { ) {
collect_content_change_diagnostics(connection, &mut diagnostics)?; collect_content_change_diagnostics(connection, &mut diagnostics)?;
} }
if matches!(shape, TicketSchemaShape::Outbox15) { if matches!(
shape,
TicketSchemaShape::Outbox15 | TicketSchemaShape::ParentLinks16
) {
collect_service_outbox_diagnostics(connection, &mut diagnostics)?; collect_service_outbox_diagnostics(connection, &mut diagnostics)?;
} }
if matches!(shape, TicketSchemaShape::ParentLinks16) {
collect_parent_link_diagnostics(connection, &mut diagnostics)?;
}
if diagnostics.is_empty() { if diagnostics.is_empty() {
Ok(()) Ok(())
} else { } else {
@@ -978,6 +1009,9 @@ fn apply_component_migration(connection: &Connection, version: i64) -> Result<()
15 => connection 15 => connection
.execute_batch(crate::service_outbox::OUTBOX_SCHEMA_SQL) .execute_batch(crate::service_outbox::OUTBOX_SCHEMA_SQL)
.map_err(sqlite_err), .map_err(sqlite_err),
16 => connection
.execute_batch(crate::parent_links::SCHEMA_SQL)
.map_err(sqlite_err),
_ => unreachable!("bounded component migration"), _ => unreachable!("bounded component migration"),
} }
} }
@@ -991,6 +1025,7 @@ fn component_migration_name(version: i64) -> &'static str {
13 => "Ticket content change facts", 13 => "Ticket content change facts",
14 => "Subject Ticket responsibility", 14 => "Subject Ticket responsibility",
15 => "Ticket Service transactional outbox", 15 => "Ticket Service transactional outbox",
16 => "Ticket work-decomposition parent links",
_ => unreachable!("bounded component receipt"), _ => unreachable!("bounded component receipt"),
} }
} }
@@ -1091,6 +1126,39 @@ fn collect_content_change_diagnostics(
Ok(()) Ok(())
} }
fn collect_parent_link_diagnostics(
connection: &Connection,
diagnostics: &mut Vec<String>,
) -> Result<()> {
// These statements are tables/indexes only. Compare the complete owned DDL,
// including the composite Workspace FKs, self-link CHECK and children index.
for expected in crate::parent_links::SCHEMA_SQL.split(';') {
let expected = expected.trim();
if expected.is_empty() {
continue;
}
let words: Vec<_> = expected.split_whitespace().collect();
let kind = words[1].to_ascii_lowercase();
let name = words[2];
let actual: Option<String> = connection
.query_row(
"SELECT sql FROM sqlite_master WHERE type=?1 AND name=?2",
params![kind, name],
|row| row.get(0),
)
.optional()
.map_err(sqlite_err)?;
let normalize = |sql: &str| sql.split_whitespace().collect::<String>();
if actual.as_deref().map(normalize) != Some(normalize(expected)) {
push_diagnostic(
diagnostics,
format!("invalid Ticket parent-link {kind} {name}"),
);
}
}
Ok(())
}
fn collect_service_outbox_diagnostics( fn collect_service_outbox_diagnostics(
connection: &Connection, connection: &Connection,
diagnostics: &mut Vec<String>, diagnostics: &mut Vec<String>,
@@ -1385,7 +1453,7 @@ fn recognized_schema_version(applied: &BTreeMap<i64, String>) -> Result<i64> {
} }
// Versions 8 and 9 were independent baseline generations, not // Versions 8 and 9 were independent baseline generations, not
// ordered upgrades. Both compose at10; v6 additionally needs v7. // ordered upgrades. Both compose at10; v6 additionally needs v7.
for version in [7, 10, 11, 12, 13, 14, 15] { for version in [7, 10, 11, 12, 13, 14, 15, 16] {
if version <= baseline || (version == 7 && baseline != 6) { if version <= baseline || (version == 7 && baseline != 6) {
continue; continue;
} }
@@ -1901,7 +1969,8 @@ mod tests {
(12, "Canonical Ticket posts".to_owned()), (12, "Canonical Ticket posts".to_owned()),
(13, "Ticket content change facts".to_owned()), (13, "Ticket content change facts".to_owned()),
(14, "Subject Ticket responsibility".to_owned()), (14, "Subject Ticket responsibility".to_owned()),
(15, "Ticket Service transactional outbox".to_owned()) (15, "Ticket Service transactional outbox".to_owned()),
(16, "Ticket work-decomposition parent links".to_owned())
]) ])
); );
} }
@@ -2044,7 +2113,7 @@ mod tests {
let error = migrate_sqlite_ticket_schema(&connection).unwrap_err(); let error = migrate_sqlite_ticket_schema(&connection).unwrap_err();
assert!(error.to_string().contains( assert!(error.to_string().contains(
"migration history must contain a canonical baseline and ordered migrations through 15" "migration history must contain a canonical baseline and ordered migrations through 16"
)); ));
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 2); assert_eq!(load_applied_migrations(&connection).unwrap().len(), 2);
} }
@@ -2066,7 +2135,7 @@ mod tests {
let error = migrate_sqlite_ticket_schema(&connection).unwrap_err(); let error = migrate_sqlite_ticket_schema(&connection).unwrap_err();
assert!(error.to_string().contains( assert!(error.to_string().contains(
"migration history must contain a canonical baseline and ordered migrations through 15" "migration history must contain a canonical baseline and ordered migrations through 16"
)); ));
assert!(!table_exists(&connection, "typed_tickets").unwrap()); assert!(!table_exists(&connection, "typed_tickets").unwrap());
} }
+17
View File
@@ -253,6 +253,23 @@ impl From<crate::SqliteTicketBackend> for TicketToolBackend {
} }
impl TicketBackend for TicketToolBackend { impl TicketBackend for TicketToolBackend {
fn get_ticket_parent(&self, id: TicketIdOrSlug) -> TicketResult<crate::TicketParentLink> {
self.backend.get_ticket_parent(id)
}
fn set_ticket_parent(
&self,
id: TicketIdOrSlug,
request: crate::TicketParentSetRequest,
) -> TicketResult<crate::TicketParentLink> {
self.backend.set_ticket_parent(id, request)
}
fn query_ticket_children(
&self,
id: TicketIdOrSlug,
request: crate::TicketChildrenRequest,
) -> TicketResult<crate::TicketChildrenPage> {
self.backend.query_ticket_children(id, request)
}
fn edit_post_with_snapshots( fn edit_post_with_snapshots(
&self, &self,
id: TicketIdOrSlug, id: TicketIdOrSlug,
+1 -1
View File
@@ -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()
+1 -1
View File
@@ -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"))
+2 -2
View File
@@ -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},
@@ -47,6 +47,7 @@ struct ModelTicketTarget {
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub(super) struct ModelTicketDetail { pub(super) struct ModelTicketDetail {
parent_link: ticket::TicketParentLink,
ticket: String, ticket: String,
content_digest: String, content_digest: String,
title: String, title: String,
@@ -351,6 +352,7 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
Ok(ModelTicketDetail { Ok(ModelTicketDetail {
parent_link: typed_field(root, "parent_link")?,
ticket: resource_ref(root, "resource_key", "T-")?, ticket: resource_ref(root, "resource_key", "T-")?,
content_digest: string_field(root, "content_digest")?, content_digest: string_field(root, "content_digest")?,
title: string_field(root, "title")?, title: string_field(root, "title")?,
@@ -850,6 +852,10 @@ mod tests {
use super::*; use super::*;
use serde_json::json; use serde_json::json;
fn parent_fixture() -> Value {
json!({"ticket_id":"fixture-ticket", "observation":{"parent_ticket_id":null,"last_operation_key":null},"parent":null})
}
fn post_fixture(initial: bool, body: &str) -> Value { fn post_fixture(initial: bool, body: &str) -> Value {
json!({ json!({
"sequence": if initial { 0 } else { 1 }, "sequence": if initial { 0 } else { 1 },
@@ -864,7 +870,7 @@ mod tests {
#[test] #[test]
fn mrless_evidence_is_inapplicable_not_missing_or_vacuously_approved() { fn mrless_evidence_is_inapplicable_not_missing_or_vacuously_approved() {
for state in ["open", "closed"] { for state in ["open", "closed"] {
let projected = project_ticket_detail(json!({ let projected = project_ticket_detail(json!({ "parent_link": parent_fixture(),
"resource_key": "T-718", "assignment_tracking": [], "tags": [], "content_digest": "rev-1", "title": "No Git required", "body": "Decision", "body_truncated": false, "targets": [], "initial_post": post_fixture(true, "Decision"), "resource_key": "T-718", "assignment_tracking": [], "tags": [], "content_digest": "rev-1", "title": "No Git required", "body": "Decision", "body_truncated": false, "targets": [], "initial_post": post_fixture(true, "Decision"),
"closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null, "closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null,
"state": state, "events": [post_fixture(false, "No Git needed")], "state": state, "events": [post_fixture(false, "No Git needed")],
@@ -907,6 +913,7 @@ mod tests {
"queued_by": "workspace-user:alice", "queued_at": "2025-12-31T00:00:00Z", "resolution": null "queued_by": "workspace-user:alice", "queued_at": "2025-12-31T00:00:00Z", "resolution": null
}); });
let response = json!({ let response = json!({
"parent_link": parent_fixture(),
"assignment_tracking": [], "tags": [{"workspace_id":"ws", "id":"tag-1", "name":"closed", "last_operation_key":"tag-create", "deleted":false}], "assignment_tracking": [], "tags": [{"workspace_id":"ws", "id":"tag-1", "name":"closed", "last_operation_key":"tag-create", "deleted":false}],
"resource_key": "T-739", "content_digest": "content-digest", "title": "Case", "body": "Requirements", "body_truncated": false, "targets": [], "initial_post": post_fixture(true, "Requirements"), "resource_key": "T-739", "content_digest": "content-digest", "title": "Case", "body": "Requirements", "body_truncated": false, "targets": [], "initial_post": post_fixture(true, "Requirements"),
"state": "open", "closure_operation_key": "close-1", "current_closure": null, "state": "open", "closure_operation_key": "close-1", "current_closure": null,
@@ -1053,7 +1060,7 @@ mod tests {
#[test] #[test]
fn ticket_detail_projection_accepts_current_blocker_shape() { fn ticket_detail_projection_accepts_current_blocker_shape() {
let projected = project_ticket_detail(json!({ let projected = project_ticket_detail(json!({ "parent_link": parent_fixture(),
"id": "internal-ticket", "id": "internal-ticket",
"resource_key": "T-588", "assignment_tracking": [], "tags": [], "resource_key": "T-588", "assignment_tracking": [], "tags": [],
"content_digest": "ticket-content-digest", "content_digest": "ticket-content-digest",
@@ -1158,7 +1165,7 @@ mod tests {
"integration_evidence_error": integration_error, "integration_evidence_error": integration_error,
"review_excerpt": "Reviewed source" "review_excerpt": "Reviewed source"
}); });
let projected = project_ticket_detail(json!({ let projected = project_ticket_detail(json!({ "parent_link": parent_fixture(),
"resource_key": "T-716", "assignment_tracking": [], "tags": [], "resource_key": "T-716", "assignment_tracking": [], "tags": [],
"content_digest": "current-ticket-content-digest", "content_digest": "current-ticket-content-digest",
"closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null, "closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null,
@@ -1346,7 +1353,7 @@ mod tests {
let tag = json!({"workspace_id":"ws", "id":"tag-1", "name":"closed", "last_operation_key":"tag-create", "deleted":false}); let tag = json!({"workspace_id":"ws", "id":"tag-1", "name":"closed", "last_operation_key":"tag-create", "deleted":false});
let closure = json!({"operation_key":"close-1", "reason":"not_planned", "explanation":"Not proceeding", "references":[{"kind":"ticket", "target":"T-12"}], "author":"workspace-user:alice", "at":"2026-01-01T00:00:00Z"}); let closure = json!({"operation_key":"close-1", "reason":"not_planned", "explanation":"Not proceeding", "references":[{"kind":"ticket", "target":"T-12"}], "author":"workspace-user:alice", "at":"2026-01-01T00:00:00Z"});
let history = json!([{"operation_key":"close-1", "previous_closure_operation_key":null, "action":{"action":"close", "reason":"not_planned"}, "explanation":"Not proceeding", "references":[{"kind":"ticket", "target":"T-12"}], "author":"workspace-user:alice", "at":"2026-01-01T00:00:00Z"}]); let history = json!([{"operation_key":"close-1", "previous_closure_operation_key":null, "action":{"action":"close", "reason":"not_planned"}, "explanation":"Not proceeding", "references":[{"kind":"ticket", "target":"T-12"}], "author":"workspace-user:alice", "at":"2026-01-01T00:00:00Z"}]);
let detail = json!({ let detail = json!({ "parent_link": parent_fixture(),
"resource_key":"T-739", "title":"Case", "body":"Requirements", "body_truncated":false, "targets":[], "initial_post":post_fixture(true, "Requirements"), "created_by":"workspace-user:creator", "content_digest":"content-1", "resource_key":"T-739", "title":"Case", "body":"Requirements", "body_truncated":false, "targets":[], "initial_post":post_fixture(true, "Requirements"), "created_by":"workspace-user:creator", "content_digest":"content-1",
"state":"closed", "assignment_tracking": [tracking], "tags":[tag], "closure_operation_key":"close-1", "current_closure":closure, "state":"closed", "assignment_tracking": [tracking], "tags":[tag], "closure_operation_key":"close-1", "current_closure":closure,
"legacy_handoff":null, "closure_history":history, "events":[], "linked_objectives":[], "assignments":[], "implementation_reports":[], "merge_requests":[] "legacy_handoff":null, "closure_history":history, "events":[], "linked_objectives":[], "assignments":[], "implementation_reports":[], "merge_requests":[]
+157 -9
View File
@@ -44,6 +44,7 @@ mod assignment_public;
mod assignment_tracking; mod assignment_tracking;
mod native; mod native;
mod notification_diagnostics; mod notification_diagnostics;
mod parent_links;
mod results; mod results;
mod tags; mod tags;
@@ -106,10 +107,10 @@ impl WorkspaceTicketReadKind {
fn description(self) -> &'static str { fn description(self) -> &'static str {
match self { match self {
Self::Query => { Self::Query => {
"Query authoritative Workspace Tickets with bounded typed filters, stable snippets, evidence summaries, and cursor metadata." "Query authoritative Workspace Tickets with bounded typed filters (including direct parent or parentless roots), stable snippets, evidence summaries, and cursor metadata. Parent decomposition does not imply state, assignment, dependency or inherited authority."
} }
Self::Show => { Self::Show => {
"Show one authoritative Workspace Ticket with its ordered implementation targets (repository_key, ref_selector, access), content digest, paged thread, links, historical implementation reports, and current Merge Request readiness evidence. Read targets here before choosing a work repository; a null selector remains unspecified, not an inferred default." "Show one authoritative Workspace Ticket with its ordered implementation targets (repository_key, ref_selector, access), content digest, parent_link including its exact nullable CAS observation, paged thread, links, historical implementation reports, and current Merge Request readiness evidence. Read targets here before choosing a work repository; a null selector remains unspecified, not an inferred default. Read direct children with TicketChildrenQuery. Reread parent_link before a new parent mutation; replay receipts are historical, not fresh observations. Parent links grant neither state/assignment changes nor inherited authority."
} }
} }
} }
@@ -207,6 +208,8 @@ struct WorkspaceQueryTicketInput {
/// Related Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility. /// Related Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
related_ticket_id: Option<String>, related_ticket_id: Option<String>,
relation_kind: Option<WorkspaceTicketRelationFilter>, relation_kind: Option<WorkspaceTicketRelationFilter>,
/// Work decomposition: no filter when omitted; kind none selects roots, parent selects direct children.
parent: Option<ticket::TicketParentFilter>,
/// All stable tag IDs must be attached (AND); at most 100 unique IDs. /// All stable tag IDs must be attached (AND); at most 100 unique IDs.
#[serde(default)] #[serde(default)]
#[schemars(length(max = 100))] #[schemars(length(max = 100))]
@@ -292,6 +295,14 @@ impl WorkspaceTicketReadTool {
})?; })?;
let (path, body, target) = match command { let (path, body, target) = match command {
WorkspaceTicketReadCommand::Query(input) => { WorkspaceTicketReadCommand::Query(input) => {
if let Some(ticket::TicketParentFilter::Parent { ticket }) = &input.parent {
authorize_ticket_item(
self.client.as_ref(),
"ticket.relation.query",
&TicketIdOrSlug::from(ticket.as_str()),
)
.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),
("tag_ids_any", &input.tag_ids_any), ("tag_ids_any", &input.tag_ids_any),
@@ -324,7 +335,11 @@ impl WorkspaceTicketReadTool {
} }
}; };
match target { match target {
Some(id) => authorize_ticket_item(self.client.as_ref(), "ticket.read", &id), Some(id) => {
authorize_ticket_item(self.client.as_ref(), "ticket.read", &id)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
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()))?;
@@ -352,9 +367,9 @@ impl WorkspaceTicketReadTool {
WorkspaceTicketReadKind::Query => serde_json::to_value( WorkspaceTicketReadKind::Query => serde_json::to_value(
&project_ticket_query(response_value).map_err(ToolError::ExecutionFailed)?, &project_ticket_query(response_value).map_err(ToolError::ExecutionFailed)?,
), ),
WorkspaceTicketReadKind::Show => serde_json::to_value( WorkspaceTicketReadKind::Show => {
&project_ticket_detail(response_value).map_err(ToolError::ExecutionFailed)?, return parent_links::project_detail(self.client.as_ref(), response_value);
), }
} }
.map_err(|error| ToolError::Internal(error.to_string())) .map_err(|error| ToolError::Internal(error.to_string()))
} }
@@ -547,6 +562,7 @@ fn workspace_ticket_tool_description(name: &str) -> String {
assignment_tracking::description(name).into() assignment_tracking::description(name).into()
} }
name if tags::TAG_TOOL_NAMES.contains(&name) => tags::description(name).into(), name if tags::TAG_TOOL_NAMES.contains(&name) => tags::description(name).into(),
name if parent_links::TOOL_NAMES.contains(&name) => parent_links::description(name).into(),
_ => ticket_tool_description(name, None), _ => ticket_tool_description(name, None),
} }
} }
@@ -698,6 +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| {
*name == "TicketChildrenQuery" || self.authoring || self.workflow
}),
)
.collect() .collect()
} }
@@ -723,6 +744,7 @@ const READ_ONLY_TOOL_NAMES: &[&str] = &[
"TicketAssignmentDiagnostics", "TicketAssignmentDiagnostics",
"TicketImplementationResultQuery", "TicketImplementationResultQuery",
"NotificationDiagnostics", "NotificationDiagnostics",
"TicketChildrenQuery",
]; ];
const AUTHORING_TOOL_NAMES: &[&str] = &[ const AUTHORING_TOOL_NAMES: &[&str] = &[
@@ -762,6 +784,8 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
"TicketImplementationResult", "TicketImplementationResult",
"TicketImplementationResultQuery", "TicketImplementationResultQuery",
"NotificationDiagnostics", "NotificationDiagnostics",
"TicketParentSet",
"TicketChildrenQuery",
]; ];
#[cfg(test)] #[cfg(test)]
@@ -791,6 +815,8 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[
"TicketImplementationResult", "TicketImplementationResult",
"TicketImplementationResultQuery", "TicketImplementationResultQuery",
"NotificationDiagnostics", "NotificationDiagnostics",
"TicketParentSet",
"TicketChildrenQuery",
]; ];
const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[ const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[
@@ -1117,6 +1143,57 @@ impl WorkspaceHttpTicketBackend {
) -> TicketResult<TicketBackendOperationResult> { ) -> TicketResult<TicketBackendOperationResult> {
let base = format!("/api/w/{workspace_id}/tickets"); let base = format!("/api/w/{workspace_id}/tickets");
match operation { match operation {
TicketBackendOperation::ParentLink { id } => {
authorize_ticket_item(client.as_ref(), "ticket.relation.read", &id)?;
let link: ticket::TicketParentLink = Self::request_unprojected(
client.clone(),
WorkspaceRequestMethod::Get,
format!("{base}/{}/parent", Self::ticket_path(&id)),
None,
)?;
parent_links::authorize_link(client.as_ref(), &link)?;
Ok(TicketBackendOperationResult::ParentLink(link))
}
TicketBackendOperation::SetParentLink { id, request } => {
parent_links::validate_set(&request)?;
parent_links::authorize_set(client.clone(), &base, &id, &request)?;
let link: ticket::TicketParentLink = Self::request_unprojected(
client,
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.
Ok(TicketBackendOperationResult::ParentLink(link))
}
TicketBackendOperation::ChildrenPage { id, request } => {
let endpoint = parent_links::children_endpoint(&base, &id, &request)?;
authorize_ticket_item(client.as_ref(), "ticket.relation.read", &id)?;
let page: ticket::TicketChildrenPage = Self::request_unprojected(
client.clone(),
WorkspaceRequestMethod::Get,
endpoint,
None,
)?;
if page.items.len() > request.limit.unwrap_or(50) {
return Err(TicketError::Conflict(
"Ticket children response exceeds requested bound".into(),
));
}
authorize_ticket_item(
client.as_ref(),
"ticket.relation.read",
&TicketIdOrSlug::from(page.parent_ticket_id.as_str()),
)?;
for child in &page.items {
authorize_ticket_item(
client.as_ref(),
"ticket.relation.read",
&TicketIdOrSlug::from(child.id.as_str()),
)?;
}
Ok(TicketBackendOperationResult::ChildrenPage(page))
}
TicketBackendOperation::ListWorkspaceTags { request } => { TicketBackendOperation::ListWorkspaceTags { request } => {
authorize_ticket_operation(client.as_ref(), "ticket.tag.list")?; authorize_ticket_operation(client.as_ref(), "ticket.tag.list")?;
let mut endpoint = format!( let mut endpoint = format!(
@@ -1475,6 +1552,35 @@ macro_rules! expect_ticket_result {
} }
impl TicketBackend for WorkspaceHttpTicketBackend { impl TicketBackend for WorkspaceHttpTicketBackend {
fn get_ticket_parent(&self, id: TicketIdOrSlug) -> TicketResult<ticket::TicketParentLink> {
expect_ticket_result!(
self.invoke(TicketBackendOperation::ParentLink { id }),
TicketBackendOperationResult::ParentLink
)
}
fn set_ticket_parent(
&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> {
expect_ticket_result!(
self.invoke(TicketBackendOperation::ChildrenPage { id, request }),
TicketBackendOperationResult::ChildrenPage
)
}
fn edit_post_with_snapshots( fn edit_post_with_snapshots(
&self, &self,
id: TicketIdOrSlug, id: TicketIdOrSlug,
@@ -1720,6 +1826,18 @@ struct NativeTicketOperation {
fn native_ticket_operation(tool_name: &str) -> Option<NativeTicketOperation> { fn native_ticket_operation(tool_name: &str) -> Option<NativeTicketOperation> {
let operation = match tool_name { let operation = match tool_name {
"TicketParentSet" => NativeTicketOperation {
operation: "parent_set",
surface: NativeTicketSurface::Item,
identity_field: Some("ticket"),
mutating: true,
},
"TicketChildrenQuery" => NativeTicketOperation {
operation: "children_query",
surface: NativeTicketSurface::Item,
identity_field: Some("ticket"),
mutating: false,
},
"WorkspaceTagList" => NativeTicketOperation { "WorkspaceTagList" => NativeTicketOperation {
operation: "tag_list", operation: "tag_list",
surface: NativeTicketSurface::Collection, surface: NativeTicketSurface::Collection,
@@ -1964,6 +2082,14 @@ pub(crate) fn enabled_ticket_definitions(
if allowed.contains(&notification_diagnostics::NAME) { if allowed.contains(&notification_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,
] {
if allowed.contains(&kind.name()) {
definitions.push(parent_links::definition(client.clone(), kind));
}
}
definitions definitions
} }
@@ -2226,8 +2352,18 @@ impl WipOperationHandler for TicketItemWipHandler {
)?; )?;
let result_namespaces = context.result_namespaces.clone(); let result_namespaces = context.result_namespaces.clone();
let response = execute_native_ticket_tool(tool, &self.permissions, input, context).await?; let response = execute_native_ticket_tool(tool, &self.permissions, input, context).await?;
// Classification has its own membership CAS and never changes item content. if operation == "parent_set" {
if operation != "tags_set" { // 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
// previously read parent/children view attested by a stale route validator.
self.observations
.lock()
.unwrap_or_else(|error| error.into_inner())
.content_digests
.clear();
}
// Partial pages and classification receipts are not full item observations.
if !matches!(operation, "tags_set" | "children_query") {
record_ticket_observation( record_ticket_observation(
&self.observations, &self.observations,
Some(&self.ticket_reference), Some(&self.ticket_reference),
@@ -2256,6 +2392,10 @@ 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())
{
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(
tool.client.clone(), tool.client.clone(),
&tool.name, &tool.name,
@@ -2352,6 +2492,13 @@ async fn execute_ticket_domain(
command: Value, command: Value,
context: ToolExecutionContext, context: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
if let Some(result) = parent_links::execute_value(client.clone(), name, command.clone()) {
return result.map(|value| ToolOutput {
summary: name.into(),
content: Some(value.to_string()),
attachments: Vec::new(),
});
}
if name == "QueryTicket" || name == "ShowTicket" { if name == "QueryTicket" || name == "ShowTicket" {
let kind = if name == "QueryTicket" { let kind = if name == "QueryTicket" {
WorkspaceTicketReadKind::Query WorkspaceTicketReadKind::Query
@@ -2998,10 +3145,11 @@ mod tests {
} }
} }
fn target_detail_fixture(targets: Value) -> Value { pub(super) fn target_detail_fixture(targets: Value) -> Value {
json!({ json!({
"resource_key": "T-739", "title": "Work location", "body": "Requirements", "resource_key": "T-739", "title": "Work location", "body": "Requirements",
"body_truncated": false, "content_digest": "observed-content", "targets": targets, "body_truncated": false, "content_digest": "observed-content", "targets": targets,
"parent_link": {"ticket_id":"internal739","observation":{"parent_ticket_id":null,"last_operation_key":null},"parent":null},
"initial_post": { "initial_post": {
"sequence": 0, "event_ref": "initial", "is_initial": true, "sequence": 0, "event_ref": "initial", "is_initial": true,
"kind": "comment", "body": "Requirements", "body_truncated": false, "kind": "comment", "body": "Requirements", "body_truncated": false,
@@ -219,6 +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![
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()),
@@ -266,9 +280,30 @@ pub(super) fn contract(op: &str) -> Contract {
p("targets", false, list(target())), p("targets", false, list(target())),
], ],
), ),
"parent_set" => (
"parent_set",
parent_links::description("TicketParentSet"),
vec![
p(
"expected",
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("operation_key", true, Shape::String),
],
),
"children_query" => (
"children_query",
parent_links::description("TicketChildrenQuery"),
vec![page("cursor", "limit")],
),
"read" => ( "read" => (
"read", "read",
"Read this route-bound Ticket and a bounded thread page. Omit page for Backend defaults.", "Read this route-bound Ticket, its parent_link CAS observation and a bounded thread page. Use children_query for bounded direct children. Parent decomposition grants neither inherited authority nor state/assignment changes. Omit page for Backend defaults.",
vec![page("event_cursor", "event_limit")], vec![page("event_cursor", "event_limit")],
), ),
"edit" => ( "edit" => (
@@ -0,0 +1,328 @@
//! Decomposition links reuse relation authority, not case/assignment or inherited grants.
//! Tool and WIP inputs converge here; the Backend owns atomic CAS, cycle checks and replay.
use super::*;
#[cfg(test)]
mod tests;
pub(super) const TOOL_NAMES: &[&str] = &["TicketParentSet", "TicketChildrenQuery"];
#[derive(Clone, Copy)]
pub(super) enum ParentToolKind {
Set,
Children,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct SetInput {
/// Child Ticket reference. WIP binds this from the addressed item route.
ticket: String,
/// Exact observation from ShowTicket.parent_link; both nullable fields must be present.
expected: ticket::TicketParentObservation,
/// Desired parent reference, or explicit null to unlink. Never omit.
#[serde(deserialize_with = "deserialize_required_parent")]
#[schemars(required, schema_with = "nullable_string_schema")]
parent: Option<String>,
/// Stable exact-replay key; retain the full original selectors and input on retry.
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>>(
deserializer: D,
) -> Result<Option<String>, D::Error> {
Option::<String>::deserialize(deserializer)
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct ChildrenInput {
/// Parent Ticket reference. WIP binds this from the addressed item route.
ticket: String,
/// Opaque cursor from this parent's previous page; keep filters and scope unchanged.
cursor: Option<String>,
/// Bounded direct children, 1..=100; defaults to 50. Not a recursive tree expansion.
#[schemars(range(min = 1, max = 100))]
limit: Option<usize>,
}
impl ParentToolKind {
pub(super) fn name(self) -> &'static str {
match self {
Self::Set => TOOL_NAMES[0],
Self::Children => TOOL_NAMES[1],
}
}
fn schema(self) -> Value {
match self {
Self::Set => json!(schemars::schema_for!(SetInput)),
Self::Children => json!(schemars::schema_for!(ChildrenInput)),
}
}
}
pub(super) fn description(name: &str) -> &'static str {
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."
}
"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"),
}
}
fn required(name: &str, value: &str) -> TicketResult<()> {
if value.trim().is_empty() {
return Err(TicketError::Conflict(format!("{name} must not be empty")));
}
Ok(())
}
/// Validate without normalizing selectors or keys: exact replay fingerprints the original input.
pub(super) fn validate_set(request: &ticket::TicketParentSetRequest) -> TicketResult<()> {
required("operation_key", &request.operation_key)?;
for (name, value) in [
(
"expected.parent_ticket_id",
&request.expected.parent_ticket_id,
),
(
"expected.last_operation_key",
&request.expected.last_operation_key,
),
("parent", &request.parent),
] {
if let Some(value) = value {
required(name, value)?;
}
}
Ok(())
}
/// Resolve immutable identity using only this Ticket's metadata-read authority. Detail
/// would project parent metadata and require unrelated ancestor reads; the plain record
/// would additionally require body-read authority. Neither is needed for ID comparison.
/// The original CAS/replay input stays untouched.
fn resolve_id(
client: Arc<dyn WorkspaceClient>,
base: &str,
reference: &TicketIdOrSlug,
) -> TicketResult<String> {
authorize_ticket_item(client.as_ref(), "ticket.relation.read", reference)?;
let summary: ticket::TicketLinkSummary = WorkspaceHttpTicketBackend::request_unprojected(
client,
WorkspaceRequestMethod::Get,
format!(
"{base}/{}/link-summary",
WorkspaceHttpTicketBackend::ticket_path(reference),
),
None,
)?;
if summary.id.is_empty() {
return Err(TicketError::Conflict(
"resolved Ticket identity is unavailable".into(),
));
}
Ok(summary.id)
}
pub(super) fn authorize_set(
client: Arc<dyn WorkspaceClient>,
base: &str,
child: &TicketIdOrSlug,
request: &ticket::TicketParentSetRequest,
) -> TicketResult<()> {
let mut references = vec![("ticket.relation.read", child.clone())];
authorize_ticket_item(client.as_ref(), "ticket.relation.read", child)?;
let desired = if let Some(parent) = &request.parent {
let parent = TicketIdOrSlug::from(parent.as_str());
for reference in [child, &parent] {
authorize_ticket_item(client.as_ref(), "ticket.relation.record", reference)?;
references.push(("ticket.relation.record", reference.clone()));
}
// The receipt exposes desired-parent metadata. Mutation authority is not read
// authority, so reject before PUT instead of denying after a successful write.
authorize_ticket_item(client.as_ref(), "ticket.relation.read", &parent)?;
references.push(("ticket.relation.read", parent.clone()));
if request.expected.parent_ticket_id.is_some() {
Some(resolve_id(client.clone(), base, &parent)?)
} else {
// First attachment has no old edge to remove. No identity/body lookup is
// needed to decide its grants; Host authorization resolves the references.
None
}
} else {
None
};
let remove = request.parent.is_none()
|| (request.expected.parent_ticket_id.is_some()
&& request.expected.parent_ticket_id != desired);
if remove {
authorize_ticket_item(client.as_ref(), "ticket.relation.remove", child)?;
references.push(("ticket.relation.remove", child.clone()));
if let Some(old) = &request.expected.parent_ticket_id {
let old = TicketIdOrSlug::from(old.as_str());
authorize_ticket_item(client.as_ref(), "ticket.relation.remove", &old)?;
references.push(("ticket.relation.remove", old));
}
}
// Recheck the explicit resources immediately before the write. The resolved desired
// ID is additionally guarded when comparison required a metadata lookup. Do not
// rewrite any wire selector, observation or replay key with resolved identity.
for (operation, reference) in references {
authorize_ticket_item(client.as_ref(), operation, &reference)?;
}
if let Some(desired) = desired {
let desired = TicketIdOrSlug::Id(desired);
authorize_ticket_item(client.as_ref(), "ticket.relation.record", &desired)?;
authorize_ticket_item(client.as_ref(), "ticket.relation.read", &desired)?;
}
Ok(())
}
pub(super) fn children_endpoint(
base: &str,
id: &TicketIdOrSlug,
request: &ticket::TicketChildrenRequest,
) -> TicketResult<String> {
let limit = request.limit.unwrap_or(50);
if !(1..=100).contains(&limit) {
return Err(TicketError::Conflict("limit must be in 1..=100".into()));
}
let mut endpoint = format!(
"{base}/{}/children?limit={limit}",
WorkspaceHttpTicketBackend::ticket_path(id),
);
if let Some(cursor) = &request.cursor {
required("cursor", cursor)?;
endpoint.push_str("&cursor=");
endpoint.push_str(&WorkspaceHttpTicketBackend::ticket_path(
&TicketIdOrSlug::Id(cursor.clone()),
));
}
Ok(endpoint)
}
pub(super) fn authorize_link(
client: &dyn WorkspaceClient,
link: &ticket::TicketParentLink,
) -> TicketResult<()> {
authorize_ticket_item(
client,
"ticket.relation.read",
&TicketIdOrSlug::from(link.ticket_id.as_str()),
)?;
if let Some(parent) = &link.parent {
authorize_ticket_item(
client,
"ticket.relation.read",
&TicketIdOrSlug::from(parent.id.as_str()),
)?;
}
Ok(())
}
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())
})?)
.map_err(|error| {
ToolError::ExecutionFailed(format!("decode Ticket parent_link: {error}"))
})?;
authorize_link(client, &parent_link)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
let detail = project_ticket_detail(value).map_err(ToolError::ExecutionFailed)?;
serde_json::to_value(detail).map_err(|error| ToolError::Internal(error.to_string()))
}
#[derive(Clone)]
struct ParentTool {
client: Arc<dyn WorkspaceClient>,
kind: ParentToolKind,
}
#[async_trait]
impl Tool for ParentTool {
async fn execute(&self, input: &str, _: ToolExecutionContext) -> Result<ToolOutput, ToolError> {
let value = serde_json::from_str(input)
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
let result = execute_value(self.client.clone(), self.kind.name(), value)
.expect("registered parent tool")?;
Ok(ToolOutput {
summary: self.kind.name().into(),
content: Some(result.to_string()),
attachments: Vec::new(),
})
}
}
pub(super) fn execute_value(
client: Arc<dyn WorkspaceClient>,
name: &str,
input: Value,
) -> Option<Result<Value, ToolError>> {
if !TOOL_NAMES.contains(&name) {
return None;
}
Some((|| {
let backend = WorkspaceHttpTicketBackend::new(client);
let parse = |error: serde_json::Error| ToolError::InvalidArgument(error.to_string());
let failure = |error: TicketError| ToolError::ExecutionFailed(error.to_string());
let invalid = |error: TicketError| ToolError::InvalidArgument(error.to_string());
match name {
"TicketParentSet" => {
let input: SetInput = serde_json::from_value(input).map_err(parse)?;
required("ticket", &input.ticket).map_err(invalid)?;
let request = ticket::TicketParentSetRequest {
expected: input.expected,
parent: input.parent,
operation_key: input.operation_key,
};
validate_set(&request).map_err(invalid)?;
let result = backend
.set_ticket_parent(TicketIdOrSlug::from(input.ticket.as_str()), request)
.map_err(failure)?;
Ok(json!(result))
}
"TicketChildrenQuery" => {
let input: ChildrenInput = serde_json::from_value(input).map_err(parse)?;
required("ticket", &input.ticket).map_err(invalid)?;
let request = ticket::TicketChildrenRequest {
cursor: input.cursor,
limit: input.limit,
};
let id = TicketIdOrSlug::from(input.ticket.as_str());
children_endpoint("", &id, &request).map_err(invalid)?;
let result = backend
.query_ticket_children(id, request)
.map_err(failure)?;
Ok(json!(result))
}
_ => unreachable!(),
}
})())
}
pub(super) fn definition(client: Arc<dyn WorkspaceClient>, kind: ParentToolKind) -> ToolDefinition {
Arc::new(move || {
(
ToolMeta::new(kind.name())
.description(description(kind.name()))
.input_schema(kind.schema()),
Arc::new(ParentTool {
client: client.clone(),
kind,
}) as Arc<dyn Tool>,
)
})
}
@@ -0,0 +1,914 @@
//! 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.
use super::*;
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
use assignment_tracking::authorization_tests::{AuthorizedClient, ticket};
use server_api::authorization::PermissionResource;
#[derive(Debug)]
struct Client {
authority: Arc<AuthorizedClient>,
requests: Mutex<Vec<WorkspaceRequest>>,
response: Value,
status: u16,
}
impl Client {
fn new(response: Value) -> Arc<Self> {
Arc::new(Self {
authority: AuthorizedClient::new(json!(null)),
requests: Mutex::new(Vec::new()),
response,
status: 200,
})
}
fn grant(&self, operation: &str, id: &str) {
self.authority.grant(operation, ticket(id));
}
fn writes(&self) -> Vec<WorkspaceRequest> {
self.requests
.lock()
.unwrap()
.iter()
.filter(|request| request.method == WorkspaceRequestMethod::Put)
.cloned()
.collect()
}
}
impl WorkspaceClient for Client {
fn workspace_id(&self) -> Option<&str> {
Some("workspace")
}
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" {
return self.authority.execute(request);
}
let path = request.path.clone();
let resolving =
request.method == WorkspaceRequestMethod::Get && path.ends_with("/link-summary");
self.requests.lock().unwrap().push(request);
let response = if resolving {
let reference = path
.strip_suffix("/link-summary")
.unwrap()
.rsplit('/')
.next()
.unwrap();
let id = match reference {
"T-1" => "internalchild",
"T-2" => "internalold",
"T-3" => "internalnew",
_ => reference,
};
let key = match id {
"internalchild" => "T-1",
"internalold" => "T-2",
"internalnew" => "T-3",
_ => id,
};
summary(id, key)
} else {
self.response.clone()
};
Ok(WorkspaceResponse {
status: if resolving { 200 } else { self.status },
body: response.to_string(),
})
}
}
fn summary(id: &str, key: &str) -> Value {
json!({
"id": id,
"resource_key": key,
"title": "ordinary Ticket",
"state": "closed",
"closure_reason": "not_planned"
})
}
fn link() -> Value {
json!({
"ticket_id": "internalchild",
"observation": {"parent_ticket_id": "internalold", "last_operation_key": "before"},
"parent": summary("internalold", "T-2")
})
}
fn set_input(parent: Value) -> Value {
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> {
match json_to_wip(&value).unwrap_or_else(|_| panic!("invalid WIP fixture arguments: {value}")) {
WipValue::Record(fields) => fields,
_ => panic!("expected record"),
}
}
fn handler(client: Arc<Client>) -> TicketItemWipHandler {
TicketItemWipHandler {
tools: operation_map(
native_ticket_tools(client, TicketFeatureAccess::workflow())
.unwrap()
.into_iter()
.filter(|tool| tool.projection.surface == NativeTicketSurface::Item)
.collect(),
),
permissions: None,
ticket_reference: "internalchild".into(),
collection_route: "/cases".into(),
merge_request_route: String::new(),
objective_route: String::new(),
observations: Arc::new(Mutex::new(TicketObservationState::default())),
}
}
fn context() -> WipCallContext {
WipCallContext {
result_namespaces: Default::default(),
execution: ToolExecutionContext::direct(),
security_context: "worker".into(),
}
}
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"
}))
}
#[test]
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);
assert!(serde_json::from_value::<SetInput>(input.clone()).is_ok());
assert!(validator.is_valid(&input));
for path in [
"parent",
"expected.parent_ticket_id",
"expected.last_operation_key",
] {
let mut missing = input.clone();
if let Some(field) = path.strip_prefix("expected.") {
missing["expected"].as_object_mut().unwrap().remove(field);
} else {
missing.as_object_mut().unwrap().remove(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"
});
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"];
for field in ["parent_ticket_id", "last_operation_key"] {
assert!(
observation["required"]
.as_array()
.unwrap()
.contains(&json!(field))
);
}
}
#[tokio::test]
async fn reparent_requires_record_on_child_new_and_remove_on_child_old_in_tool_and_wip() {
let client = Client::new(link());
for operation in ["ticket.relation.record", "ticket.relation.remove"] {
client.authority.grant(
operation,
PermissionResource::Workspace {
workspace_id: "workspace".into(),
},
);
}
let (_, tool) = definition(client.clone(), ParentToolKind::Set)();
let input = set_input(json!("T-3"));
let wip = handler(client.clone());
let args = native_set(json!({"$case":"value","value":"/cases/T-3"}));
assert!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.is_err()
);
assert!(wip.call("parent_set", &args, context()).await.is_err());
assert!(client.requests.lock().unwrap().is_empty());
for id in ["internalchild", "T-3", "internalnew"] {
client.grant("ticket.relation.read", id);
}
for id in ["internalchild", "T-3", "internalnew"] {
client.grant("ticket.relation.record", id);
}
// Even record authority on all three endpoints cannot detach the old edge.
client.grant("ticket.relation.record", "internalold");
assert!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.is_err()
);
assert!(wip.call("parent_set", &args, context()).await.is_err());
assert!(client.writes().is_empty());
client.grant("ticket.relation.remove", "internalchild");
assert!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.is_err()
);
assert!(wip.call("parent_set", &args, context()).await.is_err());
assert!(client.writes().is_empty());
client.grant("ticket.relation.remove", "internalold");
let result = tool
.execute(&input.to_string(), ToolExecutionContext::direct())
.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();
assert_eq!(writes.len(), 2);
for write in writes {
assert_eq!(write.path, "/api/w/workspace/tickets/internalchild/parent");
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"})
);
}
client.authority.grants.lock().unwrap().clear();
assert!(wip.call("parent_set", &args, context()).await.is_err());
assert_eq!(client.writes().len(), 2);
}
#[tokio::test]
async fn reparent_does_not_require_record_on_old_or_remove_on_new() {
let client = Client::new(link());
for id in ["internalchild", "T-3", "internalnew"] {
client.grant("ticket.relation.read", id);
}
for id in ["internalchild", "T-3", "internalnew"] {
client.grant("ticket.relation.record", id);
}
for id in ["internalchild", "internalold"] {
client.grant("ticket.relation.remove", id);
}
let (_, tool) = definition(client.clone(), ParentToolKind::Set)();
tool.execute(
&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);
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| {
check.operation == "ticket.relation.remove" && check.resource == ticket("internalnew")
}));
assert!(checks.iter().all(|check| check.operation != "ticket.read"));
assert!(!checks.iter().any(|check| {
check.operation == "ticket.relation.read" && check.resource == ticket("internalold")
}));
drop(checks);
let requests = client.requests.lock().unwrap();
let lookups = requests
.iter()
.filter(|request| request.method == WorkspaceRequestMethod::Get)
.collect::<Vec<_>>();
assert_eq!(lookups.len(), 2);
assert!(
lookups
.iter()
.all(|request| request.path == "/api/w/workspace/tickets/T-3/link-summary")
);
}
#[tokio::test]
async fn same_parent_alias_is_compared_after_resolution_without_remove_or_wire_rewrite() {
let client = Client::new(link());
for id in ["internalchild", "T-2", "internalold"] {
client.grant("ticket.relation.read", id);
client.grant("ticket.relation.record", id);
}
let input = set_input(json!("T-2"));
let (_, tool) = definition(client.clone(), ParentToolKind::Set)();
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.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!(
client
.authority
.authorizations
.lock()
.unwrap()
.iter()
.all(|check| {
check.operation != "ticket.relation.remove" && check.operation != "ticket.read"
})
);
let requests = client.requests.lock().unwrap();
let lookups = requests
.iter()
.filter(|request| request.method == WorkspaceRequestMethod::Get)
.collect::<Vec<_>>();
assert_eq!(lookups.len(), 2);
assert!(
lookups
.iter()
.all(|request| request.path == "/api/w/workspace/tickets/T-2/link-summary")
);
drop(requests);
for write in client.writes() {
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"})
);
}
}
#[tokio::test]
async fn initial_attach_needs_only_record_and_metadata_read_without_remove_or_body_lookup() {
let client = Client::new(link());
let (_, tool) = definition(client.clone(), ParentToolKind::Set)();
let mut input = set_input(json!("T-3"));
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"
}));
for id in ["internalchild", "T-3"] {
client.grant("ticket.relation.record", id);
}
let wip = handler(client.clone());
// Record grants never imply metadata read. Missing either endpoint rejects before PUT.
assert!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.is_err()
);
assert!(wip.call("parent_set", &args, context()).await.is_err());
assert!(client.requests.lock().unwrap().is_empty());
client.grant("ticket.relation.read", "internalchild");
assert!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.is_err()
);
assert!(wip.call("parent_set", &args, context()).await.is_err());
assert!(client.requests.lock().unwrap().is_empty());
client.grant("ticket.relation.read", "T-3");
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.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!(
client
.requests
.lock()
.unwrap()
.iter()
.all(|request| request.method == WorkspaceRequestMethod::Put)
);
let checks = client.authority.authorizations.lock().unwrap();
assert!(checks.iter().all(|check| matches!(
check.operation.as_str(),
"ticket.relation.record" | "ticket.relation.read"
)));
drop(checks);
for write in client.writes() {
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"})
);
}
}
#[tokio::test]
async fn unlink_needs_metadata_read_on_child_and_remove_on_child_old_only() {
let client = Client::new(link());
let (_, tool) = definition(client.clone(), ParentToolKind::Set)();
let input = set_input(Value::Null);
let args = native_set(json!({"$case":"null"}));
let wip = handler(client.clone());
for id in ["internalchild", "internalold"] {
client.grant("ticket.relation.remove", id);
}
assert!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.is_err()
);
assert!(wip.call("parent_set", &args, context()).await.is_err());
assert!(client.requests.lock().unwrap().is_empty());
client.grant("ticket.relation.read", "internalchild");
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.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!(
client
.requests
.lock()
.unwrap()
.iter()
.all(|request| request.method == WorkspaceRequestMethod::Put)
);
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().any(|check| {
check.operation == "ticket.relation.read" && check.resource == ticket("internalold")
}));
}
#[tokio::test]
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 client = Client::new(page.clone());
let (_, tool) = definition(client.clone(), ParentToolKind::Children)();
let input = json!({"ticket":"internalchild","cursor":"page/+&=opaque","limit":1});
assert!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.is_err()
);
assert!(client.requests.lock().unwrap().is_empty());
client.grant("ticket.relation.read", "internalchild");
// Parent access alone cannot disclose an unauthorized child.
assert!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.is_err()
);
client.grant("ticket.relation.read", "internalold");
let result = tool
.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap();
assert_eq!(
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)));
let Some(WipValue::List(items)) = fields.get("items") else {
panic!("children")
};
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());
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());
let requests = client.requests.lock().unwrap();
assert!(requests.iter().all(|r| r.method == WorkspaceRequestMethod::Get
&& r.path == "/api/w/workspace/tickets/internalchild/children?limit=1&cursor=page%2F%2B%26%3Dopaque"));
drop(requests);
for limit in [0, 101] {
let before = client.requests.lock().unwrap().len();
assert!(
tool.execute(
&json!({"ticket":"internalchild","limit":limit}).to_string(),
ToolExecutionContext::direct(),
)
.await
.is_err()
);
assert_eq!(client.requests.lock().unwrap().len(), before);
}
let backend = WorkspaceHttpTicketBackend::new(client.clone());
backend
.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]
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
}));
client.grant("ticket.relation.read", "T-1");
let backend = WorkspaceHttpTicketBackend::new(client.clone());
let page = ticket::TicketChildrenRequest {
cursor: None,
limit: Some(1),
};
assert!(
backend
.query_ticket_children(TicketIdOrSlug::from("T-1"), page.clone())
.is_err()
);
client.grant("ticket.relation.read", "internalchild");
backend
.query_ticket_children(TicketIdOrSlug::from("T-1"), page)
.unwrap();
}
#[test]
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
}));
client.grant("ticket.relation.read", "T-1");
let error = WorkspaceHttpTicketBackend::new(client)
.query_ticket_children(
TicketIdOrSlug::from("T-1"),
ticket::TicketChildrenRequest {
cursor: None,
limit: Some(1),
},
)
.unwrap_err();
assert!(error.to_string().contains("bound"));
}
#[test]
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"] {
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 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);
for field in ["parent_ticket_id", "last_operation_key"] {
let mut args = args.clone();
if let Some(WipValue::Record(expected)) = args.get_mut("expected") {
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_or_else(|_| panic!("native query rejected scoped parent filter"));
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_or_else(|_| panic!("native query rejected root-only parent filter"));
assert_eq!(roots["parent"], json!({"kind":"none"}));
}
#[test]
fn parent_read_preserves_cas_ids_and_requires_actual_parent_resource() {
let client = Client::new(link());
client.grant("ticket.relation.read", "internalchild");
let backend = WorkspaceHttpTicketBackend::new(client.clone());
assert!(
backend
.get_ticket_parent(TicketIdOrSlug::from("internalchild"))
.is_err()
);
client.grant("ticket.relation.read", "internalold");
let result = backend
.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!(
client
.requests
.lock()
.unwrap()
.iter()
.all(|r| r.path.ends_with("/internalchild/parent"))
);
}
#[tokio::test]
async fn show_and_wip_read_expose_parent_observation_and_lazy_navigation() {
let mut detail = super::super::tests::target_detail_fixture(json!([]));
detail["resource_key"] = json!("T-1");
detail["parent_link"] = link();
let client = Client::new(detail);
client.grant("ticket.read", "internalchild");
client.grant("ticket.relation.read", "internalchild");
let (_, tool) =
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");
let output = tool
.execute(r#"{"id":"internalchild"}"#, ToolExecutionContext::direct())
.await
.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"]["parent"]["closure_reason"],
"not_planned"
);
handler(client.clone())
.call("read", &BTreeMap::new(), context())
.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.
assert!(
client
.requests
.lock()
.unwrap()
.iter()
.all(|r| r.path.ends_with("/internalchild/show"))
);
}
#[tokio::test]
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
})),
] {
let mut detail = super::super::tests::target_detail_fixture(json!([]));
if let Some(parent_link) = parent_link {
detail["parent_link"] = parent_link;
} else {
detail.as_object_mut().unwrap().remove("parent_link");
}
let client = Client::new(detail);
client.grant("ticket.read", "internalchild");
client.grant("ticket.relation.read", "internalchild");
let (_, tool) =
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!(
handler(client)
.call("read", &BTreeMap::new(), context())
.await
.is_err()
);
}
}
#[tokio::test]
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"}),
] {
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});
if parent["kind"] == "parent" {
assert!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.is_err()
);
assert!(client.requests.lock().unwrap().is_empty());
client.grant("ticket.relation.query", "T-3");
}
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap();
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].path, "/api/w/workspace/tickets/query");
let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
assert_eq!(body["parent"], parent);
assert_eq!(body["cursor"], "opaque/+&");
assert_eq!(body["limit"], 7);
}
}
#[tokio::test]
async fn failed_parent_write_never_refreshes_cas_or_retries_as_a_new_operation() {
for status in [409, 503] {
let client = Arc::new(Client {
authority: AuthorizedClient::new(json!(null)),
requests: Mutex::new(Vec::new()),
response: json!({"message":"reconcile current parent"}),
status,
});
for id in ["internalchild", "internalold"] {
client.grant("ticket.relation.remove", id);
}
client.grant("ticket.relation.read", "internalchild");
let (_, tool) = definition(client.clone(), ParentToolKind::Set)();
let input = set_input(Value::Null);
for attempt in 1..=2 {
assert!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.is_err()
);
assert_eq!(client.writes().len(), attempt); // no hidden mutation retry
}
let writes = client.writes();
assert_eq!(writes[0], writes[1]);
let body: Value = serde_json::from_str(writes[0].body.as_deref().unwrap()).unwrap();
assert_eq!(body["expected"], input["expected"]);
assert_eq!(body["operation_key"], "same-key");
}
}
@@ -70,6 +70,12 @@ pub(super) fn declaration(operation: &str, item: bool) -> Option<ReturnDeclarati
field("next_cursor", false, TypeExpr::String), field("next_cursor", false, TypeExpr::String),
field("has_more", true, TypeExpr::Boolean), field("has_more", true, TypeExpr::Boolean),
]), ]),
"children_query" => record(vec![
field("entry", true, TypeExpr::Entry),
field("items", true, list(item_type(true))),
field("next_cursor", false, TypeExpr::String),
field("has_more", true, TypeExpr::Boolean),
]),
"create" => item_type(true), "create" => item_type(true),
"notification_diagnostics" => item_type(false), "notification_diagnostics" => item_type(false),
_ if item => item_type(true), _ if item => item_type(true),
@@ -148,6 +154,9 @@ impl References {
let mut refs = Self::default(); let mut refs = Self::default();
match operation { match operation {
"query" | "read" => { "query" | "read" => {
if let Some(parent) = data.get("parent_link").and_then(|link| link.get("parent")) {
add(&mut refs.tickets, parent, "resource_key");
}
// ModelTicketQueryItem/ModelTicketDetail contain redacted, bounded relationships. // ModelTicketQueryItem/ModelTicketDetail contain redacted, bounded relationships.
if let Some(relations) = data.get("relations") { if let Some(relations) = data.get("relations") {
for kind in ["outgoing", "incoming", "blockers"] { for kind in ["outgoing", "incoming", "blockers"] {
@@ -188,6 +197,11 @@ impl References {
} }
} }
} }
"parent_set" => {
if let Some(parent) = data.get("parent") {
add(&mut refs.tickets, parent, "resource_key");
}
}
"record_relation" | "remove_relation" => { "record_relation" | "remove_relation" => {
add(&mut refs.tickets, data, "ticket_id"); add(&mut refs.tickets, data, "ticket_id");
add(&mut refs.tickets, data, "target"); add(&mut refs.tickets, data, "target");
@@ -313,14 +327,15 @@ pub(super) fn project(
objective_root: &str, objective_root: &str,
) -> Result<WipValue, WipOperationError> { ) -> Result<WipValue, WipOperationError> {
match operation { match operation {
"query" => { "query" | "children_query" => {
let children = operation == "children_query";
let items = data let items = data
.get("tickets") .get(if children { "items" } else { "tickets" })
.and_then(Value::as_array) .and_then(Value::as_array)
.ok_or_else(|| failure("missing bounded items"))? .ok_or_else(|| failure("missing bounded items"))?
.iter() .iter()
.map(|data| { .map(|data| {
let id = leaf(data, "ticket") let id = leaf(data, if children { "resource_key" } else { "ticket" })
.filter(|id| is_canonical_ticket_resource_key(id)) .filter(|id| is_canonical_ticket_resource_key(id))
.ok_or_else(|| failure("missing canonical subject"))?; .ok_or_else(|| failure("missing canonical subject"))?;
envelope( envelope(
@@ -344,6 +359,15 @@ pub(super) fn project(
if let Some(cursor) = leaf(&data, "next_cursor") { if let Some(cursor) = leaf(&data, "next_cursor") {
fields.insert("next_cursor".into(), WipValue::String(cursor)); fields.insert("next_cursor".into(), WipValue::String(cursor));
} }
if children {
let parent = bound
.filter(|id| ticket_leaf(id))
.ok_or_else(|| failure("missing children parent subject"))?;
fields.insert(
"entry".into(),
WipValue::String(format!("{ticket_root}/{parent}")),
);
}
Ok(WipValue::Record(fields)) Ok(WipValue::Record(fields))
} }
"create" | "read" => { "create" | "read" => {
@@ -162,10 +162,18 @@ async fn classification_reads_forward_id_filters_and_preserve_tag_observations()
assert_eq!(body[field], input[field]); assert_eq!(body[field], input[field]);
} }
} }
let detail = json!({"resource_key":"T-740", "content_digest":"content-1", "title":"Classify", "body":"Body", "body_truncated":false, "targets":[], "initial_post":{"sequence":0,"event_ref":"initial","is_initial":true,"kind":"comment","body":"Body","body_truncated":false,"body_digest":"a".repeat(64),"editable":true,"author":null,"at":null,"edited_at":null,"edited_by":null}, "state":"open", "closure_operation_key":null, "current_closure":null, "legacy_handoff":null, "closure_history":[], "assignment_tracking":[], "tags":[tag()], "events":[], "linked_objectives":[], "assignments":[], "implementation_reports":[], "merge_requests":[]}); let mut detail = super::super::tests::target_detail_fixture(json!([]));
detail["resource_key"] = json!("T-740");
detail["content_digest"] = json!("content-1");
detail["tags"] = json!([tag()]);
detail["parent_link"]["ticket_id"] = json!("T-740");
let client = recording( let client = recording(
detail, detail,
[expected_authorization("ticket.read", Some("T-740"))], [
expected_authorization("ticket.read", Some("T-740")),
expected_authorization("ticket.relation.read", Some("T-740")),
expected_authorization("ticket.relation.read", Some("T-740")),
],
); );
let (_, tool) = let (_, tool) =
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Show)(); workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Show)();
+30 -6
View File
@@ -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()) {
+50 -1
View File
@@ -424,6 +424,28 @@ impl SqliteWorkspaceAuthority {
}) })
} }
fn normalize_parent_filter(
&self,
parent: &mut Option<ticket::TicketParentFilter>,
) -> Result<()> {
if let Some(ticket::TicketParentFilter::Parent { ticket }) = parent {
if ticket.trim().is_empty() {
return Err(Error::InvalidInput(
"parent Ticket must not be blank".into(),
));
}
*ticket = self
.store
.resolve_resource_reference(
&self.workspace_id,
WorkspaceResourceKind::Ticket,
ticket,
)?
.ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(ticket.clone())))?;
}
Ok(())
}
fn query_ticket_candidate_ids( fn query_ticket_candidate_ids(
&self, &self,
query: &TicketQueryRequest, query: &TicketQueryRequest,
@@ -437,6 +459,15 @@ impl SqliteWorkspaceAuthority {
values.push(value); values.push(value);
format!("?{}", values.len()) format!("?{}", values.len())
}; };
if let Some(parent) = &query.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::Parent { ticket } => {
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})"));
}
}
}
if !query.states.is_empty() { if !query.states.is_empty() {
let states = query let states = query
.states .states
@@ -933,6 +964,9 @@ impl SqliteWorkspaceAuthority {
} }
let action_blockers = Vec::new(); let action_blockers = Vec::new();
let action_eligibility = TicketActionEligibility { let action_eligibility = TicketActionEligibility {
// The HTTP request boundary projects owner eligibility; the authority
// cannot infer a caller or permission from Ticket contents.
can_set_parent: false,
// Case openness is not readiness. These are explicit operations; // Case openness is not readiness. These are explicit operations;
// occupancy remains an independent resource constraint. // occupancy remains an independent resource constraint.
// Delegation neither occupies nor transfers existing Worker work. // Delegation neither occupies nor transfers existing Worker work.
@@ -1036,7 +1070,11 @@ impl SqliteWorkspaceAuthority {
let assignment_tracking = self let assignment_tracking = self
.store .store
.list_current_ticket_assignment_tracking(&self.workspace_id, &ticket.meta.id)?; .list_current_ticket_assignment_tracking(&self.workspace_id, &ticket.meta.id)?;
let parent_link = self
.ticket_backend
.get_ticket_parent(ticket.meta.id.clone().into())?;
Ok(TicketDetail { Ok(TicketDetail {
parent_link,
initial_post, initial_post,
created_by: ticket.meta.created_by, created_by: ticket.meta.created_by,
assignment_tracking, assignment_tracking,
@@ -1209,7 +1247,8 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
}) })
} }
fn query_tickets(&self, query: TicketQueryRequest) -> Result<TicketQueryResponse> { fn query_tickets(&self, mut query: TicketQueryRequest) -> Result<TicketQueryResponse> {
self.normalize_parent_filter(&mut query.parent)?;
validate_ticket_query(&query)?; validate_ticket_query(&query)?;
let limit = query.limit.unwrap_or(50).clamp(1, 100); let limit = query.limit.unwrap_or(50).clamp(1, 100);
let sort = normalize_ticket_sort(query.sort.as_deref(), query.query.is_some())?; let sort = normalize_ticket_sort(query.sort.as_deref(), query.query.is_some())?;
@@ -2375,6 +2414,16 @@ fn ticket_matches_query(
authoritative_case_history: &[ticket::TicketCaseHistoryEntry], authoritative_case_history: &[ticket::TicketCaseHistoryEntry],
query: &TicketQueryRequest, query: &TicketQueryRequest,
) -> bool { ) -> bool {
if let Some(parent) = &query.parent {
let actual = detail.parent_link.observation.parent_ticket_id.as_deref();
match parent {
ticket::TicketParentFilter::None {} if actual.is_some() => return false,
ticket::TicketParentFilter::Parent { ticket } if actual != Some(ticket.as_str()) => {
return false;
}
_ => {}
}
}
let has_tag = |id: &String| detail.tags.iter().any(|tag| &tag.id == id); let has_tag = |id: &String| detail.tags.iter().any(|tag| &tag.id == id);
if !query.tag_ids.iter().all(has_tag) if !query.tag_ids.iter().all(has_tag)
|| (!query.tag_ids_any.is_empty() && !query.tag_ids_any.iter().any(has_tag)) || (!query.tag_ids_any.is_empty() && !query.tag_ids_any.iter().any(has_tag))
@@ -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}");
+367 -16
View File
@@ -8094,6 +8094,18 @@ 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(
service.clone(),
))
.merge(server_api::server_api_axum::ticket_parent_set(
service.clone(),
))
.merge(server_api::server_api_axum::ticket_children_query(
service.clone(),
))
.merge(server_api::server_api_axum::ticket_tags_set( .merge(server_api::server_api_axum::ticket_tags_set(
service.clone(), service.clone(),
)) ))
@@ -12290,11 +12302,20 @@ impl server_api::ServerApi for ServerApiContractService {
async fn ticket_get_alias( async fn ticket_get_alias(
&self, &self,
context: server_api::ServerRequestContext,
id: String, id: String,
) -> std::result::Result<server_api::TicketDetail, server_api::RepositoryApiError> { ) -> std::result::Result<server_api::TicketDetail, server_api::RepositoryApiError> {
let Json(response) = get_ticket(State(self.workspace_api()?.clone()), AxumPath(id)) let Json(mut response) = get_ticket(State(self.workspace_api()?.clone()), AxumPath(id))
.await .await
.map_err(ApiError::into_repository_api_error)?; .map_err(ApiError::into_repository_api_error)?;
project_ticket_parent_eligibility(
self.workspace_api()?,
&self.workspace_api()?.config.workspace_id,
&context,
&mut response,
)
.await
.map_err(ApiError::into_repository_api_error)?;
Ok(response) Ok(response)
} }
@@ -12858,6 +12879,114 @@ impl server_api::ServerApi for ServerApiContractService {
Ok(server_api::WorkspaceTicketTag(value)) Ok(server_api::WorkspaceTicketTag(value))
} }
async fn ticket_link_summary_get(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
id: String,
) -> std::result::Result<server_api::TicketLinkSummaryResponse, server_api::RepositoryApiError>
{
let api = self.workspace_api()?.clone();
let headers = contract_request_headers(&context)?;
api.host_executors
.host()
.map_err(ApiError::from)
.map_err(ApiError::into_repository_api_error)?
.run(move || {
validate_workspace_scope(&api, &workspace_id)?;
let id = resolve_workspace_ticket_reference(&api, &workspace_id, &id)?;
if let Some(source) = authenticated_ticket_worker_source(&headers, &context)? {
authorize_ticket_relation_read(&api, &workspace_id, &source, &id)?;
}
let summary = browser_ticket_backend(&api)?
.read_link_summary(TicketIdOrSlug::Id(id))
.map_err(Error::from)?;
Ok::<_, ApiError>(server_api::TicketLinkSummaryResponse(summary))
})
.await
.map_err(ApiError::into_repository_api_error)
}
async fn ticket_parent_get(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
id: String,
) -> std::result::Result<server_api::TicketParentResponse, server_api::RepositoryApiError> {
let result = execute_ticket_rest_operation_with_context(
self.workspace_api()?,
&workspace_id,
contract_request_headers(&context)?,
Some(&context),
TicketBackendOperation::ParentLink {
id: TicketIdOrSlug::Query(id),
},
)
.await
.map_err(ApiError::into_repository_api_error)?;
let Json(value) = ticket_rest_result(result, |result| match result {
TicketBackendOperationResult::ParentLink(value) => Some(value),
_ => None,
})
.map_err(ApiError::into_repository_api_error)?;
Ok(server_api::TicketParentResponse(value))
}
async fn ticket_parent_set(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
id: String,
request: server_api::TicketParentRequest,
) -> std::result::Result<server_api::TicketParentResponse, server_api::RepositoryApiError> {
let result = execute_ticket_rest_operation_with_context(
self.workspace_api()?,
&workspace_id,
contract_request_headers(&context)?,
Some(&context),
TicketBackendOperation::SetParentLink {
id: TicketIdOrSlug::Query(id),
request: request.0,
},
)
.await
.map_err(ApiError::into_repository_api_error)?;
let Json(value) = ticket_rest_result(result, |result| match result {
TicketBackendOperationResult::ParentLink(value) => Some(value),
_ => None,
})
.map_err(ApiError::into_repository_api_error)?;
Ok(server_api::TicketParentResponse(value))
}
async fn ticket_children_query(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
id: String,
request: server_api::TicketChildrenQuery,
) -> std::result::Result<server_api::TicketChildrenResponse, server_api::RepositoryApiError>
{
let result = execute_ticket_rest_operation_with_context(
self.workspace_api()?,
&workspace_id,
contract_request_headers(&context)?,
Some(&context),
TicketBackendOperation::ChildrenPage {
id: TicketIdOrSlug::Query(id),
request: request.0,
},
)
.await
.map_err(ApiError::into_repository_api_error)?;
let Json(value) = ticket_rest_result(result, |result| match result {
TicketBackendOperationResult::ChildrenPage(value) => Some(value),
_ => None,
})
.map_err(ApiError::into_repository_api_error)?;
Ok(server_api::TicketChildrenResponse(value))
}
async fn ticket_tags_set( async fn ticket_tags_set(
&self, &self,
context: server_api::ServerRequestContext, context: server_api::ServerRequestContext,
@@ -12962,12 +13091,24 @@ impl server_api::ServerApi for ServerApiContractService {
async fn ticket_get( async fn ticket_get(
&self, &self,
context: server_api::ServerRequestContext,
workspace_id: String, workspace_id: String,
id: String, id: String,
) -> std::result::Result<server_api::TicketDetail, server_api::RepositoryApiError> { ) -> std::result::Result<server_api::TicketDetail, server_api::RepositoryApiError> {
let Json(response) = scoped_get_ticket( let Json(mut response) = scoped_get_ticket(
State(self.workspace_api()?.clone()), State(self.workspace_api()?.clone()),
AxumPath(ScopedRecordPath { workspace_id, id }), AxumPath(ScopedRecordPath {
workspace_id: workspace_id.clone(),
id,
}),
)
.await
.map_err(ApiError::into_repository_api_error)?;
project_ticket_parent_eligibility(
self.workspace_api()?,
&workspace_id,
&context,
&mut response,
) )
.await .await
.map_err(ApiError::into_repository_api_error)?; .map_err(ApiError::into_repository_api_error)?;
@@ -12981,14 +13122,25 @@ impl server_api::ServerApi for ServerApiContractService {
id: String, id: String,
request: server_api::BrowserEditTicketRequest, request: server_api::BrowserEditTicketRequest,
) -> std::result::Result<server_api::TicketDetail, server_api::RepositoryApiError> { ) -> std::result::Result<server_api::TicketDetail, server_api::RepositoryApiError> {
let Json(response) = scoped_edit_ticket_item( let Json(mut response) = scoped_edit_ticket_item(
State(self.workspace_api()?.clone()), State(self.workspace_api()?.clone()),
&context, &context,
AxumPath(ScopedRecordPath { workspace_id, id }), AxumPath(ScopedRecordPath {
workspace_id: workspace_id.clone(),
id,
}),
Json(request), Json(request),
) )
.await .await
.map_err(ApiError::into_repository_api_error)?; .map_err(ApiError::into_repository_api_error)?;
project_ticket_parent_eligibility(
self.workspace_api()?,
&workspace_id,
&context,
&mut response,
)
.await
.map_err(ApiError::into_repository_api_error)?;
Ok(response) Ok(response)
} }
@@ -13011,32 +13163,52 @@ impl server_api::ServerApi for ServerApiContractService {
event_ref: String, event_ref: String,
request: server_api::TicketPostEditRequest, request: server_api::TicketPostEditRequest,
) -> std::result::Result<TicketDetail, server_api::RepositoryApiError> { ) -> std::result::Result<TicketDetail, server_api::RepositoryApiError> {
let Json(detail) = scoped_edit_ticket_post( let Json(mut detail) = scoped_edit_ticket_post(
self.workspace_api()?.clone(), self.workspace_api()?.clone(),
context, context.clone(),
workspace_id, workspace_id.clone(),
id, id,
event_ref, event_ref,
request, request,
) )
.await .await
.map_err(ApiError::into_repository_api_error)?; .map_err(ApiError::into_repository_api_error)?;
project_ticket_parent_eligibility(
self.workspace_api()?,
&workspace_id,
&context,
&mut detail,
)
.await
.map_err(ApiError::into_repository_api_error)?;
Ok(detail) Ok(detail)
} }
async fn ticket_show( async fn ticket_show(
&self, &self,
context: server_api::ServerRequestContext,
workspace_id: String, workspace_id: String,
id: String, id: String,
request: server_api::TicketShowRequest, request: server_api::TicketShowRequest,
) -> std::result::Result<server_api::TicketDetail, server_api::RepositoryApiError> { ) -> std::result::Result<server_api::TicketDetail, server_api::RepositoryApiError> {
let Json(response) = scoped_show_ticket( let Json(mut response) = scoped_show_ticket(
State(self.workspace_api()?.clone()), State(self.workspace_api()?.clone()),
AxumPath(ScopedRecordPath { workspace_id, id }), AxumPath(ScopedRecordPath {
workspace_id: workspace_id.clone(),
id,
}),
Json(request), Json(request),
) )
.await .await
.map_err(ApiError::into_repository_api_error)?; .map_err(ApiError::into_repository_api_error)?;
project_ticket_parent_eligibility(
self.workspace_api()?,
&workspace_id,
&context,
&mut response,
)
.await
.map_err(ApiError::into_repository_api_error)?;
Ok(response) Ok(response)
} }
@@ -13479,17 +13651,29 @@ impl server_api::ServerApi for ServerApiContractService {
async fn ticket_implementation_cancel( async fn ticket_implementation_cancel(
&self, &self,
context: server_api::ServerRequestContext,
workspace_id: String, workspace_id: String,
id: String, id: String,
request: server_api::CancelTicketImplementationRequest, request: server_api::CancelTicketImplementationRequest,
) -> std::result::Result<server_api::TicketDetail, server_api::RepositoryApiError> { ) -> std::result::Result<server_api::TicketDetail, server_api::RepositoryApiError> {
let Json(response) = scoped_cancel_ticket_implementation( let Json(mut response) = scoped_cancel_ticket_implementation(
State(self.workspace_api()?.clone()), State(self.workspace_api()?.clone()),
AxumPath(ScopedRecordPath { workspace_id, id }), AxumPath(ScopedRecordPath {
workspace_id: workspace_id.clone(),
id,
}),
Json(request), Json(request),
) )
.await .await
.map_err(ApiError::into_repository_api_error)?; .map_err(ApiError::into_repository_api_error)?;
project_ticket_parent_eligibility(
self.workspace_api()?,
&workspace_id,
&context,
&mut response,
)
.await
.map_err(ApiError::into_repository_api_error)?;
Ok(response) Ok(response)
} }
@@ -13505,14 +13689,25 @@ impl server_api::ServerApi for ServerApiContractService {
reason: request.reason, reason: request.reason,
intake_summary: request.intake_summary, intake_summary: request.intake_summary,
}; };
let Json(response) = scoped_mark_ticket_ready_from_browser( let Json(mut response) = scoped_mark_ticket_ready_from_browser(
State(self.workspace_api()?.clone()), State(self.workspace_api()?.clone()),
contract_request_headers(&context)?, contract_request_headers(&context)?,
AxumPath(ScopedRecordPath { workspace_id, id }), AxumPath(ScopedRecordPath {
workspace_id: workspace_id.clone(),
id,
}),
Json(request), Json(request),
) )
.await .await
.map_err(ApiError::into_repository_api_error)?; .map_err(ApiError::into_repository_api_error)?;
project_ticket_parent_eligibility(
self.workspace_api()?,
&workspace_id,
&context,
&mut response,
)
.await
.map_err(ApiError::into_repository_api_error)?;
Ok(response) Ok(response)
} }
@@ -13523,14 +13718,25 @@ impl server_api::ServerApi for ServerApiContractService {
id: String, id: String,
request: server_api::BrowserAppendTicketEventRequest, request: server_api::BrowserAppendTicketEventRequest,
) -> std::result::Result<server_api::TicketDetail, server_api::RepositoryApiError> { ) -> std::result::Result<server_api::TicketDetail, server_api::RepositoryApiError> {
let Json(response) = scoped_append_ticket_event( let Json(mut response) = scoped_append_ticket_event(
State(self.workspace_api()?.clone()), State(self.workspace_api()?.clone()),
&context, &context,
AxumPath(ScopedRecordPath { workspace_id, id }), AxumPath(ScopedRecordPath {
workspace_id: workspace_id.clone(),
id,
}),
Json(request), Json(request),
) )
.await .await
.map_err(ApiError::into_repository_api_error)?; .map_err(ApiError::into_repository_api_error)?;
project_ticket_parent_eligibility(
self.workspace_api()?,
&workspace_id,
&context,
&mut response,
)
.await
.map_err(ApiError::into_repository_api_error)?;
Ok(response) Ok(response)
} }
@@ -18603,6 +18809,9 @@ fn execute_ticket_rest_operation_on_host(
_ => unreachable!(), _ => unreachable!(),
} }
} }
if let Some(source) = source.as_ref() {
authorize_ticket_parent_operation(api, workspace_id, source, &operation)?;
}
validate_ticket_repository_operation(api, &operation)?; validate_ticket_repository_operation(api, &operation)?;
let before = target.as_ref().and_then(|id| backend.show(id.clone()).ok()); let before = target.as_ref().and_then(|id| backend.show(id.clone()).ok());
let mut ticket_item_check = source let mut ticket_item_check = source
@@ -18748,6 +18957,19 @@ fn execute_ticket_rest_operation_on_host(
None, None,
), ),
}; };
if !is_mutation && let Some(source) = source.as_ref() {
match &result {
TicketBackendOperationResult::ParentLink(link) => {
authorize_ticket_parent_metadata(api, workspace_id, source, link)?;
}
TicketBackendOperationResult::ChildrenPage(page) => {
for child in &page.items {
authorize_ticket_relation_read(api, workspace_id, source, &child.id)?;
}
}
_ => {}
}
}
// All committed facts enter the same routing policy, including reasons for // All committed facts enter the same routing policy, including reasons for
// non-notification. Waking the dispatcher is not itself a request to a Worker. // non-notification. Waking the dispatcher is not itself a request to a Worker.
if is_mutation { if is_mutation {
@@ -21078,6 +21300,130 @@ async fn scoped_ticket_doctor(
type WorkerMutationSource = RuntimeWorkerRef; type WorkerMutationSource = RuntimeWorkerRef;
async fn project_ticket_parent_eligibility(
api: &WorkspaceApi,
workspace_id: &str,
context: &server_api::ServerRequestContext,
detail: &mut server_api::TicketDetail,
) -> ApiResult<()> {
let headers =
contract_request_headers(context).map_err(|error| Error::Config(error.message))?;
if let Some(source) = authenticated_ticket_worker_source(&headers, context)? {
let executor = api.host_executors.host()?;
let api = api.clone();
let workspace_id = workspace_id.to_owned();
let link = detail.parent_link.clone();
executor
.run(move || authorize_ticket_parent_metadata(&api, &workspace_id, &source, &link))
.await?;
}
detail.action_eligibility.can_set_parent = match context.actor.as_ref() {
Some(actor) if context.runtime_source.is_none() && context.worker_source.is_none() => api
.store
.get_workspace(workspace_id)
.await?
.is_some_and(|workspace| {
workspace.owner_account_id == actor.account_id && workspace.state == "active"
}),
_ => false,
};
Ok(())
}
fn authorize_ticket_relation_read(
api: &WorkspaceApi,
workspace_id: &str,
source: &RuntimeWorkerRef,
id: &str,
) -> ApiResult<()> {
subject_authorization::authorize(
api,
source,
&server_api::authorization::OperationAuthorizationRequest {
operation: "ticket.relation.read".into(),
resource: server_api::authorization::PermissionResource::Ticket {
workspace_id: workspace_id.into(),
ticket_id: id.into(),
},
},
)?;
Ok(())
}
fn authorize_ticket_parent_metadata(
api: &WorkspaceApi,
workspace_id: &str,
source: &RuntimeWorkerRef,
link: &ticket::TicketParentLink,
) -> ApiResult<()> {
authorize_ticket_relation_read(api, workspace_id, source, &link.ticket_id)?;
if let Some(parent) = &link.parent {
authorize_ticket_relation_read(api, workspace_id, source, &parent.id)?;
}
Ok(())
}
// Parent links reuse relation permissions on the actual edge endpoints. A link
// never grants body-read, mutation, assignment or execution rights to relatives.
fn authorize_ticket_parent_operation(
api: &WorkspaceApi,
workspace_id: &str,
source: &RuntimeWorkerRef,
operation: &TicketBackendOperation,
) -> ApiResult<()> {
fn reference(id: &TicketIdOrSlug) -> &str {
match id {
TicketIdOrSlug::Id(id) | TicketIdOrSlug::Slug(id) | TicketIdOrSlug::Query(id) => id,
}
}
let authorize = |name: &str, reference: &str| -> ApiResult<String> {
let ticket_id = resolve_workspace_ticket_reference(api, workspace_id, reference)?;
subject_authorization::authorize(
api,
source,
&server_api::authorization::OperationAuthorizationRequest {
operation: name.into(),
resource: server_api::authorization::PermissionResource::Ticket {
workspace_id: workspace_id.into(),
ticket_id: ticket_id.clone(),
},
},
)?;
Ok(ticket_id)
};
match operation {
TicketBackendOperation::ParentLink { id }
| TicketBackendOperation::ChildrenPage { id, .. } => {
authorize("ticket.relation.read", reference(id))?;
}
TicketBackendOperation::SetParentLink { id, request } => {
let desired = request
.parent
.as_deref()
.map(|parent| authorize("ticket.relation.record", parent))
.transpose()?;
// The receipt projects the desired parent's metadata. Check read access
// before committing, never return a new denial after a successful write.
authorize("ticket.relation.read", reference(id))?;
if let Some(parent) = desired.as_deref() {
authorize("ticket.relation.read", parent)?;
authorize("ticket.relation.record", reference(id))?;
}
if desired.is_none()
|| (request.expected.parent_ticket_id.is_some()
&& request.expected.parent_ticket_id != desired)
{
authorize("ticket.relation.remove", reference(id))?;
if let Some(old) = request.expected.parent_ticket_id.as_deref() {
authorize("ticket.relation.remove", old)?;
}
}
}
_ => {}
}
Ok(())
}
fn ticket_mutation_target(operation: &TicketBackendOperation) -> Option<&TicketIdOrSlug> { fn ticket_mutation_target(operation: &TicketBackendOperation) -> Option<&TicketIdOrSlug> {
match operation { match operation {
TicketBackendOperation::EditItem { id, .. } TicketBackendOperation::EditItem { id, .. }
@@ -21088,6 +21434,7 @@ fn ticket_mutation_target(operation: &TicketBackendOperation) -> Option<&TicketI
| TicketBackendOperation::MarkReady { id, .. } | TicketBackendOperation::MarkReady { id, .. }
| TicketBackendOperation::QueueReady { id, .. } | TicketBackendOperation::QueueReady { id, .. }
| TicketBackendOperation::SetTicketTags { id, .. } | TicketBackendOperation::SetTicketTags { id, .. }
| TicketBackendOperation::SetParentLink { id, .. }
| TicketBackendOperation::AddTicketRelation { id, .. } | TicketBackendOperation::AddTicketRelation { id, .. }
| TicketBackendOperation::RemoveTicketRelation { id, .. } | TicketBackendOperation::RemoveTicketRelation { id, .. }
| TicketBackendOperation::AddOrchestrationPlanRecord { id, .. } => Some(id), | TicketBackendOperation::AddOrchestrationPlanRecord { id, .. } => Some(id),
@@ -21155,6 +21502,7 @@ fn ticket_mutation_operation_kind(operation: &TicketBackendOperation) -> &'stati
TicketBackendOperation::QueueReady { .. } => "queue_ready", TicketBackendOperation::QueueReady { .. } => "queue_ready",
TicketBackendOperation::MutateWorkspaceTag { .. } => "mutate_workspace_tag", TicketBackendOperation::MutateWorkspaceTag { .. } => "mutate_workspace_tag",
TicketBackendOperation::SetTicketTags { .. } => "set_ticket_tags", TicketBackendOperation::SetTicketTags { .. } => "set_ticket_tags",
TicketBackendOperation::SetParentLink { .. } => "set_parent",
TicketBackendOperation::AddTicketRelation { .. } => "add_relation", TicketBackendOperation::AddTicketRelation { .. } => "add_relation",
TicketBackendOperation::RemoveTicketRelation { .. } => "remove_relation", TicketBackendOperation::RemoveTicketRelation { .. } => "remove_relation",
TicketBackendOperation::AddOrchestrationPlanRecord { .. } => "add_plan_record", TicketBackendOperation::AddOrchestrationPlanRecord { .. } => "add_plan_record",
@@ -39395,6 +39743,9 @@ mod tests {
mod ticket_tags_tests { mod ticket_tags_tests {
include!("server_ticket_tags_tests.rs"); include!("server_ticket_tags_tests.rs");
mod parent_link_tests {
include!("server_ticket_parent_tests.rs");
}
} }
mod ticket_browse_tests { mod ticket_browse_tests {
@@ -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 {
@@ -19,6 +19,8 @@ mod subject_body_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",
@@ -0,0 +1,595 @@
// Signed router + real Ticket DB + Host permissions. Runtime execution alone is
// substituted by the existing shared fixture; no live Workspace data is used.
use super::*;
fn parent_request(
expected: serde_json::Value,
parent: Option<&str>,
key: &str,
) -> serde_json::Value {
json!({"expected":expected,"parent":parent,"operation_key":key})
}
#[tokio::test]
async fn signed_parent_routes_replay_reparent_query_and_preserve_independent_authorities() {
let dir = tempfile::tempdir().unwrap();
let (mut api, _execution) = test_api_with_recording_backend(dir.path()).await;
let identity = RuntimeIdentityMaterial::generate("runtime-parents").unwrap();
configure_runtime_request_auth(&mut api, &identity, "runtime-parents");
seed_worker_source_member(&api, "runtime-parents", "linker");
let backend = browser_ticket_backend(&api).unwrap();
let a = backend.create(ticket::NewTicket::new("A")).unwrap().id;
let b = backend.create(ticket::NewTicket::new("B")).unwrap().id;
let c = backend.create(ticket::NewTicket::new("C")).unwrap().id;
let d = backend.create(ticket::NewTicket::new("D")).unwrap().id;
backend
.add_ticket_relation(
b.clone().into(),
ticket::NewTicketRelation {
kind: ticket::TicketRelationKind::Related,
target: d.clone(),
note: None,
author: None,
},
)
.unwrap();
let original = backend.show(b.clone().into()).unwrap();
let authority_before = tag_execution_authority_snapshot(&api);
let app = build_router(api.clone());
let parent_path = |id: &str| format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{id}/parent");
let absent = json!({"parent_ticket_id":null,"last_operation_key":null});
let first_request = parent_request(absent.clone(), Some(&a), "b-a");
let first = runtime_tag_json(
app.clone(),
&identity,
"linker",
"PUT",
&parent_path(&b),
first_request.clone(),
StatusCode::OK,
)
.await;
assert_eq!(first["observation"]["parent_ticket_id"], a);
assert!(
first["parent"]["resource_key"]
.as_str()
.unwrap()
.starts_with("T-")
);
runtime_tag_json(
app.clone(),
&identity,
"linker",
"PUT",
&parent_path(&c),
parent_request(absent.clone(), Some(&b), "c-b"),
StatusCode::OK,
)
.await;
runtime_tag_json(
app.clone(),
&identity,
"linker",
"PUT",
&parent_path(&a),
parent_request(absent.clone(), Some(&c), "cycle"),
StatusCode::CONFLICT,
)
.await;
assert_eq!(
backend
.get_ticket_parent(a.clone().into())
.unwrap()
.observation
.parent_ticket_id,
None
);
let moved = runtime_tag_json(
app.clone(),
&identity,
"linker",
"PUT",
&parent_path(&b),
parent_request(first["observation"].clone(), Some(&d), "b-d"),
StatusCode::OK,
)
.await;
runtime_tag_json(
app.clone(),
&identity,
"linker",
"PUT",
&parent_path(&b),
parent_request(first["observation"].clone(), None, "stale"),
StatusCode::CONFLICT,
)
.await;
assert_eq!(
runtime_tag_json(
app.clone(),
&identity,
"linker",
"PUT",
&parent_path(&b),
first_request,
StatusCode::OK
)
.await,
first
);
let reread = runtime_tag_json(
app.clone(),
&identity,
"linker",
"GET",
&parent_path(&b),
json!(null),
StatusCode::OK,
)
.await;
assert_eq!(reread, moved);
let query_path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/query");
let query = runtime_tag_json(
app.clone(),
&identity,
"linker",
"POST",
&query_path,
json!({"parent":{"kind":"parent","ticket":d}}),
StatusCode::OK,
)
.await;
assert_eq!(query["items"].as_array().unwrap().len(), 1);
assert_eq!(query["items"][0]["id"], b);
let roots = runtime_tag_json(
app.clone(),
&identity,
"linker",
"POST",
&query_path,
json!({"parent":{"kind":"none"}}),
StatusCode::OK,
)
.await;
assert!(
roots["items"]
.as_array()
.unwrap()
.iter()
.all(|item| item["id"] != b && item["id"] != c)
);
runtime_tag_json(
app.clone(),
&identity,
"linker",
"PUT",
&parent_path(&b),
parent_request(moved["observation"].clone(), None, "unlink"),
StatusCode::OK,
)
.await;
let after = backend.show(b.clone().into()).unwrap();
assert_eq!(after.relations, original.relations);
assert_eq!(after.meta.workflow_state, original.meta.workflow_state);
assert_eq!(after.meta.targets, original.meta.targets);
assert_eq!(after.current_closure, original.current_closure);
assert_eq!(tag_execution_authority_snapshot(&api), authority_before);
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
let (events,outbox):(i64,i64) = conn.query_row("SELECT (SELECT COUNT(*) FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2 AND kind='parent_changed'), (SELECT COUNT(*) FROM typed_ticket_service_outbox o JOIN typed_ticket_events e ON e.workspace_id=o.workspace_id AND e.ticket_id=o.ticket_id AND e.event_ref=o.domain_event_ref WHERE e.workspace_id=?1 AND e.ticket_id=?2 AND e.kind='parent_changed')",rusqlite::params![TEST_WORKSPACE_ID,b],|row| Ok((row.get(0)?,row.get(1)?))).unwrap();
assert_eq!(events, 3);
assert_eq!(outbox, events);
}
#[tokio::test]
async fn signed_parent_routes_reject_missing_wire_and_scoped_cursor_without_partial_writes() {
let dir = tempfile::tempdir().unwrap();
let (mut api, _execution) = test_api_with_recording_backend(dir.path()).await;
let identity = RuntimeIdentityMaterial::generate("runtime-parents").unwrap();
configure_runtime_request_auth(&mut api, &identity, "runtime-parents");
seed_worker_source_member(&api, "runtime-parents", "linker");
let backend = browser_ticket_backend(&api).unwrap();
let parent = backend.create(ticket::NewTicket::new("Parent")).unwrap().id;
let other = backend
.create(ticket::NewTicket::new("Other parent"))
.unwrap()
.id;
for i in 0..2 {
let child = backend
.create(ticket::NewTicket::new(format!("Child {i}")))
.unwrap()
.id;
backend
.set_ticket_parent(
child.into(),
ticket::TicketParentSetRequest {
expected: Default::default(),
parent: Some(parent.clone()),
operation_key: format!("link-{i}"),
},
)
.unwrap();
}
let before = backend.show(parent.clone().into()).unwrap();
let app = build_router(api.clone());
let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{parent}/parent");
for body in [
json!({"expected":{},"parent":null,"operation_key":"bad"}),
json!({"expected":{"parent_ticket_id":null,"last_operation_key":null},"operation_key":"bad"}),
] {
let response = app
.clone()
.oneshot(runtime_source_request(
&identity,
Some("linker"),
"PUT",
&path,
serde_json::to_vec(&body).unwrap(),
))
.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 = runtime_tag_json(
app.clone(),
&identity,
"linker",
"GET",
&page_path,
json!(null),
StatusCode::OK,
)
.await;
assert_eq!(page["items"].as_array().unwrap().len(), 1);
assert_eq!(page["has_more"], true);
let encoded: String =
url::form_urlencoded::byte_serialize(page["next_cursor"].as_str().unwrap().as_bytes())
.collect();
let wrong_scope =
format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{other}/children?limit=1&cursor={encoded}");
runtime_tag_json(
app.clone(),
&identity,
"linker",
"GET",
&wrong_scope,
json!(null),
StatusCode::CONFLICT,
)
.await;
assert_eq!(backend.show(parent.clone().into()).unwrap(), before);
let unsigned = Request::builder()
.method("PUT")
.uri(&path)
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
parent_request(
json!({"parent_ticket_id":null,"last_operation_key":null}),
Some(&other),
"unsigned",
)
.to_string(),
))
.unwrap();
runtime_tag_response(app, unsigned, StatusCode::UNAUTHORIZED).await;
assert_eq!(backend.show(parent.into()).unwrap(), before);
}
#[tokio::test]
async fn parent_reparent_requires_existing_record_and_remove_grants_on_actual_endpoints() {
use server_api::authorization::{
PermissionResource, ScopedPermission, SubjectPermissionsUpdate,
};
let workspace = tempfile::tempdir().unwrap();
let mut api = test_api(workspace.path()).await;
register_test_runtime(&api, WorkdirlessFixtureRuntime::RUNTIME_ID).await;
api.runtime
.register_or_replace(WorkdirlessFixtureRuntime::default());
let store = open_subjektiv_store(&api).unwrap();
let subject = store
.create_subject_with_behavior(
crate::subjektiv::SubjectRole::new("parent-linker").unwrap(),
"Link work decomposition".into(),
)
.unwrap();
let Json(created) = create_workspace_worker(
State(api.clone()),
HeaderMap::new(),
Json(CreateWorkspaceWorkerRequest {
runtime_id: WorkdirlessFixtureRuntime::RUNTIME_ID.into(),
display_name: "Parent linker".into(),
singleton_key: None,
profile: Some("builtin:companion".into()),
ticket_assignment: None,
initial_submit: vec![],
workdir_attachments: vec![],
feature_connections: server_api::WorkspaceWorkerFeatureConnectionsRequest {
subjektiv: Some(server_api::SubjektivWorkerConnectionRequest {
subject_id: subject.id.clone(),
}),
},
control_operation_id: None,
execution_grant: None,
}),
)
.await
.unwrap();
let identity = RuntimeIdentityMaterial::generate(&created.runtime_id).unwrap();
configure_runtime_request_auth(&mut api, &identity, &created.runtime_id);
let backend = browser_ticket_backend(&api).unwrap();
let a = backend.create(ticket::NewTicket::new("A")).unwrap().id;
let b = backend.create(ticket::NewTicket::new("B")).unwrap().id;
let child = backend.create(ticket::NewTicket::new("Child")).unwrap().id;
let grant = |operation: &str, id: &str| ScopedPermission {
operation: operation.into(),
resource: PermissionResource::Ticket {
workspace_id: TEST_WORKSPACE_ID.into(),
ticket_id: id.into(),
},
};
let set_raw = |permissions: Vec<ScopedPermission>| {
let previous = store.subject_permissions(&subject.id).unwrap();
store
.set_subject_permissions(
&subject.id,
&SubjectPermissionsUpdate {
operation_id: Uuid::now_v7().to_string(),
expected_change_id: previous.map(|policy| policy.change_id),
execution: permissions,
delegation: vec![],
},
)
.unwrap();
};
let set = |mut permissions: Vec<ScopedPermission>| {
permissions.extend([
grant("ticket.relation.read", &child),
grant("ticket.relation.read", &a),
grant("ticket.relation.read", &b),
]);
set_raw(permissions);
};
let app = build_router(api.clone());
let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{child}/parent");
let absent = json!({"parent_ticket_id":null,"last_operation_key":null});
set(vec![grant("ticket.relation.record", &child)]);
let first_request = parent_request(absent, Some(&a), "first");
// A write grant cannot be used to disclose metadata of the new parent.
set_raw(vec![
grant("ticket.relation.record", &child),
grant("ticket.relation.record", &a),
]);
runtime_tag_json(
app.clone(),
&identity,
&created.worker_id,
"PUT",
&path,
first_request.clone(),
StatusCode::FORBIDDEN,
)
.await;
assert_eq!(
backend
.get_ticket_parent(child.clone().into())
.unwrap()
.observation,
Default::default()
);
set(vec![grant("ticket.relation.record", &child)]);
runtime_tag_json(
app.clone(),
&identity,
&created.worker_id,
"PUT",
&path,
first_request.clone(),
StatusCode::FORBIDDEN,
)
.await;
assert_eq!(
backend
.get_ticket_parent(child.clone().into())
.unwrap()
.observation
.parent_ticket_id,
None
);
set(vec![
grant("ticket.relation.record", &child),
grant("ticket.relation.record", &a),
grant("ticket.relation.record", &b),
]);
let first = runtime_tag_json(
app.clone(),
&identity,
&created.worker_id,
"PUT",
&path,
first_request,
StatusCode::OK,
)
.await;
// Direct signed reads must enforce both ends, independently of Tool-side checks.
set_raw(vec![grant("ticket.relation.read", &child)]);
let summary_path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{child}/link-summary");
let metadata = runtime_tag_json(
app.clone(),
&identity,
&created.worker_id,
"GET",
&summary_path,
json!(null),
StatusCode::OK,
)
.await;
assert_eq!(metadata["id"], child);
assert_eq!(metadata.as_object().unwrap().len(), 5);
for protected in [
"body",
"parent",
"parent_link",
"targets",
"assignment_tracking",
] {
assert!(
metadata.get(protected).is_none(),
"metadata included {protected}"
);
}
let unauthorized_summary = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{a}/link-summary");
runtime_tag_json(
app.clone(),
&identity,
&created.worker_id,
"GET",
&unauthorized_summary,
json!(null),
StatusCode::FORBIDDEN,
)
.await;
runtime_tag_json(
app.clone(),
&identity,
&created.worker_id,
"GET",
&path,
json!(null),
StatusCode::FORBIDDEN,
)
.await;
let detail_path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{child}");
runtime_tag_json(
app.clone(),
&identity,
&created.worker_id,
"GET",
&detail_path,
json!(null),
StatusCode::FORBIDDEN,
)
.await;
set_raw(vec![grant("ticket.relation.read", &a)]);
let children_path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{a}/children?limit=1");
runtime_tag_json(
app.clone(),
&identity,
&created.worker_id,
"GET",
&children_path,
json!(null),
StatusCode::FORBIDDEN,
)
.await;
assert_eq!(
backend
.get_ticket_parent(child.clone().into())
.unwrap()
.observation,
serde_json::from_value(first["observation"].clone()).unwrap()
);
set(vec![
grant("ticket.relation.record", &child),
grant("ticket.relation.record", &a),
grant("ticket.relation.record", &b),
]);
let reparent = parent_request(first["observation"].clone(), Some(&b), "reparent");
runtime_tag_json(
app.clone(),
&identity,
&created.worker_id,
"PUT",
&path,
reparent.clone(),
StatusCode::FORBIDDEN,
)
.await;
set(vec![
grant("ticket.relation.record", &child),
grant("ticket.relation.record", &b),
grant("ticket.relation.remove", &child),
grant("ticket.relation.remove", &a),
]);
let moved = runtime_tag_json(
app.clone(),
&identity,
&created.worker_id,
"PUT",
&path,
reparent,
StatusCode::OK,
)
.await;
let unlink = parent_request(moved["observation"].clone(), None, "unlink");
runtime_tag_json(
app.clone(),
&identity,
&created.worker_id,
"PUT",
&path,
unlink.clone(),
StatusCode::FORBIDDEN,
)
.await;
set(vec![
grant("ticket.relation.remove", &child),
grant("ticket.relation.remove", &b),
]);
runtime_tag_json(
app,
&identity,
&created.worker_id,
"PUT",
&path,
unlink,
StatusCode::OK,
)
.await;
}
// Service-bound projection only. Router tests above cover signed mutation authority;
// this eligibility is presentation metadata, not a retained permission or write grant.
#[tokio::test]
async fn parent_editor_eligibility_requires_current_request_owner_not_ticket_contents() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let backend = browser_ticket_backend(&api).unwrap();
let child = backend.create(ticket::NewTicket::new("Child")).unwrap().id;
assert!(
!browser_ticket_detail(&api, &child)
.unwrap()
.0
.action_eligibility
.can_set_parent
);
let service = ServerApiContractService::Workspace(api);
let context = |actor, runtime_source| server_api::ServerRequestContext {
actor,
runtime_source,
worker_source: None,
origin: None,
transport_headers: vec![],
};
let mut other = test_owner_actor();
other.account_id = "other-account".into();
for (request, expected) in [
(context(None, None), false),
(context(Some(other), None), false),
(context(Some(test_owner_actor()), None), true),
] {
let detail = server_api::ServerApi::ticket_get(
&service,
request,
TEST_WORKSPACE_ID.into(),
child.clone(),
)
.await
.unwrap();
assert_eq!(detail.action_eligibility.can_set_parent, expected);
assert_eq!(detail.parent_link.observation, Default::default());
}
}
@@ -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 [
( (
+33 -4
View File
@@ -129,7 +129,7 @@ pub use ticket::{
}; };
const OLDEST_SCHEMA_VERSION: i64 = 50; const OLDEST_SCHEMA_VERSION: i64 = 50;
const LATEST_SCHEMA_VERSION: i64 = 112; const LATEST_SCHEMA_VERSION: i64 = 113;
const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants"; const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants";
const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs"; const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline"; const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
@@ -497,6 +497,11 @@ const MIGRATIONS: &[Migration] = &[
name: "Ticket Service outbox and fenced consumers", name: "Ticket Service outbox and fenced consumers",
apply: migrate_plugin_services_v111_to_v112, apply: migrate_plugin_services_v111_to_v112,
}, },
Migration {
version: 113,
name: "Ticket work-decomposition parent links",
apply: migrate_ticket_parents_v112_to_v113,
},
]; ];
// Both schema-103 identities shipped. Keep the alternate implementation and accept // Both schema-103 identities shipped. Keep the alternate implementation and accept
@@ -15945,14 +15950,35 @@ fn migrate_plugin_services_v111_to_v112(conn: &Connection) -> Result<()> {
} }
verify_schema_history(conn, 111)?; verify_schema_history(conn, 111)?;
let tx = conn.unchecked_transaction()?; let tx = conn.unchecked_transaction()?;
ticket::migrate_sqlite_ticket_schema_in_transaction(&tx) ticket::migrate_historical_sqlite_ticket_schema_in_transaction(
.map_err(|e| Error::Store(format!("Ticket outbox migration failed: {e}")))?; &tx,
ticket::HistoricalTicketSchemaTarget::Outbox15,
)
.map_err(|e| Error::Store(format!("Ticket outbox migration failed: {e}")))?;
tx.execute_batch(crate::plugin_service::SCHEMA_SQL)?; tx.execute_batch(crate::plugin_service::SCHEMA_SQL)?;
tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES(112,'Ticket Service outbox and fenced consumers')",[])?; tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES(112,'Ticket Service outbox and fenced consumers')",[])?;
tx.commit()?; tx.commit()?;
Ok(()) Ok(())
} }
fn migrate_ticket_parents_v112_to_v113(conn: &Connection) -> Result<()> {
if current_schema_version(conn)? != 112 {
return Err(Error::Store(
"Ticket parent migration requires schema 112".into(),
));
}
verify_schema_history(conn, 112)?;
let tx = conn.unchecked_transaction()?;
ticket::migrate_sqlite_ticket_schema_in_transaction(&tx)
.map_err(|e| Error::Store(format!("Ticket parent migration failed: {e}")))?;
tx.execute(
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(113,?1)",
["Ticket work-decomposition parent links"],
)?;
tx.commit()?;
Ok(())
}
fn verify_plugin_service_schema(conn: &Connection) -> Result<()> { fn verify_plugin_service_schema(conn: &Connection) -> Result<()> {
let expected = Connection::open_in_memory()?; let expected = Connection::open_in_memory()?;
expected.execute_batch(crate::plugin_service::SCHEMA_SQL)?; expected.execute_batch(crate::plugin_service::SCHEMA_SQL)?;
@@ -19825,6 +19851,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()},
] ]
); );
@@ -19947,6 +19974,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")?);
@@ -23240,7 +23268,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
} }
@@ -37,6 +37,7 @@ impl SqliteWorkspaceAuthority {
"Ticket browse supports only updated_desc".into(), "Ticket browse supports only updated_desc".into(),
)); ));
} }
self.normalize_parent_filter(&mut query.parent)?;
query.sort = Some("updated_desc".into()); query.sort = Some("updated_desc".into());
query.states.sort_by_key(|state| state.as_str()); query.states.sort_by_key(|state| state.as_str());
query.states.dedup(); query.states.dedup();
@@ -187,6 +188,15 @@ fn page_sql(
values.push(Value::Text(value)); values.push(Value::Text(value));
format!("?{}", values.len()) format!("?{}", values.len())
}; };
if let Some(parent) = &query.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::Parent { ticket } => {
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})"));
}
}
}
if !query.states.is_empty() { if !query.states.is_empty() {
let states = query let states = query
.states .states
@@ -618,3 +618,93 @@ async fn ticket_browse_plain_page_sql_uses_recent_index_without_history_or_graph
}) })
.unwrap(); .unwrap();
} }
#[tokio::test]
async fn parent_filters_match_browse_and_authoritative_queries_and_bind_cursor_scope() {
let f = Fixture::new().await;
let parent = f.ticket("Parent", Some("1"));
let other = f.ticket("Other", Some("1"));
let a = f.ticket("Child A", Some("1"));
let b = f.ticket("Child B", Some("1"));
for (child, key) in [(&a, "a"), (&b, "b")] {
f.backend
.set_ticket_parent(
child.clone().into(),
ticket::TicketParentSetRequest {
expected: Default::default(),
parent: Some(parent.clone()),
operation_key: key.into(),
},
)
.unwrap();
}
let key = f
.backend
.show(parent.clone().into())
.unwrap()
.meta
.resource_key
.unwrap();
let parent_filter = Some(ticket::TicketParentFilter::Parent { ticket: key });
let page = f.run(TicketBrowseQueryRequest {
parent: parent_filter.clone(),
limit: Some(1),
..Default::default()
});
assert_eq!(page.items.len(), 1);
assert!(page.page.has_more);
let mismatch = TicketBrowseQueryRequest {
parent: Some(ticket::TicketParentFilter::Parent {
ticket: other.clone(),
}),
cursor: page.page.next_cursor.clone(),
limit: Some(1),
..Default::default()
};
assert!(f.authority.query_ticket_browse(mismatch).is_err());
let mut expected = vec![a.clone(), b.clone()];
expected.sort();
let mut browse = ids(&f.run(TicketBrowseQueryRequest {
parent: parent_filter.clone(),
..Default::default()
}));
browse.sort();
assert_eq!(browse, expected);
let authoritative = f
.authority
.query_tickets(server_api::TicketQueryRequest {
parent: parent_filter,
..Default::default()
})
.unwrap();
let mut actual: Vec<_> = authoritative
.items
.into_iter()
.map(|item| item.id)
.collect();
actual.sort();
assert_eq!(actual, expected);
let observed = f
.backend
.get_ticket_parent(a.clone().into())
.unwrap()
.observation;
f.backend
.set_ticket_parent(
a.clone().into(),
ticket::TicketParentSetRequest {
expected: observed,
parent: None,
operation_key: "unlink".into(),
},
)
.unwrap();
let mut roots = ids(&f.run(TicketBrowseQueryRequest {
parent: Some(ticket::TicketParentFilter::None {}),
..Default::default()
}));
roots.sort();
let mut expected_roots = vec![parent, other, a];
expected_roots.sort();
assert_eq!(roots, expected_roots);
}
@@ -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"),
@@ -103,6 +103,8 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"typed_ticket_labels", "typed_ticket_labels",
"typed_ticket_orchestration_plans", "typed_ticket_orchestration_plans",
"typed_ticket_raw_frontmatter", "typed_ticket_raw_frontmatter",
"typed_ticket_parent_operations",
"typed_ticket_parent_links",
"typed_ticket_relations", "typed_ticket_relations",
"typed_ticket_risk_flags", "typed_ticket_risk_flags",
"typed_ticket_targets", "typed_ticket_targets",
+17
View File
@@ -1381,6 +1381,23 @@ mod tests {
} }
impl TicketBackend for FakeBackend { impl TicketBackend for FakeBackend {
fn get_ticket_parent(&self, _: TicketIdOrSlug) -> ticket::Result<ticket::TicketParentLink> {
unreachable!("not used by CLI case fixture")
}
fn set_ticket_parent(
&self,
_: TicketIdOrSlug,
_: ticket::TicketParentSetRequest,
) -> ticket::Result<ticket::TicketParentLink> {
unreachable!("not used by CLI case fixture")
}
fn query_ticket_children(
&self,
_: TicketIdOrSlug,
_: ticket::TicketChildrenRequest,
) -> ticket::Result<ticket::TicketChildrenPage> {
unreachable!("not used by CLI case fixture")
}
fn list(&self, filter: TicketListQuery) -> ticket::Result<Vec<TicketSummary>> { fn list(&self, filter: TicketListQuery) -> ticket::Result<Vec<TicketSummary>> {
self.list_filter.replace(Some(filter)); self.list_filter.replace(Some(filter));
Ok(vec![]) Ok(vec![])
+1 -1
View File
@@ -4,7 +4,7 @@ Ticketには、利用者の意図と、それに対して判定できる成果
## 記録の粒度 ## 記録の粒度
一つのTicketは、単独で価値と達成を判断できる成果にする。中期的な目標はObjective、一時的な進捗はセッションのTaskで扱う。進捗を束ねるだけのTicketや、その代替となる階層関係を増やさない。 一つのTicketは、単独で価値と達成を判断できる成果にする。中期的な目標はObjective、一時的な進捗はセッションのTaskで扱う。通常Ticket同士の親子リンクは仕事の分解を表し、依存・実行順や状態・担当・権限の継承を意味しない。親と子の要求充足はそれぞれ判断し、子のclosed件数を親の成功と扱わない。
作成・大幅な変更の前に、関連記録と重複を確認する。背景、要求、受入条件、決定事項、実装の裁量、未解決の問いを区別して書く。確認済みの事実、利用者の説明、仮説を混ぜない。担当者の役割制限、そのターンの計画、文書作成後に停止する判断を製品の禁止事項へ転記しない。 作成・大幅な変更の前に、関連記録と重複を確認する。背景、要求、受入条件、決定事項、実装の裁量、未解決の問いを区別して書く。確認済みの事実、利用者の説明、仮説を混ぜない。担当者の役割制限、そのターンの計画、文書作成後に停止する判断を製品の禁止事項へ転記しない。
+661 -1
View File
@@ -16106,6 +16106,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"
}, },
@@ -16120,6 +16124,7 @@
} }
}, },
"required": [ "required": [
"can_set_parent",
"can_assign_orchestrator", "can_assign_orchestrator",
"can_unassign_orchestrator", "can_unassign_orchestrator",
"can_queue", "can_queue",
@@ -16999,6 +17004,16 @@
"null" "null"
] ]
}, },
"parent": {
"anyOf": [
{
"$ref": "#/components/schemas/TicketParentFilter"
},
{
"type": "null"
}
]
},
"query": { "query": {
"type": [ "type": [
"string", "string",
@@ -17188,6 +17203,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": {
@@ -17434,6 +17500,9 @@
}, },
"type": "array" "type": "array"
}, },
"parent_link": {
"$ref": "#/components/schemas/TicketParentLink"
},
"priority": { "priority": {
"type": "string" "type": "string"
}, },
@@ -17502,6 +17571,7 @@
} }
}, },
"required": [ "required": [
"parent_link",
"initial_post", "initial_post",
"id", "id",
"tags", "tags",
@@ -17995,6 +18065,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"
@@ -18321,6 +18392,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": {
@@ -18729,6 +18835,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": {
@@ -19039,6 +19252,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",
@@ -23093,7 +23317,7 @@
"info": { "info": {
"title": "Yoi Server API", "title": "Yoi Server API",
"version": "0.1.0", "version": "0.1.0",
"x-yoi-source-digest": "sha256:83296655378ab00d5b7d4ab344879bbae5069cec5853407c44ee0d562449134a" "x-yoi-source-digest": "sha256:6a3d9c468ff204b44fc0b11d47e45f153b8131b9ec99c54ba2ebfdf2c0d14580"
}, },
"openapi": "3.1.0", "openapi": "3.1.0",
"paths": { "paths": {
@@ -45040,6 +45264,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",
@@ -45598,6 +45939,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",
@@ -45715,6 +46153,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",
+2
View File
@@ -10,6 +10,8 @@ Choose a result and return path suited to the request: conversation, an optional
Ticket cases have only `open` (still handled) and `closed` (handling ended for now); open is not execution readiness and closed is not necessarily fulfilled. Use authorized `TicketCaseUpdate` with `close`, `reopen`, or `correct_closure`, an explanation and typed closure reason when the request calls for it. Observe `content_digest`, case state, and nullable `closure_operation_key` from `ShowTicket` for CAS, and use a stable operation key for exact replay. Closure reasons and immutable history are separate from classification tags; tag changes never alter the closure judgment. Reread and reconcile after conflict; never guess a newer observation. Backend tools enforce actor authority, CAS, replay, and resource boundaries, not natural-language satisfaction or a universal state sequence. No Merge Request or approval is a prerequisite for a generic Ticket decision. Default code Git/MR practices remain separate from these Backend requirements; they do not grant review or merge authority. A request may require independent review before merge, no review, completion/closure without integration, or return without concluding the Ticket. Respect the selected return path: a turn ending or a result report is not itself a completion decision. Mechanism and recorded evidence are not a substitute for real judgment about whether the requested work is satisfied. Ticket cases have only `open` (still handled) and `closed` (handling ended for now); open is not execution readiness and closed is not necessarily fulfilled. Use authorized `TicketCaseUpdate` with `close`, `reopen`, or `correct_closure`, an explanation and typed closure reason when the request calls for it. Observe `content_digest`, case state, and nullable `closure_operation_key` from `ShowTicket` for CAS, and use a stable operation key for exact replay. Closure reasons and immutable history are separate from classification tags; tag changes never alter the closure judgment. Reread and reconcile after conflict; never guess a newer observation. Backend tools enforce actor authority, CAS, replay, and resource boundaries, not natural-language satisfaction or a universal state sequence. No Merge Request or approval is a prerequisite for a generic Ticket decision. Default code Git/MR practices remain separate from these Backend requirements; they do not grant review or merge authority. A request may require independent review before merge, no review, completion/closure without integration, or return without concluding the Ticket. Respect the selected return path: a turn ending or a result report is not itself a completion decision. Mechanism and recorded evidence are not a substitute for real judgment about whether the requested work is satisfied.
Parent/child links decompose work between ordinary Tickets in the same Workspace. Use `TicketParentSet` with the complete `parent_link.observation` from a fresh `ShowTicket` read to set, reparent or unlink; explicit nulls distinguish no parent and never-mutated observations from missing data. Query direct children with `TicketChildrenQuery` and retain its Workspace/parent-bound cursor, or filter `QueryTicket` by a parent or no parent. Retain the same operation key and full input after an uncertain response; reread and reconcile rather than minting a new mutation. Parent links never generate dependencies, execution order, assignments, permissions, targets, Worker starts or cascading case judgments. A closed child is not uniformly successful; inspect its closure reason and judge each Ticket independently.
Classification tags use stable tag IDs, not display names, for mutations and filters. Use the shared Workspace tag tools for creation/list/rename/delete and Ticket tag-set changes with their observed operation identities; names such as `ready` never grant start permission. Tag-set CAS compares `expected_tag_ids` with the observed full ID set; tag rename/delete CAS compares `expected_last_operation_key` with the observed tag operation identity. Classification is separate from a close reason and never closes/reopens or delegates a Ticket. Classification tags use stable tag IDs, not display names, for mutations and filters. Use the shared Workspace tag tools for creation/list/rename/delete and Ticket tag-set changes with their observed operation identities; names such as `ready` never grant start permission. Tag-set CAS compares `expected_tag_ids` with the observed full ID set; tag rename/delete CAS compares `expected_last_operation_key` with the observed tag operation identity. Classification is separate from a close reason and never closes/reopens or delegates a Ticket.
A Ticket's durable assignee is an explicitly selected Subject, represented by `{kind: "subject", subject_id: "<subject ID>"}`; `subject_id` is a string. Any permitted Subject can be selected: there is no fixed Workspace-wide assignee or required Orchestrator singleton. Keep the assigned Subject distinct from its current body Worker, background implementation Workers/attempts, and Repository/branch/Workdir resources. Body stop, replacement, or restore does not reassign the Ticket. Different Subjects may handle different Tickets on separate branches and Workdirs in parallel; preserve per-Subject body ownership and actual resource-conflict checks rather than imposing Workspace-wide serialization. Assignment, Profile/role names, Feature activation, and branch separation do not grant operation or resource authority. Backend authenticates the acting Worker and checks effective grants, current Subject/body ownership where required, and Ticket-specific assignment/target conditions. A Ticket's durable assignee is an explicitly selected Subject, represented by `{kind: "subject", subject_id: "<subject ID>"}`; `subject_id` is a string. Any permitted Subject can be selected: there is no fixed Workspace-wide assignee or required Orchestrator singleton. Keep the assigned Subject distinct from its current body Worker, background implementation Workers/attempts, and Repository/branch/Workdir resources. Body stop, replacement, or restore does not reassign the Ticket. Different Subjects may handle different Tickets on separate branches and Workdirs in parallel; preserve per-Subject body ownership and actual resource-conflict checks rather than imposing Workspace-wide serialization. Assignment, Profile/role names, Feature activation, and branch separation do not grant operation or resource authority. Backend authenticates the acting Worker and checks effective grants, current Subject/body ownership where required, and Ticket-specific assignment/target conditions.
@@ -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(""),
});
}
+1 -1
View File
@@ -12,7 +12,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,
+1 -1
View File
@@ -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/subjektiv/routes.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 src/lib/workspace/memory/subject-body.test.ts src/lib/workspace/memory/conversation-attempt.test.ts src/lib/workspace/memory/conversation-api.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-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/subjektiv/routes.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 src/lib/workspace/memory/subject-body.test.ts src/lib/workspace/memory/conversation-attempt.test.ts src/lib/workspace/memory/conversation-api.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 src/lib/workspace/api/ticket-parent.test.ts src/lib/workspace/tickets/ticket-parent-controller.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:83296655378ab00d5b7d4ab344879bbae5069cec5853407c44ee0d562449134a) // Input contract: 0.1.0 (sha256:6a3d9c468ff204b44fc0b11d47e45f153b8131b9ec99c54ba2ebfdf2c0d14580)
// 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:83296655378ab00d5b7d4ab344879bbae5069cec5853407c44ee0d562449134a) // Input contract: 0.1.0 (sha256:6a3d9c468ff204b44fc0b11d47e45f153b8131b9ec99c54ba2ebfdf2c0d14580)
// 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:83296655378ab00d5b7d4ab344879bbae5069cec5853407c44ee0d562449134a) // Input contract: 0.1.0 (sha256:6a3d9c468ff204b44fc0b11d47e45f153b8131b9ec99c54ba2ebfdf2c0d14580)
// 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
+17 -5
View File
@@ -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:83296655378ab00d5b7d4ab344879bbae5069cec5853407c44ee0d562449134a) // Input contract: 0.1.0 (sha256:6a3d9c468ff204b44fc0b11d47e45f153b8131b9ec99c54ba2ebfdf2c0d14580)
// 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:6a3d9c468ff204b44fc0b11d47e45f153b8131b9ec99c54ba2ebfdf2c0d14580)
// 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:83296655378ab00d5b7d4ab344879bbae5069cec5853407c44ee0d562449134a) // Input contract: 0.1.0 (sha256:6a3d9c468ff204b44fc0b11d47e45f153b8131b9ec99c54ba2ebfdf2c0d14580)
// 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
@@ -282,6 +282,7 @@ function ticketDetailFixture() {
blockers: [], blockers: [],
can_assign_orchestrator: false, can_assign_orchestrator: false,
can_queue: false, can_queue: false,
can_set_parent: false,
can_start_manual_worker: false, can_start_manual_worker: false,
can_unassign_orchestrator: false, can_unassign_orchestrator: false,
queue_tickets: [], queue_tickets: [],
@@ -316,6 +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,
},
implementation_reports: [], implementation_reports: [],
content_digest: "a".repeat(64), content_digest: "a".repeat(64),
linked_objectives: [], linked_objectives: [],
@@ -40,6 +40,9 @@ import type {
TicketLegacyWorkflowSnapshot, TicketLegacyWorkflowSnapshot,
TicketListItemSummary, TicketListItemSummary,
TicketListResponse, TicketListResponse,
TicketLinkSummary,
TicketParentLink,
TicketChildrenPage,
TicketMergeRequestSummary, TicketMergeRequestSummary,
TicketBrowseQueryItem, TicketBrowseQueryItem,
TicketBrowseQueryResponse, TicketBrowseQueryResponse,
@@ -225,6 +228,85 @@ function parseClosureReason(
return { other: string(item.other, `${label}.other`) }; return { other: string(item.other, `${label}.other`) };
} }
export function parseTicketLinkSummary(
value: unknown,
label = "Ticket link summary",
): TicketLinkSummary {
const item = object(value, label);
exact(item, ["id", "resource_key", "title", "state", "closure_reason"], label);
return {
id: string(item.id, `${label}.id`),
resource_key: string(item.resource_key, `${label}.resource_key`),
title: string(item.title, `${label}.title`),
state: parseTicketState(item.state, `${label}.state`),
closure_reason: item.closure_reason === null
? null
: parseClosureReason(item.closure_reason, `${label}.closure_reason`),
};
}
export function parseTicketParentLink(
value: unknown,
label = "Ticket parent link",
): TicketParentLink {
const item = object(value, label);
exact(item, ["ticket_id", "observation", "parent"], label);
const observation = object(item.observation, `${label}.observation`);
exact(
observation,
["parent_ticket_id", "last_operation_key"],
`${label}.observation`,
);
const result: TicketParentLink = {
ticket_id: string(item.ticket_id, `${label}.ticket_id`),
observation: {
parent_ticket_id: nullableString(
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`),
};
if (
result.parent !== null &&
result.parent.id !== result.observation.parent_ticket_id
) {
throw new TicketBrowserContractError(
`${label}.parent does not match observation`,
);
}
return result;
}
export function parseTicketChildrenPage(value: unknown): TicketChildrenPage {
const label = "Ticket children page";
const item = object(value, label);
exact(item, ["parent_ticket_id", "items", "next_cursor", "has_more"], label);
const items = array(item.items, `${label}.items`, parseTicketLinkSummary);
if (items.length > 100) {
throw new TicketBrowserContractError(`${label} exceeds its page limit`);
}
const result: TicketChildrenPage = {
parent_ticket_id: string(item.parent_ticket_id, `${label}.parent_ticket_id`),
items,
next_cursor: nullableString(item.next_cursor, `${label}.next_cursor`),
has_more: boolean(item.has_more, `${label}.has_more`),
};
if (
result.has_more !==
(result.next_cursor !== null && result.next_cursor.length > 0)
) {
throw new TicketBrowserContractError(`${label} has inconsistent pagination`);
}
return result;
}
function parseCaseAction(value: unknown, label: string): TicketCaseAction { function parseCaseAction(value: unknown, label: string): TicketCaseAction {
const item = object(value, label); const item = object(value, label);
if (item.action === "reopen") { if (item.action === "reopen") {
@@ -812,6 +894,7 @@ function parseActionEligibility(
"blockers", "blockers",
"can_assign_orchestrator", "can_assign_orchestrator",
"can_queue", "can_queue",
"can_set_parent",
"can_start_manual_worker", "can_start_manual_worker",
"can_unassign_orchestrator", "can_unassign_orchestrator",
"queue_tickets", "queue_tickets",
@@ -823,6 +906,7 @@ function parseActionEligibility(
`${label}.can_assign_orchestrator`, `${label}.can_assign_orchestrator`,
), ),
can_queue: boolean(item.can_queue, `${label}.can_queue`), can_queue: boolean(item.can_queue, `${label}.can_queue`),
can_set_parent: boolean(item.can_set_parent, `${label}.can_set_parent`),
can_start_manual_worker: boolean( can_start_manual_worker: boolean(
item.can_start_manual_worker, item.can_start_manual_worker,
`${label}.can_start_manual_worker`, `${label}.can_start_manual_worker`,
@@ -1135,6 +1219,7 @@ export function parseTicketDetail(value: unknown): TicketDetail {
"implementation_reports", "implementation_reports",
"content_digest", "content_digest",
"linked_objectives", "linked_objectives",
"parent_link",
"merge_request", "merge_request",
"merge_requests", "merge_requests",
"priority", "priority",
@@ -1152,6 +1237,15 @@ export function parseTicketDetail(value: unknown): TicketDetail {
"title", "title",
"updated_at", "updated_at",
], label); ], label);
const parentLink = parseTicketParentLink(
item.parent_link,
`${label}.parent_link`,
);
if (parentLink.ticket_id !== item.id) {
throw new TicketBrowserContractError(
`${label}.parent_link has the wrong Ticket identity`,
);
}
return { return {
...caseProjection(item, label), ...caseProjection(item, label),
closure_history: array( closure_history: array(
@@ -1203,6 +1297,7 @@ export function parseTicketDetail(value: unknown): TicketDetail {
parseTicketEvidenceEvent, parseTicketEvidenceEvent,
), ),
content_digest: string(item.content_digest, `${label}.content_digest`), content_digest: string(item.content_digest, `${label}.content_digest`),
parent_link: parentLink,
linked_objectives: array( linked_objectives: array(
item.linked_objectives, item.linked_objectives,
`${label}.linked_objectives`, `${label}.linked_objectives`,
@@ -0,0 +1,147 @@
import {
parseTicketChildrenPage,
parseTicketLinkSummary,
parseTicketParentLink,
} from "./ticket-browser.ts";
import { createTicketParentClient } from "./ticket-parent.ts";
declare const Deno: {
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 {
let rejected = false;
try {
fn();
} catch {
rejected = true;
}
assert(rejected);
}
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 () => {
const originalFetch = globalThis.fetch;
const paths: string[] = [];
globalThis.fetch = async (input) => {
paths.push(String(input));
return Response.json(parent);
};
try {
const client = createTicketParentClient();
for (const reference of ["T-8", "canonical/id"]) {
const result = await client.resolve("workspace one", reference);
assert(result.id === parent.id && result.resource_key === "T-8");
assert(
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);
} finally {
globalThis.fetch = originalFetch;
}
});
Deno.test("reference resolution denial never falls back to Ticket detail", async () => {
const originalFetch = globalThis.fetch;
const paths: string[] = [];
globalThis.fetch = async (input) => {
paths.push(String(input));
return new Response(null, { status: 403 });
};
try {
let denied = false;
try {
await createTicketParentClient().resolve("ws", "T-8");
} catch (cause) {
denied = cause instanceof Error && cause.message.includes("(403)");
}
assert(denied);
assert(
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", () => {
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.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,
});
assert(detached.observation.last_operation_key === "unlink-2");
assert(
parseTicketLinkSummary({
...parent,
closure_reason: { other: "Research returned" },
}).closure_reason !== null,
);
});
Deno.test("parent JSON rejects stale shapes mismatched identities and invalid case semantics", () => {
for (
const value of [
{ ticket_id: "child-id", parent },
{
ticket_id: "child-id",
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, closure_reason: "success" })
);
});
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,
};
assert(parseTicketChildrenPage(page).next_cursor === "opaque+cursor");
rejects(() =>
parseTicketChildrenPage({ ...page, items: Array(101).fill(parent) })
);
rejects(() => parseTicketChildrenPage({ ...page, next_cursor: null }));
rejects(() => parseTicketChildrenPage({ ...page, has_more: false }));
rejects(() => parseTicketChildrenPage({ ...page, next_cursor: "" }));
});
@@ -0,0 +1,122 @@
import type {
TicketBrowseQueryRequest,
TicketChildrenPage,
TicketLinkSummary,
TicketParentLink,
TicketParentSetRequest,
} from "#lib/generated/ticket-api.ts";
import {
workspaceApiJson,
workspaceApiJsonWithBody,
workspaceApiPath,
} from "./http.ts";
import {
parseTicketBrowseQueryResponse,
parseTicketChildrenPage,
parseTicketLinkSummary,
parseTicketParentLink,
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
} from "./ticket-browser.ts";
import {
type ObservedOperation,
observeOperation,
} from "./ticket-operations.ts";
export type ParentCandidates = {
items: TicketLinkSummary[];
hasMore: boolean;
};
export type TicketParentClient = {
parent(workspaceId: string, ticketId: string): Promise<TicketParentLink>;
children(
workspaceId: string,
ticketId: string,
cursor: string | null,
): Promise<TicketChildrenPage>;
search(workspaceId: string, query: string): Promise<ParentCandidates>;
resolve(workspaceId: string, reference: string): Promise<TicketLinkSummary>;
set(operation: ObservedOperation): Promise<TicketParentLink>;
};
function ticketPath(workspaceId: string, ticketId: string): string {
return workspaceApiPath(
workspaceId,
`/tickets/${encodeURIComponent(ticketId)}`,
);
}
export function parentOperation(
workspaceId: string,
link: TicketParentLink,
parent: string | null,
operationKey: string,
): ObservedOperation {
const request: TicketParentSetRequest = {
expected: { ...link.observation },
parent,
operation_key: operationKey,
};
return observeOperation(
`${ticketPath(workspaceId, link.ticket_id)}/parent`,
"PUT",
request,
);
}
export function createTicketParentClient(): TicketParentClient {
return {
parent: (workspaceId, ticketId) =>
workspaceApiJson(
`${ticketPath(workspaceId, ticketId)}/parent`,
parseTicketParentLink,
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
),
children: (workspaceId, ticketId, cursor) => {
const query = new URLSearchParams({ limit: "50" });
if (cursor !== null) query.set("cursor", cursor);
return workspaceApiJson(
`${ticketPath(workspaceId, ticketId)}/children?${query}`,
parseTicketChildrenPage,
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
);
},
search: async (workspaceId, query) => {
const request: TicketBrowseQueryRequest = {
query,
states: [],
limit: 20 as TicketBrowseQueryRequest["limit"],
sort: "updated_desc",
cursor: null,
};
const page = await workspaceApiJsonWithBody(
workspaceApiPath(workspaceId, "/tickets/browse"),
{ method: "POST", body: JSON.stringify(request) },
parseTicketBrowseQueryResponse,
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
);
return {
items: page.items.map((ticket) => ({
id: ticket.id,
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,
};
},
resolve: (workspaceId, reference) =>
workspaceApiJson(
`${ticketPath(workspaceId, reference)}/link-summary`,
parseTicketLinkSummary,
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,6 +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,
},
closure_history: [], closure_history: [],
content_digest: "a".repeat(64), content_digest: "a".repeat(64),
created_at: updated(1), created_at: updated(1),
@@ -93,6 +98,7 @@ export function fixtureDetail(key = "T-101") {
blockers: [], blockers: [],
can_assign_orchestrator: false, can_assign_orchestrator: false,
can_queue: false, can_queue: false,
can_set_parent: false,
can_start_manual_worker: false, can_start_manual_worker: false,
can_unassign_orchestrator: false, can_unassign_orchestrator: false,
queue_tickets: [], queue_tickets: [],
@@ -0,0 +1,161 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import type { TicketDetail } from "#lib/generated/ticket-api.ts";
import { fixtureDetail } from "#lib/workspace/home/dashboard.test-fixtures.ts";
import TicketHierarchy from "./TicketHierarchy.svelte";
afterEach(() => {
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 {
const ticket = fixtureDetail("T-1");
return {
...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 {
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,
});
}
throw new Error(`Unexpected request ${path}`);
}
test("read-only hierarchy preserves canonical navigation and non-success closure reason without mutation actions", async () => {
const ticket = detail();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => reads(ticket, String(input))),
);
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(view.queryByText("Change or unlink parent")).toBeNull();
expect(view.queryByRole("button", { name: "Unlink parent" })).toBeNull();
});
test("existing Ticket search selection sends observed CAS body and keeps exact retry after response loss", async () => {
const ticket = detail(true);
const bodies: string[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const path = String(input);
if (init?.method === "PUT") {
bodies.push(String(init.body));
throw new TypeError("response lost");
}
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.input(view.getByLabelText("Search existing Tickets"), {
target: { value: "Existing" },
});
await fireEvent.click(view.getByRole("button", { name: "Search Tickets" }));
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("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]);
expect(body.expected).toEqual(ticket.parent_link.observation);
expect(body.parent).toBe("candidate-id");
expect(typeof body.operation_key).toBe("string");
await fireEvent.click(
view.getByRole("button", { name: "Retry exact parent operation" }),
);
await waitFor(() => expect(bodies).toHaveLength(2));
expect(bodies[1]).toBe(bodies[0]);
expect(view.queryByText(/Parent operation saved\./)).toBeNull();
});
@@ -0,0 +1,385 @@
<script lang="ts">
import { onDestroy, onMount, untrack } from "svelte";
import type {
TicketClosureReason,
TicketDetail,
} from "#lib/generated/ticket-api.ts";
import { createTicketParentClient } from "#lib/workspace/api/ticket-parent.ts";
import { ticketHref } from "#lib/workspace/resource-links.ts";
import {
MAX_VISIBLE_CHILDREN,
TicketParentController,
} from "./ticket-parent-controller.ts";
let { workspaceId, routeTicketId, ticket }: {
workspaceId: string;
routeTicketId: string;
ticket: TicketDetail;
} = $props();
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 reference = $state("");
let selectedId = $state("");
let searched = $state(false);
const selected = $derived(
$controller.candidates.find((item) => item.id === selectedId) ?? null,
);
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 = "";
$effect(() => {
if (!mounted) return;
const ws = workspaceId;
const id = routeTicketId;
const loadedId = ticket.id;
untrack(() => {
const identity = JSON.stringify([ws, id, loadedId]);
if (identity === selectedIdentity) return;
selectedIdentity = identity;
query = "";
reference = "";
selectedId = "";
searched = false;
if (loadedId !== id) {
controller.suspend();
return;
}
void controller.select(
ws,
id,
ticket.parent_link,
ticket.action_eligibility.can_set_parent,
);
});
});
$effect(() => {
if (!mounted) return;
controller.setEligibility(ticket.action_eligibility.can_set_parent);
});
onDestroy(() => controller.dispose());
function closureLabel(reason: TicketClosureReason | null): string {
if (reason === null) return "reason unavailable";
return typeof reason === "string" ? reason : reason.other;
}
function search(byReference = false): void {
selectedId = "";
searched = true;
void controller.search(byReference ? reference : query, byReference);
}
</script>
{#if $controller.workspaceId === workspaceId && $controller.ticketId === routeTicketId && ticket.id === routeTicketId}
<section
class="ticket-detail-section ticket-hierarchy"
aria-label="Parent and children"
>
<div class="ticket-section-heading"><h2>Parent and children</h2></div>
<h3>Parent</h3>
{#if $controller.parent?.parent}
{@const parent = $controller.parent.parent}
<a class="hierarchy-link" href={ticketHref(workspaceId, parent)}>
<strong>{parent.resource_key}</strong> {parent.title}
<span>{parent.state}{parent.state === "closed"
? ` · ${closureLabel(parent.closure_reason)}`
: ""}</span>
</a>
{:else if $controller.parent?.observation.parent_ticket_id}
<p>Parent details unavailable.</p>
{:else if $controller.parent}
<p class="workspace-empty-copy">No parent.</p>
{/if}
{#if $controller.loadingParent}
<p role="status">Reading parent…</p>
{/if}
{#if $controller.parentError}
<p class="workspace-callout is-error" role="alert">
{$controller.parentError}
</p>
{/if}
<button
class="workspace-secondary-button"
type="button"
disabled={$controller.loadingParent || $controller.saving}
onclick={() => void controller.readParent()}
>Reread parent</button>
{#if $controller.retentionError}
<p class="workspace-callout is-error" role="alert">
{$controller.retentionError}
</p>
{/if}
{#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}
<button
class="workspace-secondary-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}
<details>
<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>
{#if $controller.children}
<ul class="hierarchy-children">
{#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>
{: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 $controller.loadingChildren}
<p role="status">Reading children…</p>
{/if}
{#if $controller.childrenError}
<p class="workspace-callout is-error" role="alert">
{$controller.childrenError}
</p>
{/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}
<style>
.ticket-hierarchy {
min-width: 0;
overflow-wrap: anywhere;
}
h3 {
font-size: var(--font-size-body);
line-height: var(--line-height-body);
margin-block: var(--space-4) var(--space-2);
}
.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>
@@ -0,0 +1,366 @@
import type {
TicketChildrenPage,
TicketLinkSummary,
TicketParentLink,
} from "#lib/generated/ticket-api.ts";
import type { TicketParentClient } from "#lib/workspace/api/ticket-parent.ts";
import {
TicketParentController,
type TicketParentState,
} from "./ticket-parent-controller.ts";
declare const Deno: { test(name: string, fn: () => Promise<void>): void };
function assert(value: unknown): void {
if (!value) throw new Error("Assertion failed");
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (cause: unknown) => void;
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
const summary = (id: string): TicketLinkSummary => ({
id,
resource_key: `T-${id}`,
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 = {
parent: async (_, id) => link(id),
children: async (_, id) => page(id),
search: async () => ({ items: [summary("8")], hasMore: false }),
resolve: async () => summary("8"),
set: async () => link("1", "8", "set-1"),
...overrides,
};
const controller = new TicketParentController(client, storage);
let state!: TicketParentState;
controller.subscribe((value) => {
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 () => {
for (const currentParent of ["8", null]) {
let sends = 0;
let retainedWrites = 0;
const observed = link("1", currentParent, "previous-operation");
const h = harness({
parent: async () => observed,
set: async () => {
++sends;
return observed;
},
}, {
getItem: () => null,
setItem: () => {
++retainedWrites;
},
removeItem: () => {},
});
await h.controller.select("ws", "1", observed, true);
await h.controller.change(
currentParent === null
? null
: { ...summary(currentParent), title: "Updated title" },
"unused-new-key",
);
assert(sends === 0 && retainedWrites === 0);
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.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 () => {
const sent: string[] = [];
let attempt = 0;
const h = harness({
set: async (operation) => {
sent.push(operation.body!);
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.change(summary("8"), "set-1");
assert(h.state.pending && !h.state.saved);
const body = JSON.parse(sent[0]);
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", "1", link("1"), true);
assert(h.state.pending && h.state.reconciled);
h.controller.useRefreshedObservation();
assert(h.state.pending);
await h.controller.change(null, "must-not-send");
assert(sent.length === 1);
await h.controller.retry();
assert(sent.length === 2 && sent[0] === sent[1]);
assert(!h.state.pending && h.state.saved);
// 再送応答の親は8だが、直後のGETでは親なしを観測する。
assert(h.state.parent?.observation.parent_ticket_id === null);
});
Deno.test("permission and CAS rejections never report success or automatically rebase", async () => {
for (const status of [403, 409]) {
const h = harness({
set: async () => {
throw new Error(`request failed (${status})`);
},
});
await h.controller.select("ws", "1", link("1"), true);
await h.controller.change(summary("8"), `rejected-${status}`);
assert(h.state.pending?.rejected && !h.state.saved);
assert(h.state.parent?.observation.parent_ticket_id === null);
h.controller.useRefreshedObservation();
assert(h.state.pending);
await h.controller.readParent();
h.controller.useRefreshedObservation();
assert(!h.state.pending && !h.state.saved);
}
});
Deno.test("rejection after an ambiguous send cannot discard that earlier unknown outcome", async () => {
let count = 0;
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.change(summary("8"), "same-key");
await h.controller.retry();
await h.controller.readParent();
h.controller.useRefreshedObservation();
assert(h.state.pending && !h.state.pending.rejected && !h.state.saved);
});
Deno.test("parent children and search responses from an old A epoch cannot overwrite A after ABA navigation", async () => {
const oldParent = deferred<TicketParentLink>();
const oldChildren = deferred<TicketChildrenPage>();
const oldSearch = deferred<
{ items: TicketLinkSummary[]; hasMore: boolean }
>();
let reads = 0;
const h = harness({
parent: async (_, id) => ++reads === 1 ? oldParent.promise : link(id),
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 searching = h.controller.search("old");
await h.controller.select("ws", "2", link("2"), true);
await h.controller.select("ws", "1", link("1"), true);
oldParent.resolve(link("1", "8", "old"));
oldChildren.resolve({ ...page("1"), items: [summary("9")] });
oldSearch.resolve({ items: [summary("8")], hasMore: true });
await first;
await searching;
assert(h.state.parent?.observation.parent_ticket_id === null);
assert(
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 () => {
const saving = deferred<TicketParentLink>();
const h = harness({ set: async () => saving.promise });
await h.controller.select("ws", "1", link("1"), true);
const operation = h.controller.change(summary("8"), "old-save");
await h.controller.select("ws", "2", link("2"), true);
saving.resolve(link("1", "8", "old-save"));
await operation;
assert(h.state.ticketId === "2" && !h.state.saved && !h.state.pending);
await h.controller.select("ws", "1", link("1"), true);
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 () => {
const cursors: (string | null)[] = [];
const h = harness({
children: async (_, id, cursor) => {
cursors.push(cursor);
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.readChildren(true);
assert(cursors[0] === null && cursors[1] === "opaque");
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 () => {
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 sent: string[] = [];
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.change(summary("8"), "tab-save");
first.controller.dispose();
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);
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();
assert(sent[0] === sent[1] && !next.state.pending && entries.size === 0);
});
Deno.test("unreadable request retention fails closed before any parent mutation", async () => {
let sends = 0;
const h = harness({
set: async () => {
++sends;
return link("1");
},
}, {
getItem: () => "corrupt",
setItem: () => {},
removeItem: () => {},
});
await h.controller.select("ws", "1", link("1"), true);
await h.controller.change(summary("8"), "do-not-send");
assert(h.state.retentionError && sends === 0);
});
Deno.test("ineligible parent actions cannot search or mutate even with visible links", async () => {
let calls = 0;
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.search("anything");
await h.controller.change(null, "forbidden");
assert(calls === 0 && !h.state.pending && !h.state.saved);
});
@@ -0,0 +1,408 @@
import type {
TicketChildrenPage,
TicketLinkSummary,
TicketParentLink,
TicketParentSetRequest,
} from "#lib/generated/ticket-api.ts";
import type { ObservedOperation } from "#lib/workspace/api/ticket-operations.ts";
import {
parentOperation,
type TicketParentClient,
} from "#lib/workspace/api/ticket-parent.ts";
export const MAX_VISIBLE_CHILDREN = 500;
type Attempt = { operation: ObservedOperation; key: string; rejected: boolean };
export type TicketParentState = {
workspaceId: string;
ticketId: string;
canSetParent: boolean;
parent: TicketParentLink | null;
children: TicketChildrenPage | null;
candidates: TicketLinkSummary[];
candidatesTruncated: boolean;
loadingParent: boolean;
loadingChildren: boolean;
searching: boolean;
saving: boolean;
parentError: string | null;
childrenError: string | null;
searchError: string | null;
operationError: string | null;
retentionError: string | null;
pending: Attempt | null;
reconciled: boolean;
saved: boolean;
};
const empty = (): TicketParentState => ({
workspaceId: "",
ticketId: "",
canSetParent: 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 {
return cause instanceof Error &&
/\((400|401|403|404|409|422)\)/.test(cause.message);
}
function failure(cause: unknown): string {
const message = cause instanceof Error
? cause.message
: "Request could not be completed.";
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.";
}
export class TicketParentController {
private value = empty();
private listeners = new Set<(state: TicketParentState) => void>();
private attempts = new Map<string, Attempt>();
private epoch = 0;
private searchEpoch = 0;
private identity = "";
constructor(
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 {
try {
if (attempt) {
this.storage?.setItem(this.storageKey(), JSON.stringify(attempt));
} else this.storage?.removeItem(this.storageKey());
if (attempt) this.attempts.set(this.identity, attempt);
else this.attempts.delete(this.identity);
return true;
} catch {
this.patch({
retentionError:
"Parent request retention is unavailable. Keep this page open; new changes are blocked.",
});
return false;
}
}
private restore(workspaceId: string, ticketId: string): Attempt | null {
const memory = this.attempts.get(this.identity);
if (memory) return memory;
const raw = this.storage?.getItem(this.storageKey());
if (!raw) return null;
if (raw.length > 16_384) throw new Error("Invalid retained request");
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");
const body = JSON.parse(value.operation.body);
const nullable = (item: unknown) =>
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 = {
expected: {
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,
};
this.attempts.set(this.identity, attempt);
if (!attempt.rejected) this.unknownKeys.add(attempt.key);
return attempt;
}
subscribe = (listener: (state: TicketParentState) => void): () => void => {
this.listeners.add(listener);
listener(this.value);
return () => {
this.listeners.delete(listener);
};
};
private patch(update: Partial<TicketParentState>): void {
this.value = { ...this.value, ...update };
for (const listener of this.listeners) listener(this.value);
}
async select(
workspaceId: string,
ticketId: string,
initial: TicketParentLink,
canSetParent: boolean,
): Promise<void> {
++this.epoch;
++this.searchEpoch;
this.identity = JSON.stringify([workspaceId, ticketId]);
this.value = empty();
let pending: Attempt | null = null;
let retentionError: string | null = null;
try {
pending = this.restore(workspaceId, ticketId);
} catch {
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()]);
}
suspend(): void {
++this.epoch;
++this.searchEpoch;
this.identity = "";
this.value = empty();
this.patch({});
}
setEligibility(canSetParent: boolean): void {
if (this.value.canSetParent !== canSetParent) {
++this.searchEpoch;
this.patch({ canSetParent, candidates: [], searching: false });
}
}
async readParent(): Promise<void> {
if (this.value.loadingParent || this.value.saving) return;
const epoch = this.epoch;
const { workspaceId, ticketId } = this.value;
this.patch({ loadingParent: true, parentError: null, reconciled: false });
try {
const link = await this.client.parent(workspaceId, ticketId);
if (epoch !== this.epoch) return;
if (link.ticket_id !== ticketId) {
throw new Error("Wrong parent response identity");
}
const pending = this.value.pending;
const saved = pending !== null &&
link.observation.last_operation_key === pending.key;
if (saved) this.retain(null);
this.patch({
parent: link,
reconciled: true,
...(saved ? { pending: null, saved: true, operationError: null } : {}),
});
} catch (cause) {
if (epoch === this.epoch) {
this.patch({
parentError: failure(cause),
...(cause instanceof Error && /\(401\)|\(403\)/.test(cause.message)
? { canSetParent: false }
: {}),
});
}
} finally {
if (epoch === this.epoch) this.patch({ loadingParent: false });
}
}
async readChildren(more = false): Promise<void> {
if (this.value.loadingChildren) return;
const previous = this.value.children;
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 { workspaceId, ticketId } = this.value;
this.patch({ loadingChildren: true, childrenError: null });
try {
const page = await this.client.children(workspaceId, ticketId, cursor);
if (epoch !== this.epoch) return;
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");
}
const items = more && previous
? [
...new Map(
[...previous.items, ...page.items].map((item) => [item.id, item]),
).values(),
]
: page.items;
this.patch({ children: { ...page, items } });
} catch (cause) {
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> {
if (!this.value.canSetParent || this.value.pending || !input.trim()) return;
const epoch = this.epoch;
const searchEpoch = ++this.searchEpoch;
const { workspaceId, ticketId } = this.value;
this.patch({
searching: true,
candidates: [],
candidatesTruncated: false,
searchError: null,
});
try {
const result = reference
? {
items: [await this.client.resolve(workspaceId, input.trim())],
hasMore: false,
}
: await this.client.search(workspaceId, input.trim());
if (epoch !== this.epoch || searchEpoch !== this.searchEpoch) return;
this.patch({
candidates: result.items.filter((item) => item.id !== ticketId),
candidatesTruncated: result.hasMore,
});
} catch (cause) {
if (epoch === this.epoch && searchEpoch === this.searchEpoch) {
this.patch({ searchError: failure(cause) });
}
} finally {
if (epoch === this.epoch && searchEpoch === this.searchEpoch) {
this.patch({ searching: false });
}
}
}
async change(
parent: TicketLinkSummary | null,
operationKey: string,
): Promise<void> {
const state = this.value;
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;
if (desired === state.parent.observation.parent_ticket_id) return;
const attempt: Attempt = {
operation: parentOperation(
state.workspaceId,
state.parent,
desired,
operationKey,
),
key: operationKey,
rejected: false,
};
await this.run(attempt);
}
async retry(): Promise<void> {
if (this.value.pending && this.value.canSetParent) {
await this.run(this.value.pending);
}
}
private async run(attempt: Attempt): Promise<void> {
if (
this.value.saving || this.value.loadingParent || this.value.retentionError
) return;
if (!this.retain({ ...attempt, rejected: false })) return;
const epoch = this.epoch;
const previouslyUnknown = this.unknownKeys.has(attempt.key);
this.unknownKeys.add(attempt.key);
++this.searchEpoch;
this.patch({
saving: true,
searching: false,
pending: attempt,
saved: false,
operationError: null,
reconciled: false,
});
try {
const result = await this.client.set(attempt.operation);
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");
this.retain(null);
this.unknownKeys.delete(attempt.key);
this.patch({ pending: null, saved: true, candidates: [] });
} catch (cause) {
if (epoch === this.epoch) {
// 後の拒否応答では、先行する応答消失時の保存有無を否定できない。
const updated = {
...attempt,
rejected: rejected(cause) && !previouslyUnknown,
};
if (updated.rejected) this.unknownKeys.delete(attempt.key);
this.retain(updated);
this.patch({
pending: updated,
operationError: failure(cause),
...(cause instanceof Error && /\(401\)|\(403\)/.test(cause.message)
? { canSetParent: false }
: {}),
});
}
} finally {
if (epoch === this.epoch) this.patch({ saving: false });
}
// 完全一致再送の保存済み応答を現在の親の観測として使わず、別に再読する。
if (epoch === this.epoch && !this.value.pending) await this.readParent();
}
private unknownKeys = new Set<string>();
useRefreshedObservation(): void {
if (
this.value.saving || !this.value.reconciled ||
!this.value.pending?.rejected
) return;
if (!this.retain(null)) return;
this.patch({ pending: null, operationError: null, saved: false });
}
dispose(): void {
++this.epoch;
++this.searchEpoch;
this.listeners.clear();
}
}
@@ -61,6 +61,39 @@ const tags: readonly WorkspaceTag[] = Object.freeze([
}), }),
]); ]);
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,
);
assertEquals(request.parent, { kind: "parent", ticket: "T-804" });
assertEquals(request.states, ["closed"]);
assertEquals(request.closure_reason, "duplicate");
assertEquals(request.tag_ids, ["tag-1"]);
assertEquals(request.assigned_principal, {
kind: "workspace_agent",
agent_key: "workspace-orchestrator",
});
assertEquals(request.query, "retain words");
assertEquals(compileTicketSearch("parent:none parent:none", tags).parent, {
kind: "none",
});
assertEquals(
compileTicketSearch('"parent:none" other:field', tags).query,
"parent:none other:field",
);
assertEquals(compileTicketSearch("ordinary text", tags).parent, undefined);
});
Deno.test("parent filters reject empty invalid and conflicting choices", () => {
assertSearchError("parent:", "Empty parent:");
for (const value of ["all", "T-0", "O-804", "T-804-title", "none-other"]) {
assertSearchError(`parent:${value}`, "Invalid parent:");
}
assertSearchError("parent:none parent:T-804", "Conflicting parent:");
assertSearchError("parent:T-804 parent:T-805", "Conflicting parent:");
});
Deno.test("only actual tag qualifiers require the tag catalog", () => { Deno.test("only actual tag qualifiers require the tag catalog", () => {
for ( for (
const input of [ const input of [
@@ -2,6 +2,7 @@ import type {
TicketAssignmentPrincipalSnapshot, TicketAssignmentPrincipalSnapshot,
TicketBrowseQueryRequest, TicketBrowseQueryRequest,
TicketClosureReason, TicketClosureReason,
TicketParentFilter,
WorkspaceTag, WorkspaceTag,
} from "../../generated/ticket-api.ts"; } from "../../generated/ticket-api.ts";
@@ -136,7 +137,7 @@ export function compileTicketSearch(
const tagIds = new Set<string>(); const tagIds = new Set<string>();
const singletons = new Map<string, string>(); const singletons = new Map<string, string>();
for (const token of tokens(input)) { for (const token of tokens(input)) {
const match = /^(state|tag|assignee|reason):(.*)$/s.exec(token); const match = /^(state|tag|assignee|reason|parent):(.*)$/s.exec(token);
if (!match) { if (!match) {
// 未知のqualifierやURLはそのまま残す。引用された通常文もfilterとして解釈しない。 // 未知のqualifierやURLはそのまま残す。引用された通常文もfilterとして解釈しない。
text.push( text.push(
@@ -163,6 +164,19 @@ export function compileTicketSearch(
canonical = value; canonical = value;
request.states = value === "all" ? [] : [value]; request.states = value === "all" ? [] : [value];
break; break;
case "parent": {
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.",
);
}
const parent: TicketParentFilter = value === "none"
? { kind: "none" }
: { kind: "parent", ticket: value };
request.parent = parent;
canonical = JSON.stringify(parent);
break;
}
case "assignee": case "assignee":
request.assigned_principal = principal(value); request.assigned_principal = principal(value);
canonical = JSON.stringify(request.assigned_principal); canonical = JSON.stringify(request.assigned_principal);
@@ -309,6 +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>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>
@@ -4,6 +4,7 @@
import TicketTags from "#lib/workspace/tickets/TicketTags.svelte"; import TicketTags from "#lib/workspace/tickets/TicketTags.svelte";
import TicketWorkerObservation from "#lib/workspace/tickets/TicketWorkerObservation.svelte"; import TicketWorkerObservation from "#lib/workspace/tickets/TicketWorkerObservation.svelte";
import TicketHandling from "#lib/workspace/tickets/TicketHandling.svelte"; import TicketHandling from "#lib/workspace/tickets/TicketHandling.svelte";
import TicketHierarchy from "#lib/workspace/tickets/TicketHierarchy.svelte";
import { observeOperation, type ObservedOperation } from "#lib/workspace/api/ticket-operations.ts"; import { observeOperation, type ObservedOperation } from "#lib/workspace/api/ticket-operations.ts";
import RichMarkdown from "#lib/workspace/console/RichMarkdown.svelte"; import RichMarkdown from "#lib/workspace/console/RichMarkdown.svelte";
import BevelLine from "#lib/workspace/ui/BevelLine.svelte"; import BevelLine from "#lib/workspace/ui/BevelLine.svelte";
@@ -588,6 +589,12 @@
<TicketTags workspaceId={data.workspaceId} {ticket} refresh={refreshOperationTicket} /> <TicketTags workspaceId={data.workspaceId} {ticket} refresh={refreshOperationTicket} />
{/key} {/key}
<BevelLine decorative />
<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">