merge: integrate orchestration

# Conflicts:
#	crates/flow/src/builtin.rs
#	crates/manifest/src/profile.rs
#	crates/worker/src/prompt/catalog.rs
#	crates/worker/src/prompt/system.rs
#	resources/flows/coder-review.dcdl
#	resources/prompts/role/coder.md
#	resources/prompts/role/orchestrator.md
#	web/workspace/src/lib/workspace/console/worker-console.ui.test.ts
#	web/workspace/src/lib/workspace/styles/tickets.css
#	web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte
#	web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts
This commit is contained in:
2026-08-18 09:55:27 +09:00
37 changed files with 2964 additions and 727 deletions
Generated
+1
View File
@@ -4417,6 +4417,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
"sha2 0.11.0",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
+100 -3
View File
@@ -18,9 +18,10 @@ use crate::model::{AuthRef, ModelManifest, ReasoningControl};
use crate::plugin::PluginConfig; use crate::plugin::PluginConfig;
use crate::{ use crate::{
CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits, CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConfig, MemoryFeatureConfig, ScopeConfig, McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConfig, MemoryFeatureConfig,
SessionConfig, SkillsConfig, TicketFeatureConfig, ToolOutputLimits, ToolPermissionConfig, MergeRequestFeatureConfig, ScopeConfig, SessionConfig, SkillsConfig, TicketFeatureConfig,
ToolPermissionRule, WebConfig, WorkerFeatureConfig, WorkerManifest, WorkerMeta, ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerFeatureConfig,
WorkerManifest, WorkerMeta,
}; };
/// Partial-form Worker manifest. Every field is optional; one or more /// Partial-form Worker manifest. Every field is optional; one or more
@@ -97,6 +98,8 @@ pub struct FeatureConfigPartial {
#[serde(default)] #[serde(default)]
pub ticket: Option<TicketFeatureConfigPartial>, pub ticket: Option<TicketFeatureConfigPartial>,
#[serde(default)] #[serde(default)]
pub merge_request: Option<MergeRequestFeatureConfigPartial>,
#[serde(default)]
pub orchestration: Option<FeatureFlagConfigPartial>, pub orchestration: Option<FeatureFlagConfigPartial>,
#[serde(default)] #[serde(default)]
pub plugins: Option<FeatureFlagConfigPartial>, pub plugins: Option<FeatureFlagConfigPartial>,
@@ -127,6 +130,11 @@ impl FeatureConfigPartial {
FeatureFlagConfigPartial::merge, FeatureFlagConfigPartial::merge,
), ),
ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge), ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge),
merge_request: merge_option(
self.merge_request,
other.merge_request,
MergeRequestFeatureConfigPartial::merge,
),
orchestration: merge_option( orchestration: merge_option(
self.orchestration, self.orchestration,
other.orchestration, other.orchestration,
@@ -216,6 +224,28 @@ impl TicketFeatureConfigPartial {
} }
} }
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
pub struct MergeRequestFeatureConfigPartial {
pub show: Option<bool>,
pub open: Option<bool>,
pub review: Option<bool>,
pub readiness_check: Option<bool>,
pub complete: Option<bool>,
}
impl MergeRequestFeatureConfigPartial {
fn merge(self, other: Self) -> Self {
Self {
show: other.show.or(self.show),
open: other.open.or(self.open),
review: other.review.or(self.review),
readiness_check: other.readiness_check.or(self.readiness_check),
complete: other.complete.or(self.complete),
}
}
}
impl From<FeatureConfigPartial> for FeatureConfig { impl From<FeatureConfigPartial> for FeatureConfig {
fn from(value: FeatureConfigPartial) -> Self { fn from(value: FeatureConfigPartial) -> Self {
Self { Self {
@@ -247,6 +277,10 @@ impl From<FeatureConfigPartial> for FeatureConfig {
.ticket .ticket
.map(TicketFeatureConfig::from) .map(TicketFeatureConfig::from)
.unwrap_or_default(), .unwrap_or_default(),
merge_request: value
.merge_request
.map(MergeRequestFeatureConfig::from)
.unwrap_or_default(),
orchestration: value orchestration: value
.orchestration .orchestration
.map(FeatureFlagConfig::from) .map(FeatureFlagConfig::from)
@@ -326,6 +360,30 @@ impl From<TicketFeatureConfig> for TicketFeatureConfigPartial {
} }
} }
impl From<MergeRequestFeatureConfigPartial> for MergeRequestFeatureConfig {
fn from(value: MergeRequestFeatureConfigPartial) -> Self {
Self {
show: value.show.unwrap_or_default(),
open: value.open.unwrap_or_default(),
review: value.review.unwrap_or_default(),
readiness_check: value.readiness_check.unwrap_or_default(),
complete: value.complete.unwrap_or_default(),
}
}
}
impl From<MergeRequestFeatureConfig> for MergeRequestFeatureConfigPartial {
fn from(value: MergeRequestFeatureConfig) -> Self {
Self {
show: Some(value.show),
open: Some(value.open),
review: Some(value.review),
readiness_check: Some(value.readiness_check),
complete: Some(value.complete),
}
}
}
impl From<FeatureConfig> for FeatureConfigPartial { impl From<FeatureConfig> for FeatureConfigPartial {
fn from(value: FeatureConfig) -> Self { fn from(value: FeatureConfig) -> Self {
Self { Self {
@@ -339,6 +397,7 @@ impl From<FeatureConfig> for FeatureConfigPartial {
objective: Some(value.objective.into()), objective: Some(value.objective.into()),
manage_workdir: Some(value.manage_workdir.into()), manage_workdir: Some(value.manage_workdir.into()),
ticket: Some(value.ticket.into()), ticket: Some(value.ticket.into()),
merge_request: Some(value.merge_request.into()),
orchestration: Some(value.orchestration.into()), orchestration: Some(value.orchestration.into()),
plugins: Some(value.plugins.into()), plugins: Some(value.plugins.into()),
} }
@@ -1880,6 +1939,7 @@ worker_max_turns = 7
assert!(!manifest.feature.objective.enabled); assert!(!manifest.feature.objective.enabled);
assert!(!manifest.feature.manage_workdir.enabled); assert!(!manifest.feature.manage_workdir.enabled);
assert!(!manifest.feature.ticket.enabled); assert!(!manifest.feature.ticket.enabled);
assert!(!manifest.feature.merge_request.any());
} }
#[test] #[test]
@@ -1899,6 +1959,13 @@ thread = false
intake = false intake = false
workflow = false workflow = false
[feature.merge_request]
show = true
open = false
review = true
readiness_check = false
complete = false
[feature.orchestration] [feature.orchestration]
enabled = false enabled = false
"#, "#,
@@ -1934,6 +2001,14 @@ enabled = false
assert!(!manifest.feature.ticket.thread); assert!(!manifest.feature.ticket.thread);
assert!(!manifest.feature.ticket.intake); assert!(!manifest.feature.ticket.intake);
assert!(!manifest.feature.ticket.workflow); assert!(!manifest.feature.ticket.workflow);
assert_eq!(
manifest.feature.merge_request,
MergeRequestFeatureConfig {
show: true,
review: true,
..Default::default()
}
);
assert!(!manifest.feature.orchestration.enabled); assert!(!manifest.feature.orchestration.enabled);
assert!(!manifest.feature.memory.enabled); assert!(!manifest.feature.memory.enabled);
assert!(!manifest.feature.memory.staging); assert!(!manifest.feature.memory.staging);
@@ -1957,6 +2032,13 @@ thread = false
intake = false intake = false
workflow = false workflow = false
[feature.merge_request]
show = true
open = false
review = true
readiness_check = false
complete = false
[feature.orchestration] [feature.orchestration]
enabled = false enabled = false
"#, "#,
@@ -1968,6 +2050,11 @@ enabled = false
thread = true thread = true
workflow = true workflow = true
[feature.merge_request]
open = true
review = false
readiness_check = true
[feature.orchestration] [feature.orchestration]
enabled = true enabled = true
@@ -2017,6 +2104,16 @@ enabled = true
assert!(manifest.feature.ticket.thread); assert!(manifest.feature.ticket.thread);
assert!(!manifest.feature.ticket.intake); assert!(!manifest.feature.ticket.intake);
assert!(manifest.feature.ticket.workflow); assert!(manifest.feature.ticket.workflow);
assert_eq!(
manifest.feature.merge_request,
MergeRequestFeatureConfig {
show: true,
open: true,
review: false,
readiness_check: true,
complete: false,
}
);
assert!(manifest.feature.orchestration.enabled); assert!(manifest.feature.orchestration.enabled);
assert!(manifest.feature.objective.enabled); assert!(manifest.feature.objective.enabled);
assert!(manifest.feature.web.enabled); assert!(manifest.feature.web.enabled);
+24
View File
@@ -125,6 +125,8 @@ pub struct FeatureConfig {
#[serde(default)] #[serde(default)]
pub ticket: TicketFeatureConfig, pub ticket: TicketFeatureConfig,
#[serde(default)] #[serde(default)]
pub merge_request: MergeRequestFeatureConfig,
#[serde(default)]
pub orchestration: FeatureFlagConfig, pub orchestration: FeatureFlagConfig,
#[serde(default)] #[serde(default)]
pub plugins: FeatureFlagConfig, pub plugins: FeatureFlagConfig,
@@ -143,6 +145,7 @@ impl Default for FeatureConfig {
objective: FeatureFlagConfig::disabled(), objective: FeatureFlagConfig::disabled(),
manage_workdir: FeatureFlagConfig::disabled(), manage_workdir: FeatureFlagConfig::disabled(),
ticket: TicketFeatureConfig::default(), ticket: TicketFeatureConfig::default(),
merge_request: MergeRequestFeatureConfig::default(),
orchestration: FeatureFlagConfig::disabled(), orchestration: FeatureFlagConfig::disabled(),
plugins: FeatureFlagConfig::disabled(), plugins: FeatureFlagConfig::disabled(),
} }
@@ -252,6 +255,27 @@ pub struct TicketFeatureConfig {
pub workflow: bool, pub workflow: bool,
} }
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(deny_unknown_fields)]
pub struct MergeRequestFeatureConfig {
#[serde(default)]
pub show: bool,
#[serde(default)]
pub open: bool,
#[serde(default)]
pub review: bool,
#[serde(default)]
pub readiness_check: bool,
#[serde(default)]
pub complete: bool,
}
impl MergeRequestFeatureConfig {
pub fn any(self) -> bool {
self.show || self.open || self.review || self.readiness_check || self.complete
}
}
/// External Agent Skills (`SKILL.md`) ingest configuration. Skills are /// External Agent Skills (`SKILL.md`) ingest configuration. Skills are
/// loaded *only* from the directories listed here — there is no /// loaded *only* from the directories listed here — there is no
/// implicit `$config_dir/skills/` or builtin probe. Profile and Manifest /// implicit `$config_dir/skills/` or builtin probe. Profile and Manifest
+47
View File
@@ -919,6 +919,37 @@ fn apply_role_profile(
_ => serde_json::json!({ "enabled": true, "authoring": true, "thread": true }), _ => serde_json::json!({ "enabled": true, "authoring": true, "thread": true }),
}; };
value["feature"]["ticket"] = ticket; value["feature"]["ticket"] = ticket;
let merge_request = match slug {
"coder" => serde_json::json!({
"show": true,
"open": true,
"review": false,
"readiness_check": false,
"complete": false
}),
"reviewer" => serde_json::json!({
"show": true,
"open": false,
"review": true,
"readiness_check": false,
"complete": false
}),
"orchestrator" => serde_json::json!({
"show": true,
"open": false,
"review": false,
"readiness_check": true,
"complete": true
}),
_ => serde_json::json!({
"show": false,
"open": false,
"review": false,
"readiness_check": false,
"complete": false
}),
};
value["feature"]["merge_request"] = merge_request;
} }
fn reject_manifest_shaped_profile(value: &serde_json::Value) -> Result<(), ProfileError> { fn reject_manifest_shaped_profile(value: &serde_json::Value) -> Result<(), ProfileError> {
@@ -1448,6 +1479,14 @@ enabled = true
authoring = false authoring = false
thread = false thread = false
intake = false intake = false
[feature.merge_request]
show = true
open = false
review = true
readiness_check = false
complete = false
[feature.orchestration] [feature.orchestration]
enabled = false enabled = false
"#, "#,
@@ -1470,6 +1509,14 @@ enabled = false
assert!(!resolved.manifest.feature.ticket.authoring); assert!(!resolved.manifest.feature.ticket.authoring);
assert!(!resolved.manifest.feature.ticket.thread); assert!(!resolved.manifest.feature.ticket.thread);
assert!(!resolved.manifest.feature.ticket.intake); assert!(!resolved.manifest.feature.ticket.intake);
assert_eq!(
resolved.manifest.feature.merge_request,
crate::MergeRequestFeatureConfig {
show: true,
review: true,
..Default::default()
}
);
assert!(!resolved.manifest.feature.orchestration.enabled); assert!(!resolved.manifest.feature.orchestration.enabled);
assert_eq!( assert_eq!(
resolved.manifest.delegation_scope.allow[0].target, resolved.manifest.delegation_scope.allow[0].target,
+29 -1
View File
@@ -75,6 +75,15 @@ pub struct MergeRequestAuth {
pub assignment_id: String, pub assignment_id: String,
} }
impl MergeRequestAuth {
fn actor(&self) -> WorkerIdentity {
WorkerIdentity {
runtime_id: self.runtime_id.clone(),
worker_id: self.worker_id.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReviewRequestedEvent { pub struct ReviewRequestedEvent {
pub event_id: String, pub event_id: String,
@@ -648,7 +657,7 @@ impl MergeRequestStore {
} }
pub fn validate_completion(&self, i: &CompleteMergeRequest) -> Result<(), MergeRequestError> { pub fn validate_completion(&self, i: &CompleteMergeRequest) -> Result<(), MergeRequestError> {
let mr = self.get(&i.auth.workspace_id, &i.ticket_id)?; let mr = self.get(&i.auth.workspace_id, &i.ticket_id)?;
self.completion_auth(&i.auth, &i.ticket_id, &mr.repository_id)?; self.repo(&i.auth, &mr.repository_id)?;
if let Some(existing) = mr.thread.iter().find_map(|event| match event { if let Some(existing) = mr.thread.iter().find_map(|event| match event {
MergeRequestThreadEvent::Merge(value) if value.operation_id == i.operation_id => { MergeRequestThreadEvent::Merge(value) if value.operation_id == i.operation_id => {
Some(value) Some(value)
@@ -656,8 +665,12 @@ impl MergeRequestStore {
_ => None, _ => None,
}) { }) {
if existing.approval_event_id == i.approval_event_id if existing.approval_event_id == i.approval_event_id
&& existing.approved_source_ref == i.current_subject_ref
&& existing.target_ref_before == i.target_ref_before && existing.target_ref_before == i.target_ref_before
&& existing.target_ref_after == i.target_ref_after && existing.target_ref_after == i.target_ref_after
&& existing.strategy == i.strategy
&& existing.resolution == i.resolution
&& existing.merged_by == i.auth.actor()
{ {
return Ok(()); return Ok(());
} }
@@ -665,6 +678,7 @@ impl MergeRequestStore {
"operation fingerprint mismatch".into(), "operation fingerprint mismatch".into(),
)); ));
} }
self.completion_auth(&i.auth, &i.ticket_id, &mr.repository_id)?;
if mr.state != MergeRequestState::Open { if mr.state != MergeRequestState::Open {
return Err(MergeRequestError::Conflict( return Err(MergeRequestError::Conflict(
"Merge Request is not open".into(), "Merge Request is not open".into(),
@@ -718,8 +732,12 @@ impl MergeRequestStore {
_ => None, _ => None,
}) { }) {
if existing.approval_event_id == i.approval_event_id if existing.approval_event_id == i.approval_event_id
&& existing.approved_source_ref == i.current_subject_ref
&& existing.target_ref_before == i.target_ref_before && existing.target_ref_before == i.target_ref_before
&& existing.target_ref_after == i.target_ref_after && existing.target_ref_after == i.target_ref_after
&& existing.strategy == i.strategy
&& existing.resolution == i.resolution
&& existing.merged_by == i.auth.actor()
{ {
return Ok(existing.clone()); return Ok(existing.clone());
} }
@@ -774,6 +792,16 @@ impl MergeRequestStore {
WHERE workspace_id=?1 AND ticket_id=?2 AND workflow_state='inprogress'", WHERE workspace_id=?1 AND ticket_id=?2 AND workflow_state='inprogress'",
params![mr.workspace_id, i.ticket_id, i.now.to_rfc3339()], params![mr.workspace_id, i.ticket_id, i.now.to_rfc3339()],
)?; )?;
let released_assignment = transaction.execute(
"DELETE FROM ticket_current_worker_assignments
WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3",
params![mr.workspace_id, i.ticket_id, i.auth.assignment_id],
)?;
if released_assignment != 1 {
return Err(MergeRequestError::Unauthorized(
"completion assignment changed while closing Ticket".into(),
));
}
let issued_grants = { let issued_grants = {
let mut statement = transaction.prepare( let mut statement = transaction.prepare(
"SELECT request_event_id,subject_ref,capability_token "SELECT request_event_id,subject_ref,capability_token
+28 -1
View File
@@ -93,7 +93,7 @@ fn approve(s: &MergeRequestStore, subject: &str, token: &str) -> ReviewEvent {
} }
#[test] #[test]
fn selectors_thread_and_completion_have_no_revision_or_commit_api() { fn selectors_thread_and_completion_have_no_revision_or_commit_api() {
let (_d, s) = fixture(); let (d, s) = fixture();
open(&s); open(&s);
let review = approve(&s, "opaque-source-ref", "token"); let review = approve(&s, "opaque-source-ref", "token");
let ready = s let ready = s
@@ -122,6 +122,33 @@ fn selectors_thread_and_completion_have_no_revision_or_commit_api() {
let mr = s.get("W", "T").unwrap(); let mr = s.get("W", "T").unwrap();
assert_eq!(mr.selector_from.as_deref(), Some("work/t")); assert_eq!(mr.selector_from.as_deref(), Some("work/t"));
assert_eq!(mr.state, MergeRequestState::Merged); assert_eq!(mr.state, MergeRequestState::Merged);
let current_assignment: bool = Connection::open(d.path().join("db"))
.unwrap()
.query_row(
"SELECT EXISTS(
SELECT 1 FROM ticket_current_worker_assignments
WHERE workspace_id='W' AND ticket_id='T'
)",
[],
|row| row.get(0),
)
.unwrap();
assert!(!current_assignment);
let replayed = s
.complete(CompleteMergeRequest {
ticket_id: "T".into(),
operation_id: "op".into(),
approval_event_id: merged.approval_event_id.clone(),
current_subject_ref: merged.approved_source_ref.clone(),
target_ref_before: merged.target_ref_before.clone(),
target_ref_after: merged.target_ref_after.clone(),
strategy: merged.strategy,
resolution: merged.resolution,
auth: auth(),
now: at(6),
})
.unwrap();
assert_eq!(replayed, merged);
let json = serde_json::to_string(&mr).unwrap(); let json = serde_json::to_string(&mr).unwrap();
for banned in [ for banned in [
"revision_id", "revision_id",
+1
View File
@@ -14,6 +14,7 @@ schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
serde_yaml = "0.9.34" serde_yaml = "0.9.34"
sha2.workspace = true
rusqlite.workspace = true rusqlite.workspace = true
thiserror.workspace = true thiserror.workspace = true
tempfile.workspace = true tempfile.workspace = true
+854 -116
View File
File diff suppressed because it is too large Load Diff
+34 -6
View File
@@ -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 = 3; pub const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 4;
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
struct Migration { struct Migration {
@@ -32,6 +32,11 @@ const MIGRATIONS: &[Migration] = &[
name: "convert_legacy_reviews_to_comments", name: "convert_legacy_reviews_to_comments",
apply: retire_legacy_ticket_review_events, apply: retire_legacy_ticket_review_events,
}, },
Migration {
version: 4,
name: "add_ticket_query_indexes",
apply: add_ticket_query_indexes,
},
]; ];
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@@ -507,6 +512,29 @@ fn retire_legacy_ticket_review_events(connection: &Connection) -> Result<()> {
.map_err(sqlite_err) .map_err(sqlite_err)
} }
fn add_ticket_query_indexes(connection: &Connection) -> Result<()> {
connection
.execute_batch(
r#"
CREATE INDEX IF NOT EXISTS typed_tickets_workspace_state_updated
ON typed_tickets(workspace_id, workflow_state, updated_at DESC, ticket_id);
CREATE INDEX IF NOT EXISTS typed_tickets_workspace_updated
ON typed_tickets(workspace_id, updated_at DESC, ticket_id);
CREATE INDEX IF NOT EXISTS typed_tickets_workspace_created
ON typed_tickets(workspace_id, created_at DESC, ticket_id);
CREATE INDEX IF NOT EXISTS typed_tickets_workspace_title
ON typed_tickets(workspace_id, title COLLATE NOCASE, ticket_id);
CREATE INDEX IF NOT EXISTS typed_ticket_events_workspace_kind_ticket
ON typed_ticket_events(workspace_id, kind, ticket_id, event_index);
CREATE INDEX IF NOT EXISTS typed_ticket_relations_workspace_source_kind
ON typed_ticket_relations(workspace_id, ticket_id, kind, target);
CREATE INDEX IF NOT EXISTS typed_ticket_relations_workspace_target_kind
ON typed_ticket_relations(workspace_id, target, kind, ticket_id);
"#,
)
.map_err(sqlite_err)
}
fn add_column_if_missing( fn add_column_if_missing(
connection: &Connection, connection: &Connection,
table: &str, table: &str,
@@ -841,10 +869,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(), 3); assert_eq!(versions.len(), 4);
assert_eq!( assert_eq!(
versions.get(&LATEST_SQLITE_TICKET_SCHEMA_VERSION), versions.get(&LATEST_SQLITE_TICKET_SCHEMA_VERSION),
Some(&"convert_legacy_reviews_to_comments".to_string()) Some(&"add_ticket_query_indexes".to_string())
); );
} }
@@ -989,7 +1017,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(), 4); assert_eq!(load_applied_migrations(&connection).unwrap().len(), 5);
} }
#[test] #[test]
@@ -1090,7 +1118,7 @@ mod tests {
connection.execute("INSERT INTO typed_ticket_events (workspace_id,ticket_id,event_index,kind,author,at,status,heading,body) VALUES ('workspace-1','ticket-1',0,'review','reviewer','2026-08-11T00:00:00Z','approve','Review','legacy evidence')",[]).unwrap(); connection.execute("INSERT INTO typed_ticket_events (workspace_id,ticket_id,event_index,kind,author,at,status,heading,body) VALUES ('workspace-1','ticket-1',0,'review','reviewer','2026-08-11T00:00:00Z','approve','Review','legacy evidence')",[]).unwrap();
connection.execute("INSERT INTO typed_ticket_event_attributes (workspace_id,ticket_id,event_index,key,value) VALUES ('workspace-1','ticket-1',0,'result','approve')",[]).unwrap(); connection.execute("INSERT INTO typed_ticket_event_attributes (workspace_id,ticket_id,event_index,key,value) VALUES ('workspace-1','ticket-1',0,'result','approve')",[]).unwrap();
connection connection
.execute("DELETE FROM ticket_schema_migrations WHERE version=3", []) .execute("DELETE FROM ticket_schema_migrations WHERE version>=3", [])
.unwrap(); .unwrap();
migrate_sqlite_ticket_schema(&connection).unwrap(); migrate_sqlite_ticket_schema(&connection).unwrap();
let (kind,status,heading,body):(String,Option<String>,Option<String>,Option<String>)=connection.query_row("SELECT kind,status,heading,body FROM typed_ticket_events WHERE workspace_id='workspace-1' AND ticket_id='ticket-1' AND event_index=0",[],|row|Ok((row.get(0)?,row.get(1)?,row.get(2)?,row.get(3)?))).unwrap(); let (kind,status,heading,body):(String,Option<String>,Option<String>,Option<String>)=connection.query_row("SELECT kind,status,heading,body FROM typed_ticket_events WHERE workspace_id='workspace-1' AND ticket_id='ticket-1' AND event_index=0",[],|row|Ok((row.get(0)?,row.get(1)?,row.get(2)?,row.get(3)?))).unwrap();
@@ -1129,6 +1157,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(), 3); assert_eq!(load_applied_migrations(&connection).unwrap().len(), 4);
} }
} }
+185 -61
View File
@@ -16,8 +16,9 @@ use crate::{
NewTicket, NewTicketEvent, NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord,
Result as TicketResult, Ticket, TicketBackend, TicketBodyReplacement, TicketDoctorDiagnostic, Result as TicketResult, Ticket, TicketBackend, TicketBodyReplacement, TicketDoctorDiagnostic,
TicketDoctorReport, TicketDoctorSeverity, TicketError, TicketEventKind, TicketIdOrSlug, TicketDoctorReport, TicketDoctorSeverity, TicketError, TicketEventKind, TicketIdOrSlug,
TicketIntakeSummary, TicketListState, TicketRef, TicketRelation, TicketRelationKind, TicketIntakeSummary, TicketListState, TicketMarkReady, TicketRef, TicketRelation,
TicketRelationView, TicketStateChange, TicketSummary, TicketWorkflowState, default_author, TicketRelationKind, TicketRelationView, TicketStateChange, TicketSummary, TicketWorkflowState,
default_author,
}; };
const DEFAULT_LIST_LIMIT: usize = 50; const DEFAULT_LIST_LIMIT: usize = 50;
@@ -42,7 +43,7 @@ pub const TICKET_BASE_TOOL_NAMES: [&str; 14] = [
"TicketPlan", "TicketPlan",
"TicketDecision", "TicketDecision",
"TicketImplementationReport", "TicketImplementationReport",
"TicketIntakeReady", "TicketMarkReady",
"TicketQueue", "TicketQueue",
"TicketWorkflowState", "TicketWorkflowState",
"TicketClose", "TicketClose",
@@ -68,7 +69,7 @@ pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 5] = [
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; 19] = [ pub const TICKET_TOOL_NAMES: [&str; 20] = [
"TicketCreate", "TicketCreate",
"TicketEditItem", "TicketEditItem",
"QueryTicket", "QueryTicket",
@@ -77,6 +78,7 @@ pub const TICKET_TOOL_NAMES: [&str; 19] = [
"TicketPlan", "TicketPlan",
"TicketDecision", "TicketDecision",
"TicketImplementationReport", "TicketImplementationReport",
"TicketMarkReady",
"TicketIntakeReady", "TicketIntakeReady",
"TicketQueue", "TicketQueue",
"TicketWorkflowState", "TicketWorkflowState",
@@ -99,13 +101,14 @@ pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 6] = [
"TicketOrchestrationPlanQuery", "TicketOrchestrationPlanQuery",
]; ];
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 13] = [ pub const TICKET_MUTATING_TOOL_NAMES: [&str; 14] = [
"TicketCreate", "TicketCreate",
"TicketEditItem", "TicketEditItem",
"TicketComment", "TicketComment",
"TicketPlan", "TicketPlan",
"TicketDecision", "TicketDecision",
"TicketImplementationReport", "TicketImplementationReport",
"TicketMarkReady",
"TicketIntakeReady", "TicketIntakeReady",
"TicketQueue", "TicketQueue",
"TicketWorkflowState", "TicketWorkflowState",
@@ -132,9 +135,12 @@ const PLAN_DESCRIPTION: &str = "Append a typed Ticket plan event. `body` is Mark
const DECISION_DESCRIPTION: &str = "Append a typed Ticket decision event. `body` is Markdown."; const DECISION_DESCRIPTION: &str = "Append a typed Ticket decision event. `body` is Markdown.";
const IMPLEMENTATION_REPORT_DESCRIPTION: &str = const IMPLEMENTATION_REPORT_DESCRIPTION: &str =
"Append a typed Ticket implementation_report event. `body` is Markdown."; "Append a typed Ticket implementation_report event. `body` is Markdown.";
const INTAKE_READY_DESCRIPTION: &str = "Mark an existing Ticket planning lane ready through the typed \ const MARK_READY_DESCRIPTION: &str = "Mark a planning Ticket ready through the typed Ticket backend. \
Ticket backend. The tool appends a bounded `intake_summary`, appends a typed `state_changed` event \ The backend atomically validates and normalizes the persisted repository/ref target, records one typed \
for `state`, and transitions state to `ready`."; 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.";
const QUEUE_DESCRIPTION: &str = "Queue a ready Ticket for Orchestrator routing through the typed \ const QUEUE_DESCRIPTION: &str = "Queue a ready Ticket for Orchestrator routing through the typed \
Ticket backend. The backend performs the gated ready -> queued transition, records queued_by/queued_at, \ Ticket backend. The backend performs the gated ready -> queued transition, records queued_by/queued_at, \
and rejects unresolved blocking relations."; and rejects unresolved blocking relations.";
@@ -174,6 +180,7 @@ fn base_tool_description(name: &str) -> &'static str {
"TicketPlan" => PLAN_DESCRIPTION, "TicketPlan" => PLAN_DESCRIPTION,
"TicketDecision" => DECISION_DESCRIPTION, "TicketDecision" => DECISION_DESCRIPTION,
"TicketImplementationReport" => IMPLEMENTATION_REPORT_DESCRIPTION, "TicketImplementationReport" => IMPLEMENTATION_REPORT_DESCRIPTION,
"TicketMarkReady" => MARK_READY_DESCRIPTION,
"TicketIntakeReady" => INTAKE_READY_DESCRIPTION, "TicketIntakeReady" => INTAKE_READY_DESCRIPTION,
"TicketQueue" => QUEUE_DESCRIPTION, "TicketQueue" => QUEUE_DESCRIPTION,
"TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION, "TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION,
@@ -305,13 +312,8 @@ impl TicketBackend for TicketToolBackend {
self.backend.set_workflow_state(id, change) self.backend.set_workflow_state(id, change)
} }
fn mark_intake_ready( fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> TicketResult<Ticket> {
&self, self.backend.mark_ready(id, request)
id: TicketIdOrSlug,
summary: TicketIntakeSummary,
change: TicketStateChange,
) -> TicketResult<()> {
self.backend.mark_intake_ready(id, summary, change)
} }
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> TicketResult<()> { fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> TicketResult<()> {
@@ -558,18 +560,24 @@ struct TicketThreadEventParams {
body: String, body: String,
} }
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketMarkReadyParams {
/// Ticket id.
ticket: String,
/// Optional reason attached to the state_changed event.
#[serde(default)]
reason: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketIntakeReadyParams { struct TicketIntakeReadyParams {
/// Ticket id. /// Ticket id.
ticket: String, ticket: String,
/// Concise bounded intake summary to append as a typed intake_summary event. /// Concise bounded intake summary appended before the ready transition.
intake_summary: String, intake_summary: String,
/// Reason attached to the state_changed event. Defaults to `planning_ready`. /// Optional reason attached to the state_changed event.
#[serde(default)] #[serde(default)]
reason: Option<String>, reason: Option<String>,
/// Optional state_changed body. If omitted, a concise default is used.
#[serde(default)]
state_change_body: Option<String>,
} }
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
@@ -836,6 +844,11 @@ struct TicketImplementationReportTool {
backend: TicketToolBackend, backend: TicketToolBackend,
} }
#[derive(Clone)]
struct TicketMarkReadyTool {
backend: TicketToolBackend,
}
#[derive(Clone)] #[derive(Clone)]
struct TicketIntakeReadyTool { struct TicketIntakeReadyTool {
backend: TicketToolBackend, backend: TicketToolBackend,
@@ -899,6 +912,23 @@ impl Tool for TicketCreateTool {
_ctx: llm_engine::tool::ToolExecutionContext, _ctx: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let params: TicketCreateParams = parse_input("TicketCreate", input_json)?; let params: TicketCreateParams = parse_input("TicketCreate", input_json)?;
if params
.state
.is_some_and(|state| !matches!(state.into_state(), TicketWorkflowState::Planning))
{
return Err(backend_error(
"TicketCreate",
TicketError::InvalidWorkflowTransition {
from: "creation".to_owned(),
to: params
.state
.expect("checked non-planning state")
.into_state()
.as_str()
.to_owned(),
},
));
}
let mut input = NewTicket::new(params.title); let mut input = NewTicket::new(params.title);
if let Some(body) = params.body { if let Some(body) = params.body {
input.body = MarkdownText::new(body); input.body = MarkdownText::new(body);
@@ -1114,41 +1144,68 @@ impl_ticket_thread_event_tool!(
TicketEventKind::ImplementationReport TicketEventKind::ImplementationReport
); );
#[async_trait]
impl Tool for TicketMarkReadyTool {
async fn execute(
&self,
input_json: &str,
ctx: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: TicketMarkReadyParams = parse_input("TicketMarkReady", input_json)?;
let ticket = self
.backend
.mark_ready(
TicketIdOrSlug::Query(params.ticket.clone()),
TicketMarkReady {
operation_key: format!("ticket-mark-ready:{}", ctx.call_id),
reason: params.reason,
author: None,
intake_summary: None,
},
)
.map_err(|error| backend_error("TicketMarkReady", error))?;
Ok(json_output(
format!("Marked ticket {} state ready", params.ticket),
json!({
"ticket": ticket.meta.id,
"state": ticket.meta.workflow_state.as_str(),
"repository_id": ticket.meta.repository_id,
"ref_selector": ticket.meta.ref_selector,
"ok": true
}),
))
}
}
#[async_trait] #[async_trait]
impl Tool for TicketIntakeReadyTool { impl Tool for TicketIntakeReadyTool {
async fn execute( async fn execute(
&self, &self,
input_json: &str, input_json: &str,
_ctx: llm_engine::tool::ToolExecutionContext, ctx: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let params: TicketIntakeReadyParams = parse_input("TicketIntakeReady", input_json)?; let params: TicketIntakeReadyParams = parse_input("TicketIntakeReady", input_json)?;
let from = TicketWorkflowState::Planning; let ticket = self
let reason = params .backend
.reason .mark_ready(
.unwrap_or_else(|| "planning_ready".to_string());
let body = params.state_change_body.unwrap_or_else(|| {
self.backend
.default_intake_ready_state_change_body(from.as_str())
});
let mut summary = TicketIntakeSummary::new(params.intake_summary);
summary.author = None;
let mut change = TicketStateChange::new(
from.as_str(),
TicketWorkflowState::Ready.as_str(),
reason,
body,
);
change.author = None;
self.backend
.mark_intake_ready(
TicketIdOrSlug::Query(params.ticket.clone()), TicketIdOrSlug::Query(params.ticket.clone()),
summary, TicketMarkReady {
change, operation_key: format!("ticket-intake-ready:{}", ctx.call_id),
reason: params.reason,
author: None,
intake_summary: Some(TicketIntakeSummary::new(params.intake_summary)),
},
) )
.map_err(|error| backend_error("TicketIntakeReady", error))?; .map_err(|error| backend_error("TicketIntakeReady", error))?;
Ok(json_output( Ok(json_output(
format!("Marked ticket {} state ready", params.ticket), format!("Marked ticket {} state ready after intake", params.ticket),
json!({ "ticket": params.ticket, "state": "ready", "ok": true }), json!({
"ticket": ticket.meta.id,
"state": ticket.meta.workflow_state.as_str(),
"repository_id": ticket.meta.repository_id,
"ref_selector": ticket.meta.ref_selector,
"ok": true
}),
)) ))
} }
} }
@@ -1726,6 +1783,7 @@ fn input_schema(name: &str) -> Value {
"TicketComment" | "TicketPlan" | "TicketDecision" | "TicketImplementationReport" => { "TicketComment" | "TicketPlan" | "TicketDecision" | "TicketImplementationReport" => {
serde_json::to_value(schemars::schema_for!(TicketThreadEventParams)) serde_json::to_value(schemars::schema_for!(TicketThreadEventParams))
} }
"TicketMarkReady" => serde_json::to_value(schemars::schema_for!(TicketMarkReadyParams)),
"TicketIntakeReady" => serde_json::to_value(schemars::schema_for!(TicketIntakeReadyParams)), "TicketIntakeReady" => serde_json::to_value(schemars::schema_for!(TicketIntakeReadyParams)),
"TicketQueue" => serde_json::to_value(schemars::schema_for!(TicketQueueParams)), "TicketQueue" => serde_json::to_value(schemars::schema_for!(TicketQueueParams)),
"TicketWorkflowState" => { "TicketWorkflowState" => {
@@ -1774,6 +1832,7 @@ impl_from_backend!(TicketCommentTool);
impl_from_backend!(TicketPlanTool); impl_from_backend!(TicketPlanTool);
impl_from_backend!(TicketDecisionTool); impl_from_backend!(TicketDecisionTool);
impl_from_backend!(TicketImplementationReportTool); impl_from_backend!(TicketImplementationReportTool);
impl_from_backend!(TicketMarkReadyTool);
impl_from_backend!(TicketIntakeReadyTool); impl_from_backend!(TicketIntakeReadyTool);
impl_from_backend!(TicketQueueTool); impl_from_backend!(TicketQueueTool);
impl_from_backend!(TicketWorkflowStateTool); impl_from_backend!(TicketWorkflowStateTool);
@@ -1801,6 +1860,7 @@ pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition
"TicketImplementationReport", "TicketImplementationReport",
backend.clone(), backend.clone(),
), ),
tool_definition::<TicketMarkReadyTool>("TicketMarkReady", backend.clone()),
tool_definition::<TicketIntakeReadyTool>("TicketIntakeReady", backend.clone()), tool_definition::<TicketIntakeReadyTool>("TicketIntakeReady", backend.clone()),
tool_definition::<TicketQueueTool>("TicketQueue", backend.clone()), tool_definition::<TicketQueueTool>("TicketQueue", backend.clone()),
tool_definition::<TicketWorkflowStateTool>("TicketWorkflowState", backend.clone()), tool_definition::<TicketWorkflowStateTool>("TicketWorkflowState", backend.clone()),
@@ -1826,8 +1886,26 @@ mod tests {
use super::*; use super::*;
use tempfile::TempDir; use tempfile::TempDir;
#[derive(Debug)]
struct TestTargetAuthority;
impl crate::TicketTargetAuthority for TestTargetAuthority {
fn resolve_target(
&self,
_workspace_id: &str,
repository_id: Option<&str>,
ref_selector: Option<&str>,
) -> crate::Result<crate::ResolvedTicketTarget> {
Ok(crate::ResolvedTicketTarget {
repository_id: repository_id.unwrap_or("main").to_owned(),
ref_selector: ref_selector.unwrap_or("develop").to_owned(),
})
}
}
fn backend(temp: &TempDir) -> LocalTicketBackend { fn backend(temp: &TempDir) -> LocalTicketBackend {
LocalTicketBackend::new(temp.path().join("tickets")) LocalTicketBackend::new(temp.path().join("tickets"))
.with_target_authority(Arc::new(TestTargetAuthority))
} }
fn tool(definition: ToolDefinition) -> Arc<dyn Tool> { fn tool(definition: ToolDefinition) -> Arc<dyn Tool> {
@@ -1877,6 +1955,7 @@ mod tests {
"TicketPlan", "TicketPlan",
"TicketDecision", "TicketDecision",
"TicketImplementationReport", "TicketImplementationReport",
"TicketMarkReady",
"TicketIntakeReady", "TicketIntakeReady",
"TicketQueue", "TicketQueue",
"TicketWorkflowState", "TicketWorkflowState",
@@ -2460,16 +2539,17 @@ mod tests {
async fn ticket_workflow_tools_mark_ready_and_transition_state() { async fn ticket_workflow_tools_mark_ready_and_transition_state() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
let backend = backend(&temp); let backend = backend(&temp);
let created = backend.create(NewTicket::new("Workflow Tool")).unwrap(); let mut input = NewTicket::new("Workflow Tool");
let intake_ready = tool_by_name(backend.clone(), "TicketIntakeReady"); input.repository_id = Some("main".to_owned());
let created = backend.create(input).unwrap();
let intake_ready = tool_by_name(backend.clone(), "TicketMarkReady");
let workflow = tool_by_name(backend.clone(), "TicketWorkflowState"); let workflow = tool_by_name(backend.clone(), "TicketWorkflowState");
intake_ready intake_ready
.execute( .execute(
&json!({ &json!({
"ticket": created.id.clone(), "ticket": created.id.clone(),
"intake_summary": "Requirements accepted; implementation can be queued.", "reason": "requirements accepted"
"author": "intake-worker"
}) })
.to_string(), .to_string(),
Default::default(), Default::default(),
@@ -2512,12 +2592,12 @@ mod tests {
let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap(); let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap();
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Done); assert_eq!(record.meta.workflow_state, TicketWorkflowState::Done);
assert!( assert!(record.events.iter().any(|event| {
record event.kind == TicketEventKind::StateChanged
.events && event.from.as_deref() == Some("planning")
.iter() && event.to.as_deref() == Some("ready")
.any(|event| event.kind == TicketEventKind::IntakeSummary) && event.attributes.contains_key("request_fingerprint")
); }));
let transitions = record let transitions = record
.events .events
.iter() .iter()
@@ -2538,6 +2618,38 @@ mod tests {
); );
} }
#[tokio::test]
async fn ticket_intake_ready_records_summary_with_validated_target() {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let mut input = NewTicket::new("Intake Workflow");
input.repository_id = Some("main".to_owned());
let created = backend.create(input).unwrap();
tool_by_name(backend.clone(), "TicketIntakeReady")
.execute(
&json!({
"ticket": created.id.clone(),
"intake_summary": "Requirements and target are accepted.",
"reason": "intake_complete"
})
.to_string(),
Default::default(),
)
.await
.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
.events
.iter()
.filter(|event| event.kind == TicketEventKind::IntakeSummary)
.count(),
1
);
}
#[tokio::test] #[tokio::test]
async fn ticket_workflow_tool_allows_return_to_planning_from_ready_and_queued() { async fn ticket_workflow_tool_allows_return_to_planning_from_ready_and_queued() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
@@ -2661,7 +2773,11 @@ mod tests {
) )
.await .await
.unwrap_err(); .unwrap_err();
assert!(ready_error.to_string().contains("not allowed")); assert!(
ready_error
.to_string()
.contains("invalid ticket workflow transition")
);
let mut done_input = NewTicket::new("Backward Bypass"); let mut done_input = NewTicket::new("Backward Bypass");
done_input.workflow_state = Some(TicketWorkflowState::Done); done_input.workflow_state = Some(TicketWorkflowState::Done);
@@ -2680,7 +2796,11 @@ mod tests {
) )
.await .await
.unwrap_err(); .unwrap_err();
assert!(backward_error.to_string().contains("not allowed")); assert!(
backward_error
.to_string()
.contains("invalid ticket workflow transition")
);
let mut queued_input = NewTicket::new("Skip Bypass"); let mut queued_input = NewTicket::new("Skip Bypass");
queued_input.workflow_state = Some(TicketWorkflowState::Queued); queued_input.workflow_state = Some(TicketWorkflowState::Queued);
@@ -2699,17 +2819,21 @@ mod tests {
) )
.await .await
.unwrap_err(); .unwrap_err();
assert!(skip_error.to_string().contains("not allowed")); assert!(
skip_error
.to_string()
.contains("invalid ticket workflow transition")
);
} }
#[tokio::test] #[tokio::test]
async fn ticket_intake_ready_tool_rejects_non_planning_ticket() { async fn ticket_mark_ready_tool_rejects_non_planning_ticket() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
let backend = backend(&temp); let backend = backend(&temp);
let mut input = NewTicket::new("Already Ready"); let mut input = NewTicket::new("Already Ready");
input.workflow_state = Some(TicketWorkflowState::Ready); input.workflow_state = Some(TicketWorkflowState::Ready);
let created = backend.create(input).unwrap(); let created = backend.create(input).unwrap();
let intake_ready = tool_by_name(backend.clone(), "TicketIntakeReady"); let intake_ready = tool_by_name(backend.clone(), "TicketMarkReady");
let error = intake_ready let error = intake_ready
.execute( .execute(
@@ -2723,7 +2847,7 @@ mod tests {
.await .await
.unwrap_err(); .unwrap_err();
assert!(error.to_string().contains("state changed concurrently")); assert!(error.to_string().contains("stale ticket workflow state"));
let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap(); let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap();
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Ready); assert_eq!(record.meta.workflow_state, TicketWorkflowState::Ready);
assert!(!record.events.iter().any(|event| { assert!(!record.events.iter().any(|event| {
@@ -2867,7 +2991,7 @@ mod tests {
"TicketPlan", "TicketPlan",
"TicketDecision", "TicketDecision",
"TicketImplementationReport", "TicketImplementationReport",
"TicketIntakeReady", "TicketMarkReady",
"TicketQueue", "TicketQueue",
"TicketRelationRecord", "TicketRelationRecord",
"TicketOrchestrationPlanRecord", "TicketOrchestrationPlanRecord",
+15
View File
@@ -769,6 +769,21 @@ where
), ),
); );
} }
if feature_config.merge_request.any() {
let workspace_client = worker.workspace_client_handle();
if !workspace_client.is_available() || workspace_client.workspace_id().is_none() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Merge Request tools require Backend Workspace API authority",
));
}
feature_registry.add_module(
crate::feature::builtin::merge_request::MergeRequestFeature::new(
workspace_client,
feature_config.merge_request,
),
);
}
if feature_config.manage_workdir.enabled { if feature_config.manage_workdir.enabled {
// Workdir lifecycle is Workspace control-plane authority. The Worker // Workdir lifecycle is Workspace control-plane authority. The Worker
// receives only the injected WorkspaceClient and never Runtime URLs, // receives only the injected WorkspaceClient and never Runtime URLs,
@@ -1,19 +1,41 @@
use crate::feature::ToolDefinition; use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureInstructionContribution,
FeatureInstructionDeclaration, FeatureInstructionId, FeatureModule, ToolContribution,
ToolDeclaration, ToolDefinition,
};
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod}; use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
use async_trait::async_trait; use async_trait::async_trait;
use llm_engine::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput}; use llm_engine::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
use manifest::MergeRequestFeatureConfig;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
use std::sync::Arc; use std::sync::Arc;
pub const MERGE_REQUEST_COMMON_TOOL_NAMES: &[&str] = &[ pub const FEATURE_ID: &str = "merge_request";
"MergeRequestShow", const FEATURE_NAME: &str = "Merge Request tools";
"MergeRequestReadinessCheck", const FEATURE_DESCRIPTION: &str =
"MergeRequestOpen", "Operation-specific Merge Request workflow tools over Workspace authority.";
"MergeRequestComplete", const FEATURE_INSTRUCTION_ID: &str = "merge_request.workflow";
pub const FEATURE_PROMPT_REF: &str = "common.merge_request";
fn workflow_instruction() -> FeatureInstructionDeclaration {
FeatureInstructionDeclaration::new(
FeatureInstructionId::builtin(FEATURE_INSTRUCTION_ID),
FEATURE_PROMPT_REF,
"Operation-specific Merge Request workflow guidance",
)
.expect("static Merge Request workflow instruction declaration is valid")
}
const ALL_KINDS: [Kind; 5] = [
Kind::Show,
Kind::Open,
Kind::Review,
Kind::Readiness,
Kind::Complete,
]; ];
pub const MERGE_REQUEST_REVIEW_TOOL_NAME: &str = "MergeRequestReviewSubmit";
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
enum Kind { enum Kind {
Show, Show,
@@ -89,13 +111,23 @@ struct ReviewFindingInput {
body: String, body: String,
} }
impl Kind { impl Kind {
fn enabled(self, config: MergeRequestFeatureConfig) -> bool {
match self {
Self::Show => config.show,
Self::Open => config.open,
Self::Review => config.review,
Self::Readiness => config.readiness_check,
Self::Complete => config.complete,
}
}
fn name(self) -> &'static str { fn name(self) -> &'static str {
match self { match self {
Self::Show => "MergeRequestShow", Self::Show => "MergeRequestShow",
Self::Readiness => "MergeRequestReadinessCheck", Self::Readiness => "MergeRequestReadinessCheck",
Self::Open => "MergeRequestOpen", Self::Open => "MergeRequestOpen",
Self::Complete => "MergeRequestComplete", Self::Complete => "MergeRequestComplete",
Self::Review => "MergeRequestReviewSubmit", Self::Review => "MergeRequestReview",
} }
} }
fn schema(self) -> serde_json::Value { fn schema(self) -> serde_json::Value {
@@ -218,24 +250,53 @@ fn definition(client: Arc<dyn WorkspaceClient>, kind: Kind) -> ToolDefinition {
) )
}) })
} }
pub fn common_tools(c: Arc<dyn WorkspaceClient>) -> Vec<ToolDefinition> { pub struct MergeRequestFeature {
vec![ client: Arc<dyn WorkspaceClient>,
definition(c.clone(), Kind::Show), config: MergeRequestFeatureConfig,
definition(c.clone(), Kind::Readiness),
definition(c.clone(), Kind::Open),
definition(c, Kind::Complete),
]
} }
pub fn reviewer_tools(c: Arc<dyn WorkspaceClient>) -> Vec<ToolDefinition> {
if c.reviewer_context().is_some() { impl MergeRequestFeature {
vec![ pub fn new(client: Arc<dyn WorkspaceClient>, config: MergeRequestFeatureConfig) -> Self {
definition(c.clone(), Kind::Show), Self { client, config }
definition(c, Kind::Review), }
]
} else { fn kinds(&self) -> impl Iterator<Item = Kind> + '_ {
vec![] ALL_KINDS
.into_iter()
.filter(|kind| kind.enabled(self.config))
} }
} }
impl FeatureModule for MergeRequestFeature {
fn descriptor(&self) -> FeatureDescriptor {
let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME)
.with_description(FEATURE_DESCRIPTION);
if self.config.any() {
descriptor = descriptor.with_instruction(workflow_instruction());
}
for kind in self.kinds() {
descriptor = descriptor.with_tool(ToolDeclaration::new(
kind.name(),
description(kind.name()).unwrap_or("Merge Request operation."),
));
}
descriptor
}
fn install(&self, ctx: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
if self.config.any() {
ctx.instructions()
.register(FeatureInstructionContribution::new(workflow_instruction()))?;
}
let mut tools = ctx.tools();
for kind in self.kinds() {
let definition = definition(self.client.clone(), kind);
tools.register(ToolContribution::new(kind.name(), definition))?;
}
Ok(())
}
}
pub fn description(n: &str) -> Option<&'static str> { pub fn description(n: &str) -> Option<&'static str> {
match n { match n {
"MergeRequestShow" => Some("Read the selector-based Merge Request and append-only thread."), "MergeRequestShow" => Some("Read the selector-based Merge Request and append-only thread."),
@@ -248,7 +309,7 @@ pub fn description(n: &str) -> Option<&'static str> {
"MergeRequestComplete" => { "MergeRequestComplete" => {
Some("Complete using an approved review event and final target-ref evidence.") Some("Complete using an approved review event and final target-ref evidence.")
} }
"MergeRequestReviewSubmit" => { "MergeRequestReview" => {
Some("Submit the injected Reviewer capability result for its captured subject ref.") Some("Submit the injected Reviewer capability result for its captured subject ref.")
} }
_ => None, _ => None,
@@ -257,6 +318,72 @@ pub fn description(n: &str) -> Option<&'static str> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::feature::FeatureRegistryBuilder;
use crate::hook::HookRegistryBuilder;
use crate::worker::TestWorkspaceHttpClient;
fn install(config: MergeRequestFeatureConfig) -> (Vec<String>, Vec<String>) {
let client: Arc<dyn WorkspaceClient> =
Arc::new(TestWorkspaceHttpClient::new("workspace", "http://unused"));
let mut pending_tools = Vec::new();
let mut hook_builder = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(MergeRequestFeature::new(client, config))
.install_into_pending(&mut pending_tools, &mut hook_builder);
assert!(!report.has_errors(), "{}", report.error_message());
(
report.installed_tool_names(),
report
.installed_instruction_contributions()
.into_iter()
.map(|instruction| instruction.prompt_ref)
.collect(),
)
}
fn tool_names(config: MergeRequestFeatureConfig) -> Vec<String> {
install(config).0
}
#[test]
fn flags_define_the_exact_registered_tool_surface() {
let coder = MergeRequestFeatureConfig {
show: true,
open: true,
..Default::default()
};
assert_eq!(tool_names(coder), ["MergeRequestShow", "MergeRequestOpen"]);
let reviewer = MergeRequestFeatureConfig {
show: true,
review: true,
..Default::default()
};
assert_eq!(
tool_names(reviewer),
["MergeRequestShow", "MergeRequestReview"]
);
let orchestrator = MergeRequestFeatureConfig {
show: true,
readiness_check: true,
complete: true,
..Default::default()
};
assert_eq!(
tool_names(orchestrator),
[
"MergeRequestShow",
"MergeRequestReadinessCheck",
"MergeRequestComplete"
]
);
assert_eq!(install(coder).1, [FEATURE_PROMPT_REF]);
let unspecified = install(MergeRequestFeatureConfig::default());
assert!(unspecified.0.is_empty());
assert!(unspecified.1.is_empty());
}
#[test] #[test]
fn schemas_hide_revision_and_commit_authority() { fn schemas_hide_revision_and_commit_authority() {
let schemas = [ let schemas = [
@@ -276,6 +403,5 @@ mod tests {
assert!(!j.contains(banned), "{banned} in {j}") assert!(!j.contains(banned), "{banned} in {j}")
} }
} }
assert!(!MERGE_REQUEST_COMMON_TOOL_NAMES.contains(&"MergeRequestRequestReview"));
} }
} }
+18 -51
View File
@@ -24,7 +24,6 @@ use ticket::{
tool::{TICKET_TOOL_NAMES, TicketToolBackend, ticket_tool_description, ticket_tools}, tool::{TICKET_TOOL_NAMES, TicketToolBackend, ticket_tool_description, ticket_tools},
}; };
use super::merge_request;
use crate::feature::{ use crate::feature::{
FeatureDescriptor, FeatureDiagnostic, FeatureInstallContext, FeatureInstallError, FeatureDescriptor, FeatureDiagnostic, FeatureInstallContext, FeatureInstallError,
FeatureInstructionContribution, FeatureInstructionDeclaration, FeatureInstructionId, FeatureInstructionContribution, FeatureInstructionDeclaration, FeatureInstructionId,
@@ -377,6 +376,7 @@ const READ_ONLY_TOOL_NAMES: &[&str] = &["QueryTicket", "ShowTicket"];
const AUTHORING_TOOL_NAMES: &[&str] = &[ const AUTHORING_TOOL_NAMES: &[&str] = &[
"TicketCreate", "TicketCreate",
"TicketEditItem", "TicketEditItem",
"TicketMarkReady",
"TicketQueue", "TicketQueue",
"TicketClose", "TicketClose",
"TicketRelationRecord", "TicketRelationRecord",
@@ -394,6 +394,7 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
"QueryTicket", "QueryTicket",
"ShowTicket", "ShowTicket",
"TicketComment", "TicketComment",
"TicketMarkReady",
"TicketQueue", "TicketQueue",
"TicketClose", "TicketClose",
"TicketRelationRecord", "TicketRelationRecord",
@@ -584,22 +585,6 @@ impl FeatureModule for TicketFeature {
ticket_tool_description(name, self.record_language.as_deref()), ticket_tool_description(name, self.record_language.as_deref()),
)); ));
} }
if let TicketFeatureBackend::WorkspaceClient(client) = &self.backend {
let names: Vec<&str> = if client.reviewer_context().is_some() {
vec![
"MergeRequestShow",
merge_request::MERGE_REQUEST_REVIEW_TOOL_NAME,
]
} else {
merge_request::MERGE_REQUEST_COMMON_TOOL_NAMES.to_vec()
};
for name in names {
descriptor = descriptor.with_tool(ToolDeclaration::new(
name,
merge_request::description(name).unwrap_or("Merge Request operation."),
));
}
}
descriptor descriptor
} }
@@ -657,17 +642,6 @@ impl FeatureModule for TicketFeature {
}; };
tools.register(ToolContribution::new(name, definition))?; tools.register(ToolContribution::new(name, definition))?;
} }
if let TicketFeatureBackend::WorkspaceClient(client) = &self.backend {
let definitions = if client.reviewer_context().is_some() {
merge_request::reviewer_tools(client.clone())
} else {
merge_request::common_tools(client.clone())
};
for definition in definitions {
let (meta, _) = definition();
tools.register(ToolContribution::new(meta.name.clone(), definition))?;
}
}
Ok(()) Ok(())
} }
} }
@@ -890,16 +864,15 @@ impl WorkspaceHttpTicketBackend {
TicketError::Conflict(format!("serialize Ticket workflow change: {error}")) TicketError::Conflict(format!("serialize Ticket workflow change: {error}"))
})?), })?),
), ),
TicketBackendOperation::MarkIntakeReady { TicketBackendOperation::MarkReady { id, request } => Self::request(
id,
summary,
change,
} => Self::request_unit(
client, client,
WorkspaceRequestMethod::Post, WorkspaceRequestMethod::Post,
format!("{base}/{}/intake-ready", Self::ticket_path(&id)), format!("{base}/{}/workflow/mark-ready", Self::ticket_path(&id)),
Some(serde_json::json!({ "summary": summary, "change": change })), Some(serde_json::to_value(request).map_err(|error| {
), TicketError::Conflict(format!("serialize Ticket mark-ready request: {error}"))
})?),
)
.map(TicketBackendOperationResult::Ticket),
TicketBackendOperation::QueueReady { id, .. } => Self::request_unit( TicketBackendOperation::QueueReady { id, .. } => Self::request_unit(
client, client,
WorkspaceRequestMethod::Post, WorkspaceRequestMethod::Post,
@@ -1115,22 +1088,15 @@ impl TicketBackend for WorkspaceHttpTicketBackend {
} }
} }
fn mark_intake_ready( fn mark_ready(
&self, &self,
id: TicketIdOrSlug, id: TicketIdOrSlug,
summary: TicketIntakeSummary, request: ticket::TicketMarkReady,
change: TicketStateChange, ) -> TicketResult<Ticket> {
) -> TicketResult<()> { expect_ticket_result!(
match self.invoke(TicketBackendOperation::MarkIntakeReady { self.invoke(TicketBackendOperation::MarkReady { id, request }),
id, TicketBackendOperationResult::Ticket
summary, )
change,
})? {
TicketBackendOperationResult::Unit => Ok(()),
other => Err(TicketError::Conflict(format!(
"unexpected ticket backend response: {other:?}"
))),
}
} }
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> TicketResult<()> { fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> TicketResult<()> {
@@ -1299,7 +1265,7 @@ mod tests {
assert_eq!(show.name, "ShowTicket"); assert_eq!(show.name, "ShowTicket");
assert!(show.input_schema["properties"]["event_limit"].is_object()); assert!(show.input_schema["properties"]["event_limit"].is_object());
let tool_names = TicketFeatureAccess::workspace_authoring().tool_names(); let tool_names = TicketFeatureAccess::workspace_authoring().tool_names();
assert_eq!(tool_names.len(), 9); assert_eq!(tool_names.len(), 10);
assert!( assert!(
tool_names.len() < 13, tool_names.len() < 13,
"authoring catalog must stay below the prior broad catalog" "authoring catalog must stay below the prior broad catalog"
@@ -1516,6 +1482,7 @@ language = "Japanese"
assert!(installed.iter().any(|tool| *tool == "TicketCreate")); assert!(installed.iter().any(|tool| *tool == "TicketCreate"));
assert!(installed.iter().any(|tool| *tool == "TicketEditItem")); assert!(installed.iter().any(|tool| *tool == "TicketEditItem"));
assert!(installed.iter().any(|tool| *tool == "TicketQueue")); assert!(installed.iter().any(|tool| *tool == "TicketQueue"));
assert!(installed.iter().any(|tool| *tool == "TicketMarkReady"));
assert!(!installed.iter().any(|tool| *tool == "TicketIntakeReady")); assert!(!installed.iter().any(|tool| *tool == "TicketIntakeReady"));
assert!(!installed.iter().any(|tool| *tool == "TicketWorkflowState")); assert!(!installed.iter().any(|tool| *tool == "TicketWorkflowState"));
assert!( assert!(
+22
View File
@@ -5890,6 +5890,13 @@ model_id = "claude-sonnet-4-20250514"
[engine] [engine]
instruction = "saved" instruction = "saved"
[feature.merge_request]
show = true
open = false
review = true
readiness_check = false
complete = false
[[scope.allow]] [[scope.allow]]
target = "/snapshot/workspace" target = "/snapshot/workspace"
permission = "read" permission = "read"
@@ -5912,6 +5919,13 @@ model_id = "claude-sonnet-4-20250514"
[engine] [engine]
instruction = "current" instruction = "current"
[feature.merge_request]
show = true
open = true
review = true
readiness_check = true
complete = true
[[scope.allow]] [[scope.allow]]
target = "/current/workspace" target = "/current/workspace"
permission = "write" permission = "write"
@@ -5931,6 +5945,14 @@ permission = "write"
.unwrap(); .unwrap();
assert_eq!(restored.engine.instruction, "saved"); assert_eq!(restored.engine.instruction, "saved");
assert_eq!(
restored.feature.merge_request,
manifest::MergeRequestFeatureConfig {
show: true,
review: true,
..Default::default()
}
);
assert_eq!(restored.scope.allow.len(), 1); assert_eq!(restored.scope.allow.len(), 1);
assert_eq!( assert_eq!(
restored.scope.allow[0].target, restored.scope.allow[0].target,
+627 -67
View File
@@ -6,9 +6,11 @@ use merge_request::{
ReviewDecision, ReviewDecision,
}; };
use project_record::{allocate_record_id, unix_epoch_millis_now}; use project_record::{allocate_record_id, unix_epoch_millis_now};
use rusqlite::{params_from_iter, types::Value as SqlValue};
use ticket::{ use ticket::{
SqliteTicketBackend, TicketBackend, TicketEvent, TicketIdOrSlug, TicketWorkspaceActionPriority, SqliteTicketBackend, SqliteTicketListCursor, SqliteTicketListItem, SqliteTicketListPageQuery,
TicketBackend, TicketEvent, TicketIdOrSlug, TicketWorkflowState, TicketWorkspaceActionPriority,
project_ticket_workspace_item, project_ticket_workspace_item,
}; };
@@ -17,8 +19,9 @@ use crate::records::{
ObjectiveQueryItem, ObjectiveQueryRequest, ObjectiveQueryResponse, ObjectiveResourceSummary, ObjectiveQueryItem, ObjectiveQueryRequest, ObjectiveQueryResponse, ObjectiveResourceSummary,
ObjectiveShowRequest, ObjectiveSummary, ProjectRecordList, QueryPage, TicketAssignmentSummary, ObjectiveShowRequest, ObjectiveSummary, ProjectRecordList, QueryPage, TicketAssignmentSummary,
TicketDetail, TicketEventDetail, TicketEvidenceEvent, TicketEvidenceSummary, TicketDetail, TicketEventDetail, TicketEvidenceEvent, TicketEvidenceSummary,
TicketMergeRequestSummary, TicketQueryItem, TicketQueryRequest, TicketQueryResponse, TicketListPageRequest, TicketMergeRequestSummary, TicketQueryItem, TicketQueryRequest,
TicketShowRequest, TicketSummary, summarize_body, truncate_body, validate_project_id, TicketQueryResponse, TicketShowRequest, TicketSummary, TicketSummaryPage, summarize_body,
truncate_body, validate_project_id,
}; };
use crate::store::{ use crate::store::{
ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord, ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord,
@@ -43,6 +46,7 @@ impl<T> WorkspaceAuthority for T where T: ObjectiveAuthority + TicketAuthority +
pub trait TicketAuthority { pub trait TicketAuthority {
fn list_tickets(&self, limit: usize) -> Result<ProjectRecordList<TicketSummary>>; fn list_tickets(&self, limit: usize) -> Result<ProjectRecordList<TicketSummary>>;
fn list_ticket_page(&self, request: TicketListPageRequest) -> Result<TicketSummaryPage>;
fn query_tickets(&self, query: TicketQueryRequest) -> Result<TicketQueryResponse>; fn query_tickets(&self, query: TicketQueryRequest) -> Result<TicketQueryResponse>;
fn ticket(&self, id: &str) -> Result<TicketDetail>; fn ticket(&self, id: &str) -> Result<TicketDetail>;
fn show_ticket(&self, id: &str, query: TicketShowRequest) -> Result<TicketDetail>; fn show_ticket(&self, id: &str, query: TicketShowRequest) -> Result<TicketDetail>;
@@ -339,11 +343,358 @@ impl SqliteWorkspaceAuthority {
}) })
} }
fn query_ticket_candidate_ids(
&self,
query: &TicketQueryRequest,
sort: TicketQuerySort,
after: Option<&(String, String)>,
limit: usize,
) -> Result<Vec<String>> {
let mut values = vec![SqlValue::Text(self.workspace_id.clone())];
let mut predicates = vec!["t.workspace_id=?1".to_string()];
let mut bind = |value: SqlValue| {
values.push(value);
format!("?{}", values.len())
};
if !query.states.is_empty() {
let states = query
.states
.iter()
.map(|state| bind(SqlValue::Text(state.clone())))
.collect::<Vec<_>>();
predicates.push(format!("t.workflow_state IN ({})", states.join(",")));
}
if let Some(text) = query.query.as_deref().filter(|text| !text.is_empty()) {
let pattern = bind(SqlValue::Text(format!("%{}%", text.to_lowercase())));
predicates.push(format!(
"(lower(t.title) LIKE {p} OR lower(t.body) LIKE {p} OR EXISTS (
SELECT 1 FROM typed_ticket_events e
WHERE e.workspace_id=t.workspace_id AND e.ticket_id=t.ticket_id
AND lower(e.body) LIKE {p}))",
p = pattern
));
}
if let Some(value) = &query.updated_after {
let value = bind(SqlValue::Text(value.clone()));
predicates.push(format!("COALESCE(t.updated_at,'')>{value}"));
}
if let Some(value) = &query.updated_before {
let value = bind(SqlValue::Text(value.clone()));
predicates.push(format!("COALESCE(t.updated_at,'')<{value}"));
}
if let Some(value) = &query.linked_objective_id {
let value = bind(SqlValue::Text(value.clone()));
predicates.push(format!("EXISTS (SELECT 1 FROM objective_ticket_links link WHERE link.workspace_id=t.workspace_id AND link.ticket_id=t.ticket_id AND link.objective_id={value})"));
}
if query.related_ticket_id.is_some() || query.relation_kind.is_some() {
let related = query
.related_ticket_id
.as_ref()
.map(|value| bind(SqlValue::Text(value.clone())));
let kind = query
.relation_kind
.as_ref()
.map(|value| bind(SqlValue::Text(value.clone())));
let related = related
.map(|value| format!("AND ((r.ticket_id=t.ticket_id AND r.target={value}) OR (r.target=t.ticket_id AND r.ticket_id={value}))"))
.unwrap_or_else(|| {
"AND (r.ticket_id=t.ticket_id OR r.target=t.ticket_id)".to_string()
});
let kind = kind
.map(|value| format!("AND r.kind={value}"))
.unwrap_or_default();
predicates.push(format!("EXISTS (SELECT 1 FROM typed_ticket_relations r WHERE r.workspace_id=t.workspace_id {related} {kind})"));
}
let blocker = "EXISTS (SELECT 1 FROM typed_ticket_relations relation
JOIN typed_tickets blocker ON blocker.workspace_id=relation.workspace_id
AND blocker.ticket_id=CASE WHEN relation.ticket_id=t.ticket_id THEN relation.target ELSE relation.ticket_id END
WHERE relation.workspace_id=t.workspace_id
AND ((relation.ticket_id=t.ticket_id AND relation.kind='depends_on')
OR (relation.target=t.ticket_id AND relation.kind='blocks'))
AND blocker.workflow_state NOT IN ('done','closed'))";
let active_blocker = "EXISTS (SELECT 1 FROM typed_ticket_relations relation
JOIN typed_tickets blocker ON blocker.workspace_id=relation.workspace_id
AND blocker.ticket_id=CASE WHEN relation.ticket_id=t.ticket_id THEN relation.target ELSE relation.ticket_id END
WHERE relation.workspace_id=t.workspace_id
AND ((relation.ticket_id=t.ticket_id AND relation.kind='depends_on')
OR (relation.target=t.ticket_id AND relation.kind='blocks'))
AND blocker.workflow_state NOT IN ('queued','inprogress','done','closed'))";
let report_index = "(SELECT max(event.event_index) FROM typed_ticket_events event WHERE event.workspace_id=t.workspace_id AND event.ticket_id=t.ticket_id AND event.kind='implementation_report')";
let edit_index = "(SELECT max(event.event_index) FROM typed_ticket_events event WHERE event.workspace_id=t.workspace_id AND event.ticket_id=t.ticket_id AND event.kind='item_edit')";
let current_report = format!(
"({report_index} IS NOT NULL AND ({edit_index} IS NULL OR {report_index}>={edit_index}))"
);
let merge_request_id = "(SELECT relation.merge_request_id FROM merge_request_ticket_relations relation
JOIN merge_requests request ON request.workspace_id=relation.workspace_id AND request.merge_request_id=relation.merge_request_id
WHERE relation.workspace_id=t.workspace_id AND relation.ticket_id=t.ticket_id
ORDER BY CASE WHEN request.state='open' THEN 0 ELSE 1 END, request.created_at DESC LIMIT 1)";
let review_subject = format!(
"(SELECT json_extract(requested.payload_json,'$.subject_ref')
FROM merge_request_thread_events requested
WHERE requested.workspace_id=t.workspace_id
AND requested.merge_request_id={merge_request_id}
AND requested.kind='review_requested'
ORDER BY requested.sequence DESC LIMIT 1)"
);
let review_decision = format!(
"(SELECT json_extract(event.payload_json,'$.decision')
FROM merge_request_thread_events event
WHERE event.workspace_id=t.workspace_id
AND event.merge_request_id={merge_request_id} AND event.kind='review'
AND json_extract(event.payload_json,'$.subject_ref')={review_subject}
AND NOT EXISTS (SELECT 1 FROM merge_request_thread_events revoked
WHERE revoked.workspace_id=event.workspace_id
AND revoked.merge_request_id=event.merge_request_id
AND revoked.kind='review_revoked'
AND json_extract(revoked.payload_json,'$.review_event_id')=event.event_id)
ORDER BY event.sequence DESC LIMIT 1)"
);
let review_status = format!(
"CASE WHEN {merge_request_id} IS NULL THEN 'none' WHEN {review_decision}='approve' THEN 'approved' WHEN {review_decision}='request_changes' THEN 'request_changes' ELSE 'pending' END"
);
let has_commit = format!(
"({review_subject} IS NOT NULL OR EXISTS (SELECT 1 FROM typed_ticket_event_references reference WHERE reference.workspace_id=t.workspace_id AND reference.ticket_id=t.ticket_id AND reference.kind='commit'))"
);
if !query.event_kinds.is_empty() {
let event_kinds = query
.event_kinds
.iter()
.map(|event_kind| bind(SqlValue::Text(event_kind.clone())))
.collect::<Vec<_>>();
predicates.push(format!("EXISTS (SELECT 1 FROM typed_ticket_events event WHERE event.workspace_id=t.workspace_id AND event.ticket_id=t.ticket_id AND event.kind IN ({}))", event_kinds.join(",")));
}
for evidence in &query.evidence {
predicates.push(match evidence.as_str() {
"implementation_report" => format!("{report_index} IS NOT NULL"),
"implementation_report_after_rescope" => current_report.clone(),
"merge_request" => format!("{merge_request_id} IS NOT NULL"),
"commit" => has_commit.clone(),
"approved_review" => format!("{review_status}='approved'"),
other => {
return Err(Error::InvalidRecordId(format!(
"unsupported evidence filter `{other}`"
)));
}
});
}
if let Some(status) = &query.review_status {
let status = if matches!(status.as_str(), "unresolved_changes" | "changes_requested") {
"request_changes"
} else {
status.as_str()
};
let status = bind(SqlValue::Text(status.to_string()));
predicates.push(format!("{review_status}={status}"));
}
for attention in &query.attention {
predicates.push(match attention.as_str() {
"done_not_closed" => "t.workflow_state='done'".to_string(),
"implementation_report_not_closed" => {
format!("{report_index} IS NOT NULL AND t.workflow_state!='closed'")
}
"report_after_rescope" => current_report.clone(),
"unresolved_review" | "unresolved_changes" => {
format!("{review_status}='request_changes'")
}
"missing_commit" => format!("NOT {has_commit}"),
"blocked" => blocker.to_string(),
"unblocked" => format!("NOT {blocker}"),
"ready" => format!("t.workflow_state='ready' AND NOT {blocker}"),
"awaiting_review" => format!("{review_status}='pending'"),
"stale_after_rescope" => {
format!("{report_index} IS NOT NULL AND NOT {current_report}")
}
"missing_evidence" => format!(
"NOT ({current_report} AND {has_commit} AND {review_status}='approved')"
),
other => {
return Err(Error::InvalidRecordId(format!(
"unsupported attention filter `{other}`"
)));
}
});
}
let rank_expression = match sort {
TicketQuerySort::Priority => format!(
"CASE WHEN t.workflow_state='ready' AND NOT {active_blocker} THEN 0 WHEN t.workflow_state IN ('queued','inprogress') THEN 1 ELSE 2 END"
),
TicketQuerySort::Relevance => {
if let Some(text) = query.query.as_deref().filter(|text| !text.is_empty()) {
let pattern = bind(SqlValue::Text(format!("%{}%", text.to_lowercase())));
format!(
"CASE WHEN lower(t.title) LIKE {pattern} THEN 0 WHEN lower(t.body) LIKE {pattern} THEN 1 WHEN EXISTS (SELECT 1 FROM typed_ticket_events event WHERE event.workspace_id=t.workspace_id AND event.ticket_id=t.ticket_id AND lower(event.body) LIKE {pattern}) THEN 2 ELSE 3 END"
)
} else {
"3".to_string()
}
}
_ => "0".to_string(),
};
if let Some((key, id)) = after {
match sort {
TicketQuerySort::UpdatedDesc => {
let key = bind(SqlValue::Text(key.clone()));
let id = bind(SqlValue::Text(id.clone()));
predicates.push(format!("(COALESCE(t.updated_at,'')<{key} OR (COALESCE(t.updated_at,'')={key} AND t.ticket_id>{id}))"));
}
TicketQuerySort::CreatedDesc => {
let key = bind(SqlValue::Text(key.clone()));
let id = bind(SqlValue::Text(id.clone()));
predicates.push(format!("(COALESCE(t.created_at,'')<{key} OR (COALESCE(t.created_at,'')={key} AND t.ticket_id>{id}))"));
}
TicketQuerySort::Title => {
let key = bind(SqlValue::Text(key.clone()));
let id = bind(SqlValue::Text(id.clone()));
predicates.push(format!(
"(lower(t.title)>{key} OR (lower(t.title)={key} AND t.ticket_id>{id}))"
));
}
TicketQuerySort::Priority | TicketQuerySort::Relevance => {
let (rank, updated_at) = key.split_once('|').unwrap_or(("9", ""));
let rank = bind(SqlValue::Integer(rank.parse::<i64>().unwrap_or(9)));
let updated_at = bind(SqlValue::Text(updated_at.to_string()));
let id = bind(SqlValue::Text(id.clone()));
predicates.push(format!("({rank_expression}>{rank} OR ({rank_expression}={rank} AND (COALESCE(t.updated_at,'')<{updated_at} OR (COALESCE(t.updated_at,'')={updated_at} AND t.ticket_id>{id}))))"));
}
}
}
let order = match sort {
TicketQuerySort::Title => "t.title COLLATE NOCASE ASC, t.ticket_id ASC".to_string(),
TicketQuerySort::CreatedDesc => "t.created_at DESC, t.ticket_id ASC".to_string(),
TicketQuerySort::UpdatedDesc => "t.updated_at DESC, t.ticket_id ASC".to_string(),
TicketQuerySort::Priority | TicketQuerySort::Relevance => {
format!("{rank_expression} ASC, t.updated_at DESC, t.ticket_id ASC")
}
};
let limit = bind(SqlValue::Integer(i64::try_from(limit).unwrap_or(i64::MAX)));
let sql = format!(
"SELECT t.ticket_id FROM typed_tickets t WHERE {} ORDER BY {order} LIMIT {limit}",
predicates.join(" AND ")
);
self.store.with_conn(|connection| {
let mut statement = connection.prepare(&sql)?;
let rows = statement.query_map(params_from_iter(values.iter()), |row| row.get(0))?;
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
})
}
fn query_objective_candidate_ids(
&self,
query: &ObjectiveQueryRequest,
sort: ObjectiveQuerySort,
after: Option<&(String, String)>,
limit: usize,
) -> Result<Vec<String>> {
let mut values = vec![SqlValue::Text(self.workspace_id.clone())];
let mut predicates = vec!["o.workspace_id=?1".to_string()];
let mut bind = |value: SqlValue| {
values.push(value);
format!("?{}", values.len())
};
if !query.states.is_empty() {
let states = query
.states
.iter()
.map(|state| bind(SqlValue::Text(state.clone())))
.collect::<Vec<_>>();
predicates.push(format!("o.state IN ({})", states.join(",")));
}
if let Some(text) = query.query.as_deref().filter(|text| !text.is_empty()) {
let pattern = bind(SqlValue::Text(format!("%{}%", text.to_lowercase())));
predicates.push(format!(
"(lower(o.title) LIKE {pattern} OR lower(o.body_md) LIKE {pattern})"
));
}
if let Some(value) = &query.updated_after {
let value = bind(SqlValue::Text(value.clone()));
predicates.push(format!("o.updated_at>{value}"));
}
if let Some(value) = &query.updated_before {
let value = bind(SqlValue::Text(value.clone()));
predicates.push(format!("o.updated_at<{value}"));
}
if let Some(value) = &query.linked_ticket_id {
let value = bind(SqlValue::Text(value.clone()));
predicates.push(format!("EXISTS (SELECT 1 FROM objective_ticket_links link WHERE link.workspace_id=o.workspace_id AND link.objective_id=o.objective_id AND link.ticket_id={value})"));
}
let relevance_rank = if let Some(text) =
query.query.as_deref().filter(|text| !text.is_empty())
{
let pattern = bind(SqlValue::Text(format!("%{}%", text.to_lowercase())));
format!(
"CASE WHEN lower(o.title) LIKE {pattern} THEN 0 WHEN lower(o.body_md) LIKE {pattern} THEN 1 ELSE 2 END"
)
} else {
"2".to_string()
};
if let Some((key, id)) = after {
match sort {
ObjectiveQuerySort::UpdatedDesc => {
let key = bind(SqlValue::Text(key.clone()));
let id = bind(SqlValue::Text(id.clone()));
predicates.push(format!(
"(o.updated_at<{key} OR (o.updated_at={key} AND o.objective_id>{id}))"
));
}
ObjectiveQuerySort::CreatedDesc => {
let key = bind(SqlValue::Text(key.clone()));
let id = bind(SqlValue::Text(id.clone()));
predicates.push(format!(
"(o.created_at<{key} OR (o.created_at={key} AND o.objective_id>{id}))"
));
}
ObjectiveQuerySort::Title => {
let key = bind(SqlValue::Text(key.clone()));
let id = bind(SqlValue::Text(id.clone()));
predicates.push(format!(
"(lower(o.title)>{key} OR (lower(o.title)={key} AND o.objective_id>{id}))"
));
}
ObjectiveQuerySort::Relevance => {
let (rank, updated_at) = key.split_once('|').unwrap_or(("9", ""));
let rank = bind(SqlValue::Integer(rank.parse::<i64>().unwrap_or(9)));
let updated_at = bind(SqlValue::Text(updated_at.to_string()));
let id = bind(SqlValue::Text(id.clone()));
predicates.push(format!("({relevance_rank}>{rank} OR ({relevance_rank}={rank} AND (o.updated_at<{updated_at} OR (o.updated_at={updated_at} AND o.objective_id>{id}))))"));
}
}
}
let order = match sort {
ObjectiveQuerySort::Title => {
"o.title COLLATE NOCASE ASC, o.objective_id ASC".to_string()
}
ObjectiveQuerySort::CreatedDesc => "o.created_at DESC, o.objective_id ASC".to_string(),
ObjectiveQuerySort::UpdatedDesc => "o.updated_at DESC, o.objective_id ASC".to_string(),
ObjectiveQuerySort::Relevance => {
format!("{relevance_rank} ASC, o.updated_at DESC, o.objective_id ASC")
}
};
let limit = bind(SqlValue::Integer(i64::try_from(limit).unwrap_or(i64::MAX)));
let sql = format!(
"SELECT o.objective_id FROM objectives o WHERE {} ORDER BY {order} LIMIT {limit}",
predicates.join(" AND ")
);
self.store.with_conn(|connection| {
let mut statement = connection.prepare(&sql)?;
let rows = statement.query_map(params_from_iter(values.iter()), |row| row.get(0))?;
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
})
}
fn read_ticket_detail(&self, id: &str, request: TicketShowRequest) -> Result<TicketDetail> { fn read_ticket_detail(&self, id: &str, request: TicketShowRequest) -> Result<TicketDetail> {
validate_project_id(id)?; validate_project_id(id)?;
let ticket = self let ticket = self
.ticket_backend .ticket_backend
.show(TicketIdOrSlug::Id(id.to_string()))?; .show(TicketIdOrSlug::Id(id.to_string()))?;
self.ticket_detail_from_ticket(ticket, request)
}
fn ticket_detail_from_ticket(
&self,
ticket: ticket::Ticket,
request: TicketShowRequest,
) -> Result<TicketDetail> {
let id = ticket.meta.id.as_str();
let (body, body_truncated) = let (body, body_truncated) =
truncate_body(ticket.document.body.as_str(), DETAIL_BODY_LIMIT); truncate_body(ticket.document.body.as_str(), DETAIL_BODY_LIMIT);
let event_limit = request let event_limit = request
@@ -490,42 +841,100 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
}) })
} }
fn list_ticket_page(&self, request: TicketListPageRequest) -> Result<TicketSummaryPage> {
let limit = request.limit.unwrap_or(30).clamp(1, 100);
let mut states = request.states;
states.sort();
states.dedup();
let parsed_states = states
.iter()
.map(|state| {
TicketWorkflowState::parse(state).ok_or_else(|| {
Error::InvalidRecordId(format!("unsupported ticket state `{state}`"))
})
})
.collect::<Result<Vec<_>>>()?;
let fingerprint = format!(
"ticket-summary:v2:sort=priority:states={}",
states.join(",")
);
let after = request
.cursor
.as_deref()
.map(|cursor| parse_ticket_summary_cursor(cursor, &fingerprint))
.transpose()?;
let page =
self.ticket_backend
.list_workspace_projection_page(SqliteTicketListPageQuery {
states: parsed_states,
limit,
after,
})?;
let items = page
.items
.into_iter()
.map(ticket_summary_from_sqlite_item)
.collect::<Vec<_>>();
let next_cursor = page
.next
.map(|position| make_ticket_summary_cursor(&fingerprint, position));
Ok(TicketSummaryPage {
page: QueryPage {
limit,
returned: items.len(),
has_more: page.has_more,
next_cursor,
sort: "priority".to_string(),
source_limit: None,
source_truncated: false,
},
items,
invalid_records: Vec::new(),
record_authority: RECORD_SOURCE_WORKSPACE_SQLITE.to_string(),
})
}
fn query_tickets(&self, query: TicketQueryRequest) -> Result<TicketQueryResponse> { fn query_tickets(&self, query: TicketQueryRequest) -> Result<TicketQueryResponse> {
validate_ticket_query(&query)?; validate_ticket_query(&query)?;
let limit = query.limit.unwrap_or(50).clamp(1, 100); let limit = query.limit.unwrap_or(50).clamp(1, 100);
let sort = normalize_ticket_sort(query.sort.as_deref(), query.query.is_some())?; let sort = normalize_ticket_sort(query.sort.as_deref(), query.query.is_some())?;
let fingerprint = ticket_query_fingerprint(&query, sort);
let cursor = query let cursor = query
.cursor .cursor
.as_deref() .as_deref()
.map(parse_query_cursor) .map(|cursor| parse_bound_query_cursor(cursor, &fingerprint))
.transpose()?; .transpose()?;
let mut summaries = self.list_tickets(1_001)?.items; let candidate_limit = limit.saturating_add(1);
let source_truncated = summaries.len() > 1_000; let candidate_ids =
summaries.truncate(1_000); self.query_ticket_candidate_ids(&query, sort, cursor.as_ref(), candidate_limit)?;
let source_truncated = candidate_ids.len() == candidate_limit;
let mut items = Vec::new(); let mut items = Vec::new();
for summary in summaries { for ticket_id in candidate_ids {
let detail = self.read_ticket_detail( let authoritative = self
&summary.id, .ticket_backend
.show(TicketIdOrSlug::Id(ticket_id.clone()))?;
let summary = ticket_summary_from_ticket(&authoritative);
let authoritative_body = authoritative.document.body.clone();
let authoritative_events = authoritative.events.clone();
let detail = self.ticket_detail_from_ticket(
authoritative,
TicketShowRequest { TicketShowRequest {
event_limit: Some(TICKET_EVENT_LIMIT), event_limit: Some(TICKET_EVENT_LIMIT),
event_cursor: None, event_cursor: None,
}, },
)?; )?;
let authoritative = self
.ticket_backend
.show(TicketIdOrSlug::Id(summary.id.clone()))?;
if ticket_matches_query( if ticket_matches_query(
&summary, &summary,
&detail, &detail,
authoritative.document.body.as_str(), authoritative_body.as_str(),
&authoritative.events, &authoritative_events,
&query, &query,
) { ) {
items.push(ticket_query_item( items.push(ticket_query_item(
summary, summary,
&detail, &detail,
authoritative.document.body.as_str(), authoritative_body.as_str(),
&authoritative.events, &authoritative_events,
&query, &query,
)); ));
} }
@@ -537,7 +946,11 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
let has_more = items.len() > limit; let has_more = items.len() > limit;
items.truncate(limit); items.truncate(limit);
let next_cursor = has_more let next_cursor = has_more
.then(|| items.last().map(|item| make_ticket_cursor(item, sort))) .then(|| {
items
.last()
.map(|item| make_ticket_cursor(item, sort, &fingerprint))
})
.flatten(); .flatten();
Ok(TicketQueryResponse { Ok(TicketQueryResponse {
page: QueryPage { page: QueryPage {
@@ -546,7 +959,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
has_more, has_more,
next_cursor, next_cursor,
sort: sort.to_string(), sort: sort.to_string(),
source_limit: Some(1_000), source_limit: Some(candidate_limit),
source_truncated, source_truncated,
}, },
items, items,
@@ -598,33 +1011,36 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
)?; )?;
let limit = query.limit.unwrap_or(50).clamp(1, 100); let limit = query.limit.unwrap_or(50).clamp(1, 100);
let sort = normalize_objective_sort(query.sort.as_deref(), query.query.is_some())?; let sort = normalize_objective_sort(query.sort.as_deref(), query.query.is_some())?;
let fingerprint = objective_query_fingerprint(&query, sort);
let cursor = query let cursor = query
.cursor .cursor
.as_deref() .as_deref()
.map(parse_query_cursor) .map(|cursor| parse_bound_query_cursor(cursor, &fingerprint))
.transpose()?; .transpose()?;
let mut objectives = self.list_objectives(1_001)?.items; let candidate_limit = limit.saturating_add(1);
let source_truncated = objectives.len() > 1_000; let objective_ids =
objectives.truncate(1_000); self.query_objective_candidate_ids(&query, sort, cursor.as_ref(), candidate_limit)?;
let source_truncated = objective_ids.len() == candidate_limit;
let mut items = Vec::new(); let mut items = Vec::new();
for objective in objectives { for objective_id in objective_ids {
let body_md = self.objective_record(&objective.id)?.body_md; let record = self.objective_record(&objective_id)?;
if !objective_matches_query(&objective, &body_md, &query) {
continue;
}
let linked_tickets = self let linked_tickets = self
.store .store
.list_objective_ticket_links(&self.workspace_id, &objective.id)? .list_objective_ticket_links(&self.workspace_id, &objective_id)?
.into_iter() .into_iter()
.map(|link| link.ticket_id) .map(|link| link.ticket_id)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
if query let body_md = record.body_md.clone();
.linked_ticket_id let objective = ObjectiveSummary {
.as_ref() id: record.objective_id,
.is_some_and(|id| !linked_tickets.iter().any(|ticket_id| ticket_id == id)) title: record.title,
{ state: record.state,
continue; created_at: Some(record.created_at),
} updated_at: Some(record.updated_at),
summary: summarize_body(&body_md),
linked_tickets: linked_tickets.clone(),
record_source: RECORD_SOURCE_WORKSPACE_SQLITE.to_string(),
};
items.push(objective_query_item( items.push(objective_query_item(
objective, objective,
linked_tickets, linked_tickets,
@@ -639,7 +1055,11 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
let has_more = items.len() > limit; let has_more = items.len() > limit;
items.truncate(limit); items.truncate(limit);
let next_cursor = has_more let next_cursor = has_more
.then(|| items.last().map(|item| make_objective_cursor(item, sort))) .then(|| {
items
.last()
.map(|item| make_objective_cursor(item, sort, &fingerprint))
})
.flatten(); .flatten();
Ok(ObjectiveQueryResponse { Ok(ObjectiveQueryResponse {
page: QueryPage { page: QueryPage {
@@ -648,7 +1068,7 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
has_more, has_more,
next_cursor, next_cursor,
sort: sort.to_string(), sort: sort.to_string(),
source_limit: Some(1_000), source_limit: Some(candidate_limit),
source_truncated, source_truncated,
}, },
items, items,
@@ -1358,6 +1778,35 @@ fn parse_offset_cursor(cursor: &str, field: &str) -> Result<usize> {
.map_err(|_| Error::InvalidRecordId(format!("invalid {field}"))) .map_err(|_| Error::InvalidRecordId(format!("invalid {field}")))
} }
fn make_bound_query_cursor(fingerprint: &str, key: &str, id: &str) -> String {
make_query_cursor(&format!("{fingerprint}\n{key}"), id)
}
fn parse_bound_query_cursor(value: &str, fingerprint: &str) -> Result<(String, String)> {
let (key, id) = parse_query_cursor(value)?;
let prefix = format!("{fingerprint}\n");
let key = key.strip_prefix(&prefix).ok_or_else(|| {
Error::InvalidRecordId("cursor does not match the current filters or sort".to_string())
})?;
Ok((key.to_string(), id))
}
fn ticket_query_fingerprint(query: &TicketQueryRequest, sort: TicketQuerySort) -> String {
let mut query = query.clone();
query.cursor = None;
query.limit = None;
query.sort = Some(sort.to_string());
serde_json::to_string(&query).expect("ticket query fingerprint must serialize")
}
fn objective_query_fingerprint(query: &ObjectiveQueryRequest, sort: ObjectiveQuerySort) -> String {
let mut query = query.clone();
query.cursor = None;
query.limit = None;
query.sort = Some(sort.to_string());
serde_json::to_string(&query).expect("objective query fingerprint must serialize")
}
fn make_query_cursor(key: &str, id: &str) -> String { fn make_query_cursor(key: &str, id: &str) -> String {
format!("v1:{}:{key}{id}", key.len()) format!("v1:{}:{key}{id}", key.len())
} }
@@ -1677,8 +2126,8 @@ fn sort_ticket_query_items(items: &mut [TicketQueryItem], sort: TicketQuerySort)
}); });
} }
fn make_ticket_cursor(item: &TicketQueryItem, sort: TicketQuerySort) -> String { fn make_ticket_cursor(item: &TicketQueryItem, sort: TicketQuerySort, fingerprint: &str) -> String {
make_query_cursor(&ticket_sort_key(item, sort), &item.id) make_bound_query_cursor(fingerprint, &ticket_sort_key(item, sort), &item.id)
} }
fn ticket_item_after_cursor( fn ticket_item_after_cursor(
@@ -1703,31 +2152,6 @@ fn ticket_item_after_cursor(
} }
} }
fn objective_matches_query(
objective: &ObjectiveSummary,
body_md: &str,
query: &ObjectiveQueryRequest,
) -> bool {
if !query.states.is_empty() && !query.states.iter().any(|state| state == &objective.state) {
return false;
}
if query
.updated_after
.as_ref()
.is_some_and(|after| objective.updated_at.as_deref().unwrap_or("") <= after.as_str())
|| query
.updated_before
.as_ref()
.is_some_and(|before| objective.updated_at.as_deref().unwrap_or("") >= before.as_str())
{
return false;
}
query.query.as_ref().is_none_or(|text| {
let needle = text.to_lowercase();
objective.title.to_lowercase().contains(&needle) || body_md.to_lowercase().contains(&needle)
})
}
fn objective_query_item( fn objective_query_item(
objective: ObjectiveSummary, objective: ObjectiveSummary,
linked_tickets: Vec<String>, linked_tickets: Vec<String>,
@@ -1805,8 +2229,12 @@ fn sort_objective_query_items(items: &mut [ObjectiveQueryItem], sort: ObjectiveQ
}); });
} }
fn make_objective_cursor(item: &ObjectiveQueryItem, sort: ObjectiveQuerySort) -> String { fn make_objective_cursor(
make_query_cursor(&objective_sort_key(item, sort), &item.id) item: &ObjectiveQueryItem,
sort: ObjectiveQuerySort,
fingerprint: &str,
) -> String {
make_bound_query_cursor(fingerprint, &objective_sort_key(item, sort), &item.id)
} }
fn objective_item_after_cursor( fn objective_item_after_cursor(
@@ -1934,6 +2362,80 @@ fn memory_resolution_from_record(record: MemoryStagingResolutionRecord) -> Memor
} }
} }
fn ticket_summary_from_ticket(ticket: &ticket::Ticket) -> TicketSummary {
let summary = ticket::TicketSummary {
id: ticket.meta.id.clone(),
slug: ticket.meta.slug.clone(),
title: ticket.meta.title.clone(),
status: ticket.meta.status.clone(),
kind: ticket.meta.kind.clone(),
priority: ticket.meta.priority.clone(),
labels: ticket.meta.labels.clone(),
readiness: ticket.meta.readiness.clone(),
workflow_state: ticket.meta.workflow_state,
workflow_state_explicit: ticket.meta.workflow_state_explicit,
queued_by: ticket.meta.queued_by.clone(),
queued_at: ticket.meta.queued_at.clone(),
updated_at: ticket.meta.updated_at.clone(),
};
ticket_summary_from_sqlite_item(SqliteTicketListItem {
summary,
relation_blockers: ticket.relations.blockers.clone(),
})
}
fn ticket_summary_from_sqlite_item(item: SqliteTicketListItem) -> TicketSummary {
let projection = project_ticket_workspace_item(&item.summary, &item.relation_blockers, None);
TicketSummary {
id: item.summary.id,
title: item.summary.title,
state: item.summary.workflow_state.as_str().to_string(),
priority: item.summary.priority,
updated_at: item.summary.updated_at,
queued_by: item.summary.queued_by,
queued_at: item.summary.queued_at,
workspace_action_priority: workspace_action_priority_name(projection.priority).to_string(),
record_source: "sqlite_yoi_ticket".to_string(),
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct TicketSummaryCursorEnvelope {
version: u8,
fingerprint: String,
position: SqliteTicketListCursor,
}
fn make_ticket_summary_cursor(fingerprint: &str, position: SqliteTicketListCursor) -> String {
make_query_cursor(
&serde_json::to_string(&TicketSummaryCursorEnvelope {
version: 1,
fingerprint: fingerprint.to_string(),
position,
})
.expect("ticket summary cursor must serialize"),
"",
)
}
fn parse_ticket_summary_cursor(
value: &str,
expected_fingerprint: &str,
) -> Result<SqliteTicketListCursor> {
let (encoded, trailing) = parse_query_cursor(value)?;
if !trailing.is_empty() {
return Err(Error::InvalidRecordId("cursor is malformed".to_string()));
}
let cursor: TicketSummaryCursorEnvelope = serde_json::from_str(&encoded)
.map_err(|_| Error::InvalidRecordId("cursor is malformed".to_string()))?;
if cursor.version != 1 || cursor.fingerprint != expected_fingerprint {
return Err(Error::InvalidRecordId(
"cursor does not match the current ticket filters or sort".to_string(),
));
}
Ok(cursor.position)
}
fn workspace_action_priority_name(priority: TicketWorkspaceActionPriority) -> &'static str { fn workspace_action_priority_name(priority: TicketWorkspaceActionPriority) -> &'static str {
match priority { match priority {
TicketWorkspaceActionPriority::ReadyForQueue => "ready_for_queue", TicketWorkspaceActionPriority::ReadyForQueue => "ready_for_queue",
@@ -2352,6 +2854,28 @@ mod tests {
.contains(&"body".to_string()) .contains(&"body".to_string())
); );
assert_eq!(ticket_query.page.limit, 1); assert_eq!(ticket_query.page.limit, 1);
let no_review = authority
.query_tickets(TicketQueryRequest {
review_status: Some("none".to_string()),
sort: Some("updated_desc".to_string()),
limit: Some(10),
..TicketQueryRequest::default()
})
.unwrap();
assert!(
no_review
.items
.iter()
.any(|item| item.id == "00000000001J2"),
"review-status storage predicate must query the authoritative MR thread schema"
);
authority
.query_tickets(TicketQueryRequest {
review_status: Some("changes_requested".to_string()),
limit: Some(10),
..TicketQueryRequest::default()
})
.expect("accepted review-status alias must execute");
let historical_event_query = authority let historical_event_query = authority
.query_tickets(TicketQueryRequest { .query_tickets(TicketQueryRequest {
query: Some("Historical event marker".to_string()), query: Some("Historical event marker".to_string()),
@@ -2404,6 +2928,35 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(exact_relation.items.len(), 1); assert_eq!(exact_relation.items.len(), 1);
assert_eq!(exact_relation.items[0].id, "00000000001J2"); assert_eq!(exact_relation.items[0].id, "00000000001J2");
let incoming_relation = authority
.query_tickets(TicketQueryRequest {
related_ticket_id: Some("00000000001J2".to_string()),
relation_kind: Some("related".to_string()),
limit: Some(10),
..TicketQueryRequest::default()
})
.unwrap();
assert_eq!(incoming_relation.items.len(), 1);
assert_eq!(incoming_relation.items[0].id, "00000000001J5");
let summary_page = authority
.list_ticket_page(TicketListPageRequest {
states: vec!["planning".to_string(), "ready".to_string()],
limit: Some(1),
cursor: None,
})
.unwrap();
assert_eq!(summary_page.items.len(), 1);
assert!(summary_page.page.has_more);
let mismatched_summary_cursor = authority.list_ticket_page(TicketListPageRequest {
states: vec!["done".to_string()],
limit: Some(1),
cursor: summary_page.page.next_cursor,
});
assert!(matches!(
mismatched_summary_cursor,
Err(Error::InvalidRecordId(_))
));
let first_page = authority let first_page = authority
.query_tickets(TicketQueryRequest { .query_tickets(TicketQueryRequest {
sort: Some("title".to_string()), sort: Some("title".to_string()),
@@ -2423,6 +2976,13 @@ mod tests {
assert_eq!(second_page.items.len(), 1); assert_eq!(second_page.items.len(), 1);
assert_ne!(first_page.items[0].id, second_page.items[0].id); assert_ne!(first_page.items[0].id, second_page.items[0].id);
assert!(second_page.page.has_more); assert!(second_page.page.has_more);
let mismatched_cursor = authority.query_tickets(TicketQueryRequest {
sort: Some("updated_desc".to_string()),
limit: Some(1),
cursor: first_page.page.next_cursor.clone(),
..TicketQueryRequest::default()
});
assert!(matches!(mismatched_cursor, Err(Error::InvalidRecordId(_))));
let third_page = authority let third_page = authority
.query_tickets(TicketQueryRequest { .query_tickets(TicketQueryRequest {
sort: Some("title".to_string()), sort: Some("title".to_string()),
+19
View File
@@ -12,6 +12,14 @@ pub struct ProjectRecordList<T> {
pub record_authority: String, pub record_authority: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TicketSummaryPage {
pub items: Vec<TicketSummary>,
pub page: QueryPage,
pub invalid_records: Vec<InvalidProjectRecord>,
pub record_authority: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct InvalidProjectRecord { pub struct InvalidProjectRecord {
@@ -33,12 +41,23 @@ pub struct TicketSummary {
pub record_source: String, pub record_source: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct TicketListPageRequest {
#[serde(default)]
pub states: Vec<String>,
#[serde(default)]
pub limit: Option<usize>,
#[serde(default)]
pub cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketListResponse { pub struct TicketListResponse {
pub workspace_id: String, pub workspace_id: String,
pub limit: usize, pub limit: usize,
pub items: Vec<TicketSummary>, pub items: Vec<TicketSummary>,
pub page: QueryPage,
pub invalid_records: Vec<InvalidProjectRecord>, pub invalid_records: Vec<InvalidProjectRecord>,
pub record_authority: String, pub record_authority: String,
} }
+8 -79
View File
@@ -97,38 +97,13 @@ pub struct CommitObservation {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum RepositoryLookupError { pub enum RepositoryLookupError {
UnknownRepository { UnknownRepository { id: RepositoryId },
id: RepositoryId, UnsupportedProvider { id: RepositoryId, provider: String },
}, MissingDefaultSelector { id: RepositoryId },
UnsupportedProvider { InvalidSelector { id: RepositoryId, selector: String },
id: RepositoryId, CommitNotFound { id: RepositoryId, commit: String },
provider: String, InvalidCommitRelation { id: RepositoryId, detail: String },
}, ProviderFailure { id: RepositoryId, operation: String },
MissingDefaultSelector {
id: RepositoryId,
},
InvalidSelector {
id: RepositoryId,
selector: String,
},
CommitNotFound {
id: RepositoryId,
commit: String,
},
InvalidCommitRelation {
id: RepositoryId,
detail: String,
},
TargetMoved {
id: RepositoryId,
selector: String,
expected: String,
observed: Option<String>,
},
ProviderFailure {
id: RepositoryId,
operation: String,
},
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -306,45 +281,6 @@ impl RepositoryRegistryReader {
} }
} }
pub fn update_merge_target(
&self,
id: &str,
selector: &str,
expected_target: &str,
result_commit: &str,
) -> Result<(), RepositoryLookupError> {
let repository = self.merge_repository(id)?;
let target_ref = normalize_target_branch_selector(id, selector)?;
self.observe_commit(id, result_commit)?;
let status = Command::new("git")
.arg("-C")
.arg(&repository.path)
.args([
"update-ref",
target_ref.as_str(),
result_commit,
expected_target,
])
.status()
.map_err(|_| RepositoryLookupError::ProviderFailure {
id: id.into(),
operation: "guarded target update".into(),
})?;
if status.success() {
return Ok(());
}
let observed = self
.observe_merge_target(id, Some(selector))
.ok()
.map(|target| target.commit);
Err(RepositoryLookupError::TargetMoved {
id: id.into(),
selector: selector.into(),
expected: expected_target.into(),
observed,
})
}
fn merge_repository(&self, id: &str) -> Result<&ConfiguredRepository, RepositoryLookupError> { fn merge_repository(&self, id: &str) -> Result<&ConfiguredRepository, RepositoryLookupError> {
let repository = self let repository = self
.find(id) .find(id)
@@ -755,20 +691,13 @@ mod tests {
vec![base.clone()] vec![base.clone()]
); );
reader.ensure_ancestor("main", &base, &source).unwrap(); reader.ensure_ancestor("main", &base, &source).unwrap();
reader
.update_merge_target("main", "main", &base, &source)
.unwrap();
assert_eq!( assert_eq!(
reader reader
.observe_merge_target("main", Some("refs/heads/main")) .observe_merge_target("main", Some("refs/heads/main"))
.unwrap() .unwrap()
.commit, .commit,
source base
); );
assert!(matches!(
reader.update_merge_target("main", "refs/heads/main", &base, &base),
Err(RepositoryLookupError::TargetMoved { .. })
));
assert!(matches!( assert!(matches!(
reader.ensure_ancestor("main", &source, &base), reader.ensure_ancestor("main", &source, &base),
Err(RepositoryLookupError::InvalidCommitRelation { .. }) Err(RepositoryLookupError::InvalidCommitRelation { .. })
+430 -123
View File
@@ -1064,11 +1064,18 @@ impl WorkspaceApi {
&self, &self,
request: &WorkerSpawnRequest, request: &WorkerSpawnRequest,
) -> ApiResult<()> { ) -> ApiResult<()> {
let selected_repository_id = let (selected_repository_id, selected_ref_selector) =
if let Some(working_directory) = request.resolved_working_directory_request.as_ref() { if let Some(working_directory) = request.resolved_working_directory_request.as_ref() {
let repository_id = working_directory.repository.id.as_str(); let repository_id = working_directory.repository.id.as_str();
self.require_workspace_repository(repository_id)?; self.require_workspace_repository(repository_id)?;
Some(repository_id.to_string()) (
Some(repository_id.to_string()),
working_directory
.repository
.selector
.as_deref()
.map(str::to_owned),
)
} else if let Some(claim) = request.resolved_working_directory.as_ref() { } else if let Some(claim) = request.resolved_working_directory.as_ref() {
let workdir = self let workdir = self
.store .store
@@ -1080,20 +1087,48 @@ impl WorkspaceApi {
))) )))
})?; })?;
self.require_workspace_repository(&workdir.repository_id)?; self.require_workspace_repository(&workdir.repository_id)?;
Some(workdir.repository_id) (Some(workdir.repository_id), workdir.creation_selector)
} else { } else {
None (None, None)
}; };
if let WorkerSpawnIntent::TicketRole { ticket_id, .. } = &request.intent { if let WorkerSpawnIntent::TicketRole { ticket_id, .. } = &request.intent {
let ticket = self.authority.ticket(ticket_id)?; let ticket = self.authority.ticket(ticket_id)?;
if let Some(repository_id) = ticket.repository_id.as_deref() { // Workdir-less Ticket Workers cannot execute repository implementation.
self.require_workspace_repository(repository_id)?; // Preserve that control-plane launch while still validating any persisted
if selected_repository_id.as_deref() != Some(repository_id) { // target (including its Workspace ownership) when one exists.
return Err(ApiError::from(Error::Config(format!( if selected_repository_id.is_none() && ticket.repository_id.is_none() {
"Ticket `{ticket_id}` targets repository `{repository_id}`, but the Worker launch does not resolve that repository in this Workspace" return Ok(());
)))); }
} let repository_id = ticket.repository_id.as_deref().ok_or_else(|| {
ApiError::from(Error::Config(
"Ticket implementation target must be validated and persisted before spawning a Ticket Worker".to_owned(),
))
})?;
let ref_selector = ticket.ref_selector.as_deref().ok_or_else(|| {
ApiError::from(Error::Config(
"Ticket implementation target selector must be validated and persisted before spawning a Ticket Worker".to_owned(),
))
})?;
self.require_workspace_repository(repository_id)?;
self.repository_reader()
.observe_merge_target(repository_id, Some(ref_selector))
.map_err(|error| {
ApiError::from(Error::Config(format!(
"Ticket implementation target is no longer resolvable: {error:?}"
)))
})?;
if selected_repository_id.as_deref() != Some(repository_id) {
return Err(ApiError::from(Error::Config(format!(
"Ticket `{ticket_id}` targets repository `{repository_id}`, but the Worker launch resolves `{}`",
selected_repository_id.as_deref().unwrap_or("none")
))));
}
if selected_ref_selector.as_deref() != Some(ref_selector) {
return Err(ApiError::from(Error::Config(format!(
"Ticket `{ticket_id}` targets selector `{ref_selector}`, but the Worker launch resolves `{}`",
selected_ref_selector.as_deref().unwrap_or("none")
))));
} }
} }
Ok(()) Ok(())
@@ -1319,8 +1354,8 @@ pub fn build_router(api: WorkspaceApi) -> Router {
post(scoped_set_ticket_workflow_state), post(scoped_set_ticket_workflow_state),
) )
.route( .route(
"/api/w/{workspace_id}/tickets/{id}/intake-ready", "/api/w/{workspace_id}/tickets/{id}/workflow/mark-ready",
post(scoped_prepare_ticket_intake_ready), post(scoped_mark_ticket_ready),
) )
.route( .route(
"/api/w/{workspace_id}/tickets/{id}/workflow/queue", "/api/w/{workspace_id}/tickets/{id}/workflow/queue",
@@ -1401,6 +1436,10 @@ pub fn build_router(api: WorkspaceApi) -> Router {
"/api/w/{workspace_id}/tickets/{id}/state", "/api/w/{workspace_id}/tickets/{id}/state",
post(scoped_transition_ticket_state), post(scoped_transition_ticket_state),
) )
.route(
"/api/w/{workspace_id}/tickets/{id}/ready",
post(scoped_mark_ticket_ready_from_browser),
)
.route( .route(
"/api/w/{workspace_id}/tickets/{id}/events", "/api/w/{workspace_id}/tickets/{id}/events",
post(scoped_append_ticket_event), post(scoped_append_ticket_event),
@@ -2233,6 +2272,9 @@ struct ObjectiveEditRequest {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct TicketListQuery { struct TicketListQuery {
limit: Option<usize>, limit: Option<usize>,
cursor: Option<String>,
/// Comma-separated workflow states. Repeated lane requests normally pass one state group.
states: Option<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -3111,6 +3153,55 @@ struct BrowserCloseTicketRequest {
resolution: String, resolution: String,
} }
#[derive(Clone)]
struct WorkspaceTicketTargetAuthority {
api: WorkspaceApi,
}
impl ticket::TicketTargetAuthority for WorkspaceTicketTargetAuthority {
fn resolve_target(
&self,
workspace_id: &str,
repository_id: Option<&str>,
ref_selector: Option<&str>,
) -> ticket::Result<ticket::ResolvedTicketTarget> {
if workspace_id != self.api.config.workspace_id {
return Err(ticket::TicketError::UnknownTargetRepository(
repository_id.unwrap_or_default().to_owned(),
));
}
let repository_id = repository_id
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or(ticket::TicketError::MissingTargetRepository)?;
let repository = self
.api
.store
.get_repository(workspace_id, repository_id)
.map_err(|error| ticket::TicketError::Conflict(error.to_string()))?
.ok_or_else(|| {
ticket::TicketError::UnknownTargetRepository(repository_id.to_owned())
})?;
let selector = ref_selector
.map(str::trim)
.filter(|value| !value.is_empty())
.or(repository.default_ref.as_deref())
.ok_or_else(|| ticket::TicketError::MissingTargetSelector(repository_id.to_owned()))?;
self.api
.repository_reader()
.observe_merge_target(repository_id, Some(selector))
.map_err(|error| ticket::TicketError::InvalidTargetSelector {
repository_id: repository_id.to_owned(),
selector: selector.to_owned(),
reason: format!("{error:?}"),
})?;
Ok(ticket::ResolvedTicketTarget {
repository_id: repository_id.to_owned(),
ref_selector: selector.to_owned(),
})
}
}
fn browser_ticket_backend(api: &WorkspaceApi) -> Result<SqliteTicketBackend> { fn browser_ticket_backend(api: &WorkspaceApi) -> Result<SqliteTicketBackend> {
let config = ticket::config::TicketConfig::load_workspace(&api.config.workspace_root) let config = ticket::config::TicketConfig::load_workspace(&api.config.workspace_root)
.map_err(|error| Error::Config(format!("load Ticket workspace settings: {error}")))?; .map_err(|error| Error::Config(format!("load Ticket workspace settings: {error}")))?;
@@ -3118,7 +3209,10 @@ fn browser_ticket_backend(api: &WorkspaceApi) -> Result<SqliteTicketBackend> {
api.config.database_path.clone(), api.config.database_path.clone(),
api.config.workspace_id.clone(), api.config.workspace_id.clone(),
)? )?
.with_record_language(config.ticket_record_language())) .with_record_language(config.ticket_record_language())
.with_target_authority(Arc::new(WorkspaceTicketTargetAuthority {
api: api.clone(),
})))
} }
fn browser_ticket_detail(api: &WorkspaceApi, ticket_id: &str) -> ApiResult<Json<TicketDetail>> { fn browser_ticket_detail(api: &WorkspaceApi, ticket_id: &str) -> ApiResult<Json<TicketDetail>> {
@@ -3212,6 +3306,26 @@ async fn scoped_append_ticket_event(
browser_ticket_detail(&api, &path.id) browser_ticket_detail(&api, &path.id)
} }
async fn scoped_mark_ticket_ready_from_browser(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRecordPath>,
Json(request): Json<TicketMarkReadyRequest>,
) -> ApiResult<Json<TicketDetail>> {
validate_workspace_scope(&api, &path.workspace_id)?;
browser_ticket_backend(&api)?
.mark_ready(
TicketIdOrSlug::Id(path.id.clone()),
ticket::TicketMarkReady {
operation_key: request.operation_key,
reason: request.reason,
author: Some("web".to_owned()),
intake_summary: None,
},
)
.map_err(Error::from)?;
browser_ticket_detail(&api, &path.id)
}
async fn scoped_queue_ticket( async fn scoped_queue_ticket(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRecordPath>, AxumPath(path): AxumPath<ScopedRecordPath>,
@@ -3273,7 +3387,10 @@ async fn execute_worker_ticket_rest_operation(
api.config.workspace_id.clone(), api.config.workspace_id.clone(),
) )
.map_err(Error::from)? .map_err(Error::from)?
.with_record_language(config.ticket_record_language()); .with_record_language(config.ticket_record_language())
.with_target_authority(Arc::new(WorkspaceTicketTargetAuthority {
api: api.clone(),
}));
let operation_kind = ticket_mutation_operation_kind(&operation); let operation_kind = ticket_mutation_operation_kind(&operation);
let is_mutation = operation_kind != "read"; let is_mutation = operation_kind != "read";
let target = ticket_mutation_target(&operation).cloned(); let target = ticket_mutation_target(&operation).cloned();
@@ -3425,6 +3542,15 @@ async fn scoped_create_ticket_record(
headers: HeaderMap, headers: HeaderMap,
Json(input): Json<ticket::NewTicket>, Json(input): Json<ticket::NewTicket>,
) -> ApiResult<Json<ticket::TicketRef>> { ) -> ApiResult<Json<ticket::TicketRef>> {
if input
.workflow_state
.is_some_and(|state| state != TicketWorkflowState::Planning)
{
return Err(settings_bad_request(
"ticket_create_state_bypass",
"Ticket creation must start in planning; use guarded workflow operations for later states",
));
}
let result = execute_worker_ticket_rest_operation( let result = execute_worker_ticket_rest_operation(
&api, &api,
&path.workspace_id, &path.workspace_id,
@@ -3538,9 +3664,12 @@ async fn scoped_add_ticket_intake_summary(
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct TicketIntakeReadyRequest { struct TicketMarkReadyRequest {
summary: ticket::TicketIntakeSummary, operation_key: String,
change: TicketStateChange, #[serde(default)]
reason: Option<String>,
#[serde(default)]
intake_summary: Option<ticket::TicketIntakeSummary>,
} }
async fn scoped_set_ticket_state_field( async fn scoped_set_ticket_state_field(
@@ -3582,24 +3711,31 @@ async fn scoped_set_ticket_workflow_state(
ticket_rest_unit(result) ticket_rest_unit(result)
} }
async fn scoped_prepare_ticket_intake_ready( async fn scoped_mark_ticket_ready(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath((workspace_id, id)): AxumPath<(String, String)>, AxumPath((workspace_id, id)): AxumPath<(String, String)>,
headers: HeaderMap, headers: HeaderMap,
Json(request): Json<TicketIntakeReadyRequest>, Json(request): Json<TicketMarkReadyRequest>,
) -> ApiResult<StatusCode> { ) -> ApiResult<Json<ticket::Ticket>> {
let result = execute_worker_ticket_rest_operation( let result = execute_worker_ticket_rest_operation(
&api, &api,
&workspace_id, &workspace_id,
headers, headers,
TicketBackendOperation::MarkIntakeReady { TicketBackendOperation::MarkReady {
id: TicketIdOrSlug::Query(id), id: TicketIdOrSlug::Query(id),
summary: request.summary, request: ticket::TicketMarkReady {
change: request.change, operation_key: request.operation_key,
reason: request.reason,
author: None,
intake_summary: request.intake_summary,
},
}, },
) )
.await?; .await?;
ticket_rest_unit(result) ticket_rest_result(result, |result| match result {
TicketBackendOperationResult::Ticket(ticket) => Some(ticket),
_ => None,
})
} }
async fn scoped_queue_ticket_record( async fn scoped_queue_ticket_record(
@@ -3773,6 +3909,37 @@ fn repository_merge_evidence_error(error: RepositoryLookupError) -> ApiError {
.into() .into()
} }
fn recorded_merge_completion<'a>(
thread: &'a [merge_request::MergeRequestThreadEvent],
operation_id: &str,
) -> Option<&'a merge_request::MergeEvent> {
thread.iter().find_map(|event| match event {
merge_request::MergeRequestThreadEvent::Merge(event)
if event.operation_id == operation_id =>
{
Some(event)
}
_ => None,
})
}
fn require_completed_target_observation(
observed: &str,
target_ref_before: &str,
target_ref_after: &str,
) -> ApiResult<()> {
if observed == target_ref_after {
return Ok(());
}
if observed == target_ref_before {
return Err(Error::InvalidInput(
"target selector is still at target_ref_before; push the verified result from the Orchestrator Workdir before MergeRequestComplete".into(),
)
.into());
}
Err(Error::InvalidInput("target selector moved outside completion evidence".into()).into())
}
async fn scoped_show_merge_request( async fn scoped_show_merge_request(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
@@ -4106,19 +4273,40 @@ async fn scoped_complete_merge_request(
require_workspace_access(&workspace_id, &api)?; require_workspace_access(&workspace_id, &api)?;
let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?; let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?;
require_online_workspace_orchestrator_source(&api, &source)?; require_online_workspace_orchestrator_source(&api, &source)?;
let store = merge_request_store(&api, &workspace_id)?;
let mr = store.get(&workspace_id, &ticket_id)?;
let repositories = api.repository_reader();
if let Some(existing) = recorded_merge_completion(&mr.thread, &input.operation_id) {
let replay = merge_request::CompleteMergeRequest {
ticket_id,
operation_id: input.operation_id,
approval_event_id: input.approval_event_id,
current_subject_ref: existing.approved_source_ref.clone(),
target_ref_before: input.target_ref_before,
target_ref_after: input.target_ref_after,
strategy: input.strategy,
resolution: input.resolution,
auth: merge_request::MergeRequestAuth {
workspace_id,
repository_id: mr.repository_id.clone(),
runtime_id: source.runtime_id,
worker_id: source.worker_id,
assignment_id: String::new(),
},
now: Utc::now(),
};
return store.complete(replay).map(Json).map_err(Into::into);
}
let assignment = api let assignment = api
.store .store
.get_current_ticket_worker_assignment(&workspace_id, &ticket_id)? .get_current_ticket_worker_assignment(&workspace_id, &ticket_id)?
.ok_or_else(|| { .ok_or_else(|| {
Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into()) Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into())
})?; })?;
let store = merge_request_store(&api, &workspace_id)?;
let mr = store.get(&workspace_id, &ticket_id)?;
let selector = mr let selector = mr
.selector_from .selector_from
.as_deref() .as_deref()
.ok_or_else(|| Error::InvalidInput("selector_from requires repair".into()))?; .ok_or_else(|| Error::InvalidInput("selector_from requires repair".into()))?;
let repositories = api.repository_reader();
let current_source_ref = repositories let current_source_ref = repositories
.observe_merge_target(&mr.repository_id, Some(selector)) .observe_merge_target(&mr.repository_id, Some(selector))
.map_err(repository_merge_evidence_error)? .map_err(repository_merge_evidence_error)?
@@ -4126,12 +4314,11 @@ async fn scoped_complete_merge_request(
let observed = repositories let observed = repositories
.observe_merge_target(&mr.repository_id, Some(&mr.selector_to)) .observe_merge_target(&mr.repository_id, Some(&mr.selector_to))
.map_err(repository_merge_evidence_error)?; .map_err(repository_merge_evidence_error)?;
if observed.commit != input.target_ref_before && observed.commit != input.target_ref_after { require_completed_target_observation(
return Err(Error::InvalidInput( &observed.commit,
"target selector moved outside completion evidence".into(), &input.target_ref_before,
) &input.target_ref_after,
.into()); )?;
}
let completion = merge_request::CompleteMergeRequest { let completion = merge_request::CompleteMergeRequest {
ticket_id, ticket_id,
operation_id: input.operation_id, operation_id: input.operation_id,
@@ -4151,31 +4338,7 @@ async fn scoped_complete_merge_request(
now: Utc::now(), now: Utc::now(),
}; };
store.validate_completion(&completion)?; store.validate_completion(&completion)?;
let already = observed.commit == input.target_ref_after; store.complete(completion).map(Json).map_err(Into::into)
if !already {
repositories
.update_merge_target(
&mr.repository_id,
&mr.selector_to,
&input.target_ref_before,
&input.target_ref_after,
)
.map_err(repository_merge_evidence_error)?
}
match store.complete(completion) {
Ok(v) => Ok(Json(v)),
Err(e) => {
if !already {
let _ = repositories.update_merge_target(
&mr.repository_id,
&mr.selector_to,
&input.target_ref_after,
&input.target_ref_before,
);
}
Err(e.into())
}
}
} }
fn reject_non_browser_reopen_auth(headers: &HeaderMap) -> Result<()> { fn reject_non_browser_reopen_auth(headers: &HeaderMap) -> Result<()> {
@@ -4381,7 +4544,7 @@ fn ticket_mutation_target(operation: &TicketBackendOperation) -> Option<&TicketI
| TicketBackendOperation::AddIntakeSummary { id, .. } | TicketBackendOperation::AddIntakeSummary { id, .. }
| TicketBackendOperation::SetStateField { id, .. } | TicketBackendOperation::SetStateField { id, .. }
| TicketBackendOperation::SetWorkflowState { id, .. } | TicketBackendOperation::SetWorkflowState { id, .. }
| TicketBackendOperation::MarkIntakeReady { id, .. } | TicketBackendOperation::MarkReady { id, .. }
| TicketBackendOperation::QueueReady { id, .. } | TicketBackendOperation::QueueReady { id, .. }
| TicketBackendOperation::Close { id, .. } | TicketBackendOperation::Close { id, .. }
| TicketBackendOperation::AddTicketRelation { id, .. } | TicketBackendOperation::AddTicketRelation { id, .. }
@@ -4422,11 +4585,11 @@ fn bind_worker_ticket_operation_source(
| TicketBackendOperation::SetStateField { change, .. } | TicketBackendOperation::SetStateField { change, .. }
| TicketBackendOperation::SetWorkflowState { change, .. } => change.author = Some(author), | TicketBackendOperation::SetWorkflowState { change, .. } => change.author = Some(author),
TicketBackendOperation::AddIntakeSummary { summary, .. } => summary.author = Some(author), TicketBackendOperation::AddIntakeSummary { summary, .. } => summary.author = Some(author),
TicketBackendOperation::MarkIntakeReady { TicketBackendOperation::MarkReady { request, .. } => {
summary, change, .. request.author = Some(author.clone());
} => { if let Some(summary) = request.intake_summary.as_mut() {
summary.author = Some(author.clone()); summary.author = Some(author);
change.author = Some(author); }
} }
TicketBackendOperation::QueueReady { queued_by, .. } => *queued_by = author, TicketBackendOperation::QueueReady { queued_by, .. } => *queued_by = author,
TicketBackendOperation::AddTicketRelation { relation, .. } => { TicketBackendOperation::AddTicketRelation { relation, .. } => {
@@ -4448,7 +4611,7 @@ fn ticket_mutation_operation_kind(operation: &TicketBackendOperation) -> &'stati
TicketBackendOperation::AddIntakeSummary { .. } => "add_intake_summary", TicketBackendOperation::AddIntakeSummary { .. } => "add_intake_summary",
TicketBackendOperation::SetStateField { .. } => "set_state_field", TicketBackendOperation::SetStateField { .. } => "set_state_field",
TicketBackendOperation::SetWorkflowState { .. } => "set_workflow_state", TicketBackendOperation::SetWorkflowState { .. } => "set_workflow_state",
TicketBackendOperation::MarkIntakeReady { .. } => "mark_intake_ready", TicketBackendOperation::MarkReady { .. } => "mark_ready",
TicketBackendOperation::QueueReady { .. } => "queue_ready", TicketBackendOperation::QueueReady { .. } => "queue_ready",
TicketBackendOperation::Close { .. } => "close", TicketBackendOperation::Close { .. } => "close",
TicketBackendOperation::AddTicketRelation { .. } => "add_relation", TicketBackendOperation::AddTicketRelation { .. } => "add_relation",
@@ -8329,17 +8492,35 @@ async fn list_tickets(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
Query(query): Query<TicketListQuery>, Query(query): Query<TicketListQuery>,
) -> ApiResult<Json<crate::records::TicketListResponse>> { ) -> ApiResult<Json<crate::records::TicketListResponse>> {
let requested_limit = query.limit.unwrap_or(api.config.max_records); let limit = query.limit.unwrap_or(30).clamp(1, 100);
let limit = requested_limit.min(1000); let states = query
let ProjectRecordList { .states
.as_deref()
.map(|states| {
states
.split(',')
.filter(|state| !state.is_empty())
.map(str::to_string)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let crate::records::TicketSummaryPage {
items, items,
page,
invalid_records, invalid_records,
record_authority, record_authority,
} = api.authority.list_tickets(limit)?; } = api
.authority
.list_ticket_page(crate::records::TicketListPageRequest {
states,
limit: Some(limit),
cursor: query.cursor,
})?;
Ok(Json(crate::records::TicketListResponse { Ok(Json(crate::records::TicketListResponse {
workspace_id: api.config.workspace_id, workspace_id: api.config.workspace_id,
limit, limit,
items, items,
page,
invalid_records, invalid_records,
record_authority, record_authority,
})) }))
@@ -11896,7 +12077,26 @@ impl From<Error> for ApiError {
ticket::TicketError::NotFound(_) => "ticket_not_found", ticket::TicketError::NotFound(_) => "ticket_not_found",
ticket::TicketError::Ambiguous { .. } => "ticket_ambiguous", ticket::TicketError::Ambiguous { .. } => "ticket_ambiguous",
ticket::TicketError::Locked { .. } => "ticket_locked", ticket::TicketError::Locked { .. } => "ticket_locked",
ticket::TicketError::Conflict(_) => "ticket_conflict", ticket::TicketError::Conflict(_)
| ticket::TicketError::StaleWorkflowState { .. }
| ticket::TicketError::InvalidWorkflowTransition { .. }
| ticket::TicketError::BlockingRelations(_)
| ticket::TicketError::OperationFingerprintMismatch { .. } => "ticket_conflict",
ticket::TicketError::MissingTargetRepository => {
"ticket_target_repository_missing"
}
ticket::TicketError::UnknownTargetRepository(_) => {
"ticket_target_repository_unknown"
}
ticket::TicketError::MissingTargetSelector(_) => {
"ticket_target_selector_missing"
}
ticket::TicketError::InvalidTargetSelector { .. } => {
"ticket_target_selector_invalid"
}
ticket::TicketError::TargetAuthorityUnavailable => {
"ticket_target_authority_unavailable"
}
ticket::TicketError::InvalidPathComponent(_) ticket::TicketError::InvalidPathComponent(_)
| ticket::TicketError::PathEscapesRoot { .. } => "invalid_ticket_request", | ticket::TicketError::PathEscapesRoot { .. } => "invalid_ticket_request",
ticket::TicketError::Io { .. } ticket::TicketError::Io { .. }
@@ -12465,6 +12665,54 @@ mod tests {
); );
} }
#[test]
fn recorded_completion_replay_is_identified_before_later_target_observation() {
let event = merge_request::MergeEvent {
event_id: "merge-event".into(),
sequence: 1,
operation_id: "operation".into(),
approval_event_id: "approval".into(),
approved_source_ref: "source".into(),
target_ref_before: "before".into(),
target_ref_after: "after".into(),
strategy: merge_request::MergeStrategy::FastForward,
resolution: merge_request::ConflictResolution::None,
merged_by: merge_request::WorkerIdentity {
runtime_id: "runtime".into(),
worker_id: "orchestrator".into(),
},
created_at: Utc::now(),
};
let thread = vec![merge_request::MergeRequestThreadEvent::Merge(event.clone())];
assert_eq!(
recorded_merge_completion(&thread, "operation"),
Some(&event)
);
assert!(recorded_merge_completion(&thread, "different").is_none());
assert!(require_completed_target_observation("later", "before", "after").is_err());
}
#[test]
fn merge_request_completion_records_only_an_observed_remote_target_update() {
require_completed_target_observation("after", "before", "after").unwrap();
let not_pushed =
require_completed_target_observation("before", "before", "after").unwrap_err();
assert!(matches!(
not_pushed.error,
Error::InvalidInput(ref message)
if message.contains("push the verified result from the Orchestrator Workdir")
));
let moved = require_completed_target_observation("other", "before", "after").unwrap_err();
assert!(matches!(
moved.error,
Error::InvalidInput(ref message)
if message.contains("moved outside completion evidence")
));
}
#[test] #[test]
fn worker_ticket_assignment_projects_coder_intent_and_run_acceptance() { fn worker_ticket_assignment_projects_coder_intent_and_run_acceptance() {
let initial_submit = vec![ let initial_submit = vec![
@@ -13562,7 +13810,7 @@ mod tests {
config.repositories = vec![ConfiguredRepository { config.repositories = vec![ConfiguredRepository {
id: TEST_REPOSITORY_ID.to_string(), id: TEST_REPOSITORY_ID.to_string(),
provider: "git".to_string(), provider: "git".to_string(),
uri: ".".to_string(), uri: workspace_root.display().to_string(),
path: workspace_root, path: workspace_root,
display_name: Some("Test Repository".to_string()), display_name: Some("Test Repository".to_string()),
default_selector: Some("HEAD".to_string()), default_selector: Some("HEAD".to_string()),
@@ -13719,7 +13967,7 @@ mod tests {
fn init_clean_git_workspace(path: &std::path::Path) { fn init_clean_git_workspace(path: &std::path::Path) {
for args in [ for args in [
vec!["init"], vec!["init", "--initial-branch=develop"],
vec!["config", "user.email", "test@example.invalid"], vec!["config", "user.email", "test@example.invalid"],
vec!["config", "user.name", "Yoi Test"], vec!["config", "user.name", "Yoi Test"],
] { ] {
@@ -13751,6 +13999,88 @@ mod tests {
} }
} }
#[tokio::test]
async fn mark_ready_resolves_workspace_target_and_closes_lifecycle_bypasses() {
let dir = tempfile::tempdir().unwrap();
init_clean_git_workspace(dir.path());
let api = test_api(dir.path()).await;
let backend = browser_ticket_backend(&api).unwrap();
let mut input = ticket::NewTicket::new("Validated target");
input.repository_id = Some(TEST_REPOSITORY_ID.to_owned());
input.ref_selector = Some("develop".to_owned());
let ticket_ref = backend.create(input).unwrap();
let request = ticket::TicketMarkReady {
operation_key: "ready-server-test".to_owned(),
reason: Some("target accepted".to_owned()),
author: Some("test".to_owned()),
intake_summary: None,
};
let ready = backend
.mark_ready(TicketIdOrSlug::Id(ticket_ref.id.clone()), request.clone())
.unwrap();
assert_eq!(ready.meta.workflow_state, TicketWorkflowState::Ready);
assert_eq!(
ready.meta.repository_id.as_deref(),
Some(TEST_REPOSITORY_ID)
);
assert_eq!(ready.meta.ref_selector.as_deref(), Some("develop"));
assert_eq!(
backend
.mark_ready(TicketIdOrSlug::Id(ticket_ref.id.clone()), request)
.unwrap()
.events
.iter()
.filter(|event| event.attributes.contains_key("operation_key"))
.count(),
1
);
assert!(matches!(
backend.edit_item(
TicketIdOrSlug::Id(ticket_ref.id.clone()),
ticket::TicketItemEdit {
target: Some(ticket::TicketTargetEdit::Set {
repository_id: TEST_REPOSITORY_ID.to_owned(),
ref_selector: Some("other".to_owned()),
}),
..Default::default()
},
),
Err(ticket::TicketError::Conflict(_))
));
let mut missing = ticket::NewTicket::new("Missing target");
missing.repository_id = Some("unknown".to_owned());
let missing = backend.create(missing).unwrap();
assert!(matches!(
backend.mark_ready(
TicketIdOrSlug::Id(missing.id.clone()),
ticket::TicketMarkReady {
operation_key: "missing-repository".to_owned(),
reason: None,
author: None,
intake_summary: None,
},
),
Err(ticket::TicketError::UnknownTargetRepository(_))
));
assert_eq!(
backend
.show(TicketIdOrSlug::Id(missing.id))
.unwrap()
.meta
.workflow_state,
TicketWorkflowState::Planning
);
assert!(matches!(
backend.set_workflow_state(
TicketIdOrSlug::Id(ticket_ref.id),
TicketStateChange::new("ready", "queued", "bypass", "must use TicketQueue",),
),
Err(ticket::TicketError::InvalidWorkflowTransition { .. })
));
}
#[test] #[test]
fn worker_source_actor_roles_use_canonical_vocabulary() { fn worker_source_actor_roles_use_canonical_vocabulary() {
assert_eq!(worker_source_actor_role(true, false), "coder"); assert_eq!(worker_source_actor_role(true, false), "coder");
@@ -13792,6 +14122,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn orchestrator_ticket_notifications_project_authoritative_post_mutation_state() { async fn orchestrator_ticket_notifications_project_authoritative_post_mutation_state() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
init_clean_git_workspace(dir.path());
let (api, execution) = test_api_with_recording_backend(dir.path()).await; let (api, execution) = test_api_with_recording_backend(dir.path()).await;
let source_worker = api let source_worker = api
.runtime .runtime
@@ -13849,29 +14180,24 @@ mod tests {
let orchestrator = started.worker.unwrap().worker; let orchestrator = started.worker.unwrap().worker;
execution.take_inputs(); execution.take_inputs();
let ticket = browser_ticket_backend(&api) let mut input = ticket::NewTicket::new("Bounded notification");
.unwrap() input.repository_id = Some(TEST_REPOSITORY_ID.to_owned());
.create(ticket::NewTicket::new("Bounded notification")) input.ref_selector = Some("develop".to_owned());
.unwrap(); let ticket = browser_ticket_backend(&api).unwrap().create(input).unwrap();
let ticket_id = TicketIdOrSlug::Id(ticket.id.clone()); let ticket_id = TicketIdOrSlug::Id(ticket.id.clone());
let operations = [ let operations = [
TicketBackendOperation::SetWorkflowState { TicketBackendOperation::MarkReady {
id: ticket_id.clone(), id: ticket_id.clone(),
change: TicketStateChange::new( request: ticket::TicketMarkReady {
"planning", operation_key: "notification-ready".to_owned(),
"ready", reason: Some("ready for implementation".to_owned()),
"ready for implementation", author: None,
"test transition", intake_summary: None,
), },
}, },
TicketBackendOperation::SetWorkflowState { TicketBackendOperation::QueueReady {
id: ticket_id.clone(), id: ticket_id.clone(),
change: TicketStateChange::new( queued_by: "spoofed".to_owned(),
"ready",
"queued",
"queued for implementation",
"test transition",
),
}, },
TicketBackendOperation::SetWorkflowState { TicketBackendOperation::SetWorkflowState {
id: ticket_id.clone(), id: ticket_id.clone(),
@@ -14276,30 +14602,11 @@ mod tests {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await; let api = test_api(dir.path()).await;
let backend = browser_ticket_backend(&api).unwrap(); let backend = browser_ticket_backend(&api).unwrap();
let ticket_ref = backend let mut input = ticket::NewTicket::new("Recover queued work");
.create(ticket::NewTicket::new("Recover queued work")) input.workflow_state = Some(TicketWorkflowState::Queued);
.unwrap(); input.repository_id = Some(TEST_REPOSITORY_ID.to_owned());
backend input.ref_selector = Some("HEAD".to_owned());
.mark_intake_ready( let ticket_ref = backend.create(input).unwrap();
TicketIdOrSlug::Id(ticket_ref.id.clone()),
ticket::TicketIntakeSummary {
author: Some("intake".to_string()),
body: MarkdownText::new("Ready"),
references: Vec::new(),
},
ticket::TicketStateChange {
from: "planning".to_string(),
to: "ready".to_string(),
reason: "ready".to_string(),
author: Some("intake".to_string()),
body: MarkdownText::new("Ready"),
references: Vec::new(),
},
)
.unwrap();
backend
.queue_ready(TicketIdOrSlug::Id(ticket_ref.id.clone()), "browser-user")
.unwrap();
*api.orchestrator_attention_fingerprint.lock().unwrap() = Some(ticket_ref.id.clone()); *api.orchestrator_attention_fingerprint.lock().unwrap() = Some(ticket_ref.id.clone());
let Json(started) = scoped_start_workspace_orchestrator( let Json(started) = scoped_start_workspace_orchestrator(
@@ -14725,6 +15032,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn ticket_browser_endpoints_mutate_typed_backend_and_return_thread() { async fn ticket_browser_endpoints_mutate_typed_backend_and_return_thread() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
init_clean_git_workspace(dir.path());
let api = test_api(dir.path()).await; let api = test_api(dir.path()).await;
let ticket_ref = browser_ticket_backend(&api) let ticket_ref = browser_ticket_backend(&api)
.unwrap() .unwrap()
@@ -14764,7 +15072,7 @@ mod tests {
replace_all: false, replace_all: false,
target: Some(TicketTargetEdit::Set { target: Some(TicketTargetEdit::Set {
repository_id: "main".to_string(), repository_id: "main".to_string(),
ref_selector: Some("feature/api".to_string()), ref_selector: Some("develop".to_string()),
}), }),
author: Some("browser-user".to_string()), author: Some("browser-user".to_string()),
}), }),
@@ -14774,7 +15082,7 @@ mod tests {
assert_eq!(edited.title, "Browser Ticket API edited"); assert_eq!(edited.title, "Browser Ticket API edited");
assert_eq!(edited.body, "Updated from the Browser API."); assert_eq!(edited.body, "Updated from the Browser API.");
assert_eq!(edited.repository_id.as_deref(), Some("main")); assert_eq!(edited.repository_id.as_deref(), Some("main"));
assert_eq!(edited.ref_selector.as_deref(), Some("feature/api")); assert_eq!(edited.ref_selector.as_deref(), Some("develop"));
assert_eq!(edited.assignee, None); assert_eq!(edited.assignee, None);
assert_eq!(edited.relations.outgoing.len(), 1); assert_eq!(edited.relations.outgoing.len(), 1);
assert_eq!(edited.relations.outgoing[0].target, related_ticket_id); assert_eq!(edited.relations.outgoing[0].target, related_ticket_id);
@@ -14795,14 +15103,13 @@ mod tests {
event.kind == "comment" && event.body.as_deref() == Some("API comment") event.kind == "comment" && event.body.as_deref() == Some("API comment")
})); }));
let Json(ready) = scoped_transition_ticket_state( let Json(ready) = scoped_mark_ticket_ready_from_browser(
State(api.clone()), State(api.clone()),
AxumPath(path()), AxumPath(path()),
Json(BrowserTransitionTicketStateRequest { Json(TicketMarkReadyRequest {
state: TicketWorkflowState::Ready, operation_key: "browser-ready".to_owned(),
reason: Some("intake complete".to_string()), reason: Some("intake complete".to_owned()),
body: Some("Ready for queue".to_string()), intake_summary: None,
author: Some("browser-user".to_string()),
}), }),
) )
.await .await
+33 -10
View File
@@ -196,6 +196,11 @@ const MIGRATIONS: &[Migration] = &[
name: "remove Worker control delegation authority", name: "remove Worker control delegation authority",
apply: remove_worker_control_delegation_authority, apply: remove_worker_control_delegation_authority,
}, },
Migration {
version: 36,
name: "add Objective query indexes",
apply: add_objective_query_indexes,
},
]; ];
struct Migration { struct Migration {
@@ -5046,6 +5051,24 @@ fn create_worker_control_delegation_operation_authority(conn: &Connection) -> Re
Ok(()) Ok(())
} }
fn add_objective_query_indexes(conn: &Connection) -> Result<()> {
conn.execute_batch(
r#"
CREATE INDEX IF NOT EXISTS objectives_workspace_state_updated
ON objectives(workspace_id, state, updated_at DESC, objective_id);
CREATE INDEX IF NOT EXISTS objectives_workspace_updated
ON objectives(workspace_id, updated_at DESC, objective_id);
CREATE INDEX IF NOT EXISTS objectives_workspace_created
ON objectives(workspace_id, created_at DESC, objective_id);
CREATE INDEX IF NOT EXISTS objectives_workspace_title
ON objectives(workspace_id, title COLLATE NOCASE, objective_id);
CREATE INDEX IF NOT EXISTS objective_ticket_links_workspace_ticket_objective
ON objective_ticket_links(workspace_id, ticket_id, objective_id);
"#,
)?;
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")?;
@@ -5799,7 +5822,7 @@ INSERT INTO worker_control_grants (
apply_migrations(&conn).unwrap(); apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 35); assert_eq!(current_schema_version(&conn).unwrap(), 36);
assert!(!table_exists(&conn, "worker_control_delegation_operations").unwrap()); assert!(!table_exists(&conn, "worker_control_delegation_operations").unwrap());
let (permissions_json, revoked_at): (String, Option<String>) = conn let (permissions_json, revoked_at): (String, Option<String>) = conn
.query_row( .query_row(
@@ -5847,7 +5870,7 @@ INSERT INTO worker_control_grants (
apply_migrations(&conn).unwrap(); apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 35); assert_eq!(current_schema_version(&conn).unwrap(), 36);
assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap()); assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap());
} }
@@ -5880,7 +5903,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(), 35); assert_eq!(current_schema_version(&conn).unwrap(), 36);
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());
@@ -5947,7 +5970,7 @@ INSERT INTO worker_workdir_attachment_reservations (
apply_migrations(&conn).unwrap(); apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 35); assert_eq!(current_schema_version(&conn).unwrap(), 36);
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'",
@@ -6127,7 +6150,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(), 35); assert_eq!(store.schema_version().await.unwrap(), 36);
assert!( assert!(
!store !store
.with_conn(|conn| table_exists(conn, "worker_workspace_credentials")) .with_conn(|conn| table_exists(conn, "worker_workspace_credentials"))
@@ -6144,7 +6167,7 @@ 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(), 35); assert_eq!(reopened.schema_version().await.unwrap(), 36);
assert_eq!( assert_eq!(
reopened.get_workspace("local-dev").await.unwrap(), reopened.get_workspace("local-dev").await.unwrap(),
Some(record) Some(record)
@@ -6691,7 +6714,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(), 35); assert_eq!(store.schema_version().await.unwrap(), 36);
store store
.with_conn(|conn| { .with_conn(|conn| {
@@ -6880,7 +6903,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(), 35); assert_eq!(store.schema_version().await.unwrap(), 36);
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,
@@ -6946,7 +6969,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(), 35); assert_eq!(store.schema_version().await.unwrap(), 36);
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,
@@ -7337,7 +7360,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(), 35); assert_eq!(store.schema_version().await.unwrap(), 36);
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(),
+29 -48
View File
@@ -12,8 +12,7 @@ use ticket::config::{
use ticket::{ use ticket::{
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation, LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation,
SqliteTicketBackend, TicketBackend, TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug, SqliteTicketBackend, TicketBackend, TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug,
TicketIntakeSummary, TicketListQuery, TicketListState, TicketRelationKind, TicketSummary, TicketListQuery, TicketListState, TicketRelationKind, TicketSummary, TicketWorkflowState,
TicketWorkflowState,
}; };
const DEFAULT_LIST_LIMIT: usize = 50; const DEFAULT_LIST_LIMIT: usize = 50;
@@ -630,8 +629,16 @@ fn state(
let id = TicketIdOrSlug::Query(options.query.clone()); let id = TicketIdOrSlug::Query(options.query.clone());
let target_state = match options.state { let target_state = match options.state {
StateTarget::Planning => TicketWorkflowState::Planning, StateTarget::Planning => TicketWorkflowState::Planning,
StateTarget::Ready => TicketWorkflowState::Ready, StateTarget::Ready => {
StateTarget::Queued => TicketWorkflowState::Queued, return Err(TicketCliError::new(
"ready requires Workspace repository authority; use the Browser Mark ready action or TicketMarkReady",
));
}
StateTarget::Queued => {
return Err(TicketCliError::new(
"queued is an Orchestrator operation; use TicketQueue after MarkReady succeeds",
));
}
StateTarget::InProgress => TicketWorkflowState::InProgress, StateTarget::InProgress => TicketWorkflowState::InProgress,
StateTarget::Done => { StateTarget::Done => {
return Err(TicketCliError::new( return Err(TicketCliError::new(
@@ -646,33 +653,16 @@ fn state(
}; };
let current = backend.show(id.clone())?; let current = backend.show(id.clone())?;
let ticket_id = current.meta.id.clone(); let ticket_id = current.meta.id.clone();
match target_state { let from = current.meta.workflow_state;
TicketWorkflowState::Ready => backend.mark_intake_ready( let change = ticket::TicketStateChange {
id, from: from.as_str().to_string(),
TicketIntakeSummary::new("Marked ready by `yoi ticket state`."), to: target_state.as_str().to_string(),
ticket::TicketStateChange { reason: "cli_state".to_string(),
from: current.meta.workflow_state.as_str().to_string(), author: Some("yoi ticket".to_string()),
to: TicketWorkflowState::Ready.as_str().to_string(), body: format!("State changed to `{}`.\n", target_state.as_str()).into(),
reason: "cli_state".to_string(), references: Vec::new(),
author: Some("yoi ticket".to_string()), };
body: "Marked ready by `yoi ticket state`.\n".into(), backend.set_workflow_state(id, change)?;
references: Vec::new(),
},
)?,
TicketWorkflowState::Queued => backend.queue_ready(id, "yoi ticket")?,
_ => {
let from = current.meta.workflow_state;
let change = ticket::TicketStateChange {
from: from.as_str().to_string(),
to: target_state.as_str().to_string(),
reason: "cli_state".to_string(),
author: Some("yoi ticket".to_string()),
body: format!("State changed to `{}`.\n", target_state.as_str()).into(),
references: Vec::new(),
};
backend.set_workflow_state(id, change)?;
}
}
Ok(success(format!( Ok(success(format!(
"state\t{}\t{}\n", "state\t{}\t{}\n",
ticket_id, ticket_id,
@@ -1335,23 +1325,14 @@ mod tests {
.contains(&format!("appended\t{}\timplementation_report", ticket_id)) .contains(&format!("appended\t{}\timplementation_report", ticket_id))
); );
let ready = run(&temp, &["state", &ticket_id, "ready"]); let ready_error = parse_ticket_args(&args(&["state", &ticket_id, "ready"]))
assert_eq!(ready.stdout, format!("state\t{}\tready\n", ticket_id)); .and_then(|cli| run_in_workspace(cli, temp.path()))
let ready_listed = run(&temp, &["list", "--state", "ready"]); .unwrap_err();
assert!(ready_listed.stdout.contains(&ticket_id)); assert!(ready_error.to_string().contains("TicketMarkReady"));
let queue_error = parse_ticket_args(&args(&["state", &ticket_id, "queued"]))
let queued = run(&temp, &["state", &ticket_id, "queued"]); .and_then(|cli| run_in_workspace(cli, temp.path()))
assert_eq!(queued.stdout, format!("state\t{}\tqueued\n", ticket_id)); .unwrap_err();
let queued_listed = run(&temp, &["list", "--state", "queued"]); assert!(queue_error.to_string().contains("TicketQueue"));
assert!(queued_listed.stdout.contains(&ticket_id));
let inprogress = run(&temp, &["state", &ticket_id, "inprogress"]);
assert_eq!(
inprogress.stdout,
format!("state\t{}\tinprogress\n", ticket_id)
);
let inprogress_listed = run(&temp, &["list", "--state", "inprogress"]);
assert!(inprogress_listed.stdout.contains(&ticket_id));
let done_error = parse_ticket_args(&args(&["state", &ticket_id, "done"])) let done_error = parse_ticket_args(&args(&["state", &ticket_id, "done"]))
.and_then(|cli| run_in_workspace(cli, temp.path())) .and_then(|cli| run_in_workspace(cli, temp.path()))
+5 -4
View File
@@ -31,14 +31,15 @@ Maintainers can inspect the local `.yoi/tickets/` files directly when debugging
## Ticket tools inside Workers ## Ticket tools inside Workers
Workers with the Ticket built-in feature can use typed Ticket tools: Workers with the Ticket and operation-specific Merge Request built-in features can use typed workflow tools:
- `TicketCreate` - `TicketCreate`
- `QueryTicket` — bounded authoritative Ticket discovery with typed state/text/event/evidence/relation/Objective/time/attention filters, stable snippets, and cursor metadata. - `QueryTicket` — bounded authoritative Ticket discovery with typed state/text/event/evidence/relation/Objective/time/attention filters, stable snippets, and cursor metadata.
- `ShowTicket` — detailed authority for one Ticket, including item revision, bounded thread/event references, relations, linked Objectives, implementation reports, and current Merge Request/review evidence. - `ShowTicket` — detailed authority for one Ticket, including item revision, bounded thread/event references, relations, linked Objectives, implementation reports, and current Merge Request/review evidence.
- `TicketComment` - `TicketComment`
- `MergeRequestShow`, `MergeRequestOpen`, `MergeRequestAddRevision`, `MergeRequestComplete` - Coder: `MergeRequestShow`, `MergeRequestOpen`
- `MergeRequestReviewSubmit` — available only inside the attested direct-child Reviewer attempt; attempt/revision capability material is not model input. - Reviewer: `MergeRequestShow`, `MergeRequestReview` — available only inside the attested direct-child Reviewer request; grant and subject-ref capability material are not model input.
- Orchestrator: `MergeRequestShow`, `MergeRequestReadinessCheck`, `MergeRequestComplete`
- `TicketClose` - `TicketClose`
- `TicketRelationRecord` - `TicketRelationRecord`
@@ -243,7 +244,7 @@ Implementation normally happens in a child git worktree created by the Orchestra
The assigned Coder launches the Reviewer as an actual direct-child `builtin:reviewer` SubWorker with read-only scope and a structured handoff bound to the current immutable Merge Request revision. Server authority revalidates the parent assignment, Runtime-owned child session, effective profile, one-shot review attempt, and revision; prose output is not approval. The assigned Coder launches the Reviewer as an actual direct-child `builtin:reviewer` SubWorker with read-only scope and a structured handoff bound to the current immutable Merge Request revision. Server authority revalidates the parent assignment, Runtime-owned child session, effective profile, one-shot review attempt, and revision; prose output is not approval.
The Reviewer records the structured result with `MergeRequestReviewSubmit`. Request changes requires a new immutable revision and a fresh child attempt. `MergeRequestComplete` performs guarded Ticket completion with operation-id dedupe/CAS semantics; Flow transitions are not completion authority. The Reviewer records the structured result with `MergeRequestReview`. Request changes requires a new immutable revision and a fresh child attempt. The Orchestrator uses `MergeRequestReadinessCheck` and then `MergeRequestComplete` for guarded integration with operation-id dedupe/CAS semantics; Flow transitions are not completion authority.
Blockers must be fixed or explicitly escalated before merge-ready submission. Blockers must be fixed or explicitly escalated before merge-ready submission.
+6 -6
View File
@@ -5,7 +5,7 @@
states = { states = {
implement = { implement = {
instructions = "Before editing, inspect the assigned Workdir's Git state. A newly delegated Git Workdir normally starts at a detached HEAD. If HEAD is detached, create and switch to a local branch named `work/<ticket-id>-<slug>`, using the canonical Ticket id and a short lowercase kebab-case slug derived from the Ticket title or implementation scope. If the Workdir is already on a suitable work branch after restore, keep it. Never delete, reset, or overwrite an existing branch to resolve a name collision; choose a concise collision-free suffix and report the actual branch. For this assigned Ticket Workdir, you are explicitly authorized to create or switch the local work branch and to use `git add` and `git commit`. Implement the requested Ticket scope, run the narrow and dependent validation required by the changed contracts, and record concrete evidence. Commit coherent, validated implementation slices while working. After the implementation is committed, validated, and clean, publish only the current Ticket work branch to the configured repository remote with a normal non-force push, then verify that the published source ref resolves to the exact current HEAD. Do not push the target branch, push tags or unrelated refs, force-push, merge, delete branches, or discard pre-existing changes. Open or update the Ticket Merge Request from that published source ref with immutable repository revision evidence. Before requesting independent review, confirm that the Workdir is clean, the published source ref and current HEAD are identical, and the current MR revision records that exact subject. A Flow transition is never Ticket completion authority."; instructions = "Inspect the assigned Workdir Git state before editing. Reuse a suitable restored `work/<ticket-id>-<slug>` branch, or create a collision-safe work branch from detached HEAD; never overwrite an existing branch. For this assigned Ticket Workdir, you are explicitly authorized to create or switch the local work branch and to use `git add` and `git commit`. Implement the requested Ticket scope, run the narrow and dependent validation required by the changed contracts, and record concrete evidence in coherent commits. After the implementation is committed, validated, and clean, publish only the current Ticket work branch to the Ticket repository remote with a normal non-force push, then verify that the published source selector resolves to the exact local HEAD. Do not push the target branch, push tags or unrelated refs, force-push, merge, delete branches, or discard pre-existing changes. Open or update the Ticket Merge Request with immutable `selector_from` / `selector_to` revision evidence. Do not request review from a dirty Workdir or an unpublished source ref. A Flow transition is never Ticket completion authority.";
transitions = { transitions = {
review = { review = {
target = "review"; target = "review";
@@ -15,7 +15,7 @@
}; };
review = { review = {
instructions = "Spawn one actual direct-child SubWorker with profile builtin:reviewer, read-only scope, and a structured review handoff bound to the current immutable Merge Request revision. The child must commit MergeRequestReviewSubmit; prose output and Worker observation are not approval authority. After the structured current-revision result exists, request a Flow transition."; instructions = "Use the current Ticket Merge Request as review authority. Confirm its immutable source selector resolves to the exact committed implementation HEAD, then spawn one actual direct-child SubWorker with profile builtin:reviewer, read-only scope, and a structured review handoff bound to the current immutable Merge Request revision. The trusted spawn layer records `ReviewRequested`; do not place commit/ref identity, capability material, or a prewritten verdict in model input. The child must commit MergeRequestReview; prose output and Worker observation are not approval authority. After the structured current-revision result exists, request a Flow transition.";
transitions = { transitions = {
approved = { approved = {
target = "complete"; target = "complete";
@@ -29,7 +29,7 @@
}; };
fix = { fix = {
instructions = "Resolve every open Reviewer finding on the same Ticket work branch, rerun the validation affected by the fixes, commit the corrected implementation as a new revision, and preserve concrete evidence. Publish the updated Ticket work branch with a normal non-force push, verify that the configured repository provider resolves the published source ref to the exact new HEAD, and update the linked Merge Request so its current revision records that same subject. Do not rewrite the previously reviewed commit, claim approval from the prior request_changes review, push the target branch, push tags or unrelated refs, force-push, merge, delete branches, or discard pre-existing changes. Request a Flow transition only after the corrected committed revision is published and ready for a new independent review."; instructions = "Resolve every open Reviewer finding on the same Ticket work branch, rerun the validation affected by the fixes, commit the corrected implementation as a new revision, and preserve concrete evidence. Publish only the updated Ticket work branch with a normal non-force push, verify that the configured repository provider resolves the published source ref to the exact new HEAD, and update the linked Merge Request so its current revision records that same subject. Request review from a fresh read-only Reviewer child so the trusted spawn layer captures the new immutable subject. Do not rewrite the previously reviewed commit, claim approval from the prior request_changes review, push the target branch, push tags or unrelated refs, force-push, merge, delete branches, or discard pre-existing changes. Request a Flow transition only after the corrected committed revision is published and ready for a new independent review.";
transitions = { transitions = {
review = { review = {
target = "review"; target = "review";
@@ -39,17 +39,17 @@
}; };
complete = { complete = {
instructions = "The exact current Merge Request subject has authoritative approval. Leave a concise human-facing summary only when useful, then hand off to the Orchestrator. Do not call MergeRequestComplete; approval and the current MR revision are the durable completion evidence. Request a Flow transition only after the Orchestrator's authoritative completion changes the Ticket to done."; instructions = "Verify the authoritative approval still matches the exact current Merge Request subject, keep the reviewed source ref immutable, leave concise implementation and validation evidence on the Ticket when useful, then hand off to the Orchestrator. Do not call MergeRequestComplete, update the target selector, or treat the Flow terminal state as Ticket completion authority. After durable handoff evidence exists, request a Flow transition.";
transitions = { transitions = {
completed = { completed = {
target = "done"; target = "done";
condition = "The Orchestrator completed the current approved Merge Request and the authoritative Ticket state is done. A Flow state or prose report alone is never sufficient."; condition = "The exact approved Merge Request revision and implementation evidence have been durably handed off to the Orchestrator. A Flow state or prose report alone is never Ticket completion authority.";
}; };
}; };
}; };
done = { done = {
instructions = "The guarded Merge Request completion operation committed Ticket state done. Flow terminal state only reflects that durable authority."; instructions = "The approved implementation has been handed off for Orchestrator-owned readiness and integration. Flow terminal state only reflects that handoff.";
terminal = true; terminal = true;
}; };
}; };
+7
View File
@@ -30,6 +30,13 @@ feature = {
worker = { enabled = false; }; worker = { enabled = false; };
objective = { enabled = true; }; objective = { enabled = true; };
ticket = { enabled = true; authoring = true; thread = true; }; ticket = { enabled = true; authoring = true; thread = true; };
merge_request = {
show = false;
open = false;
review = false;
readiness_check = false;
complete = false;
};
}; };
memory = { memory = {
+7
View File
@@ -12,5 +12,12 @@ import "./base.dcdl" // {
flow = { enabled = true; }; flow = { enabled = true; };
worker = { enabled = true; }; worker = { enabled = true; };
ticket = { enabled = true; thread = true; }; ticket = { enabled = true; thread = true; };
merge_request = {
show = true;
open = true;
review = false;
readiness_check = false;
complete = false;
};
}; };
} }
+7
View File
@@ -12,6 +12,13 @@ import "./base.dcdl" // {
worker = { enabled = true; direct_spawn = false; }; worker = { enabled = true; direct_spawn = false; };
manage_workdir = { enabled = true; }; manage_workdir = { enabled = true; };
ticket = { enabled = true; thread = true; workflow = true; }; ticket = { enabled = true; thread = true; workflow = true; };
merge_request = {
show = true;
open = false;
review = false;
readiness_check = true;
complete = true;
};
orchestration = { enabled = true; }; orchestration = { enabled = true; };
}; };
} }
+7
View File
@@ -11,5 +11,12 @@ import "./base.dcdl" // {
sub_worker = { enabled = false; }; sub_worker = { enabled = false; };
worker = { enabled = false; }; worker = { enabled = false; };
ticket = { enabled = true; thread = false; }; ticket = { enabled = true; thread = false; };
merge_request = {
show = true;
open = false;
review = true;
readiness_check = false;
complete = false;
};
}; };
} }
+2
View File
@@ -6,6 +6,7 @@ let
defaultDocument = import "./default.md"; defaultDocument = import "./default.md";
commonLanguage = import "./common/language.md"; commonLanguage = import "./common/language.md";
commonGit = import "./common/git.md"; commonGit = import "./common/git.md";
commonMergeRequest = import "./common/merge-request.md";
commonTickets = import "./common/tickets.md"; commonTickets = import "./common/tickets.md";
commonToolUsage = import "./common/tool-usage.md"; commonToolUsage = import "./common/tool-usage.md";
commonWorkerObservation = import "./common/worker-observation.md"; commonWorkerObservation = import "./common/worker-observation.md";
@@ -36,6 +37,7 @@ in
common = { common = {
git = commonGit.content; git = commonGit.content;
language = commonLanguage.content; language = commonLanguage.content;
merge_request = commonMergeRequest.content;
tickets = commonTickets.content; tickets = commonTickets.content;
tool_usage = commonToolUsage.content; tool_usage = commonToolUsage.content;
worker_observation = commonWorkerObservation.content; worker_observation = commonWorkerObservation.content;
+19
View File
@@ -0,0 +1,19 @@
## Merge Request workflow
Use only the exposed Merge Request operations; their availability expresses this Worker's workflow responsibility, not authorization to bypass Backend validation.
{% if "MergeRequestShow" in tools %}
- Reread the current Merge Request and append-only thread with `MergeRequestShow` before making review or integration decisions.
{% endif %}
{% if "MergeRequestOpen" in tools %}
- Open the Merge Request only after all intended changes are committed and the Workdir is clean. Use immutable source and target selectors; do not infer target authority from a branch name or cwd.
- Before requesting independent review, make the exact current MR revision authoritative.
{% endif %}
{% if "MergeRequestReview" in tools %}
- Review the exact current immutable MR revision independently. Submit the authoritative verdict through `MergeRequestReview`; prose alone is not approval.
{% endif %}
{% if "MergeRequestReadinessCheck" in tools %}
- Use `MergeRequestReadinessCheck` to resolve current refs and authoritative review readiness before integration.
{% endif %}
{% if "MergeRequestComplete" in tools %}
- Complete integration only after readiness confirms approval for the exact current revision and all target/ref guards pass. Merge completion is separate from implementation and review evidence.
{% endif %}
+4 -2
View File
@@ -2,8 +2,10 @@ You are the assigned Coder. Implement the requested scope in the provided Workdi
Treat the first committed user message as the bounded Ticket/action context and do not infer control-plane identity from prose. Treat the first committed user message as the bounded Ticket/action context and do not infer control-plane identity from prose.
Before opening a Merge Request, publish only the committed Ticket work branch with a normal non-force push and verify that the Ticket repository remote resolves it to the exact local `HEAD`; a local branch name or dirty Workdir is not immutable review evidence. Do not push the target branch, tags, or unrelated refs, and never force-push.
{% include "common.git" %} {% include "common.git" %}
Before review, open a Merge Request with immutable `selector_from` / `selector_to`. Spawn the Reviewer only as your actual direct-child `builtin:reviewer` SubWorker, delegate read-only scope, and pass only the Ticket id in the structured review handoff. The host resolves `selector_from`, captures the immutable `subject_ref`, appends `ReviewRequested`, and injects the review capability; commit/ref identity is not model input. Reviewer prose is not approval: the child must commit `MergeRequestReviewSubmit` through its injected capability authority. Before review, open a Merge Request with immutable `selector_from` / `selector_to`. Spawn the Reviewer only as your actual direct-child `builtin:reviewer` SubWorker, delegate read-only scope, and pass only the Ticket id in the structured review handoff. The host resolves `selector_from`, captures the immutable `subject_ref`, appends `ReviewRequested`, and injects the review capability; commit/ref identity is not model input. Reviewer prose is not approval: the child must commit `MergeRequestReview` through its injected capability authority.
A request-changes result requires a fresh Reviewer child request. Flow terminal state is not Ticket completion authority. After the exact current Merge Request subject has an authoritative approval, leave a concise human-facing summary when useful and hand off to the Orchestrator. Do not call `MergeRequestComplete`; approval and the current MR revision are the durable completion evidence. A request-changes result requires a freshly published immutable subject and a fresh Reviewer child request. Flow terminal state is not Ticket completion authority. After the exact current Merge Request subject has authoritative approval, keep that source ref immutable, leave concise implementation evidence on the Ticket when useful, and hand off integration to the Orchestrator. Do not update the target selector. Do not call `MergeRequestComplete`.
+6 -2
View File
@@ -2,11 +2,15 @@ You are the Ticket Orchestrator role.
{% include "common.git" %} {% include "common.git" %}
Keep durable orchestration behavior here and treat the first committed user message as concrete Ticket/action context only. Use typed Ticket tools and current repository state as authority. Record `inprogress` before implementation side effects, then use `SpawnTicketCoder` so Worker creation, the fixed Coder profile/Flow, and the current Ticket assignment are one guarded operation. After spawn, reread the Ticket and verify its current assignment names that Coder before asking it to implement; never route implementation to an unassigned Coder. Route implementation work to sibling Coder Workers. The human `ready -> queued` transition delegates guarded implementation, merging the current approved Merge Request, recording completion, and closing the Ticket to the Workspace Orchestrator by default; do not wait for a second merge confirmation. Stop only when the Ticket explicitly records a separate approval gate or completion requires a new decision outside the queued scope. Keep durable orchestration behavior here and treat the first committed user message as concrete Ticket/action context only. Use typed Ticket tools and current repository state as authority. Record `inprogress` before implementation side effects, then use `SpawnTicketCoder` so Worker creation, the fixed Coder profile/Flow, and the current Ticket assignment are one guarded operation. After spawn, reread the Ticket and verify its current assignment names that Coder before asking it to implement; never route implementation to an unassigned Coder. Route implementation work to sibling Coder Workers. The human `ready -> queued` transition delegates ordinary implementation, publication of the Ticket source work branch, guarded integration of the current approved Merge Request, recording completion, and closing the Ticket to the Workspace Orchestrator by default; do not wait for a second merge confirmation. This queue delegation does not grant broader repository authority from launch prose. Stop only when the Ticket explicitly records a separate approval gate or completion requires a new decision outside the queued scope.
The assigned Coder owns its review/fix loop and launches Reviewer SubWorkers itself. Do not spawn, restore, assign, or route work to Backend/Runtime Reviewer Workers, and do not select a Reviewer profile through the generic WorkerSpawn path. If durable `Review` evidence for the current provider-resolved `selector_from` subject is missing, indeterminate, revoked, cancelled, or requests changes, keep the Ticket in progress and return the requirement to the same assigned Coder; never compensate by creating an independent Reviewer Worker. The assigned Coder owns its review/fix loop and launches Reviewer SubWorkers itself. Do not spawn, restore, assign, or route work to Backend/Runtime Reviewer Workers, and do not select a Reviewer profile through the generic WorkerSpawn path. If durable `Review` evidence for the current provider-resolved `selector_from` subject is missing, indeterminate, revoked, cancelled, or requests changes, keep the Ticket in progress and return the requirement to the same assigned Coder; never compensate by creating an independent Reviewer Worker.
Treat the current linked Merge Request as implementation-completion authority. A current provider-resolved source ref, commit/repository evidence, an effective approval for that exact subject, review freshness after the latest substantive Ticket item edit, and no unresolved request-changes are sufficient; do not require an `implementation_report`. Human summaries remain optional audit context. Recheck `ShowTicket` and `MergeRequestReadinessCheck` immediately before guarded completion, and only the Orchestrator may call `MergeRequestComplete`. Treat the current linked Merge Request as implementation-completion authority. A current provider-resolved source ref, commit/repository evidence, an effective approval for that exact subject, review freshness after the latest substantive Ticket item edit, and no unresolved request-changes are sufficient; do not require an `implementation_report`. Human summaries remain optional audit context. Recheck `ShowTicket` and `MergeRequestReadinessCheck` immediately before guarded integration. Require the Merge Request source selector to remain on the exact reviewed commit; any source movement requires a fresh Reviewer attempt.
Before integration, run `MergeRequestReadinessCheck` and reread the Ticket, current assignment, and exact approved subject. In the Orchestrator Workdir, use the Ticket repository `origin` transport to fetch the current target selector and immutable source selector, verify both against readiness evidence, apply the selected fast-forward or merge strategy, and validate the resulting tree. Push only a result that descends from the observed target, using a guarded non-force push whose expected old target is `target_ref_before`; reject target movement and conflicts rather than rewriting the remote. Verify the remote target now resolves exactly to `target_ref_after`, then call `MergeRequestComplete` with that before/after evidence and the authoritative approval event. Never mutate a Server-side repository path or use local `git update-ref` as integration authority.
If the repository push succeeds but completion recording fails, do not push again or invent a new result. Retry the same completion operation and evidence: while no completion event exists, the Server requires the target to remain at the exact `target_ref_after` before it records `MergeResult`, moves the Ticket to `done`, and closes the current assignment atomically. Once that exact operation is recorded, later target movement does not invalidate an idempotent replay of the recorded result. Before recording, any other observed target is a stale/conflicting completion and must fail closed.
Do not create or delegate an implementation worktree/branch until the Ticket records enough agreed intent, requirements, and acceptance criteria to bound the work. Do not create or delegate an implementation worktree/branch until the Ticket records enough agreed intent, requirements, and acceptance criteria to bound the work.
+2 -2
View File
@@ -1,7 +1,7 @@
You are the Ticket Reviewer role running as an actual Runtime-owned direct child of the assigned Coder. You are the Ticket Reviewer role running as an actual Runtime-owned direct child of the assigned Coder.
Keep role behavior here and treat the first committed user message as bounded Ticket/Merge Request context only. Review the host-captured `ReviewRequested.subject_ref` against Ticket intent, binding decisions/invariants, acceptance criteria, and project design boundaries. Use read-only inspection and focused validation; do not merge, close, mutate the Workdir, or take over implementation. Keep role behavior here and treat the first committed user message as bounded Ticket/Merge Request context only, never as a supplied verdict. Review the host-captured `ReviewRequested.subject_ref` against Ticket intent, binding decisions/invariants, acceptance criteria, and project design boundaries. Use read-only inspection and focused validation; do not merge, close, mutate the Workdir, update a repository ref, or take over implementation.
Your prose response is not review authority. Before finishing, call `MergeRequestReviewSubmit` exactly once with `approve` or `request_changes`, a bounded evidence summary, and concrete structured findings. Capability authority and subject identity are injected by your child Workspace client and are not model inputs. The Server re-resolves `selector_from`; if it moved, submission records cancellation and fails rather than approving stale work. Your prose response is not review authority. Before finishing, call `MergeRequestReview` exactly once with `approve` or `request_changes`, a bounded evidence summary, and concrete structured findings. Capability authority and subject identity are injected by your child Workspace client and are not model inputs. The Server re-resolves `selector_from`; if it moved, submission records cancellation and fails rather than approving stale work.
Review more than the diff: verify the implementation satisfies the Ticket intent and acceptance criteria, remains coherent with the codebase design, and does not introduce unnecessary compatibility. Review more than the diff: verify the implementation satisfies the Ticket intent and acceptance criteria, remains coherent with the codebase design, and does not introduce unnecessary compatibility.
@@ -19,6 +19,7 @@ export type TicketListResponse = {
workspace_id: string; workspace_id: string;
limit: number; limit: number;
items: Array<TicketSummary>; items: Array<TicketSummary>;
page: QueryPage;
invalid_records: Array<InvalidProjectRecord>; invalid_records: Array<InvalidProjectRecord>;
record_authority: string; record_authority: string;
}; };
@@ -178,7 +178,9 @@
align-content: start; align-content: start;
gap: 0.55rem; gap: 0.55rem;
min-height: 0; min-height: 0;
max-height: min(68vh, 48rem);
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
padding: 0.6rem; padding: 0.6rem;
scrollbar-gutter: stable; scrollbar-gutter: stable;
} }
@@ -189,6 +191,27 @@
font-size: 0.7rem; font-size: 0.7rem;
text-align: center; text-align: center;
} }
.ticket-lane-page-state {
display: flex;
justify-content: center;
gap: 0.5rem;
margin: 0;
padding: 0.45rem;
color: var(--text-muted);
font-size: 0.72rem;
text-align: center;
}
.ticket-lane-page-error {
align-items: center;
color: var(--danger);
}
.ticket-lane-page-error button {
border: 1px solid var(--line);
border-radius: 0.35rem;
background: var(--bg-raised);
color: inherit;
padding: 0.2rem 0.45rem;
}
.ticket-card { .ticket-card {
display: grid; display: grid;
gap: 0.55rem; gap: 0.55rem;
@@ -11,16 +11,9 @@ import type {
} from "../../generated/ticket-api.ts"; } from "../../generated/ticket-api.ts";
declare const Deno: { declare const Deno: {
test(name: string, fn: () => Promise<void> | void): void; test(name: string, fn: () => void): void;
readTextFile(path: string): Promise<string>;
}; };
function assertIncludes(actual: string, expected: string): void {
if (!actual.includes(expected)) {
throw new Error(`expected source to include ${JSON.stringify(expected)}`);
}
}
function assertEquals<T>(actual: T, expected: T): void { function assertEquals<T>(actual: T, expected: T): void {
if (JSON.stringify(actual) !== JSON.stringify(expected)) { if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error( throw new Error(
@@ -115,29 +108,3 @@ Deno.test("ticket worker launch uses the common Worker route and bounded Ticket
"Work on Ticket 00001KYRRDVH9 as its reviewer.", "Work on Ticket 00001KYRRDVH9 as its reviewer.",
); );
}); });
Deno.test("ticket panel starts the Orchestrator explicitly and gates orchestration actions", async () => {
const panelSource = await Deno.readTextFile(
"src/routes/w/[workspaceId]/tickets/+page.svelte",
);
const detailSource = await Deno.readTextFile(
"src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
);
assertIncludes(
panelSource,
'workspaceApiPath(data.workspaceId, "/orchestrator")',
);
assertIncludes(panelSource, '{ method: "POST" }');
assertIncludes(panelSource, "Start Orchestrator");
assertIncludes(panelSource, "orchestrator.data?.online");
assertIncludes(panelSource, "lane.tickets.slice(0, lane.visibleCount)");
assertIncludes(
panelSource,
"onscroll={(event) => handleLaneScroll(event, lane.id)}",
);
assertIncludes(panelSource, "Scroll for");
assertIncludes(detailSource, "{#if orchestratorOnline}");
assertIncludes(detailSource, "!orchestratorOnline");
assertIncludes(detailSource, "Orchestrator offline");
});
@@ -1,44 +1,95 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { untrack } from "svelte";
import type { TicketListResponse } from "$lib/generated/ticket-api";
import type { ApiResult } from "$lib/workspace/api/http"; import type { ApiResult } from "$lib/workspace/api/http";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type {
QueryPage,
TicketListResponse,
TicketSummary,
} from "$lib/generated/ticket-api";
import { import {
nextTicketLaneVisibleCount,
TICKET_LANE_PAGE_SIZE,
ticketLanes, ticketLanes,
type TicketLane,
type TicketLaneId,
type WorkspaceOrchestratorStatus, type WorkspaceOrchestratorStatus,
} from "$lib/workspace/tickets/ticket-panel"; } from "$lib/workspace/tickets/ticket-panel";
import "$lib/workspace/styles/tickets.css"; import type { PageData } from "./$types";
type VisibleTicketLane = TicketLane & { visibleCount: number }; type LaneState = {
states: string[];
tickets: TicketSummary[];
page: QueryPage;
loading: boolean;
error: string | null;
};
const { data } = $props<{ let { data }: { data: PageData } = $props();
data: { // svelte-ignore state_referenced_locally
workspaceId: string; let laneState = $state<Record<string, LaneState>>(
tickets: ApiResult<TicketListResponse>; Object.fromEntries(
orchestrator: ApiResult<WorkspaceOrchestratorStatus>; Object.entries(data.ticketLanes).map(([laneId, lane]) => [
}; laneId,
}>(); {
states: [...lane.states],
let lanes = $state<VisibleTicketLane[]>( tickets: lane.response.items,
untrack(() => page: lane.response.page,
ticketLanes(data.tickets.data?.items ?? []).map((lane) => ({ loading: false,
...lane, error: null,
visibleCount: Math.min(TICKET_LANE_PAGE_SIZE, lane.tickets.length), },
})) ]),
), ),
); );
let orchestrator = $state<ApiResult<WorkspaceOrchestratorStatus>>( let orchestrator = $state<ApiResult<WorkspaceOrchestratorStatus>>(
untrack(() => data.orchestrator), untrack(() => data.orchestrator),
); );
let orchestratorStarting = $state(false); let orchestratorStarting = $state(false);
const displayedTicketCount = $derived( const tickets = $derived(
lanes.reduce((count, lane) => count + lane.visibleCount, 0), Object.values(laneState).flatMap((lane) => lane.tickets),
); );
const LANE_LOAD_THRESHOLD_PX = 96; const lanes = $derived(ticketLanes(tickets));
function mergeTickets(
current: TicketSummary[],
incoming: TicketSummary[],
): TicketSummary[] {
const byId = new Map(current.map((ticket) => [ticket.id, ticket]));
for (const ticket of incoming) byId.set(ticket.id, ticket);
return [...byId.values()];
}
async function loadMore(laneId: string): Promise<void> {
const lane = laneState[laneId];
if (!lane || lane.loading || !lane.page.has_more || !lane.page.next_cursor) {
return;
}
lane.loading = true;
lane.error = null;
try {
const search = new URLSearchParams({
limit: "30",
states: lane.states.join(","),
cursor: lane.page.next_cursor,
});
const response = await fetch(
`/api/w/${encodeURIComponent(data.workspaceId)}/tickets?${search}`,
);
if (!response.ok) {
throw new Error(`追加読み込みに失敗しました (${response.status})`);
}
const page = (await response.json()) as TicketListResponse;
lane.tickets = mergeTickets(lane.tickets, page.items);
lane.page = page.page;
} catch (error) {
lane.error = error instanceof Error ? error.message : String(error);
} finally {
lane.loading = false;
}
}
function handleLaneScroll(event: Event, laneId: string): void {
const container = event.currentTarget as HTMLElement;
const remaining =
container.scrollHeight - container.scrollTop - container.clientHeight;
if (remaining <= 96) void loadMore(laneId);
}
async function startOrchestrator() { async function startOrchestrator() {
if (orchestratorStarting || orchestrator.data?.online) return; if (orchestratorStarting || orchestrator.data?.online) return;
@@ -56,37 +107,20 @@
const date = new Date(value); const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString(); return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString();
} }
function revealNextTickets(laneId: TicketLaneId): void {
lanes = lanes.map((lane) =>
lane.id === laneId
? {
...lane,
visibleCount: nextTicketLaneVisibleCount(
lane.visibleCount,
lane.tickets.length,
),
}
: lane
);
}
function handleLaneScroll(event: Event, laneId: TicketLaneId): void {
const element = event.currentTarget as HTMLElement;
const distanceFromBottom = element.scrollHeight - element.scrollTop -
element.clientHeight;
if (distanceFromBottom <= LANE_LOAD_THRESHOLD_PX) {
revealNextTickets(laneId);
}
}
</script> </script>
<svelte:head><title>Tickets · Yoi</title></svelte:head> <svelte:head>
<title>Tickets · {data.workspaceId}</title>
</svelte:head>
<div class="workspace-page ticket-panel-page"> <div class="workspace-page ticket-panel-page">
<header class="workspace-page-header ticket-panel-header"> <header class="workspace-page-header ticket-panel-header">
<div> <div>
<p class="workspace-eyebrow">Delivery</p>
<h1>Tickets</h1> <h1>Tickets</h1>
<p class="workspace-page-lede">
Plan, route, review, and close work without leaving the workspace.
</p>
</div> </div>
<div class="ticket-panel-controls"> <div class="ticket-panel-controls">
<div class="orchestrator-status" data-online={orchestrator.data?.online ?? false}> <div class="orchestrator-status" data-online={orchestrator.data?.online ?? false}>
@@ -107,16 +141,12 @@
{/if} {/if}
</div> </div>
<div class="ticket-panel-summary" aria-label="Ticket summary"> <div class="ticket-panel-summary" aria-label="Ticket summary">
<strong>{displayedTicketCount}</strong> <strong>{tickets.length}</strong>
<span>tickets displayed</span> <span>loaded tickets</span>
</div> </div>
</div> </div>
</header> </header>
{#if data.tickets.error}
<p class="workspace-callout is-error">Tickets: {data.tickets.error}</p>
{/if}
{#if orchestrator.error} {#if orchestrator.error}
<p class="workspace-callout is-error"> <p class="workspace-callout is-error">
Orchestrator status: {orchestrator.error} Orchestrator status: {orchestrator.error}
@@ -129,24 +159,21 @@
<section class="ticket-kanban" aria-label="Ticket workflow board"> <section class="ticket-kanban" aria-label="Ticket workflow board">
{#each lanes as lane (lane.id)} {#each lanes as lane (lane.id)}
{@const displayedTickets = lane.tickets.slice(0, lane.visibleCount)} {@const pagination = laneState[lane.id]}
{@const hasMore = lane.visibleCount < lane.tickets.length}
<section class="ticket-lane" data-state={lane.id}> <section class="ticket-lane" data-state={lane.id}>
<header class="ticket-lane-header"> <header class="ticket-lane-header">
<div> <div>
<span class="ticket-state-dot"></span> <span class="ticket-state-dot"></span>
<h2>{lane.label}</h2> <h2>{lane.label}</h2>
</div> </div>
<span class="ticket-lane-count"> <span class="ticket-lane-count">{lane.tickets.length}</span>
{displayedTickets.length}{hasMore ? "+" : ""}
</span>
</header> </header>
<div <div
class="ticket-lane-cards" class="ticket-lane-cards"
data-lane-id={lane.id}
onscroll={(event) => handleLaneScroll(event, lane.id)} onscroll={(event) => handleLaneScroll(event, lane.id)}
> >
{#each displayedTickets as ticket (ticket.id)} {#each lane.tickets as ticket (ticket.id)}
<a <a
class="ticket-card" class="ticket-card"
href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(ticket.id)}`} href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(ticket.id)}`}
@@ -161,13 +188,15 @@
{:else} {:else}
<div class="ticket-lane-empty">No tickets</div> <div class="ticket-lane-empty">No tickets</div>
{/each} {/each}
{#if pagination?.loading}
{#if hasMore} <p class="ticket-lane-page-state" aria-live="polite">Loading…</p>
<p class="ticket-lane-load-status" aria-live="polite"> {:else if pagination?.error}
Scroll for {Math.min(TICKET_LANE_PAGE_SIZE, lane.tickets.length - lane.visibleCount)} more <div class="ticket-lane-page-state ticket-lane-page-error" role="alert">
</p> <span>{pagination.error}</span>
{:else if displayedTickets.length > 0} <button type="button" onclick={() => loadMore(lane.id)}>Retry</button>
<p class="ticket-lane-load-status">All tickets displayed.</p> </div>
{:else if pagination && !pagination.page.has_more && lane.tickets.length > 0}
<p class="ticket-lane-page-state">End of lane</p>
{/if} {/if}
</div> </div>
</section> </section>
@@ -1,14 +1,43 @@
import type { TicketListResponse } from "$lib/generated/ticket-api";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { TicketListResponse } from "$lib/generated/ticket-api";
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel"; import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
export const load = (async ({ fetch, params }) => { const LANE_STATES = {
"ready-planning": ["ready", "planning"],
"inprogress-queued": ["inprogress", "queued"],
"done-closed": ["done", "closed"],
} as const;
export type TicketLaneId = keyof typeof LANE_STATES;
export type TicketLanePage = {
states: readonly string[];
response: TicketListResponse;
};
export const load: PageLoad = async ({ fetch, params }) => {
const workspaceId = params.workspaceId; const workspaceId = params.workspaceId;
const [tickets, orchestrator] = await Promise.all([ const [entries, orchestrator] = await Promise.all([
loadJson<TicketListResponse>( Promise.all(
fetch, Object.entries(LANE_STATES).map(async ([laneId, states]) => {
`${workspaceApiPath(workspaceId, "/tickets")}?limit=1000`, const search = new URLSearchParams({
limit: "30",
states: states.join(","),
});
const response = await fetch(
`/api/w/${encodeURIComponent(workspaceId)}/tickets?${search}`,
);
if (!response.ok) {
throw new Error(
`failed to load ${laneId} Ticket lane (${response.status})`,
);
}
return [
laneId,
{ states: [...states], response: await response.json() },
] as const;
}),
), ),
loadJson<WorkspaceOrchestratorStatus>( loadJson<WorkspaceOrchestratorStatus>(
fetch, fetch,
@@ -16,5 +45,12 @@ export const load = (async ({ fetch, params }) => {
), ),
]); ]);
return { workspaceId, tickets, orchestrator }; return {
}) satisfies PageLoad; workspaceId,
ticketLanes: Object.fromEntries(entries) as unknown as Record<
TicketLaneId,
TicketLanePage
>,
orchestrator,
};
};
@@ -14,6 +14,7 @@
import type { ApiResult } from "$lib/workspace/api/http"; import type { ApiResult } from "$lib/workspace/api/http";
import type { import type {
RepositoryListResponse, RepositoryListResponse,
RepositorySummary,
TicketDetail, TicketDetail,
} from "$lib/workspace/sidebar/types"; } from "$lib/workspace/sidebar/types";
@@ -65,7 +66,9 @@
thread: MergeRequestThreadEvent[]; thread: MergeRequestThreadEvent[];
}; };
const MUTABLE_TICKET_STATES = TICKET_STATES.filter((state) => state !== "done"); const MUTABLE_TICKET_STATES = TICKET_STATES.filter((state) =>
state !== "done" && state !== "ready" && state !== "queued"
);
const { data } = $props<{ const { data } = $props<{
data: { data: {
@@ -115,6 +118,27 @@
let resolution = $state(""); let resolution = $state("");
let busy = $state<string | null>(null); let busy = $state<string | null>(null);
let errorMessage = $state<string | null>(null); let errorMessage = $state<string | null>(null);
let readyOperationKey = $state<string | null>(null);
const selectedRepository = $derived(
(loadedRepositories?.items ?? []).find((repository: RepositorySummary) => repository.id === repositoryId) ?? null,
);
const effectiveRefSelector = $derived(refSelector.trim() || selectedRepository?.default_ref || "");
const targetCandidateValid = $derived(
ticket.state === "planning" &&
selectedRepository !== null &&
(selectedRepository.diagnostics ?? []).length === 0 &&
effectiveRefSelector.length > 0,
);
const persistedTargetValid = $derived(
ticket.repository_id !== null &&
ticket.ref_selector !== null &&
(loadedRepositories?.items ?? []).some((repository: RepositorySummary) =>
repository.id === ticket.repository_id && (repository.diagnostics ?? []).length === 0
),
);
const implementationStartEligible = $derived(
persistedTargetValid && ticket.state !== "planning" && ticket.state !== "closed",
);
const ticketPath = $derived( const ticketPath = $derived(
workspaceApiPath( workspaceApiPath(
@@ -180,6 +204,33 @@
}, "PATCH"); }, "PATCH");
} }
async function markReady() {
if (!targetCandidateValid || busy) return;
if (
ticket.repository_id !== repositoryId ||
(ticket.ref_selector ?? "") !== refSelector.trim()
) {
const saved = await mutate("target", "", {
target: {
action: "set",
repository_id: repositoryId,
ref_selector: refSelector.trim() || null,
},
}, "PATCH");
if (!saved) return;
}
readyOperationKey ??= crypto.randomUUID();
if (
await mutate("ready", "/ready", {
operation_key: readyOperationKey,
reason: transitionReason.trim() || null,
})
) {
readyOperationKey = null;
transitionReason = "";
}
}
async function transition(event: SubmitEvent) { async function transition(event: SubmitEvent) {
event.preventDefault(); event.preventDefault();
if ( if (
@@ -329,16 +380,19 @@
<p class="ticket-assignment-line"> <p class="ticket-assignment-line">
Assigned to <strong>{ticket.assignee ?? "Unassigned"}</strong> Assigned to <strong>{ticket.assignee ?? "Unassigned"}</strong>
</p> </p>
{#if orchestratorOnline} {#if orchestratorOnline && implementationStartEligible}
<p>The Orchestrator is online. Start a role-specific Worker with the Ticket target below.</p> <p>The Orchestrator is online. Start a role-specific Worker with the validated Ticket target below.</p>
<div class="ticket-role-actions"> <div class="ticket-role-actions">
<a class="workspace-primary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket, "coder")}>Coder</a> <a class="workspace-primary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket, "coder")}>Coder</a>
</div> </div>
{:else} {:else}
<p class="workspace-callout">Start the Workspace Orchestrator from the Ticket panel before launching Ticket Workers.</p> <p class="workspace-callout">
{orchestratorOnline
? "Validate and persist the repository target before starting a Ticket Worker."
: "Start the Workspace Orchestrator from the Ticket panel before launching Ticket Workers."}
</p>
<div class="ticket-role-actions"> <div class="ticket-role-actions">
<button class="workspace-primary-button" type="button" disabled>Coder</button> <button class="workspace-primary-button" type="button" disabled>Coder</button>
<button class="workspace-secondary-button" type="button" disabled>Reviewer</button>
</div> </div>
{/if} {/if}
</section> </section>
@@ -347,15 +401,15 @@
<header><h2>Repository target</h2></header> <header><h2>Repository target</h2></header>
<form class="ticket-control-form" onsubmit={saveTarget}> <form class="ticket-control-form" onsubmit={saveTarget}>
<label>Repository <label>Repository
<select bind:value={repositoryId}> <select bind:value={repositoryId} disabled={ticket.state !== "planning"}>
<option value="">Not assigned</option> <option value="">Not assigned</option>
{#each loadedRepositories?.items ?? [] as repository} {#each loadedRepositories?.items ?? [] as repository}
<option value={repository.id}>{repository.display_name}</option> <option value={repository.id}>{repository.display_name}</option>
{/each} {/each}
</select> </select>
</label> </label>
<label>Ref selector<input bind:value={refSelector} placeholder="branch, tag, or revision" /></label> <label>Ref selector<input bind:value={refSelector} placeholder={selectedRepository?.default_ref ?? "branch, tag, or revision"} disabled={ticket.state !== "planning"} /></label>
<button class="workspace-secondary-button" type="submit" disabled={busy === "target"}> <button class="workspace-secondary-button" type="submit" disabled={busy === "target" || ticket.state !== "planning"}>
{busy === "target" ? "Saving…" : "Save target"} {busy === "target" ? "Saving…" : "Save target"}
</button> </button>
</form> </form>
@@ -374,8 +428,15 @@
Apply state Apply state
</button> </button>
</form> </form>
{#if ticket.state === "ready"} {#if ticket.state === "planning"}
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue" || !orchestratorOnline} onclick={() => mutate("queue", "/queue", {})}> <button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy !== null || !targetCandidateValid} onclick={markReady}>
{busy === "ready" ? "Marking ready…" : "Mark ready"}
</button>
{#if !targetCandidateValid}
<p class="workspace-empty-copy">Choose a healthy repository and an effective ref selector before marking ready.</p>
{/if}
{:else if ticket.state === "ready"}
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue" || !orchestratorOnline || !persistedTargetValid} onclick={() => mutate("queue", "/queue", {})}>
{busy === "queue" ? "Queueing…" : orchestratorOnline ? "Queue ticket" : "Orchestrator offline"} {busy === "queue" ? "Queueing…" : orchestratorOnline ? "Queue ticket" : "Orchestrator offline"}
</button> </button>
{/if} {/if}