Compare commits

...

5 Commits

29 changed files with 2453 additions and 1180 deletions

1
Cargo.lock generated
View File

@ -6109,6 +6109,7 @@ dependencies = [
"toml",
"tower",
"tracing",
"ts-rs",
"url",
"uuid",
"webauthn-rs",

View File

@ -495,6 +495,8 @@ pub struct NewTicket {
pub workflow_state: Option<TicketWorkflowState>,
pub queued_by: Option<String>,
pub queued_at: Option<String>,
pub repository_id: Option<String>,
pub ref_selector: Option<String>,
}
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<String>,
},
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<String>,
@ -525,6 +568,7 @@ pub struct TicketItemEdit {
pub body: Option<MarkdownText>,
#[serde(default)]
pub body_replacement: Option<TicketBodyReplacement>,
pub target: Option<TicketTargetEdit>,
pub author: Option<String>,
}
@ -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<String>,
pub queued_at: Option<String>,
pub repository_id: Option<String>,
pub ref_selector: Option<String>,
pub raw: BTreeMap<String, String>,
}
@ -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<R>(&self, op: impl FnOnce(&Connection) -> Result<R>) -> Result<R> {
@ -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<Ticket> {
let (mut meta, body, resolution): (TicketMeta, String, Option<String>) = 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<String>) = 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<Vec<TicketSummary>> {
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<R>(conn: &Connection, result: Result<R>) -> Result<R> {
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<String>,
queued_at: Option<String>,
repository_id: Option<String>,
ref_selector: Option<String>,
raw: BTreeMap<String, String>,
}
@ -4211,6 +4381,8 @@ fn parse_ticket_frontmatter(content: &str) -> std::result::Result<TicketItemFron
state_explicit,
queued_by: yaml_string(&mapping, "queued_by")?,
queued_at: yaml_string(&mapping, "queued_at")?,
repository_id: yaml_string(&mapping, "repository_id")?,
ref_selector: yaml_string(&mapping, "ref_selector")?,
raw,
})
}
@ -4340,6 +4512,8 @@ fn ticket_meta(frontmatter: TicketItemFrontmatter, id: String) -> 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<String, String> {
let mut lines: Vec<String> = 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<String, String> {
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<B: TicketBackend>(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<B: TicketBackend>(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::<std::result::Result<Vec<_>, _>>()
.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();

View File

@ -382,6 +382,12 @@ struct TicketCreateParams {
/// Optional queued_at frontmatter value.
#[serde(default)]
queued_at: Option<String>,
/// Optional target Workspace repository id.
#[serde(default)]
repository_id: Option<String>,
/// Optional target Git ref selector. Requires `repository_id`.
#[serde(default)]
ref_selector: Option<String>,
}
#[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<crate::TicketTargetEdit>,
/// Optional thread author for the audited item_edit event.
#[serde(default)]
author: Option<String>,
@ -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

View File

@ -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 }

View File

@ -0,0 +1,3 @@
fn main() {
print!("{}", yoi_workspace_server::ticket_api_typescript());
}

View File

@ -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,24 @@ 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,
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(),
relations: ticket.relations.into(),
resolution: ticket
.resolution
.map(|resolution| resolution.as_str().to_string()),
record_source: "sqlite_yoi_ticket".to_string(),
})
}

View File

@ -272,10 +272,6 @@ impl<T> RuntimeList<T> {
}
}
fn is_retired_companion_worker(worker: &WorkerSummary) -> bool {
worker.profile.as_deref() == Some("builtin:companion")
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkerLookupResult {
#[serde(skip_serializing_if = "Option::is_none")]
@ -948,7 +944,6 @@ impl RuntimeRegistry {
items.extend(
list.items
.into_iter()
.filter(|worker| !is_retired_companion_worker(worker))
.take(limit.saturating_sub(items.len())),
);
}
@ -964,12 +959,7 @@ impl RuntimeRegistry {
validate_backend_identifier("runtime_id", runtime_id)?;
let runtime = self.runtime(runtime_id)?;
let worker_list = runtime.list_workers(limit);
let mut items: Vec<_> = worker_list
.items
.into_iter()
.filter(|worker| !is_retired_companion_worker(worker))
.take(limit)
.collect();
let mut items: Vec<_> = worker_list.items.into_iter().take(limit).collect();
items.truncate(limit);
let mut diagnostics = worker_list.diagnostics;
diagnostics.truncate(MAX_DIAGNOSTICS);
@ -984,12 +974,7 @@ impl RuntimeRegistry {
validate_backend_identifier("runtime_id", runtime_id)?;
let runtime = self.runtime(runtime_id)?;
let worker_list = runtime.list_stopped_workers(limit);
let mut items: Vec<_> = worker_list
.items
.into_iter()
.filter(|worker| !is_retired_companion_worker(worker))
.take(limit)
.collect();
let mut items: Vec<_> = worker_list.items.into_iter().take(limit).collect();
items.truncate(limit);
let mut diagnostics = worker_list.diagnostics;
diagnostics.truncate(MAX_DIAGNOSTICS);
@ -1020,7 +1005,6 @@ impl RuntimeRegistry {
.items
.into_iter()
.filter(|worker| worker.host_id == host_id)
.filter(|worker| !is_retired_companion_worker(worker))
.take(limit.saturating_sub(items.len())),
);
if items.len() >= limit {
@ -1047,12 +1031,6 @@ impl RuntimeRegistry {
let worker = lookup.worker.ok_or_else(|| {
operation_failed_or_unknown_worker(runtime_id, worker_id, lookup.diagnostics)
})?;
if is_retired_companion_worker(&worker) {
return Err(RuntimeRegistryError::UnknownWorker {
runtime_id: runtime_id.to_string(),
worker_id: worker_id.to_string(),
});
}
Ok(worker)
}
@ -4108,6 +4086,24 @@ mod tests {
assert_eq!(listed.items[0].label, "worker from runtime b");
}
#[test]
fn registry_keeps_companion_profile_workers_visible() {
let mut runtime =
FixtureRuntime::with_worker("runtime-a", "host-a", "worker-a", "Companion Worker");
runtime.workers[0].profile = Some("builtin:companion".to_string());
let registry = RuntimeRegistry::new(vec![Arc::new(runtime)]);
let listed = registry.list_workers_for_runtime("runtime-a", 10).unwrap();
assert_eq!(listed.items.len(), 1);
assert_eq!(
listed.items[0].profile.as_deref(),
Some("builtin:companion")
);
let worker = registry.worker("runtime-a", "worker-a").unwrap();
assert_eq!(worker.profile.as_deref(), Some("builtin:companion"));
}
#[test]
fn registry_worker_lookup_reports_unknown_runtime_and_worker_separately() {
let registry = RuntimeRegistry::new(vec![Arc::new(FixtureRuntime::with_worker(

View File

@ -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;

View File

@ -13,12 +13,14 @@ pub struct ProjectRecordList<T> {
}
#[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<TicketSummary>,
pub invalid_records: Vec<InvalidProjectRecord>,
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,14 +54,137 @@ pub struct TicketDetail {
pub updated_at: Option<String>,
pub queued_by: Option<String>,
pub queued_at: Option<String>,
pub assignee: Option<String>,
pub repository_id: Option<String>,
pub ref_selector: Option<String>,
pub risk_flags: Vec<String>,
pub body: String,
pub body_truncated: bool,
pub event_count: usize,
pub events: Vec<TicketEventDetail>,
pub artifact_count: usize,
pub artifacts: Vec<String>,
pub relations: TicketRelationView,
pub resolution: Option<String>,
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,
pub author: Option<String>,
pub at: Option<String>,
pub status: Option<String>,
pub from: Option<String>,
pub to: Option<String>,
pub reason: Option<String>,
pub state_field: Option<String>,
pub heading: Option<String>,
pub body: Option<String>,
}
#[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<String>,
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<String>,
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<String>,
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<TicketRelation>,
pub incoming: Vec<DerivedTicketRelation>,
pub blockers: Vec<TicketRelationBlocker>,
pub notices: Vec<TicketRelationNotice>,
}
impl From<ticket::TicketRelationView> 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,
@ -82,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::<Vec<_>>()
.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::<String>()
.replace(";}", "}")
}
}
pub(crate) fn validate_project_id(id: &str) -> Result<()> {
validate_record_id(id).map_err(|_| Error::InvalidRecordId(id.to_string()))
}

View File

@ -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,
@ -77,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,
@ -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",
@ -549,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(
@ -1172,34 +1192,11 @@ pub struct RepositoryLogResponse {
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RepositoryTicketsResponse {
pub workspace_id: String,
pub repository_id: String,
pub limit: usize,
pub columns: Vec<TicketKanbanColumn>,
pub invalid_records: Vec<crate::records::InvalidProjectRecord>,
pub record_authority: String,
pub source: String,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TicketKanbanColumn {
pub state: String,
pub items: Vec<TicketSummary>,
}
#[derive(Debug, Deserialize)]
struct LogQuery {
limit: Option<usize>,
}
#[derive(Debug, Deserialize)]
struct TicketKanbanQuery {
limit: Option<usize>,
}
#[derive(Debug, Deserialize)]
struct MemoryStagingQuery {
limit: Option<usize>,
@ -1230,6 +1227,11 @@ struct ObjectiveEditRequest {
replace_all: bool,
}
#[derive(Debug, Deserialize)]
struct TicketListQuery {
limit: Option<usize>,
}
#[derive(Debug, Deserialize)]
struct ObjectiveStateRequest {
state: String,
@ -1514,8 +1516,8 @@ async fn scoped_delete_profile_source(
async fn scoped_list_tickets(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
Query(query): Query<TicketKanbanQuery>,
) -> ApiResult<Json<ListResponse<crate::records::TicketSummary>>> {
Query(query): Query<TicketListQuery>,
) -> ApiResult<Json<crate::records::TicketListResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?;
list_tickets(State(api), Query(query)).await
}
@ -1528,6 +1530,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<String>,
body: Option<String>,
old_string: Option<String>,
new_string: Option<String>,
#[serde(default)]
replace_all: bool,
target: Option<TicketTargetEdit>,
author: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct BrowserTransitionTicketStateRequest {
state: TicketWorkflowState,
reason: Option<String>,
body: Option<String>,
author: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
enum BrowserTicketThreadRole {
Comment,
Plan,
Decision,
ImplementationReport,
}
impl From<BrowserTicketThreadRole> 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<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct BrowserReviewTicketRequest {
result: TicketReviewResult,
body: String,
author: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct BrowserQueueTicketRequest {
queued_by: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct BrowserCloseTicketRequest {
resolution: String,
}
fn browser_ticket_backend(api: &WorkspaceApi) -> Result<SqliteTicketBackend> {
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<Json<TicketDetail>> {
Ok(Json(api.authority.ticket(ticket_id)?))
}
async fn scoped_edit_ticket_item(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRecordPath>,
Json(request): Json<BrowserEditTicketRequest>,
) -> ApiResult<Json<TicketDetail>> {
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<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRecordPath>,
Json(request): Json<BrowserTransitionTicketStateRequest>,
) -> ApiResult<Json<TicketDetail>> {
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<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRecordPath>,
Json(request): Json<BrowserAppendTicketEventRequest>,
) -> ApiResult<Json<TicketDetail>> {
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<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRecordPath>,
Json(request): Json<BrowserReviewTicketRequest>,
) -> ApiResult<Json<TicketDetail>> {
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<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRecordPath>,
Json(request): Json<BrowserQueueTicketRequest>,
) -> ApiResult<Json<TicketDetail>> {
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<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRecordPath>,
Json(request): Json<BrowserCloseTicketRequest>,
) -> ApiResult<Json<TicketDetail>> {
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<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
@ -1989,15 +2205,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<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRepositoryPath>,
Query(query): Query<TicketKanbanQuery>,
) -> ApiResult<Json<RepositoryTicketsResponse>> {
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<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
@ -3868,8 +4075,8 @@ fn companion_console_extension_point(status: &CompanionStatusResponse) -> Extens
async fn list_tickets(
State(api): State<WorkspaceApi>,
Query(query): Query<TicketKanbanQuery>,
) -> ApiResult<Json<ListResponse<crate::records::TicketSummary>>> {
Query(query): Query<TicketListQuery>,
) -> ApiResult<Json<crate::records::TicketListResponse>> {
let requested_limit = query.limit.unwrap_or(api.config.max_records);
let limit = requested_limit.min(1000);
let ProjectRecordList {
@ -3877,7 +4084,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,
@ -3968,35 +4175,6 @@ async fn repository_log(
}))
}
async fn repository_tickets(
State(api): State<WorkspaceApi>,
AxumPath(repository_id): AxumPath<String>,
Query(query): Query<TicketKanbanQuery>,
) -> ApiResult<Json<RepositoryTicketsResponse>> {
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<WorkspaceApi>,
) -> ApiResult<Json<RuntimeListResponse<HostSummary>>> {
@ -4348,19 +4526,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<String>,
result: WorkerSpawnResult,
) -> ApiResult<BrowserCreateWorkerResponse> {
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(),
@ -4368,13 +4562,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
@ -4389,7 +4579,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,
);
@ -4402,7 +4592,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();
@ -4414,14 +4604,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(
@ -6564,52 +6754,6 @@ fn repository_lookup<T>(result: std::result::Result<T, RepositoryLookupError>) -
})
}
fn ticket_kanban_columns(items: Vec<TicketSummary>) -> Vec<TicketKanbanColumn> {
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<WorkspaceApi>, uri: Uri) -> Response {
if uri.path().starts_with("/api/") || uri.path() == "/api" {
return (
@ -6756,6 +6900,7 @@ fn workspace_id_mismatch_error() -> ApiError {
)
}
#[derive(Debug)]
struct ApiError {
error: Error,
diagnostics: Vec<RuntimeDiagnostic>,
@ -6769,6 +6914,22 @@ impl From<Error> 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 }
@ -6785,6 +6946,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(_)
@ -6903,6 +7074,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 {
@ -7754,6 +7940,165 @@ 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(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()),
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"));
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()),
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();
@ -9095,19 +9440,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()

View File

@ -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(),

View File

@ -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<TicketSummary>;
invalid_records: Array<InvalidProjectRecord>;
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<TicketRelation>;
incoming: Array<DerivedTicketRelation>;
blockers: Array<TicketRelationBlocker>;
notices: Array<TicketRelationNotice>;
};
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<string>;
body: string;
body_truncated: boolean;
event_count: number;
events: Array<TicketEventDetail>;
artifact_count: number;
artifacts: Array<string>;
relations: TicketRelationView;
resolution: string | null;
record_source: string;
};

View File

@ -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('<table class="workers-table">') &&
workersPage.includes("workerDisplayName = worker.display_name || worker.label") &&
workersPage.includes(
"workerDisplayName = worker.display_name || worker.label",
) &&
workersPage.includes("worker <code>{worker.worker_id}</code>") &&
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<SortKey>('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("<pre>{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<TicketSummary>"),
"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(

View File

@ -1,283 +0,0 @@
<script lang="ts">
import type { RepositoryTicketsResponse, TicketSummary } from '$lib/workspace/sidebar/types';
const INITIAL_VISIBLE_ROWS = 30;
const VISIBLE_ROW_INCREMENT = 30;
const NEAR_BOTTOM_PX = 96;
type KanbanGroup = {
key: string;
label: string;
states: string[];
items: TicketSummary[];
};
type GroupMetadata = {
key: string;
label: string;
states: string[];
};
let { tickets }: { tickets: RepositoryTicketsResponse } = $props();
let visibleRowsByGroup = $state<Record<string, number>>({});
let groups = $derived(buildGroups(tickets.columns));
$effect(() => {
const groupKeys = new Set(groups.map((group) => group.key));
const nextVisibleRows = { ...visibleRowsByGroup };
let changed = false;
for (const group of groups) {
if (nextVisibleRows[group.key] === undefined) {
nextVisibleRows[group.key] = INITIAL_VISIBLE_ROWS;
changed = true;
}
}
for (const key of Object.keys(nextVisibleRows)) {
if (!groupKeys.has(key)) {
delete nextVisibleRows[key];
changed = true;
}
}
if (changed) {
visibleRowsByGroup = nextVisibleRows;
}
});
function buildGroups(columns: RepositoryTicketsResponse['columns']): KanbanGroup[] {
const groupsByKey = new Map<string, KanbanGroup>();
for (const column of columns) {
const metadata = groupMetadataForState(column.state);
let group = groupsByKey.get(metadata.key);
if (!group) {
group = {
key: metadata.key,
label: metadata.label,
states: metadata.states,
items: []
};
groupsByKey.set(metadata.key, group);
}
group.items.push(...column.items);
}
return Array.from(groupsByKey.values()).map((group) => ({
...group,
items: sortGroupItems(group.items)
}));
}
function groupMetadataForState(state: string): GroupMetadata {
if (state === 'planning' || state === 'ready') {
return {
key: 'ready-planning',
label: 'Ready / Planning',
states: ['ready', 'planning']
};
}
if (state === 'queued' || state === 'inprogress') {
return {
key: 'inprogress-queued',
label: 'In progress / Queued',
states: ['inprogress', 'queued']
};
}
if (state === 'other') {
return {
key: 'state:other',
label: 'Other states',
states: ['other']
};
}
return {
key: `state:${state}`,
label: state,
states: [state]
};
}
function sortGroupItems(items: TicketSummary[]): TicketSummary[] {
return items
.map((ticket, index) => ({ ticket, index }))
.sort((left, right) => {
const stateOrder = statePriority(left.ticket.state) - statePriority(right.ticket.state);
if (stateOrder !== 0) {
return stateOrder;
}
return left.index - right.index;
})
.map(({ ticket }) => ticket);
}
function statePriority(state: string): number {
if (state === 'ready' || state === 'inprogress') {
return 0;
}
if (state === 'planning' || state === 'queued') {
return 1;
}
return 2;
}
function visibleCount(groupKey: string): number {
return visibleRowsByGroup[groupKey] ?? INITIAL_VISIBLE_ROWS;
}
function visibleTickets(group: KanbanGroup): TicketSummary[] {
return group.items.slice(0, visibleCount(group.key));
}
function hasMore(group: KanbanGroup): boolean {
return visibleCount(group.key) < group.items.length;
}
function onGroupScroll(group: KanbanGroup, event: Event) {
if (!hasMore(group)) {
return;
}
const target = event.currentTarget;
if (!(target instanceof HTMLElement)) {
return;
}
const distanceFromBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
if (distanceFromBottom > NEAR_BOTTOM_PX) {
return;
}
visibleRowsByGroup = {
...visibleRowsByGroup,
[group.key]: Math.min(group.items.length, visibleCount(group.key) + VISIBLE_ROW_INCREMENT)
};
}
function formatDate(value: string | null | undefined): string {
return value ?? 'not recorded';
}
</script>
<div class="repository-ticket-kanban">
{#each groups as group (group.key)}
<article class="ticket-group" aria-labelledby={`${group.key}-heading`}>
<header class="ticket-group-heading">
<div>
<h3 id={`${group.key}-heading`}>{group.label}</h3>
<p>{group.states.join(' + ')}</p>
</div>
<span>{group.items.length}</span>
</header>
{#if group.items.length === 0}
<p class="muted">No tickets.</p>
{:else}
<div
class="ticket-list-scroll"
aria-label={`${group.label} tickets`}
onscroll={(event) => onGroupScroll(group, event)}
>
<ul class="ticket-list">
{#each visibleTickets(group) as ticket (ticket.id)}
<li class="ticket-row">
<div class="ticket-row-heading">
<strong>{ticket.title}</strong>
<span class="ticket-state">{ticket.state}</span>
</div>
<small><code>{ticket.id}</code> · updated {formatDate(ticket.updated_at)}</small>
</li>
{/each}
</ul>
{#if hasMore(group)}
<p class="lazy-note">Showing {visibleCount(group.key)} of {group.items.length}; scroll for more.</p>
{/if}
</div>
{/if}
</article>
{/each}
</div>
<style>
.repository-ticket-kanban {
display: grid;
gap: var(--space-5);
grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr));
margin-top: var(--space-4);
}
.ticket-group {
min-width: 0;
padding: var(--space-4) 0 0;
border-top: 1px solid var(--line);
}
.ticket-group-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.ticket-group-heading h3 {
margin: 0;
color: var(--text-muted);
font-size: 0.9rem;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.ticket-group-heading p,
.ticket-group-heading span,
.lazy-note {
color: var(--text-faint);
font-size: 0.78rem;
}
.ticket-group-heading p,
.lazy-note {
margin: var(--space-1) 0 0;
}
.ticket-list-scroll {
max-height: 34rem;
overflow-y: auto;
padding-right: var(--space-2);
scrollbar-gutter: stable;
}
.ticket-list {
display: grid;
gap: var(--space-3);
margin: 0;
padding: 0;
list-style: none;
}
.ticket-row {
padding-left: var(--space-3);
border-left: 2px solid var(--line);
}
.ticket-row-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
}
.ticket-row-heading strong {
color: var(--text-strong);
}
.ticket-state {
flex: 0 0 auto;
color: var(--text-muted);
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
</style>

View File

@ -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;

View File

@ -87,6 +87,28 @@ Deno.test("defaultWorkerLaunchForm uses the Backend-published Workspace default
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(
{
@ -117,8 +139,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", () => {

View File

@ -51,7 +51,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

View File

@ -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;
}
}
}

View File

@ -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);

View File

@ -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): void;
};
function assertEquals<T>(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.",
);
});

View File

@ -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<string, number>([
["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("_", " ");
}

View File

@ -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';

View File

@ -1,6 +1,5 @@
<script lang="ts">
import { formatDate } from '$lib/workspace/api/http';
import RepositoryTicketKanban from '$lib/workspace/pages/RepositoryTicketKanban.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
@ -92,14 +91,3 @@
<p>Loading repository commits…</p>
{/if}
</section>
<section class="card repository-tickets-card">
<h2>Repository Tickets</h2>
{#if data.repositoryTickets}
<RepositoryTicketKanban tickets={data.repositoryTickets} />
{:else if data.repositoryTicketsError}
<p class="error">{data.repositoryTicketsError}</p>
{:else}
<p>Loading repository tickets…</p>
{/if}
</section>

View File

@ -2,14 +2,13 @@ import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type {
RepositoryDetailResponse,
RepositoryLogResponse,
RepositoryTicketsResponse,
} from "$lib/workspace/sidebar/types";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path);
const repositoryId = params.repositoryId;
const [repository, log, tickets] = await Promise.all([
const [repository, log] = await Promise.all([
loadJson<RepositoryDetailResponse>(
fetch,
apiPath(`/repositories/${encodeURIComponent(repositoryId)}`),
@ -18,10 +17,6 @@ export const load: PageLoad = async ({ fetch, params }) => {
fetch,
apiPath(`/repositories/${encodeURIComponent(repositoryId)}/log`),
),
loadJson<RepositoryTicketsResponse>(
fetch,
apiPath(`/repositories/${encodeURIComponent(repositoryId)}/tickets`),
),
]);
return {
@ -30,7 +25,5 @@ export const load: PageLoad = async ({ fetch, params }) => {
repositoryError: repository.error,
repositoryLog: log.data,
repositoryLogError: log.error,
repositoryTickets: tickets.data,
repositoryTicketsError: tickets.error,
};
};

View File

@ -1,392 +1,76 @@
<script lang="ts">
import { formatDate, workspaceRoute } from '$lib/workspace/api/http';
import type { TicketSummary } from '$lib/workspace/sidebar/types';
import type { PageProps } from './$types';
import { untrack } from "svelte";
import type { ApiResult } from "$lib/workspace/api/http";
import { ticketLanes } from "$lib/workspace/tickets/ticket-panel";
import type {
TicketListResponse,
TicketSummary,
} from "$lib/workspace/sidebar/types";
type SortKey = 'panel' | 'title' | 'state' | 'priority' | 'updated_at' | 'queued_at' | 'id';
type SortDirection = 'asc' | 'desc';
const { data } = $props<{
data: {
workspaceId: string;
tickets: ApiResult<TicketListResponse>;
};
}>();
let { data }: PageProps = $props();
const initialTickets = untrack(() => data.tickets.data?.items ?? []);
let tickets = $state<TicketSummary[]>(initialTickets);
let lanes = $derived(ticketLanes(tickets));
let query = $state('');
let visibilityFilter = $state<'open' | 'closed' | 'all'>('open');
let stateFilter = $state('all');
let priorityFilter = $state('all');
let queuedFilter = $state('all');
let sortKey = $state<SortKey>('panel');
let sortDirection = $state<SortDirection>('asc');
const tickets = $derived(data.tickets.data?.items ?? []);
const states = $derived(uniqueValues(tickets.map((ticket) => ticket.state)));
const priorities = $derived(uniqueValues(tickets.map((ticket) => ticket.priority).filter(isPresent)));
const filteredTickets = $derived(filterTickets(tickets));
const visibleTickets = $derived(sortTickets(filteredTickets));
function isPresent(value: string | null | undefined): value is string {
return Boolean(value && value.trim());
}
function uniqueValues(values: string[]): string[] {
return [...new Set(values.filter((value) => value.trim()))].sort((left, right) =>
left.localeCompare(right),
);
}
function filterTickets(items: TicketSummary[]): TicketSummary[] {
const needle = query.trim().toLowerCase();
return items.filter((ticket) => {
if (visibilityFilter === 'open' && ticket.state === 'closed') {
return false;
}
if (visibilityFilter === 'closed' && ticket.state !== 'closed') {
return false;
}
if (stateFilter !== 'all' && ticket.state !== stateFilter) {
return false;
}
if (priorityFilter !== 'all' && (ticket.priority ?? '') !== priorityFilter) {
return false;
}
if (queuedFilter === 'queued' && !ticket.queued_at) {
return false;
}
if (queuedFilter === 'unqueued' && ticket.queued_at) {
return false;
}
if (!needle) {
return true;
}
return [ticket.id, ticket.title, ticket.state, ticket.priority, ticket.queued_by, ticket.record_source]
.filter(isPresent)
.some((value) => value.toLowerCase().includes(needle));
});
}
function sortTickets(items: TicketSummary[]): TicketSummary[] {
return [...items].sort((left, right) => {
const result = compareTicketValues(left, right, sortKey);
return sortDirection === 'asc' ? result : -result;
});
}
function compareTicketValues(left: TicketSummary, right: TicketSummary, key: SortKey): number {
if (key === 'panel') {
return comparePanelOrder(left, right);
}
if (key === 'updated_at' || key === 'queued_at') {
return compareDate(left[key], right[key]);
}
return compareText(ticketValue(left, key), ticketValue(right, key));
}
function comparePanelOrder(left: TicketSummary, right: TicketSummary): number {
return compareNumber(panelActionPriority(left), panelActionPriority(right))
|| compareDate(right.updated_at, left.updated_at)
|| compareText(left.title, right.title);
}
function panelActionPriority(ticket: TicketSummary): number {
if (ticket.workspace_action_priority) {
if (ticket.workspace_action_priority === 'ready_for_queue') return 0;
if (ticket.workspace_action_priority === 'active_work') return 1;
if (ticket.workspace_action_priority === 'background') return 2;
}
if (ticket.state === 'ready') return 0;
if (ticket.state === 'queued' || ticket.state === 'inprogress') return 1;
return 2;
}
function compareNumber(left: number, right: number): number {
return left - right;
}
function ticketValue(ticket: TicketSummary, key: SortKey): string | null | undefined {
if (key === 'id') return ticket.id;
if (key === 'title') return ticket.title;
if (key === 'state') return ticket.state;
if (key === 'priority') return ticket.priority;
return null;
}
function compareText(left: string | null | undefined, right: string | null | undefined): number {
const leftText = left?.trim() ?? '';
const rightText = right?.trim() ?? '';
if (!leftText && rightText) return 1;
if (leftText && !rightText) return -1;
return leftText.localeCompare(rightText);
}
function compareDate(left: string | null | undefined, right: string | null | undefined): number {
const leftTime = left ? Date.parse(left) : Number.NEGATIVE_INFINITY;
const rightTime = right ? Date.parse(right) : Number.NEGATIVE_INFINITY;
return leftTime - rightTime;
}
function toggleSort(key: SortKey) {
if (sortKey === key) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
return;
}
sortKey = key;
sortDirection = key === 'updated_at' || key === 'queued_at' ? 'desc' : 'asc';
}
function sortLabel(key: SortKey): string {
if (sortKey !== key) return '';
return sortDirection === 'asc' ? '↑' : '↓';
}
function resetFilters() {
query = '';
visibilityFilter = 'open';
stateFilter = 'all';
priorityFilter = 'all';
queuedFilter = 'all';
sortKey = 'panel';
sortDirection = 'asc';
function prettyDate(value?: string | null): string {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString();
}
</script>
<svelte:head>
<title>Tickets · Yoi Workspace</title>
<meta name="description" content="Workspace Tickets" />
</svelte:head>
<svelte:head><title>Tickets · Yoi</title></svelte:head>
<section class="card ticket-database-card">
<div class="detail-heading">
<div class="workspace-page ticket-panel-page">
<header class="workspace-page-header ticket-panel-header">
<div>
<p class="eyebrow">Workspace records</p>
<h2>Tickets</h2>
</div>
{#if data.tickets.data}
<span>{visibleTickets.length} / {data.tickets.data.items.length} ticket{data.tickets.data.items.length === 1 ? '' : 's'}</span>
{/if}
</div>
<p class="section-note">
Tickets are read from the typed Ticket backend. This read-only table supports Notion-style filtering and sorting for browsing imported workspace Tickets.
<p class="workspace-eyebrow">Delivery</p>
<h1>Tickets</h1>
<p class="workspace-page-lede">
Plan, route, review, and close work without leaving the workspace.
</p>
{#if data.tickets.data}
{#if data.tickets.data.items.length === 0}
<p>No Ticket records are present.</p>
{:else}
<div class="ticket-database-toolbar" aria-label="Ticket table controls">
<label class="ticket-filter ticket-search">
<span>Search</span>
<input bind:value={query} type="search" placeholder="Title, id, state, source…" />
</label>
<label class="ticket-filter">
<span>Visibility</span>
<select bind:value={visibilityFilter}>
<option value="open">Open</option>
<option value="closed">Closed</option>
<option value="all">All</option>
</select>
</label>
<label class="ticket-filter">
<span>State</span>
<select bind:value={stateFilter}>
<option value="all">All states</option>
{#each states as state}
<option value={state}>{state}</option>
{/each}
</select>
</label>
<label class="ticket-filter">
<span>Priority</span>
<select bind:value={priorityFilter}>
<option value="all">All priorities</option>
{#each priorities as priority}
<option value={priority}>{priority}</option>
{/each}
</select>
</label>
<label class="ticket-filter">
<span>Queue</span>
<select bind:value={queuedFilter}>
<option value="all">All</option>
<option value="queued">Queued</option>
<option value="unqueued">Unqueued</option>
</select>
</label>
<button class="secondary-button" type="button" onclick={() => toggleSort('panel')}>Panel order {sortLabel('panel')}</button>
<button class="secondary-button" type="button" onclick={resetFilters}>Reset</button>
</div>
<div class="ticket-table-wrap" aria-label="Workspace Tickets table">
<table class="ticket-table">
<thead>
<tr>
<th><button type="button" onclick={() => toggleSort('title')}>Title {sortLabel('title')}</button></th>
<th><button type="button" onclick={() => toggleSort('state')}>State {sortLabel('state')}</button></th>
<th><button type="button" onclick={() => toggleSort('priority')}>Priority {sortLabel('priority')}</button></th>
<th><button type="button" onclick={() => toggleSort('updated_at')}>Updated {sortLabel('updated_at')}</button></th>
<th><button type="button" onclick={() => toggleSort('queued_at')}>Queued {sortLabel('queued_at')}</button></th>
<th><button type="button" onclick={() => toggleSort('id')}>ID {sortLabel('id')}</button></th>
</tr>
</thead>
<tbody>
{#each visibleTickets as ticket (ticket.id)}
<tr>
<td class="ticket-title-cell">
<a href={workspaceRoute(data.workspaceId, `/tickets/${ticket.id}`)}>{ticket.title}</a>
<span>{ticket.record_source ?? 'ticket backend'}</span>
</td>
<td><span class="state-pill">{ticket.state}</span></td>
<td>{ticket.priority || '—'}</td>
<td>{ticket.updated_at ? formatDate(ticket.updated_at) : 'unknown'}</td>
<td>
{#if ticket.queued_at}
<span>{formatDate(ticket.queued_at)}</span>
{#if ticket.queued_by}
<small>{ticket.queued_by}</small>
{/if}
{:else}
<span class="muted"></span>
{/if}
</td>
<td><code>{ticket.id}</code></td>
</tr>
{/each}
</tbody>
</table>
<div class="ticket-panel-summary" aria-label="Ticket summary">
<strong>{tickets.length}</strong>
<span>tickets</span>
</div>
</header>
{#if visibleTickets.length === 0}
<p class="section-note">No tickets match the current filters.</p>
{/if}
{/if}
<section class="ticket-kanban" aria-label="Ticket workflow board">
{#each lanes as lane (lane.id)}
<section class="ticket-lane" data-state={lane.id}>
<header class="ticket-lane-header">
<div>
<span class="ticket-state-dot"></span>
<h2>{lane.label}</h2>
</div>
<span class="ticket-lane-count">{lane.tickets.length}</span>
</header>
{#if data.tickets.data.invalid_records.length > 0}
<p class="error">{data.tickets.data.invalid_records.length} invalid Ticket record(s) hidden.</p>
{/if}
{:else if data.tickets.error}
<p class="error">{data.tickets.error}</p>
<div class="ticket-lane-cards">
{#each lane.tickets as ticket (ticket.id)}
<a
class="ticket-card"
href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(ticket.id)}`}
>
<span class="ticket-card-id">{ticket.id}</span>
<strong>{ticket.title}</strong>
<div class="ticket-card-meta">
<span>{ticket.state} · {ticket.priority}</span>
<time>{prettyDate(ticket.updated_at)}</time>
</div>
</a>
{:else}
<p>Waiting for <code>/api/w/{data.workspaceId}/tickets</code></p>
{/if}
</section>
<style>
.ticket-database-card {
overflow: hidden;
}
.ticket-database-toolbar {
display: grid;
grid-template-columns: minmax(16rem, 1.8fr) repeat(4, minmax(9rem, 1fr)) auto auto;
gap: 0.75rem;
align-items: end;
margin: 1rem 0;
}
.ticket-filter {
display: grid;
gap: 0.35rem;
color: var(--text-muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.ticket-filter input,
.ticket-filter select {
min-height: 2.35rem;
border: 1px solid var(--line);
border-radius: 0.6rem;
background: var(--bg-raised);
color: inherit;
font: inherit;
font-weight: 500;
letter-spacing: normal;
padding: 0 0.7rem;
text-transform: none;
}
.secondary-button {
min-height: 2.35rem;
border: 1px solid var(--line);
border-radius: 0.6rem;
background: var(--bg-raised);
color: inherit;
cursor: pointer;
font: inherit;
font-weight: 600;
padding: 0 0.9rem;
}
.ticket-table-wrap {
border: 1px solid var(--line);
border-radius: 0.8rem;
overflow: auto;
}
.ticket-table {
width: 100%;
min-width: 58rem;
border-collapse: collapse;
background: var(--bg-raised);
color: var(--text);
font-size: 0.9rem;
}
.ticket-table th,
.ticket-table td {
border-bottom: 1px solid var(--line);
padding: 0.72rem 0.8rem;
text-align: left;
vertical-align: top;
}
.ticket-table th {
background: var(--bg-subtle);
color: var(--text-muted);
font-size: 0.76rem;
letter-spacing: 0.04em;
position: sticky;
text-transform: uppercase;
top: 0;
z-index: 1;
}
.ticket-table th button {
all: unset;
cursor: pointer;
}
.ticket-table tbody tr:hover {
background: var(--interactive-hover);
}
.ticket-title-cell {
min-width: 22rem;
}
.ticket-title-cell a {
color: inherit;
display: block;
font-weight: 700;
text-decoration: none;
}
.ticket-title-cell a:hover {
text-decoration: underline;
}
.ticket-title-cell span,
.ticket-table small,
.muted {
color: var(--text-muted);
display: block;
font-size: 0.78rem;
margin-top: 0.2rem;
}
@media (max-width: 900px) {
.ticket-database-toolbar {
grid-template-columns: 1fr;
}
}
</style>
<div class="ticket-lane-empty">No tickets</div>
{/each}
</div>
</section>
{/each}
</section>
</div>

View File

@ -1,82 +1,362 @@
<script lang="ts">
import { formatDate, workspaceRoute } from '$lib/workspace/api/http';
import type { PageProps } from './$types';
import { untrack } from "svelte";
import RichMarkdown from "$lib/workspace/console/RichMarkdown.svelte";
import {
workspaceApiJsonWithBody,
workspaceApiPath,
} from "$lib/workspace/api/http";
import {
relationLabel,
TICKET_STATES,
ticketWorkerLaunchHref,
} from "$lib/workspace/tickets/ticket-panel";
import type { ApiResult } from "$lib/workspace/api/http";
import type {
RepositoryListResponse,
TicketDetail,
} from "$lib/workspace/sidebar/types";
let { data }: PageProps = $props();
const { data } = $props<{
data: {
workspaceId: string;
ticketId: string;
ticket: ApiResult<TicketDetail>;
repositories: ApiResult<RepositoryListResponse>;
};
}>();
const initialData = untrack(() => data);
const loadedTicket = initialData.ticket.data;
if (!loadedTicket) throw new Error(initialData.ticket.error ?? "ticket load failed");
const loadedRepositories = initialData.repositories.data;
let ticket = $state<TicketDetail>(loadedTicket);
let editing = $state(false);
let editTitle = $state(loadedTicket.title);
let editBody = $state(loadedTicket.body);
let repositoryId = $state(loadedTicket.repository_id ?? "");
let refSelector = $state(loadedTicket.ref_selector ?? "");
let nextState = $state(loadedTicket.state);
let transitionReason = $state("");
let threadRole = $state("comment");
let threadBody = $state("");
let reviewResult = $state("approve");
let reviewBody = $state("");
let resolution = $state("");
let busy = $state<string | null>(null);
let errorMessage = $state<string | null>(null);
const ticketPath = $derived(
workspaceApiPath(
data.workspaceId,
`/tickets/${encodeURIComponent(data.ticketId)}`,
),
);
function applyTicket(updatedTicket: TicketDetail): void {
ticket = updatedTicket;
editTitle = ticket.title;
editBody = ticket.body;
repositoryId = ticket.repository_id ?? "";
refSelector = ticket.ref_selector ?? "";
nextState = ticket.state;
}
async function mutate(
action: string,
suffix: string,
body?: Record<string, unknown>,
method = "POST",
): Promise<boolean> {
if (busy) return false;
busy = action;
errorMessage = null;
try {
const path = `${ticketPath}${suffix}`;
const response = await workspaceApiJsonWithBody<TicketDetail>(path, {
method,
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
applyTicket(response);
return true;
} catch (error) {
errorMessage = error instanceof Error ? error.message : String(error);
return false;
} finally {
busy = null;
}
}
async function saveEdit(event: SubmitEvent) {
event.preventDefault();
if (
await mutate("edit", "", {
title: editTitle.trim(),
body: editBody,
}, "PATCH")
) editing = false;
}
async function saveTarget(event: SubmitEvent) {
event.preventDefault();
await mutate("target", "", {
target: repositoryId
? {
action: "set",
repository_id: repositoryId,
ref_selector: refSelector.trim() || null,
}
: { action: "clear" },
}, "PATCH");
}
async function transition(event: SubmitEvent) {
event.preventDefault();
if (
await mutate("state", "/state", {
state: nextState,
reason: transitionReason.trim() || null,
})
) transitionReason = "";
}
async function appendThread(event: SubmitEvent) {
event.preventDefault();
if (!threadBody.trim()) return;
if (
await mutate("thread", "/thread", {
role: threadRole,
body: threadBody.trim(),
})
) threadBody = "";
}
async function review(event: SubmitEvent) {
event.preventDefault();
if (!reviewBody.trim()) return;
if (
await mutate("review", "/review", {
result: reviewResult,
body: reviewBody.trim(),
})
) reviewBody = "";
}
async function closeTicket(event: SubmitEvent) {
event.preventDefault();
if (!resolution.trim()) return;
if (
await mutate("close", "/close", { resolution: resolution.trim() })
) resolution = "";
}
function eventTitle(kind: string): string {
return relationLabel(kind);
}
function prettyDate(value?: string | null): string {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
</script>
<svelte:head>
<title>{data.ticket.data?.title ?? data.ticketId} · Tickets · Yoi Workspace</title>
<meta name="description" content="Workspace Ticket detail" />
</svelte:head>
<svelte:head><title>{ticket.title} · Yoi</title></svelte:head>
<section class="card">
<p class="breadcrumb"><a href={workspaceRoute(data.workspaceId, '/tickets')}>Tickets</a> / {data.ticketId}</p>
<div class="workspace-page ticket-detail-page">
<a class="workspace-back-link" href={`/w/${encodeURIComponent(data.workspaceId)}/tickets`}>
← Ticket board
</a>
{#if data.ticket.data}
<div class="detail-heading">
<header class="ticket-detail-header">
<div>
<p class="eyebrow">{data.ticket.data.id}</p>
<h2>{data.ticket.data.title}</h2>
<div class="ticket-detail-kicker">
<span class="workspace-status-pill" data-status={ticket.state}>{ticket.state}</span>
<code>{ticket.id}</code>
</div>
<span class="state-pill">{data.ticket.data.state}</span>
<h1>{ticket.title}</h1>
<p>Updated {prettyDate(ticket.updated_at)}</p>
</div>
<button class="workspace-secondary-button" type="button" onclick={() => editing = !editing}>
{editing ? "Cancel edit" : "Edit ticket"}
</button>
</header>
<dl class="ticket-detail-grid">
<div>
<dt>Priority</dt>
<dd>{data.ticket.data.priority ?? 'unspecified'}</dd>
</div>
<div>
<dt>Updated</dt>
<dd>{data.ticket.data.updated_at ? formatDate(data.ticket.data.updated_at) : 'unknown'}</dd>
</div>
<div>
<dt>Created</dt>
<dd>{data.ticket.data.created_at ? formatDate(data.ticket.data.created_at) : 'unknown'}</dd>
</div>
<div>
<dt>Events</dt>
<dd>{data.ticket.data.event_count}</dd>
</div>
<div>
<dt>Artifacts</dt>
<dd>{data.ticket.data.artifact_count}</dd>
</div>
<div>
<dt>Source</dt>
<dd>{data.ticket.data.record_source}</dd>
</div>
{#if data.ticket.data.queued_at || data.ticket.data.queued_by}
<div>
<dt>Queued</dt>
<dd>
{data.ticket.data.queued_at ? formatDate(data.ticket.data.queued_at) : 'queued'}{data.ticket.data.queued_by ? ` by ${data.ticket.data.queued_by}` : ''}
</dd>
</div>
{#if errorMessage}
<div class="workspace-callout is-error" role="alert">{errorMessage}</div>
{/if}
</dl>
{#if data.ticket.data.risk_flags.length > 0}
<div class="risk-flags" aria-label="Risk flags">
{#each data.ticket.data.risk_flags as flag}
<span>{flag}</span>
{#if editing}
<form class="ticket-editor" onsubmit={saveEdit}>
<label>Title<input bind:value={editTitle} required /></label>
<label>Body<textarea bind:value={editBody} rows="12"></textarea></label>
<button class="workspace-primary-button" type="submit" disabled={busy === "edit" || !editTitle.trim()}>
{busy === "edit" ? "Saving…" : "Save changes"}
</button>
</form>
{/if}
<div class="ticket-detail-grid">
<main class="ticket-detail-main">
<section class="ticket-detail-section">
<div class="ticket-section-heading"><h2>Intent</h2></div>
{#if ticket.body}
<RichMarkdown text={ticket.body} />
{:else}
<p class="workspace-empty-copy">No body has been recorded.</p>
{/if}
</section>
<section class="ticket-detail-section">
<div class="ticket-section-heading">
<h2>Relations</h2>
<span>{ticket.relations.outgoing.length + ticket.relations.incoming.length}</span>
</div>
{#if ticket.relations.blockers.length > 0}
<div class="ticket-blocker-list">
{#each ticket.relations.blockers as blocker}
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(blocker.blocking_ticket)}`}>
<strong>Blocked by {blocker.blocking_ticket}</strong>
<span>{relationLabel(blocker.relation_kind)} · {blocker.blocking_state}</span>
</a>
{/each}
</div>
{/if}
<section class="ticket-body" aria-labelledby="ticket-body-heading">
<div class="detail-heading compact">
<h3 id="ticket-body-heading">Body</h3>
{#if data.ticket.data.body_truncated}
<span class="warning-pill">truncated</span>
<div class="ticket-relations-list">
{#each ticket.relations.outgoing as relation}
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(relation.target)}`}>
<span>{relationLabel(relation.kind)}</span>
<strong>{relation.target}</strong>
{#if relation.note}<small>{relation.note}</small>{/if}
</a>
{/each}
{#each ticket.relations.incoming as relation}
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(relation.source_ticket)}`}>
<span>{relationLabel(relation.inverse_kind)}</span>
<strong>{relation.source_ticket}</strong>
{#if relation.note}<small>{relation.note}</small>{/if}
</a>
{/each}
{#if ticket.relations.outgoing.length === 0 && ticket.relations.incoming.length === 0}
<p class="workspace-empty-copy">No Ticket relations.</p>
{/if}
</div>
<pre>{data.ticket.data.body || 'No body text is available.'}</pre>
</section>
{:else if data.ticket.error}
<p class="error">{data.ticket.error}</p>
<section class="ticket-detail-section">
<div class="ticket-section-heading">
<h2>Timeline</h2><span>{ticket.event_count}</span>
</div>
<div class="ticket-timeline">
{#each ticket.events as event (event.sequence)}
<article>
<div class="ticket-timeline-marker"></div>
<div>
<header>
<strong>{event.heading ?? eventTitle(event.kind)}</strong>
<time>{prettyDate(event.at)}</time>
</header>
{#if event.author}<p class="ticket-event-author">{event.author}</p>{/if}
{#if event.from || event.to}<p>{event.from ?? "—"}{event.to ?? "—"}</p>{/if}
{#if event.reason}<p>{event.reason}</p>{/if}
{#if event.body}<RichMarkdown text={event.body} />{/if}
</div>
</article>
{:else}
<p>Waiting for <code>/api/w/{data.workspaceId}/tickets/{data.ticketId}</code></p>
<p class="workspace-empty-copy">No timeline events.</p>
{/each}
</div>
</section>
</main>
<aside class="ticket-control-rail">
<section class="ticket-control-card ticket-worker-card">
<header><h2>Start a Worker</h2><span>Ticket role</span></header>
<p class="ticket-assignment-line">
Assigned to <strong>{ticket.assignee ?? "Unassigned"}</strong>
</p>
<p>The common launch flow carries a short canonical Ticket message and the target below.</p>
<div class="ticket-role-actions">
<a class="workspace-primary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket, "coder")}>Coder</a>
<a class="workspace-secondary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket, "reviewer")}>Reviewer</a>
</div>
</section>
<section class="ticket-control-card">
<header><h2>Repository target</h2></header>
<form class="ticket-control-form" onsubmit={saveTarget}>
<label>Repository
<select bind:value={repositoryId}>
<option value="">Not assigned</option>
{#each loadedRepositories?.items ?? [] as repository}
<option value={repository.id}>{repository.display_name}</option>
{/each}
</select>
</label>
<label>Ref selector<input bind:value={refSelector} placeholder="branch, tag, or revision" /></label>
<button class="workspace-secondary-button" type="submit" disabled={busy === "target"}>
{busy === "target" ? "Saving…" : "Save target"}
</button>
</form>
</section>
<section class="ticket-control-card">
<header><h2>Workflow</h2></header>
<form class="ticket-control-form" onsubmit={transition}>
<label>State
<select bind:value={nextState}>
{#each TICKET_STATES as state}<option value={state}>{state}</option>{/each}
</select>
</label>
<label>Reason<input bind:value={transitionReason} placeholder="Optional decision context" /></label>
<button class="workspace-secondary-button" type="submit" disabled={busy === "state" || nextState === ticket.state}>
Apply state
</button>
</form>
{#if ticket.state === "ready"}
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue"} onclick={() => mutate("queue", "/queue", {})}>
{busy === "queue" ? "Queueing…" : "Queue ticket"}
</button>
{/if}
</section>
</section>
<details class="ticket-control-card">
<summary>Append timeline event</summary>
<form class="ticket-control-form" onsubmit={appendThread}>
<label>Role<select bind:value={threadRole}>
<option value="comment">Comment</option>
<option value="plan">Plan</option>
<option value="decision">Decision</option>
<option value="implementation_report">Implementation report</option>
</select></label>
<label>Body<textarea bind:value={threadBody} rows="5" required></textarea></label>
<button class="workspace-secondary-button" type="submit" disabled={busy === "thread" || !threadBody.trim()}>Append event</button>
</form>
</details>
<details class="ticket-control-card">
<summary>Record review</summary>
<form class="ticket-control-form" onsubmit={review}>
<label>Result<select bind:value={reviewResult}>
<option value="approve">Approve</option>
<option value="request_changes">Request changes</option>
</select></label>
<label>Review body<textarea bind:value={reviewBody} rows="5" required></textarea></label>
<button class="workspace-secondary-button" type="submit" disabled={busy === "review" || !reviewBody.trim()}>Record review</button>
</form>
</details>
{#if ticket.state !== "closed"}
<details class="ticket-control-card ticket-close-card">
<summary>Close ticket</summary>
<form class="ticket-control-form" onsubmit={closeTicket}>
<label>Resolution<textarea bind:value={resolution} rows="5" required></textarea></label>
<button class="workspace-danger-button" type="submit" disabled={busy === "close" || !resolution.trim()}>Close ticket</button>
</form>
</details>
{:else if ticket.resolution}
<section class="ticket-control-card"><header><h2>Resolution</h2></header><RichMarkdown text={ticket.resolution} /></section>
{/if}
</aside>
</div>
</div>

View File

@ -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<TicketDetail>(
const [ticket, repositories] = await Promise.all([
loadJson<TicketDetail>(
fetch,
workspaceApiPath(
params.workspaceId,
`/tickets/${encodeURIComponent(ticketId)}`,
`/tickets/${encodeURIComponent(params.ticketId)}`,
),
);
),
loadJson<RepositoryListResponse>(
fetch,
workspaceApiPath(params.workspaceId, "/repositories"),
),
]);
return {
workspaceId: params.workspaceId,
ticketId,
ticketId: params.ticketId,
ticket,
repositories,
};
}) satisfies PageLoad;

View File

@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { untrack } from 'svelte';
import { workspaceApiPath } from '$lib/workspace/api/http';
import { buildBrowserCreateWorkerRequest, defaultWorkerLaunchForm } from '$lib/workspace/sidebar/worker-launch';
import type {
@ -23,6 +24,7 @@
let { data }: PageProps = $props();
let workspaceId = $derived(data.workspaceId);
const ticketContext = untrack(() => data.ticketContext);
const NEW_WORKING_DIRECTORY_VALUE = '__new_working_directory__';
@ -31,13 +33,23 @@
let optionsError = $state<string | null>(null);
let submitting = $state(false);
let submitError = $state<DisplayError | null>(null);
let displayName = $state('Worker');
let displayName = $state(
ticketContext
? `${ticketContext.ticketTitle} · ${ticketContext.ticketRole || 'Worker'}`
: 'Worker',
);
let runtimeId = $state('');
let profile = $state('');
let initialText = $state('');
let profile = $state(
ticketContext
? ticketContext.ticketRole === 'reviewer'
? 'builtin:reviewer'
: 'builtin:coder'
: '',
);
let initialText = $state(ticketContext?.initialInput ?? '');
let workingDirectoryId = $state('');
let workingDirectoryRepositoryId = $state('');
let workingDirectorySelector = $state('HEAD');
let workingDirectoryRepositoryId = $state(ticketContext?.repositoryId ?? '');
let workingDirectorySelector = $state(ticketContext?.refSelector ?? 'HEAD');
let relativeCwd = $state('');
let creatingWorkingDirectory = $state(false);
let isNewWorkingDirectorySelected = $derived(workingDirectoryId === NEW_WORKING_DIRECTORY_VALUE);
@ -107,7 +119,8 @@
runtimeId = form.runtime_id;
displayName = form.display_name;
profile = form.profile;
workingDirectoryId = form.working_directory_id;
workingDirectoryId = form.working_directory_id ||
(ticketContext?.repositoryId ? NEW_WORKING_DIRECTORY_VALUE : '');
workingDirectoryRepositoryId = form.working_directory_repository_id;
workingDirectorySelector = form.working_directory_selector;
relativeCwd = form.relative_cwd;
@ -254,6 +267,17 @@
<a class="secondary-link" href={`/w/${workspaceId}`}>Back to workspace</a>
</header>
{#if ticketContext}
<aside class="worker-ticket-context">
<div>
<span>Ticket {ticketContext.ticketRole || 'Worker'}</span>
<strong>{ticketContext.ticketTitle}</strong>
<code>{ticketContext.ticketId}</code>
</div>
<a href={`/w/${workspaceId}/tickets/${encodeURIComponent(ticketContext.ticketId)}`}>View ticket</a>
</aside>
{/if}
{#if loading}
<p class="section-state">Loading launch options…</p>
{:else if optionsError}

View File

@ -1,5 +1,19 @@
export function load({ params }: { params: { workspaceId: string } }) {
export function load(
{ params, url }: { params: { workspaceId: string }; url: URL },
) {
const ticketId = url.searchParams.get("ticketId") ?? "";
const ticketRole = url.searchParams.get("ticketRole") ?? "";
return {
workspaceId: params.workspaceId,
ticketContext: ticketId
? {
ticketId,
ticketTitle: url.searchParams.get("ticketTitle") ?? ticketId,
ticketRole,
initialInput: url.searchParams.get("initialInput") ?? "",
repositoryId: url.searchParams.get("repositoryId") ?? "",
refSelector: url.searchParams.get("refSelector") ?? "HEAD",
}
: null,
};
}