From f36a82b6c9dbcbff3e0c9015ac0ee4bfc9159053 Mon Sep 17 00:00:00 2001 From: Neonforge <48338160+Neonforge98@users.noreply.github.com> Date: Sun, 9 Aug 2026 07:11:24 -0700 Subject: [PATCH 1/8] feat(pm): add durable work item execution model --- src-tauri/Cargo.lock | 1 + .../crates/project-management/Cargo.toml | 1 + .../crates/project-management/src/lib.rs | 4 +- .../src/orchestrator/state_machine.rs | 36 +- .../orchestrator/tests/state_machine_tests.rs | 16 +- .../src/project_service/mod.rs | 16 +- .../src/projects/commands/work_items.rs | 79 +- .../project-management/src/projects/events.rs | 5 +- .../src/projects/io/git_folder_sync.rs | 2 +- .../project-management/src/projects/io/mod.rs | 14 +- .../src/projects/io/routines.rs | 222 +++ .../src/projects/io/work_items/atomic.rs | 7 +- .../projects/io/work_items/atomic_tests.rs | 4 + .../src/projects/io/work_items/batch.rs | 2 +- .../src/projects/io/work_items/crud.rs | 42 +- .../src/projects/io/work_items/crud_tests.rs | 1 + .../src/projects/io/work_items/enrichment.rs | 2 +- .../projects/io/work_items/execution_lock.rs | 42 +- .../src/projects/io/work_items/mod.rs | 9 +- .../projects/io/work_items/sync_metadata.rs | 15 +- .../src/projects/io/work_items/views.rs | 2 +- .../src/projects/io/work_items/workspace.rs | 85 +- .../project-management/src/projects/schema.rs | 180 +- .../src/projects/sync_export.rs | 2 +- .../src/projects/types/mod.rs | 2 + .../src/projects/types/orchestrator.rs | 17 + .../src/projects/types/project.rs | 20 +- .../src/projects/types/views.rs | 14 +- .../src/projects/types/work_runs.rs | 315 ++++ .../src/routine_service/mod.rs | 98 +- .../src/sync/collab_bridge/apply.rs | 20 +- .../src/sync/collab_bridge/mod.rs | 3 + .../src/sync/collab_bridge/outbox.rs | 150 +- .../src/sync/collab_bridge/tests.rs | 194 ++ .../src/sync/webhook_listener.rs | 13 +- .../src/team_inbox/store.rs | 210 ++- .../src/team_inbox/types.rs | 11 + .../src/work_item_features/commands.rs | 242 +++ .../src/work_item_features/discussion.rs | 385 ++++ .../src/work_item_features/mod.rs | 20 + .../src/work_item_features/properties.rs | 684 +++++++ .../src/work_item_features/readiness.rs | 239 +++ .../src/work_item_features/routine_webhook.rs | 618 +++++++ .../src/work_item_features/store.rs | 149 ++ .../src/work_item_features/subscriptions.rs | 437 +++++ .../src/work_item_features/tests.rs | 473 +++++ .../src/work_item_features/types.rs | 289 +++ .../src/work_run_service/mod.rs | 1590 +++++++++++++++++ .../src/work_run_service/tests.rs | 517 ++++++ .../src/work_service/audit.rs | 12 +- .../src/work_service/mod.rs | 303 +++- .../src/work_service/tests.rs | 65 +- 52 files changed, 7646 insertions(+), 233 deletions(-) create mode 100644 src-tauri/crates/project-management/src/projects/types/work_runs.rs create mode 100644 src-tauri/crates/project-management/src/work_item_features/commands.rs create mode 100644 src-tauri/crates/project-management/src/work_item_features/discussion.rs create mode 100644 src-tauri/crates/project-management/src/work_item_features/mod.rs create mode 100644 src-tauri/crates/project-management/src/work_item_features/properties.rs create mode 100644 src-tauri/crates/project-management/src/work_item_features/readiness.rs create mode 100644 src-tauri/crates/project-management/src/work_item_features/routine_webhook.rs create mode 100644 src-tauri/crates/project-management/src/work_item_features/store.rs create mode 100644 src-tauri/crates/project-management/src/work_item_features/subscriptions.rs create mode 100644 src-tauri/crates/project-management/src/work_item_features/tests.rs create mode 100644 src-tauri/crates/project-management/src/work_item_features/types.rs create mode 100644 src-tauri/crates/project-management/src/work_run_service/mod.rs create mode 100644 src-tauri/crates/project-management/src/work_run_service/tests.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8ce8d390a0..d35124c843 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6182,6 +6182,7 @@ dependencies = [ "tokio-util", "tracing", "urlencoding", + "uuid", "wiremock", ] diff --git a/src-tauri/crates/project-management/Cargo.toml b/src-tauri/crates/project-management/Cargo.toml index 1012a6ccbb..3fa35e95d5 100644 --- a/src-tauri/crates/project-management/Cargo.toml +++ b/src-tauri/crates/project-management/Cargo.toml @@ -44,6 +44,7 @@ subtle = "2.6" base64 = { workspace = true } hex = "0.4" rand = { workspace = true } +uuid = { version = "1", features = ["v4"] } urlencoding = "2.1" # HTTP client — rustls only. Workspace-shared so cargo unifies feature diff --git a/src-tauri/crates/project-management/src/lib.rs b/src-tauri/crates/project-management/src/lib.rs index a69f7703f4..34a094baf6 100644 --- a/src-tauri/crates/project-management/src/lib.rs +++ b/src-tauri/crates/project-management/src/lib.rs @@ -11,12 +11,14 @@ pub mod lineage; pub mod orchestrator; +pub mod project_service; pub mod projects; pub mod provider_host; -pub mod project_service; pub mod routine_service; pub mod sync; pub mod team_inbox; +pub mod work_item_features; +pub mod work_run_service; pub mod work_service; #[cfg(test)] diff --git a/src-tauri/crates/project-management/src/orchestrator/state_machine.rs b/src-tauri/crates/project-management/src/orchestrator/state_machine.rs index fe48e3e817..013706ddb5 100644 --- a/src-tauri/crates/project-management/src/orchestrator/state_machine.rs +++ b/src-tauri/crates/project-management/src/orchestrator/state_machine.rs @@ -12,12 +12,14 @@ use crate::projects::types::{ }; use core_types::session::PENDING_SESSION_PLACEHOLDER; -/// Auto-transition the work item `status` based on the new orchestrator phase. +/// Project the active workflow phase onto non-terminal Work Item status. +/// A workflow/Run reaching `Completed` never completes product intent; closing +/// the Work Item remains an explicit work transition. fn auto_transition_status(frontmatter: &mut WorkItemFrontmatter, phase: &OrchestratorPhase) { let new_status = match phase { OrchestratorPhase::Coding => "in_progress", OrchestratorPhase::Review => "in_review", - OrchestratorPhase::Completed => "completed", + OrchestratorPhase::Completed => return, OrchestratorPhase::AwaitingUser => "in_review", // Failed and Idle don't change status (keep in_progress for failed) _ => return, @@ -246,9 +248,7 @@ pub fn complete_linked_session( let idx = frontmatter .linked_sessions .iter() - .rposition(|ls| { - ls.session_id == session_id && ls.status == LinkedSessionStatus::Running - }) + .rposition(|ls| ls.session_id == session_id && ls.status == LinkedSessionStatus::Running) .or_else(|| { frontmatter .linked_sessions @@ -300,21 +300,25 @@ pub fn mutate_work_item( short_id: &str, mutator: impl FnOnce(&mut WorkItemFrontmatter) -> TransitionResult, ) -> Result { - // Orchestrator session-terminal handling is the DEFAULT COMPLETION - // POLICY of the Orgtrack migration (design §17): the status change is - // audited as an explicit work.transition with a policy reason, not a - // silent side effect. FSM stays flag-only here — orchestrator flows - // legitimately move through the legacy vocabulary until Phase 7. + // Session terminal handling advances workflow state and proof metadata; + // it is not Work Item completion. Any terminal product status is written + // through an explicit user/agent work.transition command. let service = io::AtomicServiceOptions { operation: Some("work.transition"), - reason: Some("completion policy: orchestrator session terminal".to_string()), + reason: Some("workflow phase: orchestrator session terminal".to_string()), ..Default::default() }; - io::update_work_item_atomic_serviced(project_slug, short_id, None, service, |frontmatter, _body| { - let result = mutator(frontmatter); - frontmatter.updated_at = chrono::Utc::now().to_rfc3339(); - Ok(result) - }) + io::update_work_item_atomic_serviced( + project_slug, + short_id, + None, + service, + |frontmatter, _body| { + let result = mutator(frontmatter); + frontmatter.updated_at = chrono::Utc::now().to_rfc3339(); + Ok(result) + }, + ) } /// What action the orchestrator should take after a transition. diff --git a/src-tauri/crates/project-management/src/orchestrator/tests/state_machine_tests.rs b/src-tauri/crates/project-management/src/orchestrator/tests/state_machine_tests.rs index a18f325a43..3a51352b16 100644 --- a/src-tauri/crates/project-management/src/orchestrator/tests/state_machine_tests.rs +++ b/src-tauri/crates/project-management/src/orchestrator/tests/state_machine_tests.rs @@ -105,7 +105,7 @@ fn effective_config_returns_default_when_none() { // ========== on_session_complete ========== #[test] -fn on_session_complete_without_review_completes() { +fn on_session_complete_without_review_does_not_complete_work_item() { let mut fm = make_frontmatter(); snapshot_config(&mut fm); let result = on_session_complete(&mut fm); @@ -113,7 +113,7 @@ fn on_session_complete_without_review_completes() { let state = fm.orchestrator_state.as_ref().unwrap(); assert_eq!(state.current_phase, OrchestratorPhase::Completed); assert!(state.active_config.is_none()); - assert_eq!(fm.status, "completed"); + assert_eq!(fm.status, "in_progress"); } #[test] @@ -179,7 +179,7 @@ fn on_session_failed_fails_immediately_without_retry() { // ========== on_review_complete ========== #[test] -fn on_review_complete_approved_completes() { +fn on_review_complete_approved_does_not_complete_work_item() { let mut fm = make_frontmatter(); fm.orchestrator_config = Some(OrchestratorConfig { review_enabled: true, @@ -191,7 +191,7 @@ fn on_review_complete_approved_completes() { assert_eq!(result, TransitionResult::Completed); let state = fm.orchestrator_state.as_ref().unwrap(); assert_eq!(state.current_phase, OrchestratorPhase::Completed); - assert_eq!(fm.status, "completed"); + assert_eq!(fm.status, "in_review"); } #[test] @@ -388,13 +388,7 @@ fn complete_linked_session_prefers_latest_running_duplicate() { LinkedSessionType::Native, ); - complete_linked_session( - &mut fm, - "sess-1", - LinkedSessionStatus::Completed, - 0.25, - 750, - ); + complete_linked_session(&mut fm, "sess-1", LinkedSessionStatus::Completed, 0.25, 750); assert_eq!(fm.linked_sessions[0].total_tokens, 0); assert_eq!(fm.linked_sessions[1].status, LinkedSessionStatus::Completed); diff --git a/src-tauri/crates/project-management/src/project_service/mod.rs b/src-tauri/crates/project-management/src/project_service/mod.rs index 578a67166d..d25a6d38d4 100644 --- a/src-tauri/crates/project-management/src/project_service/mod.rs +++ b/src-tauri/crates/project-management/src/project_service/mod.rs @@ -34,11 +34,7 @@ fn derive_prefix(name: &str) -> String { } } -fn audit_project( - operation: &'static str, - slug: &str, - org_id: Option<&str>, -) -> Result<(), String> { +fn audit_project(operation: &'static str, slug: &str, org_id: Option<&str>) -> Result<(), String> { let mut connection = project_io::helpers::conn()?; let tx = connection .transaction() @@ -95,8 +91,14 @@ pub fn create_project(request: &CreateProjectRequest) -> Result, ) -> Result { tokio::task::spawn_blocking(move || { + if matches!(to_status.as_str(), "completed" | "closed") { + crate::work_item_features::readiness::guard_completion( + &crate::work_item_features::WorkItemScope { + project_slug: Some(project_slug.clone()), + org_id: "personal-org".to_string(), + work_item_id: short_id.clone(), + }, + )?; + } let actor = crate::projects::types::WorkItemMutationActor { id: "human:desktop".to_string(), name: "Desktop".to_string(), @@ -330,6 +339,70 @@ pub async fn project_update_work_item_partial( .map_err(|err| format!("Task join error: {}", err))? } +/// Enqueue a durable Work Item execution episode. Producers use this instead +/// of sending directly to a Session so delivery survives process exit. +#[tauri::command] +pub async fn project_enqueue_work_item_run( + request: EnqueueWorkItemRunRequest, +) -> Result { + tokio::task::spawn_blocking(move || crate::work_run_service::enqueue(request)) + .await + .map_err(|err| format!("Task join error: {err}"))? +} + +#[tauri::command] +pub async fn project_list_work_item_runs( + project_slug: Option, + org_id: Option, + short_id: String, + limit: Option, +) -> Result, String> { + tokio::task::spawn_blocking(move || { + crate::work_run_service::list_for_work_item( + project_slug.as_deref(), + org_id.as_deref().unwrap_or("personal-org"), + &short_id, + limit.unwrap_or(50), + ) + }) + .await + .map_err(|err| format!("Task join error: {err}"))? +} + +#[tauri::command] +pub async fn project_retry_latest_work_item_run( + project_slug: Option, + org_id: Option, + short_id: String, + session_id: String, + idempotency_key: String, +) -> Result { + tokio::task::spawn_blocking(move || { + let runs = crate::work_run_service::list_for_work_item( + project_slug.as_deref(), + org_id.as_deref().unwrap_or("personal-org"), + &short_id, + 200, + )?; + let failed = runs + .into_iter() + .find(|run| { + run.status == WorkItemRunStatus::Failed + && run.session_id.as_deref() == Some(session_id.as_str()) + }) + .ok_or_else(|| { + format!( + "{}:no failed Run for Session {}", + crate::work_run_service::error::NOT_FOUND, + session_id + ) + })?; + crate::work_run_service::retry(&failed.id, &idempotency_key) + }) + .await + .map_err(|err| format!("Task join error: {err}"))? +} + /// Atomic partial update for an org-scoped Work Item without a project row. #[tauri::command] pub async fn work_item_update_standalone_partial( diff --git a/src-tauri/crates/project-management/src/projects/events.rs b/src-tauri/crates/project-management/src/projects/events.rs index a1fb28df5d..81c3d74a9d 100644 --- a/src-tauri/crates/project-management/src/projects/events.rs +++ b/src-tauri/crates/project-management/src/projects/events.rs @@ -42,9 +42,8 @@ pub struct WorkItemTerminalEvent { pub status: String, } -static WORK_ITEM_TERMINAL_NOTIFIER: OnceLock< - Box, -> = OnceLock::new(); +static WORK_ITEM_TERMINAL_NOTIFIER: OnceLock> = + OnceLock::new(); /// App-level registration for terminal-transition observers (the /// child-done parent wake). First call wins. diff --git a/src-tauri/crates/project-management/src/projects/io/git_folder_sync.rs b/src-tauri/crates/project-management/src/projects/io/git_folder_sync.rs index 26bd53ca06..983133fc4f 100644 --- a/src-tauri/crates/project-management/src/projects/io/git_folder_sync.rs +++ b/src-tauri/crates/project-management/src/projects/io/git_folder_sync.rs @@ -659,7 +659,7 @@ mod tests { labels: Vec::new(), milestone: None, parent: None, - stage: None, + stage: None, start_date: None, target_date: None, created_by: None, diff --git a/src-tauri/crates/project-management/src/projects/io/mod.rs b/src-tauri/crates/project-management/src/projects/io/mod.rs index 135c8c9394..15ccf4cfae 100644 --- a/src-tauri/crates/project-management/src/projects/io/mod.rs +++ b/src-tauri/crates/project-management/src/projects/io/mod.rs @@ -38,13 +38,12 @@ pub use routines::{ create_routine_fire, create_routine_fire_for_policy, create_routine_fire_for_policy_with_key, delete_routine, disable_routine, find_started_fire_by_session, find_started_fire_by_work_item, list_enabled_routines, list_routine_fires, list_routines, mark_routine_fire_failed, - read_pm_change_seq, mark_routine_fire_started, mark_routine_fire_succeeded, mark_routine_fire_work_item_created, - mark_routine_fire_work_item_started, read_routine, take_next_queued_fire, - update_routine_schedule_marks, upsert_routine, + mark_routine_fire_work_item_started, read_pm_change_seq, read_routine, + reconcile_terminal_dispatch_fires, take_next_queued_fire, update_routine_schedule_marks, + upsert_routine, }; pub use work_items::orchestrator_view; -pub(crate) use work_items::{allocate_short_id_in_tx, apply_execution_claim, resolve_project_scope_in_tx, write_work_item_in_tx}; pub use work_items::{ acquire_execution_lock, allocate_short_id, allocate_standalone_short_id, apply_remote_merge, batch_delete_work_items, batch_update_work_items, delete_work_item, find_by_external_ref, @@ -59,14 +58,17 @@ pub use work_items::{ read_workspace_work_items_data, release_execution_lock, restore_work_item, transition_standalone_work_item_handoff, transition_work_item_handoff, update_standalone_work_item_atomic, update_standalone_work_item_atomic_by, - update_standalone_work_item_atomic_serviced, - update_standalone_work_item_partial, + update_standalone_work_item_atomic_serviced, update_standalone_work_item_partial, update_work_item_atomic, update_work_item_atomic_serviced, update_work_item_atomic_with_revisions, update_work_item_partial, update_work_item_partial_enriched, update_work_item_partial_with_revisions, write_standalone_work_item, write_work_item, AtomicServiceOptions, FieldRevision, SyncMetadata, REVISION_SOURCE_LOCAL, }; +pub(crate) use work_items::{ + allocate_short_id_in_tx, apply_execution_claim, resolve_project_scope_in_tx, + write_work_item_in_tx, +}; pub(crate) use work_items::{purge_work_item, write_work_item_remote}; pub(crate) use work_items::{ read_standalone_sync_metadata, update_standalone_work_item_partial_with_revisions, diff --git a/src-tauri/crates/project-management/src/projects/io/routines.rs b/src-tauri/crates/project-management/src/projects/io/routines.rs index c53856d491..515e154fc8 100644 --- a/src-tauri/crates/project-management/src/projects/io/routines.rs +++ b/src-tauri/crates/project-management/src/projects/io/routines.rs @@ -600,6 +600,66 @@ pub fn mark_routine_fire_succeeded(fire_id: &str) -> Result read_routine_fire(fire_id) } +/// Recover Routine fires whose newest durable execution episode is terminal +/// failed. This includes both a failure before the first Session was linked +/// and a typed Retry that failed before it could resume or replace the +/// original Session. +pub fn reconcile_terminal_dispatch_fires() -> Result, String> { + let connection = conn()?; + let candidates = { + let mut statement = map_db(connection.prepare( + "WITH RECURSIVE run_lineage(id, root_trigger_json) AS ( + SELECT id, trigger_json + FROM pm_work_item_runs + WHERE parent_run_id IS NULL + UNION ALL + SELECT child.id, parent.root_trigger_json + FROM pm_work_item_runs child + JOIN run_lineage parent ON child.parent_run_id = parent.id + ) + SELECT fire_id, error + FROM ( + SELECT fire.id AS fire_id, + COALESCE(json_extract(run.failure_json, '$.message'), + 'Work Item dispatch terminated before Session launch') AS error, + fire.fired_at, + run.status AS run_status, + ROW_NUMBER() OVER ( + PARTITION BY fire.id + ORDER BY run.attempt DESC, run.created_at DESC + ) AS terminal_rank + FROM routine_fires fire + JOIN run_lineage lineage + ON json_extract(lineage.root_trigger_json, '$.kind') = 'routine' + AND json_extract(lineage.root_trigger_json, '$.fireId') = fire.id + JOIN pm_work_item_runs run ON run.id = lineage.id + WHERE fire.status IN ('pending', 'started') + ) + WHERE terminal_rank = 1 AND run_status IN ('failed', 'cancelled') + ORDER BY fired_at ASC", + ))?; + let rows = map_db(statement.query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }))?; + map_db(rows.collect::>>())? + }; + + let now = now_ms(); + for (fire_id, error) in &candidates { + map_db(connection.execute( + "UPDATE routine_fires + SET status = 'failed', error = ?2, completed_at = ?3 + WHERE id = ?1 AND status IN ('pending', 'started')", + params![fire_id, error, now], + ))?; + } + + candidates + .into_iter() + .map(|(fire_id, _)| read_routine_fire(&fire_id)) + .collect() +} + /// Look up the non-terminal fire that launched `session_id`, if any. /// Used by the session-terminal write-back path. pub fn find_started_fire_by_session(session_id: &str) -> Result, String> { @@ -998,6 +1058,168 @@ mod tests { assert!(failed.completed_at.is_some()); } + #[test] + fn reconciliation_closes_pre_session_terminal_dispatch_fire() { + use crate::projects::types::{ + EnqueueWorkItemRunRequest, WorkItemRunTarget, WorkItemRunTargetSnapshot, + WorkItemRunTrigger, + }; + use crate::work_service::{self, CreateWorkItemRequest}; + + let _sandbox = test_env::sandbox(); + upsert_routine(routine_fixture( + "routine-reconcile-dispatch", + policy(RoutineConcurrencyPolicy::CoalesceIfActive), + )) + .expect("upsert routine"); + let fire = create_routine_fire("routine-reconcile-dispatch").expect("create fire"); + + work_service::tests_support::seed_project("demo", "project-1"); + work_service::create_project_work_item( + "demo", + "AAA-0001", + &CreateWorkItemRequest { + title: "Routine dispatch".to_string(), + ..Default::default() + }, + None, + ) + .expect("seed work item"); + mark_routine_fire_work_item_started(&fire.id, "AAA-0001", None) + .expect("link fire before dispatch"); + + crate::work_run_service::enqueue(EnqueueWorkItemRunRequest { + project_slug: Some("demo".to_string()), + org_id: "personal-org".to_string(), + work_item_id: "AAA-0001".to_string(), + trigger: WorkItemRunTrigger::Routine { + routine_id: "routine-reconcile-dispatch".to_string(), + fire_id: fire.id.clone(), + }, + target_snapshot: WorkItemRunTargetSnapshot::new(WorkItemRunTarget::StartWorkItem { + account_id: Some("account-1".to_string()), + model_id: Some("model-1".to_string()), + }), + input: serde_json::json!({"prompt": "run"}), + idempotency_key: format!("routine-fire:{}", fire.id), + max_attempts: 3, + parent_run_id: None, + }) + .expect("enqueue run"); + let lease = crate::work_run_service::claim_next_dispatch("desktop-test", 30_000) + .expect("claim") + .expect("lease"); + crate::work_run_service::record_dispatch_failure( + &lease.dispatch_id, + &lease.lease_token, + "Unauthorized: invalid API key (status 401)", + ) + .expect("terminalize dispatch"); + + let recovered = reconcile_terminal_dispatch_fires().expect("reconcile"); + assert_eq!(recovered.len(), 1); + assert_eq!(recovered[0].id, fire.id); + assert_eq!(recovered[0].status, RoutineFireStatus::Failed); + assert!(recovered[0].session_id.is_none()); + assert!(recovered[0] + .error + .as_deref() + .is_some_and(|error| error.contains("Unauthorized"))); + assert!(reconcile_terminal_dispatch_fires() + .expect("idempotent reconcile") + .is_empty()); + } + + #[test] + fn reconciliation_follows_retry_ancestry_to_close_started_fire() { + use crate::projects::types::{ + EnqueueWorkItemRunRequest, WorkItemRunTarget, WorkItemRunTargetSnapshot, + WorkItemRunTrigger, WorkItemRunUsage, + }; + use crate::work_run_service::WorkItemRunTerminalOutcome; + use crate::work_service::{self, CreateWorkItemRequest}; + + let _sandbox = test_env::sandbox(); + upsert_routine(routine_fixture( + "routine-reconcile-retry", + policy(RoutineConcurrencyPolicy::CoalesceIfActive), + )) + .expect("upsert routine"); + let fire = create_routine_fire("routine-reconcile-retry").expect("create fire"); + work_service::tests_support::seed_project("demo", "project-1"); + work_service::create_project_work_item( + "demo", + "AAA-0001", + &CreateWorkItemRequest { + title: "Routine retry dispatch".to_string(), + ..Default::default() + }, + None, + ) + .expect("seed work item"); + + let first = crate::work_run_service::enqueue(EnqueueWorkItemRunRequest { + project_slug: Some("demo".to_string()), + org_id: "personal-org".to_string(), + work_item_id: "AAA-0001".to_string(), + trigger: WorkItemRunTrigger::Routine { + routine_id: "routine-reconcile-retry".to_string(), + fire_id: fire.id.clone(), + }, + target_snapshot: WorkItemRunTargetSnapshot::new(WorkItemRunTarget::StartWorkItem { + account_id: Some("account-1".to_string()), + model_id: Some("model-1".to_string()), + }), + input: serde_json::json!({"prompt": "run"}), + idempotency_key: format!("routine-fire:{}", fire.id), + max_attempts: 3, + parent_run_id: None, + }) + .expect("enqueue first run"); + let first_lease = crate::work_run_service::claim_next_dispatch("desktop-test", 30_000) + .expect("claim first") + .expect("first lease"); + crate::work_run_service::acknowledge_dispatch_started( + &first_lease.dispatch_id, + &first_lease.lease_token, + "session-retry", + ) + .expect("ack first"); + mark_routine_fire_work_item_started(&fire.id, "AAA-0001", Some("session-retry")) + .expect("link started fire"); + crate::work_run_service::record_run_terminal( + &first.id, + Some("session-retry"), + WorkItemRunTerminalOutcome::Failed, + WorkItemRunUsage::default(), + Some("request timed out"), + ) + .expect("fail first run"); + + let retry = crate::work_run_service::retry(&first.id, "retry:fire-reconcile") + .expect("enqueue retry"); + let retry_lease = crate::work_run_service::claim_next_dispatch("desktop-test", 30_000) + .expect("claim retry") + .expect("retry lease"); + assert_eq!(retry_lease.run.id, retry.id); + crate::work_run_service::record_dispatch_failure( + &retry_lease.dispatch_id, + &retry_lease.lease_token, + "invalid input while resuming Session", + ) + .expect("fail retry dispatch"); + + let recovered = reconcile_terminal_dispatch_fires().expect("reconcile retry ancestry"); + assert_eq!(recovered.len(), 1); + assert_eq!(recovered[0].id, fire.id); + assert_eq!(recovered[0].status, RoutineFireStatus::Failed); + assert_eq!(recovered[0].session_id.as_deref(), Some("session-retry")); + assert!(recovered[0] + .error + .as_deref() + .is_some_and(|error| error.contains("invalid input"))); + } + #[test] fn mark_work_item_created_links_fire_without_session() { let _sandbox = test_env::sandbox(); diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/atomic.rs b/src-tauri/crates/project-management/src/projects/io/work_items/atomic.rs index 80f18c7ebd..8e63457e4e 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/atomic.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/atomic.rs @@ -471,9 +471,10 @@ where let status_changed = core.status != frontmatter.status; let mut fsm_violation: Option = None; if status_changed { - if let Err(violation) = - crate::work_service::state::validate_legacy_transition(&core.status, &frontmatter.status) - { + if let Err(violation) = crate::work_service::state::validate_legacy_transition( + &core.status, + &frontmatter.status, + ) { if service.strict_fsm { return Err(crate::work_service::error::invalid_transition( &core.status, diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/atomic_tests.rs b/src-tauri/crates/project-management/src/projects/io/work_items/atomic_tests.rs index 2e290961a4..59627f74d9 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/atomic_tests.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/atomic_tests.rs @@ -169,6 +169,7 @@ fn partial_update_records_comment_history_event() { content: "Looks good".to_string(), created_at: "2026-01-01T00:00:00Z".to_string(), mentioned_user_ids: Vec::new(), + ..Default::default() }]), actor: Some(WorkItemMutationActor { id: "member-1".to_string(), @@ -497,6 +498,7 @@ fn standalone_partial_update_persists_collaboration_fields_atomically() { content: "@Ada ready for review".to_string(), created_at: "2026-07-29T09:00:00.000Z".to_string(), mentioned_user_ids: vec!["member-a".to_string()], + ..Default::default() }]), actor: Some(WorkItemMutationActor { id: "member-b".to_string(), @@ -766,6 +768,7 @@ fn partial_appends_comment_via_full_replace_semantics() { content: "first".into(), created_at: "2026-01-01T00:00:00Z".into(), mentioned_user_ids: Vec::new(), + ..Default::default() }]); update_work_item_partial("demo", "AAA-0001", &first).expect("first"); @@ -776,6 +779,7 @@ fn partial_appends_comment_via_full_replace_semantics() { content: "replaced".into(), created_at: "2026-01-02T00:00:00Z".into(), mentioned_user_ids: Vec::new(), + ..Default::default() }]); let result = update_work_item_partial("demo", "AAA-0001", &second).expect("second"); assert_eq!(result.frontmatter.comments.len(), 1); diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/batch.rs b/src-tauri/crates/project-management/src/projects/io/work_items/batch.rs index 28f15f6538..4e345b0d76 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/batch.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/batch.rs @@ -140,7 +140,7 @@ mod tests { labels: vec![], milestone: None, parent: None, - stage: None, + stage: None, start_date: None, target_date: None, created_by: None, diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/crud.rs b/src-tauri/crates/project-management/src/projects/io/work_items/crud.rs index de97458690..6abea21bb5 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/crud.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/crud.rs @@ -130,7 +130,7 @@ pub fn read_all_work_items_scoped_filtered( )?; let mut labels_by_work_item = read_project_labels(&connection, &project_id)?; let mut out = Vec::new(); - for (core, extras_json) in rows { + for (core, extras_json, _) in rows { if read_bucket .map(|bucket| !bucket.matches(&core.status)) .unwrap_or(false) @@ -196,7 +196,7 @@ pub fn read_standalone_work_items_filtered( )?; let mut labels_by_work_item = read_standalone_labels(&connection, org_id)?; let mut out = Vec::new(); - for (core, extras_json) in rows { + for (core, extras_json, _) in rows { if read_bucket .map(|bucket| !bucket.matches(&core.status)) .unwrap_or(false) @@ -213,18 +213,45 @@ pub fn read_standalone_work_items_filtered( Ok(out) } +pub(super) fn read_all_standalone_work_items_filtered( + read_bucket: Option, +) -> Result, String> { + let connection = conn()?; + let rows = + read_work_item_rows_with_extras(&connection, "WHERE w.project_id IS NULL", params![])?; + let mut labels_by_work_item = + read_label_map(&connection, "WHERE w.project_id IS NULL", params![])?; + let mut out = Vec::new(); + for (core, extras_json, org_id) in rows { + if read_bucket + .map(|bucket| !bucket.matches(&core.status)) + .unwrap_or(false) + { + continue; + } + let work_item_id = core.work_item_id.clone(); + let labels = labels_by_work_item + .remove(&work_item_id) + .unwrap_or_default(); + let extras = parse_extras_json(&work_item_id, extras_json.as_deref()); + out.push((org_id, assemble_work_item(core, labels, extras))); + } + Ok(out) +} + fn read_work_item_rows_with_extras

( connection: &rusqlite::Connection, where_clause: &str, query_params: P, -) -> Result)>, String> +) -> Result, String)>, String> where P: rusqlite::Params, { let sql = format!( "SELECT w.id, w.project_id, w.short_id, w.title, w.body, w.status, w.priority, w.assignee, w.assignee_type, w.milestone, w.parent, w.start_date, - w.target_date, w.created_at, w.updated_at, w.deleted_at, e.extras_json + w.target_date, w.created_at, w.updated_at, w.deleted_at, e.extras_json, + w.org_id FROM workitems w LEFT JOIN workitem_extras e ON e.work_item_id = w.id {where_clause} @@ -232,7 +259,11 @@ where ); let mut stmt = map_db(connection.prepare(&sql))?; let rows = map_db(stmt.query_map(query_params, |row| { - Ok((row_to_core(row)?, row.get::<_, Option>(16)?)) + Ok(( + row_to_core(row)?, + row.get::<_, Option>(16)?, + row.get::<_, String>(17)?, + )) }))?; let mut out = Vec::new(); for row in rows { @@ -755,7 +786,6 @@ pub(crate) fn allocate_short_id_in_tx( tx: &rusqlite::Transaction, project_slug: &str, ) -> Result { - let (project_id, org_id, prefix, mut next_id) = map_db( tx.query_row( "SELECT id, org_id, short_id_prefix, next_work_item_id diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/crud_tests.rs b/src-tauri/crates/project-management/src/projects/io/work_items/crud_tests.rs index f917667dd0..28d6994d31 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/crud_tests.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/crud_tests.rs @@ -350,6 +350,7 @@ fn extras_round_trip_carries_todos_and_comments() { content: "lgtm".into(), created_at: "2026-01-01T00:00:00Z".into(), mentioned_user_ids: Vec::new(), + ..Default::default() }]; write_work_item("demo", "AAA-0001", &fm, "").expect("write"); diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/enrichment.rs b/src-tauri/crates/project-management/src/projects/io/work_items/enrichment.rs index 7970ea39ae..5ab68385b7 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/enrichment.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/enrichment.rs @@ -333,7 +333,7 @@ mod tests { labels: vec![], milestone: None, parent: None, - stage: None, + stage: None, start_date: None, target_date: None, created_by: None, diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/execution_lock.rs b/src-tauri/crates/project-management/src/projects/io/work_items/execution_lock.rs index 3bbc1f8674..6efccf4430 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/execution_lock.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/execution_lock.rs @@ -25,9 +25,15 @@ pub fn acquire_execution_lock( operation: Some("work.claim"), ..AtomicServiceOptions::default() }; - update_work_item_atomic_serviced(project_slug, short_id, None, service, |frontmatter, _body| { - apply_execution_claim(frontmatter, short_id, session_id, agent_role, reason) - }) + update_work_item_atomic_serviced( + project_slug, + short_id, + None, + service, + |frontmatter, _body| { + apply_execution_claim(frontmatter, short_id, session_id, agent_role, reason) + }, + ) } /// Pure frontmatter mutation shared by the standalone lock acquisition @@ -137,18 +143,24 @@ pub fn release_execution_lock( operation: Some("work.release"), ..AtomicServiceOptions::default() }; - update_work_item_atomic_serviced(project_slug, short_id, None, service, |frontmatter, _body| { - if frontmatter - .execution_lock - .as_ref() - .and_then(|lock| lock.active_session_id.as_deref()) - == Some(session_id) - { - frontmatter.execution_lock = None; - frontmatter.updated_at = chrono::Utc::now().to_rfc3339(); - } - Ok(()) - }) + update_work_item_atomic_serviced( + project_slug, + short_id, + None, + service, + |frontmatter, _body| { + if frontmatter + .execution_lock + .as_ref() + .and_then(|lock| lock.active_session_id.as_deref()) + == Some(session_id) + { + frontmatter.execution_lock = None; + frontmatter.updated_at = chrono::Utc::now().to_rfc3339(); + } + Ok(()) + }, + ) } fn parse_agent_role(raw: Option<&str>) -> AgentRole { diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/mod.rs b/src-tauri/crates/project-management/src/projects/io/work_items/mod.rs index 1a116abacc..d79ef7ed7c 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/mod.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/mod.rs @@ -56,8 +56,7 @@ mod workspace; pub(crate) use atomic::update_standalone_work_item_partial_with_revisions; pub use atomic::{ update_standalone_work_item_atomic, update_standalone_work_item_atomic_by, - update_standalone_work_item_atomic_serviced, - update_standalone_work_item_partial, + update_standalone_work_item_atomic_serviced, update_standalone_work_item_partial, update_work_item_atomic, update_work_item_atomic_as, update_work_item_atomic_serviced, update_work_item_atomic_with_revisions, update_work_item_partial, update_work_item_partial_with_revisions, AtomicServiceOptions, @@ -65,7 +64,6 @@ pub use atomic::{ pub use batch::{batch_delete_work_items, batch_update_work_items}; pub(crate) use crud::purge_work_item; pub(crate) use crud::write_work_item_remote; -pub(crate) use crud::{allocate_short_id_in_tx, resolve_project_scope_in_tx, write_work_item_in_tx}; pub use crud::{ allocate_short_id, allocate_standalone_short_id, delete_work_item, move_work_item, purge_expired_deleted_work_items, read_all_work_items, read_all_work_items_scoped, @@ -74,13 +72,16 @@ pub use crud::{ read_work_item, read_work_item_by_row_id, read_work_item_scoped, restore_work_item, write_standalone_work_item, write_work_item, }; +pub(crate) use crud::{ + allocate_short_id_in_tx, resolve_project_scope_in_tx, write_work_item_in_tx, +}; pub use enrichment::{ read_all_work_items_enriched, read_all_work_items_enriched_scoped, read_all_work_items_enriched_scoped_filtered, read_work_item_enriched, read_work_item_enriched_scoped, update_work_item_partial_enriched, }; -pub use execution_lock::{acquire_execution_lock, release_execution_lock}; pub(crate) use execution_lock::apply_execution_claim; +pub use execution_lock::{acquire_execution_lock, release_execution_lock}; pub use handoff::{transition_standalone_work_item_handoff, transition_work_item_handoff}; pub(crate) use sync_metadata::read_standalone_sync_metadata; pub use sync_metadata::{ diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/sync_metadata.rs b/src-tauri/crates/project-management/src/projects/io/work_items/sync_metadata.rs index 860b553f14..3f6ef9ee88 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/sync_metadata.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/sync_metadata.rs @@ -87,17 +87,14 @@ pub(crate) fn read_standalone_sync_metadata( work_item_id: &str, ) -> Result, String> { let connection = conn()?; - let exists: bool = map_db( - connection - .query_row( - "SELECT EXISTS( + let exists: bool = map_db(connection.query_row( + "SELECT EXISTS( SELECT 1 FROM workitems WHERE id = ?1 AND org_id = ?2 AND project_id IS NULL )", - params![work_item_id, org_id], - |row| row.get(0), - ), - )?; + params![work_item_id, org_id], + |row| row.get(0), + ))?; if !exists { return Ok(None); } @@ -398,7 +395,7 @@ mod tests { labels: vec![], milestone: None, parent: None, - stage: None, + stage: None, start_date: None, target_date: None, created_by: None, diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/views.rs b/src-tauri/crates/project-management/src/projects/io/work_items/views.rs index b191ac1e47..ec4603e995 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/views.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/views.rs @@ -358,7 +358,7 @@ mod tests { labels: vec![], milestone: None, parent: None, - stage: None, + stage: None, start_date: None, target_date: None, created_by: None, diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/workspace.rs b/src-tauri/crates/project-management/src/projects/io/work_items/workspace.rs index ee20baa583..b63a2f9c5c 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/workspace.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/workspace.rs @@ -6,9 +6,11 @@ use crate::projects::io::{read_all_projects_scoped, read_project_orgs}; use crate::projects::types::{ - WorkItemReadBucket, WorkspaceProjectWorkItems, WorkspaceWorkItemsData, + WorkItemReadBucket, WorkspaceProjectWorkItems, WorkspaceStandaloneWorkItem, + WorkspaceWorkItemsData, }; +use super::crud::read_all_standalone_work_items_filtered; use super::{ enrichment::enrich_work_items_for_project, read_all_work_items_scoped_filtered, read_standalone_work_items_filtered, @@ -30,9 +32,23 @@ pub fn read_workspace_work_items_data( }); } + let standalone_work_items = match org_id { + Some(org_id) => read_standalone_work_items_filtered(Some(org_id), read_bucket)? + .into_iter() + .map(|work_item| WorkspaceStandaloneWorkItem { + org_id: org_id.to_string(), + work_item, + }) + .collect(), + None => read_all_standalone_work_items_filtered(read_bucket)? + .into_iter() + .map(|(org_id, work_item)| WorkspaceStandaloneWorkItem { org_id, work_item }) + .collect(), + }; + Ok(WorkspaceWorkItemsData { project_entries, - standalone_work_items: read_standalone_work_items_filtered(org_id, read_bucket)?, + standalone_work_items, orgs: read_project_orgs()?, }) } @@ -40,9 +56,10 @@ pub fn read_workspace_work_items_data( #[cfg(test)] mod tests { use super::*; + use crate::projects::io::create_project_org; use crate::projects::io::projects::write_project; - use crate::projects::io::work_items::write_work_item; - use crate::projects::types::{ProjectMeta, WorkItemFrontmatter}; + use crate::projects::io::work_items::{write_standalone_work_item, write_work_item}; + use crate::projects::types::{CreateProjectOrgRequest, ProjectMeta, WorkItemFrontmatter}; use test_helpers::test_env; fn project_fixture() -> ProjectMeta { @@ -81,7 +98,7 @@ mod tests { labels: vec![], milestone: None, parent: None, - stage: None, + stage: None, start_date: None, target_date: None, created_by: None, @@ -140,4 +157,62 @@ mod tests { "completed" ); } + + #[test] + fn workspace_read_preserves_every_standalone_organization_scope() { + let _sandbox = test_env::sandbox(); + create_project_org(&CreateProjectOrgRequest { + id: Some("cloud-org".into()), + name: "Cloud Org".into(), + }) + .expect("cloud org"); + write_standalone_work_item( + Some("personal-org"), + "WI-0001", + &work_item_fixture("WI-0001", "planned"), + "", + ) + .expect("personal standalone item"); + + let mut cloud_active = work_item_fixture("WI-0001", "planned"); + cloud_active.id = "cloud-active".into(); + write_standalone_work_item(Some("cloud-org"), "WI-0001", &cloud_active, "") + .expect("cloud standalone item"); + + let mut cloud_completed = work_item_fixture("WI-0002", "completed"); + cloud_completed.id = "cloud-completed".into(); + write_standalone_work_item(Some("cloud-org"), "WI-0002", &cloud_completed, "") + .expect("completed cloud standalone item"); + + let all_active = read_workspace_work_items_data(None, Some(WorkItemReadBucket::Active)) + .expect("all-org workspace data"); + let mut active_scopes = all_active + .standalone_work_items + .iter() + .map(|entry| { + ( + entry.org_id.as_str(), + entry.work_item.frontmatter.short_id.as_str(), + ) + }) + .collect::>(); + active_scopes.sort_unstable(); + assert_eq!( + active_scopes, + [("cloud-org", "WI-0001"), ("personal-org", "WI-0001")] + ); + + let cloud_active = + read_workspace_work_items_data(Some("cloud-org"), Some(WorkItemReadBucket::Active)) + .expect("cloud workspace data"); + assert_eq!(cloud_active.standalone_work_items.len(), 1); + assert_eq!(cloud_active.standalone_work_items[0].org_id, "cloud-org"); + assert_eq!( + cloud_active.standalone_work_items[0] + .work_item + .frontmatter + .short_id, + "WI-0001" + ); + } } diff --git a/src-tauri/crates/project-management/src/projects/schema.rs b/src-tauri/crates/project-management/src/projects/schema.rs index 492a815a68..d9e5634458 100644 --- a/src-tauri/crates/project-management/src/projects/schema.rs +++ b/src-tauri/crates/project-management/src/projects/schema.rs @@ -11,6 +11,8 @@ //! - `members` — known project members / assignees //! - `routine_definitions` — durable automation definitions that launch agent runs //! - `routine_fires` — provenance for each routine occurrence +//! - `pm_work_item_runs` — durable execution episodes for Work Items +//! - `pm_dispatch_outbox` — lease-based delivery queue for Work Item Runs //! //! Sync tables: //! - `outbox_entries` — durable replay log for external sync adapters @@ -59,6 +61,11 @@ pub fn init_project_tables(conn: &Connection) -> SqliteResult<()> { /// mutation) — this table is insert-only and queryable. /// - `pm_idempotency`: idempotency records scoped by /// `(actor, operation, scope, key)` per the frozen wire contract §14.4. +/// - `pm_work_item_runs`: execution truth kept separate from both Work Item +/// lifecycle and Session lifecycle. A terminal Run never implies a terminal +/// Work Item. +/// - `pm_dispatch_outbox`: lease-based at-least-once delivery. The Run service +/// and outbox row are always mutated in one transaction. pub fn init_pm_service_tables(conn: &Connection) -> SqliteResult<()> { conn.execute_batch( r#" @@ -159,6 +166,162 @@ pub fn init_pm_service_tables(conn: &Connection) -> SqliteResult<()> { created_at INTEGER NOT NULL, -- unix ms PRIMARY KEY (actor_id, operation, scope_id, idem_key) ); + + CREATE TABLE IF NOT EXISTS pm_work_item_runs ( + id TEXT PRIMARY KEY, + scope_key TEXT NOT NULL, + project_slug TEXT, + org_id TEXT NOT NULL, + work_item_id TEXT NOT NULL, + work_item_revision INTEGER NOT NULL, + trigger_kind TEXT NOT NULL, + trigger_json TEXT NOT NULL, + target_json TEXT NOT NULL, + input_json TEXT NOT NULL, + status TEXT NOT NULL, + attempt INTEGER NOT NULL, + max_attempts INTEGER NOT NULL, + parent_run_id TEXT, + session_id TEXT, + failure_json TEXT, + usage_json TEXT, + idempotency_key TEXT NOT NULL, + request_hash TEXT NOT NULL, + generation INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + started_at INTEGER, + completed_at INTEGER + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_pm_work_item_runs_idempotency + ON pm_work_item_runs(scope_key, work_item_id, idempotency_key); + CREATE INDEX IF NOT EXISTS idx_pm_work_item_runs_session + ON pm_work_item_runs(session_id) + WHERE session_id IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_pm_work_item_runs_item + ON pm_work_item_runs(scope_key, work_item_id, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_pm_work_item_runs_status + ON pm_work_item_runs(status, updated_at); + + CREATE TABLE IF NOT EXISTS pm_dispatch_outbox ( + id TEXT PRIMARY KEY, + run_id TEXT NOT NULL, + generation INTEGER NOT NULL, + status TEXT NOT NULL, + delivery_attempt INTEGER NOT NULL DEFAULT 0, + available_at INTEGER NOT NULL, + lease_token TEXT, + lease_owner TEXT, + lease_expires_at INTEGER, + delivered_at INTEGER, + last_error_json TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(run_id, generation) + ); + CREATE INDEX IF NOT EXISTS idx_pm_dispatch_outbox_ready + ON pm_dispatch_outbox(status, available_at, created_at); + CREATE INDEX IF NOT EXISTS idx_pm_dispatch_outbox_lease + ON pm_dispatch_outbox(status, lease_expires_at); + + CREATE TABLE IF NOT EXISTS pm_event_consumers ( + consumer_id TEXT PRIMARY KEY, + last_seq INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS pm_work_item_path_locks ( + workspace_path TEXT PRIMARY KEY, + run_id TEXT NOT NULL UNIQUE, + work_item_id TEXT NOT NULL, + acquired_at INTEGER NOT NULL, + lease_expires_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_pm_work_item_path_locks_expiry + ON pm_work_item_path_locks(lease_expires_at); + + CREATE TABLE IF NOT EXISTS pm_work_item_subscriptions ( + scope_key TEXT NOT NULL, + work_item_id TEXT NOT NULL, + subscriber_id TEXT NOT NULL, + reason TEXT NOT NULL, + created_at INTEGER NOT NULL, + muted_at INTEGER, + PRIMARY KEY (scope_key, work_item_id, subscriber_id) + ); + CREATE INDEX IF NOT EXISTS idx_pm_work_item_subscriptions_subscriber + ON pm_work_item_subscriptions(subscriber_id, muted_at); + + CREATE TABLE IF NOT EXISTS pm_work_item_inbox_events ( + id TEXT PRIMARY KEY, + scope_key TEXT NOT NULL, + work_item_id TEXT NOT NULL, + recipient_id TEXT NOT NULL, + kind TEXT NOT NULL, + actor_id TEXT, + payload_json TEXT NOT NULL, + coalesce_key TEXT NOT NULL, + occurred_at INTEGER NOT NULL, + archived_at INTEGER, + UNIQUE(recipient_id, coalesce_key) + ); + CREATE INDEX IF NOT EXISTS idx_pm_work_item_inbox_recipient + ON pm_work_item_inbox_events(recipient_id, archived_at, occurred_at DESC); + + CREATE TABLE IF NOT EXISTS pm_routine_webhooks ( + routine_name TEXT PRIMARY KEY, + secret_hash TEXT NOT NULL, + secret_hint TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + consecutive_failures INTEGER NOT NULL DEFAULT 0, + paused_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS pm_routine_webhook_deliveries ( + id TEXT PRIMARY KEY, + routine_name TEXT NOT NULL, + provider TEXT NOT NULL, + event_kind TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + payload_json TEXT NOT NULL, + status TEXT NOT NULL, + reason TEXT, + routine_run_id TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(routine_name, idempotency_key) + ); + CREATE INDEX IF NOT EXISTS idx_pm_routine_webhook_deliveries_routine + ON pm_routine_webhook_deliveries(routine_name, created_at DESC); + + CREATE TABLE IF NOT EXISTS pm_property_definitions ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL, + name TEXT NOT NULL, + property_type TEXT NOT NULL, + description TEXT, + config_json TEXT NOT NULL DEFAULT '{}', + position INTEGER NOT NULL DEFAULT 0, + archived_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_pm_property_definitions_name + ON pm_property_definitions(org_id, name) WHERE archived_at IS NULL; + CREATE INDEX IF NOT EXISTS idx_pm_property_definitions_org + ON pm_property_definitions(org_id, archived_at, position); + CREATE TABLE IF NOT EXISTS pm_work_item_property_values ( + property_id TEXT NOT NULL, + scope_key TEXT NOT NULL, + work_item_id TEXT NOT NULL, + value_json TEXT NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (property_id, scope_key, work_item_id) + ); + CREATE INDEX IF NOT EXISTS idx_pm_work_item_property_values_item + ON pm_work_item_property_values(scope_key, work_item_id); "#, )?; Ok(()) @@ -697,11 +860,10 @@ fn ensure_workitems_allow_standalone_scope(conn: &Connection) -> SqliteResult<() migration?; foreign_keys_result?; - let foreign_key_violation: i64 = conn.query_row( - "SELECT COUNT(*) FROM pragma_foreign_key_check", - [], - |row| row.get(0), - )?; + let foreign_key_violation: i64 = + conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get(0) + })?; if foreign_key_violation != 0 { return Err(rusqlite::Error::ExecuteReturnedResults); } @@ -1014,11 +1176,9 @@ mod tests { assert_eq!(detached_project_id, None); let foreign_key_violations: i64 = conn - .query_row( - "SELECT COUNT(*) FROM pragma_foreign_key_check", - [], - |row| row.get(0), - ) + .query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get(0) + }) .expect("foreign-key check"); assert_eq!(foreign_key_violations, 0); } diff --git a/src-tauri/crates/project-management/src/projects/sync_export.rs b/src-tauri/crates/project-management/src/projects/sync_export.rs index bedac484c3..9734ad2117 100644 --- a/src-tauri/crates/project-management/src/projects/sync_export.rs +++ b/src-tauri/crates/project-management/src/projects/sync_export.rs @@ -218,7 +218,7 @@ mod tests { labels: Vec::new(), milestone: None, parent: None, - stage: None, + stage: None, start_date: None, target_date: None, created_by: None, diff --git a/src-tauri/crates/project-management/src/projects/types/mod.rs b/src-tauri/crates/project-management/src/projects/types/mod.rs index e70f6bbb32..c2163e3a8e 100644 --- a/src-tauri/crates/project-management/src/projects/types/mod.rs +++ b/src-tauri/crates/project-management/src/projects/types/mod.rs @@ -10,6 +10,7 @@ pub mod project; pub mod routines; pub mod views; pub mod work_items; +pub mod work_runs; pub use config::*; pub use enriched::*; @@ -18,3 +19,4 @@ pub use project::*; pub use routines::*; pub use views::*; pub use work_items::*; +pub use work_runs::*; diff --git a/src-tauri/crates/project-management/src/projects/types/orchestrator.rs b/src-tauri/crates/project-management/src/projects/types/orchestrator.rs index 115f26a6e2..70211050b7 100644 --- a/src-tauri/crates/project-management/src/projects/types/orchestrator.rs +++ b/src-tauri/crates/project-management/src/projects/types/orchestrator.rs @@ -16,6 +16,18 @@ pub use core_types::workflow::{ ReviewFeedback, ReviewOutcome, ReviewerRef, TestResults, WorkItemDiffStats, WorkItemSchedule, }; +/// How an execution path should be mounted into an agent Session. +/// +/// A local workspace is the primary checkout itself. A worktree is an +/// already-registered secondary checkout and must pass the stricter Git +/// worktree validation during launch. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceExecutionMode { + LocalWorkspace, + Worktree, +} + /// Per-work-item orchestrator configuration (user-editable) #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct OrchestratorConfig { @@ -53,6 +65,10 @@ pub struct OrchestratorConfig { /// Overrides the project-level `linked_repos` fallback. #[serde(skip_serializing_if = "Option::is_none")] pub worktree_path: Option, + /// Whether `worktree_path` is the primary checkout or a registered Git + /// worktree. Optional for backward compatibility with older Work Items. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_mode: Option, } fn default_false() -> bool { @@ -79,6 +95,7 @@ impl Default for OrchestratorConfig { agent_mode: None, agent_definition_id: None, worktree_path: None, + workspace_mode: None, } } } diff --git a/src-tauri/crates/project-management/src/projects/types/project.rs b/src-tauri/crates/project-management/src/projects/types/project.rs index e7719cfc71..2b24bcb695 100644 --- a/src-tauri/crates/project-management/src/projects/types/project.rs +++ b/src-tauri/crates/project-management/src/projects/types/project.rs @@ -171,6 +171,10 @@ fn default_false() -> bool { false } +fn is_false(value: &bool) -> bool { + !*value +} + /// Combined project data returned to the frontend #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProjectData { @@ -206,7 +210,7 @@ fn default_todo_status() -> String { } /// A comment on a work item -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct CommentEntry { pub id: String, pub author: String, @@ -214,6 +218,20 @@ pub struct CommentEntry { pub created_at: String, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub mentioned_user_ids: Vec, + /// Replies form a stable thread tree. `thread_id` always names the root; + /// top-level comments use their own id. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thread_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resolved_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resolved_by: Option, + #[serde(default, skip_serializing_if = "is_false")] + pub conclusion: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_session_id: Option, } /// A market delegation entry on a work item diff --git a/src-tauri/crates/project-management/src/projects/types/views.rs b/src-tauri/crates/project-management/src/projects/types/views.rs index adc146d02f..6ef6545648 100644 --- a/src-tauri/crates/project-management/src/projects/types/views.rs +++ b/src-tauri/crates/project-management/src/projects/types/views.rs @@ -126,11 +126,23 @@ pub struct WorkspaceProjectWorkItems { pub work_items: Vec, } +/// A standalone work item and the organization scope that owns it. +/// +/// Standalone short IDs are allocated per organization, so workspace-level +/// callers must keep the scope beside the row instead of assigning a default +/// organization after deserialization. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceStandaloneWorkItem { + pub org_id: String, + pub work_item: WorkItemData, +} + /// Complete local dataset needed by the workspace work-items surface. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WorkspaceWorkItemsData { pub project_entries: Vec, - pub standalone_work_items: Vec, + pub standalone_work_items: Vec, pub orgs: Vec, } diff --git a/src-tauri/crates/project-management/src/projects/types/work_runs.rs b/src-tauri/crates/project-management/src/projects/types/work_runs.rs new file mode 100644 index 0000000000..044be1a87e --- /dev/null +++ b/src-tauri/crates/project-management/src/projects/types/work_runs.rs @@ -0,0 +1,315 @@ +//! Durable Work Item execution and dispatch wire types. +//! +//! A Work Item Run is one execution episode. It is deliberately separate +//! from the Work Item lifecycle (product intent) and linked Session lifecycle +//! (runtime transport). `pm_dispatch_outbox` owns delivery attempts; neither +//! a delivered dispatch nor a terminal Session may silently complete the +//! Work Item. + +use serde::{Deserialize, Serialize}; + +use super::WorkspaceExecutionMode; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkItemRunStatus { + Queued, + Deferred, + Dispatching, + Running, + Waiting, + Succeeded, + Failed, + Cancelled, +} + +impl WorkItemRunStatus { + pub fn as_str(self) -> &'static str { + match self { + Self::Queued => "queued", + Self::Deferred => "deferred", + Self::Dispatching => "dispatching", + Self::Running => "running", + Self::Waiting => "waiting", + Self::Succeeded => "succeeded", + Self::Failed => "failed", + Self::Cancelled => "cancelled", + } + } + + pub fn is_terminal(self) -> bool { + matches!(self, Self::Succeeded | Self::Failed | Self::Cancelled) + } +} + +impl TryFrom<&str> for WorkItemRunStatus { + type Error = String; + + fn try_from(value: &str) -> Result { + match value { + "queued" => Ok(Self::Queued), + "deferred" => Ok(Self::Deferred), + "dispatching" => Ok(Self::Dispatching), + "running" => Ok(Self::Running), + "waiting" => Ok(Self::Waiting), + "succeeded" => Ok(Self::Succeeded), + "failed" => Ok(Self::Failed), + "cancelled" => Ok(Self::Cancelled), + other => Err(format!("unknown Work Item Run status '{other}'")), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + tag = "kind", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +pub enum WorkItemRunTrigger { + Manual, + Schedule { + schedule_key: String, + }, + Routine { + routine_id: String, + fire_id: String, + }, + DiscussionComment { + comment_id: String, + author_id: Option, + }, + StageBarrier { + parent_work_item_id: String, + stage: Option, + settled_key: String, + }, + Review { + previous_run_id: String, + }, + FollowUp { + previous_run_id: String, + }, + Retry { + previous_run_id: String, + }, +} + +impl WorkItemRunTrigger { + pub fn kind(&self) -> &'static str { + match self { + Self::Manual => "manual", + Self::Schedule { .. } => "schedule", + Self::Routine { .. } => "routine", + Self::DiscussionComment { .. } => "discussion_comment", + Self::StageBarrier { .. } => "stage_barrier", + Self::Review { .. } => "review", + Self::FollowUp { .. } => "follow_up", + Self::Retry { .. } => "retry", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + tag = "kind", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +pub enum WorkItemRunTarget { + StartWorkItem { + account_id: Option, + model_id: Option, + }, + ResumeSession { + session_id: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkItemRunTargetSnapshot { + pub target: WorkItemRunTarget, + pub work_item_revision: i64, + /// Immutable work-item content used to build the execution brief. Older + /// Runs deserialize these as `None` and retain the legacy live-read path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub work_item_title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub work_item_body: Option, + /// Project context and repository identity captured when the Run is + /// enqueued. Dispatch must not silently switch repositories after a + /// project or Work Item is edited. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_description: Option, + pub workspace_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repository: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repository_ref: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_branch: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub linked_repositories: Vec, + /// Explicit escape hatch for environments that deliberately coordinate a + /// shared checkout outside ORG2. The safe default is one active Run per + /// resolved workspace path. + #[serde(default)] + pub allow_shared_checkout: bool, + /// Immutable launch interpretation for `workspace_path`. This prevents a + /// local checkout from being reinterpreted as a Git worktree at dispatch. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_mode: Option, + pub agent_definition_id: Option, + pub agent_org_id: Option, +} + +impl WorkItemRunTargetSnapshot { + pub fn new(target: WorkItemRunTarget) -> Self { + Self { + target, + work_item_revision: 0, + work_item_title: None, + work_item_body: None, + project_description: None, + workspace_path: None, + repository: None, + repository_ref: None, + default_branch: None, + linked_repositories: Vec::new(), + allow_shared_checkout: false, + workspace_mode: None, + agent_definition_id: None, + agent_org_id: None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkItemRunFailureClass { + TransientNetwork, + ProviderUnavailable, + Timeout, + Authentication, + Authorization, + Quota, + Configuration, + InvalidInput, + Model, + ContextOverflow, + Runtime, + Cancelled, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkItemRunRetryDisposition { + ResumeSession, + StartNewSession, + DoNotRetry, + ManualReview, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkItemRunFailure { + pub class: WorkItemRunFailureClass, + pub code: String, + pub message: String, + pub retryable: bool, + pub retry_disposition: WorkItemRunRetryDisposition, + pub occurred_at: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkItemRunUsage { + pub input_tokens: u64, + pub output_tokens: u64, + pub cache_read_tokens: u64, + pub cache_write_tokens: u64, + pub total_tokens: u64, + pub cost_usd: f64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkItemRun { + pub id: String, + pub project_slug: Option, + pub org_id: String, + pub work_item_id: String, + pub trigger: WorkItemRunTrigger, + pub target_snapshot: WorkItemRunTargetSnapshot, + pub input: serde_json::Value, + pub status: WorkItemRunStatus, + pub attempt: u32, + pub max_attempts: u32, + pub parent_run_id: Option, + pub session_id: Option, + pub failure: Option, + pub usage: WorkItemRunUsage, + pub idempotency_key: String, + pub generation: u64, + pub created_at: String, + pub updated_at: String, + pub started_at: Option, + pub completed_at: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnqueueWorkItemRunRequest { + pub project_slug: Option, + pub org_id: String, + pub work_item_id: String, + pub trigger: WorkItemRunTrigger, + pub target_snapshot: WorkItemRunTargetSnapshot, + #[serde(default)] + pub input: serde_json::Value, + pub idempotency_key: String, + #[serde(default = "default_run_max_attempts")] + pub max_attempts: u32, + pub parent_run_id: Option, +} + +pub fn default_run_max_attempts() -> u32 { + 3 +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkItemDispatchStatus { + Pending, + Leased, + RetryWait, + Delivered, + DeadLetter, + Cancelled, +} + +impl WorkItemDispatchStatus { + pub fn as_str(self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Leased => "leased", + Self::RetryWait => "retry_wait", + Self::Delivered => "delivered", + Self::DeadLetter => "dead_letter", + Self::Cancelled => "cancelled", + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkItemDispatchLease { + pub dispatch_id: String, + pub lease_token: String, + pub lease_owner: String, + pub lease_expires_at: String, + pub delivery_attempt: u32, + pub run: WorkItemRun, +} diff --git a/src-tauri/crates/project-management/src/routine_service/mod.rs b/src-tauri/crates/project-management/src/routine_service/mod.rs index 42d86d75dc..1c6c458673 100644 --- a/src-tauri/crates/project-management/src/routine_service/mod.rs +++ b/src-tauri/crates/project-management/src/routine_service/mod.rs @@ -147,7 +147,10 @@ pub mod error { /// Substitute `{{ inputs. }}` template markers (with or without /// inner spaces) in root-work templates. Declarative only. -fn substitute_inputs(template: &str, inputs: &std::collections::BTreeMap) -> String { +fn substitute_inputs( + template: &str, + inputs: &std::collections::BTreeMap, +) -> String { let mut result = template.to_string(); for (name, value) in inputs { for marker in [ @@ -200,18 +203,28 @@ pub fn invoke( for (name, decl) in &snapshot.spec.inputs { if decl.required && !inputs.contains_key(name) { - return Err(format!("{}:missing required input '{}'", error::INPUTS_INVALID, name)); + return Err(format!( + "{}:missing required input '{}'", + error::INPUTS_INVALID, + name + )); } } for name in inputs.keys() { if !snapshot.spec.inputs.contains_key(name) { - return Err(format!("{}:unknown input '{}'", error::INPUTS_INVALID, name)); + return Err(format!( + "{}:unknown input '{}'", + error::INPUTS_INVALID, + name + )); } } let now = chrono::Utc::now().timestamp_millis(); let run_id = format!("run_{}{:05}", now, std::process::id() % 100_000); - let actor_id = created_by.map(|actor| actor.id.as_str()).unwrap_or("system"); + let actor_id = created_by + .map(|actor| actor.id.as_str()) + .unwrap_or("system"); let canonical_request = serde_json::json!({ "routine": routine_name, "scope": scope_project_slug, @@ -260,35 +273,34 @@ pub fn invoke( let (project_id, org_id) = project_io::resolve_project_scope_in_tx(&tx, scope_project_slug)?; let seq = work_service::audit::bump_change_seq(&tx)?; - let create_item = |short_id: &str, - request: &work_service::CreateWorkItemRequest| - -> Result<(), String> { - work_service::guard_new_work_item_id_in_tx(&tx, short_id)?; - let frontmatter = work_service::build_frontmatter_for_graph(short_id, request); - project_io::write_work_item_in_tx( - &tx, - Some(project_id.clone()), - &org_id, - short_id, - &frontmatter, - &request.body, - true, - )?; - work_service::audit::append_audit_event( - &tx, - &work_service::audit::AuditEventRow { - operation: "work.create", - entity_type: "work_item", - entity_id: short_id, - project_slug: Some(scope_project_slug), - org_id: None, - actor: created_by, - revision: 0, - seq, - payload: serde_json::json!({}), - }, - ) - }; + let create_item = + |short_id: &str, request: &work_service::CreateWorkItemRequest| -> Result<(), String> { + work_service::guard_new_work_item_id_in_tx(&tx, short_id)?; + let frontmatter = work_service::build_frontmatter_for_graph(short_id, request); + project_io::write_work_item_in_tx( + &tx, + Some(project_id.clone()), + &org_id, + short_id, + &frontmatter, + &request.body, + true, + )?; + work_service::audit::append_audit_event( + &tx, + &work_service::audit::AuditEventRow { + operation: "work.create", + entity_type: "work_item", + entity_id: short_id, + project_slug: Some(scope_project_slug), + org_id: None, + actor: created_by, + revision: 0, + seq, + payload: serde_json::json!({}), + }, + ) + }; let root_short_id = project_io::allocate_short_id_in_tx(&tx, scope_project_slug)?; let root_request = work_service::CreateWorkItemRequest { @@ -429,7 +441,14 @@ pub fn invoke( "INSERT INTO pm_idempotency (actor_id, operation, scope_id, idem_key, request_hash, response_json, created_at) VALUES (?1, 'routine.invoke', ?2, ?3, ?4, ?5, ?6)", - rusqlite::params![actor_id, scope_project_slug, key, canonical, response_raw, now], + rusqlite::params![ + actor_id, + scope_project_slug, + key, + canonical, + response_raw, + now + ], ) .map_err(|err| format!("routine invoke idempotency record: {err}"))?; } @@ -529,7 +548,11 @@ pub fn scheduled_candidates() -> Result, String> { } /// Persist the scheduler watermark after an evaluation pass. -pub fn mark_evaluated(name: &str, evaluated_at: i64, next_fire_at: Option) -> Result<(), String> { +pub fn mark_evaluated( + name: &str, + evaluated_at: i64, + next_fire_at: Option, +) -> Result<(), String> { let connection = project_io::helpers::conn()?; connection .execute( @@ -702,10 +725,7 @@ pub fn set_enabled(name: &str, enabled: bool) -> Result<(), String> { /// List routine runs, newest first, optionally filtered to one scope. /// Row-level listing for the Runs surface — per-run WorkItem projection /// stays in [`run_status`], which the UI calls on expand. -pub fn list_runs( - scope_id: Option<&str>, - limit: usize, -) -> Result, String> { +pub fn list_runs(scope_id: Option<&str>, limit: usize) -> Result, String> { let connection = project_io::helpers::conn()?; let mut statement = connection .prepare( diff --git a/src-tauri/crates/project-management/src/sync/collab_bridge/apply.rs b/src-tauri/crates/project-management/src/sync/collab_bridge/apply.rs index aa1f781ffb..2ef3865ad2 100644 --- a/src-tauri/crates/project-management/src/sync/collab_bridge/apply.rs +++ b/src-tauri/crates/project-management/src/sync/collab_bridge/apply.rs @@ -471,6 +471,7 @@ fn apply_project(org_id: &str, entity: &CollabRemoteEntity) -> Result Result Result { @@ -933,10 +933,16 @@ fn apply_work_item(org_id: &str, entity: &CollabRemoteEntity) -> Result, + work_item_id: &str, + field_path: &str, +) -> Result<(), String> { + if !is_collab_org(conn, org_id)? { + return Ok(()); + } + append_collab_row( + conn, + org_id, + project_slug.unwrap_or(""), + EntityType::WorkItem, + work_item_id, + OutboxOp::Update, + Some(field_path), + ) +} + +/// Enqueue one existing org entity as the carrier for org-wide typed-property +/// definitions. Project rows are preferred; an org-scoped standalone Work +/// Item is the fallback. If the org has no entity yet, the first future entity +/// write will carry the definitions in its full snapshot. +pub(crate) fn record_property_definitions_touch( + conn: &Connection, + org_id: &str, + property_id: &str, +) -> Result<(), String> { + if !is_collab_org(conn, org_id)? { + return Ok(()); + } + let project_anchor: Option<(EntityType, String, String)> = conn + .query_row( + "SELECT 'project', id, slug + FROM projects + WHERE org_id = ?1 + ORDER BY updated_at DESC, id ASC + LIMIT 1", + params![org_id], + |row| { + Ok(( + EntityType::Project, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }, + ) + .optional() + .map_err(|err| format!("DB error (property definition project anchor): {err}"))?; + let anchor = match project_anchor { + Some(anchor) => Some(anchor), + None => conn + .query_row( + "SELECT 'work_item', id, '' + FROM workitems + WHERE org_id = ?1 AND project_id IS NULL AND deleted_at IS NULL + ORDER BY updated_at DESC, id ASC + LIMIT 1", + params![org_id], + |row| { + Ok(( + EntityType::WorkItem, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }, + ) + .optional() + .map_err(|err| format!("DB error (property definition Work Item anchor): {err}"))?, + }; + let Some((entity_type, entity_id, project_slug)) = anchor else { + return Ok(()); + }; + append_collab_row( + conn, + org_id, + &project_slug, + entity_type, + &entity_id, + OutboxOp::Update, + Some(&format!("propertyDefinitions.{property_id}")), + ) +} + /// Hook for full work-item writes (create / delete / restore / full /// update). `deleted` selects the outbox op; the drain re-derives the /// effective op from current row state anyway. @@ -347,6 +436,15 @@ pub fn drain_outbox(org_id: &str, max: u32) -> Result, Strin } drop(stmt); + if order.is_empty() { + return Ok(Vec::new()); + } + + // Org-wide definitions are shared by every entity snapshot. Load them + // once per non-empty bounded drain instead of once per Work Item. + let property_definitions = + crate::work_item_features::properties::export_definitions(&conn, org_id)?; + // Claim everything we're about to hand out. for (ids, _) in groups.values() { for id in ids { @@ -369,8 +467,22 @@ pub fn drain_outbox(org_id: &str, max: u32) -> Result, Strin let (entry_ids, field_paths) = groups.remove(&key).unwrap_or_default(); let (entity_type, entity_id) = key; let item = match entity_type.as_str() { - "project" => hydrate_project(&conn, org_id, &entity_id, entry_ids, field_paths)?, - "work_item" => hydrate_work_item(&conn, org_id, &entity_id, entry_ids, field_paths)?, + "project" => hydrate_project( + &conn, + org_id, + &entity_id, + entry_ids, + field_paths, + &property_definitions, + )?, + "work_item" => hydrate_work_item( + &conn, + org_id, + &entity_id, + entry_ids, + field_paths, + &property_definitions, + )?, other => { let message = format!("unsupported collab entity_type: {other}"); tracing::warn!( @@ -394,6 +506,7 @@ fn hydrate_project( project_id: &str, entry_ids: Vec, field_paths: Vec, + property_definitions: &[crate::work_item_features::PropertyDefinition], ) -> Result { let row = conn .query_row( @@ -474,6 +587,7 @@ fn hydrate_project( "workItemPrefix": prefix, "createdAt": to_iso8601(created_at), "updatedAt": to_iso8601(updated_at), + "propertyDefinitions": property_definitions, }); Ok(CollabPushItem { @@ -505,6 +619,7 @@ fn hydrate_work_item( work_item_id: &str, entry_ids: Vec, field_paths: Vec, + property_definitions: &[crate::work_item_features::PropertyDefinition], ) -> Result { let base_version: Option = conn .query_row( @@ -521,11 +636,6 @@ fn hydrate_work_item( None => (OP_DELETE.to_string(), None), Some(data) if data.frontmatter.deleted_at.is_some() => (OP_DELETE.to_string(), None), Some(data) => { - // Per-field revision times ride the wire so the puller can merge - // per field instead of against our whole-row updatedAt (which - // would revert a teammate's edit to any field we didn't change). - // Only project-scoped items carry them; standalone items use - // whole-row semantics on both ends. let project_slug: Option = conn .query_row( "SELECT p.slug FROM workitems w @@ -535,7 +645,27 @@ fn hydrate_work_item( |row| row.get(0), ) .optional() - .map_err(|err| format!("DB error (work item slug): {}", err))?; + .map_err(|err| format!("DB error (work item slug): {err}"))?; + // Project-scoped definitions ride on project rows, which the + // puller applies first. Standalone items have no project carrier, + // so their full snapshot includes the org definitions. + let definitions = if project_slug.is_none() { + property_definitions.to_vec() + } else { + Vec::new() + }; + let property_snapshot = + crate::work_item_features::properties::export_work_item_snapshot( + conn, + org_id, + work_item_id, + definitions, + )?; + // Per-field revision times ride the wire so the puller can merge + // per field instead of against our whole-row updatedAt (which + // would revert a teammate's edit to any field we didn't change). + // Only project-scoped items carry them; standalone items use + // whole-row semantics on both ends. let field_revisions = match &project_slug { Some(slug) => read_sync_metadata(slug, &data.frontmatter.short_id)? .map(|m| m.field_revisions) @@ -548,6 +678,7 @@ fn hydrate_work_item( &data.frontmatter, &data.body, &field_revisions, + &property_snapshot, )), ) } @@ -573,6 +704,7 @@ fn work_item_wire( frontmatter: &WorkItemFrontmatter, body: &str, field_revisions: &std::collections::HashMap, + property_snapshot: &crate::work_item_features::TypedPropertyWireSnapshot, ) -> Value { fn to_value(value: &T) -> Value { serde_json::to_value(value).unwrap_or(Value::Null) @@ -615,6 +747,8 @@ fn work_item_wire( "executionLock": to_value(&frontmatter.execution_lock), "closeOut": to_value(&frontmatter.close_out), "workProducts": to_value(&frontmatter.work_products), + "propertyDefinitions": to_value(&property_snapshot.definitions), + "propertyValues": to_value(&property_snapshot.values), }) } diff --git a/src-tauri/crates/project-management/src/sync/collab_bridge/tests.rs b/src-tauri/crates/project-management/src/sync/collab_bridge/tests.rs index 6e9f8f41d4..1d3d84894c 100644 --- a/src-tauri/crates/project-management/src/sync/collab_bridge/tests.rs +++ b/src-tauri/crates/project-management/src/sync/collab_bridge/tests.rs @@ -16,6 +16,10 @@ use crate::projects::types::{ }; use crate::sync::io; use crate::sync::types::OutboxStatus; +use crate::work_item_features::{ + PropertyConfig, PropertyType, SetWorkItemPropertyValueRequest, UpsertPropertyDefinitionRequest, + WorkItemScope, +}; use rusqlite::params; use serde_json::{json, Value}; use test_helpers::test_env; @@ -345,6 +349,18 @@ fn apply_remote_creates_entities_without_echo() { "workItemPrefix": "REM", "description": "from teammate", "updatedAt": "2026-07-01T00:00:00Z", + "propertyDefinitions": [{ + "id": "prop_remote_effort", + "orgId": ORG, + "name": "Remote effort", + "propertyType": "number", + "description": null, + "config": { "options": [] }, + "position": 0, + "archivedAt": null, + "createdAt": "2026-07-01T00:00:00Z", + "updatedAt": "2026-07-01T00:00:00Z" + }], }), version: 3, updated_by: Some("member-b".to_string()), @@ -362,6 +378,12 @@ fn apply_remote_creates_entities_without_echo() { "priority": "none", "labels": [], "updatedAt": "2026-07-01T00:00:00Z", + "propertyDefinitions": [], + "propertyValues": [{ + "propertyId": "prop_remote_effort", + "value": 5, + "updatedAt": "2026-07-01T00:00:00Z" + }], }), version: 2, updated_by: Some("member-b".to_string()), @@ -379,6 +401,15 @@ fn apply_remote_creates_entities_without_echo() { let item = read_work_item("remote-project", "REM-0001").expect("item exists"); assert_eq!(item.frontmatter.title, "Remote item"); assert_eq!(item.body, "remote body"); + let values = crate::work_item_features::properties::list_values(&WorkItemScope { + project_slug: Some("remote-project".to_string()), + org_id: ORG.to_string(), + work_item_id: "REM-0001".to_string(), + }) + .expect("remote typed property exists"); + assert_eq!(values.len(), 1); + assert_eq!(values[0].definition.name, "Remote effort"); + assert_eq!(values[0].value, json!(5)); // No echo: remote application must not enqueue bridge rows. assert_eq!(pending_org_rows(), 0, "apply_remote echoed into the outbox"); @@ -440,6 +471,7 @@ fn standalone_pending_update_rebases_and_merges_remote_tail_without_conflict_loo content: "local pending comment".to_string(), created_at: "2026-07-29T01:00:00Z".to_string(), mentioned_user_ids: vec![], + ..Default::default() }; update_standalone_work_item_partial( Some(ORG), @@ -459,6 +491,7 @@ fn standalone_pending_update_rebases_and_merges_remote_tail_without_conflict_loo content: "remote teammate comment".to_string(), created_at: "2026-07-29T01:00:01Z".to_string(), mentioned_user_ids: vec!["member-a".to_string()], + ..Default::default() }; let applied = apply_remote( ORG, @@ -865,6 +898,7 @@ fn pending_local_push_blocks_remote_tail_clobber_and_unions_lists() { content: "local pending comment".to_string(), created_at: "2026-07-01T00:01:00Z".to_string(), mentioned_user_ids: Vec::new(), + ..Default::default() }]); update_work_item_partial("remote-project", "REM-0001", &update).expect("local comment"); assert!(pending_org_rows() >= 1, "local comment should be pending"); @@ -1418,6 +1452,166 @@ fn drain_project_carries_per_field_revisions() { ); } +#[test] +fn typed_properties_round_trip_and_preserve_pending_local_value() { + let _sandbox = test_env::sandbox(); + seed_collab_org(); + seed_project("alpha"); + write_work_item( + "alpha", + "AAA-0001", + &work_item_frontmatter("AAA-0001", "Typed properties"), + "", + ) + .expect("write item"); + let scope = WorkItemScope { + project_slug: Some("alpha".to_string()), + org_id: ORG.to_string(), + work_item_id: "AAA-0001".to_string(), + }; + crate::work_item_features::properties::upsert_definition(UpsertPropertyDefinitionRequest { + id: Some("prop_effort".to_string()), + org_id: ORG.to_string(), + name: "Effort".to_string(), + property_type: PropertyType::Number, + description: None, + config: PropertyConfig::default(), + position: 0, + }) + .expect("create property"); + crate::work_item_features::properties::set_value(SetWorkItemPropertyValueRequest { + scope: scope.clone(), + property_id: "prop_effort".to_string(), + value: Some(json!(8)), + }) + .expect("set value"); + + let pushed = drain_outbox(ORG, 50).expect("drain typed property snapshot"); + let work_item = pushed + .iter() + .find(|item| item.kind == KIND_WORK_ITEM) + .expect("work item push"); + let project = pushed + .iter() + .find(|item| item.kind == KIND_PROJECT) + .expect("project definition carrier"); + let mut remote_payload = work_item.payload.clone().expect("work item payload"); + assert_eq!( + project.payload.as_ref().unwrap()["propertyDefinitions"][0]["id"], + "prop_effort" + ); + assert_eq!(remote_payload["propertyDefinitions"], json!([])); + assert_eq!(remote_payload["propertyValues"][0]["value"], json!(8)); + ack_outbox( + pushed + .iter() + .map(|item| CollabAckResult { + entry_ids: item.entry_ids.clone(), + kind: item.kind.clone(), + entity_id: item.entity_id.clone(), + ok: true, + remote_version: Some(1), + error: None, + }) + .collect(), + ) + .expect("ack initial snapshot"); + + remote_payload["propertyValues"][0]["value"] = json!(13); + remote_payload["propertyValues"][0]["updatedAt"] = json!("2099-01-01T00:00:00Z"); + assert_eq!( + apply_remote( + ORG, + None, + vec![CollabRemoteEntity { + kind: KIND_WORK_ITEM.to_string(), + payload: remote_payload.clone(), + version: 2, + updated_by: Some("member-b".to_string()), + deleted_at: None, + }], + ) + .expect("apply remote value"), + 1 + ); + let values = crate::work_item_features::properties::list_values(&scope) + .expect("list remote property value"); + assert_eq!(values[0].value, json!(13)); + + crate::work_item_features::properties::set_value(SetWorkItemPropertyValueRequest { + scope: scope.clone(), + property_id: "prop_effort".to_string(), + value: Some(json!(21)), + }) + .expect("set pending local value"); + remote_payload["propertyValues"][0]["value"] = json!(7); + remote_payload["propertyValues"][0]["updatedAt"] = json!("2100-01-01T00:00:00Z"); + apply_remote( + ORG, + None, + vec![CollabRemoteEntity { + kind: KIND_WORK_ITEM.to_string(), + payload: remote_payload.clone(), + version: 3, + updated_by: Some("member-b".to_string()), + deleted_at: None, + }], + ) + .expect("apply conflicting remote value"); + let values = crate::work_item_features::properties::list_values(&scope) + .expect("list protected property value"); + assert_eq!( + values[0].value, + json!(21), + "a pending local edit wins the OCC rebase for the same property" + ); + let retry = drain_outbox(ORG, 50).expect("drain rebased property value"); + let retry_item = retry + .iter() + .find(|item| item.kind == KIND_WORK_ITEM) + .expect("rebased work item"); + assert_eq!(retry_item.base_version, Some(3)); + assert_eq!( + retry_item.payload.as_ref().unwrap()["propertyValues"][0]["value"], + json!(21) + ); + ack_outbox( + retry + .iter() + .map(|item| CollabAckResult { + entry_ids: item.entry_ids.clone(), + kind: item.kind.clone(), + entity_id: item.entity_id.clone(), + ok: true, + remote_version: Some(4), + error: None, + }) + .collect(), + ) + .expect("ack rebased value"); + + remote_payload["propertyValues"][0]["value"] = Value::Null; + remote_payload["propertyValues"][0]["updatedAt"] = json!("2101-01-01T00:00:00Z"); + apply_remote( + ORG, + None, + vec![CollabRemoteEntity { + kind: KIND_WORK_ITEM.to_string(), + payload: remote_payload, + version: 5, + updated_by: Some("member-b".to_string()), + deleted_at: None, + }], + ) + .expect("apply clear tombstone"); + assert!( + crate::work_item_features::properties::list_values(&scope) + .expect("list after clear") + .is_empty(), + "a remote null tombstone clears the visible value" + ); +} + #[test] fn apply_remote_tombstone_soft_deletes() { let _sandbox = test_env::sandbox(); diff --git a/src-tauri/crates/project-management/src/sync/webhook_listener.rs b/src-tauri/crates/project-management/src/sync/webhook_listener.rs index 6cc814af91..ed25741359 100644 --- a/src-tauri/crates/project-management/src/sync/webhook_listener.rs +++ b/src-tauri/crates/project-management/src/sync/webhook_listener.rs @@ -76,10 +76,15 @@ pub const WEBHOOK_BASE_PATH: &str = "/sync/webhook"; /// process-singleton accessed through the standard module entry /// points. pub fn router() -> Router { - Router::new().route( - "/sync/webhook/{adapter_id}/{project_slug}", - post(handle_webhook_request), - ) + Router::new() + .route( + "/sync/webhook/{adapter_id}/{project_slug}", + post(handle_webhook_request), + ) + .route( + "/routine/webhook/{routine_name}", + post(crate::work_item_features::routine_webhook::handle_http), + ) } /// Path params for the webhook route. diff --git a/src-tauri/crates/project-management/src/team_inbox/store.rs b/src-tauri/crates/project-management/src/team_inbox/store.rs index 2e607d156d..917bfd5682 100644 --- a/src-tauri/crates/project-management/src/team_inbox/store.rs +++ b/src-tauri/crates/project-management/src/team_inbox/store.rs @@ -16,6 +16,7 @@ use crate::projects::types::{ const ASSIGNED_SOURCE_KIND: &str = "work_item_assigned"; const COMMENT_MENTION_SOURCE_KIND: &str = "work_item_comment_mention"; +const SUBSCRIPTION_SOURCE_KIND: &str = "work_item_subscription_event"; const DEFAULT_PAGE_LIMIT: usize = 50; const MAX_PAGE_LIMIT: usize = 100; const ACTIONABLE_ASSIGNMENT_PREDICATE: &str = @@ -193,6 +194,14 @@ pub(crate) fn list_page_with_connection( fetch_limit, )?); } + if options.filter == TeamInboxFilter::All { + items.extend(list_subscription_events( + connection, + &viewer_ids, + options.cursor.as_ref(), + fetch_limit, + )?); + } items.sort_by(|left, right| { right .occurred_at @@ -219,6 +228,118 @@ pub(crate) fn list_page_with_connection( }) } +fn list_subscription_events( + connection: &Connection, + viewer_ids: &[String], + cursor: Option<&TeamInboxCursor>, + limit: usize, +) -> Result, String> { + let placeholders = sql_placeholders(viewer_ids.len()); + let receipt_placeholders = sql_placeholders(viewer_ids.len()); + let item_id_expression = format!("'{SUBSCRIPTION_SOURCE_KIND}:' || event.id"); + let cursor_predicate = if cursor.is_some() { + format!( + "AND (event.occurred_at < ? OR + (event.occurred_at = ? AND {item_id_expression} < ?))" + ) + } else { + String::new() + }; + let sql = format!( + "SELECT event.id, event.kind, event.actor_id, event.payload_json, + event.occurred_at, w.id, w.org_id, w.project_id, p.slug, + CASE WHEN json_valid(p.linked_repos_json) + THEN json_extract(p.linked_repos_json, '$[0]') ELSE NULL END, + w.short_id, w.title, w.status, w.priority, event.recipient_id, + (SELECT MAX(receipt.read_at) FROM team_inbox_read_receipts receipt + WHERE receipt.source_kind = '{SUBSCRIPTION_SOURCE_KIND}' + AND receipt.source_id = event.id + AND receipt.viewer_member_id IN ({receipt_placeholders})) + FROM pm_work_item_inbox_events event + JOIN workitems w ON w.short_id = event.work_item_id + LEFT JOIN projects p ON p.id = w.project_id + WHERE event.archived_at IS NULL + AND event.kind <> 'mention' + AND event.recipient_id IN ({placeholders}) + AND w.deleted_at IS NULL + AND ((event.scope_key = 'project:' || p.slug) + OR (w.project_id IS NULL AND event.scope_key = 'org:' || w.org_id)) + {cursor_predicate} + ORDER BY event.occurred_at DESC, {item_id_expression} DESC + LIMIT ?" + ); + let mut values = viewer_ids + .iter() + .chain(viewer_ids.iter()) + .cloned() + .map(Value::from) + .collect::>(); + if let Some(cursor) = cursor { + values.push(Value::from(cursor.occurred_at)); + values.push(Value::from(cursor.occurred_at)); + values.push(Value::from(cursor.item_id.clone())); + } + values.push(Value::from(limit as i64)); + let mut statement = connection.prepare(&sql).map_err(db_error)?; + let rows = statement + .query_map(params_from_iter(values), |row| { + let event_id: String = row.get(0)?; + let event_kind: String = row.get(1)?; + let actor_id: Option = row.get(2)?; + let payload_raw: String = row.get(3)?; + let payload: serde_json::Value = serde_json::from_str(&payload_raw).unwrap_or_default(); + let title = payload + .get("title") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| row.get::<_, String>(11).unwrap_or_default()); + let summary = payload + .get("comment") + .and_then(serde_json::Value::as_str) + .and_then(work_item_summary_excerpt) + .or_else(|| { + payload + .get("failure") + .and_then(|failure| failure.get("message")) + .and_then(serde_json::Value::as_str) + .and_then(work_item_summary_excerpt) + }); + Ok(TeamInboxItem { + id: subscription_item_id(&event_id), + kind: if event_kind == "run_failed" { + TeamInboxItemKind::WorkItemRunFailed + } else { + TeamInboxItemKind::WorkItemUpdated + }, + occurred_at: row.get(4)?, + read_at: row.get(15)?, + actor: actor_id.map(|id| TeamInboxActor { + display_name: id.clone(), + id, + avatar_url: None, + }), + target: TeamInboxTarget::WorkItem { + work_item_id: row.get(5)?, + org_id: row.get(6)?, + project_id: row.get(7)?, + project_slug: row.get(8)?, + repository: row.get(9)?, + short_id: row.get(10)?, + }, + payload: TeamInboxPayload::WorkItemUpdated { + title, + event_kind, + status: row.get(12)?, + priority: row.get(13)?, + recipient_member_id: row.get(14)?, + summary, + }, + }) + }) + .map_err(db_error)?; + rows.collect::, _>>().map_err(db_error) +} + fn list_assigned_items( connection: &Connection, viewer_ids: &[String], @@ -419,7 +540,42 @@ pub(crate) fn unread_count_with_connection( } else { comment_mention_unread_count(connection, &viewer_ids)? }; - Ok(assigned_count + mention_count) + let subscription_count = if filter == TeamInboxFilter::All { + subscription_event_unread_count(connection, &viewer_ids)? + } else { + 0 + }; + Ok(assigned_count + mention_count + subscription_count) +} + +fn subscription_event_unread_count( + connection: &Connection, + viewer_ids: &[String], +) -> Result { + let placeholders = sql_placeholders(viewer_ids.len()); + let receipt_placeholders = sql_placeholders(viewer_ids.len()); + let sql = format!( + "SELECT COUNT(*) FROM pm_work_item_inbox_events event + WHERE event.archived_at IS NULL + AND event.kind <> 'mention' + AND event.recipient_id IN ({placeholders}) + AND NOT EXISTS ( + SELECT 1 FROM team_inbox_read_receipts receipt + WHERE receipt.source_kind = '{SUBSCRIPTION_SOURCE_KIND}' + AND receipt.source_id = event.id + AND receipt.viewer_member_id IN ({receipt_placeholders}) + )" + ); + let values = viewer_ids + .iter() + .chain(viewer_ids.iter()) + .cloned() + .map(Value::from) + .collect::>(); + let count: i64 = connection + .query_row(&sql, params_from_iter(values), |row| row.get(0)) + .map_err(db_error)?; + Ok(count.max(0) as u64) } fn assigned_unread_count(connection: &Connection, viewer_ids: &[String]) -> Result { @@ -524,6 +680,15 @@ pub(crate) fn mark_read_with_connection( sql, values, ) + } else if let Some(source_id) = subscription_source_id(item_id) { + let sql = format!( + "SELECT 1 FROM pm_work_item_inbox_events event + WHERE event.id = ? AND event.archived_at IS NULL + AND event.recipient_id IN ({placeholders})" + ); + let mut values = vec![Value::from(source_id.to_string())]; + values.extend(viewer_ids.iter().cloned().map(Value::from)); + (SUBSCRIPTION_SOURCE_KIND, source_id.to_string(), sql, values) } else { return Err(format!("Unsupported Team Inbox item id: {item_id}")); }; @@ -633,6 +798,36 @@ pub(crate) fn mark_all_read_with_connection( .map(|id| (COMMENT_MENTION_SOURCE_KIND, id)), ); } + if filter == TeamInboxFilter::All { + let query = format!( + "SELECT event.id FROM pm_work_item_inbox_events event + WHERE event.archived_at IS NULL + AND event.kind <> 'mention' + AND event.recipient_id IN ({placeholders}) + AND NOT EXISTS ( + SELECT 1 FROM team_inbox_read_receipts receipt + WHERE receipt.source_kind = '{SUBSCRIPTION_SOURCE_KIND}' + AND receipt.source_id = event.id + AND receipt.viewer_member_id IN ({placeholders}) + )" + ); + let values = viewer_ids + .iter() + .chain(viewer_ids.iter()) + .cloned() + .map(Value::from) + .collect::>(); + let mut statement = tx.prepare(&query).map_err(db_error)?; + let rows = statement + .query_map(params_from_iter(values), |row| row.get::<_, String>(0)) + .map_err(db_error)?; + sources.extend( + rows.collect::, _>>() + .map_err(db_error)? + .into_iter() + .map(|id| (SUBSCRIPTION_SOURCE_KIND, id)), + ); + } for (source_kind, source_id) in sources { for viewer_id in &viewer_ids { @@ -662,6 +857,8 @@ pub(crate) fn mark_unread_with_connection( (ASSIGNED_SOURCE_KIND, source_id) } else if let Some(source_id) = comment_mention_source_id(item_id) { (COMMENT_MENTION_SOURCE_KIND, source_id) + } else if let Some(source_id) = subscription_source_id(item_id) { + (SUBSCRIPTION_SOURCE_KIND, source_id) } else { return Err(format!("Unsupported Team Inbox item id: {item_id}")); }; @@ -747,6 +944,17 @@ fn comment_mention_source_id(item_id: &str) -> Option<&str> { .filter(|value| value.split_once(':').is_some()) } +fn subscription_item_id(source_id: &str) -> String { + format!("{SUBSCRIPTION_SOURCE_KIND}:{source_id}") +} + +fn subscription_source_id(item_id: &str) -> Option<&str> { + item_id + .strip_prefix(SUBSCRIPTION_SOURCE_KIND) + .and_then(|value| value.strip_prefix(':')) + .filter(|value| !value.is_empty()) +} + fn now_ms() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/src-tauri/crates/project-management/src/team_inbox/types.rs b/src-tauri/crates/project-management/src/team_inbox/types.rs index 411eb38dac..f557e6be49 100644 --- a/src-tauri/crates/project-management/src/team_inbox/types.rs +++ b/src-tauri/crates/project-management/src/team_inbox/types.rs @@ -17,6 +17,8 @@ pub enum TeamInboxFilter { pub enum TeamInboxItemKind { CommentMention, WorkItemAssigned, + WorkItemUpdated, + WorkItemRunFailed, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -90,6 +92,15 @@ pub enum TeamInboxPayload { #[serde(skip_serializing_if = "Option::is_none")] handoff: Option, }, + WorkItemUpdated { + title: String, + event_kind: String, + status: String, + priority: String, + recipient_member_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + summary: Option, + }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/src-tauri/crates/project-management/src/work_item_features/commands.rs b/src-tauri/crates/project-management/src/work_item_features/commands.rs new file mode 100644 index 0000000000..1d2db68ce6 --- /dev/null +++ b/src-tauri/crates/project-management/src/work_item_features/commands.rs @@ -0,0 +1,242 @@ +use crate::projects::types::CommentEntry; + +use super::{ + discussion, properties, readiness, routine_webhook, subscriptions, DiscussionPostRequest, + DiscussionPostResult, DiscussionThreadMutation, DiscussionTriggerPreview, + DiscussionTriggerPreviewRequest, PrReadiness, PropertyDefinition, RoutineWebhookDelivery, + RoutineWebhookInstallInfo, RoutineWebhookStatus, SetWorkItemPropertyValueRequest, + SubscriptionMutation, UpsertPropertyDefinitionRequest, WorkItemPropertyValue, WorkItemScope, + WorkItemSubscription, +}; + +#[tauri::command] +pub async fn project_discussion_preview_trigger( + request: DiscussionTriggerPreviewRequest, +) -> Result { + tokio::task::spawn_blocking(move || discussion::preview(request)) + .await + .map_err(|err| format!("Task join error: {err}"))? +} + +#[tauri::command] +pub async fn project_discussion_post_comment( + app: tauri::AppHandle, + request: DiscussionPostRequest, +) -> Result { + let result = tokio::task::spawn_blocking(move || discussion::post(request)) + .await + .map_err(|err| format!("Task join error: {err}"))?; + if result.is_ok() { + use tauri::Emitter; + let _ = app.emit( + crate::projects::events::DATA_CHANGED_EVENT, + chrono::Utc::now().to_rfc3339(), + ); + } + result +} + +#[tauri::command] +pub async fn project_discussion_resolve_thread( + app: tauri::AppHandle, + request: DiscussionThreadMutation, +) -> Result, String> { + let result = tokio::task::spawn_blocking(move || discussion::resolve_thread(request)) + .await + .map_err(|err| format!("Task join error: {err}"))?; + if result.is_ok() { + use tauri::Emitter; + let _ = app.emit( + crate::projects::events::DATA_CHANGED_EVENT, + chrono::Utc::now().to_rfc3339(), + ); + } + result +} + +#[tauri::command] +pub async fn project_discussion_reopen_thread( + app: tauri::AppHandle, + request: DiscussionThreadMutation, +) -> Result, String> { + let result = tokio::task::spawn_blocking(move || discussion::reopen_thread(request)) + .await + .map_err(|err| format!("Task join error: {err}"))?; + if result.is_ok() { + use tauri::Emitter; + let _ = app.emit( + crate::projects::events::DATA_CHANGED_EVENT, + chrono::Utc::now().to_rfc3339(), + ); + } + result +} + +#[tauri::command] +pub async fn project_subscribe_work_item( + request: SubscriptionMutation, +) -> Result, String> { + tokio::task::spawn_blocking(move || subscriptions::subscribe(request)) + .await + .map_err(|err| format!("Task join error: {err}"))? +} + +#[tauri::command] +pub async fn project_unsubscribe_work_item( + request: SubscriptionMutation, +) -> Result, String> { + tokio::task::spawn_blocking(move || subscriptions::unsubscribe(request)) + .await + .map_err(|err| format!("Task join error: {err}"))? +} + +#[tauri::command] +pub async fn project_list_work_item_subscriptions( + scope: WorkItemScope, +) -> Result, String> { + tokio::task::spawn_blocking(move || subscriptions::list(&scope)) + .await + .map_err(|err| format!("Task join error: {err}"))? +} + +#[tauri::command] +pub async fn project_get_work_item_pr_readiness( + scope: WorkItemScope, +) -> Result { + tokio::task::spawn_blocking(move || readiness::get(&scope)) + .await + .map_err(|err| format!("Task join error: {err}"))? +} + +#[tauri::command] +pub async fn project_upsert_property_definition( + app: tauri::AppHandle, + request: UpsertPropertyDefinitionRequest, +) -> Result { + let result = tokio::task::spawn_blocking(move || properties::upsert_definition(request)) + .await + .map_err(|err| format!("Task join error: {err}"))?; + if result.is_ok() { + use tauri::Emitter; + let _ = app.emit( + crate::projects::events::DATA_CHANGED_EVENT, + chrono::Utc::now().to_rfc3339(), + ); + } + result +} + +#[tauri::command] +pub async fn project_list_property_definitions( + org_id: String, + include_archived: Option, +) -> Result, String> { + tokio::task::spawn_blocking(move || { + properties::list_definitions(&org_id, include_archived.unwrap_or(false)) + }) + .await + .map_err(|err| format!("Task join error: {err}"))? +} + +#[tauri::command] +pub async fn project_archive_property_definition( + app: tauri::AppHandle, + property_id: String, +) -> Result { + let result = tokio::task::spawn_blocking(move || properties::archive_definition(&property_id)) + .await + .map_err(|err| format!("Task join error: {err}"))?; + if result.is_ok() { + use tauri::Emitter; + let _ = app.emit( + crate::projects::events::DATA_CHANGED_EVENT, + chrono::Utc::now().to_rfc3339(), + ); + } + result +} + +#[tauri::command] +pub async fn project_set_work_item_property_value( + app: tauri::AppHandle, + request: SetWorkItemPropertyValueRequest, +) -> Result, String> { + let result = tokio::task::spawn_blocking(move || properties::set_value(request)) + .await + .map_err(|err| format!("Task join error: {err}"))?; + if result.is_ok() { + use tauri::Emitter; + let _ = app.emit( + crate::projects::events::DATA_CHANGED_EVENT, + chrono::Utc::now().to_rfc3339(), + ); + } + result +} + +#[tauri::command] +pub async fn project_list_work_item_property_values( + scope: WorkItemScope, +) -> Result, String> { + tokio::task::spawn_blocking(move || properties::list_values(&scope)) + .await + .map_err(|err| format!("Task join error: {err}"))? +} + +#[tauri::command] +pub async fn project_routine_webhook_install( + routine_name: String, +) -> Result { + tokio::task::spawn_blocking(move || routine_webhook::install(&routine_name)) + .await + .map_err(|err| format!("Task join error: {err}"))? +} + +#[tauri::command] +pub async fn project_routine_webhook_rotate( + routine_name: String, +) -> Result { + tokio::task::spawn_blocking(move || routine_webhook::install(&routine_name)) + .await + .map_err(|err| format!("Task join error: {err}"))? +} + +#[tauri::command] +pub async fn project_routine_webhook_status( + routine_name: String, +) -> Result { + tokio::task::spawn_blocking(move || routine_webhook::status(&routine_name)) + .await + .map_err(|err| format!("Task join error: {err}"))? +} + +#[tauri::command] +pub async fn project_routine_webhook_set_enabled( + routine_name: String, + enabled: bool, +) -> Result { + tokio::task::spawn_blocking(move || routine_webhook::set_enabled(&routine_name, enabled)) + .await + .map_err(|err| format!("Task join error: {err}"))? +} + +#[tauri::command] +pub async fn project_routine_webhook_list_deliveries( + routine_name: String, + limit: Option, +) -> Result, String> { + tokio::task::spawn_blocking(move || { + routine_webhook::list_deliveries(&routine_name, limit.unwrap_or(50)) + }) + .await + .map_err(|err| format!("Task join error: {err}"))? +} + +#[tauri::command] +pub async fn project_routine_webhook_replay( + delivery_id: String, +) -> Result { + tokio::task::spawn_blocking(move || routine_webhook::replay(&delivery_id)) + .await + .map_err(|err| format!("Task join error: {err}"))? +} diff --git a/src-tauri/crates/project-management/src/work_item_features/discussion.rs b/src-tauri/crates/project-management/src/work_item_features/discussion.rs new file mode 100644 index 0000000000..8c773a41c6 --- /dev/null +++ b/src-tauri/crates/project-management/src/work_item_features/discussion.rs @@ -0,0 +1,385 @@ +use rusqlite::{params, TransactionBehavior}; + +use super::store::{append_audit, persist_extras, resolve_work_item}; +use super::subscriptions; +use super::{ + DiscussionPostRequest, DiscussionPostResult, DiscussionThreadMutation, + DiscussionTriggerPreview, DiscussionTriggerPreviewRequest, +}; +use crate::projects::io::helpers::{conn, now_ms}; +use crate::projects::types::{ + CommentEntry, EnqueueWorkItemRunRequest, LinkedSession, WorkItemRunTarget, + WorkItemRunTargetSnapshot, WorkItemRunTrigger, +}; + +fn is_note_only(content: &str) -> bool { + let trimmed = content.trim_start(); + trimmed == "/note" || trimmed.starts_with("/note ") || trimmed.starts_with("/note\n") +} + +fn latest_top_level_session(extras: &serde_json::Value) -> Option { + let mut sessions = extras + .get("linked_sessions") + .cloned() + .and_then(|value| serde_json::from_value::>(value).ok()) + .unwrap_or_default() + .into_iter() + .filter(|session| session.parent_session_id.is_none()) + .collect::>(); + sessions.sort_by(|left, right| right.started_at.cmp(&left.started_at)); + sessions.first().map(|session| session.session_id.clone()) +} + +fn preview_for( + content: &str, + explicit_target: Option<&str>, + extras: &serde_json::Value, +) -> DiscussionTriggerPreview { + let target_session_id = explicit_target + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or_else(|| latest_top_level_session(extras)); + if is_note_only(content) { + return DiscussionTriggerPreview { + will_wake: false, + reason: "note_only".to_string(), + target_session_id, + }; + } + if target_session_id.is_none() { + return DiscussionTriggerPreview { + will_wake: false, + reason: "no_linked_session".to_string(), + target_session_id, + }; + } + DiscussionTriggerPreview { + will_wake: true, + reason: "discussion_reply".to_string(), + target_session_id, + } +} + +pub(super) fn preview( + request: DiscussionTriggerPreviewRequest, +) -> Result { + let connection = conn()?; + let item = resolve_work_item(&connection, &request.scope)?; + Ok(preview_for( + &request.content, + request.target_session_id.as_deref(), + &item.extras, + )) +} + +fn comments_from_extras(extras: &serde_json::Value) -> Vec { + extras + .get("comments") + .cloned() + .and_then(|value| serde_json::from_value(value).ok()) + .unwrap_or_default() +} + +fn store_comments(extras: &mut serde_json::Value, comments: &[CommentEntry]) -> Result<(), String> { + let object = extras + .as_object_mut() + .ok_or_else(|| "work item extras must be a JSON object".to_string())?; + object.insert( + "comments".to_string(), + serde_json::to_value(comments).map_err(|err| format!("Discussion serialization: {err}"))?, + ); + Ok(()) +} + +fn build_forward_message(short_id: &str, comment_id: &str, author: &str, content: &str) -> String { + [ + format!("[Work Item Discussion] {author} commented on {short_id}:"), + String::new(), + content.to_string(), + String::new(), + "This is a Reply turn. Answer on the Discussion with exactly one receipt:".to_string(), + format!( + " org2-pm work note {short_id} --kind comment --parent-id {comment_id} --body \"\"" + ), + "(use --body-file for multi-line or shell-sensitive replies)".to_string(), + "Do not change status or edit fields unless the comment explicitly asks for it." + .to_string(), + ] + .join("\n") +} + +pub(super) fn post(request: DiscussionPostRequest) -> Result { + if request.comment_id.trim().is_empty() + || request.author_id.trim().is_empty() + || request.content.trim().is_empty() + { + return Err("commentId, authorId, and content are required".to_string()); + } + let mut connection = conn()?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|err| format!("Discussion tx: {err}"))?; + let item = resolve_work_item(&tx, &request.scope)?; + let mut extras = item.extras.clone(); + let mut comments = comments_from_extras(&extras); + + if let Some(existing) = comments + .iter() + .find(|comment| comment.id == request.comment_id) + { + if existing.author != request.author_id || existing.content != request.content.trim() { + return Err(format!( + "PM_ERR:IDEMPOTENCY_CONFLICT:discussion:{}", + request.comment_id + )); + } + let preview = preview_for( + &request.content, + request.target_session_id.as_deref(), + &extras, + ); + let run = tx + .query_row( + "SELECT id FROM pm_work_item_runs + WHERE scope_key = ?1 AND work_item_id = ?2 + AND idempotency_key = ?3", + params![ + item.scope_key, + item.short_id, + format!("discussion-comment:{}", request.comment_id) + ], + |row| row.get::<_, String>(0), + ) + .ok() + .and_then(|run_id| crate::work_run_service::read(&run_id).ok()); + let result = DiscussionPostResult { + comment: existing.clone(), + run, + thread_reopened: false, + wake_reason: preview.reason, + }; + tx.commit() + .map_err(|err| format!("Discussion commit: {err}"))?; + return Ok(result); + } + + let parent = request + .parent_id + .as_deref() + .map(|parent_id| { + comments + .iter() + .find(|comment| comment.id == parent_id) + .cloned() + .ok_or_else(|| format!("Discussion parent '{parent_id}' not found")) + }) + .transpose()?; + let thread_id = parent + .as_ref() + .and_then(|comment| comment.thread_id.clone()) + .or_else(|| parent.as_ref().map(|comment| comment.id.clone())) + .unwrap_or_else(|| request.comment_id.clone()); + let mut thread_reopened = false; + if parent.is_some() { + if let Some(root) = comments.iter_mut().find(|comment| comment.id == thread_id) { + if root.resolved_at.take().is_some() { + thread_reopened = true; + } + root.resolved_by = None; + } + if thread_reopened { + for existing in comments + .iter_mut() + .filter(|comment| comment.thread_id.as_deref() == Some(&thread_id)) + { + existing.conclusion = false; + } + } + } + + let preview = preview_for( + &request.content, + request.target_session_id.as_deref(), + &extras, + ); + let now = now_ms(); + let comment = CommentEntry { + id: request.comment_id.clone(), + author: request.author_id.clone(), + content: request.content.trim().to_string(), + created_at: super::store::iso8601(now), + mentioned_user_ids: request.mentioned_user_ids.clone(), + parent_id: request.parent_id.clone(), + thread_id: Some(thread_id.clone()), + resolved_at: None, + resolved_by: None, + conclusion: false, + agent_session_id: preview.target_session_id.clone(), + }; + comments.push(comment.clone()); + store_comments(&mut extras, &comments)?; + let revision = persist_extras(&tx, &item, &extras, now)?; + + subscriptions::notify_comment( + &tx, + subscriptions::CommentNotification { + scope_key: &item.scope_key, + work_item_id: &item.short_id, + title: &item.title, + comment_id: &comment.id, + author_id: &request.author_id, + content: &comment.content, + mentioned_user_ids: &comment.mentioned_user_ids, + now, + }, + )?; + + let run = if preview.will_wake { + let target_session_id = preview + .target_session_id + .clone() + .expect("wake preview has a session"); + Some(crate::work_run_service::enqueue_in_transaction( + &tx, + EnqueueWorkItemRunRequest { + project_slug: item.project_slug.clone(), + org_id: item.org_id.clone(), + work_item_id: item.short_id.clone(), + trigger: WorkItemRunTrigger::DiscussionComment { + comment_id: comment.id.clone(), + author_id: Some(request.author_id.clone()), + }, + target_snapshot: WorkItemRunTargetSnapshot::new(WorkItemRunTarget::ResumeSession { + session_id: target_session_id, + }), + input: serde_json::json!({ + "content": build_forward_message( + &item.short_id, + &comment.id, + &request.author_name, + &comment.content, + ), + "displayText": format!("💬 {}", comment.content), + "discussionThreadId": thread_id, + "discussionCommentId": comment.id, + }), + idempotency_key: format!("discussion-comment:{}", comment.id), + max_attempts: 3, + parent_run_id: None, + }, + 0, + )?) + } else { + None + }; + + append_audit( + &tx, + &item, + "work.discussion_comment", + revision, + Some(&request.author_id), + serde_json::json!({ + "commentId": comment.id, + "parentId": comment.parent_id, + "threadId": thread_id, + "mentionedUserIds": comment.mentioned_user_ids, + "wakeReason": preview.reason, + "runId": run.as_ref().map(|value| value.id.as_str()), + "threadReopened": thread_reopened, + }), + )?; + crate::sync::collab_bridge::record_work_item_payload_touch_in_connection( + &tx, + &item.org_id, + item.project_slug.as_deref(), + &item.row_id, + "comments", + )?; + tx.commit() + .map_err(|err| format!("Discussion commit: {err}"))?; + Ok(DiscussionPostResult { + comment, + run, + thread_reopened, + wake_reason: preview.reason, + }) +} + +fn mutate_thread( + request: DiscussionThreadMutation, + resolved: bool, +) -> Result, String> { + let mut connection = conn()?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|err| format!("Discussion tx: {err}"))?; + let item = resolve_work_item(&tx, &request.scope)?; + let mut extras = item.extras.clone(); + let mut comments = comments_from_extras(&extras); + let root = comments + .iter_mut() + .find(|comment| comment.id == request.thread_id) + .ok_or_else(|| format!("Discussion thread '{}' not found", request.thread_id))?; + let now = now_ms(); + root.resolved_at = resolved.then(|| super::store::iso8601(now)); + root.resolved_by = resolved.then(|| request.actor_id.clone()); + if !resolved { + for comment in comments + .iter_mut() + .filter(|comment| comment.thread_id.as_deref() == Some(&request.thread_id)) + { + comment.conclusion = false; + } + } + if let Some(conclusion_id) = request.conclusion_comment_id.as_deref() { + let conclusion = comments + .iter_mut() + .find(|comment| { + comment.id == conclusion_id + && comment.thread_id.as_deref() == Some(&request.thread_id) + }) + .ok_or_else(|| format!("Conclusion comment '{conclusion_id}' is not in this thread"))?; + conclusion.conclusion = resolved; + } + store_comments(&mut extras, &comments)?; + let revision = persist_extras(&tx, &item, &extras, now)?; + append_audit( + &tx, + &item, + if resolved { + "work.discussion_resolve" + } else { + "work.discussion_reopen" + }, + revision, + Some(&request.actor_id), + serde_json::json!({ + "threadId": request.thread_id, + "conclusionCommentId": request.conclusion_comment_id, + }), + )?; + crate::sync::collab_bridge::record_work_item_payload_touch_in_connection( + &tx, + &item.org_id, + item.project_slug.as_deref(), + &item.row_id, + "comments", + )?; + tx.commit() + .map_err(|err| format!("Discussion commit: {err}"))?; + Ok(comments) +} + +pub(super) fn resolve_thread( + request: DiscussionThreadMutation, +) -> Result, String> { + mutate_thread(request, true) +} + +pub(super) fn reopen_thread( + request: DiscussionThreadMutation, +) -> Result, String> { + mutate_thread(request, false) +} diff --git a/src-tauri/crates/project-management/src/work_item_features/mod.rs b/src-tauri/crates/project-management/src/work_item_features/mod.rs new file mode 100644 index 0000000000..16a50b75d8 --- /dev/null +++ b/src-tauri/crates/project-management/src/work_item_features/mod.rs @@ -0,0 +1,20 @@ +//! Durable collaboration and metadata capabilities attached to Work Items. +//! +//! The module keeps Discussion, subscriptions, PR readiness, provider-event +//! delivery, and typed properties behind project-management persistence +//! boundaries instead of letting individual UI surfaces invent state. + +mod commands; +mod discussion; +pub(crate) mod properties; +pub(crate) mod readiness; +pub mod routine_webhook; +mod store; +pub(crate) mod subscriptions; +mod types; + +pub use commands::*; +pub use types::*; + +#[cfg(test)] +mod tests; diff --git a/src-tauri/crates/project-management/src/work_item_features/properties.rs b/src-tauri/crates/project-management/src/work_item_features/properties.rs new file mode 100644 index 0000000000..48e8ab3a4e --- /dev/null +++ b/src-tauri/crates/project-management/src/work_item_features/properties.rs @@ -0,0 +1,684 @@ +use std::collections::BTreeSet; + +use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior}; + +use super::store::{append_audit, iso8601, resolve_work_item}; +use super::{ + PropertyDefinition, PropertyType, SetWorkItemPropertyValueRequest, SyncedWorkItemPropertyValue, + TypedPropertyWireSnapshot, UpsertPropertyDefinitionRequest, WorkItemPropertyValue, + WorkItemScope, +}; +use crate::projects::io::helpers::{conn, now_ms}; + +const MAX_PROPERTY_NAME_CHARS: usize = 80; +const MAX_TEXT_CHARS: usize = 20_000; + +fn decode_definition(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let property_type: String = row.get(3)?; + let config_json: String = row.get(5)?; + Ok(PropertyDefinition { + id: row.get(0)?, + org_id: row.get(1)?, + name: row.get(2)?, + property_type: PropertyType::try_from(property_type.as_str()).map_err(|err| { + rusqlite::Error::FromSqlConversionFailure( + 3, + rusqlite::types::Type::Text, + std::io::Error::new(std::io::ErrorKind::InvalidData, err).into(), + ) + })?, + description: row.get(4)?, + config: serde_json::from_str(&config_json).unwrap_or_default(), + position: row.get(6)?, + archived_at: row.get::<_, Option>(7)?.map(iso8601), + created_at: iso8601(row.get(8)?), + updated_at: iso8601(row.get(9)?), + }) +} + +fn read_definition( + connection: &Connection, + property_id: &str, +) -> Result { + connection + .query_row( + "SELECT id, org_id, name, property_type, description, config_json, + position, archived_at, created_at, updated_at + FROM pm_property_definitions WHERE id = ?1", + params![property_id], + decode_definition, + ) + .optional() + .map_err(|err| format!("typed property store: {err}"))? + .ok_or_else(|| format!("Property definition '{property_id}' not found")) +} + +fn validate_definition(request: &UpsertPropertyDefinitionRequest) -> Result<(), String> { + let name = request.name.trim(); + if name.is_empty() || name.chars().count() > MAX_PROPERTY_NAME_CHARS { + return Err(format!( + "Property name must contain 1-{MAX_PROPERTY_NAME_CHARS} characters" + )); + } + if matches!( + request.property_type, + PropertyType::Select | PropertyType::MultiSelect + ) { + if request.config.options.is_empty() { + return Err("Select properties require at least one option".to_string()); + } + let mut ids = BTreeSet::new(); + for option in &request.config.options { + if option.id.trim().is_empty() || option.name.trim().is_empty() { + return Err("Property option id and name are required".to_string()); + } + if !ids.insert(option.id.trim()) { + return Err(format!("Duplicate property option id '{}'", option.id)); + } + } + } else if !request.config.options.is_empty() { + return Err("Only select properties may define options".to_string()); + } + Ok(()) +} + +pub(crate) fn upsert_definition( + request: UpsertPropertyDefinitionRequest, +) -> Result { + validate_definition(&request)?; + if request.org_id.trim().is_empty() { + return Err("orgId is required".to_string()); + } + let mut connection = conn()?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|err| format!("typed property tx: {err}"))?; + let id = request + .id + .clone() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| format!("prop_{}", uuid::Uuid::new_v4().simple())); + let existing_type: Option = tx + .query_row( + "SELECT property_type FROM pm_property_definitions WHERE id = ?1", + params![id], + |row| row.get(0), + ) + .optional() + .map_err(|err| format!("typed property store: {err}"))?; + if existing_type + .as_deref() + .is_some_and(|stored| stored != request.property_type.as_str()) + { + let value_count: i64 = tx + .query_row( + "SELECT COUNT(*) FROM pm_work_item_property_values WHERE property_id = ?1", + params![id], + |row| row.get(0), + ) + .map_err(|err| format!("typed property store: {err}"))?; + if value_count > 0 { + return Err( + "A property type cannot change after Work Items have values; archive it and create a new property" + .to_string(), + ); + } + } + let config_json = serde_json::to_string(&request.config) + .map_err(|err| format!("typed property config serialization: {err}"))?; + let now = now_ms(); + tx.execute( + "INSERT INTO pm_property_definitions ( + id, org_id, name, property_type, description, config_json, + position, archived_at, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8, ?8) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + config_json = excluded.config_json, + position = excluded.position, + archived_at = NULL, + updated_at = excluded.updated_at", + params![ + id, + request.org_id, + request.name.trim(), + request.property_type.as_str(), + request.description, + config_json, + request.position, + now + ], + ) + .map_err(|err| format!("typed property store: {err}"))?; + crate::sync::collab_bridge::record_property_definitions_touch(&tx, &request.org_id, &id)?; + tx.commit() + .map_err(|err| format!("typed property commit: {err}"))?; + let connection = conn()?; + read_definition(&connection, &id) +} + +pub(crate) fn list_definitions( + org_id: &str, + include_archived: bool, +) -> Result, String> { + let connection = conn()?; + let archived_predicate = if include_archived { + "" + } else { + "AND archived_at IS NULL" + }; + let sql = format!( + "SELECT id, org_id, name, property_type, description, config_json, + position, archived_at, created_at, updated_at + FROM pm_property_definitions + WHERE org_id = ?1 {archived_predicate} + ORDER BY position ASC, created_at ASC, id ASC" + ); + let mut statement = connection + .prepare(&sql) + .map_err(|err| format!("typed property store: {err}"))?; + let definitions = statement + .query_map(params![org_id], decode_definition) + .map_err(|err| format!("typed property store: {err}"))? + .collect::, _>>() + .map_err(|err| format!("typed property store: {err}"))?; + Ok(definitions) +} + +pub(crate) fn archive_definition(property_id: &str) -> Result { + let mut connection = conn()?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|err| format!("typed property tx: {err}"))?; + let now = now_ms(); + let changed = tx + .execute( + "UPDATE pm_property_definitions + SET archived_at = COALESCE(archived_at, ?2), updated_at = ?2 + WHERE id = ?1", + params![property_id, now], + ) + .map_err(|err| format!("typed property store: {err}"))?; + if changed != 1 { + return Err(format!("Property definition '{property_id}' not found")); + } + let definition = read_definition(&tx, property_id)?; + crate::sync::collab_bridge::record_property_definitions_touch( + &tx, + &definition.org_id, + property_id, + )?; + tx.commit() + .map_err(|err| format!("typed property commit: {err}"))?; + Ok(definition) +} + +fn validate_value( + definition: &PropertyDefinition, + value: &serde_json::Value, +) -> Result<(), String> { + let invalid = + |expected: &str| Err(format!("Property '{}' expects {expected}", definition.name)); + match definition.property_type { + PropertyType::Text => { + let Some(text) = value.as_str() else { + return invalid("text"); + }; + if text.chars().count() > MAX_TEXT_CHARS { + return Err(format!( + "Text properties are limited to {MAX_TEXT_CHARS} characters" + )); + } + } + PropertyType::Number => { + let Some(number) = value.as_f64() else { + return invalid("a number"); + }; + if !number.is_finite() { + return invalid("a finite number"); + } + } + PropertyType::Select => { + let Some(option_id) = value.as_str() else { + return invalid("one option id"); + }; + if !definition + .config + .options + .iter() + .any(|option| option.id == option_id) + { + return Err(format!( + "Unknown option '{option_id}' for '{}'", + definition.name + )); + } + } + PropertyType::MultiSelect => { + let Some(values) = value.as_array() else { + return invalid("an array of option ids"); + }; + let allowed = definition + .config + .options + .iter() + .map(|option| option.id.as_str()) + .collect::>(); + let mut seen = BTreeSet::new(); + for item in values { + let Some(option_id) = item.as_str() else { + return invalid("an array of option ids"); + }; + if !allowed.contains(option_id) { + return Err(format!( + "Unknown option '{option_id}' for '{}'", + definition.name + )); + } + if !seen.insert(option_id) { + return Err(format!( + "Duplicate option '{option_id}' for '{}'", + definition.name + )); + } + } + } + PropertyType::Date => { + let Some(date) = value.as_str() else { + return invalid("an ISO date"); + }; + if chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d").is_err() + && chrono::DateTime::parse_from_rfc3339(date).is_err() + { + return invalid("an ISO date or timestamp"); + } + } + PropertyType::Checkbox => { + if !value.is_boolean() { + return invalid("true or false"); + } + } + PropertyType::Url => { + let Some(raw) = value.as_str() else { + return invalid("an http(s) URL"); + }; + let url = reqwest::Url::parse(raw) + .map_err(|_| format!("Property '{}' expects an http(s) URL", definition.name))?; + if !matches!(url.scheme(), "http" | "https") { + return invalid("an http(s) URL"); + } + } + } + Ok(()) +} + +pub(crate) fn set_value( + request: SetWorkItemPropertyValueRequest, +) -> Result, String> { + let mut connection = conn()?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|err| format!("typed property tx: {err}"))?; + let item = resolve_work_item(&tx, &request.scope)?; + let definition = read_definition(&tx, &request.property_id)?; + if definition.org_id != item.org_id { + return Err("Property definition belongs to another organization".to_string()); + } + if definition.archived_at.is_some() { + return Err("Archived properties are read-only".to_string()); + } + let now = now_ms(); + if let Some(value) = request.value.as_ref() { + validate_value(&definition, value)?; + let raw = serde_json::to_string(value) + .map_err(|err| format!("typed property value serialization: {err}"))?; + tx.execute( + "INSERT INTO pm_work_item_property_values ( + property_id, scope_key, work_item_id, value_json, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(property_id, scope_key, work_item_id) DO UPDATE SET + value_json = excluded.value_json, + updated_at = excluded.updated_at", + params![request.property_id, item.scope_key, item.short_id, raw, now], + ) + .map_err(|err| format!("typed property store: {err}"))?; + } else { + // Keep a null tombstone instead of deleting the row. The collaboration + // payload must distinguish "cleared here" from "an older peer omitted + // this property" so a clear cannot be resurrected on another device. + tx.execute( + "INSERT INTO pm_work_item_property_values ( + property_id, scope_key, work_item_id, value_json, updated_at + ) VALUES (?1, ?2, ?3, 'null', ?4) + ON CONFLICT(property_id, scope_key, work_item_id) DO UPDATE SET + value_json = 'null', updated_at = excluded.updated_at", + params![request.property_id, item.scope_key, item.short_id, now], + ) + .map_err(|err| format!("typed property store: {err}"))?; + } + crate::sync::collab_bridge::record_work_item_payload_touch_in_connection( + &tx, + &item.org_id, + item.project_slug.as_deref(), + &item.row_id, + &format!("propertyValues.{}", request.property_id), + )?; + append_audit( + &tx, + &item, + if request.value.is_some() { + "work.property_set" + } else { + "work.property_clear" + }, + item.revision, + None, + serde_json::json!({ + "propertyId": request.property_id, + "propertyName": definition.name, + "value": request.value, + }), + )?; + tx.commit() + .map_err(|err| format!("typed property commit: {err}"))?; + Ok(request.value.map(|value| WorkItemPropertyValue { + definition, + value, + updated_at: iso8601(now), + })) +} + +pub(crate) fn list_values(scope: &WorkItemScope) -> Result, String> { + let connection = conn()?; + let item = resolve_work_item(&connection, scope)?; + let mut statement = connection + .prepare( + "SELECT d.id, d.org_id, d.name, d.property_type, d.description, + d.config_json, d.position, d.archived_at, d.created_at, + d.updated_at, v.value_json, v.updated_at + FROM pm_work_item_property_values v + JOIN pm_property_definitions d ON d.id = v.property_id + WHERE v.scope_key = ?1 AND v.work_item_id = ?2 + AND v.value_json <> 'null' + ORDER BY d.position ASC, d.created_at ASC, d.id ASC", + ) + .map_err(|err| format!("typed property store: {err}"))?; + let rows = statement + .query_map(params![item.scope_key, item.short_id], |row| { + let definition = decode_definition(row)?; + let raw: String = row.get(10)?; + let updated_at: i64 = row.get(11)?; + Ok((definition, raw, updated_at)) + }) + .map_err(|err| format!("typed property store: {err}"))? + .collect::, _>>() + .map_err(|err| format!("typed property store: {err}"))?; + rows.into_iter() + .map(|(definition, raw, updated_at)| { + Ok(WorkItemPropertyValue { + definition, + value: serde_json::from_str(&raw) + .map_err(|err| format!("typed property value decode: {err}"))?, + updated_at: iso8601(updated_at), + }) + }) + .collect() +} + +pub(crate) fn export_definitions( + connection: &Connection, + org_id: &str, +) -> Result, String> { + let mut statement = connection + .prepare( + "SELECT id, org_id, name, property_type, description, config_json, + position, archived_at, created_at, updated_at + FROM pm_property_definitions + WHERE org_id = ?1 + ORDER BY position ASC, created_at ASC, id ASC", + ) + .map_err(|err| format!("typed property export: {err}"))?; + let definitions = statement + .query_map(params![org_id], decode_definition) + .map_err(|err| format!("typed property export: {err}"))? + .collect::, _>>() + .map_err(|err| format!("typed property export: {err}"))?; + Ok(definitions) +} + +pub(crate) fn export_work_item_snapshot( + connection: &Connection, + org_id: &str, + work_item_row_id: &str, + definitions: Vec, +) -> Result { + let scope: Option<(String, String)> = connection + .query_row( + "SELECT CASE + WHEN p.slug IS NULL THEN 'org:' || w.org_id + ELSE 'project:' || p.slug + END, + w.short_id + FROM workitems w + LEFT JOIN projects p ON p.id = w.project_id + WHERE w.id = ?1 AND w.org_id = ?2", + params![work_item_row_id, org_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(|err| format!("typed property export scope: {err}"))?; + let Some((scope_key, short_id)) = scope else { + return Ok(TypedPropertyWireSnapshot { + definitions, + values: Vec::new(), + }); + }; + let mut statement = connection + .prepare( + "SELECT property_id, value_json, updated_at + FROM pm_work_item_property_values + WHERE scope_key = ?1 AND work_item_id = ?2 + ORDER BY property_id ASC", + ) + .map_err(|err| format!("typed property export: {err}"))?; + let values = statement + .query_map(params![scope_key, short_id], |row| { + let raw: String = row.get(1)?; + Ok((row.get::<_, String>(0)?, raw, row.get::<_, i64>(2)?)) + }) + .map_err(|err| format!("typed property export: {err}"))? + .collect::, _>>() + .map_err(|err| format!("typed property export: {err}"))? + .into_iter() + .map(|(property_id, raw, updated_at)| { + Ok(SyncedWorkItemPropertyValue { + property_id, + value: serde_json::from_str(&raw) + .map_err(|err| format!("typed property export value: {err}"))?, + updated_at: iso8601(updated_at), + }) + }) + .collect::, String>>()?; + Ok(TypedPropertyWireSnapshot { + definitions, + values, + }) +} + +fn timestamp_ms(value: &str) -> Result { + chrono::DateTime::parse_from_rfc3339(value) + .map(|date| date.timestamp_millis()) + .map_err(|err| format!("typed property wire timestamp '{value}': {err}")) +} + +fn pending_property_path( + connection: &Connection, + org_id: &str, + path: &str, +) -> Result { + connection + .query_row( + "SELECT 1 FROM outbox_entries + WHERE org_id = ?1 + AND status IN ('pending', 'in_flight') + AND instr(',' || coalesce(field_path, '') || ',', ',' || ?2 || ',') > 0 + LIMIT 1", + params![org_id, path], + |_| Ok(true), + ) + .optional() + .map(|found| found.unwrap_or(false)) + .map_err(|err| format!("typed property pending-path probe: {err}")) +} + +pub(crate) fn apply_wire_definitions( + connection: &Connection, + org_id: &str, + payload: &serde_json::Value, +) -> Result<(), String> { + let Some(raw) = payload.get("propertyDefinitions") else { + return Ok(()); + }; + let definitions: Vec = serde_json::from_value(raw.clone()) + .map_err(|err| format!("typed property wire definitions: {err}"))?; + for definition in definitions { + if definition.org_id != org_id { + return Err(format!( + "typed property definition '{}' belongs to another organization", + definition.id + )); + } + let remote_updated_at = timestamp_ms(&definition.updated_at)?; + let local_updated_at: Option = connection + .query_row( + "SELECT updated_at FROM pm_property_definitions WHERE id = ?1", + params![definition.id], + |row| row.get(0), + ) + .optional() + .map_err(|err| format!("typed property definition watermark: {err}"))?; + if local_updated_at.is_some_and(|local| local >= remote_updated_at) { + continue; + } + if pending_property_path( + connection, + org_id, + &format!("propertyDefinitions.{}", definition.id), + )? { + continue; + } + let config_json = serde_json::to_string(&definition.config) + .map_err(|err| format!("typed property wire config: {err}"))?; + connection + .execute( + "INSERT INTO pm_property_definitions ( + id, org_id, name, property_type, description, config_json, + position, archived_at, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + config_json = excluded.config_json, + position = excluded.position, + archived_at = excluded.archived_at, + updated_at = excluded.updated_at + WHERE excluded.updated_at >= pm_property_definitions.updated_at", + params![ + definition.id, + definition.org_id, + definition.name, + definition.property_type.as_str(), + definition.description, + config_json, + definition.position, + definition + .archived_at + .as_deref() + .map(timestamp_ms) + .transpose()?, + timestamp_ms(&definition.created_at)?, + remote_updated_at, + ], + ) + .map_err(|err| format!("typed property apply definition: {err}"))?; + } + Ok(()) +} + +pub(crate) fn apply_work_item_wire_snapshot( + connection: &Connection, + org_id: &str, + work_item_row_id: &str, + payload: &serde_json::Value, +) -> Result<(), String> { + apply_wire_definitions(connection, org_id, payload)?; + let Some(raw) = payload.get("propertyValues") else { + return Ok(()); + }; + let values: Vec = serde_json::from_value(raw.clone()) + .map_err(|err| format!("typed property wire values: {err}"))?; + let scope: Option<(String, String)> = connection + .query_row( + "SELECT CASE + WHEN p.slug IS NULL THEN 'org:' || w.org_id + ELSE 'project:' || p.slug + END, + w.short_id + FROM workitems w + LEFT JOIN projects p ON p.id = w.project_id + WHERE w.id = ?1 AND w.org_id = ?2", + params![work_item_row_id, org_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(|err| format!("typed property apply scope: {err}"))?; + let Some((scope_key, short_id)) = scope else { + return Err(format!( + "typed property apply Work Item '{work_item_row_id}' not found" + )); + }; + for value in values { + let remote_updated_at = timestamp_ms(&value.updated_at)?; + let local_updated_at: Option = connection + .query_row( + "SELECT updated_at FROM pm_work_item_property_values + WHERE property_id = ?1 AND scope_key = ?2 AND work_item_id = ?3", + params![value.property_id, scope_key, short_id], + |row| row.get(0), + ) + .optional() + .map_err(|err| format!("typed property value watermark: {err}"))?; + if local_updated_at.is_some_and(|local| local >= remote_updated_at) { + continue; + } + if pending_property_path( + connection, + org_id, + &format!("propertyValues.{}", value.property_id), + )? { + continue; + } + let raw = serde_json::to_string(&value.value) + .map_err(|err| format!("typed property wire value: {err}"))?; + connection + .execute( + "INSERT INTO pm_work_item_property_values ( + property_id, scope_key, work_item_id, value_json, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(property_id, scope_key, work_item_id) DO UPDATE SET + value_json = excluded.value_json, + updated_at = excluded.updated_at + WHERE excluded.updated_at >= pm_work_item_property_values.updated_at", + params![ + value.property_id, + scope_key, + short_id, + raw, + remote_updated_at, + ], + ) + .map_err(|err| format!("typed property apply value: {err}"))?; + } + Ok(()) +} diff --git a/src-tauri/crates/project-management/src/work_item_features/readiness.rs b/src-tauri/crates/project-management/src/work_item_features/readiness.rs new file mode 100644 index 0000000000..c2af482a8a --- /dev/null +++ b/src-tauri/crates/project-management/src/work_item_features/readiness.rs @@ -0,0 +1,239 @@ +use rusqlite::{params, OptionalExtension}; + +use super::store::resolve_work_item; +use super::{PrReadiness, WorkItemScope}; +use crate::projects::io::helpers::conn; +use crate::projects::types::{ + PrStatus, ProofOfWork, WorkItemCloseOut, WorkItemCloseOutStatus, WorkItemWorkProduct, + WorkItemWorkProductStatus, WorkItemWorkProductType, +}; + +fn metadata_bool(product: &WorkItemWorkProduct, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + product + .metadata + .get(*key) + .and_then(serde_json::Value::as_bool) + }) +} + +fn metadata_string(product: &WorkItemWorkProduct, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + product + .metadata + .get(*key) + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }) +} + +fn metadata_strings(product: &WorkItemWorkProduct, keys: &[&str]) -> Vec { + keys.iter() + .find_map(|key| { + product + .metadata + .get(*key) + .and_then(serde_json::Value::as_array) + }) + .map(|values| { + values + .iter() + .filter_map(serde_json::Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + +fn status_string(status: &PrStatus) -> String { + match status { + PrStatus::Draft => "draft", + PrStatus::Open => "open", + PrStatus::Merged => "merged", + PrStatus::Closed => "closed", + } + .to_string() +} + +pub(crate) fn evaluate(scope: &WorkItemScope) -> Result<(PrReadiness, bool), String> { + let connection = conn()?; + let item = resolve_work_item(&connection, scope)?; + let proof = item + .extras + .get("proof_of_work") + .cloned() + .and_then(|value| serde_json::from_value::(value).ok()); + let products = item + .extras + .get("work_products") + .cloned() + .and_then(|value| serde_json::from_value::>(value).ok()) + .unwrap_or_default(); + let close_out = item + .extras + .get("close_out") + .cloned() + .and_then(|value| serde_json::from_value::(value).ok()); + let pull_requests = products + .iter() + .filter(|product| product.product_type == WorkItemWorkProductType::PullRequest) + .collect::>(); + let primary = pull_requests + .iter() + .copied() + .find(|product| product.is_primary) + .or_else(|| pull_requests.first().copied()); + + let pr_url = primary + .and_then(|product| product.url.clone()) + .or_else(|| proof.as_ref().and_then(|value| value.pr_url.clone())); + let pr_status = proof + .as_ref() + .and_then(|value| value.pr_status.as_ref()) + .map(status_string) + .or_else(|| { + primary.map(|product| match product.status.as_ref() { + Some(WorkItemWorkProductStatus::Merged) => "merged".to_string(), + Some(WorkItemWorkProductStatus::Pending) + | Some(WorkItemWorkProductStatus::Passed) + | Some(WorkItemWorkProductStatus::Unknown) + | None => "open".to_string(), + Some(WorkItemWorkProductStatus::Failed) => "open".to_string(), + Some(WorkItemWorkProductStatus::Deployed) => "merged".to_string(), + }) + }); + let is_draft = primary + .and_then(|product| metadata_bool(product, &["isDraft", "is_draft", "draft"])) + .unwrap_or_else(|| pr_status.as_deref() == Some("draft")); + let mergeable = + primary.and_then(|product| metadata_bool(product, &["mergeable", "canMerge", "can_merge"])); + let ci_status = primary.and_then(|product| { + metadata_string( + product, + &["ciStatus", "ci_status", "checksStatus", "checks_status"], + ) + }); + let failed_checks = primary + .map(|product| { + metadata_strings(product, &["failedChecks", "failed_checks", "failingChecks"]) + }) + .unwrap_or_default(); + let other_open_prs = pull_requests + .iter() + .copied() + .filter(|product| primary.is_none_or(|selected| selected.id != product.id)) + .filter(|product| { + !matches!( + product.status, + Some(WorkItemWorkProductStatus::Merged | WorkItemWorkProductStatus::Deployed) + ) + }) + .filter_map(|product| product.url.clone().or_else(|| Some(product.title.clone()))) + .collect::>(); + let latest_snapshot_revision: Option = connection + .query_row( + "SELECT work_item_revision FROM pm_work_item_runs + WHERE scope_key = ?1 AND work_item_id = ?2 + ORDER BY created_at DESC, id DESC LIMIT 1", + params![item.scope_key, item.short_id], + |row| row.get(0), + ) + .optional() + .map_err(|err| format!("PR readiness store: {err}"))?; + let snapshot_stale = latest_snapshot_revision.is_some_and(|revision| revision != item.revision); + let close_intent = close_out + .as_ref() + .is_some_and(|value| value.status == WorkItemCloseOutStatus::Done) + || primary + .and_then(|product| metadata_bool(product, &["closeIntent", "close_intent"])) + .unwrap_or(false); + let has_pr_evidence = pr_url.is_some() || primary.is_some(); + let mut blockers = Vec::new(); + if !has_pr_evidence { + blockers.push("No pull request is associated with this Work Item".to_string()); + } + if is_draft { + blockers.push("The primary pull request is still a draft".to_string()); + } + if mergeable == Some(false) { + blockers.push("The primary pull request has merge conflicts".to_string()); + } + if !failed_checks.is_empty() { + blockers.push(format!( + "{} required check(s) are failing", + failed_checks.len() + )); + } + if ci_status + .as_deref() + .is_some_and(|status| !matches!(status, "success" | "passed" | "completed" | "neutral")) + { + blockers.push(format!( + "CI is not ready ({})", + ci_status.as_deref().unwrap_or("unknown") + )); + } + if !other_open_prs.is_empty() { + blockers.push("Another pull request for this Work Item is still open".to_string()); + } + if snapshot_stale { + blockers.push("Execution evidence is stale relative to the Work Item revision".to_string()); + } + if pr_status.as_deref() != Some("merged") { + blockers.push("The primary pull request has not been merged".to_string()); + } + if !close_intent { + blockers.push("No explicit close intent has been recorded".to_string()); + } + let can_complete = has_pr_evidence + && pr_status.as_deref() == Some("merged") + && close_intent + && !is_draft + && mergeable != Some(false) + && failed_checks.is_empty() + && other_open_prs.is_empty() + && !snapshot_stale; + let state = if can_complete { + "ready_to_complete" + } else if !has_pr_evidence { + "missing" + } else if pr_status.as_deref() == Some("merged") { + "merged_blocked" + } else { + "blocked" + } + .to_string(); + Ok(( + PrReadiness { + state, + pr_url, + pr_status, + is_draft, + mergeable, + ci_status, + failed_checks, + other_open_prs, + snapshot_stale, + close_intent, + can_complete, + blockers, + evidence_at: chrono::Utc::now().to_rfc3339(), + }, + has_pr_evidence, + )) +} + +pub(super) fn get(scope: &WorkItemScope) -> Result { + evaluate(scope).map(|(readiness, _)| readiness) +} + +pub(crate) fn guard_completion(scope: &WorkItemScope) -> Result<(), String> { + let (readiness, has_pr_evidence) = evaluate(scope)?; + if !has_pr_evidence || readiness.can_complete { + return Ok(()); + } + Err(format!( + "PM_ERR:PR_NOT_READY:{}", + readiness.blockers.join("; ") + )) +} diff --git a/src-tauri/crates/project-management/src/work_item_features/routine_webhook.rs b/src-tauri/crates/project-management/src/work_item_features/routine_webhook.rs new file mode 100644 index 0000000000..0029627cb3 --- /dev/null +++ b/src-tauri/crates/project-management/src/work_item_features/routine_webhook.rs @@ -0,0 +1,618 @@ +use std::collections::BTreeMap; + +use axum::body::Bytes; +use axum::extract::Path; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use base64::Engine; +use rand::RngCore; +use rusqlite::{params, OptionalExtension, TransactionBehavior}; +use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; + +use super::store::iso8601; +use super::{RoutineWebhookDelivery, RoutineWebhookInstallInfo, RoutineWebhookStatus}; +use crate::projects::io::helpers::{conn, now_ms}; +use crate::routine_service::spec::{Activation, RoutineSpecFile}; + +pub const ROUTINE_WEBHOOK_BASE_PATH: &str = "/routine/webhook"; +const MAX_PAYLOAD_BYTES: usize = 256 * 1024; +const FAILURE_PAUSE_THRESHOLD: i64 = 5; + +fn secret_hash(secret: &str) -> String { + hex::encode(Sha256::digest(secret.as_bytes())) +} + +fn mint_secret() -> String { + let mut bytes = [0_u8; 32]; + rand::rng().fill_bytes(&mut bytes); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) +} + +fn secret_hint(secret: &str) -> String { + let prefix = secret.chars().take(4).collect::(); + let suffix = secret + .chars() + .rev() + .take(4) + .collect::() + .chars() + .rev() + .collect::(); + format!("{prefix}…{suffix}") +} + +fn routine_has_provider_activation(spec: &RoutineSpecFile) -> bool { + spec.spec + .activations + .iter() + .any(|activation| matches!(activation, Activation::ProviderEvent { .. })) +} + +pub fn install(routine_name: &str) -> Result { + let connection = conn()?; + let spec_json: String = connection + .query_row( + "SELECT spec_json FROM pm_routines WHERE name = ?1", + params![routine_name], + |row| row.get(0), + ) + .optional() + .map_err(|err| format!("routine webhook store: {err}"))? + .ok_or_else(|| format!("Routine '{routine_name}' not found"))?; + let spec: RoutineSpecFile = serde_json::from_str(&spec_json) + .map_err(|err| format!("Routine '{routine_name}' has an invalid snapshot: {err}"))?; + if !routine_has_provider_activation(&spec) { + return Err(format!( + "Routine '{routine_name}' has no provider_event activation" + )); + } + let secret = mint_secret(); + let hint = secret_hint(&secret); + let now = now_ms(); + connection + .execute( + "INSERT INTO pm_routine_webhooks ( + routine_name, secret_hash, secret_hint, enabled, + consecutive_failures, paused_at, created_at, updated_at + ) VALUES (?1, ?2, ?3, 1, 0, NULL, ?4, ?4) + ON CONFLICT(routine_name) DO UPDATE SET + secret_hash = excluded.secret_hash, + secret_hint = excluded.secret_hint, + enabled = 1, + consecutive_failures = 0, + paused_at = NULL, + updated_at = excluded.updated_at", + params![routine_name, secret_hash(&secret), hint, now], + ) + .map_err(|err| format!("routine webhook store: {err}"))?; + Ok(RoutineWebhookInstallInfo { + routine_name: routine_name.to_string(), + url_path: format!("{ROUTINE_WEBHOOK_BASE_PATH}/{routine_name}"), + secret, + secret_hint: hint, + rotated_at: iso8601(now), + }) +} + +pub fn status(routine_name: &str) -> Result { + let connection = conn()?; + let row: Option<(i64, String, i64, Option)> = connection + .query_row( + "SELECT enabled, secret_hint, consecutive_failures, paused_at + FROM pm_routine_webhooks WHERE routine_name = ?1", + params![routine_name], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .optional() + .map_err(|err| format!("routine webhook store: {err}"))?; + Ok(match row { + Some((enabled, hint, failures, paused_at)) => RoutineWebhookStatus { + routine_name: routine_name.to_string(), + installed: true, + enabled: enabled != 0 && paused_at.is_none(), + secret_hint: Some(hint), + consecutive_failures: failures.max(0) as u32, + paused_at: paused_at.map(iso8601), + }, + None => RoutineWebhookStatus { + routine_name: routine_name.to_string(), + installed: false, + enabled: false, + secret_hint: None, + consecutive_failures: 0, + paused_at: None, + }, + }) +} + +pub fn set_enabled(routine_name: &str, enabled: bool) -> Result { + let connection = conn()?; + let changed = connection + .execute( + "UPDATE pm_routine_webhooks + SET enabled = ?2, + paused_at = CASE WHEN ?2 = 1 THEN NULL ELSE paused_at END, + consecutive_failures = CASE WHEN ?2 = 1 THEN 0 ELSE consecutive_failures END, + updated_at = ?3 + WHERE routine_name = ?1", + params![routine_name, i64::from(enabled), now_ms()], + ) + .map_err(|err| format!("routine webhook store: {err}"))?; + if changed != 1 { + return Err(format!("Routine webhook '{routine_name}' is not installed")); + } + status(routine_name) +} + +fn json_subset(filter: &serde_json::Value, payload: &serde_json::Value) -> bool { + match filter { + serde_json::Value::Object(expected) => payload.as_object().is_some_and(|actual| { + expected.iter().all(|(key, value)| { + actual + .get(key) + .is_some_and(|found| json_subset(value, found)) + }) + }), + serde_json::Value::Array(expected) => payload.as_array().is_some_and(|actual| { + expected + .iter() + .all(|value| actual.iter().any(|found| json_subset(value, found))) + }), + other => other == payload, + } +} + +fn scalar_inputs(payload: &serde_json::Value) -> BTreeMap { + payload + .get("inputs") + .and_then(serde_json::Value::as_object) + .map(|inputs| { + inputs + .iter() + .filter_map(|(key, value)| { + let rendered = match value { + serde_json::Value::String(value) => value.clone(), + serde_json::Value::Number(value) => value.to_string(), + serde_json::Value::Bool(value) => value.to_string(), + _ => return None, + }; + Some((key.clone(), rendered)) + }) + .collect() + }) + .unwrap_or_default() +} + +struct DeliveryRecord<'a> { + routine_name: &'a str, + provider: &'a str, + event_kind: &'a str, + idempotency_key: &'a str, + payload: &'a serde_json::Value, + status: &'a str, + reason: Option<&'a str>, + routine_run_id: Option<&'a str>, + now: i64, +} + +type WebhookConfigRow = (String, Option, i64, i64, Option); + +fn record_delivery( + tx: &rusqlite::Transaction<'_>, + record: DeliveryRecord<'_>, +) -> Result { + let DeliveryRecord { + routine_name, + provider, + event_kind, + idempotency_key, + payload, + status, + reason, + routine_run_id, + now, + } = record; + let id = format!("rwd_{}", uuid::Uuid::new_v4().simple()); + let payload_json = serde_json::to_string(payload) + .map_err(|err| format!("routine webhook payload serialization: {err}"))?; + tx.execute( + "INSERT INTO pm_routine_webhook_deliveries ( + id, routine_name, provider, event_kind, idempotency_key, + payload_json, status, reason, routine_run_id, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?10)", + params![ + id, + routine_name, + provider, + event_kind, + idempotency_key, + payload_json, + status, + reason, + routine_run_id, + now + ], + ) + .map_err(|err| format!("routine webhook delivery: {err}"))?; + Ok(RoutineWebhookDelivery { + id, + routine_name: routine_name.to_string(), + provider: provider.to_string(), + event_kind: event_kind.to_string(), + idempotency_key: idempotency_key.to_string(), + status: status.to_string(), + reason: reason.map(str::to_string), + routine_run_id: routine_run_id.map(str::to_string), + created_at: iso8601(now), + updated_at: iso8601(now), + }) +} + +fn ingest_verified( + routine_name: &str, + provider: &str, + event_kind: &str, + idempotency_key: &str, + payload: serde_json::Value, + replay_of: Option<&str>, +) -> Result { + if provider.trim().is_empty() + || event_kind.trim().is_empty() + || idempotency_key.trim().is_empty() + { + return Err("provider, event kind, and delivery id are required".to_string()); + } + let mut connection = conn()?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|err| format!("routine webhook tx: {err}"))?; + if replay_of.is_none() { + let duplicate: Option = tx + .query_row( + "SELECT id FROM pm_routine_webhook_deliveries + WHERE routine_name = ?1 AND idempotency_key = ?2", + params![routine_name, idempotency_key], + |row| row.get(0), + ) + .optional() + .map_err(|err| format!("routine webhook delivery: {err}"))?; + if let Some(delivery_id) = duplicate { + tx.commit() + .map_err(|err| format!("routine webhook commit: {err}"))?; + return read_delivery(&delivery_id); + } + } + let row: Option = tx + .query_row( + "SELECT r.spec_json, r.default_scope, r.enabled, w.enabled, w.paused_at + FROM pm_routines r + JOIN pm_routine_webhooks w ON w.routine_name = r.name + WHERE r.name = ?1", + params![routine_name], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + )) + }, + ) + .optional() + .map_err(|err| format!("routine webhook store: {err}"))?; + let Some((spec_json, default_scope, routine_enabled, webhook_enabled, paused_at)) = row else { + return Err(format!("Routine webhook '{routine_name}' is not installed")); + }; + if routine_enabled == 0 || webhook_enabled == 0 || paused_at.is_some() { + let delivery = record_delivery( + &tx, + DeliveryRecord { + routine_name, + provider, + event_kind, + idempotency_key, + payload: &payload, + status: "skipped", + reason: Some("routine or webhook is disabled"), + routine_run_id: None, + now: now_ms(), + }, + )?; + tx.commit() + .map_err(|err| format!("routine webhook commit: {err}"))?; + return Ok(delivery); + } + let spec: RoutineSpecFile = serde_json::from_str(&spec_json) + .map_err(|err| format!("routine webhook snapshot parse: {err}"))?; + let activation = spec.spec.activations.iter().find(|activation| { + matches!(activation, Activation::ProviderEvent { + provider: expected_provider, + event_kind: expected_kind, + filter, + .. + } if expected_provider == provider && expected_kind == event_kind + && filter.as_ref().is_none_or(|expected| json_subset(expected, &payload))) + }); + let now = now_ms(); + if activation.is_none() { + let delivery = record_delivery( + &tx, + DeliveryRecord { + routine_name, + provider, + event_kind, + idempotency_key, + payload: &payload, + status: "ignored", + reason: Some("event did not match a provider activation or filter"), + routine_run_id: None, + now, + }, + )?; + tx.commit() + .map_err(|err| format!("routine webhook commit: {err}"))?; + return Ok(delivery); + } + let Some(scope) = default_scope else { + let delivery = record_delivery( + &tx, + DeliveryRecord { + routine_name, + provider, + event_kind, + idempotency_key, + payload: &payload, + status: "rejected", + reason: Some("routine has no default project scope"), + routine_run_id: None, + now, + }, + )?; + tx.commit() + .map_err(|err| format!("routine webhook commit: {err}"))?; + return Ok(delivery); + }; + tx.commit() + .map_err(|err| format!("routine webhook pre-invoke commit: {err}"))?; + + let invoke_key = replay_of + .map(|delivery_id| format!("webhook-replay:{delivery_id}:{idempotency_key}")) + .unwrap_or_else(|| format!("webhook:{provider}:{idempotency_key}")); + match crate::routine_service::invoke( + routine_name, + &scope, + &scalar_inputs(&payload), + None, + Some(&invoke_key), + ) { + Ok(run) => { + let mut connection = conn()?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|err| format!("routine webhook result tx: {err}"))?; + let replay_reason = replay_of.map(|id| format!("replay of {id}")); + let delivery = record_delivery( + &tx, + DeliveryRecord { + routine_name, + provider, + event_kind, + idempotency_key, + payload: &payload, + status: "accepted", + reason: replay_reason.as_deref(), + routine_run_id: Some(&run.run_id), + now: now_ms(), + }, + )?; + tx.execute( + "UPDATE pm_routine_webhooks + SET consecutive_failures = 0, updated_at = ?2 + WHERE routine_name = ?1", + params![routine_name, now_ms()], + ) + .map_err(|err| format!("routine webhook store: {err}"))?; + tx.commit() + .map_err(|err| format!("routine webhook result commit: {err}"))?; + Ok(delivery) + } + Err(error) => { + let mut connection = conn()?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|err| format!("routine webhook failure tx: {err}"))?; + let failures: i64 = tx + .query_row( + "SELECT consecutive_failures + 1 FROM pm_routine_webhooks + WHERE routine_name = ?1", + params![routine_name], + |row| row.get(0), + ) + .map_err(|err| format!("routine webhook store: {err}"))?; + let failed_at = now_ms(); + tx.execute( + "UPDATE pm_routine_webhooks + SET consecutive_failures = ?2, + paused_at = CASE WHEN ?2 >= ?3 THEN ?4 ELSE paused_at END, + updated_at = ?4 + WHERE routine_name = ?1", + params![routine_name, failures, FAILURE_PAUSE_THRESHOLD, failed_at], + ) + .map_err(|err| format!("routine webhook store: {err}"))?; + let delivery = record_delivery( + &tx, + DeliveryRecord { + routine_name, + provider, + event_kind, + idempotency_key, + payload: &payload, + status: "failed", + reason: Some(&error), + routine_run_id: None, + now: failed_at, + }, + )?; + tx.commit() + .map_err(|err| format!("routine webhook failure commit: {err}"))?; + Ok(delivery) + } + } +} + +fn read_delivery(delivery_id: &str) -> Result { + let connection = conn()?; + connection + .query_row( + "SELECT id, routine_name, provider, event_kind, idempotency_key, + status, reason, routine_run_id, created_at, updated_at + FROM pm_routine_webhook_deliveries WHERE id = ?1", + params![delivery_id], + |row| { + Ok(RoutineWebhookDelivery { + id: row.get(0)?, + routine_name: row.get(1)?, + provider: row.get(2)?, + event_kind: row.get(3)?, + idempotency_key: row.get(4)?, + status: row.get(5)?, + reason: row.get(6)?, + routine_run_id: row.get(7)?, + created_at: iso8601(row.get(8)?), + updated_at: iso8601(row.get(9)?), + }) + }, + ) + .map_err(|err| format!("routine webhook delivery: {err}")) +} + +pub fn list_deliveries( + routine_name: &str, + limit: usize, +) -> Result, String> { + let connection = conn()?; + let mut statement = connection + .prepare( + "SELECT id FROM pm_routine_webhook_deliveries + WHERE routine_name = ?1 + ORDER BY created_at DESC, id DESC LIMIT ?2", + ) + .map_err(|err| format!("routine webhook delivery: {err}"))?; + let ids = statement + .query_map(params![routine_name, limit.clamp(1, 200) as i64], |row| { + row.get::<_, String>(0) + }) + .map_err(|err| format!("routine webhook delivery: {err}"))? + .collect::, _>>() + .map_err(|err| format!("routine webhook delivery: {err}"))?; + ids.into_iter().map(|id| read_delivery(&id)).collect() +} + +pub fn replay(delivery_id: &str) -> Result { + let connection = conn()?; + let row: (String, String, String, String) = connection + .query_row( + "SELECT routine_name, provider, event_kind, payload_json + FROM pm_routine_webhook_deliveries WHERE id = ?1", + params![delivery_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .map_err(|err| format!("routine webhook delivery: {err}"))?; + let payload = serde_json::from_str(&row.3) + .map_err(|err| format!("routine webhook payload decode: {err}"))?; + ingest_verified( + &row.0, + &row.1, + &row.2, + &format!("replay:{}", uuid::Uuid::new_v4().simple()), + payload, + Some(delivery_id), + ) +} + +pub async fn handle_http( + Path(routine_name): Path, + headers: HeaderMap, + body: Bytes, +) -> Response { + if body.len() > MAX_PAYLOAD_BYTES { + return (StatusCode::PAYLOAD_TOO_LARGE, "payload exceeds 256 KiB").into_response(); + } + let get_header = |name: &str| { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::to_string) + }; + let Some(token) = get_header("x-org2-webhook-token") else { + return (StatusCode::UNAUTHORIZED, "missing webhook token").into_response(); + }; + let Some(provider) = get_header("x-org2-provider") else { + return (StatusCode::BAD_REQUEST, "missing provider").into_response(); + }; + let Some(event_kind) = get_header("x-org2-event") else { + return (StatusCode::BAD_REQUEST, "missing event kind").into_response(); + }; + let Some(delivery_id) = get_header("x-org2-delivery-id") else { + return (StatusCode::BAD_REQUEST, "missing delivery id").into_response(); + }; + let connection = match conn() { + Ok(connection) => connection, + Err(error) => return (StatusCode::INTERNAL_SERVER_ERROR, error).into_response(), + }; + let stored_hash: Option = match connection + .query_row( + "SELECT secret_hash FROM pm_routine_webhooks WHERE routine_name = ?1", + params![routine_name], + |row| row.get(0), + ) + .optional() + { + Ok(value) => value, + Err(error) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("routine webhook store: {error}"), + ) + .into_response() + } + }; + let Some(stored_hash) = stored_hash else { + return (StatusCode::NOT_FOUND, "routine webhook not found").into_response(); + }; + let candidate = secret_hash(&token); + if stored_hash + .as_bytes() + .ct_eq(candidate.as_bytes()) + .unwrap_u8() + != 1 + { + return (StatusCode::UNAUTHORIZED, "invalid webhook token").into_response(); + } + let payload: serde_json::Value = match serde_json::from_slice(&body) { + Ok(value) => value, + Err(error) => { + return (StatusCode::BAD_REQUEST, format!("invalid JSON: {error}")).into_response() + } + }; + match ingest_verified( + &routine_name, + &provider, + &event_kind, + &delivery_id, + payload, + None, + ) { + Ok(delivery) => { + let status = if delivery.status == "failed" { + StatusCode::UNPROCESSABLE_ENTITY + } else { + StatusCode::ACCEPTED + }; + (status, axum::Json(delivery)).into_response() + } + Err(error) => (StatusCode::BAD_REQUEST, error).into_response(), + } +} diff --git a/src-tauri/crates/project-management/src/work_item_features/store.rs b/src-tauri/crates/project-management/src/work_item_features/store.rs new file mode 100644 index 0000000000..3eb5fce355 --- /dev/null +++ b/src-tauri/crates/project-management/src/work_item_features/store.rs @@ -0,0 +1,149 @@ +use rusqlite::{params, Connection, OptionalExtension}; + +use super::WorkItemScope; + +#[derive(Debug, Clone)] +pub(super) struct ResolvedWorkItem { + pub row_id: String, + pub scope_key: String, + pub project_slug: Option, + pub org_id: String, + pub short_id: String, + pub title: String, + pub body: String, + pub status: String, + pub revision: i64, + pub created_by: Option, + pub assigned_human_id: Option, + pub extras: serde_json::Value, +} + +pub(super) fn iso8601(epoch_ms: i64) -> String { + chrono::DateTime::from_timestamp_millis(epoch_ms) + .map(|value| value.to_rfc3339()) + .unwrap_or_else(|| epoch_ms.to_string()) +} + +pub(super) fn scope_key(project_slug: Option<&str>, org_id: &str) -> String { + project_slug + .map(|slug| format!("project:{slug}")) + .unwrap_or_else(|| format!("org:{org_id}")) +} + +pub(super) fn resolve_work_item( + connection: &Connection, + scope: &WorkItemScope, +) -> Result { + let result = match scope.project_slug.as_deref() { + Some(slug) => connection + .query_row( + "SELECT w.id, p.slug, w.org_id, w.short_id, w.title, w.body, + w.status, w.local_version, + json_extract(e.extras_json, '$.created_by'), + w.assigned_human_id, e.extras_json + FROM workitems w + JOIN projects p ON p.id = w.project_id + LEFT JOIN workitem_extras e ON e.work_item_id = w.id + WHERE p.slug = ?1 AND w.short_id = ?2 AND w.deleted_at IS NULL", + params![slug, scope.work_item_id], + row_to_resolved, + ) + .optional(), + None => connection + .query_row( + "SELECT w.id, NULL, w.org_id, w.short_id, w.title, w.body, + w.status, w.local_version, + json_extract(e.extras_json, '$.created_by'), + w.assigned_human_id, e.extras_json + FROM workitems w + LEFT JOIN workitem_extras e ON e.work_item_id = w.id + WHERE w.project_id IS NULL AND w.org_id = ?1 + AND w.short_id = ?2 AND w.deleted_at IS NULL", + params![scope.org_id, scope.work_item_id], + row_to_resolved, + ) + .optional(), + } + .map_err(|err| format!("work item feature store: {err}"))? + .ok_or_else(|| format!("Work item '{}' not found", scope.work_item_id))?; + Ok(result) +} + +fn row_to_resolved(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let project_slug: Option = row.get(1)?; + let org_id: String = row.get(2)?; + let raw_extras: Option = row.get(10)?; + Ok(ResolvedWorkItem { + row_id: row.get(0)?, + scope_key: scope_key(project_slug.as_deref(), &org_id), + project_slug, + org_id, + short_id: row.get(3)?, + title: row.get(4)?, + body: row.get::<_, Option>(5)?.unwrap_or_default(), + status: row.get(6)?, + revision: row.get(7)?, + created_by: row.get(8)?, + assigned_human_id: row.get(9)?, + extras: raw_extras + .as_deref() + .and_then(|raw| serde_json::from_str(raw).ok()) + .unwrap_or_else(|| serde_json::json!({})), + }) +} + +pub(super) fn persist_extras( + connection: &Connection, + item: &ResolvedWorkItem, + extras: &serde_json::Value, + now: i64, +) -> Result { + let raw = serde_json::to_string(extras) + .map_err(|err| format!("work item extras serialization: {err}"))?; + connection + .execute( + "INSERT INTO workitem_extras (work_item_id, extras_json) + VALUES (?1, ?2) + ON CONFLICT(work_item_id) DO UPDATE SET extras_json = excluded.extras_json", + params![item.row_id, raw], + ) + .map_err(|err| format!("work item feature store: {err}"))?; + connection + .execute( + "UPDATE workitems + SET local_version = local_version + 1, updated_at = ?2 + WHERE id = ?1", + params![item.row_id, now], + ) + .map_err(|err| format!("work item feature store: {err}"))?; + Ok(item.revision.saturating_add(1)) +} + +pub(super) fn append_audit( + tx: &rusqlite::Transaction<'_>, + item: &ResolvedWorkItem, + operation: &str, + revision: i64, + actor_id: Option<&str>, + payload: serde_json::Value, +) -> Result<(), String> { + let actor = actor_id.map(|id| crate::projects::types::WorkItemMutationActor { + id: id.to_string(), + name: id.to_string(), + }); + let seq = crate::work_service::audit::bump_change_seq(tx)?; + crate::work_service::audit::append_audit_event( + tx, + &crate::work_service::audit::AuditEventRow { + operation, + entity_type: "work_item", + entity_id: &item.short_id, + project_slug: item.project_slug.as_deref(), + org_id: Some(&item.org_id), + actor: actor.as_ref(), + revision, + seq, + payload, + }, + ) +} diff --git a/src-tauri/crates/project-management/src/work_item_features/subscriptions.rs b/src-tauri/crates/project-management/src/work_item_features/subscriptions.rs new file mode 100644 index 0000000000..0de47021fd --- /dev/null +++ b/src-tauri/crates/project-management/src/work_item_features/subscriptions.rs @@ -0,0 +1,437 @@ +use std::collections::BTreeSet; + +use rusqlite::{params, Transaction, TransactionBehavior}; + +use super::store::{iso8601, resolve_work_item}; +use super::{SubscriptionMutation, SubscriptionReason, WorkItemScope, WorkItemSubscription}; +use crate::projects::io::helpers::{conn, now_ms}; +use crate::projects::types::WorkItemRun; + +pub(super) fn ensure_subscription( + tx: &Transaction<'_>, + item_scope: &str, + work_item_id: &str, + subscriber_id: &str, + reason: SubscriptionReason, + now: i64, +) -> Result<(), String> { + let subscriber_id = subscriber_id.trim(); + if subscriber_id.is_empty() { + return Ok(()); + } + tx.execute( + "INSERT INTO pm_work_item_subscriptions ( + scope_key, work_item_id, subscriber_id, reason, created_at, muted_at + ) VALUES (?1, ?2, ?3, ?4, ?5, NULL) + ON CONFLICT(scope_key, work_item_id, subscriber_id) DO UPDATE SET + reason = CASE + WHEN pm_work_item_subscriptions.reason = 'manual' THEN 'manual' + ELSE excluded.reason + END, + muted_at = NULL", + params![ + item_scope, + work_item_id, + subscriber_id, + reason.as_str(), + now + ], + ) + .map_err(|err| format!("work item subscription: {err}"))?; + Ok(()) +} + +fn bootstrap_implicit_subscriptions( + tx: &Transaction<'_>, + scope: &WorkItemScope, +) -> Result { + let item = resolve_work_item(tx, scope)?; + let now = now_ms(); + if let Some(creator) = item.created_by.as_deref() { + ensure_subscription( + tx, + &item.scope_key, + &item.short_id, + creator, + SubscriptionReason::Creator, + now, + )?; + } + if let Some(assignee) = item.assigned_human_id.as_deref() { + ensure_subscription( + tx, + &item.scope_key, + &item.short_id, + assignee, + SubscriptionReason::Assignee, + now, + )?; + } + // Description mentions use durable member ids (`<@id>` or `@[id]`), so + // display-name edits cannot silently retarget a subscription. + for mentioned_id in description_mention_ids(&item.body) { + ensure_subscription( + tx, + &item.scope_key, + &item.short_id, + &mentioned_id, + SubscriptionReason::Mentioned, + now, + )?; + } + if matches!( + item.status.trim().to_ascii_lowercase().as_str(), + "completed" | "closed" | "cancelled" | "canceled" | "duplicate" + ) { + tx.execute( + "UPDATE pm_work_item_inbox_events SET archived_at = COALESCE(archived_at, ?3) + WHERE scope_key = ?1 AND work_item_id = ?2", + params![item.scope_key, item.short_id, now], + ) + .map_err(|err| format!("work item inbox event: {err}"))?; + } + Ok(item) +} + +fn description_mention_ids(body: &str) -> BTreeSet { + let mut ids = BTreeSet::new(); + for (prefix, suffix) in [("<@", ">"), ("@[", "]")] { + let mut remainder = body; + while let Some(start) = remainder.find(prefix) { + let after_prefix = &remainder[start + prefix.len()..]; + let Some(end) = after_prefix.find(suffix) else { + break; + }; + let id = after_prefix[..end].trim(); + if !id.is_empty() + && id + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | ':' | '.')) + { + ids.insert(id.to_string()); + } + remainder = &after_prefix[end + suffix.len()..]; + } + } + ids +} + +pub(super) fn subscribe( + request: SubscriptionMutation, +) -> Result, String> { + let mut connection = conn()?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|err| format!("work item subscription tx: {err}"))?; + let item = bootstrap_implicit_subscriptions(&tx, &request.scope)?; + ensure_subscription( + &tx, + &item.scope_key, + &item.short_id, + &request.subscriber_id, + SubscriptionReason::Manual, + now_ms(), + )?; + tx.commit() + .map_err(|err| format!("work item subscription commit: {err}"))?; + list(&request.scope) +} + +pub(super) fn unsubscribe( + request: SubscriptionMutation, +) -> Result, String> { + let mut connection = conn()?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|err| format!("work item subscription tx: {err}"))?; + let item = resolve_work_item(&tx, &request.scope)?; + tx.execute( + "UPDATE pm_work_item_subscriptions + SET muted_at = ?4 + WHERE scope_key = ?1 AND work_item_id = ?2 AND subscriber_id = ?3", + params![ + item.scope_key, + item.short_id, + request.subscriber_id, + now_ms() + ], + ) + .map_err(|err| format!("work item subscription: {err}"))?; + tx.commit() + .map_err(|err| format!("work item subscription commit: {err}"))?; + list(&request.scope) +} + +pub(super) fn list(scope: &WorkItemScope) -> Result, String> { + let mut connection = conn()?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|err| format!("work item subscription tx: {err}"))?; + let item = bootstrap_implicit_subscriptions(&tx, scope)?; + let mut statement = tx + .prepare( + "SELECT subscriber_id, reason, created_at, muted_at + FROM pm_work_item_subscriptions + WHERE scope_key = ?1 AND work_item_id = ?2 + ORDER BY created_at ASC, subscriber_id ASC", + ) + .map_err(|err| format!("work item subscription: {err}"))?; + let rows = statement + .query_map(params![item.scope_key, item.short_id], |row| { + let reason: String = row.get(1)?; + Ok(( + row.get::<_, String>(0)?, + reason, + row.get::<_, i64>(2)?, + row.get::<_, Option>(3)?, + )) + }) + .map_err(|err| format!("work item subscription: {err}"))? + .collect::, _>>() + .map_err(|err| format!("work item subscription: {err}"))?; + drop(statement); + tx.commit() + .map_err(|err| format!("work item subscription commit: {err}"))?; + rows.into_iter() + .map(|(subscriber_id, reason, created_at, muted_at)| { + Ok(WorkItemSubscription { + subscriber_id, + reason: parse_reason(&reason)?, + created_at: iso8601(created_at), + muted_at: muted_at.map(iso8601), + }) + }) + .collect() +} + +fn parse_reason(value: &str) -> Result { + match value { + "creator" => Ok(SubscriptionReason::Creator), + "assignee" => Ok(SubscriptionReason::Assignee), + "commenter" => Ok(SubscriptionReason::Commenter), + "mentioned" => Ok(SubscriptionReason::Mentioned), + "manual" => Ok(SubscriptionReason::Manual), + "agent" => Ok(SubscriptionReason::Agent), + "delegated" => Ok(SubscriptionReason::Delegated), + other => Err(format!("unknown subscription reason '{other}'")), + } +} + +struct InboxEvent<'a> { + scope_key: &'a str, + work_item_id: &'a str, + recipient_id: &'a str, + kind: &'a str, + actor_id: Option<&'a str>, + payload: &'a serde_json::Value, + coalesce_key: &'a str, + now: i64, +} + +pub(super) struct CommentNotification<'a> { + pub(super) scope_key: &'a str, + pub(super) work_item_id: &'a str, + pub(super) title: &'a str, + pub(super) comment_id: &'a str, + pub(super) author_id: &'a str, + pub(super) content: &'a str, + pub(super) mentioned_user_ids: &'a [String], + pub(super) now: i64, +} + +fn upsert_inbox_event(tx: &Transaction<'_>, event: InboxEvent<'_>) -> Result<(), String> { + let InboxEvent { + scope_key, + work_item_id, + recipient_id, + kind, + actor_id, + payload, + coalesce_key, + now, + } = event; + let raw = serde_json::to_string(payload) + .map_err(|err| format!("inbox event payload serialization: {err}"))?; + tx.execute( + "INSERT INTO pm_work_item_inbox_events ( + id, scope_key, work_item_id, recipient_id, kind, actor_id, + payload_json, coalesce_key, occurred_at, archived_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, NULL) + ON CONFLICT(recipient_id, coalesce_key) DO UPDATE SET + id = excluded.id, + kind = excluded.kind, + actor_id = excluded.actor_id, + payload_json = excluded.payload_json, + occurred_at = excluded.occurred_at, + archived_at = NULL", + params![ + format!("wie_{}", uuid::Uuid::new_v4().simple()), + scope_key, + work_item_id, + recipient_id, + kind, + actor_id, + raw, + coalesce_key, + now + ], + ) + .map_err(|err| format!("work item inbox event: {err}"))?; + Ok(()) +} + +pub(super) fn notify_comment( + tx: &Transaction<'_>, + notification: CommentNotification<'_>, +) -> Result<(), String> { + let CommentNotification { + scope_key, + work_item_id, + title, + comment_id, + author_id, + content, + mentioned_user_ids, + now, + } = notification; + ensure_subscription( + tx, + scope_key, + work_item_id, + author_id, + SubscriptionReason::Commenter, + now, + )?; + let mentioned = mentioned_user_ids + .iter() + .map(|value| value.trim()) + .filter(|value| !value.is_empty() && *value != author_id) + .map(str::to_string) + .collect::>(); + for recipient in &mentioned { + ensure_subscription( + tx, + scope_key, + work_item_id, + recipient, + SubscriptionReason::Mentioned, + now, + )?; + let payload = serde_json::json!({ + "title": title, + "commentId": comment_id, + "comment": content, + "mentioned": true, + }); + let coalesce_key = format!("mention:{comment_id}:{recipient}"); + upsert_inbox_event( + tx, + InboxEvent { + scope_key, + work_item_id, + recipient_id: recipient, + kind: "mention", + actor_id: Some(author_id), + payload: &payload, + coalesce_key: &coalesce_key, + now, + }, + )?; + } + + let mut statement = tx + .prepare( + "SELECT subscriber_id FROM pm_work_item_subscriptions + WHERE scope_key = ?1 AND work_item_id = ?2 AND muted_at IS NULL", + ) + .map_err(|err| format!("work item subscription: {err}"))?; + let subscribers = statement + .query_map(params![scope_key, work_item_id], |row| { + row.get::<_, String>(0) + }) + .map_err(|err| format!("work item subscription: {err}"))? + .collect::, _>>() + .map_err(|err| format!("work item subscription: {err}"))?; + drop(statement); + for recipient in subscribers { + if recipient == author_id || mentioned.contains(&recipient) { + continue; + } + let payload = serde_json::json!({ + "title": title, + "commentId": comment_id, + "comment": content, + "mentioned": false, + }); + let coalesce_key = format!("work-item:{scope_key}:{work_item_id}"); + upsert_inbox_event( + tx, + InboxEvent { + scope_key, + work_item_id, + recipient_id: &recipient, + kind: "discussion_updated", + actor_id: Some(author_id), + payload: &payload, + coalesce_key: &coalesce_key, + now, + }, + )?; + } + Ok(()) +} + +pub(crate) fn notify_run_terminal(run: &WorkItemRun) -> Result<(), String> { + if run.status.as_str() != "failed" { + return Ok(()); + } + let mut connection = conn()?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|err| format!("work item inbox tx: {err}"))?; + let scope = WorkItemScope { + project_slug: run.project_slug.clone(), + org_id: run.org_id.clone(), + work_item_id: run.work_item_id.clone(), + }; + let item = bootstrap_implicit_subscriptions(&tx, &scope)?; + let mut statement = tx + .prepare( + "SELECT subscriber_id FROM pm_work_item_subscriptions + WHERE scope_key = ?1 AND work_item_id = ?2 AND muted_at IS NULL", + ) + .map_err(|err| format!("work item subscription: {err}"))?; + let subscribers = statement + .query_map(params![item.scope_key, item.short_id], |row| { + row.get::<_, String>(0) + }) + .map_err(|err| format!("work item subscription: {err}"))? + .collect::, _>>() + .map_err(|err| format!("work item subscription: {err}"))?; + drop(statement); + let now = now_ms(); + for recipient in subscribers { + let payload = serde_json::json!({ + "title": item.title, + "runId": run.id, + "failure": run.failure, + }); + let coalesce_key = format!("work-item:{}:{}", item.scope_key, item.short_id); + upsert_inbox_event( + &tx, + InboxEvent { + scope_key: &item.scope_key, + work_item_id: &item.short_id, + recipient_id: &recipient, + kind: "run_failed", + actor_id: None, + payload: &payload, + coalesce_key: &coalesce_key, + now, + }, + )?; + } + tx.commit() + .map_err(|err| format!("work item inbox commit: {err}"))?; + Ok(()) +} diff --git a/src-tauri/crates/project-management/src/work_item_features/tests.rs b/src-tauri/crates/project-management/src/work_item_features/tests.rs new file mode 100644 index 0000000000..25a4cef4d2 --- /dev/null +++ b/src-tauri/crates/project-management/src/work_item_features/tests.rs @@ -0,0 +1,473 @@ +use axum::body::{to_bytes, Bytes}; +use axum::extract::Path; +use axum::http::{HeaderMap, HeaderValue, StatusCode}; +use serde_json::json; +use test_helpers::test_env; + +use super::*; +use crate::projects::io::helpers::conn; +use crate::projects::types::{ + AgentRole, LinkedSession, LinkedSessionStatus, LinkedSessionType, WorkItemCloseOut, + WorkItemCloseOutStatus, WorkItemWorkProduct, WorkItemWorkProductStatus, + WorkItemWorkProductType, +}; +use crate::routine_service::spec::{Activation, ActivationPolicies, RoutineSpecFile}; +use crate::work_service::{self, CreateWorkItemRequest}; + +fn scope() -> WorkItemScope { + WorkItemScope { + project_slug: Some("demo".to_string()), + org_id: "personal-org".to_string(), + work_item_id: "AAA-0001".to_string(), + } +} + +fn seed(linked_session: bool) { + work_service::tests_support::seed_project("demo", "project-1"); + work_service::create_project_work_item( + "demo", + "AAA-0001", + &CreateWorkItemRequest { + title: "Durable collaboration".to_string(), + body: "Ship the durable path and notify <@member-description>.".to_string(), + created_by: Some("creator-1".to_string()), + linked_sessions: linked_session + .then(|| LinkedSession { + session_id: "session-1".to_string(), + session_type: LinkedSessionType::Native, + agent_role: AgentRole::Coding, + started_at: "2026-08-08T10:00:00Z".to_string(), + completed_at: None, + status: LinkedSessionStatus::Running, + cost_usd: 0.0, + total_tokens: 0, + parent_session_id: None, + sub_agent_name: None, + sub_agent_instance: None, + result_preview: None, + }) + .into_iter() + .collect(), + ..Default::default() + }, + None, + ) + .expect("seed Work Item"); +} + +fn post(comment_id: &str, content: &str, parent_id: Option<&str>) -> DiscussionPostResult { + discussion::post(DiscussionPostRequest { + scope: scope(), + comment_id: comment_id.to_string(), + author_id: "member-1".to_string(), + author_name: "Member One".to_string(), + content: content.to_string(), + mentioned_user_ids: Vec::new(), + parent_id: parent_id.map(str::to_string), + target_session_id: None, + }) + .expect("post Discussion comment") +} + +#[test] +fn discussion_comment_and_run_are_atomic_and_threads_reopen_on_reply() { + let _sandbox = test_env::sandbox(); + seed(true); + + let root = post("comment-root", "Please include the retry proof.", None); + assert_eq!(root.wake_reason, "discussion_reply"); + assert!( + root.run.is_some(), + "a linked Session must be woken through a Run" + ); + work_service::note_project_work_item_threaded( + "demo", + "AAA-0001", + "comment", + "Agent receipt", + Some("comment-root"), + None, + ) + .expect("append agent receipt in the same thread"); + + let note = post("comment-note", "/note internal context only", None); + assert_eq!(note.wake_reason, "note_only"); + assert!(note.run.is_none(), "/note must persist without dispatching"); + + let reply = post( + "comment-reply", + "The proof is attached.", + Some("comment-root"), + ); + assert_eq!(reply.comment.thread_id.as_deref(), Some("comment-root")); + let resolved = discussion::resolve_thread(DiscussionThreadMutation { + scope: scope(), + thread_id: "comment-root".to_string(), + actor_id: "reviewer-1".to_string(), + conclusion_comment_id: Some("comment-reply".to_string()), + }) + .expect("resolve thread"); + assert!(resolved + .iter() + .any(|comment| comment.id == "comment-reply" && comment.conclusion)); + + let reopened = post( + "comment-after-resolution", + "One more question.", + Some("comment-reply"), + ); + assert!(reopened.thread_reopened); + let item = crate::projects::io::read_work_item("demo", "AAA-0001").expect("read item"); + let root = item + .frontmatter + .comments + .iter() + .find(|comment| comment.id == "comment-root") + .expect("root comment"); + assert!(root.resolved_at.is_none()); + assert!(item.frontmatter.comments.iter().any(|comment| { + comment.content == "Agent receipt" + && comment.parent_id.as_deref() == Some("comment-root") + && comment.thread_id.as_deref() == Some("comment-root") + })); + assert!(!item + .frontmatter + .comments + .iter() + .any(|comment| comment.id == "comment-reply" && comment.conclusion)); + + let connection = conn().expect("connection"); + let run_count: i64 = connection + .query_row( + "SELECT COUNT(*) FROM pm_work_item_runs WHERE work_item_id = 'AAA-0001'", + [], + |row| row.get(0), + ) + .expect("run count"); + let outbox_count: i64 = connection + .query_row("SELECT COUNT(*) FROM pm_dispatch_outbox", [], |row| { + row.get(0) + }) + .expect("outbox count"); + assert_eq!( + run_count, 3, + "root, reply, and reopened reply dispatch once each" + ); + assert_eq!(outbox_count, run_count); +} + +#[test] +fn discussion_mutation_commits_a_collaboration_outbox_row() { + let _sandbox = test_env::sandbox(); + seed(false); + crate::projects::io::configure_project_org_collab_sync("personal-org", Some("personal-org")) + .expect("enable collaboration"); + + post("comment-collab", "/note visible on peers", None); + + let connection = conn().expect("connection"); + let row: (String, String) = connection + .query_row( + "SELECT o.status, o.field_path + FROM outbox_entries o + JOIN workitems w ON w.id = o.entity_id + WHERE o.org_id = 'personal-org' + AND o.entity_type = 'work_item' + AND w.short_id = 'AAA-0001' + ORDER BY o.id DESC + LIMIT 1", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .expect("discussion collaboration row"); + assert_eq!(row.0, "pending"); + assert_eq!(row.1, "comments"); +} + +#[test] +fn subscriptions_coalesce_updates_but_keep_mentions_separate() { + let _sandbox = test_env::sandbox(); + seed(false); + subscriptions::subscribe(SubscriptionMutation { + scope: scope(), + subscriber_id: "watcher-1".to_string(), + }) + .expect("subscribe watcher"); + + for (id, body) in [("comment-1", "first"), ("comment-2", "second")] { + discussion::post(DiscussionPostRequest { + scope: scope(), + comment_id: id.to_string(), + author_id: "author-1".to_string(), + author_name: "Author".to_string(), + content: body.to_string(), + mentioned_user_ids: vec!["mentioned-1".to_string()], + parent_id: None, + target_session_id: None, + }) + .expect("post comment"); + } + + let connection = conn().expect("connection"); + let watcher_events: i64 = connection + .query_row( + "SELECT COUNT(*) FROM pm_work_item_inbox_events + WHERE recipient_id = 'watcher-1' AND kind = 'discussion_updated'", + [], + |row| row.get(0), + ) + .expect("watcher event count"); + let mention_events: i64 = connection + .query_row( + "SELECT COUNT(*) FROM pm_work_item_inbox_events + WHERE recipient_id = 'mentioned-1' AND kind = 'mention'", + [], + |row| row.get(0), + ) + .expect("mention event count"); + assert_eq!(watcher_events, 1, "ordinary updates coalesce per Work Item"); + assert_eq!(mention_events, 2, "mentions are never coalesced away"); + + let page = crate::team_inbox::list_page(crate::team_inbox::TeamInboxListOptions { + viewer_member_ids: vec!["watcher-1".to_string()], + filter: crate::team_inbox::TeamInboxFilter::All, + cursor: None, + limit: 20, + }) + .expect("project subscription event into Team Inbox"); + assert!(page.items.iter().any(|item| { + item.kind == crate::team_inbox::TeamInboxItemKind::WorkItemUpdated + && matches!( + &item.payload, + crate::team_inbox::TeamInboxPayload::WorkItemUpdated { event_kind, .. } + if event_kind == "discussion_updated" + ) + })); +} + +#[test] +fn typed_properties_validate_values_and_keep_archived_history() { + let _sandbox = test_env::sandbox(); + seed(false); + let definition = properties::upsert_definition(UpsertPropertyDefinitionRequest { + id: Some("prop_effort".to_string()), + org_id: "personal-org".to_string(), + name: "Effort".to_string(), + property_type: PropertyType::Number, + description: None, + config: PropertyConfig::default(), + position: 0, + }) + .expect("create property"); + let invalid = properties::set_value(SetWorkItemPropertyValueRequest { + scope: scope(), + property_id: definition.id.clone(), + value: Some(json!("large")), + }) + .expect_err("number property rejects text"); + assert!(invalid.contains("expects a number"), "{invalid}"); + + properties::set_value(SetWorkItemPropertyValueRequest { + scope: scope(), + property_id: definition.id.clone(), + value: Some(json!(8.5)), + }) + .expect("set number"); + let renamed = properties::upsert_definition(UpsertPropertyDefinitionRequest { + id: Some(definition.id.clone()), + org_id: "personal-org".to_string(), + name: "Estimated effort".to_string(), + property_type: PropertyType::Number, + description: None, + config: PropertyConfig::default(), + position: 0, + }) + .expect("rename property"); + assert_eq!( + renamed.id, definition.id, + "renames preserve property identity" + ); + properties::archive_definition(&definition.id).expect("archive property"); + + let values = properties::list_values(&scope()).expect("list historical values"); + assert_eq!(values.len(), 1); + assert_eq!(values[0].definition.name, "Estimated effort"); + assert!(values[0].definition.archived_at.is_some()); + assert_eq!(values[0].value, json!(8.5)); +} + +#[test] +fn pr_readiness_requires_current_execution_evidence_and_close_intent() { + let _sandbox = test_env::sandbox(); + seed(false); + let product = WorkItemWorkProduct { + id: "pr-1".to_string(), + session_id: Some("session-1".to_string()), + product_type: WorkItemWorkProductType::PullRequest, + title: "PR #123".to_string(), + provider: Some("github".to_string()), + external_id: Some("123".to_string()), + url: Some("https://github.com/org/repo/pull/123".to_string()), + status: Some(WorkItemWorkProductStatus::Merged), + review_state: None, + is_primary: true, + summary: None, + metadata: serde_json::Map::from_iter([ + ("mergeable".to_string(), json!(true)), + ("ciStatus".to_string(), json!("success")), + ]), + created_at: "2026-08-08T10:00:00Z".to_string(), + updated_at: "2026-08-08T10:05:00Z".to_string(), + }; + let close_out = WorkItemCloseOut { + status: WorkItemCloseOutStatus::Done, + session_id: Some("session-1".to_string()), + reviewer_target: None, + summary: Some("Merged and ready to close".to_string()), + decision_reason: None, + next_owner: None, + created_at: Some("2026-08-08T10:05:00Z".to_string()), + resolved_at: Some("2026-08-08T10:05:00Z".to_string()), + }; + let connection = conn().expect("connection"); + let row_id: String = connection + .query_row( + "SELECT id FROM workitems WHERE short_id = 'AAA-0001'", + [], + |row| row.get(0), + ) + .expect("row id"); + connection + .execute( + "UPDATE workitem_extras SET extras_json = ?2 WHERE work_item_id = ?1", + rusqlite::params![ + row_id, + json!({ + "work_products": [product], + "close_out": close_out, + }) + .to_string() + ], + ) + .expect("persist PR evidence"); + + let ready = readiness::get(&scope()).expect("ready state"); + assert!(ready.can_complete, "{:?}", ready.blockers); + + let request = crate::projects::types::EnqueueWorkItemRunRequest { + project_slug: Some("demo".to_string()), + org_id: "personal-org".to_string(), + work_item_id: "AAA-0001".to_string(), + trigger: crate::projects::types::WorkItemRunTrigger::Manual, + target_snapshot: crate::projects::types::WorkItemRunTargetSnapshot::new( + crate::projects::types::WorkItemRunTarget::StartWorkItem { + account_id: None, + model_id: None, + }, + ), + input: json!({}), + idempotency_key: "readiness-snapshot".to_string(), + max_attempts: 1, + parent_run_id: None, + }; + crate::work_run_service::enqueue(request).expect("capture execution snapshot"); + connection + .execute( + "UPDATE workitems SET local_version = local_version + 1 WHERE id = ?1", + rusqlite::params![row_id], + ) + .expect("advance Work Item revision"); + let stale = readiness::get(&scope()).expect("stale state"); + assert!(stale.snapshot_stale); + assert!(!stale.can_complete); + assert!(readiness::guard_completion(&scope()).is_err()); +} + +fn routine_fixture() -> RoutineSpecFile { + let raw = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../docs/orgtrack-pm-protocol/fixtures/routine-spec.json"), + ) + .expect("fixture readable"); + let mut file: RoutineSpecFile = serde_json::from_str(&raw).expect("fixture parses"); + file.spec.activations.push(Activation::ProviderEvent { + provider: "github".to_string(), + event_kind: "pull_request".to_string(), + filter: Some(json!({ "action": "closed" })), + policies: ActivationPolicies::default(), + }); + file +} + +#[tokio::test] +async fn provider_webhook_authenticates_filters_and_deduplicates_deliveries() { + let _sandbox = test_env::sandbox(); + let fixture = routine_fixture(); + crate::routine_service::apply(&fixture).expect("apply Routine"); + let install = routine_webhook::install(&fixture.metadata.name).expect("install webhook"); + + let mut invalid_headers = HeaderMap::new(); + invalid_headers.insert("x-org2-webhook-token", HeaderValue::from_static("wrong")); + invalid_headers.insert("x-org2-provider", HeaderValue::from_static("github")); + invalid_headers.insert("x-org2-event", HeaderValue::from_static("pull_request")); + invalid_headers.insert("x-org2-delivery-id", HeaderValue::from_static("delivery-1")); + let invalid = routine_webhook::handle_http( + Path(fixture.metadata.name.clone()), + invalid_headers, + Bytes::from_static(br#"{"action":"opened"}"#), + ) + .await; + assert_eq!(invalid.status(), StatusCode::UNAUTHORIZED); + + let request_headers = || { + let mut headers = HeaderMap::new(); + headers.insert( + "x-org2-webhook-token", + HeaderValue::from_str(&install.secret).expect("secret header"), + ); + headers.insert("x-org2-provider", HeaderValue::from_static("github")); + headers.insert("x-org2-event", HeaderValue::from_static("pull_request")); + headers.insert("x-org2-delivery-id", HeaderValue::from_static("delivery-1")); + headers + }; + let first = routine_webhook::handle_http( + Path(fixture.metadata.name.clone()), + request_headers(), + Bytes::from_static(br#"{"action":"opened"}"#), + ) + .await; + assert_eq!(first.status(), StatusCode::ACCEPTED); + let first: RoutineWebhookDelivery = serde_json::from_slice( + &to_bytes(first.into_body(), 1024 * 1024) + .await + .expect("response body"), + ) + .expect("delivery response"); + assert_eq!( + first.status, "ignored", + "filter mismatch must not invoke the Routine" + ); + + let replayed = routine_webhook::handle_http( + Path(fixture.metadata.name), + request_headers(), + Bytes::from_static(br#"{"action":"opened"}"#), + ) + .await; + let replayed: RoutineWebhookDelivery = serde_json::from_slice( + &to_bytes(replayed.into_body(), 1024 * 1024) + .await + .expect("response body"), + ) + .expect("delivery response"); + assert_eq!( + replayed.id, first.id, + "delivery idempotency returns the original row" + ); + assert_eq!( + routine_webhook::list_deliveries(&replayed.routine_name, 20) + .expect("list deliveries") + .len(), + 1 + ); +} diff --git a/src-tauri/crates/project-management/src/work_item_features/types.rs b/src-tauri/crates/project-management/src/work_item_features/types.rs new file mode 100644 index 0000000000..f0a67cd14a --- /dev/null +++ b/src-tauri/crates/project-management/src/work_item_features/types.rs @@ -0,0 +1,289 @@ +use serde::{Deserialize, Serialize}; + +use crate::projects::types::{CommentEntry, WorkItemRun}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkItemScope { + pub project_slug: Option, + #[serde(default = "default_org_id")] + pub org_id: String, + pub work_item_id: String, +} + +fn default_org_id() -> String { + "personal-org".to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DiscussionPostRequest { + #[serde(flatten)] + pub scope: WorkItemScope, + pub comment_id: String, + pub author_id: String, + pub author_name: String, + pub content: String, + #[serde(default)] + pub mentioned_user_ids: Vec, + pub parent_id: Option, + pub target_session_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DiscussionPostResult { + pub comment: CommentEntry, + pub run: Option, + pub thread_reopened: bool, + pub wake_reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DiscussionTriggerPreview { + pub will_wake: bool, + pub reason: String, + pub target_session_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DiscussionTriggerPreviewRequest { + #[serde(flatten)] + pub scope: WorkItemScope, + pub content: String, + pub target_session_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DiscussionThreadMutation { + #[serde(flatten)] + pub scope: WorkItemScope, + pub thread_id: String, + pub actor_id: String, + pub conclusion_comment_id: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SubscriptionReason { + Creator, + Assignee, + Commenter, + Mentioned, + Manual, + Agent, + Delegated, +} + +impl SubscriptionReason { + pub fn as_str(self) -> &'static str { + match self { + Self::Creator => "creator", + Self::Assignee => "assignee", + Self::Commenter => "commenter", + Self::Mentioned => "mentioned", + Self::Manual => "manual", + Self::Agent => "agent", + Self::Delegated => "delegated", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkItemSubscription { + pub subscriber_id: String, + pub reason: SubscriptionReason, + pub created_at: String, + pub muted_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SubscriptionMutation { + #[serde(flatten)] + pub scope: WorkItemScope, + pub subscriber_id: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PropertyType { + Text, + Number, + Select, + MultiSelect, + Date, + Checkbox, + Url, +} + +impl PropertyType { + pub fn as_str(self) -> &'static str { + match self { + Self::Text => "text", + Self::Number => "number", + Self::Select => "select", + Self::MultiSelect => "multi_select", + Self::Date => "date", + Self::Checkbox => "checkbox", + Self::Url => "url", + } + } +} + +impl TryFrom<&str> for PropertyType { + type Error = String; + + fn try_from(value: &str) -> Result { + match value { + "text" => Ok(Self::Text), + "number" => Ok(Self::Number), + "select" => Ok(Self::Select), + "multi_select" => Ok(Self::MultiSelect), + "date" => Ok(Self::Date), + "checkbox" => Ok(Self::Checkbox), + "url" => Ok(Self::Url), + other => Err(format!("unknown property type '{other}'")), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PropertyOption { + pub id: String, + pub name: String, + pub color: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PropertyConfig { + #[serde(default)] + pub options: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PropertyDefinition { + pub id: String, + pub org_id: String, + pub name: String, + pub property_type: PropertyType, + pub description: Option, + pub config: PropertyConfig, + pub position: i64, + pub archived_at: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpsertPropertyDefinitionRequest { + pub id: Option, + pub org_id: String, + pub name: String, + pub property_type: PropertyType, + pub description: Option, + #[serde(default)] + pub config: PropertyConfig, + #[serde(default)] + pub position: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkItemPropertyValue { + pub definition: PropertyDefinition, + pub value: serde_json::Value, + pub updated_at: String, +} + +/// Durable collaboration projection for one typed-property value. +/// +/// A JSON `null` value is a tombstone. Keeping clears on the wire avoids +/// resurrecting an older value when another device pulls after the clear. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SyncedWorkItemPropertyValue { + pub property_id: String, + pub value: serde_json::Value, + pub updated_at: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct TypedPropertyWireSnapshot { + #[serde(default)] + pub definitions: Vec, + #[serde(default)] + pub values: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetWorkItemPropertyValueRequest { + #[serde(flatten)] + pub scope: WorkItemScope, + pub property_id: String, + pub value: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PrReadiness { + pub state: String, + pub pr_url: Option, + pub pr_status: Option, + pub is_draft: bool, + pub mergeable: Option, + pub ci_status: Option, + pub failed_checks: Vec, + pub other_open_prs: Vec, + pub snapshot_stale: bool, + pub close_intent: bool, + pub can_complete: bool, + pub blockers: Vec, + pub evidence_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RoutineWebhookInstallInfo { + pub routine_name: String, + pub url_path: String, + pub secret: String, + pub secret_hint: String, + pub rotated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RoutineWebhookStatus { + pub routine_name: String, + pub installed: bool, + pub enabled: bool, + pub secret_hint: Option, + pub consecutive_failures: u32, + pub paused_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RoutineWebhookDelivery { + pub id: String, + pub routine_name: String, + pub provider: String, + pub event_kind: String, + pub idempotency_key: String, + pub status: String, + pub reason: Option, + pub routine_run_id: Option, + pub created_at: String, + pub updated_at: String, +} diff --git a/src-tauri/crates/project-management/src/work_run_service/mod.rs b/src-tauri/crates/project-management/src/work_run_service/mod.rs new file mode 100644 index 0000000000..19e62aad78 --- /dev/null +++ b/src-tauri/crates/project-management/src/work_run_service/mod.rs @@ -0,0 +1,1590 @@ +//! Durable Work Item Run application service. +//! +//! This module is the single persistence boundary for execution episodes and +//! dispatch delivery. Enqueue writes the Run and outbox row atomically; +//! workers claim with expiring leases; every acknowledgement checks the lease +//! token. Work Item lifecycle is intentionally absent from this module. + +use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBehavior}; +use sha2::{Digest, Sha256}; + +use crate::projects::io::helpers::{conn, now_ms}; +use crate::projects::types::{ + EnqueueWorkItemRunRequest, WorkItemDispatchLease, WorkItemRun, WorkItemRunFailure, + WorkItemRunFailureClass, WorkItemRunRetryDisposition, WorkItemRunStatus, WorkItemRunTarget, + WorkItemRunUsage, PERSONAL_ORG_ID, +}; +use crate::work_service; + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; + +pub mod error { + pub const PREFIX: &str = "PM_RUN_ERR:"; + pub const INVALID_REQUEST: &str = "PM_RUN_ERR:INVALID_REQUEST"; + pub const NOT_FOUND: &str = "PM_RUN_ERR:NOT_FOUND"; + pub const IDEMPOTENCY_CONFLICT: &str = "PM_RUN_ERR:IDEMPOTENCY_CONFLICT"; + pub const STALE_LEASE: &str = "PM_RUN_ERR:STALE_LEASE"; + pub const INVALID_TRANSITION: &str = "PM_RUN_ERR:INVALID_TRANSITION"; + pub const RETRY_NOT_ALLOWED: &str = "PM_RUN_ERR:RETRY_NOT_ALLOWED"; + pub const PATH_LOCKED: &str = "PM_RUN_ERR:PATH_LOCKED"; +} + +const RUN_COLUMNS: &str = "id, project_slug, org_id, work_item_id, trigger_json, + target_json, input_json, status, attempt, max_attempts, parent_run_id, + session_id, failure_json, usage_json, idempotency_key, generation, + created_at, updated_at, started_at, completed_at"; +const DEFAULT_LEASE_MS: i64 = 30_000; +const MAX_LEASE_MS: i64 = 5 * 60_000; +const MAX_RUN_ATTEMPTS: u32 = 10; +const PATH_LOCK_TTL_MS: i64 = 7 * 24 * 60 * 60 * 1_000; + +#[derive(Debug)] +struct WorkItemExecutionContext { + org_id: String, + revision: i64, + title: String, + body: String, + project_description: Option, + linked_repositories: Vec, + configured_workspace_path: Option, + configured_workspace_mode: Option, + agent_definition_id: Option, + agent_org_id: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WorkItemRunTerminalOutcome { + Succeeded, + Failed, + Cancelled, +} + +fn db(result: rusqlite::Result) -> Result { + result.map_err(|err| format!("work run store: {err}")) +} + +fn iso8601(epoch_ms: i64) -> String { + chrono::DateTime::from_timestamp_millis(epoch_ms) + .map(|value| value.to_rfc3339()) + .unwrap_or_else(|| epoch_ms.to_string()) +} + +fn scope_key(project_slug: Option<&str>, org_id: &str) -> String { + match project_slug { + Some(slug) => format!("project:{slug}"), + None => format!("org:{org_id}"), + } +} + +/// Session-plane org scopes may arrive as `cloud:`, while the PM store +/// persists the local project-org id without that transport prefix. Unknown +/// scopes follow the same contract as standalone Work Item bootstrap and land +/// in the personal org rather than creating an unreadable split scope. +fn canonical_standalone_org_id( + connection: &Connection, + raw_org_id: &str, +) -> Result { + let bare = raw_org_id + .trim() + .strip_prefix("cloud:") + .unwrap_or(raw_org_id.trim()); + if bare.is_empty() || bare == PERSONAL_ORG_ID { + return Ok(PERSONAL_ORG_ID.to_string()); + } + let exists = db(connection + .query_row( + "SELECT 1 FROM project_orgs WHERE id = ?1", + params![bare], + |_| Ok(()), + ) + .optional())? + .is_some(); + Ok(if exists { + bare.to_string() + } else { + PERSONAL_ORG_ID.to_string() + }) +} + +fn canonical_hash(request: &EnqueueWorkItemRunRequest) -> Result { + let json = serde_json::to_vec(request) + .map_err(|err| format!("work run request serialization: {err}"))?; + Ok(hex::encode(Sha256::digest(json))) +} + +#[allow(clippy::type_complexity)] +fn query_stored_run( + connection: &Connection, + run_id: &str, +) -> Result< + Option<( + String, + Option, + String, + String, + String, + String, + String, + String, + i64, + i64, + Option, + Option, + Option, + Option, + String, + i64, + i64, + i64, + Option, + Option, + )>, + String, +> { + let sql = format!("SELECT {RUN_COLUMNS} FROM pm_work_item_runs WHERE id = ?1"); + db(connection + .query_row(&sql, params![run_id], |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + row.get(7)?, + row.get(8)?, + row.get(9)?, + row.get(10)?, + row.get(11)?, + row.get(12)?, + row.get(13)?, + row.get(14)?, + row.get(15)?, + row.get(16)?, + row.get(17)?, + row.get(18)?, + row.get(19)?, + )) + }) + .optional()) +} + +fn decode_run(connection: &Connection, run_id: &str) -> Result, String> { + let Some(( + id, + project_slug, + org_id, + work_item_id, + trigger_json, + target_json, + input_json, + status, + attempt, + max_attempts, + parent_run_id, + session_id, + failure_json, + usage_json, + idempotency_key, + generation, + created_at, + updated_at, + started_at, + completed_at, + )) = query_stored_run(connection, run_id)? + else { + return Ok(None); + }; + + let trigger = serde_json::from_str(&trigger_json) + .map_err(|err| format!("work run {id}: invalid trigger snapshot: {err}"))?; + let target_snapshot = serde_json::from_str(&target_json) + .map_err(|err| format!("work run {id}: invalid target snapshot: {err}"))?; + let input = serde_json::from_str(&input_json) + .map_err(|err| format!("work run {id}: invalid input snapshot: {err}"))?; + let failure = failure_json + .as_deref() + .map(serde_json::from_str) + .transpose() + .map_err(|err| format!("work run {id}: invalid failure snapshot: {err}"))?; + let usage = usage_json + .as_deref() + .map(serde_json::from_str) + .transpose() + .map_err(|err| format!("work run {id}: invalid usage snapshot: {err}"))? + .unwrap_or_default(); + + Ok(Some(WorkItemRun { + id, + project_slug, + org_id, + work_item_id, + trigger, + target_snapshot, + input, + status: WorkItemRunStatus::try_from(status.as_str())?, + attempt: u32::try_from(attempt) + .map_err(|_| format!("work run attempt out of range: {attempt}"))?, + max_attempts: u32::try_from(max_attempts) + .map_err(|_| format!("work run max_attempts out of range: {max_attempts}"))?, + parent_run_id, + session_id, + failure, + usage, + idempotency_key, + generation: u64::try_from(generation) + .map_err(|_| format!("work run generation out of range: {generation}"))?, + created_at: iso8601(created_at), + updated_at: iso8601(updated_at), + started_at: started_at.map(iso8601), + completed_at: completed_at.map(iso8601), + })) +} + +fn require_run(connection: &Connection, run_id: &str) -> Result { + decode_run(connection, run_id)?.ok_or_else(|| format!("{}:{}", error::NOT_FOUND, run_id)) +} + +fn resolve_work_item_scope( + tx: &Transaction<'_>, + request: &EnqueueWorkItemRunRequest, +) -> Result { + match request.project_slug.as_deref() { + Some(slug) if !slug.trim().is_empty() => db(tx + .query_row( + "SELECT p.org_id, w.local_version, w.title, w.body, + NULLIF(TRIM(p.description), ''), p.linked_repos_json, + json_extract(e.extras_json, '$.orchestrator_config.worktree_path'), + json_extract(e.extras_json, '$.orchestrator_config.workspace_mode'), + json_extract(e.extras_json, '$.orchestrator_config.agent_definition_id'), + json_extract(e.extras_json, '$.orchestrator_config.org_id') + FROM workitems w + JOIN projects p ON p.id = w.project_id + LEFT JOIN workitem_extras e ON e.work_item_id = w.id + WHERE p.slug = ?1 AND w.short_id = ?2 AND w.deleted_at IS NULL", + params![slug, request.work_item_id], + |row| { + let linked_json: String = row.get(5)?; + Ok(WorkItemExecutionContext { + org_id: row.get(0)?, + revision: row.get(1)?, + title: row.get(2)?, + body: row.get::<_, Option>(3)?.unwrap_or_default(), + project_description: row.get(4)?, + linked_repositories: serde_json::from_str(&linked_json).unwrap_or_default(), + configured_workspace_path: row.get(6)?, + configured_workspace_mode: row.get::<_, Option>(7)?.and_then( + |value| serde_json::from_value(serde_json::Value::String(value)).ok(), + ), + agent_definition_id: row.get(8)?, + agent_org_id: row.get(9)?, + }) + }, + ) + .optional())? + .ok_or_else(|| { + format!( + "{}:work item {}/{} not found", + error::INVALID_REQUEST, + slug, + request.work_item_id + ) + }), + Some(_) => Err(format!( + "{}:project_slug cannot be blank", + error::INVALID_REQUEST + )), + None => { + let org_id = canonical_standalone_org_id(tx, &request.org_id)?; + db(tx + .query_row( + "SELECT w.org_id, w.local_version, w.title, w.body, + json_extract(e.extras_json, '$.orchestrator_config.worktree_path'), + json_extract(e.extras_json, '$.orchestrator_config.workspace_mode'), + json_extract(e.extras_json, '$.orchestrator_config.agent_definition_id'), + json_extract(e.extras_json, '$.orchestrator_config.org_id') + FROM workitems w + LEFT JOIN workitem_extras e ON e.work_item_id = w.id + WHERE w.project_id IS NULL AND w.org_id = ?1 AND w.short_id = ?2 + AND w.deleted_at IS NULL", + params![org_id, request.work_item_id], + |row| { + Ok(WorkItemExecutionContext { + org_id: row.get(0)?, + revision: row.get(1)?, + title: row.get(2)?, + body: row.get::<_, Option>(3)?.unwrap_or_default(), + project_description: None, + linked_repositories: Vec::new(), + configured_workspace_path: row.get(4)?, + configured_workspace_mode: row.get::<_, Option>(5)?.and_then( + |value| { + serde_json::from_value(serde_json::Value::String(value)).ok() + }, + ), + agent_definition_id: row.get(6)?, + agent_org_id: row.get(7)?, + }) + }, + ) + .optional())? + .ok_or_else(|| { + format!( + "{}:standalone work item {}/{} not found", + error::INVALID_REQUEST, + org_id, + request.work_item_id + ) + }) + } + } +} + +fn git_value(workspace_path: &str, args: &[&str]) -> Option { + let output = std::process::Command::new("git") + .arg("-C") + .arg(workspace_path) + .args(args) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let value = String::from_utf8(output.stdout).ok()?.trim().to_string(); + (!value.is_empty()).then_some(value) +} + +fn hydrate_target_snapshot( + request: &mut EnqueueWorkItemRunRequest, + context: WorkItemExecutionContext, +) { + request.org_id = context.org_id; + let snapshot = &mut request.target_snapshot; + snapshot.work_item_revision = context.revision; + snapshot.work_item_title = Some(context.title); + snapshot.work_item_body = Some(context.body); + snapshot.project_description = context.project_description; + if snapshot.linked_repositories.is_empty() { + snapshot.linked_repositories = context + .linked_repositories + .into_iter() + .filter(|value| !value.trim().is_empty()) + .collect(); + } + let has_configured_workspace = context + .configured_workspace_path + .as_deref() + .is_some_and(|value| !value.trim().is_empty()); + if snapshot.workspace_path.as_deref().is_none_or(str::is_empty) { + snapshot.workspace_path = context + .configured_workspace_path + .filter(|value| !value.trim().is_empty()) + .or_else(|| snapshot.linked_repositories.first().cloned()); + } + if snapshot.workspace_mode.is_none() { + snapshot.workspace_mode = context.configured_workspace_mode.or_else(|| { + // A path inherited from a project's linked repositories is the + // primary checkout unless the Work Item explicitly says it is a + // registered worktree. + (!has_configured_workspace) + .then_some(crate::projects::types::WorkspaceExecutionMode::LocalWorkspace) + }); + } + if let Some(workspace_path) = snapshot.workspace_path.as_mut() { + if let Ok(canonical) = std::fs::canonicalize(&*workspace_path) { + *workspace_path = canonical.to_string_lossy().into_owned(); + } + snapshot.repository = git_value(workspace_path, &["remote", "get-url", "origin"]) + .or_else(|| Some(workspace_path.clone())); + snapshot.repository_ref = git_value(workspace_path, &["rev-parse", "HEAD"]); + snapshot.default_branch = git_value( + workspace_path, + &["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], + ) + .and_then(|value| { + value + .strip_prefix("origin/") + .map(str::to_string) + .or(Some(value)) + }); + } + if snapshot.agent_definition_id.is_none() { + snapshot.agent_definition_id = context.agent_definition_id; + } + if snapshot.agent_org_id.is_none() { + snapshot.agent_org_id = context.agent_org_id; + } +} + +fn append_audit( + tx: &Transaction<'_>, + run_id: &str, + operation: &str, + revision: i64, + project_slug: Option<&str>, + org_id: &str, + payload: serde_json::Value, +) -> Result<(), String> { + let seq = work_service::audit::bump_change_seq(tx)?; + work_service::audit::append_audit_event( + tx, + &work_service::audit::AuditEventRow { + operation, + entity_type: "work_item_run", + entity_id: run_id, + project_slug, + org_id: Some(org_id), + actor: None, + revision, + seq, + payload, + }, + ) +} + +/// Atomically create one Work Item Run and its first dispatch row. +/// +/// Replaying the same idempotency key with an identical canonical request +/// returns the existing Run. Reusing the key with different content is a +/// typed conflict. +pub fn enqueue(request: EnqueueWorkItemRunRequest) -> Result { + enqueue_with_initial_delay(request, 0) +} + +/// Persist a Run for a caller that will deliver it synchronously. +/// +/// The outbox row is committed with a short future `available_at`, which +/// gives the caller time to claim this exact Run without racing the desktop +/// worker. If the process dies before that claim, the ordinary worker picks +/// it up after the delay, preserving crash recovery. +pub fn enqueue_for_inline_dispatch( + request: EnqueueWorkItemRunRequest, +) -> Result { + enqueue_with_initial_delay(request, DEFAULT_LEASE_MS) +} + +fn enqueue_with_initial_delay( + request: EnqueueWorkItemRunRequest, + initial_delay_ms: i64, +) -> Result { + let mut connection = conn()?; + let tx = db(connection.transaction_with_behavior(TransactionBehavior::Immediate))?; + let run = enqueue_in_transaction(&tx, request, initial_delay_ms)?; + db(tx.commit())?; + Ok(run) +} + +/// Internal composition point for producers that must commit domain state and +/// its execution dispatch atomically (for example, a Discussion comment). +/// The caller owns the surrounding `IMMEDIATE` transaction. +pub(crate) fn enqueue_in_transaction( + tx: &Transaction<'_>, + mut request: EnqueueWorkItemRunRequest, + initial_delay_ms: i64, +) -> Result { + if request.work_item_id.trim().is_empty() || request.idempotency_key.trim().is_empty() { + return Err(format!( + "{}:work_item_id and idempotency_key are required", + error::INVALID_REQUEST + )); + } + if request.max_attempts == 0 || request.max_attempts > MAX_RUN_ATTEMPTS { + return Err(format!( + "{}:max_attempts must be between 1 and {MAX_RUN_ATTEMPTS}", + error::INVALID_REQUEST + )); + } + + let execution_context = resolve_work_item_scope(tx, &request)?; + hydrate_target_snapshot(&mut request, execution_context); + let revision = request.target_snapshot.work_item_revision; + + let scope = scope_key(request.project_slug.as_deref(), &request.org_id); + let request_hash = canonical_hash(&request)?; + let existing: Option<(String, String)> = db(tx + .query_row( + "SELECT id, request_hash FROM pm_work_item_runs + WHERE scope_key = ?1 AND work_item_id = ?2 AND idempotency_key = ?3", + params![scope, request.work_item_id, request.idempotency_key], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional())?; + if let Some((run_id, stored_hash)) = existing { + if stored_hash != request_hash { + return Err(format!( + "{}:{}", + error::IDEMPOTENCY_CONFLICT, + request.idempotency_key + )); + } + return require_run(tx, &run_id); + } + + let attempt = if let Some(parent_run_id) = request.parent_run_id.as_deref() { + let parent = require_run(tx, parent_run_id)?; + if parent.project_slug != request.project_slug + || parent.org_id != request.org_id + || parent.work_item_id != request.work_item_id + { + return Err(format!( + "{}:parent Run belongs to another Work Item", + error::INVALID_REQUEST + )); + } + parent.attempt.saturating_add(1) + } else { + 1 + }; + if attempt > request.max_attempts { + return Err(format!( + "{}:attempt {attempt} exceeds max_attempts {}", + error::RETRY_NOT_ALLOWED, + request.max_attempts + )); + } + + let run_id = format!("wir_{}", uuid::Uuid::new_v4().simple()); + let dispatch_id = format!("wid_{}", uuid::Uuid::new_v4().simple()); + let now = now_ms(); + let available_at = now.saturating_add(initial_delay_ms.max(0)); + let trigger_json = serde_json::to_string(&request.trigger) + .map_err(|err| format!("work run trigger serialization: {err}"))?; + let target_json = serde_json::to_string(&request.target_snapshot) + .map_err(|err| format!("work run target serialization: {err}"))?; + let input_json = serde_json::to_string(&request.input) + .map_err(|err| format!("work run input serialization: {err}"))?; + let usage_json = serde_json::to_string(&WorkItemRunUsage::default()) + .map_err(|err| format!("work run usage serialization: {err}"))?; + + db(tx.execute( + "INSERT INTO pm_work_item_runs ( + id, scope_key, project_slug, org_id, work_item_id, + work_item_revision, trigger_kind, trigger_json, target_json, + input_json, status, attempt, max_attempts, parent_run_id, + session_id, failure_json, usage_json, idempotency_key, + request_hash, generation, created_at, updated_at + ) VALUES ( + ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, + 'queued', ?11, ?12, ?13, NULL, NULL, ?14, ?15, ?16, 1, ?17, ?17 + )", + params![ + run_id, + scope, + request.project_slug, + request.org_id, + request.work_item_id, + revision, + request.trigger.kind(), + trigger_json, + target_json, + input_json, + attempt, + request.max_attempts, + request.parent_run_id, + usage_json, + request.idempotency_key, + request_hash, + now, + ], + ))?; + db(tx.execute( + "INSERT INTO pm_dispatch_outbox ( + id, run_id, generation, status, delivery_attempt, available_at, + created_at, updated_at + ) VALUES (?1, ?2, 1, 'pending', 0, ?3, ?4, ?4)", + params![dispatch_id, run_id, available_at, now], + ))?; + append_audit( + tx, + &run_id, + "work_run.enqueue", + 1, + request.project_slug.as_deref(), + &request.org_id, + serde_json::json!({ + "workItemId": request.work_item_id, + "trigger": request.trigger.kind(), + "dispatchId": dispatch_id, + "attempt": attempt, + }), + )?; + require_run(tx, &run_id) +} + +pub fn read(run_id: &str) -> Result { + let connection = conn()?; + require_run(&connection, run_id) +} + +/// List execution episodes whose dispatch already owns a Session but whose +/// Run has not reached a durable terminal state yet. +/// +/// Startup recovery uses this projection after the Session store has marked +/// process-interrupted sessions as abandoned. Keeping the query in the Run +/// service preserves the package boundary: agent-core never reaches into PM +/// tables directly. +pub fn list_active_session_runs() -> Result, String> { + let connection = conn()?; + let ids = { + let mut statement = db(connection.prepare( + "SELECT id FROM pm_work_item_runs + WHERE session_id IS NOT NULL + AND status IN ('dispatching', 'running', 'waiting') + ORDER BY COALESCE(started_at, created_at) ASC, created_at ASC, id ASC", + ))?; + let rows = db(statement.query_map([], |row| row.get::<_, String>(0)))?; + db(rows.collect::>>())? + }; + ids.into_iter() + .map(|run_id| require_run(&connection, &run_id)) + .collect() +} + +pub fn list_for_work_item( + project_slug: Option<&str>, + org_id: &str, + work_item_id: &str, + limit: usize, +) -> Result, String> { + let connection = conn()?; + let canonical_org_id = match project_slug { + Some(slug) => db(connection + .query_row( + "SELECT org_id FROM projects WHERE slug = ?1", + params![slug], + |row| row.get::<_, String>(0), + ) + .optional())? + .unwrap_or_else(|| org_id.to_string()), + None => canonical_standalone_org_id(&connection, org_id)?, + }; + let scope = scope_key(project_slug, &canonical_org_id); + let bounded_limit = limit.clamp(1, 200) as i64; + let mut statement = db(connection.prepare( + "SELECT id FROM pm_work_item_runs + WHERE scope_key = ?1 AND work_item_id = ?2 + ORDER BY created_at DESC, id DESC LIMIT ?3", + ))?; + let ids = db( + statement.query_map(params![scope, work_item_id, bounded_limit], |row| { + row.get::<_, String>(0) + }), + )?; + let mut runs = Vec::new(); + for id in ids { + runs.push(require_run(&connection, &db(id)?)?); + } + Ok(runs) +} + +/// Newest execution episode attached to a Session, regardless of terminal +/// state. Used to attribute automatic goal-loop continuations as follow-ups +/// without conflating them with a fresh manual start. +pub fn latest_for_session(session_id: &str) -> Result, String> { + if session_id.trim().is_empty() { + return Err(format!("{}:session_id is required", error::INVALID_REQUEST)); + } + let connection = conn()?; + let run_id: Option = db(connection + .query_row( + "SELECT id FROM pm_work_item_runs + WHERE session_id = ?1 + ORDER BY COALESCE(started_at, created_at) DESC, created_at DESC, id DESC + LIMIT 1", + params![session_id], + |row| row.get(0), + ) + .optional())?; + run_id + .map(|run_id| require_run(&connection, &run_id)) + .transpose() +} + +/// Resolve the Routine that owns a Run, following typed retry ancestry. +/// +/// Retry episodes intentionally keep `trigger = retry` for auditability, so +/// consumers that project execution back onto a Routine fire must consult the +/// immutable parent chain rather than treating the newest trigger as the +/// whole provenance record. +pub fn routine_origin(run_id: &str) -> Result, String> { + let mut current_id = run_id.to_string(); + for _ in 0..=MAX_RUN_ATTEMPTS { + let run = read(¤t_id)?; + if let crate::projects::types::WorkItemRunTrigger::Routine { + routine_id, + fire_id, + } = run.trigger + { + return Ok(Some((routine_id, fire_id))); + } + let Some(parent_run_id) = run.parent_run_id else { + return Ok(None); + }; + current_id = parent_run_id; + } + Err(format!( + "{}:{} has a retry ancestry deeper than {MAX_RUN_ATTEMPTS}", + error::INVALID_REQUEST, + run_id + )) +} + +/// Create a durable audit consumer cursor on first use and return its current +/// position. New consumers start at the caller-provided watermark so enabling +/// a feature does not replay an unbounded historical stream. +pub fn initialize_consumer_cursor(consumer_id: &str, initial_seq: i64) -> Result { + if consumer_id.trim().is_empty() || initial_seq < 0 { + return Err(format!( + "{}:consumer_id and a non-negative initial_seq are required", + error::INVALID_REQUEST + )); + } + let connection = conn()?; + let now = now_ms(); + db(connection.execute( + "INSERT OR IGNORE INTO pm_event_consumers (consumer_id, last_seq, updated_at) + VALUES (?1, ?2, ?3)", + params![consumer_id, initial_seq, now], + ))?; + db(connection.query_row( + "SELECT last_seq FROM pm_event_consumers WHERE consumer_id = ?1", + params![consumer_id], + |row| row.get(0), + )) +} + +/// Monotonically advance a durable audit consumer after all side effects for +/// the covered window have themselves become durable. +pub fn advance_consumer_cursor(consumer_id: &str, through_seq: i64) -> Result { + if consumer_id.trim().is_empty() || through_seq < 0 { + return Err(format!( + "{}:consumer_id and a non-negative through_seq are required", + error::INVALID_REQUEST + )); + } + let connection = conn()?; + let now = now_ms(); + let changed = db(connection.execute( + "UPDATE pm_event_consumers + SET last_seq = MAX(last_seq, ?2), updated_at = ?3 + WHERE consumer_id = ?1", + params![consumer_id, through_seq, now], + ))?; + if changed != 1 { + return Err(format!("{}:{consumer_id}", error::NOT_FOUND)); + } + db(connection.query_row( + "SELECT last_seq FROM pm_event_consumers WHERE consumer_id = ?1", + params![consumer_id], + |row| row.get(0), + )) +} + +fn acquire_path_lock(tx: &Transaction<'_>, run: &WorkItemRun, now: i64) -> Result<(), String> { + if run.target_snapshot.allow_shared_checkout { + return Ok(()); + } + let Some(workspace_path) = run + .target_snapshot + .workspace_path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Ok(()); + }; + + db(tx.execute( + "DELETE FROM pm_work_item_path_locks WHERE lease_expires_at <= ?1", + params![now], + ))?; + let expires_at = now.saturating_add(PATH_LOCK_TTL_MS); + let changed = db(tx.execute( + "INSERT INTO pm_work_item_path_locks ( + workspace_path, run_id, work_item_id, acquired_at, + lease_expires_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?4) + ON CONFLICT(workspace_path) DO UPDATE SET + run_id = excluded.run_id, + work_item_id = excluded.work_item_id, + acquired_at = excluded.acquired_at, + lease_expires_at = excluded.lease_expires_at, + updated_at = excluded.updated_at + WHERE pm_work_item_path_locks.run_id = excluded.run_id + OR pm_work_item_path_locks.lease_expires_at <= excluded.acquired_at", + params![workspace_path, run.id, run.work_item_id, now, expires_at], + ))?; + if changed == 0 { + let owner: Option<(String, String)> = db(tx + .query_row( + "SELECT run_id, work_item_id FROM pm_work_item_path_locks + WHERE workspace_path = ?1", + params![workspace_path], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional())?; + let detail = owner + .map(|(owner_run, owner_item)| format!("{owner_run}:{owner_item}")) + .unwrap_or_else(|| "unknown".to_string()); + return Err(format!( + "{}:{}:{}", + error::PATH_LOCKED, + workspace_path, + detail + )); + } + Ok(()) +} + +fn release_path_lock(tx: &Transaction<'_>, run_id: &str) -> Result<(), String> { + db(tx.execute( + "DELETE FROM pm_work_item_path_locks WHERE run_id = ?1", + params![run_id], + ))?; + Ok(()) +} + +/// Lease the oldest ready dispatch. Expired leases are reclaimed by the same +/// query, so process death cannot strand a Run in `dispatching` forever. +pub fn claim_dispatch_for_run( + run_id: &str, + worker_id: &str, + requested_lease_ms: i64, +) -> Result { + if run_id.trim().is_empty() || worker_id.trim().is_empty() { + return Err(format!( + "{}:run_id and worker_id are required", + error::INVALID_REQUEST + )); + } + let lease_ms = if requested_lease_ms <= 0 { + DEFAULT_LEASE_MS + } else { + requested_lease_ms.min(MAX_LEASE_MS) + }; + let mut connection = conn()?; + let tx = db(connection.transaction_with_behavior(TransactionBehavior::Immediate))?; + let now = now_ms(); + let candidate: Option<(String, i64)> = db(tx + .query_row( + "SELECT d.id, d.delivery_attempt + FROM pm_dispatch_outbox d + JOIN pm_work_item_runs r ON r.id = d.run_id + WHERE d.run_id = ?1 + AND ( + d.status IN ('pending', 'retry_wait') + OR (d.status = 'leased' AND d.lease_expires_at <= ?2) + ) + AND r.status IN ('queued', 'deferred', 'dispatching') + ORDER BY d.generation DESC + LIMIT 1", + params![run_id, now], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional())?; + let Some((dispatch_id, previous_attempts)) = candidate else { + return Err(format!( + "{}:{run_id} has no claimable dispatch", + error::INVALID_TRANSITION + )); + }; + let mut run = require_run(&tx, run_id)?; + acquire_path_lock(&tx, &run, now)?; + + let lease_token = format!("lease_{}", uuid::Uuid::new_v4().simple()); + let lease_expires_at = now.saturating_add(lease_ms); + let delivery_attempt = previous_attempts.saturating_add(1); + db(tx.execute( + "UPDATE pm_dispatch_outbox + SET status = 'leased', delivery_attempt = ?2, lease_token = ?3, + lease_owner = ?4, lease_expires_at = ?5, updated_at = ?1 + WHERE id = ?6", + params![ + now, + delivery_attempt, + lease_token, + worker_id, + lease_expires_at, + dispatch_id + ], + ))?; + db(tx.execute( + "UPDATE pm_work_item_runs + SET status = 'dispatching', updated_at = ?2 + WHERE id = ?1 AND status IN ('queued', 'deferred', 'dispatching')", + params![run_id, now], + ))?; + run.status = WorkItemRunStatus::Dispatching; + run.updated_at = iso8601(now); + append_audit( + &tx, + run_id, + "work_run.dispatch_claimed", + run.generation as i64, + run.project_slug.as_deref(), + &run.org_id, + serde_json::json!({ + "dispatchId": dispatch_id, + "workerId": worker_id, + "deliveryAttempt": delivery_attempt, + "leaseExpiresAt": lease_expires_at, + "inline": true, + }), + )?; + db(tx.commit())?; + + Ok(WorkItemDispatchLease { + dispatch_id, + lease_token, + lease_owner: worker_id.to_string(), + lease_expires_at: iso8601(lease_expires_at), + delivery_attempt: u32::try_from(delivery_attempt) + .map_err(|_| "dispatch delivery_attempt out of range".to_string())?, + run, + }) +} + +/// Lease the oldest ready dispatch. Expired leases are reclaimed by the same +/// query, so process death cannot strand a Run in `dispatching` forever. +pub fn claim_next_dispatch( + worker_id: &str, + requested_lease_ms: i64, +) -> Result, String> { + if worker_id.trim().is_empty() { + return Err(format!("{}:worker_id is required", error::INVALID_REQUEST)); + } + let lease_ms = if requested_lease_ms <= 0 { + DEFAULT_LEASE_MS + } else { + requested_lease_ms.min(MAX_LEASE_MS) + }; + let mut connection = conn()?; + let tx = db(connection.transaction_with_behavior(TransactionBehavior::Immediate))?; + let now = now_ms(); + let candidate: Option<(String, String, i64)> = db(tx + .query_row( + "SELECT d.id, d.run_id, d.delivery_attempt + FROM pm_dispatch_outbox d + JOIN pm_work_item_runs r ON r.id = d.run_id + WHERE ( + (d.status IN ('pending', 'retry_wait') AND d.available_at <= ?1) + OR (d.status = 'leased' AND d.lease_expires_at <= ?1) + ) + AND r.status IN ('queued', 'deferred', 'dispatching') + AND ( + COALESCE(json_extract(r.target_json, '$.allowSharedCheckout'), 0) = 1 + OR NULLIF(TRIM(json_extract(r.target_json, '$.workspacePath')), '') IS NULL + OR NOT EXISTS ( + SELECT 1 FROM pm_work_item_path_locks path_lock + WHERE path_lock.workspace_path = json_extract(r.target_json, '$.workspacePath') + AND path_lock.run_id <> r.id + AND path_lock.lease_expires_at > ?1 + ) + ) + ORDER BY d.available_at ASC, d.created_at ASC, d.id ASC + LIMIT 1", + params![now], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional())?; + let Some((dispatch_id, run_id, previous_attempts)) = candidate else { + db(tx.commit())?; + return Ok(None); + }; + let mut run = require_run(&tx, &run_id)?; + acquire_path_lock(&tx, &run, now)?; + + let lease_token = format!("lease_{}", uuid::Uuid::new_v4().simple()); + let lease_expires_at = now.saturating_add(lease_ms); + let delivery_attempt = previous_attempts.saturating_add(1); + db(tx.execute( + "UPDATE pm_dispatch_outbox + SET status = 'leased', delivery_attempt = ?2, lease_token = ?3, + lease_owner = ?4, lease_expires_at = ?5, updated_at = ?1 + WHERE id = ?6", + params![ + now, + delivery_attempt, + lease_token, + worker_id, + lease_expires_at, + dispatch_id + ], + ))?; + db(tx.execute( + "UPDATE pm_work_item_runs + SET status = 'dispatching', updated_at = ?2 + WHERE id = ?1 AND status IN ('queued', 'deferred', 'dispatching')", + params![run_id, now], + ))?; + run.status = WorkItemRunStatus::Dispatching; + run.updated_at = iso8601(now); + append_audit( + &tx, + &run_id, + "work_run.dispatch_claimed", + run.generation as i64, + run.project_slug.as_deref(), + &run.org_id, + serde_json::json!({ + "dispatchId": dispatch_id, + "workerId": worker_id, + "deliveryAttempt": delivery_attempt, + "leaseExpiresAt": lease_expires_at, + }), + )?; + db(tx.commit())?; + + Ok(Some(WorkItemDispatchLease { + dispatch_id, + lease_token, + lease_owner: worker_id.to_string(), + lease_expires_at: iso8601(lease_expires_at), + delivery_attempt: u32::try_from(delivery_attempt) + .map_err(|_| "dispatch delivery_attempt out of range".to_string())?, + run, + })) +} + +fn leased_run_id( + tx: &Transaction<'_>, + dispatch_id: &str, + lease_token: &str, +) -> Result { + db(tx + .query_row( + "SELECT run_id FROM pm_dispatch_outbox + WHERE id = ?1 AND status = 'leased' AND lease_token = ?2", + params![dispatch_id, lease_token], + |row| row.get(0), + ) + .optional())? + .ok_or_else(|| format!("{}:{}", error::STALE_LEASE, dispatch_id)) +} + +/// Acknowledge that the runtime accepted the dispatch and materialized a +/// Session. This is a Run transition only; Work Item status is untouched. +pub fn acknowledge_dispatch_started( + dispatch_id: &str, + lease_token: &str, + session_id: &str, +) -> Result { + if session_id.trim().is_empty() { + return Err(format!("{}:session_id is required", error::INVALID_REQUEST)); + } + let mut connection = conn()?; + let tx = db(connection.transaction_with_behavior(TransactionBehavior::Immediate))?; + let run_id = leased_run_id(&tx, dispatch_id, lease_token)?; + let now = now_ms(); + db(tx.execute( + "UPDATE pm_dispatch_outbox + SET status = 'delivered', delivered_at = ?3, updated_at = ?3, + lease_token = NULL, lease_owner = NULL, lease_expires_at = NULL + WHERE id = ?1 AND lease_token = ?2", + params![dispatch_id, lease_token, now], + ))?; + let changed = db(tx.execute( + "UPDATE pm_work_item_runs + SET status = CASE WHEN status = 'dispatching' THEN 'running' ELSE status END, + session_id = COALESCE(session_id, ?2), + failure_json = CASE WHEN status = 'dispatching' THEN NULL ELSE failure_json END, + started_at = COALESCE(started_at, ?3), updated_at = ?3 + WHERE id = ?1 + AND status IN ('dispatching', 'running', 'waiting', 'succeeded', 'failed', 'cancelled') + AND (session_id IS NULL OR session_id = ?2)", + params![run_id, session_id, now], + ))?; + if changed != 1 { + return Err(format!( + "{}:{} cannot acknowledge from current state", + error::INVALID_TRANSITION, + run_id + )); + } + let run = require_run(&tx, &run_id)?; + append_audit( + &tx, + &run_id, + "work_run.started", + run.generation as i64, + run.project_slug.as_deref(), + &run.org_id, + serde_json::json!({ + "dispatchId": dispatch_id, + "sessionId": session_id, + }), + )?; + db(tx.commit())?; + read(&run_id) +} + +fn retry_delay_ms(delivery_attempt: i64) -> i64 { + let exponent = delivery_attempt.saturating_sub(1).clamp(0, 6) as u32; + (1_000_i64.saturating_mul(2_i64.saturating_pow(exponent))).min(60_000) +} + +/// Convert an untyped runtime/provider error into a stable product category +/// and retry disposition. Matching is intentionally conservative: unknown, +/// auth, quota and configuration failures never auto-retry. +pub fn classify_failure(message: &str, has_session: bool) -> WorkItemRunFailure { + let normalized = message.to_ascii_lowercase(); + let (class, code, retryable, retry_disposition) = + if normalized.contains("cancelled") || normalized.contains("canceled") { + ( + WorkItemRunFailureClass::Cancelled, + "cancelled", + false, + WorkItemRunRetryDisposition::DoNotRetry, + ) + } else if normalized.contains("context length") + || normalized.contains("context window") + || normalized.contains("too many tokens") + || normalized.contains("maximum context") + { + ( + WorkItemRunFailureClass::ContextOverflow, + "context_overflow", + false, + WorkItemRunRetryDisposition::ManualReview, + ) + } else if normalized.contains("unauthorized") + || normalized.contains("authentication") + || normalized.contains("invalid api key") + || normalized.contains("status 401") + { + ( + WorkItemRunFailureClass::Authentication, + "authentication_failed", + false, + WorkItemRunRetryDisposition::DoNotRetry, + ) + } else if normalized.contains("forbidden") + || normalized.contains("permission denied") + || normalized.contains("status 403") + { + ( + WorkItemRunFailureClass::Authorization, + "authorization_failed", + false, + WorkItemRunRetryDisposition::DoNotRetry, + ) + } else if normalized.contains("rate limit") + || normalized.contains("quota") + || normalized.contains("insufficient credit") + || normalized.contains("status 429") + { + ( + WorkItemRunFailureClass::Quota, + "quota_exhausted", + false, + WorkItemRunRetryDisposition::ManualReview, + ) + } else if normalized.contains("timed out") + || normalized.contains("timeout") + || normalized.contains("deadline exceeded") + { + ( + WorkItemRunFailureClass::Timeout, + "timeout", + true, + if has_session { + WorkItemRunRetryDisposition::ResumeSession + } else { + WorkItemRunRetryDisposition::StartNewSession + }, + ) + } else if normalized.contains("connection reset") + || normalized.contains("connection refused") + || normalized.contains("network") + || normalized.contains("dns") + || normalized.contains("tls") + { + ( + WorkItemRunFailureClass::TransientNetwork, + "network_unavailable", + true, + if has_session { + WorkItemRunRetryDisposition::ResumeSession + } else { + WorkItemRunRetryDisposition::StartNewSession + }, + ) + } else if normalized.contains("status 502") + || normalized.contains("status 503") + || normalized.contains("status 504") + || normalized.contains("provider unavailable") + || normalized.contains("service unavailable") + { + ( + WorkItemRunFailureClass::ProviderUnavailable, + "provider_unavailable", + true, + if has_session { + WorkItemRunRetryDisposition::ResumeSession + } else { + WorkItemRunRetryDisposition::StartNewSession + }, + ) + } else if normalized.contains("no selected") + || normalized.contains("not configured") + || normalized.contains("no host repo") + || normalized.contains("missing configuration") + { + ( + WorkItemRunFailureClass::Configuration, + "configuration_invalid", + false, + WorkItemRunRetryDisposition::DoNotRetry, + ) + } else if normalized.contains("model not found") + || normalized.contains("unknown model") + || normalized.contains("unsupported model") + { + ( + WorkItemRunFailureClass::Model, + "model_invalid", + false, + WorkItemRunRetryDisposition::ManualReview, + ) + } else if normalized.contains("invalid request") + || normalized.contains("invalid input") + || normalized.contains("malformed") + { + ( + WorkItemRunFailureClass::InvalidInput, + "invalid_input", + false, + WorkItemRunRetryDisposition::DoNotRetry, + ) + } else if normalized.contains("runtime crashed") + || normalized.contains("process exited") + || normalized.contains("worker died") + { + ( + WorkItemRunFailureClass::Runtime, + "runtime_failed", + true, + WorkItemRunRetryDisposition::StartNewSession, + ) + } else { + ( + WorkItemRunFailureClass::Unknown, + "unknown", + false, + WorkItemRunRetryDisposition::ManualReview, + ) + }; + WorkItemRunFailure { + class, + code: code.to_string(), + message: message.to_string(), + retryable, + retry_disposition, + occurred_at: chrono::Utc::now().to_rfc3339(), + } +} + +/// Nack a leased dispatch. Safe transient failures are delayed and retried; +/// permanent or exhausted failures move both dispatch and Run terminal. +pub fn record_dispatch_failure( + dispatch_id: &str, + lease_token: &str, + message: &str, +) -> Result { + let mut connection = conn()?; + let tx = db(connection.transaction_with_behavior(TransactionBehavior::Immediate))?; + let run_id = leased_run_id(&tx, dispatch_id, lease_token)?; + let run = require_run(&tx, &run_id)?; + let delivery_attempt: i64 = db(tx.query_row( + "SELECT delivery_attempt FROM pm_dispatch_outbox WHERE id = ?1", + params![dispatch_id], + |row| row.get(0), + ))?; + let failure = classify_failure(message, false); + let failure_json = serde_json::to_string(&failure) + .map_err(|err| format!("work run failure serialization: {err}"))?; + let retry = failure.retryable && delivery_attempt < i64::from(run.max_attempts); + let now = now_ms(); + + if retry { + let available_at = now.saturating_add(retry_delay_ms(delivery_attempt)); + db(tx.execute( + "UPDATE pm_dispatch_outbox + SET status = 'retry_wait', available_at = ?3, + lease_token = NULL, lease_owner = NULL, lease_expires_at = NULL, + last_error_json = ?4, updated_at = ?5 + WHERE id = ?1 AND lease_token = ?2", + params![dispatch_id, lease_token, available_at, failure_json, now], + ))?; + db(tx.execute( + "UPDATE pm_work_item_runs + SET status = 'deferred', failure_json = ?2, updated_at = ?3 + WHERE id = ?1 AND status = 'dispatching'", + params![run_id, failure_json, now], + ))?; + } else { + db(tx.execute( + "UPDATE pm_dispatch_outbox + SET status = 'dead_letter', lease_token = NULL, lease_owner = NULL, + lease_expires_at = NULL, last_error_json = ?3, updated_at = ?4 + WHERE id = ?1 AND lease_token = ?2", + params![dispatch_id, lease_token, failure_json, now], + ))?; + db(tx.execute( + "UPDATE pm_work_item_runs + SET status = 'failed', failure_json = ?2, completed_at = ?3, + updated_at = ?3 + WHERE id = ?1 AND status = 'dispatching'", + params![run_id, failure_json, now], + ))?; + } + release_path_lock(&tx, &run_id)?; + let updated = require_run(&tx, &run_id)?; + append_audit( + &tx, + &run_id, + if retry { + "work_run.dispatch_deferred" + } else { + "work_run.dispatch_failed" + }, + updated.generation as i64, + updated.project_slug.as_deref(), + &updated.org_id, + serde_json::json!({ + "dispatchId": dispatch_id, + "failure": failure, + "deliveryAttempt": delivery_attempt, + "willRetry": retry, + }), + )?; + db(tx.commit())?; + let persisted = read(&run_id)?; + if let Err(err) = crate::work_item_features::subscriptions::notify_run_terminal(&persisted) { + tracing::warn!(run_id = %persisted.id, error = %err, "failed to project Run failure into Inbox"); + } + Ok(persisted) +} + +/// Reconcile a turn terminal into the exact owning Run. +/// +/// `expected_session_id` guards against a stale completion from an earlier +/// Session being applied after a retry has attached the Run elsewhere. Run +/// finality is deliberately independent from Work Item completion. +pub fn record_run_terminal( + run_id: &str, + expected_session_id: Option<&str>, + outcome: WorkItemRunTerminalOutcome, + usage: WorkItemRunUsage, + error_message: Option<&str>, +) -> Result { + let mut connection = conn()?; + let tx = db(connection.transaction_with_behavior(TransactionBehavior::Immediate))?; + let existing = require_run(&tx, run_id)?; + if let Some(expected) = expected_session_id { + if existing + .session_id + .as_deref() + .is_some_and(|actual| actual != expected) + { + return Err(format!( + "{}:{} expected session {}, found {}", + error::INVALID_TRANSITION, + run_id, + expected, + existing.session_id.as_deref().unwrap_or("none") + )); + } + } + if existing.status.is_terminal() { + release_path_lock(&tx, run_id)?; + db(tx.commit())?; + return Ok(existing); + } + + let (status, failure) = match outcome { + WorkItemRunTerminalOutcome::Succeeded => (WorkItemRunStatus::Succeeded, None), + WorkItemRunTerminalOutcome::Failed => ( + WorkItemRunStatus::Failed, + Some(classify_failure( + error_message.unwrap_or("session failed without an error message"), + true, + )), + ), + WorkItemRunTerminalOutcome::Cancelled => ( + WorkItemRunStatus::Cancelled, + Some(classify_failure( + error_message.unwrap_or("session cancelled"), + true, + )), + ), + }; + let failure_json = failure + .as_ref() + .map(serde_json::to_string) + .transpose() + .map_err(|err| format!("work run failure serialization: {err}"))?; + let usage_json = serde_json::to_string(&usage) + .map_err(|err| format!("work run usage serialization: {err}"))?; + let now = now_ms(); + db(tx.execute( + "UPDATE pm_work_item_runs + SET status = ?2, failure_json = ?3, usage_json = ?4, + session_id = COALESCE(session_id, ?6), + completed_at = ?5, updated_at = ?5 + WHERE id = ?1 AND status IN ('running', 'waiting', 'dispatching')", + params![ + run_id, + status.as_str(), + failure_json, + usage_json, + now, + expected_session_id + ], + ))?; + release_path_lock(&tx, run_id)?; + let updated = require_run(&tx, run_id)?; + append_audit( + &tx, + run_id, + "work_run.terminal", + updated.generation as i64, + updated.project_slug.as_deref(), + &updated.org_id, + serde_json::json!({ + "sessionId": expected_session_id.or(existing.session_id.as_deref()), + "status": status.as_str(), + "failure": failure, + "usage": usage, + }), + )?; + db(tx.commit())?; + let persisted = read(run_id)?; + if let Err(err) = crate::work_item_features::subscriptions::notify_run_terminal(&persisted) { + tracing::warn!(run_id = %persisted.id, error = %err, "failed to project Run failure into Inbox"); + } + Ok(persisted) +} + +/// Compatibility lookup for legacy Session-terminal callers. Multiple Runs +/// may resume one Session, so only the newest non-terminal episode is chosen. +/// New code should use [`record_run_terminal`] with the durable turn intent id. +pub fn record_session_terminal( + session_id: &str, + outcome: WorkItemRunTerminalOutcome, + usage: WorkItemRunUsage, + error_message: Option<&str>, +) -> Result, String> { + let connection = conn()?; + let run_id: Option = db(connection + .query_row( + "SELECT id FROM pm_work_item_runs + WHERE session_id = ?1 + AND status IN ('dispatching', 'running', 'waiting') + ORDER BY COALESCE(started_at, created_at) DESC, created_at DESC + LIMIT 1", + params![session_id], + |row| row.get(0), + ) + .optional())?; + drop(connection); + let Some(run_id) = run_id else { + return Ok(None); + }; + record_run_terminal(&run_id, Some(session_id), outcome, usage, error_message).map(Some) +} + +pub fn mark_waiting(run_id: &str) -> Result { + let mut connection = conn()?; + let tx = db(connection.transaction_with_behavior(TransactionBehavior::Immediate))?; + let now = now_ms(); + let changed = db(tx.execute( + "UPDATE pm_work_item_runs SET status = 'waiting', updated_at = ?2 + WHERE id = ?1 AND status = 'running'", + params![run_id, now], + ))?; + if changed != 1 { + return Err(format!( + "{}:{} -> waiting", + error::INVALID_TRANSITION, + run_id + )); + } + let run = require_run(&tx, run_id)?; + append_audit( + &tx, + run_id, + "work_run.waiting", + run.generation as i64, + run.project_slug.as_deref(), + &run.org_id, + serde_json::json!({}), + )?; + db(tx.commit())?; + read(run_id) +} + +/// Create the next execution episode from a failed Run according to the +/// typed failure policy. This never mutates or reopens the previous Run. +pub fn retry(run_id: &str, idempotency_key: &str) -> Result { + let previous = read(run_id)?; + if previous.status != WorkItemRunStatus::Failed { + return Err(format!( + "{}:{} is not failed", + error::RETRY_NOT_ALLOWED, + run_id + )); + } + let failure = previous.failure.as_ref().ok_or_else(|| { + format!( + "{}:{} has no typed failure", + error::RETRY_NOT_ALLOWED, + run_id + ) + })?; + if !failure.retryable { + return Err(format!( + "{}:{}:{}", + error::RETRY_NOT_ALLOWED, + run_id, + failure.code + )); + } + if previous.attempt >= previous.max_attempts { + return Err(format!( + "{}:{} exhausted attempt budget ({}/{})", + error::RETRY_NOT_ALLOWED, + run_id, + previous.attempt, + previous.max_attempts + )); + } + + let mut target_snapshot = previous.target_snapshot.clone(); + if failure.retry_disposition == WorkItemRunRetryDisposition::ResumeSession { + let session_id = previous.session_id.clone().ok_or_else(|| { + format!( + "{}:{} requires a Session to resume", + error::RETRY_NOT_ALLOWED, + run_id + ) + })?; + target_snapshot.target = WorkItemRunTarget::ResumeSession { session_id }; + } + enqueue(EnqueueWorkItemRunRequest { + project_slug: previous.project_slug, + org_id: previous.org_id, + work_item_id: previous.work_item_id, + trigger: crate::projects::types::WorkItemRunTrigger::Retry { + previous_run_id: previous.id.clone(), + }, + target_snapshot, + input: previous.input, + idempotency_key: idempotency_key.to_string(), + max_attempts: previous.max_attempts, + parent_run_id: Some(previous.id), + }) +} diff --git a/src-tauri/crates/project-management/src/work_run_service/tests.rs b/src-tauri/crates/project-management/src/work_run_service/tests.rs new file mode 100644 index 0000000000..775129207c --- /dev/null +++ b/src-tauri/crates/project-management/src/work_run_service/tests.rs @@ -0,0 +1,517 @@ +use super::*; +use crate::projects::io; +use crate::projects::types::{ + EnqueueWorkItemRunRequest, WorkItemPartialUpdate, WorkItemRunTarget, WorkItemRunTargetSnapshot, + WorkItemRunTrigger, +}; +use crate::work_service::{self, CreateWorkItemRequest}; +use test_helpers::test_env; + +fn seed() { + work_service::tests_support::seed_project("demo", "project-1"); + work_service::create_project_work_item( + "demo", + "AAA-0001", + &CreateWorkItemRequest { + title: "Durable work".to_string(), + ..Default::default() + }, + None, + ) + .expect("seed work item"); +} + +fn request(key: &str) -> EnqueueWorkItemRunRequest { + EnqueueWorkItemRunRequest { + project_slug: Some("demo".to_string()), + org_id: "personal-org".to_string(), + work_item_id: "AAA-0001".to_string(), + trigger: WorkItemRunTrigger::Manual, + target_snapshot: WorkItemRunTargetSnapshot::new(WorkItemRunTarget::StartWorkItem { + account_id: Some("account-1".to_string()), + model_id: Some("model-1".to_string()), + }), + input: serde_json::json!({"instruction": "ship it"}), + idempotency_key: key.to_string(), + max_attempts: 3, + parent_run_id: None, + } +} + +#[test] +fn standalone_run_canonicalizes_cloud_org_scope() { + let _sandbox = test_env::sandbox(); + let org_id = "org-cloud-run"; + io::create_project_org(&crate::projects::types::CreateProjectOrgRequest { + name: "Cloud Run Org".to_string(), + id: Some(org_id.to_string()), + }) + .expect("create org"); + work_service::create_standalone_work_item( + Some(org_id), + "WI-0001", + &CreateWorkItemRequest { + title: "Cloud scoped run".to_string(), + ..Default::default() + }, + None, + ) + .expect("seed standalone work item"); + + let run = enqueue(EnqueueWorkItemRunRequest { + project_slug: None, + org_id: format!("cloud:{org_id}"), + work_item_id: "WI-0001".to_string(), + trigger: WorkItemRunTrigger::Manual, + target_snapshot: WorkItemRunTargetSnapshot::new(WorkItemRunTarget::ResumeSession { + session_id: "session-cloud-run".to_string(), + }), + input: serde_json::json!({"instruction": "ship it"}), + idempotency_key: "manual:cloud-scope".to_string(), + max_attempts: 3, + parent_run_id: None, + }) + .expect("enqueue cloud-scoped run"); + + assert_eq!(run.org_id, org_id); + assert_eq!( + list_for_work_item(None, &format!("cloud:{org_id}"), "WI-0001", 10) + .expect("list cloud-scoped runs") + .len(), + 1 + ); +} + +#[test] +fn enqueue_captures_immutable_work_item_context() { + let _sandbox = test_env::sandbox(); + seed(); + let run = enqueue(request("manual:snapshot")).expect("enqueue"); + assert_eq!( + run.target_snapshot.work_item_title.as_deref(), + Some("Durable work") + ); + assert_eq!(run.target_snapshot.work_item_revision, 0); + + io::update_work_item_partial( + "demo", + "AAA-0001", + &WorkItemPartialUpdate { + title: Some("Changed after enqueue".to_string()), + ..Default::default() + }, + ) + .expect("mutate live item"); + let stored = read(&run.id).expect("read run"); + assert_eq!( + stored.target_snapshot.work_item_title.as_deref(), + Some("Durable work") + ); + assert_eq!(stored.target_snapshot.work_item_revision, 0); +} + +#[test] +fn path_lock_serializes_runs_until_terminal_release() { + let _sandbox = test_env::sandbox(); + seed(); + let mut first_request = request("manual:path:1"); + first_request.target_snapshot.workspace_path = Some("/tmp/org2-path-lock-test".to_string()); + let first = enqueue(first_request).expect("enqueue first"); + let mut second_request = request("manual:path:2"); + second_request.target_snapshot.workspace_path = Some("/tmp/org2-path-lock-test".to_string()); + let second = enqueue(second_request).expect("enqueue second"); + + let first_lease = claim_next_dispatch("worker-1", 30_000) + .expect("claim first") + .expect("first lease"); + assert_eq!(first_lease.run.id, first.id); + assert!( + claim_next_dispatch("worker-2", 30_000) + .expect("locked claim") + .is_none(), + "a second Run cannot claim the same checkout" + ); + + record_run_terminal( + &first.id, + Some("session-path-1"), + WorkItemRunTerminalOutcome::Succeeded, + WorkItemRunUsage::default(), + None, + ) + .expect("release path lock"); + let second_lease = claim_next_dispatch("worker-2", 30_000) + .expect("claim second") + .expect("second lease"); + assert_eq!(second_lease.run.id, second.id); +} + +#[test] +fn enqueue_is_atomic_and_idempotent() { + let _sandbox = test_env::sandbox(); + seed(); + + let first = enqueue(request("manual:1")).expect("enqueue"); + let replay = enqueue(request("manual:1")).expect("idempotent replay"); + assert_eq!(first.id, replay.id); + assert_eq!(first.status, WorkItemRunStatus::Queued); + assert_eq!(first.target_snapshot.work_item_revision, 0); + + let connection = conn().expect("connection"); + let run_count: i64 = connection + .query_row("SELECT COUNT(*) FROM pm_work_item_runs", [], |row| { + row.get(0) + }) + .expect("run count"); + let dispatch_count: i64 = connection + .query_row("SELECT COUNT(*) FROM pm_dispatch_outbox", [], |row| { + row.get(0) + }) + .expect("dispatch count"); + assert_eq!(run_count, 1); + assert_eq!(dispatch_count, 1); +} + +#[test] +fn idempotency_key_rejects_different_request() { + let _sandbox = test_env::sandbox(); + seed(); + enqueue(request("manual:1")).expect("enqueue"); + + let mut conflicting = request("manual:1"); + conflicting.input = serde_json::json!({"instruction": "different"}); + let error = enqueue(conflicting).expect_err("must conflict"); + assert!(error.starts_with(error::IDEMPOTENCY_CONFLICT), "{error}"); +} + +#[test] +fn dispatch_claim_is_leased_and_ack_requires_matching_token() { + let _sandbox = test_env::sandbox(); + seed(); + let run = enqueue(request("manual:1")).expect("enqueue"); + + let lease = claim_next_dispatch("desktop-1", 30_000) + .expect("claim") + .expect("dispatch"); + assert_eq!(lease.run.id, run.id); + assert_eq!(lease.run.status, WorkItemRunStatus::Dispatching); + assert_eq!(lease.delivery_attempt, 1); + + let stale = acknowledge_dispatch_started(&lease.dispatch_id, "wrong-token", "session-1") + .expect_err("stale token"); + assert!(stale.starts_with(error::STALE_LEASE), "{stale}"); + + let started = acknowledge_dispatch_started(&lease.dispatch_id, &lease.lease_token, "session-1") + .expect("ack"); + assert_eq!(started.status, WorkItemRunStatus::Running); + assert_eq!(started.session_id.as_deref(), Some("session-1")); + assert!(claim_next_dispatch("desktop-1", 30_000) + .expect("empty claim") + .is_none()); +} + +#[test] +fn latest_for_session_returns_attached_execution_episode() { + let _sandbox = test_env::sandbox(); + seed(); + let run = enqueue(request("manual:session-latest")).expect("enqueue"); + let lease = claim_next_dispatch("desktop-1", 30_000) + .expect("claim") + .expect("dispatch"); + acknowledge_dispatch_started(&lease.dispatch_id, &lease.lease_token, "session-latest") + .expect("ack"); + + let latest = latest_for_session("session-latest") + .expect("lookup") + .expect("attached run"); + assert_eq!(latest.id, run.id); + assert!(latest_for_session("missing-session") + .expect("missing lookup") + .is_none()); +} + +#[test] +fn active_session_runs_exclude_queued_and_terminal_episodes() { + let _sandbox = test_env::sandbox(); + seed(); + let queued = enqueue(request("manual:active-queued")).expect("enqueue queued"); + let active = enqueue(request("manual:active-running")).expect("enqueue active"); + let terminal = enqueue(request("manual:active-terminal")).expect("enqueue terminal"); + + let first = claim_next_dispatch("desktop-1", 30_000) + .expect("claim first") + .expect("first dispatch"); + assert_eq!(first.run.id, queued.id); + acknowledge_dispatch_started(&first.dispatch_id, &first.lease_token, "session-queued") + .expect("ack first"); + record_run_terminal( + &queued.id, + Some("session-queued"), + WorkItemRunTerminalOutcome::Succeeded, + WorkItemRunUsage::default(), + None, + ) + .expect("finish first"); + + let second = claim_next_dispatch("desktop-2", 30_000) + .expect("claim second") + .expect("second dispatch"); + assert_eq!(second.run.id, active.id); + acknowledge_dispatch_started(&second.dispatch_id, &second.lease_token, "session-active") + .expect("ack second"); + + let runs = list_active_session_runs().expect("list active session runs"); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].id, active.id); + assert_ne!(runs[0].id, terminal.id); +} + +#[test] +fn inline_dispatch_reserves_exact_run_without_racing_background_worker() { + let _sandbox = test_env::sandbox(); + seed(); + let run = enqueue_for_inline_dispatch(request("manual:inline")).expect("enqueue inline"); + + assert!( + claim_next_dispatch("background-worker", 30_000) + .expect("background claim") + .is_none(), + "future availability must reserve the Run for its inline caller" + ); + + let lease = claim_dispatch_for_run(&run.id, "inline-worker", 30_000).expect("claim exact Run"); + assert_eq!(lease.run.id, run.id); + assert_eq!(lease.lease_owner, "inline-worker"); + let started = + acknowledge_dispatch_started(&lease.dispatch_id, &lease.lease_token, "session-inline") + .expect("ack inline"); + assert_eq!(started.status, WorkItemRunStatus::Running); + assert_eq!(started.session_id.as_deref(), Some("session-inline")); +} + +#[test] +fn transient_dispatch_failure_defers_but_auth_failure_dead_letters() { + let _sandbox = test_env::sandbox(); + seed(); + let transient = enqueue(request("manual:network")).expect("enqueue transient"); + let lease = claim_next_dispatch("desktop-1", 30_000) + .expect("claim") + .expect("dispatch"); + assert_eq!(lease.run.id, transient.id); + let deferred = record_dispatch_failure( + &lease.dispatch_id, + &lease.lease_token, + "network connection reset", + ) + .expect("record transient failure"); + assert_eq!(deferred.status, WorkItemRunStatus::Deferred); + assert_eq!( + deferred.failure.as_ref().map(|failure| failure.class), + Some(WorkItemRunFailureClass::TransientNetwork) + ); + + let permanent = enqueue(request("manual:auth")).expect("enqueue permanent"); + let lease = claim_next_dispatch("desktop-2", 30_000) + .expect("claim") + .expect("dispatch"); + assert_eq!(lease.run.id, permanent.id); + let failed = record_dispatch_failure( + &lease.dispatch_id, + &lease.lease_token, + "Unauthorized: invalid API key (status 401)", + ) + .expect("record permanent failure"); + assert_eq!(failed.status, WorkItemRunStatus::Failed); + assert!(!failed.failure.expect("typed failure").retryable); +} + +#[test] +fn session_terminal_updates_run_without_completing_work_item() { + let _sandbox = test_env::sandbox(); + seed(); + let queued = enqueue(request("manual:1")).expect("enqueue"); + let lease = claim_next_dispatch("desktop-1", 30_000) + .expect("claim") + .expect("dispatch"); + acknowledge_dispatch_started(&lease.dispatch_id, &lease.lease_token, "session-1").expect("ack"); + + let terminal = record_session_terminal( + "session-1", + WorkItemRunTerminalOutcome::Succeeded, + WorkItemRunUsage { + total_tokens: 4321, + cost_usd: 0.25, + ..Default::default() + }, + None, + ) + .expect("terminal") + .expect("owned session"); + assert_eq!(terminal.id, queued.id); + assert_eq!(terminal.status, WorkItemRunStatus::Succeeded); + assert_eq!(terminal.usage.total_tokens, 4321); + + let item = io::read_work_item("demo", "AAA-0001").expect("work item"); + assert_eq!(item.frontmatter.status, "backlog"); +} + +#[test] +fn turn_can_finish_before_dispatch_ack_without_losing_finality() { + let _sandbox = test_env::sandbox(); + seed(); + let queued = enqueue(request("manual:fast-turn")).expect("enqueue"); + let lease = claim_next_dispatch("desktop-1", 30_000) + .expect("claim") + .expect("dispatch"); + + let terminal = record_run_terminal( + &queued.id, + Some("session-fast"), + WorkItemRunTerminalOutcome::Succeeded, + WorkItemRunUsage { + total_tokens: 99, + ..Default::default() + }, + None, + ) + .expect("terminal before ack"); + assert_eq!(terminal.status, WorkItemRunStatus::Succeeded); + assert_eq!(terminal.session_id.as_deref(), Some("session-fast")); + + let acknowledged = + acknowledge_dispatch_started(&lease.dispatch_id, &lease.lease_token, "session-fast") + .expect("terminal ack is idempotent"); + assert_eq!(acknowledged.status, WorkItemRunStatus::Succeeded); + assert_eq!(acknowledged.usage.total_tokens, 99); +} + +#[test] +fn consumer_cursor_is_initialized_once_and_only_moves_forward() { + let _sandbox = test_env::sandbox(); + seed(); + + assert_eq!( + initialize_consumer_cursor("stage-barrier-test", 12).expect("initialize"), + 12 + ); + assert_eq!( + initialize_consumer_cursor("stage-barrier-test", 99).expect("reinitialize"), + 12, + "restart must keep the persisted cursor" + ); + assert_eq!( + advance_consumer_cursor("stage-barrier-test", 20).expect("advance"), + 20 + ); + assert_eq!( + advance_consumer_cursor("stage-barrier-test", 15).expect("stale advance"), + 20 + ); +} + +#[test] +fn typed_retry_creates_a_new_run_episode_and_resumes_session() { + let _sandbox = test_env::sandbox(); + seed(); + let first = enqueue(request("manual:retry-source")).expect("enqueue"); + let lease = claim_next_dispatch("desktop-1", 30_000) + .expect("claim") + .expect("dispatch"); + acknowledge_dispatch_started(&lease.dispatch_id, &lease.lease_token, "session-retry") + .expect("ack"); + record_run_terminal( + &first.id, + Some("session-retry"), + WorkItemRunTerminalOutcome::Failed, + WorkItemRunUsage::default(), + Some("request timed out"), + ) + .expect("failed terminal"); + + let retried = retry(&first.id, "retry:1").expect("typed retry"); + assert_ne!(retried.id, first.id); + assert_eq!(retried.parent_run_id.as_deref(), Some(first.id.as_str())); + assert_eq!(retried.attempt, 2); + assert_eq!( + retried.target_snapshot.target, + WorkItemRunTarget::ResumeSession { + session_id: "session-retry".to_string() + } + ); +} + +#[test] +fn retry_ancestry_preserves_routine_origin() { + let _sandbox = test_env::sandbox(); + seed(); + let mut routine_request = request("routine:retry-origin"); + routine_request.trigger = WorkItemRunTrigger::Routine { + routine_id: "routine-origin".to_string(), + fire_id: "fire-origin".to_string(), + }; + let first = enqueue(routine_request).expect("enqueue"); + let lease = claim_next_dispatch("desktop-1", 30_000) + .expect("claim") + .expect("dispatch"); + acknowledge_dispatch_started(&lease.dispatch_id, &lease.lease_token, "session-origin") + .expect("ack"); + record_run_terminal( + &first.id, + Some("session-origin"), + WorkItemRunTerminalOutcome::Failed, + WorkItemRunUsage::default(), + Some("request timed out"), + ) + .expect("failed terminal"); + let retried = retry(&first.id, "retry:routine-origin").expect("retry"); + + assert_eq!( + routine_origin(&retried.id).expect("resolve origin"), + Some(("routine-origin".to_string(), "fire-origin".to_string())) + ); +} + +#[test] +fn typed_retry_refuses_an_exhausted_attempt_budget() { + let _sandbox = test_env::sandbox(); + seed(); + let mut exhausted_request = request("manual:retry-exhausted"); + exhausted_request.max_attempts = 1; + let first = enqueue(exhausted_request).expect("enqueue"); + let lease = claim_next_dispatch("desktop-1", 30_000) + .expect("claim") + .expect("dispatch"); + acknowledge_dispatch_started(&lease.dispatch_id, &lease.lease_token, "session-exhausted") + .expect("ack"); + record_run_terminal( + &first.id, + Some("session-exhausted"), + WorkItemRunTerminalOutcome::Failed, + WorkItemRunUsage::default(), + Some("request timed out"), + ) + .expect("failed terminal"); + + let error = retry(&first.id, "retry:exhausted").expect_err("budget must reject retry"); + assert!(error.starts_with(error::RETRY_NOT_ALLOWED), "{error}"); + assert!(error.contains("exhausted attempt budget"), "{error}"); +} + +#[test] +fn failure_classifier_is_conservative_and_typed() { + let timeout = classify_failure("request timed out", true); + assert_eq!(timeout.class, WorkItemRunFailureClass::Timeout); + assert!(timeout.retryable); + assert_eq!( + timeout.retry_disposition, + WorkItemRunRetryDisposition::ResumeSession + ); + + let quota = classify_failure("status 429: quota exceeded", true); + assert_eq!(quota.class, WorkItemRunFailureClass::Quota); + assert!(!quota.retryable); + + let unknown = classify_failure("something surprising", false); + assert_eq!(unknown.class, WorkItemRunFailureClass::Unknown); + assert!(!unknown.retryable); +} diff --git a/src-tauri/crates/project-management/src/work_service/audit.rs b/src-tauri/crates/project-management/src/work_service/audit.rs index 24635094f6..39487205b6 100644 --- a/src-tauri/crates/project-management/src/work_service/audit.rs +++ b/src-tauri/crates/project-management/src/work_service/audit.rs @@ -26,9 +26,11 @@ pub(crate) fn bump_change_seq(tx: &Transaction<'_>) -> Result { ON CONFLICT(id) DO UPDATE SET seq = seq + 1", [], ))?; - map_db(tx.query_row("SELECT seq FROM pm_change_seq WHERE id = 1", [], |row| { - row.get(0) - })) + map_db( + tx.query_row("SELECT seq FROM pm_change_seq WHERE id = 1", [], |row| { + row.get(0) + }), + ) } pub(crate) struct AuditEventRow<'a> { @@ -97,9 +99,7 @@ pub struct AuditStatusTransition { /// `status_from`/`status_to` in the same transaction, so scanning the /// stream is the reliable way to observe transitions made by other /// processes (the in-process notifier cannot fire for them). -pub fn read_status_transitions_since( - after_seq: i64, -) -> Result, String> { +pub fn read_status_transitions_since(after_seq: i64) -> Result, String> { let connection = database::db::get_projects_connection().map_err(|err| format!("pm audit: {}", err))?; let mut stmt = map_db(connection.prepare( diff --git a/src-tauri/crates/project-management/src/work_service/mod.rs b/src-tauri/crates/project-management/src/work_service/mod.rs index 222cf7ed17..47ccd47b15 100644 --- a/src-tauri/crates/project-management/src/work_service/mod.rs +++ b/src-tauri/crates/project-management/src/work_service/mod.rs @@ -150,8 +150,8 @@ pub fn run_idempotent( )); } Some((_, Some(stored_response), _)) => { - let response = serde_json::from_str(&stored_response) - .unwrap_or(serde_json::Value::Null); + let response = + serde_json::from_str(&stored_response).unwrap_or(serde_json::Value::Null); return Ok(IdempotencyOutcome::Replayed(response)); } Some((_, None, reserved_at)) => { @@ -323,22 +323,27 @@ pub fn patch_standalone_work_item( let title_owned = title.map(str::to_string); let body_owned = body.map(str::to_string); let priority_owned = priority.map(str::to_string); - project_io::update_standalone_work_item_atomic_by(org_id, actor, short_id, move |frontmatter, current_body| { - if let Some(title) = title_owned { - frontmatter.title = title; - } - if let Some(body) = body_owned { - *current_body = body; - } - if let Some(priority) = priority_owned { - frontmatter.priority = priority; - } - if let Some(stage) = stage { - frontmatter.stage = stage; - } - frontmatter.updated_at = chrono::Utc::now().to_rfc3339(); - Ok(()) - })?; + project_io::update_standalone_work_item_atomic_by( + org_id, + actor, + short_id, + move |frontmatter, current_body| { + if let Some(title) = title_owned { + frontmatter.title = title; + } + if let Some(body) = body_owned { + *current_body = body; + } + if let Some(priority) = priority_owned { + frontmatter.priority = priority; + } + if let Some(stage) = stage { + frontmatter.stage = stage; + } + frontmatter.updated_at = chrono::Utc::now().to_rfc3339(); + Ok(()) + }, + )?; project_io::read_standalone_work_item(org_id, short_id) } @@ -482,7 +487,8 @@ pub fn overwrite_project_work_item( true, )?; append_create_audit_in_tx(&tx, short_id, Some(project_slug), None, actor)?; - tx.commit().map_err(|err| format!("work.write commit: {err}"))?; + tx.commit() + .map_err(|err| format!("work.write commit: {err}"))?; crate::projects::events::notify_work_item_schedule_changed(); crate::sync::collab_bridge::record_work_item_write( &org_id, @@ -504,11 +510,15 @@ pub fn overwrite_standalone_work_item( if project_io::read_standalone_work_item(org_id, short_id).is_ok() { let next_frontmatter = frontmatter.clone(); let next_body = body.to_string(); - project_io::update_standalone_work_item_atomic(org_id, short_id, move |current, current_body| { - *current = next_frontmatter; - *current_body = next_body; - Ok(()) - })?; + project_io::update_standalone_work_item_atomic( + org_id, + short_id, + move |current, current_body| { + *current = next_frontmatter; + *current_body = next_body; + Ok(()) + }, + )?; let _ = actor; return Ok(()); } @@ -519,7 +529,8 @@ pub fn overwrite_standalone_work_item( guard_new_work_item_id_in_tx(&tx, short_id)?; project_io::write_work_item_in_tx(&tx, None, &resolved_org, short_id, frontmatter, body, true)?; append_create_audit_in_tx(&tx, short_id, None, Some(&resolved_org), actor)?; - tx.commit().map_err(|err| format!("work.write commit: {err}"))?; + tx.commit() + .map_err(|err| format!("work.write commit: {err}"))?; crate::projects::events::notify_work_item_schedule_changed(); crate::sync::collab_bridge::record_work_item_write( &resolved_org, @@ -901,7 +912,8 @@ pub fn create_project_work_item( true, )?; append_create_audit_in_tx(&tx, short_id, Some(project_slug), None, actor)?; - tx.commit().map_err(|err| format!("work.create commit: {err}"))?; + tx.commit() + .map_err(|err| format!("work.create commit: {err}"))?; crate::projects::events::notify_work_item_schedule_changed(); crate::sync::collab_bridge::record_work_item_write( &org_id, @@ -915,10 +927,7 @@ pub fn create_project_work_item( /// Current OCC revision (`local_version`) of a project-scoped item — /// surfaced through `work show` so callers can supply /// `--expected-revision` on the next mutation. -pub fn read_project_work_item_revision( - project_slug: &str, - short_id: &str, -) -> Result { +pub fn read_project_work_item_revision(project_slug: &str, short_id: &str) -> Result { let connection = project_io::helpers::conn()?; connection .query_row( @@ -944,6 +953,20 @@ pub fn note_project_work_item( kind: &str, body: &str, actor: Option<&WorkItemMutationActor>, +) -> Result<(), String> { + note_project_work_item_threaded(project_slug, short_id, kind, body, None, actor) +} + +/// Append a note as a reply in a persisted Discussion thread without waking +/// the linked Session again. This is the receipt path used by an agent that +/// was already resumed for the parent comment. +pub fn note_project_work_item_threaded( + project_slug: &str, + short_id: &str, + kind: &str, + body: &str, + parent_id: Option<&str>, + actor: Option<&WorkItemMutationActor>, ) -> Result<(), String> { let author = actor .map(|a| a.name.clone()) @@ -958,6 +981,7 @@ pub fn note_project_work_item( }; let reason = Some(kind.to_string()); let body_owned = note_body; + let parent_id = parent_id.map(str::to_string); project_io::update_work_item_atomic_serviced( project_slug, short_id, @@ -969,13 +993,92 @@ pub fn note_project_work_item( }, move |frontmatter, _item_body| { let now = chrono::Utc::now().to_rfc3339(); - frontmatter.comments.push(crate::projects::types::CommentEntry { - id: format!("note-{}", chrono::Utc::now().timestamp_millis()), - author, - content: body_owned, - created_at: now, - mentioned_user_ids: vec![], - }); + let thread_id = parent_id + .as_deref() + .map(|parent_id| { + frontmatter + .comments + .iter() + .find(|comment| comment.id == parent_id) + .map(|comment| { + comment + .thread_id + .clone() + .unwrap_or_else(|| comment.id.clone()) + }) + .ok_or_else(|| format!("Discussion parent '{parent_id}' not found")) + }) + .transpose()?; + frontmatter + .comments + .push(crate::projects::types::CommentEntry { + id: format!("note-{}", chrono::Utc::now().timestamp_millis()), + author, + content: body_owned, + created_at: now, + mentioned_user_ids: vec![], + parent_id, + thread_id, + ..Default::default() + }); + Ok(()) + }, + ) +} + +/// Idempotent form of [`note_project_work_item`] for durable consumers. +/// +/// The caller owns `note_id` and must derive it from the source event. Replays +/// after a process crash become a no-op once that exact note is present, while +/// the Work Item mutation and its audit/write side effects still share the +/// normal atomic boundary. +pub fn note_project_work_item_idempotent( + project_slug: &str, + short_id: &str, + note_id: &str, + kind: &str, + body: &str, + actor: Option<&WorkItemMutationActor>, +) -> Result<(), String> { + if note_id.trim().is_empty() { + return Err("note_id is required".to_string()); + } + let author = actor + .map(|a| a.name.clone()) + .unwrap_or_else(|| "agent".to_string()); + let note_body = if kind == "comment" { + body.to_string() + } else { + format!("[{}] {}", kind, body) + }; + let stable_note_id = note_id.to_string(); + project_io::update_work_item_atomic_serviced( + project_slug, + short_id, + actor, + project_io::AtomicServiceOptions { + operation: Some("work.note"), + reason: Some(kind.to_string()), + ..Default::default() + }, + move |frontmatter, _item_body| { + if frontmatter + .comments + .iter() + .any(|comment| comment.id == stable_note_id) + { + return Ok(()); + } + frontmatter + .comments + .push(crate::projects::types::CommentEntry { + id: stable_note_id, + author, + content: note_body, + created_at: chrono::Utc::now().to_rfc3339(), + mentioned_user_ids: vec![], + ..Default::default() + }); Ok(()) }, ) @@ -987,6 +1090,17 @@ pub fn note_standalone_work_item( kind: &str, body: &str, actor: Option<&WorkItemMutationActor>, +) -> Result<(), String> { + note_standalone_work_item_threaded(org_id, short_id, kind, body, None, actor) +} + +pub fn note_standalone_work_item_threaded( + org_id: Option<&str>, + short_id: &str, + kind: &str, + body: &str, + parent_id: Option<&str>, + actor: Option<&WorkItemMutationActor>, ) -> Result<(), String> { let author = actor .map(|a| a.name.clone()) @@ -996,6 +1110,7 @@ pub fn note_standalone_work_item( } else { format!("[{}] {}", kind, body) }; + let parent_id = parent_id.map(str::to_string); project_io::update_standalone_work_item_atomic_serviced( org_id, actor, @@ -1007,13 +1122,87 @@ pub fn note_standalone_work_item( short_id, move |frontmatter, _item_body| { let now = chrono::Utc::now().to_rfc3339(); - frontmatter.comments.push(crate::projects::types::CommentEntry { - id: format!("note-{}", chrono::Utc::now().timestamp_millis()), - author, - content: note_body, - created_at: now, - mentioned_user_ids: vec![], - }); + let thread_id = parent_id + .as_deref() + .map(|parent_id| { + frontmatter + .comments + .iter() + .find(|comment| comment.id == parent_id) + .map(|comment| { + comment + .thread_id + .clone() + .unwrap_or_else(|| comment.id.clone()) + }) + .ok_or_else(|| format!("Discussion parent '{parent_id}' not found")) + }) + .transpose()?; + frontmatter + .comments + .push(crate::projects::types::CommentEntry { + id: format!("note-{}", chrono::Utc::now().timestamp_millis()), + author, + content: note_body, + created_at: now, + mentioned_user_ids: vec![], + parent_id, + thread_id, + ..Default::default() + }); + Ok(()) + }, + ) +} + +/// Standalone counterpart to [`note_project_work_item_idempotent`]. +pub fn note_standalone_work_item_idempotent( + org_id: Option<&str>, + short_id: &str, + note_id: &str, + kind: &str, + body: &str, + actor: Option<&WorkItemMutationActor>, +) -> Result<(), String> { + if note_id.trim().is_empty() { + return Err("note_id is required".to_string()); + } + let author = actor + .map(|a| a.name.clone()) + .unwrap_or_else(|| "agent".to_string()); + let note_body = if kind == "comment" { + body.to_string() + } else { + format!("[{}] {}", kind, body) + }; + let stable_note_id = note_id.to_string(); + project_io::update_standalone_work_item_atomic_serviced( + org_id, + actor, + project_io::AtomicServiceOptions { + operation: Some("work.note"), + reason: Some(kind.to_string()), + ..Default::default() + }, + short_id, + move |frontmatter, _item_body| { + if frontmatter + .comments + .iter() + .any(|comment| comment.id == stable_note_id) + { + return Ok(()); + } + frontmatter + .comments + .push(crate::projects::types::CommentEntry { + id: stable_note_id, + author, + content: note_body, + created_at: chrono::Utc::now().to_rfc3339(), + mentioned_user_ids: vec![], + ..Default::default() + }); Ok(()) }, ) @@ -1042,7 +1231,8 @@ pub fn relate_project_work_item( if !PORTABLE_RELATION_KINDS.contains(&kind) { return Err(format!( "{}:relation kind '{}' is not portable", - error::PREFIX, kind + error::PREFIX, + kind )); } // Existence check outside the tx (short id is scope-stable). @@ -1078,7 +1268,8 @@ pub fn relate_project_work_item( payload: serde_json::json!({ "kind": kind, "targetRef": target_ref }), }, )?; - tx.commit().map_err(|err| format!("pm relate commit: {}", err)) + tx.commit() + .map_err(|err| format!("pm relate commit: {}", err)) } /// Read the typed relations of a project-scoped item. @@ -1110,7 +1301,10 @@ const ROOT_BOOTSTRAP_TITLE_MAX_CHARS: usize = 80; fn derive_root_bootstrap_title(content: &str) -> String { let first_line = content.trim().lines().next().unwrap_or("").trim(); - let title: String = first_line.chars().take(ROOT_BOOTSTRAP_TITLE_MAX_CHARS).collect(); + let title: String = first_line + .chars() + .take(ROOT_BOOTSTRAP_TITLE_MAX_CHARS) + .collect(); if title.is_empty() { "Untitled project".to_string() } else { @@ -1153,9 +1347,8 @@ pub fn bootstrap_root_standalone_item( session_id, &canonical, move || { - let short_id = crate::projects::io::allocate_standalone_short_id( - org_for_execute.as_deref(), - )?; + let short_id = + crate::projects::io::allocate_standalone_short_id(org_for_execute.as_deref())?; let request = CreateWorkItemRequest { title, body, @@ -1230,8 +1423,14 @@ pub fn create_standalone_work_item( true, )?; append_create_audit_in_tx(&tx, short_id, None, Some(&resolved_org), actor)?; - tx.commit().map_err(|err| format!("work.create commit: {err}"))?; + tx.commit() + .map_err(|err| format!("work.create commit: {err}"))?; crate::projects::events::notify_work_item_schedule_changed(); - crate::sync::collab_bridge::record_work_item_write(&resolved_org, None, &frontmatter.id, false)?; + crate::sync::collab_bridge::record_work_item_write( + &resolved_org, + None, + &frontmatter.id, + false, + )?; project_io::read_standalone_work_item(org_id, short_id) } diff --git a/src-tauri/crates/project-management/src/work_service/tests.rs b/src-tauri/crates/project-management/src/work_service/tests.rs index b88a548fd7..e63accf52c 100644 --- a/src-tauri/crates/project-management/src/work_service/tests.rs +++ b/src-tauri/crates/project-management/src/work_service/tests.rs @@ -240,11 +240,17 @@ fn create_refuses_to_overwrite_an_existing_id_in_any_scope() { }; let same_scope = create_project_work_item("demo", "AAA-0001", &clobber, None).expect_err("must refuse"); - assert!(same_scope.starts_with(error::ALREADY_EXISTS), "{same_scope}"); + assert!( + same_scope.starts_with(error::ALREADY_EXISTS), + "{same_scope}" + ); let cross_scope = create_standalone_work_item(None, "AAA-0001", &clobber, None) .expect_err("cross-scope must refuse"); - assert!(cross_scope.starts_with(error::ALREADY_EXISTS), "{cross_scope}"); + assert!( + cross_scope.starts_with(error::ALREADY_EXISTS), + "{cross_scope}" + ); let survivor = read_work_item("demo", "AAA-0001").expect("survivor"); assert_eq!(survivor.frontmatter.title, "First"); @@ -356,7 +362,11 @@ fn run_idempotent_concurrent_same_key_executes_exactly_once() { let ra = a.join().expect("thread a").expect("outcome a"); let rb = b.join().expect("thread b").expect("outcome b"); - assert_eq!(executions.load(Ordering::SeqCst), 1, "exactly one execution"); + assert_eq!( + executions.load(Ordering::SeqCst), + 1, + "exactly one execution" + ); let fresh = matches!(ra, IdempotencyOutcome::Fresh(_)) as u8 + matches!(rb, IdempotencyOutcome::Fresh(_)) as u8; assert_eq!(fresh, 1, "one fresh, one replayed"); @@ -389,18 +399,54 @@ fn noted_by_actor_since_sees_only_matching_note_rows() { .expect("transition"); assert!(!work_item_noted_by_actor_since("AAA-0002", "agent:os", before_ms).expect("query")); - note_project_work_item("demo", "AAA-0002", "progress", "half way", Some(&actor)) - .expect("note"); + note_project_work_item("demo", "AAA-0002", "progress", "half way", Some(&actor)).expect("note"); assert!(work_item_noted_by_actor_since("AAA-0002", "agent:os", before_ms).expect("query")); // Different actor and a window after the write both miss. - assert!( - !work_item_noted_by_actor_since("AAA-0002", "agent:sde", before_ms).expect("query") - ); + assert!(!work_item_noted_by_actor_since("AAA-0002", "agent:sde", before_ms).expect("query")); let after_ms = chrono::Utc::now().timestamp_millis() + 1; assert!(!work_item_noted_by_actor_since("AAA-0002", "agent:os", after_ms).expect("query")); } +#[test] +fn durable_note_id_makes_stage_replay_idempotent() { + let _sandbox = test_env::sandbox(); + seed("demo", "p1"); + let actor = crate::projects::types::WorkItemMutationActor { + id: "system".to_string(), + name: "System".to_string(), + }; + + note_project_work_item_idempotent( + "demo", + "AAA-0001", + "note-stage-stable", + "progress", + "Stage 1 settled", + Some(&actor), + ) + .expect("first note"); + note_project_work_item_idempotent( + "demo", + "AAA-0001", + "note-stage-stable", + "progress", + "Stage 1 settled", + Some(&actor), + ) + .expect("replayed note"); + + let item = read_work_item("demo", "AAA-0001").expect("read"); + let matching: Vec<_> = item + .frontmatter + .comments + .iter() + .filter(|comment| comment.id == "note-stage-stable") + .collect(); + assert_eq!(matching.len(), 1); + assert_eq!(matching[0].content, "[progress] Stage 1 settled"); +} + #[test] fn root_bootstrap_is_idempotent_and_falls_back_to_personal_scope() { let _sandbox = test_env::sandbox(); @@ -440,8 +486,7 @@ fn standalone_note_audits_as_work_note() { }; let before_ms = chrono::Utc::now().timestamp_millis() - 1; - note_standalone_work_item(None, "SA-0001", "progress", "receipt", Some(&actor)) - .expect("note"); + note_standalone_work_item(None, "SA-0001", "progress", "receipt", Some(&actor)).expect("note"); // Standalone notes must stamp the canonical `work.note` operation — // the receipt-fallback dedup query depends on it (a `work.patch` From 4c53c25a4003079a55ff33d1881ce1b15f01b203 Mon Sep 17 00:00:00 2001 From: Neonforge <48338160+Neonforge98@users.noreply.github.com> Date: Sun, 9 Aug 2026 07:12:34 -0700 Subject: [PATCH 2/8] feat(agent): unify project execution across providers and routines --- .../src/core/coordination/child_done_wake.rs | 176 ++--- .../agent-core/src/core/coordination/mod.rs | 1 + .../coordination/work_item_run_dispatcher.rs | 603 ++++++++++++++++++ .../core/coordination/work_item_scheduler.rs | 69 +- .../core/interaction/plan_approval/manager.rs | 4 +- .../src/core/session/launch/launch_org.rs | 12 +- .../src/core/session/launch/launch_tests.rs | 20 + .../agent-core/src/core/session/launch/mod.rs | 54 +- .../src/core/session/persistence/crud/mod.rs | 11 +- .../src/core/session/persistence/crud/ops.rs | 25 + .../src/core/session/persistence/mod.rs | 13 +- .../agent-core/src/core/session/prompt/mod.rs | 7 + .../core/session/prompt/section_builders.rs | 4 +- .../turn/processor/post_turn_dispatch.rs | 6 +- .../src/core/session/turn/processor/prompt.rs | 5 +- .../turn/processor/receipt_fallback.rs | 15 +- .../core/tools/builtin_tools/table/aliases.rs | 3 +- .../agent-core/src/core/tools/defaults.rs | 6 +- .../tools/impls/coding/exec/shell_replay.rs | 6 +- .../impls/coding/exec/shell_replay/active.rs | 5 +- .../impls/coding/exec/shell_replay/tests.rs | 10 +- .../tools/impls/coding/exec/subprocess.rs | 7 +- .../src/core/tools/tests/defaults_tests.rs | 4 +- .../persistence/session_snapshots.rs | 39 +- .../tests/session_snapshots_tests.rs | 72 ++- .../src/foundation/session_bridge.rs | 54 +- .../src/foundation/tool_infra/mod.rs | 3 + .../tool_infra/project/execution.rs | 249 ++++++-- .../src/foundation/tool_infra/project/mod.rs | 5 +- .../integrations/automation/triggers/timer.rs | 2 - .../src/orchestrator_notify/handlers.rs | 5 +- .../agent-core/src/orchestrator_notify/mod.rs | 171 ++++- .../skills/loader/scanner/cache.rs | 2 +- .../skills/loader/scanner/fs_scan.rs | 2 +- .../skills/loader/scanner/listing.rs | 2 +- .../loader/scanner/listing_budget_tests.rs | 12 +- .../skills/loader/scanner/metadata.rs | 2 +- .../agent-core/src/state/commands/routines.rs | 94 ++- .../src/state/commands/session/create.rs | 154 +++-- .../src/state/commands/session/launch.rs | 97 ++- .../session/message/project_bootstrap.rs | 130 +++- .../state/commands/session/message/send.rs | 91 ++- .../src/state/commands/session/persistence.rs | 10 +- .../crates/database/src/db/connection.rs | 151 ++++- .../src/sources/cursor_ide/db/mod.rs | 4 +- .../src/sources/cursor_ide/db/tests.rs | 4 +- .../imported_history/watermark_tests.rs | 20 +- .../src/sources/windsurf/history_tests.rs | 4 +- .../crates/orgtrack-pm-cli/src/commands.rs | 153 +++-- .../crates/orgtrack-pm-cli/src/context.rs | 5 +- .../crates/orgtrack-pm-cli/src/envelope.rs | 11 +- .../crates/orgtrack-pm-cli/tests/cli_e2e.rs | 268 ++++++-- .../orgtrack-pm-cli/tests/conformance.rs | 37 +- .../src/agent_core_bridge.rs | 34 + .../crates/session-persistence/src/crud.rs | 11 +- .../crates/session-persistence/src/lib.rs | 4 +- .../session-persistence/src/turn_intents.rs | 6 + .../agent_sessions/cli/agent_core_bridge.rs | 21 + .../cli/commands/resume_delete.rs | 19 + .../src/agent_sessions/cli/commands/run.rs | 234 +++++-- src-tauri/src/agent_sessions/cli/mod.rs | 20 + .../src/agent_sessions/cli/parsers/codex.rs | 21 +- .../cli/parsers/codex_app_server.rs | 13 +- .../parsers/tests/codex_app_server_tests.rs | 23 + .../parsers/tests/parser_integration_tests.rs | 1 + .../cli/persistence/session_crud.rs | 46 +- .../cli/session_runner/env_setup.rs | 31 +- .../cli/session_runner/finalize.rs | 144 ++++- .../cli/session_runner/harness_hooks.rs | 228 +++++++ .../cli/session_runner/input_assembly.rs | 287 ++++++++- .../agent_sessions/cli/session_runner/mod.rs | 3 + .../cli/session_runner/session.rs | 113 ++-- .../cli/session_runner/session/tests.rs | 40 +- .../src/agent_sessions/cli/skill_sync.rs | 192 ++++-- .../event_pipeline/commands/turn_window.rs | 15 +- .../event_pipeline/tests/store_tests.rs | 4 +- .../agent_sessions/session_directory/patch.rs | 129 ++-- src-tauri/src/api/agent/test/agent_org.rs | 1 + src-tauri/src/api/agent/test/core.rs | 2 +- src-tauri/src/api/agent/test/workspace.rs | 1 + src-tauri/src/api/websocket_handler.rs | 14 +- src-tauri/src/benchmark/launch.rs | 1 + src-tauri/src/commands/handler_list.inc | 22 + .../src/infrastructure/cloud_identity.rs | 9 +- src-tauri/src/lib.rs | 76 ++- src-tauri/src/orgtrack/history_commands.rs | 3 + 86 files changed, 3935 insertions(+), 752 deletions(-) create mode 100644 src-tauri/crates/agent-core/src/core/coordination/work_item_run_dispatcher.rs create mode 100644 src-tauri/src/agent_sessions/cli/session_runner/harness_hooks.rs diff --git a/src-tauri/crates/agent-core/src/core/coordination/child_done_wake.rs b/src-tauri/crates/agent-core/src/core/coordination/child_done_wake.rs index a0cf244539..1ad36c18ae 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/child_done_wake.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/child_done_wake.rs @@ -8,15 +8,15 @@ //! only the closure of the whole (implicit) stage does, so multi-child //! plans produce one wake instead of a wake storm. -use std::collections::HashSet; -use std::sync::Mutex; -use std::sync::OnceLock; - +use sha2::{Digest, Sha256}; use tracing::{info, warn}; use project_management::projects::events::WorkItemTerminalEvent; use project_management::projects::io as pio; -use project_management::projects::types::{WorkItemData, WorkItemMutationActor}; +use project_management::projects::types::{ + EnqueueWorkItemRunRequest, WorkItemData, WorkItemMutationActor, WorkItemRunTarget, + WorkItemRunTargetSnapshot, WorkItemRunTrigger, +}; use project_management::work_service; use project_management::work_service::state::{map_legacy_status, WorkItemState}; @@ -27,11 +27,6 @@ fn is_terminal(status: &str) -> bool { ) } -fn wake_dedupe() -> &'static Mutex> { - static SET: OnceLock>> = OnceLock::new(); - SET.get_or_init(|| Mutex::new(HashSet::new())) -} - /// Register the terminal-transition observer. Called once at app setup. pub fn register(app: tauri::AppHandle) { project_management::projects::events::register_work_item_terminal_notifier(Box::new( @@ -57,18 +52,15 @@ pub fn process_event(app: tauri::AppHandle, event: WorkItemTerminalEvent) { }); } -/// Cross-process bridge: fold audit-stream status transitions committed -/// by OTHER processes (the org2-pm CLI in agent shells) into the same -/// wake pipeline. Returns the number of terminal crossings dispatched. -pub fn process_audit_window(app: &tauri::AppHandle, after_seq: i64) -> usize { - let transitions = - match project_management::work_service::audit::read_status_transitions_since(after_seq) { - Ok(rows) => rows, - Err(error) => { - warn!(error = %error, "[child-done-wake] audit window read failed"); - return 0; - } - }; +/// Cross-process bridge: fold audit-stream status transitions committed by +/// other processes into durable Stage-barrier dispatches. The caller advances +/// its persistent cursor only after this future succeeds. +pub async fn process_audit_window(app: &tauri::AppHandle, after_seq: i64) -> Result { + let transitions = tokio::task::spawn_blocking(move || { + project_management::work_service::audit::read_status_transitions_since(after_seq) + }) + .await + .map_err(|err| format!("audit read join error: {err}"))??; let mut dispatched = 0; for transition in transitions { if is_terminal(&transition.status_from) || !is_terminal(&transition.status_to) { @@ -82,7 +74,7 @@ pub fn process_audit_window(app: &tauri::AppHandle, after_seq: i64) -> usize { Ok(Some(item)) => item, _ => continue, }; - process_event( + handle_child_terminal( app.clone(), WorkItemTerminalEvent { org_id, @@ -91,10 +83,11 @@ pub fn process_audit_window(app: &tauri::AppHandle, after_seq: i64) -> usize { parent: item.frontmatter.parent.clone(), status: transition.status_to.clone(), }, - ); + ) + .await?; dispatched += 1; } - dispatched + Ok(dispatched) } async fn handle_child_terminal( @@ -117,31 +110,17 @@ async fn handle_child_terminal( return Ok(()); }; - let dedupe_key = format!( - "{}/{}/{}", - event.org_id, - event.project_slug.as_deref().unwrap_or("-"), - parent_short_id - ); - { - let mut seen = wake_dedupe().lock().map_err(|_| "dedupe poisoned")?; - if !seen.insert(format!("{dedupe_key}:{}", barrier.settled_key)) { - return Ok(()); - } - } - let note = barrier.summary.clone(); - { - let event = event.clone(); - let parent_short_id = parent_short_id.clone(); - let note = note.clone(); - tokio::task::spawn_blocking(move || post_parent_note(&event, &parent_short_id, ¬e)) - .await - .map_err(|err| format!("join error: {err}"))??; - } - project_management::projects::events::notify_data_changed(); - + let note_id = stage_note_id(&event, &parent_short_id, &barrier.settled_key); let Some(session_id) = barrier.parent_session_id else { + let event_for_note = event.clone(); + let parent_for_note = parent_short_id.clone(); + tokio::task::spawn_blocking(move || { + post_parent_note(&event_for_note, &parent_for_note, ¬e_id, ¬e) + }) + .await + .map_err(|err| format!("join error: {err}"))??; + project_management::projects::events::notify_data_changed(); info!( parent = %parent_short_id, "[child-done-wake] barrier closed; note posted (no linked session to wake)" @@ -149,43 +128,72 @@ async fn handle_child_terminal( return Ok(()); }; - use tauri::Manager; - let Some(state) = app.try_state::() else { - return Err("AgentAppState unavailable".to_string()); - }; let content = format!( "[Sub-items complete] {note}\n\nReview the parent with `org2-pm work show {parent_short_id}` \ and decide the next step — close it out, or create/advance follow-up items. \ Deliver every outcome through org2-pm with exactly one Discussion receipt." ); let display_text = format!("🧩 Sub-item barrier closed on {parent_short_id}"); - crate::state::commands::session::message::send_message_impl( - &state, - session_id.clone(), - content, - Some(display_text), - crate::state::commands::session::identity::IdentityOverrides::default(), - None, - None, - None, - false, - false, - None, - None, - None, - None, - crate::foundation::session_bridge::TurnIntentBridgeSource::Queue, - ) + let request = EnqueueWorkItemRunRequest { + project_slug: event.project_slug.clone(), + org_id: event.org_id.clone(), + work_item_id: parent_short_id.clone(), + trigger: WorkItemRunTrigger::StageBarrier { + parent_work_item_id: parent_short_id.clone(), + stage: barrier.stage, + settled_key: barrier.settled_key.clone(), + }, + target_snapshot: WorkItemRunTargetSnapshot::new(WorkItemRunTarget::ResumeSession { + session_id: session_id.clone(), + }), + input: serde_json::json!({ + "content": content, + "displayText": display_text, + }), + idempotency_key: format!("stage-barrier:{}:{}", parent_short_id, barrier.settled_key), + max_attempts: 3, + parent_run_id: None, + }; + tokio::task::spawn_blocking(move || project_management::work_run_service::enqueue(request)) + .await + .map_err(|err| format!("join error: {err}"))??; + + // The durable Run/outbox is committed first. If the process exits after + // this point, replay returns the same Run via its idempotency key and the + // stable note id below prevents duplicate Discussion receipts. + let event_for_note = event.clone(); + let parent_for_note = parent_short_id.clone(); + tokio::task::spawn_blocking(move || { + post_parent_note(&event_for_note, &parent_for_note, ¬e_id, ¬e) + }) .await - .map_err(|err| format!("wake enqueue failed: {err}"))?; + .map_err(|err| format!("join error: {err}"))??; + project_management::projects::events::notify_data_changed(); info!( parent = %parent_short_id, session_id, - "[child-done-wake] barrier closed; parent session woken" + "[child-done-wake] barrier closed; durable parent wake queued" ); + let _ = app; Ok(()) } +fn stage_note_id( + event: &WorkItemTerminalEvent, + parent_short_id: &str, + settled_key: &str, +) -> String { + let mut hasher = Sha256::new(); + hasher.update(event.org_id.as_bytes()); + hasher.update([0]); + hasher.update(event.project_slug.as_deref().unwrap_or("-").as_bytes()); + hasher.update([0]); + hasher.update(parent_short_id.as_bytes()); + hasher.update([0]); + hasher.update(settled_key.as_bytes()); + format!("note-stage-{:x}", hasher.finalize()) +} + struct BarrierClosure { /// Stable key of the settled barrier (stage-scoped), so /// re-transitions of an already-closed barrier don't wake twice. @@ -194,6 +202,7 @@ struct BarrierClosure { /// wake message. summary: String, parent_session_id: Option, + stage: Option, } fn completed_count(children: &[&WorkItemData]) -> usize { @@ -283,6 +292,7 @@ fn evaluate_barrier( settled_key: sorted_ids_key(&children), summary, parent_session_id, + stage: None, })); } @@ -333,7 +343,13 @@ fn evaluate_barrier( } else { "" }; - progress_parts.push(format!("Stage {}: {}/{}{}", stage, settled, members.len(), marker)); + progress_parts.push(format!( + "Stage {}: {}/{}{}", + stage, + settled, + members.len(), + marker + )); } let next_hint = match next_stage { Some(stage) => format!( @@ -355,12 +371,14 @@ fn evaluate_barrier( settled_key: format!("stage{}:{}", triggering_stage, sorted_ids_key(&frontier)), summary, parent_session_id, + stage: Some(triggering_stage), })) } fn post_parent_note( event: &WorkItemTerminalEvent, parent_short_id: &str, + note_id: &str, note: &str, ) -> Result<(), String> { let actor = WorkItemMutationActor { @@ -368,12 +386,18 @@ fn post_parent_note( name: "System".to_string(), }; match event.project_slug.as_deref() { - Some(slug) => { - work_service::note_project_work_item(slug, parent_short_id, "progress", note, Some(&actor)) - } - None => work_service::note_standalone_work_item( + Some(slug) => work_service::note_project_work_item_idempotent( + slug, + parent_short_id, + note_id, + "progress", + note, + Some(&actor), + ), + None => work_service::note_standalone_work_item_idempotent( Some(event.org_id.as_str()), parent_short_id, + note_id, "progress", note, Some(&actor), diff --git a/src-tauri/crates/agent-core/src/core/coordination/mod.rs b/src-tauri/crates/agent-core/src/core/coordination/mod.rs index 9aea1d49e2..b9b9e3dff8 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/mod.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/mod.rs @@ -30,6 +30,7 @@ pub mod agent_org_watchdog; pub mod child_done_wake; pub mod routine_scheduler; pub mod work_item_recovery; +pub mod work_item_run_dispatcher; pub mod work_item_scheduler; /// Initialize the complete durable Agent Org runtime schema in dependency diff --git a/src-tauri/crates/agent-core/src/core/coordination/work_item_run_dispatcher.rs b/src-tauri/crates/agent-core/src/core/coordination/work_item_run_dispatcher.rs new file mode 100644 index 0000000000..af1dea4588 --- /dev/null +++ b/src-tauri/crates/agent-core/src/core/coordination/work_item_run_dispatcher.rs @@ -0,0 +1,603 @@ +//! Durable Work Item Run dispatcher. +//! +//! Producers commit a `pm_work_item_runs` row and its outbox row in one +//! transaction. This worker is the only runtime delivery path: it claims an +//! expiring lease, materializes or resumes the target Session with the Run id +//! as the durable turn intent, then acknowledges delivery. A process crash at +//! any boundary is reconciled from the persisted Session/intent state. + +use std::time::Duration; + +use project_management::projects::types::{ + WorkItemDispatchLease, WorkItemExecutionLockReason, WorkItemRunTarget, WorkItemRunTrigger, + WorkItemRunUsage, +}; +use project_management::work_run_service::{self, WorkItemRunTerminalOutcome}; +use tauri::Manager; +use tracing::{debug, error, info, warn}; + +use crate::foundation::session_bridge::TurnIntentBridgeStatus; + +const LEASE_MS: i64 = 30_000; +const IDLE_POLL_MS: u64 = 750; +const MAX_BATCH: usize = 8; + +/// Start the single durable dispatcher loop. The first claim is immediate so +/// fully-quit recovery does not wait for the polling interval. +pub fn spawn(app: tauri::AppHandle) { + let worker_id = format!("desktop_{}", uuid::Uuid::new_v4().simple()); + tauri::async_runtime::spawn(async move { + info!(worker_id, "[work-run-dispatcher] started"); + reconcile_interrupted_session_runs(&app).await; + crate::orchestrator_notify::reconcile_terminal_routine_dispatches(&app).await; + loop { + let mut handled = 0usize; + for _ in 0..MAX_BATCH { + let claim_worker_id = worker_id.clone(); + let lease = match tokio::task::spawn_blocking(move || { + work_run_service::claim_next_dispatch(&claim_worker_id, LEASE_MS) + }) + .await + { + Ok(Ok(lease)) => lease, + Ok(Err(err)) => { + error!(error = %err, "[work-run-dispatcher] claim failed"); + break; + } + Err(err) => { + error!(error = %err, "[work-run-dispatcher] claim task failed"); + break; + } + }; + let Some(lease) = lease else { + break; + }; + handled += 1; + if let Err(err) = dispatch_claim(&app, &lease).await { + let dispatch_id = lease.dispatch_id.clone(); + let lease_token = lease.lease_token.clone(); + let failure_message = err.clone(); + match tokio::task::spawn_blocking(move || { + work_run_service::record_dispatch_failure( + &dispatch_id, + &lease_token, + &failure_message, + ) + }) + .await + { + Ok(Ok(run)) => { + warn!( + run_id = %run.id, + status = run.status.as_str(), + error = %err, + "[work-run-dispatcher] delivery failed" + ); + crate::orchestrator_notify::notify_routine_fire_dispatch_terminal( + &run, &app, + ) + .await; + } + Ok(Err(nack_err)) => error!( + run_id = %lease.run.id, + error = %err, + nack_error = %nack_err, + "[work-run-dispatcher] failed to record delivery failure" + ), + Err(join_err) => error!( + run_id = %lease.run.id, + error = %err, + join_error = %join_err, + "[work-run-dispatcher] failure task crashed" + ), + } + } + } + + if handled == 0 { + tokio::time::sleep(Duration::from_millis(IDLE_POLL_MS)).await; + } else { + tokio::task::yield_now().await; + } + } + }); +} + +/// Close the crash window between dispatch acknowledgement and provider +/// terminal persistence. +/// +/// `AgentAppState` first converts every process-interrupted Session to +/// `abandoned`. The dispatcher then settles the owning execution episode and, +/// while budget remains, enqueues a new episode that resumes the same Session. +/// Routine fires deliberately stay active across that retry and are closed by +/// the ordinary Session-terminal path once the resumed turn really finishes. +async fn reconcile_interrupted_session_runs(app: &tauri::AppHandle) { + let candidates = match tokio::task::spawn_blocking(|| { + let runs = work_run_service::list_active_session_runs()?; + runs.into_iter() + .map(|run| { + let session = run + .session_id + .as_deref() + .map(crate::session::persistence::get_session) + .transpose() + .map_err(|err| err.to_string())? + .flatten(); + Ok((run, session)) + }) + .collect::, String>>() + }) + .await + { + Ok(Ok(candidates)) => candidates, + Ok(Err(err)) => { + error!(error = %err, "[work-run-dispatcher] startup recovery query failed"); + return; + } + Err(err) => { + error!(error = %err, "[work-run-dispatcher] startup recovery task failed"); + return; + } + }; + + for (run, session) in candidates { + let Some(session) = session else { + warn!( + run_id = %run.id, + session_id = ?run.session_id, + "[work-run-dispatcher] active Run references a missing Session" + ); + continue; + }; + let Some(status) = crate::session::SessionStatus::parse(&session.status) else { + warn!( + run_id = %run.id, + session_id = %session.session_id, + status = %session.status, + "[work-run-dispatcher] active Run references a Session with unknown status" + ); + continue; + }; + + use crate::session::SessionStatus; + let (outcome, message, routine_status, should_retry) = match status { + SessionStatus::Completed => ( + WorkItemRunTerminalOutcome::Succeeded, + None, + Some(crate::persistence::db_helpers::AgentSessionStatus::Completed), + false, + ), + SessionStatus::Abandoned | SessionStatus::Timeout => ( + WorkItemRunTerminalOutcome::Failed, + Some( + "request timed out because the app restarted before the turn reached a durable terminal" + .to_string(), + ), + Some(crate::persistence::db_helpers::AgentSessionStatus::Cancelled), + true, + ), + SessionStatus::Failed | SessionStatus::Archived => ( + WorkItemRunTerminalOutcome::Failed, + Some("runtime crashed or failed before Run terminal persistence".to_string()), + Some(crate::persistence::db_helpers::AgentSessionStatus::Failed), + false, + ), + SessionStatus::Cancelled => ( + WorkItemRunTerminalOutcome::Cancelled, + Some("session was cancelled before Run terminal persistence".to_string()), + Some(crate::persistence::db_helpers::AgentSessionStatus::Cancelled), + false, + ), + SessionStatus::Pending + | SessionStatus::Idle + | SessionStatus::Running + | SessionStatus::WaitingForUser + | SessionStatus::WaitingForFunds + | SessionStatus::Paused => continue, + }; + + let run_id = run.id.clone(); + let session_id = session.session_id.clone(); + let usage = WorkItemRunUsage { + total_tokens: session.total_tokens.max(0) as u64, + ..Default::default() + }; + let terminal_message = message.clone(); + let settled = match tokio::task::spawn_blocking(move || { + work_run_service::record_run_terminal( + &run_id, + Some(&session_id), + outcome, + usage, + terminal_message.as_deref(), + ) + }) + .await + { + Ok(Ok(run)) => run, + Ok(Err(err)) => { + error!( + run_id = %run.id, + session_id = %session.session_id, + error = %err, + "[work-run-dispatcher] startup Run settlement failed" + ); + continue; + } + Err(err) => { + error!( + run_id = %run.id, + session_id = %session.session_id, + error = %err, + "[work-run-dispatcher] startup Run settlement task failed" + ); + continue; + } + }; + + if should_retry + && settled + .failure + .as_ref() + .is_some_and(|failure| failure.retryable) + { + let failed_run_id = settled.id.clone(); + let retry_key = format!("startup-recovery:{}:{}", settled.id, settled.generation); + match tokio::task::spawn_blocking(move || { + work_run_service::retry(&failed_run_id, &retry_key) + }) + .await + { + Ok(Ok(retry)) => { + info!( + run_id = %settled.id, + retry_run_id = %retry.id, + session_id = %session.session_id, + attempt = retry.attempt, + max_attempts = retry.max_attempts, + "[work-run-dispatcher] recovered interrupted Run with durable retry" + ); + continue; + } + Ok(Err(err)) => warn!( + run_id = %settled.id, + session_id = %session.session_id, + error = %err, + "[work-run-dispatcher] interrupted Run could not be retried" + ), + Err(err) => warn!( + run_id = %settled.id, + session_id = %session.session_id, + error = %err, + "[work-run-dispatcher] interrupted Run retry task failed" + ), + } + } + + if let Some(status) = routine_status { + crate::orchestrator_notify::notify_routine_fire_session_terminal( + &session.session_id, + status, + Some(app), + ) + .await; + } + } +} + +async fn dispatch_claim( + app: &tauri::AppHandle, + lease: &WorkItemDispatchLease, +) -> Result<(), String> { + let run = &lease.run; + let session_id = match &run.target_snapshot.target { + WorkItemRunTarget::StartWorkItem { + account_id, + model_id, + } => { + if let Some(launch_snapshot) = run.input.get("sessionLaunchParams") { + dispatch_snapshotted_session_launch(app, run, launch_snapshot.clone()).await? + } else { + let project_slug = run.project_slug.as_deref().ok_or_else(|| { + "starting a standalone Work Item is not supported by the native launcher" + .to_string() + })?; + let started = crate::tool_infra::start_work_item_session_with_reason( + crate::tool_infra::StartWorkItemSessionRequest { + project_slug, + short_id: &run.work_item_id, + app, + session_account_id: account_id.as_deref(), + session_model_id: model_id.as_deref(), + lock_reason: lock_reason(&run.trigger), + durable_run_id: Some(&run.id), + execution_snapshot: Some(&run.target_snapshot), + }, + ) + .await?; + started.session_id + } + } + WorkItemRunTarget::ResumeSession { session_id } => { + dispatch_session_turn(app, lease, session_id).await?; + session_id.clone() + } + }; + + let dispatch_id = lease.dispatch_id.clone(); + let lease_token = lease.lease_token.clone(); + let ack_session_id = session_id.clone(); + let acknowledged = tokio::task::spawn_blocking(move || { + work_run_service::acknowledge_dispatch_started(&dispatch_id, &lease_token, &ack_session_id) + }) + .await + .map_err(|err| format!("dispatch acknowledgement task failed: {err}"))??; + + let routine_origin = match &acknowledged.trigger { + WorkItemRunTrigger::Routine { + routine_id, + fire_id, + } => Some((routine_id.clone(), fire_id.clone())), + WorkItemRunTrigger::Retry { .. } => { + let run_id = acknowledged.id.clone(); + match tokio::task::spawn_blocking(move || work_run_service::routine_origin(&run_id)) + .await + { + Ok(Ok(origin)) => origin, + Ok(Err(err)) => { + warn!( + run_id = %acknowledged.id, + error = %err, + "[work-run-dispatcher] retry Routine provenance lookup failed" + ); + None + } + Err(err) => { + warn!( + run_id = %acknowledged.id, + error = %err, + "[work-run-dispatcher] retry Routine provenance task failed" + ); + None + } + } + } + _ => None, + }; + if let Some((routine_id, fire_id)) = routine_origin { + let fire_id_for_update = fire_id.clone(); + let work_item_id = acknowledged.work_item_id.clone(); + let fire_session_id = session_id.clone(); + let linked = tokio::task::spawn_blocking(move || { + project_management::projects::io::mark_routine_fire_work_item_started( + &fire_id_for_update, + &work_item_id, + Some(&fire_session_id), + ) + }) + .await; + match linked { + Ok(Ok(_)) => crate::state::commands::routines::emit_routine_changed( + app, + &routine_id, + Some(&fire_id), + "started", + ), + Ok(Err(err)) => warn!( + run_id = %acknowledged.id, + error = %err, + "[work-run-dispatcher] routine fire link failed after delivery" + ), + Err(err) => warn!( + run_id = %acknowledged.id, + error = %err, + "[work-run-dispatcher] routine fire link task failed after delivery" + ), + } + } + + reconcile_terminal_intent(&acknowledged.id, &session_id).await; + debug!( + run_id = %acknowledged.id, + session_id, + "[work-run-dispatcher] delivered" + ); + Ok(()) +} + +async fn dispatch_snapshotted_session_launch( + app: &tauri::AppHandle, + run: &project_management::projects::types::WorkItemRun, + launch_snapshot: serde_json::Value, +) -> Result { + let mut params: crate::state::commands::session::launch::SessionLaunchParams = + serde_json::from_value(launch_snapshot) + .map_err(|err| format!("invalid durable session launch snapshot: {err}"))?; + params.durable_run_id = Some(run.id.clone()); + let state = app.state::(); + let org_store = app.state::>(); + let result = crate::state::commands::session::launch::session_launch_impl( + &state, + Some(org_store.inner()), + params, + ) + .await?; + Ok(result.session_id) +} + +async fn dispatch_session_turn( + app: &tauri::AppHandle, + lease: &WorkItemDispatchLease, + session_id: &str, +) -> Result<(), String> { + let run_id = &lease.run.id; + if crate::foundation::session_bridge::get_turn_intent_status(session_id, run_id).is_some() { + return Ok(()); + } + + let content = durable_resume_content(&lease.run.input) + .unwrap_or_default() + .to_string(); + if content.trim().is_empty() { + return Err("durable resume dispatch is missing input.content".to_string()); + } + let display_text = lease + .run + .input + .get("displayText") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + let client_message_id = lease + .run + .input + .get("clientMessageId") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()) + .unwrap_or(run_id) + .to_string(); + + if crate::foundation::session_bridge::get_cli_tools_snapshot(session_id)?.is_some() { + return crate::foundation::session_bridge::dispatch_cli_turn( + crate::foundation::session_bridge::CliTurnDispatchParams { + session_id: session_id.to_string(), + content, + turn_intent_id: run_id.clone(), + client_message_id, + }, + ) + .await; + } + + let state: tauri::State<'_, crate::state::AgentAppState> = app.state(); + crate::state::commands::session::message::send_message_impl( + &state, + session_id.to_string(), + content, + display_text, + crate::state::commands::session::identity::IdentityOverrides::default(), + None, + None, + None, + false, + false, + Some(client_message_id), + Some(run_id.clone()), + None, + None, + crate::foundation::session_bridge::TurnIntentBridgeSource::Queue, + ) + .await + .map(|_| ()) +} + +fn durable_resume_content(input: &serde_json::Value) -> Option<&str> { + ["content", "prompt", "instruction"] + .into_iter() + .find_map(|key| input.get(key).and_then(serde_json::Value::as_str)) + .or_else(|| input.as_str()) +} + +async fn reconcile_terminal_intent(run_id: &str, session_id: &str) { + let Some(status) = + crate::foundation::session_bridge::get_turn_intent_status(session_id, run_id) + else { + return; + }; + let (outcome, message) = match status { + TurnIntentBridgeStatus::Completed => (WorkItemRunTerminalOutcome::Succeeded, None), + TurnIntentBridgeStatus::Cancelled => ( + WorkItemRunTerminalOutcome::Cancelled, + Some("durable turn was cancelled".to_string()), + ), + TurnIntentBridgeStatus::Failed => ( + WorkItemRunTerminalOutcome::Failed, + Some("runtime crashed or failed while executing durable turn".to_string()), + ), + TurnIntentBridgeStatus::Stale + | TurnIntentBridgeStatus::Coalesced + | TurnIntentBridgeStatus::Rejected => ( + WorkItemRunTerminalOutcome::Failed, + Some(format!( + "durable turn became terminal before execution: {}", + status.as_str() + )), + ), + TurnIntentBridgeStatus::Optimistic + | TurnIntentBridgeStatus::Queued + | TurnIntentBridgeStatus::Running => return, + }; + let terminal_run_id = run_id.to_string(); + let terminal_session_id = session_id.to_string(); + match tokio::task::spawn_blocking(move || { + work_run_service::record_run_terminal( + &terminal_run_id, + Some(&terminal_session_id), + outcome, + WorkItemRunUsage::default(), + message.as_deref(), + ) + }) + .await + { + Ok(Ok(run)) => info!( + run_id = %run.id, + status = run.status.as_str(), + "[work-run-dispatcher] reconciled persisted terminal intent" + ), + Ok(Err(err)) => error!( + run_id, + session_id, + error = %err, + "[work-run-dispatcher] terminal reconciliation failed" + ), + Err(err) => error!( + run_id, + session_id, + error = %err, + "[work-run-dispatcher] terminal reconciliation task failed" + ), + } +} + +fn lock_reason(trigger: &WorkItemRunTrigger) -> WorkItemExecutionLockReason { + match trigger { + WorkItemRunTrigger::Manual => WorkItemExecutionLockReason::ManualStart, + WorkItemRunTrigger::Schedule { .. } | WorkItemRunTrigger::Routine { .. } => { + WorkItemExecutionLockReason::RoutineAutoStart + } + WorkItemRunTrigger::DiscussionComment { .. } | WorkItemRunTrigger::StageBarrier { .. } => { + WorkItemExecutionLockReason::AssignmentWakeup + } + WorkItemRunTrigger::Review { .. } + | WorkItemRunTrigger::FollowUp { .. } + | WorkItemRunTrigger::Retry { .. } => WorkItemExecutionLockReason::FollowUp, + } +} + +#[cfg(test)] +mod tests { + use super::{durable_resume_content, lock_reason}; + use project_management::projects::types::{WorkItemExecutionLockReason, WorkItemRunTrigger}; + + #[test] + fn trigger_maps_to_auditable_lock_reason() { + assert_eq!( + lock_reason(&WorkItemRunTrigger::Manual), + WorkItemExecutionLockReason::ManualStart + ); + assert_eq!( + lock_reason(&WorkItemRunTrigger::StageBarrier { + parent_work_item_id: "WI-1".to_string(), + stage: Some(2), + settled_key: "stage2".to_string(), + }), + WorkItemExecutionLockReason::AssignmentWakeup + ); + } + + #[test] + fn routine_prompt_is_valid_durable_resume_content() { + let input = serde_json::json!({"prompt": "finish the routine"}); + assert_eq!(durable_resume_content(&input), Some("finish the routine")); + } +} diff --git a/src-tauri/crates/agent-core/src/core/coordination/work_item_scheduler.rs b/src-tauri/crates/agent-core/src/core/coordination/work_item_scheduler.rs index c7ade8af35..14c01ebe05 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/work_item_scheduler.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/work_item_scheduler.rs @@ -172,7 +172,7 @@ fn duration_until(deadline: DateTime, now: DateTime) -> Duration { async fn trigger_candidate( candidate: &ScheduledWorkItemCandidate, reason: &str, - app: &tauri::AppHandle, + _app: &tauri::AppHandle, ) -> Duration { let config = candidate.orchestrator_config.clone().unwrap_or_default(); if config.selected_account_id.is_none() { @@ -190,22 +190,40 @@ async fn trigger_candidate( "[scheduler] Triggering work item {} ({}) in project {}", candidate.short_id, reason, candidate.project_slug ); - match crate::tool_infra::start_work_item_with_reason( - &candidate.project_slug, - &candidate.short_id, - app, - None, - None, - project_management::projects::types::WorkItemExecutionLockReason::RoutineAutoStart, - ) + let schedule_key = candidate + .schedule + .as_ref() + .and_then(|schedule| schedule.at.clone()) + .or_else(|| candidate.start_date.clone()) + .unwrap_or_else(|| reason.to_string()); + let request = project_management::projects::types::EnqueueWorkItemRunRequest { + project_slug: Some(candidate.project_slug.clone()), + org_id: project_management::projects::types::PERSONAL_ORG_ID.to_string(), + work_item_id: candidate.short_id.clone(), + trigger: project_management::projects::types::WorkItemRunTrigger::Schedule { + schedule_key: schedule_key.clone(), + }, + target_snapshot: project_management::projects::types::WorkItemRunTargetSnapshot::new( + project_management::projects::types::WorkItemRunTarget::StartWorkItem { + account_id: config.selected_account_id.clone(), + model_id: config.selected_model_id.clone(), + }, + ), + input: serde_json::json!({ "reason": reason }), + idempotency_key: format!("schedule:{schedule_key}"), + max_attempts: 3, + parent_run_id: None, + }; + match tokio::task::spawn_blocking(move || { + project_management::work_run_service::enqueue(request) + }) .await { - Ok(msg) => { - info!("[scheduler] Started: {}", msg); - update_status_in_progress(&candidate.project_slug, &candidate.short_id).await; + Ok(Ok(run)) => { + info!("[scheduler] Queued durable Run: {}", run.id); Duration::from_secs(MAX_IDLE_RESCAN_SECS) } - Err(err) => { + Ok(Err(err)) => { warn!( "[scheduler] Failed to start {}: {}", candidate.short_id, err @@ -219,24 +237,13 @@ async fn trigger_candidate( .await; Duration::from_secs(FAILED_START_RETRY_SECS) } - } -} - -async fn update_status_in_progress(slug: &str, short_id: &str) { - let slug = slug.to_string(); - let short_id = short_id.to_string(); - match tokio::task::spawn_blocking(move || { - io::update_work_item_atomic(&slug, &short_id, |fm, _body| { - fm.status = "in_progress".to_string(); - fm.updated_at = Utc::now().to_rfc3339(); - Ok(fm.title.clone()) - }) - }) - .await - { - Ok(Ok(_)) => {} - Ok(Err(err)) => warn!("[scheduler] Status update failed: {}", err), - Err(err) => warn!("[scheduler] Status update worker failed: {}", err), + Err(err) => { + warn!( + "[scheduler] Failed to enqueue {}: {}", + candidate.short_id, err + ); + Duration::from_secs(FAILED_START_RETRY_SECS) + } } } diff --git a/src-tauri/crates/agent-core/src/core/interaction/plan_approval/manager.rs b/src-tauri/crates/agent-core/src/core/interaction/plan_approval/manager.rs index 768f4b8daf..58181d6887 100644 --- a/src-tauri/crates/agent-core/src/core/interaction/plan_approval/manager.rs +++ b/src-tauri/crates/agent-core/src/core/interaction/plan_approval/manager.rs @@ -11,7 +11,9 @@ use super::events::{build_plan_approval_event, PlanApprovalCardStatus}; use super::gc::{persist_blocking, persist_ready_row}; use super::persistence::PlanApprovalStore; use super::resolution::{resolve_pending, PlanResolution}; -use super::snapshot::{auto_approve_deadline_ms, plan_id_for, revision_id_for, PendingPlanApproval}; +use super::snapshot::{ + auto_approve_deadline_ms, plan_id_for, revision_id_for, PendingPlanApproval, +}; use super::watcher::spawn_auto_approve_watcher; pub struct PlanApprovalManager { diff --git a/src-tauri/crates/agent-core/src/core/session/launch/launch_org.rs b/src-tauri/crates/agent-core/src/core/session/launch/launch_org.rs index b79d27744e..3d621a898d 100644 --- a/src-tauri/crates/agent-core/src/core/session/launch/launch_org.rs +++ b/src-tauri/crates/agent-core/src/core/session/launch/launch_org.rs @@ -247,6 +247,7 @@ pub(super) async fn materialize_org_member_sessions( work_item_id: work_item_id.clone(), agent_role: None, product_mode: work_item_id.as_ref().map(|_| "project".to_string()), + durable_run_id: None, user_input: String::new(), ide_context: None, mode: agent_exec_mode.clone(), @@ -312,9 +313,11 @@ pub(super) async fn send_initial_turn( agent_definition_id: Option, sub_agent_ids: Vec, intent_org_run_id: Option, + durable_run_id: Option, source: crate::foundation::session_bridge::TurnIntentBridgeSource, ) -> Result<(), String> { if sub_agent_ids.is_empty() { + let client_message_id = durable_run_id.clone(); crate::state::commands::session::message::send_message_impl( state, session_id.to_string(), @@ -331,8 +334,8 @@ pub(super) async fn send_initial_turn( ide_context, false, false, - None, - None, + client_message_id, + durable_run_id, None, intent_org_run_id, source, @@ -354,6 +357,7 @@ pub(super) async fn send_initial_turn( .await?; crate::init::init_session(state, launch_spec).await?; + let client_message_id = durable_run_id.clone(); crate::state::commands::session::message::send_message_impl( state, session_id.to_string(), @@ -370,8 +374,8 @@ pub(super) async fn send_initial_turn( ide_context, false, false, - None, - None, + client_message_id, + durable_run_id, None, intent_org_run_id, crate::foundation::session_bridge::TurnIntentBridgeSource::AgentOrg, diff --git a/src-tauri/crates/agent-core/src/core/session/launch/launch_tests.rs b/src-tauri/crates/agent-core/src/core/session/launch/launch_tests.rs index 7673db5271..08813c2dd2 100644 --- a/src-tauri/crates/agent-core/src/core/session/launch/launch_tests.rs +++ b/src-tauri/crates/agent-core/src/core/session/launch/launch_tests.rs @@ -12,6 +12,26 @@ use crate::definitions::orgs::{ use core_types::key_source::KeySource; use std::collections::HashMap; +#[test] +fn session_marker_writes_explicit_build_for_legacy_null_product_mode() { + let workspace = tempfile::tempdir().expect("workspace"); + super::write_agent_session_marker( + workspace.path().to_str().expect("workspace path"), + "build-session", + Some("builtin:sde"), + None, + Some("scoped-project"), + Some("personal-org"), + ); + let marker = + std::fs::read_to_string(workspace.path().join(".orgii/agent_session_context.json")) + .expect("read marker"); + let marker: serde_json::Value = serde_json::from_str(&marker).expect("parse marker"); + assert_eq!(marker["productMode"], "build"); + assert_eq!(marker["scope"], "scoped-project"); + assert_eq!(marker["capabilities"], serde_json::json!(["work.read"])); +} + #[test] fn launch_validation_rejects_missing_agent_definition_before_session_create() { let _sandbox = test_helpers::test_env::sandbox(); diff --git a/src-tauri/crates/agent-core/src/core/session/launch/mod.rs b/src-tauri/crates/agent-core/src/core/session/launch/mod.rs index 1bce6fc0f0..3f9b5d514d 100644 --- a/src-tauri/crates/agent-core/src/core/session/launch/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/launch/mod.rs @@ -44,6 +44,9 @@ pub(crate) const MAX_AUTO_NAME_LEN: usize = 80; #[derive(Debug, Clone)] pub(crate) struct AgentRunLaunchRequest { + /// Stable WorkItemRun id used for deterministic Session and turn ids. + /// `None` preserves the ordinary user-launch behavior. + pub durable_run_id: Option, pub content: String, pub target: AgentRunTarget, pub resources: LaunchResourceSelection, @@ -173,8 +176,17 @@ pub fn write_agent_session_marker( let agent = agent_definition_id .unwrap_or("os") .trim_start_matches("builtin:"); - let capabilities: Vec<&str> = if product_mode == Some("project") { - vec!["work.read", "work.mutate", "routine.invoke", "project.mutate"] + // Historical rows may store NULL for ordinary Build, but the marker is a + // fail-closed authority boundary and must always carry an explicit mode. + // Otherwise `org2-pm --mode project` could elevate a Build session. + let product_mode = product_mode.unwrap_or("build"); + let capabilities: Vec<&str> = if product_mode == "project" { + vec![ + "work.read", + "work.mutate", + "routine.invoke", + "project.mutate", + ] } else { vec!["work.read"] }; @@ -293,6 +305,7 @@ fn spawn_session_title_generation( /// This remains as the public WorkItem-facing adapter for existing callers; /// all actual launch behavior is delegated to `launch_rust_agent_run`. pub struct WorkItemLaunchRequest<'a> { + pub durable_run_id: Option<&'a str>, pub workspace_path: &'a str, pub prompt: &'a str, pub model: &'a str, @@ -312,6 +325,7 @@ pub async fn launch_agent_session( ) -> Result { let state: tauri::State<'_, AgentAppState> = app.state(); let WorkItemLaunchRequest { + durable_run_id, workspace_path, prompt, model, @@ -343,6 +357,7 @@ pub async fn launch_agent_session( &state, None, AgentRunLaunchRequest { + durable_run_id: durable_run_id.map(str::to_string), content: prompt.to_string(), target: AgentRunTarget::AgentDefinition { agent_definition_id: agent_definition_id.map(str::to_string), @@ -534,6 +549,7 @@ pub(crate) async fn launch_rust_agent_run( request.product_mode.clone(), request.resources.native_harness_type.clone(), request.parent_session_id.clone(), + request.durable_run_id.clone(), ) .await?; @@ -646,6 +662,7 @@ pub(crate) async fn launch_rust_agent_run( let sub_agent_ids_for_send = request.sub_agent_ids.clone(); let agent_definition_id_for_send = agent_definition_id.clone(); let agent_org_run_id_for_background = agent_org_run_id.clone(); + let durable_run_id_for_background = request.durable_run_id.clone(); let project_slug_for_background = project_slug.clone(); let work_item_id_for_background = work_item_id.clone(); let app_handle_for_background = state.app_handle.clone(); @@ -715,6 +732,7 @@ pub(crate) async fn launch_rust_agent_run( agent_definition_id_for_send, sub_agent_ids_for_send, agent_org_run_id_for_background.clone(), + durable_run_id_for_background, crate::foundation::session_bridge::TurnIntentBridgeSource::AgentOrg, ) .await; @@ -803,7 +821,17 @@ pub(crate) async fn launch_rust_agent_run( Some(request.org_context.org_id.as_str()), ); - if has_initial_content { + let durable_turn_already_accepted = match request.durable_run_id.as_deref() { + // Any persisted status proves this exact durable intent was accepted + // previously. Never enqueue it twice. The dispatcher reconciles + // terminal/pre-durable statuses into Run finality. + Some(run_id) => { + crate::foundation::session_bridge::get_turn_intent_status(&session_id, run_id).is_some() + } + None => false, + }; + + if has_initial_content && !durable_turn_already_accepted { let state_for_send = state.clone(); let session_id_for_send = session_id.clone(); let workspace_path_for_send = prepared_workspace @@ -819,11 +847,12 @@ pub(crate) async fn launch_rust_agent_run( let sub_agent_ids_for_send = request.sub_agent_ids.clone(); let agent_definition_id_for_send = agent_definition_id.clone(); let agent_org_run_id_for_send = agent_org_run_id.clone(); + let durable_run_id_for_send = request.durable_run_id.clone(); let project_slug_for_send = project_slug.clone(); let work_item_id_for_send = work_item_id.clone(); let app_handle_for_send = state.app_handle.clone(); - tokio::spawn(async move { + let send_task = async move { // Title generation runs concurrently — it must not delay the // first turn. See `spawn_session_title_generation`. spawn_session_title_generation( @@ -854,6 +883,7 @@ pub(crate) async fn launch_rust_agent_run( agent_definition_id_for_send, sub_agent_ids_for_send, agent_org_run_id_for_send.clone(), + durable_run_id_for_send, crate::foundation::session_bridge::TurnIntentBridgeSource::UserSubmit, ) .await; @@ -874,8 +904,22 @@ pub(crate) async fn launch_rust_agent_run( "[session_launch] failed to mark session failed after first-message error", ) .await; + return Err(message); } - }); + Ok::<(), String>(()) + }; + + if request.durable_run_id.is_some() { + // Durable delivery is acknowledged only after the turn has been + // accepted by the scheduler. Returning before this await would + // leave a crash window where the outbox says delivered but no + // executable turn exists. + send_task.await?; + } else { + tokio::spawn(async move { + let _ = send_task.await; + }); + } } Ok(AgentRunLaunchResult { diff --git a/src-tauri/crates/agent-core/src/core/session/persistence/crud/mod.rs b/src-tauri/crates/agent-core/src/core/session/persistence/crud/mod.rs index 392dd50378..9019cbfda6 100644 --- a/src-tauri/crates/agent-core/src/core/session/persistence/crud/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/persistence/crud/mod.rs @@ -24,17 +24,18 @@ mod workspace; pub use migration::ensure_unified_schema; pub use ops::{ backfill_agent_definition_id, delete_session, finalize_terminal_turn_status, - get_child_sessions, get_parent_session, get_session, list_sessions, + get_child_sessions, get_parent_session, get_session, link_bootstrap_work_item, list_sessions, mark_stale_running_sessions_abandoned, reconcile_sessions_with_terminal_turn_markers, register_session_delete_mirror_hook, register_session_mirror_hook, update_account_id, - update_agent_exec_mode, update_draft_text, update_model, update_model_and_account, update_name, - link_bootstrap_work_item, update_org_member_id, update_pinned, update_product_mode, - update_reply_target_event_id, update_status, update_work_item_link, upsert_session, + update_agent_exec_mode, update_draft_text, update_mode_axes, update_model, + update_model_and_account, update_name, update_org_member_id, update_pinned, + update_product_mode, update_reply_target_event_id, update_status, update_work_item_link, + upsert_session, }; -pub(super) use record::{row_to_record, UNIFIED_SESSION_SELECT}; pub(crate) use ops::{ delete_session_with_connection, finish_session_delete, prepare_session_delete, }; +pub(super) use record::{row_to_record, UNIFIED_SESSION_SELECT}; pub use record::{session_type, UnifiedSessionRecord}; pub use workspace::{ clear_worktree_metadata, load_workspace, save_workspace, save_worktree_metadata, diff --git a/src-tauri/crates/agent-core/src/core/session/persistence/crud/ops.rs b/src-tauri/crates/agent-core/src/core/session/persistence/crud/ops.rs index af4749403c..26f2754f39 100644 --- a/src-tauri/crates/agent-core/src/core/session/persistence/crud/ops.rs +++ b/src-tauri/crates/agent-core/src/core/session/persistence/crud/ops.rs @@ -646,6 +646,31 @@ pub fn update_agent_exec_mode(session_id: &str, mode: &str) -> SqliteResult SqliteResult { + let changed = with_sessions_writer(|| -> SqliteResult { + let conn = get_connection()?; + let affected = conn.execute( + "UPDATE agent_sessions + SET product_mode = ?2, agent_exec_mode = ?3 + WHERE session_id = ?1", + params![session_id, product_mode, agent_exec_mode], + )?; + Ok(affected > 0) + })?; + if changed { + notify_session_mirror(session_id); + } + Ok(changed) +} + /// Explicitly set the session's product mode (`orgtrack/v1` §5.2: /// build | plan | ask | project). Only user selection and the /// launch-from-work/routine resolver drive this — never exec mode, diff --git a/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs b/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs index d1a4ceb5de..c886c9bf16 100644 --- a/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs @@ -26,22 +26,21 @@ mod sidebar; pub use crud::{ backfill_agent_definition_id, clear_worktree_metadata, delete_session, finalize_terminal_turn_status, get_child_sessions, get_parent_session, get_session, - list_sessions, load_workspace, mark_stale_running_sessions_abandoned, + link_bootstrap_work_item, list_sessions, load_workspace, mark_stale_running_sessions_abandoned, reconcile_sessions_with_terminal_turn_markers, register_session_delete_mirror_hook, register_session_mirror_hook, save_workspace, save_worktree_metadata, session_type, - update_account_id, update_agent_exec_mode, update_draft_text, update_model, + update_account_id, update_agent_exec_mode, update_draft_text, update_mode_axes, update_model, update_model_and_account, update_name, update_org_member_id, update_pinned, - link_bootstrap_work_item, update_product_mode, - update_reply_target_event_id, update_status, update_work_item_link, + update_product_mode, update_reply_target_event_id, update_status, update_work_item_link, update_worktree_merge_status, upsert_session, UnifiedSessionRecord, }; +pub(crate) use crud::{ + delete_session_with_connection, finish_session_delete, prepare_session_delete, +}; pub use sidebar::{ list_agent_org_root_sessions_page, list_standalone_coding_sessions_page, list_unpinned_sessions_by_type_page, }; -pub(crate) use crud::{ - delete_session_with_connection, finish_session_delete, prepare_session_delete, -}; pub use messages::{ anchor_at_or_after_created_at, append_compact_boundary, clear_messages, diff --git a/src-tauri/crates/agent-core/src/core/session/prompt/mod.rs b/src-tauri/crates/agent-core/src/core/session/prompt/mod.rs index baf6e821d4..24933f281c 100644 --- a/src-tauri/crates/agent-core/src/core/session/prompt/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/prompt/mod.rs @@ -14,5 +14,12 @@ pub(crate) mod registry; pub(crate) mod section_builders; pub(crate) mod sections; +/// Load the same layered workspace instructions used by the native harness. +/// External CLI adapters call this facade so provider fallback prompts and +/// native API providers cannot drift on AGENTS/CLAUDE/.orgii semantics. +pub fn load_workspace_instructions(workspace_path: &std::path::Path) -> Option { + helpers::load_conventions(workspace_path) +} + #[cfg(test)] pub(crate) mod section_tests; diff --git a/src-tauri/crates/agent-core/src/core/session/prompt/section_builders.rs b/src-tauri/crates/agent-core/src/core/session/prompt/section_builders.rs index 8e3d0996ec..d01ac3ea4c 100644 --- a/src-tauri/crates/agent-core/src/core/session/prompt/section_builders.rs +++ b/src-tauri/crates/agent-core/src/core/session/prompt/section_builders.rs @@ -47,6 +47,7 @@ instruction, consider it in the context of software engineering tasks and the cu - If an approach fails, diagnose why before switching tactics — read the error, check your assumptions, try a focused fix. Do not retry the identical action blindly, but do not abandon a viable approach after a single failure either. - If the user denies a tool call, do NOT re-attempt the exact same call. The denial is deliberate — reconsider the approach, adjust the parameters, or ask the user what they would prefer. - Be careful not to introduce security vulnerabilities such as command injection, XSS, SQL injection, and other OWASP top 10 vulnerabilities. If you notice insecure code, fix it immediately. +- When the task specifies literal output constraints, re-read the produced artifact against them before claiming completion. For exact-content files, verify byte count and trailing bytes (for example with `wc -c` plus a hex/byte dump); command substitution and trimmed text readers hide trailing newlines and are not proof of byte equality. ## Code style @@ -546,7 +547,8 @@ pub(super) fn build_task_routing_section(include_pm_guidance: bool) -> String { **When unsure**, ask the user.\n\n", ); } - section.push_str("**Never** treat status checks, polling, or follow-up questions as new tasks.\n"); + section + .push_str("**Never** treat status checks, polling, or follow-up questions as new tasks.\n"); section } diff --git a/src-tauri/crates/agent-core/src/core/session/turn/processor/post_turn_dispatch.rs b/src-tauri/crates/agent-core/src/core/session/turn/processor/post_turn_dispatch.rs index dcf5da6eba..9681244851 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/processor/post_turn_dispatch.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/processor/post_turn_dispatch.rs @@ -160,11 +160,7 @@ impl UnifiedMessageProcessor { // receipt from the final output (fire-and-forget; gating on the // session record happens inside the spawned task). if final_turn_state == DialogTurnState::Completed && !result.is_stream_error { - self.spawn_work_item_receipt_fallback( - session_id, - response_text, - turn_started_at_ms, - ); + self.spawn_work_item_receipt_fallback(session_id, response_text, turn_started_at_ms); } // 9e. Goal continuation loop (Ralph loop) — judge the completed diff --git a/src-tauri/crates/agent-core/src/core/session/turn/processor/prompt.rs b/src-tauri/crates/agent-core/src/core/session/turn/processor/prompt.rs index 7b6a3c7548..6ef3e64b6a 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/processor/prompt.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/processor/prompt.rs @@ -19,7 +19,10 @@ use crate::core::session::prompt::cache::SkillListingCacheKey; use crate::core::session::prompt::sections::build_agent_org_context_section_with_task_snapshot; use crate::core::session::types::{SystemPromptConfig, ToolSummary}; -fn render_orgtrack_cli_brief(product_mode: Option<&str>, project_slug: Option<&str>) -> Option { +fn render_orgtrack_cli_brief( + product_mode: Option<&str>, + project_slug: Option<&str>, +) -> Option { if product_mode != Some("project") { return None; } diff --git a/src-tauri/crates/agent-core/src/core/session/turn/processor/receipt_fallback.rs b/src-tauri/crates/agent-core/src/core/session/turn/processor/receipt_fallback.rs index 796e43bdcf..59177585dc 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/processor/receipt_fallback.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/processor/receipt_fallback.rs @@ -47,8 +47,7 @@ impl UnifiedMessageProcessor { }; let session_id = session_id.to_string(); tokio::task::spawn_blocking(move || { - if let Err(error) = - synthesize_receipt_blocking(&session_id, &body, turn_started_at_ms) + if let Err(error) = synthesize_receipt_blocking(&session_id, &body, turn_started_at_ms) { warn!( session_id, @@ -106,9 +105,8 @@ fn synthesize_receipt_blocking( Some(&actor), )?, None => { - let org_id = project_management::projects::io::resolve_local_org_scope( - record.org_id.as_deref(), - ); + let org_id = + project_management::projects::io::resolve_local_org_scope(record.org_id.as_deref()); project_management::work_service::note_standalone_work_item( org_id.as_deref(), work_item_id, @@ -120,9 +118,7 @@ fn synthesize_receipt_blocking( } info!( session_id, - work_item_id, - actor_id, - "[receipt_fallback] synthesized turn-end Discussion receipt" + work_item_id, actor_id, "[receipt_fallback] synthesized turn-end Discussion receipt" ); Ok(()) } @@ -139,7 +135,8 @@ mod tests { #[test] fn keeps_substantial_output_with_auto_marker() { - let text = "Implemented the export flow and verified the generated CSV against the fixture data."; + let text = + "Implemented the export flow and verified the generated CSV against the fixture data."; let body = receipt_body(text).expect("substantial output"); assert!(body.starts_with("(auto) Implemented")); assert!(body.contains("fixture data.")); diff --git a/src-tauri/crates/agent-core/src/core/tools/builtin_tools/table/aliases.rs b/src-tauri/crates/agent-core/src/core/tools/builtin_tools/table/aliases.rs index f05c0835f3..a545e4b7e7 100644 --- a/src-tauri/crates/agent-core/src/core/tools/builtin_tools/table/aliases.rs +++ b/src-tauri/crates/agent-core/src/core/tools/builtin_tools/table/aliases.rs @@ -12,8 +12,7 @@ pub(super) use crate::definitions::capabilities::RequiredCapability; pub(super) use AppSubtool::{ Browser as SubBrowser, Explore, FileRead, FileWrite, Glob as SubGlob, InternalBrowser as SubInternalBrowser, Message, OtherInteractions, OtherTool, - Search as SubSearch, Shell, Subagent as SubSubagent, - Thinking as SubThinking, Todo as SubTodo, + Search as SubSearch, Shell, Subagent as SubSubagent, Thinking as SubThinking, Todo as SubTodo, }; // ChatBlock aliases — one per actual React block component. pub(super) use ChatBlock::{ diff --git a/src-tauri/crates/agent-core/src/core/tools/defaults.rs b/src-tauri/crates/agent-core/src/core/tools/defaults.rs index 47ab31bc36..8e0d92bafc 100644 --- a/src-tauri/crates/agent-core/src/core/tools/defaults.rs +++ b/src-tauri/crates/agent-core/src/core/tools/defaults.rs @@ -104,9 +104,9 @@ pub fn derive_disabled_tools(restrict_to: &[String], excluded: &[String]) -> Has /// keeps Settings/Wizard affordances aligned with the default harness role. pub fn supported_agents_for(tool_name: &str) -> Vec { match tool_name { - tool_names::CONTROL_ORGII - | tool_names::MANAGE_SESSION - | tool_names::MANAGE_AGENT_DEF => vec![AgentKind::Os, AgentKind::Custom], + tool_names::CONTROL_ORGII | tool_names::MANAGE_SESSION | tool_names::MANAGE_AGENT_DEF => { + vec![AgentKind::Os, AgentKind::Custom] + } _ => vec![AgentKind::Os, AgentKind::Sde, AgentKind::Custom], } } diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/shell_replay.rs b/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/shell_replay.rs index 31a37391e7..82a21e7cb8 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/shell_replay.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/shell_replay.rs @@ -11,10 +11,10 @@ mod active; mod cleanup; mod range; mod recovery; -mod text; -mod writer; #[cfg(test)] mod tests; +mod text; +mod writer; pub use active::{ active_state, active_states_for_session, ShellReplayAppend, ShellReplayStream, @@ -31,8 +31,8 @@ pub use range::{ pub use recovery::recover_incomplete_replays; pub use writer::ShellReplayWriter; -pub(super) use range::load_complete_replay_state_if_matches; pub use range::__cmd__shell_replay_read_range; +pub(super) use range::load_complete_replay_state_if_matches; pub(super) use text::complete_terminal_prefix_len; #[cfg(test)] pub(super) use text::complete_utf8_prefix_len; diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/shell_replay/active.rs b/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/shell_replay/active.rs index 1b28d2c973..fdcb267215 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/shell_replay/active.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/shell_replay/active.rs @@ -33,8 +33,9 @@ impl ActiveReplayState { } } -pub(super) static ACTIVE_REPLAYS: LazyLock>>> = - LazyLock::new(|| RwLock::new(HashMap::new())); +pub(super) static ACTIVE_REPLAYS: LazyLock< + RwLock>>, +> = LazyLock::new(|| RwLock::new(HashMap::new())); #[derive(Debug, Clone)] pub struct ShellReplayTarget { diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/shell_replay/tests.rs b/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/shell_replay/tests.rs index 47f8e88063..01022adf75 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/shell_replay/tests.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/shell_replay/tests.rs @@ -205,8 +205,7 @@ fn range_aligns_to_complete_sequence_and_never_splits_emoji() { with_test_home(|home| { let root = home.join("replays"); let target = ShellReplayTarget::new("session-utf8", "call-utf8"); - let mut writer = - ShellReplayWriter::create(&root, target, "emit utf8", home, None).unwrap(); + let mut writer = ShellReplayWriter::create(&root, target, "emit utf8", home, None).unwrap(); let text = format!("{}🙂END", "x".repeat(1_000)); let append = writer .append(ShellReplayStream::Stdout, text.as_bytes()) @@ -457,8 +456,7 @@ fn oversized_frame_is_rejected_and_corrupt_length_is_never_allocated() { let root = home.join("replays"); let target = ShellReplayTarget::new("session-corrupt-length", "call-corrupt-length"); let path = { - let mut writer = - ShellReplayWriter::create(&root, target, "emit", home, None).unwrap(); + let mut writer = ShellReplayWriter::create(&root, target, "emit", home, None).unwrap(); let oversized = vec![b'x'; SHELL_REPLAY_FRAME_MAX_BYTES + 1]; assert!(writer .append(ShellReplayStream::Stdout, &oversized) @@ -674,9 +672,7 @@ fn shell_replay_rss_plateau_after_ten_megabyte_warmup() { } let final_peak = peak_rss_bytes(); let delta = final_peak.saturating_sub(warm_peak); - eprintln!( - "shell replay RSS: warm_peak={warm_peak} final_peak={final_peak} delta={delta}" - ); + eprintln!("shell replay RSS: warm_peak={warm_peak} final_peak={final_peak} delta={delta}"); assert!( delta <= 64 * 1024 * 1024, "RSS grew by {delta} bytes after warmup" diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/subprocess.rs b/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/subprocess.rs index e853a426f3..a212584d45 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/subprocess.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/subprocess.rs @@ -316,9 +316,10 @@ fn configure_orgtrack_environment(cmd: &mut tokio::process::Command, session_id: .trim_start_matches("builtin:") .to_string(); cmd.env("ORGII_ACTOR", format!("agent:{agent}")); - if let Some(mode) = record.product_mode.as_deref() { - cmd.env("ORGII_MODE", mode); - } + cmd.env( + "ORGII_MODE", + record.product_mode.as_deref().unwrap_or("build"), + ); if let Some(slug) = record.project_slug.as_deref() { cmd.env("ORGII_SCOPE", slug); } diff --git a/src-tauri/crates/agent-core/src/core/tools/tests/defaults_tests.rs b/src-tauri/crates/agent-core/src/core/tools/tests/defaults_tests.rs index 0426d04182..d4ad9efedb 100644 --- a/src-tauri/crates/agent-core/src/core/tools/tests/defaults_tests.rs +++ b/src-tauri/crates/agent-core/src/core/tools/tests/defaults_tests.rs @@ -30,9 +30,7 @@ fn non_management_tools_supported_on_every_parent_agent_kind() { for entry in BUILTIN_TOOLS.iter().filter(|entry| { !matches!( entry.name, - tool_names::MANAGE_SESSION - | tool_names::MANAGE_AGENT_DEF - | tool_names::CONTROL_ORGII + tool_names::MANAGE_SESSION | tool_names::MANAGE_AGENT_DEF | tool_names::CONTROL_ORGII ) }) { let agents = supported_agents_for(entry.name); diff --git a/src-tauri/crates/agent-core/src/foundation/persistence/session_snapshots.rs b/src-tauri/crates/agent-core/src/foundation/persistence/session_snapshots.rs index 8b136834e2..43d4b2d11e 100644 --- a/src-tauri/crates/agent-core/src/foundation/persistence/session_snapshots.rs +++ b/src-tauri/crates/agent-core/src/foundation/persistence/session_snapshots.rs @@ -246,15 +246,46 @@ pub fn ensure_tables_with(conn: &Connection) -> SqliteResult<()> { "ALTER TABLE agent_sessions ADD COLUMN last_turn_cancelled INTEGER NOT NULL DEFAULT 0", ); + // This schema owner can be initialized before the shared session CRUD + // migrations in isolated tests and recovery paths. Ensure product_mode is + // present before the normalization query below references it. + try_migrate( + conn, + "ALTER TABLE agent_sessions ADD COLUMN product_mode TEXT", + ); + // Canonicalize the product axis as well: a Work Item linkage is the + // legacy repair signal for Project, while every other missing/unknown + // value is ordinary Build. Keeping the row explicit makes the PM + // capability boundary inspectable instead of relying on NULL folklore. + conn.execute( + "UPDATE agent_sessions + SET product_mode = CASE + WHEN work_item_id IS NOT NULL THEN 'project' + ELSE 'build' + END + WHERE product_mode IS NULL + OR product_mode NOT IN ('build', 'plan', 'ask', 'project') + OR (work_item_id IS NOT NULL AND product_mode != 'project')", + [], + )?; + // Per-session execution mode (build / ask / plan / debug / review / - // wingman). NULL means the user has never explicitly chosen one for this - // session — frontend falls back to the global `creatorDefaultExecModeAtom` - // until the first explicit patch. CLI sessions never write here (they have - // no mode concept); this column is `agent_sessions`-only on purpose. + // wingman). Every session owns a canonical value. Historical NULL, + // blank, and retired/unknown values are normalized to Build so an + // existing session can never inherit the mutable creator default. try_migrate( conn, "ALTER TABLE agent_sessions ADD COLUMN agent_exec_mode TEXT", ); + conn.execute( + "UPDATE agent_sessions + SET agent_exec_mode = 'build' + WHERE agent_exec_mode IS NULL + OR TRIM(agent_exec_mode) = '' + OR agent_exec_mode NOT IN ('build', 'ask', 'plan', 'debug', 'review', 'wingman') + OR product_mode = 'project'", + [], + )?; // Per-session composer state (P3): unsent draft text + the message id // the user has currently selected as their reply target. Both are diff --git a/src-tauri/crates/agent-core/src/foundation/persistence/tests/session_snapshots_tests.rs b/src-tauri/crates/agent-core/src/foundation/persistence/tests/session_snapshots_tests.rs index 3f7c4f0711..b6e94256bc 100644 --- a/src-tauri/crates/agent-core/src/foundation/persistence/tests/session_snapshots_tests.rs +++ b/src-tauri/crates/agent-core/src/foundation/persistence/tests/session_snapshots_tests.rs @@ -1,6 +1,6 @@ use rusqlite::Connection; -use super::{query_session_file_tool_rows, SESSION_FILE_MODIFY_TOOLS}; +use super::{ensure_tables_with, query_session_file_tool_rows, SESSION_FILE_MODIFY_TOOLS}; use crate::persistence::db_helpers::AgentSessionStatus; use crate::persistence::session_snapshots::extract_paths_from_tool_input; @@ -90,6 +90,76 @@ fn as_ref_round_trips() { } } +#[test] +fn schema_normalizes_project_exec_mode_without_assuming_migration_order() { + let conn = Connection::open_in_memory().unwrap(); + ensure_tables_with(&conn).expect("initialize schema"); + conn.execute( + "INSERT INTO agent_sessions + (session_id, name, status, created_at, updated_at, product_mode, agent_exec_mode) + VALUES ('project-session', 'Project', 'idle', 'now', 'now', 'project', NULL)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO agent_sessions + (session_id, name, status, created_at, updated_at, product_mode, agent_exec_mode) + VALUES ('legacy-build', 'Build', 'idle', 'now', 'now', NULL, NULL)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO agent_sessions + (session_id, name, status, created_at, updated_at, work_item_id, product_mode, agent_exec_mode) + VALUES ('legacy-work-item', 'Work Item', 'idle', 'now', 'now', 'WI-1', 'build', 'ask')", + [], + ) + .unwrap(); + + ensure_tables_with(&conn).expect("rerun migrations"); + let mode: String = conn + .query_row( + "SELECT agent_exec_mode FROM agent_sessions WHERE session_id = 'project-session'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(mode, "build"); + let axes = conn + .prepare( + "SELECT session_id, product_mode, agent_exec_mode + FROM agent_sessions + WHERE session_id IN ('legacy-build', 'legacy-work-item') + ORDER BY session_id", + ) + .unwrap() + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }) + .unwrap() + .collect::, _>>() + .unwrap(); + assert_eq!( + axes, + vec![ + ( + "legacy-build".to_string(), + "build".to_string(), + "build".to_string(), + ), + ( + "legacy-work-item".to_string(), + "project".to_string(), + "build".to_string(), + ), + ] + ); +} + // -- extract_paths_from_tool_input -- #[test] diff --git a/src-tauri/crates/agent-core/src/foundation/session_bridge.rs b/src-tauri/crates/agent-core/src/foundation/session_bridge.rs index f945a3fbe2..116293e068 100644 --- a/src-tauri/crates/agent-core/src/foundation/session_bridge.rs +++ b/src-tauri/crates/agent-core/src/foundation/session_bridge.rs @@ -66,6 +66,10 @@ pub struct CliLaunchParams { /// row so Project-mode CLI sessions bootstrap a root Work Item and /// get the `work.mutate` capability surface. pub product_mode: Option, + /// Stable WorkItemRun id when this launch came from the durable + /// dispatcher. The first CLI turn must keep this identity through + /// terminal accounting. + pub durable_run_id: Option, // Run-side params pub user_input: String, @@ -127,6 +131,33 @@ pub async fn launch_cli_agent(params: CliLaunchParams) -> Result std::pin::Pin> + Send>>; + +static DISPATCH_CLI_TURN: OnceLock = OnceLock::new(); + +pub fn register_dispatch_cli_turn(implementation: DispatchCliTurnFn) { + let _ = DISPATCH_CLI_TURN.set(implementation); +} + +pub async fn dispatch_cli_turn(params: CliTurnDispatchParams) -> Result<(), String> { + match DISPATCH_CLI_TURN.get() { + Some(implementation) => implementation(params).await, + None => Err("session-bridge: CLI turn dispatcher is not registered".to_string()), + } +} + pub type DeleteCliSessionFn = fn(&str) -> Result; static DELETE_CLI_SESSION: OnceLock = OnceLock::new(); @@ -379,7 +410,7 @@ pub fn clear_cli_resume_state(session_id: &str, mutation_reason: &str) -> rusqli // `TurnIntentStatus` / `TurnIntentSource` types from session_persistence so // the bridge signature stays leaf-level and the adapter parses them. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TurnIntentBridgeStatus { Optimistic, Queued, @@ -471,10 +502,14 @@ pub type UpsertTurnIntentFn = fn( pub type UpdateTurnIntentStatusFn = fn(session_id: &str, turn_intent_id: &str, new_status: TurnIntentBridgeStatus); +pub type GetTurnIntentStatusFn = + fn(session_id: &str, turn_intent_id: &str) -> Option; + pub type MarkPendingTurnIntentsStaleFn = fn(session_id: &str); static UPSERT_TURN_INTENT: OnceLock = OnceLock::new(); static UPDATE_TURN_INTENT_STATUS: OnceLock = OnceLock::new(); +static GET_TURN_INTENT_STATUS: OnceLock = OnceLock::new(); static MARK_PENDING_TURN_INTENTS_STALE: OnceLock = OnceLock::new(); pub fn register_upsert_turn_intent(implementation: UpsertTurnIntentFn) { @@ -485,6 +520,10 @@ pub fn register_update_turn_intent_status(implementation: UpdateTurnIntentStatus let _ = UPDATE_TURN_INTENT_STATUS.set(implementation); } +pub fn register_get_turn_intent_status(implementation: GetTurnIntentStatusFn) { + let _ = GET_TURN_INTENT_STATUS.set(implementation); +} + pub fn register_mark_pending_turn_intents_stale(implementation: MarkPendingTurnIntentsStaleFn) { let _ = MARK_PENDING_TURN_INTENTS_STALE.set(implementation); } @@ -530,6 +569,19 @@ pub fn update_turn_intent_status( } } +/// Read a durable intent status for crash-safe dispatch reconciliation. +pub fn get_turn_intent_status( + session_id: &str, + turn_intent_id: &str, +) -> Option { + if turn_intent_id.is_empty() { + return None; + } + GET_TURN_INTENT_STATUS + .get() + .and_then(|implementation| implementation(session_id, turn_intent_id)) +} + /// Bulk-mark every `optimistic` / `queued` row for the session as `stale`. /// Called by `DialogScheduler::invalidate_pending` so the durable log /// catches up with the in-memory generation bump. diff --git a/src-tauri/crates/agent-core/src/foundation/tool_infra/mod.rs b/src-tauri/crates/agent-core/src/foundation/tool_infra/mod.rs index 981ab2ae17..f0ad4e4c56 100644 --- a/src-tauri/crates/agent-core/src/foundation/tool_infra/mod.rs +++ b/src-tauri/crates/agent-core/src/foundation/tool_infra/mod.rs @@ -52,11 +52,14 @@ pub use project::{ resolve_slug, slugify, start_work_item, + start_work_item_session_with_reason, start_work_item_with_reason, update_project, update_work_item, OrchestratorConfigOverrides, PhaseLaunch, + StartWorkItemSessionRequest, + StartedWorkItemSession, }; use std::time::Duration; diff --git a/src-tauri/crates/agent-core/src/foundation/tool_infra/project/execution.rs b/src-tauri/crates/agent-core/src/foundation/tool_infra/project/execution.rs index 643c164f60..93d24f5964 100644 --- a/src-tauri/crates/agent-core/src/foundation/tool_infra/project/execution.rs +++ b/src-tauri/crates/agent-core/src/foundation/tool_infra/project/execution.rs @@ -303,13 +303,18 @@ fn resolve_agent_def_id_from_assignee( /// (review / fix / retry). struct LaunchContext { data: WorkItemData, + project_description: Option, config: OrchestratorConfig, agent_def_id: Option, agent_def: Option, account_id: String, model_id: String, worktree_path: String, + workspace_mode: project_management::projects::types::WorkspaceExecutionMode, linked_repos: Vec, + repository: Option, + repository_ref: Option, + default_branch: Option, } async fn resolve_launch_context( @@ -317,17 +322,27 @@ async fn resolve_launch_context( short_id: &str, session_account_id: Option<&str>, session_model_id: Option<&str>, + execution_snapshot: Option<&project_management::projects::types::WorkItemRunTargetSnapshot>, ) -> Result { let slug = project_slug.to_string(); let sid = short_id.to_string(); - let data = run_blocking("start_read_work_item", { + let mut data = run_blocking("start_read_work_item", { let slug = slug.clone(); let sid = sid.clone(); move || io::read_work_item(&slug, &sid) }) .await?; + if let Some(snapshot) = execution_snapshot { + if let Some(title) = snapshot.work_item_title.as_ref() { + data.frontmatter.title = title.clone(); + } + if let Some(body) = snapshot.work_item_body.as_ref() { + data.body = body.clone(); + } + } + let config = data .frontmatter .orchestrator_config @@ -403,48 +418,102 @@ async fn resolve_launch_context( .await? }; - let linked_repos: Vec = project_data - .meta - .linked_repos - .iter() - .filter(|repo| !repo.is_empty()) - .cloned() - .collect(); - - let worktree_path = config - .worktree_path - .as_ref() - .filter(|p| !p.is_empty() && std::path::Path::new(p).is_dir()) - .cloned() - .or_else(|| { - linked_repos + let linked_repos: Vec = execution_snapshot + .filter(|snapshot| !snapshot.linked_repositories.is_empty()) + .map(|snapshot| snapshot.linked_repositories.clone()) + .unwrap_or_else(|| { + project_data + .meta + .linked_repos .iter() - .find(|r| std::path::Path::new(r).is_dir()) + .filter(|repo| !repo.is_empty()) .cloned() - }) - .ok_or( - "Cannot start: no host repo. Set the project's linked_repos or the work item's worktree_path." - .to_string(), - )?; + .collect() + }); + + let snapshotted_path = execution_snapshot + .and_then(|snapshot| snapshot.workspace_path.as_ref()) + .filter(|path| !path.trim().is_empty()); + let has_configured_workspace = config + .worktree_path + .as_deref() + .is_some_and(|path| !path.is_empty() && std::path::Path::new(path).is_dir()); + let worktree_path = if let Some(path) = snapshotted_path { + if !std::path::Path::new(path).is_dir() { + return Err(format!( + "Cannot start: snapshotted workspace '{}' is no longer available", + path + )); + } + path.clone() + } else { + config + .worktree_path + .as_ref() + .filter(|p| !p.is_empty() && std::path::Path::new(p).is_dir()) + .cloned() + .or_else(|| { + linked_repos + .iter() + .find(|r| std::path::Path::new(r).is_dir()) + .cloned() + }) + .ok_or( + "Cannot start: no host repo. Set the project's linked_repos or the work item's worktree_path." + .to_string(), + )? + }; + let default_workspace_mode = if has_configured_workspace { + project_management::projects::types::WorkspaceExecutionMode::Worktree + } else { + project_management::projects::types::WorkspaceExecutionMode::LocalWorkspace + }; + let workspace_mode = execution_snapshot + .and_then(|snapshot| snapshot.workspace_mode) + .or(config.workspace_mode) + .unwrap_or(default_workspace_mode); Ok(LaunchContext { data, + project_description: execution_snapshot + .and_then(|snapshot| snapshot.project_description.clone()) + .or_else(|| { + (!project_data.description.trim().is_empty()).then_some(project_data.description) + }), config, agent_def_id, agent_def, account_id, model_id, worktree_path, + workspace_mode, linked_repos, + repository: execution_snapshot.and_then(|snapshot| snapshot.repository.clone()), + repository_ref: execution_snapshot.and_then(|snapshot| snapshot.repository_ref.clone()), + default_branch: execution_snapshot.and_then(|snapshot| snapshot.default_branch.clone()), }) } fn append_workspace_section(prompt: &mut String, ctx: &LaunchContext) { - if ctx.linked_repos.is_empty() { + if ctx.linked_repos.is_empty() && ctx.project_description.is_none() { return; } prompt.push_str("\n\n## Project Workspace\n"); + if let Some(description) = ctx.project_description.as_deref() { + prompt.push_str("Project context:\n"); + prompt.push_str(description); + prompt.push('\n'); + } prompt.push_str(&format!("Primary repo: `{}`\n", ctx.worktree_path)); + if let Some(repository) = ctx.repository.as_deref() { + prompt.push_str(&format!("Repository: `{repository}`\n")); + } + if let Some(repository_ref) = ctx.repository_ref.as_deref() { + prompt.push_str(&format!("Pinned revision: `{repository_ref}`\n")); + } + if let Some(default_branch) = ctx.default_branch.as_deref() { + prompt.push_str(&format!("Default branch: `{default_branch}`\n")); + } if ctx.linked_repos.len() > 1 || ctx.linked_repos.first().map(|r| r.as_str()) != Some(ctx.worktree_path.as_str()) { @@ -503,13 +572,83 @@ pub async fn start_work_item_with_reason( session_model_id: Option<&str>, lock_reason: WorkItemExecutionLockReason, ) -> Result { + let started = start_work_item_session_with_reason(StartWorkItemSessionRequest { + project_slug, + short_id, + app, + session_account_id, + session_model_id, + lock_reason, + durable_run_id: None, + execution_snapshot: None, + }) + .await?; + + Ok(format!( + "Started work item {} execution.\n\ + Session: {}\n\ + Agent: {}\n\ + Model: {}\n\ + Account: {}\n\n\ + The agent is now running in the background. \ + Use session(action=\"list\") or session(action=\"get_status\") to check progress.", + short_id, started.session_id, started.agent_role, started.model_id, started.account_id + )) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StartedWorkItemSession { + pub session_id: String, + pub agent_role: String, + pub model_id: String, + pub account_id: String, +} + +/// Complete, immutable input to a Work Item session launch. Keeping the +/// durable identity and execution snapshot beside the human-selected runtime +/// fields makes dispatcher redelivery harder to call with a mismatched set of +/// arguments. +pub struct StartWorkItemSessionRequest<'a> { + pub project_slug: &'a str, + pub short_id: &'a str, + pub app: &'a tauri::AppHandle, + pub session_account_id: Option<&'a str>, + pub session_model_id: Option<&'a str>, + pub lock_reason: WorkItemExecutionLockReason, + pub durable_run_id: Option<&'a str>, + pub execution_snapshot: + Option<&'a project_management::projects::types::WorkItemRunTargetSnapshot>, +} + +/// Typed durable launch entry point used by the dispatch worker. A stable +/// Run id makes both Session creation and first-turn acceptance idempotent. +pub async fn start_work_item_session_with_reason( + request: StartWorkItemSessionRequest<'_>, +) -> Result { use project_management::orchestrator::state_machine; + let StartWorkItemSessionRequest { + project_slug, + short_id, + app, + session_account_id, + session_model_id, + lock_reason, + durable_run_id, + execution_snapshot, + } = request; + let slug = project_slug.to_string(); let sid = short_id.to_string(); - let ctx = resolve_launch_context(project_slug, short_id, session_account_id, session_model_id) - .await?; + let ctx = resolve_launch_context( + project_slug, + short_id, + session_account_id, + session_model_id, + execution_snapshot, + ) + .await?; let (agent_role, mut prompt) = if let Some(ref definition) = ctx.agent_def { let prompt = build_agent_prompt(&sid, &ctx.data.frontmatter, &ctx.data.body); @@ -528,6 +667,7 @@ pub async fn start_work_item_with_reason( } else { AgentRole::Coding }; + let durable_redelivery = durable_run_id.is_some(); run_blocking("orchestrator_start", { let slug = slug.clone(); @@ -540,20 +680,32 @@ pub async fn start_work_item_with_reason( .map(|s| &s.current_phase) .unwrap_or(&OrchestratorPhase::Idle); - if !matches!(current_phase, OrchestratorPhase::Idle) { + let is_durable_dispatch = durable_redelivery + && matches!( + current_phase, + OrchestratorPhase::Coding + | OrchestratorPhase::Failed + | OrchestratorPhase::Completed + | OrchestratorPhase::AwaitingUser + ); + if !matches!(current_phase, OrchestratorPhase::Idle) && !is_durable_dispatch { return Err(format!( "Cannot start: orchestrator is in phase '{:?}', expected idle", current_phase )); } - state_machine::snapshot_config(frontmatter); - state_machine::add_linked_session( - frontmatter, - "pending", - linked_role, - LinkedSessionType::Native, - ); + let needs_new_episode = matches!(current_phase, OrchestratorPhase::Idle) + || (is_durable_dispatch && !matches!(current_phase, OrchestratorPhase::Coding)); + if needs_new_episode { + state_machine::snapshot_config(frontmatter); + state_machine::add_linked_session( + frontmatter, + "pending", + linked_role, + LinkedSessionType::Native, + ); + } frontmatter.updated_at = chrono::Utc::now().to_rfc3339(); Ok(()) }) @@ -573,13 +725,18 @@ pub async fn start_work_item_with_reason( let session_id = crate::session::launch::launch_agent_session( app, crate::session::launch::WorkItemLaunchRequest { + durable_run_id, workspace_path: &ctx.worktree_path, prompt: &prompt, model: &ctx.model_id, account_id: &ctx.account_id, work_item_id: &sid, project_slug: &slug, - worktree_path: Some(&ctx.worktree_path), + worktree_path: matches!( + ctx.workspace_mode, + project_management::projects::types::WorkspaceExecutionMode::Worktree + ) + .then_some(ctx.worktree_path.as_str()), agent_definition_id: ctx.agent_def_id.as_deref(), agent_role: &agent_role, sub_agent_ids: ctx.config.sub_agent_ids.as_slice(), @@ -588,16 +745,12 @@ pub async fn start_work_item_with_reason( ) .await?; - Ok(format!( - "Started work item {} execution.\n\ - Session: {}\n\ - Agent: {}\n\ - Model: {}\n\ - Account: {}\n\n\ - The agent is now running in the background. \ - Use session(action=\"list\") or session(action=\"get_status\") to check progress.", - sid, session_id, agent_role, ctx.model_id, ctx.account_id - )) + Ok(StartedWorkItemSession { + session_id, + agent_role, + model_id: ctx.model_id, + account_id: ctx.account_id, + }) } /// Which post-transition session the orchestrator needs launched. @@ -652,6 +805,7 @@ pub async fn launch_phase_session( short_id, review_account.as_deref(), review_model.as_deref(), + None, ) .await?; @@ -690,13 +844,18 @@ pub async fn launch_phase_session( let session_id = crate::session::launch::launch_agent_session( app, crate::session::launch::WorkItemLaunchRequest { + durable_run_id: None, workspace_path: &ctx.worktree_path, prompt: &prompt, model: &ctx.model_id, account_id: &ctx.account_id, work_item_id: short_id, project_slug, - worktree_path: Some(&ctx.worktree_path), + worktree_path: matches!( + ctx.workspace_mode, + project_management::projects::types::WorkspaceExecutionMode::Worktree + ) + .then_some(ctx.worktree_path.as_str()), agent_definition_id: agent_definition_id.as_deref(), agent_role: &agent_role, sub_agent_ids: ctx.config.sub_agent_ids.as_slice(), diff --git a/src-tauri/crates/agent-core/src/foundation/tool_infra/project/mod.rs b/src-tauri/crates/agent-core/src/foundation/tool_infra/project/mod.rs index cdb07f9265..1865f690e3 100644 --- a/src-tauri/crates/agent-core/src/foundation/tool_infra/project/mod.rs +++ b/src-tauri/crates/agent-core/src/foundation/tool_infra/project/mod.rs @@ -34,7 +34,10 @@ mod work_items; #[cfg(debug_assertions)] pub use execution::debug_parse_work_item_launch_sources; pub use execution::start_work_item; -pub use execution::{launch_phase_session, start_work_item_with_reason, PhaseLaunch}; +pub use execution::{ + launch_phase_session, start_work_item_session_with_reason, start_work_item_with_reason, + PhaseLaunch, StartWorkItemSessionRequest, StartedWorkItemSession, +}; pub use helpers::{resolve_slug, slugify, OrchestratorConfigOverrides}; pub use projects::{create_project, delete_project, list_projects, read_project, update_project}; pub use search::find_across_workspaces; diff --git a/src-tauri/crates/agent-core/src/integrations/automation/triggers/timer.rs b/src-tauri/crates/agent-core/src/integrations/automation/triggers/timer.rs index 64b115f81b..c5dc2a98de 100644 --- a/src-tauri/crates/agent-core/src/integrations/automation/triggers/timer.rs +++ b/src-tauri/crates/agent-core/src/integrations/automation/triggers/timer.rs @@ -321,5 +321,3 @@ impl ScheduleWeekday { } } } - - diff --git a/src-tauri/crates/agent-core/src/orchestrator_notify/handlers.rs b/src-tauri/crates/agent-core/src/orchestrator_notify/handlers.rs index 717efc2bc4..d000f47af4 100644 --- a/src-tauri/crates/agent-core/src/orchestrator_notify/handlers.rs +++ b/src-tauri/crates/agent-core/src/orchestrator_notify/handlers.rs @@ -276,10 +276,7 @@ pub(super) fn apply_proof_of_work( project_management::orchestrator::proof_of_work::set_branch(frontmatter, branch_name); } if let Some(ref stats) = collected.diff_stats { - project_management::orchestrator::proof_of_work::set_diff_stats( - frontmatter, - stats.clone(), - ); + project_management::orchestrator::proof_of_work::set_diff_stats(frontmatter, stats.clone()); } } diff --git a/src-tauri/crates/agent-core/src/orchestrator_notify/mod.rs b/src-tauri/crates/agent-core/src/orchestrator_notify/mod.rs index 32e7516855..9b7d79d2e1 100644 --- a/src-tauri/crates/agent-core/src/orchestrator_notify/mod.rs +++ b/src-tauri/crates/agent-core/src/orchestrator_notify/mod.rs @@ -11,6 +11,125 @@ fn estimate_cost_usd(total_tokens: u64) -> f64 { (total_tokens as f64 / 1000.0) * 0.003 } +/// Reconcile pre-Session Routine failures left behind by a process exit or an +/// older build, then unblock the routine's configured concurrency queue. +pub(crate) async fn reconcile_terminal_routine_dispatches(app: &tauri::AppHandle) { + let fires = match tokio::task::spawn_blocking( + project_management::projects::io::reconcile_terminal_dispatch_fires, + ) + .await + { + Ok(Ok(fires)) => fires, + Ok(Err(err)) => { + tracing::warn!(error = %err, "[routine] terminal dispatch reconciliation failed"); + return; + } + Err(err) => { + tracing::warn!(error = %err, "[routine] terminal dispatch reconciliation task failed"); + return; + } + }; + + for fire in fires { + crate::state::commands::routines::emit_routine_changed( + app, + &fire.routine_id, + Some(&fire.id), + "failed", + ); + dequeue_next_routine_fire(app, &fire.routine_id).await; + } +} + +/// A Routine-backed dispatch can fail before a Session exists, so the normal +/// Session-terminal notifier never gets a chance to close its fire. Reconcile +/// that terminal edge directly from the durable Work Item Run and continue +/// the routine's queued-fire policy. +pub(crate) async fn notify_routine_fire_dispatch_terminal( + run: &project_management::projects::types::WorkItemRun, + app: &tauri::AppHandle, +) { + use project_management::projects::types::{WorkItemRunStatus, WorkItemRunTrigger}; + + if !matches!( + run.status, + WorkItemRunStatus::Failed | WorkItemRunStatus::Cancelled + ) { + return; + } + let origin = match &run.trigger { + WorkItemRunTrigger::Routine { + routine_id, + fire_id, + } => Some((routine_id.clone(), fire_id.clone())), + WorkItemRunTrigger::Retry { .. } => { + let run_id = run.id.clone(); + match tokio::task::spawn_blocking(move || { + project_management::work_run_service::routine_origin(&run_id) + }) + .await + { + Ok(Ok(origin)) => origin, + Ok(Err(err)) => { + tracing::warn!( + run_id = %run.id, + error = %err, + "[routine] retry provenance lookup failed" + ); + None + } + Err(err) => { + tracing::warn!( + run_id = %run.id, + error = %err, + "[routine] retry provenance task failed" + ); + None + } + } + } + _ => None, + }; + let Some((routine_id, fire_id)) = origin else { + return; + }; + + let fire_id_for_update = fire_id.clone(); + let message = run + .failure + .as_ref() + .map(|failure| failure.message.clone()) + .unwrap_or_else(|| "Work Item dispatch terminated before Session launch".to_string()); + let result = tokio::task::spawn_blocking(move || { + project_management::projects::io::mark_routine_fire_failed(&fire_id_for_update, &message) + }) + .await; + + match result { + Ok(Ok(updated)) => { + crate::state::commands::routines::emit_routine_changed( + app, + &updated.routine_id, + Some(&updated.id), + "failed", + ); + dequeue_next_routine_fire(app, &routine_id).await; + } + Ok(Err(err)) => tracing::warn!( + run_id = %run.id, + fire_id, + error = %err, + "[routine] failed to close fire after terminal dispatch" + ), + Err(err) => tracing::warn!( + run_id = %run.id, + fire_id, + error = %err, + "[routine] fire close task failed after terminal dispatch" + ), + } +} + /// Close the loop on routine fires when their session terminates: /// mark the fire succeeded/failed, then execute the oldest queued fire /// of the same routine (QueueIfActive dequeue). @@ -198,6 +317,54 @@ pub async fn notify_orchestrator_session_terminal( } }; + // CLI sessions and legacy transports do not always expose the exact + // durable turn-intent id at their terminal callback. Reconcile the + // newest active Run for this Session before touching Work Item workflow + // state. Rust turns normally arrive here already terminal and this call + // becomes a no-op, preserving their exact per-turn usage snapshot. + let run_outcome = match status { + AgentSessionStatus::Completed => { + project_management::work_run_service::WorkItemRunTerminalOutcome::Succeeded + } + AgentSessionStatus::Cancelled => { + project_management::work_run_service::WorkItemRunTerminalOutcome::Cancelled + } + _ => project_management::work_run_service::WorkItemRunTerminalOutcome::Failed, + }; + let run_terminal_session_id = session_id.to_string(); + let run_terminal_total_tokens = session.total_tokens.max(0) as u64; + let run_terminal_error = if matches!(status, AgentSessionStatus::Failed) { + Some("Session failed".to_string()) + } else { + None + }; + match tokio::task::spawn_blocking(move || { + project_management::work_run_service::record_session_terminal( + &run_terminal_session_id, + run_outcome, + project_management::projects::types::WorkItemRunUsage { + total_tokens: run_terminal_total_tokens, + cost_usd: estimate_cost_usd(run_terminal_total_tokens), + ..Default::default() + }, + run_terminal_error.as_deref(), + ) + }) + .await + { + Ok(Ok(_)) => {} + Ok(Err(err)) => tracing::warn!( + session_id, + error = %err, + "[work-run] compatibility terminal reconciliation failed" + ), + Err(err) => tracing::warn!( + session_id, + error = %err, + "[work-run] compatibility terminal reconciliation task failed" + ), + } + let workspace_path = match session.workspace_path { Some(ref path) if !path.is_empty() => path.clone(), _ => { @@ -673,9 +840,7 @@ fn notify_inbox_awaiting_user(work_item_id: &str) { } mod handlers; -use handlers::{ - apply_proof_of_work, collect_proof_of_work_data_bounded, extract_review_feedback, -}; +use handlers::{apply_proof_of_work, collect_proof_of_work_data_bounded, extract_review_feedback}; #[cfg(test)] pub(crate) use handlers::{ diff --git a/src-tauri/crates/agent-core/src/specialization/skills/loader/scanner/cache.rs b/src-tauri/crates/agent-core/src/specialization/skills/loader/scanner/cache.rs index 7a47878f05..986a363c37 100644 --- a/src-tauri/crates/agent-core/src/specialization/skills/loader/scanner/cache.rs +++ b/src-tauri/crates/agent-core/src/specialization/skills/loader/scanner/cache.rs @@ -9,8 +9,8 @@ use std::path::PathBuf; use std::sync::{Arc, LazyLock}; use std::time::Duration; -use super::SkillsLoader; use super::super::types::SkillInfo; +use super::SkillsLoader; use crate::utils::swr_cache::SwrCache; const SKILL_SCAN_CACHE_TTL: Duration = Duration::from_secs(2); diff --git a/src-tauri/crates/agent-core/src/specialization/skills/loader/scanner/fs_scan.rs b/src-tauri/crates/agent-core/src/specialization/skills/loader/scanner/fs_scan.rs index 6305037f93..9fe3429eac 100644 --- a/src-tauri/crates/agent-core/src/specialization/skills/loader/scanner/fs_scan.rs +++ b/src-tauri/crates/agent-core/src/specialization/skills/loader/scanner/fs_scan.rs @@ -7,9 +7,9 @@ use std::fs; use std::path::{Path, PathBuf}; -use super::SkillsLoader; use super::super::helpers::{collect_bundled_files, estimate_summary_line_tokens, estimate_tokens}; use super::super::types::{DescriptionQuality, SkillInfo}; +use super::SkillsLoader; const DISCOVERED_SKILL_ROOT_MAX_DEPTH: usize = 4; const DISCOVERED_SKILL_ROOT_MAX_ENTRIES: usize = 500; diff --git a/src-tauri/crates/agent-core/src/specialization/skills/loader/scanner/listing.rs b/src-tauri/crates/agent-core/src/specialization/skills/loader/scanner/listing.rs index 53e1d45191..182c72bd56 100644 --- a/src-tauri/crates/agent-core/src/specialization/skills/loader/scanner/listing.rs +++ b/src-tauri/crates/agent-core/src/specialization/skills/loader/scanner/listing.rs @@ -1,8 +1,8 @@ //! Per-turn skill listing rendering: entries, budgeted description //! truncation, and the `always: true` skills manifest. -use super::SkillsLoader; use super::super::types::{SkillInfo, SkillListingEntry}; +use super::SkillsLoader; // Listing budget mirrors the reference harness: the listing exists for // discovery only (full bodies load via the `skill` tool), so verbose diff --git a/src-tauri/crates/agent-core/src/specialization/skills/loader/scanner/listing_budget_tests.rs b/src-tauri/crates/agent-core/src/specialization/skills/loader/scanner/listing_budget_tests.rs index dd28c40501..d4b7b75dfd 100644 --- a/src-tauri/crates/agent-core/src/specialization/skills/loader/scanner/listing_budget_tests.rs +++ b/src-tauri/crates/agent-core/src/specialization/skills/loader/scanner/listing_budget_tests.rs @@ -24,8 +24,7 @@ fn listing_lines(rendered: &str) -> Vec<&str> { fn per_entry_description_capped_at_250_chars() { let long_desc = "x".repeat(SKILL_LISTING_MAX_DESC_CHARS + 150); let entries = vec![entry("verbose", &long_desc)]; - let rendered = - SkillsLoader::format_skill_listing_entries(&entries).expect("listing populated"); + let rendered = SkillsLoader::format_skill_listing_entries(&entries).expect("listing populated"); let line = listing_lines(&rendered)[0]; assert!( line.contains('\u{2026}'), @@ -49,8 +48,7 @@ fn total_budget_trims_descriptions_evenly() { let entries: Vec = (0..50) .map(|i| entry(&format!("s-{i:03}"), &desc)) .collect(); - let rendered = - SkillsLoader::format_skill_listing_entries(&entries).expect("listing populated"); + let rendered = SkillsLoader::format_skill_listing_entries(&entries).expect("listing populated"); let lines = listing_lines(&rendered); assert_eq!(lines.len(), 50, "no entry may be dropped"); let total_chars: usize = @@ -77,8 +75,7 @@ fn names_only_floor_never_drops_entries() { let entries: Vec = (0..400) .map(|i| entry(&format!("s-{i:03}"), &desc)) .collect(); - let rendered = - SkillsLoader::format_skill_listing_entries(&entries).expect("listing populated"); + let rendered = SkillsLoader::format_skill_listing_entries(&entries).expect("listing populated"); let lines = listing_lines(&rendered); assert_eq!(lines.len(), 400, "floor keeps every invocable name"); for (i, line) in lines.iter().enumerate() { @@ -93,8 +90,7 @@ fn names_only_floor_never_drops_entries() { #[test] fn under_budget_listing_keeps_full_descriptions() { let entries = vec![entry("alpha", "first"), entry("beta", "second")]; - let rendered = - SkillsLoader::format_skill_listing_entries(&entries).expect("listing populated"); + let rendered = SkillsLoader::format_skill_listing_entries(&entries).expect("listing populated"); assert!(rendered.contains("- **alpha** (workspace): first [available]")); assert!(rendered.contains("- **beta** (workspace): second [available]")); } diff --git a/src-tauri/crates/agent-core/src/specialization/skills/loader/scanner/metadata.rs b/src-tauri/crates/agent-core/src/specialization/skills/loader/scanner/metadata.rs index 56140fe3a0..b318f87088 100644 --- a/src-tauri/crates/agent-core/src/specialization/skills/loader/scanner/metadata.rs +++ b/src-tauri/crates/agent-core/src/specialization/skills/loader/scanner/metadata.rs @@ -1,7 +1,7 @@ //! SKILL.md YAML-frontmatter parsing and requirement checks. -use super::SkillsLoader; use super::super::types::SkillMetadata; +use super::SkillsLoader; impl SkillsLoader { pub(super) fn skill_metadata_applies_to_agent(&self, meta: &SkillMetadata) -> bool { diff --git a/src-tauri/crates/agent-core/src/state/commands/routines.rs b/src-tauri/crates/agent-core/src/state/commands/routines.rs index 696e8d33d3..204c7c6821 100644 --- a/src-tauri/crates/agent-core/src/state/commands/routines.rs +++ b/src-tauri/crates/agent-core/src/state/commands/routines.rs @@ -193,7 +193,7 @@ async fn launch_routine_direct_session( async fn create_work_item_from_routine( routine: &types::RoutineDefinition, pending_fire: &types::RoutineFire, - app: &tauri::AppHandle, + _app: &tauri::AppHandle, ) -> Result { let routine_owned = routine.clone(); let pending_fire_owned = pending_fire.clone(); @@ -287,16 +287,7 @@ async fn create_work_item_from_routine( // requires a project slug (standalone items cannot run the orchestrator). if routine.output_policy.auto_start { if let Some(slug) = project_slug.as_deref() { - match crate::tool_infra::start_work_item_with_reason( - slug, - &short_id, - app, - None, - None, - types::WorkItemExecutionLockReason::RoutineAutoStart, - ) - .await - { + match enqueue_routine_work_item_run(routine, pending_fire, slug, &short_id).await { Ok(_) => { let fire_id = pending_fire.id.clone(); let short_id_for_mark = short_id.clone(); @@ -342,7 +333,7 @@ async fn create_work_item_from_routine( async fn update_existing_work_item_from_routine( routine: &types::RoutineDefinition, pending_fire: &types::RoutineFire, - app: &tauri::AppHandle, + _app: &tauri::AppHandle, ) -> Result { let short_id = routine .output_policy @@ -407,15 +398,7 @@ async fn update_existing_work_item_from_routine( .map_err(|err| format!("Task join error: {}", err))??; } - crate::tool_infra::start_work_item_with_reason( - &project_slug, - &short_id, - app, - None, - None, - types::WorkItemExecutionLockReason::RoutineAutoStart, - ) - .await?; + enqueue_routine_work_item_run(routine, pending_fire, &project_slug, &short_id).await?; let fire_id = pending_fire.id.clone(); let short_id_for_mark = short_id.clone(); @@ -432,12 +415,66 @@ async fn update_existing_work_item_from_routine( }) } +async fn enqueue_routine_work_item_run( + routine: &types::RoutineDefinition, + pending_fire: &types::RoutineFire, + project_slug: &str, + short_id: &str, +) -> Result { + let (agent_definition_id, agent_org_id) = match &routine.run_template.target { + types::RoutineRunTarget::AgentDefinition { + agent_definition_id, + } => (agent_definition_id.clone(), None), + types::RoutineRunTarget::AgentOrg { agent_org_id } => (None, Some(agent_org_id.clone())), + }; + let request = types::EnqueueWorkItemRunRequest { + project_slug: Some(project_slug.to_string()), + org_id: types::PERSONAL_ORG_ID.to_string(), + work_item_id: short_id.to_string(), + trigger: types::WorkItemRunTrigger::Routine { + routine_id: routine.id.clone(), + fire_id: pending_fire.id.clone(), + }, + target_snapshot: types::WorkItemRunTargetSnapshot { + target: types::WorkItemRunTarget::StartWorkItem { + account_id: routine.run_template.resources.account_id.clone(), + model_id: routine.run_template.resources.model.clone(), + }, + work_item_revision: 0, + work_item_title: None, + work_item_body: None, + project_description: None, + workspace_path: routine_workspace_path(&routine.run_template.workspace), + repository: None, + repository_ref: None, + default_branch: None, + linked_repositories: Vec::new(), + allow_shared_checkout: false, + workspace_mode: routine_workspace_mode(&routine.run_template.workspace), + agent_definition_id, + agent_org_id, + }, + input: serde_json::json!({ + "prompt": routine.run_template.prompt, + "routineName": routine.name, + "routineFireId": pending_fire.id, + }), + idempotency_key: format!("routine-fire:{}", pending_fire.id), + max_attempts: 3, + parent_run_id: None, + }; + tokio::task::spawn_blocking(move || project_management::work_run_service::enqueue(request)) + .await + .map_err(|err| format!("Task join error: {err}"))? +} + fn routine_to_orchestrator_config(routine: &types::RoutineDefinition) -> types::OrchestratorConfig { let mut config = types::OrchestratorConfig { selected_account_id: routine.run_template.resources.account_id.clone(), selected_model_id: routine.run_template.resources.model.clone(), agent_mode: routine.run_template.mode.clone(), worktree_path: routine_workspace_path(&routine.run_template.workspace), + workspace_mode: routine_workspace_mode(&routine.run_template.workspace), ..Default::default() }; @@ -457,6 +494,20 @@ fn routine_to_orchestrator_config(routine: &types::RoutineDefinition) -> types:: config } +fn routine_workspace_mode( + workspace: &types::RoutineWorkspaceTarget, +) -> Option { + match workspace { + types::RoutineWorkspaceTarget::None => None, + types::RoutineWorkspaceTarget::LocalWorkspace { .. } => { + Some(types::WorkspaceExecutionMode::LocalWorkspace) + } + types::RoutineWorkspaceTarget::Worktree { .. } => { + Some(types::WorkspaceExecutionMode::Worktree) + } + } +} + fn routine_workspace_path(workspace: &types::RoutineWorkspaceTarget) -> Option { match workspace { types::RoutineWorkspaceTarget::None => None, @@ -519,6 +570,7 @@ fn routine_to_launch_request( }; AgentRunLaunchRequest { + durable_run_id: None, content: routine.run_template.prompt.clone(), target, resources: LaunchResourceSelection { diff --git a/src-tauri/crates/agent-core/src/state/commands/session/create.rs b/src-tauri/crates/agent-core/src/state/commands/session/create.rs index 97a633dd8f..f8cf203f09 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/create.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/create.rs @@ -49,6 +49,7 @@ pub(crate) async fn create_session_impl( product_mode: Option, native_harness_type: Option, parent_session_id: Option, + durable_session_key: Option, ) -> Result { // Trace the incoming key_source so drift between frontend and // backend posture is visible in logs. The field is now persisted @@ -97,7 +98,19 @@ pub(crate) async fn create_session_impl( } }; let prefix = resolve_session_prefix(agent_definition_id.as_deref(), has_project); - let session_id = format!("{}{}", prefix, uuid::Uuid::new_v4()); + let session_id = match durable_session_key.as_deref() { + Some(key) + if !key.is_empty() + && key.len() <= 128 + && key + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) => + { + format!("{prefix}{key}") + } + Some(_) => return Err("durable_session_key contains unsupported characters".to_string()), + None => format!("{}{}", prefix, uuid::Uuid::new_v4()), + }; let now = chrono::Utc::now().to_rfc3339(); let effective_model = match model { Some(m) if !m.is_empty() => m, @@ -109,57 +122,102 @@ pub(crate) async fn create_session_impl( .unwrap_or_else(|| PERSONAL_ORG_ID.to_string()); let wid_for_link = work_item_id.clone(); let slug_for_link = project_slug.clone(); + let requested_product_mode = if wid_for_link.is_some() { + "project".to_string() + } else { + product_mode + .filter(|mode| matches!(mode.as_str(), "build" | "plan" | "ask" | "project")) + .unwrap_or_else(|| "build".to_string()) + }; + let requested_exec_mode = if requested_product_mode == "project" { + crate::session::AgentExecMode::Build + } else { + match agent_exec_mode + .as_deref() + .map(str::trim) + .filter(|mode| !mode.is_empty()) + { + Some(mode) => crate::session::AgentExecMode::parse(mode) + .ok_or_else(|| format!("Unknown agent_exec_mode: {mode:?}"))?, + None => crate::session::AgentExecMode::Build, + } + }; - let session = session_persistence::UnifiedSessionRecord { - session_id: session_id.clone(), - name: name.unwrap_or_else(|| "New coding session".to_string()), - status: crate::session::SessionStatus::Idle.as_str().to_owned(), - model: Some(effective_model.clone()), - account_id, - workspace_path: Some(workspace_path.clone()), - org_id: Some(resolved_org_id), - project_id, - project_name, - user_input: None, - total_tokens: 0, - created_at: now.clone(), - updated_at: now, - session_type: effective_agent_type.to_string(), - work_item_id, - agent_role, - worktree_path, - project_slug, - agent_definition_id, - parent_session_id, - key_source: resolved_key_source, - // Persist the user's launch-time mode choice (from `SessionLaunchParams.mode`) - // so the row reflects the ModePill selection from the very first turn, - // instead of staying NULL until the user clicks the in-session pill. - // Empty/whitespace strings are treated as "no choice" so we don't trip - // the dispatcher's mode parser with an empty value. - agent_exec_mode: agent_exec_mode.filter(|m| !m.trim().is_empty()), - // Product-mode resolver (orgtrack/v1 frozen decisions §1), fixed - // precedence: launched from a WorkItem/Routine → project; the - // user's explicit launch-time choice; else NULL (= build). Never - // inferred from exec mode, query length or agent judgment. - product_mode: if wid_for_link.is_some() { - Some("project".to_string()) - } else { - product_mode.filter(|m| { - matches!(m.as_str(), "build" | "plan" | "ask" | "project") - }) - }, - native_harness_type: resolved_native_harness_type, - ..Default::default() + let existing = if durable_session_key.is_some() { + session_persistence::get_session(&session_id).map_err(|err| err.to_string())? + } else { + None }; - let resolved_product_mode = session.product_mode.clone(); + let resolved_product_mode = if let Some(existing) = existing { + if existing.work_item_id != wid_for_link || existing.project_slug != slug_for_link { + return Err(format!( + "durable session {} belongs to a different Work Item", + session_id + )); + } + let canonical_existing_mode = if existing.product_mode.as_deref() == Some("project") { + crate::session::AgentExecMode::Build + } else { + existing + .agent_exec_mode + .as_deref() + .and_then(crate::session::AgentExecMode::parse) + .unwrap_or(crate::session::AgentExecMode::Build) + }; + if existing.agent_exec_mode.as_deref() != Some(canonical_existing_mode.as_str()) { + session_persistence::update_agent_exec_mode( + &session_id, + canonical_existing_mode.as_str(), + ) + .map_err(|err| format!("normalize durable session mode: {err}"))?; + } + tracing::info!("[agent_session] Reusing durable session: {}", session_id); + existing.product_mode + } else { + let session = session_persistence::UnifiedSessionRecord { + session_id: session_id.clone(), + name: name.unwrap_or_else(|| "New coding session".to_string()), + status: crate::session::SessionStatus::Idle.as_str().to_owned(), + model: Some(effective_model.clone()), + account_id, + workspace_path: Some(workspace_path.clone()), + org_id: Some(resolved_org_id), + project_id, + project_name, + user_input: None, + total_tokens: 0, + created_at: now.clone(), + updated_at: now, + session_type: effective_agent_type.to_string(), + work_item_id, + agent_role, + worktree_path, + project_slug, + agent_definition_id, + parent_session_id, + key_source: resolved_key_source, + // Persist a canonical mode from the first byte of the session. + // Project is the product axis and always executes with Build's + // tool policy; it must never inherit a previous creator Ask mode. + agent_exec_mode: Some(requested_exec_mode.as_str().to_string()), + // Product-mode resolver (orgtrack/v1 frozen decisions §1), fixed + // precedence: launched from a WorkItem/Routine → project; the + // user's explicit launch-time choice; else explicit build. Never + // inferred from exec mode, query length or agent judgment. + product_mode: Some(requested_product_mode), + native_harness_type: resolved_native_harness_type, + ..Default::default() + }; + let resolved_product_mode = session.product_mode.clone(); - tokio::task::spawn_blocking(move || session_persistence::upsert_session(&session)) - .await - .map_err(|err| err.to_string())? - .map_err(|err| err.to_string())?; + tokio::task::spawn_blocking(move || session_persistence::upsert_session(&session)) + .await + .map_err(|err| err.to_string())? + .map_err(|err| err.to_string())?; - tracing::info!("[agent_session] Created session: {}", session_id); + tracing::info!("[agent_session] Created session: {}", session_id); + resolved_product_mode + }; if let Some(ref wid) = wid_for_link { let sid = session_id.clone(); diff --git a/src-tauri/crates/agent-core/src/state/commands/session/launch.rs b/src-tauri/crates/agent-core/src/state/commands/session/launch.rs index ba97fe6cda..b1793b4d86 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/launch.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/launch.rs @@ -4,7 +4,10 @@ //! Rust-agent launch service or the CLI launch bridge. use key_vault::{AuthMethod, ModelType}; -use project_management::projects::types::PERSONAL_ORG_ID; +use project_management::projects::types::{ + EnqueueWorkItemRunRequest, WorkItemRunTarget, WorkItemRunTargetSnapshot, WorkItemRunTrigger, + WorkspaceExecutionMode, PERSONAL_ORG_ID, +}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -27,7 +30,7 @@ pub const SESSION_CATEGORY_RUST_AGENT: &str = "rust_agent"; /// process (Cursor CLI, Claude Code, Codex, Gemini, …). pub const SESSION_CATEGORY_CLI_AGENT: &str = "cli_agent"; -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionLaunchParams { /// "rust_agent" or "cli_agent" @@ -84,6 +87,13 @@ pub struct SessionLaunchParams { pub project_slug: Option, pub parent_session_id: Option, + /// Internal durable Work Item Run identity. Ordinary frontend launches + /// omit this; `session_launch_impl` creates and claims the Run before + /// materializing the Session. Recovery deliveries set it explicitly so + /// they never enqueue a second episode. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub durable_run_id: Option, + /// Extra workspace folders granted at launch time (multi-root IDE /// workspaces). Each path is injected into the session's /// `SessionWorkspace.additional_directories` with @@ -130,7 +140,7 @@ pub struct SessionLaunchResult { pub async fn session_launch_impl( state: &AgentAppState, org_store: Option<&AgentOrgsStore>, - params: SessionLaunchParams, + mut params: SessionLaunchParams, ) -> Result { validate_workspace_launch_fields( params.isolate, @@ -140,6 +150,85 @@ pub async fn session_launch_impl( )?; let auto_name = derive_name(params.name.as_deref(), ¶ms.content); + if params.work_item_id.is_some() && params.durable_run_id.is_none() { + let work_item_id = params.work_item_id.clone().unwrap_or_default(); + let org_id = params + .org_id + .clone() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| PERSONAL_ORG_ID.to_string()); + let mut target_snapshot = + WorkItemRunTargetSnapshot::new(WorkItemRunTarget::StartWorkItem { + account_id: params.account_id.clone(), + model_id: params.model.clone(), + }); + target_snapshot.workspace_path = params.workspace_path.clone(); + target_snapshot.workspace_mode = Some( + if params.isolate + || params + .worktree_path + .as_deref() + .is_some_and(|path| !path.trim().is_empty()) + { + WorkspaceExecutionMode::Worktree + } else { + WorkspaceExecutionMode::LocalWorkspace + }, + ); + target_snapshot.agent_definition_id = params.agent_definition_id.clone(); + target_snapshot.agent_org_id = params.agent_org_id.clone(); + let launch_snapshot = serde_json::to_value(¶ms) + .map_err(|err| format!("manual Work Item launch snapshot: {err}"))?; + let run = project_management::work_run_service::enqueue_for_inline_dispatch( + EnqueueWorkItemRunRequest { + project_slug: params.project_slug.clone(), + org_id, + work_item_id, + trigger: WorkItemRunTrigger::Manual, + target_snapshot, + input: serde_json::json!({ + "content": params.content.clone(), + "displayText": params.content.clone(), + "sessionLaunchParams": launch_snapshot, + }), + idempotency_key: format!("manual-launch:{}", uuid::Uuid::new_v4().simple()), + max_attempts: 3, + parent_run_id: None, + }, + )?; + let worker_id = format!("inline_session_{}", uuid::Uuid::new_v4().simple()); + let lease = project_management::work_run_service::claim_dispatch_for_run( + &run.id, &worker_id, 30_000, + )?; + params.durable_run_id = Some(run.id); + + let result = match params.category.as_str() { + SESSION_CATEGORY_RUST_AGENT => { + launch_rust_agent(state, org_store, params, auto_name).await + } + SESSION_CATEGORY_CLI_AGENT => launch_cli_agent(params, auto_name).await, + other => Err(format!("Unknown session category: {other}")), + }; + return match result { + Ok(result) => { + project_management::work_run_service::acknowledge_dispatch_started( + &lease.dispatch_id, + &lease.lease_token, + &result.session_id, + )?; + Ok(result) + } + Err(err) => { + let _ = project_management::work_run_service::record_dispatch_failure( + &lease.dispatch_id, + &lease.lease_token, + &err, + ); + Err(err) + } + }; + } + match params.category.as_str() { SESSION_CATEGORY_RUST_AGENT => launch_rust_agent(state, org_store, params, auto_name).await, SESSION_CATEGORY_CLI_AGENT => launch_cli_agent(params, auto_name).await, @@ -244,6 +333,7 @@ async fn launch_rust_agent( state, org_store, AgentRunLaunchRequest { + durable_run_id: params.durable_run_id.clone(), content: params.content, target, resources: LaunchResourceSelection { @@ -387,6 +477,7 @@ async fn launch_cli_agent( work_item_id: work_item_id.clone(), agent_role: agent_role.clone(), product_mode: params.product_mode.clone(), + durable_run_id: params.durable_run_id.clone(), user_input: params.content, ide_context: params.ide_context, mode: params.mode, diff --git a/src-tauri/crates/agent-core/src/state/commands/session/message/project_bootstrap.rs b/src-tauri/crates/agent-core/src/state/commands/session/message/project_bootstrap.rs index 95ab0cf4c7..2ba60d350b 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/message/project_bootstrap.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/message/project_bootstrap.rs @@ -11,33 +11,133 @@ //! earlier attempt created the item but failed to link it, the replay //! returns the stored short id and only the link is re-applied. -/// Best-effort bootstrap called from the message-accept path. Failures -/// are logged, never turned into a turn error — a broken PM store must -/// not take chat down with it. -pub(super) async fn ensure_project_root_work_item(session_id: &str, content: &str) { +use crate::foundation::session_bridge::TurnIntentBridgeSource; +use project_management::projects::types::{ + EnqueueWorkItemRunRequest, WorkItemRun, WorkItemRunTarget, WorkItemRunTargetSnapshot, + WorkItemRunTrigger, PERSONAL_ORG_ID, +}; + +/// Bootstrap called from the message-accept path. Project mode is an explicit +/// product contract: execution must not silently degrade to plain Build when +/// its Work Item ledger cannot be made durable. +pub(super) async fn ensure_project_root_work_item( + session_id: &str, + content: &str, +) -> Result, String> { if content.trim().is_empty() { - return; + return Ok(None); } let sid = session_id.to_string(); let body = content.to_string(); - let joined = - tokio::task::spawn_blocking(move || bootstrap_root_work_item(&sid, &body)).await; - match joined { + match tokio::task::spawn_blocking(move || bootstrap_root_work_item(&sid, &body)).await { Ok(Ok(Some(short_id))) => { tracing::info!( session_id, short_id, "[project-bootstrap] created and linked root work item" ); + Ok(Some(short_id)) } - Ok(Ok(None)) => {} - Ok(Err(err)) => { - tracing::warn!(session_id, error = %err, "[project-bootstrap] failed"); - } - Err(err) => { - tracing::warn!(session_id, error = %err, "[project-bootstrap] worker failed"); - } + Ok(Ok(None)) => Ok(None), + Ok(Err(err)) => Err(err), + Err(err) => Err(format!("Project Work Item bootstrap worker failed: {err}")), + } +} + +/// Route an ordinary Project turn through the same durable dispatcher used by +/// Discussion, Stage and Routine. A dispatcher-owned `wir_*` turn is already +/// durable and passes through unchanged. +#[allow(clippy::too_many_arguments)] +pub(super) async fn enqueue_project_turn_if_needed( + session_id: &str, + content: &str, + display_text: Option<&str>, + turn_intent_id: &str, + client_message_id: Option<&str>, + source: TurnIntentBridgeSource, +) -> Result, String> { + if content.trim().is_empty() || turn_intent_id.starts_with("wir_") { + return Ok(None); } + let sid = session_id.to_string(); + let record = + tokio::task::spawn_blocking(move || crate::session::persistence::get_session(&sid)) + .await + .map_err(|err| format!("Project Session lookup worker failed: {err}"))? + .map_err(|err| format!("Project Session lookup failed: {err}"))?; + let Some(record) = record else { + return Ok(None); + }; + if record.product_mode.as_deref() != Some("project") { + return Ok(None); + } + let Some(work_item_id) = record.work_item_id.clone() else { + return Err(format!( + "Project Session {session_id} has no durable Work Item after bootstrap" + )); + }; + + let trigger = match source { + TurnIntentBridgeSource::UserSubmit + | TurnIntentBridgeSource::ForceSend + | TurnIntentBridgeSource::MobileRemote => WorkItemRunTrigger::Manual, + TurnIntentBridgeSource::Queue => { + let latest_session_id = session_id.to_string(); + let previous = tokio::task::spawn_blocking(move || { + project_management::work_run_service::latest_for_session(&latest_session_id) + }) + .await + .map_err(|err| format!("Project follow-up lookup worker failed: {err}"))??; + previous.map_or(WorkItemRunTrigger::Manual, |run| { + WorkItemRunTrigger::FollowUp { + previous_run_id: run.id, + } + }) + } + TurnIntentBridgeSource::Resume + | TurnIntentBridgeSource::AgentOrg + | TurnIntentBridgeSource::Wingman => return Ok(None), + }; + + let mut target_snapshot = WorkItemRunTargetSnapshot::new(WorkItemRunTarget::ResumeSession { + session_id: session_id.to_string(), + }); + target_snapshot.workspace_path = record + .worktree_path + .clone() + .or_else(|| record.workspace_path.clone()); + target_snapshot.workspace_mode = Some(if record.worktree_path.is_some() { + project_management::projects::types::WorkspaceExecutionMode::Worktree + } else { + project_management::projects::types::WorkspaceExecutionMode::LocalWorkspace + }); + target_snapshot.repository = record.workspace_path.clone(); + target_snapshot.repository_ref = record + .worktree_branch + .clone() + .or_else(|| record.base_branch.clone()); + target_snapshot.default_branch = record.base_branch.clone(); + target_snapshot.agent_definition_id = record.agent_definition_id.clone(); + + let request = EnqueueWorkItemRunRequest { + project_slug: record.project_slug, + org_id: record.org_id.unwrap_or_else(|| PERSONAL_ORG_ID.to_string()), + work_item_id, + trigger, + target_snapshot, + input: serde_json::json!({ + "content": content, + "displayText": display_text, + "clientMessageId": client_message_id, + }), + idempotency_key: format!("project-session-turn:{session_id}:{turn_intent_id}"), + max_attempts: 3, + parent_run_id: None, + }; + tokio::task::spawn_blocking(move || project_management::work_run_service::enqueue(request)) + .await + .map_err(|err| format!("Project WorkItemRun enqueue worker failed: {err}"))? + .map(Some) } /// Blocking core, also driven directly by the `Track this` command — diff --git a/src-tauri/crates/agent-core/src/state/commands/session/message/send.rs b/src-tauri/crates/agent-core/src/state/commands/session/message/send.rs index 15cf9053f6..183b8153fc 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/message/send.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/message/send.rs @@ -362,7 +362,37 @@ pub(crate) async fn send_message_impl( // no active WorkItem creates and links its root. Resumes replay an // already-accepted submission, so they never bootstrap. if !is_resume { - super::project_bootstrap::ensure_project_root_work_item(&session_id, &content).await; + super::project_bootstrap::ensure_project_root_work_item(&session_id, &content).await?; + if let Some(run) = super::project_bootstrap::enqueue_project_turn_if_needed( + &session_id, + &content, + display_text.as_deref(), + &effective_turn_intent_id, + client_message_id.as_deref(), + source, + ) + .await? + { + tracing::info!( + session_id = %session_id, + run_id = %run.id, + "queued Project turn through durable WorkItem dispatcher" + ); + return Ok(AgentResponse { + content: serde_json::json!({ + "queued": true, + "durableRunId": run.id, + "messageId": client_message_id + .as_deref() + .unwrap_or(&effective_turn_intent_id), + "queuePosition": 0, + "duplicate": false, + }) + .to_string(), + session_id, + model: effective_model, + }); + } } // ── 5. Build the processing closure ────────────────────────────────── @@ -546,6 +576,65 @@ pub(crate) async fn send_message_impl( ); } + // A durable WorkItemRun owns exactly this turn, not the whole + // Session. Persist its terminal state before lifecycle fan-out so + // app exit cannot lose finality and a later turn on the same + // Session cannot be mistaken for this Run. + if turn_intent_id.starts_with("wir_") { + let run_id = turn_intent_id.clone(); + let run_session_id = sid.clone(); + let outcome = match final_turn_state { + crate::session::DialogTurnState::Cancelled => { + project_management::work_run_service::WorkItemRunTerminalOutcome::Cancelled + } + crate::session::DialogTurnState::Failed => { + project_management::work_run_service::WorkItemRunTerminalOutcome::Failed + } + crate::session::DialogTurnState::Running + | crate::session::DialogTurnState::Completed => { + project_management::work_run_service::WorkItemRunTerminalOutcome::Succeeded + } + }; + let usage = response + .as_ref() + .ok() + .map( + |result| project_management::projects::types::WorkItemRunUsage { + input_tokens: u64::try_from(result.prompt_tokens).unwrap_or(0), + output_tokens: u64::try_from(result.completion_tokens).unwrap_or(0), + total_tokens: u64::try_from(result.total_tokens).unwrap_or(0), + ..Default::default() + }, + ) + .unwrap_or_default(); + let terminal_error = response.as_ref().err().cloned(); + match tokio::task::spawn_blocking(move || { + project_management::work_run_service::record_run_terminal( + &run_id, + Some(&run_session_id), + outcome, + usage, + terminal_error.as_deref(), + ) + }) + .await + { + Ok(Ok(_)) => {} + Ok(Err(err)) => tracing::error!( + session_id = %sid, + turn_intent_id = %turn_intent_id, + error = %err, + "failed to persist Work Item Run terminal" + ), + Err(err) => tracing::error!( + session_id = %sid, + turn_intent_id = %turn_intent_id, + error = %err, + "Work Item Run terminal task failed" + ), + } + } + let terminal_turn = response .as_ref() diff --git a/src-tauri/crates/agent-core/src/state/commands/session/persistence.rs b/src-tauri/crates/agent-core/src/state/commands/session/persistence.rs index a785a55d90..89964e14bb 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/persistence.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/persistence.rs @@ -878,17 +878,12 @@ pub async fn agent_track_session_as_project( .map_err(|err| err.to_string())? .ok_or_else(|| format!("Session not found: {sid}"))?; - session_persistence::update_product_mode(&sid, "project") - .map_err(|err| format!("track session: set product_mode: {err}"))?; - // Same derivation the ModePill applies: Project pins the exec // mode to Build (a read-only Plan session would otherwise keep // its deny layer while claiming to do project work). let exec_mode = crate::session::AgentExecMode::Build; - if record.agent_exec_mode.as_deref() != Some(exec_mode.as_str()) { - session_persistence::update_agent_exec_mode(&sid, exec_mode.as_str()) - .map_err(|err| format!("track session: set exec mode: {err}"))?; - } + session_persistence::update_mode_axes(&sid, "project", exec_mode.as_str()) + .map_err(|err| format!("track session: set Project mode axes: {err}"))?; // Root creation at conversion time, from the recorded first // user input. An empty session converts mode-only; the @@ -1026,7 +1021,6 @@ fn remove_linked_session_from_work_item( .map(|_| ()) } - #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/crates/database/src/db/connection.rs b/src-tauri/crates/database/src/db/connection.rs index dbb803c2d7..5c29eee5df 100644 --- a/src-tauri/crates/database/src/db/connection.rs +++ b/src-tauri/crates/database/src/db/connection.rs @@ -28,9 +28,9 @@ //! applied and no schema attempted. use rusqlite::{Connection, Result as SqliteResult}; -use std::collections::HashSet; +use std::collections::HashMap; use std::path::{Path, PathBuf}; -use std::sync::{Mutex, OnceLock}; +use std::sync::{Condvar, Mutex, OnceLock}; /// Per-connection PRAGMA settings (must run on every new connection). /// @@ -53,11 +53,11 @@ pub fn configure_connection(conn: &Connection) -> SqliteResult<()> { // 4KB page, which Linux/macOS/Windows can all flush in well under // the writer's typical hold time. conn.execute_batch( - "PRAGMA journal_mode = WAL; + "PRAGMA busy_timeout = 15000; + PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA cache_size = -64000; PRAGMA temp_store = MEMORY; - PRAGMA busy_timeout = 15000; PRAGMA wal_autocheckpoint = 2000;", )?; Ok(()) @@ -156,49 +156,87 @@ pub fn register_projects_init(init_fn: InitFn) { let _ = projects_init_cell().set(init_fn); } -/// Set of physical DB paths that have already had their schema initialized -/// in this process. Schema DDL is idempotent (all statements use -/// `IF NOT EXISTS`) so re-initializing is safe — but running it once per -/// path saves work and avoids log spam on repeated connections. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InitState { + Initializing, + Ready, +} + +#[derive(Debug, Default)] +struct InitBarrier { + states: Mutex>, + ready: Condvar, +} + +/// Per-physical-path schema barrier. /// /// We intentionally do NOT use `std::sync::Once` here: in production the -/// path is stable and hits the `Once` equivalent (first-seen insert into -/// the set), while in tests `ORGII_HOME` rotates per sandbox, so every -/// fresh tempdir picks up a new entry and runs init against its own -/// brand-new SQLite file. The `Once`-based implementation could not -/// express that, and poisoning the `Once` via any init panic would take -/// down the rest of the test suite. -fn initialized_paths() -> &'static Mutex> { - static INITIALIZED: OnceLock>> = OnceLock::new(); - INITIALIZED.get_or_init(|| Mutex::new(HashSet::new())) +/// path is stable and reaches `Ready`, while in tests `ORGII_HOME` rotates +/// per sandbox. `Initializing` is observable so concurrent first callers +/// wait for DDL completion instead of receiving a connection to a +/// half-created schema. +fn init_barrier() -> &'static InitBarrier { + static INITIALIZED: OnceLock = OnceLock::new(); + INITIALIZED.get_or_init(InitBarrier::default) } /// Open a SQLite file at `db_path`, apply per-connection PRAGMAs, and run /// `init_fn` exactly once per physical path per process. /// -/// On init failure the path is removed from the initialized set so the -/// next caller retries — a transient I/O blip on first touch should not -/// disable schema migration for the rest of the process lifetime. +/// On init failure the path is removed from the barrier so the next caller +/// retries — a transient I/O blip on first touch should not disable schema +/// migration for the rest of the process lifetime. fn open_with_init(db_path: &Path, init_fn: Option) -> SqliteResult { let conn = Connection::open(db_path)?; - configure_connection(&conn)?; let Some(init_fn) = init_fn else { + configure_connection(&conn)?; return Ok(conn); }; - let needs_init = { - let mut set = initialized_paths() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - set.insert(db_path.to_path_buf()) - }; - if needs_init { - if let Err(err) = init_fn(&conn) { - let mut set = initialized_paths() + let barrier = init_barrier(); + let mut states = barrier + .states + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + loop { + match states.get(db_path) { + Some(InitState::Ready) => { + drop(states); + configure_connection(&conn)?; + return Ok(conn); + } + Some(InitState::Initializing) => { + states = barrier + .ready + .wait(states) + .unwrap_or_else(|poisoned| poisoned.into_inner()); + } + None => { + states.insert(db_path.to_path_buf(), InitState::Initializing); + break; + } + } + } + drop(states); + + let initialized = configure_connection(&conn).and_then(|()| init_fn(&conn)); + match initialized { + Ok(()) => { + let mut states = barrier + .states .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - set.remove(db_path); + states.insert(db_path.to_path_buf(), InitState::Ready); + barrier.ready.notify_all(); + } + Err(err) => { + let mut states = barrier + .states + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + states.remove(db_path); + barrier.ready.notify_all(); tracing::error!( "[database::db] schema init failed for {}: {}", db_path.display(), @@ -259,3 +297,54 @@ pub fn get_projects_connection() -> SqliteResult { conn.execute_batch("PRAGMA foreign_keys = ON;")?; Ok(conn) } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Barrier}; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + static SLOW_INIT_CALLS: AtomicUsize = AtomicUsize::new(0); + + fn slow_test_init(conn: &Connection) -> SqliteResult<()> { + SLOW_INIT_CALLS.fetch_add(1, Ordering::SeqCst); + std::thread::sleep(Duration::from_millis(75)); + conn.execute_batch("CREATE TABLE cold_start_barrier (id INTEGER PRIMARY KEY);") + } + + #[test] + fn concurrent_first_connections_wait_for_schema_completion() { + SLOW_INIT_CALLS.store(0, Ordering::SeqCst); + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let path = Arc::new(std::env::temp_dir().join(format!( + "orgii-schema-barrier-{}-{nonce}.db", + std::process::id() + ))); + let start = Arc::new(Barrier::new(3)); + let handles = (0..2) + .map(|_| { + let path = Arc::clone(&path); + let start = Arc::clone(&start); + std::thread::spawn(move || { + start.wait(); + let conn = open_with_init(&path, Some(slow_test_init)) + .expect("open initialized connection"); + conn.query_row("SELECT COUNT(*) FROM cold_start_barrier", [], |row| { + row.get::<_, i64>(0) + }) + .expect("schema is complete before connection returns") + }) + }) + .collect::>(); + start.wait(); + for handle in handles { + assert_eq!(handle.join().expect("connection thread"), 0); + } + assert_eq!(SLOW_INIT_CALLS.load(Ordering::SeqCst), 1); + let _ = std::fs::remove_file(path.as_ref()); + } +} diff --git a/src-tauri/crates/orgtrack-core/src/sources/cursor_ide/db/mod.rs b/src-tauri/crates/orgtrack-core/src/sources/cursor_ide/db/mod.rs index ffceffe443..14e7870247 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/cursor_ide/db/mod.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/cursor_ide/db/mod.rs @@ -32,7 +32,9 @@ mod sync; use sync::delta_sync; #[cfg(test)] -use sync::{build_inputs_from_index, discover_from_headers, discover_from_index, discover_sessions}; +use sync::{ + build_inputs_from_index, discover_from_headers, discover_from_index, discover_sessions, +}; // v9: modern `composer.composerHeaders` subagents stay attached to their // parent even when the parent's composer blob omits `subagentComposerIds`. diff --git a/src-tauri/crates/orgtrack-core/src/sources/cursor_ide/db/tests.rs b/src-tauri/crates/orgtrack-core/src/sources/cursor_ide/db/tests.rs index d7f5a87da0..76f22e1f2b 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/cursor_ide/db/tests.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/cursor_ide/db/tests.rs @@ -573,9 +573,7 @@ fn unrecognized_header_types_filtering_to_empty_are_not_authoritative() { ] })); - assert!(discover_from_headers(&conn) - .expect("discover") - .is_none()); + assert!(discover_from_headers(&conn).expect("discover").is_none()); } #[test] diff --git a/src-tauri/crates/orgtrack-core/src/sources/imported_history/watermark_tests.rs b/src-tauri/crates/orgtrack-core/src/sources/imported_history/watermark_tests.rs index 01b02ea809..1e769b9d8b 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/imported_history/watermark_tests.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/imported_history/watermark_tests.rs @@ -276,7 +276,10 @@ fn oversized_string_value_is_truncated_and_the_record_still_parses() { let (mtime, size) = stat(&path); let mut reader = WatermarkedTranscriptReader::open(&path, "Test", None, 1, mtime, size).expect("open"); - let line = reader.next_line().expect("read record").expect("one record"); + let line = reader + .next_line() + .expect("read record") + .expect("one record"); let value: serde_json::Value = serde_json::from_str(&line.text).expect("truncated record is valid JSON"); @@ -315,14 +318,20 @@ fn many_under_budget_values_are_truncated_rather_than_overflowing() { let (mtime, size) = stat(&path); let mut reader = WatermarkedTranscriptReader::open(&path, "Test", None, 1, mtime, size).expect("open"); - let line = reader.next_line().expect("read record").expect("one record"); + let line = reader + .next_line() + .expect("read record") + .expect("one record"); let parsed: serde_json::Value = serde_json::from_str(&line.text).expect("truncated record is valid JSON"); // The record survives with all 24 keys and the trailing field intact. assert_eq!(parsed["tail"], "kept"); assert_eq!(parsed["k0"].as_str().expect("k0").len(), value.len()); - assert!(parsed["k23"].as_str().expect("k23").ends_with("...[truncated]")); + assert!(parsed["k23"] + .as_str() + .expect("k23") + .ends_with("...[truncated]")); assert!(line.text.len() <= MAX_JSONL_LINE_BYTES); assert_eq!(reader.next_line().expect("read eof"), None); @@ -347,7 +356,10 @@ fn truncation_never_splits_an_escape_sequence() { let (mtime, size) = stat(&path); let mut reader = WatermarkedTranscriptReader::open(&path, "Test", None, 1, mtime, size).expect("open"); - let line = reader.next_line().expect("read record").expect("one record"); + let line = reader + .next_line() + .expect("read record") + .expect("one record"); let value: serde_json::Value = serde_json::from_str(&line.text).unwrap_or_else(|err| { panic!("offset {offset}: truncated record must stay valid JSON: {err}") }); diff --git a/src-tauri/crates/orgtrack-core/src/sources/windsurf/history_tests.rs b/src-tauri/crates/orgtrack-core/src/sources/windsurf/history_tests.rs index cd0eea5905..00e96f0cad 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/windsurf/history_tests.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/windsurf/history_tests.rs @@ -113,8 +113,8 @@ fn session_signature_tolerates_null_blobs() { [], ) .expect("insert NULL bubble"); - let with_null_bubble = windsurf_session_activity_signature(&path, "a") - .expect("signature with NULL bubble value"); + let with_null_bubble = + windsurf_session_activity_signature(&path, "a").expect("signature with NULL bubble value"); assert_ne!(with_null_bubble, before); assert!(with_null_bubble.is_some()); diff --git a/src-tauri/crates/orgtrack-pm-cli/src/commands.rs b/src-tauri/crates/orgtrack-pm-cli/src/commands.rs index a693cca5d2..5955b6f958 100644 --- a/src-tauri/crates/orgtrack-pm-cli/src/commands.rs +++ b/src-tauri/crates/orgtrack-pm-cli/src/commands.rs @@ -104,15 +104,16 @@ fn guarded( /// quoting mangles backticks/`$()` in inline bodies; agents write the /// body to a file and pass the path instead. fn resolve_body_flag(flags: &HashMap) -> Result, CliError> { - if let Some(path) = flags.get("body-file").filter(|value| !value.trim().is_empty()) { - return std::fs::read_to_string(path) - .map(Some) - .map_err(|err| { - CliError::new( - ErrorCode::InvalidArgument, - format!("--body-file {path}: {err}"), - ) - }); + if let Some(path) = flags + .get("body-file") + .filter(|value| !value.trim().is_empty()) + { + return std::fs::read_to_string(path).map(Some).map_err(|err| { + CliError::new( + ErrorCode::InvalidArgument, + format!("--body-file {path}: {err}"), + ) + }); } Ok(flags.get("body").cloned()) } @@ -122,10 +123,7 @@ fn resolve_body_flag(flags: &HashMap) -> Result, /// resolved project has no such item while a standalone row exists. Lets /// a session bound to a standalone root item (Project-mode bootstrap) /// address it without knowing the `--standalone` flag. -fn standalone_fallback_item( - context: &ExecutionContext, - short_id: &str, -) -> Option { +fn standalone_fallback_item(context: &ExecutionContext, short_id: &str) -> Option { let org = context.org_id.as_deref(); match context.require_scope() { Err(_) => pio::read_standalone_work_item(org, short_id).ok(), @@ -189,8 +187,7 @@ fn cmd_work_list(context: &ExecutionContext, flags: &HashMap) -> .filter(|item| { status_filter .map(|state| { - work_service::state::map_legacy_status(&item.frontmatter.status) - == Some(state) + work_service::state::map_legacy_status(&item.frontmatter.status) == Some(state) }) .unwrap_or(true) }) @@ -221,8 +218,10 @@ fn cmd_work_list(context: &ExecutionContext, flags: &HashMap) -> None }; matched.truncate(limit); - let filtered: Vec = - matched.iter().map(|item| item_to_wire(item, None)).collect(); + let filtered: Vec = matched + .iter() + .map(|item| item_to_wire(item, None)) + .collect(); emit_success(serde_json::json!({ "items": filtered }), None, next_cursor) } @@ -435,7 +434,10 @@ fn cmd_work_update( return emit_error( CliError::new( ErrorCode::InvalidArgument, - format!("Invalid --stage '{}'; expected a positive integer or 'none'", raw), + format!( + "Invalid --stage '{}'; expected a positive integer or 'none'", + raw + ), ) .with_details(serde_json::json!({ "field": "--stage", "value": raw })), ) @@ -709,19 +711,17 @@ fn cmd_work_claim( &session_ref.provider, &session_ref.external_id, ) { - return emit_error( - CliError::new(ErrorCode::InvalidArgument, err).with_details(serde_json::json!({ + return emit_error(CliError::new(ErrorCode::InvalidArgument, err).with_details( + serde_json::json!({ "field": "--session-ref", "provider": session_ref.provider, - })), - ); + }), + )); } let expected_revision = flags .get("expected-revision") .and_then(|value| value.parse::().ok()); - if flags.contains_key("standalone") - || standalone_fallback_item(context, &short_id).is_some() - { + if flags.contains_key("standalone") || standalone_fallback_item(context, &short_id).is_some() { return match work_service::claim_standalone_work_item( context.org_id.as_deref(), &short_id, @@ -766,11 +766,9 @@ fn cmd_work_claim( Some(&actor_for_exec), expected_revision, )?; - let revision = work_service::read_project_work_item_revision( - &scope_for_exec, - &short_id_for_exec, - ) - .ok(); + let revision = + work_service::read_project_work_item_revision(&scope_for_exec, &short_id_for_exec) + .ok(); Ok(item_to_wire(&item, revision)) }, ); @@ -825,9 +823,7 @@ fn cmd_work_transition( let expected_revision = flags .get("expected-revision") .and_then(|value| value.parse::().ok()); - if flags.contains_key("standalone") - || standalone_fallback_item(context, &short_id).is_some() - { + if flags.contains_key("standalone") || standalone_fallback_item(context, &short_id).is_some() { let caller_session = context .session_ref .as_ref() @@ -881,11 +877,9 @@ fn cmd_work_transition( expected_revision, caller_session.as_deref(), )?; - let revision = work_service::read_project_work_item_revision( - &scope_for_exec, - &short_id_for_exec, - ) - .ok(); + let revision = + work_service::read_project_work_item_revision(&scope_for_exec, &short_id_for_exec) + .ok(); Ok(item_to_wire(&item, revision)) }, ); @@ -923,9 +917,11 @@ fn cmd_work_note( Err(err) => return emit_error(err), }; let body = body.as_str(); + let parent_id = flags.get("parent-id").map(String::as_str); let kind = flags.get("kind").map(String::as_str).unwrap_or("comment"); - const KINDS: &[&str] = - &["comment", "progress", "blocker", "decision", "handoff", "review"]; + const KINDS: &[&str] = &[ + "comment", "progress", "blocker", "decision", "handoff", "review", + ]; if !KINDS.contains(&kind) { return emit_error(CliError::new( ErrorCode::InvalidArgument, @@ -935,14 +931,19 @@ fn cmd_work_note( ), )); } - return match work_service::note_standalone_work_item( + return match work_service::note_standalone_work_item_threaded( context.org_id.as_deref(), &short_id, kind, body, + parent_id, Some(&actor), ) { - Ok(()) => emit_success(serde_json::json!({ "appended": true, "kind": kind }), None, None), + Ok(()) => emit_success( + serde_json::json!({ "appended": true, "kind": kind }), + None, + None, + ), Err(err) => emit_error(CliError::from_service(err)), }; } @@ -965,11 +966,11 @@ fn cmd_work_note( Err(err) => return emit_error(err), }; let body = body.as_str(); - let kind = flags - .get("kind") - .map(String::as_str) - .unwrap_or("comment"); - const KINDS: &[&str] = &["comment", "progress", "blocker", "decision", "handoff", "review"]; + let parent_id = flags.get("parent-id").map(String::as_str); + let kind = flags.get("kind").map(String::as_str).unwrap_or("comment"); + const KINDS: &[&str] = &[ + "comment", "progress", "blocker", "decision", "handoff", "review", + ]; if !KINDS.contains(&kind) { return emit_error(CliError::new( ErrorCode::InvalidArgument, @@ -980,16 +981,19 @@ fn cmd_work_note( )); } if standalone_fallback_item(context, &short_id).is_some() { - return match work_service::note_standalone_work_item( + return match work_service::note_standalone_work_item_threaded( context.org_id.as_deref(), &short_id, kind, body, + parent_id, Some(&actor), ) { - Ok(()) => { - emit_success(serde_json::json!({ "appended": true, "kind": kind }), None, None) - } + Ok(()) => emit_success( + serde_json::json!({ "appended": true, "kind": kind }), + None, + None, + ), Err(err) => emit_error(CliError::from_service(err)), }; } @@ -997,8 +1001,19 @@ fn cmd_work_note( Ok(scope) => scope.to_string(), Err(err) => return emit_error(err), }; - match work_service::note_project_work_item(&scope, &short_id, kind, body, Some(&actor)) { - Ok(()) => emit_success(serde_json::json!({ "appended": true, "kind": kind }), None, None), + match work_service::note_project_work_item_threaded( + &scope, + &short_id, + kind, + body, + parent_id, + Some(&actor), + ) { + Ok(()) => emit_success( + serde_json::json!({ "appended": true, "kind": kind }), + None, + None, + ), Err(err) => emit_error(CliError::from_service(err)), } } @@ -1036,12 +1051,12 @@ fn cmd_work_relate( if let Err(err) = project_management::provider_host::validate_session_ref(provider, external_id) { - return emit_error( - CliError::new(ErrorCode::InvalidArgument, err).with_details(serde_json::json!({ + return emit_error(CliError::new(ErrorCode::InvalidArgument, err).with_details( + serde_json::json!({ "field": "--target", "provider": provider, - })), - ); + }), + )); } } match work_service::relate_project_work_item(&scope, &short_id, kind, target, Some(&actor)) { @@ -1086,7 +1101,10 @@ fn load_spec_file(path: &str) -> Result CliError { if let Some(details) = err.strip_prefix(routine_service::error::SPEC_INVALID) { let violations: serde_json::Value = serde_json::from_str(details.trim_start_matches(':')).unwrap_or_default(); - return CliError::new( - ErrorCode::InvalidArgument, - "Routine spec failed validation", - ) - .with_details(serde_json::json!({ "violations": violations })); + return CliError::new(ErrorCode::InvalidArgument, "Routine spec failed validation") + .with_details(serde_json::json!({ "violations": violations })); } if let Some(rest) = err.strip_prefix(routine_service::error::INPUTS_INVALID) { return CliError::new( @@ -1287,7 +1302,9 @@ pub fn dispatch_project( } } -fn project_to_wire(project: &project_management::projects::types::ProjectData) -> serde_json::Value { +fn project_to_wire( + project: &project_management::projects::types::ProjectData, +) -> serde_json::Value { serde_json::json!({ "slug": project.slug, "name": project.meta.name, @@ -1306,8 +1323,7 @@ fn cmd_project_list(_context: &ExecutionContext, flags: &HashMap let org = flags.get("org").map(String::as_str); match pio::read_all_projects_scoped(org) { Ok(projects) => { - let items: Vec = - projects.iter().map(project_to_wire).collect(); + let items: Vec = projects.iter().map(project_to_wire).collect(); emit_success(serde_json::json!({ "items": items }), None, None) } Err(err) => emit_error(CliError::from_service(err)), @@ -1406,7 +1422,10 @@ fn cmd_project_create(context: &ExecutionContext, flags: &HashMap String { } /// Print the success envelope to stdout and return exit code 0. -pub fn emit_success(data: serde_json::Value, revision: Option, next_cursor: Option) -> i32 { +pub fn emit_success( + data: serde_json::Value, + revision: Option, + next_cursor: Option, +) -> i32 { #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct Success { diff --git a/src-tauri/crates/orgtrack-pm-cli/tests/cli_e2e.rs b/src-tauri/crates/orgtrack-pm-cli/tests/cli_e2e.rs index ea83d1689f..1807f18e47 100644 --- a/src-tauri/crates/orgtrack-pm-cli/tests/cli_e2e.rs +++ b/src-tauri/crates/orgtrack-pm-cli/tests/cli_e2e.rs @@ -92,7 +92,10 @@ fn seed(slug: &str) { fn run_cli(args: &[&str]) -> (i32, serde_json::Value) { let exe = env!("CARGO_BIN_EXE_org2-pm"); - let output = Command::new(exe).args(args).output().expect("spawn org2-pm"); + let output = Command::new(exe) + .args(args) + .output() + .expect("spawn org2-pm"); let stdout = String::from_utf8_lossy(&output.stdout); let value: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|err| { panic!( @@ -172,7 +175,10 @@ fn external_shell_agent_completes_a_work_item_end_to_end() { // Discover ready work. let (exit, listed) = run_cli(&[&["work", "list", "--ready"], &base[..]].concat()); assert_eq!(exit, 0, "list envelope: {listed}"); - assert_eq!(listed["data"]["items"][0]["frontmatter"]["short_id"], "AAA-0001"); + assert_eq!( + listed["data"]["items"][0]["frontmatter"]["short_id"], + "AAA-0001" + ); // Claim with the observed revision: lock + strict open -> in_progress // and the OCC precondition hold in ONE transaction. @@ -184,7 +190,13 @@ fn external_shell_agent_completes_a_work_item_end_to_end() { let revision_flag = shown_revision.to_string(); let (exit, claimed) = run_cli( &[ - &["work", "claim", "AAA-0001", "--expected-revision", &revision_flag], + &[ + "work", + "claim", + "AAA-0001", + "--expected-revision", + &revision_flag, + ], &base[..], ] .concat(), @@ -199,7 +211,9 @@ fn external_shell_agent_completes_a_work_item_end_to_end() { // Progress note. let (exit, noted) = run_cli( &[ - &["work", "note", "AAA-0001", "--kind", "progress", "--body", "half way"], + &[ + "work", "note", "AAA-0001", "--kind", "progress", "--body", "half way", + ], &base[..], ] .concat(), @@ -227,7 +241,15 @@ fn external_shell_agent_completes_a_work_item_end_to_end() { // Complete. let (exit, done) = run_cli( &[ - &["work", "transition", "AAA-0001", "--to", "completed", "--reason", "done"], + &[ + "work", + "transition", + "AAA-0001", + "--to", + "completed", + "--reason", + "done", + ], &base[..], ] .concat(), @@ -283,7 +305,13 @@ fn idempotency_replays_and_conflicts() { ]; let claim_args = [ - &["work", "claim", "AAA-0001", "--idempotency-key", "sess:claim"], + &[ + "work", + "claim", + "AAA-0001", + "--idempotency-key", + "sess:claim", + ], &base[..], ] .concat(); @@ -431,13 +459,16 @@ fn wire_validation_maps_to_stable_codes() { ]; let (exit, envelope) = run_cli( - &[&["work", "transition", "AAA-0001", "--to", "done"], &base[..]].concat(), + &[ + &["work", "transition", "AAA-0001", "--to", "done"], + &base[..], + ] + .concat(), ); assert_eq!(exit, 2, "envelope: {envelope}"); assert_eq!(envelope["error"]["code"], "INVALID_ARGUMENT"); - let (exit, envelope) = - run_cli(&[&["work", "show", "AAA-9999"], &base[..]].concat()); + let (exit, envelope) = run_cli(&[&["work", "show", "AAA-9999"], &base[..]].concat()); assert_eq!(exit, 3, "envelope: {envelope}"); assert_eq!(envelope["error"]["code"], "NOT_FOUND"); @@ -502,14 +533,28 @@ fn assign_release_pagination_and_portable_filter() { "claude_code:session_e2e_2", ]; - let (exit, page1) = run_cli(&[&["work", "list", "--status", "open", "--limit", "2"], &base[..]].concat()); + let (exit, page1) = run_cli( + &[ + &["work", "list", "--status", "open", "--limit", "2"], + &base[..], + ] + .concat(), + ); assert_eq!(exit, 0, "{page1}"); assert_eq!(page1["data"]["items"].as_array().expect("items").len(), 2); - let cursor = page1["meta"]["nextCursor"].as_str().expect("nextCursor").to_string(); + let cursor = page1["meta"]["nextCursor"] + .as_str() + .expect("nextCursor") + .to_string(); let (exit, page2) = run_cli( - &[&["work", "list", "--status", "open", "--limit", "2", "--cursor", &cursor], &base[..]] - .concat(), + &[ + &[ + "work", "list", "--status", "open", "--limit", "2", "--cursor", &cursor, + ], + &base[..], + ] + .concat(), ); assert_eq!(exit, 0, "{page2}"); assert_eq!(page2["data"]["items"].as_array().expect("items").len(), 2); @@ -524,7 +569,17 @@ fn assign_release_pagination_and_portable_filter() { assert_eq!(bad["error"]["code"], "INVALID_ARGUMENT"); let (exit, assigned) = run_cli( - &[&["work", "assign", "AAA-0002", "--assignee", "agent:builtin-os"], &base[..]].concat(), + &[ + &[ + "work", + "assign", + "AAA-0002", + "--assignee", + "agent:builtin-os", + ], + &base[..], + ] + .concat(), ); assert_eq!(exit, 0, "{assigned}"); assert_eq!(assigned["data"]["frontmatter"]["assignee"], "builtin-os"); @@ -536,7 +591,10 @@ fn assign_release_pagination_and_portable_filter() { let (exit, released) = run_cli(&[&["work", "release", "AAA-0002"], &base[..]].concat()); assert_eq!(exit, 0, "{released}"); assert_eq!(released["data"]["frontmatter"]["status"], "open"); - assert!(released["data"]["frontmatter"]["execution_lock"].is_null(), "{released}"); + assert!( + released["data"]["frontmatter"]["execution_lock"].is_null(), + "{released}" + ); let (exit, foreign) = run_cli( &[ @@ -552,15 +610,29 @@ fn assign_release_pagination_and_portable_filter() { assert_eq!(denied["error"]["code"], "ALREADY_CLAIMED"); let (exit, foreign_update) = run_cli( - &[&["work", "update", "AAA-0002", "--title", "hijack"], &base[..]].concat(), + &[ + &["work", "update", "AAA-0002", "--title", "hijack"], + &base[..], + ] + .concat(), + ); + assert_eq!( + exit, 4, + "update on a foreign-claimed item must fail: {foreign_update}" ); - assert_eq!(exit, 4, "update on a foreign-claimed item must fail: {foreign_update}"); assert_eq!(foreign_update["error"]["code"], "ALREADY_CLAIMED"); let (exit, foreign_transition) = run_cli( - &[&["work", "transition", "AAA-0002", "--to", "completed"], &base[..]].concat(), + &[ + &["work", "transition", "AAA-0002", "--to", "completed"], + &base[..], + ] + .concat(), + ); + assert_eq!( + exit, 4, + "transition on a foreign-claimed item must fail: {foreign_transition}" ); - assert_eq!(exit, 4, "transition on a foreign-claimed item must fail: {foreign_transition}"); assert_eq!(foreign_transition["error"]["code"], "ALREADY_CLAIMED"); } @@ -571,15 +643,29 @@ fn project_family_creates_reads_and_updates_through_the_boundary() { let base = ["--mode", "project", "--actor", "human:vince"]; let (exit, created) = run_cli( - &[&["project", "create", "--name", "Dog Walker MVP", "--description", "walkies"], &base[..]] - .concat(), + &[ + &[ + "project", + "create", + "--name", + "Dog Walker MVP", + "--description", + "walkies", + ], + &base[..], + ] + .concat(), ); assert_eq!(exit, 0, "{created}"); assert_eq!(created["data"]["slug"], "dog-walker-mvp"); assert_eq!(created["data"]["orgId"], "personal-org"); let (exit, dup) = run_cli( - &[&["project", "create", "--name", "Dog Walker MVP"], &base[..]].concat(), + &[ + &["project", "create", "--name", "Dog Walker MVP"], + &base[..], + ] + .concat(), ); assert_eq!(exit, 4, "duplicate slug must refuse: {dup}"); assert_eq!(dup["error"]["code"], "ALREADY_EXISTS"); @@ -589,7 +675,11 @@ fn project_family_creates_reads_and_updates_through_the_boundary() { assert_eq!(shown["data"]["description"], "walkies"); let (exit, updated) = run_cli( - &[&["project", "update", "dog-walker-mvp", "--status", "active"], &base[..]].concat(), + &[ + &["project", "update", "dog-walker-mvp", "--status", "active"], + &base[..], + ] + .concat(), ); assert_eq!(exit, 0, "{updated}"); assert_eq!(updated["data"]["status"], "active"); @@ -647,14 +737,68 @@ fn session_marker_locks_identity_fail_closed() { let (exit, created) = run_in_workspace(&["work", "create", "--title", "marker item"]); assert_eq!(exit, 0, "identity injected from the marker: {created}"); - let (exit, denied) = run_in_workspace(&["work", "create", "--title", "spoof", "--actor", "human:vince"]); + let (exit, denied) = run_in_workspace(&[ + "work", + "create", + "--title", + "spoof", + "--actor", + "human:vince", + ]); assert_eq!(exit, 8, "actor spoofing must be refused: {denied}"); assert_eq!(denied["error"]["code"], "PERMISSION_DENIED"); - let (exit, foreign) = run_in_workspace(&["work", "claim", "AAA-0001", "--session-ref", "claude_code:other"]); + let (exit, foreign) = run_in_workspace(&[ + "work", + "claim", + "AAA-0001", + "--session-ref", + "claude_code:other", + ]); assert_eq!(exit, 8, "session override must be refused: {foreign}"); } +#[test] +fn build_marker_cannot_be_elevated_by_mode_flag_even_with_project_scope() { + let _sandbox = test_env::sandbox(); + seed("demo"); + let home = std::env::var("ORGII_HOME").expect("sandbox sets ORGII_HOME"); + let workspace = std::path::Path::new(&home).join("build-marker-workspace"); + std::fs::create_dir_all(workspace.join(".orgii")).expect("workspace dirs"); + std::fs::write( + workspace.join(".orgii/agent_session_context.json"), + serde_json::json!({ + "apiVersion": "orgtrack/v1", + "sessionRef": "org2:ordinary_build_session", + "actor": "agent:sde", + "productMode": "build", + "scope": "demo", + "capabilities": ["work.read"], + "issuedAt": "2026-08-09T00:00:00Z", + }) + .to_string(), + ) + .expect("marker written"); + + let output = Command::new(env!("CARGO_BIN_EXE_org2-pm")) + .args([ + "work", + "create", + "--title", + "must stay denied", + "--mode", + "project", + ]) + .current_dir(&workspace) + .output() + .expect("spawn org2-pm"); + let denied: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("parse denied response"); + assert_eq!(output.status.code(), Some(5), "{denied}"); + assert_eq!(denied["error"]["code"], "PROJECT_MODE_REQUIRED"); + assert_eq!(denied["error"]["details"]["currentMode"], "build"); +} + #[test] fn standalone_items_are_reachable_without_scope() { let _sandbox = test_env::sandbox(); @@ -690,7 +834,15 @@ fn standalone_items_are_reachable_without_scope() { let (exit, bare_noted) = run_cli( &[ - &["work", "note", "STA-0001", "--kind", "comment", "--body", "bare-id receipt"], + &[ + "work", + "note", + "STA-0001", + "--kind", + "comment", + "--body", + "bare-id receipt", + ], &base[..], ] .concat(), @@ -700,7 +852,17 @@ fn standalone_items_are_reachable_without_scope() { // Stage round-trips through create and update (barrier grouping). let (exit, staged) = run_cli( &[ - &["work", "create", "--standalone", "--title", "Staged child", "--parent", "STA-0001", "--stage", "2"], + &[ + "work", + "create", + "--standalone", + "--title", + "Staged child", + "--parent", + "STA-0001", + "--stage", + "2", + ], &base[..], ] .concat(), @@ -711,9 +873,8 @@ fn standalone_items_are_reachable_without_scope() { .as_str() .expect("short id") .to_string(); - let (exit, restaged) = run_cli( - &[&["work", "update", &staged_id, "--stage", "1"], &base[..]].concat(), - ); + let (exit, restaged) = + run_cli(&[&["work", "update", &staged_id, "--stage", "1"], &base[..]].concat()); assert_eq!(exit, 0, "stage update: {restaged}"); assert_eq!(restaged["data"]["frontmatter"]["stage"], 1); @@ -724,24 +885,41 @@ fn standalone_items_are_reachable_without_scope() { assert_eq!(exit, 0, "scope-less bare-id claim: {claimed}"); assert_eq!(claimed["data"]["frontmatter"]["status"], "in_progress"); let (exit, done) = run_cli( - &[&["work", "transition", "STA-0001", "--to", "completed"], &base[..]].concat(), + &[ + &["work", "transition", "STA-0001", "--to", "completed"], + &base[..], + ] + .concat(), ); assert_eq!(exit, 0, "scope-less bare-id transition: {done}"); assert_eq!(done["data"]["frontmatter"]["status"], "completed"); // `--standalone` routes to the org-scoped store without any scope. - let (exit, shown) = run_cli(&[&["work", "show", "STA-0001", "--standalone"], &base[..]].concat()); + let (exit, shown) = + run_cli(&[&["work", "show", "STA-0001", "--standalone"], &base[..]].concat()); assert_eq!(exit, 0, "show envelope: {shown}"); assert_eq!(shown["data"]["frontmatter"]["short_id"], "STA-0001"); let (exit, listed) = run_cli(&[&["work", "list", "--standalone"], &base[..]].concat()); assert_eq!(exit, 0, "list envelope: {listed}"); - assert_eq!(listed["data"]["items"][0]["frontmatter"]["short_id"], "STA-0001"); + assert_eq!( + listed["data"]["items"][0]["frontmatter"]["short_id"], + "STA-0001" + ); // Progress note lands in the standalone item's comment thread. let (exit, noted) = run_cli( &[ - &["work", "note", "STA-0001", "--standalone", "--kind", "progress", "--body", "half way"], + &[ + "work", + "note", + "STA-0001", + "--standalone", + "--kind", + "progress", + "--body", + "half way", + ], &base[..], ] .concat(), @@ -753,7 +931,8 @@ fn standalone_items_are_reachable_without_scope() { item.frontmatter .comments .iter() - .any(|comment| comment.content == "[progress] half way" && comment.author == "cli-tester"), + .any(|comment| comment.content == "[progress] half way" + && comment.author == "cli-tester"), "note must land in comments: {:?}", item.frontmatter.comments ); @@ -764,7 +943,14 @@ fn standalone_items_are_reachable_without_scope() { let body_path_str = body_path.to_string_lossy().to_string(); let (exit, filed) = run_cli( &[ - &["work", "update", "STA-0001", "--standalone", "--body-file", &body_path_str], + &[ + "work", + "update", + "STA-0001", + "--standalone", + "--body-file", + &body_path_str, + ], &base[..], ] .concat(), @@ -778,7 +964,15 @@ fn standalone_items_are_reachable_without_scope() { // A sub item created with --parent records the parent linkage. let (exit, child) = run_cli( &[ - &["work", "create", "--standalone", "--title", "Child task", "--parent", "STA-0001"], + &[ + "work", + "create", + "--standalone", + "--title", + "Child task", + "--parent", + "STA-0001", + ], &base[..], ] .concat(), diff --git a/src-tauri/crates/orgtrack-pm-cli/tests/conformance.rs b/src-tauri/crates/orgtrack-pm-cli/tests/conformance.rs index d354b82d09..4240103c02 100644 --- a/src-tauri/crates/orgtrack-pm-cli/tests/conformance.rs +++ b/src-tauri/crates/orgtrack-pm-cli/tests/conformance.rs @@ -63,7 +63,11 @@ fn assert_valid(validator: &jsonschema::Validator, value: &serde_json::Value, la .iter_errors(value) .map(|err| format!("{}: {}", err.instance_path, err)) .collect(); - assert!(errors.is_empty(), "{label} failed schema validation:\n{}", errors.join("\n")); + assert!( + errors.is_empty(), + "{label} failed schema validation:\n{}", + errors.join("\n") + ); } #[test] @@ -75,14 +79,32 @@ fn real_envelopes_validate_against_frozen_schemas() { let (exit, bare_context) = run_cli(&["context"]); assert_eq!(exit, 0, "{bare_context}"); assert_valid(&envelope_schema, &bare_context, "bare context envelope"); - assert_valid(&context_schema, &bare_context["data"], "bare context data (null scope/actor)"); + assert_valid( + &context_schema, + &bare_context["data"], + "bare context data (null scope/actor)", + ); let (exit, project_context) = run_cli(&[ - "context", "--mode", "project", "--scope", "demo", "--actor", "human:conformance", + "context", + "--mode", + "project", + "--scope", + "demo", + "--actor", + "human:conformance", ]); assert_eq!(exit, 0, "{project_context}"); - assert_valid(&envelope_schema, &project_context, "project context envelope"); - assert_valid(&context_schema, &project_context["data"], "project context data"); + assert_valid( + &envelope_schema, + &project_context, + "project context envelope", + ); + assert_valid( + &context_schema, + &project_context["data"], + "project context data", + ); let (exit, gated) = run_cli(&["work", "create", "--title", "x", "--scope", "demo"]); assert_eq!(exit, 5, "{gated}"); @@ -108,7 +130,10 @@ fn error_fixtures_match_the_implementation_strings() { ); checked += 1; } - assert!(checked >= 18, "expected the 18 frozen error fixtures, found {checked}"); + assert!( + checked >= 18, + "expected the 18 frozen error fixtures, found {checked}" + ); } fn expected_retryable(code: &str) -> bool { diff --git a/src-tauri/crates/session-persistence/src/agent_core_bridge.rs b/src-tauri/crates/session-persistence/src/agent_core_bridge.rs index ab94e6b43d..3c296f918d 100644 --- a/src-tauri/crates/session-persistence/src/agent_core_bridge.rs +++ b/src-tauri/crates/session-persistence/src/agent_core_bridge.rs @@ -101,6 +101,21 @@ fn map_bridge_status(status: session_bridge::TurnIntentBridgeStatus) -> PsStatus } } +fn map_persisted_status(status: PsStatus) -> session_bridge::TurnIntentBridgeStatus { + use session_bridge::TurnIntentBridgeStatus as B; + match status { + PsStatus::Optimistic => B::Optimistic, + PsStatus::Queued => B::Queued, + PsStatus::Running => B::Running, + PsStatus::Completed => B::Completed, + PsStatus::Failed => B::Failed, + PsStatus::Cancelled => B::Cancelled, + PsStatus::Stale => B::Stale, + PsStatus::Coalesced => B::Coalesced, + PsStatus::Rejected => B::Rejected, + } +} + fn map_bridge_source(source: session_bridge::TurnIntentBridgeSource) -> PsSource { use session_bridge::TurnIntentBridgeSource as B; match source { @@ -160,6 +175,24 @@ fn update_turn_intent_status_adapter( } } +fn get_turn_intent_status_adapter( + session_id: &str, + turn_intent_id: &str, +) -> Option { + match turn_intents::read_intent(session_id, turn_intent_id) { + Ok(row) => row.map(|row| map_persisted_status(row.status)), + Err(err) => { + tracing::warn!( + session_id = %session_id, + turn_intent_id = %turn_intent_id, + error = ?err, + "turn_intents.read_intent failed" + ); + None + } + } +} + fn mark_pending_turn_intents_stale_adapter(session_id: &str) { if let Err(err) = turn_intents::mark_pending_stale(session_id) { tracing::warn!( @@ -177,6 +210,7 @@ pub fn register() { session_bridge::register_record_usage_telemetry_batch(record_usage_telemetry_batch_adapter); session_bridge::register_upsert_turn_intent(upsert_turn_intent_adapter); session_bridge::register_update_turn_intent_status(update_turn_intent_status_adapter); + session_bridge::register_get_turn_intent_status(get_turn_intent_status_adapter); session_bridge::register_mark_pending_turn_intents_stale( mark_pending_turn_intents_stale_adapter, ); diff --git a/src-tauri/crates/session-persistence/src/crud.rs b/src-tauri/crates/session-persistence/src/crud.rs index 0c9e386a02..9b2600b74d 100644 --- a/src-tauri/crates/session-persistence/src/crud.rs +++ b/src-tauri/crates/session-persistence/src/crud.rs @@ -140,8 +140,8 @@ fn refresh_session_metadata_from_events( conn: &Connection, session_id: &str, ) -> SqliteResult { - let (event_count, time_start, time_end): (i64, Option, Option) = - conn.query_row( + let (event_count, time_start, time_end): (i64, Option, Option) = conn + .query_row( "SELECT COUNT(*), MIN(created_at), MAX(created_at) FROM events WHERE session_id=?1", [session_id], @@ -962,11 +962,8 @@ mod tests { ], ) .expect("append first import page"); - save_events_deferred( - session_id, - &[cached_event(session_id, "event-3", t3)], - ) - .expect("append second import page"); + save_events_deferred(session_id, &[cached_event(session_id, "event-3", t3)]) + .expect("append second import page"); assert!( get_session_metadata(session_id) diff --git a/src-tauri/crates/session-persistence/src/lib.rs b/src-tauri/crates/session-persistence/src/lib.rs index 23c3d5ae3f..e9c99fa570 100644 --- a/src-tauri/crates/session-persistence/src/lib.rs +++ b/src-tauri/crates/session-persistence/src/lib.rs @@ -56,8 +56,8 @@ pub use connection::get_connection; pub use schema::init_session_tables; pub use crud::{ - clear_old_sessions, count_events, delete_session, find_awaiting_user_events_by_function, - finalize_deferred_event_import, get_all_sessions, get_cache_stats, get_event, + clear_old_sessions, count_events, delete_session, finalize_deferred_event_import, + find_awaiting_user_events_by_function, get_all_sessions, get_cache_stats, get_event, get_session_metadata, load_events, load_session, save_events, save_events_deferred, save_session, search_all_sessions, search_events, update_session_specs, }; diff --git a/src-tauri/crates/session-persistence/src/turn_intents.rs b/src-tauri/crates/session-persistence/src/turn_intents.rs index 1283ec94d1..1f8aeeb6f2 100644 --- a/src-tauri/crates/session-persistence/src/turn_intents.rs +++ b/src-tauri/crates/session-persistence/src/turn_intents.rs @@ -459,6 +459,12 @@ pub fn get_intent( .optional() } +/// Read a single intent through the crate-owned connection. +pub fn read_intent(session_id: &str, turn_intent_id: &str) -> SqliteResult> { + let conn = get_connection()?; + get_intent(&conn, session_id, turn_intent_id) +} + /// All intent rows for a session, ordered by `created_at`. The turn indexer /// uses this to look up lifecycle status alongside event-store rows. pub fn list_for_session(session_id: &str) -> SqliteResult> { diff --git a/src-tauri/src/agent_sessions/cli/agent_core_bridge.rs b/src-tauri/src/agent_sessions/cli/agent_core_bridge.rs index 130eb21b51..655390a4db 100644 --- a/src-tauri/src/agent_sessions/cli/agent_core_bridge.rs +++ b/src-tauri/src/agent_sessions/cli/agent_core_bridge.rs @@ -12,6 +12,7 @@ use std::pin::Pin; use agent_core::foundation::session_bridge::{ self, CliLaunchOutcome, CliLaunchParams, CliPlanApprovalResponseParams, CliToolsSnapshot, + CliTurnDispatchParams, }; use agent_core::interaction::plan_approval::{self, PlanResolution}; use agent_core::session::AgentExecMode; @@ -63,12 +64,15 @@ fn run( let created_at = session.created_at.clone(); if !params.user_input.trim().is_empty() { + let durable_run_id = params.durable_run_id.clone(); if let Err(err) = cli_agent_run(CliRunRequest { session_id: session_id.clone(), user_input: params.user_input, ide_context: params.ide_context, mode: params.mode, images: params.images, + turn_intent_id: durable_run_id.clone(), + client_message_id: durable_run_id, ..Default::default() }) .await @@ -99,6 +103,22 @@ fn run( }) } +fn dispatch_turn( + params: CliTurnDispatchParams, +) -> Pin> + Send>> { + Box::pin(async move { + cli_agent_message(CliMessageRequest { + session_id: params.session_id, + content: params.content, + turn_intent_id: Some(params.turn_intent_id), + client_message_id: Some(params.client_message_id), + ..Default::default() + }) + .await + .map(|_| ()) + }) +} + fn tools_snapshot(session_id: &str) -> Result, String> { let session = persistence::get_session(session_id) .map_err(|err| format!("DB error loading CLI session {session_id}: {err}"))?; @@ -232,6 +252,7 @@ fn cli_registered_tool_names() -> Vec { /// Register CLI adapters into agent_core's session bridge slots. pub fn register() { session_bridge::register_launch_cli_agent(run); + session_bridge::register_dispatch_cli_turn(dispatch_turn); session_bridge::register_delete_cli_session(|session_id| { persistence::delete_session(session_id).map_err(|err| format!("DB error: {err}")) }); diff --git a/src-tauri/src/agent_sessions/cli/commands/resume_delete.rs b/src-tauri/src/agent_sessions/cli/commands/resume_delete.rs index 282735fe6c..acac8f10cf 100644 --- a/src-tauri/src/agent_sessions/cli/commands/resume_delete.rs +++ b/src-tauri/src/agent_sessions/cli/commands/resume_delete.rs @@ -193,6 +193,7 @@ pub async fn cli_agent_delete(session_id: String) -> Result { // Clean up persistent Cursor config dir (contains chat session data for --resume) session_runner::cleanup_cursor_config_dir(&session_id); + session_runner::forget_session_context(&session_id); // Clean up worktree if session had isolation enabled let session = tokio::task::spawn_blocking({ @@ -203,6 +204,24 @@ pub async fn cli_agent_delete(session_id: String) -> Result { .map_err(|e| format!("Task error: {}", e))??; if let Some(ref session) = session { + if let Some(agent) = session + .cli_agent_type + .as_deref() + .and_then(key_vault::key_store::ModelType::from_str) + { + session_runner::stop_session_hooks( + &session_id, + &agent, + session.model.as_deref(), + session + .worktree_path + .as_deref() + .filter(|path| !path.is_empty() && std::path::Path::new(path).is_dir()) + .or(session.repo_path.as_deref()), + ) + .await; + } + // Only `base_branch`-bearing worktrees are session-owned isolation. // A reused linked worktree is borrowed and must survive deletion. if session.base_branch.is_some() { diff --git a/src-tauri/src/agent_sessions/cli/commands/run.rs b/src-tauri/src/agent_sessions/cli/commands/run.rs index 5c2a3908ed..11d268f9ab 100644 --- a/src-tauri/src/agent_sessions/cli/commands/run.rs +++ b/src-tauri/src/agent_sessions/cli/commands/run.rs @@ -30,6 +30,8 @@ pub struct CliRunRequest { pub ide_context: Option, pub mode: Option, pub images: Option>, + pub turn_intent_id: Option, + pub client_message_id: Option, } /// Send a follow-up message on an existing session, optionally switching the @@ -60,11 +62,6 @@ struct TurnIdentity { } impl TurnIdentity { - /// Mint a fresh pair for a turn no client pre-assigned ids for. - fn generate() -> Self { - Self::from_client(None, None) - } - /// Adopt whichever halves the client supplied, minting the rest. fn from_client(turn_intent_id: Option, client_message_id: Option) -> Self { Self { @@ -113,8 +110,131 @@ pub async fn cli_agent_tui_release(session_id: String) -> Result { /// Run a code session (spawn CLI agent in background). #[tauri::command] -pub async fn cli_agent_run(request: CliRunRequest) -> Result<(), String> { - run_turn(request, TurnIdentity::generate()).await +pub async fn cli_agent_run(mut request: CliRunRequest) -> Result<(), String> { + let turn = TurnIdentity::from_client( + request.turn_intent_id.take(), + request.client_message_id.take(), + ); + run_turn(request, turn).await +} + +/// Create the root Work Item on the first non-empty Project-mode turn. +/// +/// This lives in the shared run path so a freshly launched CLI session and a +/// resumed/follow-up session have identical Project semantics. Bootstrap is a +/// best-effort product side effect: a temporary PM failure must not swallow the +/// user's message. +async fn bootstrap_project_root_if_needed( + session_id: &str, + user_input: &str, +) -> Result, String> { + if user_input.trim().is_empty() { + return Ok(None); + } + + let sid = session_id.to_string(); + let session = tokio::task::spawn_blocking(move || persistence::get_session(&sid)) + .await + .map_err(|err| format!("Task error: {err}"))? + .map_err(|err| format!("DB error: {err}"))? + .ok_or_else(|| format!("Session {session_id} not found"))?; + + if session.product_mode.as_deref() != Some("project") || session.work_item_id.is_some() { + return Ok(None); + } + + let sid = session_id.to_string(); + let org_id = session.org_id; + let body = user_input.to_string(); + let result = tokio::task::spawn_blocking(move || { + let short_id = project_management::work_service::bootstrap_root_standalone_item( + &sid, + Some(org_id.as_str()), + &body, + )?; + persistence::link_bootstrap_work_item(&sid, &short_id) + .map_err(|err| format!("link bootstrap work item (cli): {err}"))?; + Ok::(short_id) + }) + .await + .map_err(|err| format!("Task error: {err}"))?; + + match result { + Ok(short_id) => { + tracing::info!( + session_id, + short_id, + "[project-bootstrap] created and linked root work item (cli)" + ); + Ok(Some(short_id)) + } + Err(err) => Err(err), + } +} + +async fn enqueue_project_turn_if_needed( + session_id: &str, + user_input: &str, + turn_intent_id: &str, + client_message_id: &str, +) -> Result, String> { + if user_input.trim().is_empty() || turn_intent_id.starts_with("wir_") { + return Ok(None); + } + let sid = session_id.to_string(); + let session = tokio::task::spawn_blocking(move || persistence::get_session(&sid)) + .await + .map_err(|err| format!("Project CLI Session lookup worker failed: {err}"))? + .map_err(|err| format!("Project CLI Session lookup failed: {err}"))?; + let Some(session) = session else { + return Ok(None); + }; + if session.product_mode.as_deref() != Some("project") { + return Ok(None); + } + let work_item_id = session.work_item_id.clone().ok_or_else(|| { + format!("Project CLI Session {session_id} has no durable Work Item after bootstrap") + })?; + let mut target_snapshot = project_management::projects::types::WorkItemRunTargetSnapshot::new( + project_management::projects::types::WorkItemRunTarget::ResumeSession { + session_id: session_id.to_string(), + }, + ); + target_snapshot.workspace_path = session + .worktree_path + .clone() + .or_else(|| session.repo_path.clone()); + target_snapshot.workspace_mode = Some(if session.worktree_path.is_some() { + project_management::projects::types::WorkspaceExecutionMode::Worktree + } else { + project_management::projects::types::WorkspaceExecutionMode::LocalWorkspace + }); + target_snapshot.repository = session.repo_path.clone(); + target_snapshot.repository_ref = session + .worktree_branch + .clone() + .or_else(|| session.base_branch.clone()) + .or_else(|| session.branch.clone()); + target_snapshot.default_branch = session.base_branch.clone(); + let request = project_management::projects::types::EnqueueWorkItemRunRequest { + project_slug: session.project_slug, + org_id: session.org_id, + work_item_id, + trigger: project_management::projects::types::WorkItemRunTrigger::Manual, + target_snapshot, + input: serde_json::json!({ + "content": user_input, + "displayText": user_input, + "clientMessageId": client_message_id, + }), + idempotency_key: format!("project-session-turn:{session_id}:{turn_intent_id}"), + max_attempts: 3, + parent_run_id: None, + }; + tokio::task::spawn_blocking(move || project_management::work_run_service::enqueue(request)) + .await + .map_err(|err| format!("Project CLI WorkItemRun enqueue worker failed: {err}"))? + .map(Some) } /// Shared turn body behind both `cli_agent_run` and `cli_agent_message`: @@ -128,6 +248,8 @@ async fn run_turn(request: CliRunRequest, turn: TurnIdentity) -> Result<(), Stri ide_context, mode, images, + turn_intent_id: _, + client_message_id: _, } = request; let TurnIdentity { turn_intent_id, @@ -146,13 +268,39 @@ async fn run_turn(request: CliRunRequest, turn: TurnIdentity) -> Result<(), Stri let sid = session_id.clone(); let requested_mode = requested_mode.to_string(); tokio::task::spawn_blocking(move || { - persistence::update_agent_exec_mode(&sid, &requested_mode) + let session = persistence::get_session(&sid) + .map_err(|err| format!("DB error: {err}"))? + .ok_or_else(|| format!("Session {sid} not found"))?; + let effective_mode = if session.product_mode.as_deref() == Some("project") { + agent_core::session::AgentExecMode::Build + } else { + agent_core::session::AgentExecMode::parse(&requested_mode) + .ok_or_else(|| format!("Unknown agent_exec_mode: {requested_mode:?}"))? + }; + persistence::update_agent_exec_mode(&sid, effective_mode.as_str()) .map_err(|err| format!("DB error: {}", err)) }) .await .map_err(|err| format!("Task error: {}", err))??; } + bootstrap_project_root_if_needed(&session_id, &user_input).await?; + if let Some(run) = enqueue_project_turn_if_needed( + &session_id, + &user_input, + &turn_intent_id, + &client_message_id, + ) + .await? + { + tracing::info!( + session_id = %session_id, + run_id = %run.id, + "queued CLI Project turn through durable WorkItem dispatcher" + ); + return Ok(()); + } + // Hold the registry lock across acceptance persistence + spawn so two // concurrent calls cannot both create a running intent for one session. let mut sessions = session_runner::RUNNING_SESSIONS.lock().await; @@ -209,6 +357,7 @@ async fn run_turn(request: CliRunRequest, turn: TurnIdentity) -> Result<(), Stri .await { tracing::error!("[CodeSession] Session {} failed: {}", sid, e); + session_runner::forget_session_context(&sid); session_runner::flush_cli_streams_for_session(&sid).await; // Best-effort: if marking the row as Failed itself fails, log // it explicitly rather than silently dropping the persistence @@ -239,6 +388,36 @@ async fn run_turn(request: CliRunRequest, turn: TurnIdentity) -> Result<(), Stri persist_err ); } + if runner_turn_intent_id.starts_with("wir_") { + let failed_run_id = runner_turn_intent_id.clone(); + let failed_session_id = sid.clone(); + let work_run_error = e.clone(); + match tokio::task::spawn_blocking(move || { + project_management::work_run_service::record_run_terminal( + &failed_run_id, + Some(&failed_session_id), + project_management::work_run_service::WorkItemRunTerminalOutcome::Failed, + Default::default(), + Some(&work_run_error), + ) + }) + .await + { + Ok(Ok(_)) => {} + Ok(Err(err)) => tracing::error!( + session_id = %sid, + turn_intent_id = %runner_turn_intent_id, + error = %err, + "failed to persist CLI WorkItemRun setup failure" + ), + Err(err) => tracing::error!( + session_id = %sid, + turn_intent_id = %runner_turn_intent_id, + error = %err, + "CLI WorkItemRun setup failure task failed" + ), + } + } integrations::proxy::server::stop_session_proxy(&sid).await; session_runner::release_proxy_token_for_session_pub(&sid).await; super::failure_broadcast::broadcast_async_run_failure( @@ -308,43 +487,6 @@ pub async fn cli_agent_message(request: CliMessageRequest) -> Result(short_id) - }) - .await - .map_err(|e| format!("Task error: {}", e))?; - match bootstrapped { - Ok(short_id) => { - tracing::info!( - session_id = %session_id, - short_id, - "[project-bootstrap] created and linked root work item (cli)" - ); - } - Err(err) => { - tracing::warn!(session_id = %session_id, error = %err, "[project-bootstrap] cli bootstrap failed"); - } - } - } - let target_account_id = account_id.as_deref().or(session.account_id.as_deref()); // If the user switched model/account, persist the change so run_session picks it up. @@ -477,6 +619,8 @@ pub async fn cli_agent_message(request: CliMessageRequest) -> Result SqliteResult<()> { // agent_sessions so external CLIs can enter Project mode. conn.execute("ALTER TABLE code_sessions ADD COLUMN product_mode TEXT", []) .ok(); + conn.execute( + "UPDATE code_sessions + SET product_mode = CASE + WHEN work_item_id IS NOT NULL THEN 'project' + ELSE 'build' + END + WHERE product_mode IS NULL + OR product_mode NOT IN ('build', 'plan', 'ask', 'project') + OR (work_item_id IS NOT NULL AND product_mode != 'project')", + [], + )?; // Schema update: add hosted_token column for proxy token release on session cleanup conn.execute("ALTER TABLE code_sessions ADD COLUMN hosted_token TEXT", []) @@ -214,6 +225,15 @@ pub fn init_cli_agent_tables(conn: &Connection) -> SqliteResult<()> { [], ) .ok(); + conn.execute( + "UPDATE code_sessions + SET agent_exec_mode = 'build' + WHERE agent_exec_mode IS NULL + OR TRIM(agent_exec_mode) = '' + OR agent_exec_mode NOT IN ('build', 'ask', 'plan', 'debug', 'review', 'wingman') + OR product_mode = 'project'", + [], + )?; // P3 — per-session composer state (draft text + reply target). // Mirrors the same two columns added to `agent_sessions`; the diff --git a/src-tauri/src/agent_sessions/cli/parsers/codex.rs b/src-tauri/src/agent_sessions/cli/parsers/codex.rs index 8df054c1f8..1c56dcd083 100644 --- a/src-tauri/src/agent_sessions/cli/parsers/codex.rs +++ b/src-tauri/src/agent_sessions/cli/parsers/codex.rs @@ -270,15 +270,17 @@ impl CodexParser { // Extract usage if let Some(usage) = data.get("usage") { + let input_tokens = usage + .get("input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let output_tokens = usage + .get("output_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0); self.usage = Some(TokenUsage { - input_tokens: usage - .get("input_tokens") - .and_then(|v| v.as_u64()) - .unwrap_or(0), - output_tokens: usage - .get("output_tokens") - .and_then(|v| v.as_u64()) - .unwrap_or(0), + input_tokens, + output_tokens, cache_read_tokens: usage .get("cached_input_tokens") .and_then(|v| v.as_u64()) @@ -287,7 +289,8 @@ impl CodexParser { total_tokens: usage .get("total_tokens") .and_then(|v| v.as_u64()) - .unwrap_or(0), + .filter(|value| *value > 0) + .unwrap_or_else(|| input_tokens.saturating_add(output_tokens)), model: data .get("model") .and_then(|v| v.as_str()) diff --git a/src-tauri/src/agent_sessions/cli/parsers/codex_app_server.rs b/src-tauri/src/agent_sessions/cli/parsers/codex_app_server.rs index 983b09a10c..b7fdf431a7 100644 --- a/src-tauri/src/agent_sessions/cli/parsers/codex_app_server.rs +++ b/src-tauri/src/agent_sessions/cli/parsers/codex_app_server.rs @@ -376,12 +376,19 @@ impl CodexAppServerEventParser { "thread/tokenUsage/updated" => { if let Some(last) = params.get("tokenUsage").and_then(|u| u.get("last")) { let read = |key: &str| last.get(key).and_then(|v| v.as_u64()).unwrap_or(0); + let input_tokens = read("inputTokens"); + let output_tokens = read("outputTokens"); + let reported_total = read("totalTokens"); self.usage = Some(TokenUsage { - input_tokens: read("inputTokens"), - output_tokens: read("outputTokens"), + input_tokens, + output_tokens, cache_read_tokens: read("cachedInputTokens"), cache_write_tokens: 0, - total_tokens: read("totalTokens"), + total_tokens: if reported_total > 0 { + reported_total + } else { + input_tokens.saturating_add(output_tokens) + }, model: None, }); } diff --git a/src-tauri/src/agent_sessions/cli/parsers/tests/codex_app_server_tests.rs b/src-tauri/src/agent_sessions/cli/parsers/tests/codex_app_server_tests.rs index 59c9096d4b..dcdf9fece8 100644 --- a/src-tauri/src/agent_sessions/cli/parsers/tests/codex_app_server_tests.rs +++ b/src-tauri/src/agent_sessions/cli/parsers/tests/codex_app_server_tests.rs @@ -325,6 +325,29 @@ fn token_usage_updated_captures_last_breakdown() { assert_eq!(usage.total_tokens, 12098); } +#[test] +fn token_usage_derives_total_when_provider_omits_it() { + let mut p = parser(); + let chunks = notif( + &mut p, + "thread/tokenUsage/updated", + json!({ + "threadId": "t", "turnId": "u", + "tokenUsage": { + "last": { + "inputTokens": 91133, + "cachedInputTokens": 76288, + "outputTokens": 1876 + } + } + }), + ); + assert!(chunks.is_empty()); + let usage = p.usage().expect("usage captured"); + assert_eq!(usage.total_tokens, 93009); + assert_eq!(usage.cache_read_tokens, 76288); +} + #[test] fn turn_completed_emits_session_end_and_records_status() { let mut p = parser(); diff --git a/src-tauri/src/agent_sessions/cli/parsers/tests/parser_integration_tests.rs b/src-tauri/src/agent_sessions/cli/parsers/tests/parser_integration_tests.rs index b15c588e6c..de92565b5d 100644 --- a/src-tauri/src/agent_sessions/cli/parsers/tests/parser_integration_tests.rs +++ b/src-tauri/src/agent_sessions/cli/parsers/tests/parser_integration_tests.rs @@ -190,6 +190,7 @@ mod tests { assert_eq!(usage.input_tokens, 1500); assert_eq!(usage.output_tokens, 300); assert_eq!(usage.cache_read_tokens, 500); + assert_eq!(usage.total_tokens, 1800); } #[test] diff --git a/src-tauri/src/agent_sessions/cli/persistence/session_crud.rs b/src-tauri/src/agent_sessions/cli/persistence/session_crud.rs index 19bff48e3d..9c7231158d 100644 --- a/src-tauri/src/agent_sessions/cli/persistence/session_crud.rs +++ b/src-tauri/src/agent_sessions/cli/persistence/session_crud.rs @@ -88,6 +88,15 @@ pub fn create_session( .as_ref() .filter(|v| !v.is_empty()) .map(|v| serde_json::to_string(v).unwrap_or_else(|_| "[]".to_string())); + let product_mode = if params.work_item_id.is_some() { + "project".to_string() + } else { + params + .product_mode + .clone() + .filter(|mode| matches!(mode.as_str(), "build" | "plan" | "ask" | "project")) + .unwrap_or_else(|| "build".to_string()) + }; // Native-transcript capability is decided once at creation and frozen: // a later capability flip must never re-route an existing session's @@ -104,8 +113,8 @@ pub fn create_session( proxy_session_id, background, key_source, additional_directories, parent_session_id, org_member_id, org_id, project_id, project_name, project_slug, work_item_id, agent_role, created_at, updated_at, - transcript_source, product_mode) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30)", + transcript_source, product_mode, agent_exec_mode) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31)", params![ session_id, name, SessionStatus::Pending.as_ref(), flow, runner, params.cli_agent_type, params.model, params.tier, params.account_id, @@ -114,7 +123,7 @@ pub fn create_session( additional_dirs_json, params.parent_session_id, params.org_member_id, org_id, params.project_id, params.project_name, params.project_slug, params.work_item_id, params.agent_role, ts, ts, transcript_source, - params.product_mode, + product_mode, AgentExecMode::Build.as_str(), ], )?; @@ -775,9 +784,6 @@ pub fn update_model_and_account( Ok(affected > 0) } -/// Update the per-session execution mode on a CLI session row. -/// Mirrors `agent_core::session::persistence::update_agent_exec_mode`. -/// Does not bump `updated_at`; this is composer control state, not activity. /// Link the Project root Work Item created by the bootstrap flow. /// Guarded on `work_item_id IS NULL` so a concurrent duplicate submit /// can never repoint an already-linked session (same contract as the @@ -809,6 +815,9 @@ pub fn update_product_mode(session_id: &str, product_mode: &str) -> SqliteResult Ok(affected > 0) } +/// Update the per-session execution mode on a CLI session row. +/// Mirrors `agent_core::session::persistence::update_agent_exec_mode`. +/// Does not bump `updated_at`; this is composer control state, not activity. pub fn update_agent_exec_mode(session_id: &str, mode: &str) -> SqliteResult { let parsed = AgentExecMode::parse(mode).ok_or_else(|| { rusqlite::Error::ToSqlConversionFailure( @@ -826,6 +835,31 @@ pub fn update_agent_exec_mode(session_id: &str, mode: &str) -> SqliteResult 0) } +/// Atomically update the product-mode and execution-mode axes behind one +/// composer selection. See the native-session equivalent for the invariant. +pub fn update_mode_axes( + session_id: &str, + product_mode: &str, + agent_exec_mode: &str, +) -> SqliteResult { + let parsed = AgentExecMode::parse(agent_exec_mode).ok_or_else(|| { + rusqlite::Error::ToSqlConversionFailure( + format!("unknown AgentExecMode value: {agent_exec_mode:?}").into(), + ) + })?; + let conn = get_connection()?; + let affected = conn.execute( + "UPDATE code_sessions + SET product_mode = ?2, agent_exec_mode = ?3 + WHERE session_id = ?1", + params![session_id, product_mode, parsed.as_str()], + )?; + if affected > 0 { + sync_orgtrack_mirror(session_id); + } + Ok(affected > 0) +} + /// Update the per-session unsent draft text on a CLI session row. /// Mirror of `agent_core::session::persistence::update_draft_text` — /// see that helper for the empty-string normalization rationale. diff --git a/src-tauri/src/agent_sessions/cli/session_runner/env_setup.rs b/src-tauri/src/agent_sessions/cli/session_runner/env_setup.rs index 68d849aa89..a1abd92af5 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/env_setup.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/env_setup.rs @@ -640,6 +640,16 @@ pub(super) fn configure_agent_profile( /// so the bundled `org2-pm` always matches the app version. External CLIs /// (Claude Code, Codex, …) go through the same org2-pm surface as native /// agents because of this injection. +pub(super) fn resolve_orgtrack_product_mode( + persisted_product_mode: Option<&str>, + has_work_item: bool, +) -> &str { + // WorkItem linkage is a frozen resolver rule and may repair a legacy row. + // A project slug is scope only: elevating it here would let an ordinary + // Build session launched inside a project acquire PM mutation capability. + persisted_product_mode.unwrap_or(if has_work_item { "project" } else { "build" }) +} + pub(super) fn inject_orgtrack_environment( session: &CodeSession, session_id: &str, @@ -649,25 +659,24 @@ pub(super) fn inject_orgtrack_environment( let agent = session.cli_agent_type.as_deref().unwrap_or("cli"); // The persisted product-mode axis wins; sessions from before the // column (or launched by flows that never set it) fall back to the - // linkage-derived mode so work-item runs keep their mutation surface. - let derived_mode = (session.work_item_id.is_some() || session.project_slug.is_some()) - .then_some("project"); - let product_mode = session.product_mode.as_deref().or(derived_mode); + // WorkItem linkage may repair a historical row; project scope alone must + // never elevate an ordinary Build session into Project capability. + let product_mode = resolve_orgtrack_product_mode( + session.product_mode.as_deref(), + session.work_item_id.is_some(), + ); env_vars.insert( "ORGII_SESSION_REF".to_string(), format!("org2:{session_id}"), ); env_vars.insert("ORGII_ACTOR".to_string(), format!("agent:{agent}")); - if let Some(mode) = product_mode { - env_vars.insert("ORGII_MODE".to_string(), mode.to_string()); - } + env_vars.insert("ORGII_MODE".to_string(), product_mode.to_string()); if let Some(slug) = session.project_slug.as_deref() { env_vars.insert("ORGII_SCOPE".to_string(), slug.to_string()); } - let org_scope = project_management::projects::io::resolve_local_org_scope(Some( - session.org_id.as_str(), - )); + let org_scope = + project_management::projects::io::resolve_local_org_scope(Some(session.org_id.as_str())); if let Some(org) = org_scope.as_deref() { env_vars.insert("ORGII_ORG".to_string(), org.to_string()); } @@ -695,7 +704,7 @@ pub(super) fn inject_orgtrack_environment( working_dir, session_id, Some(agent), - product_mode, + Some(product_mode), session.project_slug.as_deref(), Some(session.org_id.as_str()), ); diff --git a/src-tauri/src/agent_sessions/cli/session_runner/finalize.rs b/src-tauri/src/agent_sessions/cli/session_runner/finalize.rs index a4fcb35426..fcd8d170ed 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/finalize.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/finalize.rs @@ -24,6 +24,14 @@ use super::proxy_release::release_proxy_token_for_session; use super::token_sync::sync_codex_cli_auth_to_key_vault; use crate::api::websocket_handler; +fn normalized_total_tokens(input_tokens: u64, output_tokens: u64, reported_total: u64) -> u64 { + if reported_total > 0 { + reported_total + } else { + input_tokens.saturating_add(output_tokens) + } +} + /// Outcome of the spawn/stdout loop, consumed by [`finalize_session_run`]. pub(super) struct SessionRunOutcome { pub exit_code: i32, @@ -41,6 +49,48 @@ pub(super) struct SessionRunOutcome { pub stderr_lines: Arc>>, } +fn work_item_run_usage_since( + session_id: &str, + run_started_at: chrono::DateTime, +) -> project_management::projects::types::WorkItemRunUsage { + let records = match session_persistence::token_usage::get_token_usage_records(session_id) { + Ok(records) => records, + Err(err) => { + tracing::warn!( + session_id, + error = %err, + "failed to load CLI usage for WorkItemRun finalization" + ); + return Default::default(); + } + }; + let mut usage = project_management::projects::types::WorkItemRunUsage::default(); + for record in records { + let belongs_to_turn = chrono::DateTime::parse_from_rfc3339(&record.created_at) + .map(|created_at| created_at.with_timezone(&chrono::Utc) >= run_started_at) + .unwrap_or(false); + if !belongs_to_turn { + continue; + } + let input_tokens = u64::try_from(record.input_tokens).unwrap_or(0); + let output_tokens = u64::try_from(record.output_tokens).unwrap_or(0); + usage.input_tokens = usage.input_tokens.saturating_add(input_tokens); + usage.output_tokens = usage.output_tokens.saturating_add(output_tokens); + usage.cache_read_tokens = usage + .cache_read_tokens + .saturating_add(u64::try_from(record.cache_read_tokens).unwrap_or(0)); + usage.cache_write_tokens = usage + .cache_write_tokens + .saturating_add(u64::try_from(record.cache_write_tokens).unwrap_or(0)); + usage.total_tokens = usage.total_tokens.saturating_add(normalized_total_tokens( + input_tokens, + output_tokens, + u64::try_from(record.total_tokens).unwrap_or(0), + )); + } + usage +} + fn is_meaningful_stderr_line(line: &str) -> bool { // Keep this fallback in step with what the structured parsers suppress. // `not found` below would otherwise re-promote a notice the parser @@ -211,6 +261,9 @@ pub(super) async fn finalize_session_run( } else { SessionStatus::Failed }; + if raw_final_status == SessionStatus::Failed { + super::input_assembly::forget_session_context(session_id); + } // CLI member sessions inside an Agent Org run must land on `Idle` after each // successful turn so they remain available for the next coordinator dispatch. @@ -230,6 +283,20 @@ pub(super) async fn finalize_session_run( None }; + super::harness_hooks::finish_turn( + session_id, + agent, + session.model.as_deref(), + session + .worktree_path + .as_deref() + .filter(|path| !path.is_empty() && std::path::Path::new(path).is_dir()) + .or(session.repo_path.as_deref()), + final_status.as_ref(), + exit_code, + ) + .await; + let should_record_oauth_failure = *agent == ModelType::Codex && oauth_retry_eligible && error_message @@ -281,6 +348,63 @@ pub(super) async fn finalize_session_run( tracing::error!("[CodeSession] Failed to persist final lifecycle: {}", err); } + let durable_run_id = turn_intent_id + .filter(|turn_intent_id| turn_intent_id.starts_with("wir_")) + .map(str::to_string); + + // Cursor usage is fetched from its dashboard after process exit. Await it + // for a durable turn so the immutable Run receipt is not finalized with a + // permanent zero while the Session total updates a few seconds later. + if *agent == ModelType::CursorCli + && raw_final_status == SessionStatus::Completed + && durable_run_id.is_some() + { + fetch_cursor_usage_for_session(session_id, account_id, run_started_at).await; + } + + if let Some(run_id) = durable_run_id.as_deref() { + let usage = work_item_run_usage_since(session_id, run_started_at); + let outcome = match raw_final_status { + SessionStatus::Completed | SessionStatus::Idle => { + project_management::work_run_service::WorkItemRunTerminalOutcome::Succeeded + } + SessionStatus::Cancelled => { + project_management::work_run_service::WorkItemRunTerminalOutcome::Cancelled + } + SessionStatus::Pending | SessionStatus::Running | SessionStatus::Failed => { + project_management::work_run_service::WorkItemRunTerminalOutcome::Failed + } + }; + let terminal_run_id = run_id.to_string(); + let terminal_session_id = session_id.to_string(); + let terminal_error = error_message.clone(); + match tokio::task::spawn_blocking(move || { + project_management::work_run_service::record_run_terminal( + &terminal_run_id, + Some(&terminal_session_id), + outcome, + usage, + terminal_error.as_deref(), + ) + }) + .await + { + Ok(Ok(_)) => {} + Ok(Err(err)) => tracing::error!( + session_id, + run_id, + error = %err, + "failed to persist CLI WorkItemRun terminal" + ), + Err(err) => tracing::error!( + session_id, + run_id, + error = %err, + "CLI WorkItemRun terminal task failed" + ), + } + } + if final_status.is_terminal() { clear_live_status(agent, session_id, cli_session_id_out.as_deref()); } @@ -357,7 +481,10 @@ pub(super) async fn finalize_session_run( } // ── Cursor: fetch token usage from Dashboard API ── - if *agent == ModelType::CursorCli && raw_final_status == SessionStatus::Completed { + if *agent == ModelType::CursorCli + && raw_final_status == SessionStatus::Completed + && durable_run_id.is_none() + { let sid = session_id.to_string(); let acc_id = session.account_id.clone(); @@ -378,3 +505,18 @@ pub(super) async fn finalize_session_run( super::super::skill_sync::cleanup_synced_skill_files(synced_rule_files); } + +#[cfg(test)] +mod usage_tests { + use super::normalized_total_tokens; + + #[test] + fn reported_total_wins_when_present() { + assert_eq!(normalized_total_tokens(10, 20, 25), 25); + } + + #[test] + fn missing_total_is_derived_from_input_and_output() { + assert_eq!(normalized_total_tokens(91_133, 1_876, 0), 93_009); + } +} diff --git a/src-tauri/src/agent_sessions/cli/session_runner/harness_hooks.rs b/src-tauri/src/agent_sessions/cli/session_runner/harness_hooks.rs new file mode 100644 index 0000000000..d1ceeef81a --- /dev/null +++ b/src-tauri/src/agent_sessions/cli/session_runner/harness_hooks.rs @@ -0,0 +1,228 @@ +//! Provider-neutral lifecycle hooks for opaque CLI harnesses. +//! +//! Native/Rust agents can stop and resume inside their tool loop. An external +//! CLI is an opaque subprocess, so ORGII owns the lifecycle events around that +//! process while provider-native hooks continue to own individual tool calls. + +use std::path::PathBuf; + +use agent_core::specialization::hooks::events::HookContext; +use agent_core::specialization::hooks::executor::HookResult; +use agent_core::specialization::hooks::{HookEvent, HookExecutor}; +use key_vault::key_store::ModelType; + +const HOOK_CONTEXT_MAX_CHARS: usize = 10_000; + +fn workspace_root(repo_path: Option<&str>) -> PathBuf { + repo_path + .filter(|path| !path.trim().is_empty()) + .map(PathBuf::from) + .unwrap_or_else(app_paths::orgii_root) +} + +fn executor(repo_path: Option<&str>) -> HookExecutor { + let root = workspace_root(repo_path); + HookExecutor::load_with_workspace_scope(&root, repo_path.is_some()) +} + +fn additional_context_from_stdout(stdout: &str) -> Option { + let stdout = stdout.trim(); + if stdout.is_empty() { + return None; + } + + let parsed = serde_json::from_str::(stdout).ok(); + let context = parsed.as_ref().and_then(|value| { + value + .get("additionalContext") + .or_else(|| value.get("additional_context")) + .and_then(serde_json::Value::as_str) + .or_else(|| { + value + .get("hookSpecificOutput") + .and_then(|output| output.get("additionalContext")) + .and_then(serde_json::Value::as_str) + }) + }); + + // JSON hook protocols use an explicit additionalContext field. Plain + // stdout remains useful for simple shell hooks and matches common CLI + // harness behavior, so preserve it when the output is not JSON. + let context = context.or_else(|| parsed.is_none().then_some(stdout))?; + Some(agent_core::utils::safe_truncate_chars_to_string( + context, + HOOK_CONTEXT_MAX_CHARS, + )) +} + +fn collect_context( + event: HookEvent, + prompt: Option, + results: &[HookResult], +) -> Option { + let mut sections = Vec::new(); + if let Some(prompt) = prompt.filter(|value| !value.trim().is_empty()) { + sections.push(prompt); + } + for result in results { + if result.success { + if let Some(context) = additional_context_from_stdout(&result.stdout) { + sections.push(context); + } + } else { + tracing::warn!( + event = %event, + stderr = %result.stderr, + "[cli-hooks] lifecycle hook failed" + ); + } + } + (!sections.is_empty()).then(|| sections.join("\n\n")) +} + +fn base_context( + session_id: &str, + agent: &ModelType, + model: Option<&str>, + repo_path: Option<&str>, +) -> HookContext { + HookContext::for_session(session_id) + .with_var("ORGII_PROVIDER", agent.as_str()) + .with_var("ORGII_MODEL", model.unwrap_or("")) + .with_var( + "ORGII_WORKSPACE_ROOT", + workspace_root(repo_path).to_string_lossy(), + ) +} + +/// Fire inbound-message notification hooks on every turn and synchronously +/// collect SessionStart context for a fresh provider conversation. +pub(super) async fn prepare_turn( + session_id: &str, + agent: &ModelType, + model: Option<&str>, + repo_path: Option<&str>, + user_input: &str, + is_fresh_session: bool, +) -> Option { + let executor = executor(repo_path); + let notification_context = base_context(session_id, agent, model, repo_path).with_var( + "ORGII_USER_MESSAGE", + agent_core::utils::safe_truncate_chars_to_string(user_input, 5_000), + ); + if executor.has_hooks_for(HookEvent::NotificationReceived) { + let notification_executor = executor.clone(); + tokio::spawn(async move { + notification_executor + .run(HookEvent::NotificationReceived, ¬ification_context) + .await; + }); + } + + if !is_fresh_session || !executor.has_hooks_for(HookEvent::SessionStart) { + return None; + } + + let prompt = executor.collect_prompt_hooks(HookEvent::SessionStart); + let results = executor + .run( + HookEvent::SessionStart, + &base_context(session_id, agent, model, repo_path), + ) + .await; + collect_context(HookEvent::SessionStart, prompt, &results) +} + +/// Fire the provider-neutral Stop event once the opaque CLI turn has ended. +/// Tool-level blocking remains the provider's native hook responsibility. +pub(super) async fn finish_turn( + session_id: &str, + agent: &ModelType, + model: Option<&str>, + repo_path: Option<&str>, + status: &str, + exit_code: i32, +) { + let executor = executor(repo_path); + if !executor.has_hooks_for(HookEvent::Stop) { + return; + } + let context = base_context(session_id, agent, model, repo_path) + .with_var("ORGII_STATUS", status) + .with_var("ORGII_EXIT_CODE", exit_code.to_string()); + executor.run(HookEvent::Stop, &context).await; +} + +/// Fire SessionStop before persistent session state is deleted. +pub(crate) async fn stop_session( + session_id: &str, + agent: &ModelType, + model: Option<&str>, + repo_path: Option<&str>, +) { + let executor = executor(repo_path); + if !executor.has_hooks_for(HookEvent::SessionStop) { + return; + } + executor + .run( + HookEvent::SessionStop, + &base_context(session_id, agent, model, repo_path), + ) + .await; +} + +#[cfg(test)] +mod tests { + use super::{additional_context_from_stdout, prepare_turn}; + use key_vault::key_store::ModelType; + + #[test] + fn extracts_common_additional_context_contracts() { + assert_eq!( + additional_context_from_stdout(r#"{"additionalContext":"alpha"}"#).as_deref(), + Some("alpha") + ); + assert_eq!( + additional_context_from_stdout( + r#"{"hookSpecificOutput":{"additionalContext":"beta"}}"# + ) + .as_deref(), + Some("beta") + ); + assert_eq!( + additional_context_from_stdout("plain context").as_deref(), + Some("plain context") + ); + assert_eq!(additional_context_from_stdout(r#"{"ok":true}"#), None); + } + + #[tokio::test] + async fn session_start_prompt_hook_reaches_the_cli_turn() { + let workspace = tempfile::tempdir().expect("workspace"); + std::fs::create_dir_all(workspace.path().join(".orgii")).expect("create .orgii"); + std::fs::write( + workspace.path().join(".orgii/hooks.json"), + r#"{ + "hooks": { + "session_start": [ + { "type": "prompt", "content": "SESSION_HOOK_SENTINEL" } + ] + } + }"#, + ) + .expect("write hook config"); + + let context = prepare_turn( + "hook-session", + &ModelType::Codex, + Some("gpt-test"), + workspace.path().to_str(), + "hello", + true, + ) + .await + .expect("session hook context"); + assert!(context.contains("SESSION_HOOK_SENTINEL")); + } +} diff --git a/src-tauri/src/agent_sessions/cli/session_runner/input_assembly.rs b/src-tauri/src/agent_sessions/cli/session_runner/input_assembly.rs index 1064c2b035..1b6e95003e 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/input_assembly.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/input_assembly.rs @@ -6,11 +6,52 @@ //! injection. Extracted from `session::run_session` to keep the runner's //! orchestration readable. +use std::collections::HashMap; +use std::sync::{LazyLock, Mutex}; + use agent_core::session::AgentExecMode; use key_vault::key_store::ModelType; +use sha2::{Digest, Sha256}; use super::context_bridge::build_context_bridge; +type ProviderContextKey = (String, String); +type ProviderContextDigest = [u8; 32]; +type DeliveredContextDigests = HashMap; + +/// Last provider-context digest delivered in this app process. A resumed CLI +/// already has prior turn context, so unchanged rules/skill catalogs do not +/// need to consume tokens again. Process restart and multi-instance use are +/// deliberately independent: each live harness re-delivers once. +static DELIVERED_CONTEXT_DIGESTS: LazyLock> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +fn should_deliver_context( + session_id: &str, + agent: &ModelType, + context: &str, + is_fresh_session: bool, +) -> bool { + let digest: [u8; 32] = Sha256::digest(context.as_bytes()).into(); + let key = (session_id.to_string(), agent.as_str().to_string()); + let mut delivered = DELIVERED_CONTEXT_DIGESTS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if !is_fresh_session && delivered.get(&key) == Some(&digest) { + false + } else { + delivered.insert(key, digest); + true + } +} + +pub(crate) fn forget_session_context(session_id: &str) { + DELIVERED_CONTEXT_DIGESTS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .retain(|(delivered_session_id, _), _| delivered_session_id != session_id); +} + /// Maps a per-session exec mode to the `` preamble /// injected ahead of the user's prompt. `Wingman` (and any unparseable mode) /// contributes no preamble. @@ -31,6 +72,7 @@ pub(super) fn cli_exec_mode_bridge(mode: Option<&str>) -> Option<&'static str> { "\n", "You are running inside ORGII BUILD mode. Execute the approved or requested work directly. ", "Do not create a new approval plan unless the user explicitly asks to switch back to Plan mode.\n", + "Before claiming completion, re-read the produced artifact and check every literal acceptance constraint. For a file whose content must be exact, verify byte count and trailing bytes (for example with `wc -c` plus a hex/byte dump); command substitution and trimmed text readers hide trailing newlines and are not proof of byte equality.\n", "" )), AgentExecMode::Ask => Some(concat!( @@ -52,6 +94,47 @@ pub(super) fn cli_exec_mode_bridge(mode: Option<&str>) -> Option<&'static str> { } } +/// Product-mode overlay for external CLIs. `build` is the provider execution +/// mode; `project` is the separate capability axis that grants the guarded PM +/// CLI surface and durable Work Item contract. +fn project_mode_bridge( + product_mode: Option<&str>, + project_slug: Option<&str>, + work_item_id: Option<&str>, +) -> Option { + if product_mode != Some("project") { + return None; + } + + let scope = match project_slug { + Some(slug) => format!( + "Project scope is injected as ORGII_SCOPE={}; omit --scope unless inspecting another project.", + slug + ), + None => "This is a standalone Work Item; pass --standalone to work commands when required." + .to_string(), + }; + let linked_item = match work_item_id { + Some(id) => format!( + "This session is linked to Work Item {}. Read it with `org2-pm work show {}` and update that item for refinements; do not create a duplicate root item.", + id, id + ), + None => "The first requested deliverable is the root Work Item. Create it with `org2-pm work create` if bootstrap has not linked one yet." + .to_string(), + }; + + Some(format!( + "\n\ + You are in ORGII Project product mode: Build execution plus the guarded `org2-pm` work-management CLI. Ordinary Build sessions do not have this PM mutation capability.\n\ + Use `org2-pm --help` for discovery and `--output json` for machine-readable results.\n\ + {}\n\ + {}\n\ + Split genuinely independent deliverables into child items. When the requested work is complete, post exactly one outcome receipt with `org2-pm work note --kind progress --body \"...\"`; if blocked, transition the item to blocked and state why. Keep Work Item ids and bookkeeping mechanics out of the user-facing reply.\n\ + ", + scope, linked_item + )) +} + /// Assemble the effective prompt from the raw user input plus the CLI-session /// preambles. `is_fresh_session` is true when there is no `cli_resume_id` /// (only a fresh conversation gets the prior-context bridge). `skills_enabled` @@ -60,6 +143,9 @@ pub(super) fn cli_exec_mode_bridge(mode: Option<&str>) -> Option<&'static str> { pub(super) fn build_effective_input( user_input: &str, mode: Option<&str>, + product_mode: Option<&str>, + project_slug: Option<&str>, + work_item_id: Option<&str>, session_id: &str, is_fresh_session: bool, agent: &ModelType, @@ -75,6 +161,11 @@ pub(super) fn build_effective_input( effective_input = format!("{}\n\n{}", exec_mode_bridge, effective_input); } + if let Some(project_mode_bridge) = project_mode_bridge(product_mode, project_slug, work_item_id) + { + effective_input = format!("{}\n\n{}", project_mode_bridge, effective_input); + } + if is_fresh_session { if let Some(context_bridge) = build_context_bridge(session_id) { effective_input = format!("{}\n\n{}", context_bridge, effective_input); @@ -95,19 +186,193 @@ pub(super) fn build_effective_input( ); } - // For ACP agents without native rules file sync, inject skills into the prompt. - // Reuse the already-resolved skills config (§11.4 row 17). - if matches!(agent, ModelType::Kiro | ModelType::OpenCode) { - if let Some(path) = repo_path { - if let Some(skills_block) = super::super::skill_sync::build_skills_prompt_injection( - std::path::Path::new(path), - skills_enabled, - disabled_skills, - ) { - effective_input = format!("{}\n\n{}", skills_block, effective_input); - } + // Deliver one provider-neutral workspace contract to every CLI, even when + // that provider also has a native rules file. Native discovery behavior + // differs across versions and typically understands only one ecosystem + // filename (for example CLAUDE.md *or* AGENTS.md); the shared envelope + // guarantees parity across providers. The digest gate sends unchanged + // context once per app process/provider conversation and re-sends it when + // rules or the progressive skill catalog change. + if let Some(path) = repo_path.and_then(|path| { + let path = std::path::Path::new(path); + path.is_dir().then_some(path) + }) { + if let Some(context) = super::super::skill_sync::build_cli_context_prompt_injection( + path, + skills_enabled, + disabled_skills, + ) + .filter(|context| should_deliver_context(session_id, agent, context, is_fresh_session)) + { + effective_input = format!("{}\n\n{}", context, effective_input); } } + // Prompt hooks are dynamic and therefore apply on every turn, including a + // resumed provider conversation. Command/http lifecycle hooks are fired by + // the CLI runner itself; only Prompt entries are materialized here. + let workspace_root = repo_path + .map(std::path::PathBuf::from) + .unwrap_or_else(app_paths::orgii_root); + let hook_executor = agent_core::specialization::hooks::HookExecutor::load_with_workspace_scope( + &workspace_root, + repo_path.is_some(), + ); + if let Some(hook_prompt) = hook_executor + .collect_prompt_hooks(agent_core::specialization::hooks::HookEvent::PrePromptBuild) + { + effective_input = format!( + "\n{}\n\n\n{}", + hook_prompt, effective_input + ); + } + effective_input } + +#[cfg(test)] +mod tests { + use super::{build_effective_input, project_mode_bridge}; + use key_vault::key_store::ModelType; + + #[test] + fn ordinary_build_does_not_receive_pm_cli_guidance() { + assert!(project_mode_bridge(Some("build"), Some("repo"), Some("WI-1")).is_none()); + } + + #[test] + fn project_is_build_plus_guarded_pm_cli() { + let bridge = project_mode_bridge(Some("project"), Some("repo"), Some("WI-1")) + .expect("project overlay"); + assert!(bridge.contains("Build execution plus")); + assert!(bridge.contains("org2-pm work show WI-1")); + assert!(bridge.contains("ORGII_SCOPE=repo")); + } + + #[test] + fn every_cli_provider_receives_workspace_context() { + let workspace = tempfile::tempdir().expect("workspace"); + std::fs::write( + workspace.path().join("AGENTS.md"), + "PROVIDER_CONTEXT_SENTINEL", + ) + .expect("write AGENTS.md"); + let providers = [ + ModelType::CursorCli, + ModelType::ClaudeCode, + ModelType::Codex, + ModelType::Copilot, + ModelType::Kiro, + ModelType::KimiCli, + ModelType::OpenCode, + ModelType::Aider, + ModelType::Goose, + ModelType::Amp, + ModelType::Cline, + ModelType::Kilo, + ModelType::Grok, + ModelType::Devin, + ModelType::Rovo, + ModelType::Hermes, + ModelType::OpenClaw, + ModelType::Aug, + ModelType::Codebuff, + ModelType::QwenCode, + ModelType::MimoCode, + ModelType::Antigravity, + ModelType::Continue, + ModelType::Droid, + ModelType::MistralVibe, + ModelType::Autohand, + ModelType::Omp, + ModelType::Pi, + ModelType::QoderCli, + ModelType::TraeCli, + ]; + + for provider in providers { + assert!(provider.is_cli_agent()); + let prompt = build_effective_input( + "do the task", + Some("build"), + Some("build"), + None, + None, + "session-1", + true, + &provider, + &[], + false, + workspace.path().to_str(), + false, + &[], + ); + assert!( + prompt.contains("PROVIDER_CONTEXT_SENTINEL"), + "{} missed workspace context", + provider.as_str() + ); + assert!( + !prompt.contains("orgii_project_mode"), + "{} received Project capabilities in ordinary Build", + provider.as_str() + ); + } + } + + #[test] + fn unchanged_context_is_delivered_once_and_changes_are_reinjected() { + let workspace = tempfile::tempdir().expect("workspace"); + let agents_md = workspace.path().join("AGENTS.md"); + std::fs::write(&agents_md, "CONTEXT_V1").expect("write v1"); + + let build = || { + build_effective_input( + "do the task", + Some("build"), + Some("build"), + None, + None, + "digest-session", + false, + &ModelType::Codex, + &[], + false, + workspace.path().to_str(), + false, + &[], + ) + }; + assert!(build().contains("CONTEXT_V1")); + assert!(!build().contains("CONTEXT_V1")); + + std::fs::write(&agents_md, "CONTEXT_V2").expect("write v2"); + assert!(build().contains("CONTEXT_V2")); + } + + #[test] + fn a_fresh_provider_conversation_always_receives_context() { + let workspace = tempfile::tempdir().expect("workspace"); + std::fs::write(workspace.path().join("AGENTS.md"), "FRESH_CONTEXT").expect("write context"); + let build = |is_fresh_session| { + build_effective_input( + "do the task", + Some("build"), + Some("build"), + None, + None, + "fresh-session", + is_fresh_session, + &ModelType::Codex, + &[], + false, + workspace.path().to_str(), + false, + &[], + ) + }; + assert!(build(true).contains("FRESH_CONTEXT")); + assert!(!build(false).contains("FRESH_CONTEXT")); + assert!(build(true).contains("FRESH_CONTEXT")); + } +} diff --git a/src-tauri/src/agent_sessions/cli/session_runner/mod.rs b/src-tauri/src/agent_sessions/cli/session_runner/mod.rs index 19d4c4e5a6..1605f6d3d4 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/mod.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/mod.rs @@ -23,6 +23,7 @@ mod context_bridge; mod cursor_usage; mod env_setup; mod finalize; +mod harness_hooks; mod helpers; mod input_assembly; pub(crate) mod launch_profiles; @@ -33,7 +34,9 @@ mod proxy_release; mod session; mod token_sync; +pub(crate) use harness_hooks::stop_session as stop_session_hooks; pub use helpers::{flush_cli_streams_for_session, RUNNING_SESSIONS}; +pub(crate) use input_assembly::forget_session_context; pub use lifecycle::{ cancel_session, cleanup_cursor_config_dir, kill_running_agent, terminate_process_tree, }; diff --git a/src-tauri/src/agent_sessions/cli/session_runner/session.rs b/src-tauri/src/agent_sessions/cli/session_runner/session.rs index d0207eae95..e03781cff6 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/session.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/session.rs @@ -17,6 +17,7 @@ use tokio::process::Command; use tokio::sync::Mutex; use crate::api::websocket_handler; +use agent_core::session::AgentExecMode; use key_vault::key_store::{KeyService, ModelType, KEY_SERVICE}; use super::super::launch_profile_store::resolve_cli_launch_profile; @@ -207,6 +208,21 @@ fn resolve_session_model( } } +fn resolve_cli_effective_mode( + product_mode: Option<&str>, + requested_mode: Option<&str>, + persisted_mode: Option<&str>, +) -> AgentExecMode { + if product_mode == Some("project") { + AgentExecMode::Build + } else { + requested_mode + .and_then(AgentExecMode::parse) + .or_else(|| persisted_mode.and_then(AgentExecMode::parse)) + .unwrap_or(AgentExecMode::Build) + } +} + /// Run a code session: spawn CLI, parse stdout, broadcast events. /// /// This is spawned as a background Tokio task. @@ -266,6 +282,29 @@ pub async fn run_session( let model = resolve_session_model(&agent, key_model_type.as_ref(), session.model.as_deref()); let repo_path = session.repo_path.as_deref(); let account_id = session.account_id.as_deref(); + let effective_mode = resolve_cli_effective_mode( + session.product_mode.as_deref(), + mode, + session.agent_exec_mode.as_deref(), + ); + if session.agent_exec_mode.as_deref() != Some(effective_mode.as_str()) { + persistence::update_agent_exec_mode(&session_id, effective_mode.as_str()) + .map_err(|err| format!("normalize CLI session mode: {err}"))?; + } + let effective_mode_str = effective_mode.as_str(); + let base_working_dir = repo_path.filter(|path| !path.is_empty()).ok_or_else(|| { + "repo_path is required — cannot run agent without a working directory".to_string() + })?; + let working_dir = session + .worktree_path + .as_deref() + .filter(|path| !path.is_empty() && std::path::Path::new(path).is_dir()) + .unwrap_or(base_working_dir); + if !std::path::Path::new(working_dir).is_dir() { + return Err(format!( + "Working directory does not exist or is not a directory: {working_dir}" + )); + } if matches!(agent, ModelType::CursorCli) && session.key_source == KeySource::OwnKey { let has_api_key = selected_key @@ -288,12 +327,11 @@ pub async fn run_session( // Sync .orgii/agent-rules.md → agent-native rules file let mut synced_rule_files: Vec = Vec::new(); - if let Some(path) = repo_path { - let project = std::path::Path::new(path); - synced_rule_files.extend(super::super::skill_sync::sync_conventions_for_agent( - &agent, project, - )); - } + let active_workspace = std::path::Path::new(working_dir); + synced_rule_files.extend(super::super::skill_sync::sync_conventions_for_agent( + &agent, + active_workspace, + )); // Sync skills to agent-native rules files. // @@ -302,15 +340,12 @@ pub async fn run_session( // are a host-wide concern carried on the SDE definition) and read // `skills.enabled` + `skills.disabled` off `ResolvedAgent`. let skills_cfg = resolve_sde_skills(); - if let Some(path) = repo_path { - let project = std::path::Path::new(path); - synced_rule_files.extend(super::super::skill_sync::sync_skills_for_agent( - &agent, - project, - skills_cfg.enabled, - &skills_cfg.disabled, - )); - } + synced_rule_files.extend(super::super::skill_sync::sync_skills_for_agent( + &agent, + active_workspace, + skills_cfg.enabled, + &skills_cfg.disabled, + )); // Pre-message anchor snapshot for CLI rollback support. // `snapshot_cli_file_edit` populates this snapshot with git-HEAD bytes of @@ -352,18 +387,37 @@ pub async fn run_session( let image_paths = persist_attached_images(&session_id, images.as_deref()).await; - let effective_input = super::input_assembly::build_effective_input( + let lifecycle_hook_context = super::harness_hooks::prepare_turn( + &session_id, + &agent, + model.as_deref(), + Some(working_dir), &user_input, - mode, + cli_resume_id.is_none(), + ) + .await; + + let mut effective_input = super::input_assembly::build_effective_input( + &user_input, + Some(effective_mode_str), + session.product_mode.as_deref(), + session.project_slug.as_deref(), + session.work_item_id.as_deref(), &session_id, cli_resume_id.is_none(), &agent, &image_paths, use_codex_app_server, - repo_path, + Some(working_dir), skills_cfg.enabled, &skills_cfg.disabled, ); + if let Some(context) = lifecycle_hook_context { + effective_input = format!( + "\n{}\n\n\n{}", + context, effective_input + ); + } // Build CLI command let api_key_for_cli = if session.key_source == KeySource::HostedKey @@ -390,8 +444,8 @@ pub async fn run_session( resume_id: cli_resume_id.as_deref(), api_key: api_key_for_cli, endpoint: endpoint_for_cli, - mode, - repo_path, + mode: Some(effective_mode_str), + repo_path: Some(working_dir), additional_dirs, }); @@ -437,23 +491,6 @@ pub async fn run_session( ); } - let base_working_dir = repo_path.filter(|p| !p.is_empty()).ok_or_else(|| { - "repo_path is required — cannot run agent without a working directory".to_string() - })?; - - let working_dir = session - .worktree_path - .as_deref() - .filter(|p| !p.is_empty() && std::path::Path::new(p).is_dir()) - .unwrap_or(base_working_dir); - - if !std::path::Path::new(&working_dir).is_dir() { - return Err(format!( - "Working directory does not exist or is not a directory: {}", - working_dir - )); - } - let snapshot_working_dir = working_dir.to_string(); // ── Build environment variables ── @@ -749,7 +786,7 @@ pub async fn run_session( oauth_retry_eligible, overload_retry_eligible, agent.clone(), - mode, + Some(effective_mode_str), account_id, model.as_deref(), session_timeout, diff --git a/src-tauri/src/agent_sessions/cli/session_runner/session/tests.rs b/src-tauri/src/agent_sessions/cli/session_runner/session/tests.rs index 3aa4f4d181..66e2fe7691 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/session/tests.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/session/tests.rs @@ -1,7 +1,7 @@ use super::super::env_setup::{ atlascloud_model_id, clear_codex_compatible_profile, codex_needs_compatible_profile, - opencode_zenmux_model_id, setup_codex_compatible_profile, setup_codex_hosted_profile, - setup_opencode_atlascloud_profile, setup_opencode_zenmux_profile, + opencode_zenmux_model_id, resolve_orgtrack_product_mode, setup_codex_compatible_profile, + setup_codex_hosted_profile, setup_opencode_atlascloud_profile, setup_opencode_zenmux_profile, validate_codex_own_key_provider, }; use super::super::input_assembly::cli_exec_mode_bridge; @@ -21,6 +21,29 @@ use std::sync::Mutex as StdMutex; static ORGII_HOME_TEST_LOCK: StdMutex<()> = StdMutex::new(()); +#[test] +fn project_is_always_build_execution_while_ordinary_modes_stay_distinct() { + assert_eq!( + resolve_cli_effective_mode(Some("project"), Some("ask"), Some("plan")), + AgentExecMode::Build + ); + assert_eq!( + resolve_cli_effective_mode(Some("build"), Some("ask"), Some("build")), + AgentExecMode::Ask + ); + assert_eq!( + resolve_cli_effective_mode(Some("build"), None, Some("plan")), + AgentExecMode::Plan + ); +} + +#[test] +fn project_scope_does_not_grant_project_product_capability() { + assert_eq!(resolve_orgtrack_product_mode(Some("build"), false), "build"); + assert_eq!(resolve_orgtrack_product_mode(None, false), "build"); + assert_eq!(resolve_orgtrack_product_mode(None, true), "project"); +} + fn with_temp_orgii_home(run: impl FnOnce(&Path) -> R) -> R { let _guard = ORGII_HOME_TEST_LOCK .lock() @@ -670,6 +693,13 @@ fn cli_plan_mode_bridge_preserves_side_chat_semantics() { assert!(bridge.contains("canonicalizes the written plan file into the approval card")); } +#[test] +fn cli_build_mode_bridge_requires_byte_exact_verification() { + let bridge = cli_exec_mode_bridge(Some("build")).expect("build bridge"); + assert!(bridge.contains("verify byte count and trailing bytes")); + assert!(bridge.contains("trimmed text readers hide trailing newlines")); +} + #[test] fn cli_plan_markdown_detection_accepts_buildable_plan_text_only() { assert!(looks_like_buildable_plan_body( @@ -884,7 +914,11 @@ async fn a_reader_the_grandchild_holds_open_is_aborted_not_detached() { child.stderr.take().expect("stderr was piped"), "test-session".to_string(), ); - assert!(child.wait().await.expect("wait for stderr writer").success()); + assert!(child + .wait() + .await + .expect("wait for stderr writer") + .success()); let lines = collector.lines(); assert_eq!( diff --git a/src-tauri/src/agent_sessions/cli/skill_sync.rs b/src-tauri/src/agent_sessions/cli/skill_sync.rs index 7515147aa8..b6b74bae6e 100644 --- a/src-tauri/src/agent_sessions/cli/skill_sync.rs +++ b/src-tauri/src/agent_sessions/cli/skill_sync.rs @@ -24,6 +24,8 @@ use key_vault::key_store::ModelType; /// Marker header embedded in every generated file so we can identify our own files. const ORGII_MARKER: &str = ""; +const PROVIDER_SKILL_CATALOG_CHAR_BUDGET: usize = 8_000; +const PROVIDER_SKILL_DESCRIPTION_MAX_CHARS: usize = 250; /// Sync skills into the CLI agent's native rules files. /// @@ -49,14 +51,14 @@ pub fn sync_skills_for_agent( let all_skills: Vec = loader .list_skills() .into_iter() - .filter(|skill| skill.enabled) + .filter(|skill| skill.enabled && skill.available) .collect(); if all_skills.is_empty() { return Vec::new(); } - let content = build_skills_content(&loader, &all_skills, disabled_skills); + let content = build_skills_content(&all_skills); let targets = rule_targets_for_agent(agent, workspace_path); let mut written: Vec = Vec::new(); @@ -122,12 +124,10 @@ fn rule_targets_for_agent(agent: &ModelType, workspace_path: &Path) -> Vec { - // Codex walks up from cwd looking for AGENTS.md; we write a separate - // file and rely on Codex picking up all .md files in the workspace root. - // Use .codex/AGENTS.md which Codex also discovers. - vec![workspace_path.join(".codex").join("orgii-skills.md")] - } + // Codex does not discover arbitrary markdown below `.codex/`. + // Mutating a user's root AGENTS.md would race concurrent sessions, so + // its guaranteed delivery path is the provider-neutral prompt fallback. + ModelType::Codex => Vec::new(), ModelType::Copilot => { // Copilot reads CLAUDE.md, AGENTS.md, and GEMINI.md. // Write to .github/instructions/ which Copilot explicitly supports. @@ -146,7 +146,7 @@ fn rule_targets_for_agent(agent: &ModelType, workspace_path: &Path) -> Vec = loader .list_skills() .into_iter() - .filter(|skill| skill.enabled) + .filter(|skill| skill.enabled && skill.available) .collect(); if all_skills.is_empty() { return None; } - let content = build_skills_content(&loader, &all_skills, disabled_skills); + let content = build_skills_content(&all_skills); Some(format!("\n{}\n", content)) } +/// Build the provider-neutral context envelope used by every CLI. The +/// workspace instruction section is shared with the Rust harness; the skill +/// catalog stays progressive-disclosure only (descriptions + paths, never +/// every SKILL.md body). +pub fn build_cli_context_prompt_injection( + workspace_path: &Path, + skills_enabled: bool, + disabled_skills: &[String], +) -> Option { + let mut sections = Vec::new(); + if let Some(instructions) = + agent_core::session::prompt::load_workspace_instructions(workspace_path) + { + if !instructions.trim().is_empty() { + sections.push(format!("## Workspace Instructions\n\n{instructions}")); + } + } + if let Some(skills) = + build_skills_prompt_injection(workspace_path, skills_enabled, disabled_skills) + { + sections.push(skills); + } + if sections.is_empty() { + None + } else { + Some(format!( + "\n{}\n", + sections.join("\n\n---\n\n") + )) + } +} + // ========================================================================== // Private helpers // ========================================================================== /// Build the formatted skills content shared by all agents. -fn build_skills_content( - loader: &SkillsLoader, - skills: &[SkillInfo], - disabled_skills: &[String], -) -> String { - let mut parts: Vec = Vec::new(); - - let always_sections = loader.build_always_skills_manifest_section(disabled_skills, None); - parts.extend(always_sections); - - // Summary of all available skills with scan instructions - let summary_lines: Vec = skills +fn build_skills_content(skills: &[SkillInfo]) -> String { + let mut skills = skills.iter().collect::>(); + skills.sort_by(|left, right| { + left.name + .cmp(&right.name) + .then_with(|| left.path.cmp(&right.path)) + }); + + // Keep discovery byte-stable and bounded. Full SKILL.md bodies are loaded + // only after the provider selects a matching entry. + let mut description_budget = PROVIDER_SKILL_DESCRIPTION_MAX_CHARS; + let render_lines = |description_budget: usize| -> Vec { + skills + .iter() + .map(|skill| { + let desc = if skill.description.is_empty() { + "No description".to_string() + } else { + agent_core::utils::safe_truncate_chars_to_string( + &skill.description, + description_budget, + ) + }; + format!( + "- **{}** ({}): {}\n Path: `{}`", + skill.name, + skill.source, + desc, + skill.path.display() + ) + }) + .collect() + }; + let mut summary_lines = render_lines(description_budget); + let mut rendered_chars = summary_lines .iter() - .map(|skill| { - let status = if skill.available { - "available" - } else { - "unavailable" - }; - let desc = if skill.description.is_empty() { - "No description".to_string() - } else { - skill.description.clone() - }; - format!( - "- **{}** ({}): {} [{}]\n Path: `{}`", - skill.name, - skill.source, - desc, - status, - skill.path.display() - ) - }) - .collect(); + .map(|line| line.chars().count()) + .sum::(); + if rendered_chars > PROVIDER_SKILL_CATALOG_CHAR_BUDGET && !skills.is_empty() { + let overflow = rendered_chars - PROVIDER_SKILL_CATALOG_CHAR_BUDGET; + description_budget = description_budget + .saturating_sub(overflow.div_ceil(skills.len())) + .max(20); + summary_lines = render_lines(description_budget); + rendered_chars = summary_lines + .iter() + .map(|line| line.chars().count()) + .sum::(); + } + if rendered_chars > PROVIDER_SKILL_CATALOG_CHAR_BUDGET { + summary_lines = skills + .iter() + .map(|skill| format!("- **{}** — `{}`", skill.name, skill.path.display())) + .collect(); + } - parts.push(format!( + format!( "## Skills (mandatory)\n\n\ Before replying: scan the skill descriptions below.\n\ - If exactly one skill clearly applies: read its SKILL.md using read_file, then follow it.\n\ @@ -225,9 +278,7 @@ fn build_skills_content( Constraints: never read more than one skill up front; only read after selecting.\n\n\ {}", summary_lines.join("\n") - )); - - parts.join("\n\n---\n\n") + ) } /// Write a rule file with the appropriate wrapper for its format. @@ -332,9 +383,7 @@ fn convention_targets_for_agent(agent: &ModelType, workspace_path: &Path) -> Vec .join("rules") .join("orgii-conventions.md")] } - ModelType::Codex => { - vec![workspace_path.join(".codex").join("orgii-conventions.md")] - } + ModelType::Codex => Vec::new(), ModelType::Copilot => { vec![workspace_path .join(".github") @@ -345,3 +394,44 @@ fn convention_targets_for_agent(agent: &ModelType, workspace_path: &Path) -> Vec _ => Vec::new(), } } + +#[cfg(test)] +mod tests { + use super::build_skills_prompt_injection; + + #[test] + fn provider_skill_catalog_is_stable_bounded_and_keeps_load_paths() { + let workspace = tempfile::tempdir().expect("workspace"); + for index in 0..80 { + let skill_dir = workspace + .path() + .join(".agents/skills") + .join(format!("skill-{index:03}")); + std::fs::create_dir_all(&skill_dir).expect("create skill dir"); + std::fs::write( + skill_dir.join("SKILL.md"), + format!( + "---\nname: skill-{index:03}\ndescription: {}\n---\n\nBody {index}", + "description ".repeat(80) + ), + ) + .expect("write skill"); + } + + let first = build_skills_prompt_injection(workspace.path(), true, &[]) + .expect("provider skill catalog"); + let second = build_skills_prompt_injection(workspace.path(), true, &[]) + .expect("provider skill catalog"); + assert_eq!( + first, second, + "catalog must be byte-stable for digest reuse" + ); + assert!(first.contains("skill-000")); + assert!(first.contains("SKILL.md")); + assert!( + first.chars().count() <= 12_000, + "catalog must stay near its 8k entry budget: {} chars", + first.chars().count() + ); + } +} diff --git a/src-tauri/src/agent_sessions/event_pipeline/commands/turn_window.rs b/src-tauri/src/agent_sessions/event_pipeline/commands/turn_window.rs index 17fdefccb3..48c20071c9 100644 --- a/src-tauri/src/agent_sessions/event_pipeline/commands/turn_window.rs +++ b/src-tauri/src/agent_sessions/event_pipeline/commands/turn_window.rs @@ -295,13 +295,14 @@ pub async fn es_unload_turn_body( let turn = match persisted_turn { Some(turn) => turn, None => { - let orgtrack_turn = crate::orgtrack::history_commands::orgtrack_session_turn_metadata_index( - session_id.clone(), - Some(vec![turn_id.clone()]), - ) - .await? - .into_iter() - .next(); + let orgtrack_turn = + crate::orgtrack::history_commands::orgtrack_session_turn_metadata_index( + session_id.clone(), + Some(vec![turn_id.clone()]), + ) + .await? + .into_iter() + .next(); match orgtrack_turn { Some(turn) => turn, None => { diff --git a/src-tauri/src/agent_sessions/event_pipeline/tests/store_tests.rs b/src-tauri/src/agent_sessions/event_pipeline/tests/store_tests.rs index 5f6f115631..14218dd98a 100644 --- a/src-tauri/src/agent_sessions/event_pipeline/tests/store_tests.rs +++ b/src-tauri/src/agent_sessions/event_pipeline/tests/store_tests.rs @@ -1493,7 +1493,9 @@ fn test_unload_turn_body_missing_turn_is_noop() { assert_eq!(removed, 0); assert!(store.get_by_id("turn-1").is_some()); assert!(store.get_by_id("turn-1-body-1").is_some()); - assert!(store.get_by_id("turn-placeholder-turn-does-not-exist").is_none()); + assert!(store + .get_by_id("turn-placeholder-turn-does-not-exist") + .is_none()); } #[test] diff --git a/src-tauri/src/agent_sessions/session_directory/patch.rs b/src-tauri/src/agent_sessions/session_directory/patch.rs index 3d89b1e6ac..a9fa118a49 100644 --- a/src-tauri/src/agent_sessions/session_directory/patch.rs +++ b/src-tauri/src/agent_sessions/session_directory/patch.rs @@ -21,10 +21,9 @@ //! - `name` set on its own (rename / generated title) //! - `model` set with optional `account_id` (a model-pick is one user //! action; the account binds to the model) -//! - `agent_exec_mode` set on its own (a ModePill click) -//! - both set in one call (the rare "switch model AND mode" case; -//! still atomic at the SQL level via two `UPDATE` rows under one -//! command call). +//! - `product_mode` + derived `agent_exec_mode` set together (a ModePill click) +//! - model and composer fields may share one command for compound UI actions; +//! each logical pair is written atomically by its persistence helper. //! //! Fields that are deliberately *not* exposed: //! @@ -98,12 +97,11 @@ pub struct SessionPatch { /// Account ID associated with the new model. Only meaningful /// alongside `model`; passing it without `model` is rejected. pub account_id: Option, - /// Per-session execution mode. Only legal for `agent_sessions` - /// rows; rejected for CLI sessions. + /// Per-session execution mode. Native and CLI-backed rows both carry it. pub agent_exec_mode: Option, /// Persistent product mode (`orgtrack/v1` §5.2): - /// `build | plan | ask | project`. Only legal for `agent_sessions` - /// rows; validated against the closed enum. + /// `build | plan | ask | project`. Native and CLI-backed rows both carry + /// it; imported history does not. Validated against the closed enum. #[serde(default, skip_serializing_if = "Option::is_none")] pub product_mode: Option, /// Per-session unsent draft text (P3). Three-state — see the @@ -210,6 +208,44 @@ fn validate_account_model_compat(account_id: &str, model: &str) -> Result<(), St Ok(()) } +/// Resolve a user-visible composer selection into the two persisted axes. +/// The product axis is authoritative: Project always derives Build execution; +/// build/plan/ask derive their matching execution policies. A supplied exec +/// value is still validated so malformed wire payloads fail closed. +fn resolve_atomic_mode_axes( + product_mode: Option<&str>, + agent_exec_mode: Option<&str>, +) -> Result, String> { + if let Some(mode) = agent_exec_mode { + agent_core::session::AgentExecMode::parse(mode).ok_or_else(|| { + format!( + "session_patch: unknown agent_exec_mode '{mode}' \ + (expected build|ask|plan|debug|review|wingman)" + ) + })?; + } + + let Some(product_mode) = product_mode else { + return Ok(None); + }; + let derived_exec_mode = match product_mode { + "build" => agent_core::session::AgentExecMode::Build, + "plan" => agent_core::session::AgentExecMode::Plan, + "ask" => agent_core::session::AgentExecMode::Ask, + "project" => agent_core::session::AgentExecMode::Build, + _ => { + return Err(format!( + "session_patch: unknown product_mode '{product_mode}' \ + (expected build|plan|ask|project)" + )) + } + }; + Ok(Some(( + product_mode.to_string(), + derived_exec_mode.as_str().to_string(), + ))) +} + /// Apply a patch synchronously. Public for `#[tauri::command]` /// adapter; tests can also call this directly with an in-memory DB /// once the connection abstraction allows it. @@ -255,8 +291,7 @@ pub fn apply_session_patch(session_id: &str, patch: &SessionPatch) -> Result<(), .map_err(|err| format!("session_patch update name (cli): {err}"))?; } SessionLocation::Imported => { - return Err("session_patch: imported sessions do not support name" - .to_string()); + return Err("session_patch: imported sessions do not support name".to_string()); } } } @@ -280,50 +315,45 @@ pub fn apply_session_patch(session_id: &str, patch: &SessionPatch) -> Result<(), .map_err(|err| format!("session_patch update model (cli): {err}"))?; } SessionLocation::Imported => { - return Err("session_patch: imported sessions do not support model" - .to_string()); + return Err("session_patch: imported sessions do not support model".to_string()); } } } - if let Some(mode) = patch.agent_exec_mode.as_deref() { + let resolved_mode_axes = resolve_atomic_mode_axes( + patch.product_mode.as_deref(), + patch.agent_exec_mode.as_deref(), + )?; + if let Some((product_mode, agent_exec_mode)) = resolved_mode_axes { match location { SessionLocation::Agent => { - session_persistence::update_agent_exec_mode(session_id, mode).map_err(|err| { - format!("session_patch update agent_exec_mode (agent): {err}") - })?; + session_persistence::update_mode_axes(session_id, &product_mode, &agent_exec_mode) + .map_err(|err| format!("session_patch update mode axes (agent): {err}"))?; } SessionLocation::Cli => { - cli_persistence::update_agent_exec_mode(session_id, mode) - .map_err(|err| format!("session_patch update agent_exec_mode (cli): {err}"))?; + cli_persistence::update_mode_axes(session_id, &product_mode, &agent_exec_mode) + .map_err(|err| format!("session_patch update mode axes (cli): {err}"))?; } SessionLocation::Imported => { - return Err("session_patch: imported sessions do not support agent_exec_mode" - .to_string()); + return Err( + "session_patch: imported sessions do not carry composer modes".to_string(), + ); } } - } - - if let Some(product_mode) = patch.product_mode.as_deref() { - // Closed enum (orgtrack/v1 §5.2); a typo must not silently - // grant or drop the Project mutation surface. - if !matches!(product_mode, "build" | "plan" | "ask" | "project") { - return Err(format!( - "session_patch: unknown product_mode '{product_mode}' (expected build|plan|ask|project)" - )); - } + } else if let Some(mode) = patch.agent_exec_mode.as_deref() { match location { SessionLocation::Agent => { - session_persistence::update_product_mode(session_id, product_mode) - .map_err(|err| format!("session_patch update product_mode (agent): {err}"))?; + session_persistence::update_agent_exec_mode(session_id, mode).map_err(|err| { + format!("session_patch update agent_exec_mode (agent): {err}") + })?; } SessionLocation::Cli => { - cli_persistence::update_product_mode(session_id, product_mode) - .map_err(|err| format!("session_patch update product_mode (cli): {err}"))?; + cli_persistence::update_agent_exec_mode(session_id, mode) + .map_err(|err| format!("session_patch update agent_exec_mode (cli): {err}"))?; } SessionLocation::Imported => { return Err( - "session_patch: imported sessions do not carry a product_mode".to_string() + "session_patch: imported sessions do not support agent_exec_mode".to_string(), ); } } @@ -348,8 +378,9 @@ pub fn apply_session_patch(session_id: &str, patch: &SessionPatch) -> Result<(), .map_err(|err| format!("session_patch update draft_text (cli): {err}"))?; } SessionLocation::Imported => { - return Err("session_patch: imported sessions do not support draft_text" - .to_string()); + return Err( + "session_patch: imported sessions do not support draft_text".to_string() + ); } } } @@ -368,8 +399,10 @@ pub fn apply_session_patch(session_id: &str, patch: &SessionPatch) -> Result<(), )?; } SessionLocation::Imported => { - return Err("session_patch: imported sessions do not support reply_target_event_id" - .to_string()); + return Err( + "session_patch: imported sessions do not support reply_target_event_id" + .to_string(), + ); } } } @@ -503,6 +536,24 @@ pub async fn session_patch( mod tests { use super::*; + #[test] + fn project_derives_build_and_ordinary_modes_never_gain_pm_capability() { + assert_eq!( + resolve_atomic_mode_axes(Some("project"), Some("ask")).unwrap(), + Some(("project".to_string(), "build".to_string())) + ); + assert_eq!( + resolve_atomic_mode_axes(Some("build"), Some("build")).unwrap(), + Some(("build".to_string(), "build".to_string())) + ); + assert_eq!( + resolve_atomic_mode_axes(Some("plan"), Some("plan")).unwrap(), + Some(("plan".to_string(), "plan".to_string())) + ); + assert!(resolve_atomic_mode_axes(Some("project-ish"), Some("build")).is_err()); + assert!(resolve_atomic_mode_axes(Some("project"), Some("unrestricted")).is_err()); + } + #[test] fn double_option_distinguishes_absent_null_value() { // Field absent → None → "leave alone" diff --git a/src-tauri/src/api/agent/test/agent_org.rs b/src-tauri/src/api/agent/test/agent_org.rs index d4f241aa18..8820ad53f3 100644 --- a/src-tauri/src/api/agent/test/agent_org.rs +++ b/src-tauri/src/api/agent/test/agent_org.rs @@ -319,6 +319,7 @@ pub async fn test_agent_org_launch_coordinator( worktree_path: None, project_slug: None, parent_session_id: None, + durable_run_id: None, additional_directories: Vec::new(), }; diff --git a/src-tauri/src/api/agent/test/core.rs b/src-tauri/src/api/agent/test/core.rs index d5980f75c3..a8a9d9cd6b 100644 --- a/src-tauri/src/api/agent/test/core.rs +++ b/src-tauri/src/api/agent/test/core.rs @@ -1120,7 +1120,7 @@ async fn debug_work_item_runtime_launch_impl( labels: Vec::new(), milestone: None, parent: None, - stage: None, + stage: None, start_date: None, target_date: None, created_by: Some("e2e".to_string()), diff --git a/src-tauri/src/api/agent/test/workspace.rs b/src-tauri/src/api/agent/test/workspace.rs index d6a55019d7..aa42b26eb2 100644 --- a/src-tauri/src/api/agent/test/workspace.rs +++ b/src-tauri/src/api/agent/test/workspace.rs @@ -303,6 +303,7 @@ pub async fn test_session_launch_seed_only( worktree_path: None, project_slug: None, parent_session_id: None, + durable_run_id: None, additional_directories, }; diff --git a/src-tauri/src/api/websocket_handler.rs b/src-tauri/src/api/websocket_handler.rs index 3c4be5d3f7..9203a8fbff 100644 --- a/src-tauri/src/api/websocket_handler.rs +++ b/src-tauri/src/api/websocket_handler.rs @@ -223,10 +223,16 @@ fn dispatch_to_channels(message: &str) { event_type(message).as_deref(), Some("agent:complete") | Some("agent:error") ) { - tracing::warn!( - "[IPC] no channel registered for session {}; dropping {} — \ - the frontend never subscribed this session's events and \ - its turn will only end via the planning-indicator watchdog", + // Background-dispatched sessions (for example Routine or + // Work Item runs) intentionally have no mounted webview. The + // runtime persists their transcript and terminal state before + // this live notification, so opening the session later + // hydrates the authoritative result without a watchdog. Keep + // a diagnostic for channel-lifecycle investigations without + // misclassifying this expected path as a lost terminal. + tracing::debug!( + "[IPC] no live channel for session {}; skipped {} delivery; \ + durable session state remains authoritative", sid, event_type(message).as_deref().unwrap_or("?") ); diff --git a/src-tauri/src/benchmark/launch.rs b/src-tauri/src/benchmark/launch.rs index 82279a34c1..5b177f24b2 100644 --- a/src-tauri/src/benchmark/launch.rs +++ b/src-tauri/src/benchmark/launch.rs @@ -51,6 +51,7 @@ pub(super) fn benchmark_launch_params( worktree_path: launch.worktree_path.clone(), project_slug: launch.project_slug.clone(), parent_session_id, + durable_run_id: None, additional_directories: launch.additional_directories.clone(), } } diff --git a/src-tauri/src/commands/handler_list.inc b/src-tauri/src/commands/handler_list.inc index db964afa65..916136cfa2 100644 --- a/src-tauri/src/commands/handler_list.inc +++ b/src-tauri/src/commands/handler_list.inc @@ -602,6 +602,28 @@ project_management::projects::commands::project_create_work_item, project_management::projects::commands::work_item_create_standalone, project_management::projects::commands::project_transition_work_item, project_management::projects::commands::project_update_work_item_partial, +project_management::projects::commands::project_enqueue_work_item_run, +project_management::projects::commands::project_list_work_item_runs, +project_management::projects::commands::project_retry_latest_work_item_run, +project_management::work_item_features::project_discussion_preview_trigger, +project_management::work_item_features::project_discussion_post_comment, +project_management::work_item_features::project_discussion_resolve_thread, +project_management::work_item_features::project_discussion_reopen_thread, +project_management::work_item_features::project_subscribe_work_item, +project_management::work_item_features::project_unsubscribe_work_item, +project_management::work_item_features::project_list_work_item_subscriptions, +project_management::work_item_features::project_get_work_item_pr_readiness, +project_management::work_item_features::project_upsert_property_definition, +project_management::work_item_features::project_list_property_definitions, +project_management::work_item_features::project_archive_property_definition, +project_management::work_item_features::project_set_work_item_property_value, +project_management::work_item_features::project_list_work_item_property_values, +project_management::work_item_features::project_routine_webhook_install, +project_management::work_item_features::project_routine_webhook_rotate, +project_management::work_item_features::project_routine_webhook_status, +project_management::work_item_features::project_routine_webhook_set_enabled, +project_management::work_item_features::project_routine_webhook_list_deliveries, +project_management::work_item_features::project_routine_webhook_replay, project_management::projects::commands::work_item_update_standalone_partial, project_management::projects::commands::project_transition_work_item_handoff, project_management::projects::commands::work_item_transition_standalone_handoff, diff --git a/src-tauri/src/infrastructure/cloud_identity.rs b/src-tauri/src/infrastructure/cloud_identity.rs index f44ff31706..052b178995 100644 --- a/src-tauri/src/infrastructure/cloud_identity.rs +++ b/src-tauri/src/infrastructure/cloud_identity.rs @@ -72,13 +72,8 @@ fn ensure_device_id(path: &Path) -> Result { } if path.exists() { - let existing = fs::read_to_string(path).map_err(|err| { - format!( - "Failed to read cloud device id {}: {}", - path.display(), - err - ) - })?; + let existing = fs::read_to_string(path) + .map_err(|err| format!("Failed to read cloud device id {}: {}", path.display(), err))?; let trimmed = existing.trim(); if let Ok(parsed) = Uuid::parse_str(trimmed) { return Ok(parsed.to_string()); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bd42754805..df565436bc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -740,6 +740,12 @@ pub fn run() { "[HousekeeperCompaction] opt-in MiniCPM context worker initialized" ); + // Durable WorkItemRun outbox consumer. This starts before the + // legacy schedulers so every producer can converge on one + // crash-safe delivery path during migration. + agent_core::coordination::work_item_run_dispatcher::spawn(app.handle().clone()); + tracing::info!("[work-run-dispatcher] started"); + // Spawn work item schedule executor { let scheduler_handle = app.handle().clone(); @@ -817,7 +823,26 @@ pub fn run() { let watermark_handle = app.handle().clone(); tauri::async_runtime::spawn(async move { use tauri::Emitter; - let mut last_seq: i64 = -1; + const STAGE_BARRIER_CONSUMER: &str = "stage_barrier_dispatch_v1"; + let initial_seq = tokio::task::spawn_blocking( + project_management::projects::io::read_pm_change_seq, + ) + .await + .ok() + .and_then(Result::ok) + .unwrap_or(0) + .max(0); + let mut last_seq = initial_seq; + let mut stage_cursor = tokio::task::spawn_blocking(move || { + project_management::work_run_service::initialize_consumer_cursor( + STAGE_BARRIER_CONSUMER, + initial_seq, + ) + }) + .await + .ok() + .and_then(Result::ok) + .unwrap_or(initial_seq); loop { tokio::time::sleep(std::time::Duration::from_secs(2)).await; let seq = tokio::task::spawn_blocking( @@ -827,24 +852,45 @@ pub fn run() { .ok() .and_then(Result::ok) .unwrap_or(-1); - if seq >= 0 && last_seq >= 0 && seq != last_seq { + if seq >= 0 && seq != last_seq { let _ = watermark_handle.emit( project_management::projects::events::DATA_CHANGED_EVENT, serde_json::json!({ "source": "pm-watermark" }), ); - // Fold CLI-committed status transitions into the - // child-done wake pipeline; in-process writes were - // already delivered by the terminal notifier and - // dedupe at the barrier level. - let bridge_handle = watermark_handle.clone(); - let after_seq = last_seq; - let _ = tokio::task::spawn_blocking(move || { - agent_core::coordination::child_done_wake::process_audit_window( - &bridge_handle, - after_seq, - ) - }) - .await; + } + if seq > stage_cursor { + match agent_core::coordination::child_done_wake::process_audit_window( + &watermark_handle, + stage_cursor, + ) + .await + { + Ok(_) => { + let through_seq = seq; + match tokio::task::spawn_blocking(move || { + project_management::work_run_service::advance_consumer_cursor( + STAGE_BARRIER_CONSUMER, + through_seq, + ) + }) + .await + { + Ok(Ok(cursor)) => stage_cursor = cursor, + Ok(Err(err)) => tracing::warn!( + "[child-done-wake] cursor advance failed: {}", + err + ), + Err(err) => tracing::warn!( + "[child-done-wake] cursor task failed: {}", + err + ), + } + } + Err(err) => tracing::warn!( + "[child-done-wake] audit window failed: {}", + err + ), + } } if seq >= 0 { last_seq = seq; diff --git a/src-tauri/src/orgtrack/history_commands.rs b/src-tauri/src/orgtrack/history_commands.rs index 4f123f0985..b883bcc7e9 100644 --- a/src-tauri/src/orgtrack/history_commands.rs +++ b/src-tauri/src/orgtrack/history_commands.rs @@ -91,6 +91,9 @@ impl ImportedTurnProjectionCache { .position(|entry| entry.session_id == session_id)?; let entry = self.entries.remove(index)?; if entry.signature != signature { + // A stale caller must miss without evicting the newer projection + // that another reader already cached for this session. + self.entries.push_back(entry); return None; } if entry.quality < min_quality { From fa8869fb5c29e549f22118f5e2bb93ef35416789 Mon Sep 17 00:00:00 2001 From: Neonforge <48338160+Neonforge98@users.noreply.github.com> Date: Sun, 9 Aug 2026 07:13:57 -0700 Subject: [PATCH 3/8] feat(ui): expose durable project workflows and work item controls --- .../WorkItemContent.md | 28 ++ src/api/http/project/client.ts | 217 +++++++++ src/api/http/project/index.ts | 21 + src/api/http/project/types.ts | 2 + src/api/http/project/types/agentWorkflow.ts | 1 + src/api/http/project/types/common.ts | 6 + src/api/http/project/types/index.ts | 2 + .../http/project/types/workItemFeatures.ts | 146 ++++++ src/api/http/project/types/workItems.ts | 7 +- src/api/http/project/types/workRuns.ts | 95 ++++ src/api/tauri/rpc/schemas/sessionAggregate.ts | 7 +- src/config/sessionCreatorConfig.test.ts | 20 + src/config/sessionCreatorConfig.ts | 14 +- src/engines/ChatPanel/ChatView.tsx | 25 +- .../InputArea/components/ModePill.tsx | 32 +- .../components/SessionWorkstationRail.test.ts | 18 + .../components/SessionWorkstationRail.tsx | 16 +- .../hooks/useInputArea/useSlashCommand.ts | 17 +- .../useWorkspaceChat/useMessageDispatch.ts | 10 +- .../useWorkspaceChat/useUserIntentSubmit.ts | 8 +- .../ChatPanel/panels/WorkItemPanelView.tsx | 8 +- .../hooks/session/useQueueDispatch.ts | 9 +- .../ChannelPanelView/useChannelWorkItem.ts | 36 +- src/hooks/session/useSessionPatch.ts | 47 +- .../Routines/Table/RoutinesTable.tsx | 2 +- .../MainApp/TeamInbox/__tests__/api.test.ts | 47 ++ src/modules/MainApp/TeamInbox/api.ts | 47 +- .../WorkManagementProjectsSurface.tsx | 19 +- ...ojectWorkItemsTabContentDataLoader.test.ts | 88 ++++ .../ProjectWorkItemsTabContentDataLoader.ts | 4 +- .../CustomPropertiesSection.tsx | 452 ++++++++++++++++++ .../components/WorkItemContent/HistoryTab.tsx | 222 ++++++++- .../components/WorkItemContent/OutputTab.tsx | 107 ++++- .../components/WorkItemContent/PrSection.tsx | 136 ++++-- .../WorkItemRunUsageSummary.tsx | 199 ++++++++ .../__tests__/HistoryTab.test.ts | 66 +++ .../__tests__/WorkItemRunUsageSummary.test.ts | 63 +++ .../discussionCommentForward.ts | 91 +++- .../hooks/useWorkItemContentState.tsx | 181 ++++++- .../components/WorkItemContent/index.tsx | 58 ++- .../components/WorkItemContent/types.ts | 12 + .../WorkItemDetail/WorkItemDetailBody.tsx | 3 + .../components/WorkItemDetail/index.tsx | 7 +- .../components/WorkItemDetail/types.ts | 1 + .../StandaloneWorkItemDetailPage.tsx | 14 +- .../components/WorkItemDetailPage/types.ts | 1 + .../WorkItems/workItemPartialUpdate.ts | 6 + .../detail/PrCommitsTab.test.ts | 6 +- .../TabContent/renderers/workItemDetail.tsx | 1 + .../__tests__/chatPanelTabsAtom.test.ts | 32 +- src/store/chatPanel/chatPanelTabFactories.ts | 12 +- .../chatPanel/chatPanelTabLifecycleAtoms.ts | 25 +- src/store/chatPanel/chatPanelTabOpenAtoms.ts | 9 +- src/store/session/sessionAtom/types.ts | 7 +- .../tabs/__tests__/tabFactory.test.ts | 23 + .../workstation/tabs/factories/project.ts | 8 +- src/store/workstation/tabs/types.ts | 1 + src/types/core/workItem.ts | 6 + .../__tests__/gifMetadata.test.ts | 16 +- 59 files changed, 2543 insertions(+), 221 deletions(-) create mode 100644 docs/frontend-ui-audit-2026-08-08/WorkItemContent.md create mode 100644 src/api/http/project/types/workItemFeatures.ts create mode 100644 src/api/http/project/types/workRuns.ts create mode 100644 src/config/sessionCreatorConfig.test.ts create mode 100644 src/modules/ProjectManager/ProjectManagerLayout/components/ProjectWorkItemsTabContentDataLoader.test.ts create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemContent/CustomPropertiesSection.tsx create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemContent/WorkItemRunUsageSummary.tsx create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/WorkItemRunUsageSummary.test.ts diff --git a/docs/frontend-ui-audit-2026-08-08/WorkItemContent.md b/docs/frontend-ui-audit-2026-08-08/WorkItemContent.md new file mode 100644 index 0000000000..60d58fda44 --- /dev/null +++ b/docs/frontend-ui-audit-2026-08-08/WorkItemContent.md @@ -0,0 +1,28 @@ +# Work Item Content UI audit + +Scope: the Work Item Discussion, custom properties, subscription, and PR readiness UI changed by `codex/durable-workitem-runs`. + +## Verdict + +- Fix: 6 +- Keep with reason: 3 +- Abstract: 0 + +## Fixed + +1. Typed property controls use the shared `Input`, `Select`, `Checkbox`, `Button`, and `InlineAlert` components. +2. Removed the custom arbitrary grid-template value from property rows in favor of standard flex sizing utilities. +3. Discussion actions are real shared buttons with accessible labels and native keyboard behavior. +4. Resolve, reopen, and reply controls are hidden when their callback is unavailable, so read-only views do not expose dead actions. +5. New user-facing labels use translation keys with English fallback text. +6. Loading, empty, error, resolved, conclusion, and reply states have visible text in addition to icons or color. + +## Kept with reason + +1. Avatar colors remain inline CSS variables because member colors are runtime data, not fixed design tokens. +2. `

+ void onSave(checked)} + > + {value === true + ? t("workItems.properties.yes", { defaultValue: "Yes" }) + : t("workItems.properties.no", { defaultValue: "No" })} + +
+ ); + } + + if ( + property.propertyType === "select" || + property.propertyType === "multi_select" + ) { + const options: SelectOption[] = property.config.options.map((option) => ({ + value: option.id, + label: option.name, + })); + const selectValue = + property.propertyType === "multi_select" + ? Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string") + : [] + : typeof value === "string" + ? value + : undefined; + return ( + + ); +} + +const CustomPropertiesSection: React.FC = ({ + projectSlug, + orgId, + shortId, + editable, +}) => { + const { t } = useTranslation("projects"); + const resolvedOrgId = orgId || "personal-org"; + const scope = useMemo( + () => + shortId + ? { + projectSlug: projectSlug ?? null, + orgId: resolvedOrgId, + workItemId: shortId, + } + : null, + [projectSlug, resolvedOrgId, shortId] + ); + const [definitions, setDefinitions] = useState([]); + const [values, setValues] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [busyPropertyId, setBusyPropertyId] = useState(null); + const [error, setError] = useState(null); + const [showCreate, setShowCreate] = useState(false); + const [draftName, setDraftName] = useState(""); + const [draftType, setDraftType] = useState("text"); + const [draftOptions, setDraftOptions] = useState(""); + + const reload = useCallback(async () => { + if (!scope) { + setDefinitions([]); + setValues([]); + setIsLoading(false); + return; + } + setIsLoading(true); + try { + const [nextDefinitions, nextValues] = await Promise.all([ + projectApi.listPropertyDefinitions(scope.orgId), + projectApi.listWorkItemPropertyValues(scope), + ]); + setDefinitions(nextDefinitions); + setValues(nextValues); + setError(null); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setIsLoading(false); + } + }, [scope]); + + useEffect(() => { + void reload(); + }, [reload]); + + const valuesByPropertyId = useMemo( + () => new Map(values.map((entry) => [entry.definition.id, entry.value])), + [values] + ); + const typeOptions = useMemo( + () => + PROPERTY_TYPES.map((type) => ({ + value: type, + label: type.replace("_", " "), + })), + [] + ); + + const handleSaveValue = useCallback( + async (propertyId: string, value: unknown | null) => { + if (!scope) return; + setBusyPropertyId(propertyId); + try { + await projectApi.setWorkItemPropertyValue(scope, propertyId, value); + await reload(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setBusyPropertyId(null); + } + }, + [reload, scope] + ); + + const handleCreate = useCallback(async () => { + const name = draftName.trim(); + if (!name) return; + const optionNames = draftOptions + .split(",") + .map((option) => option.trim()) + .filter(Boolean); + if ( + (draftType === "select" || draftType === "multi_select") && + optionNames.length === 0 + ) { + setError( + t("workItems.properties.optionsRequired", { + defaultValue: "Select properties require comma-separated options.", + }) + ); + return; + } + setBusyPropertyId("new"); + try { + await projectApi.upsertPropertyDefinition({ + orgId: resolvedOrgId, + name, + propertyType: draftType, + config: { + options: optionNames.map((option, index) => ({ + id: `option_${Date.now()}_${index}`, + name: option, + })), + }, + position: definitions.length, + }); + setDraftName(""); + setDraftOptions(""); + setDraftType("text"); + setShowCreate(false); + await reload(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setBusyPropertyId(null); + } + }, [ + definitions.length, + draftName, + draftOptions, + draftType, + reload, + resolvedOrgId, + t, + ]); + + const handleArchive = useCallback( + async (propertyId: string) => { + setBusyPropertyId(propertyId); + try { + await projectApi.archivePropertyDefinition(propertyId); + await reload(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setBusyPropertyId(null); + } + }, + [reload] + ); + + if (!scope) return null; + + return ( +
+
+

+ {t("workItems.properties.title", { + defaultValue: "Custom properties", + })} +

+ {editable ? ( + + ) : null} +
+ + {error ? ( + + {error} + + ) : null} + + {showCreate ? ( +
+ + + ) : null} +
+ +
+
+ ) : null} + + {isLoading ? ( +

+ {t("workItems.properties.loading", { + defaultValue: "Loading properties…", + })} +

+ ) : definitions.length === 0 ? ( +

+ {t("workItems.properties.empty", { + defaultValue: "No custom properties yet.", + })} +

+ ) : ( +
+ {definitions.map((property) => ( +
+
+

+ {property.name} +

+

+ {property.propertyType.replace("_", " ")} +

+
+
+ handleSaveValue(property.id, value)} + /> +
+ {editable ? ( +
+ ))} +
+ )} +
+ ); +}; + +export default CustomPropertiesSection; diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/HistoryTab.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/HistoryTab.tsx index e544a65628..98748526ae 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/HistoryTab.tsx +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/HistoryTab.tsx @@ -1,4 +1,13 @@ -import { ArrowUp, Bell, BellOff, ChevronRight } from "lucide-react"; +import { + ArrowUp, + Bell, + BellOff, + CheckCircle2, + ChevronRight, + CornerUpLeft, + RotateCcw, + X, +} from "lucide-react"; import React, { useMemo } from "react"; import { useTranslation } from "react-i18next"; @@ -7,14 +16,178 @@ import Button from "@src/components/Button"; import ComposerShell from "@src/components/ComposerShell"; import { COMPOSER_BOTTOM_DOCK_PADDING_CLASS } from "@src/config/composerStackTokens"; import { DETAIL_PANEL_TOKENS } from "@src/config/detailPanelTokens"; +import { MarkdownContent } from "@src/modules/shared/components/ActivityTimeline"; import RichMarkdownEditor from "@src/modules/shared/components/RichMarkdownEditor"; import { ScrollTrailTarget } from "@src/modules/shared/layouts/blocks"; +import type { Person } from "@src/types/core/shared"; +import type { WorkItemComment } from "@src/types/core/workItem"; import { WorkItemActivityTimeline } from "./WorkItemActivityTimeline"; import WorkItemMentionPicker from "./WorkItemMentionPicker"; import { partitionDiscussionTimeline } from "./discussionTimelineModel"; import type { HistoryTabProps } from "./types"; +interface DiscussionThreadsProps { + comments: WorkItemComment[]; + currentUser: Person; + teamMembers: Person[]; + onReply?: (commentId: string | null) => void; + onResolve?: (threadId: string, conclusionCommentId?: string) => void; + onReopen?: (threadId: string) => void; +} + +function commentAuthor( + comment: WorkItemComment, + currentUser: Person, + teamMembers: Person[] +): Person { + return ( + teamMembers.find((member) => member.id === comment.author) ?? + (currentUser.id === comment.author + ? currentUser + : { id: comment.author, name: comment.author }) + ); +} + +const DiscussionThreads: React.FC = ({ + comments, + currentUser, + teamMembers, + onReply, + onResolve, + onReopen, +}) => { + const { t } = useTranslation("projects"); + const roots = comments.filter((comment) => !comment.parent_id); + + return ( +
+ {roots.map((root) => { + const threadId = root.thread_id || root.id; + const replies = comments.filter( + (comment) => comment.id !== root.id && comment.thread_id === threadId + ); + const conclusionId = replies.at(-1)?.id ?? root.id; + const threadComments = [root, ...replies]; + return ( +
+
+
+ + {t("workItems.activity.messageCount", { + defaultValue: `${replies.length + 1} messages`, + count: replies.length + 1, + })} + + {root.resolved_at ? ( + + + {t("workItems.activity.resolved", { + defaultValue: "Resolved", + })} + + ) : null} +
+ {(root.resolved_at && onReopen) || + (!root.resolved_at && onResolve) ? ( + + ) : null} +
+
+ {threadComments.map((comment, index) => { + const author = commentAuthor(comment, currentUser, teamMembers); + return ( +
+
+ + {author.name.charAt(0).toUpperCase()} + + + {author.name} + + {comment.conclusion ? ( + + {t("workItems.activity.conclusion", { + defaultValue: "Conclusion", + })} + + ) : null} + +
+ + {onReply ? ( +
+ +
+ ) : null} +
+ ); + })} +
+
+ ); + })} +
+ ); +}; + const HistoryTab: React.FC = ({ timelineEntries, currentUser, @@ -27,6 +200,11 @@ const HistoryTab: React.FC = ({ teamMembers = [], onCommentSubmit, isSubmittingComment, + comments = [], + replyToCommentId, + onReplyToComment, + onResolveThread, + onReopenThread, presentation = "default", canComment = true, threadNavigation, @@ -75,6 +253,17 @@ const HistoryTab: React.FC = ({ navigationEnabled={isThread} /> ); + const discussionThreads = + comments.length > 0 ? ( + + ) : null; const activityTimeline = ( = ({ {currentUser.name.charAt(0).toUpperCase()}
+ {replyToCommentId ? ( +
+ + {t("workItems.activity.replyingInThread", { + defaultValue: "Replying in thread", + })} + +
+ ) : null} = ({ {threadNavigation} {subscriptionControl}
- {discussionEntries.length > 0 ? ( + {comments.length > 0 ? ( + discussionThreads + ) : discussionEntries.length > 0 ? ( discussionTimeline ) : (
= ({ workItem, repoPath, + projectSlug, + shortId, + orgId, onOpenFileDiff, onOpenFileAtLine, onReviewAllFiles, @@ -28,6 +36,63 @@ const OutputTab: React.FC = ({ workItem.orchestratorState?.current_phase ?? "idle"; const proofOfWork = workItem.proofOfWork; const isLiveSde = phase === "sde"; + const runQueryKey = `${orgId ?? "personal-org"}:${projectSlug ?? "-"}:${shortId ?? "-"}`; + const [runState, setRunState] = useState<{ + key: string; + runs: WorkItemRun[]; + }>({ key: "", runs: [] }); + const runs = useMemo( + () => (shortId && runState.key === runQueryKey ? runState.runs : []), + [runQueryKey, runState, shortId] + ); + const [runRefreshKey, setRunRefreshKey] = useState(0); + useProjectDataChanged(() => setRunRefreshKey((value) => value + 1)); + + useEffect(() => { + if (!shortId) return; + let cancelled = false; + projectApi + .listWorkItemRuns({ projectSlug, orgId, shortId }) + .then((nextRuns) => { + if (!cancelled) setRunState({ key: runQueryKey, runs: nextRuns }); + }) + .catch(() => { + if (!cancelled) setRunState({ key: runQueryKey, runs: [] }); + }); + return () => { + cancelled = true; + }; + }, [ + orgId, + projectSlug, + runQueryKey, + runRefreshKey, + shortId, + workItem.updated_time, + ]); + + const runUsage = useMemo( + () => + runs.reduce( + (total, run) => ({ + inputTokens: total.inputTokens + run.usage.inputTokens, + outputTokens: total.outputTokens + run.usage.outputTokens, + totalTokens: total.totalTokens + run.usage.totalTokens, + costUsd: total.costUsd + run.usage.costUsd, + }), + { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 } + ), + [runs] + ); + const displayedUsage = + runs.length > 0 + ? runUsage + : { + inputTokens: 0, + outputTokens: 0, + totalTokens: proofOfWork?.total_tokens ?? 0, + costUsd: proofOfWork?.total_cost_usd ?? 0, + }; const liveDiffStats = useLiveDiffStats({ sessionId: workItem.session_id, @@ -57,6 +122,9 @@ const OutputTab: React.FC = ({ phase={phase} autoCreatePr={workItem.orchestratorConfig?.auto_create_pr ?? true} onCreatePr={onCreatePr} + projectSlug={projectSlug} + orgId={orgId} + shortId={shortId} /> @@ -105,20 +173,29 @@ const OutputTab: React.FC = ({ )} - {proofOfWork && - (proofOfWork.total_cost_usd > 0 || proofOfWork.total_tokens > 0) && ( - 0 || displayedUsage.totalTokens > 0) && ( + +
-
- {t("workItems.outputTab.totalCost")}: $ - {proofOfWork.total_cost_usd.toFixed(4)} ·{" "} - {proofOfWork.total_tokens.toLocaleString()}{" "} - {t("workItems.outputTab.tokens")} -
- - )} + {t("workItems.outputTab.totalCost")}: $ + {displayedUsage.costUsd.toFixed(4)} ·{" "} + {displayedUsage.totalTokens.toLocaleString()}{" "} + {t("workItems.outputTab.tokens")} + {runs.length > 0 && ( + + · {runs.length} runs ·{" "} + {displayedUsage.inputTokens.toLocaleString()} in ·{" "} + {displayedUsage.outputTokens.toLocaleString()} out + + )} +
+
+ )} ); }; diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/PrSection.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/PrSection.tsx index 94eb1c7c93..1b65edff72 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/PrSection.tsx +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/PrSection.tsx @@ -2,6 +2,11 @@ import { GitPullRequest, Loader2, SquareArrowOutUpRight } from "lucide-react"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { + type PrReadiness, + type PrStatus, + projectApi, +} from "@src/api/http/project"; import Button from "@src/components/Button"; import InlineAlert from "@src/components/InlineAlert"; import PrStatusBadge from "@src/components/PrStatusBadge"; @@ -15,16 +20,88 @@ const PrSection: React.FC = ({ phase, autoCreatePr, onCreatePr, + projectSlug, + orgId, + shortId, }) => { const { t } = useTranslation("projects"); const [prState, setPrState] = useState("idle"); const [errorMessage, setErrorMessage] = useState(null); + const readinessKey = `${projectSlug ?? "standalone"}:${orgId ?? "personal-org"}:${shortId ?? "none"}`; + const [readinessState, setReadinessState] = useState<{ + key: string; + value: PrReadiness | null; + } | null>(null); + const readiness = + readinessState?.key === readinessKey ? readinessState.value : null; const autoTriggeredRef = useRef(false); const isRunning = phase === "sde" || phase === "review"; const isFinished = phase === "completed" || phase === "failed"; const readyToCreate = isFinished && !!branch && !prUrl; + useEffect(() => { + if (!shortId) { + return; + } + let cancelled = false; + projectApi + .getWorkItemPrReadiness({ + projectSlug: projectSlug ?? null, + orgId: orgId || "personal-org", + workItemId: shortId, + }) + .then((result) => { + if (!cancelled) setReadinessState({ key: readinessKey, value: result }); + }) + .catch(() => { + if (!cancelled) setReadinessState({ key: readinessKey, value: null }); + }); + return () => { + cancelled = true; + }; + }, [ + branch, + orgId, + phase, + prStatus, + prUrl, + projectSlug, + readinessKey, + shortId, + ]); + + const displayPrUrl = prUrl ?? readiness?.prUrl ?? undefined; + const displayPrStatus = prStatus ?? readiness?.prStatus ?? undefined; + const readinessAlert = + readiness && readiness.prUrl ? ( + + {readiness.blockers.length > 0 ? ( +
    + {readiness.blockers.map((blocker) => ( +
  • {blocker}
  • + ))} +
+ ) : ( +

+ Merge state, checks, execution snapshot, and close intent are + verified. +

+ )} +
+ ) : null; + const handleCreate = useCallback(async () => { if (!onCreatePr) return; setPrState("creating"); @@ -57,38 +134,41 @@ const PrSection: React.FC = ({ } }, [readyToCreate]); - if (prUrl) { + if (displayPrUrl) { return ( -
-
-
- - - {prUrl.replace(/^https?:\/\/[^/]+\//, "")} - -
- {prStatus && ( - - )} - {branch && ( - {branch} - )} +
+
+
+
+ + + {displayPrUrl.replace(/^https?:\/\/[^/]+\//, "")} + +
+ {displayPrStatus && ( + + )} + {branch && ( + {branch} + )} +
+ {readinessAlert}
); } diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/WorkItemRunUsageSummary.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/WorkItemRunUsageSummary.tsx new file mode 100644 index 0000000000..d50ddbfdd3 --- /dev/null +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/WorkItemRunUsageSummary.tsx @@ -0,0 +1,199 @@ +import { Repeat } from "lucide-react"; +import React, { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { type WorkItemRun, projectApi } from "@src/api/http/project"; +import { useProjectDataChanged } from "@src/hooks/project"; +import { + formatTokensShort, + formatUsd, +} from "@src/modules/shared/dataSource/usageFormat"; +import { + ScrollTrailTarget, + SessionTable, + type SessionTableItem, +} from "@src/modules/shared/layouts/blocks"; +import { + formatReplayDateLabel, + toIntlLocaleTag, +} from "@src/util/data/formatters/date"; + +interface WorkItemRunUsageSummaryProps { + projectSlug?: string | null; + orgId?: string | null; + shortId?: string | null; + navigationEnabled?: boolean; + onOpenSession?: (sessionId: string) => void; +} + +const RUN_STATUS_COLOR: Record = { + queued: "var(--color-fill-4)", + deferred: "var(--color-warning-6)", + dispatching: "var(--color-primary-5)", + running: "var(--color-primary-6)", + waiting: "var(--color-warning-6)", + succeeded: "var(--color-success-6)", + failed: "var(--color-danger-6)", + cancelled: "var(--color-warning-6)", +}; + +export function summarizeWorkItemRuns(runs: WorkItemRun[]) { + return runs.reduce( + (total, run) => ({ + inputTokens: total.inputTokens + run.usage.inputTokens, + outputTokens: total.outputTokens + run.usage.outputTokens, + cacheReadTokens: total.cacheReadTokens + run.usage.cacheReadTokens, + cacheWriteTokens: total.cacheWriteTokens + run.usage.cacheWriteTokens, + totalTokens: total.totalTokens + run.usage.totalTokens, + costUsd: total.costUsd + run.usage.costUsd, + }), + { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens: 0, + costUsd: 0, + } + ); +} + +const triggerLabel = (run: WorkItemRun) => + run.trigger.kind + .split("_") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); + +const WorkItemRunUsageSummary: React.FC = ({ + projectSlug, + orgId, + shortId, + navigationEnabled = false, + onOpenSession, +}) => { + const { t, i18n } = useTranslation(["projects", "common"]); + const queryKey = `${orgId ?? "personal-org"}:${projectSlug ?? "-"}:${shortId ?? "-"}`; + const [runState, setRunState] = useState<{ + key: string; + runs: WorkItemRun[]; + }>({ key: "", runs: [] }); + const [refreshKey, setRefreshKey] = useState(0); + useProjectDataChanged(() => setRefreshKey((value) => value + 1)); + + useEffect(() => { + if (!shortId) return; + let cancelled = false; + projectApi + .listWorkItemRuns({ projectSlug, orgId, shortId }) + .then((runs) => { + if (!cancelled) setRunState({ key: queryKey, runs }); + }) + .catch(() => { + if (!cancelled) setRunState({ key: queryKey, runs: [] }); + }); + return () => { + cancelled = true; + }; + }, [orgId, projectSlug, queryKey, refreshKey, shortId]); + + const runs = useMemo( + () => (runState.key === queryKey ? runState.runs : []), + [queryKey, runState] + ); + const usage = useMemo(() => summarizeWorkItemRuns(runs), [runs]); + const dateOptions = useMemo( + () => ({ + todayLabel: t("common:relativeDate.today"), + yesterdayLabel: t("common:relativeDate.yesterday"), + locale: toIntlLocaleTag(i18n.resolvedLanguage), + }), + [i18n.resolvedLanguage, t] + ); + const tableItems = useMemo( + () => + runs.map((run) => ({ + id: run.id, + title: triggerLabel(run), + description: run.sessionId ?? run.id, + statusLabel: run.status, + statusColor: RUN_STATUS_COLOR[run.status], + agentIcon: , + agentLabel: "Run", + modelLabel: run.trigger.kind, + tokensLabel: + run.usage.totalTokens > 0 + ? formatTokensShort(run.usage.totalTokens) + : undefined, + tokensValue: + run.usage.totalTokens > 0 ? run.usage.totalTokens : undefined, + startedLabel: formatReplayDateLabel(run.startedAt ?? run.createdAt, { + ...dateOptions, + withSeconds: false, + monthStyle: "short", + }), + lastUpdatedLabel: formatReplayDateLabel( + run.completedAt ?? run.updatedAt, + { + ...dateOptions, + withSeconds: false, + monthStyle: "short", + } + ), + disabled: !run.sessionId || !onOpenSession, + testId: `work-item-run-${run.id}`, + })), + [dateOptions, onOpenSession, runs] + ); + + if (runs.length === 0) return null; + + return ( + +
+
+ + {runs.length} {runs.length === 1 ? "run" : "runs"} + + {formatTokensShort(usage.totalTokens)} tokens + {usage.inputTokens.toLocaleString()} in + {usage.outputTokens.toLocaleString()} out + {usage.cacheReadTokens > 0 ? ( + {usage.cacheReadTokens.toLocaleString()} cache read + ) : null} + {usage.cacheWriteTokens > 0 ? ( + {usage.cacheWriteTokens.toLocaleString()} cache write + ) : null} + {usage.costUsd > 0 ? ( + {formatUsd(usage.costUsd, 4)} + ) : null} +
+ { + const sessionId = runs.find((run) => run.id === item.id)?.sessionId; + if (sessionId) onOpenSession?.(sessionId); + }} + showSearch={false} + maxHeight={320} + columnVisibility={{ + agent: false, + model: true, + workspace: false, + impact: false, + filesChanged: false, + relatedCommits: false, + committedRate: false, + tokens: true, + started: true, + lastUpdated: true, + }} + /> +
+
+ ); +}; + +export default WorkItemRunUsageSummary; diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/HistoryTab.test.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/HistoryTab.test.ts index 72aeb904c5..261870b3d8 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/HistoryTab.test.ts +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/HistoryTab.test.ts @@ -211,6 +211,72 @@ describe("HistoryTab discussion and activity presentation", () => { ).not.toBeNull(); }); + it("renders persisted reply threads with resolve, reopen, and reply actions", () => { + const onReplyToComment = vi.fn(); + const onResolveThread = vi.fn(); + const onReopenThread = vi.fn(); + act(() => { + root.render( + createElement(HistoryTab, { + ...baseProps, + timelineEntries: [], + presentation: "thread", + comments: [ + { + id: "comment-root", + author: "user-1", + content: "Please show the proof.", + created_at: "2026-08-08T10:00:00.000Z", + thread_id: "comment-root", + }, + { + id: "comment-reply", + author: "user-1", + content: "Proof attached.", + created_at: "2026-08-08T10:01:00.000Z", + parent_id: "comment-root", + thread_id: "comment-root", + }, + ], + replyToCommentId: "comment-root", + onReplyToComment, + onResolveThread, + onReopenThread, + }) + ); + }); + + expect( + container.querySelector( + "[data-testid='work-item-discussion-thread-comment-root']" + )?.textContent + ).toContain("Proof attached."); + expect( + container.querySelector( + "[data-testid='work-item-discussion-reply-context']" + ) + ).not.toBeNull(); + + act(() => { + ( + container.querySelector( + "[data-testid='work-item-discussion-reply-comment-reply']" + ) as HTMLButtonElement + ).click(); + ( + container.querySelector( + "[data-testid='work-item-discussion-resolve-comment-root']" + ) as HTMLButtonElement + ).click(); + }); + expect(onReplyToComment).toHaveBeenCalledWith("comment-reply"); + expect(onResolveThread).toHaveBeenCalledWith( + "comment-root", + "comment-reply" + ); + expect(onReopenThread).not.toHaveBeenCalled(); + }); + it("keeps the full editor treatment in the default presentation", () => { renderHistory(); diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/WorkItemRunUsageSummary.test.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/WorkItemRunUsageSummary.test.ts new file mode 100644 index 0000000000..75d770abb3 --- /dev/null +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/WorkItemRunUsageSummary.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; + +import type { WorkItemRun } from "@src/api/http/project"; + +import { summarizeWorkItemRuns } from "../WorkItemRunUsageSummary"; + +const run = (overrides: Partial): WorkItemRun => ({ + id: crypto.randomUUID(), + orgId: "org-1", + workItemId: "WI-0001", + trigger: { kind: "manual" }, + targetSnapshot: { + target: { kind: "resume_session", sessionId: "session-1" }, + workItemRevision: 1, + }, + input: {}, + status: "succeeded", + attempt: 1, + maxAttempts: 3, + usage: { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens: 0, + costUsd: 0, + ...overrides, + }, + idempotencyKey: crypto.randomUUID(), + createdAt: "2026-08-09T00:00:00Z", + updatedAt: "2026-08-09T00:00:01Z", +}); + +describe("summarizeWorkItemRuns", () => { + it("keeps billable and cache token dimensions visible", () => { + expect( + summarizeWorkItemRuns([ + run({ + inputTokens: 4, + outputTokens: 973, + cacheReadTokens: 34_458, + cacheWriteTokens: 7_687, + totalTokens: 977, + }), + run({ + inputTokens: 10, + outputTokens: 20, + cacheReadTokens: 30, + cacheWriteTokens: 40, + totalTokens: 30, + costUsd: 0.25, + }), + ]) + ).toEqual({ + inputTokens: 14, + outputTokens: 993, + cacheReadTokens: 34_488, + cacheWriteTokens: 7_727, + totalTokens: 1_007, + costUsd: 0.25, + }); + }); +}); diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/discussionCommentForward.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/discussionCommentForward.ts index dd797732bf..a220a53c05 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/discussionCommentForward.ts +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/discussionCommentForward.ts @@ -9,6 +9,7 @@ * the CLI, never through this UI submit path, so forwarding cannot * recurse. */ +import { projectApi } from "@src/api/http/project"; import type { LinkedSession } from "@src/api/http/project/types/agentWorkflow"; import { SessionService } from "@src/engines/SessionCore/services/SessionService"; import { createLogger } from "@src/hooks/logger"; @@ -60,45 +61,77 @@ export function buildDiscussionForwardMessage({ * remaining work and deliver through org2-pm. Fire-and-forget; failures * (session gone, other device) only log. */ -export function retryFailedLinkedSession({ +export async function retryFailedLinkedSession({ + projectSlug, + orgId, shortId, sessionId, }: { + projectSlug?: string | null; + orgId?: string | null; shortId: string; sessionId: string; -}): void { +}): Promise { if (!shortId || !sessionId) return; + try { + await projectApi.retryLatestWorkItemRun({ + projectSlug, + orgId, + shortId, + sessionId, + idempotencyKey: `retry:${shortId}:${sessionId}:${crypto.randomUUID()}`, + }); + return; + } catch (error) { + if (!String(error).includes("PM_RUN_ERR:NOT_FOUND")) { + logger.warn(`Typed retry for ${sessionId} rejected: ${String(error)}`); + return; + } + } + + // Migration fallback for linked Sessions created before WorkItemRun + // persistence existed. const content = [ `[Retry] The previous run on ${shortId} did not finish successfully.`, "", `Re-read the item with \`org2-pm work show ${shortId}\`, finish the remaining work,`, "and deliver through org2-pm with exactly one Discussion receipt.", ].join("\n"); - void SessionService.sendMessage({ - sessionId, - content, - displayText: `↻ Retry ${shortId}`, - turnIntentSource: "user_submit", - }).catch((error) => { + try { + await SessionService.sendMessage({ + sessionId, + content, + displayText: `↻ Retry ${shortId}`, + turnIntentSource: "user_submit", + }); + } catch (error) { logger.warn(`Retry forward to ${sessionId} failed: ${String(error)}`); - }); + } } /** - * Fire-and-forget forward. Failures (session busy, session not on this - * device) only log — the comment itself is already durably on the item. + * Persist a durable reply Run. Delivery is owned by the backend outbox, so + * quitting the app after commenting cannot lose or duplicate the turn. */ -export function forwardDiscussionCommentToLinkedSession({ +export async function forwardDiscussionCommentToLinkedSession({ + projectSlug, + orgId, shortId, + commentId, + authorId, author, comment, linkedSessions, }: { + projectSlug?: string | null; + orgId?: string | null; shortId: string; + commentId: string; + authorId?: string | null; author: string; comment: string; linkedSessions: LinkedSession[] | undefined; -}): void { +}): Promise { const target = pickForwardTargetSession(linkedSessions); if (!target || !shortId) return; const { content, displayText } = buildDiscussionForwardMessage({ @@ -106,14 +139,30 @@ export function forwardDiscussionCommentToLinkedSession({ author, comment, }); - void SessionService.sendMessage({ - sessionId: target.session_id, - content, - displayText, - turnIntentSource: "user_submit", - }).catch((error) => { + try { + await projectApi.enqueueWorkItemRun({ + projectSlug: projectSlug ?? null, + orgId: orgId || "personal-org", + workItemId: shortId, + trigger: { + kind: "discussion_comment", + commentId, + authorId: authorId ?? null, + }, + targetSnapshot: { + target: { + kind: "resume_session", + sessionId: target.session_id, + }, + workItemRevision: 0, + }, + input: { content, displayText }, + idempotencyKey: `discussion-comment:${commentId}`, + maxAttempts: 3, + }); + } catch (error) { logger.warn( - `Discussion forward to ${target.session_id} failed: ${String(error)}` + `Discussion Run for ${target.session_id} failed: ${String(error)}` ); - }); + } } diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/hooks/useWorkItemContentState.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/hooks/useWorkItemContentState.tsx index bd93308ce9..6f374b6286 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/hooks/useWorkItemContentState.tsx +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/hooks/useWorkItemContentState.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import { projectApi } from "@src/api/http/project"; import type { TabPillItem } from "@src/components/TabPill"; import { createLogger } from "@src/hooks/logger"; import { useCurrentUserMemberIds } from "@src/hooks/project/useCurrentUserMemberId"; @@ -11,10 +12,10 @@ import { import type { Person } from "@src/types/core/shared"; import type { TodoItem, + WorkItemComment, WorkItem as WorkItemExtended, } from "@src/types/core/workItem"; -import { forwardDiscussionCommentToLinkedSession } from "../discussionCommentForward"; import { SESSION_TAB_KEYS, type SessionTab } from "../types"; import { useWorkItemTimeline } from "../useWorkItemTimeline"; import { normalizeWorkItemMentionIds } from "../workItemMentions"; @@ -29,6 +30,7 @@ interface UseWorkItemContentStateOptions { teamMembers?: Person[]; projectSlug?: string | null; shortId?: string | null; + orgId?: string | null; } export function useWorkItemContentState( @@ -42,6 +44,7 @@ export function useWorkItemContentState( teamMembers = [], projectSlug, shortId, + orgId, } = options; const { t } = useTranslation("projects"); @@ -69,12 +72,42 @@ export function useWorkItemContentState( const [activeSessionTab, setActiveSessionTab] = useState("session"); const [commentText, setCommentText] = useState(""); + const [replyToCommentId, setReplyToCommentId] = useState(null); const [mentionedUserIds, setMentionedUserIds] = useState([]); - const [isSubscribed, setIsSubscribed] = useState(true); + const [isSubscribed, setIsSubscribed] = useState(false); const [isSubmittingComment, setIsSubmittingComment] = useState(false); const currentPhase = workItem.orchestratorState?.current_phase ?? "idle"; const isAgentRunning = currentPhase === "sde" || currentPhase === "review"; + const scopedShortId = shortId ?? workItem.shortId ?? ""; + + useEffect(() => { + if (!scopedShortId || !currentUser.id) return; + let cancelled = false; + projectApi + .listWorkItemSubscriptions({ + projectSlug: projectSlug ?? null, + orgId: orgId || "personal-org", + workItemId: scopedShortId, + }) + .then((subscriptions) => { + if (!cancelled) { + setIsSubscribed( + subscriptions.some( + (subscription) => + subscription.subscriberId === currentUser.id && + !subscription.mutedAt + ) + ); + } + }) + .catch((error) => + logger.warn("Failed to read Work Item subscriptions", error) + ); + return () => { + cancelled = true; + }; + }, [currentUser.id, orgId, projectSlug, scopedShortId]); const sessionTabItems: TabPillItem[] = useMemo( () => @@ -192,32 +225,33 @@ export function useWorkItemContentState( ); const handleCommentSubmit = useCallback(async () => { - if (!commentText.trim() || isSubmittingComment) return; + if (!scopedShortId || !commentText.trim() || isSubmittingComment) return; setIsSubmittingComment(true); try { - const newComment = { - id: `cmt-${Date.now()}`, - author: currentUser.id, + const mentioned = normalizeWorkItemMentionIds( + mentionedUserIds, + teamMembers, + currentUser.id + ); + const result = await projectApi.postDiscussionComment({ + projectSlug: projectSlug ?? null, + orgId: orgId || "personal-org", + workItemId: scopedShortId, + commentId: `cmt-${Date.now()}-${crypto.randomUUID()}`, + authorId: currentUser.id, + authorName: currentUser.name ?? currentUser.id, content: commentText.trim(), - created_at: new Date().toISOString(), - mentioned_user_ids: normalizeWorkItemMentionIds( - mentionedUserIds, - teamMembers, - currentUser.id - ), - }; - onUpdateWorkItem?.({ - comments: [...(workItem.comments ?? []), newComment], - } as Partial); - forwardDiscussionCommentToLinkedSession({ - shortId: shortId ?? workItem.shortId ?? "", - author: currentUser.name ?? currentUser.id, - comment: newComment.content, - linkedSessions: workItem.linkedSessions, + mentionedUserIds: mentioned, + parentId: replyToCommentId, }); + setIsSubscribed(true); setCommentText(""); + setReplyToCommentId(null); setMentionedUserIds([]); + logger.debug( + `Persisted Discussion comment ${result.comment.id} (${result.wakeReason})` + ); } catch (err) { logger.error("Failed to create comment", err); } finally { @@ -226,15 +260,108 @@ export function useWorkItemContentState( }, [ commentText, isSubmittingComment, - workItem, + scopedShortId, currentUser.id, currentUser.name, mentionedUserIds, teamMembers, - onUpdateWorkItem, - shortId, + orgId, + projectSlug, + replyToCommentId, ]); + const handleResolveDiscussionThread = useCallback( + async (threadId: string, conclusionCommentId?: string) => { + if (!scopedShortId || !currentUser.id) return; + try { + const comments = await projectApi.resolveDiscussionThread({ + scope: { + projectSlug: projectSlug ?? null, + orgId: orgId || "personal-org", + workItemId: scopedShortId, + }, + threadId, + actorId: currentUser.id, + conclusionCommentId: conclusionCommentId ?? null, + }); + const nextComments = comments as WorkItemComment[]; + if (onUpdateWorkItemImmediate) { + onUpdateWorkItemImmediate({ comments: nextComments }); + } else { + onUpdateWorkItem?.({ comments: nextComments }); + } + } catch (error) { + logger.error("Failed to resolve Discussion thread", error); + } + }, + [ + currentUser.id, + onUpdateWorkItem, + onUpdateWorkItemImmediate, + orgId, + projectSlug, + scopedShortId, + ] + ); + + const handleReopenDiscussionThread = useCallback( + async (threadId: string) => { + if (!scopedShortId || !currentUser.id) return; + try { + const comments = await projectApi.reopenDiscussionThread({ + scope: { + projectSlug: projectSlug ?? null, + orgId: orgId || "personal-org", + workItemId: scopedShortId, + }, + threadId, + actorId: currentUser.id, + }); + const nextComments = comments as WorkItemComment[]; + if (onUpdateWorkItemImmediate) { + onUpdateWorkItemImmediate({ comments: nextComments }); + } else { + onUpdateWorkItem?.({ comments: nextComments }); + } + } catch (error) { + logger.error("Failed to reopen Discussion thread", error); + } + }, + [ + currentUser.id, + onUpdateWorkItem, + onUpdateWorkItemImmediate, + orgId, + projectSlug, + scopedShortId, + ] + ); + + const handleToggleSubscription = useCallback(async () => { + if (!scopedShortId || !currentUser.id) return; + const next = !isSubscribed; + try { + const subscriptions = await projectApi.setWorkItemSubscribed( + { + projectSlug: projectSlug ?? null, + orgId: orgId || "personal-org", + workItemId: scopedShortId, + }, + currentUser.id, + next + ); + setIsSubscribed( + subscriptions.some( + (subscription) => + subscription.subscriberId === currentUser.id && + !subscription.mutedAt + ) + ); + } catch (error) { + logger.error("Failed to update Work Item subscription", error); + } + }, [currentUser.id, isSubscribed, orgId, projectSlug, scopedShortId]); + return { currentUser, currentUserMemberIds, @@ -242,10 +369,12 @@ export function useWorkItemContentState( setActiveSessionTab, commentText, setCommentText, + replyToCommentId, + setReplyToCommentId, mentionedUserIds, setMentionedUserIds, isSubscribed, - setIsSubscribed, + handleToggleSubscription, isSubmittingComment, currentPhase, isAgentRunning, @@ -257,5 +386,7 @@ export function useWorkItemContentState( handleDescriptionChange, handleTodosChange, handleCommentSubmit, + handleResolveDiscussionThread, + handleReopenDiscussionThread, }; } diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/index.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/index.tsx index 87a1586f4b..1b989d942c 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/index.tsx +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/index.tsx @@ -46,11 +46,13 @@ import { type WorkItemThreadView, WorkItemThreadViewAction, } from "../WorkItemThread"; +import CustomPropertiesSection from "./CustomPropertiesSection"; import GitHubIssueComposer from "./GitHubIssueComposer"; import HistoryTab from "./HistoryTab"; import OutputTab from "./OutputTab"; import ThreadTodoChecklist from "./ThreadTodoChecklist"; import WorkItemHandoffNotice from "./WorkItemHandoffNotice"; +import WorkItemRunUsageSummary from "./WorkItemRunUsageSummary"; import { normalizeLegacyEscapedMarkdown } from "./descriptionMarkdown"; import { retryFailedLinkedSession } from "./discussionCommentForward"; import { useGitHubIssueTimeline } from "./hooks/useGitHubIssueTimeline"; @@ -61,6 +63,8 @@ import type { SessionTab, WorkItemContentProps } from "./types"; interface LinkedSessionsListProps { sessions: LinkedSession[]; shortId?: string | null; + projectSlug?: string | null; + orgId?: string | null; activeAgentSessionId?: string | null; onOpenSession?: (sessionId: string) => void; } @@ -81,6 +85,8 @@ function getLinkedSessionTitle(session: LinkedSession): string { const LinkedSessionsList: React.FC = ({ sessions, shortId, + projectSlug, + orgId, activeAgentSessionId, onOpenSession, }) => { @@ -163,6 +169,8 @@ const LinkedSessionsList: React.FC = ({ className="flex cursor-pointer items-center gap-1 rounded-md px-1.5 py-1 text-[11px] text-text-3 transition-colors hover:bg-fill-2 hover:text-text-1" onClick={() => { retryFailedLinkedSession({ + projectSlug, + orgId, shortId, sessionId: session.session_id, }); @@ -183,6 +191,8 @@ const LinkedSessionsList: React.FC = ({ activeAgentSessionId, dateTimeLabelOptions, onOpenSession, + orgId, + projectSlug, sessions, shortId, t, @@ -276,10 +286,12 @@ const WorkItemContent: React.FC = ({ setActiveSessionTab, commentText, setCommentText, + replyToCommentId, + setReplyToCommentId, mentionedUserIds, setMentionedUserIds, isSubscribed, - setIsSubscribed, + handleToggleSubscription, isSubmittingComment, sessionTabItems, resolvedDescription, @@ -289,6 +301,8 @@ const WorkItemContent: React.FC = ({ handleDescriptionChange, handleTodosChange, handleCommentSubmit, + handleResolveDiscussionThread, + handleReopenDiscussionThread, } = useWorkItemContentState({ workItem, onUpdateWorkItem, @@ -297,6 +311,7 @@ const WorkItemContent: React.FC = ({ teamMembers, projectSlug, shortId, + orgId, }); const creatorName = @@ -687,10 +702,29 @@ const WorkItemContent: React.FC = ({ ); + const customPropertiesSection = !isGitHubWorkItem ? ( + + + + ) : null; + const outputContent = ( = ({ timelineEntries={timelineEntries} currentUser={currentUser} isSubscribed={isSubscribed} - onToggleSubscribe={() => setIsSubscribed(!isSubscribed)} + onToggleSubscribe={handleToggleSubscription} commentText={commentText} onCommentTextChange={setCommentText} mentionedUserIds={mentionedUserIds} @@ -717,6 +751,11 @@ const WorkItemContent: React.FC = ({ teamMembers={teamMembers} onCommentSubmit={handleCommentSubmit} isSubmittingComment={isSubmittingComment} + comments={workItem.comments ?? []} + replyToCommentId={replyToCommentId} + onReplyToComment={setReplyToCommentId} + onResolveThread={handleResolveDiscussionThread} + onReopenThread={handleReopenDiscussionThread} presentation={presentation} canComment={Boolean(onUpdateWorkItem)} threadNavigation={ @@ -753,6 +792,8 @@ const WorkItemContent: React.FC = ({ @@ -766,6 +807,15 @@ const WorkItemContent: React.FC = ({ const threadLowerSection = ( <> + {!isGitHubWorkItem && !sectionPolicy.showInlineOutput ? ( + + ) : null} {(workItem.linkedSessions?.length ?? 0) > 0 ? ( = ({ @@ -813,6 +865,7 @@ const WorkItemContent: React.FC = ({ {todosSection} + {customPropertiesSection} {subItemsSection} {threadLowerSection} {!isGitHubWorkItem ? ( @@ -862,6 +915,7 @@ const WorkItemContent: React.FC = ({ todosContent={todosSection} lowerContent={ <> + {customPropertiesSection} {subItemsSection} {sectionPolicy.showTabbedLowerSection ? tabbedLowerSection diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/types.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/types.ts index c9746804b6..e3f3d4f9b2 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/types.ts +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/types.ts @@ -14,6 +14,7 @@ import type { } from "@src/api/tauri/github"; import type { Person } from "@src/types/core/shared"; import type { WorkItem as WorkItemExtended } from "@src/types/core/workItem"; +import type { WorkItemComment } from "@src/types/core/workItem"; import type { WorkItemContentPresentation } from "./presentation"; @@ -107,6 +108,9 @@ export interface GitHubIssueInteractionConfig { export interface OutputTabContentProps { workItem: WorkItemExtended; repoPath?: string | null; + projectSlug?: string | null; + shortId?: string | null; + orgId?: string | null; onOpenFileDiff?: (filePath: string) => void; onOpenFileAtLine?: (filePath: string, line?: number) => void; onReviewAllFiles?: (filePaths: string[]) => void; @@ -125,6 +129,9 @@ export interface PrSectionProps { phase: OrchestratorPhase; autoCreatePr: boolean; onCreatePr?: () => Promise<{ url?: string; error?: string }>; + projectSlug?: string | null; + orgId?: string | null; + shortId?: string | null; } export type PrCreationState = "idle" | "creating" | "error"; @@ -141,6 +148,11 @@ export interface HistoryTabProps { teamMembers?: Person[]; onCommentSubmit: () => void; isSubmittingComment: boolean; + comments?: WorkItemComment[]; + replyToCommentId?: string | null; + onReplyToComment?: (commentId: string | null) => void; + onResolveThread?: (threadId: string, conclusionCommentId?: string) => void; + onReopenThread?: (threadId: string) => void; presentation?: WorkItemContentPresentation; canComment?: boolean; threadNavigation?: ReactNode; diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemDetail/WorkItemDetailBody.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemDetail/WorkItemDetailBody.tsx index f9fbe108c1..a7cabe9c7f 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemDetail/WorkItemDetailBody.tsx +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemDetail/WorkItemDetailBody.tsx @@ -38,6 +38,7 @@ interface WorkItemDetailBodyProps { showTime: boolean; repoPath?: string | null; projectSlug?: string | null; + orgId?: string | null; shortId?: string | null; activeAgentSessionId?: string | null; onOpenSubItem?: (item: WorkItemDataPayload) => void; @@ -70,6 +71,7 @@ export function WorkItemDetailBody({ showTime, repoPath, projectSlug, + orgId, shortId, activeAgentSessionId, onOpenSubItem, @@ -122,6 +124,7 @@ export function WorkItemDetailBody({ teamMembers={availableMembers} repoPath={repoPath} projectSlug={projectSlug} + orgId={orgId} shortId={shortId} onCancelAgent={onCancelAgent} onRetry={onRetry} diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemDetail/index.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemDetail/index.tsx index d3dc3eec01..d056eb9f27 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemDetail/index.tsx +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemDetail/index.tsx @@ -70,6 +70,7 @@ const WorkItemDetail: React.FC = ({ onRegisterActions, repoPath, projectSlug, + orgId, shortId, onRefreshWorkItem, onOpenSession, @@ -162,11 +163,12 @@ const WorkItemDetail: React.FC = ({ projectSlug ?? undefined, undefined, undefined, - item.frontmatter.status + item.frontmatter.status, + orgId ?? undefined ) ); }, - [openStationTab, projectSlug] + [openStationTab, orgId, projectSlug] ); const handleOpenSessionWithContext = useCallback( @@ -410,6 +412,7 @@ const WorkItemDetail: React.FC = ({ showTime={showTime} repoPath={repoPath} projectSlug={projectSlug} + orgId={orgId} shortId={shortId} activeAgentSessionId={activeAgentSessionId} onOpenSubItem={handleOpenSubItem} diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemDetail/types.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemDetail/types.ts index c2060adeb6..6688b7102c 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemDetail/types.ts +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemDetail/types.ts @@ -48,6 +48,7 @@ export interface WorkItemDetailProps { onRegisterActions?: (actions: WorkItemDetailActions) => void; repoPath?: string | null; projectSlug?: string | null; + orgId?: string | null; shortId?: string | null; onRefreshWorkItem?: () => void; onOpenSession?: (sessionId: string, title?: string) => void; diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemDetailPage/StandaloneWorkItemDetailPage.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemDetailPage/StandaloneWorkItemDetailPage.tsx index f754a09b26..6049a5df89 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemDetailPage/StandaloneWorkItemDetailPage.tsx +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemDetailPage/StandaloneWorkItemDetailPage.tsx @@ -20,6 +20,7 @@ const EMPTY_RELATION_MAPS = { export function StandaloneWorkItemDetailPage({ workItemId, + orgId, onClose, onOpenChatSession, pendingUpdates, @@ -36,12 +37,15 @@ export function StandaloneWorkItemDetailPage({ const loadWorkItem = useCallback(async () => { setLoading(true); try { - const item = await projectApi.readStandaloneWorkItem(workItemId); + const item = await projectApi.readStandaloneWorkItem( + workItemId, + orgId ? { orgId } : undefined + ); setWorkItem(workItemDataToUI(item, EMPTY_RELATION_MAPS)); } finally { setLoading(false); } - }, [workItemId]); + }, [orgId, workItemId]); useEffect(() => { void loadWorkItem(); @@ -76,11 +80,12 @@ export function StandaloneWorkItemDetailPage({ // silently dropped by a client-side merge + whole-row write. await projectApi.updateStandaloneWorkItemPartial( workItemId, - standaloneWorkItemUpdatesToPartial(updates, updates.spec) + standaloneWorkItemUpdatesToPartial(updates, updates.spec), + orgId ? { orgId } : undefined ); await loadWorkItem(); }, - [loadWorkItem, onWorkItemNameUpdated, workItem, workItemId] + [loadWorkItem, onWorkItemNameUpdated, orgId, workItem, workItemId] ); if (!workItem) { @@ -110,6 +115,7 @@ export function StandaloneWorkItemDetailPage({ showTime repoPath={activeWorkspaceRootPath || null} projectSlug={null} + orgId={orgId} shortId={workItemId} onRefreshWorkItem={loadWorkItem} onOpenSession={onOpenChatSession} diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemDetailPage/types.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemDetailPage/types.ts index 5b9ca54b5b..84ef10fc84 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemDetailPage/types.ts +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemDetailPage/types.ts @@ -2,6 +2,7 @@ export interface WorkItemDetailPageProps { projectId?: string; projectName?: string; projectSlug?: string; + orgId?: string; workItemId: string; onClose: () => void; /** Open an agent session in a chat tab. */ diff --git a/src/modules/ProjectManager/WorkItems/workItemPartialUpdate.ts b/src/modules/ProjectManager/WorkItems/workItemPartialUpdate.ts index 89b7c3dae1..bc0b20a886 100644 --- a/src/modules/ProjectManager/WorkItems/workItemPartialUpdate.ts +++ b/src/modules/ProjectManager/WorkItems/workItemPartialUpdate.ts @@ -65,6 +65,12 @@ export function toWorkItemPartialUpdate( content: comment.content, created_at: comment.created_at, mentioned_user_ids: comment.mentioned_user_ids, + parent_id: comment.parent_id, + thread_id: comment.thread_id, + resolved_at: comment.resolved_at, + resolved_by: comment.resolved_by, + conclusion: comment.conclusion, + agent_session_id: comment.agent_session_id, })); } if (updates.linkedSessions !== undefined) { diff --git a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/PullRequestContent/detail/PrCommitsTab.test.ts b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/PullRequestContent/detail/PrCommitsTab.test.ts index 5469a1f4e6..27a93e8c37 100644 --- a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/PullRequestContent/detail/PrCommitsTab.test.ts +++ b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/PullRequestContent/detail/PrCommitsTab.test.ts @@ -67,12 +67,12 @@ const commits: Record[] = [ author: { name: "Neon Forge", email: "neon@example.com", - date: "2026-08-06T00:00:00Z", + date: "2026-08-06T12:00:00Z", }, committer: { name: "Neon Forge", email: "neon@example.com", - date: "2026-08-06T00:00:00Z", + date: "2026-08-06T12:00:00Z", }, verification: { verified: true }, }, @@ -119,7 +119,7 @@ describe("PrCommitsTab", () => { beforeEach(() => { vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-08-06T01:00:00Z")); + vi.setSystemTime(new Date("2026-08-06T13:00:00Z")); container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); diff --git a/src/modules/WorkStation/TabContent/renderers/workItemDetail.tsx b/src/modules/WorkStation/TabContent/renderers/workItemDetail.tsx index 0c08085420..6e1d9038cb 100644 --- a/src/modules/WorkStation/TabContent/renderers/workItemDetail.tsx +++ b/src/modules/WorkStation/TabContent/renderers/workItemDetail.tsx @@ -23,6 +23,7 @@ const WorkItemDetailTabRenderer: React.FC = memo( projectId={tab.data.projectId as string} projectName={tab.data.projectName as string} projectSlug={tab.data.projectSlug as string | undefined} + orgId={tab.data.orgId as string | undefined} workItemId={tab.data.workItemId as string} onClose={() => onCloseTab(tab.id)} onOpenChatSession={onOpenChatSession} diff --git a/src/store/chatPanel/__tests__/chatPanelTabsAtom.test.ts b/src/store/chatPanel/__tests__/chatPanelTabsAtom.test.ts index 110a2e492e..ce2fcfa783 100644 --- a/src/store/chatPanel/__tests__/chatPanelTabsAtom.test.ts +++ b/src/store/chatPanel/__tests__/chatPanelTabsAtom.test.ts @@ -429,7 +429,7 @@ describe("closeWorkItemChatPanelTabAtom", () => { .tabs.some((tab) => tab.workItem?.shortId === "ORG-1") ).toBe(true); - store.set(closeWorkItemChatPanelTabAtom, "ORG-1"); + store.set(closeWorkItemChatPanelTabAtom, selectedWorkItem); expect( store @@ -439,6 +439,36 @@ describe("closeWorkItemChatPanelTabAtom", () => { expect(store.get(chatPanelSelectedWorkItemAtom)).toBeNull(); }); + it("keeps identical standalone short IDs isolated by organization", async () => { + const { chatPanelTabsAtom, openWorkItemInChatPanelTabAtom, store } = + await loadChatPanelTabAtoms(); + const personal = { + shortId: "WI-0001", + orgId: "personal-org", + projectSlug: "", + projectId: "", + projectName: "Standalone Work Items", + workItem: { session_id: "personal-row", name: "Personal item" }, + }; + const cloud = { + ...personal, + orgId: "cloud-org", + workItem: { session_id: "cloud-row", name: "Cloud item" }, + }; + + store.set(openWorkItemInChatPanelTabAtom, personal as never); + store.set(openWorkItemInChatPanelTabAtom, cloud as never); + + const workItemTabs = store + .get(chatPanelTabsAtom) + .tabs.filter((tab) => tab.type === "work-item"); + expect(workItemTabs).toHaveLength(2); + expect(workItemTabs.map((tab) => tab.workItem?.orgId).sort()).toEqual([ + "cloud-org", + "personal-org", + ]); + }); + it("restores a session's split Station layout after visiting a work item", async () => { const { activateChatPanelTabAtom, diff --git a/src/store/chatPanel/chatPanelTabFactories.ts b/src/store/chatPanel/chatPanelTabFactories.ts index 7dfa47a93f..da7c92c7b3 100644 --- a/src/store/chatPanel/chatPanelTabFactories.ts +++ b/src/store/chatPanel/chatPanelTabFactories.ts @@ -159,9 +159,17 @@ export const createTerminalTab = defineChatPanelTabFactory<{ }); // --------------------------------------------------------------------------- -// work-item — one pill per work item, deduped by shortId +// work-item — one pill per organization + project + short ID scope // --------------------------------------------------------------------------- +export function getChatPanelWorkItemTabKey( + workItem: ChatPanelSelectedWorkItem +): string { + const orgId = + workItem.orgId ?? workItem.sourceProject?.orgId ?? "personal-org"; + return `${orgId}:${workItem.projectId || "standalone"}:${workItem.shortId}`; +} + export const createWorkItemTab = defineChatPanelTabFactory<{ workItem: ChatPanelSelectedWorkItem; }>({ @@ -169,7 +177,7 @@ export const createWorkItemTab = defineChatPanelTabFactory<{ idStrategy: { type: "keyed", prefix: "work-item", - getKey: (data) => data.workItem.shortId, + getKey: (data) => getChatPanelWorkItemTabKey(data.workItem), }, getTitle: (data) => data.workItem.workItem.name || "Work item", toPayload: (data) => ({ workItem: data.workItem }), diff --git a/src/store/chatPanel/chatPanelTabLifecycleAtoms.ts b/src/store/chatPanel/chatPanelTabLifecycleAtoms.ts index 3b1f0c1f46..ce40a58740 100644 --- a/src/store/chatPanel/chatPanelTabLifecycleAtoms.ts +++ b/src/store/chatPanel/chatPanelTabLifecycleAtoms.ts @@ -11,7 +11,10 @@ import { } from "@src/store/ui/chatPanelAtom"; import type { WorkManagementSection } from "@src/store/workstation"; -import { buildDefaultLaunchpadTab } from "./chatPanelTabFactories"; +import { + buildDefaultLaunchpadTab, + getChatPanelWorkItemTabKey, +} from "./chatPanelTabFactories"; import { activateChatPanelTabAtom } from "./chatPanelTabPresentationAtoms"; import { type ChatPanelSelectedChannel, @@ -134,18 +137,20 @@ closeOrganizationChatPanelTabAtom.debugLabel = "closeOrganizationChatPanelTab"; */ export const closeWorkItemChatPanelTabAtom = atom( null, - (get, set, shortId: string) => { + (get, set, workItem: ChatPanelSelectedWorkItem) => { + const workItemKey = getChatPanelWorkItemTabKey(workItem); const tab = get(chatPanelTabsAtom).tabs.find( (candidate) => candidate.type === "work-item" && - candidate.workItem?.shortId === shortId + candidate.workItem !== undefined && + getChatPanelWorkItemTabKey(candidate.workItem) === workItemKey ); if (tab) { set(closeChatPanelTabAtom, tab.id); return; } const selected = get(chatPanelSelectedWorkItemAtom); - if (selected?.shortId === shortId) { + if (selected && getChatPanelWorkItemTabKey(selected) === workItemKey) { set(chatPanelSelectedWorkItemAtom, null); } } @@ -360,17 +365,21 @@ export const setChatPanelTabTitleAtom = atom( * Keep a work-item tab's stored payload in sync with in-place edits made * through `chatPanelSelectedWorkItemAtom` (rename / status change / refresh). * Without this, switching away and back would replay the stale payload and - * revert the edit. Matched by `shortId`; a no-op (returns the previous state) - * when the payload reference is unchanged — e.g. the seed written on tab - * activation — so it never churns tab state or persistence. + * revert the edit. Matched by organization, project, and short ID; a no-op + * (returns the previous state) when the payload reference is unchanged — e.g. + * the seed written on tab activation — so it never churns tab state or + * persistence. */ export const patchChatPanelWorkItemTabAtom = atom( null, (_get, set, workItem: ChatPanelSelectedWorkItem) => { + const workItemKey = getChatPanelWorkItemTabKey(workItem); set(chatPanelTabsAtom, (prev) => { const target = prev.tabs.find( (tab) => - tab.type === "work-item" && tab.workItem?.shortId === workItem.shortId + tab.type === "work-item" && + tab.workItem !== undefined && + getChatPanelWorkItemTabKey(tab.workItem) === workItemKey ); if (!target || target.workItem === workItem) return prev; return { diff --git a/src/store/chatPanel/chatPanelTabOpenAtoms.ts b/src/store/chatPanel/chatPanelTabOpenAtoms.ts index ce624f5185..1d6e5a76e1 100644 --- a/src/store/chatPanel/chatPanelTabOpenAtoms.ts +++ b/src/store/chatPanel/chatPanelTabOpenAtoms.ts @@ -41,6 +41,7 @@ import { createWorkItemTab, createWorkManagementTab, createWorkspaceTab, + getChatPanelWorkItemTabKey, } from "./chatPanelTabFactories"; import { activateChatPanelTabAtom, @@ -442,7 +443,8 @@ addChatPanelTerminalTabAtom.debugLabel = "addChatPanelTerminalTab"; /** * Open — or focus, if already open — a dedicated tab for a work item. Each - * work item gets its own pill (deduped by `shortId`); activating it replays + * work item gets its own pill (deduped by organization, project, and short + * ID); activating it replays * the payload into the legacy surface atoms via `chatPanelNavigateAtom` so the * work-item panel renders. Re-opening refreshes the stored payload (name / * status can drift) before focusing. @@ -450,9 +452,12 @@ addChatPanelTerminalTabAtom.debugLabel = "addChatPanelTerminalTab"; export const openWorkItemInChatPanelTabAtom = atom( null, (get, set, workItem: ChatPanelSelectedWorkItem) => { + const workItemKey = getChatPanelWorkItemTabKey(workItem); const existingTab = get(chatPanelTabsAtom).tabs.find( (tab) => - tab.type === "work-item" && tab.workItem?.shortId === workItem.shortId + tab.type === "work-item" && + tab.workItem !== undefined && + getChatPanelWorkItemTabKey(tab.workItem) === workItemKey ); if (existingTab) { set(chatPanelTabsAtom, (prev) => ({ diff --git a/src/store/session/sessionAtom/types.ts b/src/store/session/sessionAtom/types.ts index 9c068f2b5a..9159fd6853 100644 --- a/src/store/session/sessionAtom/types.ts +++ b/src/store/session/sessionAtom/types.ts @@ -210,10 +210,9 @@ export interface Session { /** * Per-session execution mode (Rust-agent sessions only). * - * `undefined` means the user has never patched this session — UI - * components fall back to `creatorDefaultExecModeAtom` until the - * first ModePill click, which calls `rpc.sessionAggregate.patch` - * and writes the value here. CLI sessions always have `undefined`. + * `undefined` is tolerated only for historical rows; existing-session UI + * resolves it to the canonical `build` default and never consults the + * mutable creator default. Both Rust and CLI sessions persist this field. * * Source of truth lives in the Rust `agent_sessions.agent_exec_mode` * column; this field is the camelCase mirror exposed via diff --git a/src/store/workstation/tabs/__tests__/tabFactory.test.ts b/src/store/workstation/tabs/__tests__/tabFactory.test.ts index 65de8b84a4..ece852651a 100644 --- a/src/store/workstation/tabs/__tests__/tabFactory.test.ts +++ b/src/store/workstation/tabs/__tests__/tabFactory.test.ts @@ -398,6 +398,29 @@ describe("Project Manager Factories", () => { expect(tab.data.workItemStatus).toBe("open"); }); + + it("isolates identical standalone short IDs by organization", () => { + const personal = createWorkItemDetailTab( + undefined, + "Standalone Work Items", + "WI-0001", + "Personal item" + ); + const cloud = createWorkItemDetailTab( + undefined, + "Cloud", + "WI-0001", + "Cloud item", + undefined, + undefined, + undefined, + undefined, + "cloud-org" + ); + + expect(personal.id).not.toBe(cloud.id); + expect(cloud.data.orgId).toBe("cloud-org"); + }); }); describe("createGitHubIssueDetailTab", () => { diff --git a/src/store/workstation/tabs/factories/project.ts b/src/store/workstation/tabs/factories/project.ts index b6150d16e8..a3d7bc225e 100644 --- a/src/store/workstation/tabs/factories/project.ts +++ b/src/store/workstation/tabs/factories/project.ts @@ -443,6 +443,7 @@ export interface WorkItemDetailTabData { projectId?: string; projectName?: string; projectSlug?: string; + orgId?: string; workItemId: string; workItemName: string; workItemStatus?: string; @@ -460,7 +461,8 @@ export const workItemDetailTabFactory = defineTabFactory( idStrategy: { type: "keyed", prefix: "workItem-detail", - getKey: (data) => data.workItemId, + getKey: (data) => + `${data.orgId ?? "personal-org"}:${data.projectId || "standalone"}:${data.workItemId}`, }, getTitle: (data) => getWorkItemDetailTabTitle(data.workItemName), icon: WORK_ITEM_DETAIL_TAB_ICON, @@ -489,12 +491,14 @@ export function createWorkItemDetailTab( projectSlug?: string, pendingUpdates?: Record, returnTabId?: string, - workItemStatus?: string + workItemStatus?: string, + orgId?: string ): WorkStationTab { return workItemDetailTabFactory({ projectId, projectName, projectSlug, + orgId, workItemId, workItemName, ...(workItemStatus && { workItemStatus }), diff --git a/src/store/workstation/tabs/types.ts b/src/store/workstation/tabs/types.ts index ec8a481be7..88d578d5fa 100644 --- a/src/store/workstation/tabs/types.ts +++ b/src/store/workstation/tabs/types.ts @@ -338,6 +338,7 @@ export interface WorkItemDetailTabData { projectId?: string; projectName?: string; projectSlug?: string; + orgId?: string; dataPath?: string; workItemId: string; workItemName: string; diff --git a/src/types/core/workItem.ts b/src/types/core/workItem.ts index 0323ba8de7..46e8ce0e73 100644 --- a/src/types/core/workItem.ts +++ b/src/types/core/workItem.ts @@ -146,6 +146,12 @@ export interface WorkItemComment { content: string; created_at: string; mentioned_user_ids?: string[]; + parent_id?: string; + thread_id?: string; + resolved_at?: string; + resolved_by?: string; + conclusion?: boolean; + agent_session_id?: string; } // ============================================ diff --git a/src/util/optimization/__tests__/gifMetadata.test.ts b/src/util/optimization/__tests__/gifMetadata.test.ts index 3ef66b391c..210ad4e630 100644 --- a/src/util/optimization/__tests__/gifMetadata.test.ts +++ b/src/util/optimization/__tests__/gifMetadata.test.ts @@ -102,12 +102,16 @@ describe("GIF metadata inspection", () => { }); it("rejects an unsafe animation before invoking the browser image decoder", async () => { - const file = new File([createGif(2048, 2048, 3)], "oversized.gif", { - type: "image/gif", - }); + const file = new File( + [Uint8Array.from(createGif(2048, 2048, 3))], + "oversized.gif", + { + type: "image/gif", + } + ); - await expect(optimizeImage(file)).rejects.toMatchObject< - Partial - >({ code: "GIF_LIMIT_EXCEEDED" }); + await expect(optimizeImage(file)).rejects.toMatchObject({ + code: "GIF_LIMIT_EXCEEDED", + } satisfies Partial); }); }); From 1558de015b6e1c4f11673f591f4f015af496412b Mon Sep 17 00:00:00 2001 From: Neonforge <48338160+Neonforge98@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:49:49 -0700 Subject: [PATCH 4/8] fix(runtime): eliminate replay and dispatch polling spikes --- .../coordination/work_item_run_dispatcher.rs | 80 ++++++- .../project-management/src/projects/events.rs | 21 ++ .../src/work_item_features/discussion.rs | 3 + .../src/work_run_service/mod.rs | 84 +++++-- .../src/work_run_service/tests.rs | 42 ++++ .../crates/session-persistence/src/crud.rs | 103 +++++++-- .../crates/session-persistence/src/schema.rs | 6 + .../crates/session-persistence/src/types.rs | 1 + src-tauri/src/lib.rs | 6 + .../session_provenance/historical_backfill.rs | 7 +- src/api/tauri/rpc/schemas/sessionCore.ts | 1 + .../SessionCore/core/store/EventStoreProxy.ts | 20 ++ .../SessionCore/storage/sqliteCache.ts | 1 + .../org2CloudSessionSync.seed.test.ts | 205 +++++++++++++++++- .../Org2Cloud/org2CloudSessionSync.state.ts | 60 +++-- .../Org2Cloud/org2CloudSessionSync.ts | 115 +++++++--- .../Org2Cloud/org2CloudSessionSync.types.ts | 2 + src/features/Org2Cloud/org2CloudSyncAtoms.ts | 15 ++ .../org2CloudSyncEngine.sessionColdStart.ts | 8 +- .../org2CloudSyncEngine.sessions.test.ts | 24 ++ .../org2CloudSyncEngine.testUtils.ts | 29 ++- src/features/Org2Cloud/org2CloudSyncEngine.ts | 50 ++++- .../Org2Cloud/org2CloudSyncLifecycle.ts | 3 + .../orgScopeRepoFilter.test.ts | 18 +- .../repoScopeResolver.test.ts | 38 ++++ .../TeamCollaboration/repoScopeResolver.ts | 55 +++-- 26 files changed, 874 insertions(+), 123 deletions(-) diff --git a/src-tauri/crates/agent-core/src/core/coordination/work_item_run_dispatcher.rs b/src-tauri/crates/agent-core/src/core/coordination/work_item_run_dispatcher.rs index af1dea4588..4d778e2cb2 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/work_item_run_dispatcher.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/work_item_run_dispatcher.rs @@ -6,7 +6,10 @@ //! as the durable turn intent, then acknowledges delivery. A process crash at //! any boundary is reconciled from the persisted Session/intent state. -use std::time::Duration; +use std::{ + sync::{Arc, OnceLock}, + time::Duration, +}; use project_management::projects::types::{ WorkItemDispatchLease, WorkItemExecutionLockReason, WorkItemRunTarget, WorkItemRunTrigger, @@ -19,18 +22,60 @@ use tracing::{debug, error, info, warn}; use crate::foundation::session_bridge::TurnIntentBridgeStatus; const LEASE_MS: i64 = 30_000; -const IDLE_POLL_MS: u64 = 750; +// Normal delivery is event-driven: in-process commits notify `DISPATCH_WAKE` +// and external CLI/desktop commits advance the PM watermark, which wakes this +// loop within the watermark observer's short window. Keep a coarse final +// safety net for corrupted/missed signals without turning an idle desktop +// into a recurring SQLite scan. +const CRASH_RECOVERY_POLL_MS: u64 = 5 * 60_000; +const BLOCKED_PATH_RECHECK_MS: u64 = 5_000; const MAX_BATCH: usize = 8; +static DISPATCH_WAKE: OnceLock> = OnceLock::new(); + +/// Wake the process-local dispatcher after the cross-process PM watermark +/// observes a durable outbox write. +pub fn wake_from_watermark() { + if let Some(wake) = DISPATCH_WAKE.get() { + wake.notify_one(); + } +} -/// Start the single durable dispatcher loop. The first claim is immediate so -/// fully-quit recovery does not wait for the polling interval. +/// Start the single durable dispatcher loop. The first readiness probe is +/// immediate so fully-quit recovery does not wait for the recovery interval. pub fn spawn(app: tauri::AppHandle) { let worker_id = format!("desktop_{}", uuid::Uuid::new_v4().simple()); + let wake = Arc::clone(DISPATCH_WAKE.get_or_init(|| Arc::new(tokio::sync::Notify::new()))); + let notifier = Arc::clone(&wake); + project_management::projects::events::register_work_item_dispatch_ready_notifier(Box::new( + move || notifier.notify_one(), + )); tauri::async_runtime::spawn(async move { info!(worker_id, "[work-run-dispatcher] started"); reconcile_interrupted_session_runs(&app).await; crate::orchestrator_notify::reconcile_terminal_routine_dispatches(&app).await; loop { + let ready = + match tokio::task::spawn_blocking(work_run_service::has_claimable_dispatch).await { + Ok(Ok(ready)) => ready, + Ok(Err(err)) => { + error!(error = %err, "[work-run-dispatcher] readiness probe failed"); + false + } + Err(err) => { + error!(error = %err, "[work-run-dispatcher] readiness task failed"); + false + } + }; + + if !ready { + let delay = dispatcher_wait_duration().await; + tokio::select! { + _ = wake.notified() => {} + _ = tokio::time::sleep(delay) => {} + } + continue; + } + let mut handled = 0usize; for _ in 0..MAX_BATCH { let claim_worker_id = worker_id.clone(); @@ -94,15 +139,36 @@ pub fn spawn(app: tauri::AppHandle) { } } - if handled == 0 { - tokio::time::sleep(Duration::from_millis(IDLE_POLL_MS)).await; - } else { + if handled > 0 { tokio::task::yield_now().await; } } }); } +async fn dispatcher_wait_duration() -> Duration { + let due_at = tokio::task::spawn_blocking(work_run_service::next_dispatch_due_at_ms) + .await + .ok() + .and_then(Result::ok) + .flatten(); + let Some(due_at) = due_at else { + return Duration::from_millis(CRASH_RECOVERY_POLL_MS); + }; + let now = chrono::Utc::now().timestamp_millis(); + if due_at <= now { + // A ready deadline with no claimable row is normally a path-lock + // conflict. Recheck read-only at a coarse cadence until the terminal + // signal releases the lock; never spin or reserve SQLite's writer. + return Duration::from_millis(BLOCKED_PATH_RECHECK_MS); + } + Duration::from_millis( + u64::try_from(due_at - now) + .unwrap_or(CRASH_RECOVERY_POLL_MS) + .min(CRASH_RECOVERY_POLL_MS), + ) +} + /// Close the crash window between dispatch acknowledgement and provider /// terminal persistence. /// diff --git a/src-tauri/crates/project-management/src/projects/events.rs b/src-tauri/crates/project-management/src/projects/events.rs index 81c3d74a9d..f02a5a78db 100644 --- a/src-tauri/crates/project-management/src/projects/events.rs +++ b/src-tauri/crates/project-management/src/projects/events.rs @@ -17,6 +17,7 @@ use std::sync::OnceLock; static DATA_CHANGED_NOTIFIER: OnceLock> = OnceLock::new(); static WORK_ITEM_SCHEDULE_CHANGED_NOTIFIER: OnceLock> = OnceLock::new(); +static WORK_ITEM_DISPATCH_READY_NOTIFIER: OnceLock> = OnceLock::new(); /// App-level registration of the frontend notifier (Tauri emit). First call wins. pub fn register_data_changed_notifier(notifier: Box) { @@ -79,3 +80,23 @@ pub(crate) fn notify_work_item_schedule_changed() { notifier(); } } + +/// Register the process-local wake-up used by the durable Run dispatcher. +/// +/// Cross-process writers are still discovered by the persisted +/// `pm_change_seq` watermark. This callback removes the idle polling delay for +/// producers in the current process without coupling the PM crate to Tauri or +/// the agent runtime. First call wins. +pub fn register_work_item_dispatch_ready_notifier(notifier: Box) { + let _ = WORK_ITEM_DISPATCH_READY_NOTIFIER.set(notifier); +} + +/// Wake the durable Run dispatcher after an outbox transaction commits. +/// +/// Calling before runtime startup is intentionally a no-op: the dispatcher's +/// immediate recovery probe observes the durable row when it starts. +pub(crate) fn notify_work_item_dispatch_ready() { + if let Some(notifier) = WORK_ITEM_DISPATCH_READY_NOTIFIER.get() { + notifier(); + } +} diff --git a/src-tauri/crates/project-management/src/work_item_features/discussion.rs b/src-tauri/crates/project-management/src/work_item_features/discussion.rs index 8c773a41c6..37990e80e8 100644 --- a/src-tauri/crates/project-management/src/work_item_features/discussion.rs +++ b/src-tauri/crates/project-management/src/work_item_features/discussion.rs @@ -299,6 +299,9 @@ pub(super) fn post(request: DiscussionPostRequest) -> Result Result, String> { - if worker_id.trim().is_empty() { - return Err(format!("{}:worker_id is required", error::INVALID_REQUEST)); - } - let lease_ms = if requested_lease_ms <= 0 { - DEFAULT_LEASE_MS - } else { - requested_lease_ms.min(MAX_LEASE_MS) - }; - let mut connection = conn()?; - let tx = db(connection.transaction_with_behavior(TransactionBehavior::Immediate))?; - let now = now_ms(); - let candidate: Option<(String, String, i64)> = db(tx +fn select_claimable_dispatch( + connection: &Connection, + now: i64, +) -> Result, String> { + db(connection .query_row( "SELECT d.id, d.run_id, d.delivery_attempt FROM pm_dispatch_outbox d @@ -989,7 +977,62 @@ pub fn claim_next_dispatch( params![now], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), ) - .optional())?; + .optional()) +} + +/// Cheap read-only readiness probe used before entering an `IMMEDIATE` +/// transaction. An idle dispatcher therefore never takes SQLite's writer +/// reservation merely to discover an empty queue. +fn has_claimable_dispatch_on(connection: &Connection) -> Result { + Ok(select_claimable_dispatch(connection, now_ms())?.is_some()) +} + +pub fn has_claimable_dispatch() -> Result { + let connection = conn()?; + has_claimable_dispatch_on(&connection) +} + +/// Earliest persisted dispatch/lease deadline. The dispatcher sleeps until +/// this instant (bounded by its crash-recovery interval) and can still be +/// interrupted immediately by an in-process outbox commit or the +/// cross-process PM watermark. +pub fn next_dispatch_due_at_ms() -> Result, String> { + let connection = conn()?; + db(connection.query_row( + "SELECT MIN( + CASE + WHEN d.status IN ('pending', 'retry_wait') THEN d.available_at + WHEN d.status = 'leased' THEN d.lease_expires_at + ELSE NULL + END + ) + FROM pm_dispatch_outbox d + JOIN pm_work_item_runs r ON r.id = d.run_id + WHERE d.status IN ('pending', 'retry_wait', 'leased') + AND r.status IN ('queued', 'deferred', 'dispatching')", + [], + |row| row.get(0), + )) +} + +/// Lease the oldest ready dispatch. Expired leases are reclaimed by the same +/// query, so process death cannot strand a Run in `dispatching` forever. +pub fn claim_next_dispatch( + worker_id: &str, + requested_lease_ms: i64, +) -> Result, String> { + if worker_id.trim().is_empty() { + return Err(format!("{}:worker_id is required", error::INVALID_REQUEST)); + } + let lease_ms = if requested_lease_ms <= 0 { + DEFAULT_LEASE_MS + } else { + requested_lease_ms.min(MAX_LEASE_MS) + }; + let mut connection = conn()?; + let tx = db(connection.transaction_with_behavior(TransactionBehavior::Immediate))?; + let now = now_ms(); + let candidate = select_claimable_dispatch(&tx, now)?; let Some((dispatch_id, run_id, previous_attempts)) = candidate else { db(tx.commit())?; return Ok(None); @@ -1362,6 +1405,7 @@ pub fn record_dispatch_failure( }), )?; db(tx.commit())?; + crate::projects::events::notify_work_item_dispatch_ready(); let persisted = read(&run_id)?; if let Err(err) = crate::work_item_features::subscriptions::notify_run_terminal(&persisted) { tracing::warn!(run_id = %persisted.id, error = %err, "failed to project Run failure into Inbox"); @@ -1402,6 +1446,7 @@ pub fn record_run_terminal( if existing.status.is_terminal() { release_path_lock(&tx, run_id)?; db(tx.commit())?; + crate::projects::events::notify_work_item_dispatch_ready(); return Ok(existing); } @@ -1462,6 +1507,7 @@ pub fn record_run_terminal( }), )?; db(tx.commit())?; + crate::projects::events::notify_work_item_dispatch_ready(); let persisted = read(run_id)?; if let Err(err) = crate::work_item_features::subscriptions::notify_run_terminal(&persisted) { tracing::warn!(run_id = %persisted.id, error = %err, "failed to project Run failure into Inbox"); diff --git a/src-tauri/crates/project-management/src/work_run_service/tests.rs b/src-tauri/crates/project-management/src/work_run_service/tests.rs index 775129207c..9a8be0c199 100644 --- a/src-tauri/crates/project-management/src/work_run_service/tests.rs +++ b/src-tauri/crates/project-management/src/work_run_service/tests.rs @@ -110,6 +110,48 @@ fn enqueue_captures_immutable_work_item_context() { assert_eq!(stored.target_snapshot.work_item_revision, 0); } +#[test] +fn readiness_probe_tracks_durable_deadlines_without_claiming() { + let _sandbox = test_env::sandbox(); + seed(); + assert!(!has_claimable_dispatch().expect("empty readiness")); + assert_eq!(next_dispatch_due_at_ms().expect("empty deadline"), None); + + let before_enqueue = now_ms(); + enqueue(request("manual:readiness")).expect("enqueue"); + assert!(has_claimable_dispatch().expect("pending readiness")); + assert!(next_dispatch_due_at_ms() + .expect("pending deadline") + .is_some_and(|due_at| due_at >= before_enqueue && due_at <= now_ms())); + + claim_next_dispatch("readiness-worker", 30_000) + .expect("claim") + .expect("lease"); + assert!(!has_claimable_dispatch().expect("leased readiness")); + assert!(next_dispatch_due_at_ms() + .expect("lease deadline") + .is_some_and(|due_at| due_at > now_ms())); +} + +#[test] +fn readiness_probe_never_competes_for_the_sqlite_writer_reservation() { + let _sandbox = test_env::sandbox(); + seed(); + let mut writer = conn().expect("writer connection"); + let tx = writer + .transaction_with_behavior(TransactionBehavior::Immediate) + .expect("reserve writer"); + // Avoid the test-only `conn()` schema idempotency pass: production schema + // initialization is process-once, while this assertion isolates the + // dispatcher's steady-state query under a concurrent writer. + let reader = database::db::get_projects_connection().expect("reader connection"); + + // A second IMMEDIATE transaction would block/fail here. The readiness + // path remains a plain read and can inspect the empty queue concurrently. + assert!(!has_claimable_dispatch_on(&reader).expect("readiness under writer")); + tx.commit().expect("release writer"); +} + #[test] fn path_lock_serializes_runs_until_terminal_release() { let _sandbox = test_env::sandbox(); diff --git a/src-tauri/crates/session-persistence/src/crud.rs b/src-tauri/crates/session-persistence/src/crud.rs index 9b2600b74d..fbf0910ca6 100644 --- a/src-tauri/crates/session-persistence/src/crud.rs +++ b/src-tauri/crates/session-persistence/src/crud.rs @@ -72,8 +72,9 @@ fn upsert_event_rows( conn: &Connection, session_id: &str, events: &[CachedEvent], -) -> SqliteResult<()> { +) -> SqliteResult { get_next_sequence(conn, session_id)?; + let mut content_changed = false; // Conflict target is the PRIMARY KEY (id). The table also carries // UNIQUE(id, session_id), but that constraint cannot conflict without @@ -119,7 +120,7 @@ fn upsert_event_rows( .unwrap_or_else(|| increment_sequence(session_id)), }; - stmt.execute(params![ + content_changed |= stmt.execute(params![ event.id, event.session_id, event.event_type, @@ -131,14 +132,15 @@ fn upsert_event_rows( event.created_at, event.meta_json, seq, - ])?; + ])? > 0; } - Ok(()) + Ok(content_changed) } fn refresh_session_metadata_from_events( conn: &Connection, session_id: &str, + content_changed: bool, ) -> SqliteResult { let (event_count, time_start, time_end): (i64, Option, Option) = conn .query_row( @@ -151,14 +153,25 @@ fn refresh_session_metadata_from_events( let now = Utc::now().timestamp(); conn.execute( "INSERT INTO sessions - (session_id, event_count, cached_at, time_range_start, time_range_end, specs_json) - VALUES (?1, ?2, ?3, ?4, ?5, NULL) + (session_id, event_count, cached_at, content_revision, time_range_start, time_range_end, specs_json) + VALUES (?1, ?2, ?3, CASE WHEN ?6 THEN 1 ELSE 0 END, ?4, ?5, NULL) ON CONFLICT(session_id) DO UPDATE SET event_count = excluded.event_count, cached_at = excluded.cached_at, + content_revision = CASE + WHEN ?6 THEN sessions.content_revision + 1 + ELSE sessions.content_revision + END, time_range_start = excluded.time_range_start, time_range_end = excluded.time_range_end", - params![session_id, event_count, now, time_start, time_end], + params![ + session_id, + event_count, + now, + time_start, + time_end, + content_changed + ], )?; Ok(event_count.max(0) as usize) } @@ -190,7 +203,7 @@ pub fn save_events(session_id: &str, events: &[CachedEvent]) -> SqliteResult<()> let conn = get_connection()?; let tx = begin_immediate(&conn)?; - upsert_event_rows(&conn, session_id, events)?; + let content_changed = upsert_event_rows(&conn, session_id, events)?; // `save_events` is incremental: callers may submit one newly // materialized Agent Org inbox event after a session already contains @@ -198,7 +211,7 @@ pub fn save_events(session_id: &str, events: &[CachedEvent]) -> SqliteResult<()> // only this batch would shrink the session metadata and make history // pagination skip durable events. Recompute from the transaction's // full event set instead. - refresh_session_metadata_from_events(&conn, session_id)?; + refresh_session_metadata_from_events(&conn, session_id, content_changed)?; normalize_session_sequences(&conn, session_id)?; tx.commit()?; @@ -227,7 +240,7 @@ pub fn finalize_deferred_event_import(session_id: &str) -> SqliteResult { let event_count = with_sessions_writer(|| -> SqliteResult { let conn = get_connection()?; let tx = begin_immediate(&conn)?; - let count = refresh_session_metadata_from_events(&conn, session_id)?; + let count = refresh_session_metadata_from_events(&conn, session_id, true)?; normalize_session_sequences(&conn, session_id)?; tx.commit()?; Ok(count) @@ -514,7 +527,7 @@ pub fn search_all_sessions(query: &str, limit: i64) -> SqliteResult SqliteResult> { let conn = get_connection()?; let mut stmt = conn.prepare_cached( - "SELECT session_id, event_count, cached_at, time_range_start, time_range_end, specs_json + "SELECT session_id, event_count, cached_at, content_revision, time_range_start, time_range_end, specs_json FROM sessions WHERE session_id = ?1", )?; @@ -523,9 +536,10 @@ pub fn get_session_metadata(session_id: &str) -> SqliteResult SqliteResult { pub fn get_all_sessions() -> SqliteResult> { let conn = get_connection()?; let mut stmt = conn.prepare( - "SELECT session_id, event_count, cached_at, time_range_start, time_range_end, specs_json + "SELECT session_id, event_count, cached_at, content_revision, time_range_start, time_range_end, specs_json FROM sessions ORDER BY cached_at DESC", )?; @@ -631,9 +645,10 @@ pub fn get_all_sessions() -> SqliteResult> { session_id: row.get(0)?, event_count: row.get(1)?, cached_at: row.get(2)?, - time_range_start: row.get(3)?, - time_range_end: row.get(4)?, - specs_json: row.get(5)?, + content_revision: row.get(3)?, + time_range_start: row.get(4)?, + time_range_end: row.get(5)?, + specs_json: row.get(6)?, }) })? .collect::>>()?; @@ -677,13 +692,14 @@ pub(crate) fn update_session_metadata(conn: &Connection, session_id: &str) -> Sq .unwrap_or((None, None)); conn.execute( - "INSERT INTO sessions (session_id, event_count, cached_at, time_range_start, time_range_end, specs_json) + "INSERT INTO sessions (session_id, event_count, cached_at, content_revision, time_range_start, time_range_end, specs_json) VALUES (?1, (SELECT COUNT(*) FROM events WHERE session_id = ?1), - ?2, ?3, ?4, NULL) + ?2, 1, ?3, ?4, NULL) ON CONFLICT(session_id) DO UPDATE SET event_count = excluded.event_count, cached_at = excluded.cached_at, + content_revision = sessions.content_revision + 1, time_range_start = excluded.time_range_start, time_range_end = excluded.time_range_end", params![session_id, now, time_range.0, time_range.1], @@ -742,9 +758,16 @@ pub fn save_session(session: &CachedSession) -> SqliteResult<()> { let now = Utc::now().timestamp(); tx.execute( - "INSERT OR REPLACE INTO sessions - (session_id, event_count, cached_at, time_range_start, time_range_end, specs_json) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + "INSERT INTO sessions + (session_id, event_count, cached_at, content_revision, time_range_start, time_range_end, specs_json) + VALUES (?1, ?2, ?3, 1, ?4, ?5, ?6) + ON CONFLICT(session_id) DO UPDATE SET + event_count = excluded.event_count, + cached_at = excluded.cached_at, + content_revision = sessions.content_revision + 1, + time_range_start = excluded.time_range_start, + time_range_end = excluded.time_range_end, + specs_json = excluded.specs_json", params![ session.session_id, persisted_count, @@ -942,6 +965,40 @@ mod tests { }); } + #[test] + fn content_revision_advances_only_when_transcript_content_changes() { + with_temp_orgii_home(|| { + let conn = get_connection().expect("open sessions DB"); + super::super::schema::init_session_tables(&conn).expect("init session schema"); + drop(conn); + + let session_id = "durable-content-revision-session"; + let event = cached_event(session_id, "event-1", "2026-07-17T00:00:01.000Z"); + save_events(session_id, std::slice::from_ref(&event)).expect("seed event"); + let first = get_session_metadata(session_id) + .expect("read first revision") + .expect("metadata exists") + .content_revision; + + save_events(session_id, std::slice::from_ref(&event)) + .expect("resubmit unchanged event"); + let unchanged = get_session_metadata(session_id) + .expect("read unchanged revision") + .expect("metadata exists") + .content_revision; + assert_eq!(unchanged, first); + + let mut changed = event; + changed.content = "changed".to_string(); + save_events(session_id, &[changed]).expect("update event content"); + let updated = get_session_metadata(session_id) + .expect("read updated revision") + .expect("metadata exists") + .content_revision; + assert!(updated > unchanged); + }); + } + #[test] fn deferred_import_publishes_metadata_only_when_finalized() { with_temp_orgii_home(|| { diff --git a/src-tauri/crates/session-persistence/src/schema.rs b/src-tauri/crates/session-persistence/src/schema.rs index 96af2ab6f7..f0a4f17da4 100644 --- a/src-tauri/crates/session-persistence/src/schema.rs +++ b/src-tauri/crates/session-persistence/src/schema.rs @@ -203,6 +203,7 @@ pub fn init_session_tables(conn: &Connection) -> SqliteResult<()> { session_id TEXT PRIMARY KEY, event_count INTEGER NOT NULL DEFAULT 0, cached_at INTEGER NOT NULL, + content_revision INTEGER NOT NULL DEFAULT 0, time_range_start TEXT, time_range_end TEXT, specs_json TEXT @@ -213,6 +214,11 @@ pub fn init_session_tables(conn: &Connection) -> SqliteResult<()> { // Migration: add specs_json column for existing DBs conn.execute("ALTER TABLE sessions ADD COLUMN specs_json TEXT", []) .ok(); + conn.execute( + "ALTER TABLE sessions ADD COLUMN content_revision INTEGER NOT NULL DEFAULT 0", + [], + ) + .ok(); // ============================================ // Human session note entries diff --git a/src-tauri/crates/session-persistence/src/types.rs b/src-tauri/crates/session-persistence/src/types.rs index 7c452595af..7d72d02fd5 100644 --- a/src-tauri/crates/session-persistence/src/types.rs +++ b/src-tauri/crates/session-persistence/src/types.rs @@ -33,6 +33,7 @@ pub struct SessionMetadata { pub session_id: String, pub event_count: i64, pub cached_at: i64, + pub content_revision: i64, pub time_range_start: Option, pub time_range_end: Option, pub specs_json: Option, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index df565436bc..763cc618bc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -853,6 +853,12 @@ pub fn run() { .and_then(Result::ok) .unwrap_or(-1); if seq >= 0 && seq != last_seq { + // The same durable watermark covers WorkItemRun + // outbox writes made by another desktop/CLI + // process. Wake the dispatcher; its read-only + // readiness probe avoids a writer lock for PM + // changes unrelated to dispatch. + agent_core::coordination::work_item_run_dispatcher::wake_from_watermark(); let _ = watermark_handle.emit( project_management::projects::events::DATA_CHANGED_EVENT, serde_json::json!({ "source": "pm-watermark" }), diff --git a/src-tauri/src/orgtrack/session_provenance/historical_backfill.rs b/src-tauri/src/orgtrack/session_provenance/historical_backfill.rs index 1351c6dc40..801f8f3af6 100644 --- a/src-tauri/src/orgtrack/session_provenance/historical_backfill.rs +++ b/src-tauri/src/orgtrack/session_provenance/historical_backfill.rs @@ -45,7 +45,12 @@ use super::{ // derivation and keep every session's rows on a single repository_id. const HISTORICAL_INTERACTION_PARSER_VERSION: i64 = 3; const BACKFILL_REFRESH_INTERVAL: Duration = Duration::from_secs(30); -const CODEX_WRITE_RECONCILIATION_POLL_INTERVAL: Duration = Duration::from_secs(30); +// Healthy Codex sessions are captured by lifecycle hooks. This loop is only a +// best-effort repair path for a missing SessionStart receipt, so a 30-second +// cadence made the exceptional path a permanent large-database poll. The +// active-session throttle and discovery cadence are already five minutes; +// align the repair wake with them and rely on the hook path for live updates. +const CODEX_WRITE_RECONCILIATION_POLL_INTERVAL: Duration = Duration::from_secs(5 * 60); /// The 30-second loop may cheaply inspect the SQLite cache, but a recursive /// walk of every Codex rollout directory plus session_index.jsonl belongs on /// the same low-frequency cadence as external-history discovery. diff --git a/src/api/tauri/rpc/schemas/sessionCore.ts b/src/api/tauri/rpc/schemas/sessionCore.ts index d0090adeed..ee784f92d9 100644 --- a/src/api/tauri/rpc/schemas/sessionCore.ts +++ b/src/api/tauri/rpc/schemas/sessionCore.ts @@ -207,6 +207,7 @@ export const SessionMetadataSchema = z.object({ sessionId: z.string(), eventCount: z.number(), cachedAt: z.number(), + contentRevision: z.number(), timeRangeStart: z.string().optional(), timeRangeEnd: z.string().optional(), }); diff --git a/src/engines/SessionCore/core/store/EventStoreProxy.ts b/src/engines/SessionCore/core/store/EventStoreProxy.ts index c82a796e31..a52cbaec1d 100644 --- a/src/engines/SessionCore/core/store/EventStoreProxy.ts +++ b/src/engines/SessionCore/core/store/EventStoreProxy.ts @@ -375,6 +375,26 @@ class EventStoreProxyImpl { return rpc.sessionCore.cache.countEvents({ sessionId }); } + /** + * Read the cache's durable content revision without materializing events. + * `contentRevision` is advanced by Rust only when an event row actually + * changes, so metadata-only session edits and the periodic cache save do + * not make a cloud replay look dirty. + */ + async getPersistedEventRevision( + sessionId: string + ): Promise<{ eventCount: number; revision: number } | null> { + const metadata = await rpc.sessionCore.cache.getSessionMetadata({ + sessionId, + }); + return metadata + ? { + eventCount: metadata.eventCount, + revision: metadata.contentRevision, + } + : null; + } + /** * Persist one bounded event batch directly to SQLite without materializing * the session in the Rust/JS in-memory stores. Large cloud replays use this diff --git a/src/engines/SessionCore/storage/sqliteCache.ts b/src/engines/SessionCore/storage/sqliteCache.ts index 01bf3a39d6..efef0bea4f 100644 --- a/src/engines/SessionCore/storage/sqliteCache.ts +++ b/src/engines/SessionCore/storage/sqliteCache.ts @@ -31,6 +31,7 @@ export interface SessionMetadata { sessionId: string; eventCount: number; cachedAt: number; + contentRevision: number; timeRangeStart?: string; timeRangeEnd?: string; } diff --git a/src/features/Org2Cloud/org2CloudSessionSync.seed.test.ts b/src/features/Org2Cloud/org2CloudSessionSync.seed.test.ts index 7db45d7ffe..70d04c2c63 100644 --- a/src/features/Org2Cloud/org2CloudSessionSync.seed.test.ts +++ b/src/features/Org2Cloud/org2CloudSessionSync.seed.test.ts @@ -1,13 +1,21 @@ import { createStore } from "jotai"; import { describe, expect, it, vi } from "vitest"; +import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; import { COLLAB_SESSION_ACCESS_MODE } from "@src/store/collaboration/types"; import type { CloudPushAccess } from "./org2CloudAccessSettings"; import { Org2CloudSessionSync } from "./org2CloudSessionSync"; import { buildCloudSessionMetadata } from "./org2CloudSessionSync.metadata"; +import { Org2CloudSessionSyncState } from "./org2CloudSessionSync.state"; import type { Org2CloudSyncClientDeps } from "./org2CloudSessionSync.types"; -import { AUTH, SCOPE_KEY, SESSION } from "./org2CloudSyncEngine.testUtils"; +import { org2CloudPushCursorsAtom } from "./org2CloudSyncAtoms"; +import { + AUTH, + SCOPE_KEY, + SESSION, + eventStoreMock, +} from "./org2CloudSyncEngine.testUtils"; const ORG_ID = "corg-1"; @@ -16,6 +24,16 @@ const ACCESS: CloudPushAccess = { visibility: "org", }; +class SessionSyncStateHarness extends Org2CloudSessionSyncState { + cache(key: string): void { + this.cachePreparedPushEvents(key, Promise.resolve({} as never)); + } + + get cacheSize(): number { + return this.passPushPrepareCache.size; + } +} + function makeSeedClient() { return { upsertSessionMetadata: vi.fn(async () => {}), @@ -73,4 +91,189 @@ describe("Org2CloudSessionSync seedFromRemoteSummary", () => { expect(client.upsertSessionMetadata).toHaveBeenCalledTimes(1); }); + + it("skips native transcript materialization when cursor, content revision, and remote summary match", async () => { + const client = makeSeedClient(); + const store = createStore(); + const sync = new Org2CloudSessionSync(() => store, client); + const access: CloudPushAccess = { + accessMode: COLLAB_SESSION_ACCESS_MODE.FULL_REPLAY, + visibility: "org", + }; + store.set(org2CloudPushCursorsAtom, { + [`${ORG_ID}:${SESSION.session_id}`]: { + orgId: ORG_ID, + sessionId: SESSION.session_id, + epoch: 7, + frozenSeq: 3, + pushedCount: 42, + frozenEventCount: 40, + frozenChainHash: "frozen-hash", + tailHash: "tail-hash", + localContentRevision: 99, + }, + }); + const remote = buildCloudSessionMetadata( + SESSION, + ORG_ID, + AUTH.userId, + AUTH.profile?.displayName ?? AUTH.userId, + SCOPE_KEY, + access, + AUTH.profile?.avatarUrl + ); + remote.eventsEpoch = 7; + remote.eventsFrozenSeq = 3; + remote.eventsCount = 42; + remote.eventsTailHash = "tail-hash"; + vi.spyOn( + eventStoreProxy, + "getPersistedEventRevision" + ).mockResolvedValueOnce({ eventCount: 42, revision: 99 }); + eventStoreMock.getPersistedEvents.mockClear(); + + await sync.seedFromRemoteSummary( + AUTH, + ORG_ID, + SESSION, + SCOPE_KEY, + access, + remote + ); + await sync.pushSession(AUTH, ORG_ID, SESSION, SCOPE_KEY, access); + + expect(eventStoreMock.getPersistedEvents).not.toHaveBeenCalled(); + expect(client.appendSessionEvents).not.toHaveBeenCalled(); + expect(client.rewriteSessionEvents).not.toHaveBeenCalled(); + }); + + it("upgrades a legacy native cursor with a cheap persisted-count proof", async () => { + const client = makeSeedClient(); + const store = createStore(); + const sync = new Org2CloudSessionSync(() => store, client); + const access: CloudPushAccess = { + accessMode: COLLAB_SESSION_ACCESS_MODE.FULL_REPLAY, + visibility: "org", + }; + const cursorKey = `${ORG_ID}:${SESSION.session_id}`; + store.set(org2CloudPushCursorsAtom, { + [cursorKey]: { + orgId: ORG_ID, + sessionId: SESSION.session_id, + epoch: 7, + frozenSeq: 3, + pushedCount: 42, + frozenEventCount: 40, + frozenChainHash: "frozen-hash", + tailHash: "tail-hash", + }, + }); + const remote = buildCloudSessionMetadata( + SESSION, + ORG_ID, + AUTH.userId, + AUTH.profile?.displayName ?? AUTH.userId, + SCOPE_KEY, + access, + AUTH.profile?.avatarUrl + ); + remote.eventsEpoch = 7; + remote.eventsFrozenSeq = 3; + remote.eventsCount = 42; + remote.eventsTailHash = "tail-hash"; + const revisionSpy = vi + .spyOn(eventStoreProxy, "getPersistedEventRevision") + .mockResolvedValueOnce({ eventCount: 42, revision: 101 }); + eventStoreMock.getPersistedEvents.mockClear(); + + await sync.seedFromRemoteSummary( + AUTH, + ORG_ID, + SESSION, + SCOPE_KEY, + access, + remote + ); + await sync.pushSession(AUTH, ORG_ID, SESSION, SCOPE_KEY, access); + + expect(revisionSpy).toHaveBeenCalledWith(SESSION.session_id); + expect(eventStoreMock.getPersistedEvents).not.toHaveBeenCalled(); + expect( + store.get(org2CloudPushCursorsAtom)[cursorKey]?.localContentRevision + ).toBe(101); + }); + + it("pushes renamed metadata without re-reading an unchanged native replay", async () => { + const client = makeSeedClient(); + const store = createStore(); + const sync = new Org2CloudSessionSync(() => store, client); + const access: CloudPushAccess = { + accessMode: COLLAB_SESSION_ACCESS_MODE.FULL_REPLAY, + visibility: "org", + }; + const renamed = { + ...SESSION, + name: "Renamed metadata only", + updated_at: "2026-08-09T09:00:00.000Z", + }; + store.set(org2CloudPushCursorsAtom, { + [`${ORG_ID}:${SESSION.session_id}`]: { + orgId: ORG_ID, + sessionId: SESSION.session_id, + epoch: 7, + frozenSeq: 3, + pushedCount: 42, + frozenEventCount: 40, + frozenChainHash: "frozen-hash", + tailHash: "tail-hash", + localContentRevision: 99, + }, + }); + const remote = buildCloudSessionMetadata( + SESSION, + ORG_ID, + AUTH.userId, + AUTH.profile?.displayName ?? AUTH.userId, + SCOPE_KEY, + access, + AUTH.profile?.avatarUrl + ); + remote.eventsEpoch = 7; + remote.eventsFrozenSeq = 3; + remote.eventsCount = 42; + remote.eventsTailHash = "tail-hash"; + vi.spyOn( + eventStoreProxy, + "getPersistedEventRevision" + ).mockResolvedValueOnce({ eventCount: 42, revision: 99 }); + eventStoreMock.getPersistedEvents.mockClear(); + + await sync.seedFromRemoteSummary( + AUTH, + ORG_ID, + renamed, + SCOPE_KEY, + access, + remote + ); + await sync.pushSession(AUTH, ORG_ID, renamed, SCOPE_KEY, access); + + expect(client.upsertSessionMetadata).toHaveBeenCalledTimes(1); + expect(eventStoreMock.getPersistedEvents).not.toHaveBeenCalled(); + expect(client.appendSessionEvents).not.toHaveBeenCalled(); + expect(client.rewriteSessionEvents).not.toHaveBeenCalled(); + }); +}); + +describe("Org2CloudSessionSync pass memory", () => { + it("bounds prepared transcripts and releases them at pass completion", () => { + const state = new SessionSyncStateHarness(() => null); + state.cache("session-1"); + state.cache("session-2"); + state.cache("session-3"); + expect(state.cacheSize).toBe(2); + + state.endPass(); + expect(state.cacheSize).toBe(0); + }); }); diff --git a/src/features/Org2Cloud/org2CloudSessionSync.state.ts b/src/features/Org2Cloud/org2CloudSessionSync.state.ts index 75964e5ff2..f0a068e4ef 100644 --- a/src/features/Org2Cloud/org2CloudSessionSync.state.ts +++ b/src/features/Org2Cloud/org2CloudSessionSync.state.ts @@ -25,8 +25,8 @@ import { EXTERNAL_HISTORY_ACTIVITY_DEBOUNCE_MS, } from "./org2CloudSyncLifecycle"; -/** Safety TTL for rechecking a session whose events plane was verified clean. */ -const EVENTS_CLEAN_TTL_MS = 10 * 60_000; +/** Keep at most the active and immediately previous preparation alive. */ +const MAX_PASS_PREPARE_CACHE_ENTRIES = 2; export class Org2CloudSessionSyncState { /** `${orgId}:${sessionId}` to hash of the last upserted metadata. */ @@ -73,6 +73,26 @@ export class Org2CloudSessionSyncState { this.passPushPrepareCache.clear(); } + /** Release transcript arrays as soon as the pass finishes. */ + endPass(): void { + this.passPushPrepareCache.clear(); + } + + protected cachePreparedPushEvents( + key: string, + prepared: Promise + ): void { + this.passPushPrepareCache.delete(key); + this.passPushPrepareCache.set(key, prepared); + while (this.passPushPrepareCache.size > MAX_PASS_PREPARE_CACHE_ENTRIES) { + const oldest = this.passPushPrepareCache.keys().next().value as + | string + | undefined; + if (oldest === undefined) break; + this.passPushPrepareCache.delete(oldest); + } + } + /** * Keep app-lifetime acceleration state inside the currently reachable data * set. Durable cursors/markers remain in Jotai storage until their own @@ -177,20 +197,20 @@ export class Org2CloudSessionSyncState { protected isEventPlaneClean(orgId: string, session: Session): boolean { const clean = this.cleanEventPlanes.get(session.session_id)?.get(orgId); - if (!clean || Date.now() - clean.verifiedAt >= EVENTS_CLEAN_TTL_MS) { - return false; - } - return ( - !isImportedHistorySession(session.session_id) || - clean.sourceUpdatedAt === session.updated_at - ); + if (!clean) return false; + // EventStore notifications clear this stamp immediately; the durable + // session version is the backstop for writes missed while the renderer + // was suspended. A verified unchanged version stays clean for the app + // lifetime instead of forcing a full-history reread every ten minutes. + return clean.sourceUpdatedAt === session.updated_at; } protected markEventPlaneClean( orgId: string, session: Session, stampAtRead: number, - verifiedAt = Date.now() + verifiedAt = Date.now(), + localContentRevision?: number ): void { const sessionId = session.session_id; if ((this.eventActivityStamps.get(sessionId) ?? 0) !== stampAtRead) return; @@ -201,10 +221,24 @@ export class Org2CloudSessionSyncState { } byOrg.set(orgId, { verifiedAt, - sourceUpdatedAt: isImportedHistorySession(sessionId) - ? session.updated_at - : undefined, + sourceUpdatedAt: session.updated_at, }); + const cursor = this.getCursor(orgId, sessionId); + if (!cursor) return; + if ( + localContentRevision !== undefined && + cursor.localContentRevision !== localContentRevision + ) { + this.setCursor({ ...cursor, localContentRevision }); + } else if ( + localContentRevision === undefined && + cursor.localContentUpdatedAt !== session.updated_at + ) { + // Provider-native histories without an events-cache row still use the + // source session version. Native cached histories use the independent + // revision above so renaming/pinning never dirties their replay. + this.setCursor({ ...cursor, localContentUpdatedAt: session.updated_at }); + } } protected getCursor( diff --git a/src/features/Org2Cloud/org2CloudSessionSync.ts b/src/features/Org2Cloud/org2CloudSessionSync.ts index d21bd9b2ad..f36417a4d2 100644 --- a/src/features/Org2Cloud/org2CloudSessionSync.ts +++ b/src/features/Org2Cloud/org2CloudSessionSync.ts @@ -94,6 +94,7 @@ interface ImportedReplayAnchorDraft { interface LoadedPushEvents { events: SessionEvent[]; + localContentRevision?: number; anchorDraft?: ImportedReplayAnchorDraft; precomputedEventHashes?: string[]; precomputedLocalFrozenEventCount?: number; @@ -243,13 +244,7 @@ export class Org2CloudSessionSync extends Org2CloudSessionSyncState { ); } - /** - * Seed the volatile cold-start caches from a server-authoritative listing. - * For imported CLI sessions the local `updated_at` comes from the source - * transcript and is part of the uploaded metadata. When that payload and - * the persisted cursor both match the server summary, a restart does not - * need to read/normalize/hash the entire transcript again. - */ + /** Seed volatile cold-start caches from a server-authoritative listing. */ async seedFromRemoteSummary( auth: Org2CloudAuthState, orgId: string, @@ -283,17 +278,21 @@ export class Org2CloudSessionSync extends Org2CloudSessionSyncState { sha256Hex(stableStringify(metadataPayloadForHash(localMetadata))), sha256Hex(stableStringify(metadataPayloadForHash(remote))), ]); - if (localHash !== remoteHash) return; + if (localHash === remoteHash) { + // upsertMetadataIfChanged gates on the FULL payload hash; seeding the + // stripped comparison hash would never match it and every restart would + // re-upsert an identical payload for every pushed session. + this.lastPushedMetadataHashes.set( + key, + await sha256Hex(stableStringify(localMetadata)) + ); + this.setPushedMetadataMarker(orgId, session.session_id); + } - // upsertMetadataIfChanged gates on the FULL payload hash; seeding the - // stripped comparison hash would never match it and every restart would - // re-upsert an identical payload for every pushed session. - this.lastPushedMetadataHashes.set( - key, - await sha256Hex(stableStringify(localMetadata)) - ); - this.setPushedMetadataMarker(orgId, session.session_id); - if (!isImportedHistorySession(session.session_id)) return; + // Metadata and transcript are independent planes. Even if a title or + // access field changed locally, a cursor stamped with this exact local + // content version plus the server summary proves the event plane clean. + // Legacy cursors lack the stamp and deliberately take one normal read. const cursor = this.getCursor(orgId, session.session_id); if ( !cursor || @@ -304,10 +303,39 @@ export class Org2CloudSessionSync extends Org2CloudSessionSyncState { ) { return; } + let localContentRevision: number | undefined; + if (!isImportedHistorySession(session.session_id)) { + const durable = await eventStoreProxy.getPersistedEventRevision( + session.session_id + ); + if (durable && durable.eventCount > 0) { + if (durable.eventCount !== cursor.pushedCount) return; + if ( + cursor.localContentRevision !== undefined && + cursor.localContentRevision !== durable.revision + ) { + return; + } + // Legacy revisions are upgraded from the server cursor + local count + // proof. Crucially this is independent of Session.updated_at: rename, + // pin and org-access edits are metadata changes and must not trigger a + // multi-GB replay materialization. + localContentRevision = durable.revision; + if (cursor.localContentRevision !== durable.revision) { + this.setCursor({ ...cursor, localContentRevision: durable.revision }); + } + } else if (cursor.localContentUpdatedAt !== session.updated_at) { + return; + } + } else if (cursor.localContentUpdatedAt !== session.updated_at) { + return; + } this.markEventPlaneClean( orgId, session, - this.eventActivityStamps.get(session.session_id) ?? 0 + this.eventActivityStamps.get(session.session_id) ?? 0, + Date.now(), + localContentRevision ); } @@ -433,9 +461,20 @@ export class Org2CloudSessionSync extends Org2CloudSessionSyncState { } return { events }; } + const revisionBefore = + await eventStoreProxy.getPersistedEventRevision(sessionId); const persisted = await eventStoreProxy.getPersistedEvents(sessionId); + const revisionAfter = + await eventStoreProxy.getPersistedEventRevision(sessionId); + const localContentRevision = + revisionBefore && + revisionAfter && + revisionBefore.revision === revisionAfter.revision && + revisionAfter.eventCount === persisted.length + ? revisionAfter.revision + : undefined; if (persisted.length > 0 || !isCliSession(sessionId)) { - return { events: persisted }; + return { events: persisted, localContentRevision }; } // Live CLI sessions keep their transcript of record in the CLI's native // store (account-profile aware) and never write the events cache, so a @@ -717,7 +756,14 @@ export class Org2CloudSessionSync extends Org2CloudSessionSyncState { } return planPromise; }; - return { stampAtRead, mode, baseEventCount, events, plan }; + return { + stampAtRead, + mode, + baseEventCount, + localContentRevision: loaded.localContentRevision, + events, + plan, + }; } private async computeFrozenHashAtCount( @@ -809,7 +855,7 @@ export class Org2CloudSessionSync extends Org2CloudSessionSyncState { await this.loadFullPushEvents(sessionId) ); })(); - this.passPushPrepareCache.set(prepareKey, prepared); + this.cachePreparedPushEvents(prepareKey, prepared); return prepared; } @@ -886,7 +932,16 @@ export class Org2CloudSessionSync extends Org2CloudSessionSyncState { } const cursor = this.getCursor(orgId, sessionId); const prepared = await this.preparePushEventsForPass(sessionId, cursor); - const { stampAtRead, mode, baseEventCount, events } = prepared; + const { stampAtRead, mode, baseEventCount, localContentRevision, events } = + prepared; + const markPreparedClean = () => + this.markEventPlaneClean( + orgId, + session, + stampAtRead, + Date.now(), + localContentRevision + ); if (!cursor && events.length === 0) { await this.upsertMetadataIfChanged( auth, @@ -895,7 +950,7 @@ export class Org2CloudSessionSync extends Org2CloudSessionSyncState { scopeKey, access ); - this.markEventPlaneClean(orgId, session, stampAtRead); + markPreparedClean(); return; } const shrinkKey = `${orgId}:${sessionId}`; @@ -983,7 +1038,7 @@ export class Org2CloudSessionSync extends Org2CloudSessionSyncState { importedReplay, }); } - this.markEventPlaneClean(orgId, session, stampAtRead); + markPreparedClean(); return; } await this.upsertMetadataIfChanged( @@ -1017,7 +1072,7 @@ export class Org2CloudSessionSync extends Org2CloudSessionSyncState { }); } broadcastOrgControlChangedToPeers(orgId, "sessions"); - this.markEventPlaneClean(orgId, session, stampAtRead); + markPreparedClean(); void this.publishTurnIndexBestEffort(auth, orgId, session, stampAtRead); return; } @@ -1079,7 +1134,7 @@ export class Org2CloudSessionSync extends Org2CloudSessionSyncState { // still-valid checkpoint must survive a transiently failed probe. this.setCursor({ ...cursor, frozenChainHash, importedReplay }); } - this.markEventPlaneClean(orgId, session, stampAtRead); + markPreparedClean(); return; } await this.upsertMetadataIfChanged( @@ -1114,7 +1169,7 @@ export class Org2CloudSessionSync extends Org2CloudSessionSyncState { } ); broadcastOrgControlChangedToPeers(orgId, "sessions"); - this.markEventPlaneClean(orgId, session, stampAtRead); + markPreparedClean(); void this.publishTurnIndexBestEffort( auth, orgId, @@ -1137,7 +1192,7 @@ export class Org2CloudSessionSync extends Org2CloudSessionSyncState { importedReplay, newEpoch: null, }); - this.markEventPlaneClean(orgId, session, stampAtRead); + markPreparedClean(); void this.publishTurnIndexBestEffort( auth, orgId, @@ -1161,7 +1216,7 @@ export class Org2CloudSessionSync extends Org2CloudSessionSyncState { importedReplay, newEpoch: cursor.epoch + 1, }); - this.markEventPlaneClean(orgId, session, stampAtRead); + markPreparedClean(); void this.publishTurnIndexBestEffort(auth, orgId, session, stampAtRead); return; } @@ -1179,7 +1234,7 @@ export class Org2CloudSessionSync extends Org2CloudSessionSyncState { importedReplay, newEpoch: 1, }); - this.markEventPlaneClean(orgId, session, stampAtRead); + markPreparedClean(); void this.publishTurnIndexBestEffort(auth, orgId, session, stampAtRead); } diff --git a/src/features/Org2Cloud/org2CloudSessionSync.types.ts b/src/features/Org2Cloud/org2CloudSessionSync.types.ts index 52d5bfdfed..5e7ffb64dc 100644 --- a/src/features/Org2Cloud/org2CloudSessionSync.types.ts +++ b/src/features/Org2Cloud/org2CloudSessionSync.types.ts @@ -44,6 +44,8 @@ export interface PreparedPushEvents { mode: "full" | "incremental"; /** Absolute count of validated events omitted from `events`. */ baseEventCount: number; + /** Durable native-cache revision covered by this materialization. */ + localContentRevision?: number; events: SessionEvent[]; plan(): Promise; } diff --git a/src/features/Org2Cloud/org2CloudSyncAtoms.ts b/src/features/Org2Cloud/org2CloudSyncAtoms.ts index d9dc5ec730..8f017346a3 100644 --- a/src/features/Org2Cloud/org2CloudSyncAtoms.ts +++ b/src/features/Org2Cloud/org2CloudSyncAtoms.ts @@ -56,6 +56,19 @@ export interface CollabSessionPushCursor { frozenChainHash: string; /** segment_hash of the last pushed tail (null = tail was empty). */ tailHash: string | null; + /** + * Revision of the durable native event cache covered by this cursor. + * Unlike Session.updated_at, this changes only when transcript rows change. + */ + localContentRevision?: number; + /** + * Local session content version covered by this cursor. On restart, a + * matching remote summary plus this stamp proves that neither the native + * EventStore nor an imported transcript needs to be materialized again. + * Optional for upgrade safety: legacy cursors pay one authoritative read + * and are stamped after that successful pass. + */ + localContentUpdatedAt?: string; /** * Source-local checkpoint for bounded imported-history refreshes. It is * optional so existing/native cursors retain their current wire behavior; @@ -126,6 +139,8 @@ const CloudPushCursorSchema = z.object({ frozenEventCount: z.number(), frozenChainHash: z.string(), tailHash: z.string().nullable(), + localContentRevision: z.number().int().nonnegative().optional(), + localContentUpdatedAt: z.string().optional(), importedReplay: z .object({ version: z.literal(1), diff --git a/src/features/Org2Cloud/org2CloudSyncEngine.sessionColdStart.ts b/src/features/Org2Cloud/org2CloudSyncEngine.sessionColdStart.ts index a4fe2b993c..8406fd84d3 100644 --- a/src/features/Org2Cloud/org2CloudSyncEngine.sessionColdStart.ts +++ b/src/features/Org2Cloud/org2CloudSyncEngine.sessionColdStart.ts @@ -36,7 +36,7 @@ export class Org2CloudSessionColdStart { orgId: string, generation: number, isCurrentGeneration: (generation: number) => boolean - ): Promise | undefined> { + ): Promise | undefined | null> { if (this.hydratedOrgIds.has(orgId)) return undefined; try { const result = await this.client.listOrgSessions(auth.accessToken, orgId); @@ -52,7 +52,11 @@ export class Org2CloudSessionColdStart { `cloud session summary hydration failed for org ${orgId}:`, error ); - return undefined; + // Distinguish a failed prerequisite from an already-hydrated org. The + // caller must not materialize local transcripts while the network is + // unavailable merely to discover that their eventual upload also + // cannot run; reconnect/visibility/user events will retry this read. + return null; } } } diff --git a/src/features/Org2Cloud/org2CloudSyncEngine.sessions.test.ts b/src/features/Org2Cloud/org2CloudSyncEngine.sessions.test.ts index 98eaacf2f8..6a3bf408ca 100644 --- a/src/features/Org2Cloud/org2CloudSyncEngine.sessions.test.ts +++ b/src/features/Org2Cloud/org2CloudSyncEngine.sessions.test.ts @@ -16,6 +16,7 @@ import { eventStoreMock, makeEvent, messageMock, + notifyScopeKeysResolved, notifySessionEvents, peekMock, primeMock, @@ -679,6 +680,29 @@ describe("Org2CloudSyncEngine session publishing", () => { expect(client.upsertSessionMetadata).not.toHaveBeenCalled(); }); + it("does not materialize local histories when cold-start summary hydration is offline", async () => { + client.listOrgSessions.mockRejectedValueOnce(new Error("offline")); + eventStoreMock.getPersistedEvents.mockClear(); + + await engine.runSyncPass(); + + expect(eventStoreMock.getPersistedEvents).not.toHaveBeenCalled(); + expect(client.upsertSessionMetadata).not.toHaveBeenCalled(); + expect(client.rewriteSessionEvents).not.toHaveBeenCalled(); + }); + + it("runs one event-driven pass when a repository identity finishes resolving", async () => { + await vi.advanceTimersByTimeAsync(0); + await engine.runSyncPassAndWaitForDrain(); + const passCount = engine.startedPassCount; + + notifyScopeKeysResolved(); + notifyScopeKeysResolved(); + await vi.advanceTimersByTimeAsync(1_000); + + expect(engine.startedPassCount).toBe(passCount + 1); + }); + it("never pushes a tagged out-of-scope session and drops the stale tag", async () => { // Scope is the HARD boundary: the org's scope does NOT match the // session's repo, so the tag must not cause a push — instead the engine diff --git a/src/features/Org2Cloud/org2CloudSyncEngine.testUtils.ts b/src/features/Org2Cloud/org2CloudSyncEngine.testUtils.ts index c76c49e999..ee029d3dc0 100644 --- a/src/features/Org2Cloud/org2CloudSyncEngine.testUtils.ts +++ b/src/features/Org2Cloud/org2CloudSyncEngine.testUtils.ts @@ -16,9 +16,11 @@ import { chatPanelSelectedCloudOrgAtom } from "@src/store/ui/chatPanelAtom"; import { createInstrumentedStore } from "@src/util/core/state/instrumentedStore"; import { + peekMatchingOrgRepoScope, peekShareableScopeKeys, primeShareableScopeKey, resolveMatchingOrgRepoScope, + subscribeShareableScopeKeys, } from "../TeamCollaboration/repoScopeResolver"; import { PERSONAL_EXCLUDED_TOKEN, @@ -72,8 +74,9 @@ import { PROJECT_PUSH_RETRY_DELAY_MS, } from "./org2CloudSyncEngine"; -const { tauriEventListeners } = vi.hoisted(() => ({ +const { tauriEventListeners, scopeKeyListeners } = vi.hoisted(() => ({ tauriEventListeners: new Map void>>(), + scopeKeyListeners: new Set<() => void>(), })); export function getTauriEventListeners() { @@ -96,6 +99,8 @@ vi.mock("@src/engines/SessionCore/core/store/EventStoreProxy", () => ({ eventStoreProxy: { subscribe: vi.fn(() => () => undefined), getPersistedEvents: vi.fn(), + countPersistedEvents: vi.fn(), + getPersistedEventRevision: vi.fn(), }, })); @@ -111,10 +116,18 @@ vi.mock("../TeamCollaboration/repoScopeResolver", () => ({ shareableScopeKeysFromRemoteUrls: vi.fn((urls: string[] | undefined) => urls?.length ? [...urls] : null ), + peekMatchingOrgRepoScope: vi.fn( + (keys: string[] | null, scopes: string[] | undefined) => + scopes?.find((scope) => keys?.includes(scope)) ?? null + ), resolveMatchingOrgRepoScope: vi.fn( async (keys: string[] | null, scopes: string[] | undefined) => scopes?.find((scope) => keys?.includes(scope)) ?? null ), + subscribeShareableScopeKeys: vi.fn((listener: () => void) => { + scopeKeyListeners.add(listener); + return () => scopeKeyListeners.delete(listener); + }), })); vi.mock("@src/components/Message", () => ({ @@ -142,9 +155,15 @@ export const eventStoreMock = vi.mocked(eventStoreProxy); export const processChunksRustMock = vi.mocked(processChunksRust); export const peekMock = vi.mocked(peekShareableScopeKeys); export const primeMock = vi.mocked(primeShareableScopeKey); +export const peekMatchingScopeMock = vi.mocked(peekMatchingOrgRepoScope); export const resolveMatchingScopeMock = vi.mocked(resolveMatchingOrgRepoScope); +export const subscribeScopeKeysMock = vi.mocked(subscribeShareableScopeKeys); export const messageMock = vi.mocked(Message); +export function notifyScopeKeysResolved(): void { + for (const listener of scopeKeyListeners) listener(); +} + /** Minimal visibility stub for the engine's browser lifecycle triggers. */ export class DocumentStub extends EventTarget { visibilityState: DocumentVisibilityState = "visible"; @@ -326,6 +345,10 @@ export function createEngineFixture() { peekMock.mockImplementation((path: string) => path === REPO_PATH ? [SCOPE_KEY] : null ); + peekMatchingScopeMock.mockImplementation( + (keys: string[] | null | undefined, scopes: string[] | null | undefined) => + scopes?.find((scope) => keys?.includes(scope)) ?? null + ); client.getOrgRepoScopes.mockImplementation( async (_token: string, orgId: string) => ({ repoScopes: store.get(org2CloudRepoScopesAtom)[orgId] ?? [], @@ -339,6 +362,10 @@ export function createEngineFixture() { makeEvent("e1"), makeEvent("e2", "running"), ]); + eventStoreMock.getPersistedEventRevision.mockResolvedValue({ + eventCount: 2, + revision: 1, + }); processChunksRustMock.mockResolvedValue([]); vi.useFakeTimers(); engine.start(store); diff --git a/src/features/Org2Cloud/org2CloudSyncEngine.ts b/src/features/Org2Cloud/org2CloudSyncEngine.ts index c9dcc31168..c9730226a7 100644 --- a/src/features/Org2Cloud/org2CloudSyncEngine.ts +++ b/src/features/Org2Cloud/org2CloudSyncEngine.ts @@ -63,7 +63,10 @@ import { isImportedHistorySession } from "@src/util/session/sessionDispatch"; import type { ProjectSyncBridge } from "../TeamCollaboration/engine/projectSyncBridge"; import { tauriProjectSyncBridge } from "../TeamCollaboration/engine/projectSyncBridge"; import { getSessionForkedFrom } from "../TeamCollaboration/forkSession"; -import { resolveMatchingOrgRepoScope } from "../TeamCollaboration/repoScopeResolver"; +import { + peekMatchingOrgRepoScope, + subscribeShareableScopeKeys, +} from "../TeamCollaboration/repoScopeResolver"; import { isSessionTaggedToCloudOrg, sessionOrgTagsAtom, @@ -166,11 +169,15 @@ export type { Org2CloudProjectsClientDeps } from "./org2CloudSyncEngine.projects export type { Org2CloudSchemaVersionProbe } from "./org2CloudSyncEngine.schemaGate"; const log = createLogger("Org2CloudSyncEngine"); +const SCOPE_RESOLUTION_DEBOUNCE_MS = 1_000; export class Org2CloudSyncEngine extends Org2CloudSyncLifecycle { /** Last roster version for each locally owned external-history session. */ private readonly externalHistoryRosterVersions = new Map(); + private externalHistoryRosterInitialized = false; private sessionRosterUnsubscribe: (() => void) | null = null; + private scopeResolutionUnsubscribe: (() => void) | null = null; + private scopeResolutionTimer: ReturnType | null = null; /** Per-org entitlement backoff deadlines + notification state, split out * to `Org2CloudOrgBackoffTracker` — see that module for the per-map * rationale (kept together there since a policy signal touches more than @@ -245,12 +252,26 @@ export class Org2CloudSyncEngine extends Org2CloudSyncLifecycle { this.sessionRosterUnsubscribe = store.sub(sessionsAtom, () => { this.captureExternalHistoryRosterActivity(store); }); + this.scopeResolutionUnsubscribe = subscribeShareableScopeKeys(() => { + if (this.scopeResolutionTimer !== null) return; + this.scopeResolutionTimer = setTimeout(() => { + this.scopeResolutionTimer = null; + void this.runSyncPass({ pushSessions: true }); + }, SCOPE_RESOLUTION_DEBOUNCE_MS); + }); } override stop(): void { this.sessionRosterUnsubscribe?.(); this.sessionRosterUnsubscribe = null; + this.scopeResolutionUnsubscribe?.(); + this.scopeResolutionUnsubscribe = null; + if (this.scopeResolutionTimer !== null) { + clearTimeout(this.scopeResolutionTimer); + } + this.scopeResolutionTimer = null; this.externalHistoryRosterVersions.clear(); + this.externalHistoryRosterInitialized = false; super.stop(); } @@ -260,6 +281,7 @@ export class Org2CloudSyncEngine extends Org2CloudSyncLifecycle { * bounded quiet-window trigger used by native event notifications. */ private captureExternalHistoryRosterActivity(store: CloudStore): void { + const shouldNotifyChanges = this.externalHistoryRosterInitialized; const nextVersions = new Map(); const changedSessionIds: string[] = []; for (const session of store.get(sessionsAtom)) { @@ -281,6 +303,10 @@ export class Org2CloudSyncEngine extends Org2CloudSyncLifecycle { for (const [sessionId, version] of nextVersions) { this.externalHistoryRosterVersions.set(sessionId, version); } + this.externalHistoryRosterInitialized = true; + // Bootstrap already schedules the authoritative first pass. Treating the + // initial roster as fresh activity added a redundant 30-second replay. + if (!shouldNotifyChanges) return; if (changedSessionIds.length === 0) return; for (const sessionId of changedSessionIds) { this.noteSessionEventActivity(sessionId); @@ -314,6 +340,10 @@ export class Org2CloudSyncEngine extends Org2CloudSyncLifecycle { this.sessionSync.noteSessionEventActivity(sessionId); } + protected override afterSyncPass(): void { + this.sessionSync.endPass(); + } + protected override async syncAllOrgs( generation: number, options: { pushSessions: boolean } @@ -434,6 +464,7 @@ export class Org2CloudSyncEngine extends Org2CloudSyncLifecycle { generation, (gen) => this.generation === gen ); + if (remoteSummaries === null) continue; for (const session of store.get(sessionsAtom)) { if (this.generation !== generation) return; if (!isCloudPushCandidate(session)) continue; @@ -551,17 +582,16 @@ export class Org2CloudSyncEngine extends Org2CloudSyncLifecycle { // retract the server row if we ever pushed it, then drop the tag so // the org falls out of the target set. scopeKeys null (no git // remote) is out of scope by definition. - const matchedScope = await resolveMatchingOrgRepoScope( - scopeKeys, - scopes - ); + const matchedScope = peekMatchingOrgRepoScope(scopeKeys, scopes); if (matchedScope === undefined) { - // A failed network-identity lookup cannot prove out-of-scope: - // retracting on it flapped rename-family scopes every time the - // identity API blipped. Skip until a lookup answers. - log.info( + // A pending or failed network-identity lookup cannot prove + // out-of-scope. Skip until the resolver's completion event runs one + // coalesced follow-up pass. + log.rateLimited( + `scope-check-deferred-${session.session_id}-${org.orgId}`, + 60_000, `scope check deferred for session ${session.session_id} org ` + - `${org.orgId}: network identity lookup failed this pass` + `${org.orgId}: network identity unresolved this pass` ); continue; } diff --git a/src/features/Org2Cloud/org2CloudSyncLifecycle.ts b/src/features/Org2Cloud/org2CloudSyncLifecycle.ts index 35508ced79..8e3963f025 100644 --- a/src/features/Org2Cloud/org2CloudSyncLifecycle.ts +++ b/src/features/Org2Cloud/org2CloudSyncLifecycle.ts @@ -92,6 +92,8 @@ export abstract class Org2CloudSyncLifecycle { protected abstract clearOrgBackoff(orgId: string): void; protected abstract clearAllOrgBackoffs(): void; protected abstract invalidateFullInboundState(orgId?: string): void; + /** Release pass-scoped resources even when a pass exits early or fails. */ + protected afterSyncPass(): void {} /** * Visibility regain is a one-shot catch-up trigger. There is deliberately @@ -227,6 +229,7 @@ export abstract class Org2CloudSyncLifecycle { code: described.code, }); } finally { + this.afterSyncPass(); this.passRunning = false; if (this.started && this.generation === generation && this.passDirty) { this.passDirty = false; diff --git a/src/features/TeamCollaboration/orgScopeRepoFilter.test.ts b/src/features/TeamCollaboration/orgScopeRepoFilter.test.ts index 9c4ee71765..b60f124ac8 100644 --- a/src/features/TeamCollaboration/orgScopeRepoFilter.test.ts +++ b/src/features/TeamCollaboration/orgScopeRepoFilter.test.ts @@ -156,12 +156,26 @@ describe("repoEligibleForOrgScopedPicker (optimistic)", () => { expect(prime).toHaveBeenCalledWith("/Users/me/org2"); }); - it("hides a resolved out-of-scope or remote-less checkout", () => { + it("keeps a checkout visible while provider network identity is unresolved", () => { expect( repoEligibleForOrgScopedPicker( { fs_uri: "/Users/me/other" }, SCOPES, - () => ["github.com/acme/elsewhere"] + () => ["github.com/acme/elsewhere"], + vi.fn(), + () => undefined + ) + ).toBe(true); + }); + + it("hides a confirmed out-of-scope or remote-less checkout", () => { + expect( + repoEligibleForOrgScopedPicker( + { fs_uri: "/Users/me/other" }, + SCOPES, + () => ["github.com/acme/elsewhere"], + vi.fn(), + () => null ) ).toBe(false); expect( diff --git a/src/features/TeamCollaboration/repoScopeResolver.test.ts b/src/features/TeamCollaboration/repoScopeResolver.test.ts index d87ec65cb1..5fa68cedfb 100644 --- a/src/features/TeamCollaboration/repoScopeResolver.test.ts +++ b/src/features/TeamCollaboration/repoScopeResolver.test.ts @@ -5,6 +5,7 @@ import { resolveGitHubRepoNetworkIdentityLocal } from "@src/api/tauri/github"; import { MAX_RESOLVER_CACHE_ENTRIES, + REPO_NETWORK_LOOKUP_CONCURRENCY, clearShareableScopeKeyCache, peekMatchingOrgRepoScope, peekShareableScopeKey, @@ -341,4 +342,41 @@ describe("GitHub fork-network org scope matching", () => { MAX_RESOLVER_CACHE_ENTRIES + 2 ); }); + + it("bounds concurrent provider identity lookups", async () => { + networkIdentityMock.mockClear(); + let active = 0; + let maxActive = 0; + const releases: Array<() => void> = []; + networkIdentityMock.mockImplementation( + (fullName) => + new Promise((resolve) => { + active += 1; + maxActive = Math.max(maxActive, active); + releases.push(() => { + active -= 1; + resolve({ full_name: fullName, source_full_name: fullName }); + }); + }) + ); + + const lookups = Array.from( + { length: REPO_NETWORK_LOOKUP_CONCURRENCY + 3 }, + (_, index) => resolveRepoNetworkScopeKey(`github.com/acme/cap-${index}`) + ); + await vi.waitFor(() => { + expect(networkIdentityMock).toHaveBeenCalledTimes( + REPO_NETWORK_LOOKUP_CONCURRENCY + ); + }); + expect(maxActive).toBe(REPO_NETWORK_LOOKUP_CONCURRENCY); + + while (releases.length > 0) { + releases.shift()?.(); + await Promise.resolve(); + await Promise.resolve(); + } + await Promise.all(lookups); + expect(maxActive).toBe(REPO_NETWORK_LOOKUP_CONCURRENCY); + }); }); diff --git a/src/features/TeamCollaboration/repoScopeResolver.ts b/src/features/TeamCollaboration/repoScopeResolver.ts index 88e5d11380..64c697da2a 100644 --- a/src/features/TeamCollaboration/repoScopeResolver.ts +++ b/src/features/TeamCollaboration/repoScopeResolver.ts @@ -70,6 +70,9 @@ interface RepoNetworkScopeCacheEntry { const repoNetworkScopeCache = new Map(); const repoNetworkScopeInFlight = new Map>(); +export const REPO_NETWORK_LOOKUP_CONCURRENCY = 4; +let activeRepoNetworkLookups = 0; +const repoNetworkLookupWaiters: Array<() => void> = []; const NETWORK_LOOKUP_FAILURE_TTL_MS = 30_000; /** * Repeated failures back off geometrically (30s → 2m → 8m → 30m cap). An @@ -87,6 +90,23 @@ function networkLookupFailureTtlMs(streak: number): number { return Math.min(ttl, NETWORK_LOOKUP_FAILURE_TTL_MAX_MS); } +async function withRepoNetworkLookupPermit( + operation: () => Promise +): Promise { + if (activeRepoNetworkLookups >= REPO_NETWORK_LOOKUP_CONCURRENCY) { + await new Promise((resolve) => + repoNetworkLookupWaiters.push(resolve) + ); + } + activeRepoNetworkLookups += 1; + try { + return await operation(); + } finally { + activeRepoNetworkLookups -= 1; + repoNetworkLookupWaiters.shift()?.(); + } +} + function readLruEntry(cache: Map, key: K): V | undefined { const value = cache.get(key); if (value === undefined) return undefined; @@ -337,7 +357,9 @@ export async function resolveRepoNetworkScopeKey( const pending = repoNetworkScopeInFlight.get(normalized); if (pending) return pending; - const task = resolveGitHubRepoNetworkIdentityLocal(fullName) + const task = withRepoNetworkLookupPermit(() => + resolveGitHubRepoNetworkIdentityLocal(fullName) + ) .then((identity) => { const sourceKey = normalizeRepoScopeKey( `github.com/${identity.source_full_name}` @@ -413,22 +435,27 @@ export function peekMatchingOrgRepoScope( if (!repoScopeKeys?.length || !orgScopes?.length) return null; let unresolved = false; - for (const repoScopeKey of repoScopeKeys) { - const repoRoot = peekRepoNetworkScopeKey(repoScopeKey); - if (repoRoot === undefined) { + const resolveCachedRoot = (scopeKey: string): string | null | undefined => { + if (peekRepoNetworkLookupFailed(scopeKey)) { unresolved = true; - primeRepoNetworkScopeKey(repoScopeKey); - continue; + return undefined; } + const root = peekRepoNetworkScopeKey(scopeKey); + if (root === undefined) { + unresolved = true; + primeRepoNetworkScopeKey(scopeKey); + } + return root; + }; + // Prime both sides in one pass so repo and org identities share the same + // bounded provider batch instead of resolving in alternating sync waves. + const repoRoots = repoScopeKeys.map(resolveCachedRoot); + const orgRoots = orgScopes.map(resolveCachedRoot); + for (const repoRoot of repoRoots) { if (!repoRoot) continue; - for (const orgScope of orgScopes) { - const orgRoot = peekRepoNetworkScopeKey(orgScope); - if (orgRoot === undefined) { - unresolved = true; - primeRepoNetworkScopeKey(orgScope); - continue; - } - if (orgRoot && repoRoot === orgRoot) return orgScope; + for (let index = 0; index < orgRoots.length; index += 1) { + const orgRoot = orgRoots[index]; + if (orgRoot && repoRoot === orgRoot) return orgScopes[index]!; } } return unresolved ? undefined : null; From f8ada22312319613c78158b8753a34ed75a960c3 Mon Sep 17 00:00:00 2001 From: Neonforge <48338160+Neonforge98@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:25:31 -0700 Subject: [PATCH 5/8] feat(project): move successful work item runs into review --- .../project-management/src/projects/schema.rs | 2 +- .../src/work_run_service/mod.rs | 116 ++++++++++++- .../src/work_run_service/tests.rs | 160 +++++++++++++++++- .../src/work_service/mod.rs | 107 ++++++++++++ 4 files changed, 381 insertions(+), 4 deletions(-) diff --git a/src-tauri/crates/project-management/src/projects/schema.rs b/src-tauri/crates/project-management/src/projects/schema.rs index d9e5634458..83bd1bac17 100644 --- a/src-tauri/crates/project-management/src/projects/schema.rs +++ b/src-tauri/crates/project-management/src/projects/schema.rs @@ -63,7 +63,7 @@ pub fn init_project_tables(conn: &Connection) -> SqliteResult<()> { /// `(actor, operation, scope, key)` per the frozen wire contract §14.4. /// - `pm_work_item_runs`: execution truth kept separate from both Work Item /// lifecycle and Session lifecycle. A terminal Run never implies a terminal -/// Work Item. +/// Work Item; a successful Run may only request human review. /// - `pm_dispatch_outbox`: lease-based at-least-once delivery. The Run service /// and outbox row are always mutated in one transaction. pub fn init_pm_service_tables(conn: &Connection) -> SqliteResult<()> { diff --git a/src-tauri/crates/project-management/src/work_run_service/mod.rs b/src-tauri/crates/project-management/src/work_run_service/mod.rs index 0a9149b1a5..53a18523b9 100644 --- a/src-tauri/crates/project-management/src/work_run_service/mod.rs +++ b/src-tauri/crates/project-management/src/work_run_service/mod.rs @@ -3,7 +3,8 @@ //! This module is the single persistence boundary for execution episodes and //! dispatch delivery. Enqueue writes the Run and outbox row atomically; //! workers claim with expiring leases; every acknowledgement checks the lease -//! token. Work Item lifecycle is intentionally absent from this module. +//! token. Run terminal state never completes product intent; a successful Run +//! only projects the Work Item to `in_review` for explicit human acceptance. use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBehavior}; use sha2::{Digest, Sha256}; @@ -39,6 +40,7 @@ const DEFAULT_LEASE_MS: i64 = 30_000; const MAX_LEASE_MS: i64 = 5 * 60_000; const MAX_RUN_ATTEMPTS: u32 = 10; const PATH_LOCK_TTL_MS: i64 = 7 * 24 * 60 * 60 * 1_000; +const REVIEW_PROJECTION_SETTLED_OPERATION: &str = "work_run.review_projection_settled"; #[derive(Debug)] struct WorkItemExecutionContext { @@ -1447,6 +1449,17 @@ pub fn record_run_terminal( release_path_lock(&tx, run_id)?; db(tx.commit())?; crate::projects::events::notify_work_item_dispatch_ready(); + if existing.status == WorkItemRunStatus::Succeeded { + match review_projection_is_settled(run_id) { + Ok(false) => project_succeeded_run_for_review(&existing), + Ok(true) => {} + Err(error) => tracing::warn!( + run_id, + error = %error, + "failed to read Work Item review projection receipt" + ), + } + } return Ok(existing); } @@ -1509,12 +1522,111 @@ pub fn record_run_terminal( db(tx.commit())?; crate::projects::events::notify_work_item_dispatch_ready(); let persisted = read(run_id)?; + if persisted.status == WorkItemRunStatus::Succeeded { + project_succeeded_run_for_review(&persisted); + } if let Err(err) = crate::work_item_features::subscriptions::notify_run_terminal(&persisted) { - tracing::warn!(run_id = %persisted.id, error = %err, "failed to project Run failure into Inbox"); + tracing::warn!(run_id = %persisted.id, error = %err, "failed to project Run terminal into Inbox"); } Ok(persisted) } +fn project_succeeded_run_for_review(run: &WorkItemRun) { + let projection = match work_service::project_run_success_to_review( + run.project_slug.as_deref(), + &run.org_id, + &run.work_item_id, + run.session_id.as_deref(), + ) { + Ok(projection) => projection, + Err(error) => { + // Run finality is authoritative and must not be rolled back when + // its human-lifecycle projection temporarily fails. A repeated + // terminal reconciliation can retry until a receipt is written. + tracing::warn!( + run_id = %run.id, + work_item_id = %run.work_item_id, + error = %error, + "failed to move successful Work Item Run into review" + ); + return; + } + }; + match projection { + work_service::RunSuccessReviewProjection::Transitioned => { + tracing::info!( + run_id = %run.id, + work_item_id = %run.work_item_id, + "successful Work Item Run is awaiting review" + ); + } + work_service::RunSuccessReviewProjection::AlreadyInReview + | work_service::RunSuccessReviewProjection::PreservedStatus + | work_service::RunSuccessReviewProjection::Superseded => {} + } + + if let Err(error) = mark_review_projection_settled(run, projection) { + tracing::warn!( + run_id = %run.id, + error = %error, + "failed to persist Work Item review projection receipt" + ); + } +} + +fn review_projection_is_settled(run_id: &str) -> Result { + let connection = conn()?; + Ok(db(connection + .query_row( + "SELECT 1 FROM pm_audit_events + WHERE entity_type = 'work_item_run' AND entity_id = ?1 AND operation = ?2 + LIMIT 1", + params![run_id, REVIEW_PROJECTION_SETTLED_OPERATION], + |_| Ok(()), + ) + .optional())? + .is_some()) +} + +fn mark_review_projection_settled( + run: &WorkItemRun, + projection: work_service::RunSuccessReviewProjection, +) -> Result<(), String> { + let mut connection = conn()?; + let tx = db(connection.transaction_with_behavior(TransactionBehavior::Immediate))?; + let exists = db(tx + .query_row( + "SELECT 1 FROM pm_audit_events + WHERE entity_type = 'work_item_run' AND entity_id = ?1 AND operation = ?2 + LIMIT 1", + params![&run.id, REVIEW_PROJECTION_SETTLED_OPERATION], + |_| Ok(()), + ) + .optional())? + .is_some(); + if !exists { + let outcome = match projection { + work_service::RunSuccessReviewProjection::Transitioned => "transitioned", + work_service::RunSuccessReviewProjection::AlreadyInReview => "already_in_review", + work_service::RunSuccessReviewProjection::PreservedStatus => "preserved_status", + work_service::RunSuccessReviewProjection::Superseded => "superseded", + }; + append_audit( + &tx, + &run.id, + REVIEW_PROJECTION_SETTLED_OPERATION, + run.generation as i64, + run.project_slug.as_deref(), + &run.org_id, + serde_json::json!({ + "workItemId": run.work_item_id, + "outcome": outcome, + }), + )?; + } + db(tx.commit()) +} + /// Compatibility lookup for legacy Session-terminal callers. Multiple Runs /// may resume one Session, so only the newest non-terminal episode is chosen. /// New code should use [`record_run_terminal`] with the durable turn intent id. diff --git a/src-tauri/crates/project-management/src/work_run_service/tests.rs b/src-tauri/crates/project-management/src/work_run_service/tests.rs index 9a8be0c199..eb35c09235 100644 --- a/src-tauri/crates/project-management/src/work_run_service/tests.rs +++ b/src-tauri/crates/project-management/src/work_run_service/tests.rs @@ -80,6 +80,22 @@ fn standalone_run_canonicalizes_cloud_org_scope() { .len(), 1 ); + + let lease = claim_next_dispatch("standalone-worker", 30_000) + .expect("claim") + .expect("dispatch"); + acknowledge_dispatch_started(&lease.dispatch_id, &lease.lease_token, "session-cloud-run") + .expect("ack"); + record_run_terminal( + &run.id, + Some("session-cloud-run"), + WorkItemRunTerminalOutcome::Succeeded, + WorkItemRunUsage::default(), + None, + ) + .expect("terminal"); + let item = io::read_standalone_work_item(Some(org_id), "WI-0001").expect("work item"); + assert_eq!(item.frontmatter.status, "in_review"); } #[test] @@ -368,7 +384,7 @@ fn transient_dispatch_failure_defers_but_auth_failure_dead_letters() { } #[test] -fn session_terminal_updates_run_without_completing_work_item() { +fn session_terminal_moves_work_item_to_review_without_completing_it() { let _sandbox = test_env::sandbox(); seed(); let queued = enqueue(request("manual:1")).expect("enqueue"); @@ -393,10 +409,152 @@ fn session_terminal_updates_run_without_completing_work_item() { assert_eq!(terminal.status, WorkItemRunStatus::Succeeded); assert_eq!(terminal.usage.total_tokens, 4321); + let item = io::read_work_item("demo", "AAA-0001").expect("work item"); + assert_eq!(item.frontmatter.status, "in_review"); +} + +#[test] +fn failed_run_does_not_request_review() { + let _sandbox = test_env::sandbox(); + seed(); + let queued = enqueue(request("manual:failed-review")).expect("enqueue"); + let lease = claim_next_dispatch("desktop-1", 30_000) + .expect("claim") + .expect("dispatch"); + acknowledge_dispatch_started( + &lease.dispatch_id, + &lease.lease_token, + "session-failed-review", + ) + .expect("ack"); + + record_run_terminal( + &queued.id, + Some("session-failed-review"), + WorkItemRunTerminalOutcome::Failed, + WorkItemRunUsage::default(), + Some("provider failed"), + ) + .expect("terminal"); + let item = io::read_work_item("demo", "AAA-0001").expect("work item"); assert_eq!(item.frontmatter.status, "backlog"); } +#[test] +fn succeeded_run_preserves_explicitly_completed_work_item() { + let _sandbox = test_env::sandbox(); + seed(); + work_service::transition_project_work_item("demo", "AAA-0001", "in_progress", None, None, None) + .expect("start work"); + work_service::transition_project_work_item("demo", "AAA-0001", "completed", None, None, None) + .expect("complete explicitly"); + + let queued = enqueue(request("manual:completed-preserved")).expect("enqueue"); + let lease = claim_next_dispatch("desktop-1", 30_000) + .expect("claim") + .expect("dispatch"); + acknowledge_dispatch_started( + &lease.dispatch_id, + &lease.lease_token, + "session-completed-preserved", + ) + .expect("ack"); + record_run_terminal( + &queued.id, + Some("session-completed-preserved"), + WorkItemRunTerminalOutcome::Succeeded, + WorkItemRunUsage::default(), + None, + ) + .expect("terminal"); + + let item = io::read_work_item("demo", "AAA-0001").expect("work item"); + assert_eq!(item.frontmatter.status, "completed"); +} + +#[test] +fn stale_succeeded_run_does_not_override_a_newer_execution_claim() { + let _sandbox = test_env::sandbox(); + seed(); + work_service::claim_project_work_item( + "demo", + "AAA-0001", + "session-newer", + Some("coding"), + crate::projects::types::WorkItemExecutionLockReason::ManualStart, + None, + None, + ) + .expect("newer claim"); + + let queued = enqueue(request("manual:stale-success")).expect("enqueue"); + let lease = claim_next_dispatch("desktop-1", 30_000) + .expect("claim") + .expect("dispatch"); + acknowledge_dispatch_started(&lease.dispatch_id, &lease.lease_token, "session-older") + .expect("ack"); + record_run_terminal( + &queued.id, + Some("session-older"), + WorkItemRunTerminalOutcome::Succeeded, + WorkItemRunUsage::default(), + None, + ) + .expect("terminal"); + + let item = io::read_work_item("demo", "AAA-0001").expect("work item"); + assert_eq!(item.frontmatter.status, "in_progress"); + assert_eq!( + item.frontmatter + .execution_lock + .and_then(|lock| lock.active_session_id) + .as_deref(), + Some("session-newer") + ); +} + +#[test] +fn duplicate_terminal_does_not_reapply_review_after_human_reopens_work() { + let _sandbox = test_env::sandbox(); + seed(); + let queued = enqueue(request("manual:terminal-replay")).expect("enqueue"); + let lease = claim_next_dispatch("desktop-1", 30_000) + .expect("claim") + .expect("dispatch"); + acknowledge_dispatch_started(&lease.dispatch_id, &lease.lease_token, "session-replay") + .expect("ack"); + record_run_terminal( + &queued.id, + Some("session-replay"), + WorkItemRunTerminalOutcome::Succeeded, + WorkItemRunUsage::default(), + None, + ) + .expect("terminal"); + work_service::transition_project_work_item( + "demo", + "AAA-0001", + "in_progress", + Some("changes requested"), + None, + None, + ) + .expect("human reopens work"); + + record_run_terminal( + &queued.id, + Some("session-replay"), + WorkItemRunTerminalOutcome::Succeeded, + WorkItemRunUsage::default(), + None, + ) + .expect("duplicate terminal"); + + let item = io::read_work_item("demo", "AAA-0001").expect("work item"); + assert_eq!(item.frontmatter.status, "in_progress"); +} + #[test] fn turn_can_finish_before_dispatch_ack_without_losing_finality() { let _sandbox = test_env::sandbox(); diff --git a/src-tauri/crates/project-management/src/work_service/mod.rs b/src-tauri/crates/project-management/src/work_service/mod.rs index 47ccd47b15..4e38e8f537 100644 --- a/src-tauri/crates/project-management/src/work_service/mod.rs +++ b/src-tauri/crates/project-management/src/work_service/mod.rs @@ -63,6 +63,113 @@ use crate::projects::types::{ WorkItemHandoff, WorkItemMutationActor, WorkItemSchedule, }; +/// Result of projecting a successful execution episode onto the human-owned +/// Work Item lifecycle. A successful Run is ready for verification, not +/// automatically accepted as completed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RunSuccessReviewProjection { + Transitioned, + AlreadyInReview, + PreservedStatus, + Superseded, +} + +const RUN_REVIEW_ALREADY: &str = "PM_RUN_REVIEW:ALREADY_IN_REVIEW"; +const RUN_REVIEW_PRESERVE: &str = "PM_RUN_REVIEW:PRESERVE_STATUS"; +const RUN_REVIEW_SUPERSEDED: &str = "PM_RUN_REVIEW:SUPERSEDED"; + +fn apply_run_success_review_projection( + frontmatter: &mut WorkItemFrontmatter, + terminal_session_id: Option<&str>, +) -> Result<(), String> { + if let Some(lock) = frontmatter.execution_lock.as_ref() { + match (lock.active_session_id.as_deref(), terminal_session_id) { + (Some(active), Some(terminal)) if active == terminal => {} + // A newer Session or an Agent Org still owns execution. A stale + // terminal must not move the Work Item out from under it. + _ => return Err(RUN_REVIEW_SUPERSEDED.to_string()), + } + } + + match frontmatter.status.as_str() { + "backlog" | "planned" | "in_progress" => { + frontmatter.status = "in_review".to_string(); + frontmatter.execution_lock = None; + frontmatter.updated_at = chrono::Utc::now().to_rfc3339(); + Ok(()) + } + "in_review" => Err(RUN_REVIEW_ALREADY.to_string()), + // Completed/cancelled items are explicit human decisions. Provider + // statuses (`open`/`closed`) and custom workflow states also retain + // their native semantics rather than being rewritten to an ORGII + // status by a background execution callback. + _ => Err(RUN_REVIEW_PRESERVE.to_string()), + } +} + +fn review_projection_outcome(error: String) -> Result { + match error.as_str() { + RUN_REVIEW_ALREADY => Ok(RunSuccessReviewProjection::AlreadyInReview), + RUN_REVIEW_PRESERVE => Ok(RunSuccessReviewProjection::PreservedStatus), + RUN_REVIEW_SUPERSEDED => Ok(RunSuccessReviewProjection::Superseded), + _ => Err(error), + } +} + +/// Move a successfully executed native Work Item into `in_review` through +/// the canonical atomic mutation path. This keeps Run finality separate from +/// product acceptance: success requests review, while only an explicit work +/// transition may mark the item completed. +/// +/// The terminal Session id is checked against the execution lock so a late +/// callback from an older Run cannot overwrite a newer active execution. +pub fn project_run_success_to_review( + project_slug: Option<&str>, + org_id: &str, + short_id: &str, + terminal_session_id: Option<&str>, +) -> Result { + let terminal_session_id = terminal_session_id.map(str::to_string); + let mutation = match project_slug { + Some(project_slug) => project_io::update_work_item_atomic_serviced( + project_slug, + short_id, + None, + project_io::AtomicServiceOptions { + operation: Some("work.run_succeeded"), + strict_fsm: true, + reason: Some("execution succeeded; awaiting review".to_string()), + ..Default::default() + }, + move |frontmatter, _body| { + apply_run_success_review_projection(frontmatter, terminal_session_id.as_deref()) + }, + ), + None => project_io::update_standalone_work_item_atomic_serviced( + Some(org_id), + None, + project_io::AtomicServiceOptions { + operation: Some("work.run_succeeded"), + strict_fsm: true, + reason: Some("execution succeeded; awaiting review".to_string()), + ..Default::default() + }, + short_id, + move |frontmatter, _body| { + apply_run_success_review_projection(frontmatter, terminal_session_id.as_deref()) + }, + ), + }; + + match mutation { + Ok(()) => { + crate::projects::events::notify_data_changed(); + Ok(RunSuccessReviewProjection::Transitioned) + } + Err(error) => review_projection_outcome(error), + } +} + /// Typed error sentinels understood by upper layers. pub mod error { pub const PREFIX: &str = "PM_ERR:"; From 9e507da93edc02cc665ebc9bb4320599551ee6ed Mon Sep 17 00:00:00 2001 From: Neonforge <48338160+Neonforge98@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:41:55 -0700 Subject: [PATCH 6/8] test(project): stabilize GitHub issue SSR coverage --- .../components/GitHubIssueThreadSurface.test.ts | 14 ++++++++++++++ .../IssueDetailExternalLinkButton.test.ts | 10 ++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/modules/ProjectManager/WorkItems/components/GitHubIssueThreadSurface.test.ts b/src/modules/ProjectManager/WorkItems/components/GitHubIssueThreadSurface.test.ts index 3e2b580995..36ce28e4a5 100644 --- a/src/modules/ProjectManager/WorkItems/components/GitHubIssueThreadSurface.test.ts +++ b/src/modules/ProjectManager/WorkItems/components/GitHubIssueThreadSurface.test.ts @@ -10,12 +10,26 @@ import GitHubIssueThreadSurface, { import type { GitHubIssueInteractionConfig } from "./WorkItemContent/types"; import { toggleExternalAssigneeIds } from "./WorkItemProperties/AssigneePropertyField"; +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + vi.mock("@src/components/IntegrationIcon", () => ({ default: ({ type }: { type: string }) => React.createElement("span", { "data-integration-icon": type }), })); +// The product renderer is lazy-loaded behind Suspense. These server-rendered +// structure tests need a synchronous leaf so React 19 does not abort static +// markup generation while the dynamic Markdown chunk is loading. +vi.mock("@src/components/MarkDown", () => ({ + default: ({ textContent }: { textContent: string }) => + React.createElement("div", { "data-testid": "markdown" }, textContent), +})); + vi.mock("@src/modules/shared/components/RichMarkdownEditor", () => ({ + RICH_MARKDOWN_COMPOSER_TOOLBAR_CLASS: + "!min-h-0 !border-b-0 !pb-0.5 [&_svg]:size-3.5", default: ({ dataTestId }: { dataTestId?: string }) => React.createElement("div", { "data-testid": dataTestId }), })); diff --git a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/IssuesContent/__tests__/IssueDetailExternalLinkButton.test.ts b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/IssuesContent/__tests__/IssueDetailExternalLinkButton.test.ts index cbdb12524e..b4246f1b48 100644 --- a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/IssuesContent/__tests__/IssueDetailExternalLinkButton.test.ts +++ b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/IssuesContent/__tests__/IssueDetailExternalLinkButton.test.ts @@ -33,7 +33,17 @@ vi.mock("@src/components/IntegrationIcon", () => ({ createElement("span", { "data-integration-icon": type }), })); +// The product renderer is lazy-loaded behind Suspense. These server-rendered +// structure tests need a synchronous leaf so React 19 does not abort static +// markup generation while the dynamic Markdown chunk is loading. +vi.mock("@src/components/MarkDown", () => ({ + default: ({ textContent }: { textContent: string }) => + createElement("div", { "data-testid": "markdown" }, textContent), +})); + vi.mock("@src/modules/shared/components/RichMarkdownEditor", () => ({ + RICH_MARKDOWN_COMPOSER_TOOLBAR_CLASS: + "!min-h-0 !border-b-0 !pb-0.5 [&_svg]:size-3.5", default: forwardRef(function MockRichMarkdownEditor( { appearance, From bf98b55c2ca83cb618b2c118b9c4c57cfa95d998 Mon Sep 17 00:00:00 2001 From: Neonforge <48338160+Neonforge98@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:45:38 -0700 Subject: [PATCH 7/8] fix(updater): keep local branch builds pinned --- scripts/tauri/build-fast-local.cjs | 4 ++++ scripts/tauri/build-fast-open.cjs | 4 ++++ scripts/tauri/build-fast-parallel.cjs | 16 +++++++++----- src-tauri/src/app_update.rs | 31 +++++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/scripts/tauri/build-fast-local.cjs b/scripts/tauri/build-fast-local.cjs index c780c86bf8..ed42685800 100644 --- a/scripts/tauri/build-fast-local.cjs +++ b/scripts/tauri/build-fast-local.cjs @@ -100,6 +100,10 @@ function createPnpmExecCommand(binaryName, args) { } const configOverride = JSON.stringify({ + plugins: { + // Local artifacts must not install a published release over themselves. + updater: { active: false }, + }, build: { beforeBuildCommand: "webpack --mode production", }, diff --git a/scripts/tauri/build-fast-open.cjs b/scripts/tauri/build-fast-open.cjs index bae00dc2d4..b8fa1b0f4c 100644 --- a/scripts/tauri/build-fast-open.cjs +++ b/scripts/tauri/build-fast-open.cjs @@ -119,6 +119,10 @@ const appPath = path.join(targetDir, "dev-build/bundle/macos/ORG2.app"); const binaryPath = path.join(targetDir, "dev-build/org2"); const configOverride = JSON.stringify({ + plugins: { + // Keep the explicitly opened branch build pinned to its local artifact. + updater: { active: false }, + }, bundle: { createUpdaterArtifacts: false, }, diff --git a/scripts/tauri/build-fast-parallel.cjs b/scripts/tauri/build-fast-parallel.cjs index 5ed8dbfbb1..9ea9c9e43b 100644 --- a/scripts/tauri/build-fast-parallel.cjs +++ b/scripts/tauri/build-fast-parallel.cjs @@ -285,14 +285,20 @@ async function main() { ? { productName: instanceProfile.productName, identifier: instanceProfile.identifier, - plugins: { + } + : {}), + plugins: { + ...(instanceProfile + ? { "deep-link": { desktop: { schemes: instanceProfile.deepLinkSchemes }, }, - updater: { active: false }, - }, - } - : {}), + } + : {}), + // A branch build must never replace itself with a published release. + // Production builds continue to use the updater from tauri.conf.json. + updater: { active: false }, + }, build: { // Empty string = skip beforeBuildCommand; artifacts already on disk. beforeBuildCommand: "", diff --git a/src-tauri/src/app_update.rs b/src-tauri/src/app_update.rs index 3ac09a8575..fd0dc5337d 100644 --- a/src-tauri/src/app_update.rs +++ b/src-tauri/src/app_update.rs @@ -61,12 +61,23 @@ pub struct UpdateMetadata { raw_json: serde_json::Value, } +fn updater_is_active(config: Option<&serde_json::Value>) -> bool { + config + .and_then(|value| value.get("active")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(true) +} + #[tauri::command] pub async fn check_app_update( webview: Webview, channel: UpdateChannel, timeout_ms: Option, ) -> Result, String> { + if !updater_is_active(webview.config().plugins.0.get("updater")) { + return Ok(None); + } + let endpoint = Url::parse(channel.manifest_url()).map_err(|err| err.to_string())?; let mut builder = webview @@ -92,3 +103,23 @@ pub async fn check_app_update( rid: webview.resources_table().add(update), })) } + +#[cfg(test)] +mod tests { + use super::updater_is_active; + + #[test] + fn explicit_inactive_config_disables_channel_checks() { + let config = serde_json::json!({ "active": false }); + assert!(!updater_is_active(Some(&config))); + } + + #[test] + fn missing_active_flag_keeps_release_updates_enabled() { + let config = serde_json::json!({ + "endpoints": ["https://example.com/latest.json"] + }); + assert!(updater_is_active(Some(&config))); + assert!(updater_is_active(None)); + } +} From 5f4764b69c6c9a8edc450405c6c005a40584d24e Mon Sep 17 00:00:00 2001 From: Neonforge <48338160+Neonforge98@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:55:08 -0700 Subject: [PATCH 8/8] Revert "fix(updater): keep local branch builds pinned" This reverts commit bf98b55c2ca83cb618b2c118b9c4c57cfa95d998. --- scripts/tauri/build-fast-local.cjs | 4 ---- scripts/tauri/build-fast-open.cjs | 4 ---- scripts/tauri/build-fast-parallel.cjs | 16 +++++--------- src-tauri/src/app_update.rs | 31 --------------------------- 4 files changed, 5 insertions(+), 50 deletions(-) diff --git a/scripts/tauri/build-fast-local.cjs b/scripts/tauri/build-fast-local.cjs index ed42685800..c780c86bf8 100644 --- a/scripts/tauri/build-fast-local.cjs +++ b/scripts/tauri/build-fast-local.cjs @@ -100,10 +100,6 @@ function createPnpmExecCommand(binaryName, args) { } const configOverride = JSON.stringify({ - plugins: { - // Local artifacts must not install a published release over themselves. - updater: { active: false }, - }, build: { beforeBuildCommand: "webpack --mode production", }, diff --git a/scripts/tauri/build-fast-open.cjs b/scripts/tauri/build-fast-open.cjs index b8fa1b0f4c..bae00dc2d4 100644 --- a/scripts/tauri/build-fast-open.cjs +++ b/scripts/tauri/build-fast-open.cjs @@ -119,10 +119,6 @@ const appPath = path.join(targetDir, "dev-build/bundle/macos/ORG2.app"); const binaryPath = path.join(targetDir, "dev-build/org2"); const configOverride = JSON.stringify({ - plugins: { - // Keep the explicitly opened branch build pinned to its local artifact. - updater: { active: false }, - }, bundle: { createUpdaterArtifacts: false, }, diff --git a/scripts/tauri/build-fast-parallel.cjs b/scripts/tauri/build-fast-parallel.cjs index 9ea9c9e43b..5ed8dbfbb1 100644 --- a/scripts/tauri/build-fast-parallel.cjs +++ b/scripts/tauri/build-fast-parallel.cjs @@ -285,20 +285,14 @@ async function main() { ? { productName: instanceProfile.productName, identifier: instanceProfile.identifier, - } - : {}), - plugins: { - ...(instanceProfile - ? { + plugins: { "deep-link": { desktop: { schemes: instanceProfile.deepLinkSchemes }, }, - } - : {}), - // A branch build must never replace itself with a published release. - // Production builds continue to use the updater from tauri.conf.json. - updater: { active: false }, - }, + updater: { active: false }, + }, + } + : {}), build: { // Empty string = skip beforeBuildCommand; artifacts already on disk. beforeBuildCommand: "", diff --git a/src-tauri/src/app_update.rs b/src-tauri/src/app_update.rs index fd0dc5337d..3ac09a8575 100644 --- a/src-tauri/src/app_update.rs +++ b/src-tauri/src/app_update.rs @@ -61,23 +61,12 @@ pub struct UpdateMetadata { raw_json: serde_json::Value, } -fn updater_is_active(config: Option<&serde_json::Value>) -> bool { - config - .and_then(|value| value.get("active")) - .and_then(serde_json::Value::as_bool) - .unwrap_or(true) -} - #[tauri::command] pub async fn check_app_update( webview: Webview, channel: UpdateChannel, timeout_ms: Option, ) -> Result, String> { - if !updater_is_active(webview.config().plugins.0.get("updater")) { - return Ok(None); - } - let endpoint = Url::parse(channel.manifest_url()).map_err(|err| err.to_string())?; let mut builder = webview @@ -103,23 +92,3 @@ pub async fn check_app_update( rid: webview.resources_table().add(update), })) } - -#[cfg(test)] -mod tests { - use super::updater_is_active; - - #[test] - fn explicit_inactive_config_disables_channel_checks() { - let config = serde_json::json!({ "active": false }); - assert!(!updater_is_active(Some(&config))); - } - - #[test] - fn missing_active_flag_keeps_release_updates_enabled() { - let config = serde_json::json!({ - "endpoints": ["https://example.com/latest.json"] - }); - assert!(updater_is_active(Some(&config))); - assert!(updater_is_active(None)); - } -}