feat: integrate dependency queue planning
This commit is contained in:
@@ -473,8 +473,12 @@ impl TicketBackend for BackendWorkspaceProductClient {
|
|||||||
.map_err(ticket_client_error)
|
.map_err(ticket_client_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn queue_ready(&self, id: TicketIdOrSlug, _queued_by: &str) -> ticket::Result<()> {
|
fn queue_ready(
|
||||||
self.send_unit::<()>(
|
&self,
|
||||||
|
id: TicketIdOrSlug,
|
||||||
|
_queued_by: &str,
|
||||||
|
) -> ticket::Result<ticket::TicketQueueOutcome> {
|
||||||
|
self.send_json::<(), _>(
|
||||||
Method::POST,
|
Method::POST,
|
||||||
&format!(
|
&format!(
|
||||||
"/tickets/{}/workflow/queue",
|
"/tickets/{}/workflow/queue",
|
||||||
|
|||||||
+1137
-232
File diff suppressed because it is too large
Load Diff
@@ -142,8 +142,8 @@ const INTAKE_READY_DESCRIPTION: &str = "Record a bounded intake summary and mark
|
|||||||
The backend applies the same target validation and lock as TicketMarkReady and commits the summary, \
|
The backend applies the same target validation and lock as TicketMarkReady and commits the summary, \
|
||||||
state_changed event, effective target, and planning -> ready transition atomically.";
|
state_changed event, effective target, and planning -> ready transition atomically.";
|
||||||
const QUEUE_DESCRIPTION: &str = "Queue a ready Ticket for Orchestrator routing through the typed \
|
const QUEUE_DESCRIPTION: &str = "Queue a ready Ticket for Orchestrator routing through the typed \
|
||||||
Ticket backend. The backend performs the gated ready -> queued transition, records queued_by/queued_at, \
|
Ticket backend. The backend rejects transitive planning dependencies and cycles, atomically queues the \
|
||||||
and rejects unresolved blocking relations.";
|
requested Ticket plus every transitive ready dependency, and leaves queued or in-progress dependencies unchanged.";
|
||||||
const WORKFLOW_STATE_DESCRIPTION: &str = "Transition Ticket `state` through the typed \
|
const WORKFLOW_STATE_DESCRIPTION: &str = "Transition Ticket `state` through the typed \
|
||||||
Ticket backend with a bounded `state_changed` event. Treat `queued -> inprogress` \
|
Ticket backend with a bounded `state_changed` event. Treat `queued -> inprogress` \
|
||||||
as the implementation acceptance step: implementation side effects should happen only after that \
|
as the implementation acceptance step: implementation side effects should happen only after that \
|
||||||
@@ -316,7 +316,11 @@ impl TicketBackend for TicketToolBackend {
|
|||||||
self.backend.mark_ready(id, request)
|
self.backend.mark_ready(id, request)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> TicketResult<()> {
|
fn queue_ready(
|
||||||
|
&self,
|
||||||
|
id: TicketIdOrSlug,
|
||||||
|
queued_by: &str,
|
||||||
|
) -> TicketResult<crate::TicketQueueOutcome> {
|
||||||
self.backend.queue_ready(id, queued_by)
|
self.backend.queue_ready(id, queued_by)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1219,12 +1223,22 @@ impl Tool for TicketQueueTool {
|
|||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let params: TicketQueueParams = parse_input("TicketQueue", input_json)?;
|
let params: TicketQueueParams = parse_input("TicketQueue", input_json)?;
|
||||||
let queued_by = default_author();
|
let queued_by = default_author();
|
||||||
self.backend
|
let outcome = self
|
||||||
|
.backend
|
||||||
.queue_ready(TicketIdOrSlug::Query(params.ticket.clone()), &queued_by)
|
.queue_ready(TicketIdOrSlug::Query(params.ticket.clone()), &queued_by)
|
||||||
.map_err(|error| backend_error("TicketQueue", error))?;
|
.map_err(|error| backend_error("TicketQueue", error))?;
|
||||||
Ok(json_output(
|
Ok(json_output(
|
||||||
format!("Queued ticket {} for Orchestrator", params.ticket),
|
format!(
|
||||||
json!({ "ticket": params.ticket, "state": "queued", "queued_by": queued_by, "ok": true }),
|
"Queued {} ticket(s) for Orchestrator",
|
||||||
|
outcome.queued_tickets.len()
|
||||||
|
),
|
||||||
|
json!({
|
||||||
|
"ticket": outcome.requested_ticket,
|
||||||
|
"queued_tickets": outcome.queued_tickets,
|
||||||
|
"state": "queued",
|
||||||
|
"queued_by": queued_by,
|
||||||
|
"ok": true
|
||||||
|
}),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+136
-40
@@ -4,6 +4,7 @@ use std::fmt;
|
|||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use client::ticket_role::{
|
use client::ticket_role::{
|
||||||
@@ -4125,7 +4126,10 @@ async fn dispatch_ticket_action(
|
|||||||
let config = TicketConfig::load_workspace(&request.workspace_root)
|
let config = TicketConfig::load_workspace(&request.workspace_root)
|
||||||
.map_err(|error| TicketActionError::BackendConfig(error.to_string()))?;
|
.map_err(|error| TicketActionError::BackendConfig(error.to_string()))?;
|
||||||
let backend = LocalTicketBackend::new(config.backend_root())
|
let backend = LocalTicketBackend::new(config.backend_root())
|
||||||
.with_record_language(config.ticket_record_language());
|
.with_record_language(config.ticket_record_language())
|
||||||
|
.with_target_authority(Arc::new(DashboardTicketTargetAuthority {
|
||||||
|
workspace_root: request.workspace_root.clone(),
|
||||||
|
}));
|
||||||
if request.action == NextUserAction::Close {
|
if request.action == NextUserAction::Close {
|
||||||
return dispatch_panel_close(&backend, &request.ticket_id);
|
return dispatch_panel_close(&backend, &request.ticket_id);
|
||||||
}
|
}
|
||||||
@@ -4201,17 +4205,32 @@ async fn dispatch_panel_queue(
|
|||||||
"root-ticket-state-after-orchestration-merge",
|
"root-ticket-state-after-orchestration-merge",
|
||||||
&preflight.root_top_level,
|
&preflight.root_top_level,
|
||||||
)?;
|
)?;
|
||||||
backend
|
let queue_outcome = backend
|
||||||
.queue_ready(TicketIdOrSlug::Id(ticket_id.to_owned()), "workspace-panel")
|
.queue_ready(TicketIdOrSlug::Id(ticket_id.to_owned()), "workspace-panel")
|
||||||
.map_err(|error| TicketActionError::Ticket(error.to_string()))?;
|
.map_err(|error| TicketActionError::Ticket(error.to_string()))?;
|
||||||
|
let expected_queue_tickets = preflight
|
||||||
|
.queue_tickets
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
let actual_queue_tickets = queue_outcome
|
||||||
|
.queued_tickets
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
if actual_queue_tickets != expected_queue_tickets {
|
||||||
|
return Err(TicketActionError::Stale(format!(
|
||||||
|
"Queue dependency plan changed after confirmation for Ticket {ticket_id}; reload and retry"
|
||||||
|
)));
|
||||||
|
}
|
||||||
let commit = commit_panel_queue_ticket_record(&preflight)?;
|
let commit = commit_panel_queue_ticket_record(&preflight)?;
|
||||||
let sync = sync_panel_queue_to_orchestration(&preflight, &commit)?;
|
let sync = sync_panel_queue_to_orchestration(&preflight, &commit)?;
|
||||||
verify_panel_queue_synced(&preflight, &commit)?;
|
verify_panel_queue_synced(&preflight, &commit)?;
|
||||||
let notification = notify_workspace_orchestrator(orchestrator, current_ticket).await;
|
let notification = notify_workspace_orchestrator(orchestrator, current_ticket).await;
|
||||||
Ok(TicketActionOutcome {
|
Ok(TicketActionOutcome {
|
||||||
notice: format!(
|
notice: format!(
|
||||||
"Queued Ticket {}; root Queue commit {}; {}; orchestration sync {}; {}. Orchestrator routing is authorized; implementation side effects still require queued -> inprogress acceptance.",
|
"Queued Ticket closure [{}]; root Queue commit {}; {}; orchestration sync {}; {}. Orchestrator routing is authorized; implementation side effects still require queued -> inprogress acceptance.",
|
||||||
ticket_id,
|
queue_outcome.queued_tickets.join(", "),
|
||||||
commit.sha,
|
commit.sha,
|
||||||
root_merge.sentence(),
|
root_merge.sentence(),
|
||||||
sync.sentence(),
|
sync.sentence(),
|
||||||
@@ -4220,12 +4239,52 @@ async fn dispatch_panel_queue(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct DashboardTicketTargetAuthority {
|
||||||
|
workspace_root: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ticket::TicketTargetAuthority for DashboardTicketTargetAuthority {
|
||||||
|
fn resolve_target(
|
||||||
|
&self,
|
||||||
|
_workspace_id: &str,
|
||||||
|
repository_id: Option<&str>,
|
||||||
|
ref_selector: Option<&str>,
|
||||||
|
) -> ticket::Result<ticket::ResolvedTicketTarget> {
|
||||||
|
let repository_id = repository_id.unwrap_or("main");
|
||||||
|
if repository_id != "main" {
|
||||||
|
return Err(ticket::TicketError::UnknownTargetRepository(
|
||||||
|
repository_id.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let ref_selector = ref_selector.unwrap_or("HEAD");
|
||||||
|
git_capture(
|
||||||
|
&self.workspace_root,
|
||||||
|
&[
|
||||||
|
"rev-parse",
|
||||||
|
"--verify",
|
||||||
|
&format!("{ref_selector}^{{commit}}"),
|
||||||
|
],
|
||||||
|
"resolve Queue Ticket target",
|
||||||
|
)
|
||||||
|
.map_err(|reason| ticket::TicketError::InvalidTargetSelector {
|
||||||
|
repository_id: repository_id.to_string(),
|
||||||
|
selector: ref_selector.to_string(),
|
||||||
|
reason,
|
||||||
|
})?;
|
||||||
|
Ok(ticket::ResolvedTicketTarget {
|
||||||
|
repository_id: repository_id.to_string(),
|
||||||
|
ref_selector: ref_selector.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
struct PanelQueueHandoffPreflight {
|
struct PanelQueueHandoffPreflight {
|
||||||
ticket_id: String,
|
ticket_id: String,
|
||||||
root_top_level: PathBuf,
|
root_top_level: PathBuf,
|
||||||
orchestration: OrchestrationWorktreeLayout,
|
orchestration: OrchestrationWorktreeLayout,
|
||||||
ticket_record_dir: PathBuf,
|
queue_tickets: Vec<String>,
|
||||||
|
ticket_record_dirs: Vec<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -4393,27 +4452,53 @@ fn prepare_panel_queue_handoff(
|
|||||||
&root_top_level,
|
&root_top_level,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let ticket_record_dir = backend.root().join(ticket_id);
|
let dependency_check = backend
|
||||||
if !ticket_record_dir.join("item.md").is_file() {
|
.dependency_check(TicketIdOrSlug::Id(ticket_id.to_owned()))
|
||||||
|
.map_err(|error| TicketActionError::Ticket(error.to_string()))?;
|
||||||
|
if !dependency_check.queue_guard.can_queue_for_orchestrator {
|
||||||
return Err(queue_check_failed(
|
return Err(queue_check_failed(
|
||||||
"target-ticket-record",
|
"dependency-queue-plan",
|
||||||
ticket_id,
|
ticket_id,
|
||||||
&ticket_record_dir,
|
&root_top_level,
|
||||||
"target Ticket item.md is missing".to_string(),
|
dependency_check
|
||||||
|
.queue_guard
|
||||||
|
.blocked_reason
|
||||||
|
.or(dependency_check.queue_guard.reason)
|
||||||
|
.unwrap_or_else(|| "Queue dependency validation failed".to_string()),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
ensure_git_path_clean(
|
let queue_tickets = dependency_check.queue_tickets;
|
||||||
"root-ticket-clean",
|
let mut ticket_record_dirs = Vec::with_capacity(queue_tickets.len());
|
||||||
ticket_id,
|
for queue_ticket in &queue_tickets {
|
||||||
&root_top_level,
|
let ticket_record_dir = backend.root().join(queue_ticket);
|
||||||
&ticket_record_dir,
|
if !ticket_record_dir.join("item.md").is_file() {
|
||||||
)?;
|
return Err(queue_check_failed(
|
||||||
|
"target-ticket-record",
|
||||||
|
&queue_ticket,
|
||||||
|
&ticket_record_dir,
|
||||||
|
"Queue Ticket item.md is missing".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let clean_stage = if queue_ticket == ticket_id {
|
||||||
|
"root-ticket-clean"
|
||||||
|
} else {
|
||||||
|
"queue-dependency-clean"
|
||||||
|
};
|
||||||
|
ensure_git_path_clean(
|
||||||
|
clean_stage,
|
||||||
|
&queue_ticket,
|
||||||
|
&root_top_level,
|
||||||
|
&ticket_record_dir,
|
||||||
|
)?;
|
||||||
|
ticket_record_dirs.push(ticket_record_dir);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(PanelQueueHandoffPreflight {
|
Ok(PanelQueueHandoffPreflight {
|
||||||
ticket_id: ticket_id.to_string(),
|
ticket_id: ticket_id.to_string(),
|
||||||
root_top_level,
|
root_top_level,
|
||||||
orchestration,
|
orchestration,
|
||||||
ticket_record_dir,
|
queue_tickets,
|
||||||
|
ticket_record_dirs,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4504,37 +4589,36 @@ fn sync_orchestration_to_root_before_queue(
|
|||||||
fn commit_panel_queue_ticket_record(
|
fn commit_panel_queue_ticket_record(
|
||||||
preflight: &PanelQueueHandoffPreflight,
|
preflight: &PanelQueueHandoffPreflight,
|
||||||
) -> Result<PanelQueueCommit, TicketActionError> {
|
) -> Result<PanelQueueCommit, TicketActionError> {
|
||||||
let ticket_rel = path_relative_to_root(
|
let ticket_rels = preflight
|
||||||
&preflight.root_top_level,
|
.ticket_record_dirs
|
||||||
&preflight.ticket_record_dir,
|
.iter()
|
||||||
"target-ticket-record",
|
.map(|ticket_record_dir| {
|
||||||
&preflight.ticket_id,
|
path_relative_to_root(
|
||||||
)?;
|
&preflight.root_top_level,
|
||||||
|
ticket_record_dir,
|
||||||
|
"target-ticket-record",
|
||||||
|
&preflight.ticket_id,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
let mut add = Command::new("git");
|
let mut add = Command::new("git");
|
||||||
add.arg("-C")
|
add.arg("-C")
|
||||||
.arg(&preflight.root_top_level)
|
.arg(&preflight.root_top_level)
|
||||||
.arg("add")
|
.arg("add")
|
||||||
.arg("--")
|
.arg("--")
|
||||||
.arg(&ticket_rel);
|
.args(&ticket_rels);
|
||||||
run_git_command(add, "stage Queue Ticket record").map_err(|message| {
|
run_git_command(add, "stage Queue Ticket records").map_err(|message| {
|
||||||
queue_check_failed(
|
queue_check_failed(
|
||||||
"queue-commit-stage",
|
"queue-commit-stage",
|
||||||
&preflight.ticket_id,
|
&preflight.ticket_id,
|
||||||
&preflight.ticket_record_dir,
|
&preflight.root_top_level,
|
||||||
message,
|
message,
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let ticket_rel_string = git_path_string(&ticket_rel);
|
|
||||||
let staged = git_capture(
|
let staged = git_capture(
|
||||||
&preflight.root_top_level,
|
&preflight.root_top_level,
|
||||||
&[
|
&["diff", "--cached", "--name-only"],
|
||||||
"diff",
|
|
||||||
"--cached",
|
|
||||||
"--name-only",
|
|
||||||
"--",
|
|
||||||
ticket_rel_string.as_str(),
|
|
||||||
],
|
|
||||||
"list staged Queue Ticket files",
|
"list staged Queue Ticket files",
|
||||||
)
|
)
|
||||||
.map_err(|message| {
|
.map_err(|message| {
|
||||||
@@ -4545,19 +4629,31 @@ fn commit_panel_queue_ticket_record(
|
|||||||
message,
|
message,
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
let allowed = ticket_rels
|
||||||
|
.iter()
|
||||||
|
.map(|path| format!("{}/", git_path_string(path).trim_end_matches('/')))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
let staged_paths = staged
|
let staged_paths = staged
|
||||||
.lines()
|
.lines()
|
||||||
.filter(|line| !line.trim().is_empty())
|
.filter(|line| !line.trim().is_empty())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
if staged_paths.is_empty() {
|
if staged_paths.is_empty()
|
||||||
|
|| staged_paths
|
||||||
|
.iter()
|
||||||
|
.any(|path| !allowed.iter().any(|root| path.starts_with(root)))
|
||||||
|
{
|
||||||
return Err(queue_check_failed(
|
return Err(queue_check_failed(
|
||||||
"queue-commit-pathscope",
|
"queue-commit-pathscope",
|
||||||
&preflight.ticket_id,
|
&preflight.ticket_id,
|
||||||
&preflight.ticket_record_dir,
|
&preflight.root_top_level,
|
||||||
"Queue mutation produced no staged Ticket record changes".to_string(),
|
"Queue mutation staged no Ticket records or included files outside the confirmed dependency closure"
|
||||||
|
.to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let message = format!("ticket: queue {}", preflight.ticket_id);
|
let message = format!(
|
||||||
|
"chore: queue Ticket dependency closure {}",
|
||||||
|
preflight.ticket_id
|
||||||
|
);
|
||||||
let mut commit = Command::new("git");
|
let mut commit = Command::new("git");
|
||||||
commit
|
commit
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
@@ -4567,8 +4663,8 @@ fn commit_panel_queue_ticket_record(
|
|||||||
.arg("-m")
|
.arg("-m")
|
||||||
.arg(message)
|
.arg(message)
|
||||||
.arg("--")
|
.arg("--")
|
||||||
.arg(&ticket_rel);
|
.args(&ticket_rels);
|
||||||
run_git_command(commit, "commit Queue Ticket record").map_err(|message| {
|
run_git_command(commit, "commit Queue Ticket records").map_err(|message| {
|
||||||
queue_check_failed(
|
queue_check_failed(
|
||||||
"queue-commit-create",
|
"queue-commit-create",
|
||||||
&preflight.ticket_id,
|
&preflight.ticket_id,
|
||||||
|
|||||||
@@ -462,7 +462,7 @@ pub(super) fn panel_ticket_detail(row: &PanelRow) -> String {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|ticket| ticket.blocked_reason.as_deref())
|
.and_then(|ticket| ticket.blocked_reason.as_deref())
|
||||||
{
|
{
|
||||||
parts.push(format!("Gate: waiting for {blocked_reason}"));
|
parts.push(format!("Dependencies: {blocked_reason}"));
|
||||||
} else {
|
} else {
|
||||||
parts.push("Gate: clear".to_string());
|
parts.push("Gate: clear".to_string());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1846,24 +1846,23 @@ fn panel_orchestration_overlay_uses_compact_status_column_and_detail_line() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ready_ticket_with_waiting_gate_shows_queue_disabled_reason() {
|
fn ready_ticket_with_dependency_context_keeps_queue_action_available() {
|
||||||
let mut row = panel_test_ticket_row(
|
let mut row = panel_test_ticket_row(
|
||||||
"00001WAITING",
|
"00001WAITING",
|
||||||
"Ready but gated",
|
"Ready with dependency context",
|
||||||
ActionPriority::Background,
|
ActionPriority::ReadyForQueue,
|
||||||
NextUserAction::Wait,
|
NextUserAction::Queue,
|
||||||
"ready",
|
"ready",
|
||||||
);
|
);
|
||||||
row.disabled_reason = Some("Queue disabled: waiting for BLOCKER-1".to_string());
|
|
||||||
row.ticket.as_mut().unwrap().blocked_reason = Some("BLOCKER-1 via depends_on".to_string());
|
row.ticket.as_mut().unwrap().blocked_reason = Some("BLOCKER-1 via depends_on".to_string());
|
||||||
|
|
||||||
let lines = panel_row_lines(&row, true, 160);
|
let lines = panel_row_lines(&row, true, 160);
|
||||||
let detail = &lines[1];
|
let detail = &lines[1];
|
||||||
let detail_line = plain_line(&detail);
|
let detail_line = plain_line(&detail);
|
||||||
|
|
||||||
assert!(detail_line.contains("Gate: waiting for BLOCKER-1 via depends_on"));
|
assert!(detail_line.contains("Dependencies: BLOCKER-1 via depends_on"));
|
||||||
assert!(detail_line.contains("Action: queue disabled"));
|
assert!(detail_line.contains("Action: Queue"));
|
||||||
assert!(detail_line.contains("Reason: Queue disabled: waiting for BLOCKER-1"));
|
assert!(!detail_line.contains("Queue disabled"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -2203,7 +2203,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workspace_panel_marks_ready_ticket_with_unresolved_relation_waiting_gate() {
|
fn workspace_panel_blocks_ready_ticket_with_planning_relation() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
write_ticket_config(temp.path());
|
write_ticket_config(temp.path());
|
||||||
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
||||||
@@ -2235,12 +2235,7 @@ mod tests {
|
|||||||
assert_eq!(row.kind, PanelRowKind::Ticket);
|
assert_eq!(row.kind, PanelRowKind::Ticket);
|
||||||
assert_eq!(row.next_action, Some(NextUserAction::Wait));
|
assert_eq!(row.next_action, Some(NextUserAction::Wait));
|
||||||
assert_eq!(row.priority, ActionPriority::Background);
|
assert_eq!(row.priority, ActionPriority::Background);
|
||||||
assert!(
|
assert!(row.disabled_reason.is_some());
|
||||||
row.disabled_reason
|
|
||||||
.as_deref()
|
|
||||||
.unwrap()
|
|
||||||
.contains("Queue disabled: waiting for")
|
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
row.ticket
|
row.ticket
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -2253,7 +2248,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workspace_panel_allows_ready_ticket_when_relation_prerequisite_is_queued() {
|
fn workspace_panel_queues_ready_ticket_when_relation_prerequisite_is_queued() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
write_ticket_config(temp.path());
|
write_ticket_config(temp.path());
|
||||||
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
||||||
@@ -2286,13 +2281,16 @@ mod tests {
|
|||||||
assert_eq!(row.next_action, Some(NextUserAction::Queue));
|
assert_eq!(row.next_action, Some(NextUserAction::Queue));
|
||||||
assert_eq!(row.priority, ActionPriority::ReadyForQueue);
|
assert_eq!(row.priority, ActionPriority::ReadyForQueue);
|
||||||
assert!(row.disabled_reason.is_none());
|
assert!(row.disabled_reason.is_none());
|
||||||
assert!(row.ticket.as_ref().unwrap().blocked_reason.is_none());
|
|
||||||
assert!(
|
assert!(
|
||||||
row.key_hint
|
row.ticket
|
||||||
.as_deref()
|
.as_ref()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.contains("Queue allowed: prerequisites are already queued/in progress")
|
.blocked_reason
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains(&dependency.id)
|
||||||
);
|
);
|
||||||
|
assert!(row.key_hint.as_deref().unwrap().contains("Queue targets:"));
|
||||||
assert!(row.key_hint.as_deref().unwrap().contains(&dependency.id));
|
assert!(row.key_hint.as_deref().unwrap().contains(&dependency.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -873,12 +873,13 @@ impl WorkspaceHttpTicketBackend {
|
|||||||
})?),
|
})?),
|
||||||
)
|
)
|
||||||
.map(TicketBackendOperationResult::Ticket),
|
.map(TicketBackendOperationResult::Ticket),
|
||||||
TicketBackendOperation::QueueReady { id, .. } => Self::request_unit(
|
TicketBackendOperation::QueueReady { id, .. } => Self::request(
|
||||||
client,
|
client,
|
||||||
WorkspaceRequestMethod::Post,
|
WorkspaceRequestMethod::Post,
|
||||||
format!("{base}/{}/workflow/queue", Self::ticket_path(&id)),
|
format!("{base}/{}/workflow/queue", Self::ticket_path(&id)),
|
||||||
None,
|
None,
|
||||||
),
|
)
|
||||||
|
.map(TicketBackendOperationResult::QueueOutcome),
|
||||||
TicketBackendOperation::Close { id, resolution } => Self::request_unit(
|
TicketBackendOperation::Close { id, resolution } => Self::request_unit(
|
||||||
client,
|
client,
|
||||||
WorkspaceRequestMethod::Post,
|
WorkspaceRequestMethod::Post,
|
||||||
@@ -1099,16 +1100,18 @@ impl TicketBackend for WorkspaceHttpTicketBackend {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> TicketResult<()> {
|
fn queue_ready(
|
||||||
match self.invoke(TicketBackendOperation::QueueReady {
|
&self,
|
||||||
id,
|
id: TicketIdOrSlug,
|
||||||
queued_by: queued_by.to_string(),
|
queued_by: &str,
|
||||||
})? {
|
) -> TicketResult<ticket::TicketQueueOutcome> {
|
||||||
TicketBackendOperationResult::Unit => Ok(()),
|
expect_ticket_result!(
|
||||||
other => Err(TicketError::Conflict(format!(
|
self.invoke(TicketBackendOperation::QueueReady {
|
||||||
"unexpected ticket backend response: {other:?}"
|
id,
|
||||||
))),
|
queued_by: queued_by.to_string(),
|
||||||
}
|
}),
|
||||||
|
TicketBackendOperationResult::QueueOutcome
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> TicketResult<()> {
|
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> TicketResult<()> {
|
||||||
|
|||||||
@@ -704,6 +704,15 @@ impl SqliteWorkspaceAuthority {
|
|||||||
&self,
|
&self,
|
||||||
reference: &str,
|
reference: &str,
|
||||||
request: TicketShowRequest,
|
request: TicketShowRequest,
|
||||||
|
) -> Result<TicketDetail> {
|
||||||
|
self.read_ticket_detail_with_backend(reference, request, &self.ticket_backend)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn read_ticket_detail_with_backend(
|
||||||
|
&self,
|
||||||
|
reference: &str,
|
||||||
|
request: TicketShowRequest,
|
||||||
|
backend: &SqliteTicketBackend,
|
||||||
) -> Result<TicketDetail> {
|
) -> Result<TicketDetail> {
|
||||||
let id = self
|
let id = self
|
||||||
.store
|
.store
|
||||||
@@ -713,16 +722,19 @@ impl SqliteWorkspaceAuthority {
|
|||||||
reference,
|
reference,
|
||||||
)?
|
)?
|
||||||
.ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(reference.to_string())))?;
|
.ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(reference.to_string())))?;
|
||||||
let ticket = self.ticket_backend.show(TicketIdOrSlug::Id(id))?;
|
let ticket = backend.show(TicketIdOrSlug::Id(id))?;
|
||||||
self.ticket_detail_from_ticket(ticket, request)
|
self.ticket_detail_from_ticket(ticket, request, backend)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ticket_detail_from_ticket(
|
fn ticket_detail_from_ticket(
|
||||||
&self,
|
&self,
|
||||||
ticket: ticket::Ticket,
|
ticket: ticket::Ticket,
|
||||||
request: TicketShowRequest,
|
request: TicketShowRequest,
|
||||||
|
dependency_backend: &SqliteTicketBackend,
|
||||||
) -> Result<TicketDetail> {
|
) -> Result<TicketDetail> {
|
||||||
let id = ticket.meta.id.as_str();
|
let id = ticket.meta.id.as_str();
|
||||||
|
let dependency_check =
|
||||||
|
dependency_backend.dependency_check(TicketIdOrSlug::Id(id.to_string()))?;
|
||||||
let (body, body_truncated) =
|
let (body, body_truncated) =
|
||||||
truncate_body(ticket.document.body.as_str(), DETAIL_BODY_LIMIT);
|
truncate_body(ticket.document.body.as_str(), DETAIL_BODY_LIMIT);
|
||||||
let event_limit = request
|
let event_limit = request
|
||||||
@@ -822,6 +834,27 @@ impl SqliteWorkspaceAuthority {
|
|||||||
.any(|assignment| assignment.role == TicketAssignmentRole::Coder);
|
.any(|assignment| assignment.role == TicketAssignmentRole::Coder);
|
||||||
let has_target = ticket.meta.repository_id.is_some() && ticket.meta.ref_selector.is_some();
|
let has_target = ticket.meta.repository_id.is_some() && ticket.meta.ref_selector.is_some();
|
||||||
let has_blockers = !ticket.relations.blockers.is_empty();
|
let has_blockers = !ticket.relations.blockers.is_empty();
|
||||||
|
let mut queue_assignment_blockers = Vec::new();
|
||||||
|
for ticket_id in &dependency_check.queue_tickets {
|
||||||
|
let assignments = self
|
||||||
|
.store
|
||||||
|
.list_current_ticket_role_assignments(&self.workspace_id, ticket_id)?;
|
||||||
|
if !assignments
|
||||||
|
.iter()
|
||||||
|
.any(|assignment| assignment.role == TicketAssignmentRole::Orchestrator)
|
||||||
|
{
|
||||||
|
queue_assignment_blockers.push(format!(
|
||||||
|
"Ticket {ticket_id} requires an active Orchestrator assignment"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if assignments
|
||||||
|
.iter()
|
||||||
|
.any(|assignment| assignment.role == TicketAssignmentRole::Coder)
|
||||||
|
{
|
||||||
|
queue_assignment_blockers
|
||||||
|
.push(format!("Ticket {ticket_id} has an active Coder assignment"));
|
||||||
|
}
|
||||||
|
}
|
||||||
let mut assignment_diagnostics = Vec::new();
|
let mut assignment_diagnostics = Vec::new();
|
||||||
if let Some(legacy_assignee) = ticket
|
if let Some(legacy_assignee) = ticket
|
||||||
.meta
|
.meta
|
||||||
@@ -833,6 +866,19 @@ impl SqliteWorkspaceAuthority {
|
|||||||
"legacy Ticket assignee `{legacy_assignee}` is not assignment authority"
|
"legacy Ticket assignee `{legacy_assignee}` is not assignment authority"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
let mut action_blockers = Vec::new();
|
||||||
|
if !has_target {
|
||||||
|
action_blockers.push("Ticket target is required".to_string());
|
||||||
|
}
|
||||||
|
if !dependency_check.queue_guard.can_queue_for_orchestrator {
|
||||||
|
if let Some(reason) = dependency_check.queue_guard.blocked_reason.clone() {
|
||||||
|
action_blockers.push(reason);
|
||||||
|
} else if let Some(reason) = dependency_check.queue_guard.reason.clone() {
|
||||||
|
action_blockers.push(reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let queue_assignments_valid = queue_assignment_blockers.is_empty();
|
||||||
|
action_blockers.extend(queue_assignment_blockers);
|
||||||
let action_eligibility = TicketActionEligibility {
|
let action_eligibility = TicketActionEligibility {
|
||||||
can_assign_orchestrator: matches!(
|
can_assign_orchestrator: matches!(
|
||||||
ticket.meta.workflow_state,
|
ticket.meta.workflow_state,
|
||||||
@@ -848,19 +894,15 @@ impl SqliteWorkspaceAuthority {
|
|||||||
&& has_orchestrator
|
&& has_orchestrator
|
||||||
&& !has_coder
|
&& !has_coder
|
||||||
&& has_target
|
&& has_target
|
||||||
&& !has_blockers,
|
&& dependency_check.queue_guard.can_queue_for_orchestrator
|
||||||
|
&& queue_assignments_valid,
|
||||||
can_start_manual_coder: ticket.meta.workflow_state == TicketWorkflowState::Ready
|
can_start_manual_coder: ticket.meta.workflow_state == TicketWorkflowState::Ready
|
||||||
&& !has_orchestrator
|
&& !has_orchestrator
|
||||||
&& !has_coder
|
&& !has_coder
|
||||||
&& has_target
|
&& has_target
|
||||||
&& !has_blockers,
|
&& !has_blockers,
|
||||||
blockers: [
|
queue_tickets: dependency_check.queue_tickets.clone(),
|
||||||
(!has_target).then_some("Ticket target is required".to_string()),
|
blockers: action_blockers,
|
||||||
has_blockers.then_some("unresolved blocking relations remain".to_string()),
|
|
||||||
]
|
|
||||||
.into_iter()
|
|
||||||
.flatten()
|
|
||||||
.collect(),
|
|
||||||
};
|
};
|
||||||
let merge_request = match self.merge_request_store.get(&self.workspace_id, id) {
|
let merge_request = match self.merge_request_store.get(&self.workspace_id, id) {
|
||||||
Ok(request) => {
|
Ok(request) => {
|
||||||
@@ -1084,6 +1126,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
|
|||||||
event_limit: Some(TICKET_EVENT_LIMIT),
|
event_limit: Some(TICKET_EVENT_LIMIT),
|
||||||
event_cursor: None,
|
event_cursor: None,
|
||||||
},
|
},
|
||||||
|
&self.ticket_backend,
|
||||||
)?;
|
)?;
|
||||||
if ticket_matches_query(
|
if ticket_matches_query(
|
||||||
&summary,
|
&summary,
|
||||||
@@ -2927,7 +2970,7 @@ mod tests {
|
|||||||
async fn sqlite_workspace_authority_reads_sqlite_records_without_filesystem_authority() {
|
async fn sqlite_workspace_authority_reads_sqlite_records_without_filesystem_authority() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
write_ticket(dir.path(), "00000000001J2", "Read bridge", "ready");
|
write_ticket(dir.path(), "00000000001J2", "Read bridge", "ready");
|
||||||
write_ticket(dir.path(), "00000000001J5", "Second ticket", "planning");
|
write_ticket(dir.path(), "00000000001J5", "Second ticket", "queued");
|
||||||
write_ticket(dir.path(), "00000000001J6", "Third ticket", "planning");
|
write_ticket(dir.path(), "00000000001J6", "Third ticket", "planning");
|
||||||
let db_path = dir.path().join("workspace.db");
|
let db_path = dir.path().join("workspace.db");
|
||||||
let store = SqliteWorkspaceStore::open(&db_path).unwrap();
|
let store = SqliteWorkspaceStore::open(&db_path).unwrap();
|
||||||
@@ -3038,10 +3081,22 @@ VALUES ('workspace-test', 'ticket', 4);
|
|||||||
.ticket_backend
|
.ticket_backend
|
||||||
.add_ticket_relation(
|
.add_ticket_relation(
|
||||||
TicketIdOrSlug::Id("00000000001J2".to_string()),
|
TicketIdOrSlug::Id("00000000001J2".to_string()),
|
||||||
|
ticket::NewTicketRelation {
|
||||||
|
kind: ticket::TicketRelationKind::DependsOn,
|
||||||
|
target: "00000000001J5".to_string(),
|
||||||
|
note: Some("queued dependency with a transitive blocker".to_string()),
|
||||||
|
author: Some("tester".to_string()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
authority
|
||||||
|
.ticket_backend
|
||||||
|
.add_ticket_relation(
|
||||||
|
TicketIdOrSlug::Id("00000000001J5".to_string()),
|
||||||
ticket::NewTicketRelation {
|
ticket::NewTicketRelation {
|
||||||
kind: ticket::TicketRelationKind::DependsOn,
|
kind: ticket::TicketRelationKind::DependsOn,
|
||||||
target: "00000000001J6".to_string(),
|
target: "00000000001J6".to_string(),
|
||||||
note: Some("separate dependency relation".to_string()),
|
note: Some("transitive planning dependency".to_string()),
|
||||||
author: Some("tester".to_string()),
|
author: Some("tester".to_string()),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -3056,6 +3111,14 @@ VALUES ('workspace-test', 'ticket', 4);
|
|||||||
assert_eq!(ticket_by_key.id, tickets.items[0].id);
|
assert_eq!(ticket_by_key.id, tickets.items[0].id);
|
||||||
|
|
||||||
let ticket = authority.ticket("00000000001J2").unwrap();
|
let ticket = authority.ticket("00000000001J2").unwrap();
|
||||||
|
assert!(!ticket.action_eligibility.can_queue);
|
||||||
|
assert!(
|
||||||
|
ticket
|
||||||
|
.action_eligibility
|
||||||
|
.blockers
|
||||||
|
.iter()
|
||||||
|
.any(|reason| reason.contains("00000000001J6"))
|
||||||
|
);
|
||||||
assert!(ticket.body.contains("Ticket body"));
|
assert!(ticket.body.contains("Ticket body"));
|
||||||
assert!(ticket.body_truncated);
|
assert!(ticket.body_truncated);
|
||||||
assert!(!ticket.body.contains("Deep Ticket marker"));
|
assert!(!ticket.body.contains("Deep Ticket marker"));
|
||||||
@@ -3139,8 +3202,8 @@ VALUES ('workspace-test', 'ticket', 4);
|
|||||||
assert!(note_only_kind.items.is_empty());
|
assert!(note_only_kind.items.is_empty());
|
||||||
let crossed_relation_filters = authority
|
let crossed_relation_filters = authority
|
||||||
.query_tickets(TicketQueryRequest {
|
.query_tickets(TicketQueryRequest {
|
||||||
related_ticket_id: Some("00000000001J5".to_string()),
|
related_ticket_id: Some("00000000001J6".to_string()),
|
||||||
relation_kind: Some("depends_on".to_string()),
|
relation_kind: Some("related".to_string()),
|
||||||
..TicketQueryRequest::default()
|
..TicketQueryRequest::default()
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -296,6 +296,7 @@ pub struct TicketActionEligibility {
|
|||||||
pub can_unassign_orchestrator: bool,
|
pub can_unassign_orchestrator: bool,
|
||||||
pub can_queue: bool,
|
pub can_queue: bool,
|
||||||
pub can_start_manual_coder: bool,
|
pub can_start_manual_coder: bool,
|
||||||
|
pub queue_tickets: Vec<String>,
|
||||||
pub blockers: Vec<String>,
|
pub blockers: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3388,7 +3388,7 @@ async fn scoped_get_ticket(
|
|||||||
AxumPath(path): AxumPath<ScopedRecordPath>,
|
AxumPath(path): AxumPath<ScopedRecordPath>,
|
||||||
) -> ApiResult<Json<TicketDetail>> {
|
) -> ApiResult<Json<TicketDetail>> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
get_ticket(State(api), AxumPath(path.id)).await
|
browser_ticket_detail(&api, &path.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn scoped_query_tickets(
|
async fn scoped_query_tickets(
|
||||||
@@ -3406,7 +3406,10 @@ async fn scoped_show_ticket(
|
|||||||
Json(query): Json<TicketShowRequest>,
|
Json(query): Json<TicketShowRequest>,
|
||||||
) -> ApiResult<Json<TicketDetail>> {
|
) -> ApiResult<Json<TicketDetail>> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
Ok(Json(api.authority.show_ticket(&path.id, query)?))
|
let backend = browser_ticket_backend(&api)?;
|
||||||
|
Ok(Json(api.authority.read_ticket_detail_with_backend(
|
||||||
|
&path.id, query, &backend,
|
||||||
|
)?))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -4136,7 +4139,12 @@ fn browser_ticket_backend(api: &WorkspaceApi) -> Result<SqliteTicketBackend> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn browser_ticket_detail(api: &WorkspaceApi, ticket_id: &str) -> ApiResult<Json<TicketDetail>> {
|
fn browser_ticket_detail(api: &WorkspaceApi, ticket_id: &str) -> ApiResult<Json<TicketDetail>> {
|
||||||
Ok(Json(api.authority.ticket(ticket_id)?))
|
let backend = browser_ticket_backend(api)?;
|
||||||
|
Ok(Json(api.authority.read_ticket_detail_with_backend(
|
||||||
|
ticket_id,
|
||||||
|
TicketShowRequest::default(),
|
||||||
|
&backend,
|
||||||
|
)?))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn scoped_edit_ticket_item(
|
async fn scoped_edit_ticket_item(
|
||||||
@@ -4358,27 +4366,58 @@ async fn execute_ticket_rest_operation(
|
|||||||
.to_string(),
|
.to_string(),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let assignment = active_orchestrator_assignment(api, workspace_id, &ticket.meta.id)?
|
let dependency_check = backend
|
||||||
.ok_or_else(|| {
|
.dependency_check(TicketIdOrSlug::Id(ticket.meta.id.clone()))
|
||||||
Error::TicketAssignmentConflict(
|
.map_err(Error::from)?;
|
||||||
"Queue requires role=orchestrator assignment to workspace-orchestrator"
|
if !dependency_check.queue_guard.can_queue_for_orchestrator {
|
||||||
.to_string(),
|
let reason = dependency_check
|
||||||
)
|
.queue_guard
|
||||||
})?;
|
.blocked_reason
|
||||||
|
.or(dependency_check.queue_guard.reason)
|
||||||
|
.unwrap_or_else(|| "Queue dependency validation failed".to_string());
|
||||||
|
return Err(Error::TicketAssignmentConflict(reason).into());
|
||||||
|
}
|
||||||
|
let candidates = dependency_check.queue_tickets;
|
||||||
|
let mut assignment_ids = BTreeMap::new();
|
||||||
|
for ticket_id in candidates {
|
||||||
|
if api
|
||||||
|
.store
|
||||||
|
.get_current_ticket_role_assignment(
|
||||||
|
workspace_id,
|
||||||
|
&ticket_id,
|
||||||
|
TicketAssignmentRole::Coder,
|
||||||
|
)?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Err(Error::TicketAssignmentConflict(format!(
|
||||||
|
"Queue rejects Ticket {ticket_id} while a Coder assignment is active"
|
||||||
|
))
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let assignment = active_orchestrator_assignment(api, workspace_id, &ticket_id)?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
Error::TicketAssignmentConflict(format!(
|
||||||
|
"Queue requires role=orchestrator assignment for Ticket {ticket_id}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
assignment_ids.insert(ticket_id, assignment.assignment_id);
|
||||||
|
}
|
||||||
|
let assignment_json = serde_json::to_string(&assignment_ids).map_err(|error| {
|
||||||
|
Error::Config(format!("failed to encode Queue assignment fence: {error}"))
|
||||||
|
})?;
|
||||||
let operation_id = new_id("tqueue");
|
let operation_id = new_id("tqueue");
|
||||||
let fingerprint = Sha256::digest(format!(
|
let fingerprint = Sha256::digest(format!(
|
||||||
"ticket-queue:v1\0{workspace_id}\0{}\0{}\0{}",
|
"ticket-queue:v2\0{workspace_id}\0{}\0{}\0{assignment_json}",
|
||||||
ticket.meta.id,
|
ticket.meta.id,
|
||||||
ticket.meta.workflow_state.as_str(),
|
ticket.meta.workflow_state.as_str(),
|
||||||
assignment.assignment_id
|
|
||||||
))
|
))
|
||||||
.iter()
|
.iter()
|
||||||
.map(|byte| format!("{byte:02x}"))
|
.map(|byte| format!("{byte:02x}"))
|
||||||
.collect::<String>();
|
.collect::<String>();
|
||||||
event_attributes.extend([
|
event_attributes.extend([
|
||||||
(
|
(
|
||||||
"orchestrator_assignment_id".to_string(),
|
"queue_orchestrator_assignments".to_string(),
|
||||||
assignment.assignment_id,
|
assignment_json,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"routing_principal".to_string(),
|
"routing_principal".to_string(),
|
||||||
@@ -4399,18 +4438,30 @@ async fn execute_ticket_rest_operation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let result = execute_ticket_backend_operation(&backend, operation).map_err(Error::from)?;
|
let result = execute_ticket_backend_operation(&backend, operation).map_err(Error::from)?;
|
||||||
if is_mutation
|
if is_mutation {
|
||||||
&& let Some(target) = target
|
if let TicketBackendOperationResult::QueueOutcome(outcome) = &result {
|
||||||
&& let Ok(ticket) = backend.show(target)
|
for ticket_id in &outcome.queued_tickets {
|
||||||
{
|
notify_ticket_recipients(
|
||||||
notify_ticket_recipients(
|
api,
|
||||||
api,
|
workspace_id,
|
||||||
workspace_id,
|
ticket_id,
|
||||||
&ticket.meta.id,
|
TicketWorkflowState::Ready.as_str(),
|
||||||
&previous_state,
|
TicketWorkflowState::Queued.as_str(),
|
||||||
ticket.meta.workflow_state.as_str(),
|
source.clone(),
|
||||||
source,
|
);
|
||||||
);
|
}
|
||||||
|
} else if let Some(target) = target
|
||||||
|
&& let Ok(ticket) = backend.show(target)
|
||||||
|
{
|
||||||
|
notify_ticket_recipients(
|
||||||
|
api,
|
||||||
|
workspace_id,
|
||||||
|
&ticket.meta.id,
|
||||||
|
&previous_state,
|
||||||
|
ticket.meta.workflow_state.as_str(),
|
||||||
|
source,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
@@ -4734,7 +4785,7 @@ async fn scoped_queue_ticket_record(
|
|||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath((workspace_id, id)): AxumPath<(String, String)>,
|
AxumPath((workspace_id, id)): AxumPath<(String, String)>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
) -> ApiResult<StatusCode> {
|
) -> ApiResult<Json<ticket::TicketQueueOutcome>> {
|
||||||
let result = execute_ticket_rest_operation(
|
let result = execute_ticket_rest_operation(
|
||||||
&api,
|
&api,
|
||||||
&workspace_id,
|
&workspace_id,
|
||||||
@@ -4745,7 +4796,10 @@ async fn scoped_queue_ticket_record(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
ticket_rest_unit(result)
|
ticket_rest_result(result, |result| match result {
|
||||||
|
TicketBackendOperationResult::QueueOutcome(outcome) => Some(outcome),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
#[derive(Debug, serde::Deserialize)]
|
||||||
@@ -17871,7 +17925,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assign_test_orchestrator(&api, &ticket.id);
|
assign_test_orchestrator(&api, &ticket.id);
|
||||||
scoped_queue_ticket_record(State(api.clone()), AxumPath(path), HeaderMap::new())
|
let _ = scoped_queue_ticket_record(State(api.clone()), AxumPath(path), HeaderMap::new())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let queued = backend.show(ticket.id.into()).unwrap();
|
let queued = backend.show(ticket.id.into()).unwrap();
|
||||||
@@ -17889,6 +17943,195 @@ mod tests {
|
|||||||
assert!(event.attributes.contains_key("routing_request_fingerprint"));
|
assert!(event.attributes.contains_key("routing_request_fingerprint"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn queue_reports_dependency_cycle_before_assignment_validation() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
init_clean_git_workspace(dir.path());
|
||||||
|
let api = test_api(dir.path()).await;
|
||||||
|
let backend = browser_ticket_backend(&api).unwrap();
|
||||||
|
let mut first_input = ticket::NewTicket::new("First cycle Ticket");
|
||||||
|
first_input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||||
|
first_input.repository_id = Some(TEST_REPOSITORY_ID.to_string());
|
||||||
|
first_input.ref_selector = Some("develop".to_string());
|
||||||
|
let first = backend.create(first_input).unwrap();
|
||||||
|
let mut second_input = ticket::NewTicket::new("Second cycle Ticket");
|
||||||
|
second_input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||||
|
second_input.repository_id = Some(TEST_REPOSITORY_ID.to_string());
|
||||||
|
second_input.ref_selector = Some("develop".to_string());
|
||||||
|
let second = backend.create(second_input).unwrap();
|
||||||
|
for (ticket_id, target) in [
|
||||||
|
(first.id.clone(), second.id.clone()),
|
||||||
|
(second.id.clone(), first.id.clone()),
|
||||||
|
] {
|
||||||
|
backend
|
||||||
|
.add_ticket_relation(
|
||||||
|
ticket_id.into(),
|
||||||
|
ticket::NewTicketRelation {
|
||||||
|
kind: ticket::TicketRelationKind::DependsOn,
|
||||||
|
target,
|
||||||
|
note: None,
|
||||||
|
author: Some("test".to_string()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let error = execute_ticket_rest_operation(
|
||||||
|
&api,
|
||||||
|
TEST_WORKSPACE_ID,
|
||||||
|
HeaderMap::new(),
|
||||||
|
TicketBackendOperation::QueueReady {
|
||||||
|
id: TicketIdOrSlug::Id(first.id.clone()),
|
||||||
|
queued_by: "workspace-web".to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(error.error.to_string().contains("cycle"));
|
||||||
|
for ticket_id in [first.id, second.id] {
|
||||||
|
assert_eq!(
|
||||||
|
backend.show(ticket_id.into()).unwrap().meta.workflow_state,
|
||||||
|
TicketWorkflowState::Ready
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn browser_queue_eligibility_uses_authoritative_dependency_targets() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
init_clean_git_workspace(dir.path());
|
||||||
|
let api = test_api(dir.path()).await;
|
||||||
|
let backend = browser_ticket_backend(&api).unwrap();
|
||||||
|
let mut dependency_input = ticket::NewTicket::new("Invalid target dependency");
|
||||||
|
dependency_input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||||
|
dependency_input.repository_id = Some(TEST_REPOSITORY_ID.to_string());
|
||||||
|
dependency_input.ref_selector = Some("missing-ref".to_string());
|
||||||
|
let dependency = backend.create(dependency_input).unwrap();
|
||||||
|
let mut root_input = ticket::NewTicket::new("Queue root");
|
||||||
|
root_input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||||
|
root_input.repository_id = Some(TEST_REPOSITORY_ID.to_string());
|
||||||
|
root_input.ref_selector = Some("develop".to_string());
|
||||||
|
let root = backend.create(root_input).unwrap();
|
||||||
|
backend
|
||||||
|
.add_ticket_relation(
|
||||||
|
root.id.clone().into(),
|
||||||
|
ticket::NewTicketRelation {
|
||||||
|
kind: ticket::TicketRelationKind::DependsOn,
|
||||||
|
target: dependency.id.clone(),
|
||||||
|
note: None,
|
||||||
|
author: Some("test".to_string()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assign_test_orchestrator(&api, &root.id);
|
||||||
|
assign_test_orchestrator(&api, &dependency.id);
|
||||||
|
|
||||||
|
let Json(detail) = browser_ticket_detail(&api, &root.id).unwrap();
|
||||||
|
assert!(!detail.action_eligibility.can_queue);
|
||||||
|
assert!(
|
||||||
|
detail
|
||||||
|
.action_eligibility
|
||||||
|
.blockers
|
||||||
|
.iter()
|
||||||
|
.any(|blocker| blocker.contains("missing-ref"))
|
||||||
|
);
|
||||||
|
let result = scoped_queue_ticket_record(
|
||||||
|
State(api.clone()),
|
||||||
|
AxumPath((TEST_WORKSPACE_ID.to_string(), root.id.clone())),
|
||||||
|
HeaderMap::new(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
for ticket_id in [dependency.id, root.id] {
|
||||||
|
assert_eq!(
|
||||||
|
backend.show(ticket_id.into()).unwrap().meta.workflow_state,
|
||||||
|
TicketWorkflowState::Ready
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn queue_requires_assignments_for_every_ready_dependency() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
init_clean_git_workspace(dir.path());
|
||||||
|
let api = test_api(dir.path()).await;
|
||||||
|
let backend = browser_ticket_backend(&api).unwrap();
|
||||||
|
let mut dependency_input = ticket::NewTicket::new("Ready dependency");
|
||||||
|
dependency_input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||||
|
dependency_input.repository_id = Some(TEST_REPOSITORY_ID.to_string());
|
||||||
|
dependency_input.ref_selector = Some("develop".to_string());
|
||||||
|
let dependency = backend.create(dependency_input).unwrap();
|
||||||
|
let mut root_input = ticket::NewTicket::new("Queue root");
|
||||||
|
root_input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||||
|
root_input.repository_id = Some(TEST_REPOSITORY_ID.to_string());
|
||||||
|
root_input.ref_selector = Some("develop".to_string());
|
||||||
|
let root = backend.create(root_input).unwrap();
|
||||||
|
backend
|
||||||
|
.add_ticket_relation(
|
||||||
|
root.id.clone().into(),
|
||||||
|
ticket::NewTicketRelation {
|
||||||
|
kind: ticket::TicketRelationKind::DependsOn,
|
||||||
|
target: dependency.id.clone(),
|
||||||
|
note: Some("must queue first".to_string()),
|
||||||
|
author: Some("test".to_string()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assign_test_orchestrator(&api, &root.id);
|
||||||
|
let error = scoped_queue_ticket_record(
|
||||||
|
State(api.clone()),
|
||||||
|
AxumPath((TEST_WORKSPACE_ID.to_string(), root.id.clone())),
|
||||||
|
HeaderMap::new(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err()
|
||||||
|
.into_response();
|
||||||
|
assert_eq!(error.status(), StatusCode::CONFLICT);
|
||||||
|
assert_eq!(
|
||||||
|
backend
|
||||||
|
.show(root.id.clone().into())
|
||||||
|
.unwrap()
|
||||||
|
.meta
|
||||||
|
.workflow_state,
|
||||||
|
TicketWorkflowState::Ready
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
backend
|
||||||
|
.show(dependency.id.clone().into())
|
||||||
|
.unwrap()
|
||||||
|
.meta
|
||||||
|
.workflow_state,
|
||||||
|
TicketWorkflowState::Ready
|
||||||
|
);
|
||||||
|
|
||||||
|
assign_test_orchestrator(&api, &dependency.id);
|
||||||
|
let Json(outcome) = scoped_queue_ticket_record(
|
||||||
|
State(api.clone()),
|
||||||
|
AxumPath((TEST_WORKSPACE_ID.to_string(), root.id.clone())),
|
||||||
|
HeaderMap::new(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(outcome.requested_ticket, root.id);
|
||||||
|
assert_eq!(
|
||||||
|
outcome.queued_tickets,
|
||||||
|
vec![dependency.id.clone(), root.id.clone()]
|
||||||
|
);
|
||||||
|
for ticket_id in [dependency.id, root.id] {
|
||||||
|
let queued = backend.show(ticket_id.clone().into()).unwrap();
|
||||||
|
assert_eq!(queued.meta.workflow_state, TicketWorkflowState::Queued);
|
||||||
|
assert_eq!(
|
||||||
|
queued
|
||||||
|
.events
|
||||||
|
.last()
|
||||||
|
.and_then(|event| event.attributes.get("orchestrator_assignment_id"))
|
||||||
|
.cloned(),
|
||||||
|
Some(format!("orchestrator-{ticket_id}"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn queued_ticket_mutation_succeeds_without_orchestrator() {
|
async fn queued_ticket_mutation_succeeds_without_orchestrator() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -18667,9 +18910,13 @@ mod tests {
|
|||||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
id: ticket_id.clone(),
|
id: ticket_id.clone(),
|
||||||
};
|
};
|
||||||
|
let mut related_input = ticket::NewTicket::new("Related Browser Ticket");
|
||||||
|
related_input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||||
|
related_input.repository_id = Some(TEST_REPOSITORY_ID.to_string());
|
||||||
|
related_input.ref_selector = Some("develop".to_string());
|
||||||
let related_ticket_id = browser_ticket_backend(&api)
|
let related_ticket_id = browser_ticket_backend(&api)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.create(ticket::NewTicket::new("Related Browser Ticket"))
|
.create(related_input)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.id;
|
.id;
|
||||||
browser_ticket_backend(&api)
|
browser_ticket_backend(&api)
|
||||||
@@ -18677,7 +18924,7 @@ mod tests {
|
|||||||
.add_ticket_relation(
|
.add_ticket_relation(
|
||||||
ticket_id.clone().into(),
|
ticket_id.clone().into(),
|
||||||
ticket::NewTicketRelation {
|
ticket::NewTicketRelation {
|
||||||
kind: ticket::TicketRelationKind::Related,
|
kind: ticket::TicketRelationKind::DependsOn,
|
||||||
target: related_ticket_id.clone(),
|
target: related_ticket_id.clone(),
|
||||||
note: Some("Browser relation".to_string()),
|
note: Some("Browser relation".to_string()),
|
||||||
author: Some("browser-user".to_string()),
|
author: Some("browser-user".to_string()),
|
||||||
@@ -18711,7 +18958,7 @@ mod tests {
|
|||||||
assert!(edited.assignment_diagnostics.is_empty());
|
assert!(edited.assignment_diagnostics.is_empty());
|
||||||
assert_eq!(edited.relations.outgoing.len(), 1);
|
assert_eq!(edited.relations.outgoing.len(), 1);
|
||||||
assert_eq!(edited.relations.outgoing[0].target, related_ticket_id);
|
assert_eq!(edited.relations.outgoing[0].target, related_ticket_id);
|
||||||
assert_eq!(edited.relations.outgoing[0].kind, "related");
|
assert_eq!(edited.relations.outgoing[0].kind, "depends_on");
|
||||||
|
|
||||||
let Json(commented) = scoped_append_ticket_event(
|
let Json(commented) = scoped_append_ticket_event(
|
||||||
State(api.clone()),
|
State(api.clone()),
|
||||||
@@ -18741,6 +18988,18 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(ready.state, "ready");
|
assert_eq!(ready.state, "ready");
|
||||||
assign_test_orchestrator(&api, &ticket_id);
|
assign_test_orchestrator(&api, &ticket_id);
|
||||||
|
assign_test_orchestrator(&api, &related_ticket_id);
|
||||||
|
let Json(ready_detail) = scoped_get_ticket(State(api.clone()), AxumPath(path()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
ready_detail.action_eligibility.can_queue,
|
||||||
|
"Queue blockers: {:?}",
|
||||||
|
ready_detail.action_eligibility.blockers
|
||||||
|
);
|
||||||
|
assert!(ready_detail.action_eligibility.blockers.is_empty());
|
||||||
|
assert_eq!(ready_detail.relations.blockers.len(), 1);
|
||||||
|
assert_eq!(ready_detail.relations.blockers[0].reason_kind, "depends_on");
|
||||||
|
|
||||||
let Json(queued) = scoped_queue_ticket(
|
let Json(queued) = scoped_queue_ticket(
|
||||||
State(api.clone()),
|
State(api.clone()),
|
||||||
@@ -18751,6 +19010,8 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(queued.state, "queued");
|
assert_eq!(queued.state, "queued");
|
||||||
assert_eq!(queued.queued_by.as_deref(), Some("workspace-web"));
|
assert_eq!(queued.queued_by.as_deref(), Some("workspace-web"));
|
||||||
|
assert_eq!(queued.relations.blockers.len(), 1);
|
||||||
|
assert_eq!(queued.relations.blockers[0].reason_kind, "depends_on");
|
||||||
let Json(closed) = scoped_close_ticket(
|
let Json(closed) = scoped_close_ticket(
|
||||||
State(api),
|
State(api),
|
||||||
AxumPath(path()),
|
AxumPath(path()),
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export type TicketRoleAssignmentSummary = { assignment_id: string, role: string,
|
|||||||
|
|
||||||
export type TicketAssignmentPrincipalSummary = { "kind": "user", account_id: string, } | { "kind": "worker", runtime_id: string, worker_id: string, } | { "kind": "workspace_agent", agent_key: string, };
|
export type TicketAssignmentPrincipalSummary = { "kind": "user", account_id: string, } | { "kind": "worker", runtime_id: string, worker_id: string, } | { "kind": "workspace_agent", agent_key: string, };
|
||||||
|
|
||||||
export type TicketActionEligibility = { can_assign_orchestrator: boolean, can_unassign_orchestrator: boolean, can_queue: boolean, can_start_manual_coder: boolean, blockers: Array<string>, };
|
export type TicketActionEligibility = { can_assign_orchestrator: boolean, can_unassign_orchestrator: boolean, can_queue: boolean, can_start_manual_coder: boolean, queue_tickets: Array<string>, blockers: Array<string>, };
|
||||||
|
|
||||||
export type TicketMergeRequestSummary = { merge_request_id: string, repository_id: string, state: string, review_status: string, selector_from: string | null, selector_to: string, updated_at: string, current_subject_ref: string | null, review_subject_ref: string | null, review_requested_at: string | null, review_submitted_at: string | null, review_excerpt: string | null, };
|
export type TicketMergeRequestSummary = { merge_request_id: string, repository_id: string, state: string, review_status: string, selector_from: string | null, selector_to: string, updated_at: string, current_subject_ref: string | null, review_subject_ref: string | null, review_requested_at: string | null, review_submitted_at: string | null, review_excerpt: string | null, };
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,14 @@ Deno.test("ticket detail uses server-derived role assignment actions", async ()
|
|||||||
);
|
);
|
||||||
|
|
||||||
assertEquals(source.includes("ticket.action_eligibility.can_queue"), true);
|
assertEquals(source.includes("ticket.action_eligibility.can_queue"), true);
|
||||||
|
assertEquals(source.includes("ticket.relations.blockers.length > 0"), true);
|
||||||
|
assertEquals(source.includes("ticket.action_eligibility.queue_tickets"), true);
|
||||||
|
assertEquals(source.includes("This operation queues:"), true);
|
||||||
|
assertEquals(source.includes("outcome.queued_tickets.join"), true);
|
||||||
|
assertEquals(
|
||||||
|
source.includes("resolve the listed blockers before Queue"),
|
||||||
|
false,
|
||||||
|
);
|
||||||
assertEquals(
|
assertEquals(
|
||||||
source.includes("ticket.action_eligibility.can_assign_orchestrator"),
|
source.includes("ticket.action_eligibility.can_assign_orchestrator"),
|
||||||
true,
|
true,
|
||||||
|
|||||||
@@ -38,6 +38,11 @@
|
|||||||
if (!loadedTicket) throw new Error(initialData.ticket.error ?? "ticket load failed");
|
if (!loadedTicket) throw new Error(initialData.ticket.error ?? "ticket load failed");
|
||||||
const loadedRepositories = initialData.repositories.data;
|
const loadedRepositories = initialData.repositories.data;
|
||||||
|
|
||||||
|
type QueueOutcome = {
|
||||||
|
requested_ticket: string;
|
||||||
|
queued_tickets: string[];
|
||||||
|
};
|
||||||
|
|
||||||
let ticket = $state<TicketDetail>(loadedTicket);
|
let ticket = $state<TicketDetail>(loadedTicket);
|
||||||
const mergeRequest = $derived(ticket.merge_request);
|
const mergeRequest = $derived(ticket.merge_request);
|
||||||
let editing = $state(false);
|
let editing = $state(false);
|
||||||
@@ -52,6 +57,7 @@
|
|||||||
let resolution = $state("");
|
let resolution = $state("");
|
||||||
let busy = $state<string | null>(null);
|
let busy = $state<string | null>(null);
|
||||||
let errorMessage = $state<string | null>(null);
|
let errorMessage = $state<string | null>(null);
|
||||||
|
let queueMessage = $state<string | null>(null);
|
||||||
let readyOperationKey = $state<string | null>(null);
|
let readyOperationKey = $state<string | null>(null);
|
||||||
let manualRuntimeId = $state("");
|
let manualRuntimeId = $state("");
|
||||||
let manualWorkerId = $state("");
|
let manualWorkerId = $state("");
|
||||||
@@ -121,6 +127,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function queueTicket(): Promise<void> {
|
||||||
|
if (busy) return;
|
||||||
|
busy = "queue";
|
||||||
|
errorMessage = null;
|
||||||
|
queueMessage = null;
|
||||||
|
try {
|
||||||
|
const outcome = await workspaceApiJsonWithBody<QueueOutcome>(
|
||||||
|
`${ticketPath}/queue`,
|
||||||
|
{ method: "POST", body: JSON.stringify({}) },
|
||||||
|
);
|
||||||
|
queueMessage = `Queued ${outcome.queued_tickets.length} Ticket(s): ${outcome.queued_tickets.join(", ")}`;
|
||||||
|
applyTicket(await workspaceApiJson<TicketDetail>(ticketPath));
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
} finally {
|
||||||
|
busy = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function mutateAssignment(
|
async function mutateAssignment(
|
||||||
action: string,
|
action: string,
|
||||||
role: "orchestrator" | "coder",
|
role: "orchestrator" | "coder",
|
||||||
@@ -288,6 +313,10 @@
|
|||||||
<div class="workspace-callout is-error" role="alert">{errorMessage}</div>
|
<div class="workspace-callout is-error" role="alert">{errorMessage}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if queueMessage}
|
||||||
|
<div class="workspace-callout" role="status">{queueMessage}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if editing}
|
{#if editing}
|
||||||
<form class="ticket-editor" onsubmit={saveEdit}>
|
<form class="ticket-editor" onsubmit={saveEdit}>
|
||||||
<label>Title<input bind:value={editTitle} required /></label>
|
<label>Title<input bind:value={editTitle} required /></label>
|
||||||
@@ -496,11 +525,16 @@
|
|||||||
<p class="workspace-empty-copy">Choose a healthy repository and an effective ref selector before marking ready.</p>
|
<p class="workspace-empty-copy">Choose a healthy repository and an effective ref selector before marking ready.</p>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if ticket.state === "ready"}
|
{:else if ticket.state === "ready"}
|
||||||
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue" || !ticket.action_eligibility.can_queue} onclick={() => mutate("queue", "/queue", {})}>
|
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue" || !ticket.action_eligibility.can_queue} onclick={() => void queueTicket()}>
|
||||||
{busy === "queue" ? "Queueing…" : "Queue ticket"}
|
{busy === "queue" ? "Queueing…" : `Queue ${ticket.action_eligibility.queue_tickets.length} Ticket(s)`}
|
||||||
</button>
|
</button>
|
||||||
{#if !ticket.action_eligibility.can_queue}
|
{#if !ticket.action_eligibility.can_queue}
|
||||||
<p class="workspace-empty-copy">Assign the Orchestrator role and resolve the listed blockers before Queue.</p>
|
<p class="workspace-empty-copy">Queue requires a valid target, an active Orchestrator assignment, no active Coder assignment, and no dependency still in planning.</p>
|
||||||
|
{:else if ticket.action_eligibility.queue_tickets.length > 0}
|
||||||
|
<p class="workspace-empty-copy">This operation queues: {ticket.action_eligibility.queue_tickets.join(", ")}.</p>
|
||||||
|
{#if ticket.relations.blockers.length > 0}
|
||||||
|
<p class="workspace-empty-copy">Ready dependencies are queued atomically. Queued or in-progress dependencies remain unchanged for the Orchestrator to schedule.</p>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user