AkurAI Build
Menu

akurai-tasks

public

Latest 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(&current, "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);
    }
}