5 Commits
27 changed files with 1751 additions and 409 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
+73
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> {
@@ -1361,6 +1392,7 @@ mod tests {
assert!(companion.feature.objective.enabled); assert!(companion.feature.objective.enabled);
assert!(!companion.feature.ticket.intake); assert!(!companion.feature.ticket.intake);
assert!(!companion.feature.orchestration.enabled); assert!(!companion.feature.orchestration.enabled);
assert!(!companion.feature.merge_request.any());
assert_eq!( assert_eq!(
companion.compaction.as_ref().unwrap().threshold, companion.compaction.as_ref().unwrap().threshold,
Some(240000) Some(240000)
@@ -1401,6 +1433,15 @@ mod tests {
assert!(!orchestrator.feature.sub_worker.enabled); assert!(!orchestrator.feature.sub_worker.enabled);
assert!(orchestrator.feature.worker.enabled); assert!(orchestrator.feature.worker.enabled);
assert!(!orchestrator.feature.worker.direct_spawn); assert!(!orchestrator.feature.worker.direct_spawn);
assert_eq!(
orchestrator.feature.merge_request,
crate::MergeRequestFeatureConfig {
show: true,
readiness_check: true,
complete: true,
..Default::default()
}
);
assert!(orchestrator.feature.ticket.enabled); assert!(orchestrator.feature.ticket.enabled);
assert!(orchestrator.feature.ticket.enabled); assert!(orchestrator.feature.ticket.enabled);
assert!(!orchestrator.feature.ticket.authoring); assert!(!orchestrator.feature.ticket.authoring);
@@ -1424,6 +1465,14 @@ mod tests {
assert!(coder.feature.sub_worker.enabled); assert!(coder.feature.sub_worker.enabled);
assert!(coder.feature.flow.enabled); assert!(coder.feature.flow.enabled);
assert!(!coder.feature.worker.enabled); assert!(!coder.feature.worker.enabled);
assert_eq!(
coder.feature.merge_request,
crate::MergeRequestFeatureConfig {
show: true,
open: true,
..Default::default()
}
);
assert!(coder.scope.allow.is_empty()); assert!(coder.scope.allow.is_empty());
assert!(coder.delegation_scope.allow.is_empty()); assert!(coder.delegation_scope.allow.is_empty());
assert_eq!(coder.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5")); assert_eq!(coder.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
@@ -1442,6 +1491,14 @@ mod tests {
assert!(!reviewer.feature.sub_worker.enabled); assert!(!reviewer.feature.sub_worker.enabled);
assert!(!reviewer.feature.flow.enabled); assert!(!reviewer.feature.flow.enabled);
assert!(!reviewer.feature.worker.enabled); assert!(!reviewer.feature.worker.enabled);
assert_eq!(
reviewer.feature.merge_request,
crate::MergeRequestFeatureConfig {
show: true,
review: true,
..Default::default()
}
);
assert!(reviewer.feature.ticket.enabled); assert!(reviewer.feature.ticket.enabled);
assert!(reviewer.feature.ticket.enabled); assert!(reviewer.feature.ticket.enabled);
assert!(!reviewer.feature.ticket.authoring); assert!(!reviewer.feature.ticket.authoring);
@@ -1604,6 +1661,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
"#, "#,
@@ -1626,6 +1691,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,
+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
+625 -114
View File
@@ -19,6 +19,7 @@ use project_record::{allocate_record_id, unix_epoch_millis_now, validate_record_
use rusqlite::{Connection, OptionalExtension, params}; use rusqlite::{Connection, OptionalExtension, params};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_yaml::{Mapping as YamlMapping, Value as YamlValue}; use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
use sha2::{Digest, Sha256};
use thiserror::Error; use thiserror::Error;
pub mod config; pub mod config;
@@ -80,6 +81,32 @@ pub enum TicketError {
Locked { path: PathBuf }, Locked { path: PathBuf },
#[error("ticket conflict: {0}")] #[error("ticket conflict: {0}")]
Conflict(String), Conflict(String),
#[error("ticket target repository is required")]
MissingTargetRepository,
#[error("ticket target repository `{0}` is not registered in this workspace")]
UnknownTargetRepository(String),
#[error("ticket target selector is required for repository `{0}`")]
MissingTargetSelector(String),
#[error(
"ticket target selector `{selector}` is invalid for repository `{repository_id}`: {reason}"
)]
InvalidTargetSelector {
repository_id: String,
selector: String,
reason: String,
},
#[error("ticket target authority is unavailable")]
TargetAuthorityUnavailable,
#[error("stale ticket workflow state: expected `{expected}`, found `{actual}`")]
StaleWorkflowState { expected: String, actual: String },
#[error("invalid ticket workflow transition `{from}` -> `{to}`")]
InvalidWorkflowTransition { from: String, to: String },
#[error("ticket has unresolved blocking relations: {0}")]
BlockingRelations(String),
#[error(
"ticket operation key `{operation_key}` was reused with a different request fingerprint"
)]
OperationFingerprintMismatch { operation_key: String },
#[error("SQLite ticket backend error: {0}")] #[error("SQLite ticket backend error: {0}")]
Sqlite(String), Sqlite(String),
#[error("ticket parse error in {path}: {message}")] #[error("ticket parse error in {path}: {message}")]
@@ -475,6 +502,37 @@ pub enum TicketTargetEdit {
Clear, Clear,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolvedTicketTarget {
pub repository_id: String,
pub ref_selector: String,
}
/// Workspace-owned authority used to resolve and validate implementation targets.
///
/// Ticket storage never infers repositories from cwd or repository paths. The
/// Workspace Backend supplies this boundary from its authoritative repository
/// catalog. Backends without it fail closed for ready/queue transitions.
pub trait TicketTargetAuthority: Send + Sync {
fn resolve_target(
&self,
workspace_id: &str,
repository_id: Option<&str>,
ref_selector: Option<&str>,
) -> Result<ResolvedTicketTarget>;
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketMarkReady {
pub operation_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub intake_summary: Option<TicketIntakeSummary>,
}
impl TicketTargetEdit { impl TicketTargetEdit {
fn validate(&self) -> Result<()> { fn validate(&self) -> Result<()> {
if let Self::Set { if let Self::Set {
@@ -504,6 +562,116 @@ fn validate_ticket_target(repository_id: Option<&str>, ref_selector: Option<&str
Ok(()) Ok(())
} }
fn resolve_ready_target(
authority: Option<&Arc<dyn TicketTargetAuthority>>,
workspace_id: &str,
ticket: &Ticket,
) -> Result<ResolvedTicketTarget> {
authority
.ok_or(TicketError::TargetAuthorityUnavailable)?
.resolve_target(
workspace_id,
ticket.meta.repository_id.as_deref(),
ticket.meta.ref_selector.as_deref(),
)
}
fn mark_ready_fingerprint(
ticket: &Ticket,
request: &TicketMarkReady,
target: &ResolvedTicketTarget,
) -> String {
let mut digest = Sha256::new();
digest.update(b"ticket.mark-ready.v1\0");
digest.update(ticket.meta.id.as_str().as_bytes());
digest.update(b"\0planning\0");
digest.update(target.repository_id.as_bytes());
digest.update(b"\0");
digest.update(target.ref_selector.as_bytes());
digest.update(b"\0");
if let Some(reason) = request.reason.as_deref() {
digest.update(reason.as_bytes());
}
if let Some(summary) = request.intake_summary.as_ref() {
digest.update(b"\0intake-summary\0");
digest.update(summary.body.as_str().as_bytes());
for reference in &summary.references {
digest.update(b"\0");
digest.update(reference.kind.as_bytes());
digest.update(b":");
digest.update(reference.target.as_bytes());
}
}
digest
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
fn validate_mark_ready_replay(ticket: &Ticket, request: &TicketMarkReady) -> Result<bool> {
let Some(event) = ticket
.events
.iter()
.find(|event| event.attributes.get("operation_key") == Some(&request.operation_key))
else {
return Ok(false);
};
let target = ResolvedTicketTarget {
repository_id: event
.attributes
.get("repository_id")
.cloned()
.ok_or_else(|| {
TicketError::Conflict("mark-ready event is missing repository_id".to_owned())
})?,
ref_selector: event
.attributes
.get("ref_selector")
.cloned()
.ok_or_else(|| {
TicketError::Conflict("mark-ready event is missing ref_selector".to_owned())
})?,
};
let fingerprint = mark_ready_fingerprint(ticket, request, &target);
if event.attributes.get("request_fingerprint") != Some(&fingerprint) {
return Err(TicketError::OperationFingerprintMismatch {
operation_key: request.operation_key.clone(),
});
}
if ticket.meta.workflow_state != TicketWorkflowState::Ready
|| ticket.meta.repository_id.as_deref() != Some(target.repository_id.as_str())
|| ticket.meta.ref_selector.as_deref() != Some(target.ref_selector.as_str())
{
return Err(TicketError::StaleWorkflowState {
expected: TicketWorkflowState::Ready.as_str().to_owned(),
actual: ticket.meta.workflow_state.as_str().to_owned(),
});
}
Ok(true)
}
fn validate_generic_state_change(
current: TicketWorkflowState,
to: TicketWorkflowState,
) -> Result<()> {
if current == TicketWorkflowState::Planning && to == TicketWorkflowState::Ready
|| current == TicketWorkflowState::Ready && to == TicketWorkflowState::Queued
{
return Err(TicketError::InvalidWorkflowTransition {
from: current.as_str().to_owned(),
to: to.as_str().to_owned(),
});
}
if !TicketWorkflowState::is_role_transition(current, to) {
return Err(TicketError::InvalidWorkflowTransition {
from: current.as_str().to_owned(),
to: to.as_str().to_owned(),
});
}
Ok(())
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketItemEdit { pub struct TicketItemEdit {
pub title: Option<String>, pub title: Option<String>,
@@ -1524,12 +1692,7 @@ pub trait TicketBackend {
change: TicketStateChange, change: TicketStateChange,
) -> Result<()>; ) -> Result<()>;
fn set_workflow_state(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()>; fn set_workflow_state(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()>;
fn mark_intake_ready( fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> Result<Ticket>;
&self,
id: TicketIdOrSlug,
summary: TicketIntakeSummary,
change: TicketStateChange,
) -> Result<()>;
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result<()>; fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result<()>;
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()>; fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()>;
fn add_ticket_relation( fn add_ticket_relation(
@@ -1605,10 +1768,9 @@ pub enum TicketBackendOperation {
id: TicketIdOrSlug, id: TicketIdOrSlug,
change: TicketStateChange, change: TicketStateChange,
}, },
MarkIntakeReady { MarkReady {
id: TicketIdOrSlug, id: TicketIdOrSlug,
summary: TicketIntakeSummary, request: TicketMarkReady,
change: TicketStateChange,
}, },
QueueReady { QueueReady {
id: TicketIdOrSlug, id: TicketIdOrSlug,
@@ -1710,13 +1872,8 @@ where
backend.set_workflow_state(id, change)?; backend.set_workflow_state(id, change)?;
TicketBackendOperationResult::Unit TicketBackendOperationResult::Unit
} }
TicketBackendOperation::MarkIntakeReady { TicketBackendOperation::MarkReady { id, request } => {
id, TicketBackendOperationResult::Ticket(backend.mark_ready(id, request)?)
summary,
change,
} => {
backend.mark_intake_ready(id, summary, change)?;
TicketBackendOperationResult::Unit
} }
TicketBackendOperation::QueueReady { id, queued_by } => { TicketBackendOperation::QueueReady { id, queued_by } => {
backend.queue_ready(id, &queued_by)?; backend.queue_ready(id, &queued_by)?;
@@ -1756,10 +1913,25 @@ where
}) })
} }
#[derive(Debug, Clone)] #[derive(Clone)]
pub struct LocalTicketBackend { pub struct LocalTicketBackend {
root: PathBuf, root: PathBuf,
record_language: Option<String>, record_language: Option<String>,
target_authority: Option<Arc<dyn TicketTargetAuthority>>,
}
impl fmt::Debug for LocalTicketBackend {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("LocalTicketBackend")
.field("root", &self.root)
.field("record_language", &self.record_language)
.field(
"target_authority",
&self.target_authority.as_ref().map(|_| "configured"),
)
.finish()
}
} }
impl LocalTicketBackend { impl LocalTicketBackend {
@@ -1767,6 +1939,7 @@ impl LocalTicketBackend {
Self { Self {
root: root.into(), root: root.into(),
record_language: None, record_language: None,
target_authority: None,
} }
} }
@@ -1775,6 +1948,11 @@ impl LocalTicketBackend {
self self
} }
pub fn with_target_authority(mut self, authority: Arc<dyn TicketTargetAuthority>) -> Self {
self.target_authority = Some(authority);
self
}
pub fn record_language(&self) -> Option<&str> { pub fn record_language(&self) -> Option<&str> {
self.record_language.as_deref() self.record_language.as_deref()
} }
@@ -2076,6 +2254,16 @@ impl LocalTicketBackend {
dir: &Path, dir: &Path,
change: &TicketStateChange, change: &TicketStateChange,
state_field: Option<&str>, state_field: Option<&str>,
) -> Result<()> {
self.append_state_changed_event_with_attributes(dir, change, state_field, &[])
}
fn append_state_changed_event_with_attributes(
&self,
dir: &Path,
change: &TicketStateChange,
state_field: Option<&str>,
extra_attributes: &[(&str, &str)],
) -> Result<()> { ) -> Result<()> {
validate_state_change(change)?; validate_state_change(change)?;
let author = change.author.clone().unwrap_or_else(default_author); let author = change.author.clone().unwrap_or_else(default_author);
@@ -2087,6 +2275,7 @@ impl LocalTicketBackend {
if let Some(state_field) = state_field { if let Some(state_field) = state_field {
attrs.push(("field", state_field)); attrs.push(("field", state_field));
} }
attrs.extend_from_slice(extra_attributes);
self.append_thread_event( self.append_thread_event(
dir, dir,
TicketEventKind::StateChanged.as_str(), TicketEventKind::StateChanged.as_str(),
@@ -2247,6 +2436,7 @@ pub struct SqliteTicketBackend {
record_language: Option<String>, record_language: Option<String>,
event_attributes: BTreeMap<String, String>, event_attributes: BTreeMap<String, String>,
mutation_hook: Option<Arc<SqliteTicketMutationHook>>, mutation_hook: Option<Arc<SqliteTicketMutationHook>>,
target_authority: Option<Arc<dyn TicketTargetAuthority>>,
#[cfg(test)] #[cfg(test)]
full_ticket_load_count: Arc<AtomicUsize>, full_ticket_load_count: Arc<AtomicUsize>,
} }
@@ -2263,6 +2453,10 @@ impl fmt::Debug for SqliteTicketBackend {
"mutation_hook", "mutation_hook",
&self.mutation_hook.as_ref().map(|_| "configured"), &self.mutation_hook.as_ref().map(|_| "configured"),
) )
.field(
"target_authority",
&self.target_authority.as_ref().map(|_| "configured"),
)
.finish() .finish()
} }
} }
@@ -2275,6 +2469,7 @@ impl SqliteTicketBackend {
record_language: None, record_language: None,
event_attributes: BTreeMap::new(), event_attributes: BTreeMap::new(),
mutation_hook: None, mutation_hook: None,
target_authority: None,
#[cfg(test)] #[cfg(test)]
full_ticket_load_count: Arc::new(AtomicUsize::new(0)), full_ticket_load_count: Arc::new(AtomicUsize::new(0)),
} }
@@ -2316,6 +2511,11 @@ impl SqliteTicketBackend {
self self
} }
pub fn with_target_authority(mut self, authority: Arc<dyn TicketTargetAuthority>) -> Self {
self.target_authority = Some(authority);
self
}
pub fn db_path(&self) -> &Path { pub fn db_path(&self) -> &Path {
self.db_path.as_path() self.db_path.as_path()
} }
@@ -3166,6 +3366,15 @@ impl TicketBackend for SqliteTicketBackend {
validate_required_event_value("author", author)?; validate_required_event_value("author", author)?;
} }
let ticket_id = self.resolve_ticket_id(conn, id)?; let ticket_id = self.resolve_ticket_id(conn, id)?;
if edit.target.is_some() {
let current = self.load_ticket(conn, &ticket_id)?.meta.workflow_state;
if current != TicketWorkflowState::Planning {
return Err(TicketError::Conflict(format!(
"ticket implementation target is locked after planning (current state: {})",
current.as_str()
)));
}
}
let now = now_utc(); let now = now_utc();
let mut body_edit_audit = TicketBodyEditAudit::None; let mut body_edit_audit = TicketBodyEditAudit::None;
if let Some(title) = edit.title.as_ref() { if let Some(title) = edit.title.as_ref() {
@@ -3331,10 +3540,41 @@ impl TicketBackend for SqliteTicketBackend {
} }
fn set_workflow_state(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()> { fn set_workflow_state(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()> {
validate_state_change(&change)?;
let from = TicketWorkflowState::parse(&change.from).ok_or_else(|| {
TicketError::InvalidWorkflowTransition {
from: change.from.clone(),
to: change.to.clone(),
}
})?;
let to = TicketWorkflowState::parse(&change.to).ok_or_else(|| {
TicketError::InvalidWorkflowTransition {
from: change.from.clone(),
to: change.to.clone(),
}
})?;
validate_generic_state_change(from, to)?;
self.with_write(|conn| { self.with_write(|conn| {
validate_state_change(&change)?;
let ticket_id = self.resolve_ticket_id(conn, id)?; let ticket_id = self.resolve_ticket_id(conn, id)?;
let to = TicketWorkflowState::parse(&change.to).ok_or_else(|| TicketError::Conflict(format!("unknown workflow_state '{}':", change.to)))?; let current = self.load_ticket(conn, &ticket_id)?.meta.workflow_state;
if current != from {
return Err(TicketError::StaleWorkflowState {
expected: from.as_str().to_owned(),
actual: current.as_str().to_owned(),
});
}
if from == TicketWorkflowState::Queued && to == TicketWorkflowState::InProgress {
let ticket = self.load_ticket(conn, &ticket_id)?;
let blockers = ticket
.relations
.blockers
.into_iter()
.filter(|blocker| !relation_blocker_allows_queue(blocker))
.collect::<Vec<_>>();
if !blockers.is_empty() {
return Err(TicketError::BlockingRelations(format_relation_blockers(&blockers)));
}
}
let at = now_utc(); let at = now_utc();
self.insert_event(conn, &ticket_id, &TicketEvent { kind: TicketEventKind::StateChanged, author: Some(change.author.clone().unwrap_or_else(default_author)), at: Some(at.clone()), status: None, from: Some(change.from), to: Some(change.to), reason: Some(change.reason), state_field: Some("state".to_string()), heading: Some(TicketEventKind::StateChanged.heading()), body: change.body, references: change.references, attributes: BTreeMap::new() })?; self.insert_event(conn, &ticket_id, &TicketEvent { kind: TicketEventKind::StateChanged, author: Some(change.author.clone().unwrap_or_else(default_author)), at: Some(at.clone()), status: None, from: Some(change.from), to: Some(change.to), reason: Some(change.reason), state_field: Some("state".to_string()), heading: Some(TicketEventKind::StateChanged.heading()), body: change.body, references: change.references, attributes: BTreeMap::new() })?;
conn.execute("UPDATE typed_tickets SET workflow_state = ?3, workflow_state_explicit = 1, updated_at = ?4, status = CASE WHEN ?3 = 'closed' THEN 'closed' ELSE status END WHERE workspace_id = ?1 AND ticket_id = ?2", params![self.workspace_id, ticket_id, to.as_str(), at]).map_err(sqlite_err)?; conn.execute("UPDATE typed_tickets SET workflow_state = ?3, workflow_state_explicit = 1, updated_at = ?4, status = CASE WHEN ?3 = 'closed' THEN 'closed' ELSE status END WHERE workspace_id = ?1 AND ticket_id = ?2", params![self.workspace_id, ticket_id, to.as_str(), at]).map_err(sqlite_err)?;
@@ -3342,24 +3582,120 @@ impl TicketBackend for SqliteTicketBackend {
}) })
} }
fn mark_intake_ready( fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> Result<Ticket> {
&self, validate_required_event_value("operation_key", &request.operation_key)?;
id: TicketIdOrSlug,
summary: TicketIntakeSummary,
change: TicketStateChange,
) -> Result<()> {
self.add_intake_summary(id.clone(), summary)?;
self.set_workflow_state(id, change)
}
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result<()> {
self.with_write(|conn| { self.with_write(|conn| {
let ticket_id = self.resolve_ticket_id(conn, id)?; let ticket_id = self.resolve_ticket_id(conn, id)?;
let ticket = self.load_ticket(conn, &ticket_id)?; let ticket = self.load_ticket(conn, &ticket_id)?;
if ticket.meta.workflow_state != TicketWorkflowState::Ready { return Err(TicketError::Conflict(format!("Ticket state is {}; only ready Tickets can be queued", ticket.meta.workflow_state.as_str()))); } if validate_mark_ready_replay(&ticket, &request)? {
return Ok(ticket);
}
let target = resolve_ready_target(
self.target_authority.as_ref(),
&self.workspace_id,
&ticket,
)?;
let fingerprint = mark_ready_fingerprint(&ticket, &request, &target);
if ticket.meta.workflow_state != TicketWorkflowState::Planning {
return Err(TicketError::StaleWorkflowState {
expected: TicketWorkflowState::Planning.as_str().to_owned(),
actual: ticket.meta.workflow_state.as_str().to_owned(),
});
}
let reason = request
.reason
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("implementation target validated")
.to_owned();
let at = now_utc(); let at = now_utc();
conn.execute("UPDATE typed_tickets SET workflow_state = 'queued', workflow_state_explicit = 1, queued_by = ?3, queued_at = ?4, updated_at = ?4 WHERE workspace_id = ?1 AND ticket_id = ?2", params![self.workspace_id, ticket_id, queued_by, at]).map_err(sqlite_err)?; if let Some(mut summary) = request.intake_summary.clone() {
self.insert_event(conn, &ticket_id, &TicketEvent { kind: TicketEventKind::StateChanged, author: Some(queued_by.to_string()), at: Some(at), status: None, from: Some("ready".to_string()), to: Some("queued".to_string()), reason: Some("queued".to_string()), state_field: Some("state".to_string()), heading: Some(TicketEventKind::StateChanged.heading()), body: MarkdownText::new(format!("Queued for Orchestrator by {queued_by}.")), references: Vec::new(), attributes: BTreeMap::new() }) validate_intake_summary(&summary)?;
summary.author = request.author.clone().or(summary.author);
self.insert_event(
conn,
&ticket_id,
&TicketEvent {
kind: TicketEventKind::IntakeSummary,
author: summary.author,
at: None,
status: None,
from: None,
to: None,
reason: None,
state_field: None,
heading: Some(TicketEventKind::IntakeSummary.heading()),
body: summary.body,
references: summary.references,
attributes: BTreeMap::new(),
},
)?;
}
self.insert_event(
conn,
&ticket_id,
&TicketEvent {
kind: TicketEventKind::StateChanged,
author: Some(request.author.unwrap_or_else(default_author)),
at: Some(at.clone()),
status: None,
from: Some(TicketWorkflowState::Planning.as_str().to_owned()),
to: Some(TicketWorkflowState::Ready.as_str().to_owned()),
reason: Some(reason),
state_field: Some("state".to_owned()),
heading: Some(TicketEventKind::StateChanged.heading()),
body: MarkdownText::new(format!(
"Implementation target `{}` at selector `{}` was validated and the Ticket was marked ready.",
target.repository_id, target.ref_selector
)),
references: Vec::new(),
attributes: BTreeMap::from([
("operation_key".to_owned(), request.operation_key),
("request_fingerprint".to_owned(), fingerprint),
("repository_id".to_owned(), target.repository_id.clone()),
("ref_selector".to_owned(), target.ref_selector.clone()),
]),
},
)?;
conn.execute(
"UPDATE typed_tickets SET workflow_state = 'ready', workflow_state_explicit = 1, repository_id = ?3, ref_selector = ?4, updated_at = ?5 WHERE workspace_id = ?1 AND ticket_id = ?2 AND workflow_state = 'planning'",
params![self.workspace_id, ticket_id, target.repository_id, target.ref_selector, at],
)
.map_err(sqlite_err)?;
self.load_ticket(conn, &ticket_id)
})
}
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result<()> {
validate_required_event_value("queued_by", queued_by)?;
self.with_write(|conn| {
let ticket_id = self.resolve_ticket_id(conn, id)?;
let ticket = self.load_ticket(conn, &ticket_id)?;
if ticket.meta.workflow_state != TicketWorkflowState::Ready {
return Err(TicketError::StaleWorkflowState {
expected: TicketWorkflowState::Ready.as_str().to_owned(),
actual: ticket.meta.workflow_state.as_str().to_owned(),
});
}
let target = resolve_ready_target(
self.target_authority.as_ref(),
&self.workspace_id,
&ticket,
)?;
let blockers = ticket
.relations
.blockers
.iter()
.filter(|blocker| !relation_blocker_allows_queue(blocker))
.cloned()
.collect::<Vec<_>>();
if !blockers.is_empty() {
return Err(TicketError::BlockingRelations(format_relation_blockers(&blockers)));
}
let at = now_utc();
conn.execute("UPDATE typed_tickets SET workflow_state = 'queued', workflow_state_explicit = 1, queued_by = ?3, queued_at = ?4, repository_id = ?5, ref_selector = ?6, updated_at = ?4 WHERE workspace_id = ?1 AND ticket_id = ?2 AND workflow_state = 'ready'", params![self.workspace_id, ticket_id, queued_by, at, target.repository_id, target.ref_selector]).map_err(sqlite_err)?;
self.insert_event(conn, &ticket_id, &TicketEvent { kind: TicketEventKind::StateChanged, author: Some(queued_by.to_string()), at: Some(at.clone()), status: None, from: Some("ready".to_string()), to: Some("queued".to_string()), reason: Some("queued".to_string()), state_field: Some("state".to_string()), heading: Some(TicketEventKind::StateChanged.heading()), body: MarkdownText::new(format!("Queued for Orchestrator by {queued_by}.")), references: Vec::new(), attributes: BTreeMap::from([("queued_by".to_owned(), queued_by.to_owned()), ("queued_at".to_owned(), at), ("repository_id".to_owned(), target.repository_id), ("ref_selector".to_owned(), target.ref_selector)]) })
}) })
} }
@@ -3676,6 +4012,15 @@ impl TicketBackend for LocalTicketBackend {
let _lock = self.acquire_lock()?; let _lock = self.acquire_lock()?;
let dir = self.find_ticket_dir(&id)?; let dir = self.find_ticket_dir(&id)?;
let item = dir.join("item.md"); let item = dir.join("item.md");
if edit.target.is_some() {
let current = self.ticket_workflow_state_from_dir(&dir)?;
if current != TicketWorkflowState::Planning {
return Err(TicketError::Conflict(format!(
"ticket implementation target is locked after planning (current state: {})",
current.as_str()
)));
}
}
let mut content = fs::read_to_string(&item).map_err(|e| io_err(&item, e))?; let mut content = fs::read_to_string(&item).map_err(|e| io_err(&item, e))?;
let mut body_edit_audit = TicketBodyEditAudit::None; let mut body_edit_audit = TicketBodyEditAudit::None;
let mut updates = Vec::new(); let mut updates = Vec::new();
@@ -3892,13 +4237,7 @@ impl TicketBackend for LocalTicketBackend {
change.to change.to
)) ))
})?; })?;
if !TicketWorkflowState::is_role_transition(from, to) { validate_generic_state_change(from, to)?;
return Err(TicketError::Conflict(format!(
"workflow_state transition {} -> {} is not allowed through set_workflow_state; use dedicated planning-ready or queue APIs for gated transitions",
from.as_str(),
to.as_str()
)));
}
let _lock = self.acquire_lock()?; let _lock = self.acquire_lock()?;
let dir = self.find_ticket_dir(&id)?; let dir = self.find_ticket_dir(&id)?;
if from == TicketWorkflowState::Queued && to == TicketWorkflowState::InProgress { if from == TicketWorkflowState::Queued && to == TicketWorkflowState::InProgress {
@@ -3916,43 +4255,62 @@ impl TicketBackend for LocalTicketBackend {
self.apply_workflow_state_change(&dir, from, to, change, &[]) self.apply_workflow_state_change(&dir, from, to, change, &[])
} }
fn mark_intake_ready( fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> Result<Ticket> {
&self, validate_required_event_value("operation_key", &request.operation_key)?;
id: TicketIdOrSlug,
summary: TicketIntakeSummary,
change: TicketStateChange,
) -> Result<()> {
let from = TicketWorkflowState::parse(&change.from).ok_or_else(|| {
TicketError::Conflict(format!(
"invalid workflow_state transition source: {}",
change.from
))
})?;
let to = TicketWorkflowState::parse(&change.to).ok_or_else(|| {
TicketError::Conflict(format!(
"invalid workflow_state transition target: {}",
change.to
))
})?;
if !TicketWorkflowState::is_planning_ready_transition(from, to) {
return Err(TicketError::Conflict(format!(
"mark_intake_ready only allows state planning -> ready, got {} -> {}",
from.as_str(),
to.as_str()
)));
}
let _lock = self.acquire_lock()?; let _lock = self.acquire_lock()?;
let dir = self.find_ticket_dir(&id)?; let dir = self.find_ticket_dir(&id)?;
let current = self.ticket_workflow_state_from_dir(&dir)?; let ticket = self.ticket_from_dir(&dir)?;
if current != from { if validate_mark_ready_replay(&ticket, &request)? {
return Err(TicketError::Conflict(format!( return Ok(ticket);
"state changed concurrently: expected `{}`, found `{}`",
from.as_str(),
current.as_str()
)));
} }
self.append_intake_summary_event(&dir, &summary)?; let target = resolve_ready_target(self.target_authority.as_ref(), "local", &ticket)?;
self.apply_workflow_state_change(&dir, from, to, change, &[]) let fingerprint = mark_ready_fingerprint(&ticket, &request, &target);
if ticket.meta.workflow_state != TicketWorkflowState::Planning {
return Err(TicketError::StaleWorkflowState {
expected: TicketWorkflowState::Planning.as_str().to_owned(),
actual: ticket.meta.workflow_state.as_str().to_owned(),
});
}
let reason = request
.reason
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("implementation target validated");
let mut change = TicketStateChange::new(
TicketWorkflowState::Planning.as_str(),
TicketWorkflowState::Ready.as_str(),
reason,
MarkdownText::new(format!(
"Implementation target `{}` at selector `{}` was validated and the Ticket was marked ready.",
target.repository_id, target.ref_selector
)),
);
change.author = request.author.clone().or_else(|| Some(default_author()));
if let Some(mut summary) = request.intake_summary {
summary.author = request.author.clone().or(summary.author);
self.append_intake_summary_event(&dir, &summary)?;
}
self.append_state_changed_event_with_attributes(
&dir,
&change,
Some("state"),
&[
("operation_key", request.operation_key.as_str()),
("request_fingerprint", fingerprint.as_str()),
("repository_id", target.repository_id.as_str()),
("ref_selector", target.ref_selector.as_str()),
],
)?;
self.set_frontmatter_fields(
&dir.join("item.md"),
&[
("state", TicketWorkflowState::Ready.as_str()),
("repository_id", target.repository_id.as_str()),
("ref_selector", target.ref_selector.as_str()),
],
)?;
self.ticket_from_dir(&dir)
} }
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result<()> { fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result<()> {
@@ -3961,14 +4319,22 @@ impl TicketBackend for LocalTicketBackend {
let dir = self.find_ticket_dir(&id)?; let dir = self.find_ticket_dir(&id)?;
let item = dir.join("item.md"); let item = dir.join("item.md");
let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?; let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?;
if meta.workflow_state != TicketWorkflowState::Ready {
return Err(TicketError::StaleWorkflowState {
expected: TicketWorkflowState::Ready.as_str().to_owned(),
actual: meta.workflow_state.as_str().to_owned(),
});
}
let ticket = self.ticket_from_dir(&dir)?;
let target = resolve_ready_target(self.target_authority.as_ref(), "local", &ticket)?;
let blockers = self.relation_blockers_for_meta(&meta)?; let blockers = self.relation_blockers_for_meta(&meta)?;
let active_blockers = blockers let active_blockers = blockers
.into_iter() .into_iter()
.filter(|blocker| !relation_blocker_allows_queue(blocker)) .filter(|blocker| !relation_blocker_allows_queue(blocker))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
if !active_blockers.is_empty() { if !active_blockers.is_empty() {
return Err(TicketError::Conflict(format!( return Err(TicketError::BlockingRelations(format!(
"ticket {} has unresolved blocking relation(s): {}", "{}: {}",
meta.id, meta.id,
format_relation_blockers(&active_blockers) format_relation_blockers(&active_blockers)
))); )));
@@ -3986,7 +4352,12 @@ impl TicketBackend for LocalTicketBackend {
TicketWorkflowState::Ready, TicketWorkflowState::Ready,
TicketWorkflowState::Queued, TicketWorkflowState::Queued,
change, change,
&[("queued_by", queued_by), ("queued_at", at.as_str())], &[
("queued_by", queued_by),
("queued_at", at.as_str()),
("repository_id", target.repository_id.as_str()),
("ref_selector", target.ref_selector.as_str()),
],
) )
} }
@@ -4723,9 +5094,17 @@ fn invalid_ticket_record_reason(error: &TicketError) -> &'static str {
TicketError::Locked { .. } => "ticket backend is locked", TicketError::Locked { .. } => "ticket backend is locked",
TicketError::Sqlite(_) => "could not read ticket record", TicketError::Sqlite(_) => "could not read ticket record",
TicketError::NotFound(_) => "ticket record is missing", TicketError::NotFound(_) => "ticket record is missing",
TicketError::Ambiguous { .. } | TicketError::Conflict(_) => { TicketError::Ambiguous { .. }
"invalid ticket record metadata" | TicketError::Conflict(_)
} | TicketError::MissingTargetRepository
| TicketError::UnknownTargetRepository(_)
| TicketError::MissingTargetSelector(_)
| TicketError::InvalidTargetSelector { .. }
| TicketError::TargetAuthorityUnavailable
| TicketError::StaleWorkflowState { .. }
| TicketError::InvalidWorkflowTransition { .. }
| TicketError::BlockingRelations(_)
| TicketError::OperationFingerprintMismatch { .. } => "invalid ticket record metadata",
} }
} }
@@ -6064,8 +6443,32 @@ mod tests {
use super::*; use super::*;
use tempfile::TempDir; use tempfile::TempDir;
#[derive(Debug)]
struct TestTargetAuthority;
impl TicketTargetAuthority for TestTargetAuthority {
fn resolve_target(
&self,
_workspace_id: &str,
repository_id: Option<&str>,
ref_selector: Option<&str>,
) -> Result<ResolvedTicketTarget> {
let repository_id = repository_id.unwrap_or("main");
if repository_id == "unknown" {
return Err(TicketError::UnknownTargetRepository(
repository_id.to_owned(),
));
}
Ok(ResolvedTicketTarget {
repository_id: repository_id.to_owned(),
ref_selector: ref_selector.unwrap_or("develop").to_owned(),
})
}
}
fn backend(dir: &TempDir) -> LocalTicketBackend { fn backend(dir: &TempDir) -> LocalTicketBackend {
LocalTicketBackend::new(dir.path().join("tickets")) LocalTicketBackend::new(dir.path().join("tickets"))
.with_target_authority(Arc::new(TestTargetAuthority))
} }
fn assert_ticket_target_edit_semantics<B: TicketBackend>(backend: &B) { fn assert_ticket_target_edit_semantics<B: TicketBackend>(backend: &B) {
@@ -6574,6 +6977,92 @@ state: planning
assert_ticket_target_edit_semantics(&backend); assert_ticket_target_edit_semantics(&backend);
} }
#[test]
fn sqlite_mark_ready_and_queue_enforce_target_and_blockers_atomically() {
let tmp = TempDir::new().unwrap();
let backend = SqliteTicketBackend::open(tmp.path().join("workspace.db"), "workspace-test")
.unwrap()
.with_target_authority(Arc::new(TestTargetAuthority));
let mut dependency = NewTicket::new("Dependency");
dependency.repository_id = Some("main".to_owned());
let dependency = backend.create(dependency).unwrap();
let mut implementation = NewTicket::new("Implementation");
implementation.repository_id = Some("main".to_owned());
let implementation = backend.create(implementation).unwrap();
backend
.add_ticket_relation(
TicketIdOrSlug::Id(implementation.id.clone()),
NewTicketRelation {
kind: TicketRelationKind::DependsOn,
target: dependency.id.clone(),
note: None,
author: Some("test".to_owned()),
},
)
.unwrap();
let request = TicketMarkReady {
operation_key: "sqlite-ready".to_owned(),
reason: Some("target accepted".to_owned()),
author: Some("test".to_owned()),
intake_summary: None,
};
let ready = backend
.mark_ready(
TicketIdOrSlug::Id(implementation.id.clone()),
request.clone(),
)
.unwrap();
assert_eq!(ready.meta.ref_selector.as_deref(), Some("develop"));
let replay = backend
.mark_ready(TicketIdOrSlug::Id(implementation.id.clone()), request)
.unwrap();
assert_eq!(
replay
.events
.iter()
.filter(|event| event.attributes.contains_key("operation_key"))
.count(),
1
);
assert!(matches!(
backend.queue_ready(
TicketIdOrSlug::Id(implementation.id.clone()),
"orchestrator",
),
Err(TicketError::BlockingRelations(_))
));
let after_rejection = backend
.show(TicketIdOrSlug::Id(implementation.id.clone()))
.unwrap();
assert_eq!(
after_rejection.meta.workflow_state,
TicketWorkflowState::Ready
);
assert!(!after_rejection.events.iter().any(|event| {
event.from.as_deref() == Some("ready") && event.to.as_deref() == Some("queued")
}));
backend
.close(
TicketIdOrSlug::Id(dependency.id),
MarkdownText::new("resolved"),
)
.unwrap();
backend
.queue_ready(
TicketIdOrSlug::Id(implementation.id.clone()),
"orchestrator",
)
.unwrap();
assert_eq!(
backend
.show(TicketIdOrSlug::Id(implementation.id))
.unwrap()
.meta
.workflow_state,
TicketWorkflowState::Queued
);
}
#[test] #[test]
fn sqlite_backend_persists_and_edits_ticket_target() { fn sqlite_backend_persists_and_edits_ticket_target() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
@@ -6917,24 +7406,25 @@ state: planning
fn add_event_status_and_close_preserve_local_layout() { fn add_event_status_and_close_preserve_local_layout() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
let backend = backend(&tmp); let backend = backend(&tmp);
let ticket = backend.create(NewTicket::new("Flow Ticket")).unwrap(); let mut input = NewTicket::new("Flow Ticket");
input.repository_id = Some("main".to_owned());
let ticket = backend.create(input).unwrap();
backend backend
.add_event( .add_event(
TicketIdOrSlug::Id(ticket.id.clone()), TicketIdOrSlug::Id(ticket.id.clone()),
NewTicketEvent::new(TicketEventKind::Plan, "Implementation plan."), NewTicketEvent::new(TicketEventKind::Plan, "Implementation plan."),
) )
.unwrap(); .unwrap();
let mut summary = TicketIntakeSummary::new("Ready for queue.");
summary.author = Some("test".to_string());
let mut change = TicketStateChange::new(
"planning",
"ready",
"ready_for_queue",
MarkdownText::new("Ready for queue."),
);
change.author = Some("test".to_string());
backend backend
.mark_intake_ready(TicketIdOrSlug::Id(ticket.id.clone()), summary, change) .mark_ready(
TicketIdOrSlug::Id(ticket.id.clone()),
TicketMarkReady {
operation_key: "test-flow-ready".to_owned(),
reason: Some("ready_for_queue".to_owned()),
author: Some("test".to_owned()),
intake_summary: None,
},
)
.unwrap(); .unwrap();
let current_item = tmp.path().join("tickets").join(&ticket.id).join("item.md"); let current_item = tmp.path().join("tickets").join(&ticket.id).join("item.md");
assert!(current_item.exists()); assert!(current_item.exists());
@@ -7127,6 +7617,8 @@ state: planning
let mut ready_input = NewTicket::new("Ready Workflow"); let mut ready_input = NewTicket::new("Ready Workflow");
ready_input.workflow_state = Some(TicketWorkflowState::Ready); ready_input.workflow_state = Some(TicketWorkflowState::Ready);
ready_input.repository_id = Some("main".to_owned());
ready_input.ref_selector = Some("develop".to_owned());
let ready = backend.create(ready_input).unwrap(); let ready = backend.create(ready_input).unwrap();
backend backend
.queue_ready(TicketIdOrSlug::Id(ready.id.clone()), "workspace-panel") .queue_ready(TicketIdOrSlug::Id(ready.id.clone()), "workspace-panel")
@@ -7156,7 +7648,7 @@ state: planning
assert!(matches!( assert!(matches!(
backend.queue_ready(TicketIdOrSlug::Id(ticket.id.clone()), "workspace-panel"), backend.queue_ready(TicketIdOrSlug::Id(ticket.id.clone()), "workspace-panel"),
Err(TicketError::Conflict(_)) Err(TicketError::StaleWorkflowState { .. })
)); ));
let record = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap(); let record = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap();
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Planning); assert_eq!(record.meta.workflow_state, TicketWorkflowState::Planning);
@@ -7192,41 +7684,60 @@ state: planning
} }
#[test] #[test]
fn mark_intake_ready_records_summary_and_state_change() { fn mark_ready_resolves_target_and_is_idempotent() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
let backend = backend(&tmp); let backend = backend(&tmp);
let ticket = backend.create(NewTicket::new("Planning Ready")).unwrap(); let mut input = NewTicket::new("Planning Ready");
let mut summary = TicketIntakeSummary::new("Concise accepted requirements."); input.repository_id = Some("main".to_owned());
summary.author = Some("intake".to_string()); let ticket = backend.create(input).unwrap();
let mut change = let request = TicketMarkReady {
TicketStateChange::new("planning", "ready", "accepted", "Ticket is ready to queue."); operation_key: "ready-op-1".to_owned(),
change.author = Some("intake".to_string()); reason: Some("accepted".to_owned()),
author: Some("intake".to_owned()),
intake_summary: None,
};
backend let first = backend
.mark_intake_ready(TicketIdOrSlug::Id(ticket.id.clone()), summary, change) .mark_ready(TicketIdOrSlug::Id(ticket.id.clone()), request.clone())
.unwrap(); .unwrap();
let record = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap(); let second = backend
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Ready); .mark_ready(TicketIdOrSlug::Id(ticket.id.clone()), request)
assert!( .unwrap();
record assert_eq!(first.meta.workflow_state, TicketWorkflowState::Ready);
assert_eq!(first.meta.repository_id.as_deref(), Some("main"));
assert_eq!(first.meta.ref_selector.as_deref(), Some("develop"));
assert_eq!(first.events, second.events);
assert_eq!(
first
.events .events
.iter() .iter()
.any(|event| event.kind == TicketEventKind::IntakeSummary) .filter(|event| {
event.kind == TicketEventKind::StateChanged
&& event.from.as_deref() == Some("planning")
&& event.to.as_deref() == Some("ready")
})
.count(),
1
); );
assert!(record.events.iter().any(|event| { assert!(matches!(
event.kind == TicketEventKind::StateChanged backend.mark_ready(
&& event.state_field.as_deref() == Some("state") TicketIdOrSlug::Id(ticket.id),
&& event.from.as_deref() == Some("planning") TicketMarkReady {
&& event.to.as_deref() == Some("ready") operation_key: "ready-op-1".to_owned(),
})); reason: Some("different".to_owned()),
author: Some("intake".to_owned()),
intake_summary: None,
},
),
Err(TicketError::OperationFingerprintMismatch { .. })
));
} }
#[test] #[test]
fn close_sets_state_closed() { fn close_sets_state_closed() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
let backend = backend(&tmp); let backend = backend(&tmp);
let mut input = NewTicket::new("Close Workflow"); let input = NewTicket::new("Close Workflow");
input.workflow_state = Some(TicketWorkflowState::Queued);
let ticket = backend.create(input).unwrap(); let ticket = backend.create(input).unwrap();
backend backend
+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,
@@ -381,6 +380,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",
@@ -398,6 +398,7 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
"QueryTicket", "QueryTicket",
"ShowTicket", "ShowTicket",
"TicketComment", "TicketComment",
"TicketMarkReady",
"TicketQueue", "TicketQueue",
"TicketClose", "TicketClose",
"TicketRelationRecord", "TicketRelationRecord",
@@ -588,22 +589,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
} }
@@ -661,17 +646,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(())
} }
} }
@@ -894,16 +868,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,
@@ -1119,22 +1092,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<()> {
@@ -1303,7 +1269,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"
@@ -1520,6 +1486,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!(
+33
View File
@@ -527,6 +527,39 @@ mod tests {
); );
} }
#[test]
fn merge_request_instruction_matches_the_exposed_operations() {
let catalog = PromptCatalog::builtins_only().unwrap();
let coder = catalog
.render_name(
"common.merge_request",
Value::from_serialize(serde_json::json!({
"tools": ["MergeRequestShow", "MergeRequestOpen"]
})),
)
.unwrap();
assert!(coder.contains("Reread the current Merge Request"));
assert!(coder.contains("Open the Merge Request only after"));
assert!(!coder.contains("Submit the authoritative verdict"));
assert!(!coder.contains("Complete integration only after"));
let orchestrator = catalog
.render_name(
"common.merge_request",
Value::from_serialize(serde_json::json!({
"tools": [
"MergeRequestShow",
"MergeRequestReadinessCheck",
"MergeRequestComplete"
]
})),
)
.unwrap();
assert!(orchestrator.contains("Use `MergeRequestReadinessCheck`"));
assert!(orchestrator.contains("Complete integration only after"));
assert!(!orchestrator.contains("Open the Merge Request only after"));
}
#[test] #[test]
fn commit_capable_roles_classify_commits_by_change_type() { fn commit_capable_roles_classify_commits_by_change_type() {
let catalog = PromptCatalog::builtins_only().unwrap(); let catalog = PromptCatalog::builtins_only().unwrap();
+43
View File
@@ -319,6 +319,7 @@ fn append_trailing_section(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::feature::FeatureInstructionId;
use chrono::TimeZone; use chrono::TimeZone;
use manifest::{Permission, ScopeConfig, ScopeRule}; use manifest::{Permission, ScopeConfig, ScopeRule};
use tempfile::TempDir; use tempfile::TempDir;
@@ -400,6 +401,48 @@ mod tests {
} }
} }
#[test]
fn merge_request_role_prompts_match_operation_specific_tool_surfaces() {
fn render(role: &str, tools: &[&str]) -> String {
let tmp = TempDir::new().unwrap();
let scope = build_scope(tmp.path());
let prompts = PromptCatalog::builtins_only().unwrap();
let template =
SystemPromptTemplate::parse(role, PromptCatalogSource::builtins_only()).unwrap();
let instruction = FeatureInstructionDeclaration::new(
FeatureInstructionId::builtin("merge_request.workflow"),
"common.merge_request",
"Merge Request workflow",
)
.unwrap();
let mut ctx = context(tmp.path(), &scope, &prompts);
ctx.tool_names = tools.iter().map(|name| (*name).to_string()).collect();
ctx.feature_instructions = std::slice::from_ref(&instruction);
template.render(&ctx).unwrap()
}
let coder = render("role.coder", &["MergeRequestShow", "MergeRequestOpen"]);
assert!(coder.contains("Open the Merge Request only after"));
assert!(!coder.contains("Complete integration only after"));
assert!(coder.contains("Do not call `MergeRequestComplete`"));
let reviewer = render("role.reviewer", &["MergeRequestShow", "MergeRequestReview"]);
assert!(reviewer.contains("Submit the authoritative verdict"));
assert!(!reviewer.contains("Open the Merge Request only after"));
let orchestrator = render(
"role.orchestrator",
&[
"MergeRequestShow",
"MergeRequestReadinessCheck",
"MergeRequestComplete",
],
);
assert!(orchestrator.contains("Use `MergeRequestReadinessCheck`"));
assert!(orchestrator.contains("Complete integration only after"));
assert!(!orchestrator.contains("Submit the authoritative verdict"));
}
#[test] #[test]
fn role_templates_are_selected_without_filesystem_resolution() { fn role_templates_are_selected_without_filesystem_resolution() {
let loader = PromptCatalogSource::builtins_only(); let loader = PromptCatalogSource::builtins_only();
+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,
+296 -85
View File
@@ -1060,11 +1060,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
@@ -1076,20 +1083,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(())
@@ -1315,8 +1350,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",
@@ -1397,6 +1432,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),
@@ -3107,6 +3146,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}")))?;
@@ -3114,7 +3202,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>> {
@@ -3208,6 +3299,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>,
@@ -3269,7 +3380,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();
@@ -3421,6 +3535,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,
@@ -3534,9 +3657,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(
@@ -3578,24 +3704,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(
@@ -4368,7 +4501,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, .. }
@@ -4409,11 +4542,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, .. } => {
@@ -4435,7 +4568,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",
@@ -11883,7 +12016,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 { .. }
@@ -13549,7 +13701,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()),
@@ -13706,7 +13858,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"],
] { ] {
@@ -13738,6 +13890,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");
@@ -13779,6 +14013,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
@@ -13836,29 +14071,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(),
@@ -14263,30 +14493,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(
@@ -14712,6 +14923,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()
@@ -14751,7 +14963,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()),
}), }),
@@ -14761,7 +14973,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);
@@ -14782,14 +14994,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
+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.
+4 -4
View File
@@ -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 = "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 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";
@@ -39,17 +39,17 @@
}; };
complete = { complete = {
instructions = "Call MergeRequestComplete with a fresh operation_id and the approved current revision. The Server must revalidate current assignment, immutable revision, registered Reviewer attempt, and Ticket inprogress CAS. Only after the authoritative operation returns Ticket state done, request a Flow transition."; instructions = "Leave concise implementation and validation evidence on the Ticket, then hand off the exact approved Merge Request revision to the Orchestrator for readiness and integration. Do not call MergeRequestComplete; Coder approval handoff is not Ticket completion authority. After durable handoff evidence exists, request a Flow transition.";
transitions = { transitions = {
completed = { completed = {
target = "done"; target = "done";
condition = "MergeRequestComplete durably returned done for this exact operation_id and current approved revision. 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 %}
+2 -2
View File
@@ -4,6 +4,6 @@ Treat the first committed user message as the bounded Ticket/action context and
{% 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. Complete only through `MergeRequestComplete` with a unique operation id, the approved `Review` event id, and final target-ref evidence; the Server re-resolves selectors, revalidates assignment, and fences Ticket state side effects. A request-changes result requires a fresh Reviewer child request. Flow terminal state is not Ticket completion authority. After the exact current Merge Request revision has authoritative approval, leave concise implementation evidence on the Ticket and hand off integration to the Orchestrator. Do not call `MergeRequestComplete`.
+1 -1
View File
@@ -2,6 +2,6 @@ You are the Ticket Reviewer role running as an actual Runtime-owned direct child
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. 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.
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.
@@ -247,9 +247,11 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
assert( assert(
ticketDetailLoad.includes("/repositories") && ticketDetailLoad.includes("/repositories") &&
ticketDetailPage.includes('mutate("state", "/state"') && ticketDetailPage.includes('mutate("state", "/state"') &&
ticketDetailPage.includes('mutate("ready", "/ready"') &&
ticketDetailPage.includes('mutate("queue", "/queue"') && ticketDetailPage.includes('mutate("queue", "/queue"') &&
ticketDetailPage.includes("targetCandidateValid") &&
ticketDetailPage.includes("persistedTargetValid") &&
!ticketDetailPage.includes("/merge-request/merge") && !ticketDetailPage.includes("/merge-request/merge") &&
ticketDetailPage.includes("merged_result_commit") &&
!ticketDetailPage.includes('mutate("review", "/review"') && !ticketDetailPage.includes('mutate("review", "/review"') &&
ticketDetailPage.includes('mutate("close", "/close"') && ticketDetailPage.includes('mutate("close", "/close"') &&
ticketDetailPage.includes("ticketWorkerLaunchHref") && ticketDetailPage.includes("ticketWorkerLaunchHref") &&
@@ -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}