feat: add workspace resource human keys

This commit is contained in:
2026-08-20 02:17:00 +09:00
parent bb8eda379f
commit 1ca36d6b66
12 changed files with 899 additions and 60 deletions
+140 -2
View File
@@ -124,6 +124,7 @@ fn read_ticket_summary_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<TicketSu
let workflow_state = row.get::<_, String>(7)?;
Ok(TicketSummary {
id: row.get(0)?,
human_key: None,
slug: row.get(1)?,
title: row.get(2)?,
status: ExtensibleTicketStatus::from(row.get::<_, String>(3)?.as_str()),
@@ -926,6 +927,8 @@ impl TicketListQuery {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketRef {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub human_key: Option<String>,
pub slug: String,
pub status: TicketStatus,
}
@@ -1540,6 +1543,8 @@ pub struct OrchestrationPlanRecord {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketMeta {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub human_key: Option<String>,
pub slug: String,
pub title: String,
pub status: ExtensibleTicketStatus,
@@ -1563,6 +1568,8 @@ pub struct TicketMeta {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketSummary {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub human_key: Option<String>,
pub slug: String,
pub title: String,
pub status: ExtensibleTicketStatus,
@@ -2669,6 +2676,9 @@ impl SqliteTicketBackend {
let mut summaries = rows
.collect::<std::result::Result<Vec<_>, _>>()
.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;
summaries.truncate(query.limit);
let next = has_more.then(|| {
@@ -2752,6 +2762,7 @@ impl SqliteTicketBackend {
updated_at,
) = row.map_err(sqlite_err)?;
summaries.push(TicketSummary {
human_key: Self::human_key_for(conn, &self.workspace_id, &id)?,
id,
slug,
title,
@@ -2928,7 +2939,16 @@ impl SqliteTicketBackend {
fn resolve_ticket_id(&self, conn: &Connection, id: TicketIdOrSlug) -> Result<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
.query_map(params![self.workspace_id, query], |row| {
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> {
Ok(conn
.query_row(
@@ -3090,6 +3165,7 @@ impl SqliteTicketBackend {
let state_raw: String = row.get(12)?;
Ok(TicketMeta {
id: row.get(0)?,
human_key: None,
slug: row.get(1)?,
title: row.get(2)?,
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);
let (mut meta, body, resolution): (TicketMeta, String, Option<String>) = conn.query_row(r#"SELECT ticket_id, slug, title, status, kind, priority, created_at, updated_at, assignee, readiness, body, resolution, workflow_state, workflow_state_explicit, queued_by, queued_at, repository_id, ref_selector FROM typed_tickets WHERE workspace_id = ?1 AND ticket_id = ?2"#,
params![self.workspace_id, ticket_id], |row| Ok((Self::ticket_meta_from_row(row)?, row.get(10)?, row.get(11)?))).optional().map_err(sqlite_err)?.ok_or_else(|| TicketError::NotFound(ticket_id.to_string()))?;
meta.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.risk_flags =
self.load_ordered_values(conn, "typed_ticket_risk_flags", "risk_flag", ticket_id)?;
@@ -3288,6 +3365,7 @@ impl SqliteTicketBackend {
let mut summaries = Vec::new();
for row in rows {
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) {
continue;
}
@@ -3437,6 +3515,7 @@ impl TicketBackend for SqliteTicketBackend {
};
let meta = TicketMeta {
id: id.clone(),
human_key: None,
slug: input.slug.clone().unwrap_or_else(|| id.clone()),
title: input.title,
status,
@@ -3473,7 +3552,7 @@ impl TicketBackend for SqliteTicketBackend {
events: vec![TicketEvent {
kind: TicketEventKind::Create,
author: Some(author),
at: Some(now),
at: Some(now.clone()),
status: None,
from: None,
to: None,
@@ -3488,9 +3567,11 @@ impl TicketBackend for SqliteTicketBackend {
relations: TicketRelationView::default(),
resolution: None,
};
let human_key = Self::allocate_human_key(conn, &self.workspace_id, &id, &now)?;
self.insert_ticket(conn, &ticket)?;
Ok(TicketRef {
id: id.clone(),
human_key: Some(human_key),
slug: id,
status: TicketStatus::Open,
})
@@ -4137,6 +4218,7 @@ impl TicketBackend for LocalTicketBackend {
atomic_write(&dir.join("thread.md"), thread.as_bytes())?;
Ok(TicketRef {
id: id.clone(),
human_key: None,
slug: id,
status: TicketStatus::Open,
})
@@ -5174,6 +5256,7 @@ fn ticket_meta(frontmatter: TicketItemFrontmatter, id: String) -> TicketMeta {
};
TicketMeta {
id: id.clone(),
human_key: None,
slug: id,
title: frontmatter.title.unwrap_or_default(),
status,
@@ -5198,6 +5281,7 @@ fn ticket_meta(frontmatter: TicketItemFrontmatter, id: String) -> TicketMeta {
fn ticket_summary_from_meta(meta: TicketMeta) -> TicketSummary {
TicketSummary {
id: meta.id,
human_key: meta.human_key,
slug: meta.slug,
title: meta.title,
status: meta.status,
@@ -6806,6 +6890,7 @@ mod tests {
fn summary_with_state(state: TicketWorkflowState) -> TicketSummary {
TicketSummary {
id: "000TEST".to_string(),
human_key: Some("T-1".to_string()),
slug: "000TEST".to_string(),
title: "Test Ticket".to_string(),
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]
fn sqlite_backend_persists_and_edits_ticket_target() {
let tmp = TempDir::new().unwrap();
+109 -5
View File
@@ -7,7 +7,7 @@ use crate::{Result, TicketError, sqlite_err};
const MIGRATION_TABLE: &str = "ticket_schema_migrations";
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)]
struct Migration {
@@ -37,6 +37,11 @@ const MIGRATIONS: &[Migration] = &[
name: "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)]
@@ -535,6 +540,57 @@ fn add_ticket_query_indexes(connection: &Connection) -> Result<()> {
.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(
connection: &Connection,
table: &str,
@@ -869,10 +925,10 @@ mod tests {
verify_sqlite_ticket_schema(&connection).unwrap();
let versions = load_applied_migrations(&connection).unwrap();
assert_eq!(versions.len(), 4);
assert_eq!(versions.len(), 5);
assert_eq!(
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));
}
#[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]
fn upgrades_legacy_schema_without_repository_target_columns() {
let connection = Connection::open_in_memory().unwrap();
@@ -1017,7 +1121,7 @@ mod tests {
.to_string()
.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]
@@ -1157,6 +1261,6 @@ mod tests {
let connection = Connection::open(database).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);
}
}