ticket: add orchestration plan tools

This commit is contained in:
2026-06-08 22:16:08 +09:00
parent 68770a2b66
commit b28b7759f5
4 changed files with 853 additions and 8 deletions
+569
View File
@@ -12,6 +12,7 @@ use std::path::{Component, Path, PathBuf};
use chrono::Utc;
use fs4::fs_std::FileExt;
use serde::{Deserialize, Serialize};
use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
use thiserror::Error;
@@ -37,6 +38,9 @@ const REQUIRED_FIELDS: [&str; 10] = [
];
const MAX_STATE_CHANGE_REASON_BYTES: usize = 1024;
const MAX_INTAKE_SUMMARY_BODY_BYTES: usize = 16 * 1024;
const ORCHESTRATION_PLAN_ARTIFACT: &str = "orchestration-plan.jsonl";
const MAX_ORCHESTRATION_PLAN_TEXT_BYTES: usize = 16 * 1024;
const MAX_ORCHESTRATION_PLAN_FIELD_BYTES: usize = 1024;
const DEFAULT_TICKET_BODY: &str =
"## Background\n\nCreated by LocalTicketBackend.\n\n## Acceptance criteria\n\n- TBD\n";
const JAPANESE_TICKET_BODY: &str =
@@ -550,6 +554,104 @@ pub struct TicketRef {
pub status: TicketStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OrchestrationPlanKind {
Before,
After,
BlockedBy,
Blocks,
ConflictsWith,
DoNotParallelize,
WaitingCapacityNote,
AcceptedPlan,
}
impl OrchestrationPlanKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Before => "before",
Self::After => "after",
Self::BlockedBy => "blocked_by",
Self::Blocks => "blocks",
Self::ConflictsWith => "conflicts_with",
Self::DoNotParallelize => "do_not_parallelize",
Self::WaitingCapacityNote => "waiting_capacity_note",
Self::AcceptedPlan => "accepted_plan",
}
}
pub fn parse(value: &str) -> Option<Self> {
match value {
"before" => Some(Self::Before),
"after" => Some(Self::After),
"blocked_by" => Some(Self::BlockedBy),
"blocks" => Some(Self::Blocks),
"conflicts_with" => Some(Self::ConflictsWith),
"do_not_parallelize" => Some(Self::DoNotParallelize),
"waiting_capacity_note" => Some(Self::WaitingCapacityNote),
"accepted_plan" => Some(Self::AcceptedPlan),
_ => None,
}
}
fn requires_related_ticket(self) -> bool {
matches!(
self,
Self::Before
| Self::After
| Self::BlockedBy
| Self::Blocks
| Self::ConflictsWith
| Self::DoNotParallelize
)
}
}
impl fmt::Display for OrchestrationPlanKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AcceptedOrchestrationPlan {
pub summary: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worktree: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role_plan: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewOrchestrationPlanRecord {
pub kind: OrchestrationPlanKind,
pub related_ticket: Option<String>,
pub note: Option<String>,
pub accepted_plan: Option<AcceptedOrchestrationPlan>,
pub author: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OrchestrationPlanRecord {
pub id: String,
pub ticket_id: String,
pub ticket_slug: String,
pub kind: OrchestrationPlanKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub related_ticket: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub accepted_plan: Option<AcceptedOrchestrationPlan>,
pub author: String,
pub at: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketMeta {
pub id: String,
@@ -700,6 +802,16 @@ pub trait TicketBackend {
fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()>;
fn set_status(&self, id: TicketIdOrSlug, status: TicketStatus) -> Result<()>;
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()>;
fn add_orchestration_plan_record(
&self,
id: TicketIdOrSlug,
record: NewOrchestrationPlanRecord,
) -> Result<OrchestrationPlanRecord>;
fn query_orchestration_plan_records(
&self,
ticket: Option<TicketIdOrSlug>,
kind: Option<OrchestrationPlanKind>,
) -> Result<Vec<OrchestrationPlanRecord>>;
fn doctor(&self) -> Result<TicketDoctorReport>;
}
@@ -1021,6 +1133,20 @@ impl LocalTicketBackend {
})?;
atomic_write(item, updated.as_bytes())
}
fn orchestration_plan_path(&self, dir: &Path) -> PathBuf {
dir.join("artifacts").join(ORCHESTRATION_PLAN_ARTIFACT)
}
fn read_orchestration_plan_records_for_dir(
&self,
dir: &Path,
) -> Result<Vec<OrchestrationPlanRecord>> {
let item = dir.join("item.md");
let meta = ticket_meta(read_item_file(&item)?.frontmatter);
let path = self.orchestration_plan_path(dir);
read_orchestration_plan_artifact(&path, Some(&meta))
}
}
impl TicketBackend for LocalTicketBackend {
@@ -1446,6 +1572,90 @@ impl TicketBackend for LocalTicketBackend {
)
}
fn add_orchestration_plan_record(
&self,
id: TicketIdOrSlug,
record: NewOrchestrationPlanRecord,
) -> Result<OrchestrationPlanRecord> {
validate_new_orchestration_plan_record(&record)?;
let _lock = self.acquire_lock()?;
self.ensure_backend_dirs()?;
let dir = self.find_ticket_dir(&id)?;
let item = dir.join("item.md");
let meta = ticket_meta(read_item_file(&item)?.frontmatter);
let artifacts = dir.join("artifacts");
fs::create_dir_all(&artifacts).map_err(|e| io_err(&artifacts, e))?;
let path = self.orchestration_plan_path(&dir);
ensure_child_of(&artifacts, &path)?;
let line_count = if path.exists() {
fs::read_to_string(&path)
.map_err(|e| io_err(&path, e))?
.lines()
.filter(|line| !line.trim().is_empty())
.count()
} else {
0
};
let at = now_utc();
let output = OrchestrationPlanRecord {
id: format!("orch-plan-{}-{}", compact_now_utc(), line_count + 1),
ticket_id: meta.id.clone(),
ticket_slug: meta.slug.clone(),
kind: record.kind,
related_ticket: record.related_ticket.map(trim_owned),
note: record.note.map(trim_owned),
accepted_plan: record.accepted_plan.map(trim_accepted_orchestration_plan),
author: record.author.map(trim_owned).unwrap_or_else(default_author),
at: at.clone(),
};
validate_orchestration_plan_record(&output, Some(&meta))?;
let serialized = serde_json::to_string(&output).map_err(|e| {
TicketError::Conflict(format!(
"failed to serialize orchestration plan record: {e}"
))
})?;
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| io_err(&path, e))?;
writeln!(file, "{serialized}").map_err(|e| io_err(&path, e))?;
file.sync_all().map_err(|e| io_err(&path, e))?;
self.set_frontmatter_fields(&item, &[("updated_at", &at)])?;
Ok(output)
}
fn query_orchestration_plan_records(
&self,
ticket: Option<TicketIdOrSlug>,
kind: Option<OrchestrationPlanKind>,
) -> Result<Vec<OrchestrationPlanRecord>> {
let mut records = Vec::new();
if let Some(ticket) = ticket {
let dir = self.find_ticket_dir(&ticket)?;
records.extend(self.read_orchestration_plan_records_for_dir(&dir)?);
} else {
for status in STATUSES {
let status_dir = self.status_dir(status);
if !status_dir.is_dir() {
continue;
}
for entry in fs::read_dir(&status_dir).map_err(|e| io_err(&status_dir, e))? {
let entry = entry.map_err(|e| io_err(&status_dir, e))?;
let dir = entry.path();
if dir.is_dir() {
records.extend(self.read_orchestration_plan_records_for_dir(&dir)?);
}
}
}
}
if let Some(kind) = kind {
records.retain(|record| record.kind == kind);
}
records.sort_by(|a, b| a.at.cmp(&b.at).then_with(|| a.id.cmp(&b.id)));
Ok(records)
}
fn doctor(&self) -> Result<TicketDoctorReport> {
let mut report = TicketDoctorReport::default();
for status in STATUSES {
@@ -1577,6 +1787,12 @@ impl TicketBackend for LocalTicketBackend {
}
if artifacts.exists() {
doctor_artifacts(&artifacts, &mut report)?;
let meta = ticket_meta(parsed.frontmatter.clone());
doctor_orchestration_plan_artifact(
&artifacts.join(ORCHESTRATION_PLAN_ARTIFACT),
&meta,
&mut report,
)?;
}
}
}
@@ -1866,6 +2082,224 @@ fn ticket_meta(frontmatter: TicketItemFrontmatter) -> TicketMeta {
}
}
fn trim_owned(value: String) -> String {
value.trim().to_string()
}
fn trim_accepted_orchestration_plan(plan: AcceptedOrchestrationPlan) -> AcceptedOrchestrationPlan {
AcceptedOrchestrationPlan {
summary: plan.summary.trim().to_string(),
branch: plan.branch.map(trim_owned),
worktree: plan.worktree.map(trim_owned),
role_plan: plan.role_plan.map(trim_owned),
}
}
fn validate_plan_required_text(label: &str, value: &str, max_bytes: usize) -> Result<()> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(TicketError::Conflict(format!(
"orchestration plan {label} must not be empty"
)));
}
validate_plan_optional_text(label, Some(trimmed), max_bytes)
}
fn validate_plan_optional_text(label: &str, value: Option<&str>, max_bytes: usize) -> Result<()> {
if let Some(value) = value {
if value.as_bytes().len() > max_bytes {
return Err(TicketError::Conflict(format!(
"orchestration plan {label} exceeds {max_bytes} bytes"
)));
}
if value.contains('\0') {
return Err(TicketError::Conflict(format!(
"orchestration plan {label} must not contain NUL bytes"
)));
}
}
Ok(())
}
fn validate_plan_optional_single_line(
label: &str,
value: Option<&str>,
max_bytes: usize,
) -> Result<()> {
validate_plan_optional_text(label, value, max_bytes)?;
if let Some(value) = value {
if value.contains('\n') || value.contains('\r') {
return Err(TicketError::Conflict(format!(
"orchestration plan {label} must be a single line"
)));
}
}
Ok(())
}
fn validate_accepted_orchestration_plan(plan: &AcceptedOrchestrationPlan) -> Result<()> {
validate_plan_required_text(
"accepted_plan.summary",
&plan.summary,
MAX_ORCHESTRATION_PLAN_TEXT_BYTES,
)?;
validate_plan_optional_single_line(
"accepted_plan.branch",
plan.branch.as_deref(),
MAX_ORCHESTRATION_PLAN_FIELD_BYTES,
)?;
validate_plan_optional_single_line(
"accepted_plan.worktree",
plan.worktree.as_deref(),
MAX_ORCHESTRATION_PLAN_FIELD_BYTES,
)?;
validate_plan_optional_text(
"accepted_plan.role_plan",
plan.role_plan.as_deref(),
MAX_ORCHESTRATION_PLAN_TEXT_BYTES,
)
}
fn validate_new_orchestration_plan_record(record: &NewOrchestrationPlanRecord) -> Result<()> {
if record.kind.requires_related_ticket() {
let related = record.related_ticket.as_deref().ok_or_else(|| {
TicketError::Conflict(format!(
"orchestration plan kind `{}` requires related_ticket",
record.kind
))
})?;
validate_plan_required_text(
"related_ticket",
related,
MAX_ORCHESTRATION_PLAN_FIELD_BYTES,
)?;
validate_plan_optional_single_line(
"related_ticket",
Some(related),
MAX_ORCHESTRATION_PLAN_FIELD_BYTES,
)?;
} else if let Some(related) = record.related_ticket.as_deref() {
validate_plan_optional_single_line(
"related_ticket",
Some(related),
MAX_ORCHESTRATION_PLAN_FIELD_BYTES,
)?;
}
if matches!(record.kind, OrchestrationPlanKind::AcceptedPlan) {
let plan = record.accepted_plan.as_ref().ok_or_else(|| {
TicketError::Conflict("accepted_plan record requires accepted_plan fields".to_string())
})?;
validate_accepted_orchestration_plan(plan)?;
} else if record.accepted_plan.is_some() {
return Err(TicketError::Conflict(
"accepted_plan fields are only valid for accepted_plan records".to_string(),
));
}
if matches!(record.kind, OrchestrationPlanKind::WaitingCapacityNote) {
let note = record.note.as_deref().ok_or_else(|| {
TicketError::Conflict("waiting_capacity_note records require note".to_string())
})?;
validate_plan_required_text("note", note, MAX_ORCHESTRATION_PLAN_TEXT_BYTES)?;
} else {
validate_plan_optional_text(
"note",
record.note.as_deref(),
MAX_ORCHESTRATION_PLAN_TEXT_BYTES,
)?;
}
validate_plan_optional_single_line(
"author",
record.author.as_deref(),
MAX_ORCHESTRATION_PLAN_FIELD_BYTES,
)
}
fn validate_orchestration_plan_record(
record: &OrchestrationPlanRecord,
meta: Option<&TicketMeta>,
) -> Result<()> {
validate_plan_required_text("id", &record.id, MAX_ORCHESTRATION_PLAN_FIELD_BYTES)?;
validate_plan_optional_single_line("id", Some(&record.id), MAX_ORCHESTRATION_PLAN_FIELD_BYTES)?;
validate_plan_required_text(
"ticket_id",
&record.ticket_id,
MAX_ORCHESTRATION_PLAN_FIELD_BYTES,
)?;
validate_plan_optional_single_line(
"ticket_id",
Some(&record.ticket_id),
MAX_ORCHESTRATION_PLAN_FIELD_BYTES,
)?;
validate_plan_required_text(
"ticket_slug",
&record.ticket_slug,
MAX_ORCHESTRATION_PLAN_FIELD_BYTES,
)?;
validate_plan_optional_single_line(
"ticket_slug",
Some(&record.ticket_slug),
MAX_ORCHESTRATION_PLAN_FIELD_BYTES,
)?;
validate_plan_required_text("author", &record.author, MAX_ORCHESTRATION_PLAN_FIELD_BYTES)?;
validate_plan_optional_single_line(
"author",
Some(&record.author),
MAX_ORCHESTRATION_PLAN_FIELD_BYTES,
)?;
validate_plan_required_text("at", &record.at, MAX_ORCHESTRATION_PLAN_FIELD_BYTES)?;
validate_plan_optional_single_line("at", Some(&record.at), MAX_ORCHESTRATION_PLAN_FIELD_BYTES)?;
let new_record = NewOrchestrationPlanRecord {
kind: record.kind,
related_ticket: record.related_ticket.clone(),
note: record.note.clone(),
accepted_plan: record.accepted_plan.clone(),
author: Some(record.author.clone()),
};
validate_new_orchestration_plan_record(&new_record)?;
if let Some(meta) = meta {
if record.ticket_id != meta.id || record.ticket_slug != meta.slug {
return Err(TicketError::Conflict(format!(
"orchestration plan record {} targets {}/{} but artifact belongs to {}/{}",
record.id, record.ticket_id, record.ticket_slug, meta.id, meta.slug
)));
}
}
Ok(())
}
fn read_orchestration_plan_artifact(
path: &Path,
meta: Option<&TicketMeta>,
) -> Result<Vec<OrchestrationPlanRecord>> {
if !path.exists() {
return Ok(Vec::new());
}
let content = fs::read_to_string(path).map_err(|e| io_err(path, e))?;
let mut records = Vec::new();
for (idx, line) in content.lines().enumerate() {
let line = line.trim();
if line.is_empty() {
continue;
}
let record: OrchestrationPlanRecord =
serde_json::from_str(line).map_err(|e| TicketError::Parse {
path: path.to_path_buf(),
message: format!("invalid orchestration plan record on line {}: {e}", idx + 1),
})?;
validate_orchestration_plan_record(&record, meta).map_err(|err| TicketError::Parse {
path: path.to_path_buf(),
message: format!(
"invalid orchestration plan record on line {}: {err}",
idx + 1
),
})?;
records.push(record);
}
Ok(records)
}
fn format_yaml_string_scalar(value: &str) -> String {
let mut out = String::from("'");
for ch in value.chars() {
@@ -2335,6 +2769,25 @@ fn collect_artifacts_inner(
Ok(())
}
fn doctor_orchestration_plan_artifact(
path: &Path,
meta: &TicketMeta,
report: &mut TicketDoctorReport,
) -> Result<()> {
match read_orchestration_plan_artifact(path, Some(meta)) {
Ok(_) => Ok(()),
Err(TicketError::Parse { message, .. }) => {
report.push_error(message, Some(path.to_path_buf()));
Ok(())
}
Err(TicketError::Conflict(message)) => {
report.push_error(message, Some(path.to_path_buf()));
Ok(())
}
Err(err) => Err(err),
}
}
fn doctor_artifacts(dir: &Path, report: &mut TicketDoctorReport) -> Result<()> {
for entry in fs::read_dir(dir).map_err(|e| io_err(dir, e))? {
let entry = entry.map_err(|e| io_err(dir, e))?;
@@ -3135,4 +3588,120 @@ workflow_state: planning
.unwrap_err();
assert!(matches!(err, TicketError::InvalidPathComponent(_)));
}
#[test]
fn orchestration_plan_records_persist_and_query_by_ticket_and_kind() {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let first = backend.create(NewTicket::new("First ticket")).unwrap();
let second = backend.create(NewTicket::new("Second ticket")).unwrap();
let before = backend
.add_orchestration_plan_record(
TicketIdOrSlug::Id(first.id.clone()),
NewOrchestrationPlanRecord {
kind: OrchestrationPlanKind::Before,
related_ticket: Some(second.slug.clone()),
note: Some(
"First must land before second because both touch routing.".to_string(),
),
accepted_plan: None,
author: Some("orchestrator".to_string()),
},
)
.unwrap();
assert_eq!(before.ticket_id, first.id);
assert_eq!(before.kind, OrchestrationPlanKind::Before);
backend
.add_orchestration_plan_record(
TicketIdOrSlug::Slug(first.slug.clone()),
NewOrchestrationPlanRecord {
kind: OrchestrationPlanKind::AcceptedPlan,
related_ticket: None,
note: Some("Accepted during routing.".to_string()),
accepted_plan: Some(AcceptedOrchestrationPlan {
summary: "Implement in a sibling coder worktree, then review before merge."
.to_string(),
branch: Some("ticket-orchestration-plan-tool".to_string()),
worktree: Some(".worktree/ticket-orchestration-plan-tool".to_string()),
role_plan: Some(
"Coder implements; Reviewer checks capability boundaries.".to_string(),
),
}),
author: Some("orchestrator".to_string()),
},
)
.unwrap();
let ticket_records = backend
.query_orchestration_plan_records(Some(TicketIdOrSlug::Query(first.slug.clone())), None)
.unwrap();
assert_eq!(ticket_records.len(), 2);
assert!(
ticket_records
.iter()
.any(|record| record.kind == OrchestrationPlanKind::AcceptedPlan)
);
let before_records = backend
.query_orchestration_plan_records(None, Some(OrchestrationPlanKind::Before))
.unwrap();
assert_eq!(before_records.len(), 1);
assert_eq!(
before_records[0].related_ticket.as_deref(),
Some(second.slug.as_str())
);
let path = temp
.path()
.join("tickets")
.join("open")
.join(&first.id)
.join("artifacts")
.join(ORCHESTRATION_PLAN_ARTIFACT);
assert!(path.is_file());
let content = fs::read_to_string(path).unwrap();
assert_eq!(content.lines().count(), 2);
assert_eq!(backend.doctor().unwrap().error_count(), 0);
}
#[test]
fn orchestration_plan_validation_rejects_missing_related_ticket_and_bad_artifacts() {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let ticket = backend
.create(NewTicket::new("Needs plan validation"))
.unwrap();
let err = backend
.add_orchestration_plan_record(
TicketIdOrSlug::Id(ticket.id.clone()),
NewOrchestrationPlanRecord {
kind: OrchestrationPlanKind::BlockedBy,
related_ticket: None,
note: Some("Missing related ticket should fail.".to_string()),
accepted_plan: None,
author: None,
},
)
.unwrap_err();
assert!(err.to_string().contains("requires related_ticket"));
let artifact = temp
.path()
.join("tickets")
.join("open")
.join(&ticket.id)
.join("artifacts")
.join(ORCHESTRATION_PLAN_ARTIFACT);
fs::write(&artifact, "{not json}\n").unwrap();
let report = backend.doctor().unwrap();
assert!(report.error_count() > 0);
assert!(report.diagnostics.iter().any(|diagnostic| {
diagnostic
.message
.contains("invalid orchestration plan record")
}));
}
}