5 Commits
10 changed files with 591 additions and 18 deletions
+12 -3
View File
@@ -26,17 +26,26 @@ Workerの状態から純粋に再現可能で、且つ揮発性の無い操作
```sh ```sh
cargo test -p <crate> --lib <test-or-module-filter> cargo test -p <crate> --lib <test-or-module-filter>
cargo test -p <crate> --test <test-target> <test-filter> cargo test -p <crate> --test <test-target> <test-filter>
cargo check -p <crate> -p <dependent-crate>
``` ```
完了前には変更内容に応じて、対象crate全体のtest、影響するfeature構成、依存crateのcheckを追加する。通常の差分検証には以下を使う。 完了前には、workspace rootで必ず`cargo check`を実行する。rootの`cargo check`
`default-members`に含まれるTUIやServerを含む通常のcompile closureを確認するため、公開型の
変更ごとにLLMがreverse dependencyを推測して`-p`を列挙する運用にはしない。
```sh ```sh
cargo check
cargo test -p <changed-crate>
cargo fmt --all -- --check cargo fmt --all -- --check
git diff --check HEAD git diff --check HEAD
``` ```
workspace全体、E2E、Nix/Docker buildなどの重い検証は、変更した境界を狭い検証では証明できない場合や明示的に要求された場合に選ぶ。実行した検証が何を証明するのかを意識し、広い検証を形式的に回すだけにしない。 変更したcrate全体のtestに加え、影響するfeature構成やtest-only targetがある場合は、その検証を
追加する。`cargo check`はtestを実行せず、通常有効でないfeatureまでは確認しないため、semanticな
証明とfeature境界の検証はtargeted test/checkで補う。
workspace全体のtest、`--all-targets`、E2E、Nix/Docker buildなどの重い検証は、変更した境界を
通常のroot checkと狭い検証では証明できない場合や、明示的に要求された場合に選ぶ。実行した検証が
何を証明するのかを意識し、広い検証を形式的に回すだけにしない。
--- ---
+43 -7
View File
@@ -677,7 +677,28 @@ impl SqliteMergeRequestStore {
} }
pub fn migrate(conn: &Connection) -> Result<()> { pub fn migrate(conn: &Connection) -> Result<()> {
conn.busy_timeout(Duration::from_secs(5)).map_err(db)?;
conn.pragma_update(None, "foreign_keys", "ON").map_err(db)?; conn.pragma_update(None, "foreign_keys", "ON").map_err(db)?;
// Acquire the writer lock before reading either the version or table layout so
// concurrent store initialization cannot act on a stale migration decision.
conn.execute_batch("BEGIN IMMEDIATE").map_err(db)?;
let result = migrate_transaction(conn);
match result {
Ok(()) => match conn.execute_batch("COMMIT") {
Ok(()) => Ok(()),
Err(error) => {
let _ = conn.execute_batch("ROLLBACK");
Err(db(error))
}
},
Err(error) => {
let _ = conn.execute_batch("ROLLBACK");
Err(error)
}
}
}
fn migrate_transaction(conn: &Connection) -> Result<()> {
conn.execute_batch("CREATE TABLE IF NOT EXISTS merge_request_schema_migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);").map_err(db)?; conn.execute_batch("CREATE TABLE IF NOT EXISTS merge_request_schema_migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);").map_err(db)?;
let version: i64 = conn let version: i64 = conn
.query_row( .query_row(
@@ -1014,26 +1035,41 @@ fn archive_incompatible_legacy_tables(conn: &Connection, version: i64) -> Result
"merge_request_revisions", "merge_request_revisions",
"merge_requests", "merge_requests",
]; ];
conn.pragma_update(None, "foreign_keys", "OFF")
.map_err(db)?;
for table in tables { for table in tables {
if !table_exists(conn, table)? { if !table_exists(conn, table)? {
continue; continue;
} }
let archive = format!("legacy_v6_{table}"); let archive = format!("legacy_v6_{table}");
if table_exists(conn, &archive)? { if table_exists(conn, &archive)? {
conn.pragma_update(None, "foreign_keys", "ON").map_err(db)?; // The retired non-transactional migration could archive a table, recreate
return Err(MergeRequestError::Database(format!( // its empty replacement, and then fail. Resume that exact state without
"legacy archive table {archive} already exists" // ever choosing between two populated copies.
))); if !table_is_empty(conn, table)? {
return Err(MergeRequestError::Database(format!(
"legacy archive table {archive} already exists while {table} still contains data"
)));
}
conn.execute_batch(&format!("DROP TABLE {table};"))
.map_err(db)?;
continue;
} }
conn.execute_batch(&format!("ALTER TABLE {table} RENAME TO {archive};")) conn.execute_batch(&format!("ALTER TABLE {table} RENAME TO {archive};"))
.map_err(db)?; .map_err(db)?;
} }
conn.pragma_update(None, "foreign_keys", "ON").map_err(db)?;
Ok(()) Ok(())
} }
fn table_is_empty(conn: &Connection, table: &str) -> Result<bool> {
let has_row: i64 = conn
.query_row(
&format!("SELECT EXISTS(SELECT 1 FROM {table} LIMIT 1)"),
[],
|row| row.get(0),
)
.map_err(db)?;
Ok(has_row == 0)
}
fn table_has_columns(conn: &Connection, table: &str, required: &[&str]) -> Result<bool> { fn table_has_columns(conn: &Connection, table: &str, required: &[&str]) -> Result<bool> {
if !table_exists(conn, table)? { if !table_exists(conn, table)? {
return Ok(false); return Ok(false);
+77
View File
@@ -192,6 +192,83 @@ fn rejected_v6_schema_missing_diff_digest_is_archived_before_fresh_v7() {
} }
} }
#[test]
fn interrupted_legacy_archive_with_empty_recreated_table_resumes() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("interrupted.db");
let conn = Connection::open(&path).unwrap();
conn.execute_batch(
"CREATE TABLE merge_request_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);\
INSERT INTO merge_request_schema_migrations(version,name) VALUES(6,'rejected_merge_request_v6');\
CREATE TABLE merge_requests(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,ticket_id TEXT NOT NULL);\
CREATE TABLE merge_request_review_findings(workspace_id TEXT NOT NULL,attempt_id TEXT NOT NULL,ordinal INTEGER NOT NULL,severity TEXT NOT NULL,code TEXT,path TEXT,line INTEGER,body TEXT NOT NULL);\
CREATE TABLE legacy_v6_merge_request_review_findings(workspace_id TEXT NOT NULL,attempt_id TEXT NOT NULL,ordinal INTEGER NOT NULL,severity TEXT NOT NULL,code TEXT,path TEXT,line INTEGER,body TEXT NOT NULL);\
INSERT INTO legacy_v6_merge_request_review_findings VALUES('ws-a','AT1',0,'warning',NULL,NULL,NULL,'preserved evidence');",
)
.unwrap();
drop(conn);
SqliteMergeRequestStore::open(&path, "ws-a").unwrap();
let conn = Connection::open(&path).unwrap();
let archived_body: String = conn
.query_row(
"SELECT body FROM legacy_v6_merge_request_review_findings",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(archived_body, "preserved evidence");
let version: i64 = conn
.query_row(
"SELECT MAX(version) FROM merge_request_schema_migrations",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(version, 8);
drop(conn);
SqliteMergeRequestStore::open(&path, "ws-a").unwrap();
}
#[test]
fn conflicting_legacy_archive_rolls_back_all_table_renames() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("conflict.db");
let conn = Connection::open(&path).unwrap();
conn.execute_batch(
"CREATE TABLE merge_request_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);\
INSERT INTO merge_request_schema_migrations(version,name) VALUES(6,'rejected_merge_request_v6');\
CREATE TABLE merge_requests(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,ticket_id TEXT NOT NULL);\
CREATE TABLE merge_request_review_findings(body TEXT NOT NULL);\
CREATE TABLE merge_request_reviews(body TEXT NOT NULL);\
INSERT INTO merge_request_reviews VALUES('unarchived evidence');\
CREATE TABLE legacy_v6_merge_request_reviews(body TEXT NOT NULL);",
)
.unwrap();
let error = migrate(&conn).unwrap_err();
assert!(error.to_string().contains(
"legacy archive table legacy_v6_merge_request_reviews already exists while merge_request_reviews still contains data"
));
let current_findings: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='merge_request_review_findings'",
[],
|row| row.get(0),
)
.unwrap();
let archived_findings: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='legacy_v6_merge_request_review_findings'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(current_findings, 1);
assert_eq!(archived_findings, 0);
}
#[test] #[test]
fn v7_completion_operations_are_preserved_as_legacy_assigned_coder_authority() { fn v7_completion_operations_are_preserved_as_legacy_assigned_coder_authority() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
+165
View File
@@ -1537,6 +1537,12 @@ pub trait TicketBackend {
id: TicketIdOrSlug, id: TicketIdOrSlug,
relation: NewTicketRelation, relation: NewTicketRelation,
) -> Result<TicketRelation>; ) -> Result<TicketRelation>;
fn remove_ticket_relation(
&self,
id: TicketIdOrSlug,
kind: TicketRelationKind,
target: TicketIdOrSlug,
) -> Result<TicketRelation>;
fn query_ticket_relations( fn query_ticket_relations(
&self, &self,
ticket: Option<TicketIdOrSlug>, ticket: Option<TicketIdOrSlug>,
@@ -1616,6 +1622,11 @@ pub enum TicketBackendOperation {
id: TicketIdOrSlug, id: TicketIdOrSlug,
relation: NewTicketRelation, relation: NewTicketRelation,
}, },
RemoveTicketRelation {
id: TicketIdOrSlug,
kind: TicketRelationKind,
target: TicketIdOrSlug,
},
QueryTicketRelations { QueryTicketRelations {
ticket: Option<TicketIdOrSlug>, ticket: Option<TicketIdOrSlug>,
kind: Option<TicketRelationKind>, kind: Option<TicketRelationKind>,
@@ -1718,6 +1729,11 @@ where
TicketBackendOperation::AddTicketRelation { id, relation } => { TicketBackendOperation::AddTicketRelation { id, relation } => {
TicketBackendOperationResult::Relation(backend.add_ticket_relation(id, relation)?) TicketBackendOperationResult::Relation(backend.add_ticket_relation(id, relation)?)
} }
TicketBackendOperation::RemoveTicketRelation { id, kind, target } => {
TicketBackendOperationResult::Relation(
backend.remove_ticket_relation(id, kind, target)?,
)
}
TicketBackendOperation::QueryTicketRelations { ticket, kind } => { TicketBackendOperation::QueryTicketRelations { ticket, kind } => {
TicketBackendOperationResult::Relations(backend.query_ticket_relations(ticket, kind)?) TicketBackendOperationResult::Relations(backend.query_ticket_relations(ticket, kind)?)
} }
@@ -3378,6 +3394,58 @@ impl TicketBackend for SqliteTicketBackend {
}) })
} }
fn remove_ticket_relation(
&self,
id: TicketIdOrSlug,
kind: TicketRelationKind,
target: TicketIdOrSlug,
) -> Result<TicketRelation> {
self.with_write(|conn| {
let ticket_id = self.resolve_ticket_id(conn, id)?;
let target = self.resolve_ticket_id(conn, target)?;
let relation = conn
.query_row(
"SELECT note, author, at FROM typed_ticket_relations WHERE workspace_id = ?1 AND ticket_id = ?2 AND kind = ?3 AND target = ?4",
params![self.workspace_id, ticket_id, kind.as_str(), target],
|row| {
Ok(TicketRelation {
ticket_id: ticket_id.clone(),
kind,
target: target.clone(),
note: row.get(0)?,
author: row.get(1)?,
at: row.get(2)?,
})
},
)
.optional()
.map_err(sqlite_err)?
.ok_or_else(|| {
TicketError::NotFound(format!(
"relation {} {} {}",
ticket_id, kind, target
))
})?;
let deleted = conn
.execute(
"DELETE FROM typed_ticket_relations WHERE workspace_id = ?1 AND ticket_id = ?2 AND kind = ?3 AND target = ?4",
params![self.workspace_id, ticket_id, kind.as_str(), target],
)
.map_err(sqlite_err)?;
if deleted != 1 {
return Err(TicketError::Conflict(format!(
"expected to remove one ticket relation, removed {deleted}"
)));
}
conn.execute(
"UPDATE typed_tickets SET updated_at = ?3 WHERE workspace_id = ?1 AND ticket_id = ?2",
params![self.workspace_id, ticket_id, now_utc()],
)
.map_err(sqlite_err)?;
Ok(relation)
})
}
fn query_ticket_relations( fn query_ticket_relations(
&self, &self,
ticket: Option<TicketIdOrSlug>, ticket: Option<TicketIdOrSlug>,
@@ -4017,6 +4085,39 @@ impl TicketBackend for LocalTicketBackend {
Ok(output) Ok(output)
} }
fn remove_ticket_relation(
&self,
id: TicketIdOrSlug,
kind: TicketRelationKind,
target: TicketIdOrSlug,
) -> Result<TicketRelation> {
let _lock = self.acquire_lock()?;
self.ensure_backend_dirs()?;
let dir = self.find_ticket_dir(&id)?;
let item = dir.join("item.md");
let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?;
let target_id = match target {
TicketIdOrSlug::Id(value) => value,
other => ticket_id_from_dir(&self.find_ticket_dir(&other)?)?,
};
let path = self.ticket_relations_path(&dir);
let mut relations = read_ticket_relations_artifact(&path, Some(&meta))?;
let Some(index) = relations
.iter()
.position(|relation| relation.kind == kind && relation.target == target_id)
else {
return Err(TicketError::NotFound(format!(
"relation {} {} {}",
meta.id, kind, target_id
)));
};
let removed = relations.remove(index);
write_ticket_relations_artifact(&path, &relations)?;
let at = now_utc();
self.set_frontmatter_fields(&item, &[("updated_at", &at)])?;
Ok(removed)
}
fn query_ticket_relations( fn query_ticket_relations(
&self, &self,
ticket: Option<TicketIdOrSlug>, ticket: Option<TicketIdOrSlug>,
@@ -6103,6 +6204,55 @@ mod tests {
assert!(matches!(ambiguous_err, TicketError::Conflict(_))); assert!(matches!(ambiguous_err, TicketError::Conflict(_)));
} }
fn assert_ticket_relation_removal_semantics<B: TicketBackend>(backend: &B) {
let source = backend.create(NewTicket::new("source")).unwrap();
let target = backend.create(NewTicket::new("target")).unwrap();
backend
.add_ticket_relation(
TicketIdOrSlug::Id(source.id.clone()),
NewTicketRelation {
kind: TicketRelationKind::DependsOn,
target: target.id.clone(),
note: Some("obsolete blocker".to_string()),
author: Some("tester".to_string()),
},
)
.unwrap();
let removed = backend
.remove_ticket_relation(
TicketIdOrSlug::Id(source.id.clone()),
TicketRelationKind::DependsOn,
TicketIdOrSlug::Id(target.id.clone()),
)
.unwrap();
assert_eq!(removed.ticket_id, source.id);
assert_eq!(removed.target, target.id);
assert_eq!(removed.note.as_deref(), Some("obsolete blocker"));
assert!(
backend
.relation_view(TicketIdOrSlug::Id(source.id.clone()))
.unwrap()
.outgoing
.is_empty()
);
assert!(
backend
.relation_view(TicketIdOrSlug::Id(target.id.clone()))
.unwrap()
.incoming
.is_empty()
);
assert!(matches!(
backend.remove_ticket_relation(
TicketIdOrSlug::Id(source.id),
TicketRelationKind::DependsOn,
TicketIdOrSlug::Id(target.id),
),
Err(TicketError::NotFound(_))
));
}
fn summary_with_state(state: TicketWorkflowState) -> TicketSummary { fn summary_with_state(state: TicketWorkflowState) -> TicketSummary {
TicketSummary { TicketSummary {
id: "000TEST".to_string(), id: "000TEST".to_string(),
@@ -6432,6 +6582,21 @@ state: planning
assert_ticket_target_edit_semantics(&backend); assert_ticket_target_edit_semantics(&backend);
} }
#[test]
fn local_backend_removes_ticket_relations() {
let tmp = TempDir::new().unwrap();
let backend = backend(&tmp);
assert_ticket_relation_removal_semantics(&backend);
}
#[test]
fn sqlite_backend_removes_ticket_relations() {
let tmp = TempDir::new().unwrap();
let backend =
SqliteTicketBackend::open(tmp.path().join("workspace.db"), "workspace-test").unwrap();
assert_ticket_relation_removal_semantics(&backend);
}
#[test] #[test]
fn local_backend_edit_item_supports_partial_body_replacement() { fn local_backend_edit_item_supports_partial_body_replacement() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
+104 -4
View File
@@ -57,8 +57,9 @@ pub const TICKET_BASE_READ_ONLY_TOOL_NAMES: [&str; 4] = [
"TicketDoctor", "TicketDoctor",
]; ];
pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 4] = [ pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 5] = [
"TicketRelationRecord", "TicketRelationRecord",
"TicketRelationRemove",
"TicketRelationQuery", "TicketRelationQuery",
"TicketOrchestrationPlanRecord", "TicketOrchestrationPlanRecord",
"TicketOrchestrationPlanQuery", "TicketOrchestrationPlanQuery",
@@ -67,7 +68,7 @@ pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 4] = [
pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] = pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] =
["TicketRelationQuery", "TicketOrchestrationPlanQuery"]; ["TicketRelationQuery", "TicketOrchestrationPlanQuery"];
pub const TICKET_TOOL_NAMES: [&str; 18] = [ pub const TICKET_TOOL_NAMES: [&str; 19] = [
"TicketCreate", "TicketCreate",
"TicketEditItem", "TicketEditItem",
"TicketList", "TicketList",
@@ -83,6 +84,7 @@ pub const TICKET_TOOL_NAMES: [&str; 18] = [
"TicketDependencyCheck", "TicketDependencyCheck",
"TicketDoctor", "TicketDoctor",
"TicketRelationRecord", "TicketRelationRecord",
"TicketRelationRemove",
"TicketRelationQuery", "TicketRelationQuery",
"TicketOrchestrationPlanRecord", "TicketOrchestrationPlanRecord",
"TicketOrchestrationPlanQuery", "TicketOrchestrationPlanQuery",
@@ -97,7 +99,7 @@ pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 6] = [
"TicketOrchestrationPlanQuery", "TicketOrchestrationPlanQuery",
]; ];
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 12] = [ pub const TICKET_MUTATING_TOOL_NAMES: [&str; 13] = [
"TicketCreate", "TicketCreate",
"TicketEditItem", "TicketEditItem",
"TicketComment", "TicketComment",
@@ -109,6 +111,7 @@ pub const TICKET_MUTATING_TOOL_NAMES: [&str; 12] = [
"TicketWorkflowState", "TicketWorkflowState",
"TicketClose", "TicketClose",
"TicketRelationRecord", "TicketRelationRecord",
"TicketRelationRemove",
"TicketOrchestrationPlanRecord", "TicketOrchestrationPlanRecord",
]; ];
@@ -146,6 +149,9 @@ a close event.";
const RELATION_RECORD_DESCRIPTION: &str = "Record a forward typed Ticket-to-Ticket relation as durable \ const RELATION_RECORD_DESCRIPTION: &str = "Record a forward typed Ticket-to-Ticket relation as durable \
project-level metadata. Supported kinds are depends_on, blocks, related, supersedes, and duplicate_of; \ project-level metadata. Supported kinds are depends_on, blocks, related, supersedes, and duplicate_of; \
inverse views are derived, not stored."; inverse views are derived, not stored.";
const RELATION_REMOVE_DESCRIPTION: &str = "Remove one exact forward typed Ticket relation identified by \
source Ticket, relation kind, and target Ticket. Use this to correct obsolete or erroneous project-level \
relation metadata; derived inverse views update automatically.";
const RELATION_QUERY_DESCRIPTION: &str = "Query durable typed Ticket relation metadata. When a Ticket \ const RELATION_QUERY_DESCRIPTION: &str = "Query durable typed Ticket relation metadata. When a Ticket \
is provided, both outgoing records owned by it and incoming forward records that target it are returned."; is provided, both outgoing records owned by it and incoming forward records that target it are returned.";
const ORCHESTRATION_PLAN_RECORD_DESCRIPTION: &str = "Append a typed Ticket orchestration plan record \ const ORCHESTRATION_PLAN_RECORD_DESCRIPTION: &str = "Append a typed Ticket orchestration plan record \
@@ -174,6 +180,7 @@ fn base_tool_description(name: &str) -> &'static str {
"TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION, "TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION,
"TicketClose" => CLOSE_DESCRIPTION, "TicketClose" => CLOSE_DESCRIPTION,
"TicketRelationRecord" => RELATION_RECORD_DESCRIPTION, "TicketRelationRecord" => RELATION_RECORD_DESCRIPTION,
"TicketRelationRemove" => RELATION_REMOVE_DESCRIPTION,
"TicketRelationQuery" => RELATION_QUERY_DESCRIPTION, "TicketRelationQuery" => RELATION_QUERY_DESCRIPTION,
"TicketOrchestrationPlanRecord" => ORCHESTRATION_PLAN_RECORD_DESCRIPTION, "TicketOrchestrationPlanRecord" => ORCHESTRATION_PLAN_RECORD_DESCRIPTION,
"TicketOrchestrationPlanQuery" => ORCHESTRATION_PLAN_QUERY_DESCRIPTION, "TicketOrchestrationPlanQuery" => ORCHESTRATION_PLAN_QUERY_DESCRIPTION,
@@ -324,6 +331,15 @@ impl TicketBackend for TicketToolBackend {
self.backend.add_ticket_relation(id, relation) self.backend.add_ticket_relation(id, relation)
} }
fn remove_ticket_relation(
&self,
id: TicketIdOrSlug,
kind: TicketRelationKind,
target: TicketIdOrSlug,
) -> TicketResult<TicketRelation> {
self.backend.remove_ticket_relation(id, kind, target)
}
fn query_ticket_relations( fn query_ticket_relations(
&self, &self,
ticket: Option<TicketIdOrSlug>, ticket: Option<TicketIdOrSlug>,
@@ -626,6 +642,16 @@ struct TicketRelationRecordParams {
note: Option<String>, note: Option<String>,
} }
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketRelationRemoveParams {
/// Ticket id that owns the forward relation.
ticket: String,
/// Forward relation kind to remove.
kind: TicketRelationKindParam,
/// Target canonical Ticket id.
target: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketRelationQueryParams { struct TicketRelationQueryParams {
/// Optional Ticket id to query. Includes outgoing and incoming forward records for that id. /// Optional Ticket id to query. Includes outgoing and incoming forward records for that id.
@@ -836,6 +862,11 @@ struct TicketRelationRecordTool {
backend: TicketToolBackend, backend: TicketToolBackend,
} }
#[derive(Clone)]
struct TicketRelationRemoveTool {
backend: TicketToolBackend,
}
#[derive(Clone)] #[derive(Clone)]
struct TicketRelationQueryTool { struct TicketRelationQueryTool {
backend: TicketToolBackend, backend: TicketToolBackend,
@@ -1230,6 +1261,32 @@ impl Tool for TicketRelationRecordTool {
} }
} }
#[async_trait]
impl Tool for TicketRelationRemoveTool {
async fn execute(
&self,
input_json: &str,
_ctx: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: TicketRelationRemoveParams = parse_input("TicketRelationRemove", input_json)?;
let output = self
.backend
.remove_ticket_relation(
TicketIdOrSlug::Id(params.ticket),
params.kind.into_kind(),
TicketIdOrSlug::Id(params.target),
)
.map_err(|error| backend_error("TicketRelationRemove", error))?;
Ok(json_output(
format!(
"Removed ticket relation {} {} {}",
output.ticket_id, output.kind, output.target
),
ticket_relation_json(&output),
))
}
}
#[async_trait] #[async_trait]
impl Tool for TicketRelationQueryTool { impl Tool for TicketRelationQueryTool {
async fn execute( async fn execute(
@@ -1682,6 +1739,9 @@ fn input_schema(name: &str) -> Value {
"TicketRelationRecord" => { "TicketRelationRecord" => {
serde_json::to_value(schemars::schema_for!(TicketRelationRecordParams)) serde_json::to_value(schemars::schema_for!(TicketRelationRecordParams))
} }
"TicketRelationRemove" => {
serde_json::to_value(schemars::schema_for!(TicketRelationRemoveParams))
}
"TicketRelationQuery" => { "TicketRelationQuery" => {
serde_json::to_value(schemars::schema_for!(TicketRelationQueryParams)) serde_json::to_value(schemars::schema_for!(TicketRelationQueryParams))
} }
@@ -1720,6 +1780,7 @@ impl_from_backend!(TicketQueueTool);
impl_from_backend!(TicketWorkflowStateTool); impl_from_backend!(TicketWorkflowStateTool);
impl_from_backend!(TicketCloseTool); impl_from_backend!(TicketCloseTool);
impl_from_backend!(TicketRelationRecordTool); impl_from_backend!(TicketRelationRecordTool);
impl_from_backend!(TicketRelationRemoveTool);
impl_from_backend!(TicketRelationQueryTool); impl_from_backend!(TicketRelationQueryTool);
impl_from_backend!(TicketOrchestrationPlanRecordTool); impl_from_backend!(TicketOrchestrationPlanRecordTool);
impl_from_backend!(TicketOrchestrationPlanQueryTool); impl_from_backend!(TicketOrchestrationPlanQueryTool);
@@ -1748,6 +1809,7 @@ pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition
tool_definition::<TicketDependencyCheckTool>("TicketDependencyCheck", backend.clone()), tool_definition::<TicketDependencyCheckTool>("TicketDependencyCheck", backend.clone()),
tool_definition::<TicketDoctorTool>("TicketDoctor", backend.clone()), tool_definition::<TicketDoctorTool>("TicketDoctor", backend.clone()),
tool_definition::<TicketRelationRecordTool>("TicketRelationRecord", backend.clone()), tool_definition::<TicketRelationRecordTool>("TicketRelationRecord", backend.clone()),
tool_definition::<TicketRelationRemoveTool>("TicketRelationRemove", backend.clone()),
tool_definition::<TicketRelationQueryTool>("TicketRelationQuery", backend.clone()), tool_definition::<TicketRelationQueryTool>("TicketRelationQuery", backend.clone()),
tool_definition::<TicketOrchestrationPlanRecordTool>( tool_definition::<TicketOrchestrationPlanRecordTool>(
"TicketOrchestrationPlanRecord", "TicketOrchestrationPlanRecord",
@@ -1821,6 +1883,7 @@ mod tests {
"TicketWorkflowState", "TicketWorkflowState",
"TicketClose", "TicketClose",
"TicketRelationRecord", "TicketRelationRecord",
"TicketRelationRemove",
"TicketOrchestrationPlanRecord" "TicketOrchestrationPlanRecord"
] ]
); );
@@ -2254,12 +2317,13 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn ticket_relation_tools_record_query_and_show_derived_view() { async fn ticket_relation_tools_record_query_remove_and_show_derived_view() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
let backend = backend(&temp); let backend = backend(&temp);
let source = backend.create(NewTicket::new("Relation Source")).unwrap(); let source = backend.create(NewTicket::new("Relation Source")).unwrap();
let target = backend.create(NewTicket::new("Relation Target")).unwrap(); let target = backend.create(NewTicket::new("Relation Target")).unwrap();
let record = tool_by_name(backend.clone(), "TicketRelationRecord"); let record = tool_by_name(backend.clone(), "TicketRelationRecord");
let remove = tool_by_name(backend.clone(), "TicketRelationRemove");
let query = tool_by_name(backend.clone(), "TicketRelationQuery"); let query = tool_by_name(backend.clone(), "TicketRelationQuery");
let show = tool_by_name(backend.clone(), "TicketShow"); let show = tool_by_name(backend.clone(), "TicketShow");
@@ -2305,6 +2369,42 @@ mod tests {
shown_json["relations"]["incoming"][0]["inverse_kind"], shown_json["relations"]["incoming"][0]["inverse_kind"],
"dependency_of" "dependency_of"
); );
let removed = remove
.execute(
&json!({
"ticket": source.id.clone(),
"kind": "depends_on",
"target": target.id.clone()
})
.to_string(),
Default::default(),
)
.await
.unwrap();
assert!(removed.summary.contains("Removed ticket relation"));
let removed_json: Value = serde_json::from_str(&removed.content.unwrap()).unwrap();
assert_eq!(removed_json["kind"], "depends_on");
assert_eq!(removed_json["target"], target.id);
let queried_after_remove = query
.execute(
&json!({ "ticket": source.id }).to_string(),
Default::default(),
)
.await
.unwrap();
let queried_after_remove_json: Value =
serde_json::from_str(&queried_after_remove.content.unwrap()).unwrap();
assert_eq!(queried_after_remove_json["count"], 0);
let shown_after_remove = show
.execute(&json!({ "id": target.id }).to_string(), Default::default())
.await
.unwrap();
let shown_after_remove_json: Value =
serde_json::from_str(&shown_after_remove.content.unwrap()).unwrap();
assert_eq!(shown_after_remove_json["relations"]["incoming"], json!([]));
} }
#[tokio::test] #[tokio::test]
+8 -1
View File
@@ -2645,7 +2645,14 @@ mod composer_history_persistence_tests {
let mut app = App::new_with_input_history_store("test".into(), store); let mut app = App::new_with_input_history_store("test".into(), store);
submit_text(&mut app, "synthetic entry outside workspace yoi"); submit_text(&mut app, "synthetic entry outside workspace yoi");
assert!(data_dir.path().join("composer-history").exists()); assert!(
data_dir
.path()
.join("client")
.join("composer-history")
.exists()
);
assert!(!data_dir.path().join("composer-history").exists());
assert!(!workspace.path().join(".yoi").exists()); assert!(!workspace.path().join(".yoi").exists());
} }
+35 -2
View File
@@ -70,6 +70,7 @@ impl ComposerHistoryStore {
let workspace = workspace_identity(workspace_root); let workspace = workspace_identity(workspace_root);
let path = data_dir let path = data_dir
.as_ref() .as_ref()
.join("client")
.join("composer-history") .join("composer-history")
.join("workspaces") .join("workspaces")
.join(format!("{}-{}", workspace.label, workspace.key)) .join(format!("{}-{}", workspace.label, workspace.key))
@@ -186,7 +187,7 @@ mod tests {
use tempfile::TempDir; use tempfile::TempDir;
#[test] #[test]
fn store_path_is_workspace_scoped_under_data_dir() { fn store_path_is_workspace_scoped_under_client_data_dir() {
let data_dir = TempDir::new().unwrap(); let data_dir = TempDir::new().unwrap();
let store = ComposerHistoryStore::for_data_dir(data_dir.path(), Path::new("/repo/yoi")); let store = ComposerHistoryStore::for_data_dir(data_dir.path(), Path::new("/repo/yoi"));
let other = ComposerHistoryStore::for_data_dir(data_dir.path(), Path::new("/repo/other")); let other = ComposerHistoryStore::for_data_dir(data_dir.path(), Path::new("/repo/other"));
@@ -196,11 +197,43 @@ mod tests {
store store
.path() .path()
.to_string_lossy() .to_string_lossy()
.contains("composer-history/workspaces/yoi-") .contains("client/composer-history/workspaces/yoi-")
); );
assert_ne!(store.path(), other.path()); assert_ne!(store.path(), other.path());
} }
#[test]
fn legacy_top_level_history_is_ignored_without_migration_or_fallback() {
let data_dir = TempDir::new().unwrap();
let workspace_root = Path::new("/repo/yoi");
let workspace = workspace_identity(workspace_root);
let legacy_path = data_dir
.path()
.join("composer-history")
.join("workspaces")
.join(format!("{}-{}", workspace.label, workspace.key))
.join("history.json");
let legacy_file = ComposerHistoryFile {
version: COMPOSER_HISTORY_VERSION,
workspace,
entries: vec![vec![Segment::text("legacy entry")]],
};
let legacy_bytes = serde_json::to_vec_pretty(&legacy_file).unwrap();
fs::create_dir_all(legacy_path.parent().unwrap()).unwrap();
fs::write(&legacy_path, &legacy_bytes).unwrap();
let store = ComposerHistoryStore::for_data_dir(data_dir.path(), workspace_root);
assert!(store.load().unwrap().is_empty());
assert!(!store.path().exists());
assert_eq!(fs::read(&legacy_path).unwrap(), legacy_bytes);
let current_entries = VecDeque::from([vec![Segment::text("current entry")]]);
store.save(&current_entries).unwrap();
assert_eq!(store.load().unwrap(), current_entries);
assert_eq!(fs::read(&legacy_path).unwrap(), legacy_bytes);
}
#[test] #[test]
fn file_records_workspace_identity_metadata_and_typed_segments() { fn file_records_workspace_identity_metadata_and_typed_segments() {
let data_dir = TempDir::new().unwrap(); let data_dir = TempDir::new().unwrap();
+36
View File
@@ -6,6 +6,7 @@
//! [`crate::http`]. //! [`crate::http`].
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fmt;
/// Stable Workspace identity for a Worker hosted by a Runtime. /// Stable Workspace identity for a Worker hosted by a Runtime.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
@@ -42,6 +43,24 @@ pub enum WorkingDirectoryStatusKind {
Unknown, Unknown,
} }
impl WorkingDirectoryStatusKind {
pub const fn as_str(&self) -> &'static str {
match self {
Self::Active => "active",
Self::CleanupPending => "cleanup_pending",
Self::Corrupted => "corrupted",
Self::NotFound => "not_found",
Self::Unknown => "unknown",
}
}
}
impl fmt::Display for WorkingDirectoryStatusKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct WorkingDirectoryCleanupTarget { pub struct WorkingDirectoryCleanupTarget {
@@ -200,6 +219,23 @@ pub struct WorkingDirectoryDetailResponse {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn working_directory_status_display_matches_wire_values() {
for (status, expected) in [
(WorkingDirectoryStatusKind::Active, "active"),
(
WorkingDirectoryStatusKind::CleanupPending,
"cleanup_pending",
),
(WorkingDirectoryStatusKind::Corrupted, "corrupted"),
(WorkingDirectoryStatusKind::NotFound, "not_found"),
(WorkingDirectoryStatusKind::Unknown, "unknown"),
] {
assert_eq!(status.to_string(), expected);
assert_eq!(serde_json::to_value(status).unwrap(), expected);
}
}
#[test] #[test]
fn occupied_and_free_list_response_round_trips() { fn occupied_and_free_list_response_round_trips() {
let response = WorkingDirectoryListResponse { let response = WorkingDirectoryListResponse {
@@ -157,6 +157,7 @@ const AUTHORING_TOOL_NAMES: &[&str] = &[
"TicketQueue", "TicketQueue",
"TicketClose", "TicketClose",
"TicketRelationRecord", "TicketRelationRecord",
"TicketRelationRemove",
]; ];
const THREAD_TOOL_NAMES: &[&str] = &["TicketComment"]; const THREAD_TOOL_NAMES: &[&str] = &["TicketComment"];
@@ -175,6 +176,7 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
"TicketDependencyCheck", "TicketDependencyCheck",
"TicketDoctor", "TicketDoctor",
"TicketRelationRecord", "TicketRelationRecord",
"TicketRelationRemove",
"TicketRelationQuery", "TicketRelationQuery",
"TicketOrchestrationPlanQuery", "TicketOrchestrationPlanQuery",
]; ];
@@ -189,6 +191,7 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[
"TicketDependencyCheck", "TicketDependencyCheck",
"TicketDoctor", "TicketDoctor",
"TicketRelationRecord", "TicketRelationRecord",
"TicketRelationRemove",
"TicketRelationQuery", "TicketRelationQuery",
"TicketOrchestrationPlanRecord", "TicketOrchestrationPlanRecord",
"TicketOrchestrationPlanQuery", "TicketOrchestrationPlanQuery",
@@ -198,6 +201,7 @@ const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[
"TicketWorkflowState", "TicketWorkflowState",
"TicketClose", "TicketClose",
"TicketRelationRecord", "TicketRelationRecord",
"TicketRelationRemove",
"TicketOrchestrationPlanRecord", "TicketOrchestrationPlanRecord",
]; ];
@@ -689,6 +693,20 @@ impl WorkspaceHttpTicketBackend {
)?; )?;
Ok(TicketBackendOperationResult::Relation(relation)) Ok(TicketBackendOperationResult::Relation(relation))
} }
TicketBackendOperation::RemoveTicketRelation { id, kind, target } => {
let target = match target {
TicketIdOrSlug::Id(value)
| TicketIdOrSlug::Slug(value)
| TicketIdOrSlug::Query(value) => value,
};
let relation = Self::request(
client,
WorkspaceRequestMethod::Delete,
format!("{base}/{}/relations", Self::ticket_path(&id)),
Some(serde_json::json!({ "kind": kind, "target": target })),
)?;
Ok(TicketBackendOperationResult::Relation(relation))
}
TicketBackendOperation::QueryTicketRelations { ticket, kind } => { TicketBackendOperation::QueryTicketRelations { ticket, kind } => {
let relations = Self::request( let relations = Self::request(
client, client,
@@ -915,6 +933,18 @@ impl TicketBackend for WorkspaceHttpTicketBackend {
) )
} }
fn remove_ticket_relation(
&self,
id: TicketIdOrSlug,
kind: TicketRelationKind,
target: TicketIdOrSlug,
) -> TicketResult<TicketRelation> {
expect_ticket_result!(
self.invoke(TicketBackendOperation::RemoveTicketRelation { id, kind, target }),
TicketBackendOperationResult::Relation
)
}
fn query_ticket_relations( fn query_ticket_relations(
&self, &self,
ticket: Option<TicketIdOrSlug>, ticket: Option<TicketIdOrSlug>,
@@ -1448,6 +1478,55 @@ provider = "github"
server.join().unwrap(); server.join().unwrap();
} }
#[test]
fn workspace_http_backend_deletes_exact_ticket_relation() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 8192];
let len = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..len]);
assert!(
request
.starts_with("DELETE /api/w/workspace-a/tickets/01SOURCE/relations HTTP/1.1")
);
assert!(request.contains("\"kind\":\"depends_on\""));
assert!(request.contains("\"target\":\"01TARGET\""));
let response_body = serde_json::to_string(&TicketRelation {
ticket_id: "01SOURCE".to_string(),
kind: TicketRelationKind::DependsOn,
target: "01TARGET".to_string(),
note: Some("obsolete".to_string()),
author: "tester".to_string(),
at: "2026-08-06T00:00:00Z".to_string(),
})
.unwrap();
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
response_body.len(),
response_body
)
.unwrap();
});
let backend = WorkspaceHttpTicketBackend::new(Arc::new(
crate::worker::TestWorkspaceHttpClient::new("workspace-a", base_url),
));
let removed = backend
.remove_ticket_relation(
TicketIdOrSlug::Id("01SOURCE".to_string()),
TicketRelationKind::DependsOn,
TicketIdOrSlug::Id("01TARGET".to_string()),
)
.unwrap();
server.join().unwrap();
assert_eq!(removed.ticket_id, "01SOURCE");
assert_eq!(removed.target, "01TARGET");
}
#[test] #[test]
fn workspace_http_backend_executes_ticket_create_operation() { fn workspace_http_backend_executes_ticket_create_operation() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+32 -1
View File
@@ -1324,7 +1324,7 @@ pub fn build_router(api: WorkspaceApi) -> Router {
) )
.route( .route(
"/api/w/{workspace_id}/tickets/{id}/relations", "/api/w/{workspace_id}/tickets/{id}/relations",
post(scoped_record_ticket_relation), post(scoped_record_ticket_relation).delete(scoped_remove_ticket_relation),
) )
.route( .route(
"/api/w/{workspace_id}/tickets/{id}/orchestration-plans", "/api/w/{workspace_id}/tickets/{id}/orchestration-plans",
@@ -3938,6 +3938,35 @@ async fn scoped_record_ticket_relation(
}) })
} }
#[derive(Debug, Deserialize)]
struct TicketRelationRemoveRequest {
kind: ticket::TicketRelationKind,
target: String,
}
async fn scoped_remove_ticket_relation(
State(api): State<WorkspaceApi>,
AxumPath((workspace_id, id)): AxumPath<(String, String)>,
headers: HeaderMap,
Json(relation): Json<TicketRelationRemoveRequest>,
) -> ApiResult<Json<ticket::TicketRelation>> {
let result = execute_worker_ticket_rest_operation(
&api,
&workspace_id,
headers,
TicketBackendOperation::RemoveTicketRelation {
id: TicketIdOrSlug::Query(id),
kind: relation.kind,
target: TicketIdOrSlug::Id(relation.target),
},
)
.await?;
ticket_rest_result(result, |result| match result {
TicketBackendOperationResult::Relation(relation) => Some(relation),
_ => None,
})
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct TicketRelationSearchRequest { struct TicketRelationSearchRequest {
ticket: Option<TicketIdOrSlug>, ticket: Option<TicketIdOrSlug>,
@@ -4068,6 +4097,7 @@ fn ticket_mutation_target(operation: &TicketBackendOperation) -> Option<&TicketI
| TicketBackendOperation::QueueReady { id, .. } | TicketBackendOperation::QueueReady { id, .. }
| TicketBackendOperation::Close { id, .. } | TicketBackendOperation::Close { id, .. }
| TicketBackendOperation::AddTicketRelation { id, .. } | TicketBackendOperation::AddTicketRelation { id, .. }
| TicketBackendOperation::RemoveTicketRelation { id, .. }
| TicketBackendOperation::AddOrchestrationPlanRecord { id, .. } => Some(id), | TicketBackendOperation::AddOrchestrationPlanRecord { id, .. } => Some(id),
_ => None, _ => None,
} }
@@ -4134,6 +4164,7 @@ fn ticket_mutation_operation_kind(operation: &TicketBackendOperation) -> &'stati
TicketBackendOperation::QueueReady { .. } => "queue_ready", TicketBackendOperation::QueueReady { .. } => "queue_ready",
TicketBackendOperation::Close { .. } => "close", TicketBackendOperation::Close { .. } => "close",
TicketBackendOperation::AddTicketRelation { .. } => "add_relation", TicketBackendOperation::AddTicketRelation { .. } => "add_relation",
TicketBackendOperation::RemoveTicketRelation { .. } => "remove_relation",
TicketBackendOperation::AddOrchestrationPlanRecord { .. } => "add_plan_record", TicketBackendOperation::AddOrchestrationPlanRecord { .. } => "add_plan_record",
_ => "read", _ => "read",
} }