feat: add workspace resource human keys
This commit is contained in:
@@ -103,6 +103,8 @@ pub struct BackendWorkerCapabilitySummary {
|
|||||||
pub struct BackendWorkerSummary {
|
pub struct BackendWorkerSummary {
|
||||||
pub runtime_id: String,
|
pub runtime_id: String,
|
||||||
pub worker_id: String,
|
pub worker_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub human_key: Option<String>,
|
||||||
pub host_id: String,
|
pub host_id: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
|
|||||||
+140
-2
@@ -124,6 +124,7 @@ fn read_ticket_summary_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<TicketSu
|
|||||||
let workflow_state = row.get::<_, String>(7)?;
|
let workflow_state = row.get::<_, String>(7)?;
|
||||||
Ok(TicketSummary {
|
Ok(TicketSummary {
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
|
human_key: None,
|
||||||
slug: row.get(1)?,
|
slug: row.get(1)?,
|
||||||
title: row.get(2)?,
|
title: row.get(2)?,
|
||||||
status: ExtensibleTicketStatus::from(row.get::<_, String>(3)?.as_str()),
|
status: ExtensibleTicketStatus::from(row.get::<_, String>(3)?.as_str()),
|
||||||
@@ -926,6 +927,8 @@ impl TicketListQuery {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct TicketRef {
|
pub struct TicketRef {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub human_key: Option<String>,
|
||||||
pub slug: String,
|
pub slug: String,
|
||||||
pub status: TicketStatus,
|
pub status: TicketStatus,
|
||||||
}
|
}
|
||||||
@@ -1540,6 +1543,8 @@ pub struct OrchestrationPlanRecord {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct TicketMeta {
|
pub struct TicketMeta {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub human_key: Option<String>,
|
||||||
pub slug: String,
|
pub slug: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub status: ExtensibleTicketStatus,
|
pub status: ExtensibleTicketStatus,
|
||||||
@@ -1563,6 +1568,8 @@ pub struct TicketMeta {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct TicketSummary {
|
pub struct TicketSummary {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub human_key: Option<String>,
|
||||||
pub slug: String,
|
pub slug: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub status: ExtensibleTicketStatus,
|
pub status: ExtensibleTicketStatus,
|
||||||
@@ -2669,6 +2676,9 @@ impl SqliteTicketBackend {
|
|||||||
let mut summaries = rows
|
let mut summaries = rows
|
||||||
.collect::<std::result::Result<Vec<_>, _>>()
|
.collect::<std::result::Result<Vec<_>, _>>()
|
||||||
.map_err(sqlite_err)?;
|
.map_err(sqlite_err)?;
|
||||||
|
for summary in &mut summaries {
|
||||||
|
summary.human_key = Self::human_key_for(conn, &self.workspace_id, &summary.id)?;
|
||||||
|
}
|
||||||
let has_more = summaries.len() > query.limit;
|
let has_more = summaries.len() > query.limit;
|
||||||
summaries.truncate(query.limit);
|
summaries.truncate(query.limit);
|
||||||
let next = has_more.then(|| {
|
let next = has_more.then(|| {
|
||||||
@@ -2752,6 +2762,7 @@ impl SqliteTicketBackend {
|
|||||||
updated_at,
|
updated_at,
|
||||||
) = row.map_err(sqlite_err)?;
|
) = row.map_err(sqlite_err)?;
|
||||||
summaries.push(TicketSummary {
|
summaries.push(TicketSummary {
|
||||||
|
human_key: Self::human_key_for(conn, &self.workspace_id, &id)?,
|
||||||
id,
|
id,
|
||||||
slug,
|
slug,
|
||||||
title,
|
title,
|
||||||
@@ -2928,7 +2939,16 @@ impl SqliteTicketBackend {
|
|||||||
|
|
||||||
fn resolve_ticket_id(&self, conn: &Connection, id: TicketIdOrSlug) -> Result<String> {
|
fn resolve_ticket_id(&self, conn: &Connection, id: TicketIdOrSlug) -> Result<String> {
|
||||||
let query = id.as_query().to_string();
|
let query = id.as_query().to_string();
|
||||||
let mut stmt = conn.prepare("SELECT ticket_id FROM typed_tickets WHERE workspace_id = ?1 AND (ticket_id = ?2 OR slug = ?2) ORDER BY ticket_id").map_err(sqlite_err)?;
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT ticket_id FROM typed_tickets
|
||||||
|
WHERE workspace_id = ?1 AND (ticket_id = ?2 OR slug = ?2)
|
||||||
|
UNION
|
||||||
|
SELECT resource_id FROM workspace_resource_human_keys
|
||||||
|
WHERE workspace_id = ?1 AND resource_kind = 'ticket' AND human_key = ?2
|
||||||
|
ORDER BY 1",
|
||||||
|
)
|
||||||
|
.map_err(sqlite_err)?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map(params![self.workspace_id, query], |row| {
|
.query_map(params![self.workspace_id, query], |row| {
|
||||||
row.get::<_, String>(0)
|
row.get::<_, String>(0)
|
||||||
@@ -2947,6 +2967,61 @@ impl SqliteTicketBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn human_key_for(
|
||||||
|
conn: &Connection,
|
||||||
|
workspace_id: &str,
|
||||||
|
ticket_id: &str,
|
||||||
|
) -> Result<Option<String>> {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT human_key FROM workspace_resource_human_keys
|
||||||
|
WHERE workspace_id = ?1 AND resource_kind = 'ticket' AND resource_id = ?2",
|
||||||
|
params![workspace_id, ticket_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(sqlite_err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allocate_human_key(
|
||||||
|
conn: &Connection,
|
||||||
|
workspace_id: &str,
|
||||||
|
ticket_id: &str,
|
||||||
|
allocated_at: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
if let Some(existing) = Self::human_key_for(conn, workspace_id, ticket_id)? {
|
||||||
|
return Ok(existing);
|
||||||
|
}
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO workspace_resource_human_key_counters
|
||||||
|
(workspace_id, resource_kind, next_sequence) VALUES (?1, 'ticket', 1)",
|
||||||
|
params![workspace_id],
|
||||||
|
)
|
||||||
|
.map_err(sqlite_err)?;
|
||||||
|
let sequence: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT next_sequence FROM workspace_resource_human_key_counters
|
||||||
|
WHERE workspace_id = ?1 AND resource_kind = 'ticket'",
|
||||||
|
params![workspace_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.map_err(sqlite_err)?;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE workspace_resource_human_key_counters SET next_sequence = ?3
|
||||||
|
WHERE workspace_id = ?1 AND resource_kind = 'ticket' AND next_sequence = ?2",
|
||||||
|
params![workspace_id, sequence, sequence + 1],
|
||||||
|
)
|
||||||
|
.map_err(sqlite_err)?;
|
||||||
|
let human_key = format!("T-{sequence}");
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO workspace_resource_human_keys
|
||||||
|
(workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at)
|
||||||
|
VALUES (?1, 'ticket', ?2, ?3, ?4, ?5)",
|
||||||
|
params![workspace_id, ticket_id, sequence, human_key, allocated_at],
|
||||||
|
)
|
||||||
|
.map_err(sqlite_err)?;
|
||||||
|
Ok(human_key)
|
||||||
|
}
|
||||||
|
|
||||||
fn ticket_exists(&self, conn: &Connection, id: &str) -> Result<bool> {
|
fn ticket_exists(&self, conn: &Connection, id: &str) -> Result<bool> {
|
||||||
Ok(conn
|
Ok(conn
|
||||||
.query_row(
|
.query_row(
|
||||||
@@ -3090,6 +3165,7 @@ impl SqliteTicketBackend {
|
|||||||
let state_raw: String = row.get(12)?;
|
let state_raw: String = row.get(12)?;
|
||||||
Ok(TicketMeta {
|
Ok(TicketMeta {
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
|
human_key: None,
|
||||||
slug: row.get(1)?,
|
slug: row.get(1)?,
|
||||||
title: row.get(2)?,
|
title: row.get(2)?,
|
||||||
status: ExtensibleTicketStatus::from(row.get::<_, String>(3)?.as_str()),
|
status: ExtensibleTicketStatus::from(row.get::<_, String>(3)?.as_str()),
|
||||||
@@ -3117,6 +3193,7 @@ impl SqliteTicketBackend {
|
|||||||
self.full_ticket_load_count.fetch_add(1, Ordering::SeqCst);
|
self.full_ticket_load_count.fetch_add(1, Ordering::SeqCst);
|
||||||
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"#,
|
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()))?;
|
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.human_key = Self::human_key_for(conn, &self.workspace_id, ticket_id)?;
|
||||||
meta.labels = self.load_ordered_values(conn, "typed_ticket_labels", "label", ticket_id)?;
|
meta.labels = self.load_ordered_values(conn, "typed_ticket_labels", "label", ticket_id)?;
|
||||||
meta.risk_flags =
|
meta.risk_flags =
|
||||||
self.load_ordered_values(conn, "typed_ticket_risk_flags", "risk_flag", ticket_id)?;
|
self.load_ordered_values(conn, "typed_ticket_risk_flags", "risk_flag", ticket_id)?;
|
||||||
@@ -3288,6 +3365,7 @@ impl SqliteTicketBackend {
|
|||||||
let mut summaries = Vec::new();
|
let mut summaries = Vec::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let mut meta = row.map_err(sqlite_err)?;
|
let mut meta = row.map_err(sqlite_err)?;
|
||||||
|
meta.human_key = Self::human_key_for(conn, &self.workspace_id, &meta.id)?;
|
||||||
if !filter.matches_state(meta.workflow_state) {
|
if !filter.matches_state(meta.workflow_state) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -3437,6 +3515,7 @@ impl TicketBackend for SqliteTicketBackend {
|
|||||||
};
|
};
|
||||||
let meta = TicketMeta {
|
let meta = TicketMeta {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
|
human_key: None,
|
||||||
slug: input.slug.clone().unwrap_or_else(|| id.clone()),
|
slug: input.slug.clone().unwrap_or_else(|| id.clone()),
|
||||||
title: input.title,
|
title: input.title,
|
||||||
status,
|
status,
|
||||||
@@ -3473,7 +3552,7 @@ impl TicketBackend for SqliteTicketBackend {
|
|||||||
events: vec![TicketEvent {
|
events: vec![TicketEvent {
|
||||||
kind: TicketEventKind::Create,
|
kind: TicketEventKind::Create,
|
||||||
author: Some(author),
|
author: Some(author),
|
||||||
at: Some(now),
|
at: Some(now.clone()),
|
||||||
status: None,
|
status: None,
|
||||||
from: None,
|
from: None,
|
||||||
to: None,
|
to: None,
|
||||||
@@ -3488,9 +3567,11 @@ impl TicketBackend for SqliteTicketBackend {
|
|||||||
relations: TicketRelationView::default(),
|
relations: TicketRelationView::default(),
|
||||||
resolution: None,
|
resolution: None,
|
||||||
};
|
};
|
||||||
|
let human_key = Self::allocate_human_key(conn, &self.workspace_id, &id, &now)?;
|
||||||
self.insert_ticket(conn, &ticket)?;
|
self.insert_ticket(conn, &ticket)?;
|
||||||
Ok(TicketRef {
|
Ok(TicketRef {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
|
human_key: Some(human_key),
|
||||||
slug: id,
|
slug: id,
|
||||||
status: TicketStatus::Open,
|
status: TicketStatus::Open,
|
||||||
})
|
})
|
||||||
@@ -4137,6 +4218,7 @@ impl TicketBackend for LocalTicketBackend {
|
|||||||
atomic_write(&dir.join("thread.md"), thread.as_bytes())?;
|
atomic_write(&dir.join("thread.md"), thread.as_bytes())?;
|
||||||
Ok(TicketRef {
|
Ok(TicketRef {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
|
human_key: None,
|
||||||
slug: id,
|
slug: id,
|
||||||
status: TicketStatus::Open,
|
status: TicketStatus::Open,
|
||||||
})
|
})
|
||||||
@@ -5174,6 +5256,7 @@ fn ticket_meta(frontmatter: TicketItemFrontmatter, id: String) -> TicketMeta {
|
|||||||
};
|
};
|
||||||
TicketMeta {
|
TicketMeta {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
|
human_key: None,
|
||||||
slug: id,
|
slug: id,
|
||||||
title: frontmatter.title.unwrap_or_default(),
|
title: frontmatter.title.unwrap_or_default(),
|
||||||
status,
|
status,
|
||||||
@@ -5198,6 +5281,7 @@ fn ticket_meta(frontmatter: TicketItemFrontmatter, id: String) -> TicketMeta {
|
|||||||
fn ticket_summary_from_meta(meta: TicketMeta) -> TicketSummary {
|
fn ticket_summary_from_meta(meta: TicketMeta) -> TicketSummary {
|
||||||
TicketSummary {
|
TicketSummary {
|
||||||
id: meta.id,
|
id: meta.id,
|
||||||
|
human_key: meta.human_key,
|
||||||
slug: meta.slug,
|
slug: meta.slug,
|
||||||
title: meta.title,
|
title: meta.title,
|
||||||
status: meta.status,
|
status: meta.status,
|
||||||
@@ -6806,6 +6890,7 @@ mod tests {
|
|||||||
fn summary_with_state(state: TicketWorkflowState) -> TicketSummary {
|
fn summary_with_state(state: TicketWorkflowState) -> TicketSummary {
|
||||||
TicketSummary {
|
TicketSummary {
|
||||||
id: "000TEST".to_string(),
|
id: "000TEST".to_string(),
|
||||||
|
human_key: Some("T-1".to_string()),
|
||||||
slug: "000TEST".to_string(),
|
slug: "000TEST".to_string(),
|
||||||
title: "Test Ticket".to_string(),
|
title: "Test Ticket".to_string(),
|
||||||
status: ExtensibleTicketStatus::Open,
|
status: ExtensibleTicketStatus::Open,
|
||||||
@@ -7210,6 +7295,59 @@ state: planning
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_human_keys_are_workspace_scoped_monotonic_and_resolvable() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let db_path = tmp.path().join("workspace.db");
|
||||||
|
let backend = SqliteTicketBackend::open(&db_path, "workspace-a").unwrap();
|
||||||
|
let first = backend.create(NewTicket::new("First")).unwrap();
|
||||||
|
let second = backend.create(NewTicket::new("Second")).unwrap();
|
||||||
|
assert_eq!(first.human_key.as_deref(), Some("T-1"));
|
||||||
|
assert_eq!(second.human_key.as_deref(), Some("T-2"));
|
||||||
|
assert_eq!(backend.show("T-1".into()).unwrap().meta.id, first.id);
|
||||||
|
let projection = backend.list_workspace_projection(100).unwrap();
|
||||||
|
let projected_second = projection
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.summary.id == second.id)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(projected_second.summary.human_key.as_deref(), Some("T-2"));
|
||||||
|
|
||||||
|
let other = SqliteTicketBackend::open(&db_path, "workspace-b").unwrap();
|
||||||
|
let other_first = other.create(NewTicket::new("Other")).unwrap();
|
||||||
|
assert_eq!(other_first.human_key.as_deref(), Some("T-1"));
|
||||||
|
assert_eq!(other.show("T-1".into()).unwrap().meta.id, other_first.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_human_key_allocation_is_concurrency_safe() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let db_path = tmp.path().join("workspace.db");
|
||||||
|
SqliteTicketBackend::open(&db_path, "workspace-a").unwrap();
|
||||||
|
let barrier = Arc::new(std::sync::Barrier::new(8));
|
||||||
|
let handles = (0..8)
|
||||||
|
.map(|index| {
|
||||||
|
let db_path = db_path.clone();
|
||||||
|
let barrier = barrier.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let backend = SqliteTicketBackend::open(db_path, "workspace-a").unwrap();
|
||||||
|
barrier.wait();
|
||||||
|
backend
|
||||||
|
.create(NewTicket::new(format!("Ticket {index}")))
|
||||||
|
.unwrap()
|
||||||
|
.human_key
|
||||||
|
.unwrap()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut keys = handles
|
||||||
|
.into_iter()
|
||||||
|
.map(|handle| handle.join().unwrap())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
keys.sort_by_key(|key| key.trim_start_matches("T-").parse::<u64>().unwrap());
|
||||||
|
assert_eq!(keys, (1..=8).map(|n| format!("T-{n}")).collect::<Vec<_>>());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sqlite_backend_persists_and_edits_ticket_target() {
|
fn sqlite_backend_persists_and_edits_ticket_target() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use crate::{Result, TicketError, sqlite_err};
|
|||||||
|
|
||||||
const MIGRATION_TABLE: &str = "ticket_schema_migrations";
|
const MIGRATION_TABLE: &str = "ticket_schema_migrations";
|
||||||
const MAX_SCHEMA_DIAGNOSTICS: usize = 32;
|
const MAX_SCHEMA_DIAGNOSTICS: usize = 32;
|
||||||
pub const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 4;
|
pub const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 5;
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
struct Migration {
|
struct Migration {
|
||||||
@@ -37,6 +37,11 @@ const MIGRATIONS: &[Migration] = &[
|
|||||||
name: "add_ticket_query_indexes",
|
name: "add_ticket_query_indexes",
|
||||||
apply: add_ticket_query_indexes,
|
apply: add_ticket_query_indexes,
|
||||||
},
|
},
|
||||||
|
Migration {
|
||||||
|
version: 5,
|
||||||
|
name: "add_workspace_human_keys",
|
||||||
|
apply: add_workspace_human_keys,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
@@ -535,6 +540,57 @@ fn add_ticket_query_indexes(connection: &Connection) -> Result<()> {
|
|||||||
.map_err(sqlite_err)
|
.map_err(sqlite_err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn add_workspace_human_keys(connection: &Connection) -> Result<()> {
|
||||||
|
connection
|
||||||
|
.execute_batch(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS workspace_resource_human_keys (
|
||||||
|
workspace_id TEXT NOT NULL,
|
||||||
|
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')),
|
||||||
|
resource_id TEXT NOT NULL,
|
||||||
|
sequence INTEGER NOT NULL CHECK (sequence > 0),
|
||||||
|
human_key TEXT NOT NULL,
|
||||||
|
allocated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (workspace_id, resource_kind, resource_id),
|
||||||
|
UNIQUE (workspace_id, resource_kind, sequence),
|
||||||
|
UNIQUE (workspace_id, human_key)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS workspace_resource_human_key_counters (
|
||||||
|
workspace_id TEXT NOT NULL,
|
||||||
|
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')),
|
||||||
|
next_sequence INTEGER NOT NULL CHECK (next_sequence > 0),
|
||||||
|
PRIMARY KEY (workspace_id, resource_kind)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO workspace_resource_human_keys (
|
||||||
|
workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at
|
||||||
|
)
|
||||||
|
SELECT workspace_id,
|
||||||
|
'ticket',
|
||||||
|
ticket_id,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY workspace_id ORDER BY created_at ASC, ticket_id ASC
|
||||||
|
),
|
||||||
|
'T-' || ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY workspace_id ORDER BY created_at ASC, ticket_id ASC
|
||||||
|
),
|
||||||
|
COALESCE(created_at, updated_at)
|
||||||
|
FROM typed_tickets;
|
||||||
|
|
||||||
|
INSERT INTO workspace_resource_human_key_counters (
|
||||||
|
workspace_id, resource_kind, next_sequence
|
||||||
|
)
|
||||||
|
SELECT workspace_id, 'ticket', MAX(sequence) + 1
|
||||||
|
FROM workspace_resource_human_keys
|
||||||
|
WHERE resource_kind = 'ticket'
|
||||||
|
GROUP BY workspace_id
|
||||||
|
ON CONFLICT(workspace_id, resource_kind) DO UPDATE SET
|
||||||
|
next_sequence = MAX(next_sequence, excluded.next_sequence);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.map_err(sqlite_err)
|
||||||
|
}
|
||||||
|
|
||||||
fn add_column_if_missing(
|
fn add_column_if_missing(
|
||||||
connection: &Connection,
|
connection: &Connection,
|
||||||
table: &str,
|
table: &str,
|
||||||
@@ -869,10 +925,10 @@ mod tests {
|
|||||||
verify_sqlite_ticket_schema(&connection).unwrap();
|
verify_sqlite_ticket_schema(&connection).unwrap();
|
||||||
|
|
||||||
let versions = load_applied_migrations(&connection).unwrap();
|
let versions = load_applied_migrations(&connection).unwrap();
|
||||||
assert_eq!(versions.len(), 4);
|
assert_eq!(versions.len(), 5);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
versions.get(&LATEST_SQLITE_TICKET_SCHEMA_VERSION),
|
versions.get(&LATEST_SQLITE_TICKET_SCHEMA_VERSION),
|
||||||
Some(&"add_ticket_query_indexes".to_string())
|
Some(&"add_workspace_human_keys".to_string())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -959,6 +1015,54 @@ mod tests {
|
|||||||
assert_eq!(preserved, (1, 1, 1, 1, 1));
|
assert_eq!(preserved, (1, 1, 1, 1, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v5_backfills_ticket_keys_by_creation_order_and_advances_counter() {
|
||||||
|
let connection = Connection::open_in_memory().unwrap();
|
||||||
|
migrate_sqlite_ticket_schema(&connection).unwrap();
|
||||||
|
connection.execute_batch(
|
||||||
|
"DROP TABLE workspace_resource_human_key_counters;
|
||||||
|
DROP TABLE workspace_resource_human_keys;
|
||||||
|
DELETE FROM ticket_schema_migrations WHERE version = 5;
|
||||||
|
INSERT INTO typed_tickets (
|
||||||
|
workspace_id, ticket_id, slug, title, status, kind, priority, body,
|
||||||
|
workflow_state, workflow_state_explicit, created_at, updated_at
|
||||||
|
) VALUES
|
||||||
|
('workspace-1', 'later', 'later', 'Later', 'open', 'task', 'medium', '', 'ready', 1, '2026-01-02T00:00:00Z', '2026-01-02T00:00:00Z'),
|
||||||
|
('workspace-1', 'earlier', 'earlier', 'Earlier', 'open', 'task', 'medium', '', 'ready', 1, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z');"
|
||||||
|
).unwrap();
|
||||||
|
|
||||||
|
migrate_sqlite_ticket_schema(&connection).unwrap();
|
||||||
|
let keys = connection
|
||||||
|
.prepare(
|
||||||
|
"SELECT resource_id, human_key FROM workspace_resource_human_keys
|
||||||
|
WHERE workspace_id = 'workspace-1' AND resource_kind = 'ticket'
|
||||||
|
ORDER BY sequence",
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
keys,
|
||||||
|
vec![
|
||||||
|
("earlier".into(), "T-1".into()),
|
||||||
|
("later".into(), "T-2".into())
|
||||||
|
]
|
||||||
|
);
|
||||||
|
let next: i64 = connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT next_sequence FROM workspace_resource_human_key_counters
|
||||||
|
WHERE workspace_id = 'workspace-1' AND resource_kind = 'ticket'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(next, 3);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn upgrades_legacy_schema_without_repository_target_columns() {
|
fn upgrades_legacy_schema_without_repository_target_columns() {
|
||||||
let connection = Connection::open_in_memory().unwrap();
|
let connection = Connection::open_in_memory().unwrap();
|
||||||
@@ -1017,7 +1121,7 @@ mod tests {
|
|||||||
.to_string()
|
.to_string()
|
||||||
.contains("unsupported Ticket schema migration version 99")
|
.contains("unsupported Ticket schema migration version 99")
|
||||||
);
|
);
|
||||||
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 5);
|
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 6);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1157,6 +1261,6 @@ mod tests {
|
|||||||
|
|
||||||
let connection = Connection::open(database).unwrap();
|
let connection = Connection::open(database).unwrap();
|
||||||
verify_sqlite_ticket_schema(&connection).unwrap();
|
verify_sqlite_ticket_schema(&connection).unwrap();
|
||||||
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 4);
|
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -317,6 +317,9 @@ fn state_style(state: &str) -> Style {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn short_worker_id(worker: &BackendWorkerSummary) -> String {
|
fn short_worker_id(worker: &BackendWorkerSummary) -> String {
|
||||||
|
if let Some(human_key) = worker.human_key.as_ref() {
|
||||||
|
return human_key.clone();
|
||||||
|
}
|
||||||
format!(
|
format!(
|
||||||
"{}:{}",
|
"{}:{}",
|
||||||
short_text(&worker.runtime_id),
|
short_text(&worker.runtime_id),
|
||||||
@@ -358,6 +361,7 @@ mod tests {
|
|||||||
BackendWorkerSummary {
|
BackendWorkerSummary {
|
||||||
runtime_id: runtime_id.to_string(),
|
runtime_id: runtime_id.to_string(),
|
||||||
worker_id: worker_id.to_string(),
|
worker_id: worker_id.to_string(),
|
||||||
|
human_key: None,
|
||||||
host_id: "host".to_string(),
|
host_id: "host".to_string(),
|
||||||
label: "label".to_string(),
|
label: "label".to_string(),
|
||||||
display_name: "label".to_string(),
|
display_name: "label".to_string(),
|
||||||
|
|||||||
@@ -535,7 +535,12 @@ pub(super) fn ticket_detail_style(row: &PanelRow) -> Style {
|
|||||||
pub(super) fn panel_ticket_reference(row: &PanelRow) -> String {
|
pub(super) fn panel_ticket_reference(row: &PanelRow) -> String {
|
||||||
row.ticket
|
row.ticket
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ticket| ticket.id.clone())
|
.map(|ticket| {
|
||||||
|
ticket
|
||||||
|
.human_key
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| ticket.id.clone())
|
||||||
|
})
|
||||||
.unwrap_or_else(|| match &row.key {
|
.unwrap_or_else(|| match &row.key {
|
||||||
PanelRowKey::Ticket(id) | PanelRowKey::InvalidTicket(id) => id.clone(),
|
PanelRowKey::Ticket(id) | PanelRowKey::InvalidTicket(id) => id.clone(),
|
||||||
PanelRowKey::TicketIntakeWorker { ticket_id, .. } => ticket_id.clone(),
|
PanelRowKey::TicketIntakeWorker { ticket_id, .. } => ticket_id.clone(),
|
||||||
|
|||||||
@@ -1737,6 +1737,7 @@ fn panel_ticket_rows_render_state_title_then_detail_line() {
|
|||||||
let state_start = 2;
|
let state_start = 2;
|
||||||
let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1;
|
let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1;
|
||||||
let row_id = row.ticket.as_ref().unwrap().id.as_str();
|
let row_id = row.ticket.as_ref().unwrap().id.as_str();
|
||||||
|
let human_key = row.ticket.as_ref().unwrap().human_key.as_deref().unwrap();
|
||||||
|
|
||||||
assert!(title_line.starts_with("▶ "));
|
assert!(title_line.starts_with("▶ "));
|
||||||
assert!(detail_line.starts_with("│ meta "));
|
assert!(detail_line.starts_with("│ meta "));
|
||||||
@@ -1746,7 +1747,7 @@ fn panel_ticket_rows_render_state_title_then_detail_line() {
|
|||||||
display_column(&title_line, "Workspace Dashboard composer targets"),
|
display_column(&title_line, "Workspace Dashboard composer targets"),
|
||||||
title_start
|
title_start
|
||||||
);
|
);
|
||||||
assert!(detail_line.contains(row_id));
|
assert!(detail_line.contains(human_key));
|
||||||
assert!(detail_line.contains("Gate: clear"));
|
assert!(detail_line.contains("Gate: clear"));
|
||||||
assert!(detail_line.contains("Action: Wait"));
|
assert!(detail_line.contains("Action: Wait"));
|
||||||
}
|
}
|
||||||
@@ -1769,7 +1770,7 @@ fn panel_ticket_non_selected_rows_align_with_selected_marker_space() {
|
|||||||
let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1;
|
let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1;
|
||||||
|
|
||||||
assert!(title_line.starts_with(" ready"));
|
assert!(title_line.starts_with(" ready"));
|
||||||
assert!(detail_line.starts_with(" meta 00001KTTB479X"));
|
assert!(detail_line.starts_with(" meta T-1"));
|
||||||
assert_eq!(display_column(&title_line, "ready"), state_start);
|
assert_eq!(display_column(&title_line, "ready"), state_start);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
display_column(&title_line, "Long Ticket title"),
|
display_column(&title_line, "Long Ticket title"),
|
||||||
@@ -1797,7 +1798,7 @@ fn panel_ticket_title_truncates_after_state_column() {
|
|||||||
assert_eq!(display_column(&title_line, "Very long Ticket"), title_start);
|
assert_eq!(display_column(&title_line, "Very long Ticket"), title_start);
|
||||||
assert!(title_line.ends_with('…'));
|
assert!(title_line.ends_with('…'));
|
||||||
assert_eq!(detail_line.width(), 42);
|
assert_eq!(detail_line.width(), 42);
|
||||||
assert!(detail_line.starts_with(" meta 00001KTTB479X · Gate: clear"));
|
assert!(detail_line.starts_with(" meta T-1 · Gate: clear"));
|
||||||
assert!(detail_line.ends_with('…'));
|
assert!(detail_line.ends_with('…'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3259,6 +3260,7 @@ fn panel_test_ticket_row(
|
|||||||
) -> PanelRow {
|
) -> PanelRow {
|
||||||
let ticket = crate::workspace_panel::TicketPanelEntry {
|
let ticket = crate::workspace_panel::TicketPanelEntry {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
|
human_key: Some("T-1".to_string()),
|
||||||
title: title.to_string(),
|
title: title.to_string(),
|
||||||
priority: "P2".to_string(),
|
priority: "P2".to_string(),
|
||||||
workflow_state: TicketWorkflowState::parse(state).unwrap_or(TicketWorkflowState::Planning),
|
workflow_state: TicketWorkflowState::parse(state).unwrap_or(TicketWorkflowState::Planning),
|
||||||
|
|||||||
@@ -253,6 +253,7 @@ impl NextUserAction {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub(crate) struct TicketPanelEntry {
|
pub(crate) struct TicketPanelEntry {
|
||||||
pub(crate) id: String,
|
pub(crate) id: String,
|
||||||
|
pub(crate) human_key: Option<String>,
|
||||||
pub(crate) title: String,
|
pub(crate) title: String,
|
||||||
pub(crate) priority: String,
|
pub(crate) priority: String,
|
||||||
pub(crate) workflow_state: TicketWorkflowState,
|
pub(crate) workflow_state: TicketWorkflowState,
|
||||||
@@ -1063,6 +1064,7 @@ pub(crate) fn build_current_ticket_row(
|
|||||||
fn ticket_summary_from_meta(meta: &TicketMeta) -> TicketSummary {
|
fn ticket_summary_from_meta(meta: &TicketMeta) -> TicketSummary {
|
||||||
TicketSummary {
|
TicketSummary {
|
||||||
id: meta.id.clone(),
|
id: meta.id.clone(),
|
||||||
|
human_key: meta.human_key.clone(),
|
||||||
slug: meta.slug.clone(),
|
slug: meta.slug.clone(),
|
||||||
title: meta.title.clone(),
|
title: meta.title.clone(),
|
||||||
status: meta.status.clone(),
|
status: meta.status.clone(),
|
||||||
@@ -1238,6 +1240,7 @@ fn ticket_row(
|
|||||||
let next_action = projection.next_action.map(next_user_action_from_workspace);
|
let next_action = projection.next_action.map(next_user_action_from_workspace);
|
||||||
let entry = TicketPanelEntry {
|
let entry = TicketPanelEntry {
|
||||||
id: summary.id.clone(),
|
id: summary.id.clone(),
|
||||||
|
human_key: summary.human_key.clone(),
|
||||||
title: summary.title.clone(),
|
title: summary.title.clone(),
|
||||||
priority: summary.priority.clone(),
|
priority: summary.priority.clone(),
|
||||||
workflow_state: summary.workflow_state,
|
workflow_state: summary.workflow_state,
|
||||||
|
|||||||
@@ -20,12 +20,13 @@ use crate::records::{
|
|||||||
ObjectiveShowRequest, ObjectiveSummary, ProjectRecordList, QueryPage, TicketAssignmentSummary,
|
ObjectiveShowRequest, ObjectiveSummary, ProjectRecordList, QueryPage, TicketAssignmentSummary,
|
||||||
TicketDetail, TicketEventDetail, TicketEvidenceEvent, TicketEvidenceSummary,
|
TicketDetail, TicketEventDetail, TicketEvidenceEvent, TicketEvidenceSummary,
|
||||||
TicketListPageRequest, TicketMergeRequestSummary, TicketQueryItem, TicketQueryRequest,
|
TicketListPageRequest, TicketMergeRequestSummary, TicketQueryItem, TicketQueryRequest,
|
||||||
TicketQueryResponse, TicketShowRequest, TicketSummary, TicketSummaryPage, summarize_body,
|
TicketQueryResponse, TicketRelationView, TicketShowRequest, TicketSummary, TicketSummaryPage,
|
||||||
truncate_body, validate_project_id,
|
summarize_body, truncate_body, validate_project_id,
|
||||||
};
|
};
|
||||||
use crate::store::{
|
use crate::store::{
|
||||||
ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord,
|
ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord,
|
||||||
ObjectiveEventRecord, ObjectiveRecord, ObjectiveTicketLinkRecord, SqliteWorkspaceStore,
|
ObjectiveEventRecord, ObjectiveRecord, ObjectiveTicketLinkRecord, SqliteWorkspaceStore,
|
||||||
|
WorkspaceResourceKind,
|
||||||
};
|
};
|
||||||
use crate::{Error, Result};
|
use crate::{Error, Result};
|
||||||
|
|
||||||
@@ -227,10 +228,24 @@ impl SqliteWorkspaceAuthority {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
fn objective_record(&self, id: &str) -> Result<ObjectiveRecord> {
|
fn human_key(&self, kind: WorkspaceResourceKind, resource_id: &str) -> Result<String> {
|
||||||
self.store
|
self.store
|
||||||
.get_objective(&self.workspace_id, id)?
|
.resource_human_key(&self.workspace_id, kind, resource_id)?
|
||||||
.ok_or_else(|| unknown_objective_error(id))
|
.ok_or_else(|| Error::Store(format!("missing human key for {resource_id}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn objective_record(&self, reference: &str) -> Result<ObjectiveRecord> {
|
||||||
|
let id = self
|
||||||
|
.store
|
||||||
|
.resolve_resource_reference(
|
||||||
|
&self.workspace_id,
|
||||||
|
WorkspaceResourceKind::Objective,
|
||||||
|
reference,
|
||||||
|
)?
|
||||||
|
.ok_or_else(|| unknown_objective_error(reference))?;
|
||||||
|
self.store
|
||||||
|
.get_objective(&self.workspace_id, &id)?
|
||||||
|
.ok_or_else(|| unknown_objective_error(reference))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn objective_detail_from_record(&self, record: ObjectiveRecord) -> Result<ObjectiveDetail> {
|
fn objective_detail_from_record(&self, record: ObjectiveRecord) -> Result<ObjectiveDetail> {
|
||||||
@@ -247,6 +262,7 @@ impl SqliteWorkspaceAuthority {
|
|||||||
.filter(|ticket| linked_tickets.iter().any(|id| id == &ticket.id))
|
.filter(|ticket| linked_tickets.iter().any(|id| id == &ticket.id))
|
||||||
.map(|ticket| ObjectiveLinkedTicketSummary {
|
.map(|ticket| ObjectiveLinkedTicketSummary {
|
||||||
id: ticket.id,
|
id: ticket.id,
|
||||||
|
human_key: ticket.human_key,
|
||||||
title: ticket.title,
|
title: ticket.title,
|
||||||
state: ticket.state,
|
state: ticket.state,
|
||||||
})
|
})
|
||||||
@@ -288,6 +304,7 @@ impl SqliteWorkspaceAuthority {
|
|||||||
.unwrap_or("none")
|
.unwrap_or("none")
|
||||||
);
|
);
|
||||||
Ok(ObjectiveDetail {
|
Ok(ObjectiveDetail {
|
||||||
|
human_key: self.human_key(WorkspaceResourceKind::Objective, &record.objective_id)?,
|
||||||
id: record.objective_id,
|
id: record.objective_id,
|
||||||
title: record.title,
|
title: record.title,
|
||||||
state: record.state,
|
state: record.state,
|
||||||
@@ -718,12 +735,16 @@ impl SqliteWorkspaceAuthority {
|
|||||||
.store
|
.store
|
||||||
.list_objectives_for_ticket(&self.workspace_id, id, 1_000)?
|
.list_objectives_for_ticket(&self.workspace_id, id, 1_000)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|objective| ObjectiveLinkSummary {
|
.map(|objective| {
|
||||||
|
Ok::<_, Error>(ObjectiveLinkSummary {
|
||||||
|
human_key: self
|
||||||
|
.human_key(WorkspaceResourceKind::Objective, &objective.objective_id)?,
|
||||||
id: objective.objective_id,
|
id: objective.objective_id,
|
||||||
title: objective.title,
|
title: objective.title,
|
||||||
state: objective.state,
|
state: objective.state,
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
})
|
||||||
|
.collect::<Result<Vec<_>>>()?;
|
||||||
let implementation_reports = ticket
|
let implementation_reports = ticket
|
||||||
.events
|
.events
|
||||||
.iter()
|
.iter()
|
||||||
@@ -734,11 +755,20 @@ impl SqliteWorkspaceAuthority {
|
|||||||
let current_assignment = self
|
let current_assignment = self
|
||||||
.store
|
.store
|
||||||
.get_current_ticket_worker_assignment(&self.workspace_id, id)?
|
.get_current_ticket_worker_assignment(&self.workspace_id, id)?
|
||||||
.map(|assignment| TicketAssignmentSummary {
|
.map(|assignment| {
|
||||||
|
let worker_human_key = self.store.resource_human_key(
|
||||||
|
&self.workspace_id,
|
||||||
|
WorkspaceResourceKind::Worker,
|
||||||
|
&assignment.worker.worker_id,
|
||||||
|
)?;
|
||||||
|
Ok::<_, Error>(TicketAssignmentSummary {
|
||||||
assignment_id: assignment.assignment_id,
|
assignment_id: assignment.assignment_id,
|
||||||
runtime_id: assignment.worker.runtime_id,
|
runtime_id: assignment.worker.runtime_id,
|
||||||
worker_id: assignment.worker.worker_id,
|
worker_id: assignment.worker.worker_id,
|
||||||
});
|
worker_human_key,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.transpose()?;
|
||||||
let merge_request = match self.merge_request_store.get(&self.workspace_id, id) {
|
let merge_request = match self.merge_request_store.get(&self.workspace_id, id) {
|
||||||
Ok(request) => {
|
Ok(request) => {
|
||||||
let current_subject_ref = request.selector_from.as_deref().and_then(|selector| {
|
let current_subject_ref = request.selector_from.as_deref().and_then(|selector| {
|
||||||
@@ -763,8 +793,41 @@ impl SqliteWorkspaceAuthority {
|
|||||||
.and_then(|event| event.attributes.get("event_id").cloned())
|
.and_then(|event| event.attributes.get("event_id").cloned())
|
||||||
.or_else(|| ticket.meta.updated_at.clone())
|
.or_else(|| ticket.meta.updated_at.clone())
|
||||||
.unwrap_or_else(|| format!("{}:0", ticket.meta.id));
|
.unwrap_or_else(|| format!("{}:0", ticket.meta.id));
|
||||||
|
let human_key = ticket
|
||||||
|
.meta
|
||||||
|
.human_key
|
||||||
|
.clone()
|
||||||
|
.or(self.store.resource_human_key(
|
||||||
|
&self.workspace_id,
|
||||||
|
WorkspaceResourceKind::Ticket,
|
||||||
|
&ticket.meta.id,
|
||||||
|
)?)
|
||||||
|
.ok_or_else(|| Error::Store(format!("missing human key for {}", ticket.meta.id)))?;
|
||||||
|
let mut relations: TicketRelationView = ticket.relations.into();
|
||||||
|
for relation in &mut relations.outgoing {
|
||||||
|
relation.target_human_key = self.store.resource_human_key(
|
||||||
|
&self.workspace_id,
|
||||||
|
WorkspaceResourceKind::Ticket,
|
||||||
|
&relation.target,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
for relation in &mut relations.incoming {
|
||||||
|
relation.source_human_key = self.store.resource_human_key(
|
||||||
|
&self.workspace_id,
|
||||||
|
WorkspaceResourceKind::Ticket,
|
||||||
|
&relation.source_ticket,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
for blocker in &mut relations.blockers {
|
||||||
|
blocker.blocking_human_key = self.store.resource_human_key(
|
||||||
|
&self.workspace_id,
|
||||||
|
WorkspaceResourceKind::Ticket,
|
||||||
|
&blocker.blocking_ticket,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
Ok(TicketDetail {
|
Ok(TicketDetail {
|
||||||
id: ticket.meta.id,
|
id: ticket.meta.id,
|
||||||
|
human_key,
|
||||||
title: ticket.meta.title,
|
title: ticket.meta.title,
|
||||||
state: ticket.meta.workflow_state.as_str().to_string(),
|
state: ticket.meta.workflow_state.as_str().to_string(),
|
||||||
readiness: ticket.meta.readiness,
|
readiness: ticket.meta.readiness,
|
||||||
@@ -797,7 +860,7 @@ impl SqliteWorkspaceAuthority {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|artifact| artifact.relative_path.display().to_string())
|
.map(|artifact| artifact.relative_path.display().to_string())
|
||||||
.collect(),
|
.collect(),
|
||||||
relations: ticket.relations.into(),
|
relations,
|
||||||
linked_objectives,
|
linked_objectives,
|
||||||
implementation_reports,
|
implementation_reports,
|
||||||
current_assignment,
|
current_assignment,
|
||||||
@@ -820,7 +883,11 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
|
|||||||
.map(|item| {
|
.map(|item| {
|
||||||
let projection =
|
let projection =
|
||||||
project_ticket_workspace_item(&item.summary, &item.relation_blockers, None);
|
project_ticket_workspace_item(&item.summary, &item.relation_blockers, None);
|
||||||
TicketSummary {
|
let human_key = item.summary.human_key.clone().ok_or_else(|| {
|
||||||
|
Error::Store(format!("missing human key for {}", item.summary.id))
|
||||||
|
})?;
|
||||||
|
Ok::<_, Error>(TicketSummary {
|
||||||
|
human_key,
|
||||||
id: item.summary.id,
|
id: item.summary.id,
|
||||||
title: item.summary.title,
|
title: item.summary.title,
|
||||||
state: item.summary.workflow_state.as_str().to_string(),
|
state: item.summary.workflow_state.as_str().to_string(),
|
||||||
@@ -831,9 +898,9 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
|
|||||||
workspace_action_priority: workspace_action_priority_name(projection.priority)
|
workspace_action_priority: workspace_action_priority_name(projection.priority)
|
||||||
.to_string(),
|
.to_string(),
|
||||||
record_source: "sqlite_yoi_ticket".to_string(),
|
record_source: "sqlite_yoi_ticket".to_string(),
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.collect();
|
})
|
||||||
|
.collect::<Result<Vec<_>>>()?;
|
||||||
Ok(ProjectRecordList {
|
Ok(ProjectRecordList {
|
||||||
items,
|
items,
|
||||||
invalid_records: Vec::new(),
|
invalid_records: Vec::new(),
|
||||||
@@ -874,7 +941,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
|
|||||||
.items
|
.items
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(ticket_summary_from_sqlite_item)
|
.map(ticket_summary_from_sqlite_item)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Result<Vec<_>>>()?;
|
||||||
let next_cursor = page
|
let next_cursor = page
|
||||||
.next
|
.next
|
||||||
.map(|position| make_ticket_summary_cursor(&fingerprint, position));
|
.map(|position| make_ticket_summary_cursor(&fingerprint, position));
|
||||||
@@ -913,7 +980,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
|
|||||||
let authoritative = self
|
let authoritative = self
|
||||||
.ticket_backend
|
.ticket_backend
|
||||||
.show(TicketIdOrSlug::Id(ticket_id.clone()))?;
|
.show(TicketIdOrSlug::Id(ticket_id.clone()))?;
|
||||||
let summary = ticket_summary_from_ticket(&authoritative);
|
let summary = ticket_summary_from_ticket(&authoritative)?;
|
||||||
let authoritative_body = authoritative.document.body.clone();
|
let authoritative_body = authoritative.document.body.clone();
|
||||||
let authoritative_events = authoritative.events.clone();
|
let authoritative_events = authoritative.events.clone();
|
||||||
let detail = self.ticket_detail_from_ticket(
|
let detail = self.ticket_detail_from_ticket(
|
||||||
@@ -987,6 +1054,8 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
|
|||||||
.map(|link| link.ticket_id)
|
.map(|link| link.ticket_id)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
items.push(ObjectiveSummary {
|
items.push(ObjectiveSummary {
|
||||||
|
human_key: self
|
||||||
|
.human_key(WorkspaceResourceKind::Objective, &record.objective_id)?,
|
||||||
id: record.objective_id,
|
id: record.objective_id,
|
||||||
title: record.title,
|
title: record.title,
|
||||||
state: record.state,
|
state: record.state,
|
||||||
@@ -1032,6 +1101,8 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
|
|||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let body_md = record.body_md.clone();
|
let body_md = record.body_md.clone();
|
||||||
let objective = ObjectiveSummary {
|
let objective = ObjectiveSummary {
|
||||||
|
human_key: self
|
||||||
|
.human_key(WorkspaceResourceKind::Objective, &record.objective_id)?,
|
||||||
id: record.objective_id,
|
id: record.objective_id,
|
||||||
title: record.title,
|
title: record.title,
|
||||||
state: record.state,
|
state: record.state,
|
||||||
@@ -2023,6 +2094,7 @@ fn ticket_query_item(
|
|||||||
}
|
}
|
||||||
TicketQueryItem {
|
TicketQueryItem {
|
||||||
id: summary.id,
|
id: summary.id,
|
||||||
|
human_key: summary.human_key,
|
||||||
title: summary.title,
|
title: summary.title,
|
||||||
state: summary.state,
|
state: summary.state,
|
||||||
readiness: detail.readiness.clone(),
|
readiness: detail.readiness.clone(),
|
||||||
@@ -2362,9 +2434,10 @@ fn memory_resolution_from_record(record: MemoryStagingResolutionRecord) -> Memor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ticket_summary_from_ticket(ticket: &ticket::Ticket) -> TicketSummary {
|
fn ticket_summary_from_ticket(ticket: &ticket::Ticket) -> Result<TicketSummary> {
|
||||||
let summary = ticket::TicketSummary {
|
let summary = ticket::TicketSummary {
|
||||||
id: ticket.meta.id.clone(),
|
id: ticket.meta.id.clone(),
|
||||||
|
human_key: ticket.meta.human_key.clone(),
|
||||||
slug: ticket.meta.slug.clone(),
|
slug: ticket.meta.slug.clone(),
|
||||||
title: ticket.meta.title.clone(),
|
title: ticket.meta.title.clone(),
|
||||||
status: ticket.meta.status.clone(),
|
status: ticket.meta.status.clone(),
|
||||||
@@ -2384,9 +2457,15 @@ fn ticket_summary_from_ticket(ticket: &ticket::Ticket) -> TicketSummary {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ticket_summary_from_sqlite_item(item: SqliteTicketListItem) -> TicketSummary {
|
fn ticket_summary_from_sqlite_item(item: SqliteTicketListItem) -> Result<TicketSummary> {
|
||||||
let projection = project_ticket_workspace_item(&item.summary, &item.relation_blockers, None);
|
let projection = project_ticket_workspace_item(&item.summary, &item.relation_blockers, None);
|
||||||
TicketSummary {
|
let human_key = item
|
||||||
|
.summary
|
||||||
|
.human_key
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| Error::Store(format!("missing human key for {}", item.summary.id)))?;
|
||||||
|
Ok(TicketSummary {
|
||||||
|
human_key,
|
||||||
id: item.summary.id,
|
id: item.summary.id,
|
||||||
title: item.summary.title,
|
title: item.summary.title,
|
||||||
state: item.summary.workflow_state.as_str().to_string(),
|
state: item.summary.workflow_state.as_str().to_string(),
|
||||||
@@ -2396,7 +2475,7 @@ fn ticket_summary_from_sqlite_item(item: SqliteTicketListItem) -> TicketSummary
|
|||||||
queued_at: item.summary.queued_at,
|
queued_at: item.summary.queued_at,
|
||||||
workspace_action_priority: workspace_action_priority_name(projection.priority).to_string(),
|
workspace_action_priority: workspace_action_priority_name(projection.priority).to_string(),
|
||||||
record_source: "sqlite_yoi_ticket".to_string(),
|
record_source: "sqlite_yoi_ticket".to_string(),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
|||||||
@@ -246,6 +246,8 @@ pub struct WorkerCapabilitySummary {
|
|||||||
pub struct WorkerSummary {
|
pub struct WorkerSummary {
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub worker: RuntimeWorkerRef,
|
pub worker: RuntimeWorkerRef,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub human_key: Option<String>,
|
||||||
pub host_id: String,
|
pub host_id: String,
|
||||||
/// Human-readable display name. This is not identity and may be duplicated.
|
/// Human-readable display name. This is not identity and may be duplicated.
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
@@ -1678,6 +1680,7 @@ impl EmbeddedWorkerRuntime {
|
|||||||
);
|
);
|
||||||
WorkerSummary {
|
WorkerSummary {
|
||||||
worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()),
|
worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()),
|
||||||
|
human_key: None,
|
||||||
host_id: self.host_id.clone(),
|
host_id: self.host_id.clone(),
|
||||||
display_name: display.display_name.clone(),
|
display_name: display.display_name.clone(),
|
||||||
label: display.display_name,
|
label: display.display_name,
|
||||||
@@ -1717,6 +1720,7 @@ impl EmbeddedWorkerRuntime {
|
|||||||
);
|
);
|
||||||
WorkerSummary {
|
WorkerSummary {
|
||||||
worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()),
|
worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()),
|
||||||
|
human_key: None,
|
||||||
host_id: self.host_id.clone(),
|
host_id: self.host_id.clone(),
|
||||||
display_name: display.display_name.clone(),
|
display_name: display.display_name.clone(),
|
||||||
label: display.display_name,
|
label: display.display_name,
|
||||||
@@ -2794,6 +2798,7 @@ impl RemoteWorkerRuntime {
|
|||||||
);
|
);
|
||||||
WorkerSummary {
|
WorkerSummary {
|
||||||
worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()),
|
worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()),
|
||||||
|
human_key: None,
|
||||||
host_id: self.host_id.clone(),
|
host_id: self.host_id.clone(),
|
||||||
display_name: display.display_name.clone(),
|
display_name: display.display_name.clone(),
|
||||||
label: display.display_name,
|
label: display.display_name,
|
||||||
@@ -2837,6 +2842,7 @@ impl RemoteWorkerRuntime {
|
|||||||
);
|
);
|
||||||
WorkerSummary {
|
WorkerSummary {
|
||||||
worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()),
|
worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()),
|
||||||
|
human_key: None,
|
||||||
host_id: self.host_id.clone(),
|
host_id: self.host_id.clone(),
|
||||||
display_name: display.display_name.clone(),
|
display_name: display.display_name.clone(),
|
||||||
label: display.display_name,
|
label: display.display_name,
|
||||||
@@ -4208,6 +4214,7 @@ pub fn placeholder_worker(host_id: impl Into<String>) -> WorkerSummary {
|
|||||||
let host_id = host_id.into();
|
let host_id = host_id.into();
|
||||||
WorkerSummary {
|
WorkerSummary {
|
||||||
worker: RuntimeWorkerRef::new("placeholder", "worker-placeholder"),
|
worker: RuntimeWorkerRef::new("placeholder", "worker-placeholder"),
|
||||||
|
human_key: None,
|
||||||
host_id,
|
host_id,
|
||||||
display_name: "Worker runtime actions are not implemented".to_string(),
|
display_name: "Worker runtime actions are not implemented".to_string(),
|
||||||
label: "Worker runtime actions are not implemented".to_string(),
|
label: "Worker runtime actions are not implemented".to_string(),
|
||||||
@@ -4601,6 +4608,7 @@ mod tests {
|
|||||||
host_id: host_id.to_string(),
|
host_id: host_id.to_string(),
|
||||||
workers: vec![WorkerSummary {
|
workers: vec![WorkerSummary {
|
||||||
worker: RuntimeWorkerRef::new(runtime_id, worker_id),
|
worker: RuntimeWorkerRef::new(runtime_id, worker_id),
|
||||||
|
human_key: None,
|
||||||
host_id: host_id.to_string(),
|
host_id: host_id.to_string(),
|
||||||
display_name: label.to_string(),
|
display_name: label.to_string(),
|
||||||
label: label.to_string(),
|
label: label.to_string(),
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ pub struct InvalidProjectRecord {
|
|||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct TicketSummary {
|
pub struct TicketSummary {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
pub human_key: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub state: String,
|
pub state: String,
|
||||||
pub priority: String,
|
pub priority: String,
|
||||||
@@ -66,6 +67,7 @@ pub struct TicketListResponse {
|
|||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct TicketDetail {
|
pub struct TicketDetail {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
pub human_key: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub state: String,
|
pub state: String,
|
||||||
pub readiness: Option<String>,
|
pub readiness: Option<String>,
|
||||||
@@ -121,6 +123,8 @@ pub struct TicketRelation {
|
|||||||
pub ticket_id: String,
|
pub ticket_id: String,
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
pub target: String,
|
pub target: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub target_human_key: Option<String>,
|
||||||
pub note: Option<String>,
|
pub note: Option<String>,
|
||||||
pub author: String,
|
pub author: String,
|
||||||
pub at: String,
|
pub at: String,
|
||||||
@@ -130,6 +134,8 @@ pub struct TicketRelation {
|
|||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct DerivedTicketRelation {
|
pub struct DerivedTicketRelation {
|
||||||
pub source_ticket: String,
|
pub source_ticket: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub source_human_key: Option<String>,
|
||||||
pub inverse_kind: String,
|
pub inverse_kind: String,
|
||||||
pub forward_kind: String,
|
pub forward_kind: String,
|
||||||
pub note: Option<String>,
|
pub note: Option<String>,
|
||||||
@@ -141,6 +147,8 @@ pub struct DerivedTicketRelation {
|
|||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct TicketRelationBlocker {
|
pub struct TicketRelationBlocker {
|
||||||
pub blocking_ticket: String,
|
pub blocking_ticket: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub blocking_human_key: Option<String>,
|
||||||
pub reason_kind: String,
|
pub reason_kind: String,
|
||||||
pub relation_kind: String,
|
pub relation_kind: String,
|
||||||
pub note: Option<String>,
|
pub note: Option<String>,
|
||||||
@@ -174,6 +182,7 @@ impl From<ticket::TicketRelationView> for TicketRelationView {
|
|||||||
ticket_id: relation.ticket_id,
|
ticket_id: relation.ticket_id,
|
||||||
kind: relation.kind.as_str().to_string(),
|
kind: relation.kind.as_str().to_string(),
|
||||||
target: relation.target,
|
target: relation.target,
|
||||||
|
target_human_key: None,
|
||||||
note: relation.note,
|
note: relation.note,
|
||||||
author: relation.author,
|
author: relation.author,
|
||||||
at: relation.at,
|
at: relation.at,
|
||||||
@@ -184,6 +193,7 @@ impl From<ticket::TicketRelationView> for TicketRelationView {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|relation| DerivedTicketRelation {
|
.map(|relation| DerivedTicketRelation {
|
||||||
source_ticket: relation.source_ticket,
|
source_ticket: relation.source_ticket,
|
||||||
|
source_human_key: None,
|
||||||
inverse_kind: relation.inverse_kind,
|
inverse_kind: relation.inverse_kind,
|
||||||
forward_kind: relation.forward_kind.as_str().to_string(),
|
forward_kind: relation.forward_kind.as_str().to_string(),
|
||||||
note: relation.note,
|
note: relation.note,
|
||||||
@@ -196,6 +206,7 @@ impl From<ticket::TicketRelationView> for TicketRelationView {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|blocker| TicketRelationBlocker {
|
.map(|blocker| TicketRelationBlocker {
|
||||||
blocking_ticket: blocker.blocking_ticket,
|
blocking_ticket: blocker.blocking_ticket,
|
||||||
|
blocking_human_key: None,
|
||||||
reason_kind: blocker.reason_kind,
|
reason_kind: blocker.reason_kind,
|
||||||
relation_kind: blocker.relation_kind.as_str().to_string(),
|
relation_kind: blocker.relation_kind.as_str().to_string(),
|
||||||
note: blocker.note,
|
note: blocker.note,
|
||||||
@@ -231,6 +242,7 @@ pub struct QueryPage {
|
|||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct ObjectiveLinkSummary {
|
pub struct ObjectiveLinkSummary {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
pub human_key: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub state: String,
|
pub state: String,
|
||||||
}
|
}
|
||||||
@@ -252,6 +264,8 @@ pub struct TicketAssignmentSummary {
|
|||||||
pub assignment_id: String,
|
pub assignment_id: String,
|
||||||
pub runtime_id: String,
|
pub runtime_id: String,
|
||||||
pub worker_id: String,
|
pub worker_id: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub worker_human_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -313,6 +327,7 @@ pub struct TicketQueryRequest {
|
|||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct TicketQueryItem {
|
pub struct TicketQueryItem {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
pub human_key: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub state: String,
|
pub state: String,
|
||||||
pub readiness: Option<String>,
|
pub readiness: Option<String>,
|
||||||
@@ -399,6 +414,7 @@ pub struct ObjectiveEventDetail {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct ObjectiveLinkedTicketSummary {
|
pub struct ObjectiveLinkedTicketSummary {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
pub human_key: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub state: String,
|
pub state: String,
|
||||||
}
|
}
|
||||||
@@ -406,6 +422,7 @@ pub struct ObjectiveLinkedTicketSummary {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct ObjectiveSummary {
|
pub struct ObjectiveSummary {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
pub human_key: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub state: String,
|
pub state: String,
|
||||||
pub created_at: Option<String>,
|
pub created_at: Option<String>,
|
||||||
@@ -418,6 +435,7 @@ pub struct ObjectiveSummary {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct ObjectiveDetail {
|
pub struct ObjectiveDetail {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
pub human_key: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub state: String,
|
pub state: String,
|
||||||
pub revision: String,
|
pub revision: String,
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ use crate::store::{
|
|||||||
AccountRecord, ApiTokenRecord, AuthChallengeRecord, BrowserSessionRecord, ControlPlaneStore,
|
AccountRecord, ApiTokenRecord, AuthChallengeRecord, BrowserSessionRecord, ControlPlaneStore,
|
||||||
DeviceLoginFlowRecord, FlowSourceRecord, PasskeyCredentialRecord, RepositoryRecord,
|
DeviceLoginFlowRecord, FlowSourceRecord, PasskeyCredentialRecord, RepositoryRecord,
|
||||||
TicketWorkerAssignmentRecord, UserRecord, WorkdirRegistryRecord, WorkerControlGrantRecord,
|
TicketWorkerAssignmentRecord, UserRecord, WorkdirRegistryRecord, WorkerControlGrantRecord,
|
||||||
WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord,
|
WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord, WorkspaceResourceKind,
|
||||||
};
|
};
|
||||||
use crate::{Error, Result};
|
use crate::{Error, Result};
|
||||||
use worker_runtime::catalog::{
|
use worker_runtime::catalog::{
|
||||||
@@ -1638,6 +1638,10 @@ pub fn build_router(api: WorkspaceApi) -> Router {
|
|||||||
"/api/w/{workspace_id}/workers",
|
"/api/w/{workspace_id}/workers",
|
||||||
get(scoped_list_workers).post(scoped_create_workspace_worker),
|
get(scoped_list_workers).post(scoped_create_workspace_worker),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/workers/{worker_ref}",
|
||||||
|
get(scoped_get_workspace_worker),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/w/{workspace_id}/protocol/ws",
|
"/api/w/{workspace_id}/protocol/ws",
|
||||||
get(scoped_workspace_protocol_ws),
|
get(scoped_workspace_protocol_ws),
|
||||||
@@ -2464,6 +2468,12 @@ struct ScopedConfigBundlePath {
|
|||||||
bundle_id: String,
|
bundle_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ScopedWorkspaceWorkerReferencePath {
|
||||||
|
workspace_id: String,
|
||||||
|
worker_ref: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct ScopedRuntimeWorkerPath {
|
struct ScopedRuntimeWorkerPath {
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
@@ -6235,6 +6245,35 @@ async fn scoped_worker_remove_source_boundary(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn scoped_get_workspace_worker(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedWorkspaceWorkerReferencePath>,
|
||||||
|
) -> ApiResult<Json<WorkerSummary>> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
let worker_id = api
|
||||||
|
.store
|
||||||
|
.resolve_resource_reference(
|
||||||
|
&api.config.workspace_id,
|
||||||
|
WorkspaceResourceKind::Worker,
|
||||||
|
&path.worker_ref,
|
||||||
|
)?
|
||||||
|
.ok_or_else(|| Error::UnknownWorker {
|
||||||
|
worker: RuntimeWorkerRef::new("unknown", &path.worker_ref),
|
||||||
|
})?;
|
||||||
|
let workers = workers_response(api.clone())?;
|
||||||
|
workers
|
||||||
|
.items
|
||||||
|
.into_iter()
|
||||||
|
.find(|worker| worker.worker.worker_id == worker_id)
|
||||||
|
.map(Json)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
Error::UnknownWorker {
|
||||||
|
worker: RuntimeWorkerRef::new("unknown", worker_id),
|
||||||
|
}
|
||||||
|
.into()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fn scoped_list_workers(
|
async fn scoped_list_workers(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
@@ -9511,11 +9550,36 @@ struct WorkerShowProjection {
|
|||||||
updated_at: String,
|
updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_workspace_worker_reference(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
runtime_id: &str,
|
||||||
|
reference: &str,
|
||||||
|
) -> ApiResult<RuntimeWorkerRef> {
|
||||||
|
let worker_id = api
|
||||||
|
.store
|
||||||
|
.resolve_resource_reference(
|
||||||
|
&api.config.workspace_id,
|
||||||
|
WorkspaceResourceKind::Worker,
|
||||||
|
reference,
|
||||||
|
)?
|
||||||
|
.ok_or_else(|| Error::UnknownWorker {
|
||||||
|
worker: RuntimeWorkerRef::new(runtime_id, reference),
|
||||||
|
})?;
|
||||||
|
let worker = RuntimeWorkerRef::new(runtime_id, worker_id);
|
||||||
|
let record = api
|
||||||
|
.store
|
||||||
|
.get_worker_registry(&api.config.workspace_id, &worker)?
|
||||||
|
.ok_or_else(|| Error::UnknownWorker {
|
||||||
|
worker: RuntimeWorkerRef::new(runtime_id, reference),
|
||||||
|
})?;
|
||||||
|
Ok(record.worker)
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_runtime_worker(
|
async fn get_runtime_worker(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>,
|
AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>,
|
||||||
) -> ApiResult<Json<WorkerShowProjection>> {
|
) -> ApiResult<Json<WorkerShowProjection>> {
|
||||||
let worker_ref = RuntimeWorkerRef::new(runtime_id, worker_id);
|
let worker_ref = resolve_workspace_worker_reference(&api, &runtime_id, &worker_id)?;
|
||||||
let worker = api
|
let worker = api
|
||||||
.runtime
|
.runtime
|
||||||
.worker(&worker_ref)
|
.worker(&worker_ref)
|
||||||
@@ -9528,17 +9592,20 @@ async fn get_runtime_worker(
|
|||||||
.store
|
.store
|
||||||
.list_workdir_registry(&api.config.workspace_id, 500)?;
|
.list_workdir_registry(&api.config.workspace_id, 500)?;
|
||||||
let updated_at = record.updated_at.clone();
|
let updated_at = record.updated_at.clone();
|
||||||
Ok(Json(WorkerShowProjection {
|
let mut worker = merge_worker_registry_projection(Some(&worker), &record, links, &workdirs);
|
||||||
worker: merge_worker_registry_projection(Some(&worker), &record, links, &workdirs),
|
worker.human_key = api.store.resource_human_key(
|
||||||
updated_at,
|
&api.config.workspace_id,
|
||||||
}))
|
WorkspaceResourceKind::Worker,
|
||||||
|
&worker_ref.worker_id,
|
||||||
|
)?;
|
||||||
|
Ok(Json(WorkerShowProjection { worker, updated_at }))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn restore_runtime_worker(
|
async fn restore_runtime_worker(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>,
|
AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>,
|
||||||
) -> ApiResult<Json<WorkerRestoreResponse>> {
|
) -> ApiResult<Json<WorkerRestoreResponse>> {
|
||||||
let worker = RuntimeWorkerRef::new(&runtime_id, &worker_id);
|
let worker = resolve_workspace_worker_reference(&api, &runtime_id, &worker_id)?;
|
||||||
let mut result = api.restore_workspace_worker(&worker)?;
|
let mut result = api.restore_workspace_worker(&worker)?;
|
||||||
if let Some(worker) = result.worker.as_ref() {
|
if let Some(worker) = result.worker.as_ref() {
|
||||||
let record = sync_worker_observation(&api, worker)?;
|
let record = sync_worker_observation(&api, worker)?;
|
||||||
@@ -10145,7 +10212,7 @@ async fn send_runtime_worker_input(
|
|||||||
AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>,
|
AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>,
|
||||||
Json(request): Json<WorkerInputRequest>,
|
Json(request): Json<WorkerInputRequest>,
|
||||||
) -> ApiResult<Json<WorkerInputResult>> {
|
) -> ApiResult<Json<WorkerInputResult>> {
|
||||||
let worker = RuntimeWorkerRef::new(&runtime_id, &worker_id);
|
let worker = resolve_workspace_worker_reference(&api, &runtime_id, &worker_id)?;
|
||||||
let result = api
|
let result = api
|
||||||
.runtime
|
.runtime
|
||||||
.send_input(&worker, request)
|
.send_input(&worker, request)
|
||||||
@@ -10158,7 +10225,7 @@ async fn runtime_worker_completions(
|
|||||||
AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>,
|
AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>,
|
||||||
Json(request): Json<WorkerCompletionsRequest>,
|
Json(request): Json<WorkerCompletionsRequest>,
|
||||||
) -> ApiResult<Json<WorkerCompletionsResult>> {
|
) -> ApiResult<Json<WorkerCompletionsResult>> {
|
||||||
let worker = RuntimeWorkerRef::new(&runtime_id, &worker_id);
|
let worker = resolve_workspace_worker_reference(&api, &runtime_id, &worker_id)?;
|
||||||
let result = api
|
let result = api
|
||||||
.runtime
|
.runtime
|
||||||
.worker_completions(&worker, request)
|
.worker_completions(&worker, request)
|
||||||
@@ -10171,7 +10238,7 @@ async fn stop_runtime_worker(
|
|||||||
AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>,
|
AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>,
|
||||||
Json(request): Json<WorkerLifecycleRequest>,
|
Json(request): Json<WorkerLifecycleRequest>,
|
||||||
) -> ApiResult<Json<WorkerLifecycleResult>> {
|
) -> ApiResult<Json<WorkerLifecycleResult>> {
|
||||||
let worker = RuntimeWorkerRef::new(&runtime_id, &worker_id);
|
let worker = resolve_workspace_worker_reference(&api, &runtime_id, &worker_id)?;
|
||||||
let result = api
|
let result = api
|
||||||
.runtime
|
.runtime
|
||||||
.stop_worker(&worker, request)
|
.stop_worker(&worker, request)
|
||||||
@@ -10194,7 +10261,7 @@ async fn cancel_runtime_worker(
|
|||||||
AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>,
|
AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>,
|
||||||
Json(request): Json<WorkerLifecycleRequest>,
|
Json(request): Json<WorkerLifecycleRequest>,
|
||||||
) -> ApiResult<Json<WorkerLifecycleResult>> {
|
) -> ApiResult<Json<WorkerLifecycleResult>> {
|
||||||
let worker = RuntimeWorkerRef::new(&runtime_id, &worker_id);
|
let worker = resolve_workspace_worker_reference(&api, &runtime_id, &worker_id)?;
|
||||||
let result = api
|
let result = api
|
||||||
.runtime
|
.runtime
|
||||||
.cancel_worker(&worker, request)
|
.cancel_worker(&worker, request)
|
||||||
@@ -10631,12 +10698,18 @@ fn workers_response(api: WorkspaceApi) -> ApiResult<RuntimeListResponse<WorkerSu
|
|||||||
let links = api
|
let links = api
|
||||||
.store
|
.store
|
||||||
.list_worker_workdir_links(&api.config.workspace_id, &record.worker)?;
|
.list_worker_workdir_links(&api.config.workspace_id, &record.worker)?;
|
||||||
items.push(merge_worker_registry_projection(
|
let mut summary = merge_worker_registry_projection(
|
||||||
observed.get(&record.worker),
|
observed.get(&record.worker),
|
||||||
&record,
|
&record,
|
||||||
links,
|
links,
|
||||||
&workdir_records,
|
&workdir_records,
|
||||||
));
|
);
|
||||||
|
summary.human_key = api.store.resource_human_key(
|
||||||
|
&api.config.workspace_id,
|
||||||
|
WorkspaceResourceKind::Worker,
|
||||||
|
&record.worker.worker_id,
|
||||||
|
)?;
|
||||||
|
items.push(summary);
|
||||||
}
|
}
|
||||||
Ok(RuntimeListResponse {
|
Ok(RuntimeListResponse {
|
||||||
workspace_id: api.config.workspace_id,
|
workspace_id: api.config.workspace_id,
|
||||||
@@ -11504,6 +11577,7 @@ fn record_worker_summary(
|
|||||||
fn worker_summary_from_registry(record: &WorkerRegistryRecord) -> WorkerSummary {
|
fn worker_summary_from_registry(record: &WorkerRegistryRecord) -> WorkerSummary {
|
||||||
WorkerSummary {
|
WorkerSummary {
|
||||||
worker: record.worker.clone(),
|
worker: record.worker.clone(),
|
||||||
|
human_key: None,
|
||||||
host_id: "backend-registry".to_string(),
|
host_id: "backend-registry".to_string(),
|
||||||
display_name: record.display_name.clone(),
|
display_name: record.display_name.clone(),
|
||||||
label: record.display_name.clone(),
|
label: record.display_name.clone(),
|
||||||
|
|||||||
@@ -206,6 +206,11 @@ const MIGRATIONS: &[Migration] = &[
|
|||||||
name: "promote Workspace Worker UUIDv7 identity",
|
name: "promote Workspace Worker UUIDv7 identity",
|
||||||
apply: promote_workspace_worker_uuid_identity,
|
apply: promote_workspace_worker_uuid_identity,
|
||||||
},
|
},
|
||||||
|
Migration {
|
||||||
|
version: 38,
|
||||||
|
name: "add Workspace resource human keys",
|
||||||
|
apply: add_workspace_resource_human_keys,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
struct Migration {
|
struct Migration {
|
||||||
@@ -517,9 +522,46 @@ pub struct FlowSourceRevisionRecord {
|
|||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum WorkspaceResourceKind {
|
||||||
|
Ticket,
|
||||||
|
Objective,
|
||||||
|
Worker,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkspaceResourceKind {
|
||||||
|
fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Ticket => "ticket",
|
||||||
|
Self::Objective => "objective",
|
||||||
|
Self::Worker => "worker",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prefix(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Ticket => "T",
|
||||||
|
Self::Objective => "O",
|
||||||
|
Self::Worker => "W",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait ControlPlaneStore: Send + Sync {
|
pub trait ControlPlaneStore: Send + Sync {
|
||||||
async fn schema_version(&self) -> Result<i64>;
|
async fn schema_version(&self) -> Result<i64>;
|
||||||
|
fn resource_human_key(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
kind: WorkspaceResourceKind,
|
||||||
|
resource_id: &str,
|
||||||
|
) -> Result<Option<String>>;
|
||||||
|
fn resolve_resource_reference(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
kind: WorkspaceResourceKind,
|
||||||
|
reference: &str,
|
||||||
|
) -> Result<Option<String>>;
|
||||||
async fn upsert_workspace(&self, record: &WorkspaceRecord) -> Result<()>;
|
async fn upsert_workspace(&self, record: &WorkspaceRecord) -> Result<()>;
|
||||||
async fn get_workspace(&self, workspace_id: &str) -> Result<Option<WorkspaceRecord>>;
|
async fn get_workspace(&self, workspace_id: &str) -> Result<Option<WorkspaceRecord>>;
|
||||||
async fn get_trusted_runtime(&self, runtime_id: &str) -> Result<Option<TrustedRuntimeRecord>>;
|
async fn get_trusted_runtime(&self, runtime_id: &str) -> Result<Option<TrustedRuntimeRecord>>;
|
||||||
@@ -1017,6 +1059,13 @@ impl SqliteWorkspaceStore {
|
|||||||
|
|
||||||
let worker_id = WorkerId::now_v7();
|
let worker_id = WorkerId::now_v7();
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
allocate_resource_human_key(
|
||||||
|
&tx,
|
||||||
|
workspace_id,
|
||||||
|
WorkspaceResourceKind::Worker,
|
||||||
|
&worker_id.to_string(),
|
||||||
|
&now,
|
||||||
|
)?;
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO worker_create_reservations(\
|
"INSERT INTO worker_create_reservations(\
|
||||||
workspace_id, allocation_key, worker_id, runtime_id, create_fingerprint,\
|
workspace_id, allocation_key, worker_id, runtime_id, create_fingerprint,\
|
||||||
@@ -1142,6 +1191,63 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
self.with_conn(current_schema_version)
|
self.with_conn(current_schema_version)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resource_human_key(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
kind: WorkspaceResourceKind,
|
||||||
|
resource_id: &str,
|
||||||
|
) -> Result<Option<String>> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT human_key FROM workspace_resource_human_keys
|
||||||
|
WHERE workspace_id = ?1 AND resource_kind = ?2 AND resource_id = ?3",
|
||||||
|
params![workspace_id, kind.as_str(), resource_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(Error::from)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_resource_reference(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
kind: WorkspaceResourceKind,
|
||||||
|
reference: &str,
|
||||||
|
) -> Result<Option<String>> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
if let Some(resource_id) = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT resource_id FROM workspace_resource_human_keys
|
||||||
|
WHERE workspace_id = ?1 AND resource_kind = ?2 AND human_key = ?3",
|
||||||
|
params![workspace_id, kind.as_str(), reference],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
{
|
||||||
|
return Ok(Some(resource_id));
|
||||||
|
}
|
||||||
|
let exists = match kind {
|
||||||
|
WorkspaceResourceKind::Ticket => conn.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM typed_tickets WHERE workspace_id = ?1 AND ticket_id = ?2)",
|
||||||
|
params![workspace_id, reference],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)?,
|
||||||
|
WorkspaceResourceKind::Objective => conn.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM objectives WHERE workspace_id = ?1 AND objective_id = ?2)",
|
||||||
|
params![workspace_id, reference],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)?,
|
||||||
|
WorkspaceResourceKind::Worker => conn.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM worker_registry WHERE workspace_id = ?1 AND worker_id = ?2)",
|
||||||
|
params![workspace_id, reference],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)?,
|
||||||
|
};
|
||||||
|
Ok((exists != 0).then(|| reference.to_string()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fn upsert_workspace(&self, record: &WorkspaceRecord) -> Result<()> {
|
async fn upsert_workspace(&self, record: &WorkspaceRecord) -> Result<()> {
|
||||||
self.with_conn(|conn| {
|
self.with_conn(|conn| {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -1596,7 +1702,15 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
|
|
||||||
fn upsert_objective(&self, record: &ObjectiveRecord) -> Result<()> {
|
fn upsert_objective(&self, record: &ObjectiveRecord) -> Result<()> {
|
||||||
self.with_conn(|conn| {
|
self.with_conn(|conn| {
|
||||||
conn.execute(
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
allocate_resource_human_key(
|
||||||
|
&tx,
|
||||||
|
&record.workspace_id,
|
||||||
|
WorkspaceResourceKind::Objective,
|
||||||
|
&record.objective_id,
|
||||||
|
&record.created_at,
|
||||||
|
)?;
|
||||||
|
tx.execute(
|
||||||
r#"INSERT INTO objectives (
|
r#"INSERT INTO objectives (
|
||||||
workspace_id, objective_id, title, state, body_md, created_at, updated_at
|
workspace_id, objective_id, title, state, body_md, created_at, updated_at
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||||
@@ -1617,6 +1731,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
record.updated_at,
|
record.updated_at,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -5469,6 +5584,135 @@ fn promote_workspace_worker_uuid_identity(conn: &Connection) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn allocate_resource_human_key(
|
||||||
|
conn: &Connection,
|
||||||
|
workspace_id: &str,
|
||||||
|
kind: WorkspaceResourceKind,
|
||||||
|
resource_id: &str,
|
||||||
|
allocated_at: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
if let Some(existing) = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT human_key FROM workspace_resource_human_keys
|
||||||
|
WHERE workspace_id = ?1 AND resource_kind = ?2 AND resource_id = ?3",
|
||||||
|
params![workspace_id, kind.as_str(), resource_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
{
|
||||||
|
return Ok(existing);
|
||||||
|
}
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO workspace_resource_human_key_counters
|
||||||
|
(workspace_id, resource_kind, next_sequence) VALUES (?1, ?2, 1)",
|
||||||
|
params![workspace_id, kind.as_str()],
|
||||||
|
)?;
|
||||||
|
let sequence: i64 = conn.query_row(
|
||||||
|
"SELECT next_sequence FROM workspace_resource_human_key_counters
|
||||||
|
WHERE workspace_id = ?1 AND resource_kind = ?2",
|
||||||
|
params![workspace_id, kind.as_str()],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE workspace_resource_human_key_counters SET next_sequence = ?3
|
||||||
|
WHERE workspace_id = ?1 AND resource_kind = ?2",
|
||||||
|
params![workspace_id, kind.as_str(), sequence + 1],
|
||||||
|
)?;
|
||||||
|
let human_key = format!("{}-{sequence}", kind.prefix());
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO workspace_resource_human_keys
|
||||||
|
(workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||||
|
params![
|
||||||
|
workspace_id,
|
||||||
|
kind.as_str(),
|
||||||
|
resource_id,
|
||||||
|
sequence,
|
||||||
|
human_key,
|
||||||
|
allocated_at
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(human_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_workspace_resource_human_keys(conn: &Connection) -> Result<()> {
|
||||||
|
conn.execute_batch(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS workspace_resource_human_keys (
|
||||||
|
workspace_id TEXT NOT NULL,
|
||||||
|
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')),
|
||||||
|
resource_id TEXT NOT NULL,
|
||||||
|
sequence INTEGER NOT NULL CHECK (sequence > 0),
|
||||||
|
human_key TEXT NOT NULL,
|
||||||
|
allocated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (workspace_id, resource_kind, resource_id),
|
||||||
|
UNIQUE (workspace_id, resource_kind, sequence),
|
||||||
|
UNIQUE (workspace_id, human_key)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS workspace_resource_human_key_counters (
|
||||||
|
workspace_id TEXT NOT NULL,
|
||||||
|
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')),
|
||||||
|
next_sequence INTEGER NOT NULL CHECK (next_sequence > 0),
|
||||||
|
PRIMARY KEY (workspace_id, resource_kind)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO workspace_resource_human_keys (
|
||||||
|
workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at
|
||||||
|
)
|
||||||
|
SELECT workspace_id,
|
||||||
|
'objective',
|
||||||
|
objective_id,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY workspace_id ORDER BY created_at ASC, objective_id ASC),
|
||||||
|
'O-' || ROW_NUMBER() OVER (PARTITION BY workspace_id ORDER BY created_at ASC, objective_id ASC),
|
||||||
|
created_at
|
||||||
|
FROM objectives;
|
||||||
|
INSERT OR IGNORE INTO workspace_resource_human_keys (
|
||||||
|
workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at
|
||||||
|
)
|
||||||
|
SELECT workspace_id,
|
||||||
|
'worker',
|
||||||
|
worker_id,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY workspace_id ORDER BY created_at ASC, worker_id ASC),
|
||||||
|
'W-' || ROW_NUMBER() OVER (PARTITION BY workspace_id ORDER BY created_at ASC, worker_id ASC),
|
||||||
|
created_at
|
||||||
|
FROM worker_registry;
|
||||||
|
|
||||||
|
INSERT INTO workspace_resource_human_key_counters (workspace_id, resource_kind, next_sequence)
|
||||||
|
SELECT workspace_id, resource_kind, MAX(sequence) + 1
|
||||||
|
FROM workspace_resource_human_keys
|
||||||
|
GROUP BY workspace_id, resource_kind
|
||||||
|
ON CONFLICT(workspace_id, resource_kind) DO UPDATE SET
|
||||||
|
next_sequence = MAX(next_sequence, excluded.next_sequence);
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
if table_exists(conn, "typed_tickets")? {
|
||||||
|
conn.execute_batch(
|
||||||
|
r#"
|
||||||
|
INSERT OR IGNORE INTO workspace_resource_human_keys (
|
||||||
|
workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at
|
||||||
|
)
|
||||||
|
SELECT workspace_id,
|
||||||
|
'ticket',
|
||||||
|
ticket_id,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY workspace_id ORDER BY created_at ASC, ticket_id ASC),
|
||||||
|
'T-' || ROW_NUMBER() OVER (PARTITION BY workspace_id ORDER BY created_at ASC, ticket_id ASC),
|
||||||
|
created_at
|
||||||
|
FROM typed_tickets;
|
||||||
|
INSERT INTO workspace_resource_human_key_counters (
|
||||||
|
workspace_id, resource_kind, next_sequence
|
||||||
|
)
|
||||||
|
SELECT workspace_id, 'ticket', MAX(sequence) + 1
|
||||||
|
FROM workspace_resource_human_keys
|
||||||
|
WHERE resource_kind = 'ticket'
|
||||||
|
GROUP BY workspace_id
|
||||||
|
ON CONFLICT(workspace_id, resource_kind) DO UPDATE SET
|
||||||
|
next_sequence = MAX(next_sequence, excluded.next_sequence);
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn remove_worker_control_delegation_authority(conn: &Connection) -> Result<()> {
|
fn remove_worker_control_delegation_authority(conn: &Connection) -> Result<()> {
|
||||||
let mut statement =
|
let mut statement =
|
||||||
conn.prepare("SELECT workspace_id, grant_id, permissions_json FROM worker_control_grants")?;
|
conn.prepare("SELECT workspace_id, grant_id, permissions_json FROM worker_control_grants")?;
|
||||||
@@ -6125,6 +6369,87 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v38_backfills_workspace_scoped_objective_and_worker_human_keys() {
|
||||||
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
|
configure_sqlite(&conn).unwrap();
|
||||||
|
apply_migrations_through(&conn, 37).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO workspaces(workspace_id, display_name, state, created_at, updated_at)
|
||||||
|
VALUES ('workspace-a', 'Workspace A', 'active', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')",
|
||||||
|
[],
|
||||||
|
).unwrap();
|
||||||
|
for (id, created_at) in [
|
||||||
|
("objective-later", "2026-01-02T00:00:00Z"),
|
||||||
|
("objective-earlier", "2026-01-01T00:00:00Z"),
|
||||||
|
] {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO objectives(workspace_id, objective_id, title, body_md, state, created_at, updated_at)
|
||||||
|
VALUES ('workspace-a', ?1, ?1, '', 'active', ?2, ?2)",
|
||||||
|
params![id, created_at],
|
||||||
|
).unwrap();
|
||||||
|
}
|
||||||
|
for (id, created_at) in [
|
||||||
|
(
|
||||||
|
"019b57c8-5c00-7000-8000-000000000002",
|
||||||
|
"2026-01-02T00:00:00Z",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"019b5280-0000-7000-8000-000000000001",
|
||||||
|
"2026-01-01T00:00:00Z",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO worker_registry(workspace_id, worker_id, runtime_id, display_name, retention_state, created_at, updated_at)
|
||||||
|
VALUES ('workspace-a', ?1, 'runtime-a', ?1, 'normal', ?2, ?2)",
|
||||||
|
params![id, created_at],
|
||||||
|
).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
ticket::migrate_sqlite_ticket_schema(&conn).unwrap();
|
||||||
|
apply_migrations(&conn).unwrap();
|
||||||
|
let mut statement = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT resource_kind, resource_id, human_key FROM workspace_resource_human_keys
|
||||||
|
ORDER BY resource_kind, sequence",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let keys = statement
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, String>(0)?,
|
||||||
|
row.get::<_, String>(1)?,
|
||||||
|
row.get::<_, String>(2)?,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
keys,
|
||||||
|
vec![
|
||||||
|
("objective".into(), "objective-earlier".into(), "O-1".into()),
|
||||||
|
("objective".into(), "objective-later".into(), "O-2".into()),
|
||||||
|
(
|
||||||
|
"worker".into(),
|
||||||
|
"019b5280-0000-7000-8000-000000000001".into(),
|
||||||
|
"W-1".into()
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"worker".into(),
|
||||||
|
"019b57c8-5c00-7000-8000-000000000002".into(),
|
||||||
|
"W-2".into()
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(current_schema_version(&conn).unwrap(), 38);
|
||||||
|
let foreign_key_error: Option<String> = conn
|
||||||
|
.query_row("PRAGMA foreign_key_check", [], |row| row.get(0))
|
||||||
|
.optional()
|
||||||
|
.unwrap();
|
||||||
|
assert!(foreign_key_error.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn startup_fails_closed_when_current_ticket_schema_has_drifted() {
|
fn startup_fails_closed_when_current_ticket_schema_has_drifted() {
|
||||||
let conn = Connection::open_in_memory().unwrap();
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
@@ -6241,7 +6566,7 @@ INSERT INTO worker_orphan_diagnostics (
|
|||||||
|
|
||||||
apply_migrations(&conn).unwrap();
|
apply_migrations(&conn).unwrap();
|
||||||
|
|
||||||
assert_eq!(current_schema_version(&conn).unwrap(), 37);
|
assert_eq!(current_schema_version(&conn).unwrap(), 38);
|
||||||
assert!(!table_exists(&conn, "worker_control_delegation_operations").unwrap());
|
assert!(!table_exists(&conn, "worker_control_delegation_operations").unwrap());
|
||||||
let controller_worker_id: String = conn
|
let controller_worker_id: String = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
@@ -6359,7 +6684,7 @@ INSERT INTO worker_orphan_diagnostics (
|
|||||||
|
|
||||||
apply_migrations(&conn).unwrap();
|
apply_migrations(&conn).unwrap();
|
||||||
|
|
||||||
assert_eq!(current_schema_version(&conn).unwrap(), 37);
|
assert_eq!(current_schema_version(&conn).unwrap(), 38);
|
||||||
assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap());
|
assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6392,7 +6717,7 @@ CREATE TABLE flow_events (event_id TEXT PRIMARY KEY);
|
|||||||
|
|
||||||
apply_migrations(&conn).unwrap();
|
apply_migrations(&conn).unwrap();
|
||||||
|
|
||||||
assert_eq!(current_schema_version(&conn).unwrap(), 37);
|
assert_eq!(current_schema_version(&conn).unwrap(), 38);
|
||||||
assert!(table_exists(&conn, "flow_sources").unwrap());
|
assert!(table_exists(&conn, "flow_sources").unwrap());
|
||||||
assert!(table_exists(&conn, "flow_source_revisions").unwrap());
|
assert!(table_exists(&conn, "flow_source_revisions").unwrap());
|
||||||
assert!(!table_exists(&conn, "flow_instances").unwrap());
|
assert!(!table_exists(&conn, "flow_instances").unwrap());
|
||||||
@@ -6459,7 +6784,7 @@ INSERT INTO worker_workdir_attachment_reservations (
|
|||||||
|
|
||||||
apply_migrations(&conn).unwrap();
|
apply_migrations(&conn).unwrap();
|
||||||
|
|
||||||
assert_eq!(current_schema_version(&conn).unwrap(), 37);
|
assert_eq!(current_schema_version(&conn).unwrap(), 38);
|
||||||
let repositories_sql: String = conn
|
let repositories_sql: String = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'repositories'",
|
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'repositories'",
|
||||||
@@ -6639,7 +6964,7 @@ INSERT INTO workdir_registry (
|
|||||||
let db = dir.path().join("control-plane.sqlite");
|
let db = dir.path().join("control-plane.sqlite");
|
||||||
let store = SqliteWorkspaceStore::open(&db).unwrap();
|
let store = SqliteWorkspaceStore::open(&db).unwrap();
|
||||||
|
|
||||||
assert_eq!(store.schema_version().await.unwrap(), 37);
|
assert_eq!(store.schema_version().await.unwrap(), 38);
|
||||||
assert!(
|
assert!(
|
||||||
!store
|
!store
|
||||||
.with_conn(|conn| table_exists(conn, "worker_workspace_credentials"))
|
.with_conn(|conn| table_exists(conn, "worker_workspace_credentials"))
|
||||||
@@ -6656,13 +6981,59 @@ INSERT INTO workdir_registry (
|
|||||||
store.upsert_workspace(&record).await.unwrap();
|
store.upsert_workspace(&record).await.unwrap();
|
||||||
|
|
||||||
let reopened = SqliteWorkspaceStore::open(&db).unwrap();
|
let reopened = SqliteWorkspaceStore::open(&db).unwrap();
|
||||||
assert_eq!(reopened.schema_version().await.unwrap(), 37);
|
assert_eq!(reopened.schema_version().await.unwrap(), 38);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
reopened.get_workspace("local-dev").await.unwrap(),
|
reopened.get_workspace("local-dev").await.unwrap(),
|
||||||
Some(record)
|
Some(record)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn objective_creation_allocates_and_resolves_workspace_human_key() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let store = SqliteWorkspaceStore::open(dir.path().join("server.db")).unwrap();
|
||||||
|
store
|
||||||
|
.upsert_workspace(&WorkspaceRecord {
|
||||||
|
workspace_id: "workspace-a".into(),
|
||||||
|
owner_account_id: None,
|
||||||
|
display_name: "Workspace A".into(),
|
||||||
|
state: "active".into(),
|
||||||
|
created_at: "1".into(),
|
||||||
|
updated_at: "1".into(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.upsert_objective(&ObjectiveRecord {
|
||||||
|
workspace_id: "workspace-a".into(),
|
||||||
|
objective_id: "objective-internal".into(),
|
||||||
|
title: "Ship it".into(),
|
||||||
|
state: "active".into(),
|
||||||
|
body_md: String::new(),
|
||||||
|
created_at: "2".into(),
|
||||||
|
updated_at: "2".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.resource_human_key(
|
||||||
|
"workspace-a",
|
||||||
|
WorkspaceResourceKind::Objective,
|
||||||
|
"objective-internal"
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.as_deref(),
|
||||||
|
Some("O-1")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.resolve_resource_reference("workspace-a", WorkspaceResourceKind::Objective, "O-1")
|
||||||
|
.unwrap()
|
||||||
|
.as_deref(),
|
||||||
|
Some("objective-internal")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn worker_create_reservation_allocates_uuid_before_runtime_and_replays_exact_input() {
|
async fn worker_create_reservation_allocates_uuid_before_runtime_and_replays_exact_input() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -6697,6 +7068,37 @@ INSERT INTO workdir_registry (
|
|||||||
.reserve_worker_create("workspace-a", "arcadia", "operation-1", "sha256:different")
|
.reserve_worker_create("workspace-a", "arcadia", "operation-1", "sha256:different")
|
||||||
.is_err()
|
.is_err()
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.resource_human_key(
|
||||||
|
"workspace-a",
|
||||||
|
WorkspaceResourceKind::Worker,
|
||||||
|
&reserved.to_string()
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.as_deref(),
|
||||||
|
Some("W-1")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.resolve_resource_reference("workspace-a", WorkspaceResourceKind::Worker, "W-1")
|
||||||
|
.unwrap(),
|
||||||
|
Some(reserved.to_string())
|
||||||
|
);
|
||||||
|
let second = store
|
||||||
|
.reserve_worker_create("workspace-a", "arcadia", "operation-2", "sha256:two")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.resource_human_key(
|
||||||
|
"workspace-a",
|
||||||
|
WorkspaceResourceKind::Worker,
|
||||||
|
&second.to_string()
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.as_deref(),
|
||||||
|
Some("W-2")
|
||||||
|
);
|
||||||
store
|
store
|
||||||
.complete_worker_create_reservation("workspace-a", reserved)
|
.complete_worker_create_reservation("workspace-a", reserved)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -7255,7 +7657,7 @@ INSERT INTO workdir_registry (
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let store = SqliteWorkspaceStore::from_connection(conn).unwrap();
|
let store = SqliteWorkspaceStore::from_connection(conn).unwrap();
|
||||||
assert_eq!(store.schema_version().await.unwrap(), 37);
|
assert_eq!(store.schema_version().await.unwrap(), 38);
|
||||||
|
|
||||||
store
|
store
|
||||||
.with_conn(|conn| {
|
.with_conn(|conn| {
|
||||||
@@ -7444,7 +7846,7 @@ CREATE TABLE ticket_assignment_operations (
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn repository_records_round_trip() {
|
async fn repository_records_round_trip() {
|
||||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||||
assert_eq!(store.schema_version().await.unwrap(), 37);
|
assert_eq!(store.schema_version().await.unwrap(), 38);
|
||||||
let workspace = WorkspaceRecord {
|
let workspace = WorkspaceRecord {
|
||||||
workspace_id: "local-dev".to_string(),
|
workspace_id: "local-dev".to_string(),
|
||||||
owner_account_id: None,
|
owner_account_id: None,
|
||||||
@@ -7510,7 +7912,7 @@ CREATE TABLE ticket_assignment_operations (
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn memory_authority_records_round_trip_and_close_staging() {
|
async fn memory_authority_records_round_trip_and_close_staging() {
|
||||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||||
assert_eq!(store.schema_version().await.unwrap(), 37);
|
assert_eq!(store.schema_version().await.unwrap(), 38);
|
||||||
let workspace = WorkspaceRecord {
|
let workspace = WorkspaceRecord {
|
||||||
workspace_id: "local-dev".to_string(),
|
workspace_id: "local-dev".to_string(),
|
||||||
owner_account_id: None,
|
owner_account_id: None,
|
||||||
@@ -7901,7 +8303,7 @@ CREATE TABLE ticket_assignment_operations (
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn account_and_login_records_round_trip() {
|
async fn account_and_login_records_round_trip() {
|
||||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||||
assert_eq!(store.schema_version().await.unwrap(), 37);
|
assert_eq!(store.schema_version().await.unwrap(), 38);
|
||||||
let now = "2026-07-22T00:00:00Z".to_string();
|
let now = "2026-07-22T00:00:00Z".to_string();
|
||||||
let account = AccountRecord {
|
let account = AccountRecord {
|
||||||
account_id: "acct-user-alice".to_string(),
|
account_id: "acct-user-alice".to_string(),
|
||||||
|
|||||||
Reference in New Issue
Block a user