use std::collections::BTreeSet;
use argonauts_core::TaskId;
use argonauts_core::api::StatusSummary;
use argonauts_core::names::{PlanLabel, RoleName};
use argonauts_core::settings::protocol::VisibilitySettings;
use crate::status::Viewer;
use crate::status::folder::Tagged;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Audience {
Everyone,
Draft {
label: PlanLabel,
round: u8,
},
BlindReview(TaskId),
UserOnly,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Moment {
pub sealed_through: u8,
pub settled: BTreeSet<TaskId>,
}
pub fn enabled(role: RoleName, rules: &VisibilitySettings) -> bool {
rules.status_summary.get(&role).copied().unwrap_or(true)
}
pub fn sees(
viewer: &Viewer,
audience: &Audience,
moment: &Moment,
rules: &VisibilitySettings,
) -> bool {
let viewer = normalized(viewer);
match (viewer, audience) {
(_, Audience::Everyone) | (Viewer::User, _) => true,
(_, Audience::UserOnly) => false,
(Viewer::Narrator, _) => true,
(
Viewer::Session { role, label, .. },
Audience::Draft {
label: draft,
round,
},
) => {
*role != RoleName::Draft
|| !rules.drafts_hidden_until_sealed
|| *round <= moment.sealed_through
|| *label == Some(*draft)
}
(Viewer::Session { role, task, .. }, Audience::BlindReview(reviewed)) => {
let fellow_reviewer = *role == RoleName::Reviewer && task.as_ref() == Some(reviewed);
!fellow_reviewer
|| !rules.reviews_hidden_until_settled
|| moment.settled.contains(reviewed)
}
}
}
pub fn shows_authors(viewer: &Viewer, rules: &VisibilitySettings) -> bool {
match normalized(viewer) {
Viewer::User | Viewer::Narrator => true,
Viewer::Session { role, .. } => *role == RoleName::Judge && rules.judges_see_authors,
}
}
pub fn filter(tagged: &Tagged, viewer: &Viewer, rules: &VisibilitySettings) -> StatusSummary {
if let Viewer::Session { role, .. } = normalized(viewer)
&& !enabled(*role, rules)
{
return StatusSummary {
run: tagged.run.clone(),
..StatusSummary::default()
};
}
let authors = shows_authors(viewer, rules);
let seen = |audience: &Audience| sees(viewer, audience, &tagged.moment, rules);
StatusSummary {
run: tagged.run.clone(),
goal: tagged.goal.clone(),
plan: tagged.plan.clone(),
decisions: tagged.decisions.clone(),
tasks: tagged
.tasks
.iter()
.filter(|task| seen(&task.audience))
.map(|task| task.brief.clone())
.collect(),
recent: tagged
.recent
.iter()
.chain(&tagged.now)
.filter(|line| seen(&line.audience))
.map(|line| match (&line.with_authors, authors) {
(Some(named), true) => named.clone(),
_ => line.text.clone(),
})
.collect(),
spend: tagged.spend.clone(),
plan_windows: tagged.plan_windows.clone(),
waiting: tagged
.waiting
.iter()
.map(|(_, title)| title.clone())
.collect(),
}
}
fn normalized(viewer: &Viewer) -> &Viewer {
match viewer {
Viewer::Session {
role: RoleName::Narrator,
..
} => &Viewer::Narrator,
other => other,
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use time::OffsetDateTime;
use argonauts_core::RunId;
use argonauts_core::events::plan::PlanEvent;
use argonauts_core::events::task::{CheckKind, ReviewEvent, TaskEvent, TaskKind, TaskSpec};
use argonauts_core::events::{RunEvent, RunEventKind};
use argonauts_core::names::{DraftId, ModelRef};
use argonauts_core::steps::judgment::{ReviewOutput, ReviewVerdict, SettleOutput};
use super::*;
use crate::status::folder::Folder;
fn event(seq: u64, task: Option<&str>, kind: RunEventKind) -> RunEvent {
RunEvent {
seq,
ts: OffsetDateTime::UNIX_EPOCH,
run_id: RunId::new("run-01").unwrap(),
task_id: task.map(|task| TaskId::new(task).unwrap()),
session: None,
source: "argonauts-code".to_owned(),
kind,
}
}
fn sealed(seq: u64, label: PlanLabel, worker: &str, round: u8) -> RunEvent {
event(
seq,
None,
RunEventKind::Plan(PlanEvent::Sealed {
draft: DraftId::numbered(u32::from(label)),
label,
worker: worker.parse().unwrap(),
round,
summary: format!("draft {label}"),
path: PathBuf::from(format!("drafts/{label}.md")),
sha256: String::new(),
}),
)
}
fn spec(id: &str, owned: &[&str], hidden: &[&str]) -> TaskSpec {
TaskSpec {
id: TaskId::new(id).unwrap(),
title: format!("{id} title"),
task: String::new(),
kind: TaskKind::Build,
owned_paths: owned.iter().map(|path| (*path).to_owned()).collect(),
read_only_paths: Vec::new(),
forbidden_paths: Vec::new(),
hidden_paths: hidden.iter().map(|path| (*path).to_owned()).collect(),
depends_on: Vec::new(),
criteria: Vec::new(),
worker: Some(ModelRef::new("codex", None)),
reviewers: Vec::new(),
limit: Default::default(),
predictions: None,
item: None,
}
}
fn review(seq: u64, task: &str, reviewer: &str) -> RunEvent {
event(
seq,
Some(task),
RunEventKind::Review(ReviewEvent::Reviewed {
reviewer: reviewer.parse().unwrap(),
blind: true,
round: 1,
review: ReviewOutput {
verdict: ReviewVerdict::Approve,
summary: "fine".to_owned(),
comments: Vec::new(),
tamper_suspected: false,
},
}),
)
}
fn drafting_agent(label: Option<PlanLabel>) -> Viewer {
Viewer::Session {
role: RoleName::Draft,
task: None,
label,
}
}
fn reviewer(task: &str) -> Viewer {
Viewer::Session {
role: RoleName::Reviewer,
task: Some(TaskId::new(task).unwrap()),
label: None,
}
}
fn judge() -> Viewer {
Viewer::Session {
role: RoleName::Judge,
task: None,
label: None,
}
}
fn mentions(summary: &StatusSummary, text: &str) -> bool {
summary.recent.iter().any(|line| line.contains(text))
}
#[test]
fn status_summary_visibility_rules() {
let mut folder = Folder::default();
folder.push(&sealed(0, 1, "claude-code", 1));
folder.push(&sealed(1, 2, "codex", 1));
let one = folder.summary(&drafting_agent(Some(1)));
assert!(mentions(&one, "Plan 1 sealed"));
assert!(!mentions(&one, "Plan 2"));
assert!(!mentions(&folder.summary(&drafting_agent(None)), "Plan"));
let you = folder.summary(&Viewer::User);
assert!(mentions(&you, "Plan 1 (claude-code) sealed"));
assert!(mentions(&you, "Plan 2 (codex) sealed"));
folder.push(&event(
2,
None,
RunEventKind::Plan(PlanEvent::Mapped {
round: 1,
worker: "agy".parse().unwrap(),
topics: Vec::new(),
}),
));
let one = folder.summary(&drafting_agent(Some(1)));
assert!(mentions(&one, "Plan 2 sealed"), "{:?}", one.recent);
let judged = folder.summary(&judge());
assert!(mentions(&judged, "Plan 1 sealed"));
assert!(!judged.recent.iter().any(|line| line.contains("codex")));
assert!(mentions(&folder.summary(&Viewer::Narrator), "(codex)"));
let open = VisibilitySettings {
judges_see_authors: true,
..VisibilitySettings::default()
};
let mut open_folder = Folder::new(open);
open_folder.push(&sealed(0, 1, "codex", 1));
assert!(mentions(&open_folder.summary(&judge()), "(codex)"));
folder.push(&event(
3,
Some("task-002"),
RunEventKind::Task(TaskEvent::Created(Box::new(spec(
"task-002",
&["src/limiter/**"],
&[],
)))),
));
folder.push(&review(4, "task-002", "claude-code"));
assert!(!mentions(
&folder.summary(&reviewer("task-002")),
"reviewed"
));
assert!(mentions(
&folder.summary(&reviewer("task-003")),
"task-002 reviewed"
));
assert!(mentions(
&folder.summary(&Viewer::User),
"task-002 reviewed"
));
folder.push(&event(
5,
Some("task-002"),
RunEventKind::Review(ReviewEvent::Settled {
judge: "agy".parse().unwrap(),
ruling: SettleOutput {
verdict: ReviewVerdict::Approve,
reasons: vec!["both fine".to_owned()],
required_changes: Vec::new(),
},
}),
));
assert!(mentions(&folder.summary(&reviewer("task-002")), "reviewed"));
folder.push(&event(
6,
Some("test-009"),
RunEventKind::Task(TaskEvent::Created(Box::new(spec(
"test-009",
&["tests/hidden/**"],
&["tests/hidden/**"],
)))),
));
folder.push(&event(
7,
Some("task-002"),
RunEventKind::Check(argonauts_core::events::task::CheckOutcome {
name: "tests/hidden/limits.rs".to_owned(),
kind: CheckKind::Command,
passed: false,
command: None,
exit_code: Some(1),
duration_ms: 10,
summary: "1 failed".to_owned(),
output_tail: String::new(),
violations: Vec::new(),
sandboxed: true,
skipped: false,
}),
));
let you = folder.summary(&Viewer::User);
assert!(
you.tasks
.iter()
.any(|task| task.task.as_str() == "test-009")
);
assert!(mentions(&you, "tests/hidden"));
for agent in [
Viewer::Narrator,
judge(),
reviewer("task-003"),
drafting_agent(Some(1)),
] {
let seen = folder.summary(&agent);
assert!(
!seen
.tasks
.iter()
.any(|task| task.task.as_str() == "test-009")
);
assert!(
!mentions(&seen, "tests/hidden"),
"{agent:?}: {:?}",
seen.recent
);
}
let mut off = VisibilitySettings::default();
off.status_summary.insert(RoleName::Judge, false);
let judged = filter(folder.tagged(), &judge(), &off);
assert!(judged.run.is_some() && judged.tasks.is_empty() && judged.recent.is_empty());
}
}