From 1c2d2845780e1fd3cb8bb8d0dd06724cf1b6fd19 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 30 Jul 2026 22:13:01 +0900 Subject: [PATCH 1/2] workspace: add typed Ticket API and targets --- crates/ticket/src/lib.rs | 303 ++++++++++++++- crates/ticket/src/tool.rs | 12 + crates/workspace-server/src/authority.rs | 34 +- crates/workspace-server/src/records.rs | 20 + crates/workspace-server/src/server.rs | 445 ++++++++++++++++++++++- crates/workspace-server/src/store.rs | 221 ++++------- 6 files changed, 863 insertions(+), 172 deletions(-) diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index 05f38972..999be609 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -495,6 +495,8 @@ pub struct NewTicket { pub workflow_state: Option, pub queued_by: Option, pub queued_at: Option, + pub repository_id: Option, + pub ref_selector: Option, } impl NewTicket { @@ -513,10 +515,51 @@ impl NewTicket { workflow_state: None, queued_by: None, queued_at: None, + repository_id: None, + ref_selector: None, } } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum TicketTargetEdit { + Set { + repository_id: String, + ref_selector: Option, + }, + Clear, +} + +impl TicketTargetEdit { + fn validate(&self) -> Result<()> { + if let Self::Set { + repository_id, + ref_selector, + } = self + { + validate_ticket_target(Some(repository_id), ref_selector.as_deref())?; + } + Ok(()) + } +} + +fn validate_ticket_target(repository_id: Option<&str>, ref_selector: Option<&str>) -> Result<()> { + let Some(repository_id) = repository_id else { + if ref_selector.is_some() { + return Err(TicketError::Conflict( + "ref_selector requires repository_id".to_string(), + )); + } + return Ok(()); + }; + validate_required_event_value("repository_id", repository_id)?; + if let Some(ref_selector) = ref_selector { + validate_required_event_value("ref_selector", ref_selector)?; + } + Ok(()) +} + #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct TicketItemEdit { pub title: Option, @@ -525,6 +568,7 @@ pub struct TicketItemEdit { pub body: Option, #[serde(default)] pub body_replacement: Option, + pub target: Option, pub author: Option, } @@ -560,12 +604,18 @@ impl TicketItemEdit { if let Some(replacement) = &self.body_replacement { replacement.validate()?; } + if let Some(target) = &self.target { + target.validate()?; + } Ok(()) } fn has_changes(&self) -> bool { - self.title.is_some() || self.body.is_some() || self.body_replacement.is_some() + self.title.is_some() + || self.body.is_some() + || self.body_replacement.is_some() + || self.target.is_some() } } @@ -1373,6 +1423,8 @@ pub struct TicketMeta { pub workflow_state_explicit: bool, pub queued_by: Option, pub queued_at: Option, + pub repository_id: Option, + pub ref_selector: Option, pub raw: BTreeMap, } @@ -2298,6 +2350,8 @@ CREATE TABLE IF NOT EXISTS typed_tickets ( queued_by TEXT, queued_at TEXT, resolution TEXT, + repository_id TEXT, + ref_selector TEXT, PRIMARY KEY (workspace_id, ticket_id) ); CREATE TABLE IF NOT EXISTS typed_ticket_labels ( @@ -2368,7 +2422,11 @@ CREATE TABLE IF NOT EXISTS typed_ticket_artifacts ( PRIMARY KEY (workspace_id, ticket_id, relative_path), FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE ); -"#).map_err(sqlite_err) +"#) + .map_err(sqlite_err)?; + ensure_sqlite_ticket_column(conn, "repository_id", "TEXT")?; + ensure_sqlite_ticket_column(conn, "ref_selector", "TEXT")?; + Ok(()) } fn with_write(&self, op: impl FnOnce(&Connection) -> Result) -> Result { @@ -2475,9 +2533,9 @@ CREATE TABLE IF NOT EXISTS typed_ticket_artifacts ( fn insert_ticket(&self, conn: &Connection, ticket: &Ticket) -> Result<()> { conn.execute(r#"INSERT INTO typed_tickets - (workspace_id, ticket_id, slug, title, status, kind, priority, body, created_at, updated_at, assignee, readiness, workflow_state, workflow_state_explicit, queued_by, queued_at, resolution) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)"#, - params![self.workspace_id, ticket.meta.id, ticket.meta.slug, ticket.meta.title, ticket.meta.status.as_str(), ticket.meta.kind, ticket.meta.priority, ticket.document.body.as_str(), ticket.meta.created_at, ticket.meta.updated_at, ticket.meta.assignee, ticket.meta.readiness, ticket.meta.workflow_state.as_str(), if ticket.meta.workflow_state_explicit { 1 } else { 0 }, ticket.meta.queued_by, ticket.meta.queued_at, ticket.resolution.as_ref().map(|body| body.as_str())] + (workspace_id, ticket_id, slug, title, status, kind, priority, body, created_at, updated_at, assignee, readiness, workflow_state, workflow_state_explicit, queued_by, queued_at, resolution, repository_id, ref_selector) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)"#, + params![self.workspace_id, ticket.meta.id, ticket.meta.slug, ticket.meta.title, ticket.meta.status.as_str(), ticket.meta.kind, ticket.meta.priority, ticket.document.body.as_str(), ticket.meta.created_at, ticket.meta.updated_at, ticket.meta.assignee, ticket.meta.readiness, ticket.meta.workflow_state.as_str(), if ticket.meta.workflow_state_explicit { 1 } else { 0 }, ticket.meta.queued_by, ticket.meta.queued_at, ticket.resolution.as_ref().map(|body| body.as_str()), ticket.meta.repository_id, ticket.meta.ref_selector] ).map_err(sqlite_err)?; self.insert_ordered_values( conn, @@ -2565,12 +2623,14 @@ CREATE TABLE IF NOT EXISTS typed_ticket_artifacts ( workflow_state_explicit: row.get::<_, i64>(13)? != 0, queued_by: row.get(14)?, queued_at: row.get(15)?, + repository_id: row.get(16)?, + ref_selector: row.get(17)?, raw: BTreeMap::new(), }) } fn load_ticket(&self, conn: &Connection, ticket_id: &str) -> Result { - let (mut meta, body, resolution): (TicketMeta, String, Option) = conn.query_row(r#"SELECT ticket_id, slug, title, status, kind, priority, created_at, updated_at, assignee, readiness, body, resolution, workflow_state, workflow_state_explicit, queued_by, queued_at FROM typed_tickets WHERE workspace_id = ?1 AND ticket_id = ?2"#, + let (mut meta, body, resolution): (TicketMeta, String, Option) = conn.query_row(r#"SELECT ticket_id, slug, title, status, kind, priority, created_at, updated_at, assignee, readiness, body, resolution, workflow_state, workflow_state_explicit, queued_by, queued_at, repository_id, ref_selector FROM typed_tickets WHERE workspace_id = ?1 AND ticket_id = ?2"#, params![self.workspace_id, ticket_id], |row| Ok((Self::ticket_meta_from_row(row)?, row.get(10)?, row.get(11)?))).optional().map_err(sqlite_err)?.ok_or_else(|| TicketError::NotFound(ticket_id.to_string()))?; meta.labels = self.load_ordered_values(conn, "typed_ticket_labels", "label", ticket_id)?; meta.risk_flags = @@ -2733,7 +2793,7 @@ CREATE TABLE IF NOT EXISTS typed_ticket_artifacts ( conn: &Connection, filter: TicketListQuery, ) -> Result> { - let mut stmt = conn.prepare(r#"SELECT ticket_id, slug, title, status, kind, priority, created_at, updated_at, assignee, readiness, body, resolution, workflow_state, workflow_state_explicit, queued_by, queued_at FROM typed_tickets WHERE workspace_id = ?1 ORDER BY ticket_id ASC"#).map_err(sqlite_err)?; + let mut stmt = conn.prepare(r#"SELECT ticket_id, slug, title, status, kind, priority, created_at, updated_at, assignee, readiness, body, resolution, workflow_state, workflow_state_explicit, queued_by, queued_at, repository_id, ref_selector FROM typed_tickets WHERE workspace_id = ?1 ORDER BY ticket_id ASC"#).map_err(sqlite_err)?; let rows = stmt .query_map(params![self.workspace_id], Self::ticket_meta_from_row) .map_err(sqlite_err)?; @@ -2806,6 +2866,30 @@ CREATE TABLE IF NOT EXISTS typed_ticket_artifacts ( } } +fn ensure_sqlite_ticket_column( + conn: &rusqlite::Connection, + name: &str, + sql_type: &str, +) -> Result<()> { + let mut statement = conn + .prepare("PRAGMA table_info(typed_tickets)") + .map_err(sqlite_err)?; + let columns = statement + .query_map([], |row| row.get::<_, String>(1)) + .map_err(sqlite_err)?; + for column in columns { + if column.map_err(sqlite_err)? == name { + return Ok(()); + } + } + conn.execute( + format!("ALTER TABLE typed_tickets ADD COLUMN {name} {sql_type}").as_str(), + [], + ) + .map_err(sqlite_err)?; + Ok(()) +} + fn finish_sqlite_transaction(conn: &Connection, result: Result) -> Result { match result { Ok(output) => { @@ -2865,6 +2949,10 @@ impl TicketBackend for SqliteTicketBackend { "ticket title must not be empty".to_string(), )); } + validate_ticket_target( + input.repository_id.as_deref(), + input.ref_selector.as_deref(), + )?; let base_millis = unix_epoch_millis_now().map_err(|err| { TicketError::Conflict(format!("failed to read ticket id timestamp: {err}")) })?; @@ -2900,6 +2988,8 @@ impl TicketBackend for SqliteTicketBackend { workflow_state_explicit: true, queued_by: input.queued_by, queued_at: input.queued_at, + repository_id: input.repository_id, + ref_selector: input.ref_selector, raw: BTreeMap::new(), }; let body = if input.body.as_str() == DEFAULT_TICKET_BODY { @@ -2948,7 +3038,7 @@ impl TicketBackend for SqliteTicketBackend { edit.validate_body_edit_request()?; if !edit.has_changes() { return Err(TicketError::Conflict( - "TicketEditItem requires at least one of title, body, or body_replacement" + "TicketEditItem requires at least one of title, body, body_replacement, or target" .to_string(), )); } @@ -2986,6 +3076,20 @@ impl TicketBackend for SqliteTicketBackend { if let Some(body) = updated_body.as_ref() { conn.execute("UPDATE typed_tickets SET body = ?3, updated_at = ?4 WHERE workspace_id = ?1 AND ticket_id = ?2", params![self.workspace_id, ticket_id, body.as_str(), now]).map_err(sqlite_err)?; } + if let Some(target) = edit.target.as_ref() { + let (repository_id, ref_selector) = match target { + TicketTargetEdit::Set { + repository_id, + ref_selector, + } => (Some(repository_id.as_str()), ref_selector.as_deref()), + TicketTargetEdit::Clear => (None, None), + }; + conn.execute( + "UPDATE typed_tickets SET repository_id = ?3, ref_selector = ?4, updated_at = ?5 WHERE workspace_id = ?1 AND ticket_id = ?2", + params![self.workspace_id, ticket_id, repository_id, ref_selector, now], + ) + .map_err(sqlite_err)?; + } let mut changes = Vec::new(); if edit.title.is_some() { @@ -2994,6 +3098,9 @@ impl TicketBackend for SqliteTicketBackend { if !matches!(body_edit_audit, TicketBodyEditAudit::None) { changes.push("body"); } + if edit.target.is_some() { + changes.push("target"); + } let mut attributes = BTreeMap::new(); attributes.insert("changes".to_string(), changes.join(",")); let body = match body_edit_audit { @@ -3305,6 +3412,10 @@ impl TicketBackend for LocalTicketBackend { "ticket title must not be empty".to_string(), )); } + validate_ticket_target( + input.repository_id.as_deref(), + input.ref_selector.as_deref(), + )?; let base_millis = unix_epoch_millis_now().map_err(|err| { TicketError::Conflict(format!("failed to read ticket id timestamp: {err}")) })?; @@ -3375,6 +3486,18 @@ impl TicketBackend for LocalTicketBackend { format_yaml_string_scalar(queued_at.as_str()), )); } + if let Some(repository_id) = input.repository_id { + fields.push(( + "repository_id".to_string(), + format_yaml_string_scalar(repository_id.as_str()), + )); + } + if let Some(ref_selector) = input.ref_selector { + fields.push(( + "ref_selector".to_string(), + format_yaml_string_scalar(ref_selector.as_str()), + )); + } let item_body = if input.body.as_str() == DEFAULT_TICKET_BODY { self.generated_default_body() } else { @@ -3426,6 +3549,48 @@ impl TicketBackend for LocalTicketBackend { } })?; } + if let Some(target) = edit.target.as_ref() { + match target { + TicketTargetEdit::Set { + repository_id, + ref_selector, + } => { + content = replace_frontmatter_fields( + &content, + &[("repository_id", repository_id.as_str())], + ) + .map_err(|message| TicketError::Parse { + path: item.clone(), + message, + })?; + if let Some(ref_selector) = ref_selector { + content = replace_frontmatter_fields( + &content, + &[("ref_selector", ref_selector.as_str())], + ) + .map_err(|message| TicketError::Parse { + path: item.clone(), + message, + })?; + } else { + content = remove_frontmatter_fields(&content, &["ref_selector"]).map_err( + |message| TicketError::Parse { + path: item.clone(), + message, + }, + )?; + } + } + TicketTargetEdit::Clear => { + content = + remove_frontmatter_fields(&content, &["repository_id", "ref_selector"]) + .map_err(|message| TicketError::Parse { + path: item.clone(), + message, + })?; + } + } + } if let Some(body) = edit.body.as_ref() { content = replace_item_body(&content, body.as_str()).map_err(|message| { TicketError::Parse { @@ -3461,6 +3626,9 @@ impl TicketBackend for LocalTicketBackend { if !matches!(body_edit_audit, TicketBodyEditAudit::None) { changes.push("body"); } + if edit.target.is_some() { + changes.push("target"); + } let mut attrs = Vec::new(); attrs.push(("changes", changes.join(","))); let body = match body_edit_audit { @@ -4104,6 +4272,8 @@ struct TicketItemFrontmatter { state_explicit: bool, queued_by: Option, queued_at: Option, + repository_id: Option, + ref_selector: Option, raw: BTreeMap, } @@ -4211,6 +4381,8 @@ fn parse_ticket_frontmatter(content: &str) -> std::result::Result TicketMeta { workflow_state_explicit: frontmatter.state_explicit, queued_by: frontmatter.queued_by, queued_at: frontmatter.queued_at, + repository_id: frontmatter.repository_id, + ref_selector: frontmatter.ref_selector, raw: frontmatter.raw, } } @@ -5032,6 +5206,37 @@ fn replace_frontmatter_fields( Ok(out) } +fn remove_frontmatter_fields( + content: &str, + fields: &[&str], +) -> std::result::Result { + let mut lines: Vec = content.lines().map(ToOwned::to_owned).collect(); + if lines.first().map(String::as_str) != Some("---") { + return Err("item.md missing frontmatter opener".to_string()); + } + let Some(end) = lines + .iter() + .enumerate() + .skip(1) + .find_map(|(idx, line)| (line == "---").then_some(idx)) + else { + return Err("item.md missing frontmatter closer".to_string()); + }; + for index in (1..end).rev() { + let should_remove = lines[index] + .split_once(':') + .is_some_and(|(key, _)| fields.contains(&key.trim())); + if should_remove { + lines.remove(index); + } + } + let mut out = lines.join("\n"); + if content.ends_with('\n') { + out.push('\n'); + } + Ok(out) +} + fn replace_item_body(content: &str, body: &str) -> std::result::Result { let mut lines = content.lines(); if lines.next() != Some("---") { @@ -5692,6 +5897,56 @@ mod tests { LocalTicketBackend::new(dir.path().join("tickets")) } + fn assert_ticket_target_edit_semantics(backend: &B) { + let mut input = NewTicket::new("Target Ticket"); + input.repository_id = Some("main".to_string()); + input.ref_selector = Some("feature/api".to_string()); + let created = backend.create(input).unwrap(); + let ticket = backend + .show(TicketIdOrSlug::Id(created.id.clone())) + .unwrap(); + assert_eq!(ticket.meta.repository_id.as_deref(), Some("main")); + assert_eq!(ticket.meta.ref_selector.as_deref(), Some("feature/api")); + + let edited = backend + .edit_item( + TicketIdOrSlug::Id(created.id.clone()), + TicketItemEdit { + target: Some(TicketTargetEdit::Set { + repository_id: "secondary".to_string(), + ref_selector: None, + }), + author: Some("tester".to_string()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(edited.meta.repository_id.as_deref(), Some("secondary")); + assert_eq!(edited.meta.ref_selector, None); + let edit_event = edited + .events + .iter() + .rev() + .find(|event| event.kind == TicketEventKind::Other("item_edit".to_string())) + .expect("item_edit event"); + assert_eq!( + edit_event.attributes.get("changes"), + Some(&"target".to_string()) + ); + + let cleared = backend + .edit_item( + TicketIdOrSlug::Id(created.id), + TicketItemEdit { + target: Some(TicketTargetEdit::Clear), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(cleared.meta.repository_id, None); + assert_eq!(cleared.meta.ref_selector, None); + } + fn assert_partial_body_replacement_semantics(backend: &B) { let mut input = NewTicket::new("Body Edit Ticket"); input.body = MarkdownText::new("alpha\nbeta\nalpha\n"); @@ -6092,6 +6347,38 @@ state: planning assert!(report.is_ok(), "{:?}", report.diagnostics); } + #[test] + fn local_backend_persists_and_edits_ticket_target() { + let tmp = TempDir::new().unwrap(); + let backend = backend(&tmp); + assert_ticket_target_edit_semantics(&backend); + } + + #[test] + fn sqlite_backend_persists_and_edits_ticket_target() { + let tmp = TempDir::new().unwrap(); + let backend = SqliteTicketBackend::new(tmp.path().join("workspace.db"), "workspace-test"); + assert_ticket_target_edit_semantics(&backend); + } + + #[test] + fn sqlite_ticket_target_columns_are_added_to_existing_table() { + let tmp = TempDir::new().unwrap(); + let conn = rusqlite::Connection::open(tmp.path().join("workspace.db")).unwrap(); + conn.execute_batch("CREATE TABLE typed_tickets (ticket_id TEXT PRIMARY KEY);") + .unwrap(); + ensure_sqlite_ticket_column(&conn, "repository_id", "TEXT").unwrap(); + ensure_sqlite_ticket_column(&conn, "ref_selector", "TEXT").unwrap(); + let mut statement = conn.prepare("PRAGMA table_info(typed_tickets)").unwrap(); + let columns = statement + .query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .collect::, _>>() + .unwrap(); + assert!(columns.iter().any(|column| column == "repository_id")); + assert!(columns.iter().any(|column| column == "ref_selector")); + } + #[test] fn local_backend_edit_item_supports_partial_body_replacement() { let tmp = TempDir::new().unwrap(); diff --git a/crates/ticket/src/tool.rs b/crates/ticket/src/tool.rs index 6477a05d..fb88153e 100644 --- a/crates/ticket/src/tool.rs +++ b/crates/ticket/src/tool.rs @@ -382,6 +382,12 @@ struct TicketCreateParams { /// Optional queued_at frontmatter value. #[serde(default)] queued_at: Option, + /// Optional target Workspace repository id. + #[serde(default)] + repository_id: Option, + /// Optional target Git ref selector. Requires `repository_id`. + #[serde(default)] + ref_selector: Option, } #[derive(Debug, Deserialize, schemars::JsonSchema)] @@ -403,6 +409,9 @@ struct TicketEditItemParams { /// Replace every occurrence of `old_string`; by default exactly one occurrence is required. #[serde(default)] replace_all: bool, + /// Optional target repository/ref update. + #[serde(default)] + target: Option, /// Optional thread author for the audited item_edit event. #[serde(default)] author: Option, @@ -916,6 +925,8 @@ impl Tool for TicketCreateTool { input.workflow_state = params.state.map(TicketWorkflowStateParam::into_state); input.queued_by = params.queued_by; input.queued_at = params.queued_at; + input.repository_id = params.repository_id; + input.ref_selector = params.ref_selector; let created = self .backend @@ -959,6 +970,7 @@ impl Tool for TicketEditItemTool { title: params.title, body: params.body.map(MarkdownText::new), body_replacement, + target: params.target, author: params.author, }; let ticket = self diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index bd4f7f1e..2105cfa0 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -10,7 +10,7 @@ use ticket::{ use crate::records::{ ObjectiveDetail, ObjectiveResourceSummary, ObjectiveSummary, ProjectRecordList, TicketDetail, - TicketSummary, summarize_body, truncate_body, validate_project_id, + TicketEventDetail, TicketSummary, summarize_body, truncate_body, validate_project_id, }; use crate::store::{ ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord, @@ -19,6 +19,8 @@ use crate::store::{ use crate::{Error, Result}; const DETAIL_BODY_LIMIT: usize = 64 * 1024; +const TICKET_EVENT_LIMIT: usize = 100; +const TICKET_EVENT_BODY_LIMIT: usize = 16 * 1024; const DEFAULT_MEMORY_DOCUMENT_BODY: &str = "# Memory\n\n"; const RECORD_SOURCE_WORKSPACE_SQLITE: &str = "workspace-sqlite"; @@ -244,6 +246,25 @@ impl TicketAuthority for SqliteWorkspaceAuthority { .show(TicketIdOrSlug::Id(id.to_string()))?; let (body, body_truncated) = truncate_body(ticket.document.body.as_str(), DETAIL_BODY_LIMIT); + let event_start = ticket.events.len().saturating_sub(TICKET_EVENT_LIMIT); + let events = ticket.events[event_start..] + .iter() + .enumerate() + .map(|(index, event)| TicketEventDetail { + sequence: event_start + index, + kind: event.kind.as_str().to_owned(), + author: event.author.clone(), + at: event.at.clone(), + status: event.status.clone(), + from: event.from.clone(), + to: event.to.clone(), + reason: event.reason.clone(), + state_field: event.state_field.clone(), + heading: event.heading.clone(), + body: (!event.body.as_str().is_empty()) + .then(|| truncate_body(event.body.as_str(), TICKET_EVENT_BODY_LIMIT).0), + }) + .collect(); Ok(TicketDetail { id: ticket.meta.id, title: ticket.meta.title, @@ -253,11 +274,22 @@ impl TicketAuthority for SqliteWorkspaceAuthority { updated_at: ticket.meta.updated_at, queued_by: ticket.meta.queued_by, queued_at: ticket.meta.queued_at, + repository_id: ticket.meta.repository_id, + ref_selector: ticket.meta.ref_selector, risk_flags: ticket.meta.risk_flags, body, body_truncated, event_count: ticket.events.len(), + events, artifact_count: ticket.artifacts.len(), + artifacts: ticket + .artifacts + .into_iter() + .map(|artifact| artifact.relative_path.display().to_string()) + .collect(), + resolution: ticket + .resolution + .map(|resolution| resolution.as_str().to_string()), record_source: "sqlite_yoi_ticket".to_string(), }) } diff --git a/crates/workspace-server/src/records.rs b/crates/workspace-server/src/records.rs index 121741e7..830a8a66 100644 --- a/crates/workspace-server/src/records.rs +++ b/crates/workspace-server/src/records.rs @@ -41,14 +41,34 @@ pub struct TicketDetail { pub updated_at: Option, pub queued_by: Option, pub queued_at: Option, + pub repository_id: Option, + pub ref_selector: Option, pub risk_flags: Vec, pub body: String, pub body_truncated: bool, pub event_count: usize, + pub events: Vec, pub artifact_count: usize, + pub artifacts: Vec, + pub resolution: Option, pub record_source: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TicketEventDetail { + pub sequence: usize, + pub kind: String, + pub author: Option, + pub at: Option, + pub status: Option, + pub from: Option, + pub to: Option, + pub reason: Option, + pub state_field: Option, + pub heading: Option, + pub body: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ObjectiveSummary { pub id: String, diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 3ee955db..736591c0 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -19,6 +19,11 @@ use memory::backend::{ use protocol::stream::{decode_method, encode_event}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use ticket::{ + MarkdownText, NewTicketEvent, TicketBackend, TicketBodyReplacement, TicketEventKind, + TicketIdOrSlug, TicketItemEdit, TicketReview, TicketReviewResult, TicketStateChange, + TicketTargetEdit, TicketWorkflowState, +}; use ticket::{ SqliteTicketBackend, TicketBackendHttpResponse, TicketBackendOperation, execute_ticket_backend_operation, @@ -511,7 +516,30 @@ pub fn build_router(api: WorkspaceApi) -> Router { "/api/w/{workspace_id}/skills/{name}/activate", get(scoped_activate_skill), ) - .route("/api/w/{workspace_id}/tickets/{id}", get(scoped_get_ticket)) + .route( + "/api/w/{workspace_id}/tickets/{id}", + get(scoped_get_ticket).patch(scoped_edit_ticket_item), + ) + .route( + "/api/w/{workspace_id}/tickets/{id}/state", + post(scoped_transition_ticket_state), + ) + .route( + "/api/w/{workspace_id}/tickets/{id}/events", + post(scoped_append_ticket_event), + ) + .route( + "/api/w/{workspace_id}/tickets/{id}/reviews", + post(scoped_review_ticket), + ) + .route( + "/api/w/{workspace_id}/tickets/{id}/queue", + post(scoped_queue_ticket), + ) + .route( + "/api/w/{workspace_id}/tickets/{id}/close", + post(scoped_close_ticket), + ) .route("/api/objectives", get(list_objectives)) .route( "/api/w/{workspace_id}/objectives", @@ -1526,6 +1554,220 @@ async fn scoped_get_ticket( get_ticket(State(api), AxumPath(path.id)).await } +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct BrowserEditTicketRequest { + title: Option, + body: Option, + old_string: Option, + new_string: Option, + #[serde(default)] + replace_all: bool, + target: Option, + author: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct BrowserTransitionTicketStateRequest { + state: TicketWorkflowState, + reason: Option, + body: Option, + author: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +enum BrowserTicketThreadRole { + Comment, + Plan, + Decision, + ImplementationReport, +} + +impl From for TicketEventKind { + fn from(role: BrowserTicketThreadRole) -> Self { + match role { + BrowserTicketThreadRole::Comment => Self::Comment, + BrowserTicketThreadRole::Plan => Self::Plan, + BrowserTicketThreadRole::Decision => Self::Decision, + BrowserTicketThreadRole::ImplementationReport => Self::ImplementationReport, + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct BrowserAppendTicketEventRequest { + role: BrowserTicketThreadRole, + body: String, + author: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct BrowserReviewTicketRequest { + result: TicketReviewResult, + body: String, + author: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct BrowserQueueTicketRequest { + queued_by: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct BrowserCloseTicketRequest { + resolution: String, +} + +fn browser_ticket_backend(api: &WorkspaceApi) -> Result { + let config = ticket::config::TicketConfig::load_workspace(&api.config.workspace_root) + .map_err(|error| Error::Config(format!("load Ticket workspace settings: {error}")))?; + Ok(SqliteTicketBackend::new( + api.config.database_path.clone(), + api.config.workspace_id.clone(), + ) + .with_record_language(config.ticket_record_language())) +} + +fn browser_ticket_detail(api: &WorkspaceApi, ticket_id: &str) -> ApiResult> { + Ok(Json(api.authority.ticket(ticket_id)?)) +} + +async fn scoped_edit_ticket_item( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + if let Some(TicketTargetEdit::Set { repository_id, .. }) = request.target.as_ref() { + if !api + .store + .list_repositories(&api.config.workspace_id)? + .iter() + .any(|repository| repository.repository_id == *repository_id) + { + return Err(settings_bad_request( + "unknown_ticket_repository", + "repository_id must identify a repository registered in this Workspace", + )); + } + } + browser_ticket_backend(&api)? + .edit_item( + TicketIdOrSlug::Id(path.id.clone()), + TicketItemEdit { + title: request.title, + body: request.body.map(MarkdownText::new), + body_replacement: match (request.old_string, request.new_string) { + (Some(old_string), Some(new_string)) => Some(TicketBodyReplacement { + old_string, + new_string, + replace_all: request.replace_all, + }), + (None, None) => None, + _ => { + return Err(settings_bad_request( + "invalid_ticket_edit_replacement", + "old_string and new_string must be provided together", + )); + } + }, + target: request.target, + author: request.author, + }, + ) + .map_err(Error::from)?; + browser_ticket_detail(&api, &path.id) +} + +async fn scoped_transition_ticket_state( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let current = api.authority.ticket(&path.id)?; + let mut change = TicketStateChange::new( + current.state, + request.state.as_str(), + request + .reason + .unwrap_or_else(|| "state changed from Web Ticket API".to_owned()), + request.body.unwrap_or_default(), + ); + change.author = request.author; + browser_ticket_backend(&api)? + .set_workflow_state(TicketIdOrSlug::Id(path.id.clone()), change) + .map_err(Error::from)?; + browser_ticket_detail(&api, &path.id) +} + +async fn scoped_append_ticket_event( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let mut event = NewTicketEvent::new(request.role.into(), request.body); + event.author = request.author; + browser_ticket_backend(&api)? + .add_event(TicketIdOrSlug::Id(path.id.clone()), event) + .map_err(Error::from)?; + browser_ticket_detail(&api, &path.id) +} + +async fn scoped_review_ticket( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + browser_ticket_backend(&api)? + .review( + TicketIdOrSlug::Id(path.id.clone()), + TicketReview { + result: request.result, + body: MarkdownText::new(request.body), + author: request.author, + }, + ) + .map_err(Error::from)?; + browser_ticket_detail(&api, &path.id) +} + +async fn scoped_queue_ticket( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let queued_by = request.queued_by.as_deref().unwrap_or("web"); + browser_ticket_backend(&api)? + .queue_ready(TicketIdOrSlug::Id(path.id.clone()), queued_by) + .map_err(Error::from)?; + browser_ticket_detail(&api, &path.id) +} + +async fn scoped_close_ticket( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + browser_ticket_backend(&api)? + .close( + TicketIdOrSlug::Id(path.id.clone()), + MarkdownText::new(request.resolution), + ) + .map_err(Error::from)?; + browser_ticket_detail(&api, &path.id) +} + async fn scoped_ticket_backend_operation( State(api): State, AxumPath(path): AxumPath, @@ -4329,19 +4571,35 @@ async fn create_workspace_worker( }, ) .map_err(|err| err.into_error())?; + Ok(Json(record_browser_worker_spawn( + &api, + request.runtime_id, + display_name, + selected_working_directory_id, + result, + )?)) +} + +fn record_browser_worker_spawn( + api: &WorkspaceApi, + requested_runtime_id: String, + display_name: String, + selected_working_directory_id: Option, + result: WorkerSpawnResult, +) -> ApiResult { if result.state != WorkerOperationState::Accepted { return Err(worker_create_not_accepted_error( - request.runtime_id.clone(), + requested_runtime_id.clone(), result.diagnostics, )); } let worker = result.worker.ok_or_else(|| Error::RuntimeOperationFailed { - runtime_id: request.runtime_id.clone(), + runtime_id: requested_runtime_id, code: "workspace_worker_create_missing_summary".to_string(), message: "Runtime completed worker creation without returning a Worker summary".to_string(), })?; let worker_record = record_worker_summary( - &api, + api, &worker, display_name.as_str(), worker.profile.clone(), @@ -4349,13 +4607,9 @@ async fn create_workspace_worker( )?; if let Some(working_directory) = worker.working_directory.as_ref() { let workdir_record = - workdir_record_from_summary(&api, worker.runtime_id.as_str(), working_directory); + workdir_record_from_summary(api, worker.runtime_id.as_str(), working_directory); api.store.upsert_workdir_registry(&workdir_record)?; - link_worker_to_workdir( - &api, - &worker_record, - &working_directory.working_directory_id, - )?; + link_worker_to_workdir(api, &worker_record, &working_directory.working_directory_id)?; } if let Some(workdir_id) = selected_working_directory_id.as_deref() { if api @@ -4370,7 +4624,7 @@ async fn create_workspace_worker( { if let Some(status) = result.working_directory { let record = workdir_record_from_summary( - &api, + api, worker.runtime_id.as_str(), &status.summary, ); @@ -4383,7 +4637,7 @@ async fn create_workspace_worker( .get_workdir_registry(&api.config.workspace_id, workdir_id)? .is_some() { - link_worker_to_workdir(&api, &worker_record, workdir_id)?; + link_worker_to_workdir(api, &worker_record, workdir_id)?; } } let runtime_id = worker.runtime_id.clone(); @@ -4395,14 +4649,14 @@ async fn create_workspace_worker( encode_path_segment(&runtime_id), encode_path_segment(&worker_id) ); - Ok(Json(BrowserCreateWorkerResponse { + Ok(BrowserCreateWorkerResponse { workspace_id, runtime_id, worker_id, console_href, worker, diagnostics: result.diagnostics, - })) + }) } async fn post_internal_runtime_resource_fetch( @@ -6749,6 +7003,7 @@ fn workspace_id_mismatch_error() -> ApiError { ) } +#[derive(Debug)] struct ApiError { error: Error, diagnostics: Vec, @@ -6762,6 +7017,22 @@ impl From for ApiError { severity: DiagnosticSeverity::Error, message: sanitize_backend_error(message), }], + Error::Ticket(ticket_error) => vec![RuntimeDiagnostic { + code: match ticket_error { + ticket::TicketError::NotFound(_) => "ticket_not_found", + ticket::TicketError::Ambiguous { .. } => "ticket_ambiguous", + ticket::TicketError::Locked { .. } => "ticket_locked", + ticket::TicketError::Conflict(_) => "ticket_conflict", + ticket::TicketError::InvalidPathComponent(_) + | ticket::TicketError::PathEscapesRoot { .. } => "invalid_ticket_request", + ticket::TicketError::Io { .. } + | ticket::TicketError::Parse { .. } + | ticket::TicketError::Sqlite(_) => "ticket_backend_error", + } + .to_string(), + severity: DiagnosticSeverity::Error, + message: sanitize_backend_error(&ticket_error.to_string()), + }], _ => Vec::new(), }; Self { error, diagnostics } @@ -6778,6 +7049,16 @@ impl IntoResponse for ApiError { fn into_response(self) -> Response { let status = match &self.error { Error::InvalidRuntimeIdentifier { .. } => StatusCode::BAD_REQUEST, + Error::Ticket(ticket::TicketError::NotFound(_)) => StatusCode::NOT_FOUND, + Error::Ticket( + ticket::TicketError::Ambiguous { .. } + | ticket::TicketError::Locked { .. } + | ticket::TicketError::Conflict(_), + ) => StatusCode::CONFLICT, + Error::Ticket( + ticket::TicketError::InvalidPathComponent(_) + | ticket::TicketError::PathEscapesRoot { .. }, + ) => StatusCode::BAD_REQUEST, Error::InvalidRecordId(_) | Error::MissingFrontmatter(_) | Error::UnknownHost(_) @@ -6896,6 +7177,21 @@ mod tests { const TEST_REPOSITORY_ID: &str = "main"; const TEST_CREATED_AT: &str = "2026-06-23T06:43:28Z"; + #[test] + fn ticket_api_errors_preserve_http_status() { + let not_found = ApiError::from(Error::Ticket(ticket::TicketError::NotFound( + "0000000000000".to_string(), + ))) + .into_response(); + assert_eq!(not_found.status(), StatusCode::NOT_FOUND); + + let conflict = ApiError::from(Error::Ticket(ticket::TicketError::Conflict( + "invalid transition".to_string(), + ))) + .into_response(); + assert_eq!(conflict.status(), StatusCode::CONFLICT); + } + #[test] fn backend_worker_projection_preserves_missing_rows_links_and_redacts_paths() { let worker = WorkerRegistryRecord { @@ -7669,6 +7965,127 @@ mod tests { } } + #[tokio::test] + async fn ticket_browser_endpoints_mutate_typed_backend_and_return_thread() { + let dir = tempfile::tempdir().unwrap(); + let api = test_api(dir.path()).await; + let Json(created) = scoped_ticket_backend_operation( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + Json(TicketBackendOperation::Create { + input: ticket::NewTicket::new("Browser Ticket API"), + }), + ) + .await + .unwrap(); + let ticket_id = match created { + TicketBackendHttpResponse::Ok { + result: ticket::TicketBackendOperationResult::TicketRef(ticket_ref), + } => ticket_ref.id, + other => panic!("unexpected create response: {other:?}"), + }; + let path = || ScopedRecordPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + id: ticket_id.clone(), + }; + + let Json(edited) = scoped_edit_ticket_item( + State(api.clone()), + AxumPath(path()), + Json(BrowserEditTicketRequest { + title: Some("Browser Ticket API edited".to_string()), + body: Some("Updated from the Browser API.".to_string()), + old_string: None, + new_string: None, + replace_all: false, + target: Some(TicketTargetEdit::Set { + repository_id: "main".to_string(), + ref_selector: Some("feature/api".to_string()), + }), + author: Some("browser-user".to_string()), + }), + ) + .await + .unwrap(); + assert_eq!(edited.title, "Browser Ticket API edited"); + assert_eq!(edited.body, "Updated from the Browser API."); + assert_eq!(edited.repository_id.as_deref(), Some("main")); + assert_eq!(edited.ref_selector.as_deref(), Some("feature/api")); + + let Json(commented) = scoped_append_ticket_event( + State(api.clone()), + AxumPath(path()), + Json(BrowserAppendTicketEventRequest { + role: BrowserTicketThreadRole::Comment, + body: "API comment".to_string(), + author: Some("browser-user".to_string()), + }), + ) + .await + .unwrap(); + assert!(commented.events.iter().any(|event| { + event.kind == "comment" && event.body.as_deref() == Some("API comment") + })); + + let Json(ready) = scoped_transition_ticket_state( + State(api.clone()), + AxumPath(path()), + Json(BrowserTransitionTicketStateRequest { + state: TicketWorkflowState::Ready, + reason: Some("intake complete".to_string()), + body: Some("Ready for queue".to_string()), + author: Some("browser-user".to_string()), + }), + ) + .await + .unwrap(); + assert_eq!(ready.state, "ready"); + + let Json(queued) = scoped_queue_ticket( + State(api.clone()), + AxumPath(path()), + Json(BrowserQueueTicketRequest { + queued_by: Some("browser-user".to_string()), + }), + ) + .await + .unwrap(); + assert_eq!(queued.state, "queued"); + assert_eq!(queued.queued_by.as_deref(), Some("browser-user")); + + let Json(reviewed) = scoped_review_ticket( + State(api.clone()), + AxumPath(path()), + Json(BrowserReviewTicketRequest { + result: TicketReviewResult::Approve, + body: "API review".to_string(), + author: Some("reviewer".to_string()), + }), + ) + .await + .unwrap(); + assert!(reviewed.events.iter().any(|event| { + event.kind == "review" && event.body.as_deref() == Some("API review") + })); + + let Json(closed) = scoped_close_ticket( + State(api), + AxumPath(path()), + Json(BrowserCloseTicketRequest { + resolution: "Closed through the Browser API.".to_string(), + }), + ) + .await + .unwrap(); + assert_eq!(closed.state, "closed"); + assert_eq!( + closed.resolution.as_deref(), + Some("Closed through the Browser API.") + ); + } + #[tokio::test] async fn ticket_backend_endpoint_uses_workspace_sqlite_backend() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index f94949c7..65ae6711 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -82,6 +82,11 @@ const MIGRATIONS: &[Migration] = &[ name: "objective mutation audit events", apply: create_objective_event_tables, }, + Migration { + version: 14, + name: "remove unused control-plane Ticket tables", + apply: remove_unused_control_plane_ticket_tables, + }, ]; struct Migration { @@ -2156,6 +2161,20 @@ CREATE TABLE IF NOT EXISTS memory_staging_resolutions ( Ok(()) } +fn remove_unused_control_plane_ticket_tables(conn: &Connection) -> Result<()> { + conn.execute_batch( + r#" +DROP TABLE IF EXISTS ticket_target_paths; +DROP TABLE IF EXISTS ticket_worker_links; +DROP TABLE IF EXISTS ticket_targets; +DROP TABLE IF EXISTS ticket_relations; +DROP TABLE IF EXISTS ticket_events; +DROP TABLE IF EXISTS tickets; +"#, + )?; + Ok(()) +} + fn create_objective_event_tables(conn: &Connection) -> Result<()> { conn.execute_batch( r#" @@ -2649,68 +2668,6 @@ CREATE TABLE IF NOT EXISTS workspaces ( updated_at TEXT NOT NULL ); -CREATE TABLE IF NOT EXISTS tickets ( - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - ticket_id TEXT PRIMARY KEY, - title TEXT NOT NULL, - state TEXT NOT NULL, - priority TEXT, - assignee_kind TEXT, - assignee_key TEXT, - assignee_display TEXT, - body_md TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - closed_at TEXT, - resolution_event_id TEXT -); - -CREATE TABLE IF NOT EXISTS ticket_events ( - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - event_id TEXT PRIMARY KEY, - ticket_id TEXT NOT NULL REFERENCES tickets(ticket_id) ON DELETE CASCADE, - event_seq INTEGER NOT NULL, - kind TEXT NOT NULL, - activity_id TEXT, - author_kind TEXT NOT NULL, - author_key TEXT NOT NULL, - author_display TEXT NOT NULL, - author_source_kind TEXT, - author_source_key TEXT, - created_at TEXT NOT NULL, - body_md TEXT, - subject_kind TEXT, - subject_id TEXT, - previous_state TEXT, - new_state TEXT, - status TEXT, - artifact_id TEXT, - worker_ref_kind TEXT, - worker_ref_key TEXT, - worker_display TEXT, - host_ref_kind TEXT, - host_ref_key TEXT, - host_display TEXT, - repository_id TEXT, - caused_by_event_id TEXT, - UNIQUE (ticket_id, event_seq) -); - -CREATE TABLE IF NOT EXISTS ticket_relations ( - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - source_ticket_id TEXT NOT NULL REFERENCES tickets(ticket_id) ON DELETE CASCADE, - target_ticket_id TEXT NOT NULL REFERENCES tickets(ticket_id) ON DELETE CASCADE, - kind TEXT NOT NULL, - created_at TEXT NOT NULL, - author_kind TEXT NOT NULL, - author_key TEXT NOT NULL, - author_display TEXT NOT NULL, - author_source_kind TEXT, - author_source_key TEXT, - note TEXT, - PRIMARY KEY (source_ticket_id, target_ticket_id, kind) -); - CREATE TABLE IF NOT EXISTS objectives ( workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, objective_id TEXT PRIMARY KEY, @@ -2793,43 +2750,6 @@ CREATE TABLE IF NOT EXISTS repositories ( updated_at TEXT NOT NULL ); -CREATE TABLE IF NOT EXISTS ticket_targets ( - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - ticket_id TEXT NOT NULL REFERENCES tickets(ticket_id) ON DELETE CASCADE, - target_id TEXT NOT NULL, - repository_id TEXT NOT NULL REFERENCES repositories(repository_id) ON DELETE CASCADE, - role TEXT NOT NULL, - intent TEXT NOT NULL, - ref_selector TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (ticket_id, target_id) -); - -CREATE TABLE IF NOT EXISTS ticket_target_paths ( - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - ticket_id TEXT NOT NULL, - target_id TEXT NOT NULL, - path TEXT NOT NULL, - PRIMARY KEY (ticket_id, target_id, path), - FOREIGN KEY (ticket_id, target_id) REFERENCES ticket_targets(ticket_id, target_id) ON DELETE CASCADE -); - -CREATE TABLE IF NOT EXISTS ticket_worker_links ( - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - ticket_id TEXT NOT NULL REFERENCES tickets(ticket_id) ON DELETE CASCADE, - worker_ref_kind TEXT NOT NULL, - worker_ref_key TEXT NOT NULL, - worker_display TEXT, - role TEXT NOT NULL, - status TEXT NOT NULL, - activity_id TEXT, - assigned_at TEXT, - released_at TEXT, - last_event_id TEXT, - PRIMARY KEY (ticket_id, worker_ref_kind, worker_ref_key, role) -); - CREATE TABLE IF NOT EXISTS artifacts ( workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, artifact_id TEXT PRIMARY KEY, @@ -2904,13 +2824,40 @@ mod tests { use super::*; use std::collections::BTreeSet; + #[test] + fn removes_unused_control_plane_ticket_tables() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + r#" +CREATE TABLE tickets (ticket_id TEXT PRIMARY KEY); +CREATE TABLE ticket_events (event_id TEXT PRIMARY KEY, ticket_id TEXT REFERENCES tickets(ticket_id)); +CREATE TABLE ticket_relations (source_ticket_id TEXT, target_ticket_id TEXT); +CREATE TABLE ticket_targets (ticket_id TEXT, target_id TEXT, PRIMARY KEY (ticket_id, target_id)); +CREATE TABLE ticket_target_paths (ticket_id TEXT, target_id TEXT, path TEXT); +CREATE TABLE ticket_worker_links (ticket_id TEXT, worker_ref_key TEXT); +"#, + ) + .unwrap(); + remove_unused_control_plane_ticket_tables(&conn).unwrap(); + for table in [ + "tickets", + "ticket_events", + "ticket_relations", + "ticket_targets", + "ticket_target_paths", + "ticket_worker_links", + ] { + assert!(!table_exists(&conn, table).unwrap(), "{table} still exists"); + } + } + #[tokio::test] async fn migrates_sqlite_and_preserves_workspace_record() { let dir = tempfile::tempdir().unwrap(); let db = dir.path().join("control-plane.sqlite"); let store = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 13); + assert_eq!(store.schema_version().await.unwrap(), 14); let record = WorkspaceRecord { workspace_id: "local-dev".to_string(), @@ -2923,7 +2870,7 @@ mod tests { store.upsert_workspace(&record).await.unwrap(); let reopened = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(reopened.schema_version().await.unwrap(), 13); + assert_eq!(reopened.schema_version().await.unwrap(), 14); assert_eq!( reopened.get_workspace("local-dev").await.unwrap(), Some(record) @@ -2939,9 +2886,6 @@ mod tests { let tables = table_names(&conn); for expected in [ "workspaces", - "tickets", - "ticket_events", - "ticket_relations", "objectives", "objective_ticket_links", "objective_resources", @@ -2949,9 +2893,6 @@ mod tests { "workspace_memory_documents", "memory_staging_resolutions", "repositories", - "ticket_targets", - "ticket_target_paths", - "ticket_worker_links", "artifacts", "audit_events", "worker_registry", @@ -2977,6 +2918,12 @@ mod tests { "actors", "validation_results", "ci_results", + "tickets", + "ticket_events", + "ticket_relations", + "ticket_targets", + "ticket_target_paths", + "ticket_worker_links", ] { assert!( !tables.contains(forbidden), @@ -3017,39 +2964,6 @@ mod tests { "updated_at", ], ); - assert_columns( - &conn, - "ticket_events", - [ - "workspace_id", - "event_id", - "ticket_id", - "event_seq", - "kind", - "activity_id", - "author_kind", - "author_key", - "author_display", - "author_source_kind", - "author_source_key", - "created_at", - "body_md", - "subject_kind", - "subject_id", - "previous_state", - "new_state", - "status", - "artifact_id", - "worker_ref_kind", - "worker_ref_key", - "worker_display", - "host_ref_kind", - "host_ref_key", - "host_display", - "repository_id", - "caused_by_event_id", - ], - ); assert_columns( &conn, "worker_registry", @@ -3098,7 +3012,7 @@ mod tests { ], ); - for table in ["workspaces", "repositories", "ticket_events", "artifacts"] { + for table in ["workspaces", "repositories", "artifacts"] { let columns = table_columns(&conn, table).unwrap(); for forbidden_column in [ "payload", @@ -3144,7 +3058,7 @@ mod tests { .unwrap(); let store = SqliteWorkspaceStore::from_connection(conn).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 13); + assert_eq!(store.schema_version().await.unwrap(), 14); store .with_conn(|conn| { @@ -3152,9 +3066,6 @@ mod tests { for expected in [ "workspaces", "repositories", - "tickets", - "ticket_events", - "ticket_worker_links", "artifacts", "audit_events", "workspace_memory_documents", @@ -3172,7 +3083,19 @@ mod tests { "missing {expected} after upgrade" ); } - for forbidden in ["runs", "hosts", "workers", "actors", "validation_results"] { + for forbidden in [ + "runs", + "hosts", + "workers", + "actors", + "validation_results", + "tickets", + "ticket_events", + "ticket_relations", + "ticket_targets", + "ticket_target_paths", + "ticket_worker_links", + ] { assert!( !tables.contains(forbidden), "upgraded schema must not retain forbidden canonical table {forbidden}" @@ -3238,7 +3161,7 @@ mod tests { #[tokio::test] async fn repository_records_round_trip() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 13); + assert_eq!(store.schema_version().await.unwrap(), 14); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), owner_account_id: None, @@ -3276,7 +3199,7 @@ mod tests { #[tokio::test] async fn memory_authority_records_round_trip_and_close_staging() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 13); + assert_eq!(store.schema_version().await.unwrap(), 14); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), owner_account_id: None, @@ -3450,7 +3373,7 @@ mod tests { #[tokio::test] async fn account_and_login_records_round_trip() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 13); + assert_eq!(store.schema_version().await.unwrap(), 14); let now = "2026-07-22T00:00:00Z".to_string(); let account = AccountRecord { account_id: "acct-user-alice".to_string(), From e720af38a6200518807407c6c26e1d582b2d92ad Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 31 Jul 2026 01:12:10 +0900 Subject: [PATCH 2/2] workspace: add ticket panel workflow --- Cargo.lock | 1 + crates/workspace-server/Cargo.toml | 5 + .../examples/generate_ticket_api_types.rs | 3 + crates/workspace-server/src/authority.rs | 2 + crates/workspace-server/src/lib.rs | 2 + crates/workspace-server/src/records.rs | 172 +++++++ crates/workspace-server/src/server.rs | 192 +++----- web/workspace/src/lib/generated/ticket-api.ts | 101 ++++ .../console/worker-console.ui.test.ts | 80 +++- .../pages/RepositoryTicketKanban.svelte | 283 ----------- .../src/lib/workspace/sidebar/types.ts | 67 +-- .../workspace/sidebar/worker-launch.test.ts | 63 ++- .../lib/workspace/sidebar/worker-launch.ts | 17 +- .../src/lib/workspace/styles/tickets.css | 368 +++++++++++++++ .../src/lib/workspace/styles/workers.css | 24 + .../workspace/tickets/ticket-panel.test.ts | 99 ++++ .../src/lib/workspace/tickets/ticket-panel.ts | 111 +++++ .../src/routes/w/[workspaceId]/+layout.svelte | 1 + .../repositories/[repositoryId]/+page.svelte | 12 - .../repositories/[repositoryId]/+page.ts | 9 +- .../w/[workspaceId]/tickets/+page.svelte | 444 +++--------------- .../tickets/[ticketId]/+page.svelte | 424 ++++++++++++++--- .../[workspaceId]/tickets/[ticketId]/+page.ts | 27 +- .../w/[workspaceId]/workers/new/+page.svelte | 32 +- .../w/[workspaceId]/workers/new/+page.ts | 16 +- 25 files changed, 1570 insertions(+), 985 deletions(-) create mode 100644 crates/workspace-server/examples/generate_ticket_api_types.rs create mode 100644 web/workspace/src/lib/generated/ticket-api.ts delete mode 100644 web/workspace/src/lib/workspace/pages/RepositoryTicketKanban.svelte create mode 100644 web/workspace/src/lib/workspace/styles/tickets.css create mode 100644 web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts create mode 100644 web/workspace/src/lib/workspace/tickets/ticket-panel.ts diff --git a/Cargo.lock b/Cargo.lock index 18e91ad8..13cfdfc4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6109,6 +6109,7 @@ dependencies = [ "toml", "tower", "tracing", + "ts-rs", "url", "uuid", "webauthn-rs", diff --git a/crates/workspace-server/Cargo.toml b/crates/workspace-server/Cargo.toml index 8aa6ef83..6617487b 100644 --- a/crates/workspace-server/Cargo.toml +++ b/crates/workspace-server/Cargo.toml @@ -10,6 +10,10 @@ autobins = false name = "yoi-server" path = "src/main.rs" +[features] +default = ["typescript"] +typescript = ["dep:ts-rs"] + [dependencies] async-trait.workspace = true axum = { workspace = true, features = ["ws"] } @@ -33,6 +37,7 @@ worker.workspace = true worker-runtime.workspace = true toml.workspace = true tracing.workspace = true +ts-rs = { version = "12.0.1", optional = true } url.workspace = true uuid = { workspace = true, features = ["v7"] } webauthn-rs = { workspace = true } diff --git a/crates/workspace-server/examples/generate_ticket_api_types.rs b/crates/workspace-server/examples/generate_ticket_api_types.rs new file mode 100644 index 00000000..f30c422b --- /dev/null +++ b/crates/workspace-server/examples/generate_ticket_api_types.rs @@ -0,0 +1,3 @@ +fn main() { + print!("{}", yoi_workspace_server::ticket_api_typescript()); +} diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index 2105cfa0..528b955e 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -274,6 +274,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority { updated_at: ticket.meta.updated_at, queued_by: ticket.meta.queued_by, queued_at: ticket.meta.queued_at, + assignee: ticket.meta.assignee, repository_id: ticket.meta.repository_id, ref_selector: ticket.meta.ref_selector, risk_flags: ticket.meta.risk_flags, @@ -287,6 +288,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority { .into_iter() .map(|artifact| artifact.relative_path.display().to_string()) .collect(), + relations: ticket.relations.into(), resolution: ticket .resolution .map(|resolution| resolution.as_str().to_string()), diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 008ba54a..0747f467 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -15,6 +15,8 @@ pub mod memory_staging; pub mod observation; pub mod profile_settings; pub mod records; +#[cfg(feature = "typescript")] +pub use records::ticket_api_typescript; pub mod repositories; pub mod resource_broker; pub mod server; diff --git a/crates/workspace-server/src/records.rs b/crates/workspace-server/src/records.rs index 830a8a66..c95aa816 100644 --- a/crates/workspace-server/src/records.rs +++ b/crates/workspace-server/src/records.rs @@ -13,12 +13,14 @@ pub struct ProjectRecordList { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct InvalidProjectRecord { pub label: String, pub reason: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketSummary { pub id: String, pub title: String, @@ -32,6 +34,17 @@ pub struct TicketSummary { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct TicketListResponse { + pub workspace_id: String, + pub limit: usize, + pub items: Vec, + pub invalid_records: Vec, + pub record_authority: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketDetail { pub id: String, pub title: String, @@ -41,6 +54,7 @@ pub struct TicketDetail { pub updated_at: Option, pub queued_by: Option, pub queued_at: Option, + pub assignee: Option, pub repository_id: Option, pub ref_selector: Option, pub risk_flags: Vec, @@ -50,11 +64,13 @@ pub struct TicketDetail { pub events: Vec, pub artifact_count: usize, pub artifacts: Vec, + pub relations: TicketRelationView, pub resolution: Option, pub record_source: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketEventDetail { pub sequence: usize, pub kind: String, @@ -69,6 +85,106 @@ pub struct TicketEventDetail { pub body: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct TicketRelation { + pub ticket_id: String, + pub kind: String, + pub target: String, + pub note: Option, + pub author: String, + pub at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct DerivedTicketRelation { + pub source_ticket: String, + pub inverse_kind: String, + pub forward_kind: String, + pub note: Option, + pub author: String, + pub at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct TicketRelationBlocker { + pub blocking_ticket: String, + pub reason_kind: String, + pub relation_kind: String, + pub note: Option, + pub blocking_state: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct TicketRelationNotice { + pub related_ticket: String, + pub kind: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct TicketRelationView { + pub outgoing: Vec, + pub incoming: Vec, + pub blockers: Vec, + pub notices: Vec, +} + +impl From for TicketRelationView { + fn from(value: ticket::TicketRelationView) -> Self { + Self { + outgoing: value + .outgoing + .into_iter() + .map(|relation| TicketRelation { + ticket_id: relation.ticket_id, + kind: relation.kind.as_str().to_string(), + target: relation.target, + note: relation.note, + author: relation.author, + at: relation.at, + }) + .collect(), + incoming: value + .incoming + .into_iter() + .map(|relation| DerivedTicketRelation { + source_ticket: relation.source_ticket, + inverse_kind: relation.inverse_kind, + forward_kind: relation.forward_kind.as_str().to_string(), + note: relation.note, + author: relation.author, + at: relation.at, + }) + .collect(), + blockers: value + .blockers + .into_iter() + .map(|blocker| TicketRelationBlocker { + blocking_ticket: blocker.blocking_ticket, + reason_kind: blocker.reason_kind, + relation_kind: blocker.relation_kind.as_str().to_string(), + note: blocker.note, + blocking_state: blocker.blocking_state.as_str().to_string(), + }) + .collect(), + notices: value + .notices + .into_iter() + .map(|notice| TicketRelationNotice { + related_ticket: notice.related_ticket, + kind: notice.kind.as_str().to_string(), + message: notice.message, + }) + .collect(), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ObjectiveSummary { pub id: String, @@ -102,6 +218,62 @@ pub struct ObjectiveResourceSummary { pub updated_at: String, } +#[cfg(feature = "typescript")] +pub fn ticket_api_typescript() -> String { + use ts_rs::TS; + + let config = ts_rs::Config::default(); + let declarations = [ + InvalidProjectRecord::decl(&config), + TicketSummary::decl(&config), + TicketListResponse::decl(&config), + TicketEventDetail::decl(&config), + TicketRelation::decl(&config), + DerivedTicketRelation::decl(&config), + TicketRelationBlocker::decl(&config), + TicketRelationNotice::decl(&config), + TicketRelationView::decl(&config), + TicketDetail::decl(&config), + ]; + format!( + "// Generated from yoi-workspace-server. Do not edit by hand.\n// Regenerate: cargo run -q -p yoi-workspace-server --features typescript --example generate_ticket_api_types > web/workspace/src/lib/generated/ticket-api.ts\n\n{}\n", + declarations + .into_iter() + .map(|declaration| format!("export {declaration}")) + .collect::>() + .join("\n\n") + ) +} + +#[cfg(all(test, feature = "typescript"))] +mod typescript_tests { + #[test] + fn generated_ticket_api_contract_is_current() { + let expected = super::ticket_api_typescript(); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../web/workspace/src/lib/generated/ticket-api.ts"); + let actual = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); + assert_eq!( + normalize(&actual), + normalize(&expected), + "regenerate Ticket API TypeScript types with `cargo run -q -p yoi-workspace-server --features typescript --example generate_ticket_api_types > web/workspace/src/lib/generated/ticket-api.ts` and format the generated file", + ); + } + + fn normalize(value: &str) -> String { + value + .chars() + .filter_map(|character| match character { + character if character.is_whitespace() => None, + ',' => Some(';'), + character => Some(character), + }) + .collect::() + .replace(";}", "}") + } +} + pub(crate) fn validate_project_id(id: &str) -> Result<()> { validate_record_id(id).map_err(|_| Error::InvalidRecordId(id.to_string())) } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 736591c0..df4e1f66 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -82,7 +82,7 @@ use crate::profile_settings::{ UpdateWorkspaceMetadataRequest, UpdateWorkspaceProfileRegistryRequest, UpdateWorkspaceProfileSourceRequest, WriteWorkspaceProfileTreeFileRequest, }; -use crate::records::{ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary}; +use crate::records::{ObjectiveDetail, ProjectRecordList, TicketDetail}; use crate::repositories::{ ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError, RepositoryRegistryReader, RepositorySummary, @@ -577,14 +577,6 @@ pub fn build_router(api: WorkspaceApi) -> Router { "/api/w/{workspace_id}/repositories/{repository_id}/log", get(scoped_repository_log), ) - .route( - "/api/repositories/{repository_id}/tickets", - get(repository_tickets), - ) - .route( - "/api/w/{workspace_id}/repositories/{repository_id}/tickets", - get(scoped_repository_tickets), - ) .route("/api/hosts", get(list_hosts)) .route("/api/w/{workspace_id}/hosts", get(scoped_list_hosts)) .route( @@ -1198,34 +1190,11 @@ pub struct RepositoryLogResponse { pub diagnostics: Vec, } -#[derive(Debug, Serialize, Deserialize)] -pub struct RepositoryTicketsResponse { - pub workspace_id: String, - pub repository_id: String, - pub limit: usize, - pub columns: Vec, - pub invalid_records: Vec, - pub record_authority: String, - pub source: String, - pub diagnostics: Vec, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct TicketKanbanColumn { - pub state: String, - pub items: Vec, -} - #[derive(Debug, Deserialize)] struct LogQuery { limit: Option, } -#[derive(Debug, Deserialize)] -struct TicketKanbanQuery { - limit: Option, -} - #[derive(Debug, Deserialize)] struct MemoryStagingQuery { limit: Option, @@ -1256,6 +1225,11 @@ struct ObjectiveEditRequest { replace_all: bool, } +#[derive(Debug, Deserialize)] +struct TicketListQuery { + limit: Option, +} + #[derive(Debug, Deserialize)] struct ObjectiveStateRequest { state: String, @@ -1540,8 +1514,8 @@ async fn scoped_delete_profile_source( async fn scoped_list_tickets( State(api): State, AxumPath(path): AxumPath, - Query(query): Query, -) -> ApiResult>> { + Query(query): Query, +) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; list_tickets(State(api), Query(query)).await } @@ -2232,15 +2206,6 @@ async fn scoped_repository_log( repository_log(State(api), AxumPath(path.repository_id), Query(query)).await } -async fn scoped_repository_tickets( - State(api): State, - AxumPath(path): AxumPath, - Query(query): Query, -) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - repository_tickets(State(api), AxumPath(path.repository_id), Query(query)).await -} - async fn scoped_list_hosts( State(api): State, AxumPath(path): AxumPath, @@ -4111,8 +4076,8 @@ fn companion_console_extension_point(status: &CompanionStatusResponse) -> Extens async fn list_tickets( State(api): State, - Query(query): Query, -) -> ApiResult>> { + Query(query): Query, +) -> ApiResult> { let requested_limit = query.limit.unwrap_or(api.config.max_records); let limit = requested_limit.min(1000); let ProjectRecordList { @@ -4120,7 +4085,7 @@ async fn list_tickets( invalid_records, record_authority, } = api.authority.list_tickets(limit)?; - Ok(Json(ListResponse { + Ok(Json(crate::records::TicketListResponse { workspace_id: api.config.workspace_id, limit, items, @@ -4211,35 +4176,6 @@ async fn repository_log( })) } -async fn repository_tickets( - State(api): State, - AxumPath(repository_id): AxumPath, - Query(query): Query, -) -> ApiResult> { - repository_lookup(api.repository_reader().summary(&repository_id))?; - let canonical_repository_id = repository_id; - let limit = query.limit.unwrap_or(api.config.max_records).min(200); - let ProjectRecordList { - items, - invalid_records, - record_authority, - } = api.authority.list_tickets(limit)?; - Ok(Json(RepositoryTicketsResponse { - workspace_id: api.config.workspace_id, - repository_id: canonical_repository_id, - limit, - columns: ticket_kanban_columns(items), - invalid_records, - record_authority, - source: "workspace_local_ticket_fallback".to_string(), - diagnostics: vec![RuntimeDiagnostic { - code: "repository_ticket_target_metadata_absent".to_string(), - severity: DiagnosticSeverity::Info, - message: "Ticket target Repository metadata is not available yet; Kanban groups all workspace-local Tickets by state as a read-only fallback.".to_string(), - }], - })) -} - async fn list_hosts( State(api): State, ) -> ApiResult>> { @@ -6811,52 +6747,6 @@ fn repository_lookup(result: std::result::Result) - }) } -fn ticket_kanban_columns(items: Vec) -> Vec { - let mut columns = vec![ - TicketKanbanColumn { - state: "planning".to_string(), - items: Vec::new(), - }, - TicketKanbanColumn { - state: "ready".to_string(), - items: Vec::new(), - }, - TicketKanbanColumn { - state: "queued".to_string(), - items: Vec::new(), - }, - TicketKanbanColumn { - state: "inprogress".to_string(), - items: Vec::new(), - }, - TicketKanbanColumn { - state: "done".to_string(), - items: Vec::new(), - }, - TicketKanbanColumn { - state: "closed".to_string(), - items: Vec::new(), - }, - TicketKanbanColumn { - state: "other".to_string(), - items: Vec::new(), - }, - ]; - for item in items { - let index = match item.state.as_str() { - "planning" => 0, - "ready" => 1, - "queued" => 2, - "inprogress" => 3, - "done" => 4, - "closed" => 5, - _ => 6, - }; - columns[index].items.push(item); - } - columns -} - async fn static_or_spa_fallback(State(api): State, uri: Uri) -> Response { if uri.path().starts_with("/api/") || uri.path() == "/api" { return ( @@ -7990,6 +7880,40 @@ mod tests { workspace_id: TEST_WORKSPACE_ID.to_string(), id: ticket_id.clone(), }; + let Json(related) = scoped_ticket_backend_operation( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + Json(TicketBackendOperation::Create { + input: ticket::NewTicket::new("Related Browser Ticket"), + }), + ) + .await + .unwrap(); + let related_ticket_id = match related { + TicketBackendHttpResponse::Ok { + result: ticket::TicketBackendOperationResult::TicketRef(ticket_ref), + } => ticket_ref.id, + other => panic!("unexpected related create response: {other:?}"), + }; + let _ = scoped_ticket_backend_operation( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + Json(TicketBackendOperation::AddTicketRelation { + id: ticket_id.clone().into(), + relation: ticket::NewTicketRelation { + kind: ticket::TicketRelationKind::Related, + target: related_ticket_id.clone(), + note: Some("Browser relation".to_string()), + author: Some("browser-user".to_string()), + }, + }), + ) + .await + .unwrap(); let Json(edited) = scoped_edit_ticket_item( State(api.clone()), @@ -8013,6 +7937,10 @@ mod tests { assert_eq!(edited.body, "Updated from the Browser API."); assert_eq!(edited.repository_id.as_deref(), Some("main")); assert_eq!(edited.ref_selector.as_deref(), Some("feature/api")); + assert_eq!(edited.assignee, None); + assert_eq!(edited.relations.outgoing.len(), 1); + assert_eq!(edited.relations.outgoing[0].target, related_ticket_id); + assert_eq!(edited.relations.outgoing[0].kind, "related"); let Json(commented) = scoped_append_ticket_event( State(api.clone()), @@ -9408,19 +9336,17 @@ mod tests { assert_eq!(repository_log["default_selector"], "HEAD"); assert_eq!(repository_log["limit"], 3); - let repository_tickets = get_json(app.clone(), "/api/repositories/main/tickets").await; - assert_eq!(repository_tickets["repository_id"], TEST_REPOSITORY_ID); - let ready_column = repository_tickets["columns"] - .as_array() - .unwrap() - .iter() - .find(|column| column["state"] == "ready") + let removed_repository_tickets = app + .clone() + .oneshot( + Request::builder() + .uri("/api/repositories/main/tickets") + .body(Body::empty()) + .unwrap(), + ) + .await .unwrap(); - assert_eq!(ready_column["items"][0]["title"], "API Ticket"); - assert_eq!( - repository_tickets["diagnostics"][0]["code"], - "repository_ticket_target_metadata_absent" - ); + assert_eq!(removed_repository_tickets.status(), StatusCode::NOT_FOUND); let unknown_repository_response = app .clone() diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts new file mode 100644 index 00000000..df60854d --- /dev/null +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -0,0 +1,101 @@ +// Generated from yoi-workspace-server. Do not edit by hand. +// Regenerate: cargo run -q -p yoi-workspace-server --features typescript --example generate_ticket_api_types > web/workspace/src/lib/generated/ticket-api.ts + +export type InvalidProjectRecord = { label: string; reason: string }; + +export type TicketSummary = { + id: string; + title: string; + state: string; + priority: string; + updated_at: string | null; + queued_by: string | null; + queued_at: string | null; + workspace_action_priority: string; + record_source: string; +}; + +export type TicketListResponse = { + workspace_id: string; + limit: number; + items: Array; + invalid_records: Array; + record_authority: string; +}; + +export type TicketEventDetail = { + sequence: number; + kind: string; + author: string | null; + at: string | null; + status: string | null; + from: string | null; + to: string | null; + reason: string | null; + state_field: string | null; + heading: string | null; + body: string | null; +}; + +export type TicketRelation = { + ticket_id: string; + kind: string; + target: string; + note: string | null; + author: string; + at: string; +}; + +export type DerivedTicketRelation = { + source_ticket: string; + inverse_kind: string; + forward_kind: string; + note: string | null; + author: string; + at: string; +}; + +export type TicketRelationBlocker = { + blocking_ticket: string; + reason_kind: string; + relation_kind: string; + note: string | null; + blocking_state: string; +}; + +export type TicketRelationNotice = { + related_ticket: string; + kind: string; + message: string; +}; + +export type TicketRelationView = { + outgoing: Array; + incoming: Array; + blockers: Array; + notices: Array; +}; + +export type TicketDetail = { + id: string; + title: string; + state: string; + priority: string; + created_at: string | null; + updated_at: string | null; + queued_by: string | null; + queued_at: string | null; + assignee: string | null; + repository_id: string | null; + ref_selector: string | null; + risk_flags: Array; + body: string; + body_truncated: boolean; + event_count: number; + events: Array; + artifact_count: number; + artifacts: Array; + relations: TicketRelationView; + resolution: string | null; + record_source: string; +}; diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index c377bc91..34fe0f72 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -114,7 +114,9 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async () assert( workersPage.includes("workerConsoleHref(worker, data.workspaceId)") && workersPage.includes('') && - workersPage.includes("workerDisplayName = worker.display_name || worker.label") && + workersPage.includes( + "workerDisplayName = worker.display_name || worker.label", + ) && workersPage.includes("worker {worker.worker_id}") && workersPage.includes("Delete ${workerDisplayName}"), "dedicated Workers page should expose a table, console link target, and icon actions per Worker", @@ -136,7 +138,7 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async () ); }); -Deno.test("workspace Tickets surface uses read-only Backend Ticket APIs", async () => { +Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", async () => { const ticketsNav = await Deno.readTextFile( new URL("../sidebar/TicketsNavSection.svelte", import.meta.url), ); @@ -164,6 +166,24 @@ Deno.test("workspace Tickets surface uses read-only Backend Ticket APIs", async import.meta.url, ), ); + const ticketPanelModel = await Deno.readTextFile( + new URL("../tickets/ticket-panel.ts", import.meta.url), + ); + const generatedTicketApi = await Deno.readTextFile( + new URL("../../generated/ticket-api.ts", import.meta.url), + ); + const repositoryLoad = await Deno.readTextFile( + new URL( + "./../../../routes/w/[workspaceId]/repositories/[repositoryId]/+page.ts", + import.meta.url, + ), + ); + const repositoryPage = await Deno.readTextFile( + new URL( + "./../../../routes/w/[workspaceId]/repositories/[repositoryId]/+page.svelte", + import.meta.url, + ), + ); assert( ticketsNav.includes("workspaceRoute(workspaceId, '/tickets')") && @@ -171,26 +191,41 @@ Deno.test("workspace Tickets surface uses read-only Backend Ticket APIs", async "Tickets sidebar section should link to the workspace Tickets surface", ); assert( - ticketsLoad.includes( - '`${workspaceApiPath(params.workspaceId, "/tickets")}?limit=1000`', - ) && - ticketsPage.includes("Notion-style filtering and sorting") && - ticketsPage.includes("toggleSort('updated_at')") && - ticketsPage.includes("bind:value={visibilityFilter}") && - ticketsPage.includes("sortKey = $state('panel')") && - ticketsPage.includes("workspace_action_priority") && - ticketsPage.includes("bind:value={stateFilter}") && - ticketsPage.includes( - "workspaceRoute(data.workspaceId, `/tickets/${ticket.id}`)", - ), - "Tickets list should read the workspace-scoped Ticket API and expose sortable/filterable table links", + ticketsLoad.includes("?limit=1000") && + ticketsPage.includes("data.tickets.data?.items") && + ticketsPage.includes("ticketLanes(tickets)") && + ticketsPage.includes('class="ticket-kanban"') && + ticketsPage.includes("lane.tickets"), + "Tickets list should load every workflow state and render a workspace Kanban board", ); assert( - ticketDetailLoad.includes("`/tickets/${encodeURIComponent(ticketId)}`") && - ticketDetailPage.includes("event_count") && - ticketDetailPage.includes("artifact_count") && - ticketDetailPage.includes("
{data.ticket.data.body"),
-    "Ticket detail should read one Ticket record and expose body plus metadata without mutation controls",
+    ticketPanelModel.includes('label: "Ready + Planning"') &&
+      ticketPanelModel.includes('label: "In progress + Queued"') &&
+      ticketPanelModel.includes('label: "Done + Closed"') &&
+      ticketPanelModel.includes("updatedAt(right) - updatedAt(left)"),
+    "Ticket Kanban should combine related states and sort state priority before recency",
+  );
+  assert(
+    generatedTicketApi.includes("Generated from yoi-workspace-server") &&
+      generatedTicketApi.includes("export type TicketListResponse") &&
+      generatedTicketApi.includes("items: Array"),
+    "Ticket response types should come from the generated Server contract",
+  );
+  assert(
+    !repositoryLoad.includes("/tickets") &&
+      !repositoryPage.includes("Repository Tickets") &&
+      !repositoryPage.includes("RepositoryTicketKanban"),
+    "Repository detail should not keep a second Repository Tickets surface",
+  );
+  assert(
+    ticketDetailLoad.includes("/repositories") &&
+      ticketDetailPage.includes('mutate("state", "/state"') &&
+      ticketDetailPage.includes('mutate("queue", "/queue"') &&
+      ticketDetailPage.includes('mutate("review", "/review"') &&
+      ticketDetailPage.includes('mutate("close", "/close"') &&
+      ticketDetailPage.includes("ticketWorkerLaunchHref") &&
+      ticketDetailPage.includes("ticket.relations.outgoing"),
+    "Ticket detail should expose typed lifecycle actions, relations, target selection, and role Worker launch",
   );
 });
 
@@ -199,7 +234,10 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async ()
     new URL("../sidebar/MemoryNavSection.svelte", import.meta.url),
   );
   const memoryDocumentLoad = await Deno.readTextFile(
-    new URL("./../../../routes/w/[workspaceId]/memory/+page.ts", import.meta.url),
+    new URL(
+      "./../../../routes/w/[workspaceId]/memory/+page.ts",
+      import.meta.url,
+    ),
   );
   const memoryDocumentPage = await Deno.readTextFile(
     new URL(
diff --git a/web/workspace/src/lib/workspace/pages/RepositoryTicketKanban.svelte b/web/workspace/src/lib/workspace/pages/RepositoryTicketKanban.svelte
deleted file mode 100644
index 1a8b66e5..00000000
--- a/web/workspace/src/lib/workspace/pages/RepositoryTicketKanban.svelte
+++ /dev/null
@@ -1,283 +0,0 @@
-
-
-
- {#each groups as group (group.key)} -
-
-
-

{group.label}

-

{group.states.join(' + ')}

-
- {group.items.length} -
- - {#if group.items.length === 0} -

No tickets.

- {:else} -
onGroupScroll(group, event)} - > -
    - {#each visibleTickets(group) as ticket (ticket.id)} -
  • -
    - {ticket.title} - {ticket.state} -
    - {ticket.id} · updated {formatDate(ticket.updated_at)} -
  • - {/each} -
- {#if hasMore(group)} -

Showing {visibleCount(group.key)} of {group.items.length}; scroll for more.

- {/if} -
- {/if} -
- {/each} -
- - diff --git a/web/workspace/src/lib/workspace/sidebar/types.ts b/web/workspace/src/lib/workspace/sidebar/types.ts index f25c2c1b..5a2834c5 100644 --- a/web/workspace/src/lib/workspace/sidebar/types.ts +++ b/web/workspace/src/lib/workspace/sidebar/types.ts @@ -390,62 +390,17 @@ export type MemoryStagingListResponse = { items: MemoryStagingEntry[]; }; -export type TicketSummary = { - id: string; - title: string; - state: string; - priority?: string | null; - updated_at?: string | null; - queued_by?: string | null; - queued_at?: string | null; - workspace_action_priority?: - | "ready_for_queue" - | "active_work" - | "background" - | null; - record_source?: string; -}; - -export type TicketListResponse = { - workspace_id: string; - limit: number; - items: TicketSummary[]; - invalid_records: InvalidProjectRecord[]; - record_authority: string; -}; - -export type TicketDetail = { - id: string; - title: string; - state: string; - priority?: string | null; - created_at?: string | null; - updated_at?: string | null; - queued_by?: string | null; - queued_at?: string | null; - risk_flags: string[]; - body: string; - body_truncated: boolean; - event_count: number; - artifact_count: number; - record_source: string; -}; - -export type TicketKanbanColumn = { - state: string; - items: TicketSummary[]; -}; - -export type RepositoryTicketsResponse = { - workspace_id: string; - repository_id: string; - limit: number; - columns: TicketKanbanColumn[]; - invalid_records: InvalidProjectRecord[]; - record_authority: string; - source: string; - diagnostics: Diagnostic[]; -}; +export type { + DerivedTicketRelation, + TicketDetail, + TicketEventDetail, + TicketListResponse, + TicketRelation, + TicketRelationBlocker, + TicketRelationNotice, + TicketRelationView, + TicketSummary, +} from "$lib/generated/ticket-api"; export type ObjectiveSummary = { id: string; diff --git a/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts b/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts index 93750e96..1882934a 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts @@ -86,6 +86,28 @@ Deno.test("defaultWorkerLaunchForm chooses active runtime, coder profile, reposi assertEquals(form.working_directory_selector, "HEAD"); }); +Deno.test("defaultWorkerLaunchForm preserves an available Ticket role profile", () => { + const reviewerOptions = { + ...options, + profiles: [ + ...options.profiles, + { id: "builtin:reviewer", label: "Reviewer", description: "review" }, + ], + }; + const form = defaultWorkerLaunchForm(reviewerOptions, { + runtime_id: "", + display_name: "Review worker", + profile: "builtin:reviewer", + initial_text: "Review the ticket.", + working_directory_id: "", + working_directory_repository_id: "repo", + working_directory_selector: "HEAD", + relative_cwd: "", + }); + + assertEquals(form.profile, "builtin:reviewer"); +}); + Deno.test("defaultWorkerLaunchForm skips occupied working directories", () => { const form = defaultWorkerLaunchForm( { @@ -116,8 +138,45 @@ Deno.test("defaultWorkerLaunchForm skips occupied working directories", () => { ); assertEquals(form.working_directory_id, ""); - assertEquals(form.working_directory_repository_id, "repo"); - assertEquals(form.working_directory_selector, "HEAD"); +}); + +Deno.test("defaultWorkerLaunchForm preserves a Ticket repository target", () => { + const form = defaultWorkerLaunchForm( + { + ...options, + repositories: [ + ...options.repositories, + { + id: "ticket-repo", + display_name: "Ticket repo", + default_selector: "main", + }, + ], + working_directories: [ + options.working_directories[0], + { + ...options.working_directories[0], + working_directory_id: "ticket-workdir", + repository_id: "ticket-repo", + requested_selector: "work/ticket", + }, + ], + }, + { + runtime_id: "", + display_name: "Ticket worker", + profile: "builtin:coder", + initial_text: "Work on a ticket.", + working_directory_id: "", + working_directory_repository_id: "ticket-repo", + working_directory_selector: "work/ticket", + relative_cwd: "", + }, + ); + + assertEquals(form.working_directory_id, "ticket-workdir"); + assertEquals(form.working_directory_repository_id, "ticket-repo"); + assertEquals(form.working_directory_selector, "work/ticket"); }); Deno.test("buildBrowserCreateWorkerRequest sends working_directory id and relative cwd only", () => { diff --git a/web/workspace/src/lib/workspace/sidebar/worker-launch.ts b/web/workspace/src/lib/workspace/sidebar/worker-launch.ts index 3049d9d8..974a64ac 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-launch.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-launch.ts @@ -32,9 +32,12 @@ export function defaultWorkerLaunchForm( ) ?? options?.runtimes.find((runtime) => runtime.can_spawn_worker) ?? options?.runtimes[0]; - const preferredProfile = + const coderProfile = options?.profiles.find((candidate) => candidate.id === "builtin:coder") ?? options?.profiles[0]; + const preferredProfile = + options?.profiles.find((candidate) => candidate.id === current.profile) ?? + coderProfile; const availableWorkingDirectories = options?.working_directories.filter((directory) => directory.status === "active" && @@ -51,7 +54,17 @@ export function defaultWorkerLaunchForm( selectedRuntime?.working_directory_required === false; const preferredWorkingDirectory = workdirlessRuntime ? undefined - : availableWorkingDirectories[0]; + : availableWorkingDirectories.find((directory) => + Boolean(current.working_directory_repository_id) && + directory.repository_id === current.working_directory_repository_id && + (!current.working_directory_selector || + directory.requested_selector === current.working_directory_selector) + ) ?? availableWorkingDirectories.find((directory) => + Boolean(current.working_directory_repository_id) && + directory.repository_id === current.working_directory_repository_id + ) ?? (current.working_directory_repository_id + ? undefined + : availableWorkingDirectories[0]); const preferredRepository = options?.repositories.find((repository) => repository.id === current.working_directory_repository_id diff --git a/web/workspace/src/lib/workspace/styles/tickets.css b/web/workspace/src/lib/workspace/styles/tickets.css new file mode 100644 index 00000000..10797bfa --- /dev/null +++ b/web/workspace/src/lib/workspace/styles/tickets.css @@ -0,0 +1,368 @@ +@layer reset, tokens, base, layout, components; +@layer components { + .workspace-primary-button, + .workspace-secondary-button, + .workspace-danger-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 2.35rem; + border-radius: 0.55rem; + padding: 0.5rem 0.8rem; + font: inherit; + font-size: 0.82rem; + font-weight: 700; + text-decoration: none; + cursor: pointer; + } + .workspace-primary-button { + border: 1px solid var(--accent); + background: var(--accent); + color: var(--bg); + } + .workspace-secondary-button { + border: 1px solid var(--line); + background: var(--bg-raised); + color: var(--text-strong); + } + .workspace-danger-button { + border: 1px solid #a63f3f; + background: #a63f3f; + color: #fff; + } + .workspace-primary-button:disabled, + .workspace-secondary-button:disabled, + .workspace-danger-button:disabled { + cursor: not-allowed; + opacity: 0.5; + } + .workspace-back-link { + color: var(--text-muted); + font-size: 0.82rem; + text-decoration: none; + } + .workspace-callout { + border: 1px solid var(--line); + border-radius: 0.6rem; + padding: 0.7rem 0.85rem; + } + .workspace-callout.is-error { + border-color: #a63f3f; + color: #d66; + } + .workspace-empty-copy { + margin: 0; + color: var(--text-muted); + } + .ticket-panel-page { + max-width: none; + } + .ticket-panel-header, + .ticket-detail-header, + .ticket-detail-kicker, + .ticket-section-heading, + .ticket-control-card > header, + .ticket-lane-header, + .ticket-lane-header > div, + .ticket-card-meta, + .ticket-role-actions { + display: flex; + align-items: center; + } + .ticket-panel-header, .ticket-detail-header { + justify-content: space-between; + gap: var(--space-4); + } + .ticket-panel-summary { + display: grid; + justify-items: end; + color: var(--text-muted); + } + .ticket-panel-summary strong { + color: var(--text-strong); + font-size: 1.8rem; + line-height: 1; + } + .ticket-kanban { + display: grid; + grid-template-columns: repeat(3, minmax(18rem, 1fr)); + gap: var(--space-3); + overflow-x: auto; + padding-bottom: var(--space-3); + } + .ticket-lane { + min-height: 24rem; + border: 1px solid var(--line); + border-radius: 0.8rem; + background: color-mix(in srgb, var(--bg-raised) 55%, transparent); + } + .ticket-lane-header { + justify-content: space-between; + padding: 0.75rem; + border-bottom: 1px solid var(--line); + } + .ticket-lane-header > div { + gap: 0.45rem; + } + .ticket-lane-header h2 { + margin: 0; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.04em; + } + .ticket-state-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: var(--text-muted); + } + .ticket-lane[data-state="ready-planning"] .ticket-state-dot { + background: #43a66d; + } + .ticket-lane[data-state="inprogress-queued"] .ticket-state-dot { + background: #d09b37; + } + .ticket-lane[data-state="done-closed"] .ticket-state-dot { + background: var(--accent); + } + .ticket-lane-count { + color: var(--text-muted); + font-size: 0.75rem; + } + .ticket-lane-cards { + display: grid; + align-content: start; + gap: 0.55rem; + padding: 0.6rem; + } + .ticket-card { + display: grid; + gap: 0.55rem; + border: 1px solid var(--line); + border-radius: 0.65rem; + background: var(--bg-raised); + color: var(--text-strong); + padding: 0.75rem; + text-decoration: none; + } + .ticket-card:hover { + border-color: var(--accent); + } + .ticket-card-id { + overflow: hidden; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 0.68rem; + text-overflow: ellipsis; + } + .ticket-card strong { + font-size: 0.85rem; + line-height: 1.35; + } + .ticket-card-meta { + justify-content: space-between; + color: var(--text-muted); + font-size: 0.68rem; + } + .ticket-risk-list { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + } + .ticket-risk-list span { + border: 1px solid #a97531; + border-radius: 999px; + color: #d3a55d; + padding: 0.1rem 0.35rem; + font-size: 0.62rem; + } + .ticket-lane-empty { + padding: 1.2rem 0.4rem; + color: var(--text-muted); + font-size: 0.75rem; + text-align: center; + } + .ticket-detail-page { + gap: var(--space-4); + max-width: 88rem; + } + .ticket-detail-header h1 { + margin: 0.45rem 0 0.25rem; + color: var(--text-strong); + font-size: clamp(1.5rem, 3vw, 2.4rem); + } + .ticket-detail-header p { + margin: 0; + color: var(--text-muted); + font-size: 0.78rem; + } + .ticket-detail-kicker { + gap: 0.6rem; + } + .ticket-detail-kicker code { + color: var(--text-muted); + font-size: 0.72rem; + } + .workspace-status-pill { + border: 1px solid var(--line); + border-radius: 999px; + padding: 0.15rem 0.45rem; + color: var(--text-muted); + font-size: 0.68rem; + text-transform: uppercase; + } + .ticket-detail-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(18rem, 24rem); + gap: var(--space-5); + align-items: start; + } + .ticket-detail-main, .ticket-control-rail { + display: grid; + gap: var(--space-4); + } + .ticket-detail-section, .ticket-control-card, .ticket-editor { + border: 1px solid var(--line); + border-radius: 0.8rem; + background: var(--bg-raised); + padding: var(--space-4); + } + .ticket-section-heading, .ticket-control-card > header { + justify-content: space-between; + gap: var(--space-3); + margin-bottom: var(--space-3); + } + .ticket-section-heading h2, .ticket-control-card h2 { + margin: 0; + color: var(--text-strong); + font-size: 0.9rem; + } + .ticket-section-heading span, .ticket-control-card header span { + color: var(--text-muted); + font-size: 0.7rem; + } + .ticket-editor, .ticket-control-form { + display: grid; + gap: 0.75rem; + } + .ticket-editor label, .ticket-control-form label { + display: grid; + gap: 0.3rem; + color: var(--text-muted); + font-size: 0.72rem; + } + .ticket-editor input, + .ticket-editor textarea, + .ticket-control-form input, + .ticket-control-form select, + .ticket-control-form textarea { + width: 100%; + border: 1px solid var(--line); + border-radius: 0.5rem; + background: var(--bg); + color: var(--text-strong); + padding: 0.55rem; + font: inherit; + } + .ticket-worker-card p { + color: var(--text-muted); + font-size: 0.78rem; + line-height: 1.5; + } + .ticket-role-actions { + gap: 0.55rem; + } + .ticket-role-actions > * { + flex: 1; + } + .ticket-queue-button { + width: 100%; + margin-top: 0.75rem; + } + details.ticket-control-card summary { + cursor: pointer; + color: var(--text-strong); + font-size: 0.85rem; + font-weight: 700; + } + details.ticket-control-card[open] summary { + margin-bottom: var(--space-3); + } + .ticket-close-card { + border-color: color-mix(in srgb, #a63f3f 55%, var(--line)); + } + .ticket-relations-list, .ticket-blocker-list { + display: grid; + gap: 0.5rem; + } + .ticket-blocker-list { + margin-bottom: 0.75rem; + } + .ticket-relations-list a, .ticket-blocker-list a { + display: grid; + gap: 0.18rem; + border: 1px solid var(--line); + border-radius: 0.55rem; + color: var(--text-strong); + padding: 0.6rem; + text-decoration: none; + } + .ticket-blocker-list a { + border-color: #a97531; + } + .ticket-relations-list span, + .ticket-relations-list small, + .ticket-blocker-list span { + color: var(--text-muted); + font-size: 0.68rem; + } + .ticket-timeline { + display: grid; + } + .ticket-timeline article { + display: grid; + grid-template-columns: 0.65rem minmax(0, 1fr); + gap: 0.75rem; + min-height: 4rem; + } + .ticket-timeline-marker { + position: relative; + margin-top: 0.35rem; + width: 0.55rem; + height: 0.55rem; + border: 2px solid var(--accent); + border-radius: 50%; + } + .ticket-timeline-marker::after { + position: absolute; + top: 0.55rem; + bottom: -3.3rem; + left: 0.15rem; + width: 1px; + background: var(--line); + content: ""; + } + .ticket-timeline article:last-child .ticket-timeline-marker::after { + display: none; + } + .ticket-timeline header { + display: flex; + justify-content: space-between; + gap: 0.75rem; + } + .ticket-timeline time, .ticket-event-author { + color: var(--text-muted); + font-size: 0.68rem; + } + .ticket-event-author { + margin: 0.2rem 0; + } + @media (max-width: 64rem) { + .ticket-detail-grid { + grid-template-columns: 1fr; + } + .ticket-control-rail { + grid-row: 1; + } + } +} diff --git a/web/workspace/src/lib/workspace/styles/workers.css b/web/workspace/src/lib/workspace/styles/workers.css index 6b2e08ba..081330d2 100644 --- a/web/workspace/src/lib/workspace/styles/workers.css +++ b/web/workspace/src/lib/workspace/styles/workers.css @@ -92,6 +92,30 @@ margin-top: var(--space-2); color: var(--text-muted); } + .worker-ticket-context { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + max-width: 48rem; + border: 1px solid var(--accent); + border-radius: 0.7rem; + background: var(--bg-raised); + padding: var(--space-3); + } + .worker-ticket-context > div { + display: grid; + gap: var(--space-1); + } + .worker-ticket-context span, + .worker-ticket-context code { + color: var(--text-muted); + font-size: 0.72rem; + } + .worker-ticket-context a { + color: var(--accent); + font-size: 0.78rem; + } .worker-launch-form { display: grid; gap: var(--space-5); diff --git a/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts b/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts new file mode 100644 index 00000000..873f6f79 --- /dev/null +++ b/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts @@ -0,0 +1,99 @@ +import { + ticketLanes, + ticketWorkerLaunchHref, + ticketWorkerMessage, +} from "./ticket-panel.ts"; +import type { + TicketDetail, + TicketSummary, +} from "../../generated/ticket-api.ts"; + +declare const Deno: { + test(name: string, fn: () => Promise | void): void; +}; + +function assertEquals(actual: T, expected: T): void { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error( + `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + ); + } +} + +Deno.test("ticketLanes combines workflow states and sorts by state then update time", () => { + const tickets = [ + { + id: "planning", + state: "planning", + title: "Planning", + updated_at: "2026-07-30T12:00:00Z", + }, + { + id: "ready-old", + state: "ready", + title: "Ready old", + updated_at: "2026-07-29T12:00:00Z", + }, + { + id: "ready-new", + state: "ready", + title: "Ready new", + updated_at: "2026-07-30T12:00:00Z", + }, + { id: "queued", state: "queued", title: "Queued", updated_at: null }, + { + id: "inprogress", + state: "inprogress", + title: "In progress", + updated_at: null, + }, + { id: "closed", state: "closed", title: "Closed", updated_at: null }, + { id: "done", state: "done", title: "Done", updated_at: null }, + ] as TicketSummary[]; + + const lanes = ticketLanes(tickets); + assertEquals(lanes.map((lane) => lane.id), [ + "ready-planning", + "inprogress-queued", + "done-closed", + ]); + assertEquals(lanes[0].tickets.map((ticket) => ticket.id), [ + "ready-new", + "ready-old", + "planning", + ]); + assertEquals(lanes[1].tickets.map((ticket) => ticket.id), [ + "inprogress", + "queued", + ]); + assertEquals(lanes[2].tickets.map((ticket) => ticket.id), [ + "done", + "closed", + ]); +}); + +Deno.test("ticket worker launch uses the common Worker route and bounded Ticket context", () => { + const ticket = { + id: "00001KYRRDVH9", + title: "Ticket panel API", + repository_id: "main repo", + ref_selector: "work/ticket", + } as TicketDetail; + + assertEquals( + ticketWorkerMessage(ticket.id, "coder"), + "Work on Ticket 00001KYRRDVH9 as its coder.", + ); + + const href = ticketWorkerLaunchHref("workspace one", ticket, "reviewer"); + const url = new URL(href, "https://example.test"); + assertEquals(url.pathname, "/w/workspace%20one/workers/new"); + assertEquals(url.searchParams.get("ticketId"), ticket.id); + assertEquals(url.searchParams.get("ticketRole"), "reviewer"); + assertEquals(url.searchParams.get("repositoryId"), "main repo"); + assertEquals(url.searchParams.get("refSelector"), "work/ticket"); + assertEquals( + url.searchParams.get("initialInput"), + "Work on Ticket 00001KYRRDVH9 as its reviewer.", + ); +}); diff --git a/web/workspace/src/lib/workspace/tickets/ticket-panel.ts b/web/workspace/src/lib/workspace/tickets/ticket-panel.ts new file mode 100644 index 00000000..bcfac7b8 --- /dev/null +++ b/web/workspace/src/lib/workspace/tickets/ticket-panel.ts @@ -0,0 +1,111 @@ +import type { TicketDetail, TicketSummary } from "$lib/generated/ticket-api"; + +export const TICKET_STATES = [ + "planning", + "ready", + "queued", + "inprogress", + "done", + "closed", +] as const; + +export type TicketState = (typeof TICKET_STATES)[number]; +export type TicketWorkerRole = "coder" | "reviewer"; + +const LANE_DEFINITIONS = [ + { + id: "ready-planning", + label: "Ready + Planning", + states: ["ready", "planning"], + }, + { + id: "inprogress-queued", + label: "In progress + Queued", + states: ["inprogress", "queued"], + }, + { + id: "done-closed", + label: "Done + Closed", + states: ["done", "closed"], + }, +] as const; + +const STATE_SORT_ORDER = new Map([ + ["ready", 0], + ["planning", 1], + ["inprogress", 0], + ["queued", 1], + ["done", 0], + ["closed", 1], +]); + +export type TicketLane = { + id: string; + label: string; + states: readonly TicketState[]; + tickets: TicketSummary[]; +}; + +function updatedAt(ticket: TicketSummary): number { + if (!ticket.updated_at) return 0; + const parsed = Date.parse(ticket.updated_at); + return Number.isNaN(parsed) ? 0 : parsed; +} + +export function sortTickets(tickets: TicketSummary[]): TicketSummary[] { + return [...tickets].sort((left, right) => { + const stateDelta = (STATE_SORT_ORDER.get(left.state) ?? 99) - + (STATE_SORT_ORDER.get(right.state) ?? 99); + if (stateDelta !== 0) return stateDelta; + const updatedDelta = updatedAt(right) - updatedAt(left); + if (updatedDelta !== 0) return updatedDelta; + return left.id.localeCompare(right.id); + }); +} + +export function ticketLanes(tickets: TicketSummary[]): TicketLane[] { + return LANE_DEFINITIONS.map((definition) => ({ + ...definition, + tickets: sortTickets( + tickets.filter((ticket) => + (definition.states as readonly string[]).includes(ticket.state) + ), + ), + })); +} + +export function ticketWorkerMessage( + ticketId: string, + role: TicketWorkerRole, +): string { + return `Work on Ticket ${ticketId} as its ${role}.`; +} + +export function ticketWorkerLaunchHref( + workspaceId: string, + ticket: Pick< + TicketDetail, + "id" | "title" | "repository_id" | "ref_selector" + >, + role: TicketWorkerRole, +): string { + const params = new URLSearchParams({ + ticketId: ticket.id, + ticketTitle: ticket.title, + ticketRole: role, + initialInput: ticketWorkerMessage(ticket.id, role), + }); + if (ticket.repository_id) { + params.set("repositoryId", ticket.repository_id); + } + if (ticket.ref_selector) { + params.set("refSelector", ticket.ref_selector); + } + return `/w/${ + encodeURIComponent(workspaceId) + }/workers/new?${params.toString()}`; +} + +export function relationLabel(kind: string): string { + return kind.replaceAll("_", " "); +} diff --git a/web/workspace/src/routes/w/[workspaceId]/+layout.svelte b/web/workspace/src/routes/w/[workspaceId]/+layout.svelte index 05fb45a5..18590574 100644 --- a/web/workspace/src/routes/w/[workspaceId]/+layout.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/+layout.svelte @@ -3,6 +3,7 @@ import SidebarOverride from '$lib/workspace/sidebar/SidebarOverride.svelte'; import WorkspaceSidebar from '$lib/workspace/sidebar/WorkspaceSidebar.svelte'; import '$lib/workspace/styles/workspace-pages.css'; + import '$lib/workspace/styles/tickets.css'; import '$lib/workspace/styles/workers.css'; import type { LayoutProps } from './$types'; diff --git a/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryId]/+page.svelte index a71194cd..ed8a2205 100644 --- a/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryId]/+page.svelte @@ -1,6 +1,5 @@ - - Tickets · Yoi Workspace - - +Tickets · Yoi -
-
+
+
-

Workspace records

-

Tickets

+

Delivery

+

Tickets

+

+ Plan, route, review, and close work without leaving the workspace. +

- {#if data.tickets.data} - {visibleTickets.length} / {data.tickets.data.items.length} ticket{data.tickets.data.items.length === 1 ? '' : 's'} - {/if} -
+
+ {tickets.length} + tickets +
+ -

- Tickets are read from the typed Ticket backend. This read-only table supports Notion-style filtering and sorting for browsing imported workspace Tickets. -

+
+ {#each lanes as lane (lane.id)} +
+
+
+ +

{lane.label}

+
+ {lane.tickets.length} +
- {#if data.tickets.data} - {#if data.tickets.data.items.length === 0} -

No Ticket records are present.

- {:else} -
- - - - - - - -
- -
-
- - - - - - - - - - - - {#each visibleTickets as ticket (ticket.id)} - - - - - - - - - {/each} - -
- {ticket.title} - {ticket.record_source ?? 'ticket backend'} - {ticket.state}{ticket.priority || '—'}{ticket.updated_at ? formatDate(ticket.updated_at) : 'unknown'} - {#if ticket.queued_at} - {formatDate(ticket.queued_at)} - {#if ticket.queued_by} - {ticket.queued_by} - {/if} - {:else} - - {/if} - {ticket.id}
- - - {#if visibleTickets.length === 0} -

No tickets match the current filters.

- {/if} - {/if} - - {#if data.tickets.data.invalid_records.length > 0} -

{data.tickets.data.invalid_records.length} invalid Ticket record(s) hidden.

- {/if} - {:else if data.tickets.error} -

{data.tickets.error}

- {:else} -

Waiting for /api/w/{data.workspaceId}/tickets

- {/if} - - - +
+ {#each lane.tickets as ticket (ticket.id)} + + {ticket.id} + {ticket.title} +
+ {ticket.state} · {ticket.priority} + +
+
+ {:else} +
No tickets
+ {/each} +
+ + {/each} + + diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte index bbe3c125..bcaef620 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte @@ -1,82 +1,362 @@ - - {data.ticket.data?.title ?? data.ticketId} · Tickets · Yoi Workspace - - +{ticket.title} · Yoi -
- +
+ + ← Ticket board + - {#if data.ticket.data} -
-
-

{data.ticket.data.id}

-

{data.ticket.data.title}

+
+
+
+ {ticket.state} + {ticket.id}
- {data.ticket.data.state} +

{ticket.title}

+

Updated {prettyDate(ticket.updated_at)}

+ +
-
-
-
Priority
-
{data.ticket.data.priority ?? 'unspecified'}
-
-
-
Updated
-
{data.ticket.data.updated_at ? formatDate(data.ticket.data.updated_at) : 'unknown'}
-
-
-
Created
-
{data.ticket.data.created_at ? formatDate(data.ticket.data.created_at) : 'unknown'}
-
-
-
Events
-
{data.ticket.data.event_count}
-
-
-
Artifacts
-
{data.ticket.data.artifact_count}
-
-
-
Source
-
{data.ticket.data.record_source}
-
- {#if data.ticket.data.queued_at || data.ticket.data.queued_by} -
-
Queued
-
- {data.ticket.data.queued_at ? formatDate(data.ticket.data.queued_at) : 'queued'}{data.ticket.data.queued_by ? ` by ${data.ticket.data.queued_by}` : ''} -
-
- {/if} -
- - {#if data.ticket.data.risk_flags.length > 0} -
- {#each data.ticket.data.risk_flags as flag} - {flag} - {/each} -
- {/if} - -
-
-

Body

- {#if data.ticket.data.body_truncated} - truncated - {/if} -
-
{data.ticket.data.body || 'No body text is available.'}
-
- {:else if data.ticket.error} -

{data.ticket.error}

- {:else} -

Waiting for /api/w/{data.workspaceId}/tickets/{data.ticketId}

+ {#if errorMessage} + {/if} -
+ + {#if editing} +
+ + + +
+ {/if} + +
+
+
+

Intent

+ {#if ticket.body} + + {:else} +

No body has been recorded.

+ {/if} +
+ +
+
+

Relations

+ {ticket.relations.outgoing.length + ticket.relations.incoming.length} +
+ {#if ticket.relations.blockers.length > 0} + + {/if} +
+ {#each ticket.relations.outgoing as relation} + + {relationLabel(relation.kind)} + {relation.target} + {#if relation.note}{relation.note}{/if} + + {/each} + {#each ticket.relations.incoming as relation} + + {relationLabel(relation.inverse_kind)} + {relation.source_ticket} + {#if relation.note}{relation.note}{/if} + + {/each} + {#if ticket.relations.outgoing.length === 0 && ticket.relations.incoming.length === 0} +

No Ticket relations.

+ {/if} +
+
+ +
+
+

Timeline

{ticket.event_count} +
+
+ {#each ticket.events as event (event.sequence)} +
+
+
+
+ {event.heading ?? eventTitle(event.kind)} + +
+ {#if event.author}

{event.author}

{/if} + {#if event.from || event.to}

{event.from ?? "—"} → {event.to ?? "—"}

{/if} + {#if event.reason}

{event.reason}

{/if} + {#if event.body}{/if} +
+
+ {:else} +

No timeline events.

+ {/each} +
+
+
+ + +
+ diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.ts b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.ts index 6277d9a7..b2075538 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.ts @@ -1,20 +1,29 @@ import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; -import type { TicketDetail } from "$lib/workspace/sidebar/types"; +import type { + RepositoryListResponse, + TicketDetail, +} from "$lib/workspace/sidebar/types"; import type { PageLoad } from "./$types"; export const load = (async ({ fetch, params }) => { - const ticketId = params.ticketId; - const ticket = await loadJson( - fetch, - workspaceApiPath( - params.workspaceId, - `/tickets/${encodeURIComponent(ticketId)}`, + const [ticket, repositories] = await Promise.all([ + loadJson( + fetch, + workspaceApiPath( + params.workspaceId, + `/tickets/${encodeURIComponent(params.ticketId)}`, + ), ), - ); + loadJson( + fetch, + workspaceApiPath(params.workspaceId, "/repositories"), + ), + ]); return { workspaceId: params.workspaceId, - ticketId, + ticketId: params.ticketId, ticket, + repositories, }; }) satisfies PageLoad; diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte index 2c2a4b87..3bf4e270 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte @@ -1,5 +1,6 @@