Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/src/types/turnState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | null;
order: number;
updatedAt: string;
}
Expand Down
10 changes: 10 additions & 0 deletions src/openhuman/agent/task_board.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ pub struct TaskBoardCard {
pub notes: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blocker: Option<String>,
/// 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_json::Value>,
#[serde(default)]
pub order: u32,
#[serde(default)]
Expand Down Expand Up @@ -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(),
},
Expand All @@ -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(),
},
Expand Down Expand Up @@ -506,6 +514,7 @@ mod tests {
evidence: Vec::new(),
notes: None,
blocker: None,
source_metadata: None,
order: 99,
updated_at: String::new(),
},
Expand All @@ -522,6 +531,7 @@ mod tests {
evidence: Vec::new(),
notes: None,
blocker: None,
source_metadata: None,
order: 99,
updated_at: String::new(),
},
Expand Down
136 changes: 135 additions & 1 deletion src/openhuman/task_sources/route.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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()
},
)
Expand All @@ -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.
Expand Down Expand Up @@ -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() {
Expand All @@ -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"));
}
}
1 change: 1 addition & 0 deletions src/openhuman/threads/turn_state/mirror_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}],
Expand Down
65 changes: 65 additions & 0 deletions src/openhuman/todos/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ pub struct CardPatch {
pub evidence: Option<Vec<String>>,
pub notes: Option<String>,
pub blocker: Option<String>,
/// Provider/source identifiers for a task-source-ingested card. `Some`
/// sets the card's `source_metadata`; `None` leaves it untouched.
pub source_metadata: Option<serde_json::Value>,
}

/// Where to load/save the working set of cards.
Expand Down Expand Up @@ -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(),
};
Expand Down Expand Up @@ -322,6 +326,9 @@ pub fn edit(location: &BoardLocation, id: &str, patch: CardPatch) -> Result<Todo
if let Some(blocker) = patch.blocker {
card.blocker = non_empty(blocker);
}
if let Some(source_metadata) = patch.source_metadata {
card.source_metadata = Some(source_metadata);
}
card.updated_at = Utc::now().to_rfc3339();
enforce_single_in_progress(&cards)?;
let cards = save_cards(location, cards)?;
Expand Down Expand Up @@ -533,6 +540,62 @@ mod tests {
assert_eq!(snap.cards[0].title, "Refined plan");
}

#[test]
fn source_metadata_round_trips_through_add_and_edit() {
let dir = tempdir().unwrap();
let loc = thread_loc(dir.path(), "t1");
let added = add(
&loc,
"ingested task",
CardPatch {
source_metadata: Some(serde_json::json!({
"provider": "github",
"external_id": "7",
})),
..Default::default()
},
)
.unwrap();
let id = added.cards[0].id.clone();
assert_eq!(
added.cards[0].source_metadata.as_ref().unwrap()["external_id"],
serde_json::json!("7")
);

// A subsequent edit with `Some(..)` replaces the stamped metadata.
let snap = edit(
&loc,
&id,
CardPatch {
source_metadata: Some(serde_json::json!({
"provider": "github",
"external_id": "8",
})),
..Default::default()
},
)
.unwrap();
assert_eq!(
snap.cards[0].source_metadata.as_ref().unwrap()["external_id"],
serde_json::json!("8")
);

// An edit that leaves `source_metadata: None` preserves the value.
let snap2 = edit(
&loc,
&id,
CardPatch {
notes: Some("touch".into()),
..Default::default()
},
)
.unwrap();
assert_eq!(
snap2.cards[0].source_metadata.as_ref().unwrap()["external_id"],
serde_json::json!("8")
);
}

#[test]
fn edit_can_clear_approval_mode() {
let dir = tempdir().unwrap();
Expand Down Expand Up @@ -609,6 +672,7 @@ mod tests {
evidence: Vec::new(),
notes: None,
blocker: None,
source_metadata: None,
order: 0,
updated_at: String::new(),
},
Expand All @@ -625,6 +689,7 @@ mod tests {
evidence: Vec::new(),
notes: None,
blocker: None,
source_metadata: None,
order: 1,
updated_at: String::new(),
},
Expand Down
2 changes: 2 additions & 0 deletions src/openhuman/todos/schemas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ fn handle_add(params: Map<String, Value>) -> 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)?)
Expand All @@ -314,6 +315,7 @@ fn handle_edit(params: Map<String, Value>) -> 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)?)
Expand Down
1 change: 1 addition & 0 deletions src/openhuman/tools/impl/agent/todo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ fn patch_from_args(args: &serde_json::Value) -> anyhow::Result<CardPatch> {
evidence: optional_string_array(args, "evidence")?,
notes: optional_string(args, "notes"),
blocker: optional_string(args, "blocker"),
source_metadata: None,
})
}

Expand Down
Loading