Menu
akurai-tasks
publicLatest change a3b219eee35afd2833ddd5655e5fb4889674a57f - feat: add strategy outcomes and delivery metrics by Ólafur Búi Ólafsson
use super::*;
#[derive(Debug, Clone, Default)]
pub struct OutcomePatch {
pub title: Option<String>,
pub description: Option<String>,
pub projects: Option<Vec<String>>,
pub repos: Option<Vec<String>>,
pub owner: Option<String>,
pub target_metric: Option<String>,
pub baseline: Option<Value>,
pub target: Option<Value>,
pub observation_start: Option<i64>,
pub observation_end: Option<i64>,
pub result_links: Option<Vec<String>>,
}
#[derive(Debug, Clone)]
pub struct CreateOutcome {
pub title: String,
pub description: String,
pub projects: Vec<String>,
pub repos: Vec<String>,
pub owner: String,
pub target_metric: String,
pub baseline: Value,
pub target: Value,
pub observation_start: i64,
pub observation_end: i64,
pub result_links: Vec<String>,
}
impl Store {
pub fn create_outcome(&self, input: CreateOutcome, actor: &str, idem: &str) -> Result<Value> {
let CreateOutcome {
title,
description,
projects,
repos,
owner,
target_metric,
baseline,
target,
observation_start,
observation_end,
result_links,
} = input;
let projects = normalize_projects(projects)?;
let repos = normalize_repos(repos)?;
validate_window(observation_start, observation_end)?;
validate_links(&result_links)?;
require_plan_scope(self, actor, &projects)?;
let request = object(vec![
("command", string("outcome_create")),
("project", string(&projects[0])),
("title", string(title.trim())),
("projects", strings(&projects)),
("repos", strings(&repos)),
("owner", string(owner.trim())),
("targetMetric", string(target_metric.trim())),
("baseline", baseline.clone()),
("target", target.clone()),
("observationStart", Value::Int(observation_start)),
("observationEnd", Value::Int(observation_end)),
("resultLinks", strings(&result_links)),
]);
self.mutate(actor, idem, &request, |db, _| {
let sequence = next_counter(db, "counter/outcome")?;
let id = format!("OUT-{sequence}");
let timestamp = now();
let outcome = object(vec![
("id", string(&id)),
("title", string(title.trim())),
("description", string(description.trim())),
("projects", strings(&projects)),
("repos", strings(&repos)),
("owner", string(owner.trim())),
("targetMetric", string(target_metric.trim())),
("baseline", baseline.clone()),
("target", target.clone()),
("observationStart", Value::Int(observation_start)),
("observationEnd", Value::Int(observation_end)),
("resultLinks", strings(&result_links)),
("state", string("unknown")),
("revision", Value::Int(1)),
("createdAt", Value::Int(timestamp)),
("updatedAt", Value::Int(timestamp)),
]);
put(db, &format!("outcome/{id}"), &outcome)?;
put(db, &format!("outcome-version/{id}/0000000001"), &outcome)?;
for project in &projects {
put(
db,
&format!("outcome-by-project/{project}/{sequence:020}/{id}"),
&outcome,
)?;
}
for project in &projects {
if let Some(mut project_record) = get_value(db, &format!("project/{project}"))? {
let mut links = project_record
.get("outcomes")
.cloned()
.unwrap_or(Value::Array(Vec::new()));
append_unique(&mut links, string(&id));
update(&mut project_record, "outcomes", links);
put(db, &format!("project/{project}"), &project_record)?;
}
}
Ok(outcome)
})
}
pub fn get_outcome(&self, id: &str) -> Result<Value> {
self.get(&format!("outcome/{id}"), format!("outcome {id}"))
}
pub fn update_outcome(
&self,
id: &str,
expected: i64,
patch: OutcomePatch,
actor: &str,
idem: &str,
) -> Result<Value> {
validate_patch(&patch)?;
let existing = self.get_outcome(id)?;
let projects = patch
.projects
.clone()
.unwrap_or_else(|| array_strings(existing.get("projects")));
require_plan_scope(self, actor, &projects)?;
let request = outcome_patch_request(id, expected, &patch);
self.mutate(actor, idem, &request, |db, _| {
let mut outcome = get_value(db, &format!("outcome/{id}"))?
.ok_or_else(|| Error::NotFound(format!("outcome {id}")))?;
check_revision(&outcome, expected)?;
apply_patch(&mut outcome, &patch);
bump(&mut outcome);
update(&mut outcome, "updatedAt", Value::Int(now()));
let revision = outcome.get("revision").and_then(Value::as_i64).unwrap_or(1);
put(db, &format!("outcome/{id}"), &outcome)?;
put(
db,
&format!("outcome-version/{id}/{revision:020}"),
&outcome,
)?;
refresh_indexes(db, &outcome)?;
refresh_project_links(db, &outcome)?;
Ok(outcome)
})
}
pub fn link_outcome(
&self,
outcome_id: &str,
item_id: &str,
actor: &str,
idem: &str,
) -> Result<Value> {
let outcome = self.get_outcome(outcome_id)?;
let item = self.get_item(item_id)?;
let mut projects = array_strings(outcome.get("projects"));
if let Some(project) = field(&item, "project") {
if !projects.iter().any(|p| p == project) {
projects.push(project.to_string());
}
}
require_plan_scope(self, actor, &projects)?;
let request = object(vec![
("command", string("outcome_link")),
("outcome", string(outcome_id)),
("item", string(item_id)),
]);
self.mutate(actor, idem, &request, |db, _| {
let outcome = get_value(db, &format!("outcome/{outcome_id}"))?
.ok_or_else(|| Error::NotFound(format!("outcome {outcome_id}")))?;
let mut item = require_item(db, item_id)?;
let project = field(&item, "project").unwrap_or("");
if !array_contains(outcome.get("projects"), project) {
return Err(Error::Forbidden(
"work item project is outside outcome scope".into(),
));
}
if let Some(repo) = field(&item, "repo") {
if let Some(Value::Array(repos)) = outcome.get("repos") {
if !repos.is_empty() && !repos.iter().any(|v| v.as_str() == Some(repo)) {
return Err(Error::Forbidden(
"work item repository is outside outcome scope".into(),
));
}
}
}
let key = format!("outcome-link/{outcome_id}/{item_id}");
let link = object(vec![
("outcome", string(outcome_id)),
("item", string(item_id)),
("project", string(project)),
("repo", optional_string(field(&item, "repo"))),
("createdAt", Value::Int(now())),
("actor", string(actor)),
]);
if db.get(key.as_bytes()).map_err(storage)?.is_none() {
put(db, &key, &link)?;
let mut links = item
.get("outcomes")
.cloned()
.unwrap_or(Value::Array(Vec::new()));
append_unique(&mut links, string(outcome_id));
update(&mut item, "outcomes", links);
bump(&mut item);
save_item(db, &item)?;
}
Ok(link)
})
}
pub fn unlink_outcome(
&self,
outcome_id: &str,
item_id: &str,
actor: &str,
idem: &str,
) -> Result<Value> {
let outcome = self.get_outcome(outcome_id)?;
let item = self.get_item(item_id)?;
let mut projects = array_strings(outcome.get("projects"));
if let Some(project) = field(&item, "project") {
if !projects.iter().any(|p| p == project) {
projects.push(project.to_string());
}
}
require_plan_scope(self, actor, &projects)?;
let request = object(vec![
("command", string("outcome_unlink")),
("outcome", string(outcome_id)),
("item", string(item_id)),
]);
self.mutate(actor, idem, &request, |db, _| {
get_value(db, &format!("outcome/{outcome_id}"))?
.ok_or_else(|| Error::NotFound(format!("outcome {outcome_id}")))?;
let mut item = require_item(db, item_id)?;
db.delete(format!("outcome-link/{outcome_id}/{item_id}").as_bytes())
.map_err(storage)?;
if let Some(Value::Array(values)) = item.get("outcomes").cloned() {
update(
&mut item,
"outcomes",
Value::Array(
values
.into_iter()
.filter(|v| v.as_str() != Some(outcome_id))
.collect(),
),
);
bump(&mut item);
save_item(db, &item)?;
}
Ok(object(vec![
("outcome", string(outcome_id)),
("item", string(item_id)),
("linked", Value::Bool(false)),
]))
})
}
pub fn add_outcome_evidence(
&self,
outcome_id: &str,
state: &str,
body: &str,
result_links: Vec<String>,
actor: &str,
idem: &str,
) -> Result<Value> {
let outcome = self.get_outcome(outcome_id)?;
require_plan_scope(self, actor, &array_strings(outcome.get("projects")))?;
if !matches!(state, "on_track" | "at_risk" | "off_track" | "achieved") {
return Err(Error::Invalid("outcome evidence state is invalid".into()));
}
required("outcome evidence", body)?;
validate_links(&result_links)?;
let request = object(vec![
("command", string("outcome_evidence")),
("outcome", string(outcome_id)),
("state", string(state)),
("body", string(body.trim())),
("resultLinks", strings(&result_links)),
]);
self.mutate(actor, idem, &request, |db, sequence| {
let mut outcome = get_value(db, &format!("outcome/{outcome_id}"))?
.ok_or_else(|| Error::NotFound(format!("outcome {outcome_id}")))?;
let evidence = object(vec![
("id", Value::Int(sequence)),
("outcome", string(outcome_id)),
("state", string(state)),
("body", string(body.trim())),
("resultLinks", strings(&result_links)),
("actor", string(actor)),
("createdAt", Value::Int(now())),
]);
put(
db,
&format!("outcome-evidence/{outcome_id}/{sequence:020}"),
&evidence,
)?;
update(&mut outcome, "state", string(state));
let mut links = outcome
.get("resultLinks")
.cloned()
.unwrap_or(Value::Array(Vec::new()));
for link in &result_links {
append_unique(&mut links, string(link));
}
update(&mut outcome, "resultLinks", links);
bump(&mut outcome);
update(&mut outcome, "updatedAt", Value::Int(now()));
put(db, &format!("outcome/{outcome_id}"), &outcome)?;
refresh_indexes(db, &outcome)?;
Ok(evidence)
})
}
pub fn query_outcomes(
&self,
projects: Vec<String>,
repos: Vec<String>,
owners: Vec<String>,
states: Vec<String>,
limit: usize,
) -> Result<Value> {
if limit == 0 || limit > 500 {
return Err(Error::Invalid("outcome query limit must be 1..=500".into()));
}
let projects = if projects.is_empty() {
Vec::new()
} else {
normalize_projects(projects)?
};
let repos = normalize_repos(repos)?;
let mut db = self
.db
.lock()
.map_err(|_| Error::Storage("lock poisoned".into()))?;
let entries = db
.range(b"outcome/", &upper_bound(b"outcome/"))
.map_err(storage)?;
let mut values = Vec::new();
let mut truncated = false;
for (_, bytes) in entries {
let outcome = decode(&bytes)?;
if !projects.is_empty()
&& !projects
.iter()
.any(|project| array_contains(outcome.get("projects"), project))
{
continue;
}
if !repos.is_empty()
&& !repos
.iter()
.any(|repo| array_contains(outcome.get("repos"), repo))
{
continue;
}
if !owners.is_empty() && !contains_string(&owners, field(&outcome, "owner")) {
continue;
}
if !states.is_empty() && !contains_string(&states, field(&outcome, "state")) {
continue;
}
if values.len() >= limit {
truncated = true;
break;
}
let id = field(&outcome, "id").unwrap_or("");
let prefix = format!("outcome-link/{id}/");
let mut work = Vec::new();
for (_, link) in db
.range(prefix.as_bytes(), &upper_bound(prefix.as_bytes()))
.map_err(storage)?
{
if let Some(item) = field(&decode(&link)?, "item") {
work.push(string(item));
}
}
let mut enriched = outcome;
update(&mut enriched, "supportingWorkIds", Value::Array(work));
values.push(enriched);
}
Ok(object(vec![
("outcomes", Value::Array(values)),
("limit", Value::Int(limit as i64)),
("truncated", Value::Bool(truncated)),
(
"source",
string("outcome records and explicit evidence; task counts do not derive state"),
),
]))
}
}
fn array_strings(value: Option<&Value>) -> Vec<String> {
match value {
Some(Value::Array(values)) => values
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect(),
_ => Vec::new(),
}
}
fn require_plan_scope(store: &Store, actor: &str, projects: &[String]) -> Result<()> {
if !store.bootstrap_complete()? {
return Ok(());
}
for project in projects {
if !store.authorize(actor, Some(project), Permission::Plan)? {
return Err(Error::Forbidden(format!(
"principal lacks plan permission for project {project}"
)));
}
}
Ok(())
}
fn normalize_projects(projects: Vec<String>) -> Result<Vec<String>> {
let mut out = Vec::new();
for project in projects {
validate_key(&project)?;
if !out.contains(&project) {
out.push(project);
}
}
if out.is_empty() {
return Err(Error::Invalid("at least one project is required".into()));
}
Ok(out)
}
fn validate_window(start: i64, end: i64) -> Result<()> {
if start <= 0 || end <= 0 || end < start {
Err(Error::Invalid(
"observation window must be positive and ordered".into(),
))
} else {
Ok(())
}
}
fn validate_links(links: &[String]) -> Result<()> {
if links.len() > 64 {
return Err(Error::Invalid("at most 64 result links are allowed".into()));
}
for link in links {
if link.trim().is_empty() || link.len() > 2048 {
return Err(Error::Invalid("result links must be 1..=2048 bytes".into()));
}
}
Ok(())
}
fn validate_patch(p: &OutcomePatch) -> Result<()> {
if let Some(v) = &p.projects {
normalize_projects(v.clone())?;
}
if let Some(v) = &p.repos {
normalize_repos(v.clone())?;
}
if let (Some(a), Some(b)) = (p.observation_start, p.observation_end) {
validate_window(a, b)?;
}
if let Some(v) = &p.result_links {
validate_links(v)?;
}
Ok(())
}
fn outcome_patch_request(id: &str, revision: i64, p: &OutcomePatch) -> Value {
object(vec![
("command", string("outcome_update")),
("outcome", string(id)),
("id", string(id)),
("expectedRevision", Value::Int(revision)),
(
"title",
p.title.as_deref().map(string).unwrap_or(Value::Null),
),
(
"description",
p.description.as_deref().map(string).unwrap_or(Value::Null),
),
(
"projects",
p.projects
.as_ref()
.map(|v| strings(v))
.unwrap_or(Value::Null),
),
(
"repos",
p.repos.as_ref().map(|v| strings(v)).unwrap_or(Value::Null),
),
(
"owner",
p.owner.as_deref().map(string).unwrap_or(Value::Null),
),
(
"targetMetric",
p.target_metric
.as_deref()
.map(string)
.unwrap_or(Value::Null),
),
("baseline", p.baseline.clone().unwrap_or(Value::Null)),
("target", p.target.clone().unwrap_or(Value::Null)),
(
"observationStart",
p.observation_start.map(Value::Int).unwrap_or(Value::Null),
),
(
"observationEnd",
p.observation_end.map(Value::Int).unwrap_or(Value::Null),
),
(
"resultLinks",
p.result_links
.as_ref()
.map(|v| strings(v))
.unwrap_or(Value::Null),
),
])
}
fn apply_patch(o: &mut Value, p: &OutcomePatch) {
if let Some(v) = &p.title {
update(o, "title", string(v));
}
if let Some(v) = &p.description {
update(o, "description", string(v));
}
if let Some(v) = &p.projects {
update(o, "projects", strings(v));
}
if let Some(v) = &p.repos {
update(o, "repos", strings(v));
}
if let Some(v) = &p.owner {
update(o, "owner", string(v));
}
if let Some(v) = &p.target_metric {
update(o, "targetMetric", string(v));
}
if let Some(v) = &p.baseline {
update(o, "baseline", v.clone());
}
if let Some(v) = &p.target {
update(o, "target", v.clone());
}
if let Some(v) = p.observation_start {
update(o, "observationStart", Value::Int(v));
}
if let Some(v) = p.observation_end {
update(o, "observationEnd", Value::Int(v));
}
if let Some(v) = &p.result_links {
update(o, "resultLinks", strings(v));
}
}
fn refresh_indexes(db: &mut akurai_storage::BTree, o: &Value) -> Result<()> {
let id = field(o, "id").ok_or_else(|| Error::Storage("outcome id missing".into()))?;
let entries = db
.range(b"outcome-by-project/", &upper_bound(b"outcome-by-project/"))
.map_err(storage)?;
for (key, _) in entries {
if key.ends_with(format!("/{id}").as_bytes()) {
db.delete(&key).map_err(storage)?;
}
}
let sequence = o.get("createdAt").and_then(Value::as_i64).unwrap_or(0);
if let Some(Value::Array(projects)) = o.get("projects") {
for project in projects.iter().filter_map(Value::as_str) {
put(
db,
&format!("outcome-by-project/{project}/{sequence:020}/{id}"),
o,
)?;
}
}
Ok(())
}
fn refresh_project_links(db: &mut akurai_storage::BTree, o: &Value) -> Result<()> {
let id = field(o, "id").ok_or_else(|| Error::Storage("outcome id missing".into()))?;
let wanted = array_strings(o.get("projects"));
let entries = db
.range(b"project/", &upper_bound(b"project/"))
.map_err(storage)?;
for (key, bytes) in entries {
let project_key = std::str::from_utf8(&key)
.map_err(|e| Error::Storage(e.to_string()))?
.strip_prefix("project/")
.unwrap_or("");
let mut project = decode(&bytes)?;
let mut links = array_strings(project.get("outcomes"));
links.retain(|link| link != id);
if wanted.iter().any(|p| p == project_key) {
links.push(id.to_string());
}
update(
&mut project,
"outcomes",
Value::Array(links.into_iter().map(|v| string(&v)).collect()),
);
put(db, &format!("project/{project_key}"), &project)?;
}
Ok(())
}
fn array_contains(value: Option<&Value>, wanted: &str) -> bool {
value
.and_then(|v| match v {
Value::Array(values) => Some(values.iter().any(|v| v.as_str() == Some(wanted))),
_ => None,
})
.unwrap_or(false)
}
fn contains_string(values: &[String], actual: Option<&str>) -> bool {
actual.is_some_and(|v| values.iter().any(|w| w == v))
}
fn append_unique(array: &mut Value, value: Value) {
if let Value::Array(values) = array {
if !values.iter().any(|v| v == &value) {
values.push(value);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn outcome_revision_links_and_explicit_evidence_are_audited() {
let path = std::env::temp_dir().join(format!("tasks-outcome-{}.db", std::process::id()));
let _ = std::fs::remove_file(&path);
let store = Store::open(&path).unwrap();
store
.create_project("OUT", "Outcomes", vec!["org/repo".into()], "admin", "p")
.unwrap();
let item = store
.create_item(CreateItem {
project: "OUT",
title: "support",
description: "",
repo: Some("org/repo"),
priority: "normal",
actor: "admin",
idempotency_key: "i",
})
.unwrap();
let outcome = store
.create_outcome(
CreateOutcome {
title: "Retention".into(),
description: String::new(),
projects: vec!["OUT".into()],
repos: vec!["org/repo".into()],
owner: "owner".into(),
target_metric: "retention".into(),
baseline: Value::Int(10),
target: Value::Int(20),
observation_start: 1,
observation_end: 2,
result_links: vec!["https://example.test/result".into()],
},
"admin",
"o",
)
.unwrap();
let id = field(&outcome, "id").unwrap();
store
.link_outcome(id, field(&item, "id").unwrap(), "admin", "l")
.unwrap();
let evidence = store
.add_outcome_evidence(id, "on_track", "observed result", vec![], "admin", "e")
.unwrap();
assert_eq!(field(&evidence, "state"), Some("on_track"));
let current = store.get_outcome(id).unwrap();
assert_eq!(field(¤t, "state"), Some("on_track"));
assert_eq!(current.get("revision").and_then(Value::as_i64), Some(2));
let queried = store
.query_outcomes(vec!["OUT".into()], vec![], vec![], vec![], 10)
.unwrap();
assert!(queried.to_json().contains(field(&item, "id").unwrap()));
let _ = std::fs::remove_file(path);
}
}