From 13f7dab5f0a263b5067bdecd610926ee9003c845 Mon Sep 17 00:00:00 2001 From: dave Date: Fri, 15 May 2026 01:58:33 +0000 Subject: [PATCH] huskies: merge 1088 --- frontend/src/api/client/types.ts | 2 ++ .../WorkItemDetailPanel.agents.test.tsx | 1 + .../WorkItemDetailPanel.data.test.tsx | 1 + .../components/WorkItemDetailPanel.test.tsx | 5 +++ .../src/components/WorkItemDetailPanel.tsx | 35 +++++++++++++++++++ server/src/crdt_state/mod.rs | 3 +- server/src/crdt_state/read.rs | 13 +++++++ server/src/crdt_state/types.rs | 18 ++++++++++ server/src/crdt_state/write/item.rs | 27 ++++++++++++++ server/src/crdt_state/write/mod.rs | 4 +-- server/src/crdt_sync/rpc.rs | 1 + server/src/http/mcp/diagnostics/mod.rs | 1 + server/src/http/mcp/status_tools.rs | 3 ++ server/src/http/mcp/story_tools/bug.rs | 2 ++ server/src/http/mcp/story_tools/epic.rs | 2 ++ server/src/http/mcp/story_tools/mod.rs | 27 ++++++++++++++ server/src/http/mcp/story_tools/refactor.rs | 2 ++ server/src/http/mcp/story_tools/spike.rs | 2 ++ .../src/http/mcp/story_tools/story/create.rs | 2 ++ server/src/service/agents/mod.rs | 8 +++++ 20 files changed, 156 insertions(+), 3 deletions(-) diff --git a/frontend/src/api/client/types.ts b/frontend/src/api/client/types.ts index 279b26aa..38a33446 100644 --- a/frontend/src/api/client/types.ts +++ b/frontend/src/api/client/types.ts @@ -241,6 +241,8 @@ export interface WorkItemContent { stage: string; name: string; agent: string | null; + /** Origin JSON string (story 1088), or null for pre-origin items. */ + origin: string | null; } /** Result for a single test case from the server's test runner. */ diff --git a/frontend/src/components/WorkItemDetailPanel.agents.test.tsx b/frontend/src/components/WorkItemDetailPanel.agents.test.tsx index 4f99df28..8cddfd74 100644 --- a/frontend/src/components/WorkItemDetailPanel.agents.test.tsx +++ b/frontend/src/components/WorkItemDetailPanel.agents.test.tsx @@ -43,6 +43,7 @@ const DEFAULT_CONTENT = { stage: "current", name: "Big Title Story", agent: null, + origin: null, }; beforeEach(() => { diff --git a/frontend/src/components/WorkItemDetailPanel.data.test.tsx b/frontend/src/components/WorkItemDetailPanel.data.test.tsx index 9d762890..3b5a9702 100644 --- a/frontend/src/components/WorkItemDetailPanel.data.test.tsx +++ b/frontend/src/components/WorkItemDetailPanel.data.test.tsx @@ -43,6 +43,7 @@ const DEFAULT_CONTENT = { stage: "current", name: "Big Title Story", agent: null, + origin: null, }; const sampleTestResults: TestResultsResponse = { diff --git a/frontend/src/components/WorkItemDetailPanel.test.tsx b/frontend/src/components/WorkItemDetailPanel.test.tsx index 6a4de598..57178bd5 100644 --- a/frontend/src/components/WorkItemDetailPanel.test.tsx +++ b/frontend/src/components/WorkItemDetailPanel.test.tsx @@ -42,6 +42,7 @@ const DEFAULT_CONTENT = { stage: "current", name: "Big Title Story", agent: null, + origin: null, }; beforeEach(() => { @@ -127,6 +128,7 @@ describe("WorkItemDetailPanel", () => { stage: "current", name: "My Story Name", agent: null, + origin: null, }); render( { stage: "current", name: "My Story Name", agent: null, + origin: null, }); render( { stage: "current", name: "My Story Name", agent: null, + origin: null, }); render( { stage: "current", name: "My Story Name", agent: null, + origin: null, }); render( (""); const [name, setName] = useState(null); const [assignedAgent, setAssignedAgent] = useState(null); + const [origin, setOrigin] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [agentInfo, setAgentInfo] = useState(null); @@ -63,6 +84,7 @@ export function WorkItemDetailPanel({ setStage(data.stage); setName(data.name); setAssignedAgent(data.agent); + setOrigin(data.origin); }) .catch((err: unknown) => { setError(err instanceof Error ? err.message : "Failed to load content"); @@ -289,6 +311,19 @@ export function WorkItemDetailPanel({ + {!loading && ( +
+ origin: {formatOrigin(origin)} +
+ )} +
, } /// Top-level debug dump of the in-memory CRDT state. @@ -149,6 +151,10 @@ pub fn dump_crdt_state(story_id_filter: Option<&str>) -> CrdtStateDump { JsonValue::Number(n) if n > 0.0 => Some(n), _ => None, }; + let origin = match item_crdt.origin.view() { + JsonValue::String(s) if !s.is_empty() => Some(s), + _ => None, + }; let content_index = op.id.iter().map(|b| format!("{b:02x}")).collect::(); @@ -163,6 +169,7 @@ pub fn dump_crdt_state(story_id_filter: Option<&str>) -> CrdtStateDump { claim_ts, content_index, is_deleted: op.is_deleted, + origin, }); } @@ -408,6 +415,11 @@ pub(super) fn extract_item_view(item: &PipelineItemCrdt) -> Option None, }; + let origin = match item.origin.view() { + JsonValue::String(s) if !s.is_empty() => Some(s), + _ => None, + }; + let stage = project_stage_for_view( &stage_str, &story_id, @@ -429,6 +441,7 @@ pub(super) fn extract_item_view(item: &PipelineItemCrdt) -> Option, + /// Story 1088: origin of the work item — who or what created it. + /// + /// Stored as a compact JSON string, e.g. + /// `{"kind":"user","id":"","ts":1716768000.0}` or + /// `{"kind":"agent","id":"coder-1","ts":1716768000.0}`. + /// Empty string on older items that pre-date this register; the typed + /// read path surfaces those as `None`, which the UI renders as `"unknown"`. + pub origin: LwwRegisterCrdt, } /// CRDT node that holds a single peer's presence entry. @@ -203,6 +211,9 @@ pub struct WorkItem { pub(super) item_type: Option, /// Epic this item belongs to. `None` when the item has no parent epic. pub(super) epic: Option, + /// Origin of the work item (story 1088). `None` for items created before + /// the origin register was introduced; those display as `"unknown"`. + pub(super) origin: Option, } impl WorkItem { @@ -261,6 +272,12 @@ impl WorkItem { self.epic } + /// Origin of the work item (story 1088), or `None` for items created before + /// the origin register was introduced. + pub fn origin(&self) -> Option<&str> { + self.origin.as_deref() + } + /// Construct a `WorkItem` for use in tests outside `crdt_state::*`. /// /// Within `crdt_state` use a struct literal directly (fields are `pub(super)`). @@ -286,6 +303,7 @@ impl WorkItem { qa_mode, item_type, epic, + origin: None, } } } diff --git a/server/src/crdt_state/write/item.rs b/server/src/crdt_state/write/item.rs index 81471de5..9535ceee 100644 --- a/server/src/crdt_state/write/item.rs +++ b/server/src/crdt_state/write/item.rs @@ -235,6 +235,31 @@ pub fn set_plan_state(story_id: &str, state: crate::pipeline_state::PlanState) - true } +/// Set the `origin` CRDT register for a pipeline item (story 1088). +/// +/// Writes a compact JSON string describing who or what created the item, e.g. +/// `{"kind":"user","id":"","ts":1716768000.0}` or +/// `{"kind":"agent","id":"coder-1","ts":1716768000.0}`. +/// +/// Passing an empty string is treated as "no origin set" (equivalent to the +/// pre-1088 state for older items). Returns `true` if the item was found and +/// the op was applied, `false` otherwise. +pub fn set_origin(story_id: &str, origin: &str) -> bool { + let Some(state_mutex) = get_crdt() else { + return false; + }; + let Ok(mut state) = state_mutex.lock() else { + return false; + }; + let Some(&idx) = state.index.get(story_id) else { + return false; + }; + apply_and_persist(&mut state, |s| { + s.crdt.doc.items[idx].origin.set(origin.to_string()) + }); + true +} + /// Write a pipeline item state through CRDT operations. /// /// If the item exists, updates its registers. If not, inserts a new item @@ -394,6 +419,7 @@ pub fn write_item( "resume_to": "", "plan_state": "", "merge_server_start": merge_server_start_val, + "origin": "", }) .into(); @@ -424,6 +450,7 @@ pub fn write_item( item.resume_to.advance_seq(floor); item.plan_state.advance_seq(floor); item.merge_server_start.advance_seq(floor); + item.origin.advance_seq(floor); } // Broadcast a CrdtEvent for the new item. diff --git a/server/src/crdt_state/write/mod.rs b/server/src/crdt_state/write/mod.rs index ce693b5f..3ad85917 100644 --- a/server/src/crdt_state/write/mod.rs +++ b/server/src/crdt_state/write/mod.rs @@ -10,8 +10,8 @@ mod migrations; mod tests; pub use item::{ - bump_retry_count, set_agent, set_depends_on, set_epic, set_item_type, set_name, set_plan_state, - set_qa_mode, set_resume_to, set_resume_to_raw, set_retry_count, write_item, + bump_retry_count, set_agent, set_depends_on, set_epic, set_item_type, set_name, set_origin, + set_plan_state, set_qa_mode, set_resume_to, set_resume_to_raw, set_retry_count, write_item, }; #[cfg(test)] diff --git a/server/src/crdt_sync/rpc.rs b/server/src/crdt_sync/rpc.rs index d38e4f28..5ab47ec0 100644 --- a/server/src/crdt_sync/rpc.rs +++ b/server/src/crdt_sync/rpc.rs @@ -434,6 +434,7 @@ async fn handle_work_items_get(params: Value) -> Value { "stage": c.stage, "name": c.name, "agent": c.agent, + "origin": c.origin, }), Err(e) => serde_json::json!({"error": e.to_string()}), } diff --git a/server/src/http/mcp/diagnostics/mod.rs b/server/src/http/mcp/diagnostics/mod.rs index 8cd17573..dba7d3cf 100644 --- a/server/src/http/mcp/diagnostics/mod.rs +++ b/server/src/http/mcp/diagnostics/mod.rs @@ -103,6 +103,7 @@ pub(crate) fn tool_dump_crdt(args: &Value) -> Result { "claimed_at": item.claim_ts, "content_index": item.content_index, "is_deleted": item.is_deleted, + "origin": item.origin, }) }) .collect(); diff --git a/server/src/http/mcp/status_tools.rs b/server/src/http/mcp/status_tools.rs index a90d8831..b4935170 100644 --- a/server/src/http/mcp/status_tools.rs +++ b/server/src/http/mcp/status_tools.rs @@ -195,6 +195,9 @@ pub(super) async fn tool_status(args: &Value, ctx: &AppContext) -> Result claim.as_ref(), crate::pipeline_state::Stage::Merge { claim, .. } => claim.as_ref(), diff --git a/server/src/http/mcp/story_tools/bug.rs b/server/src/http/mcp/story_tools/bug.rs index 872bed1c..772a9de7 100644 --- a/server/src/http/mcp/story_tools/bug.rs +++ b/server/src/http/mcp/story_tools/bug.rs @@ -38,6 +38,8 @@ pub(crate) fn tool_create_bug(args: &Value, ctx: &AppContext) -> Result Result}`. +/// +/// Callers that create items on behalf of system automation (e.g. gate-failure +/// auto-filing) should pass `kind = "system"` and `id = ""`. +pub(super) fn build_origin(args: &serde_json::Value) -> String { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64(); + + if let Some(origin_obj) = args.get("origin").and_then(|v| v.as_object()) { + let kind = origin_obj + .get("kind") + .and_then(|v| v.as_str()) + .unwrap_or("user"); + let id = origin_obj.get("id").and_then(|v| v.as_str()).unwrap_or(""); + let ts_val = origin_obj.get("ts").and_then(|v| v.as_f64()).unwrap_or(ts); + serde_json::json!({"kind": kind, "id": id, "ts": ts_val}).to_string() + } else { + serde_json::json!({"kind": "user", "id": "", "ts": ts}).to_string() + } +} + pub(crate) use bug::{tool_close_bug, tool_create_bug, tool_list_bugs}; pub(crate) use criteria::{ tool_add_criterion, tool_check_criterion, tool_edit_criterion, tool_ensure_acceptance, diff --git a/server/src/http/mcp/story_tools/refactor.rs b/server/src/http/mcp/story_tools/refactor.rs index 3cb29cc7..040e6c11 100644 --- a/server/src/http/mcp/story_tools/refactor.rs +++ b/server/src/http/mcp/story_tools/refactor.rs @@ -36,6 +36,8 @@ pub(crate) fn tool_create_refactor(args: &Value, ctx: &AppContext) -> Result Result Result, + /// Origin of the work item (story 1088). `None` for items that pre-date + /// the origin register; the web UI renders these as `"unknown"`. + pub origin: Option, } /// A single entry in the project's configured agent roster. @@ -176,6 +179,9 @@ pub fn get_work_item_content( .map(|v| v.name().to_string()) .unwrap_or_default(); let crdt_agent = crdt_view.as_ref().and_then(|v| v.agent()); + let crdt_origin = crdt_view + .as_ref() + .and_then(|v| v.origin().map(str::to_string)); for (stage_dir, stage) in &stages { if let Some(content) = io::read_work_item_from_stage(&work_dir, stage_dir, &filename)? { @@ -184,6 +190,7 @@ pub fn get_work_item_content( stage: stage.clone(), name: crdt_name.clone(), agent: crdt_agent, + origin: crdt_origin.clone(), }); } } @@ -201,6 +208,7 @@ pub fn get_work_item_content( stage, name: crdt_name, agent: crdt_agent, + origin: crdt_origin, }); }