crates/signaltty-core/src/model.rs
//! Server → Workspace → Tab → Pane model. See docs/02.
use std::path::PathBuf;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::error::CoreError;
use crate::state::{Attention, Lifecycle, TaskState};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PtySize {
pub cols: u16,
pub rows: u16,
}
impl PtySize {
pub const MIN_COLS: u16 = 20;
pub const MAX_COLS: u16 = 500;
pub const MIN_ROWS: u16 = 5;
pub const MAX_ROWS: u16 = 200;
pub fn new(cols: u16, rows: u16) -> Result<PtySize, CoreError> {
if !(Self::MIN_COLS..=Self::MAX_COLS).contains(&cols)
|| !(Self::MIN_ROWS..=Self::MAX_ROWS).contains(&rows)
{
return Err(CoreError::BadSize(format!("{cols}x{rows} out of range")));
}
Ok(PtySize { cols, rows })
}
/// Clamp client-requested sizes into range (resize arbitration).
pub fn clamp(cols: u16, rows: u16) -> PtySize {
PtySize {
cols: cols.clamp(Self::MIN_COLS, Self::MAX_COLS),
rows: rows.clamp(Self::MIN_ROWS, Self::MAX_ROWS),
}
}
}
impl Default for PtySize {
fn default() -> Self {
PtySize { cols: 80, rows: 24 }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SplitDir {
Right,
Down,
}
/// Binary split tree. The server stores it; the GUI renders it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Layout {
Pane {
pane_id: String,
},
Split {
dir: SplitDir,
ratio: f32,
first: Box<Layout>,
second: Box<Layout>,
},
}
impl Layout {
/// Hard stops for divider ratios: a client can never collapse a
/// pane entirely, whatever pixels it reports.
pub const MIN_RATIO: f32 = 0.05;
pub const MAX_RATIO: f32 = 0.95;
/// Clamp a divider ratio into range. Callers must reject
/// non-finite input first (`clamp` panics on NaN).
pub fn clamp_ratio(ratio: f32) -> f32 {
ratio.clamp(Self::MIN_RATIO, Self::MAX_RATIO)
}
/// Validate leaf uniqueness and finite ratios, then normalize dividers.
pub fn validate_and_normalize(&mut self) -> Result<(), CoreError> {
fn validate(
layout: &Layout,
seen: &mut std::collections::HashSet<String>,
) -> Result<(), CoreError> {
match layout {
Layout::Pane { pane_id } => {
if !seen.insert(pane_id.clone()) {
return Err(CoreError::BadLayout(format!("duplicate pane {pane_id}")));
}
}
Layout::Split {
ratio,
first,
second,
..
} => {
if !ratio.is_finite() {
return Err(CoreError::BadLayout("ratio must be finite".into()));
}
validate(first, seen)?;
validate(second, seen)?;
}
}
Ok(())
}
fn normalize(layout: &mut Layout) {
if let Layout::Split {
ratio,
first,
second,
..
} = layout
{
*ratio = Layout::clamp_ratio(*ratio);
normalize(first);
normalize(second);
}
}
validate(self, &mut std::collections::HashSet::new())?;
normalize(self);
Ok(())
}
/// Replace the leaf holding `pane_id` with a split containing the old
/// pane and `new_pane_id`, then divide the run evenly: every visual
/// sibling in the same direction ends up with the same width (or
/// height). Returns false if `pane_id` was not found.
pub fn split(&mut self, pane_id: &str, dir: SplitDir, new_pane_id: String) -> bool {
let inserted = self.split_at(pane_id, dir, new_pane_id.clone());
if inserted {
self.equalize_run(&new_pane_id, dir);
}
inserted
}
fn split_at(&mut self, pane_id: &str, dir: SplitDir, new_pane_id: String) -> bool {
match self {
Layout::Pane { pane_id: id } if id == pane_id => {
let old = std::mem::replace(
self,
Layout::Pane {
pane_id: String::new(),
},
);
*self = Layout::Split {
dir,
ratio: 0.5,
first: Box::new(old),
second: Box::new(Layout::Pane {
pane_id: new_pane_id,
}),
};
true
}
Layout::Pane { .. } => false,
Layout::Split { first, second, .. } => {
first.split_at(pane_id, dir, new_pane_id.clone())
|| second.split_at(pane_id, dir, new_pane_id)
}
}
}
/// Recompute the ratios on `pane_id`'s run in `dir` so every
/// column (or row) gets an equal share. The run is the maximal
/// subtree around the pane whose splits all point in `dir`;
/// ancestors above it and nested cross-direction splits keep
/// their ratios. Dragged dividers in the run reset to even.
fn equalize_run(&mut self, pane_id: &str, dir: SplitDir) {
let mut path = Vec::new();
if !self.path_to(pane_id, &mut path) {
return;
}
// The segment starts below the last cross-direction split on
// the path; without one it is the whole tree.
let mut depth = 0;
let mut node = &*self;
for (i, &side) in path.iter().enumerate() {
match node {
Layout::Split {
dir: d,
first,
second,
..
} => {
if *d != dir {
depth = i + 1;
}
node = if side { second } else { first };
}
Layout::Pane { .. } => break,
}
}
let mut seg = &mut *self;
for &side in &path[..depth] {
match seg {
Layout::Split { first, second, .. } => {
seg = if side { second } else { first };
}
Layout::Pane { .. } => return,
}
}
seg.assign_equal(dir);
}
/// Rewrite every `dir` ratio in this subtree for equal shares.
/// Returns the subtree's width in columns (a cross-direction
/// subtree counts as one column, whatever it holds).
fn assign_equal(&mut self, dir: SplitDir) -> f32 {
match self {
Layout::Split {
dir: d,
ratio,
first,
second,
} if *d == dir => {
let w_first = first.assign_equal(dir);
let w_second = second.assign_equal(dir);
*ratio = w_first / (w_first + w_second);
w_first + w_second
}
_ => 1.0,
}
}
/// Child choices from the root to `pane_id` (`false` = first,
/// `true` = second). False if the pane is not in the tree.
fn path_to(&self, pane_id: &str, path: &mut Vec<bool>) -> bool {
match self {
Layout::Pane { pane_id: id } => id == pane_id,
Layout::Split { first, second, .. } => {
path.push(false);
if first.path_to(pane_id, path) {
return true;
}
path.pop();
path.push(true);
if second.path_to(pane_id, path) {
return true;
}
path.pop();
false
}
}
}
fn node_at(&self, path: &[bool]) -> Option<&Layout> {
let mut node = self;
for &side in path {
match node {
Layout::Split { first, second, .. } => {
node = if side { second } else { first };
}
Layout::Pane { .. } => return None,
}
}
Some(node)
}
fn node_at_mut(&mut self, path: &[bool]) -> Option<&mut Layout> {
let mut node = self;
for &side in path {
match node {
Layout::Split { first, second, .. } => {
node = if side { second } else { first };
}
Layout::Pane { .. } => return None,
}
}
Some(node)
}
/// Set the ratio of the split at `path` (empty = root). False
/// unless the path resolves to a `Split`.
pub fn set_ratio_at_path(&mut self, path: &[bool], ratio: f32) -> bool {
match self.node_at_mut(path) {
Some(Layout::Split { ratio: r, .. }) => {
*r = ratio;
true
}
_ => false,
}
}
/// Ratio of the split at `path`, if it resolves to a `Split`.
pub fn ratio_at_path(&self, path: &[bool]) -> Option<f32> {
match self.node_at(path) {
Some(Layout::Split { ratio, .. }) => Some(*ratio),
_ => None,
}
}
/// True when `path` resolves to a `Split` (clients use it to tell
/// a stale drag from a failed send).
pub fn has_split_at(&self, path: &[bool]) -> bool {
self.ratio_at_path(path).is_some()
}
/// Same tree ignoring split ratios: panes, directions and nesting
/// must match. The GUI reconciles widget trees on this so a
/// dragged divider never rebuilds a terminal.
pub fn same_structure(&self, other: &Layout) -> bool {
match (self, other) {
(Layout::Pane { pane_id: a }, Layout::Pane { pane_id: b }) => a == b,
(
Layout::Split {
dir: da,
first: fa,
second: sa,
..
},
Layout::Split {
dir: db,
first: fb,
second: sb,
..
},
) => da == db && fa.same_structure(fb) && sa.same_structure(sb),
_ => false,
}
}
/// Remove the leaf holding `pane_id`, collapsing its parent split.
/// Returns false if `pane_id` was not found.
pub fn remove(&mut self, pane_id: &str) -> bool {
match self {
Layout::Pane { pane_id: id } => id == pane_id,
Layout::Split { first, second, .. } => {
if matches!(**first, Layout::Pane { pane_id: ref id } if id == pane_id)
|| matches!(**second, Layout::Pane { pane_id: ref id } if id == pane_id)
{
// Collapse: keep the surviving sibling.
let survivor = if matches!(**first, Layout::Pane { pane_id: ref id } if id == pane_id)
{
std::mem::replace(
second.as_mut(),
Layout::Pane {
pane_id: String::new(),
},
)
} else {
std::mem::replace(
first.as_mut(),
Layout::Pane {
pane_id: String::new(),
},
)
};
*self = survivor;
true
} else {
first.remove(pane_id) || second.remove(pane_id)
}
}
}
}
pub fn panes(&self) -> Vec<String> {
let mut out = Vec::new();
self.collect(&mut out);
out
}
fn collect(&self, out: &mut Vec<String>) {
match self {
Layout::Pane { pane_id } => out.push(pane_id.clone()),
Layout::Split { first, second, .. } => {
first.collect(out);
second.collect(out);
}
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AgentKind {
Codex,
Claude,
Opencode,
Cursor,
Pi,
Generic,
#[default]
None,
}
impl AgentKind {
pub fn as_str(self) -> &'static str {
match self {
AgentKind::Codex => "codex",
AgentKind::Claude => "claude",
AgentKind::Opencode => "opencode",
AgentKind::Cursor => "cursor",
AgentKind::Pi => "pi",
AgentKind::Generic => "generic",
AgentKind::None => "none",
}
}
pub fn parse(s: &str) -> Option<AgentKind> {
Some(match s {
"codex" => AgentKind::Codex,
"claude" => AgentKind::Claude,
"opencode" => AgentKind::Opencode,
"cursor" => AgentKind::Cursor,
"pi" => AgentKind::Pi,
"generic" => AgentKind::Generic,
"none" => AgentKind::None,
_ => return None,
})
}
}
/// Adapter-owned native session identity. Persisted; never scraped from
/// terminal text while a better channel exists. See docs/07, docs/09.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentInfo {
pub kind: AgentKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_session_id: Option<String>,
/// Official adapter-built resume argv. Never auto-run without opt-in.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resume_argv: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// Non-secret provider config directories retained for an official resume.
#[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
pub config_env: std::collections::HashMap<String, String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum LiveState {
Live,
Exited { code: Option<i32> },
}
/// Honest post-restart presence. See docs/09.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum RestoreState {
Live,
Restored,
Resumable,
Exited,
}
/// One answerable option of a pending [`Decision`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DecisionOption {
pub id: String,
pub label: String,
}
/// A structured decision request from an agent (directive 2): rendered as
/// data-driven buttons, never scraped from terminal text. At most one per
/// pane; newer supersedes older. See `docs/adr/0008-decision-answer.md`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Decision {
pub id: String,
pub prompt: String,
pub options: Vec<DecisionOption>,
/// Captured at ingest from the adapter's answer channel: buttons iff
/// true, else a read-only prompt with an answer-in-terminal hint.
/// Defaults to true for forward-compat payloads predating channels.
#[serde(default = "crate::model::decision_answerable_default")]
pub answerable: bool,
pub received_at: DateTime<Utc>,
}
fn decision_answerable_default() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pane {
pub id: String,
pub workspace_id: String,
pub tab_id: String,
pub title: String,
pub cwd: String,
pub argv: Vec<String>,
pub pty_size: PtySize,
pub live: LiveState,
pub restore_state: RestoreState,
pub agent: AgentInfo,
pub lifecycle: Lifecycle,
pub last_lifecycle: Lifecycle,
/// When `lifecycle` last changed; drives "Working for 2m…".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lifecycle_since: Option<DateTime<Utc>>,
/// Length of the most recent `working` stretch, in seconds;
/// drives "Worked for 2m" once the turn ends.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_run_secs: Option<i64>,
pub attention: Attention,
/// When `attention` was last raised; drives "Approval 10m".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attention_since: Option<DateTime<Utc>>,
/// Latest explicit notification / hook summary. Never a raw scrape.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_message: Option<String>,
/// Pending structured decision (directive 2). None = plain terminal.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pending_decision: Option<Decision>,
pub created_at: DateTime<Utc>,
pub last_activity_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_seen_at: Option<DateTime<Utc>>,
/// Lineage & orchestration tracking (directive 018).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_pane_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub root_pane_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub relationship: Option<Relationship>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub task_id: Option<String>,
}
impl Pane {
/// A freshly spawned, live pane titled after its command.
pub fn new(
workspace_id: String,
tab_id: String,
cwd: String,
argv: Vec<String>,
pty_size: PtySize,
now: DateTime<Utc>,
) -> Pane {
let title = argv
.first()
.map(|a| {
std::path::Path::new(a)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| a.clone())
})
.unwrap_or_else(|| "shell".to_string());
Pane {
id: crate::ids::new_pane_id(),
workspace_id,
tab_id,
title,
cwd,
argv,
pty_size,
live: LiveState::Live,
restore_state: RestoreState::Live,
agent: AgentInfo::default(),
lifecycle: Lifecycle::Unknown,
last_lifecycle: Lifecycle::Unknown,
lifecycle_since: Some(now),
last_run_secs: None,
attention: Attention::None,
attention_since: None,
last_message: None,
pending_decision: None,
created_at: now,
last_activity_at: now,
last_seen_at: None,
parent_pane_id: None,
root_pane_id: None,
label: None,
relationship: None,
task_id: None,
}
}
/// Attention clears only on explicit per-pane interaction.
pub fn mark_seen(&mut self, now: DateTime<Utc>) {
self.attention = Attention::None;
self.attention_since = None;
self.last_seen_at = Some(now);
}
pub fn raise_attention(&mut self, next: Attention) {
self.attention = self.attention.raise(next);
}
/// Leaving `working` records how long the run took.
pub fn set_lifecycle(&mut self, next: Lifecycle, now: DateTime<Utc>) {
if self.lifecycle == next {
return;
}
if self.lifecycle == Lifecycle::Working {
if let Some(since) = self.lifecycle_since {
let secs = (now - since).num_seconds().max(0);
self.last_run_secs = Some(secs);
}
}
self.last_lifecycle = self.lifecycle;
self.lifecycle = next;
self.lifecycle_since = Some(now);
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tab {
pub id: String,
pub workspace_id: String,
pub title: String,
/// None = empty tab (no panes yet).
pub layout: Option<Layout>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_pane_id: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GitInfo {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub root: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
#[serde(default)]
pub detached: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dirty: Option<bool>,
}
/// Slugify a workspace name into a stable handle: lowercase alphanumerics
/// joined by single `-`, truncated to 32 chars, `ws` fallback. Never
/// contains `_`, so handles and `ws_…` ids stay disjoint by construction.
pub fn slugify(name: &str) -> String {
let mut out = String::new();
let mut dash = false;
for c in name.chars().flat_map(|c| c.to_lowercase()) {
if c.is_ascii_alphanumeric() {
out.push(c);
dash = false;
} else if !dash {
out.push('-');
dash = true;
}
if out.len() >= 32 {
break;
}
}
let slug = out.trim_matches('-').to_string();
if slug.is_empty() {
"ws".to_string()
} else {
slug
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Workspace {
pub id: String,
pub name: String,
/// Immutable unique handle (`my-api`): the stable typable address.
/// `name` is the editable label. Backfilled on legacy snapshots.
#[serde(default)]
pub handle: String,
pub cwd: String,
#[serde(default)]
pub git: GitInfo,
pub tabs: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_tab_id: Option<String>,
#[serde(default)]
pub auto_resume: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NotificationSeverity {
Info,
Warning,
Error,
}
impl NotificationSeverity {
pub fn parse(s: &str) -> Option<NotificationSeverity> {
Some(match s {
"info" => NotificationSeverity::Info,
"warning" => NotificationSeverity::Warning,
"error" => NotificationSeverity::Error,
_ => return None,
})
}
pub fn attention(self) -> Attention {
match self {
NotificationSeverity::Info => Attention::Unread,
NotificationSeverity::Warning => Attention::Warning,
NotificationSeverity::Error => Attention::Error,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Notification {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pane_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
pub severity: NotificationSeverity,
pub source: String,
pub created_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub read_at: Option<DateTime<Utc>>,
}
/// Relationship between parent and child tasks/panes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum Relationship {
Fork,
#[default]
Subagent,
}
/// Task objective and requirements contract.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Contract {
pub objective: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub constraints: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub acceptance_criteria: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_format: Option<String>,
}
impl Contract {
pub const MIN_OBJECTIVE_BYTES: usize = 1;
pub const MAX_OBJECTIVE_BYTES: usize = 32 * 1024; // 32 KiB
pub fn new(objective: impl Into<String>) -> Result<Self, CoreError> {
let contract = Self {
objective: objective.into(),
constraints: None,
acceptance_criteria: None,
output_format: None,
};
contract.validate()?;
Ok(contract)
}
pub fn validate(&self) -> Result<(), CoreError> {
if self.objective.len() < Self::MIN_OBJECTIVE_BYTES {
return Err(CoreError::InvalidContract(
"objective must not be empty".to_string(),
));
}
if self.objective.len() > Self::MAX_OBJECTIVE_BYTES {
return Err(CoreError::InvalidContract(format!(
"objective length {} exceeds max 32 KiB",
self.objective.len()
)));
}
Ok(())
}
}
/// Artifact produced by a task worker.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Artifact {
pub name: String,
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
/// Reported outcome status for a task result.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskResultStatus {
Completed,
Failed,
Rejected,
}
impl TaskResultStatus {
pub fn to_state(self) -> TaskState {
match self {
TaskResultStatus::Completed => TaskState::Completed,
TaskResultStatus::Failed => TaskState::Failed,
TaskResultStatus::Rejected => TaskState::Rejected,
}
}
}
/// Structured result reported by a task worker.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TaskResult {
pub status: TaskResultStatus,
pub summary: String,
#[serde(default)]
pub artifacts: Vec<Artifact>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub evidence: Option<serde_json::Value>,
pub reported_at: DateTime<Utc>,
}
impl TaskResult {
pub const MIN_SUMMARY_BYTES: usize = 1;
pub const MAX_SUMMARY_BYTES: usize = 8 * 1024; // 8 KiB
pub const MAX_ARTIFACTS: usize = 32;
pub fn validate(&self) -> Result<(), CoreError> {
if self.summary.len() < Self::MIN_SUMMARY_BYTES {
return Err(CoreError::InvalidTaskResult(
"summary must not be empty".to_string(),
));
}
if self.summary.len() > Self::MAX_SUMMARY_BYTES {
return Err(CoreError::InvalidTaskResult(format!(
"summary length {} exceeds max 8 KiB",
self.summary.len()
)));
}
if self.artifacts.len() > Self::MAX_ARTIFACTS {
return Err(CoreError::InvalidTaskResult(format!(
"artifacts count {} exceeds max 32",
self.artifacts.len()
)));
}
Ok(())
}
}
/// Final outcome of a completed/failed task.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum DispositionOutcome {
#[default]
None,
Merged,
Discarded,
}
/// Recorded finish disposition of a task.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Disposition {
pub outcome: DispositionOutcome,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub merged_sha: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch_deleted: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub at: Option<DateTime<Utc>>,
}
impl Default for Disposition {
fn default() -> Self {
Self {
outcome: DispositionOutcome::None,
target_ref: None,
merged_sha: None,
branch_deleted: None,
at: None,
}
}
}
/// Pull request state on the forge (e.g. GitHub).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum PrState {
#[default]
Open,
Merged,
Closed,
}
impl PrState {
pub fn parse_gh(s: &str) -> Option<Self> {
match s.trim().to_uppercase().as_str() {
"OPEN" => Some(Self::Open),
"MERGED" => Some(Self::Merged),
"CLOSED" => Some(Self::Closed),
_ => None,
}
}
}
/// Rollup status of CI checks on a pull request.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum PrChecks {
#[default]
None,
Pending,
Passing,
Failing,
}
/// Review decision status on a pull request.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum PrReview {
#[default]
None,
ReviewRequired,
Approved,
ChangesRequested,
}
/// Forge pull request associated with an orchestrated task.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaskPr {
pub number: u64,
pub url: String,
#[serde(default)]
pub state: PrState,
#[serde(default)]
pub checks: PrChecks,
#[serde(default)]
pub review: PrReview,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checked_at: Option<DateTime<Utc>>,
}
/// Parse a GitHub GraphQL reviewDecision string into `PrReview`.
pub fn parse_review_decision(s: &str) -> PrReview {
match s.trim().to_uppercase().as_str() {
"REVIEW_REQUIRED" => PrReview::ReviewRequired,
"APPROVED" => PrReview::Approved,
"CHANGES_REQUESTED" => PrReview::ChangesRequested,
_ => PrReview::None,
}
}
/// Roll up GitHub `statusCheckRollup` items into a single `PrChecks` status.
///
/// Priority 1: Failing (any failure, error, cancelled, timed_out, action_required, startup_failure)
/// Priority 2: Pending (any status != COMPLETED or state in PENDING, EXPECTED)
/// Priority 3: Passing (non-empty items and none of the above)
/// Priority 4: None (empty items)
pub fn rollup_status_checks(checks: &[serde_json::Value]) -> PrChecks {
if checks.is_empty() {
return PrChecks::None;
}
const FAILING_CONCLUSIONS: &[&str] = &[
"FAILURE",
"ERROR",
"CANCELLED",
"TIMED_OUT",
"ACTION_REQUIRED",
"STARTUP_FAILURE",
];
const FAILING_STATES: &[&str] = &["FAILURE", "ERROR"];
const PENDING_STATES: &[&str] = &["PENDING", "EXPECTED"];
let mut has_pending = false;
for item in checks {
let conclusion = item
.get("conclusion")
.and_then(|v| v.as_str())
.map(|s| s.to_uppercase());
let status = item
.get("status")
.and_then(|v| v.as_str())
.map(|s| s.to_uppercase());
let state = item
.get("state")
.and_then(|v| v.as_str())
.map(|s| s.to_uppercase());
if let Some(ref c) = conclusion {
if FAILING_CONCLUSIONS.contains(&c.as_str()) {
return PrChecks::Failing;
}
}
if let Some(ref st) = state {
if FAILING_STATES.contains(&st.as_str()) {
return PrChecks::Failing;
}
}
if let Some(ref st) = status {
if st != "COMPLETED" {
has_pending = true;
}
}
if let Some(ref st) = state {
if PENDING_STATES.contains(&st.as_str()) {
has_pending = true;
}
}
}
if has_pending {
PrChecks::Pending
} else {
PrChecks::Passing
}
}
/// An orchestrated sub-task.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Task {
pub id: String,
pub context_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_task_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pane_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_pane_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub root_pane_id: Option<String>,
#[serde(default)]
pub relationship: Relationship,
pub label: String,
pub contract: Contract,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
pub source_repo: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_branch: Option<String>,
pub worktree_path: PathBuf,
pub branch: String,
#[serde(default)]
pub preexisting_branch: bool,
pub base_ref: String,
pub base_sha: String,
pub state: TaskState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result: Option<TaskResult>,
#[serde(default)]
pub disposition: Disposition,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pr: Option<TaskPr>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status_reason: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub finish_error: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_pid: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_cmd: Option<Vec<String>>,
/// Caller-chosen idempotency key for `task.start`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_request_id: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Compose the submitted prompt from the task contract and metadata.
///
/// Pure function combining a fixed worker preamble, objective, optional constraints,
/// bulleted acceptance criteria, and expected output format.
pub fn compose_worker_prompt(
task_id: &str,
branch: &str,
base_sha: &str,
contract: &Contract,
) -> String {
let mut out = format!(
"You are a worker for task {task_id} in an isolated worktree on branch {branch} from base {base_sha}; stay inside this worktree; commit your work; when done or blocked run signaltty report --status completed|failed|rejected --summary … with evidence — it reads $SIGNALTTY_TASK.\n\n## Objective\n{}\n",
contract.objective.trim()
);
if let Some(constraints) = &contract.constraints {
let trimmed = constraints.trim();
if !trimmed.is_empty() {
out.push_str("\n## Constraints\n");
out.push_str(trimmed);
out.push('\n');
}
}
if let Some(criteria) = &contract.acceptance_criteria {
if !criteria.is_empty() {
out.push_str("\n## Acceptance Criteria\n");
for item in criteria {
out.push_str(&format!("- {}\n", item.trim()));
}
}
}
if let Some(output_format) = &contract.output_format {
let trimmed = output_format.trim();
if !trimmed.is_empty() {
out.push_str("\n## Expected Output Format\n");
out.push_str(trimmed);
out.push('\n');
}
}
out
}
impl Task {
pub fn compose_worker_prompt(&self) -> String {
compose_worker_prompt(&self.id, &self.branch, &self.base_sha, &self.contract)
}
pub fn transition_to(&mut self, next: TaskState, now: DateTime<Utc>) -> Result<(), CoreError> {
if !self.state.can_transition_to(next) {
return Err(CoreError::InvalidTaskTransition(self.state, next));
}
self.state = next;
self.updated_at = now;
Ok(())
}
pub fn apply_report(
&mut self,
result: TaskResult,
now: DateTime<Utc>,
) -> Result<(), CoreError> {
result.validate()?;
let target_state = result.status.to_state();
if self.state.is_terminal() {
return Err(CoreError::InvalidTaskTransition(self.state, target_state));
}
if !self.state.can_transition_to(target_state) {
return Err(CoreError::InvalidTaskTransition(self.state, target_state));
}
self.state = target_state;
self.result = Some(result);
self.updated_at = now;
Ok(())
}
pub fn apply_turn_ended_without_report(
&mut self,
evidence: Option<serde_json::Value>,
now: DateTime<Utc>,
) -> bool {
if self.state.is_terminal() {
// Report wins over turn end race
return false;
}
if self.state == TaskState::Working {
self.state = TaskState::InputRequired;
self.status_reason = evidence;
self.updated_at = now;
return true;
}
false
}
}
#[cfg(test)]
mod tests {
#[test]
fn layout_validation_rejects_non_finite_ratios_without_mutation() {
use super::{Layout, SplitDir};
for ratio in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
let mut layout = Layout::Split {
dir: SplitDir::Right,
ratio,
first: Box::new(Layout::Pane {
pane_id: "a".into(),
}),
second: Box::new(Layout::Pane {
pane_id: "b".into(),
}),
};
assert!(layout.validate_and_normalize().is_err());
assert_eq!(
layout.ratio_at_path(&[]).unwrap().to_bits(),
ratio.to_bits()
);
}
}
use super::*;
fn pane(id: &str) -> Layout {
Layout::Pane {
pane_id: id.to_string(),
}
}
#[test]
fn layout_split_and_remove() {
let mut l = pane("a");
assert!(l.split("a", SplitDir::Right, "b".into()));
assert_eq!(l.panes(), vec!["a".to_string(), "b".to_string()]);
assert!(l.split("b", SplitDir::Down, "c".into()));
assert_eq!(
l.panes(),
vec!["a".to_string(), "b".to_string(), "c".to_string()]
);
assert!(!l.split("zzz", SplitDir::Right, "d".into()));
assert!(l.remove("b"));
assert_eq!(l.panes(), vec!["a".to_string(), "c".to_string()]);
assert!(!l.remove("zzz"));
}
/// Width share of each pane left-to-right, for Right runs.
fn widths(l: &Layout) -> Vec<f32> {
fn walk(l: &Layout, scale: f32, out: &mut Vec<f32>) {
match l {
Layout::Pane { .. } => out.push(scale),
Layout::Split {
ratio,
first,
second,
..
} => {
walk(first, scale * ratio, out);
walk(second, scale * (1.0 - ratio), out);
}
}
}
let mut out = Vec::new();
walk(l, 1.0, &mut out);
out
}
fn assert_widths(l: &Layout, expected: &[f32]) {
let got = widths(l);
assert_eq!(got.len(), expected.len(), "widths {got:?}");
for (g, e) in got.iter().zip(expected) {
assert!((g - e).abs() < 1e-6, "widths {got:?} != {expected:?}");
}
}
#[test]
fn split_equalizes_same_direction_run() {
// Repeatedly splitting the newest pane keeps every column equal.
let mut l = pane("a");
assert!(l.split("a", SplitDir::Right, "b".into()));
assert_widths(&l, &[0.5, 0.5]);
assert!(l.split("b", SplitDir::Right, "c".into()));
assert_widths(&l, &[1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0]);
assert!(l.split("c", SplitDir::Right, "d".into()));
assert_widths(&l, &[0.25, 0.25, 0.25, 0.25]);
}
#[test]
fn split_equalizes_middle_of_run() {
// Splitting a middle column equalizes the whole run, not just
// the new pair.
let mut l = pane("a");
l.split("a", SplitDir::Right, "b".into());
l.split("b", SplitDir::Right, "c".into());
assert!(l.split("a", SplitDir::Right, "d".into()));
assert_widths(&l, &[0.25, 0.25, 0.25, 0.25]);
assert_eq!(l.panes(), vec!["a", "d", "b", "c"]);
}
#[test]
fn split_leaves_other_direction_ratios_alone() {
let mut l = pane("a");
l.split("a", SplitDir::Right, "b".into());
l.split("b", SplitDir::Right, "c".into());
assert!(l.split("c", SplitDir::Down, "d".into()));
// The Right run still divides in thirds; the Down pair in halves.
assert_eq!(l.ratio_at_path(&[]), Some(1.0 / 3.0));
assert_eq!(l.ratio_at_path(&[true]), Some(0.5));
assert_eq!(l.ratio_at_path(&[true, true]), Some(0.5));
}
#[test]
fn split_resets_dragged_ratios_in_run() {
let mut l = pane("a");
l.split("a", SplitDir::Right, "b".into());
assert!(l.set_ratio_at_path(&[], 0.9));
assert!(l.split("b", SplitDir::Right, "c".into()));
assert_widths(&l, &[1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0]);
}
#[test]
fn split_equalizes_nested_same_direction_columns() {
// Side subtrees with their own same-direction splits count all
// their columns: [X Y] P -> [X Y] [P Q] in quarters.
let mut l = Layout::Split {
dir: SplitDir::Right,
ratio: 0.5,
first: Box::new(Layout::Split {
dir: SplitDir::Right,
ratio: 0.5,
first: Box::new(pane("x")),
second: Box::new(pane("y")),
}),
second: Box::new(pane("p")),
};
assert!(l.split("p", SplitDir::Right, "q".into()));
assert_widths(&l, &[0.25, 0.25, 0.25, 0.25]);
}
#[test]
fn ratio_at_path_resolves_splits_only() {
let mut l = pane("a");
l.split("a", SplitDir::Right, "b".into());
assert_eq!(l.ratio_at_path(&[]), Some(0.5));
assert_eq!(l.ratio_at_path(&[false]), None); // leaf, not a split
assert_eq!(l.ratio_at_path(&[true, false]), None); // past a leaf
assert!(l.set_ratio_at_path(&[], 0.25));
assert_eq!(l.ratio_at_path(&[]), Some(0.25));
assert!(!l.set_ratio_at_path(&[false], 0.25));
assert!(!l.set_ratio_at_path(&[true, true], 0.25));
assert!(l.has_split_at(&[]));
assert!(!l.has_split_at(&[true]));
}
#[test]
fn clamp_ratio_keeps_dividers_sane() {
assert_eq!(Layout::clamp_ratio(0.5), 0.5);
assert_eq!(Layout::clamp_ratio(0.0), 0.05);
assert_eq!(Layout::clamp_ratio(1.0), 0.95);
assert_eq!(Layout::clamp_ratio(-3.0), 0.05);
}
#[test]
fn same_structure_ignores_ratios_only() {
let mut a = pane("a");
a.split("a", SplitDir::Right, "b".into());
let mut b = a.clone();
assert!(a.same_structure(&b));
b.set_ratio_at_path(&[], 0.9);
assert!(a.same_structure(&b));
b.split("b", SplitDir::Down, "c".into());
assert!(!a.same_structure(&b));
}
#[test]
fn pty_size_clamp_and_validate() {
assert!(PtySize::new(80, 24).is_ok());
assert!(PtySize::new(1, 24).is_err());
let s = PtySize::clamp(1000, 1);
assert_eq!((s.cols, s.rows), (PtySize::MAX_COLS, PtySize::MIN_ROWS));
}
#[test]
fn pane_mark_seen_keeps_lifecycle() {
let now = Utc::now();
let mut p = Pane::new(
"ws_x".into(),
"tab_x".into(),
"/tmp".into(),
vec!["sh".into()],
PtySize::default(),
now,
);
p.lifecycle = Lifecycle::Done;
p.attention = Attention::Unread;
p.mark_seen(now);
assert_eq!(p.attention, Attention::None);
assert_eq!(p.lifecycle, Lifecycle::Done);
assert!(p.last_seen_at.is_some());
}
#[test]
fn pane_new_titles_after_command() {
let now = Utc::now();
let p = Pane::new(
"w".into(),
"t".into(),
"/".into(),
vec!["/usr/bin/claude".into()],
PtySize::default(),
now,
);
assert_eq!(p.title, "claude");
let p = Pane::new(
"w".into(),
"t".into(),
"/".into(),
vec![],
PtySize::default(),
now,
);
assert_eq!(p.title, "shell");
}
#[test]
fn leaving_working_records_run_length() {
let t0 = Utc::now();
let mut p = Pane::new(
"w".into(),
"t".into(),
"/".into(),
vec!["sh".into()],
PtySize::default(),
t0,
);
p.set_lifecycle(Lifecycle::Working, t0);
p.set_lifecycle(Lifecycle::Working, t0 + chrono::Duration::seconds(30));
assert_eq!(
p.lifecycle_since,
Some(t0),
"no-op transition keeps the clock"
);
p.set_lifecycle(Lifecycle::Done, t0 + chrono::Duration::seconds(125));
assert_eq!(p.last_run_secs, Some(125));
assert_eq!(p.last_lifecycle, Lifecycle::Working);
p.set_lifecycle(Lifecycle::Idle, t0 + chrono::Duration::seconds(200));
assert_eq!(p.last_run_secs, Some(125), "only working stretches count");
}
#[test]
fn slugify_makes_stable_typable_handles() {
assert_eq!(slugify("My API!!"), "my-api");
assert_eq!(slugify(" spaced out "), "spaced-out");
assert_eq!(slugify("!!!"), "ws");
assert_eq!(slugify(""), "ws");
assert_eq!(slugify("UPPER_snake"), "upper-snake");
let long = slugify(&"a".repeat(100));
assert!(long.len() <= 32);
for s in ["my-api", "ws", &long] {
assert!(!s.contains('_'), "{s}");
assert!(!s.starts_with('-') && !s.ends_with('-'), "{s}");
}
}
#[test]
fn decision_roundtrips_and_defaults_for_old_snapshots() {
let now = Utc::now();
let d = Decision {
id: "d1".into(),
prompt: "Allow rm -rf /tmp/x?".into(),
options: vec![
DecisionOption {
id: "once".into(),
label: "Once".into(),
},
DecisionOption {
id: "always".into(),
label: "Always".into(),
},
DecisionOption {
id: "deny".into(),
label: "Deny".into(),
},
],
answerable: false,
received_at: now,
};
let back: Decision = serde_json::from_value(serde_json::to_value(&d).unwrap()).unwrap();
assert_eq!(back, d);
// Payloads predating channels stay answerable.
let legacy: Decision = serde_json::from_value(serde_json::json!({
"id": "d1", "prompt": "p", "options": [],
"received_at": now,
}))
.unwrap();
assert!(legacy.answerable);
// Old pane snapshots load with no pending decision and default lineage fields.
let p = Pane::new(
"w".into(),
"t".into(),
"/".into(),
vec!["sh".into()],
PtySize::default(),
now,
);
assert_eq!(p.pending_decision, None);
let v = serde_json::to_value(&p).unwrap();
assert!(
v.get("pending_decision").is_none(),
"None skips serialization"
);
let back: Pane = serde_json::from_value(serde_json::json!({
"id": p.id, "workspace_id": "w", "tab_id": "t", "title": "sh",
"cwd": "/", "argv": ["sh"], "pty_size": {"cols": 80, "rows": 24},
"live": {"state": "live"}, "restore_state": "LIVE",
"agent": {"kind": "none"}, "lifecycle": "unknown", "last_lifecycle": "unknown",
"attention": "none", "created_at": now, "last_activity_at": now,
}))
.unwrap();
assert_eq!(back.pending_decision, None);
assert_eq!(back.parent_pane_id, None);
assert_eq!(back.root_pane_id, None);
assert_eq!(back.label, None);
assert_eq!(back.relationship, None);
assert_eq!(back.task_id, None);
}
#[test]
fn contract_validation_bounds() {
assert!(Contract::new("").is_err());
assert!(Contract::new("x").is_ok());
let max_str = "a".repeat(Contract::MAX_OBJECTIVE_BYTES);
assert!(Contract::new(max_str).is_ok());
let over_str = "a".repeat(Contract::MAX_OBJECTIVE_BYTES + 1);
assert!(Contract::new(over_str).is_err());
}
#[test]
fn task_result_validation_bounds() {
let now = Utc::now();
let res_empty = TaskResult {
status: TaskResultStatus::Completed,
summary: "".to_string(),
artifacts: vec![],
evidence: None,
reported_at: now,
};
assert!(res_empty.validate().is_err());
let res_max_summary = TaskResult {
status: TaskResultStatus::Completed,
summary: "s".repeat(TaskResult::MAX_SUMMARY_BYTES),
artifacts: vec![],
evidence: None,
reported_at: now,
};
assert!(res_max_summary.validate().is_ok());
let res_over_summary = TaskResult {
status: TaskResultStatus::Completed,
summary: "s".repeat(TaskResult::MAX_SUMMARY_BYTES + 1),
artifacts: vec![],
evidence: None,
reported_at: now,
};
assert!(res_over_summary.validate().is_err());
let artifacts_ok: Vec<Artifact> = (0..32)
.map(|i| Artifact {
name: format!("art_{i}"),
path: format!("path/{i}"),
version: None,
})
.collect();
let res_max_art = TaskResult {
status: TaskResultStatus::Completed,
summary: "ok".to_string(),
artifacts: artifacts_ok,
evidence: None,
reported_at: now,
};
assert!(res_max_art.validate().is_ok());
let artifacts_over: Vec<Artifact> = (0..33)
.map(|i| Artifact {
name: format!("art_{i}"),
path: format!("path/{i}"),
version: None,
})
.collect();
let res_over_art = TaskResult {
status: TaskResultStatus::Completed,
summary: "ok".to_string(),
artifacts: artifacts_over,
evidence: None,
reported_at: now,
};
assert!(res_over_art.validate().is_err());
}
#[test]
fn task_lifecycle_and_report_wins() {
let now = Utc::now();
let contract = Contract::new("Do work").unwrap();
let mut task = Task {
id: crate::ids::new_task_id(),
context_id: crate::ids::new_context_id(),
parent_task_id: None,
pane_id: Some("pane_1".to_string()),
parent_pane_id: None,
root_pane_id: None,
relationship: Relationship::Subagent,
label: "test-task".to_string(),
contract,
agent: Some("codex".to_string()),
source_repo: PathBuf::from("/tmp/repo"),
target_branch: Some("main".to_string()),
worktree_path: PathBuf::from("/tmp/worktree"),
branch: "task-branch".to_string(),
preexisting_branch: false,
base_ref: "main".to_string(),
base_sha: "abcd1234abcd".to_string(),
state: TaskState::Pending,
result: None,
disposition: Disposition::default(),
pr: None,
status_reason: None,
finish_error: None,
worker_pid: None,
worker_cmd: None,
client_request_id: None,
created_at: now,
updated_at: now,
};
// Pending -> Working
assert!(task.transition_to(TaskState::Working, now).is_ok());
assert_eq!(task.state, TaskState::Working);
// Working -> turn ended without report -> InputRequired
let reason = serde_json::json!({"reason": "turn_ended_without_report"});
assert!(task.apply_turn_ended_without_report(Some(reason.clone()), now));
assert_eq!(task.state, TaskState::InputRequired);
assert_eq!(task.status_reason, Some(reason));
// Follow-up: InputRequired -> Working
assert!(task.transition_to(TaskState::Working, now).is_ok());
assert_eq!(task.state, TaskState::Working);
// Worker reports completed
let result = TaskResult {
status: TaskResultStatus::Completed,
summary: "All done successfully".to_string(),
artifacts: vec![],
evidence: None,
reported_at: now,
};
assert!(task.apply_report(result.clone(), now).is_ok());
assert_eq!(task.state, TaskState::Completed);
assert_eq!(task.result, Some(result));
// Turn end arrived after report: report wins, turn end is ignored
assert!(!task.apply_turn_ended_without_report(None, now));
assert_eq!(task.state, TaskState::Completed);
// Terminal state is immutable
assert!(task.transition_to(TaskState::Working, now).is_err());
}
#[test]
fn worker_prompt_composition_from_full_contract() {
let contract = Contract {
objective: "Implement feature X".to_string(),
constraints: Some("Do not touch vendor folder".to_string()),
acceptance_criteria: Some(vec![
"All tests pass".to_string(),
"Clippy clean".to_string(),
]),
output_format: Some("JSON format output".to_string()),
};
let prompt = compose_worker_prompt("task_123", "feature-x", "abc456", &contract);
assert!(prompt.contains("You are a worker for task task_123 in an isolated worktree on branch feature-x from base abc456;"));
assert!(prompt.contains("stay inside this worktree; commit your work; when done or blocked run signaltty report --status completed|failed|rejected --summary … with evidence — it reads $SIGNALTTY_TASK"));
assert!(prompt.contains("## Objective\nImplement feature X\n"));
assert!(prompt.contains("## Constraints\nDo not touch vendor folder\n"));
assert!(prompt.contains("## Acceptance Criteria\n- All tests pass\n- Clippy clean\n"));
assert!(prompt.contains("## Expected Output Format\nJSON format output\n"));
// Minimal contract without optional fields
let minimal = Contract::new("Just the objective").unwrap();
let min_prompt = compose_worker_prompt("task_999", "main", "base000", &minimal);
assert!(min_prompt.contains("You are a worker for task task_999 in an isolated worktree on branch main from base base000"));
assert!(min_prompt.contains("## Objective\nJust the objective\n"));
assert!(!min_prompt.contains("## Constraints"));
assert!(!min_prompt.contains("## Acceptance Criteria"));
assert!(!min_prompt.contains("## Expected Output Format"));
}
#[test]
fn test_parse_review_decision() {
assert_eq!(
parse_review_decision("REVIEW_REQUIRED"),
PrReview::ReviewRequired
);
assert_eq!(
parse_review_decision("review_required"),
PrReview::ReviewRequired
);
assert_eq!(parse_review_decision("APPROVED"), PrReview::Approved);
assert_eq!(parse_review_decision("approved"), PrReview::Approved);
assert_eq!(
parse_review_decision("CHANGES_REQUESTED"),
PrReview::ChangesRequested
);
assert_eq!(
parse_review_decision("changes_requested"),
PrReview::ChangesRequested
);
assert_eq!(parse_review_decision(""), PrReview::None);
assert_eq!(parse_review_decision("unknown"), PrReview::None);
}
#[test]
fn test_pr_state_parse_gh() {
assert_eq!(PrState::parse_gh("OPEN"), Some(PrState::Open));
assert_eq!(PrState::parse_gh("open"), Some(PrState::Open));
assert_eq!(PrState::parse_gh("MERGED"), Some(PrState::Merged));
assert_eq!(PrState::parse_gh("merged"), Some(PrState::Merged));
assert_eq!(PrState::parse_gh("CLOSED"), Some(PrState::Closed));
assert_eq!(PrState::parse_gh("closed"), Some(PrState::Closed));
assert_eq!(PrState::parse_gh("draft"), None);
}
#[test]
fn test_rollup_status_checks() {
use serde_json::json;
// Empty checks -> None
assert_eq!(rollup_status_checks(&[]), PrChecks::None);
// All passing check runs
let passing = vec![
json!({"status": "COMPLETED", "conclusion": "SUCCESS"}),
json!({"status": "COMPLETED", "conclusion": "NEUTRAL"}),
json!({"state": "SUCCESS"}),
];
assert_eq!(rollup_status_checks(&passing), PrChecks::Passing);
// Pending check run (status != COMPLETED)
let pending1 = vec![
json!({"status": "COMPLETED", "conclusion": "SUCCESS"}),
json!({"status": "IN_PROGRESS", "conclusion": null}),
];
assert_eq!(rollup_status_checks(&pending1), PrChecks::Pending);
// Pending status context (state == PENDING)
let pending2 = vec![
json!({"status": "COMPLETED", "conclusion": "SUCCESS"}),
json!({"state": "PENDING"}),
];
assert_eq!(rollup_status_checks(&pending2), PrChecks::Pending);
// Failing overrides pending (Priority 1 > Priority 2)
let failing1 = vec![
json!({"status": "IN_PROGRESS"}),
json!({"status": "COMPLETED", "conclusion": "FAILURE"}),
];
assert_eq!(rollup_status_checks(&failing1), PrChecks::Failing);
let failing2 = vec![
json!({"state": "ERROR"}),
json!({"status": "COMPLETED", "conclusion": "SUCCESS"}),
];
assert_eq!(rollup_status_checks(&failing2), PrChecks::Failing);
let failing_cancelled = vec![json!({"status": "COMPLETED", "conclusion": "TIMED_OUT"})];
assert_eq!(rollup_status_checks(&failing_cancelled), PrChecks::Failing);
}
#[test]
fn test_task_pr_serde_backward_compat() {
use serde_json::json;
// Task serialized without pr field deserializes with pr = None
let task_json = json!({
"id": "task_1",
"context_id": "ctx_1",
"label": "test",
"contract": {"objective": "do something"},
"source_repo": "/repo",
"worktree_path": "/wt",
"branch": "b",
"base_ref": "main",
"base_sha": "abc",
"state": "pending",
"created_at": "2026-10-04T12:00:00Z",
"updated_at": "2026-10-04T12:00:00Z"
});
let task: Task = serde_json::from_value(task_json).unwrap();
assert_eq!(task.pr, None);
// Task with pr
let pr = TaskPr {
number: 42,
url: "https://github.com/foo/bar/pull/42".to_string(),
state: PrState::Open,
checks: PrChecks::Passing,
review: PrReview::Approved,
checked_at: None,
};
let mut task_with_pr = task.clone();
task_with_pr.pr = Some(pr);
let val = serde_json::to_value(&task_with_pr).unwrap();
assert_eq!(val["pr"]["number"], 42);
assert_eq!(val["pr"]["state"], "open");
assert_eq!(val["pr"]["checks"], "passing");
assert_eq!(val["pr"]["review"], "approved");
// Round trip
let round_trip: Task = serde_json::from_value(val).unwrap();
assert_eq!(round_trip.pr, task_with_pr.pr);
}
}