5 Commits
27 changed files with 1751 additions and 409 deletions
Generated
+1
View File
@@ -4417,6 +4417,7 @@ dependencies = [
"serde",
"serde_json",
"serde_yaml",
"sha2 0.11.0",
"tempfile",
"thiserror 2.0.18",
"tokio",
+100 -3
View File
@@ -18,9 +18,10 @@ use crate::model::{AuthRef, ModelManifest, ReasoningControl};
use crate::plugin::PluginConfig;
use crate::{
CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConfig, MemoryFeatureConfig, ScopeConfig,
SessionConfig, SkillsConfig, TicketFeatureConfig, ToolOutputLimits, ToolPermissionConfig,
ToolPermissionRule, WebConfig, WorkerFeatureConfig, WorkerManifest, WorkerMeta,
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConfig, MemoryFeatureConfig,
MergeRequestFeatureConfig, ScopeConfig, SessionConfig, SkillsConfig, TicketFeatureConfig,
ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerFeatureConfig,
WorkerManifest, WorkerMeta,
};
/// Partial-form Worker manifest. Every field is optional; one or more
@@ -97,6 +98,8 @@ pub struct FeatureConfigPartial {
#[serde(default)]
pub ticket: Option<TicketFeatureConfigPartial>,
#[serde(default)]
pub merge_request: Option<MergeRequestFeatureConfigPartial>,
#[serde(default)]
pub orchestration: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub plugins: Option<FeatureFlagConfigPartial>,
@@ -127,6 +130,11 @@ impl FeatureConfigPartial {
FeatureFlagConfigPartial::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(
self.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 {
fn from(value: FeatureConfigPartial) -> Self {
Self {
@@ -247,6 +277,10 @@ impl From<FeatureConfigPartial> for FeatureConfig {
.ticket
.map(TicketFeatureConfig::from)
.unwrap_or_default(),
merge_request: value
.merge_request
.map(MergeRequestFeatureConfig::from)
.unwrap_or_default(),
orchestration: value
.orchestration
.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 {
fn from(value: FeatureConfig) -> Self {
Self {
@@ -339,6 +397,7 @@ impl From<FeatureConfig> for FeatureConfigPartial {
objective: Some(value.objective.into()),
manage_workdir: Some(value.manage_workdir.into()),
ticket: Some(value.ticket.into()),
merge_request: Some(value.merge_request.into()),
orchestration: Some(value.orchestration.into()),
plugins: Some(value.plugins.into()),
}
@@ -1880,6 +1939,7 @@ worker_max_turns = 7
assert!(!manifest.feature.objective.enabled);
assert!(!manifest.feature.manage_workdir.enabled);
assert!(!manifest.feature.ticket.enabled);
assert!(!manifest.feature.merge_request.any());
}
#[test]
@@ -1899,6 +1959,13 @@ thread = false
intake = false
workflow = false
[feature.merge_request]
show = true
open = false
review = true
readiness_check = false
complete = false
[feature.orchestration]
enabled = false
"#,
@@ -1934,6 +2001,14 @@ enabled = false
assert!(!manifest.feature.ticket.thread);
assert!(!manifest.feature.ticket.intake);
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.memory.enabled);
assert!(!manifest.feature.memory.staging);
@@ -1957,6 +2032,13 @@ thread = false
intake = false
workflow = false
[feature.merge_request]
show = true
open = false
review = true
readiness_check = false
complete = false
[feature.orchestration]
enabled = false
"#,
@@ -1968,6 +2050,11 @@ enabled = false
thread = true
workflow = true
[feature.merge_request]
open = true
review = false
readiness_check = true
[feature.orchestration]
enabled = true
@@ -2017,6 +2104,16 @@ enabled = true
assert!(manifest.feature.ticket.thread);
assert!(!manifest.feature.ticket.intake);
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.objective.enabled);
assert!(manifest.feature.web.enabled);
+24
View File
@@ -125,6 +125,8 @@ pub struct FeatureConfig {
#[serde(default)]
pub ticket: TicketFeatureConfig,
#[serde(default)]
pub merge_request: MergeRequestFeatureConfig,
#[serde(default)]
pub orchestration: FeatureFlagConfig,
#[serde(default)]
pub plugins: FeatureFlagConfig,
@@ -143,6 +145,7 @@ impl Default for FeatureConfig {
objective: FeatureFlagConfig::disabled(),
manage_workdir: FeatureFlagConfig::disabled(),
ticket: TicketFeatureConfig::default(),
merge_request: MergeRequestFeatureConfig::default(),
orchestration: FeatureFlagConfig::disabled(),
plugins: FeatureFlagConfig::disabled(),
}
@@ -252,6 +255,27 @@ pub struct TicketFeatureConfig {
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
/// loaded *only* from the directories listed here — there is no
/// 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 }),
};
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> {
@@ -1361,6 +1392,7 @@ mod tests {
assert!(companion.feature.objective.enabled);
assert!(!companion.feature.ticket.intake);
assert!(!companion.feature.orchestration.enabled);
assert!(!companion.feature.merge_request.any());
assert_eq!(
companion.compaction.as_ref().unwrap().threshold,
Some(240000)
@@ -1401,6 +1433,15 @@ mod tests {
assert!(!orchestrator.feature.sub_worker.enabled);
assert!(orchestrator.feature.worker.enabled);
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.authoring);
@@ -1424,6 +1465,14 @@ mod tests {
assert!(coder.feature.sub_worker.enabled);
assert!(coder.feature.flow.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.delegation_scope.allow.is_empty());
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.flow.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.authoring);
@@ -1604,6 +1661,14 @@ enabled = true
authoring = false
thread = false
intake = false
[feature.merge_request]
show = true
open = false
review = true
readiness_check = false
complete = false
[feature.orchestration]
enabled = false
"#,
@@ -1626,6 +1691,14 @@ enabled = false
assert!(!resolved.manifest.feature.ticket.authoring);
assert!(!resolved.manifest.feature.ticket.thread);
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_eq!(
resolved.manifest.delegation_scope.allow[0].target,
+1
View File
@@ -14,6 +14,7 @@ schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
serde_yaml = "0.9.34"
sha2.workspace = true
rusqlite.workspace = true
thiserror.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 serde::{Deserialize, Serialize};
use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
use sha2::{Digest, Sha256};
use thiserror::Error;
pub mod config;
@@ -80,6 +81,32 @@ pub enum TicketError {
Locked { path: PathBuf },
#[error("ticket conflict: {0}")]
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}")]
Sqlite(String),
#[error("ticket parse error in {path}: {message}")]
@@ -475,6 +502,37 @@ pub enum TicketTargetEdit {
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 {
fn validate(&self) -> Result<()> {
if let Self::Set {
@@ -504,6 +562,116 @@ fn validate_ticket_target(repository_id: Option<&str>, ref_selector: Option<&str
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)]
pub struct TicketItemEdit {
pub title: Option<String>,
@@ -1524,12 +1692,7 @@ pub trait TicketBackend {
change: TicketStateChange,
) -> Result<()>;
fn set_workflow_state(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()>;
fn mark_intake_ready(
&self,
id: TicketIdOrSlug,
summary: TicketIntakeSummary,
change: TicketStateChange,
) -> Result<()>;
fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> Result<Ticket>;
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result<()>;
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()>;
fn add_ticket_relation(
@@ -1605,10 +1768,9 @@ pub enum TicketBackendOperation {
id: TicketIdOrSlug,
change: TicketStateChange,
},
MarkIntakeReady {
MarkReady {
id: TicketIdOrSlug,
summary: TicketIntakeSummary,
change: TicketStateChange,
request: TicketMarkReady,
},
QueueReady {
id: TicketIdOrSlug,
@@ -1710,13 +1872,8 @@ where
backend.set_workflow_state(id, change)?;
TicketBackendOperationResult::Unit
}
TicketBackendOperation::MarkIntakeReady {
id,
summary,
change,
} => {
backend.mark_intake_ready(id, summary, change)?;
TicketBackendOperationResult::Unit
TicketBackendOperation::MarkReady { id, request } => {
TicketBackendOperationResult::Ticket(backend.mark_ready(id, request)?)
}
TicketBackendOperation::QueueReady { id, queued_by } => {
backend.queue_ready(id, &queued_by)?;
@@ -1756,10 +1913,25 @@ where
})
}
#[derive(Debug, Clone)]
#[derive(Clone)]
pub struct LocalTicketBackend {
root: PathBuf,
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 {
@@ -1767,6 +1939,7 @@ impl LocalTicketBackend {
Self {
root: root.into(),
record_language: None,
target_authority: None,
}
}
@@ -1775,6 +1948,11 @@ impl LocalTicketBackend {
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> {
self.record_language.as_deref()
}
@@ -2076,6 +2254,16 @@ impl LocalTicketBackend {
dir: &Path,
change: &TicketStateChange,
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<()> {
validate_state_change(change)?;
let author = change.author.clone().unwrap_or_else(default_author);
@@ -2087,6 +2275,7 @@ impl LocalTicketBackend {
if let Some(state_field) = state_field {
attrs.push(("field", state_field));
}
attrs.extend_from_slice(extra_attributes);
self.append_thread_event(
dir,
TicketEventKind::StateChanged.as_str(),
@@ -2247,6 +2436,7 @@ pub struct SqliteTicketBackend {
record_language: Option<String>,
event_attributes: BTreeMap<String, String>,
mutation_hook: Option<Arc<SqliteTicketMutationHook>>,
target_authority: Option<Arc<dyn TicketTargetAuthority>>,
#[cfg(test)]
full_ticket_load_count: Arc<AtomicUsize>,
}
@@ -2263,6 +2453,10 @@ impl fmt::Debug for SqliteTicketBackend {
"mutation_hook",
&self.mutation_hook.as_ref().map(|_| "configured"),
)
.field(
"target_authority",
&self.target_authority.as_ref().map(|_| "configured"),
)
.finish()
}
}
@@ -2275,6 +2469,7 @@ impl SqliteTicketBackend {
record_language: None,
event_attributes: BTreeMap::new(),
mutation_hook: None,
target_authority: None,
#[cfg(test)]
full_ticket_load_count: Arc::new(AtomicUsize::new(0)),
}
@@ -2316,6 +2511,11 @@ impl SqliteTicketBackend {
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 {
self.db_path.as_path()
}
@@ -3166,6 +3366,15 @@ impl TicketBackend for SqliteTicketBackend {
validate_required_event_value("author", author)?;
}
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 mut body_edit_audit = TicketBodyEditAudit::None;
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<()> {
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| {
validate_state_change(&change)?;
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();
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)?;
@@ -3342,24 +3582,120 @@ impl TicketBackend for SqliteTicketBackend {
})
}
fn mark_intake_ready(
&self,
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<()> {
fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> Result<Ticket> {
validate_required_event_value("operation_key", &request.operation_key)?;
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::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();
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)?;
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() })
if let Some(mut summary) = request.intake_summary.clone() {
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 dir = self.find_ticket_dir(&id)?;
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 body_edit_audit = TicketBodyEditAudit::None;
let mut updates = Vec::new();
@@ -3892,13 +4237,7 @@ impl TicketBackend for LocalTicketBackend {
change.to
))
})?;
if !TicketWorkflowState::is_role_transition(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()
)));
}
validate_generic_state_change(from, to)?;
let _lock = self.acquire_lock()?;
let dir = self.find_ticket_dir(&id)?;
if from == TicketWorkflowState::Queued && to == TicketWorkflowState::InProgress {
@@ -3916,43 +4255,62 @@ impl TicketBackend for LocalTicketBackend {
self.apply_workflow_state_change(&dir, from, to, change, &[])
}
fn mark_intake_ready(
&self,
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()
)));
}
fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> Result<Ticket> {
validate_required_event_value("operation_key", &request.operation_key)?;
let _lock = self.acquire_lock()?;
let dir = self.find_ticket_dir(&id)?;
let current = self.ticket_workflow_state_from_dir(&dir)?;
if current != from {
return Err(TicketError::Conflict(format!(
"state changed concurrently: expected `{}`, found `{}`",
from.as_str(),
current.as_str()
)));
let ticket = self.ticket_from_dir(&dir)?;
if validate_mark_ready_replay(&ticket, &request)? {
return Ok(ticket);
}
self.append_intake_summary_event(&dir, &summary)?;
self.apply_workflow_state_change(&dir, from, to, change, &[])
let target = resolve_ready_target(self.target_authority.as_ref(), "local", &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");
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<()> {
@@ -3961,14 +4319,22 @@ impl TicketBackend for LocalTicketBackend {
let dir = self.find_ticket_dir(&id)?;
let item = dir.join("item.md");
let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?;
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 active_blockers = blockers
.into_iter()
.filter(|blocker| !relation_blocker_allows_queue(blocker))
.collect::<Vec<_>>();
if !active_blockers.is_empty() {
return Err(TicketError::Conflict(format!(
"ticket {} has unresolved blocking relation(s): {}",
return Err(TicketError::BlockingRelations(format!(
"{}: {}",
meta.id,
format_relation_blockers(&active_blockers)
)));
@@ -3986,7 +4352,12 @@ impl TicketBackend for LocalTicketBackend {
TicketWorkflowState::Ready,
TicketWorkflowState::Queued,
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::Sqlite(_) => "could not read ticket record",
TicketError::NotFound(_) => "ticket record is missing",
TicketError::Ambiguous { .. } | TicketError::Conflict(_) => {
"invalid ticket record metadata"
}
TicketError::Ambiguous { .. }
| 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 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 {
LocalTicketBackend::new(dir.path().join("tickets"))
.with_target_authority(Arc::new(TestTargetAuthority))
}
fn assert_ticket_target_edit_semantics<B: TicketBackend>(backend: &B) {
@@ -6574,6 +6977,92 @@ state: planning
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]
fn sqlite_backend_persists_and_edits_ticket_target() {
let tmp = TempDir::new().unwrap();
@@ -6917,24 +7406,25 @@ state: planning
fn add_event_status_and_close_preserve_local_layout() {
let tmp = TempDir::new().unwrap();
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
.add_event(
TicketIdOrSlug::Id(ticket.id.clone()),
NewTicketEvent::new(TicketEventKind::Plan, "Implementation plan."),
)
.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
.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();
let current_item = tmp.path().join("tickets").join(&ticket.id).join("item.md");
assert!(current_item.exists());
@@ -7127,6 +7617,8 @@ state: planning
let mut ready_input = NewTicket::new("Ready Workflow");
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();
backend
.queue_ready(TicketIdOrSlug::Id(ready.id.clone()), "workspace-panel")
@@ -7156,7 +7648,7 @@ state: planning
assert!(matches!(
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();
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Planning);
@@ -7192,41 +7684,60 @@ state: planning
}
#[test]
fn mark_intake_ready_records_summary_and_state_change() {
fn mark_ready_resolves_target_and_is_idempotent() {
let tmp = TempDir::new().unwrap();
let backend = backend(&tmp);
let ticket = backend.create(NewTicket::new("Planning Ready")).unwrap();
let mut summary = TicketIntakeSummary::new("Concise accepted requirements.");
summary.author = Some("intake".to_string());
let mut change =
TicketStateChange::new("planning", "ready", "accepted", "Ticket is ready to queue.");
change.author = Some("intake".to_string());
let mut input = NewTicket::new("Planning Ready");
input.repository_id = Some("main".to_owned());
let ticket = backend.create(input).unwrap();
let request = TicketMarkReady {
operation_key: "ready-op-1".to_owned(),
reason: Some("accepted".to_owned()),
author: Some("intake".to_owned()),
intake_summary: None,
};
backend
.mark_intake_ready(TicketIdOrSlug::Id(ticket.id.clone()), summary, change)
let first = backend
.mark_ready(TicketIdOrSlug::Id(ticket.id.clone()), request.clone())
.unwrap();
let record = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap();
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Ready);
assert!(
record
let second = backend
.mark_ready(TicketIdOrSlug::Id(ticket.id.clone()), request)
.unwrap();
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
.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| {
event.kind == TicketEventKind::StateChanged
&& event.state_field.as_deref() == Some("state")
&& event.from.as_deref() == Some("planning")
&& event.to.as_deref() == Some("ready")
}));
assert!(matches!(
backend.mark_ready(
TicketIdOrSlug::Id(ticket.id),
TicketMarkReady {
operation_key: "ready-op-1".to_owned(),
reason: Some("different".to_owned()),
author: Some("intake".to_owned()),
intake_summary: None,
},
),
Err(TicketError::OperationFingerprintMismatch { .. })
));
}
#[test]
fn close_sets_state_closed() {
let tmp = TempDir::new().unwrap();
let backend = backend(&tmp);
let mut input = NewTicket::new("Close Workflow");
input.workflow_state = Some(TicketWorkflowState::Queued);
let input = NewTicket::new("Close Workflow");
let ticket = backend.create(input).unwrap();
backend
+185 -61
View File
@@ -16,8 +16,9 @@ use crate::{
NewTicket, NewTicketEvent, NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord,
Result as TicketResult, Ticket, TicketBackend, TicketBodyReplacement, TicketDoctorDiagnostic,
TicketDoctorReport, TicketDoctorSeverity, TicketError, TicketEventKind, TicketIdOrSlug,
TicketIntakeSummary, TicketListState, TicketRef, TicketRelation, TicketRelationKind,
TicketRelationView, TicketStateChange, TicketSummary, TicketWorkflowState, default_author,
TicketIntakeSummary, TicketListState, TicketMarkReady, TicketRef, TicketRelation,
TicketRelationKind, TicketRelationView, TicketStateChange, TicketSummary, TicketWorkflowState,
default_author,
};
const DEFAULT_LIST_LIMIT: usize = 50;
@@ -42,7 +43,7 @@ pub const TICKET_BASE_TOOL_NAMES: [&str; 14] = [
"TicketPlan",
"TicketDecision",
"TicketImplementationReport",
"TicketIntakeReady",
"TicketMarkReady",
"TicketQueue",
"TicketWorkflowState",
"TicketClose",
@@ -68,7 +69,7 @@ pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 5] = [
pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] =
["TicketRelationQuery", "TicketOrchestrationPlanQuery"];
pub const TICKET_TOOL_NAMES: [&str; 19] = [
pub const TICKET_TOOL_NAMES: [&str; 20] = [
"TicketCreate",
"TicketEditItem",
"QueryTicket",
@@ -77,6 +78,7 @@ pub const TICKET_TOOL_NAMES: [&str; 19] = [
"TicketPlan",
"TicketDecision",
"TicketImplementationReport",
"TicketMarkReady",
"TicketIntakeReady",
"TicketQueue",
"TicketWorkflowState",
@@ -99,13 +101,14 @@ pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 6] = [
"TicketOrchestrationPlanQuery",
];
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 13] = [
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 14] = [
"TicketCreate",
"TicketEditItem",
"TicketComment",
"TicketPlan",
"TicketDecision",
"TicketImplementationReport",
"TicketMarkReady",
"TicketIntakeReady",
"TicketQueue",
"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 IMPLEMENTATION_REPORT_DESCRIPTION: &str =
"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 \
Ticket backend. The tool appends a bounded `intake_summary`, appends a typed `state_changed` event \
for `state`, and transitions state to `ready`.";
const MARK_READY_DESCRIPTION: &str = "Mark a planning Ticket ready through the typed Ticket backend. \
The backend atomically validates and normalizes the persisted repository/ref target, records one typed \
state_changed event, and transitions planning -> ready. `reason` is optional.";
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 \
Ticket backend. The backend performs the gated ready -> queued transition, records queued_by/queued_at, \
and rejects unresolved blocking relations.";
@@ -174,6 +180,7 @@ fn base_tool_description(name: &str) -> &'static str {
"TicketPlan" => PLAN_DESCRIPTION,
"TicketDecision" => DECISION_DESCRIPTION,
"TicketImplementationReport" => IMPLEMENTATION_REPORT_DESCRIPTION,
"TicketMarkReady" => MARK_READY_DESCRIPTION,
"TicketIntakeReady" => INTAKE_READY_DESCRIPTION,
"TicketQueue" => QUEUE_DESCRIPTION,
"TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION,
@@ -305,13 +312,8 @@ impl TicketBackend for TicketToolBackend {
self.backend.set_workflow_state(id, change)
}
fn mark_intake_ready(
&self,
id: TicketIdOrSlug,
summary: TicketIntakeSummary,
change: TicketStateChange,
) -> TicketResult<()> {
self.backend.mark_intake_ready(id, summary, change)
fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> TicketResult<Ticket> {
self.backend.mark_ready(id, request)
}
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> TicketResult<()> {
@@ -558,18 +560,24 @@ struct TicketThreadEventParams {
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)]
struct TicketIntakeReadyParams {
/// Ticket id.
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,
/// Reason attached to the state_changed event. Defaults to `planning_ready`.
/// Optional reason attached to the state_changed event.
#[serde(default)]
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)]
@@ -836,6 +844,11 @@ struct TicketImplementationReportTool {
backend: TicketToolBackend,
}
#[derive(Clone)]
struct TicketMarkReadyTool {
backend: TicketToolBackend,
}
#[derive(Clone)]
struct TicketIntakeReadyTool {
backend: TicketToolBackend,
@@ -899,6 +912,23 @@ impl Tool for TicketCreateTool {
_ctx: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
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);
if let Some(body) = params.body {
input.body = MarkdownText::new(body);
@@ -1114,41 +1144,68 @@ impl_ticket_thread_event_tool!(
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]
impl Tool for TicketIntakeReadyTool {
async fn execute(
&self,
input_json: &str,
_ctx: llm_engine::tool::ToolExecutionContext,
ctx: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: TicketIntakeReadyParams = parse_input("TicketIntakeReady", input_json)?;
let from = TicketWorkflowState::Planning;
let reason = params
.reason
.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(
let ticket = self
.backend
.mark_ready(
TicketIdOrSlug::Query(params.ticket.clone()),
summary,
change,
TicketMarkReady {
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))?;
Ok(json_output(
format!("Marked ticket {} state ready", params.ticket),
json!({ "ticket": params.ticket, "state": "ready", "ok": true }),
format!("Marked ticket {} state ready after intake", 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
}),
))
}
}
@@ -1726,6 +1783,7 @@ fn input_schema(name: &str) -> Value {
"TicketComment" | "TicketPlan" | "TicketDecision" | "TicketImplementationReport" => {
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)),
"TicketQueue" => serde_json::to_value(schemars::schema_for!(TicketQueueParams)),
"TicketWorkflowState" => {
@@ -1774,6 +1832,7 @@ impl_from_backend!(TicketCommentTool);
impl_from_backend!(TicketPlanTool);
impl_from_backend!(TicketDecisionTool);
impl_from_backend!(TicketImplementationReportTool);
impl_from_backend!(TicketMarkReadyTool);
impl_from_backend!(TicketIntakeReadyTool);
impl_from_backend!(TicketQueueTool);
impl_from_backend!(TicketWorkflowStateTool);
@@ -1801,6 +1860,7 @@ pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition
"TicketImplementationReport",
backend.clone(),
),
tool_definition::<TicketMarkReadyTool>("TicketMarkReady", backend.clone()),
tool_definition::<TicketIntakeReadyTool>("TicketIntakeReady", backend.clone()),
tool_definition::<TicketQueueTool>("TicketQueue", backend.clone()),
tool_definition::<TicketWorkflowStateTool>("TicketWorkflowState", backend.clone()),
@@ -1826,8 +1886,26 @@ mod tests {
use super::*;
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 {
LocalTicketBackend::new(temp.path().join("tickets"))
.with_target_authority(Arc::new(TestTargetAuthority))
}
fn tool(definition: ToolDefinition) -> Arc<dyn Tool> {
@@ -1877,6 +1955,7 @@ mod tests {
"TicketPlan",
"TicketDecision",
"TicketImplementationReport",
"TicketMarkReady",
"TicketIntakeReady",
"TicketQueue",
"TicketWorkflowState",
@@ -2460,16 +2539,17 @@ mod tests {
async fn ticket_workflow_tools_mark_ready_and_transition_state() {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let created = backend.create(NewTicket::new("Workflow Tool")).unwrap();
let intake_ready = tool_by_name(backend.clone(), "TicketIntakeReady");
let mut input = NewTicket::new("Workflow Tool");
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");
intake_ready
.execute(
&json!({
"ticket": created.id.clone(),
"intake_summary": "Requirements accepted; implementation can be queued.",
"author": "intake-worker"
"reason": "requirements accepted"
})
.to_string(),
Default::default(),
@@ -2512,12 +2592,12 @@ mod tests {
let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap();
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Done);
assert!(
record
.events
.iter()
.any(|event| event.kind == TicketEventKind::IntakeSummary)
);
assert!(record.events.iter().any(|event| {
event.kind == TicketEventKind::StateChanged
&& event.from.as_deref() == Some("planning")
&& event.to.as_deref() == Some("ready")
&& event.attributes.contains_key("request_fingerprint")
}));
let transitions = record
.events
.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]
async fn ticket_workflow_tool_allows_return_to_planning_from_ready_and_queued() {
let temp = TempDir::new().unwrap();
@@ -2661,7 +2773,11 @@ mod tests {
)
.await
.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");
done_input.workflow_state = Some(TicketWorkflowState::Done);
@@ -2680,7 +2796,11 @@ mod tests {
)
.await
.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");
queued_input.workflow_state = Some(TicketWorkflowState::Queued);
@@ -2699,17 +2819,21 @@ mod tests {
)
.await
.unwrap_err();
assert!(skip_error.to_string().contains("not allowed"));
assert!(
skip_error
.to_string()
.contains("invalid ticket workflow transition")
);
}
#[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 backend = backend(&temp);
let mut input = NewTicket::new("Already Ready");
input.workflow_state = Some(TicketWorkflowState::Ready);
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
.execute(
@@ -2723,7 +2847,7 @@ mod tests {
.await
.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();
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Ready);
assert!(!record.events.iter().any(|event| {
@@ -2867,7 +2991,7 @@ mod tests {
"TicketPlan",
"TicketDecision",
"TicketImplementationReport",
"TicketIntakeReady",
"TicketMarkReady",
"TicketQueue",
"TicketRelationRecord",
"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 {
// Workdir lifecycle is Workspace control-plane authority. The Worker
// 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 async_trait::async_trait;
use llm_engine::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
use manifest::MergeRequestFeatureConfig;
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::json;
use std::sync::Arc;
pub const MERGE_REQUEST_COMMON_TOOL_NAMES: &[&str] = &[
"MergeRequestShow",
"MergeRequestReadinessCheck",
"MergeRequestOpen",
"MergeRequestComplete",
pub const FEATURE_ID: &str = "merge_request";
const FEATURE_NAME: &str = "Merge Request tools";
const FEATURE_DESCRIPTION: &str =
"Operation-specific Merge Request workflow tools over Workspace authority.";
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)]
enum Kind {
Show,
@@ -89,13 +111,23 @@ struct ReviewFindingInput {
body: String,
}
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 {
match self {
Self::Show => "MergeRequestShow",
Self::Readiness => "MergeRequestReadinessCheck",
Self::Open => "MergeRequestOpen",
Self::Complete => "MergeRequestComplete",
Self::Review => "MergeRequestReviewSubmit",
Self::Review => "MergeRequestReview",
}
}
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> {
vec![
definition(c.clone(), Kind::Show),
definition(c.clone(), Kind::Readiness),
definition(c.clone(), Kind::Open),
definition(c, Kind::Complete),
]
pub struct MergeRequestFeature {
client: Arc<dyn WorkspaceClient>,
config: MergeRequestFeatureConfig,
}
pub fn reviewer_tools(c: Arc<dyn WorkspaceClient>) -> Vec<ToolDefinition> {
if c.reviewer_context().is_some() {
vec![
definition(c.clone(), Kind::Show),
definition(c, Kind::Review),
]
} else {
vec![]
impl MergeRequestFeature {
pub fn new(client: Arc<dyn WorkspaceClient>, config: MergeRequestFeatureConfig) -> Self {
Self { client, config }
}
fn kinds(&self) -> impl Iterator<Item = Kind> + '_ {
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> {
match n {
"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" => {
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.")
}
_ => None,
@@ -257,6 +318,72 @@ pub fn description(n: &str) -> Option<&'static str> {
#[cfg(test)]
mod tests {
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]
fn schemas_hide_revision_and_commit_authority() {
let schemas = [
@@ -276,6 +403,5 @@ mod tests {
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},
};
use super::merge_request;
use crate::feature::{
FeatureDescriptor, FeatureDiagnostic, FeatureInstallContext, FeatureInstallError,
FeatureInstructionContribution, FeatureInstructionDeclaration, FeatureInstructionId,
@@ -381,6 +380,7 @@ const READ_ONLY_TOOL_NAMES: &[&str] = &["QueryTicket", "ShowTicket"];
const AUTHORING_TOOL_NAMES: &[&str] = &[
"TicketCreate",
"TicketEditItem",
"TicketMarkReady",
"TicketQueue",
"TicketClose",
"TicketRelationRecord",
@@ -398,6 +398,7 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
"QueryTicket",
"ShowTicket",
"TicketComment",
"TicketMarkReady",
"TicketQueue",
"TicketClose",
"TicketRelationRecord",
@@ -588,22 +589,6 @@ impl FeatureModule for TicketFeature {
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
}
@@ -661,17 +646,6 @@ impl FeatureModule for TicketFeature {
};
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(())
}
}
@@ -894,16 +868,15 @@ impl WorkspaceHttpTicketBackend {
TicketError::Conflict(format!("serialize Ticket workflow change: {error}"))
})?),
),
TicketBackendOperation::MarkIntakeReady {
id,
summary,
change,
} => Self::request_unit(
TicketBackendOperation::MarkReady { id, request } => Self::request(
client,
WorkspaceRequestMethod::Post,
format!("{base}/{}/intake-ready", Self::ticket_path(&id)),
Some(serde_json::json!({ "summary": summary, "change": change })),
),
format!("{base}/{}/workflow/mark-ready", Self::ticket_path(&id)),
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(
client,
WorkspaceRequestMethod::Post,
@@ -1119,22 +1092,15 @@ impl TicketBackend for WorkspaceHttpTicketBackend {
}
}
fn mark_intake_ready(
fn mark_ready(
&self,
id: TicketIdOrSlug,
summary: TicketIntakeSummary,
change: TicketStateChange,
) -> TicketResult<()> {
match self.invoke(TicketBackendOperation::MarkIntakeReady {
id,
summary,
change,
})? {
TicketBackendOperationResult::Unit => Ok(()),
other => Err(TicketError::Conflict(format!(
"unexpected ticket backend response: {other:?}"
))),
}
request: ticket::TicketMarkReady,
) -> TicketResult<Ticket> {
expect_ticket_result!(
self.invoke(TicketBackendOperation::MarkReady { id, request }),
TicketBackendOperationResult::Ticket
)
}
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> TicketResult<()> {
@@ -1303,7 +1269,7 @@ mod tests {
assert_eq!(show.name, "ShowTicket");
assert!(show.input_schema["properties"]["event_limit"].is_object());
let tool_names = TicketFeatureAccess::workspace_authoring().tool_names();
assert_eq!(tool_names.len(), 9);
assert_eq!(tool_names.len(), 10);
assert!(
tool_names.len() < 13,
"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 == "TicketEditItem"));
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 == "TicketWorkflowState"));
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]
fn commit_capable_roles_classify_commits_by_change_type() {
let catalog = PromptCatalog::builtins_only().unwrap();
+43
View File
@@ -319,6 +319,7 @@ fn append_trailing_section(
#[cfg(test)]
mod tests {
use super::*;
use crate::feature::FeatureInstructionId;
use chrono::TimeZone;
use manifest::{Permission, ScopeConfig, ScopeRule};
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]
fn role_templates_are_selected_without_filesystem_resolution() {
let loader = PromptCatalogSource::builtins_only();
+22
View File
@@ -5890,6 +5890,13 @@ model_id = "claude-sonnet-4-20250514"
[engine]
instruction = "saved"
[feature.merge_request]
show = true
open = false
review = true
readiness_check = false
complete = false
[[scope.allow]]
target = "/snapshot/workspace"
permission = "read"
@@ -5912,6 +5919,13 @@ model_id = "claude-sonnet-4-20250514"
[engine]
instruction = "current"
[feature.merge_request]
show = true
open = true
review = true
readiness_check = true
complete = true
[[scope.allow]]
target = "/current/workspace"
permission = "write"
@@ -5931,6 +5945,14 @@ permission = "write"
.unwrap();
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[0].target,
+296 -85
View File
@@ -1060,11 +1060,18 @@ impl WorkspaceApi {
&self,
request: &WorkerSpawnRequest,
) -> ApiResult<()> {
let selected_repository_id =
let (selected_repository_id, selected_ref_selector) =
if let Some(working_directory) = request.resolved_working_directory_request.as_ref() {
let repository_id = working_directory.repository.id.as_str();
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() {
let workdir = self
.store
@@ -1076,20 +1083,48 @@ impl WorkspaceApi {
)))
})?;
self.require_workspace_repository(&workdir.repository_id)?;
Some(workdir.repository_id)
(Some(workdir.repository_id), workdir.creation_selector)
} else {
None
(None, None)
};
if let WorkerSpawnIntent::TicketRole { ticket_id, .. } = &request.intent {
let ticket = self.authority.ticket(ticket_id)?;
if let Some(repository_id) = ticket.repository_id.as_deref() {
self.require_workspace_repository(repository_id)?;
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 does not resolve that repository in this Workspace"
))));
}
// Workdir-less Ticket Workers cannot execute repository implementation.
// Preserve that control-plane launch while still validating any persisted
// target (including its Workspace ownership) when one exists.
if selected_repository_id.is_none() && ticket.repository_id.is_none() {
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(())
@@ -1315,8 +1350,8 @@ pub fn build_router(api: WorkspaceApi) -> Router {
post(scoped_set_ticket_workflow_state),
)
.route(
"/api/w/{workspace_id}/tickets/{id}/intake-ready",
post(scoped_prepare_ticket_intake_ready),
"/api/w/{workspace_id}/tickets/{id}/workflow/mark-ready",
post(scoped_mark_ticket_ready),
)
.route(
"/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",
post(scoped_transition_ticket_state),
)
.route(
"/api/w/{workspace_id}/tickets/{id}/ready",
post(scoped_mark_ticket_ready_from_browser),
)
.route(
"/api/w/{workspace_id}/tickets/{id}/events",
post(scoped_append_ticket_event),
@@ -3107,6 +3146,55 @@ struct BrowserCloseTicketRequest {
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> {
let config = ticket::config::TicketConfig::load_workspace(&api.config.workspace_root)
.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.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>> {
@@ -3208,6 +3299,26 @@ async fn scoped_append_ticket_event(
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(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRecordPath>,
@@ -3269,7 +3380,10 @@ async fn execute_worker_ticket_rest_operation(
api.config.workspace_id.clone(),
)
.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 is_mutation = operation_kind != "read";
let target = ticket_mutation_target(&operation).cloned();
@@ -3421,6 +3535,15 @@ async fn scoped_create_ticket_record(
headers: HeaderMap,
Json(input): Json<ticket::NewTicket>,
) -> 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(
&api,
&path.workspace_id,
@@ -3534,9 +3657,12 @@ async fn scoped_add_ticket_intake_summary(
}
#[derive(Debug, Deserialize)]
struct TicketIntakeReadyRequest {
summary: ticket::TicketIntakeSummary,
change: TicketStateChange,
struct TicketMarkReadyRequest {
operation_key: String,
#[serde(default)]
reason: Option<String>,
#[serde(default)]
intake_summary: Option<ticket::TicketIntakeSummary>,
}
async fn scoped_set_ticket_state_field(
@@ -3578,24 +3704,31 @@ async fn scoped_set_ticket_workflow_state(
ticket_rest_unit(result)
}
async fn scoped_prepare_ticket_intake_ready(
async fn scoped_mark_ticket_ready(
State(api): State<WorkspaceApi>,
AxumPath((workspace_id, id)): AxumPath<(String, String)>,
headers: HeaderMap,
Json(request): Json<TicketIntakeReadyRequest>,
) -> ApiResult<StatusCode> {
Json(request): Json<TicketMarkReadyRequest>,
) -> ApiResult<Json<ticket::Ticket>> {
let result = execute_worker_ticket_rest_operation(
&api,
&workspace_id,
headers,
TicketBackendOperation::MarkIntakeReady {
TicketBackendOperation::MarkReady {
id: TicketIdOrSlug::Query(id),
summary: request.summary,
change: request.change,
request: ticket::TicketMarkReady {
operation_key: request.operation_key,
reason: request.reason,
author: None,
intake_summary: request.intake_summary,
},
},
)
.await?;
ticket_rest_unit(result)
ticket_rest_result(result, |result| match result {
TicketBackendOperationResult::Ticket(ticket) => Some(ticket),
_ => None,
})
}
async fn scoped_queue_ticket_record(
@@ -4368,7 +4501,7 @@ fn ticket_mutation_target(operation: &TicketBackendOperation) -> Option<&TicketI
| TicketBackendOperation::AddIntakeSummary { id, .. }
| TicketBackendOperation::SetStateField { id, .. }
| TicketBackendOperation::SetWorkflowState { id, .. }
| TicketBackendOperation::MarkIntakeReady { id, .. }
| TicketBackendOperation::MarkReady { id, .. }
| TicketBackendOperation::QueueReady { id, .. }
| TicketBackendOperation::Close { id, .. }
| TicketBackendOperation::AddTicketRelation { id, .. }
@@ -4409,11 +4542,11 @@ fn bind_worker_ticket_operation_source(
| TicketBackendOperation::SetStateField { change, .. }
| TicketBackendOperation::SetWorkflowState { change, .. } => change.author = Some(author),
TicketBackendOperation::AddIntakeSummary { summary, .. } => summary.author = Some(author),
TicketBackendOperation::MarkIntakeReady {
summary, change, ..
} => {
summary.author = Some(author.clone());
change.author = Some(author);
TicketBackendOperation::MarkReady { request, .. } => {
request.author = Some(author.clone());
if let Some(summary) = request.intake_summary.as_mut() {
summary.author = Some(author);
}
}
TicketBackendOperation::QueueReady { queued_by, .. } => *queued_by = author,
TicketBackendOperation::AddTicketRelation { relation, .. } => {
@@ -4435,7 +4568,7 @@ fn ticket_mutation_operation_kind(operation: &TicketBackendOperation) -> &'stati
TicketBackendOperation::AddIntakeSummary { .. } => "add_intake_summary",
TicketBackendOperation::SetStateField { .. } => "set_state_field",
TicketBackendOperation::SetWorkflowState { .. } => "set_workflow_state",
TicketBackendOperation::MarkIntakeReady { .. } => "mark_intake_ready",
TicketBackendOperation::MarkReady { .. } => "mark_ready",
TicketBackendOperation::QueueReady { .. } => "queue_ready",
TicketBackendOperation::Close { .. } => "close",
TicketBackendOperation::AddTicketRelation { .. } => "add_relation",
@@ -11883,7 +12016,26 @@ impl From<Error> for ApiError {
ticket::TicketError::NotFound(_) => "ticket_not_found",
ticket::TicketError::Ambiguous { .. } => "ticket_ambiguous",
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::PathEscapesRoot { .. } => "invalid_ticket_request",
ticket::TicketError::Io { .. }
@@ -13549,7 +13701,7 @@ mod tests {
config.repositories = vec![ConfiguredRepository {
id: TEST_REPOSITORY_ID.to_string(),
provider: "git".to_string(),
uri: ".".to_string(),
uri: workspace_root.display().to_string(),
path: workspace_root,
display_name: Some("Test Repository".to_string()),
default_selector: Some("HEAD".to_string()),
@@ -13706,7 +13858,7 @@ mod tests {
fn init_clean_git_workspace(path: &std::path::Path) {
for args in [
vec!["init"],
vec!["init", "--initial-branch=develop"],
vec!["config", "user.email", "test@example.invalid"],
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]
fn worker_source_actor_roles_use_canonical_vocabulary() {
assert_eq!(worker_source_actor_role(true, false), "coder");
@@ -13779,6 +14013,7 @@ mod tests {
#[tokio::test]
async fn orchestrator_ticket_notifications_project_authoritative_post_mutation_state() {
let dir = tempfile::tempdir().unwrap();
init_clean_git_workspace(dir.path());
let (api, execution) = test_api_with_recording_backend(dir.path()).await;
let source_worker = api
.runtime
@@ -13836,29 +14071,24 @@ mod tests {
let orchestrator = started.worker.unwrap().worker;
execution.take_inputs();
let ticket = browser_ticket_backend(&api)
.unwrap()
.create(ticket::NewTicket::new("Bounded notification"))
.unwrap();
let mut input = ticket::NewTicket::new("Bounded notification");
input.repository_id = Some(TEST_REPOSITORY_ID.to_owned());
input.ref_selector = Some("develop".to_owned());
let ticket = browser_ticket_backend(&api).unwrap().create(input).unwrap();
let ticket_id = TicketIdOrSlug::Id(ticket.id.clone());
let operations = [
TicketBackendOperation::SetWorkflowState {
TicketBackendOperation::MarkReady {
id: ticket_id.clone(),
change: TicketStateChange::new(
"planning",
"ready",
"ready for implementation",
"test transition",
),
request: ticket::TicketMarkReady {
operation_key: "notification-ready".to_owned(),
reason: Some("ready for implementation".to_owned()),
author: None,
intake_summary: None,
},
},
TicketBackendOperation::SetWorkflowState {
TicketBackendOperation::QueueReady {
id: ticket_id.clone(),
change: TicketStateChange::new(
"ready",
"queued",
"queued for implementation",
"test transition",
),
queued_by: "spoofed".to_owned(),
},
TicketBackendOperation::SetWorkflowState {
id: ticket_id.clone(),
@@ -14263,30 +14493,11 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let backend = browser_ticket_backend(&api).unwrap();
let ticket_ref = backend
.create(ticket::NewTicket::new("Recover queued work"))
.unwrap();
backend
.mark_intake_ready(
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();
let mut input = ticket::NewTicket::new("Recover queued work");
input.workflow_state = Some(TicketWorkflowState::Queued);
input.repository_id = Some(TEST_REPOSITORY_ID.to_owned());
input.ref_selector = Some("HEAD".to_owned());
let ticket_ref = backend.create(input).unwrap();
*api.orchestrator_attention_fingerprint.lock().unwrap() = Some(ticket_ref.id.clone());
let Json(started) = scoped_start_workspace_orchestrator(
@@ -14712,6 +14923,7 @@ mod tests {
#[tokio::test]
async fn ticket_browser_endpoints_mutate_typed_backend_and_return_thread() {
let dir = tempfile::tempdir().unwrap();
init_clean_git_workspace(dir.path());
let api = test_api(dir.path()).await;
let ticket_ref = browser_ticket_backend(&api)
.unwrap()
@@ -14751,7 +14963,7 @@ mod tests {
replace_all: false,
target: Some(TicketTargetEdit::Set {
repository_id: "main".to_string(),
ref_selector: Some("feature/api".to_string()),
ref_selector: Some("develop".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.body, "Updated from the Browser API.");
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.relations.outgoing.len(), 1);
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")
}));
let Json(ready) = scoped_transition_ticket_state(
let Json(ready) = scoped_mark_ticket_ready_from_browser(
State(api.clone()),
AxumPath(path()),
Json(BrowserTransitionTicketStateRequest {
state: TicketWorkflowState::Ready,
reason: Some("intake complete".to_string()),
body: Some("Ready for queue".to_string()),
author: Some("browser-user".to_string()),
Json(TicketMarkReadyRequest {
operation_key: "browser-ready".to_owned(),
reason: Some("intake complete".to_owned()),
intake_summary: None,
}),
)
.await
+29 -48
View File
@@ -12,8 +12,7 @@ use ticket::config::{
use ticket::{
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation,
SqliteTicketBackend, TicketBackend, TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug,
TicketIntakeSummary, TicketListQuery, TicketListState, TicketRelationKind, TicketSummary,
TicketWorkflowState,
TicketListQuery, TicketListState, TicketRelationKind, TicketSummary, TicketWorkflowState,
};
const DEFAULT_LIST_LIMIT: usize = 50;
@@ -630,8 +629,16 @@ fn state(
let id = TicketIdOrSlug::Query(options.query.clone());
let target_state = match options.state {
StateTarget::Planning => TicketWorkflowState::Planning,
StateTarget::Ready => TicketWorkflowState::Ready,
StateTarget::Queued => TicketWorkflowState::Queued,
StateTarget::Ready => {
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::Done => {
return Err(TicketCliError::new(
@@ -646,33 +653,16 @@ fn state(
};
let current = backend.show(id.clone())?;
let ticket_id = current.meta.id.clone();
match target_state {
TicketWorkflowState::Ready => backend.mark_intake_ready(
id,
TicketIntakeSummary::new("Marked ready by `yoi ticket state`."),
ticket::TicketStateChange {
from: current.meta.workflow_state.as_str().to_string(),
to: TicketWorkflowState::Ready.as_str().to_string(),
reason: "cli_state".to_string(),
author: Some("yoi ticket".to_string()),
body: "Marked ready by `yoi ticket state`.\n".into(),
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)?;
}
}
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!(
"state\t{}\t{}\n",
ticket_id,
@@ -1335,23 +1325,14 @@ mod tests {
.contains(&format!("appended\t{}\timplementation_report", ticket_id))
);
let ready = run(&temp, &["state", &ticket_id, "ready"]);
assert_eq!(ready.stdout, format!("state\t{}\tready\n", ticket_id));
let ready_listed = run(&temp, &["list", "--state", "ready"]);
assert!(ready_listed.stdout.contains(&ticket_id));
let queued = run(&temp, &["state", &ticket_id, "queued"]);
assert_eq!(queued.stdout, format!("state\t{}\tqueued\n", ticket_id));
let queued_listed = run(&temp, &["list", "--state", "queued"]);
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 ready_error = parse_ticket_args(&args(&["state", &ticket_id, "ready"]))
.and_then(|cli| run_in_workspace(cli, temp.path()))
.unwrap_err();
assert!(ready_error.to_string().contains("TicketMarkReady"));
let queue_error = parse_ticket_args(&args(&["state", &ticket_id, "queued"]))
.and_then(|cli| run_in_workspace(cli, temp.path()))
.unwrap_err();
assert!(queue_error.to_string().contains("TicketQueue"));
let done_error = parse_ticket_args(&args(&["state", &ticket_id, "done"]))
.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
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`
- `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.
- `TicketComment`
- `MergeRequestShow`, `MergeRequestOpen`, `MergeRequestAddRevision`, `MergeRequestComplete`
- `MergeRequestReviewSubmit` — available only inside the attested direct-child Reviewer attempt; attempt/revision capability material is not model input.
- Coder: `MergeRequestShow`, `MergeRequestOpen`
- 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`
- `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 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.
+4 -4
View File
@@ -15,7 +15,7 @@
};
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 = {
approved = {
target = "complete";
@@ -39,17 +39,17 @@
};
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 = {
completed = {
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 = {
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;
};
};
+7
View File
@@ -30,6 +30,13 @@ feature = {
worker = { enabled = false; };
objective = { enabled = true; };
ticket = { enabled = true; authoring = true; thread = true; };
merge_request = {
show = false;
open = false;
review = false;
readiness_check = false;
complete = false;
};
};
memory = {
+7
View File
@@ -12,5 +12,12 @@ import "./base.dcdl" // {
flow = { enabled = true; };
worker = { enabled = 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; };
manage_workdir = { enabled = true; };
ticket = { enabled = true; thread = true; workflow = true; };
merge_request = {
show = true;
open = false;
review = false;
readiness_check = true;
complete = true;
};
orchestration = { enabled = true; };
};
}
+7
View File
@@ -11,5 +11,12 @@ import "./base.dcdl" // {
sub_worker = { enabled = false; };
worker = { enabled = 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";
commonLanguage = import "./common/language.md";
commonGit = import "./common/git.md";
commonMergeRequest = import "./common/merge-request.md";
commonTickets = import "./common/tickets.md";
commonToolUsage = import "./common/tool-usage.md";
commonWorkerObservation = import "./common/worker-observation.md";
@@ -36,6 +37,7 @@ in
common = {
git = commonGit.content;
language = commonLanguage.content;
merge_request = commonMergeRequest.content;
tickets = commonTickets.content;
tool_usage = commonToolUsage.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" %}
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.
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.
@@ -247,9 +247,11 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
assert(
ticketDetailLoad.includes("/repositories") &&
ticketDetailPage.includes('mutate("state", "/state"') &&
ticketDetailPage.includes('mutate("ready", "/ready"') &&
ticketDetailPage.includes('mutate("queue", "/queue"') &&
ticketDetailPage.includes("targetCandidateValid") &&
ticketDetailPage.includes("persistedTargetValid") &&
!ticketDetailPage.includes("/merge-request/merge") &&
ticketDetailPage.includes("merged_result_commit") &&
!ticketDetailPage.includes('mutate("review", "/review"') &&
ticketDetailPage.includes('mutate("close", "/close"') &&
ticketDetailPage.includes("ticketWorkerLaunchHref") &&
@@ -14,6 +14,7 @@
import type { ApiResult } from "$lib/workspace/api/http";
import type {
RepositoryListResponse,
RepositorySummary,
TicketDetail,
} from "$lib/workspace/sidebar/types";
@@ -65,7 +66,9 @@
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<{
data: {
@@ -115,6 +118,27 @@
let resolution = $state("");
let busy = $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(
workspaceApiPath(
@@ -180,6 +204,33 @@
}, "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) {
event.preventDefault();
if (
@@ -329,16 +380,19 @@
<p class="ticket-assignment-line">
Assigned to <strong>{ticket.assignee ?? "Unassigned"}</strong>
</p>
{#if orchestratorOnline}
<p>The Orchestrator is online. Start a role-specific Worker with the Ticket target below.</p>
{#if orchestratorOnline && implementationStartEligible}
<p>The Orchestrator is online. Start a role-specific Worker with the validated Ticket target below.</p>
<div class="ticket-role-actions">
<a class="workspace-primary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket, "coder")}>Coder</a>
</div>
{: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">
<button class="workspace-primary-button" type="button" disabled>Coder</button>
<button class="workspace-secondary-button" type="button" disabled>Reviewer</button>
</div>
{/if}
</section>
@@ -347,15 +401,15 @@
<header><h2>Repository target</h2></header>
<form class="ticket-control-form" onsubmit={saveTarget}>
<label>Repository
<select bind:value={repositoryId}>
<select bind:value={repositoryId} disabled={ticket.state !== "planning"}>
<option value="">Not assigned</option>
{#each loadedRepositories?.items ?? [] as repository}
<option value={repository.id}>{repository.display_name}</option>
{/each}
</select>
</label>
<label>Ref selector<input bind:value={refSelector} placeholder="branch, tag, or revision" /></label>
<button class="workspace-secondary-button" type="submit" disabled={busy === "target"}>
<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" || ticket.state !== "planning"}>
{busy === "target" ? "Saving…" : "Save target"}
</button>
</form>
@@ -374,8 +428,15 @@
Apply state
</button>
</form>
{#if ticket.state === "ready"}
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue" || !orchestratorOnline} onclick={() => mutate("queue", "/queue", {})}>
{#if ticket.state === "planning"}
<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"}
</button>
{/if}