merge: add ticket orchestration plan tools
This commit is contained in:
commit
b3eab44412
|
|
@ -66,6 +66,8 @@ Orchestrator は以下を行う。
|
||||||
- `TicketComment`: routing decision / intent packet / blocked reason / next question の記録。
|
- `TicketComment`: routing decision / intent packet / blocked reason / next question の記録。
|
||||||
- `TicketStatus`: pending/open などの状態整理が明示的に許可された場合だけ使う。
|
- `TicketStatus`: pending/open などの状態整理が明示的に許可された場合だけ使う。
|
||||||
- `TicketWorkflowState`: `queued -> inprogress` acceptance、`inprogress -> done`、または concrete missing decision/information reason を伴う `ready|queued -> planning` に使う。
|
- `TicketWorkflowState`: `queued -> inprogress` acceptance、`inprogress -> done`、または concrete missing decision/information reason を伴う `ready|queued -> planning` に使う。
|
||||||
|
- `TicketOrchestrationPlanQuery`: 対象 Ticket や関連 Ticket の ordering / blocker / conflict / waiting-capacity / accepted-plan 記録を読む。queued acceptance 前に必ず確認する。
|
||||||
|
- `TicketOrchestrationPlanRecord`: Orchestrator が routing 中に project-relevant な ordering / dependency / conflict / capacity/waiting / accepted-plan decision を残す。これは queue reorder、自動起動、workflow_state 変更ではない。
|
||||||
- `TicketClose`: 完了権限と resolution が揃っている場合だけ使う。
|
- `TicketClose`: 完了権限と resolution が揃っている場合だけ使う。
|
||||||
- `TicketDoctor`: routing 前後の整合性確認。
|
- `TicketDoctor`: routing 前後の整合性確認。
|
||||||
|
|
||||||
|
|
@ -73,9 +75,10 @@ Orchestrator は以下を行う。
|
||||||
|
|
||||||
## Queued acceptance contract
|
## Queued acceptance contract
|
||||||
|
|
||||||
`workflow_state = queued` は、Ticket が routing 対象として人間により Orchestrator へ渡された状態である。Orchestrator は queued notification を受けたら、Ticket と workspace state を読んで、次のどちらかを行う。
|
`workflow_state = queued` は、Ticket が routing 対象として人間により Orchestrator へ渡された状態である。Orchestrator は queued notification を受けたら、Ticket、workspace state、対象 Ticket の `TicketOrchestrationPlanQuery` 記録を読んで、次のどちらかを行う。
|
||||||
|
|
||||||
- unblocked と判断する場合: `queued -> inprogress` を記録してから worktree 作成、implementation/review Pod spawn、その他の implementation side effect に進む。
|
- unblocked と判断する場合: `queued -> inprogress` を記録してから worktree 作成、implementation/review Pod spawn、その他の implementation side effect に進む。
|
||||||
|
- `before` / `after` / `blocked_by` / `blocks` / `conflicts_with` / `do_not_parallelize` / waiting-capacity 記録がある場合、それを acceptance 判断の入力にする。記録は自動 scheduler ではないため、実際に進めるかどうかは Orchestrator が読んだうえで明示的に決める。
|
||||||
- concrete missing decision / information がある場合: `TicketWorkflowState` で `queued -> planning` を記録し、reason/body に不足項目を残す。既存の claimed live/restorable Intake/Planning Pod があり、既存通知経路が使える場合は同じ理由を通知する。
|
- concrete missing decision / information がある場合: `TicketWorkflowState` で `queued -> planning` を記録し、reason/body に不足項目を残す。既存の claimed live/restorable Intake/Planning Pod があり、既存通知経路が使える場合は同じ理由を通知する。
|
||||||
- external action 待ちなど planning では解決しない blocker の場合: concise な理由を Ticket thread に記録し、queued のまま待つか、既存の Ticket status/state mechanism で明示的に defer/block する。
|
- external action 待ちなど planning では解決しない blocker の場合: concise な理由を Ticket thread に記録し、queued のまま待つか、既存の Ticket status/state mechanism で明示的に defer/block する。
|
||||||
|
|
||||||
|
|
@ -160,6 +163,7 @@ Action:
|
||||||
Action:
|
Action:
|
||||||
|
|
||||||
- `IntentPacket` を作る。
|
- `IntentPacket` を作る。
|
||||||
|
- project-relevant な ordering / blocker / conflict / capacity decision や accepted work plan がある場合は、`TicketOrchestrationPlanRecord` に bounded typed record として残す。local session/socket/raw model output は入れない。
|
||||||
- `TicketComment` に routing decision と IntentPacket を記録する。
|
- `TicketComment` に routing decision と IntentPacket を記録する。
|
||||||
- 許可があれば `multi-agent-workflow` に接続し、worktree + coder/reviewer sibling loop に進む。
|
- 許可があれば `multi-agent-workflow` に接続し、worktree + coder/reviewer sibling loop に進む。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,12 @@ fn tool_description(name: &str) -> &'static str {
|
||||||
}
|
}
|
||||||
"TicketStatus" => "Move a Ticket between open and pending; use TicketClose for closed.",
|
"TicketStatus" => "Move a Ticket between open and pending; use TicketClose for closed.",
|
||||||
"TicketClose" => "Close a Ticket with a resolution through the typed local Ticket backend.",
|
"TicketClose" => "Close a Ticket with a resolution through the typed local Ticket backend.",
|
||||||
|
"TicketOrchestrationPlanRecord" => {
|
||||||
|
"Append a durable typed Ticket orchestration plan record without changing workflow_state or starting work."
|
||||||
|
}
|
||||||
|
"TicketOrchestrationPlanQuery" => {
|
||||||
|
"Query durable Ticket orchestration plan records by Ticket and/or relation kind."
|
||||||
|
}
|
||||||
"TicketDoctor" => "Run typed local Ticket backend consistency checks.",
|
"TicketDoctor" => "Run typed local Ticket backend consistency checks.",
|
||||||
_ => "Typed Ticket backend tool.",
|
_ => "Typed Ticket backend tool.",
|
||||||
}
|
}
|
||||||
|
|
@ -411,7 +417,7 @@ profile = "inherit"
|
||||||
assert_eq!(report.reports[0].diagnostics.len(), 1);
|
assert_eq!(report.reports[0].diagnostics.len(), 1);
|
||||||
let message = &report.reports[0].diagnostics[0].message;
|
let message = &report.reports[0].diagnostics[0].message;
|
||||||
assert!(message.contains("Ticket tools not registered"));
|
assert!(message.contains("Ticket tools not registered"));
|
||||||
assert!(message.contains("unknown Ticket role `operator`"));
|
assert!(message.contains("unsupported Ticket role `operator`"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ use std::path::{Component, Path, PathBuf};
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use fs4::fs_std::FileExt;
|
use fs4::fs_std::FileExt;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
|
use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
|
|
@ -37,6 +38,9 @@ const REQUIRED_FIELDS: [&str; 10] = [
|
||||||
];
|
];
|
||||||
const MAX_STATE_CHANGE_REASON_BYTES: usize = 1024;
|
const MAX_STATE_CHANGE_REASON_BYTES: usize = 1024;
|
||||||
const MAX_INTAKE_SUMMARY_BODY_BYTES: usize = 16 * 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 =
|
const DEFAULT_TICKET_BODY: &str =
|
||||||
"## Background\n\nCreated by LocalTicketBackend.\n\n## Acceptance criteria\n\n- TBD\n";
|
"## Background\n\nCreated by LocalTicketBackend.\n\n## Acceptance criteria\n\n- TBD\n";
|
||||||
const JAPANESE_TICKET_BODY: &str =
|
const JAPANESE_TICKET_BODY: &str =
|
||||||
|
|
@ -550,6 +554,104 @@ pub struct TicketRef {
|
||||||
pub status: TicketStatus,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct TicketMeta {
|
pub struct TicketMeta {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|
@ -700,6 +802,16 @@ pub trait TicketBackend {
|
||||||
fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()>;
|
fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()>;
|
||||||
fn set_status(&self, id: TicketIdOrSlug, status: TicketStatus) -> Result<()>;
|
fn set_status(&self, id: TicketIdOrSlug, status: TicketStatus) -> Result<()>;
|
||||||
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> 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>;
|
fn doctor(&self) -> Result<TicketDoctorReport>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1021,6 +1133,20 @@ impl LocalTicketBackend {
|
||||||
})?;
|
})?;
|
||||||
atomic_write(item, updated.as_bytes())
|
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 {
|
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> {
|
fn doctor(&self) -> Result<TicketDoctorReport> {
|
||||||
let mut report = TicketDoctorReport::default();
|
let mut report = TicketDoctorReport::default();
|
||||||
for status in STATUSES {
|
for status in STATUSES {
|
||||||
|
|
@ -1577,6 +1787,12 @@ impl TicketBackend for LocalTicketBackend {
|
||||||
}
|
}
|
||||||
if artifacts.exists() {
|
if artifacts.exists() {
|
||||||
doctor_artifacts(&artifacts, &mut report)?;
|
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 {
|
fn format_yaml_string_scalar(value: &str) -> String {
|
||||||
let mut out = String::from("'");
|
let mut out = String::from("'");
|
||||||
for ch in value.chars() {
|
for ch in value.chars() {
|
||||||
|
|
@ -2335,6 +2769,25 @@ fn collect_artifacts_inner(
|
||||||
Ok(())
|
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<()> {
|
fn doctor_artifacts(dir: &Path, report: &mut TicketDoctorReport) -> Result<()> {
|
||||||
for entry in fs::read_dir(dir).map_err(|e| io_err(dir, e))? {
|
for entry in fs::read_dir(dir).map_err(|e| io_err(dir, e))? {
|
||||||
let entry = entry.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();
|
.unwrap_err();
|
||||||
assert!(matches!(err, TicketError::InvalidPathComponent(_)));
|
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")
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
ExtensibleTicketStatus, LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, Ticket,
|
AcceptedOrchestrationPlan, ExtensibleTicketStatus, LocalTicketBackend, MarkdownText,
|
||||||
|
NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, OrchestrationPlanKind, Ticket,
|
||||||
TicketBackend, TicketDoctorDiagnostic, TicketDoctorReport, TicketDoctorSeverity, TicketError,
|
TicketBackend, TicketDoctorDiagnostic, TicketDoctorReport, TicketDoctorSeverity, TicketError,
|
||||||
TicketEventKind, TicketIdOrSlug, TicketIntakeSummary, TicketRef, TicketReview,
|
TicketEventKind, TicketIdOrSlug, TicketIntakeSummary, TicketRef, TicketReview,
|
||||||
TicketReviewResult, TicketStateChange, TicketStatus, TicketSummary, TicketWorkflowState,
|
TicketReviewResult, TicketStateChange, TicketStatus, TicketSummary, TicketWorkflowState,
|
||||||
|
|
@ -29,7 +30,7 @@ const MAX_BODY_MAX_BYTES: usize = 64 * 1024;
|
||||||
const DEFAULT_DIAGNOSTIC_LIMIT: usize = 100;
|
const DEFAULT_DIAGNOSTIC_LIMIT: usize = 100;
|
||||||
const MAX_DIAGNOSTIC_LIMIT: usize = 500;
|
const MAX_DIAGNOSTIC_LIMIT: usize = 500;
|
||||||
|
|
||||||
pub const TICKET_TOOL_NAMES: [&str; 10] = [
|
pub const TICKET_TOOL_NAMES: [&str; 12] = [
|
||||||
"TicketCreate",
|
"TicketCreate",
|
||||||
"TicketList",
|
"TicketList",
|
||||||
"TicketShow",
|
"TicketShow",
|
||||||
|
|
@ -39,12 +40,19 @@ pub const TICKET_TOOL_NAMES: [&str; 10] = [
|
||||||
"TicketWorkflowState",
|
"TicketWorkflowState",
|
||||||
"TicketStatus",
|
"TicketStatus",
|
||||||
"TicketClose",
|
"TicketClose",
|
||||||
|
"TicketOrchestrationPlanRecord",
|
||||||
|
"TicketOrchestrationPlanQuery",
|
||||||
"TicketDoctor",
|
"TicketDoctor",
|
||||||
];
|
];
|
||||||
|
|
||||||
pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 3] = ["TicketList", "TicketShow", "TicketDoctor"];
|
pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 4] = [
|
||||||
|
"TicketList",
|
||||||
|
"TicketShow",
|
||||||
|
"TicketOrchestrationPlanQuery",
|
||||||
|
"TicketDoctor",
|
||||||
|
];
|
||||||
|
|
||||||
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 7] = [
|
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 8] = [
|
||||||
"TicketCreate",
|
"TicketCreate",
|
||||||
"TicketComment",
|
"TicketComment",
|
||||||
"TicketReview",
|
"TicketReview",
|
||||||
|
|
@ -52,6 +60,7 @@ pub const TICKET_MUTATING_TOOL_NAMES: [&str; 7] = [
|
||||||
"TicketWorkflowState",
|
"TicketWorkflowState",
|
||||||
"TicketStatus",
|
"TicketStatus",
|
||||||
"TicketClose",
|
"TicketClose",
|
||||||
|
"TicketOrchestrationPlanRecord",
|
||||||
];
|
];
|
||||||
|
|
||||||
const CREATE_DESCRIPTION: &str = "Create a Ticket through the configured typed Ticket backend. \
|
const CREATE_DESCRIPTION: &str = "Create a Ticket through the configured typed Ticket backend. \
|
||||||
|
|
@ -82,6 +91,12 @@ by `yoi ticket doctor`.";
|
||||||
const CLOSE_DESCRIPTION: &str = "Close a Ticket with a Markdown resolution through the typed Ticket \
|
const CLOSE_DESCRIPTION: &str = "Close a Ticket with a Markdown resolution through the typed Ticket \
|
||||||
backend. The backend moves the Ticket to closed/, writes resolution.md, updates item.md, and appends \
|
backend. The backend moves the Ticket to closed/, writes resolution.md, updates item.md, and appends \
|
||||||
a close event.";
|
a close event.";
|
||||||
|
const ORCHESTRATION_PLAN_RECORD_DESCRIPTION: &str = "Append a typed Ticket orchestration plan record \
|
||||||
|
for ordering, dependency, conflict, waiting/capacity, or accepted-plan decisions. Records are durable \
|
||||||
|
Ticket artifacts and do not move workflow_state, reorder queues, or start work.";
|
||||||
|
const ORCHESTRATION_PLAN_QUERY_DESCRIPTION: &str = "Query durable Ticket orchestration plan records by \
|
||||||
|
Ticket id/slug and/or relation kind. This is read-only planning context; Orchestrator must still make \
|
||||||
|
explicit workflow_state decisions.";
|
||||||
const DOCTOR_DESCRIPTION: &str = "Run typed Ticket backend consistency checks and return bounded \
|
const DOCTOR_DESCRIPTION: &str = "Run typed Ticket backend consistency checks and return bounded \
|
||||||
diagnostics through the typed backend without shelling out to external commands.";
|
diagnostics through the typed backend without shelling out to external commands.";
|
||||||
|
|
||||||
|
|
@ -304,6 +319,90 @@ struct TicketCloseParams {
|
||||||
resolution: String,
|
resolution: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
enum OrchestrationPlanKindParam {
|
||||||
|
Before,
|
||||||
|
After,
|
||||||
|
BlockedBy,
|
||||||
|
Blocks,
|
||||||
|
ConflictsWith,
|
||||||
|
DoNotParallelize,
|
||||||
|
WaitingCapacityNote,
|
||||||
|
AcceptedPlan,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OrchestrationPlanKindParam {
|
||||||
|
fn into_kind(self) -> OrchestrationPlanKind {
|
||||||
|
match self {
|
||||||
|
Self::Before => OrchestrationPlanKind::Before,
|
||||||
|
Self::After => OrchestrationPlanKind::After,
|
||||||
|
Self::BlockedBy => OrchestrationPlanKind::BlockedBy,
|
||||||
|
Self::Blocks => OrchestrationPlanKind::Blocks,
|
||||||
|
Self::ConflictsWith => OrchestrationPlanKind::ConflictsWith,
|
||||||
|
Self::DoNotParallelize => OrchestrationPlanKind::DoNotParallelize,
|
||||||
|
Self::WaitingCapacityNote => OrchestrationPlanKind::WaitingCapacityNote,
|
||||||
|
Self::AcceptedPlan => OrchestrationPlanKind::AcceptedPlan,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
|
struct AcceptedOrchestrationPlanParams {
|
||||||
|
/// Bounded project-relevant accepted plan summary.
|
||||||
|
summary: String,
|
||||||
|
/// Optional branch name for the accepted plan. Do not include runtime/session/socket details.
|
||||||
|
#[serde(default)]
|
||||||
|
branch: Option<String>,
|
||||||
|
/// Optional worktree path for the accepted plan. Do not include runtime/session/socket details.
|
||||||
|
#[serde(default)]
|
||||||
|
worktree: Option<String>,
|
||||||
|
/// Optional bounded role/work allocation plan. Do not include raw model output or private runtime details.
|
||||||
|
#[serde(default)]
|
||||||
|
role_plan: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
|
struct TicketOrchestrationPlanRecordParams {
|
||||||
|
/// Ticket id or slug that owns this orchestration plan record.
|
||||||
|
ticket: String,
|
||||||
|
/// Record kind: before/after, blocked_by/blocks, conflicts_with/do_not_parallelize, waiting_capacity_note, or accepted_plan.
|
||||||
|
kind: OrchestrationPlanKindParam,
|
||||||
|
/// Related Ticket id/slug for ordering, dependency, and conflict records.
|
||||||
|
#[serde(default)]
|
||||||
|
related_ticket: Option<String>,
|
||||||
|
/// Optional bounded rationale/note. Required for waiting_capacity_note.
|
||||||
|
#[serde(default)]
|
||||||
|
note: Option<String>,
|
||||||
|
/// Accepted plan fields. Required for accepted_plan and invalid for other kinds.
|
||||||
|
#[serde(default)]
|
||||||
|
accepted_plan: Option<AcceptedOrchestrationPlanParams>,
|
||||||
|
/// Optional record author.
|
||||||
|
#[serde(default)]
|
||||||
|
author: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
|
struct TicketOrchestrationPlanQueryParams {
|
||||||
|
/// Optional Ticket id or slug to query. Omit to query across the backend root.
|
||||||
|
#[serde(default)]
|
||||||
|
ticket: Option<String>,
|
||||||
|
/// Optional relation kind filter.
|
||||||
|
#[serde(default)]
|
||||||
|
relation_kind: Option<OrchestrationPlanKindParam>,
|
||||||
|
/// Maximum records to return. Defaults to 100, max 200.
|
||||||
|
#[serde(default)]
|
||||||
|
limit: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct TicketOrchestrationPlanQueryOutput {
|
||||||
|
count: usize,
|
||||||
|
returned: usize,
|
||||||
|
truncated: bool,
|
||||||
|
records: Vec<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
struct TicketDoctorParams {
|
struct TicketDoctorParams {
|
||||||
/// Maximum diagnostics to return. Defaults to 100, max 500.
|
/// Maximum diagnostics to return. Defaults to 100, max 500.
|
||||||
|
|
@ -382,6 +481,16 @@ struct TicketCloseTool {
|
||||||
backend: LocalTicketBackend,
|
backend: LocalTicketBackend,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct TicketOrchestrationPlanRecordTool {
|
||||||
|
backend: LocalTicketBackend,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct TicketOrchestrationPlanQueryTool {
|
||||||
|
backend: LocalTicketBackend,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct TicketDoctorTool {
|
struct TicketDoctorTool {
|
||||||
backend: LocalTicketBackend,
|
backend: LocalTicketBackend,
|
||||||
|
|
@ -673,6 +782,75 @@ impl Tool for TicketCloseTool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for TicketOrchestrationPlanRecordTool {
|
||||||
|
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||||
|
let params: TicketOrchestrationPlanRecordParams =
|
||||||
|
parse_input("TicketOrchestrationPlanRecord", input_json)?;
|
||||||
|
let accepted_plan = params.accepted_plan.map(|plan| AcceptedOrchestrationPlan {
|
||||||
|
summary: plan.summary,
|
||||||
|
branch: plan.branch,
|
||||||
|
worktree: plan.worktree,
|
||||||
|
role_plan: plan.role_plan,
|
||||||
|
});
|
||||||
|
let record = NewOrchestrationPlanRecord {
|
||||||
|
kind: params.kind.into_kind(),
|
||||||
|
related_ticket: params.related_ticket,
|
||||||
|
note: params.note,
|
||||||
|
accepted_plan,
|
||||||
|
author: params.author,
|
||||||
|
};
|
||||||
|
let output = self
|
||||||
|
.backend
|
||||||
|
.add_orchestration_plan_record(TicketIdOrSlug::Query(params.ticket.clone()), record)
|
||||||
|
.map_err(|error| backend_error("TicketOrchestrationPlanRecord", error))?;
|
||||||
|
Ok(json_output(
|
||||||
|
format!(
|
||||||
|
"Recorded orchestration plan {} for ticket {}",
|
||||||
|
output.kind, params.ticket
|
||||||
|
),
|
||||||
|
output,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for TicketOrchestrationPlanQueryTool {
|
||||||
|
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||||
|
let params: TicketOrchestrationPlanQueryParams =
|
||||||
|
parse_input("TicketOrchestrationPlanQuery", input_json)?;
|
||||||
|
let limit = bounded(params.limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
|
||||||
|
let ticket = params.ticket.clone().map(TicketIdOrSlug::Query);
|
||||||
|
let kind = params
|
||||||
|
.relation_kind
|
||||||
|
.map(OrchestrationPlanKindParam::into_kind);
|
||||||
|
let records = self
|
||||||
|
.backend
|
||||||
|
.query_orchestration_plan_records(ticket, kind)
|
||||||
|
.map_err(|error| backend_error("TicketOrchestrationPlanQuery", error))?;
|
||||||
|
let count = records.len();
|
||||||
|
let truncated = count > limit;
|
||||||
|
let returned_records = records
|
||||||
|
.into_iter()
|
||||||
|
.take(limit)
|
||||||
|
.map(|record| serde_json::to_value(record).unwrap_or_else(|_| json!({})))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
Ok(json_output(
|
||||||
|
format!(
|
||||||
|
"Found {} orchestration plan record(s){}",
|
||||||
|
count,
|
||||||
|
if truncated { " (truncated)" } else { "" }
|
||||||
|
),
|
||||||
|
TicketOrchestrationPlanQueryOutput {
|
||||||
|
count,
|
||||||
|
returned: returned_records.len(),
|
||||||
|
truncated,
|
||||||
|
records: returned_records,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Tool for TicketDoctorTool {
|
impl Tool for TicketDoctorTool {
|
||||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||||
|
|
@ -917,6 +1095,12 @@ fn input_schema(name: &str) -> Value {
|
||||||
}
|
}
|
||||||
"TicketStatus" => serde_json::to_value(schemars::schema_for!(TicketStatusParams)),
|
"TicketStatus" => serde_json::to_value(schemars::schema_for!(TicketStatusParams)),
|
||||||
"TicketClose" => serde_json::to_value(schemars::schema_for!(TicketCloseParams)),
|
"TicketClose" => serde_json::to_value(schemars::schema_for!(TicketCloseParams)),
|
||||||
|
"TicketOrchestrationPlanRecord" => {
|
||||||
|
serde_json::to_value(schemars::schema_for!(TicketOrchestrationPlanRecordParams))
|
||||||
|
}
|
||||||
|
"TicketOrchestrationPlanQuery" => {
|
||||||
|
serde_json::to_value(schemars::schema_for!(TicketOrchestrationPlanQueryParams))
|
||||||
|
}
|
||||||
"TicketDoctor" => serde_json::to_value(schemars::schema_for!(TicketDoctorParams)),
|
"TicketDoctor" => serde_json::to_value(schemars::schema_for!(TicketDoctorParams)),
|
||||||
_ => Ok(json!({})),
|
_ => Ok(json!({})),
|
||||||
}
|
}
|
||||||
|
|
@ -942,6 +1126,8 @@ impl_from_backend!(TicketIntakeReadyTool);
|
||||||
impl_from_backend!(TicketWorkflowStateTool);
|
impl_from_backend!(TicketWorkflowStateTool);
|
||||||
impl_from_backend!(TicketStatusTool);
|
impl_from_backend!(TicketStatusTool);
|
||||||
impl_from_backend!(TicketCloseTool);
|
impl_from_backend!(TicketCloseTool);
|
||||||
|
impl_from_backend!(TicketOrchestrationPlanRecordTool);
|
||||||
|
impl_from_backend!(TicketOrchestrationPlanQueryTool);
|
||||||
impl_from_backend!(TicketDoctorTool);
|
impl_from_backend!(TicketDoctorTool);
|
||||||
|
|
||||||
/// Build all MVP Ticket tool definitions over one local backend root.
|
/// Build all MVP Ticket tool definitions over one local backend root.
|
||||||
|
|
@ -964,6 +1150,16 @@ pub fn ticket_tools(backend: LocalTicketBackend) -> Vec<ToolDefinition> {
|
||||||
),
|
),
|
||||||
tool_definition::<TicketStatusTool>("TicketStatus", STATUS_DESCRIPTION, backend.clone()),
|
tool_definition::<TicketStatusTool>("TicketStatus", STATUS_DESCRIPTION, backend.clone()),
|
||||||
tool_definition::<TicketCloseTool>("TicketClose", CLOSE_DESCRIPTION, backend.clone()),
|
tool_definition::<TicketCloseTool>("TicketClose", CLOSE_DESCRIPTION, backend.clone()),
|
||||||
|
tool_definition::<TicketOrchestrationPlanRecordTool>(
|
||||||
|
"TicketOrchestrationPlanRecord",
|
||||||
|
ORCHESTRATION_PLAN_RECORD_DESCRIPTION,
|
||||||
|
backend.clone(),
|
||||||
|
),
|
||||||
|
tool_definition::<TicketOrchestrationPlanQueryTool>(
|
||||||
|
"TicketOrchestrationPlanQuery",
|
||||||
|
ORCHESTRATION_PLAN_QUERY_DESCRIPTION,
|
||||||
|
backend.clone(),
|
||||||
|
),
|
||||||
tool_definition::<TicketDoctorTool>("TicketDoctor", DOCTOR_DESCRIPTION, backend),
|
tool_definition::<TicketDoctorTool>("TicketDoctor", DOCTOR_DESCRIPTION, backend),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -996,7 +1192,12 @@ mod tests {
|
||||||
fn ticket_tool_name_partitions_are_explicit() {
|
fn ticket_tool_name_partitions_are_explicit() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
TICKET_READ_ONLY_TOOL_NAMES,
|
TICKET_READ_ONLY_TOOL_NAMES,
|
||||||
["TicketList", "TicketShow", "TicketDoctor"]
|
[
|
||||||
|
"TicketList",
|
||||||
|
"TicketShow",
|
||||||
|
"TicketOrchestrationPlanQuery",
|
||||||
|
"TicketDoctor"
|
||||||
|
]
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
TICKET_MUTATING_TOOL_NAMES,
|
TICKET_MUTATING_TOOL_NAMES,
|
||||||
|
|
@ -1007,7 +1208,8 @@ mod tests {
|
||||||
"TicketIntakeReady",
|
"TicketIntakeReady",
|
||||||
"TicketWorkflowState",
|
"TicketWorkflowState",
|
||||||
"TicketStatus",
|
"TicketStatus",
|
||||||
"TicketClose"
|
"TicketClose",
|
||||||
|
"TicketOrchestrationPlanRecord"
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
for name in TICKET_READ_ONLY_TOOL_NAMES {
|
for name in TICKET_READ_ONLY_TOOL_NAMES {
|
||||||
|
|
@ -1433,6 +1635,53 @@ mod tests {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ticket_orchestration_plan_tools_record_and_query_without_state_changes() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let backend = backend(&temp);
|
||||||
|
let first = backend.create(NewTicket::new("Plan Tool First")).unwrap();
|
||||||
|
let second = backend.create(NewTicket::new("Plan Tool Second")).unwrap();
|
||||||
|
let record = tool_by_name(backend.clone(), "TicketOrchestrationPlanRecord");
|
||||||
|
let query = tool_by_name(backend.clone(), "TicketOrchestrationPlanQuery");
|
||||||
|
|
||||||
|
let recorded = record
|
||||||
|
.execute(
|
||||||
|
&json!({
|
||||||
|
"ticket": first.slug,
|
||||||
|
"kind": "blocked_by",
|
||||||
|
"related_ticket": second.slug,
|
||||||
|
"note": "Wait for the second Ticket's API boundary decision.",
|
||||||
|
"author": "orchestrator"
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
recorded
|
||||||
|
.summary
|
||||||
|
.contains("Recorded orchestration plan blocked_by")
|
||||||
|
);
|
||||||
|
|
||||||
|
let found = query
|
||||||
|
.execute(
|
||||||
|
&json!({
|
||||||
|
"ticket": first.id,
|
||||||
|
"relation_kind": "blocked_by"
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let found_json: Value = serde_json::from_str(&found.content.unwrap()).unwrap();
|
||||||
|
assert_eq!(found_json["count"], 1);
|
||||||
|
assert_eq!(found_json["records"][0]["kind"], "blocked_by");
|
||||||
|
assert_eq!(found_json["records"][0]["related_ticket"], second.slug);
|
||||||
|
|
||||||
|
let current = backend.show(TicketIdOrSlug::Id(first.id)).unwrap();
|
||||||
|
assert_eq!(current.meta.workflow_state, TicketWorkflowState::Planning);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn ticket_show_requires_exactly_one_identifier() {
|
async fn ticket_show_requires_exactly_one_identifier() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
|
|
@ -1470,6 +1719,23 @@ mod tests {
|
||||||
.to_string();
|
.to_string();
|
||||||
assert!(!create_schema.contains("legacy_ticket"));
|
assert!(!create_schema.contains("legacy_ticket"));
|
||||||
assert!(!create_schema.contains("needs_preflight"));
|
assert!(!create_schema.contains("needs_preflight"));
|
||||||
|
let plan_record_schema = tools
|
||||||
|
.iter()
|
||||||
|
.map(|definition| definition().0)
|
||||||
|
.find(|meta| meta.name == "TicketOrchestrationPlanRecord")
|
||||||
|
.unwrap()
|
||||||
|
.input_schema
|
||||||
|
.to_string();
|
||||||
|
assert!(plan_record_schema.contains("accepted_plan"));
|
||||||
|
assert!(plan_record_schema.contains("related_ticket"));
|
||||||
|
let plan_query_schema = tools
|
||||||
|
.iter()
|
||||||
|
.map(|definition| definition().0)
|
||||||
|
.find(|meta| meta.name == "TicketOrchestrationPlanQuery")
|
||||||
|
.unwrap()
|
||||||
|
.input_schema
|
||||||
|
.to_string();
|
||||||
|
assert!(plan_query_schema.contains("relation_kind"));
|
||||||
let names = tools
|
let names = tools
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|definition| definition().0)
|
.map(|definition| definition().0)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user