feat: support permissioned multi-repository ticket targets

This commit is contained in:
2026-09-23 23:11:37 +09:00
parent 9c107eaf74
commit 9ccf2fbc3b
36 changed files with 4093 additions and 1360 deletions
+12 -1
View File
@@ -84,9 +84,20 @@ CREATE TABLE typed_tickets (
workflow_state_explicit INTEGER NOT NULL,
queued_by TEXT,
queued_at TEXT,
resolution TEXT, repository_id TEXT, ref_selector TEXT,
resolution TEXT,
PRIMARY KEY (workspace_id, ticket_id)
);
CREATE TABLE typed_ticket_targets (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
ordinal INTEGER NOT NULL,
repository_key TEXT NOT NULL,
ref_selector TEXT,
access TEXT NOT NULL CHECK (access IN ('read_only', 'read_write')),
PRIMARY KEY (workspace_id, ticket_id, ordinal),
UNIQUE (workspace_id, ticket_id, repository_key),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE "workspace_resource_key_counters" (
workspace_id TEXT NOT NULL,
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')),
+531 -158
View File
File diff suppressed because it is too large Load Diff
+304 -5
View File
@@ -7,7 +7,9 @@ use crate::{Result, TicketError, sqlite_err};
const MIGRATION_TABLE: &str = "ticket_schema_migrations";
const MAX_SCHEMA_DIAGNOSTICS: usize = 32;
const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 6;
const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 7;
const PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION: i64 = 6;
const BASELINE_MIGRATION_NAME: &str = "ticket schema baseline";
#[derive(Clone, Copy)]
struct Migration {
@@ -18,7 +20,7 @@ struct Migration {
const MIGRATIONS: &[Migration] = &[Migration {
version: LATEST_SQLITE_TICKET_SCHEMA_VERSION,
name: "ticket schema baseline",
name: BASELINE_MIGRATION_NAME,
apply: create_latest_ticket_schema,
}];
@@ -40,6 +42,7 @@ struct ExpectedForeignKey {
const OWNED_TABLES: &[&str] = &[
"typed_tickets",
"typed_ticket_targets",
"typed_ticket_labels",
"typed_ticket_risk_flags",
"typed_ticket_raw_frontmatter",
@@ -91,8 +94,15 @@ const TICKET_COLUMNS: &[ExpectedColumn] = &[
column("queued_by", "TEXT", false, 0),
column("queued_at", "TEXT", false, 0),
column("resolution", "TEXT", false, 0),
column("repository_id", "TEXT", false, 0),
];
const TARGET_COLUMNS: &[ExpectedColumn] = &[
column("workspace_id", "TEXT", true, 1),
column("ticket_id", "TEXT", true, 2),
column("ordinal", "INTEGER", true, 3),
column("repository_key", "TEXT", true, 0),
column("ref_selector", "TEXT", false, 0),
column("access", "TEXT", true, 0),
];
const LABEL_COLUMNS: &[ExpectedColumn] = &[
@@ -273,6 +283,30 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
],
)
.map_err(sqlite_err)?;
} else if applied
== BTreeMap::from([(
PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION,
BASELINE_MIGRATION_NAME.to_string(),
)])
{
migrate_v6_to_v7(connection)?;
connection
.execute(
"DELETE FROM ticket_schema_migrations WHERE version = ?1",
params![PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION],
)
.map_err(sqlite_err)?;
connection
.execute(
"INSERT INTO ticket_schema_migrations (version, name, applied_at)
VALUES (?1, ?2, ?3)",
params![
LATEST_SQLITE_TICKET_SCHEMA_VERSION,
BASELINE_MIGRATION_NAME,
chrono::Utc::now().to_rfc3339()
],
)
.map_err(sqlite_err)?;
} else {
validate_applied_migrations(&applied)?;
}
@@ -321,6 +355,7 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
for (table, columns, foreign_keys) in [
("typed_tickets", TICKET_COLUMNS, TICKET_FOREIGN_KEYS),
("typed_ticket_targets", TARGET_COLUMNS, CHILD_FOREIGN_KEYS),
("typed_ticket_labels", LABEL_COLUMNS, CHILD_FOREIGN_KEYS),
(
"typed_ticket_risk_flags",
@@ -362,6 +397,14 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
collect_table_diagnostics(connection, table, columns, foreign_keys, &mut diagnostics);
}
collect_index_diagnostics(
connection,
"typed_ticket_targets",
None,
true,
&["workspace_id", "ticket_id", "repository_key"],
&mut diagnostics,
);
collect_column_diagnostics(
connection,
"workspace_resource_keys",
@@ -431,6 +474,144 @@ fn create_latest_ticket_schema(connection: &Connection) -> Result<()> {
.map_err(sqlite_err)
}
fn migrate_v6_to_v7(connection: &Connection) -> Result<()> {
connection
.execute_batch(
"CREATE TABLE typed_ticket_targets (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
ordinal INTEGER NOT NULL,
repository_key TEXT NOT NULL,
ref_selector TEXT,
access TEXT NOT NULL CHECK (access IN ('read_only', 'read_write')),
PRIMARY KEY (workspace_id, ticket_id, ordinal),
UNIQUE (workspace_id, ticket_id, repository_key),
FOREIGN KEY (workspace_id, ticket_id)
REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);",
)
.map_err(sqlite_err)?;
let legacy_targets = {
let mut statement = connection
.prepare(
"SELECT workspace_id, ticket_id, repository_id, ref_selector
FROM typed_tickets
WHERE repository_id IS NOT NULL OR ref_selector IS NOT NULL
ORDER BY workspace_id, ticket_id",
)
.map_err(sqlite_err)?;
let rows = statement
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, Option<String>>(3)?,
))
})
.map_err(sqlite_err)?;
rows.collect::<std::result::Result<Vec<_>, _>>()
.map_err(sqlite_err)?
};
for (workspace_id, ticket_id, repository_key, ref_selector) in legacy_targets {
let repository_key = repository_key.ok_or_else(|| {
TicketError::Sqlite(format!(
"legacy Ticket {ticket_id:?} in Workspace {workspace_id:?} has ref_selector without repository_id"
))
})?;
connection
.execute(
"INSERT INTO typed_ticket_targets
(workspace_id, ticket_id, ordinal, repository_key, ref_selector, access)
VALUES (?1, ?2, 0, ?3, ?4, 'read_write')",
params![workspace_id, ticket_id, repository_key, ref_selector],
)
.map_err(sqlite_err)?;
}
let legacy_event_targets = {
let mut statement = connection
.prepare(
"SELECT repository.workspace_id, repository.ticket_id, repository.event_index,
repository.value, selector.value
FROM typed_ticket_event_attributes AS repository
JOIN typed_ticket_event_attributes AS selector
ON selector.workspace_id = repository.workspace_id
AND selector.ticket_id = repository.ticket_id
AND selector.event_index = repository.event_index
AND selector.key = 'ref_selector'
WHERE repository.key = 'repository_id'
ORDER BY repository.workspace_id, repository.ticket_id, repository.event_index",
)
.map_err(sqlite_err)?;
let rows = statement
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
))
})
.map_err(sqlite_err)?;
rows.collect::<std::result::Result<Vec<_>, _>>()
.map_err(sqlite_err)?
};
for (workspace_id, ticket_id, event_index, repository_key, ref_selector) in legacy_event_targets
{
let targets = serde_json::to_string(&[serde_json::json!({
"repository_key": repository_key,
"ref_selector": ref_selector,
"access": "read_write",
})])
.map_err(sqlite_err)?;
connection
.execute(
"INSERT OR IGNORE INTO typed_ticket_event_attributes
(workspace_id, ticket_id, event_index, key, value)
VALUES (?1, ?2, ?3, 'targets', ?4)",
params![workspace_id, ticket_id, event_index, targets],
)
.map_err(sqlite_err)?;
connection
.execute(
"INSERT OR IGNORE INTO typed_ticket_event_attributes
(workspace_id, ticket_id, event_index, key, value)
SELECT ?1, ?2, ?3, 'fingerprint_version', '1'
WHERE EXISTS (
SELECT 1 FROM typed_ticket_event_attributes
WHERE workspace_id = ?1 AND ticket_id = ?2 AND event_index = ?3
AND key = 'request_fingerprint'
)",
params![workspace_id, ticket_id, event_index],
)
.map_err(sqlite_err)?;
}
connection
.execute(
"DELETE FROM typed_ticket_event_attributes AS legacy
WHERE legacy.key IN ('repository_id', 'ref_selector')
AND EXISTS (
SELECT 1 FROM typed_ticket_event_attributes AS canonical
WHERE canonical.workspace_id = legacy.workspace_id
AND canonical.ticket_id = legacy.ticket_id
AND canonical.event_index = legacy.event_index
AND canonical.key = 'targets'
)",
[],
)
.map_err(sqlite_err)?;
connection
.execute_batch(
"ALTER TABLE typed_tickets DROP COLUMN repository_id;
ALTER TABLE typed_tickets DROP COLUMN ref_selector;",
)
.map_err(sqlite_err)
}
fn load_applied_migrations(connection: &Connection) -> Result<BTreeMap<i64, String>> {
let mut statement = connection
.prepare("SELECT version, name FROM ticket_schema_migrations ORDER BY version")
@@ -842,6 +1023,124 @@ mod tests {
assert_eq!(load_applied_migrations(&connection).unwrap(), versions);
}
#[test]
fn migrates_single_target_rows_to_ordered_read_write_targets() {
let connection = Connection::open_in_memory().unwrap();
create_latest_ticket_schema(&connection).unwrap();
connection
.execute_batch(
"DROP TABLE typed_ticket_targets;
ALTER TABLE typed_tickets ADD COLUMN repository_id TEXT;
ALTER TABLE typed_tickets ADD COLUMN ref_selector TEXT;
CREATE TABLE ticket_schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL
);
INSERT INTO ticket_schema_migrations (version, name, applied_at)
VALUES (6, 'ticket schema baseline', '2026-09-23T00:00:00Z');
INSERT INTO typed_tickets (
workspace_id, ticket_id, slug, title, status, kind, priority, body,
workflow_state, workflow_state_explicit, repository_id, ref_selector
) VALUES (
'workspace-1', 'ticket-1', 'ticket-1', 'Migrated', 'open', 'task', 'P2',
'body', 'ready', 1, 'main', 'develop'
);
INSERT INTO typed_ticket_events (
workspace_id, ticket_id, event_index, kind, body
) VALUES (
'workspace-1', 'ticket-1', 0, 'state_changed', 'ready'
);
INSERT INTO typed_ticket_event_attributes (
workspace_id, ticket_id, event_index, key, value
) VALUES
('workspace-1', 'ticket-1', 0, 'repository_id', 'main'),
('workspace-1', 'ticket-1', 0, 'ref_selector', 'develop'),
('workspace-1', 'ticket-1', 0, 'request_fingerprint', 'legacy');",
)
.unwrap();
migrate_sqlite_ticket_schema(&connection).unwrap();
verify_sqlite_ticket_schema(&connection).unwrap();
let target = connection
.query_row(
"SELECT ordinal, repository_key, ref_selector, access
FROM typed_ticket_targets
WHERE workspace_id = 'workspace-1' AND ticket_id = 'ticket-1'",
[],
|row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, String>(3)?,
))
},
)
.unwrap();
assert_eq!(
target,
(
0,
"main".to_owned(),
Some("develop".to_owned()),
"read_write".to_owned()
)
);
let columns = load_columns(&connection, "typed_tickets").unwrap();
assert!(
columns
.iter()
.all(|column| { column.name != "repository_id" && column.name != "ref_selector" })
);
let encoded_targets: String = connection
.query_row(
"SELECT value FROM typed_ticket_event_attributes
WHERE workspace_id = 'workspace-1' AND ticket_id = 'ticket-1'
AND event_index = 0 AND key = 'targets'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(
serde_json::from_str::<serde_json::Value>(&encoded_targets).unwrap(),
serde_json::json!([{
"repository_key": "main",
"ref_selector": "develop",
"access": "read_write"
}])
);
let migrated_attributes = {
let mut statement = connection
.prepare(
"SELECT key, value FROM typed_ticket_event_attributes
WHERE workspace_id = 'workspace-1' AND ticket_id = 'ticket-1'
AND event_index = 0 ORDER BY key",
)
.unwrap();
statement
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.unwrap()
.collect::<std::result::Result<BTreeMap<_, _>, _>>()
.unwrap()
};
assert_eq!(
migrated_attributes
.get("fingerprint_version")
.map(String::as_str),
Some("1")
);
assert!(!migrated_attributes.contains_key("repository_id"));
assert!(!migrated_attributes.contains_key("ref_selector"));
assert_eq!(
load_applied_migrations(&connection).unwrap(),
BTreeMap::from([(7, BASELINE_MIGRATION_NAME.to_owned())])
);
}
#[test]
fn rejects_unknown_future_migration_history_without_changing_schema() {
let connection = Connection::open_in_memory().unwrap();
@@ -856,7 +1155,7 @@ mod tests {
let error = migrate_sqlite_ticket_schema(&connection).unwrap_err();
assert!(error.to_string().contains(
"migration history must contain only the canonical version 6 baseline marker"
"migration history must contain only the canonical version 7 baseline marker"
));
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 2);
}
@@ -878,7 +1177,7 @@ mod tests {
let error = migrate_sqlite_ticket_schema(&connection).unwrap_err();
assert!(error.to_string().contains(
"migration history must contain only the canonical version 6 baseline marker"
"migration history must contain only the canonical version 7 baseline marker"
));
assert!(!table_exists(&connection, "typed_tickets").unwrap());
}
+65 -26
View File
@@ -134,11 +134,11 @@ const DECISION_DESCRIPTION: &str = "Append a typed Ticket decision event. `body`
const IMPLEMENTATION_REPORT_DESCRIPTION: &str =
"Append a typed Ticket implementation_report event. `body` is Markdown.";
const MARK_READY_DESCRIPTION: &str = "Mark a planning Ticket ready through the typed Ticket backend. \
The backend atomically validates and normalizes the persisted repository/ref target, records one typed \
state_changed event, and transitions planning -> ready. `reason` is optional.";
The backend atomically validates and normalizes every persisted repository target, requires exactly one \
read_write target, records one typed state_changed event, and transitions planning -> ready. `reason` is optional.";
const INTAKE_READY_DESCRIPTION: &str = "Record a bounded intake summary and mark a planning Ticket ready. \
The backend applies the same target validation and lock as TicketMarkReady and commits the summary, \
state_changed event, effective target, and planning -> ready transition atomically.";
The backend applies the same targets validation and lock as TicketMarkReady and commits the summary, \
state_changed event, effective targets, and planning -> ready transition atomically.";
const QUEUE_DESCRIPTION: &str = "Queue a ready Ticket for Orchestrator routing through the typed \
Ticket backend. The backend rejects transitive planning dependencies and cycles, atomically queues the \
requested Ticket plus every transitive ready dependency, and leaves queued or in-progress dependencies unchanged.";
@@ -397,12 +397,9 @@ struct TicketCreateParams {
/// Optional queued_at frontmatter value.
#[serde(default)]
queued_at: Option<String>,
/// Optional target Workspace repository id.
/// Ordered implementation targets. Selectors may be omitted while planning.
#[serde(default)]
repository_key: Option<String>,
/// Optional target Git ref selector. Requires `repository_key`.
#[serde(default)]
ref_selector: Option<String>,
targets: Vec<crate::TicketTarget>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
@@ -424,9 +421,9 @@ struct TicketEditItemParams {
/// Replace every occurrence of `old_string`; by default exactly one occurrence is required.
#[serde(default)]
replace_all: bool,
/// Optional target repository/ref update.
/// Optional ordered targets replacement or clear operation.
#[serde(default)]
target: Option<crate::TicketTargetEdit>,
targets: Option<crate::TicketTargetsEdit>,
}
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)]
@@ -941,8 +938,7 @@ impl Tool for TicketCreateTool {
input.workflow_state = params.state.map(TicketWorkflowStateParam::into_state);
input.queued_by = None;
input.queued_at = params.queued_at;
input.repository_id = params.repository_key;
input.ref_selector = params.ref_selector;
input.targets = params.targets;
let created = self
.backend
@@ -986,7 +982,7 @@ impl Tool for TicketEditItemTool {
title: params.title,
body: params.body.map(MarkdownText::new),
body_replacement,
target: params.target,
targets: params.targets,
author: None,
};
let ticket = self
@@ -1170,8 +1166,7 @@ impl Tool for TicketMarkReadyTool {
json!({
"ticket": ticket.meta.id,
"state": ticket.meta.workflow_state.as_str(),
"repository_key": ticket.meta.repository_id,
"ref_selector": ticket.meta.ref_selector,
"targets": ticket.meta.targets,
"ok": true
}),
))
@@ -1203,8 +1198,7 @@ impl Tool for TicketIntakeReadyTool {
json!({
"ticket": ticket.meta.id,
"state": ticket.meta.workflow_state.as_str(),
"repository_key": ticket.meta.repository_id,
"ref_selector": ticket.meta.ref_selector,
"targets": ticket.meta.targets,
"ok": true
}),
))
@@ -1719,6 +1713,7 @@ fn ticket_json(
"risk_flags": ticket.meta.risk_flags,
"queued_by": ticket.meta.queued_by,
"queued_at": ticket.meta.queued_at,
"targets": ticket.meta.targets,
},
"body": truncate_text(ticket.document.body.as_str(), body_max_bytes),
"events": {
@@ -1937,16 +1932,25 @@ mod tests {
fn resolve_target(
&self,
_workspace_id: &str,
repository_key: Option<&str>,
repository_key: &str,
ref_selector: Option<&str>,
) -> crate::Result<crate::ResolvedTicketTarget> {
Ok(crate::ResolvedTicketTarget {
repository_id: repository_key.unwrap_or("main").to_owned(),
repository_key: repository_key.to_owned(),
ref_selector: ref_selector.unwrap_or("develop").to_owned(),
access: crate::TicketTargetAccess::ReadOnly,
})
}
}
fn write_target(repository_key: &str) -> crate::TicketTarget {
crate::TicketTarget {
repository_key: repository_key.to_owned(),
ref_selector: None,
access: crate::TicketTargetAccess::ReadWrite,
}
}
fn backend(temp: &TempDir) -> crate::SqliteTicketBackend {
sqlite_backend(temp)
}
@@ -2076,7 +2080,18 @@ mod tests {
.execute(
&json!({
"title": "Tool Created",
"body": "## Background\n\nCreated by tool.\n"
"body": "## Background\n\nCreated by tool.\n",
"targets": [
{
"repository_key": "main",
"ref_selector": "develop",
"access": "read_write"
},
{
"repository_key": "docs",
"access": "read_only"
}
]
})
.to_string(),
Default::default(),
@@ -2114,6 +2129,11 @@ mod tests {
.unwrap();
assert!(shown.summary.contains(&id));
let shown_content = shown.content.unwrap();
let shown_json: Value = serde_json::from_str(&shown_content).unwrap();
assert_eq!(shown_json["meta"]["targets"][0]["repository_key"], "main");
assert_eq!(shown_json["meta"]["targets"][0]["access"], "read_write");
assert_eq!(shown_json["meta"]["targets"][1]["repository_key"], "docs");
assert_eq!(shown_json["meta"]["targets"][1]["access"], "read_only");
assert!(shown_content.contains("Created by tool"));
assert!(!shown_content.contains("legacy_ticket"));
assert!(!shown_content.contains("needs_preflight"));
@@ -2574,10 +2594,10 @@ mod tests {
let temp = TempDir::new().unwrap();
let inner = sqlite_backend(&temp);
let mut dependency_input = NewTicket::new("Dependency");
dependency_input.repository_id = Some("main".to_string());
dependency_input.targets = vec![write_target("main")];
let dependency = inner.create(dependency_input).unwrap();
let mut target_input = NewTicket::new("Target");
target_input.repository_id = Some("main".to_string());
target_input.targets = vec![write_target("main")];
let target = inner.create(target_input).unwrap();
inner
.add_ticket_relation(
@@ -2669,7 +2689,7 @@ mod tests {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let mut input = NewTicket::new("Workflow Tool");
input.repository_id = Some("main".to_owned());
input.targets = vec![write_target("main")];
let created = backend.create(input).unwrap();
let intake_ready = tool_by_name(backend.clone(), "TicketMarkReady");
let workflow = tool_by_name(backend.clone(), "TicketWorkflowState");
@@ -2752,7 +2772,7 @@ mod tests {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let mut input = NewTicket::new("Intake Workflow");
input.repository_id = Some("main".to_owned());
input.targets = vec![write_target("main")];
let created = backend.create(input).unwrap();
tool_by_name(backend.clone(), "TicketIntakeReady")
.execute(
@@ -2768,7 +2788,10 @@ mod tests {
.unwrap();
let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap();
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Ready);
assert_eq!(record.meta.ref_selector.as_deref(), Some("develop"));
assert_eq!(
record.meta.targets[0].ref_selector.as_deref(),
Some("develop")
);
assert_eq!(
record
.events
@@ -3088,6 +3111,15 @@ mod tests {
assert!(!create_schema.contains("needs_preflight"));
assert!(!create_schema.contains("action_required"));
assert!(!create_schema.contains("attention_required"));
let create_properties = input_schema("TicketCreate")["properties"]
.as_object()
.unwrap()
.clone();
assert!(create_properties.contains_key("targets"));
assert!(!create_properties.contains_key("repository_key"));
assert!(!create_properties.contains_key("ref_selector"));
assert!(create_schema.contains("read_only"));
assert!(create_schema.contains("read_write"));
let plan_record_schema = tools
.iter()
.map(|definition| definition().0)
@@ -3115,6 +3147,13 @@ mod tests {
assert!(edit_schema.contains("old_string"));
assert!(edit_schema.contains("new_string"));
assert!(edit_schema.contains("replace_all"));
assert!(edit_schema.contains("targets"));
assert!(
!input_schema("TicketEditItem")["properties"]
.as_object()
.unwrap()
.contains_key("target")
);
for name in [
"TicketCreate",
"TicketEditItem",