diff --git a/app/src/types/turnState.ts b/app/src/types/turnState.ts index 7d450bbb28..9a18920bfe 100644 --- a/app/src/types/turnState.ts +++ b/app/src/types/turnState.ts @@ -27,6 +27,10 @@ export interface TaskBoardCard { evidence?: string[]; notes?: string | null; blocker?: string | null; + /** Provider/source identifiers for a card ingested from a task source + * (`{provider, source_id, external_id, url, repo?, urgency}`); absent on + * agent/UI-authored cards. */ + sourceMetadata?: Record | null; order: number; updatedAt: string; } diff --git a/src/openhuman/agent/task_board.rs b/src/openhuman/agent/task_board.rs index 095c77d9d6..a3b5adf6e9 100644 --- a/src/openhuman/agent/task_board.rs +++ b/src/openhuman/agent/task_board.rs @@ -74,6 +74,12 @@ pub struct TaskBoardCard { pub notes: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub blocker: Option, + /// Provider/source identifiers for a card ingested from a task source + /// (`{provider, source_id, external_id, url, repo?, urgency}`). Set by + /// the `task_sources` route; consumed downstream for prioritisation and + /// external write-back. `None` for agent/UI-authored cards. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_metadata: Option, #[serde(default)] pub order: u32, #[serde(default)] @@ -420,6 +426,7 @@ mod tests { evidence: vec![" cargo test ".into()], notes: Some(" note ".into()), blocker: None, + source_metadata: None, order: 99, updated_at: String::new(), }, @@ -436,6 +443,7 @@ mod tests { evidence: Vec::new(), notes: Some("waiting on user".into()), blocker: None, + source_metadata: None, order: 99, updated_at: String::new(), }, @@ -506,6 +514,7 @@ mod tests { evidence: Vec::new(), notes: None, blocker: None, + source_metadata: None, order: 99, updated_at: String::new(), }, @@ -522,6 +531,7 @@ mod tests { evidence: Vec::new(), notes: None, blocker: None, + source_metadata: None, order: 99, updated_at: String::new(), }, diff --git a/src/openhuman/task_sources/route.rs b/src/openhuman/task_sources/route.rs index 115def3665..776d731c3c 100644 --- a/src/openhuman/task_sources/route.rs +++ b/src/openhuman/task_sources/route.rs @@ -19,7 +19,7 @@ use crate::openhuman::todos::ops::{ }; use crate::openhuman::{scheduler_gate, todos}; -use super::types::{EnrichedTask, SourceTarget, TaskSource}; +use super::types::{EnrichedTask, FilterSpec, SourceTarget, TaskSource}; /// Stable thread id whose board collects every ingested task. pub const TASK_SOURCES_THREAD_ID: &str = "task-sources"; @@ -112,11 +112,26 @@ fn add_card( Some(notes_parts.join("\n")) }; + // Objective: the bare upstream title (the card `content`/title is the + // `[provider] title` display form; the objective is the clean goal the + // executing agent works toward). + let objective = { + let t = task.title.trim(); + (!t.is_empty()).then(|| t.to_string()) + }; + + // Stamp the source identifiers the downstream dispatcher / write-back + // needs (provider + repo + issue id + url) plus the enrichment urgency + // used for prioritisation. This is the only writer of `source_metadata`. + let source_metadata = build_source_metadata(source, enriched); + let snapshot = todo_add( &location, &content, CardPatch { notes, + objective, + source_metadata: Some(source_metadata), ..Default::default() }, ) @@ -139,6 +154,34 @@ fn add_card( Ok(new_card_id) } +/// Build the card's `source_metadata` from the originating source + task: +/// the provider/repo/issue identifiers a later dispatcher or external +/// write-back needs to address the upstream item, plus the enrichment +/// urgency used to prioritise pickup. Repo is only present for GitHub +/// sources (the other providers don't carry a repo concept). +fn build_source_metadata(source: &TaskSource, enriched: &EnrichedTask) -> serde_json::Value { + let task = &enriched.task; + let mut meta = json!({ + "provider": task.provider, + "source_id": source.id, + "external_id": task.external_id, + "urgency": enriched.urgency, + }); + if let Some(url) = task.url.as_deref().map(str::trim).filter(|s| !s.is_empty()) { + meta["url"] = json!(url); + } + if let FilterSpec::Github { + repo: Some(repo), .. + } = &source.filter + { + let repo = repo.trim(); + if !repo.is_empty() { + meta["repo"] = json!(repo); + } + } + meta +} + /// Dispatch a triage turn for a proactive task, gated by scheduler /// capacity. Card creation already happened; a gated-off or deferred /// turn is non-fatal — the task still sits on the board. @@ -228,6 +271,9 @@ pub fn board_cards( #[cfg(test)] mod tests { use super::*; + use crate::openhuman::task_sources::types::ProviderSlug; + use crate::openhuman::task_sources::NormalizedTask; + use chrono::Utc; #[test] fn provider_label_titlecases_known_and_unknown() { @@ -236,4 +282,92 @@ mod tests { assert_eq!(provider_label("asana"), "Asana"); assert_eq!(provider_label(""), ""); } + + fn github_source(repo: Option<&str>) -> TaskSource { + TaskSource { + id: "ts-1".into(), + provider: ProviderSlug::Github, + connection_id: None, + name: None, + enabled: true, + filter: FilterSpec::Github { + repo: repo.map(str::to_string), + labels: vec![], + assignee_is_me: true, + state: None, + extra: json!({}), + }, + interval_secs: 1800, + target: SourceTarget::AgentTodoProactive, + max_tasks_per_fetch: 25, + created_at: Utc::now(), + last_fetch_at: None, + last_status: None, + } + } + + fn enriched(external_id: &str, url: Option<&str>, urgency: f32) -> EnrichedTask { + let task = NormalizedTask { + external_id: external_id.into(), + provider: "github".into(), + title: "Fix the bug".into(), + url: url.map(str::to_string), + ..Default::default() + }; + EnrichedTask { + task, + summary: "Fix the bug".into(), + urgency, + linked_people: vec![], + linked_memory_ids: vec![], + agent_prompt: "do it".into(), + enriched_at: Utc::now(), + } + } + + #[test] + fn source_metadata_carries_github_repo_and_identifiers() { + let src = github_source(Some("octo/repo")); + let e = enriched("123", Some("https://github.com/octo/repo/issues/123"), 0.7); + let meta = build_source_metadata(&src, &e); + assert_eq!(meta["provider"], json!("github")); + assert_eq!(meta["source_id"], json!("ts-1")); + assert_eq!(meta["external_id"], json!("123")); + assert_eq!(meta["repo"], json!("octo/repo")); + assert_eq!( + meta["url"], + json!("https://github.com/octo/repo/issues/123") + ); + let urgency = meta["urgency"].as_f64().expect("urgency is a number"); + assert!((urgency - 0.7).abs() < 1e-6, "urgency was {urgency}"); + } + + #[test] + fn source_metadata_omits_absent_repo_and_url() { + let src = github_source(None); + let e = enriched("9", None, 0.4); + let meta = build_source_metadata(&src, &e); + assert!(meta.get("repo").is_none()); + assert!(meta.get("url").is_none()); + assert_eq!(meta["external_id"], json!("9")); + let urgency = meta["urgency"].as_f64().expect("urgency is a number"); + assert!((urgency - 0.4).abs() < 1e-6, "urgency was {urgency}"); + } + + #[test] + fn source_metadata_has_no_repo_for_non_github_provider() { + let mut src = github_source(Some("octo/repo")); + // A non-GitHub filter carries no repo concept. + src.provider = ProviderSlug::Linear; + src.filter = FilterSpec::Linear { + team_id: None, + assignee_is_me: true, + state: None, + extra: json!({}), + }; + let e = enriched("LIN-5", None, 0.5); + let meta = build_source_metadata(&src, &e); + assert!(meta.get("repo").is_none()); + assert_eq!(meta["source_id"], json!("ts-1")); + } } diff --git a/src/openhuman/threads/turn_state/mirror_tests.rs b/src/openhuman/threads/turn_state/mirror_tests.rs index de869b8b34..5dd16cd158 100644 --- a/src/openhuman/threads/turn_state/mirror_tests.rs +++ b/src/openhuman/threads/turn_state/mirror_tests.rs @@ -164,6 +164,7 @@ fn task_board_update_is_stored_and_flushed() { evidence: Vec::new(), notes: None, blocker: None, + source_metadata: None, order: 0, updated_at: "2026-05-15T00:00:00Z".into(), }], diff --git a/src/openhuman/todos/ops.rs b/src/openhuman/todos/ops.rs index b984a5a438..1e0e162866 100644 --- a/src/openhuman/todos/ops.rs +++ b/src/openhuman/todos/ops.rs @@ -68,6 +68,9 @@ pub struct CardPatch { pub evidence: Option>, pub notes: Option, pub blocker: Option, + /// Provider/source identifiers for a task-source-ingested card. `Some` + /// sets the card's `source_metadata`; `None` leaves it untouched. + pub source_metadata: Option, } /// Where to load/save the working set of cards. @@ -261,6 +264,7 @@ pub fn add( evidence: patch.evidence.unwrap_or_default(), notes: patch.notes.and_then(non_empty), blocker: patch.blocker.and_then(non_empty), + source_metadata: patch.source_metadata, order: cards.len() as u32, updated_at: Utc::now().to_rfc3339(), }; @@ -322,6 +326,9 @@ pub fn edit(location: &BoardLocation, id: &str, patch: CardPatch) -> Result) -> ControllerFuture { evidence: p.evidence, notes: p.notes, blocker: p.blocker, + source_metadata: None, }; tracing::debug!(thread_id = %p.thread_id, "[rpc][todos] add entry"); snapshot_to_json(ops::add(&loc, &p.content, patch)?) @@ -314,6 +315,7 @@ fn handle_edit(params: Map) -> ControllerFuture { evidence: p.evidence, notes: p.notes, blocker: p.blocker, + source_metadata: None, }; tracing::debug!(thread_id = %p.thread_id, id = %p.id, "[rpc][todos] edit entry"); snapshot_to_json(ops::edit(&loc, &p.id, patch)?) diff --git a/src/openhuman/tools/impl/agent/todo.rs b/src/openhuman/tools/impl/agent/todo.rs index 8dd17e4903..85fd7332a2 100644 --- a/src/openhuman/tools/impl/agent/todo.rs +++ b/src/openhuman/tools/impl/agent/todo.rs @@ -255,6 +255,7 @@ fn patch_from_args(args: &serde_json::Value) -> anyhow::Result { evidence: optional_string_array(args, "evidence")?, notes: optional_string(args, "notes"), blocker: optional_string(args, "blocker"), + source_metadata: None, }) }