7 Commits
15 changed files with 2301 additions and 249 deletions
+3 -4
View File
@@ -70,11 +70,10 @@ Intake は以下を行う。
利用可能なら、以下の typed Ticket tools を使う。
- `TicketList`: 既存 Ticket の一覧・重複確認。
- `TicketShow`: 関連 Ticket の詳細確認。
- `QueryTicket`: 既存 Ticket の一覧・重複確認。
- `ShowTicket`: 関連 Ticket の詳細確認。
- `TicketCreate`: 合意済み Ticket の作成。
- `TicketComment`: 既存 Ticket refinement / decision / plan の記録。
- `TicketDoctor`: 必要に応じた整合性確認。
Intake は `MergeRequest*`, `TicketWorkflowState`, `TicketClose` を通常使わない。review authority は assigned Coder が起動した read-only direct-child Reviewer の immutable Merge Request attempt に属し、completion / merge / close は各guarded workflowの責務である。
@@ -96,7 +95,7 @@ Ticket tools が利用できない環境では、勝手に file write で代替
### 2. 既存 Ticket を確認する
`TicketList` / `TicketShow` で duplicate / related work を探す。
`QueryTicket` / `ShowTicket` で duplicate / related work を探す。
確認観点:
+10 -10
View File
@@ -36,8 +36,8 @@ TicketCreate / TicketComment
Orchestrator は以下を行う。
- Ticket を `TicketShow` で読む。
- 必要に応じて関連 Ticket を `TicketList` / `TicketShow` で確認する。
- Ticket を `ShowTicket` で読む。
- 必要に応じて関連 Ticket を `QueryTicket` / `ShowTicket` で確認する。
- Ticket body / thread / artifacts / resolution / review / implementation report を読む。
- Ticket が Objective context と結びついている場合は、Objective を medium-term goal / motivation / strategy / success criteria / decision context として読む。ただし Objective context は判断背景であり、Ticket body/thread/artifacts や explicit Ticket relations / OrchestrationPlan records を読む代替ではない。
- repository 状態、関連 docs/code、既存 worktree、visible Pods を必要に応じて明示的に確認する。
@@ -73,21 +73,21 @@ Orchestrator は以下を行う。
利用可能なら、以下を使う。
- `TicketList`: routing 候補関連 Ticket の確認
- `TicketShow`: 対象 Ticket の body / thread / artifacts / resolution / typed relation metadata と derived inverse/blocker view を確認。
- `QueryTicket`: routing 候補関連 Ticket、project-level forward relation (`depends_on` / `blocks` / `related` / `supersedes` / `duplicate_of`) と derived blocker summary を bounded filter/projection で確認する。`depends_on` と incoming unresolved `blocks` は queue/acceptance blocker であり、`related` は blocker ではない。`supersedes` / `duplicate_of` は visible diagnostic として扱い、自動的な lifecycle 変更や scheduler 判断にはしない
- `ShowTicket`: 対象 Ticket の body / thread / artifacts / resolution / typed relation metadata、linked Objective、assignment、implementation/review evidence を確認する
- `TicketComment`: routing decision / intent packet / blocked reason / next question の記録。
- `TicketWorkflowState`: `queued -> inprogress` acceptance、`inprogress -> done`、または concrete missing decision/information reason を伴う `ready|queued -> planning` に使う。
- `TicketRelationQuery`: project-level の forward relation (`depends_on` / `blocks` / `related` / `supersedes` / `duplicate_of`) を読む。`depends_on` と incoming unresolved `blocks` queue/acceptance blocker であり、`related` は blocker ではない。`supersedes` / `duplicate_of` は visible diagnostic として扱い、自動的な lifecycle 変更や scheduler 判断にはしない
- `TicketDependencyCheck`: queue/acceptance 直前の typed dependency readiness guard に使う
- `TicketRelationRecord` / `TicketRelationRemove`: ユーザー合意済みの durable project relation を明示的に更新する場合だけ使う。
- `TicketOrchestrationPlanQuery`: 対象 Ticket や関連 Ticket の ordering / blocker / conflict / waiting-capacity / accepted-plan 記録を読む。queued acceptance 前に必ず確認する。
- `TicketOrchestrationPlanRecord`: Orchestrator が routing 中に project-relevant な ordering / dependency / conflict / capacity/waiting / accepted-plan decision を残す。これは queue reorder、自動起動、state 変更ではない。
- `TicketClose`: 完了権限と resolution が揃っている場合だけ使う。
- `TicketDoctor`: routing 前後の整合性確認。
`TicketCreate` は通常 Intake の責務だが、routing 中に follow-up Ticket が必要だと判断した場合は、ユーザー/上位 Orchestrator の合意後にだけ使う。
## Queued acceptance contract
- `queued -> inprogress` acceptance の直前に `TicketShow` / `TicketRelationQuery` の relation blockers を再確認する。unresolved `depends_on` や incoming unresolved `blocks` が残る場合は implementation side effect を始めず、理由を thread に残して `planning` へ戻すか blocked diagnostic として停止する。
- `queued -> inprogress` acceptance の直前に `ShowTicket` / `QueryTicket` の relation blocker projection を再確認する。unresolved `depends_on` や incoming unresolved `blocks` が残る場合は implementation side effect を始めず、理由を thread に残して `planning` へ戻すか blocked diagnostic として停止する。
- Relation metadata は project-level constraint であり、OrchestrationPlan は runtime ordering/capacity decision である。relation を OrchestrationPlan で代替しないし、OrchestrationPlan を durable dependency authority として扱わない。
`state = queued` は、Ticket が routing 対象として人間により Orchestrator へ渡された状態である。Orchestrator は queued notification を受けたら、Ticket、workspace state、対象 Ticket の `TicketOrchestrationPlanQuery` 記録、risk domain に応じた bounded project context を読んで、次のどちらかを行う。
@@ -100,7 +100,7 @@ Orchestrator は以下を行う。
Parallel acceptance pass:
- 明示的な queue review 中に複数の queued Ticket が見える場合、Orchestrator は最初の1件の完了待ちを default にしない。各 Ticket について Ticket body/thread/artifacts、TicketRelationQuery、TicketOrchestrationPlanQuery、workspace/worktree dirty state、visible Pods、既存 branches、conflict/dependency notes を確認する。
- 明示的な queue review 中に複数の queued Ticket が見える場合、Orchestrator は最初の1件の完了待ちを default にしない。各 Ticket について Ticket body/thread/artifacts、QueryTicket の relation/blocker projection、TicketOrchestrationPlanQuery、workspace/worktree dirty state、visible Pods、既存 branches、conflict/dependency notes を確認する。
- 追加で開始してよいのは、blocking relation/dependency がなく、`do_not_parallelize` または applicable conflict record がなく、source/write surfaces が disjoint または conflict risk が小さく機械的で、coder/reviewer follow-up capacity があり、acceptance basis となる Ticket thread/plan/workspace records を side effect 前に記録・commit でき、別 worktree/branch/scope を切れる Ticket だけである。
- capacity が見えるのに queued Ticket を idle にする場合は、dependency / conflict / capacity / missing planning decision / dirty workspace / reviewer-coder bottleneck / migration boundary / human gate のいずれかの bounded reason を記録する。
- この pass は scheduler、background runner、resource graph solver、automatic queue drain loop ではない。unqueued Ticket を開始せず、各 Ticket の `queued -> inprogress` acceptance を個別に記録する。
@@ -269,8 +269,8 @@ Action:
### 1. 状態確認
- `git state --short --branch`
- `TicketShow <target>`
- 関連 Ticket の `TicketList` / `TicketShow`
- `ShowTicket <target>`
- 関連 Ticket の `QueryTicket` / `ShowTicket`
- 必要に応じて docs/code/workflow/history
- 必要に応じて visible Pods / worktrees / branches
+60 -61
View File
@@ -36,8 +36,8 @@ const MAX_DIAGNOSTIC_LIMIT: usize = 500;
pub const TICKET_BASE_TOOL_NAMES: [&str; 14] = [
"TicketCreate",
"TicketEditItem",
"TicketList",
"TicketShow",
"QueryTicket",
"ShowTicket",
"TicketComment",
"TicketPlan",
"TicketDecision",
@@ -51,8 +51,8 @@ pub const TICKET_BASE_TOOL_NAMES: [&str; 14] = [
];
pub const TICKET_BASE_READ_ONLY_TOOL_NAMES: [&str; 4] = [
"TicketList",
"TicketShow",
"QueryTicket",
"ShowTicket",
"TicketDependencyCheck",
"TicketDoctor",
];
@@ -71,8 +71,8 @@ pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] =
pub const TICKET_TOOL_NAMES: [&str; 19] = [
"TicketCreate",
"TicketEditItem",
"TicketList",
"TicketShow",
"QueryTicket",
"ShowTicket",
"TicketComment",
"TicketPlan",
"TicketDecision",
@@ -91,8 +91,8 @@ pub const TICKET_TOOL_NAMES: [&str; 19] = [
];
pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 6] = [
"TicketList",
"TicketShow",
"QueryTicket",
"ShowTicket",
"TicketDependencyCheck",
"TicketDoctor",
"TicketRelationQuery",
@@ -121,13 +121,12 @@ backend assigns the id and writes the local Ticket file layout under the configu
const EDIT_ITEM_DESCRIPTION: &str = "Edit a Ticket item through the configured typed Ticket backend. \
This updates the current item title/body and appends an audited item_edit thread event. Intended for \
User/Companion authoring surfaces, not Orchestrator implementation control.";
const LIST_DESCRIPTION: &str = "List Tickets from the configured typed Ticket backend as a \
lightweight bounded overview for selection only. Filter by query (`active`, `all`, a single workflow \
state, or an explicit workflow-state list). Output is short summaries only; use TicketShow before \
routing, closing, planning, or implementation decisions.";
const SHOW_DESCRIPTION: &str = "Show one Ticket by id or exact query through the configured \
typed Ticket backend. Output includes bounded Markdown body, recent thread events, resolution, and \
artifact metadata.";
const LIST_DESCRIPTION: &str = "Query Tickets from the configured typed Ticket backend as a bounded \
overview. The local backend supports workflow-state selection; Workspace-backed Workers replace this \
definition with the richer authoritative text/event/evidence/relation/Objective/time/attention query.";
const SHOW_DESCRIPTION: &str = "Show one Ticket by id or exact query through the configured typed \
Ticket backend. Output includes bounded Markdown body, recent thread events, resolution, and artifact \
metadata; Workspace-backed Workers replace this definition with the richer authoritative evidence projection.";
const COMMENT_DESCRIPTION: &str = "Append a typed Ticket comment event. `body` is Markdown.";
const PLAN_DESCRIPTION: &str = "Append a typed Ticket plan event. `body` is Markdown.";
const DECISION_DESCRIPTION: &str = "Append a typed Ticket decision event. `body` is Markdown.";
@@ -169,8 +168,8 @@ fn base_tool_description(name: &str) -> &'static str {
match name {
"TicketCreate" => CREATE_DESCRIPTION,
"TicketEditItem" => EDIT_ITEM_DESCRIPTION,
"TicketList" => LIST_DESCRIPTION,
"TicketShow" => SHOW_DESCRIPTION,
"QueryTicket" => LIST_DESCRIPTION,
"ShowTicket" => SHOW_DESCRIPTION,
"TicketComment" => COMMENT_DESCRIPTION,
"TicketPlan" => PLAN_DESCRIPTION,
"TicketDecision" => DECISION_DESCRIPTION,
@@ -464,7 +463,7 @@ impl TicketWorkflowStateParam {
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
enum TicketListStateParam {
enum QueryTicketStateParam {
Active,
Planning,
Ready,
@@ -475,7 +474,7 @@ enum TicketListStateParam {
All,
}
impl TicketListStateParam {
impl QueryTicketStateParam {
fn as_list_state(self) -> Option<TicketListState> {
match self {
Self::Planning => Some(TicketListState::Planning),
@@ -490,10 +489,10 @@ impl TicketListStateParam {
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketListParams {
struct QueryTicketParams {
/// State filter. Defaults to active Tickets (all non-closed states). Use `all` to include closed Tickets.
#[serde(default)]
state: Option<TicketListStateParam>,
state: Option<QueryTicketStateParam>,
/// Explicit workflow-state filter list. Cannot be combined with `state`.
#[serde(default)]
states: Option<Vec<TicketWorkflowStateParam>>,
@@ -502,28 +501,28 @@ struct TicketListParams {
limit: Option<usize>,
}
impl TicketListParams {
impl QueryTicketParams {
fn into_query(self) -> Result<(crate::TicketListQuery, String, Option<usize>), TicketError> {
let query = if let Some(states) = self.states {
if self.state.is_some() {
return Err(TicketError::Conflict(
"TicketList accepts either `state` or `states`, not both".to_string(),
"QueryTicket accepts either `state` or `states`, not both".to_string(),
));
}
if states.is_empty() {
return Err(TicketError::Conflict(
"TicketList `states` must include at least one workflow state".to_string(),
"QueryTicket `states` must include at least one workflow state".to_string(),
));
}
crate::TicketListQuery::states(states.into_iter().map(|state| state.into_list_state()))
} else {
match self.state.unwrap_or(TicketListStateParam::Active) {
TicketListStateParam::Active => crate::TicketListQuery::active(),
TicketListStateParam::All => crate::TicketListQuery::all(),
match self.state.unwrap_or(QueryTicketStateParam::Active) {
QueryTicketStateParam::Active => crate::TicketListQuery::active(),
QueryTicketStateParam::All => crate::TicketListQuery::all(),
state => crate::TicketListQuery::state(
state
.as_list_state()
.expect("workflow state list param maps to TicketListState"),
.expect("workflow state list param maps to QueryTicketState"),
),
}
};
@@ -533,7 +532,7 @@ impl TicketListParams {
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketShowParams {
struct ShowTicketParams {
/// Ticket id. Exactly one of `id` or `query` must be provided.
#[serde(default)]
id: Option<String>,
@@ -768,17 +767,17 @@ struct TicketRefOutput {
}
#[derive(Debug, Serialize)]
struct TicketListOutput {
struct QueryTicketOutput {
state_filter: String,
count: usize,
returned: usize,
truncated: bool,
limit: usize,
tickets: Vec<TicketListTicketOutput>,
tickets: Vec<QueryTicketTicketOutput>,
}
#[derive(Debug, Serialize)]
struct TicketListTicketOutput {
struct QueryTicketTicketOutput {
id: String,
title: String,
state: String,
@@ -808,12 +807,12 @@ struct TicketEditItemTool {
}
#[derive(Clone)]
struct TicketListTool {
struct QueryTicketTool {
backend: TicketToolBackend,
}
#[derive(Clone)]
struct TicketShowTool {
struct ShowTicketTool {
backend: TicketToolBackend,
}
@@ -976,28 +975,28 @@ impl Tool for TicketEditItemTool {
}
#[async_trait]
impl Tool for TicketListTool {
impl Tool for QueryTicketTool {
async fn execute(
&self,
input_json: &str,
_ctx: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: TicketListParams = parse_input("TicketList", input_json)?;
let params: QueryTicketParams = parse_input("QueryTicket", input_json)?;
let (filter, state_filter, params_limit) = params
.into_query()
.map_err(|error| backend_error("TicketList", error))?;
.map_err(|error| backend_error("QueryTicket", error))?;
let limit = bounded(params_limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
let tickets = self
.backend
.list(filter)
.map_err(|error| backend_error("TicketList", error))?;
.map_err(|error| backend_error("QueryTicket", error))?;
let count = tickets.len();
let returned_tickets: Vec<_> = tickets
.into_iter()
.take(limit)
.map(ticket_summary_json)
.collect();
let output = TicketListOutput {
let output = QueryTicketOutput {
state_filter: state_filter.to_string(),
count,
returned: returned_tickets.len(),
@@ -1017,13 +1016,13 @@ impl Tool for TicketListTool {
}
#[async_trait]
impl Tool for TicketShowTool {
impl Tool for ShowTicketTool {
async fn execute(
&self,
input_json: &str,
_ctx: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: TicketShowParams = parse_input("TicketShow", input_json)?;
let params: ShowTicketParams = parse_input("ShowTicket", input_json)?;
let query = id_or_query(params.id, params.query)?;
let event_limit = bounded(params.event_limit, DEFAULT_EVENT_LIMIT, MAX_EVENT_LIMIT);
let artifact_limit = bounded(
@@ -1039,7 +1038,7 @@ impl Tool for TicketShowTool {
let ticket = self
.backend
.show(query)
.map_err(|error| backend_error("TicketShow", error))?;
.map_err(|error| backend_error("ShowTicket", error))?;
let summary = format!(
"Ticket {} state {}",
ticket.meta.id,
@@ -1484,9 +1483,9 @@ fn id_or_query(id: Option<String>, query: Option<String>) -> Result<TicketIdOrSl
}
}
fn ticket_summary_json(ticket: TicketSummary) -> TicketListTicketOutput {
fn ticket_summary_json(ticket: TicketSummary) -> QueryTicketTicketOutput {
let hints = ticket_list_hints(&ticket);
TicketListTicketOutput {
QueryTicketTicketOutput {
id: ticket.id,
title: truncate_inline(ticket.title.as_str(), LIST_TITLE_MAX_CHARS),
state: ticket.workflow_state.as_str().to_string(),
@@ -1722,8 +1721,8 @@ fn input_schema(name: &str) -> Value {
match name {
"TicketCreate" => serde_json::to_value(schemars::schema_for!(TicketCreateParams)),
"TicketEditItem" => serde_json::to_value(schemars::schema_for!(TicketEditItemParams)),
"TicketList" => serde_json::to_value(schemars::schema_for!(TicketListParams)),
"TicketShow" => serde_json::to_value(schemars::schema_for!(TicketShowParams)),
"QueryTicket" => serde_json::to_value(schemars::schema_for!(QueryTicketParams)),
"ShowTicket" => serde_json::to_value(schemars::schema_for!(ShowTicketParams)),
"TicketComment" | "TicketPlan" | "TicketDecision" | "TicketImplementationReport" => {
serde_json::to_value(schemars::schema_for!(TicketThreadEventParams))
}
@@ -1769,8 +1768,8 @@ macro_rules! impl_from_backend {
impl_from_backend!(TicketCreateTool);
impl_from_backend!(TicketEditItemTool);
impl_from_backend!(TicketListTool);
impl_from_backend!(TicketShowTool);
impl_from_backend!(QueryTicketTool);
impl_from_backend!(ShowTicketTool);
impl_from_backend!(TicketCommentTool);
impl_from_backend!(TicketPlanTool);
impl_from_backend!(TicketDecisionTool);
@@ -1793,8 +1792,8 @@ pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition
vec![
tool_definition::<TicketCreateTool>("TicketCreate", backend.clone()),
tool_definition::<TicketEditItemTool>("TicketEditItem", backend.clone()),
tool_definition::<TicketListTool>("TicketList", backend.clone()),
tool_definition::<TicketShowTool>("TicketShow", backend.clone()),
tool_definition::<QueryTicketTool>("QueryTicket", backend.clone()),
tool_definition::<ShowTicketTool>("ShowTicket", backend.clone()),
tool_definition::<TicketCommentTool>("TicketComment", backend.clone()),
tool_definition::<TicketPlanTool>("TicketPlan", backend.clone()),
tool_definition::<TicketDecisionTool>("TicketDecision", backend.clone()),
@@ -1861,8 +1860,8 @@ mod tests {
assert_eq!(
TICKET_READ_ONLY_TOOL_NAMES,
[
"TicketList",
"TicketShow",
"QueryTicket",
"ShowTicket",
"TicketDependencyCheck",
"TicketDoctor",
"TicketRelationQuery",
@@ -1941,8 +1940,8 @@ mod tests {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let create = tool_by_name(backend.clone(), "TicketCreate");
let list = tool_by_name(backend.clone(), "TicketList");
let show = tool_by_name(backend.clone(), "TicketShow");
let list = tool_by_name(backend.clone(), "QueryTicket");
let show = tool_by_name(backend.clone(), "ShowTicket");
let doctor = tool_by_name(backend.clone(), "TicketDoctor");
let created = create
@@ -2004,7 +2003,7 @@ mod tests {
async fn ticket_list_tool_truncates_long_titles_and_hints() {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let list = tool_by_name(backend.clone(), "TicketList");
let list = tool_by_name(backend.clone(), "QueryTicket");
let mut ticket = NewTicket::new(format!(
"Long Title {}",
"x".repeat(LIST_TITLE_MAX_CHARS + 40)
@@ -2032,7 +2031,7 @@ mod tests {
async fn ticket_list_tool_default_and_max_limits_are_bounded() {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let list = tool_by_name(backend.clone(), "TicketList");
let list = tool_by_name(backend.clone(), "QueryTicket");
for index in 0..(MAX_LIST_LIMIT + 5) {
backend
.create(NewTicket::new(format!("Ticket {index:03}")))
@@ -2083,7 +2082,7 @@ mod tests {
async fn ticket_list_tool_caps_all_and_closed_default_listing() {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let list = tool_by_name(backend.clone(), "TicketList");
let list = tool_by_name(backend.clone(), "QueryTicket");
for index in 0..(DEFAULT_LIST_LIMIT + 3) {
let mut ticket = NewTicket::new(format!("Closed Ticket {index:03}"));
ticket.workflow_state = Some(TicketWorkflowState::Closed);
@@ -2141,7 +2140,7 @@ mod tests {
async fn ticket_list_tool_accepts_multi_state_list_and_rejects_mixed_filters() {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let list = tool_by_name(backend.clone(), "TicketList");
let list = tool_by_name(backend.clone(), "QueryTicket");
let planning = backend.create(NewTicket::new("Planning Ticket")).unwrap();
let mut ready_input = NewTicket::new("Ready Ticket");
ready_input.workflow_state = Some(TicketWorkflowState::Ready);
@@ -2188,7 +2187,7 @@ mod tests {
async fn ticket_list_tool_omits_body_thread_artifact_and_resolution_content() {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let list = tool_by_name(backend.clone(), "TicketList");
let list = tool_by_name(backend.clone(), "QueryTicket");
let close = tool_by_name(backend.clone(), "TicketClose");
let body_secret = "ITEM_BODY_SECRET_DO_NOT_LIST";
let thread_secret = "THREAD_SECRET_DO_NOT_LIST";
@@ -2325,7 +2324,7 @@ mod tests {
let record = tool_by_name(backend.clone(), "TicketRelationRecord");
let remove = tool_by_name(backend.clone(), "TicketRelationRemove");
let query = tool_by_name(backend.clone(), "TicketRelationQuery");
let show = tool_by_name(backend.clone(), "TicketShow");
let show = tool_by_name(backend.clone(), "ShowTicket");
let recorded = record
.execute(
@@ -2785,7 +2784,7 @@ mod tests {
#[tokio::test]
async fn ticket_show_requires_exactly_one_identifier() {
let temp = TempDir::new().unwrap();
let show = tool_by_name(backend(&temp), "TicketShow");
let show = tool_by_name(backend(&temp), "ShowTicket");
let error = show
.execute(
&json!({ "id": "a", "query": "b" }).to_string(),
+2 -2
View File
@@ -2045,7 +2045,7 @@ impl DashboardApp {
TicketRoleLaunchContext::new(current_workspace_root(), TicketRole::Intake);
context.ticket = Some(TicketRef::id(ticket_id.clone()));
context.user_instruction = Some(format!(
"Continue Intake for existing Ticket {ticket_id}. Do not create a duplicate Ticket unless the user explicitly requests one. Read TicketShow body/thread/artifacts before making routing or requirements decisions."
"Continue Intake for existing Ticket {ticket_id}. Do not create a duplicate Ticket unless the user explicitly requests one. Read ShowTicket body/thread/artifacts before making routing or requirements decisions."
));
let store = match PanelRegistryStore::default_for_workspace(&context.workspace_root) {
Ok(store) => store,
@@ -3925,7 +3925,7 @@ fn build_ready_ticket_refinement_thread_body(ticket_id: &str, instruction: &str)
fn build_ready_ticket_refinement_launch_instruction(ticket_id: &str, instruction: &str) -> String {
format!(
"Continue Ticket Intake / requirements sync for existing Ticket {ticket_id}. The Panel has returned the Ticket from ready to planning; do not queue the Ticket, do not route implementation, and do not create a duplicate unless the user explicitly asks for one. Read TicketShow body/thread/artifacts before making requirements or readiness decisions.\n\nUser refinement instruction:\n\n{instruction}"
"Continue Ticket Intake / requirements sync for existing Ticket {ticket_id}. The Panel has returned the Ticket from ready to planning; do not queue the Ticket, do not route implementation, and do not create a duplicate unless the user explicitly asks for one. Read ShowTicket body/thread/artifacts before making requirements or readiness decisions.\n\nUser refinement instruction:\n\n{instruction}"
)
}
+80 -80
View File
@@ -26,36 +26,45 @@ impl WorkspaceHttpObjectiveBackend {
Self { client }
}
async fn list(&self, input: ObjectiveListInput) -> Result<ToolOutput, ToolError> {
let mut url = format!(
"/api/w/{}/objectives",
async fn list(&self, input: QueryObjectiveInput) -> Result<ToolOutput, ToolError> {
let url = format!(
"/api/w/{}/objectives/query",
self.client.workspace_id().unwrap_or_default()
);
if let Some(limit) = input.limit {
url.push_str(&format!("?limit={}", limit.min(1000)));
}
let response = get_json::<ObjectiveListResponse>(self.client.as_ref(), &url)
.await
.map_err(backend_error)?;
let count = response.items.len();
let response = send_json::<QueryObjectiveInput, serde_json::Value>(
self.client.as_ref(),
reqwest::Method::POST,
&url,
&input,
)
.await
.map_err(backend_error)?;
Ok(ToolOutput {
summary: format!("Listed {count} objective(s)"),
summary: "Queried Objectives".to_string(),
content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?),
attachments: Vec::new(),
})
}
async fn show(&self, input: ObjectiveShowInput) -> Result<ToolOutput, ToolError> {
let id = validate_id(&input.id, "ObjectiveShow")?;
let url = self.objective_url(id);
let response = get_json::<ObjectiveDetail>(self.client.as_ref(), &url)
.await
.map_err(backend_error)?;
Ok(objective_output(
format!("Read objective {}", response.id),
response,
)?)
async fn show(&self, input: ShowObjectiveInput) -> Result<ToolOutput, ToolError> {
let id = validate_id(&input.id, "ShowObjective")?;
let url = format!("{}/show", self.objective_url(id));
let response = send_json::<ObjectiveShowRequest, serde_json::Value>(
self.client.as_ref(),
reqwest::Method::POST,
&url,
&ObjectiveShowRequest {
event_limit: input.event_limit,
event_cursor: input.event_cursor,
},
)
.await
.map_err(backend_error)?;
Ok(ToolOutput {
summary: format!("Read objective {id}"),
content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?),
attachments: Vec::new(),
})
}
async fn create(&self, input: ObjectiveCreateInput) -> Result<ToolOutput, ToolError> {
@@ -195,13 +204,6 @@ fn backend_error(error: WorkspaceObjectiveBackendError) -> ToolError {
ToolError::ExecutionFailed(error.to_string())
}
async fn get_json<T: for<'de> Deserialize<'de>>(
client: &dyn WorkspaceClient,
path: &str,
) -> Result<T, WorkspaceObjectiveBackendError> {
decode_response(client.execute(WorkspaceRequest::get(path))?)
}
async fn send_json<B: Serialize, T: for<'de> Deserialize<'de>>(
client: &dyn WorkspaceClient,
method: reqwest::Method,
@@ -270,14 +272,14 @@ pub fn workspace_http_objective_tools(client: Arc<dyn WorkspaceClient>) -> Vec<T
let backend = WorkspaceHttpObjectiveBackend::new(client);
vec![
objective_tool(
"ObjectiveList",
"QueryObjective",
LIST_DESCRIPTION,
list_schema(),
backend.clone(),
ObjectiveOperation::List,
),
objective_tool(
"ObjectiveShow",
"ShowObjective",
SHOW_DESCRIPTION,
show_schema(),
backend.clone(),
@@ -367,11 +369,11 @@ impl Tool for WorkspaceHttpObjectiveTool {
) -> Result<ToolOutput, ToolError> {
match self.operation {
ObjectiveOperation::List => {
let input = parse_input::<ObjectiveListInput>(input_json)?;
let input = parse_input::<QueryObjectiveInput>(input_json)?;
self.backend.list(input).await
}
ObjectiveOperation::Show => {
let input = parse_input::<ObjectiveShowInput>(input_json)?;
let input = parse_input::<ShowObjectiveInput>(input_json)?;
self.backend.show(input).await
}
ObjectiveOperation::Create => {
@@ -402,10 +404,8 @@ fn parse_input<T: for<'de> Deserialize<'de>>(input: &str) -> Result<T, ToolError
serde_json::from_str(input).map_err(|error| ToolError::InvalidArgument(error.to_string()))
}
const LIST_DESCRIPTION: &str =
"List Objective records through Backend Workspace API authority as bounded summaries.";
const SHOW_DESCRIPTION: &str =
"Show one Objective record by canonical id through Backend Workspace API authority.";
const LIST_DESCRIPTION: &str = "Query authoritative Objectives with bounded typed filters, stable snippets, linked-Ticket context, and cursor metadata.";
const SHOW_DESCRIPTION: &str = "Show one authoritative Objective with its revision, full linked-Ticket context, bounded body, and paged event metadata.";
const CREATE_DESCRIPTION: &str =
"Create an Objective record through Backend Workspace API authority.";
const EDIT_DESCRIPTION: &str =
@@ -422,13 +422,29 @@ fn list_schema() -> serde_json::Value {
"type":"object",
"additionalProperties": false,
"properties":{
"limit":{"type":["integer","null"],"minimum":0,"maximum":1000}
"query":{"type":["string","null"]},
"states":{"type":"array","items":{"type":"string"},"default":[]},
"linked_ticket_id":{"type":["string","null"]},
"updated_after":{"type":["string","null"]},
"updated_before":{"type":["string","null"]},
"sort":{"type":["string","null"],"enum":["relevance","updated_desc","created_desc","title",null]},
"limit":{"type":["integer","null"],"minimum":1,"maximum":100},
"cursor":{"type":["string","null"]}
}
})
}
fn show_schema() -> serde_json::Value {
id_schema(&["id"])
json!({
"type":"object",
"additionalProperties": false,
"required":["id"],
"properties":{
"id":{"type":"string"},
"event_limit":{"type":["integer","null"],"minimum":1,"maximum":50},
"event_cursor":{"type":["string","null"]}
}
})
}
fn create_schema() -> serde_json::Value {
@@ -480,17 +496,6 @@ fn unlink_ticket_schema() -> serde_json::Value {
id_ticket_schema(&["id", "ticket_id"])
}
fn id_schema(required: &[&str]) -> serde_json::Value {
json!({
"type":"object",
"additionalProperties": false,
"required": required,
"properties":{
"id":{"type":"string"}
}
})
}
fn id_ticket_schema(required: &[&str]) -> serde_json::Value {
json!({
"type":"object",
@@ -503,14 +508,30 @@ fn id_ticket_schema(required: &[&str]) -> serde_json::Value {
})
}
#[derive(Debug, Deserialize)]
struct ObjectiveListInput {
#[derive(Debug, Serialize, Deserialize)]
struct QueryObjectiveInput {
query: Option<String>,
#[serde(default)]
states: Vec<String>,
linked_ticket_id: Option<String>,
updated_after: Option<String>,
updated_before: Option<String>,
sort: Option<String>,
limit: Option<usize>,
cursor: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ObjectiveShowInput {
struct ShowObjectiveInput {
id: String,
event_limit: Option<usize>,
event_cursor: Option<String>,
}
#[derive(Debug, Serialize)]
struct ObjectiveShowRequest {
event_limit: Option<usize>,
event_cursor: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -574,30 +595,6 @@ fn default_state() -> String {
"active".to_string()
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
struct ObjectiveListResponse {
items: Vec<ObjectiveSummary>,
invalid_records: Vec<InvalidProjectRecord>,
record_authority: String,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
struct InvalidProjectRecord {
label: String,
reason: String,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
struct ObjectiveSummary {
id: String,
title: String,
state: String,
updated_at: Option<String>,
summary: String,
linked_tickets: Vec<String>,
record_source: String,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
struct ObjectiveDetail {
id: String,
@@ -637,10 +634,10 @@ mod tests {
"ObjectiveCreate",
"ObjectiveEdit",
"ObjectiveLinkTicket",
"ObjectiveList",
"ObjectiveSetState",
"ObjectiveShow",
"ObjectiveUnlinkTicket",
"QueryObjective",
"ShowObjective",
]
);
}
@@ -648,9 +645,12 @@ mod tests {
#[test]
fn objective_tool_schemas_are_bounded_and_mutation_scoped() {
let list = list_schema();
assert_eq!(list["properties"]["limit"]["maximum"], 1000);
assert_eq!(list["properties"]["limit"]["maximum"], 100);
assert!(list["properties"]["cursor"].is_object());
assert!(list["properties"]["linked_ticket_id"].is_object());
let show = show_schema();
assert_eq!(show["required"][0], "id");
assert_eq!(show["properties"]["event_limit"]["maximum"], 50);
let create = create_schema();
assert_eq!(create["required"][0], "title");
let edit = edit_schema();
+298 -21
View File
@@ -9,6 +9,10 @@ use std::{
sync::Arc,
};
use async_trait::async_trait;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use ticket::{
LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent,
NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult,
@@ -25,8 +29,238 @@ use crate::feature::{
FeatureDescriptor, FeatureDiagnostic, FeatureInstallContext, FeatureInstallError,
FeatureInstructionContribution, FeatureInstructionDeclaration, FeatureInstructionId,
FeatureModule, ServiceDeclaration, ServiceId, ToolContribution, ToolDeclaration,
ToolDefinition,
};
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
use llm_engine::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
#[derive(Clone, Copy)]
enum WorkspaceTicketReadKind {
Query,
Show,
}
impl WorkspaceTicketReadKind {
fn name(self) -> &'static str {
match self {
Self::Query => "QueryTicket",
Self::Show => "ShowTicket",
}
}
fn description(self) -> &'static str {
match self {
Self::Query => {
"Query authoritative Workspace Tickets with bounded typed filters, stable snippets, evidence summaries, and cursor metadata."
}
Self::Show => {
"Show one authoritative Workspace Ticket with its item revision, paged thread, links, implementation reports, and current Merge Request review evidence."
}
}
}
fn schema(self) -> Value {
match self {
Self::Query => serde_json::to_value(schemars::schema_for!(WorkspaceQueryTicketInput))
.expect("QueryTicket schema serializes"),
Self::Show => serde_json::to_value(schemars::schema_for!(WorkspaceShowTicketInput))
.expect("ShowTicket schema serializes"),
}
}
}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
enum WorkspaceTicketStateFilter {
Planning,
Ready,
Queued,
Inprogress,
Done,
Closed,
}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
enum WorkspaceTicketEvidenceFilter {
ImplementationReport,
ImplementationReportAfterRescope,
MergeRequest,
Commit,
ApprovedReview,
}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
enum WorkspaceTicketReviewFilter {
None,
Pending,
Approved,
RequestChanges,
UnresolvedChanges,
}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
enum WorkspaceTicketAttentionFilter {
DoneNotClosed,
ImplementationReportNotClosed,
ReportAfterRescope,
UnresolvedReview,
MissingCommit,
Blocked,
Unblocked,
Ready,
AwaitingReview,
UnresolvedChanges,
StaleAfterRescope,
MissingEvidence,
}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
enum WorkspaceTicketRelationFilter {
DependsOn,
Blocks,
Related,
Supersedes,
DuplicateOf,
}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
enum WorkspaceTicketSort {
Relevance,
UpdatedDesc,
CreatedDesc,
Priority,
Title,
}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
struct WorkspaceQueryTicketInput {
/// Full-text match over Ticket title, item body, and bounded thread excerpts.
query: Option<String>,
/// Exact workflow states. Empty means every state.
#[serde(default)]
states: Vec<WorkspaceTicketStateFilter>,
/// Exact typed event kinds that must occur in the bounded thread window.
#[serde(default)]
event_kinds: Vec<String>,
/// Required evidence kinds: implementation_report, implementation_report_after_rescope,
/// merge_request, commit, or approved_review.
#[serde(default)]
evidence: Vec<WorkspaceTicketEvidenceFilter>,
/// Current authoritative Merge Request review status: none, pending, approved,
/// request_changes, or unresolved_changes.
review_status: Option<WorkspaceTicketReviewFilter>,
/// Attention filters include done_not_closed, implementation_report_not_closed,
/// report_after_rescope, unresolved_review, missing_commit, blocked, and unblocked.
#[serde(default)]
attention: Vec<WorkspaceTicketAttentionFilter>,
related_ticket_id: Option<String>,
relation_kind: Option<WorkspaceTicketRelationFilter>,
linked_objective_id: Option<String>,
updated_after: Option<String>,
updated_before: Option<String>,
/// relevance (default when query is present), updated_desc, created_desc,
/// priority, or title.
sort: Option<WorkspaceTicketSort>,
/// Page size; bounded by the Backend to 1..=100.
limit: Option<usize>,
/// Opaque cursor returned by a prior QueryTicket page.
cursor: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
struct WorkspaceShowTicketInput {
id: String,
/// Most-recent thread entries to return, bounded by the Backend to 1..=50.
event_limit: Option<usize>,
/// Opaque event cursor returned by a prior ShowTicket page.
event_cursor: Option<String>,
}
#[derive(Clone)]
struct WorkspaceTicketReadTool {
client: Arc<dyn WorkspaceClient>,
kind: WorkspaceTicketReadKind,
}
#[async_trait]
impl Tool for WorkspaceTicketReadTool {
async fn execute(
&self,
input: &str,
_context: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let workspace_id = self.client.workspace_id().ok_or_else(|| {
ToolError::InvalidArgument("Workspace Ticket reads require workspace identity".into())
})?;
let (path, body) = match self.kind {
WorkspaceTicketReadKind::Query => {
let input: WorkspaceQueryTicketInput = serde_json::from_str(&input)
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
(
format!("/api/w/{workspace_id}/tickets/query"),
serde_json::to_value(input)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?,
)
}
WorkspaceTicketReadKind::Show => {
let input: WorkspaceShowTicketInput = serde_json::from_str(&input)
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
if input.id.trim().is_empty() {
return Err(ToolError::InvalidArgument(
"ShowTicket.id must not be empty".into(),
));
}
let path = format!("/api/w/{workspace_id}/tickets/{}/show", input.id.trim());
let body = json!({
"event_limit": input.event_limit,
"event_cursor": input.event_cursor,
});
(path, body)
}
};
let response = self
.client
.execute(WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
path,
serde_json::to_string(&body)
.map_err(|error| ToolError::Internal(error.to_string()))?,
))
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if !response.is_success() {
return Err(ToolError::ExecutionFailed(format!(
"Workspace Ticket API returned HTTP {}: {}",
response.status, response.body
)));
}
Ok(ToolOutput {
summary: self.kind.name().to_string(),
content: Some(response.body),
attachments: Vec::new(),
})
}
}
fn workspace_ticket_read_definition(
client: Arc<dyn WorkspaceClient>,
kind: WorkspaceTicketReadKind,
) -> ToolDefinition {
Arc::new(move || {
let meta = ToolMeta::new(kind.name())
.description(kind.description())
.input_schema(kind.schema());
let tool: Arc<dyn Tool> = Arc::new(WorkspaceTicketReadTool {
client: client.clone(),
kind,
});
(meta, tool)
})
}
const FEATURE_ID: &str = "ticket";
const FEATURE_NAME: &str = "Ticket tools";
@@ -142,14 +376,7 @@ impl TicketFeatureAccess {
}
}
const READ_ONLY_TOOL_NAMES: &[&str] = &[
"TicketList",
"TicketShow",
"TicketDependencyCheck",
"TicketDoctor",
"TicketRelationQuery",
"TicketOrchestrationPlanQuery",
];
const READ_ONLY_TOOL_NAMES: &[&str] = &["QueryTicket", "ShowTicket"];
const AUTHORING_TOOL_NAMES: &[&str] = &[
"TicketCreate",
@@ -168,31 +395,25 @@ const INTAKE_TOOL_NAMES: &[&str] = &["TicketIntakeReady"];
const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
"TicketCreate",
"TicketEditItem",
"TicketList",
"TicketShow",
"QueryTicket",
"ShowTicket",
"TicketComment",
"TicketQueue",
"TicketClose",
"TicketDependencyCheck",
"TicketDoctor",
"TicketRelationRecord",
"TicketRelationRemove",
"TicketRelationQuery",
"TicketOrchestrationPlanQuery",
];
#[cfg(test)]
const WORKFLOW_TOOL_NAMES: &[&str] = &[
"TicketList",
"TicketShow",
"QueryTicket",
"ShowTicket",
"TicketComment",
"TicketWorkflowState",
"TicketClose",
"TicketDependencyCheck",
"TicketDoctor",
"TicketRelationRecord",
"TicketRelationRemove",
"TicketRelationQuery",
"TicketOrchestrationPlanRecord",
"TicketOrchestrationPlanQuery",
];
@@ -200,9 +421,11 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[
const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[
"TicketWorkflowState",
"TicketClose",
"TicketDependencyCheck",
"TicketRelationRecord",
"TicketRelationRemove",
"TicketOrchestrationPlanRecord",
"TicketOrchestrationPlanQuery",
];
#[derive(Clone, Debug)]
@@ -413,6 +636,10 @@ impl FeatureModule for TicketFeature {
ticket_workflow_instruction(),
))?;
let allowed_tool_names = self.enabled_tool_names();
let workspace_client = match &self.backend {
TicketFeatureBackend::WorkspaceClient(client) => Some(client.clone()),
TicketFeatureBackend::Local { .. } => None,
};
let mut tools = context.tools();
for definition in ticket_tools(backend) {
let (meta, _) = definition();
@@ -423,6 +650,15 @@ impl FeatureModule for TicketFeature {
{
continue;
}
let definition = match (name.as_str(), workspace_client.as_ref()) {
("QueryTicket", Some(client)) => {
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)
}
("ShowTicket", Some(client)) => {
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Show)
}
_ => definition,
};
tools.register(ToolContribution::new(name, definition))?;
}
if let TicketFeatureBackend::WorkspaceClient(client) = &self.backend {
@@ -1044,6 +1280,47 @@ mod tests {
.expect("tool exists")
}
#[test]
fn workspace_ticket_reads_expose_bounded_query_and_show_contracts_without_legacy_aliases() {
let client: Arc<dyn WorkspaceClient> = Arc::new(
crate::worker::TestWorkspaceHttpClient::new("workspace", "http://backend"),
);
let (query, _) =
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)();
assert_eq!(query.name, "QueryTicket");
assert!(query.input_schema["properties"]["evidence"].is_object());
assert!(query.input_schema["properties"]["attention"].is_object());
assert!(query.input_schema["properties"]["cursor"].is_object());
let query_schema = serde_json::to_string(&query.input_schema).unwrap();
assert!(query_schema.contains("done_not_closed"));
assert!(query_schema.contains("request_changes"));
assert!(query_schema.contains("created_desc"));
assert!(
query_schema.len() < 8_000,
"QueryTicket schema grew unexpectedly"
);
let (show, _) = workspace_ticket_read_definition(client, WorkspaceTicketReadKind::Show)();
assert_eq!(show.name, "ShowTicket");
assert!(show.input_schema["properties"]["event_limit"].is_object());
let tool_names = TicketFeatureAccess::workspace_authoring().tool_names();
assert_eq!(tool_names.len(), 9);
assert!(
tool_names.len() < 13,
"authoring catalog must stay below the prior broad catalog"
);
let workflow_names = TicketFeatureAccess::workflow().tool_names();
assert_eq!(workflow_names.len(), 10);
assert!(
workflow_names.len() < 12,
"workflow catalog must stay below the prior broad catalog"
);
assert_eq!(TicketFeatureAccess::review().tool_names().len(), 2);
assert!(tool_names.contains(&"QueryTicket"));
assert!(tool_names.contains(&"ShowTicket"));
assert!(!tool_names.contains(&"TicketList"));
assert!(!tool_names.contains(&"TicketShow"));
}
#[test]
fn descriptor_declares_ticket_tools() {
let temp = TempDir::new().unwrap();
@@ -1200,8 +1477,8 @@ language = "Japanese"
let descriptor_description = descriptor
.tools
.iter()
.find(|tool| tool.name == "TicketShow")
.expect("TicketShow declared")
.find(|tool| tool.name == "ShowTicket")
.expect("ShowTicket declared")
.description
.clone();
assert!(descriptor_description.contains("Ticket record language: Japanese"));
@@ -1214,7 +1491,7 @@ language = "Japanese"
assert_eq!(pending_tools.len(), READ_ONLY_TOOL_NAMES.len());
assert_eq!(report.reports[0].installed_tools, READ_ONLY_TOOL_NAMES);
let description = pending_tool_description(&pending_tools, "TicketShow");
let description = pending_tool_description(&pending_tools, "ShowTicket");
assert!(description.contains("Ticket record language: Japanese"));
assert!(description.contains("distinct from worker.language"));
assert!(description.contains("Preserve protocol literals"));
+1 -1
View File
@@ -132,7 +132,7 @@ mod tests {
let request = ShutdownAfterIdleRequest::default();
let hook = TicketIntakeReadyShutdownHook::new(request.clone(), true);
hook.observe_tool_result(&tool_result("TicketShow", false));
hook.observe_tool_result(&tool_result("ShowTicket", false));
assert!(!request.is_requested());
}
File diff suppressed because it is too large Load Diff
+211
View File
@@ -49,9 +49,11 @@ pub struct TicketDetail {
pub id: String,
pub title: String,
pub state: String,
pub readiness: Option<String>,
pub priority: String,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub item_revision: String,
pub queued_by: Option<String>,
pub queued_at: Option<String>,
pub assignee: Option<String>,
@@ -62,9 +64,15 @@ pub struct TicketDetail {
pub body_truncated: bool,
pub event_count: usize,
pub events: Vec<TicketEventDetail>,
pub event_page: QueryPage,
pub artifact_count: usize,
pub artifacts: Vec<String>,
pub relations: TicketRelationView,
pub linked_objectives: Vec<ObjectiveLinkSummary>,
pub implementation_reports: Vec<TicketEvidenceEvent>,
pub current_assignment: Option<TicketAssignmentSummary>,
pub merge_request: Option<TicketMergeRequestSummary>,
pub evidence: TicketEvidenceSummary,
pub resolution: Option<String>,
pub record_source: String,
}
@@ -73,6 +81,7 @@ pub struct TicketDetail {
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketEventDetail {
pub sequence: usize,
pub event_ref: String,
pub kind: String,
pub author: Option<String>,
pub at: Option<String>,
@@ -83,6 +92,8 @@ pub struct TicketEventDetail {
pub state_field: Option<String>,
pub heading: Option<String>,
pub body: Option<String>,
pub attributes: std::collections::BTreeMap<String, String>,
pub references: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -185,11 +196,197 @@ impl From<ticket::TicketRelationView> for TicketRelationView {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct QueryPage {
pub limit: usize,
pub returned: usize,
pub has_more: bool,
pub next_cursor: Option<String>,
pub sort: String,
pub source_limit: Option<usize>,
pub source_truncated: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct ObjectiveLinkSummary {
pub id: String,
pub title: String,
pub state: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketEvidenceEvent {
pub event_ref: String,
pub sequence: usize,
pub kind: String,
pub at: Option<String>,
pub author: Option<String>,
pub excerpt: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketAssignmentSummary {
pub assignment_id: String,
pub runtime_id: String,
pub worker_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketMergeRequestSummary {
pub merge_request_id: String,
pub state: String,
pub review_status: String,
pub revision_id: String,
pub base_commit: String,
pub head_commit: String,
pub changed_paths: Vec<String>,
pub updated_at: String,
pub review_submitted_at: Option<String>,
pub review_excerpt: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketEvidenceSummary {
pub has_implementation_report: bool,
pub implementation_report_after_rescope: bool,
pub has_merge_request: bool,
pub has_commit: bool,
pub review_status: Option<String>,
pub approved: bool,
pub unresolved_request_changes: bool,
pub complete_for_integration: bool,
pub missing: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketQueryRequest {
pub query: Option<String>,
#[serde(default)]
pub states: Vec<String>,
#[serde(default)]
pub event_kinds: Vec<String>,
#[serde(default)]
pub evidence: Vec<String>,
pub review_status: Option<String>,
#[serde(default)]
pub attention: Vec<String>,
pub related_ticket_id: Option<String>,
pub relation_kind: Option<String>,
pub linked_objective_id: Option<String>,
pub updated_after: Option<String>,
pub updated_before: Option<String>,
pub sort: Option<String>,
pub limit: Option<usize>,
pub cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketQueryItem {
pub id: String,
pub title: String,
pub state: String,
pub readiness: Option<String>,
pub priority: String,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub item_revision: String,
pub workspace_action_priority: String,
pub matched_fields: Vec<String>,
pub snippet: Option<String>,
pub matching_event: Option<TicketEvidenceEvent>,
pub linked_objective_ids: Vec<String>,
pub relation_count: usize,
pub blocker_count: usize,
pub unresolved_blocker_count: usize,
pub unresolved_review_count: usize,
pub evidence: TicketEvidenceSummary,
pub merge_request: Option<TicketMergeRequestSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketQueryResponse {
pub items: Vec<TicketQueryItem>,
pub page: QueryPage,
pub record_authority: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketShowRequest {
pub event_limit: Option<usize>,
pub event_cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct ObjectiveQueryRequest {
pub query: Option<String>,
#[serde(default)]
pub states: Vec<String>,
pub linked_ticket_id: Option<String>,
pub updated_after: Option<String>,
pub updated_before: Option<String>,
pub sort: Option<String>,
pub limit: Option<usize>,
pub cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ObjectiveQueryItem {
pub id: String,
pub title: String,
pub state: String,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub matched_fields: Vec<String>,
pub snippet: Option<String>,
pub linked_ticket_count: usize,
pub linked_tickets: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ObjectiveQueryResponse {
pub items: Vec<ObjectiveQueryItem>,
pub page: QueryPage,
pub record_authority: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct ObjectiveShowRequest {
pub event_limit: Option<usize>,
pub event_cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct ObjectiveEventDetail {
pub event_ref: String,
pub kind: String,
pub body: Option<String>,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ObjectiveLinkedTicketSummary {
pub id: String,
pub title: String,
pub state: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ObjectiveSummary {
pub id: String,
pub title: String,
pub state: String,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub summary: String,
pub linked_tickets: Vec<String>,
@@ -201,12 +398,16 @@ pub struct ObjectiveDetail {
pub id: String,
pub title: String,
pub state: String,
pub revision: String,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub linked_tickets: Vec<String>,
pub linked_ticket_summaries: Vec<ObjectiveLinkedTicketSummary>,
pub resources: Vec<ObjectiveResourceSummary>,
pub body: String,
pub body_truncated: bool,
pub events: Vec<ObjectiveEventDetail>,
pub event_page: QueryPage,
pub record_source: String,
}
@@ -227,7 +428,17 @@ pub fn ticket_api_typescript() -> String {
InvalidProjectRecord::decl(&config),
TicketSummary::decl(&config),
TicketListResponse::decl(&config),
QueryPage::decl(&config),
TicketEventDetail::decl(&config),
ObjectiveLinkSummary::decl(&config),
TicketEvidenceEvent::decl(&config),
TicketAssignmentSummary::decl(&config),
TicketMergeRequestSummary::decl(&config),
TicketEvidenceSummary::decl(&config),
TicketQueryRequest::decl(&config),
TicketQueryItem::decl(&config),
TicketQueryResponse::decl(&config),
TicketShowRequest::decl(&config),
TicketRelation::decl(&config),
DerivedTicketRelation::decl(&config),
TicketRelationBlocker::decl(&config),
+112 -1
View File
@@ -92,7 +92,10 @@ use crate::observation::{
RuntimeObservationSource, RuntimeObservationSourceConfig,
};
use crate::profile_settings::UpdateWorkspaceMetadataRequest;
use crate::records::{ObjectiveDetail, ProjectRecordList, TicketDetail};
use crate::records::{
ObjectiveDetail, ObjectiveQueryRequest, ObjectiveQueryResponse, ObjectiveShowRequest,
ProjectRecordList, TicketDetail, TicketQueryRequest, TicketQueryResponse, TicketShowRequest,
};
use crate::repositories::{
ConfiguredRepository, MergeTargetObservation, RepositoryListProjection, RepositoryLogRead,
RepositoryLookupError, RepositoryRegistryReader, RepositorySummary,
@@ -1232,6 +1235,10 @@ pub fn build_router(api: WorkspaceApi) -> Router {
"/api/w/{workspace_id}/tickets",
get(scoped_list_tickets).post(scoped_create_ticket_record),
)
.route(
"/api/w/{workspace_id}/tickets/query",
post(scoped_query_tickets),
)
.route(
"/api/w/{workspace_id}/memory",
get(scoped_get_memory_document),
@@ -1368,6 +1375,10 @@ pub fn build_router(api: WorkspaceApi) -> Router {
"/api/w/{workspace_id}/tickets/{id}",
get(scoped_get_ticket).patch(scoped_edit_ticket_item),
)
.route(
"/api/w/{workspace_id}/tickets/{id}/show",
post(scoped_show_ticket),
)
.route(
"/api/w/{workspace_id}/tickets/{id}/assignment",
get(scoped_get_ticket_worker_assignment)
@@ -1399,11 +1410,19 @@ pub fn build_router(api: WorkspaceApi) -> Router {
"/api/w/{workspace_id}/objectives",
get(scoped_list_objectives).post(scoped_create_objective),
)
.route(
"/api/w/{workspace_id}/objectives/query",
post(scoped_query_objectives),
)
.route("/api/objectives/{id}", get(get_objective))
.route(
"/api/w/{workspace_id}/objectives/{objective_id}",
get(scoped_get_objective).patch(scoped_edit_objective),
)
.route(
"/api/w/{workspace_id}/objectives/{objective_id}/show",
post(scoped_show_objective),
)
.route(
"/api/w/{workspace_id}/objectives/{objective_id}/state",
post(scoped_set_objective_state),
@@ -2643,6 +2662,24 @@ async fn scoped_get_ticket(
get_ticket(State(api), AxumPath(path.id)).await
}
async fn scoped_query_tickets(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
Json(query): Json<TicketQueryRequest>,
) -> ApiResult<Json<TicketQueryResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?;
Ok(Json(api.authority.query_tickets(query)?))
}
async fn scoped_show_ticket(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRecordPath>,
Json(query): Json<TicketShowRequest>,
) -> ApiResult<Json<TicketDetail>> {
validate_workspace_scope(&api, &path.workspace_id)?;
Ok(Json(api.authority.show_ticket(&path.id, query)?))
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
struct TicketWorkerAssignmentResponse {
workspace_id: String,
@@ -5581,6 +5618,26 @@ async fn scoped_get_objective(
get_objective(State(api), AxumPath(path.objective_id)).await
}
async fn scoped_query_objectives(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
Json(query): Json<ObjectiveQueryRequest>,
) -> ApiResult<Json<ObjectiveQueryResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?;
Ok(Json(api.authority.query_objectives(query)?))
}
async fn scoped_show_objective(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedObjectivePath>,
Json(query): Json<ObjectiveShowRequest>,
) -> ApiResult<Json<ObjectiveDetail>> {
validate_workspace_scope(&api, &path.workspace_id)?;
Ok(Json(
api.authority.show_objective(&path.objective_id, query)?,
))
}
async fn scoped_create_objective(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
@@ -17703,10 +17760,64 @@ mod tests {
.await;
assert_eq!(scoped_objective["id"], "00000000001J3");
assert_eq!(scoped_objective["record_source"], "workspace-sqlite");
assert_eq!(
scoped_objective["revision"].as_str().unwrap().is_empty(),
false
);
assert_eq!(
scoped_objective["resources"][0]["path"],
"memory-architecture-overview.md"
);
let queried_tickets = request_json(
app.clone(),
"POST",
&format!("/api/w/{TEST_WORKSPACE_ID}/tickets/query"),
Some(json!({
"limit": 1
})),
StatusCode::OK,
)
.await;
assert_eq!(queried_tickets["items"][0]["title"], "API Ticket");
let queried_ticket_id = queried_tickets["items"][0]["id"]
.as_str()
.expect("query Ticket id")
.to_string();
assert_eq!(queried_tickets["page"]["limit"], 1);
let shown_ticket = request_json(
app.clone(),
"POST",
&format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{queried_ticket_id}/show"),
Some(json!({"event_limit": 10})),
StatusCode::OK,
)
.await;
assert!(shown_ticket["evidence"]["missing"].is_array());
assert!(shown_ticket["item_revision"].as_str().is_some());
let queried_objectives = request_json(
app.clone(),
"POST",
&format!("/api/w/{TEST_WORKSPACE_ID}/objectives/query"),
Some(json!({
"query": "Objective body",
"linked_ticket_id": "00000000001J2",
"limit": 1
})),
StatusCode::OK,
)
.await;
assert_eq!(queried_objectives["items"][0]["id"], "00000000001J3");
assert_eq!(queried_objectives["page"]["limit"], 1);
let shown_objective = request_json(
app.clone(),
"POST",
&format!("/api/w/{TEST_WORKSPACE_ID}/objectives/00000000001J3/show"),
Some(json!({"event_limit": 10})),
StatusCode::OK,
)
.await;
assert_eq!(shown_objective["linked_tickets"][0], "00000000001J2");
assert!(shown_objective["event_page"]["returned"].is_number());
let memory_document =
get_json(app.clone(), &format!("/api/w/{TEST_WORKSPACE_ID}/memory")).await;
+33
View File
@@ -633,6 +633,12 @@ pub trait ControlPlaneStore: Send + Sync {
fn upsert_objective(&self, record: &ObjectiveRecord) -> Result<()>;
fn list_objectives(&self, workspace_id: &str, limit: usize) -> Result<Vec<ObjectiveRecord>>;
fn list_objectives_for_ticket(
&self,
workspace_id: &str,
ticket_id: &str,
limit: usize,
) -> Result<Vec<ObjectiveRecord>>;
fn get_objective(
&self,
workspace_id: &str,
@@ -1552,6 +1558,33 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
})
}
fn list_objectives_for_ticket(
&self,
workspace_id: &str,
ticket_id: &str,
limit: usize,
) -> Result<Vec<ObjectiveRecord>> {
self.with_conn(|conn| {
let mut stmt = conn.prepare(
r#"SELECT o.workspace_id, o.objective_id, o.title, o.state, o.body_md,
o.created_at, o.updated_at
FROM objectives AS o
INNER JOIN objective_ticket_links AS l
ON l.workspace_id = o.workspace_id
AND l.objective_id = o.objective_id
WHERE o.workspace_id = ?1 AND l.ticket_id = ?2
ORDER BY o.updated_at DESC, o.objective_id ASC
LIMIT ?3"#,
)?;
let rows = stmt.query_map(
params![workspace_id, ticket_id, limit as i64],
read_objective_record,
)?;
rows.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Error::from)
})
}
fn get_objective(
&self,
workspace_id: &str,
+4 -4
View File
@@ -34,15 +34,15 @@ Maintainers can inspect the local `.yoi/tickets/` files directly when debugging
Workers with the Ticket built-in feature can use typed Ticket tools:
- `TicketCreate`
- `TicketList`lightweight bounded overview for selecting ids; it returns short summaries only and must not be used as body/thread/artifact authority.
- `TicketShow` — detailed authority for a single Ticket, including body/thread/artifact metadata/resolution context subject to its own bounds.
- `QueryTicket`bounded authoritative Ticket discovery with typed state/text/event/evidence/relation/Objective/time/attention filters, stable snippets, and cursor metadata.
- `ShowTicket` — detailed authority for one Ticket, including item revision, bounded thread/event references, relations, linked Objectives, implementation reports, and current Merge Request/review evidence.
- `TicketComment`
- `MergeRequestShow`, `MergeRequestOpen`, `MergeRequestAddRevision`, `MergeRequestComplete`
- `MergeRequestReviewSubmit` — available only inside the attested direct-child Reviewer attempt; attempt/revision capability material is not model input.
- `TicketClose`
- `TicketRelationRecord`
- `TicketRelationQuery`
- `TicketDoctor`
Profile-visible Ticket catalogs are intentionally smaller than the former broad read catalog: Workspace authoring exposes 9 tools instead of 13, workflow exposes 10 instead of 12, and review exposes only `QueryTicket` plus `ShowTicket` (2 instead of 6). The `QueryTicket` schema is regression-guarded below 8 KiB while consolidating relation/evidence/attention discovery; diagnostics are not projected into normal profiles, while specialized orchestration-plan commands remain visible only to workflow roles that need their distinct semantics.
These tools operate through the typed Ticket backend. They are not arbitrary filesystem write permission to `.yoi/tickets/`.
@@ -0,0 +1,27 @@
# Merge Request open requires an undiscoverable Ticket repository target
## Observed
While implementing Ticket `00001KZRNHB35`, the assigned Coder had a clean committed Workdir and all immutable base/head/changed-path evidence needed by `MergeRequestOpen`.
`MergeRequestOpen` rejected candidate repository ids with:
```text
invalid input: Merge Request repository must match the authoritative Ticket target
```
The typed `TicketShow` result available to the Coder rendered only the Ticket id and state; it did not expose the authoritative `repository_id` or ref selector. No typed repository/workdir lookup tool was available to the Coder. Continuing would therefore require guessing control-plane identity, mutating the Ticket target without evidence, or bypassing typed authority, all of which are correctly prohibited.
## Impact
A Coder can finish and validate implementation but cannot open the required immutable MR revision or start independent review. The failure is safe, but it strands otherwise review-ready work and provides no actionable expected target.
## Suggested improvement
At least one trusted read surface in the assigned-Coder flow should return the immutable Ticket target needed by `MergeRequestOpen`:
- include `repository_id` and `ref_selector` in `TicketShow`'s bounded authoritative projection; or
- have `MergeRequestOpen` derive repository identity from the authoritative Ticket target and remove it from model input; or
- return a bounded structured mismatch diagnostic containing the authoritative repository id when the caller is already authorized to read that Ticket.
Deriving the repository in `MergeRequestOpen` is preferable because it removes duplicated model-controlled identity and avoids target drift between Ticket read and MR creation.
+2 -2
View File
@@ -1,9 +1,9 @@
## Ticket workflow
Use the available typed Ticket tools as the authority for Ticket reads and mutations. Do not invoke a Ticket CLI or edit backend storage directly as an alternative implementation of those tools.
Use the available typed Ticket tools as the authority for Ticket reads and mutations. Use `QueryTicket` for bounded discovery and filtering, then `ShowTicket` for the authoritative item revision, thread/evidence, relations, linked Objectives, and current Merge Request context before routing, review, or closure decisions. Do not invoke a Ticket CLI or edit backend storage directly as an alternative implementation of those tools.
Read the relevant Ticket before making implementation, routing, review, state, or closure decisions. Do not infer the current contract from an id, title, notification, or remembered summary alone. Check related or potentially duplicate Tickets when creating or materially rescoping work.
Keep durable Ticket records centered on user intent, confirmed background, requirements, acceptance criteria, binding decisions, and implementation/review evidence. Separate confirmed facts from user claims, hypotheses, and open questions. Avoid prematurely turning implementation tactics into requirements.
Keep durable Ticket records centered on user intent, confirmed background, requirements, acceptance criteria, binding decisions, and implementation/review evidence. Use `QueryObjective` for bounded Objective discovery and `ShowObjective` for authoritative revision and linked-Ticket context when coordinating broader work. Separate confirmed facts from user claims, hypotheses, and open questions. Avoid prematurely turning implementation tactics into requirements.
Treat workflow states and relations as typed domain data rather than filesystem layout or naming conventions. Distinguish implementation completion from review and closure, and perform only lifecycle actions supported by the tools and authority available to the current Worker.
@@ -23,8 +23,19 @@ export type TicketListResponse = {
record_authority: string;
};
export type QueryPage = {
limit: number;
returned: number;
has_more: boolean;
next_cursor: string | null;
sort: string;
source_limit: number | null;
source_truncated: boolean;
};
export type TicketEventDetail = {
sequence: number;
event_ref: string;
kind: string;
author: string | null;
at: string | null;
@@ -35,6 +46,100 @@ export type TicketEventDetail = {
state_field: string | null;
heading: string | null;
body: string | null;
attributes: { [key in string]: string };
references: Array<string>;
};
export type ObjectiveLinkSummary = { id: string; title: string; state: string };
export type TicketEvidenceEvent = {
event_ref: string;
sequence: number;
kind: string;
at: string | null;
author: string | null;
excerpt: string;
};
export type TicketAssignmentSummary = {
assignment_id: string;
runtime_id: string;
worker_id: string;
};
export type TicketMergeRequestSummary = {
merge_request_id: string;
state: string;
review_status: string;
revision_id: string;
base_commit: string;
head_commit: string;
changed_paths: Array<string>;
updated_at: string;
review_submitted_at: string | null;
review_excerpt: string | null;
};
export type TicketEvidenceSummary = {
has_implementation_report: boolean;
implementation_report_after_rescope: boolean;
has_merge_request: boolean;
has_commit: boolean;
review_status: string | null;
approved: boolean;
unresolved_request_changes: boolean;
complete_for_integration: boolean;
missing: Array<string>;
};
export type TicketQueryRequest = {
query: string | null;
states: Array<string>;
event_kinds: Array<string>;
evidence: Array<string>;
review_status: string | null;
attention: Array<string>;
related_ticket_id: string | null;
relation_kind: string | null;
linked_objective_id: string | null;
updated_after: string | null;
updated_before: string | null;
sort: string | null;
limit: number | null;
cursor: string | null;
};
export type TicketQueryItem = {
id: string;
title: string;
state: string;
readiness: string | null;
priority: string;
created_at: string | null;
updated_at: string | null;
item_revision: string;
workspace_action_priority: string;
matched_fields: Array<string>;
snippet: string | null;
matching_event: TicketEvidenceEvent | null;
linked_objective_ids: Array<string>;
relation_count: number;
blocker_count: number;
unresolved_blocker_count: number;
unresolved_review_count: number;
evidence: TicketEvidenceSummary;
merge_request: TicketMergeRequestSummary | null;
};
export type TicketQueryResponse = {
items: Array<TicketQueryItem>;
page: QueryPage;
record_authority: string;
};
export type TicketShowRequest = {
event_limit: number | null;
event_cursor: string | null;
};
export type TicketRelation = {
@@ -80,9 +185,11 @@ export type TicketDetail = {
id: string;
title: string;
state: string;
readiness: string | null;
priority: string;
created_at: string | null;
updated_at: string | null;
item_revision: string;
queued_by: string | null;
queued_at: string | null;
assignee: string | null;
@@ -93,9 +200,15 @@ export type TicketDetail = {
body_truncated: boolean;
event_count: number;
events: Array<TicketEventDetail>;
event_page: QueryPage;
artifact_count: number;
artifacts: Array<string>;
relations: TicketRelationView;
linked_objectives: Array<ObjectiveLinkSummary>;
implementation_reports: Array<TicketEvidenceEvent>;
current_assignment: TicketAssignmentSummary | null;
merge_request: TicketMergeRequestSummary | null;
evidence: TicketEvidenceSummary;
resolution: string | null;
record_source: string;
};