From eb7160058747c4f699bf186de710e0831fd9182a Mon Sep 17 00:00:00 2001 From: Neonforge <48338160+Neonforge98@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:28:58 +0800 Subject: [PATCH 1/3] feat(pm): expand work item domain and project surfaces Add revision-aware work items, custom properties, statuses, saved views, quick actions, routines, work timelines, and their desktop UI/API contracts. --- .../schemas/routine.schema.json | 25 +- .../orgtrack-pm-cli/src/commands/routine.rs | 69 +- .../orgtrack-pm-cli/src/commands/work/mod.rs | 3 +- .../orgtrack-pm-cli/src/commands/work/note.rs | 3 + .../src/commands/work/query.rs | 106 +- .../src/commands/work/transition.rs | 32 +- .../crates/orgtrack-pm-cli/src/context.rs | 7 + src-tauri/crates/orgtrack-pm-cli/src/main.rs | 14 +- .../crates/orgtrack-pm-cli/tests/cli_e2e.rs | 56 + .../crates/project-management/src/lib.rs | 1 + .../project-management/src/org_skills/mod.rs | 462 ++++ .../src/org_skills/tests.rs | 101 + .../src/projects/commands/routines.rs | 68 +- .../src/projects/commands/work_items.rs | 24 +- .../src/projects/io/git_folder_sync.rs | 1 + .../project-management/src/projects/io/mod.rs | 18 +- .../src/projects/io/routines.rs | 80 +- .../src/projects/io/routines_tests.rs | 74 +- .../src/projects/io/work_items/atomic.rs | 3 +- .../src/projects/io/work_items/atomic/diff.rs | 26 +- .../projects/io/work_items/atomic/engine.rs | 154 +- .../projects/io/work_items/atomic/partial.rs | 149 +- .../projects/io/work_items/atomic_tests.rs | 123 ++ .../src/projects/io/work_items/crud.rs | 90 +- .../src/projects/io/work_items/enrichment.rs | 1 + .../src/projects/io/work_items/mapping.rs | 3 + .../src/projects/io/work_items/mod.rs | 9 +- .../src/projects/io/work_items/views.rs | 110 +- .../project-management/src/projects/schema.rs | 139 +- .../src/projects/sync_export.rs | 1 + .../src/projects/types/enriched.rs | 1 + .../src/projects/types/project.rs | 14 + .../src/projects/types/routines.rs | 9 +- .../src/projects/types/views.rs | 2 + .../src/projects/types/work_items.rs | 5 + .../src/projects/types/work_runs.rs | 54 + .../src/routine_service/convert.rs | 209 +- .../src/routine_service/legacy_bridge.rs | 606 ++++++ .../src/routine_service/mod.rs | 1243 ++++++++++- .../src/routine_service/spec.rs | 77 +- .../src/routine_service/tests.rs | 877 +++++++- .../src/sync/collab_bridge/apply.rs | 8 + .../src/sync/collab_bridge/mod.rs | 5 +- .../src/sync/collab_bridge/outbox.rs | 309 ++- .../src/team_inbox/commands.rs | 46 + .../project-management/src/team_inbox/mod.rs | 3 +- .../src/team_inbox/schema.rs | 7 + .../src/team_inbox/store.rs | 282 ++- .../src/team_inbox/tests.rs | 278 ++- .../src/team_inbox/types.rs | 11 + .../src/work_item_features/commands.rs | 244 ++- .../src/work_item_features/discussion.rs | 581 +++++- .../src/work_item_features/mod.rs | 12 + .../src/work_item_features/properties.rs | 320 ++- .../src/work_item_features/quick_actions.rs | 475 +++++ .../src/work_item_features/routine_webhook.rs | 77 +- .../src/work_item_features/saved_views.rs | 319 +++ .../src/work_item_features/statuses.rs | 693 ++++++ .../src/work_item_features/subscriptions.rs | 228 +- .../src/work_item_features/tests.rs | 1857 ++++++++++++++++- .../src/work_item_features/types.rs | 38 + .../src/work_run_service/enqueue.rs | 9 +- .../src/work_run_service/mod.rs | 35 + .../src/work_run_service/terminal.rs | 283 ++- .../src/work_run_service/tests.rs | 93 + .../src/work_service/error.rs | 5 +- .../src/work_service/mod.rs | 1 + .../src/work_service/notes.rs | 69 +- .../src/work_service/tests.rs | 152 +- .../src/work_service/timeline.rs | 134 ++ src/api/http/project/adapters.test.ts | 73 +- src/api/http/project/adapters.ts | 55 +- src/api/http/project/client/discussions.ts | 35 + src/api/http/project/client/index.ts | 3 + src/api/http/project/client/quickActions.ts | 45 + src/api/http/project/client/savedViews.ts | 29 + .../http/project/client/statusDefinitions.ts | 41 + .../http/project/client/workItemProperties.ts | 32 + src/api/http/project/client/workItems.test.ts | 48 + src/api/http/project/client/workItems.ts | 35 +- src/api/http/project/index.ts | 24 + src/api/http/project/revisionConflict.test.ts | 24 + src/api/http/project/revisionConflict.ts | 27 + src/api/http/project/types/common.ts | 13 + src/api/http/project/types/routines.ts | 28 +- .../http/project/types/workItemFeatures.ts | 117 +- src/api/http/project/types/workItems.ts | 5 + src/api/http/project/types/workRuns.ts | 14 + src/components/Table/index.tsx | 40 +- src/components/Table/types.ts | 8 + src/features/KanbanBoard/config.ts | 10 + src/i18n/locales/de/projects.json | 17 +- src/i18n/locales/en/projects.json | 94 +- src/i18n/locales/es/projects.json | 17 +- src/i18n/locales/fr/projects.json | 17 +- src/i18n/locales/ja/projects.json | 17 +- src/i18n/locales/ko/projects.json | 17 +- src/i18n/locales/pl/projects.json | 17 +- src/i18n/locales/pt/projects.json | 17 +- src/i18n/locales/ru/projects.json | 17 +- src/i18n/locales/tr/projects.json | 17 +- src/i18n/locales/vi/projects.json | 17 +- src/i18n/locales/zh-Hant/projects.json | 15 +- src/i18n/locales/zh/projects.json | 94 +- .../ProjectManager/LinearProjects/utils.ts | 1 + ...ProjectWorkItemsTabContentInteractions.tsx | 3 +- .../components/BatchPropertyDialog.tsx | 218 ++ .../components/BatchQuickFieldDialog.tsx | 159 ++ .../EmbeddedWorkItemDetail/index.tsx | 16 +- .../components/PropertyFilterControl.tsx | 133 ++ .../components/RevisionConflictModal.test.ts | 128 ++ .../components/RevisionConflictModal.tsx | 86 + .../components/SavedViewsControl.tsx | 347 +++ .../CustomPropertiesSection.tsx | 95 +- .../components/WorkItemContent/HistoryTab.tsx | 193 +- .../WorkItemContent/QuickActionsSection.tsx | 420 ++++ .../__tests__/CustomPropertiesSection.test.ts | 1 + .../WorkItemDescriptionEditing.test.ts | 2 + .../__tests__/customPropertiesModel.test.ts | 56 + .../__tests__/presentation.test.ts | 16 +- .../__tests__/quickActionDraft.test.ts | 49 + .../WorkItemContent/customPropertiesModel.ts | 34 + .../hooks/useWorkItemContentState.tsx | 231 +- .../components/WorkItemContent/index.tsx | 199 +- .../WorkItemContent/presentation.ts | 8 + .../WorkItemContent/quickActionDraft.ts | 41 + .../components/WorkItemContent/types.ts | 11 +- .../WorkItemContent/workItemMentions.ts | 8 +- .../WorkItemDetail/WorkItemDetailBody.tsx | 8 +- .../hooks/usePendingWorkItemUpdates.test.ts | 40 + .../hooks/usePendingWorkItemUpdates.ts | 22 + .../ProjectScopedWorkItemDetailPage.tsx | 89 +- .../StandaloneWorkItemDetailPage.tsx | 181 +- .../__tests__/model.test.ts | 11 + .../components/WorkItemDetailPage/model.ts | 33 +- .../StatusPrioritySection.tsx | 44 +- .../components/WorkItemRow/LeadingCells.tsx | 12 +- .../components/WorkItemSection/index.test.ts | 23 +- .../components/WorkItemSection/index.tsx | 5 +- .../components/WorkItemsFooterBars/index.tsx | 62 + .../__tests__/WorkItemsHeaderContent.test.ts | 1 + .../components/WorkItemsSettings/index.tsx | 15 +- .../subpages/StatusesSection.tsx | 315 +++ .../WorkItemsSettings/subpages/index.ts | 2 + .../WorkItemsStatusFilterSelect.tsx | 4 +- .../components/WorkItemsTabContent/index.tsx | 118 +- .../components/WorkItemsTableView/index.tsx | 486 +++++ .../hooks/__tests__/typeContract.test.ts | 5 + .../__tests__/useStatusDefinitions.test.ts | 62 + .../hooks/__tests__/workItemsData.test.ts | 178 ++ .../WorkItems/hooks/useStatusDefinitions.ts | 112 + .../hooks/useWorkItemPropertyView.ts | 150 ++ .../WorkItems/hooks/useWorkItems.ts | 3 + .../WorkItems/hooks/useWorkItemsData.ts | 219 +- .../WorkItems/hooks/useWorkItemsHandlers.ts | 1 + .../ProjectManager/WorkItems/index.tsx | 406 +++- .../WorkItems/propertyViewModel.test.ts | 150 ++ .../WorkItems/propertyViewModel.ts | 229 ++ .../WorkItems/savedViewPreferences.test.ts | 127 ++ .../WorkItems/savedViewPreferences.ts | 196 ++ .../WorkItems/store/statusDefinitionsAtom.ts | 9 + src/modules/ProjectManager/WorkItems/types.ts | 5 + .../WorkItems/workItemPartialUpdate.test.ts | 33 +- .../WorkItems/workItemPartialUpdate.ts | 45 +- .../WorkItems/workItemSource.ts | 13 +- .../WorkItems/workItemsViewModel.test.ts | 212 ++ .../WorkItems/workItemsViewModel.ts | 250 ++- .../ProjectManager/config/manage/colors.ts | 2 + .../config/manage/statusOptions.ts | 10 + .../useProjectsWorkItemMenuItems/constants.ts | 1 + src/types/core/project.ts | 1 + src/types/core/viewStatus.ts | 4 + src/types/core/workItem.ts | 11 + src/types/extensions/index.ts | 1 + src/types/extensions/types.ts | 29 +- .../specs/core/work-item-features-ui.spec.mjs | 905 ++++++++ 176 files changed, 18863 insertions(+), 1134 deletions(-) create mode 100644 src-tauri/crates/project-management/src/org_skills/mod.rs create mode 100644 src-tauri/crates/project-management/src/org_skills/tests.rs create mode 100644 src-tauri/crates/project-management/src/routine_service/legacy_bridge.rs create mode 100644 src-tauri/crates/project-management/src/work_item_features/quick_actions.rs create mode 100644 src-tauri/crates/project-management/src/work_item_features/saved_views.rs create mode 100644 src-tauri/crates/project-management/src/work_item_features/statuses.rs create mode 100644 src-tauri/crates/project-management/src/work_service/timeline.rs create mode 100644 src/api/http/project/client/quickActions.ts create mode 100644 src/api/http/project/client/savedViews.ts create mode 100644 src/api/http/project/client/statusDefinitions.ts create mode 100644 src/api/http/project/revisionConflict.test.ts create mode 100644 src/api/http/project/revisionConflict.ts create mode 100644 src/modules/ProjectManager/WorkItems/components/BatchPropertyDialog.tsx create mode 100644 src/modules/ProjectManager/WorkItems/components/BatchQuickFieldDialog.tsx create mode 100644 src/modules/ProjectManager/WorkItems/components/PropertyFilterControl.tsx create mode 100644 src/modules/ProjectManager/WorkItems/components/RevisionConflictModal.test.ts create mode 100644 src/modules/ProjectManager/WorkItems/components/RevisionConflictModal.tsx create mode 100644 src/modules/ProjectManager/WorkItems/components/SavedViewsControl.tsx create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemContent/QuickActionsSection.tsx create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/customPropertiesModel.test.ts create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/quickActionDraft.test.ts create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemContent/customPropertiesModel.ts create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemContent/quickActionDraft.ts create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemDetail/hooks/usePendingWorkItemUpdates.test.ts create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemsSettings/subpages/StatusesSection.tsx create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemsTableView/index.tsx create mode 100644 src/modules/ProjectManager/WorkItems/hooks/__tests__/useStatusDefinitions.test.ts create mode 100644 src/modules/ProjectManager/WorkItems/hooks/useStatusDefinitions.ts create mode 100644 src/modules/ProjectManager/WorkItems/hooks/useWorkItemPropertyView.ts create mode 100644 src/modules/ProjectManager/WorkItems/propertyViewModel.test.ts create mode 100644 src/modules/ProjectManager/WorkItems/propertyViewModel.ts create mode 100644 src/modules/ProjectManager/WorkItems/savedViewPreferences.test.ts create mode 100644 src/modules/ProjectManager/WorkItems/savedViewPreferences.ts create mode 100644 src/modules/ProjectManager/WorkItems/store/statusDefinitionsAtom.ts create mode 100644 tests/e2e/specs/core/work-item-features-ui.spec.mjs diff --git a/docs/orgtrack-pm-protocol/schemas/routine.schema.json b/docs/orgtrack-pm-protocol/schemas/routine.schema.json index 6656ed2d4c..9fce987cf4 100644 --- a/docs/orgtrack-pm-protocol/schemas/routine.schema.json +++ b/docs/orgtrack-pm-protocol/schemas/routine.schema.json @@ -114,10 +114,11 @@ "properties": { "type": { "type": "string", - "enum": ["manual", "schedule", "provider_event"] + "enum": ["manual", "schedule", "one_time", "provider_event"] }, "cron": { "type": "string" }, "timezone": { "type": "string" }, + "at": { "type": "string", "format": "date-time" }, "provider": { "type": "string" }, "eventKind": { "type": "string" }, "filter": { @@ -126,15 +127,20 @@ }, "concurrencyPolicy": { "type": "string", - "enum": ["coalesce", "skip", "queue"], + "enum": ["coalesce", "skip", "queue", "always"], "default": "skip", - "description": "Behavior when the previous Run is not terminal. coalesce/skip record an AuditEvent without creating a Run; queue creates a pending Run." + "description": "Behavior when the previous Run is not terminal. coalesce/skip record a durable activation outcome, queue records a durable activation that is promoted once the active Run settles, and always creates another Run." }, "catchUp": { "type": "string", - "enum": ["none", "fire_once"], + "enum": ["none", "fire_once", "run_all_limited"], "default": "none", "description": "Compensation for schedule fires missed while the ORG2 host process was not running." + }, + "maxCatchUpRuns": { + "type": "integer", + "minimum": 1, + "description": "Maximum missed activations replayed when catchUp is run_all_limited." } }, "allOf": [ @@ -142,9 +148,20 @@ "if": { "properties": { "type": { "const": "schedule" } } }, "then": { "required": ["cron", "timezone"] } }, + { + "if": { "properties": { "type": { "const": "one_time" } } }, + "then": { "required": ["at"] } + }, { "if": { "properties": { "type": { "const": "provider_event" } } }, "then": { "required": ["provider", "eventKind"] } + }, + { + "if": { + "required": ["catchUp"], + "properties": { "catchUp": { "const": "run_all_limited" } } + }, + "then": { "required": ["maxCatchUpRuns"] } } ], "additionalProperties": false diff --git a/src-tauri/crates/orgtrack-pm-cli/src/commands/routine.rs b/src-tauri/crates/orgtrack-pm-cli/src/commands/routine.rs index ff996d14bb..7cf6d771d0 100644 --- a/src-tauri/crates/orgtrack-pm-cli/src/commands/routine.rs +++ b/src-tauri/crates/orgtrack-pm-cli/src/commands/routine.rs @@ -105,13 +105,6 @@ pub fn dispatch_routine( } } Some("run") => { - if let Err(err) = context.require_project_mode("routine.run") { - return emit_error(err); - } - let scope = match context.require_scope() { - Ok(scope) => scope.to_string(), - Err(err) => return emit_error(err), - }; let actor = match mutation_actor(context) { Ok(actor) => actor, Err(err) => return emit_error(err), @@ -124,7 +117,38 @@ pub fn dispatch_routine( }; let input_map: BTreeMap = inputs.iter().cloned().collect(); let invoke_key = flags.get("idempotency-key").map(String::as_str); - match routine_service::invoke(name, &scope, &input_map, Some(&actor), invoke_key) { + let target = match ( + flags.get("root-work"), + context.scope_id.as_deref(), + ) { + (Some(root_work_item_id), Some(project_slug)) => { + routine_service::RoutineInvocationTarget::ExistingProjectWork { + project_slug: project_slug.to_string(), + root_work_item_id: root_work_item_id.to_string(), + } + } + (Some(root_work_item_id), None) => { + routine_service::RoutineInvocationTarget::ExistingStandaloneWork { + org_id: context.org_id.clone().unwrap_or_else(|| { + project_management::projects::types::PERSONAL_ORG_ID.to_string() + }), + root_work_item_id: root_work_item_id.to_string(), + } + } + (None, Some(project_slug)) => { + routine_service::RoutineInvocationTarget::project(project_slug) + } + (None, None) => routine_service::RoutineInvocationTarget::standalone( + context.org_id.as_deref(), + ), + }; + match routine_service::invoke_target( + name, + &target, + &input_map, + Some(&actor), + invoke_key, + ) { Ok(run) => emit_success( serde_json::json!({ "runId": run.run_id, @@ -175,14 +199,33 @@ pub fn dispatch_routine( Err(err) => emit_error(CliError::from_service(err)), } } - Some("cancel") => emit_error(CliError::new( - ErrorCode::UnsupportedCapability, - "routine cancel lands with the Phase 5 runtime (cancel_requested machinery)", - )), + Some("cancel") => { + if let Err(err) = context.require_project_mode("routine.cancel") { + return emit_error(err); + } + let actor = match mutation_actor(context) { + Ok(actor) => actor, + Err(err) => return emit_error(err), + }; + let Some(run_id) = positionals.get(1) else { + return emit_error(CliError::new( + ErrorCode::InvalidArgument, + "Usage: org2 routine cancel ", + )); + }; + match routine_service::cancel_run(run_id, Some(&actor)) { + Ok(cancelled) => emit_success( + serde_json::to_value(cancelled).unwrap_or_default(), + None, + None, + ), + Err(err) => emit_error(CliError::from_service(err)), + } + } other => emit_error(CliError::new( ErrorCode::InvalidArgument, format!( - "Unknown routine subcommand '{}'; expected list|validate|apply|run|status|enable|disable", + "Unknown routine subcommand '{}'; expected list|validate|apply|run|status|cancel|enable|disable", other.unwrap_or("") ), )), diff --git a/src-tauri/crates/orgtrack-pm-cli/src/commands/work/mod.rs b/src-tauri/crates/orgtrack-pm-cli/src/commands/work/mod.rs index b381bb344a..cfa3143dc1 100644 --- a/src-tauri/crates/orgtrack-pm-cli/src/commands/work/mod.rs +++ b/src-tauri/crates/orgtrack-pm-cli/src/commands/work/mod.rs @@ -35,6 +35,7 @@ pub fn dispatch_work( match positionals.first().map(String::as_str) { Some("list") => query::list(context, flags), Some("show") => query::show(context, positionals.get(1), flags), + Some("timeline") => query::timeline(context, positionals.get(1), flags), Some("create") => create::run(context, flags), Some("update") => update::run(context, positionals.get(1), flags), Some("assign") => ownership::assign(context, positionals.get(1), flags), @@ -46,7 +47,7 @@ pub fn dispatch_work( other => emit_error(CliError::new( ErrorCode::InvalidArgument, format!( - "Unknown work subcommand '{}'; expected list|show|create|update|claim|transition|note|relate", + "Unknown work subcommand '{}'; expected list|show|timeline|create|update|claim|transition|note|relate", other.unwrap_or("") ), )), diff --git a/src-tauri/crates/orgtrack-pm-cli/src/commands/work/note.rs b/src-tauri/crates/orgtrack-pm-cli/src/commands/work/note.rs index a257847c7e..0a804bf815 100644 --- a/src-tauri/crates/orgtrack-pm-cli/src/commands/work/note.rs +++ b/src-tauri/crates/orgtrack-pm-cli/src/commands/work/note.rs @@ -72,6 +72,7 @@ pub(super) fn run( parent_id, Some(&actor), agent_note_session(context, &actor), + context.originator.as_deref(), ) { Ok(()) => emit_success( serde_json::json!({ "appended": true, "kind": kind }), @@ -123,6 +124,7 @@ pub(super) fn run( parent_id, Some(&actor), agent_note_session(context, &actor), + context.originator.as_deref(), ) { Ok(()) => emit_success( serde_json::json!({ "appended": true, "kind": kind }), @@ -144,6 +146,7 @@ pub(super) fn run( parent_id, Some(&actor), agent_note_session(context, &actor), + context.originator.as_deref(), ) { Ok(()) => emit_success( serde_json::json!({ "appended": true, "kind": kind }), diff --git a/src-tauri/crates/orgtrack-pm-cli/src/commands/work/query.rs b/src-tauri/crates/orgtrack-pm-cli/src/commands/work/query.rs index 984bf2ce13..c9c8cda3b1 100644 --- a/src-tauri/crates/orgtrack-pm-cli/src/commands/work/query.rs +++ b/src-tauri/crates/orgtrack-pm-cli/src/commands/work/query.rs @@ -26,8 +26,12 @@ pub(super) fn list(context: &ExecutionContext, flags: &HashMap) let status_filter = match flags.get("status") { None => None, Some(raw) => match parse_portable_state(raw) { - Ok(state) => Some(state), - Err(err) => return emit_error(err), + Ok(state) => Some(StatusFilter::Portable(state)), + Err(err) => match custom_status_definition(context, raw) { + Ok(Some(_)) => Some(StatusFilter::CustomKey(raw.clone())), + Ok(None) => return emit_error(err), + Err(lookup) => return emit_error(lookup), + }, }, }; let ready_only = flags.contains_key("ready"); @@ -48,12 +52,12 @@ pub(super) fn list(context: &ExecutionContext, flags: &HashMap) .map(|last| item.frontmatter.short_id.as_str() > last) .unwrap_or(true) }) - .filter(|item| { - status_filter - .map(|state| { - work_service::state::map_legacy_status(&item.frontmatter.status) == Some(state) - }) - .unwrap_or(true) + .filter(|item| match &status_filter { + None => true, + Some(StatusFilter::Portable(state)) => { + work_service::state::map_legacy_status(&item.frontmatter.status) == Some(*state) + } + Some(StatusFilter::CustomKey(key)) => &item.frontmatter.status == key, }) .filter(|item| { if !ready_only { @@ -88,6 +92,22 @@ pub(super) fn list(context: &ExecutionContext, flags: &HashMap) emit_success(serde_json::json!({ "items": filtered }), None, next_cursor) } +pub(super) enum StatusFilter { + Portable(work_service::WorkItemState), + CustomKey(String), +} + +pub(super) fn custom_status_definition( + context: &ExecutionContext, + raw: &str, +) -> Result, CliError> { + project_management::work_item_features::find_active_status_definition( + context.org_id.as_deref(), + raw, + ) + .map_err(CliError::from_service) +} + fn parse_portable_state(raw: &str) -> Result { use work_service::WorkItemState::*; match raw { @@ -100,7 +120,7 @@ fn parse_portable_state(raw: &str) -> Result Err(CliError::new( ErrorCode::InvalidArgument, format!( - "Unknown state '{}'; expected open|in_progress|blocked|completed|failed|cancelled", + "Unknown state '{}'; expected open|in_progress|blocked|completed|failed|cancelled or an active custom status key", other ), )), @@ -148,3 +168,71 @@ pub(super) fn show( } emit_success(wire, revision, None) } + +pub(super) fn timeline( + context: &ExecutionContext, + short_id: Option<&String>, + flags: &HashMap, +) -> i32 { + let short_id = match require_short_id(short_id) { + Ok(short_id) => short_id, + Err(err) => return emit_error(err), + }; + let tail = match flags.get("tail") { + None => None, + Some(raw) => match raw.parse::() { + Ok(value) => Some(value), + Err(_) => { + return emit_error( + CliError::new( + ErrorCode::InvalidArgument, + format!("--tail expects a non-negative integer, got '{raw}'"), + ) + .with_details(serde_json::json!({ "field": "--tail", "value": raw })), + ) + } + }, + }; + if flags.contains_key("activity-only") && flags.contains_key("comments-only") { + return emit_error(CliError::new( + ErrorCode::InvalidArgument, + "--activity-only and --comments-only are mutually exclusive", + )); + } + let filter = work_service::timeline::TimelineFilter { + since: flags.get("since").map(String::as_str), + tail, + activity_only: flags.contains_key("activity-only"), + comments_only: flags.contains_key("comments-only"), + }; + let (item, revision) = if uses_standalone_scope(context, flags) { + match pio::read_standalone_work_item(context.org_id.as_deref(), &short_id) { + Ok(item) => (item, None), + Err(err) => return emit_error(CliError::from_service(err)), + } + } else if let Some(item) = standalone_fallback_item(context, &short_id) { + (item, None) + } else { + let scope = match context.require_scope() { + Ok(scope) => scope.to_string(), + Err(err) => return emit_error(err), + }; + match pio::read_work_item(&scope, &short_id) { + Ok(item) => ( + item, + work_service::read_project_work_item_revision(&scope, &short_id).ok(), + ), + Err(err) => return emit_error(CliError::from_service(err)), + } + }; + let entries = work_service::timeline::work_item_timeline(&item, filter); + emit_success( + serde_json::json!({ + "shortId": item.frontmatter.short_id, + "status": item.frontmatter.status, + "entries": entries, + }), + revision, + None, + ) +} diff --git a/src-tauri/crates/orgtrack-pm-cli/src/commands/work/transition.rs b/src-tauri/crates/orgtrack-pm-cli/src/commands/work/transition.rs index 770fa84244..79cc1fcf8b 100644 --- a/src-tauri/crates/orgtrack-pm-cli/src/commands/work/transition.rs +++ b/src-tauri/crates/orgtrack-pm-cli/src/commands/work/transition.rs @@ -2,7 +2,9 @@ use std::collections::HashMap; use project_management::work_service; -use super::{item_to_wire, require_short_id, standalone_fallback_item}; +use super::{ + item_to_wire, query::custom_status_definition, require_short_id, standalone_fallback_item, +}; use crate::commands::{guarded, mutation_actor}; use crate::context::ExecutionContext; use crate::envelope::{emit_error, emit_success, CliError, ErrorCode}; @@ -26,20 +28,26 @@ pub(super) fn run( let Some(to_state) = flags.get("to") else { return emit_error(CliError::new( ErrorCode::InvalidArgument, - "work transition requires --to ", + "work transition requires --to ", )); }; if work_service::WorkItemState::parse(to_state).is_none() { - return emit_error( - CliError::new( - ErrorCode::InvalidArgument, - format!( - "Unknown state '{}'; expected one of open|in_progress|blocked|completed|failed|cancelled", - to_state - ), - ) - .with_details(serde_json::json!({ "field": "--to", "value": to_state })), - ); + match custom_status_definition(context, to_state) { + Ok(Some(_)) => {} + Ok(None) => { + return emit_error( + CliError::new( + ErrorCode::InvalidArgument, + format!( + "Unknown state '{}'; expected one of open|in_progress|blocked|completed|failed|cancelled or an active custom status key", + to_state + ), + ) + .with_details(serde_json::json!({ "field": "--to", "value": to_state })), + ); + } + Err(err) => return emit_error(err), + } } if to_state == "in_progress" { return emit_error(CliError::new( diff --git a/src-tauri/crates/orgtrack-pm-cli/src/context.rs b/src-tauri/crates/orgtrack-pm-cli/src/context.rs index 1334ecdbab..ca3bc747cd 100644 --- a/src-tauri/crates/orgtrack-pm-cli/src/context.rs +++ b/src-tauri/crates/orgtrack-pm-cli/src/context.rs @@ -17,6 +17,7 @@ pub const ENV_ACTOR: &str = "ORGII_ACTOR"; pub const ENV_SCOPE: &str = "ORGII_SCOPE"; pub const ENV_SESSION_REF: &str = "ORGII_SESSION_REF"; pub const ENV_ORG: &str = "ORGII_ORG"; +pub const ENV_ORIGINATOR: &str = "ORGII_ORIGINATOR"; pub const ALL_CAPABILITIES: &[&str] = &[ "work.read", @@ -125,6 +126,9 @@ pub struct ExecutionContext { pub org_id: Option, pub actor: Option, pub session_ref: Option, + /// A2A chain identity injected by the run environment; env-only so an + /// agent cannot claim a different originator via flags. + pub originator: Option, pub capabilities: Vec<&'static str>, } @@ -342,6 +346,9 @@ pub fn resolve( .or(marker_org), actor, session_ref, + originator: std::env::var(ENV_ORIGINATOR) + .ok() + .filter(|value| !value.trim().is_empty()), capabilities, }) } diff --git a/src-tauri/crates/orgtrack-pm-cli/src/main.rs b/src-tauri/crates/orgtrack-pm-cli/src/main.rs index 9aba26e65c..57af70bf9a 100644 --- a/src-tauri/crates/orgtrack-pm-cli/src/main.rs +++ b/src-tauri/crates/orgtrack-pm-cli/src/main.rs @@ -7,7 +7,7 @@ //! ```text //! org2 context //! org2 work list|show|create|update|claim|transition|note|relate -//! org2 routine ... (Phase 4) +//! org2 routine ... //! ``` //! //! Process model (design §13.0): short-lived console process linking the @@ -46,7 +46,10 @@ fn parse_args(args: &[String]) -> Result { while i < args.len() { let arg = &args[i]; if let Some(name) = arg.strip_prefix("--") { - if name == "json" || name == "ready" || name == "standalone" { + if matches!( + name, + "json" | "ready" | "standalone" | "activity-only" | "comments-only" + ) { flags.insert(name.to_string(), "true".to_string()); i += 1; continue; @@ -55,10 +58,13 @@ fn parse_args(args: &[String]) -> Result { return Err(CliError::new( ErrorCode::InvalidArgument, "org2-pm is JSON-envelope only. Commands: context show | \ - work list|show|create|update|claim|transition|note|relate | \ - routine list|validate|apply|run|status|enable|disable. \ + work list|show|timeline|create|update|claim|transition|note|relate | \ + routine list|validate|apply|run|status|cancel|enable|disable. \ Common flags: --scope --mode project --actor \ --session-ref --idempotency-key . \ + Routine run also accepts --root-work . \ + work timeline accepts --since --tail \ + --activity-only|--comments-only. \ With no project scope, work list/create use the current \ organization's standalone Work Items automatically" .to_string(), 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 8a12626737..7a8150f6c1 100644 --- a/src-tauri/crates/orgtrack-pm-cli/tests/cli_e2e.rs +++ b/src-tauri/crates/orgtrack-pm-cli/tests/cli_e2e.rs @@ -504,6 +504,62 @@ fn routine_lifecycle_runs_through_the_cli() { assert_eq!(first["portableState"], "completed"); } +#[test] +fn routine_root_work_and_cancel_run_through_the_cli() { + let _sandbox = test_env::sandbox(); + seed("demo"); + let fixture_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../docs/orgtrack-pm-protocol/fixtures/routine-spec.json"); + let fixture_arg = fixture_path.to_string_lossy().to_string(); + let base = [ + "--mode", + "project", + "--scope", + "demo", + "--actor", + "agent:cli-tester", + "--session-ref", + "claude_code:session_routine_root", + ]; + + let (exit, applied) = + run_cli(&[&["routine", "apply", "--file", &fixture_arg], &base[..]].concat()); + assert_eq!(exit, 0, "apply: {applied}"); + + let (exit, run) = run_cli( + &[ + &[ + "routine", + "run", + "interaction-impact-analysis", + "--root-work", + "AAA-0001", + "--input", + "requirement_id=REQ-ROOT", + ], + &base[..], + ] + .concat(), + ); + assert_eq!(exit, 0, "root-work run: {run}"); + assert_eq!(run["data"]["rootWorkItemId"], "AAA-0001"); + let run_id = run["data"]["runId"].as_str().expect("run id"); + + let (exit, cancelled) = run_cli(&[&["routine", "cancel", run_id], &base[..]].concat()); + assert_eq!(exit, 0, "cancel: {cancelled}"); + assert_eq!(cancelled["data"]["status"], "cancelled"); + assert_eq!(cancelled["data"]["changed"], true); + + let (exit, repeated) = run_cli(&[&["routine", "cancel", run_id], &base[..]].concat()); + assert_eq!(exit, 0, "repeated cancel: {repeated}"); + assert_eq!(repeated["data"]["status"], "cancelled"); + assert_eq!(repeated["data"]["changed"], false); + + let root = project_management::projects::io::read_work_item("demo", "AAA-0001") + .expect("existing root remains"); + assert_eq!(root.frontmatter.title, "CLI target"); +} + #[test] fn wire_validation_maps_to_stable_codes() { let _sandbox = test_env::sandbox(); diff --git a/src-tauri/crates/project-management/src/lib.rs b/src-tauri/crates/project-management/src/lib.rs index 34a094baf6..eab6c46355 100644 --- a/src-tauri/crates/project-management/src/lib.rs +++ b/src-tauri/crates/project-management/src/lib.rs @@ -11,6 +11,7 @@ pub mod lineage; pub mod orchestrator; +pub mod org_skills; pub mod project_service; pub mod projects; pub mod provider_host; diff --git a/src-tauri/crates/project-management/src/org_skills/mod.rs b/src-tauri/crates/project-management/src/org_skills/mod.rs new file mode 100644 index 0000000000..1a82510472 --- /dev/null +++ b/src-tauri/crates/project-management/src/org_skills/mod.rs @@ -0,0 +1,462 @@ +//! Org-shared skills. +//! +//! A shared skill is a small durable snapshot (SKILL.md plus bundled +//! files, size-capped) that rides the org-entity sync carrier like typed +//! properties, statuses, saved views, and quick actions. Every member +//! materializes active rows into `~/.orgii/org-skills///`, +//! which the skills loader scans as its own source. Unshare is archival +//! so removals propagate through snapshots and materializations follow. + +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; + +use crate::projects::io::helpers::{conn, now_ms}; + +/// Hard cap on one shared skill's total content (SKILL.md + files). +/// The snapshot rides org entity pushes; a repo-sized skill does not +/// belong on that wire. +pub const MAX_ORG_SKILL_BYTES: usize = 256 * 1024; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OrgSkillFile { + pub relative_path: String, + pub content: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OrgSkill { + pub id: String, + pub org_id: String, + pub name: String, + pub description: String, + pub skill_md: String, + pub files: Vec, + pub provenance: Option, + pub shared_by: Option, + pub archived_at: Option, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ShareOrgSkillRequest { + pub org_id: String, + pub id: Option, + pub name: String, + #[serde(default)] + pub description: String, + pub skill_md: String, + #[serde(default)] + pub files: Vec, + pub provenance: Option, + pub shared_by: Option, +} + +fn valid_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 128 + && !name.starts_with('.') + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ' ')) +} + +fn valid_relative_path(path: &str) -> bool { + !path.is_empty() + && !path.starts_with('/') + && !path.starts_with('\\') + && !path + .split(['/', '\\']) + .any(|part| part == ".." || part.is_empty()) + && path != "SKILL.md" +} + +pub fn share(request: ShareOrgSkillRequest) -> Result { + let name = request.name.trim().to_string(); + if !valid_name(&name) { + return Err("PM_ERR:ORG_SKILL_NAME_INVALID".to_string()); + } + if request.skill_md.trim().is_empty() { + return Err("Shared skill needs a SKILL.md body".to_string()); + } + for file in &request.files { + if !valid_relative_path(&file.relative_path) { + return Err(format!( + "PM_ERR:ORG_SKILL_PATH_INVALID:{}", + file.relative_path + )); + } + } + let total_bytes = request.skill_md.len() + + request + .files + .iter() + .map(|file| file.relative_path.len() + file.content.len()) + .sum::(); + if total_bytes > MAX_ORG_SKILL_BYTES { + return Err(format!( + "PM_ERR:ORG_SKILL_TOO_LARGE:{total_bytes}:{MAX_ORG_SKILL_BYTES}" + )); + } + let id = request + .id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("org-skill:{}:{}", request.org_id, name)); + let files_json = + serde_json::to_string(&request.files).map_err(|err| format!("org skill files: {err}"))?; + let provenance_json = request + .provenance + .as_ref() + .map(|value| serde_json::to_string(value).map_err(|err| format!("org skill: {err}"))) + .transpose()?; + let connection = conn()?; + let now = now_ms(); + connection + .execute( + "INSERT INTO pm_org_skills ( + id, org_id, name, description, skill_md, files_json, + provenance_json, shared_by, archived_at, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, ?9, ?9) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + skill_md = excluded.skill_md, + files_json = excluded.files_json, + provenance_json = excluded.provenance_json, + shared_by = excluded.shared_by, + archived_at = NULL, + updated_at = excluded.updated_at", + params![ + id, + request.org_id, + name, + request.description.trim(), + request.skill_md, + files_json, + provenance_json, + request.shared_by, + now + ], + ) + .map_err(|err| format!("org skill store: {err}"))?; + crate::sync::collab_bridge::record_org_skills_touch(&connection, &request.org_id, &id)?; + let skill = read(&connection, &request.org_id, &id)?; + materialize_org(&connection, &request.org_id)?; + Ok(skill) +} + +pub fn unshare(org_id: &str, id: &str) -> Result { + let connection = conn()?; + let now = now_ms(); + let changed = connection + .execute( + "UPDATE pm_org_skills + SET archived_at = COALESCE(archived_at, ?3), updated_at = ?3 + WHERE org_id = ?1 AND id = ?2", + params![org_id, id, now], + ) + .map_err(|err| format!("org skill store: {err}"))?; + if changed == 0 { + return Err(format!("Org skill '{id}' not found")); + } + crate::sync::collab_bridge::record_org_skills_touch(&connection, org_id, id)?; + let skill = read(&connection, org_id, id)?; + materialize_org(&connection, org_id)?; + Ok(skill) +} + +pub fn list(org_id: &str) -> Result, String> { + let connection = conn()?; + query_skills( + &connection, + "SELECT id, org_id, name, description, skill_md, files_json, + provenance_json, shared_by, archived_at, created_at, updated_at + FROM pm_org_skills + WHERE org_id = ?1 AND archived_at IS NULL + ORDER BY name ASC, id ASC", + params![org_id], + ) +} + +/// Every row (archived included) so removals propagate. +pub(crate) fn export_skills( + connection: &Connection, + org_id: &str, +) -> Result, String> { + query_skills( + connection, + "SELECT id, org_id, name, description, skill_md, files_json, + provenance_json, shared_by, archived_at, created_at, updated_at + FROM pm_org_skills + WHERE org_id = ?1 + ORDER BY name ASC, id ASC", + params![org_id], + ) +} + +fn query_skills( + connection: &Connection, + sql: &str, + parameters: impl rusqlite::Params, +) -> Result, String> { + let mut statement = connection + .prepare(sql) + .map_err(|err| format!("org skill store: {err}"))?; + let skills = statement + .query_map(parameters, decode_skill) + .map_err(|err| format!("org skill store: {err}"))? + .collect::, _>>() + .map_err(|err| format!("org skill store: {err}"))?; + Ok(skills) +} + +fn read(connection: &Connection, org_id: &str, id: &str) -> Result { + connection + .query_row( + "SELECT id, org_id, name, description, skill_md, files_json, + provenance_json, shared_by, archived_at, created_at, updated_at + FROM pm_org_skills + WHERE org_id = ?1 AND id = ?2", + params![org_id, id], + decode_skill, + ) + .map_err(|err| format!("org skill store: {err}")) +} + +fn decode_skill(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let files_raw: String = row.get(5)?; + let provenance_raw: Option = row.get(6)?; + Ok(OrgSkill { + id: row.get(0)?, + org_id: row.get(1)?, + name: row.get(2)?, + description: row.get(3)?, + skill_md: row.get(4)?, + files: serde_json::from_str(&files_raw).unwrap_or_default(), + provenance: provenance_raw.and_then(|raw| serde_json::from_str(&raw).ok()), + shared_by: row.get(7)?, + archived_at: row.get(8)?, + created_at: row.get(9)?, + updated_at: row.get(10)?, + }) +} + +/// Apply org skills carried on a pulled entity snapshot, then refresh the +/// local materialization so the loader sees the change immediately. +pub(crate) fn apply_wire_skills( + connection: &Connection, + org_id: &str, + payload: &serde_json::Value, +) -> Result<(), String> { + let Some(raw) = payload.get("orgSkills") else { + return Ok(()); + }; + let skills: Vec = + serde_json::from_value(raw.clone()).map_err(|err| format!("org skill wire: {err}"))?; + let mut changed = false; + for skill in skills { + if skill.org_id != org_id { + return Err(format!( + "org skill '{}' belongs to another organization", + skill.id + )); + } + let local_updated_at: Option = connection + .query_row( + "SELECT updated_at FROM pm_org_skills WHERE id = ?1", + params![skill.id], + |row| row.get(0), + ) + .optional() + .map_err(|err| format!("org skill watermark: {err}"))?; + if local_updated_at.is_some_and(|local| local >= skill.updated_at) { + continue; + } + if crate::sync::collab_bridge::has_pending_collab_field_path( + connection, + org_id, + &format!("orgSkills.{}", skill.id), + "org skill pending-path probe", + )? { + continue; + } + let files_json = serde_json::to_string(&skill.files) + .map_err(|err| format!("org skill wire files: {err}"))?; + let provenance_json = skill + .provenance + .as_ref() + .map(|value| { + serde_json::to_string(value).map_err(|err| format!("org skill wire: {err}")) + }) + .transpose()?; + connection + .execute( + "INSERT INTO pm_org_skills ( + id, org_id, name, description, skill_md, files_json, + provenance_json, shared_by, archived_at, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + skill_md = excluded.skill_md, + files_json = excluded.files_json, + provenance_json = excluded.provenance_json, + shared_by = excluded.shared_by, + archived_at = excluded.archived_at, + updated_at = excluded.updated_at + WHERE excluded.updated_at >= pm_org_skills.updated_at", + params![ + skill.id, + skill.org_id, + skill.name, + skill.description, + skill.skill_md, + files_json, + provenance_json, + skill.shared_by, + skill.archived_at, + skill.created_at, + skill.updated_at, + ], + ) + .map_err(|err| format!("org skill apply: {err}"))?; + changed = true; + } + if changed { + materialize_org(connection, org_id)?; + } + Ok(()) +} + +/// Write every active shared skill for one org under +/// `~/.orgii/org-skills//`, and remove directories whose row is +/// archived or gone. Content writes are compared first so repeated +/// materializations are cheap and never bump mtimes needlessly. +pub(crate) fn materialize_org(connection: &Connection, org_id: &str) -> Result<(), String> { + let skills = export_skills(connection, org_id)?; + let org_dir = app_paths::org_skills_dir(org_id); + let mut active_names = std::collections::HashSet::new(); + for skill in skills.iter().filter(|skill| skill.archived_at.is_none()) { + active_names.insert(skill.name.clone()); + let skill_dir = org_dir.join(&skill.name); + std::fs::create_dir_all(&skill_dir) + .map_err(|err| format!("org skill materialize: {err}"))?; + write_if_changed(&skill_dir.join("SKILL.md"), &skill.skill_md)?; + for file in &skill.files { + let path = skill_dir.join(&file.relative_path); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|err| format!("org skill materialize: {err}"))?; + } + write_if_changed(&path, &file.content)?; + } + if let Some(provenance) = &skill.provenance { + let raw = serde_json::to_string_pretty(provenance) + .map_err(|err| format!("org skill materialize: {err}"))?; + write_if_changed(&skill_dir.join(".orgii-skill-origin.json"), &raw)?; + } + } + if org_dir.exists() { + let entries = + std::fs::read_dir(&org_dir).map_err(|err| format!("org skill materialize: {err}"))?; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if !active_names.contains(name) { + let _ = std::fs::remove_dir_all(&path); + } + } + } + Ok(()) +} + +fn write_if_changed(path: &std::path::Path, content: &str) -> Result<(), String> { + if std::fs::read_to_string(path) + .map(|existing| existing == content) + .unwrap_or(false) + { + return Ok(()); + } + std::fs::write(path, content).map_err(|err| format!("org skill materialize: {err}")) +} + +/// Startup sweep: bring every org's materialization in line with the +/// database, so a fresh checkout sees shared skills before any sync tick. +pub fn materialize_all() -> Result<(), String> { + let connection = conn()?; + let mut statement = connection + .prepare("SELECT DISTINCT org_id FROM pm_org_skills") + .map_err(|err| format!("org skill store: {err}"))?; + let orgs = statement + .query_map([], |row| row.get::<_, String>(0)) + .map_err(|err| format!("org skill store: {err}"))? + .collect::, _>>() + .map_err(|err| format!("org skill store: {err}"))?; + drop(statement); + for org_id in orgs { + materialize_org(&connection, &org_id)?; + } + Ok(()) +} + +pub mod commands { + use super::{OrgSkill, ShareOrgSkillRequest}; + + #[tauri::command] + pub async fn project_list_org_skills(org_id: String) -> Result, String> { + tokio::task::spawn_blocking(move || super::list(&org_id)) + .await + .map_err(|err| format!("Task join error: {err}"))? + } + + #[tauri::command] + pub async fn project_share_org_skill( + app: tauri::AppHandle, + request: ShareOrgSkillRequest, + ) -> Result { + let result = tokio::task::spawn_blocking(move || super::share(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_unshare_org_skill( + app: tauri::AppHandle, + org_id: String, + id: String, + ) -> Result { + let result = tokio::task::spawn_blocking(move || super::unshare(&org_id, &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 + } +} + +#[cfg(test)] +mod tests; diff --git a/src-tauri/crates/project-management/src/org_skills/tests.rs b/src-tauri/crates/project-management/src/org_skills/tests.rs new file mode 100644 index 0000000000..3705b34a29 --- /dev/null +++ b/src-tauri/crates/project-management/src/org_skills/tests.rs @@ -0,0 +1,101 @@ +use serde_json::json; +use test_helpers::test_env; + +use super::*; + +fn request(name: &str) -> ShareOrgSkillRequest { + ShareOrgSkillRequest { + org_id: "personal-org".to_string(), + id: None, + name: name.to_string(), + description: "A shared helper".to_string(), + skill_md: "---\ndescription: helper\n---\n\nDo the thing.".to_string(), + files: vec![OrgSkillFile { + relative_path: "references/notes.md".to_string(), + content: "supporting notes".to_string(), + }], + provenance: Some(json!({ "id": "stable-1", "name": name })), + shared_by: Some("member-1".to_string()), + } +} + +#[test] +fn share_materializes_and_unshare_removes_the_directory() { + let _sandbox = test_env::sandbox(); + + let shared = share(request("release-checklist")).expect("share"); + assert_eq!(shared.name, "release-checklist"); + + let skill_dir = app_paths::org_skills_dir("personal-org").join("release-checklist"); + assert!(skill_dir.join("SKILL.md").exists(), "SKILL.md materialized"); + assert!( + skill_dir.join("references/notes.md").exists(), + "bundled file materialized" + ); + assert!( + skill_dir.join(".orgii-skill-origin.json").exists(), + "provenance sidecar materialized" + ); + + let listed = list("personal-org").expect("list"); + assert_eq!(listed.len(), 1); + + unshare("personal-org", &shared.id).expect("unshare"); + assert!(list("personal-org").expect("list").is_empty()); + assert!( + !skill_dir.exists(), + "unshare removes the materialized directory" + ); +} + +#[test] +fn share_rejects_oversized_and_unsafe_payloads() { + let _sandbox = test_env::sandbox(); + + let mut oversized = request("big"); + oversized.files = vec![OrgSkillFile { + relative_path: "blob.txt".to_string(), + content: "x".repeat(MAX_ORG_SKILL_BYTES + 1), + }]; + assert!(share(oversized) + .expect_err("size cap") + .contains("ORG_SKILL_TOO_LARGE")); + + let mut traversal = request("sneaky"); + traversal.files = vec![OrgSkillFile { + relative_path: "../escape.md".to_string(), + content: "nope".to_string(), + }]; + assert!(share(traversal) + .expect_err("path guard") + .contains("ORG_SKILL_PATH_INVALID")); +} + +#[test] +fn wire_round_trip_applies_newer_snapshots_and_rematerializes() { + let _sandbox = test_env::sandbox(); + + let shared = share(request("triage-notes")).expect("share"); + let connection = crate::projects::io::helpers::conn().expect("conn"); + let exported = export_skills(&connection, "personal-org").expect("export"); + assert_eq!(exported.len(), 1); + + let mut remote = exported[0].clone(); + remote.skill_md = "---\ndescription: helper\n---\n\nUpdated remotely.".to_string(); + remote.updated_at += 1_000; + apply_wire_skills( + &connection, + "personal-org", + &json!({ "orgSkills": [remote] }), + ) + .expect("apply"); + + let materialized = std::fs::read_to_string( + app_paths::org_skills_dir("personal-org") + .join("triage-notes") + .join("SKILL.md"), + ) + .expect("materialized SKILL.md"); + assert!(materialized.contains("Updated remotely.")); + assert_eq!(list("personal-org").expect("list")[0].id, shared.id); +} diff --git a/src-tauri/crates/project-management/src/projects/commands/routines.rs b/src-tauri/crates/project-management/src/projects/commands/routines.rs index 6bc12ee269..a47263d1ef 100644 --- a/src-tauri/crates/project-management/src/projects/commands/routines.rs +++ b/src-tauri/crates/project-management/src/projects/commands/routines.rs @@ -1,41 +1,70 @@ //! Routine commands: definitions, fire history, and materialization. use super::super::io; -use super::super::types::{RoutineDefinition, RoutineFire}; +use super::super::types::{RoutineDefinition, RoutineFire, RoutineFireResult}; #[tauri::command] pub async fn project_list_routines() -> Result, String> { - tokio::task::spawn_blocking(io::list_routines) - .await - .map_err(|err| format!("Task join error: {}", err))? + tokio::task::spawn_blocking(|| { + io::list_routines()? + .into_iter() + .map(crate::routine_service::legacy_bridge::overlay_definition) + .collect() + }) + .await + .map_err(|err| format!("Task join error: {}", err))? } #[tauri::command] pub async fn project_read_routine(id: String) -> Result { - tokio::task::spawn_blocking(move || io::read_routine(&id)) - .await - .map_err(|err| format!("Task join error: {}", err))? + tokio::task::spawn_blocking(move || { + crate::routine_service::legacy_bridge::overlay_definition(io::read_routine(&id)?) + }) + .await + .map_err(|err| format!("Task join error: {}", err))? } #[tauri::command] pub async fn project_upsert_routine( routine: RoutineDefinition, ) -> Result { - tokio::task::spawn_blocking(move || io::upsert_routine(routine)) - .await - .map_err(|err| format!("Task join error: {}", err))? + tokio::task::spawn_blocking(move || { + if !routine.id.trim().is_empty() { + if let Ok(previous) = io::read_routine(&routine.id) { + crate::routine_service::legacy_bridge::ensure_binding(&previous)?; + } + } + let saved = io::upsert_routine(routine)?; + crate::routine_service::legacy_bridge::sync_definition(&saved)?; + crate::routine_service::legacy_bridge::overlay_definition(io::read_routine(&saved.id)?) + }) + .await + .map_err(|err| format!("Task join error: {}", err))? } #[tauri::command] pub async fn project_delete_routine(id: String) -> Result { - tokio::task::spawn_blocking(move || io::delete_routine(&id)) - .await - .map_err(|err| format!("Task join error: {}", err))? + tokio::task::spawn_blocking(move || { + crate::routine_service::legacy_bridge::delete_definition(&id) + }) + .await + .map_err(|err| format!("Task join error: {}", err))? } #[tauri::command] pub async fn project_list_routine_fires(routine_id: String) -> Result, String> { - tokio::task::spawn_blocking(move || io::list_routine_fires(&routine_id)) + tokio::task::spawn_blocking(move || { + crate::routine_service::legacy_bridge::list_fires(&routine_id) + }) + .await + .map_err(|err| format!("Task join error: {}", err))? +} + +/// Fire Now uses the same portable graph invocation and concurrency boundary +/// as schedule/webhook/CLI execution while retaining the legacy UI response. +#[tauri::command] +pub async fn project_fire_routine(routine_id: String) -> Result { + tokio::task::spawn_blocking(move || crate::routine_service::legacy_bridge::fire(&routine_id)) .await .map_err(|err| format!("Task join error: {}", err))? } @@ -74,3 +103,14 @@ pub async fn project_routine_run_status(run_id: String) -> Result Result { + tokio::task::spawn_blocking(move || crate::routine_service::cancel_run(&run_id, None)) + .await + .map_err(|err| format!("Task join error: {}", err))? +} diff --git a/src-tauri/crates/project-management/src/projects/commands/work_items.rs b/src-tauri/crates/project-management/src/projects/commands/work_items.rs index 91959c23af..212982c10a 100644 --- a/src-tauri/crates/project-management/src/projects/commands/work_items.rs +++ b/src-tauri/crates/project-management/src/projects/commands/work_items.rs @@ -290,9 +290,20 @@ pub async fn project_update_work_item_partial( project_slug: String, short_id: String, updates: WorkItemPartialUpdate, + expected_revision: Option, ) -> Result { tokio::task::spawn_blocking(move || { - io::update_work_item_partial_enriched(&project_slug, &short_id, &updates) + if let Some(expected_revision) = expected_revision { + io::update_work_item_partial_at_revision( + &project_slug, + &short_id, + &updates, + expected_revision, + )?; + io::read_work_item_enriched(&project_slug, &short_id) + } else { + io::update_work_item_partial_enriched(&project_slug, &short_id, &updates) + } }) .await .map_err(|err| format!("Task join error: {}", err))? @@ -368,9 +379,16 @@ pub async fn work_item_update_standalone_partial( org_id: Option, short_id: String, updates: WorkItemPartialUpdate, + expected_revision: Option, ) -> Result { - tokio::task::spawn_blocking(move || { - io::update_standalone_work_item_partial(org_id.as_deref(), &short_id, &updates) + tokio::task::spawn_blocking(move || match expected_revision { + Some(expected_revision) => io::update_standalone_work_item_partial_at_revision( + org_id.as_deref(), + &short_id, + &updates, + expected_revision, + ), + None => io::update_standalone_work_item_partial(org_id.as_deref(), &short_id, &updates), }) .await .map_err(|err| format!("Task join error: {}", err))? 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 0658d4d284..b80ba1a97c 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 @@ -578,6 +578,7 @@ fn read_work_item_markdown(path: &Path) -> Result { frontmatter, body: body.to_string(), filename, + revision: 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 7912e0362d..bec352fdf0 100644 --- a/src-tauri/crates/project-management/src/projects/io/mod.rs +++ b/src-tauri/crates/project-management/src/projects/io/mod.rs @@ -36,10 +36,11 @@ pub(crate) use projects::{ read_project_field_revisions, write_project_remote, PROJECT_SYNC_FIELDS, }; 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, - mark_routine_fire_started, mark_routine_fire_succeeded, mark_routine_fire_work_item_created, + backfill_routine_activations, 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, mark_routine_fire_started, + mark_routine_fire_succeeded, mark_routine_fire_work_item_created, 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, @@ -60,15 +61,16 @@ pub use work_items::{ 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_work_item_atomic, update_work_item_atomic_serviced, - update_work_item_atomic_with_revisions, update_work_item_partial, + update_standalone_work_item_partial_at_revision, update_work_item_atomic, + update_work_item_atomic_serviced, update_work_item_atomic_with_revisions, + update_work_item_partial, update_work_item_partial_at_revision, 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, + allocate_short_id_in_tx, allocate_standalone_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::{ 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 754b6f4e85..1066137fd7 100644 --- a/src-tauri/crates/project-management/src/projects/io/routines.rs +++ b/src-tauri/crates/project-management/src/projects/io/routines.rs @@ -2,7 +2,6 @@ use rusqlite::{params, OptionalExtension}; -use super::super::routine_schedule; use super::helpers::{conn, from_iso8601, map_db, now_ms, to_iso8601}; use crate::projects::types::{ RoutineConcurrencyPolicy, RoutineDefinition, RoutineFire, RoutineFireStatus, @@ -31,7 +30,7 @@ fn row_to_routine(row: &rusqlite::Row<'_>) -> rusqlite::Result(3)? != 0, - trigger: serde_json::from_str::(&trigger_json).map_err(|err| { + trigger: serde_json::from_str::>(&trigger_json).map_err(|err| { rusqlite::Error::FromSqlConversionFailure(4, rusqlite::types::Type::Text, Box::new(err)) })?, run_template: serde_json::from_str::(&template_json).map_err( @@ -44,6 +43,19 @@ fn row_to_routine(row: &rusqlite::Row<'_>) -> rusqlite::Result>(16)? + .map(|raw| { + serde_json::from_str(&raw).map_err(|err| { + rusqlite::Error::FromSqlConversionFailure( + 16, + rusqlite::types::Type::Text, + Box::new(err), + ) + }) + }) + .transpose()? + .unwrap_or_default(), last_evaluated_at: row.get::<_, Option>(9)?.map(to_iso8601), next_fire_at: row.get::<_, Option>(10)?.map(to_iso8601), last_fire_at: row.get::<_, Option>(11)?.map(to_iso8601), @@ -65,7 +77,8 @@ const ROUTINE_SELECT_COLUMNS: &str = routine.created_at, routine.updated_at, routine.last_evaluated_at, routine.next_fire_at, latest_fire.fired_at, latest_fire.status, latest_fire.error, - latest_fire.session_id, latest_fire.work_item_id"; + latest_fire.session_id, latest_fire.work_item_id, + routine.activations_json"; const ROUTINE_FROM: &str = "routine_definitions AS routine LEFT JOIN routine_fires AS latest_fire ON latest_fire.id = ( @@ -143,6 +156,7 @@ pub fn list_routines() -> Result, String> { let mut stmt = map_db(connection.prepare(&format!( "SELECT {ROUTINE_SELECT_COLUMNS} FROM {ROUTINE_FROM} + WHERE routine.archived_at IS NULL ORDER BY routine.updated_at DESC, routine.created_at DESC", )))?; let rows = map_db(stmt.query_map([], row_to_routine))?; @@ -174,7 +188,7 @@ pub fn list_enabled_routines() -> Result, String> { let mut stmt = map_db(connection.prepare(&format!( "SELECT {ROUTINE_SELECT_COLUMNS} FROM {ROUTINE_FROM} - WHERE routine.enabled = 1 + WHERE routine.enabled = 1 AND routine.archived_at IS NULL ORDER BY routine.created_at ASC", )))?; let rows = map_db(stmt.query_map([], row_to_routine))?; @@ -193,7 +207,7 @@ pub fn read_routine(id: &str) -> Result { &format!( "SELECT {ROUTINE_SELECT_COLUMNS} FROM {ROUTINE_FROM} - WHERE routine.id = ?1", + WHERE routine.id = ?1 AND routine.archived_at IS NULL", ), params![id], row_to_routine, @@ -213,13 +227,24 @@ pub fn upsert_routine(mut routine: RoutineDefinition) -> Result Result Result Result rusqlite::Result<()> { + let mut statement = connection.prepare( + "SELECT id, trigger_json FROM routine_definitions + WHERE activations_json IS NULL OR activations_json = '' OR activations_json = '[]'", + )?; + let rows: Vec<(String, String)> = statement + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? + .collect::>()?; + drop(statement); + for (id, trigger_json) in rows { + let Ok(Some(trigger)) = serde_json::from_str::>(&trigger_json) + else { + continue; + }; + let activations = vec![crate::routine_service::activation_from_trigger(&trigger)]; + let Ok(encoded) = serde_json::to_string(&activations) else { + continue; + }; + connection.execute( + "UPDATE routine_definitions SET activations_json = ?2 WHERE id = ?1", + params![id, encoded], + )?; + } + Ok(()) +} + pub fn delete_routine(id: &str) -> Result { let connection = conn()?; - let removed = - map_db(connection.execute("DELETE FROM routine_definitions WHERE id = ?1", [id]))?; + let removed = map_db(connection.execute( + "UPDATE routine_definitions + SET enabled = 0, next_fire_at = NULL, archived_at = ?2, updated_at = ?2 + WHERE id = ?1 AND archived_at IS NULL", + params![id, now_ms()], + ))?; Ok(removed > 0) } diff --git a/src-tauri/crates/project-management/src/projects/io/routines_tests.rs b/src-tauri/crates/project-management/src/projects/io/routines_tests.rs index 6f572c8c05..feb501e177 100644 --- a/src-tauri/crates/project-management/src/projects/io/routines_tests.rs +++ b/src-tauri/crates/project-management/src/projects/io/routines_tests.rs @@ -7,13 +7,14 @@ use test_helpers::test_env; fn routine_fixture(id: &str, policy: RoutineOutputPolicy) -> RoutineDefinition { RoutineDefinition { + activations: Vec::new(), id: id.to_string(), name: format!("Routine {id}"), description: "Routine test fixture".to_string(), enabled: true, - trigger: RoutineTrigger::OneTime { + trigger: Some(RoutineTrigger::OneTime { at: "2026-05-30T00:00:00Z".to_string(), - }, + }), run_template: RoutineRunTemplate { prompt: "Ask about the fixture".to_string(), target: RoutineRunTarget::AgentDefinition { @@ -82,6 +83,63 @@ fn upsert_round_trips_output_policy() { assert_eq!(read.output_policy, saved.output_policy); } +#[test] +fn upsert_canonicalizes_trigger_and_activations_both_ways() { + let _sandbox = test_env::sandbox(); + let mut routine = routine_fixture( + "routine-canonical", + policy(RoutineConcurrencyPolicy::AlwaysCreate), + ); + routine.trigger = Some(RoutineTrigger::Cron { + cron: "0 9 * * *".to_string(), + timezone: "UTC".to_string(), + }); + routine.activations = Vec::new(); + let saved = upsert_routine(routine).expect("upsert"); + assert_eq!(saved.activations.len(), 1); + + let mut routine = saved; + routine.activations = vec![ + crate::routine_service::spec::Activation::Manual { + policies: Default::default(), + }, + crate::routine_service::spec::Activation::Schedule { + cron: "30 8 * * 2".to_string(), + timezone: "UTC".to_string(), + policies: Default::default(), + }, + ]; + let saved = upsert_routine(routine).expect("upsert"); + assert_eq!( + saved.trigger, + Some(RoutineTrigger::Cron { + cron: "30 8 * * 2".to_string(), + timezone: "UTC".to_string(), + }) + ); + assert!(saved.next_fire_at.is_some()); +} + +#[test] +fn backfill_populates_activations_for_legacy_rows() { + let _sandbox = test_env::sandbox(); + let routine = routine_fixture( + "routine-backfill", + policy(RoutineConcurrencyPolicy::AlwaysCreate), + ); + upsert_routine(routine).expect("upsert"); + let connection = conn().expect("conn"); + connection + .execute( + "UPDATE routine_definitions SET activations_json = '[]' WHERE id = 'routine-backfill'", + [], + ) + .expect("strip"); + backfill_routine_activations(&connection).expect("backfill"); + let read = read_routine("routine-backfill").expect("read"); + assert_eq!(read.activations.len(), 1); +} + #[test] fn upsert_computes_next_fire_immediately_in_declared_timezone() { use chrono::Timelike; @@ -91,10 +149,10 @@ fn upsert_computes_next_fire_immediately_in_declared_timezone() { "routine-next-fire", policy(RoutineConcurrencyPolicy::AlwaysCreate), ); - routine.trigger = RoutineTrigger::Cron { + routine.trigger = Some(RoutineTrigger::Cron { cron: "0 9 * * *".to_string(), timezone: "America/Vancouver".to_string(), - }; + }); let saved = upsert_routine(routine).expect("upsert routine"); let next = saved.next_fire_at.expect("next fire projected on save"); @@ -594,10 +652,10 @@ fn unknown_fire_status_is_a_decode_error() { connection .execute( "INSERT INTO routine_fires ( - id, routine_id, fired_at, status, session_id, agent_org_run_id, - work_item_id, coalesced_into_fire_id, idempotency_key, started_at, - completed_at, error - ) VALUES (?1, ?2, ?3, ?4, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL)", + id, routine_id, fired_at, status, session_id, agent_org_run_id, + work_item_id, coalesced_into_fire_id, idempotency_key, started_at, + completed_at, error + ) VALUES (?1, ?2, ?3, ?4, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL)", params!["bad-fire", "routine-bad-status", now_ms(), "mystery"], ) .expect("insert bad fire"); 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 a35dca3d62..b2c663298e 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 @@ -29,7 +29,8 @@ pub use closure_api::{ }; pub(crate) use partial::update_standalone_work_item_partial_with_revisions; pub use partial::{ - update_standalone_work_item_partial, update_work_item_partial, + update_standalone_work_item_partial, update_standalone_work_item_partial_at_revision, + update_work_item_partial, update_work_item_partial_at_revision, update_work_item_partial_with_revisions, }; pub use scope::AtomicServiceOptions; diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/atomic/diff.rs b/src-tauri/crates/project-management/src/projects/io/work_items/atomic/diff.rs index c8342cb5c2..b62bfb735d 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/atomic/diff.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/atomic/diff.rs @@ -2,7 +2,7 @@ //! pre/post-mutation diff, the payload-tail fingerprint, and the outbox //! payload projection. -use crate::projects::types::{WorkItemData, WorkItemFrontmatter, WorkItemPartialUpdate}; +use crate::projects::types::{WorkItemData, WorkItemFrontmatter}; /// Sync-relevant fields whose mutations are tracked in /// `workitem_extras.field_revisions`. The names match @@ -59,26 +59,6 @@ pub(super) fn payload_tail_fingerprint(fm: &WorkItemFrontmatter) -> serde_json:: }) } -/// True when the patch touches any field that lives only in the server -/// payload jsonb (outside the sync-tracked field set). -pub(super) fn touches_payload_tail(updates: &WorkItemPartialUpdate) -> bool { - updates.todos.is_some() - || updates.comments.is_some() - || updates.handoff.is_some() - || updates.linked_sessions.is_some() - || updates.orchestrator_config.is_some() - || updates.orchestrator_state.is_some() - || updates.schedule.is_some() - || updates.execution_lock.is_some() - || updates.close_out.is_some() - || updates.work_products.is_some() - || updates.starred.is_some() - || updates.assignee_type.is_some() - || updates.project.is_some() - || updates.created_by.is_some() - || updates.stage.is_some() -} - /// Build the JSON payload that gets persisted to /// `outbox_entries.payload_json` for an `update` row. Includes every /// changed sync-tracked field's post-mutation value so the adapter @@ -141,8 +121,8 @@ pub(super) struct SyncFieldSnapshot { title: String, body: String, status: String, - priority: String, - assignee: Option, + pub(super) priority: String, + pub(super) assignee: Option, milestone: Option, start_date: Option, target_date: Option, diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/atomic/engine.rs b/src-tauri/crates/project-management/src/projects/io/work_items/atomic/engine.rs index d51a4aeca4..c979ceccae 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/atomic/engine.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/atomic/engine.rs @@ -14,6 +14,7 @@ use crate::projects::io::helpers::{conn, from_iso8601, map_db, now_ms, to_iso860 use crate::projects::io::work_items::extras::{ExtrasPayload, FieldRevision, REVISION_SOURCE_LOCAL}; use crate::projects::io::work_items::history::{append_mutation_event, WorkItemHistorySnapshot}; use crate::projects::types::WorkItemFrontmatter; +use crate::work_service::state::{map_legacy_status, WorkItemState}; pub(super) fn update_work_item_atomic_with_revisions_scoped( scope: AtomicWorkItemScope<'_>, @@ -99,11 +100,9 @@ where // (mismatch -> conflict) or queues behind us. if let Some(expected) = service.expected_local_version { if expected != core.local_version { - return Err(format!( - "{}:{}:{}", - crate::work_service::error::REVISION_CONFLICT, + return Err(crate::work_service::error::revision_conflict( expected, - core.local_version + core.local_version, )); } } @@ -170,21 +169,6 @@ where // is still visible in the audit stream. 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 service.strict_fsm { - return Err(crate::work_service::error::invalid_transition( - &core.status, - &frontmatter.status, - )); - } - fsm_violation = Some(violation); - } - } - let changed_fields = before.diff(&frontmatter, &body); let assignment_changed = core.assignee != frontmatter.assignee || core.assignee_type != frontmatter.assignee_type; @@ -227,6 +211,52 @@ where } else { core.org_id.clone() }; + let status_scope_changed = core.org_id != next_org_id; + if status_changed || status_scope_changed { + crate::work_item_features::statuses::ensure_status_assignable_in( + &tx, + &next_org_id, + &frontmatter.status, + (!status_scope_changed).then_some(core.status.as_str()), + )?; + } + let (effective_status_from, effective_status_to) = if status_changed || status_scope_changed { + ( + crate::work_item_features::statuses::effective_status_in( + &tx, + &core.org_id, + &core.status, + ), + crate::work_item_features::statuses::effective_status_in( + &tx, + &next_org_id, + &frontmatter.status, + ), + ) + } else { + (core.status.clone(), frontmatter.status.clone()) + }; + let status_semantics_changed = effective_status_from != effective_status_to; + if status_changed { + 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, + &frontmatter.status, + )); + } + fsm_violation = Some(violation); + } + } + let status_is_terminal = |status: &str| { + matches!( + map_legacy_status(status), + Some(WorkItemState::Completed | WorkItemState::Failed | WorkItemState::Cancelled) + ) + }; if next_project_id != project_id { let exists_at_dest: bool = if let Some(next_project_id) = next_project_id.as_ref() { map_db( @@ -375,6 +405,76 @@ where params![&core.work_item_id, next_extras_json], ))?; + let mut child_dispatch_ready = false; + + { + let scope_key = match scope { + AtomicWorkItemScope::Project(slug) => format!("project:{slug}"), + AtomicWorkItemScope::Standalone { .. } => format!("org:{next_org_id}"), + }; + let actor_id = actor.map(|value| value.id.as_str()); + crate::work_item_features::subscriptions::notify_field_changes( + &tx, + crate::work_item_features::subscriptions::FieldChangeNotification { + scope_key: &scope_key, + work_item_id: &core.short_id, + title: &frontmatter.title, + actor_id, + status_change: status_changed + .then_some((core.status.as_str(), frontmatter.status.as_str())), + assignee_change: assignment_changed + .then_some((before.assignee.as_deref(), frontmatter.assignee.as_deref())), + priority_change: (before.priority != frontmatter.priority) + .then_some((before.priority.as_str(), frontmatter.priority.as_str())), + dates_changed: changed_fields.contains(&"start_date") + || changed_fields.contains(&"target_date"), + now, + }, + )?; + if status_changed || status_semantics_changed { + let became_terminal = status_is_terminal(&effective_status_to) + && !status_is_terminal(&effective_status_from); + if became_terminal { + if let Some(parent) = frontmatter + .parent + .as_deref() + .map(str::trim) + .filter(|parent| !parent.is_empty()) + { + let project_slug = match scope { + AtomicWorkItemScope::Project(slug) => Some(slug), + AtomicWorkItemScope::Standalone { .. } => None, + }; + crate::work_item_features::subscriptions::notify_child_terminal( + &tx, + crate::work_item_features::subscriptions::ChildTerminalNotification { + scope_key: &scope_key, + parent_short_id: parent, + child_short_id: &core.short_id, + child_title: &frontmatter.title, + status: &frontmatter.status, + actor_id, + now, + }, + )?; + child_dispatch_ready |= + crate::work_item_features::post_child_terminal_system_comment_in_transaction( + &tx, + crate::work_item_features::ChildTerminalSystemComment { + project_slug, + org_id: &next_org_id, + parent_short_id: parent, + child_short_id: &core.short_id, + child_title: &frontmatter.title, + status: &frontmatter.status, + child_revision: next_version, + }, + )?; + } + } + } + } + // Audit + cross-process watermark, same transaction as the mutation // (frozen persistence invariant, design §19). Every RMW path funnels // through here, so UI patches, agent tools, sync merges and the @@ -412,19 +512,15 @@ where )?; map_db(tx.commit())?; + if child_dispatch_ready { + crate::projects::events::notify_work_item_dispatch_ready(); + } if scheduler_changed { crate::projects::events::notify_work_item_schedule_changed(); } - if status_changed { - use crate::work_service::state::{map_legacy_status, WorkItemState}; - let was_terminal = matches!( - map_legacy_status(&core.status), - Some(WorkItemState::Completed | WorkItemState::Failed | WorkItemState::Cancelled) - ); - let is_terminal = matches!( - map_legacy_status(&frontmatter.status), - Some(WorkItemState::Completed | WorkItemState::Failed | WorkItemState::Cancelled) - ); + if status_changed || status_semantics_changed { + let was_terminal = status_is_terminal(&effective_status_from); + let is_terminal = status_is_terminal(&effective_status_to); if is_terminal && !was_terminal { crate::projects::events::notify_work_item_terminal( crate::projects::events::WorkItemTerminalEvent { diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/atomic/partial.rs b/src-tauri/crates/project-management/src/projects/io/work_items/atomic/partial.rs index d756175768..c3987c0e8c 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/atomic/partial.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/atomic/partial.rs @@ -4,12 +4,54 @@ use std::collections::HashMap; -use super::diff::{changed_fields_payload, touches_payload_tail}; +use rusqlite::{params, OptionalExtension}; + +use super::diff::changed_fields_payload; use super::engine::update_work_item_atomic_with_revisions_scoped; use super::scope::{AtomicServiceOptions, AtomicWorkItemScope}; +use crate::projects::io::helpers::{conn, map_db}; use crate::projects::io::work_items::extras::FieldRevision; use crate::projects::types::{WorkItemData, WorkItemPartialUpdate}; +struct PersistedWorkItemLocation { + data: WorkItemData, + org_id: String, + project_slug: Option, +} + +/// Re-read the authoritative post-commit scope; a partial patch may move the +/// row to another project or to/from standalone scope. +fn read_persisted_work_item_location( + work_item_id: &str, + short_id: &str, +) -> Result { + let connection = conn()?; + let location = map_db( + connection + .query_row( + "SELECT w.org_id, p.slug + FROM workitems w + LEFT JOIN projects p ON p.id = w.project_id + WHERE w.id = ?1 AND w.short_id = ?2", + params![work_item_id, short_id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option>(1)?)), + ) + .optional(), + )? + .ok_or_else(|| format!("Work item '{}' not found after update", short_id))?; + drop(connection); + + let data = match location.1.as_deref() { + Some(project_slug) => super::super::crud::read_work_item(project_slug, short_id)?, + None => super::super::crud::read_standalone_work_item(Some(&location.0), short_id)?, + }; + Ok(PersistedWorkItemLocation { + data, + org_id: location.0, + project_slug: location.1, + }) +} + /// Apply a partial update and return the new `WorkItemData`. /// /// Outbox emission: when the project is bound to a sync adapter, @@ -24,19 +66,65 @@ pub fn update_work_item_partial( short_id: &str, updates: &WorkItemPartialUpdate, ) -> Result { - let (data, changed_fields) = - update_work_item_partial_with_revisions(project_slug, short_id, HashMap::new(), updates)?; - if !changed_fields.is_empty() { + update_project_work_item_partial_serviced( + project_slug, + short_id, + updates, + AtomicServiceOptions::default(), + ) +} + +/// UI-facing partial update with an optimistic concurrency precondition. +pub fn update_work_item_partial_at_revision( + project_slug: &str, + short_id: &str, + updates: &WorkItemPartialUpdate, + expected_revision: i64, +) -> Result { + update_project_work_item_partial_serviced( + project_slug, + short_id, + updates, + AtomicServiceOptions { + expected_local_version: Some(expected_revision), + ..Default::default() + }, + ) +} + +fn update_project_work_item_partial_serviced( + project_slug: &str, + short_id: &str, + updates: &WorkItemPartialUpdate, + service: AtomicServiceOptions, +) -> Result { + let (data, changed_fields, payload_tail_changed) = update_work_item_partial_scoped( + AtomicWorkItemScope::Project(project_slug), + short_id, + HashMap::new(), + service, + updates, + )?; + let persisted = read_persisted_work_item_location(&data.frontmatter.id, short_id)?; + let moved = persisted.project_slug.as_deref() != Some(project_slug); + if moved { + crate::sync::collab_bridge::record_work_item_write( + &persisted.org_id, + persisted.project_slug.as_deref(), + &persisted.data.frontmatter.id, + persisted.data.frontmatter.deleted_at.is_some(), + )?; + } else if !changed_fields.is_empty() { let payload = changed_fields_payload(&data, &changed_fields); crate::sync::io::record_local_update(project_slug, short_id, &changed_fields, &payload)?; - } else if touches_payload_tail(updates) { + } else if payload_tail_changed { // Payload-tail-only patch (todos / comments / linked sessions / // orchestrator state / lock …): not covered by the sync-tracked // diff, but collab-synced orgs still need to push the row — // those fields travel in the server payload jsonb (design §16.3). crate::sync::collab_bridge::record_work_item_payload_touch(project_slug, short_id)?; } - Ok(data) + Ok(persisted.data) } /// Standalone-org counterpart to [`update_work_item_partial`]. @@ -50,23 +138,56 @@ pub fn update_standalone_work_item_partial( org_id: Option<&str>, short_id: &str, updates: &WorkItemPartialUpdate, +) -> Result { + update_standalone_work_item_partial_serviced( + org_id, + short_id, + updates, + AtomicServiceOptions::default(), + ) +} + +pub fn update_standalone_work_item_partial_at_revision( + org_id: Option<&str>, + short_id: &str, + updates: &WorkItemPartialUpdate, + expected_revision: i64, +) -> Result { + update_standalone_work_item_partial_serviced( + org_id, + short_id, + updates, + AtomicServiceOptions { + expected_local_version: Some(expected_revision), + ..Default::default() + }, + ) +} + +fn update_standalone_work_item_partial_serviced( + org_id: Option<&str>, + short_id: &str, + updates: &WorkItemPartialUpdate, + service: AtomicServiceOptions, ) -> Result { let org_id = org_id.unwrap_or("personal-org"); let (data, changed_fields, payload_tail_changed) = update_work_item_partial_scoped( AtomicWorkItemScope::Standalone { org_id }, short_id, HashMap::new(), + service, updates, )?; + let persisted = read_persisted_work_item_location(&data.frontmatter.id, short_id)?; if !changed_fields.is_empty() || payload_tail_changed { crate::sync::collab_bridge::record_work_item_write( - org_id, - None, - &data.frontmatter.id, - data.frontmatter.deleted_at.is_some(), + &persisted.org_id, + persisted.project_slug.as_deref(), + &persisted.data.frontmatter.id, + persisted.data.frontmatter.deleted_at.is_some(), )?; } - Ok(data) + Ok(persisted.data) } /// Variant of [`update_work_item_partial`] that lets the caller supply @@ -87,6 +208,7 @@ pub fn update_work_item_partial_with_revisions( AtomicWorkItemScope::Project(project_slug), short_id, override_revisions, + AtomicServiceOptions::default(), updates, )?; Ok((data, changed_fields)) @@ -107,6 +229,7 @@ pub(crate) fn update_standalone_work_item_partial_with_revisions( AtomicWorkItemScope::Standalone { org_id }, short_id, override_revisions, + AtomicServiceOptions::default(), updates, )?; Ok((data, changed_fields)) @@ -116,6 +239,7 @@ fn update_work_item_partial_scoped( scope: AtomicWorkItemScope<'_>, short_id: &str, override_revisions: HashMap, + service: AtomicServiceOptions, updates: &WorkItemPartialUpdate, ) -> Result<(WorkItemData, Vec<&'static str>, bool), String> { update_work_item_atomic_with_revisions_scoped( @@ -123,7 +247,7 @@ fn update_work_item_partial_scoped( short_id, override_revisions, updates.actor.as_ref(), - AtomicServiceOptions::default(), + service, |fm, body| { let now_iso = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); @@ -206,6 +330,7 @@ fn update_work_item_partial_scoped( frontmatter: fm.clone(), body: body.clone(), filename: short_id.to_string(), + revision: None, }) }, ) 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 fc55aa6ac0..03e3a71ecc 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 @@ -132,6 +132,79 @@ fn atomic_persists_closure_mutations_and_bumps_version() { assert_eq!(current_local_version("w1"), 1, "version must bump"); } +#[test] +fn partial_update_revision_guard_rejects_stale_project_and_standalone_edits() { + let _sandbox = test_env::sandbox(); + seed("demo", "p1"); + seed_standalone("org-1"); + + let project_revision = read_work_item("demo", "AAA-0001") + .expect("read project item") + .revision + .expect("database revision"); + let updated = update_work_item_partial_at_revision( + "demo", + "AAA-0001", + &WorkItemPartialUpdate { + title: Some("Guarded edit".to_string()), + ..Default::default() + }, + project_revision, + ) + .expect("guarded project update"); + assert_eq!(updated.revision, Some(project_revision + 1)); + let stale = update_work_item_partial_at_revision( + "demo", + "AAA-0001", + &WorkItemPartialUpdate { + title: Some("Stale overwrite".to_string()), + ..Default::default() + }, + project_revision, + ) + .expect_err("stale project revision must conflict"); + assert!( + stale.starts_with(crate::work_service::error::REVISION_CONFLICT), + "{stale}" + ); + assert_eq!( + read_work_item("demo", "AAA-0001") + .expect("read guarded project item") + .frontmatter + .title, + "Guarded edit" + ); + + let standalone_revision = read_standalone_work_item(Some("org-1"), "ORG-0001") + .expect("read standalone item") + .revision + .expect("standalone database revision"); + update_standalone_work_item_partial_at_revision( + Some("org-1"), + "ORG-0001", + &WorkItemPartialUpdate { + priority: Some("high".to_string()), + ..Default::default() + }, + standalone_revision, + ) + .expect("guarded standalone update"); + let stale = update_standalone_work_item_partial_at_revision( + Some("org-1"), + "ORG-0001", + &WorkItemPartialUpdate { + priority: Some("low".to_string()), + ..Default::default() + }, + standalone_revision, + ) + .expect_err("stale standalone revision must conflict"); + assert!( + stale.starts_with(crate::work_service::error::REVISION_CONFLICT), + "{stale}" + ); +} + #[test] fn partial_update_records_property_and_body_history() { let _sandbox = test_env::sandbox(); @@ -1171,3 +1244,53 @@ fn atomic_two_calls_serialize_and_both_persist() { assert_eq!(after.frontmatter.status, "in_progress"); assert_eq!(current_local_version("w1"), 2, "both writes bumped version"); } + +#[test] +fn archived_custom_status_keeps_history_but_rejects_new_assignments() { + use crate::work_item_features::statuses::{self, UpsertStatusDefinitionRequest}; + + let _sandbox = test_env::sandbox(); + seed("demo", "p1"); + let second = work_item_fixture("w2", "AAA-0002", "Second"); + write_work_item("demo", "AAA-0002", &second, "second body").expect("seed second item"); + + let definition = statuses::upsert_definition(UpsertStatusDefinitionRequest { + id: None, + org_id: "personal-org".to_string(), + key: Some("shipped".to_string()), + name: "Shipped".to_string(), + category: Some("completed".to_string()), + color: None, + description: None, + position: None, + }) + .expect("create custom status"); + + let mut assign = WorkItemPartialUpdate::default(); + assign.status = Some("shipped".to_string()); + update_work_item_partial("demo", "AAA-0001", &assign).expect("assign active status"); + statuses::set_definition_archived("personal-org", &definition.id, true) + .expect("archive status"); + + let mut rename = WorkItemPartialUpdate::default(); + rename.title = Some("Historical shipped item".to_string()); + update_work_item_partial("demo", "AAA-0001", &rename) + .expect("existing archived status remains writable"); + assert_eq!( + read_work_item("demo", "AAA-0001") + .expect("read historical item") + .frontmatter + .status, + "shipped" + ); + + let error = update_work_item_partial("demo", "AAA-0002", &assign) + .expect_err("archived status cannot be newly assigned"); + assert_eq!(error, "PM_ERR:STATUS_ARCHIVED:shipped"); + + let mut third = work_item_fixture("w3", "AAA-0003", "Third"); + third.status = "shipped".to_string(); + let create_error = write_work_item("demo", "AAA-0003", &third, "third body") + .expect_err("new rows cannot start in an archived status"); + assert_eq!(create_error, "PM_ERR:STATUS_ARCHIVED:shipped"); +} 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 c7002a53d4..b7514b6993 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 @@ -22,6 +22,23 @@ use crate::projects::types::{ const WORK_ITEM_PREFIX_LENGTH: usize = 3; +fn effective_status_for_bucket( + connection: &rusqlite::Connection, + categories_by_org: &mut HashMap>, + org_id: &str, + raw_status: &str, +) -> String { + let categories = categories_by_org + .entry(org_id.to_string()) + .or_insert_with(|| { + crate::work_item_features::statuses::category_map_in(connection, org_id) + }); + categories + .get(raw_status) + .cloned() + .unwrap_or_else(|| raw_status.to_string()) +} + // --------------------------------------------------------------------- // Public API // --------------------------------------------------------------------- @@ -129,10 +146,17 @@ pub fn read_all_work_items_scoped_filtered( params![&project_id], )?; let mut labels_by_work_item = read_project_labels(&connection, &project_id)?; + let mut status_categories_by_org = HashMap::new(); let mut out = Vec::new(); - for (core, extras_json, _) in rows { + for (core, extras_json, row_org_id) in rows { + let effective_status = effective_status_for_bucket( + &connection, + &mut status_categories_by_org, + &row_org_id, + &core.status, + ); if read_bucket - .map(|bucket| !bucket.matches(&core.status)) + .map(|bucket| !bucket.matches(&effective_status)) .unwrap_or(false) { continue; @@ -164,7 +188,8 @@ pub fn read_work_item_scoped( connection .query_row( "SELECT id, project_id, short_id, title, body, status, priority, assignee, assignee_type, - milestone, parent, start_date, target_date, created_at, updated_at, deleted_at + milestone, parent, start_date, target_date, created_at, updated_at, deleted_at, + local_version FROM workitems WHERE project_id = ?1 AND short_id = ?2", params![&project_id, short_id], @@ -195,10 +220,17 @@ pub fn read_standalone_work_items_filtered( params![org_id], )?; let mut labels_by_work_item = read_standalone_labels(&connection, org_id)?; + let mut status_categories_by_org = HashMap::new(); let mut out = Vec::new(); - for (core, extras_json, _) in rows { + for (core, extras_json, row_org_id) in rows { + let effective_status = effective_status_for_bucket( + &connection, + &mut status_categories_by_org, + &row_org_id, + &core.status, + ); if read_bucket - .map(|bucket| !bucket.matches(&core.status)) + .map(|bucket| !bucket.matches(&effective_status)) .unwrap_or(false) { continue; @@ -221,10 +253,17 @@ pub(super) fn read_all_standalone_work_items_filtered( 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 status_categories_by_org = HashMap::new(); let mut out = Vec::new(); for (core, extras_json, org_id) in rows { + let effective_status = effective_status_for_bucket( + &connection, + &mut status_categories_by_org, + &org_id, + &core.status, + ); if read_bucket - .map(|bucket| !bucket.matches(&core.status)) + .map(|bucket| !bucket.matches(&effective_status)) .unwrap_or(false) { continue; @@ -250,8 +289,8 @@ where 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.org_id + w.target_date, w.created_at, w.updated_at, w.deleted_at, w.local_version, + e.extras_json, w.org_id FROM workitems w LEFT JOIN workitem_extras e ON e.work_item_id = w.id {where_clause} @@ -261,8 +300,8 @@ where let rows = map_db(stmt.query_map(query_params, |row| { Ok(( row_to_core(row)?, - row.get::<_, Option>(16)?, - row.get::<_, String>(17)?, + row.get::<_, Option>(17)?, + row.get::<_, String>(18)?, )) }))?; let mut out = Vec::new(); @@ -330,7 +369,8 @@ pub fn read_standalone_work_item( connection .query_row( "SELECT id, project_id, short_id, title, body, status, priority, assignee, assignee_type, - milestone, parent, start_date, target_date, created_at, updated_at, deleted_at + milestone, parent, start_date, target_date, created_at, updated_at, deleted_at, + local_version FROM workitems WHERE org_id = ?1 AND project_id IS NULL AND short_id = ?2", params![org_id, short_id], @@ -422,7 +462,8 @@ pub fn read_work_item_by_row_id( connection .query_row( "SELECT id, project_id, short_id, title, body, status, priority, assignee, assignee_type, - milestone, parent, start_date, target_date, created_at, updated_at, deleted_at + milestone, parent, start_date, target_date, created_at, updated_at, deleted_at, + local_version FROM workitems WHERE id = ?1 AND org_id = ?2", params![work_item_id, org_id], @@ -487,12 +528,13 @@ pub(crate) fn write_work_item_in_tx( let deleted_at = next_frontmatter.deleted_at.as_deref().map(from_iso8601); let existing_item: Option = map_db( tx.query_row( - "SELECT id, title, body, status, priority, assignee, milestone, + "SELECT org_id, title, body, status, priority, assignee, milestone, start_date, target_date FROM workitems WHERE id = ?1", params![&next_frontmatter.id], |row| { Ok(PriorSyncSnapshot { + org_id: row.get(0)?, title: row.get(1)?, body: row.get::<_, Option>(2)?.unwrap_or_default(), status: row.get(3)?, @@ -521,6 +563,15 @@ pub(crate) fn write_work_item_in_tx( } None => None, }; + crate::work_item_features::statuses::ensure_status_assignable_in( + tx, + org_id, + &next_frontmatter.status, + existing_item + .as_ref() + .filter(|prior| prior.org_id == org_id) + .map(|prior| prior.status.as_str()), + )?; if existing_item.is_none() { ensure_created_event(&mut next_frontmatter, &to_iso8601(created_at)); } @@ -845,9 +896,18 @@ pub fn allocate_standalone_short_id(org_id: Option<&str>) -> Result Result { let prefix = "WI"; let mut next_id = 1_i64; - if let Some(max_existing) = max_existing_standalone_work_item_number(&tx, org_id, prefix)? { + if let Some(max_existing) = max_existing_standalone_work_item_number(tx, org_id, prefix)? { next_id = (max_existing as i64).saturating_add(1); } // `workitems.id` is a GLOBAL primary key (`id = short_id` until the @@ -870,7 +930,6 @@ pub fn allocate_standalone_short_id(org_id: Option<&str>) -> Result R /// transaction so whole-row writes can stamp `("local", now)` revisions /// for the fields they actually changed. struct PriorSyncSnapshot { + org_id: String, title: String, body: String, status: String, 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 2c7201878d..f4b2bd22e5 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 @@ -200,6 +200,7 @@ pub(super) fn enrich_work_item( title: fm.title.clone(), body: item.body, filename: item.filename, + revision: item.revision.unwrap_or_default(), status: fm.status.clone(), priority: fm.priority.clone(), diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/mapping.rs b/src-tauri/crates/project-management/src/projects/io/work_items/mapping.rs index 08a4defe80..ffd393d00f 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/mapping.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/mapping.rs @@ -31,6 +31,7 @@ pub(super) struct WorkItemCore { pub created_at_ms: i64, pub updated_at_ms: i64, pub deleted_at_ms: Option, + pub local_version: i64, } /// Map a `workitems` row (in the canonical hot-column order) into a @@ -54,6 +55,7 @@ pub(super) fn row_to_core(row: &rusqlite::Row<'_>) -> rusqlite::Result, ) -> Result { let all_items = read_all_work_items_enriched_scoped(project_slug, org_id)?; + // Custom statuses fold into their category bucket for every + // interpretation below (filters, counts, kanban columns). + let status_categories = + crate::work_item_features::statuses::category_map_for_project(project_slug, org_id); + let effective_status = |status: &str| -> String { + status_categories + .get(status) + .cloned() + .unwrap_or_else(|| status.to_string()) + }; let active_items: Vec = all_items .iter() .filter(|item| item.deleted_at.is_none()) @@ -64,11 +74,11 @@ pub fn read_work_items_view_data_scoped_for_view( // Counts come from the *unfiltered* active list so the filter badges in // the sidebar always show the true totals, not "results matching // the current search". - let counts = compute_status_counts(&active_items); + let counts = compute_status_counts(&active_items, &effective_status); let visible_items: Vec = active_items .into_iter() - .filter(|item| matches_view_filters(item, status_filter, search_query)) + .filter(|item| matches_view_filters(item, status_filter, search_query, &effective_status)) .collect(); let items: Vec = all_items .into_iter() @@ -76,23 +86,32 @@ pub fn read_work_items_view_data_scoped_for_view( if item.deleted_at.is_some() && !matches_all_status_filter(status_filter) { return false; } - matches_view_filters(item, status_filter, search_query) + matches_view_filters(item, status_filter, search_query, &effective_status) }) .collect(); let include_all_projections = view.is_none(); let kanban_tasks = if include_all_projections || view == Some("kanban") { - visible_items.iter().map(to_kanban_task).collect() + visible_items + .iter() + .map(|item| to_kanban_task_with_status(item, &effective_status(&item.status))) + .collect() } else { Vec::new() }; let gantt_tasks = if include_all_projections || view == Some("gantt") { - visible_items.iter().filter_map(to_gantt_task).collect() + visible_items + .iter() + .filter_map(|item| to_gantt_task_with_status(item, &effective_status(&item.status))) + .collect() } else { Vec::new() }; let calendar_events = if include_all_projections || view == Some("calendar") { - visible_items.iter().filter_map(to_calendar_event).collect() + visible_items + .iter() + .filter_map(|item| to_calendar_event_with_status(item, &effective_status(&item.status))) + .collect() } else { Vec::new() }; @@ -114,9 +133,11 @@ fn matches_view_filters( item: &EnrichedWorkItem, status_filter: Option<&str>, search_query: Option<&str>, + effective_status: &dyn Fn(&str) -> String, ) -> bool { if let Some(filter) = status_filter { - if !matches_all_status_filter(Some(filter)) && !matches_status_filter(&item.status, filter) + if !matches_all_status_filter(Some(filter)) + && !matches_status_filter(&effective_status(&item.status), filter) { return false; } @@ -142,6 +163,7 @@ fn matches_status_filter(item_status: &str, filter: &str) -> bool { "todo" | "planned" => item_status == "planned" || item_status == "todo", "inProgress" | "in_progress" => item_status == "in_progress", "inReview" | "in_review" => item_status == "in_review", + "blocked" => item_status == "blocked", "done" | "completed" => item_status == "completed", "cancelled" => item_status == "cancelled", "duplicate" => item_status == "duplicate", @@ -174,23 +196,28 @@ fn matches_search_query(item: &EnrichedWorkItem, query: &str) -> bool { // Counts + grouping // --------------------------------------------------------------------- -fn compute_status_counts(items: &[EnrichedWorkItem]) -> StatusCounts { +fn compute_status_counts( + items: &[EnrichedWorkItem], + effective_status: &dyn Fn(&str) -> String, +) -> StatusCounts { let mut counts = StatusCounts { all: items.len(), backlog: 0, planned: 0, in_progress: 0, in_review: 0, + blocked: 0, completed: 0, cancelled: 0, duplicate: 0, }; for item in items { - match item.status.as_str() { + match effective_status(&item.status).as_str() { "backlog" => counts.backlog += 1, "planned" | "todo" => counts.planned += 1, "in_progress" => counts.in_progress += 1, "in_review" => counts.in_review += 1, + "blocked" => counts.blocked += 1, "completed" => counts.completed += 1, "cancelled" => counts.cancelled += 1, "duplicate" => counts.duplicate += 1, @@ -210,6 +237,7 @@ fn work_item_to_kanban_status(status: &str) -> KanbanStatus { "planned" | "todo" => KanbanStatus::Planned, "in_progress" => KanbanStatus::InProgress, "in_review" => KanbanStatus::InReview, + "blocked" => KanbanStatus::Blocked, "completed" => KanbanStatus::Completed, "cancelled" => KanbanStatus::Cancelled, "duplicate" => KanbanStatus::Duplicate, @@ -217,7 +245,7 @@ fn work_item_to_kanban_status(status: &str) -> KanbanStatus { } } -fn to_kanban_task(item: &EnrichedWorkItem) -> KanbanTask { +fn to_kanban_task_with_status(item: &EnrichedWorkItem, effective_status: &str) -> KanbanTask { KanbanTask { id: item.id.clone(), title: item.title.clone(), @@ -226,7 +254,7 @@ fn to_kanban_task(item: &EnrichedWorkItem) -> KanbanTask { } else { Some(item.body.clone()) }, - status: work_item_to_kanban_status(&item.status), + status: work_item_to_kanban_status(effective_status), priority: if item.priority == "none" { None } else { @@ -250,14 +278,14 @@ fn work_item_to_gantt_status(status: &str, target_date: Option<&str>) -> GanttSt } match status { "backlog" | "planned" | "todo" => GanttStatus::NotStarted, - "in_progress" | "in_review" => GanttStatus::InProgress, + "in_progress" | "in_review" | "blocked" => GanttStatus::InProgress, "completed" => GanttStatus::Completed, "cancelled" | "duplicate" => GanttStatus::Cancelled, _ => GanttStatus::NotStarted, } } -fn to_gantt_task(item: &EnrichedWorkItem) -> Option { +fn to_gantt_task_with_status(item: &EnrichedWorkItem, effective_status: &str) -> Option { let start_date = item .start_date .clone() @@ -265,9 +293,9 @@ fn to_gantt_task(item: &EnrichedWorkItem) -> Option { let end_date = if let Some(target) = &item.target_date { target.clone() } else { - let days = match item.status.as_str() { + let days = match effective_status { "completed" | "cancelled" | "duplicate" => 3, - "in_progress" | "in_review" => 7, + "in_progress" | "in_review" | "blocked" => 7, _ => 5, }; add_days_to_date(&start_date, days) @@ -277,13 +305,16 @@ fn to_gantt_task(item: &EnrichedWorkItem) -> Option { title: item.title.clone(), start_date, end_date, - status: work_item_to_gantt_status(&item.status, item.target_date.as_deref()), + status: work_item_to_gantt_status(effective_status, item.target_date.as_deref()), assignee: item.assignee.as_ref().map(|person| person.name.clone()), labels: item.labels.clone(), }) } -fn to_calendar_event(item: &EnrichedWorkItem) -> Option { +fn to_calendar_event_with_status( + item: &EnrichedWorkItem, + effective_status: &str, +) -> Option { let start_date = item .start_date .clone() @@ -298,7 +329,7 @@ fn to_calendar_event(item: &EnrichedWorkItem) -> Option { title: item.title.clone(), start_date, end_date, - status: item.status.clone(), + status: effective_status.to_string(), assignee: item.assignee.clone(), labels: item.labels.clone(), all_day, @@ -423,6 +454,49 @@ mod tests { assert_eq!(view.kanban_tasks.len(), 3); } + #[test] + fn archived_custom_status_keeps_its_blocked_view_semantics() { + let _sandbox = test_env::sandbox(); + seed(); + let definition = crate::work_item_features::statuses::upsert_definition( + crate::work_item_features::UpsertStatusDefinitionRequest { + id: None, + org_id: "personal-org".into(), + key: Some("waiting_external".into()), + name: "Waiting on external".into(), + category: Some("blocked".into()), + color: None, + description: None, + position: None, + }, + ) + .expect("custom blocked status"); + write_work_item( + "demo", + "AAA-0001", + &work_item_with("AAA-0001", "Waiting", "waiting_external"), + "", + ) + .expect("historical work item"); + crate::work_item_features::statuses::set_definition_archived( + "personal-org", + &definition.id, + true, + ) + .expect("archive custom status"); + + let view = read_work_items_view_data("demo", Some("blocked"), None) + .expect("blocked filtered view"); + assert_eq!(view.items.len(), 1); + assert_eq!(view.counts.blocked, 1); + assert!(matches!(view.kanban_tasks[0].status, KanbanStatus::Blocked)); + assert!(matches!( + view.gantt_tasks[0].status, + GanttStatus::InProgress + )); + assert_eq!(view.calendar_events[0].status, "blocked"); + } + #[test] fn list_projection_omits_unused_view_payloads() { let _sandbox = test_env::sandbox(); diff --git a/src-tauri/crates/project-management/src/projects/schema.rs b/src-tauri/crates/project-management/src/projects/schema.rs index 1885475545..43bf543605 100644 --- a/src-tauri/crates/project-management/src/projects/schema.rs +++ b/src-tauri/crates/project-management/src/projects/schema.rs @@ -111,6 +111,59 @@ pub fn init_pm_service_tables(conn: &Connection) -> SqliteResult<()> { created_at INTEGER NOT NULL, -- unix ms updated_at INTEGER NOT NULL ); + -- Canonical Routine JSON renders schedule activations as the exact + -- token below. The partial index keeps the 30-second due scan away + -- from manual/provider-only rows while the service still parses and + -- validates every selected snapshot before execution. + DROP INDEX IF EXISTS idx_pm_routines_schedule_due; + DROP INDEX IF EXISTS idx_pm_routines_activation_due_v2; + CREATE INDEX IF NOT EXISTS idx_pm_routines_activation_due + ON pm_routines(enabled, next_fire_at, name) + WHERE instr(spec_json, '"type":"schedule"') > 0 + OR instr(spec_json, '"type":"one_time"') > 0; + + -- Stable control-plane bridge. Legacy ids are UI identity; portable + -- names may change when a Routine is renamed. + CREATE TABLE IF NOT EXISTS pm_routine_legacy_bindings ( + legacy_routine_id TEXT PRIMARY KEY, + portable_name TEXT NOT NULL UNIQUE, + archived_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + -- Durable concurrency outcomes. Queued rows survive restart and are + -- promoted idempotently once the active portable run settles. + CREATE TABLE IF NOT EXISTS pm_routine_activation_events ( + id TEXT PRIMARY KEY, + routine_name TEXT NOT NULL, + invoke_key TEXT NOT NULL, + target_binding TEXT NOT NULL, + inputs_json TEXT NOT NULL, + status TEXT NOT NULL, + coalesced_run_id TEXT, + error TEXT, + scheduled_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(routine_name, invoke_key) + ); + CREATE INDEX IF NOT EXISTS idx_pm_routine_activation_queue + ON pm_routine_activation_events(status, created_at, id); + CREATE INDEX IF NOT EXISTS idx_pm_routine_activation_history + ON pm_routine_activation_events(routine_name, created_at DESC); + + -- Cross-process CAS for the short activation decision window. The + -- lease covers active-check through durable defer/invoke creation; + -- SQLite serializes claims and a crashed owner becomes recoverable. + CREATE TABLE IF NOT EXISTS pm_routine_activation_guards ( + routine_name TEXT PRIMARY KEY, + owner_token TEXT NOT NULL, + lease_expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_pm_routine_activation_guard_expiry + ON pm_routine_activation_guards(lease_expires_at); CREATE TABLE IF NOT EXISTS pm_routine_runs ( id TEXT PRIMARY KEY, -- run_ @@ -322,6 +375,82 @@ pub fn init_pm_service_tables(conn: &Connection) -> SqliteResult<()> { ); CREATE INDEX IF NOT EXISTS idx_pm_work_item_property_values_item ON pm_work_item_property_values(scope_key, work_item_id); + + CREATE TABLE IF NOT EXISTS pm_status_definitions ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL, + key TEXT NOT NULL, + name TEXT NOT NULL, + category TEXT NOT NULL, + color TEXT, + description TEXT, + 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_status_definitions_key + ON pm_status_definitions(org_id, key); + CREATE INDEX IF NOT EXISTS idx_pm_status_definitions_org + ON pm_status_definitions(org_id, archived_at, position); + + CREATE TABLE IF NOT EXISTS pm_saved_views ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL, + project_slug TEXT, + name TEXT NOT NULL, + query_json TEXT NOT NULL DEFAULT '{}', + display_json TEXT NOT NULL DEFAULT '{}', + position INTEGER NOT NULL DEFAULT 0, + created_by TEXT, + archived_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_pm_saved_views_org + ON pm_saved_views(org_id, archived_at, position); + + CREATE TABLE IF NOT EXISTS pm_quick_actions ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + target_kind TEXT NOT NULL, + target_id TEXT NOT NULL, + prompt TEXT NOT NULL, + use_count INTEGER NOT NULL DEFAULT 0, + created_by TEXT, + archived_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_pm_quick_actions_org + ON pm_quick_actions(org_id, archived_at, use_count DESC); + + CREATE TABLE IF NOT EXISTS pm_inbox_prefs ( + recipient_id TEXT NOT NULL, + kind TEXT NOT NULL, + muted_at INTEGER NOT NULL, + PRIMARY KEY (recipient_id, kind) + ); + + CREATE TABLE IF NOT EXISTS pm_org_skills ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + skill_md TEXT NOT NULL, + files_json TEXT NOT NULL DEFAULT '[]', + provenance_json TEXT, + shared_by TEXT, + archived_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_pm_org_skills_name + ON pm_org_skills(org_id, name) WHERE archived_at IS NULL; + CREATE INDEX IF NOT EXISTS idx_pm_org_skills_org + ON pm_org_skills(org_id, archived_at); "#, )?; Ok(()) @@ -707,6 +836,7 @@ fn init_local_tables(conn: &Connection) -> SqliteResult<()> { ensure_collab_sync_columns(conn)?; ensure_workitems_allow_standalone_scope(conn)?; ensure_routine_definitions_durable_columns(conn)?; + super::io::backfill_routine_activations(conn)?; ensure_routine_fires_durable_columns(conn)?; conn.execute( "CREATE INDEX IF NOT EXISTS idx_workitems_deleted_at ON workitems(deleted_at)", @@ -919,7 +1049,14 @@ fn ensure_routine_definitions_durable_columns(conn: &Connection) -> SqliteResult "TEXT NOT NULL DEFAULT '{}'", )?; ensure_column(conn, "routine_definitions", "last_evaluated_at", "INTEGER")?; - ensure_column(conn, "routine_definitions", "next_fire_at", "INTEGER") + ensure_column(conn, "routine_definitions", "next_fire_at", "INTEGER")?; + ensure_column(conn, "routine_definitions", "archived_at", "INTEGER")?; + ensure_column( + conn, + "routine_definitions", + "activations_json", + "TEXT NOT NULL DEFAULT '[]'", + ) } fn ensure_routine_fires_durable_columns(conn: &Connection) -> SqliteResult<()> { 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 5fb12becd8..c0a79efc72 100644 --- a/src-tauri/crates/project-management/src/projects/sync_export.rs +++ b/src-tauri/crates/project-management/src/projects/sync_export.rs @@ -245,6 +245,7 @@ mod tests { }, body: "Work item body".to_string(), filename: "DEM-0001".to_string(), + revision: None, }; let records = project_with_work_items_sync_records(&project, &[work_item]) diff --git a/src-tauri/crates/project-management/src/projects/types/enriched.rs b/src-tauri/crates/project-management/src/projects/types/enriched.rs index d8b34f8e5c..75628af9b1 100644 --- a/src-tauri/crates/project-management/src/projects/types/enriched.rs +++ b/src-tauri/crates/project-management/src/projects/types/enriched.rs @@ -61,6 +61,7 @@ pub struct EnrichedWorkItem { pub title: String, pub body: String, pub filename: String, + pub revision: i64, // Status pub status: String, 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 a5bf1f03c7..1529b5d617 100644 --- a/src-tauri/crates/project-management/src/projects/types/project.rs +++ b/src-tauri/crates/project-management/src/projects/types/project.rs @@ -227,6 +227,10 @@ pub struct CommentEntry { pub author: String, pub content: String, pub created_at: String, + /// Per-comment optimistic concurrency token. Legacy comments deserialize + /// as revision 0, so old workspace payloads remain editable. + #[serde(default)] + pub revision: i64, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub mentioned_user_ids: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -245,6 +249,16 @@ pub struct CommentEntry { pub conclusion: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_session_id: Option, + /// A2A chain: who caused the authoring agent's run (`member:`, + /// `session:`, or `user`). Absent on human-authored comments. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub originator: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub edited_at: Option, + /// Tombstone: content and mentions are cleared, thread structure and + /// routing metadata stay so replies keep resolving. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, } /// A market delegation entry on a work item diff --git a/src-tauri/crates/project-management/src/projects/types/routines.rs b/src-tauri/crates/project-management/src/projects/types/routines.rs index bb3cafed51..fc0b3a09cb 100644 --- a/src-tauri/crates/project-management/src/projects/types/routines.rs +++ b/src-tauri/crates/project-management/src/projects/types/routines.rs @@ -170,7 +170,14 @@ pub struct RoutineDefinition { pub name: String, pub description: String, pub enabled: bool, - pub trigger: RoutineTrigger, + /// Derived from the first schedulable activation; wire and display + /// compatibility only. `activations` is the single source of truth. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trigger: Option, + /// Complete portable activation list — the single source of truth for + /// when this routine fires. Canonicalized to non-empty on every write. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub activations: Vec, pub run_template: RoutineRunTemplate, #[serde(default = "default_output_policy")] pub output_policy: RoutineOutputPolicy, 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 6ef6545648..7eb2907019 100644 --- a/src-tauri/crates/project-management/src/projects/types/views.rs +++ b/src-tauri/crates/project-management/src/projects/types/views.rs @@ -18,6 +18,7 @@ pub enum KanbanStatus { Planned, InProgress, InReview, + Blocked, Completed, Cancelled, Duplicate, @@ -92,6 +93,7 @@ pub struct StatusCounts { pub planned: usize, pub in_progress: usize, pub in_review: usize, + pub blocked: usize, pub completed: usize, pub cancelled: usize, pub duplicate: usize, diff --git a/src-tauri/crates/project-management/src/projects/types/work_items.rs b/src-tauri/crates/project-management/src/projects/types/work_items.rs index 2acef02c02..6950b77b38 100644 --- a/src-tauri/crates/project-management/src/projects/types/work_items.rs +++ b/src-tauri/crates/project-management/src/projects/types/work_items.rs @@ -315,6 +315,10 @@ pub struct WorkItemData { pub body: String, /// Filename without extension (e.g. "AUTH-001") pub filename: String, + /// Local optimistic-concurrency revision. File/import snapshots omit it; + /// authoritative database reads always populate it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub revision: Option, } /// Narrow database projection consumed by the background schedule executor. @@ -364,6 +368,7 @@ mod work_item_read_bucket_tests { "planned", "in_progress", "in_review", + "blocked", "cancelled", "duplicate", ] { 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 index 044be1a87e..857029e3c6 100644 --- a/src-tauri/crates/project-management/src/projects/types/work_runs.rs +++ b/src-tauri/crates/project-management/src/projects/types/work_runs.rs @@ -126,6 +126,31 @@ pub enum WorkItemRunTarget { }, } +/// Provenance copied into a Run without embedding credentials or a mutable +/// filesystem path. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkItemRunSkillOrigin { + pub provider: String, + pub locator: String, +} + +/// Consent snapshot for one skill effective when the Run was enqueued. This +/// deliberately freezes identity and digests only; WorkItemRun is not a +/// package-release or full-body pinning system. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkItemRunSkillManifestEntry { + pub id: String, + pub name: String, + pub source: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub origin: Option, + pub identity_digest: String, + pub content_digest: String, + pub schema_digest: String, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WorkItemRunTargetSnapshot { @@ -162,6 +187,14 @@ pub struct WorkItemRunTargetSnapshot { pub workspace_mode: Option, pub agent_definition_id: Option, pub agent_org_id: Option, + /// Effective, available, explicitly consented skills after the resolved + /// agent include/exclude policy. Older Runs default to an empty manifest. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub skill_manifest: Vec, + /// Aggregate digest also marks that the manifest was captured. `None` + /// distinguishes legacy Runs from a new Run whose effective set is empty. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skill_manifest_digest: Option, } impl WorkItemRunTargetSnapshot { @@ -181,10 +214,31 @@ impl WorkItemRunTargetSnapshot { workspace_mode: None, agent_definition_id: None, agent_org_id: None, + skill_manifest: Vec::new(), + skill_manifest_digest: None, } } } +#[cfg(test)] +mod target_snapshot_tests { + use super::*; + + #[test] + fn legacy_target_snapshot_defaults_skill_manifest_to_empty() { + let legacy = serde_json::json!({ + "target": { "kind": "start_work_item", "accountId": null, "modelId": null }, + "workItemRevision": 1, + "workspacePath": null, + "agentDefinitionId": null, + "agentOrgId": null + }); + let snapshot: WorkItemRunTargetSnapshot = serde_json::from_value(legacy).unwrap(); + assert!(snapshot.skill_manifest.is_empty()); + assert!(snapshot.skill_manifest_digest.is_none()); + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum WorkItemRunFailureClass { diff --git a/src-tauri/crates/project-management/src/routine_service/convert.rs b/src-tauri/crates/project-management/src/routine_service/convert.rs index b147f8284a..e935383f9e 100644 --- a/src-tauri/crates/project-management/src/routine_service/convert.rs +++ b/src-tauri/crates/project-management/src/routine_service/convert.rs @@ -1,11 +1,9 @@ //! One-way conversion of legacy `RoutineDefinition` rows into portable -//! Routine specs (Phase 4 migration). +//! Routine specs plus host-local execution targets. //! //! The conversion is additive: portable definitions land in -//! `pm_routines` while legacy rows stay untouched until the Phase 5 -//! runtime unification deletes the legacy scheduler — running both -//! stores side by side cannot double-fire because the portable runtime's -//! scheduler does not exist yet. +//! `pm_routines`; legacy rows remain control-plane mirrors while the removed +//! legacy scheduler guarantees one authoritative execution path. //! //! What is expressible and what is not: //! - `CreateWorkItem` and `DirectSession` routines become single-step @@ -13,13 +11,11 @@ //! model/account/workspace/harness resources on the legacy template //! are NOT portable by design — they are reported as required //! execution bindings for the operator to configure. -//! - `UpdateExistingWorkItem` routines target an existing work item; -//! the portable equivalent (`routine run --root-work`) is not wired -//! yet, so those definitions are reported as `skipped` and keep -//! running on the legacy path until Phase 5. -//! - `OneTime` triggers have no portable activation (schedule requires -//! cron); the portable spec gets a manual activation and the report -//! notes the dropped one-shot timestamp. +//! - `UpdateExistingWorkItem` routines retain their target as a host-local +//! `RoutineInvocationTarget`; the portable spec itself stays free of +//! project/work-item identity. +//! - `OneTime` triggers retain their RFC 3339 timestamp as a portable +//! one-time activation. use serde::Serialize; use std::collections::BTreeMap; @@ -33,6 +29,7 @@ use super::spec::{ Activation, ActivationPolicies, CatchUpPolicy, ConcurrencyPolicy, RootWorkTemplate, RoutineMetadata, RoutineSpec, RoutineSpecFile, StepSpec, }; +use super::RoutineInvocationTarget; #[derive(Debug, Default, Serialize)] #[serde(rename_all = "camelCase")] @@ -50,8 +47,7 @@ pub struct ConvertedRoutine { pub name: String, pub revision: i64, /// Non-portable knowledge the operator must re-express as execution - /// bindings (model/account/harness/workspace) or accept as dropped - /// (one-shot trigger timestamps). + /// bindings (model/account/harness/workspace). pub warnings: Vec, } @@ -63,6 +59,13 @@ pub struct SkippedRoutine { pub reason: String, } +pub fn convert_and_handover( + definition: &RoutineDefinition, + _disable_legacy: bool, +) -> Result { + super::legacy_bridge::sync_definition(definition) +} + fn slugify(name: &str) -> String { let mut slug = String::new(); for c in name.chars() { @@ -81,34 +84,25 @@ fn slugify(name: &str) -> String { } fn map_policies(definition: &RoutineDefinition) -> (ActivationPolicies, Vec) { - let mut warnings = Vec::new(); + let warnings = Vec::new(); let concurrency = match definition.output_policy.concurrency_policy { RoutineConcurrencyPolicy::CoalesceIfActive => ConcurrencyPolicy::Coalesce, RoutineConcurrencyPolicy::SkipIfActive => ConcurrencyPolicy::Skip, RoutineConcurrencyPolicy::QueueIfActive => ConcurrencyPolicy::Queue, - RoutineConcurrencyPolicy::AlwaysCreate => { - warnings.push( - "concurrency 'always_create' has no portable equivalent; mapped to 'queue'" - .to_string(), - ); - ConcurrencyPolicy::Queue - } + RoutineConcurrencyPolicy::AlwaysCreate => ConcurrencyPolicy::Always, }; let catch_up = match definition.output_policy.catch_up_policy { RoutineCatchUpPolicy::SkipMissed => CatchUpPolicy::None, RoutineCatchUpPolicy::RunOnce => CatchUpPolicy::FireOnce, - RoutineCatchUpPolicy::RunAllLimited => { - warnings.push(format!( - "catch-up 'run_all_limited' (max {}) has no portable equivalent; mapped to 'fire_once'", - definition.output_policy.max_catch_up_runs - )); - CatchUpPolicy::FireOnce - } + RoutineCatchUpPolicy::RunAllLimited => CatchUpPolicy::RunAllLimited, }; ( ActivationPolicies { concurrency_policy: Some(concurrency), catch_up: Some(catch_up), + max_catch_up_runs: (definition.output_policy.catch_up_policy + == RoutineCatchUpPolicy::RunAllLimited) + .then_some(definition.output_policy.max_catch_up_runs.max(1)), }, warnings, ) @@ -119,13 +113,6 @@ fn map_policies(definition: &RoutineDefinition) -> (ActivationPolicies, Vec Result<(RoutineSpecFile, Vec), String> { - if definition.output_policy.mode == RoutineOutputMode::UpdateExistingWorkItem { - return Err(format!( - "targets existing work item {:?} — portable --root-work runs land in Phase 5", - definition.output_policy.update_work_item_short_id - )); - } - let mut warnings = Vec::new(); let (policies, policy_warnings) = map_policies(definition); warnings.extend(policy_warnings); @@ -164,17 +151,18 @@ pub fn convert_definition( } let activation = match &definition.trigger { - RoutineTrigger::Cron { cron, timezone } => Activation::Schedule { + Some(RoutineTrigger::Cron { cron, timezone }) => Activation::Schedule { cron: cron.clone(), timezone: timezone.clone(), - policies, + policies: policies.clone(), + }, + Some(RoutineTrigger::OneTime { at }) => Activation::OneTime { + at: at.clone(), + policies: policies.clone(), + }, + None => Activation::Manual { + policies: policies.clone(), }, - RoutineTrigger::OneTime { at } => { - warnings.push(format!( - "one-shot trigger at '{at}' has no portable activation; converted to manual" - )); - Activation::Manual { policies } - } }; let root_title = definition @@ -194,7 +182,7 @@ pub fn convert_definition( api_version: "orgtrack/v1".to_string(), kind: "Routine".to_string(), metadata: RoutineMetadata { - id: format!("routine_{}", slugify(&definition.name)), + id: definition.id.clone(), name: slugify(&definition.name), revision: None, }, @@ -223,57 +211,70 @@ pub fn convert_definition( inputs: BTreeMap::new(), outputs: BTreeMap::new(), }], - activations: vec![activation], + activations: resolve_activations(definition, activation, &policies), }, }; Ok((file, warnings)) } +/// Resolve the legacy output policy into deployment-local invocation state. +/// This identity never enters the portable spec or its immutable hash. +pub fn invocation_target( + definition: &RoutineDefinition, +) -> Result { + match definition.output_policy.mode { + RoutineOutputMode::UpdateExistingWorkItem => { + let root_work_item_id = definition + .output_policy + .update_work_item_short_id + .as_deref() + .filter(|id| !id.trim().is_empty()) + .ok_or_else(|| { + "UpdateExistingWorkItem routine is missing update_work_item_short_id" + .to_string() + })? + .to_string(); + Ok( + if let Some(project_slug) = definition + .output_policy + .update_work_item_project_slug + .as_deref() + .filter(|slug| !slug.trim().is_empty()) + { + RoutineInvocationTarget::ExistingProjectWork { + project_slug: project_slug.to_string(), + root_work_item_id, + } + } else { + RoutineInvocationTarget::ExistingStandaloneWork { + org_id: crate::projects::types::PERSONAL_ORG_ID.to_string(), + root_work_item_id, + } + }, + ) + } + RoutineOutputMode::CreateWorkItem => Ok(definition + .output_policy + .create_work_item_project_slug + .as_deref() + .filter(|slug| !slug.trim().is_empty()) + .map(RoutineInvocationTarget::project) + .unwrap_or_else(|| RoutineInvocationTarget::standalone(None))), + RoutineOutputMode::DirectSession => Ok(RoutineInvocationTarget::standalone(None)), + } +} + /// Convert every legacy definition currently in the store, applying the /// expressible ones into `pm_routines` and reporting the rest. /// -/// With `disable_converted_legacy`, successfully converted legacy rows -/// that carry a scope binding are disabled in the same pass so the legacy -/// scheduler can never fire them again — the portable scheduler is their -/// only driver from then on (no double-fire window). Conversions without -/// a scope binding keep their legacy row enabled: the portable pass -/// cannot fire them, so disabling would silently kill the routine. -/// Skipped definitions stay enabled on the legacy path until they become -/// expressible. -pub fn convert_all(disable_converted_legacy: bool) -> Result { +/// The legacy row remains an enabled/disabled UI mirror; only the portable +/// scheduler executes it, so keeping the mirror active cannot double-fire. +pub fn convert_all(_disable_converted_legacy: bool) -> Result { let definitions = crate::projects::io::list_routines()?; let mut report = ConversionReport::default(); for definition in &definitions { - match convert_definition(definition) { - Ok((file, warnings)) => { - let applied = super::apply(&file)?; - // Host-local scope binding: scheduled invokes need a - // target project. CreateWorkItem routines carried it on - // the legacy policy; DirectSession ones did not — those - // stay manual-only until the operator binds a scope. - let scope_bound = if let Some(scope) = definition - .output_policy - .create_work_item_project_slug - .as_deref() - { - super::set_default_scope(&applied.name, scope)?; - true - } else { - false - }; - // Disabling the legacy row without a scope binding would - // leave the routine with no working driver: the portable - // pass suppresses every scheduled fire as no_scope_binding. - if disable_converted_legacy && definition.enabled && scope_bound { - crate::projects::io::disable_routine(&definition.id)?; - } - report.converted.push(ConvertedRoutine { - legacy_id: definition.id.clone(), - name: applied.name, - revision: applied.revision, - warnings, - }); - } + match super::legacy_bridge::sync_definition(definition) { + Ok(converted) => report.converted.push(converted), Err(reason) => report.skipped.push(SkippedRoutine { legacy_id: definition.id.clone(), name: definition.name.clone(), @@ -283,3 +284,41 @@ pub fn convert_all(disable_converted_legacy: bool) -> Result &mut super::spec::ActivationPolicies { + match activation { + Activation::Manual { policies } + | Activation::Schedule { policies, .. } + | Activation::OneTime { policies, .. } + | Activation::ProviderEvent { policies, .. } => policies, + } +} + +/// The wizard's explicit activation list wins over the single legacy +/// trigger; entries without their own policies inherit the routine's +/// converted concurrency and catch-up intent so a multi-activation +/// routine gates the same way the single-trigger one did. +fn resolve_activations( + definition: &RoutineDefinition, + converted_trigger: Activation, + default_policies: &super::spec::ActivationPolicies, +) -> Vec { + if definition.activations.is_empty() { + return vec![converted_trigger]; + } + definition + .activations + .iter() + .cloned() + .map(|mut entry| { + let policies = activation_policies_mut(&mut entry); + if policies.concurrency_policy.is_none() + && policies.catch_up.is_none() + && policies.max_catch_up_runs.is_none() + { + *policies = default_policies.clone(); + } + entry + }) + .collect() +} diff --git a/src-tauri/crates/project-management/src/routine_service/legacy_bridge.rs b/src-tauri/crates/project-management/src/routine_service/legacy_bridge.rs new file mode 100644 index 0000000000..45bac61182 --- /dev/null +++ b/src-tauri/crates/project-management/src/routine_service/legacy_bridge.rs @@ -0,0 +1,606 @@ +//! Compatibility control plane for the legacy Routine UI. +//! +//! `routine_definitions.id` remains the stable UI identity while execution is +//! owned by `pm_routines`. The explicit binding below survives display-name +//! changes and prevents orphaned portable schedules. + +use std::collections::BTreeMap; + +use rusqlite::{params, OptionalExtension, TransactionBehavior}; + +use crate::projects::io; +use crate::projects::types::{ + RoutineConcurrencyPolicy, RoutineDefinition, RoutineFire, RoutineFireResult, RoutineFireStatus, +}; + +use super::convert::{self, ConvertedRoutine}; +use super::{RoutineActivationOutcome, RoutineInvocationTarget}; + +fn to_iso8601(epoch_ms: i64) -> String { + chrono::DateTime::::from_timestamp_millis(epoch_ms) + .unwrap_or(chrono::DateTime::::UNIX_EPOCH) + .to_rfc3339() +} + +fn portable_name_in( + connection: &rusqlite::Connection, + legacy_id: &str, +) -> Result, String> { + connection + .query_row( + "SELECT portable_name FROM pm_routine_legacy_bindings + WHERE legacy_routine_id = ?1", + params![legacy_id], + |row| row.get(0), + ) + .optional() + .map_err(|err| format!("routine bridge binding: {err}")) +} + +pub fn portable_name(legacy_id: &str) -> Result, String> { + let connection = io::helpers::conn()?; + portable_name_in(&connection, legacy_id) +} + +/// Adopt a pre-bridge conversion before a legacy rename changes the slug. +pub fn ensure_binding(definition: &RoutineDefinition) -> Result<(), String> { + let (file, _) = convert::convert_definition(definition)?; + let pre_bridge_routine_id = format!("routine_{}", file.metadata.name); + let connection = io::helpers::conn()?; + if portable_name_in(&connection, &definition.id)?.is_some() { + return Ok(()); + } + let exists = connection + .query_row( + "SELECT 1 FROM pm_routines + WHERE name = ?1 AND routine_id IN (?2, ?3)", + params![file.metadata.name, definition.id, pre_bridge_routine_id], + |_| Ok(()), + ) + .optional() + .map_err(|err| format!("routine bridge adopt: {err}"))? + .is_some(); + if exists { + let now = chrono::Utc::now().timestamp_millis(); + connection + .execute( + "INSERT INTO pm_routine_legacy_bindings ( + legacy_routine_id, portable_name, archived_at, created_at, updated_at + ) VALUES (?1, ?2, NULL, ?3, ?3) + ON CONFLICT(legacy_routine_id) DO NOTHING", + params![definition.id, file.metadata.name, now], + ) + .map_err(|err| format!("routine bridge adopt: {err}"))?; + } + Ok(()) +} + +/// Upsert one UI definition into the portable runtime without disabling the +/// UI mirror. Rename, binding update, activation state, webhook identity and +/// run history move under one immediate transaction. +pub fn sync_definition(definition: &RoutineDefinition) -> Result { + let (file, warnings) = convert::convert_definition(definition)?; + let violations = super::spec::validate(&file); + if !violations.is_empty() { + return Err(format!( + "{}:{}", + super::error::SPEC_INVALID, + serde_json::to_string(&violations).unwrap_or_default() + )); + } + let canonical = super::spec::canonicalize(&file)?; + let hash = super::snapshot_hash(&canonical); + let pre_bridge_routine_id = format!("routine_{}", file.metadata.name); + let target = convert::invocation_target(definition)?; + let target_binding = target.to_binding(); + let now = chrono::Utc::now(); + let next_fire_at = if definition.enabled { + super::next_activation_at(&file, &now)? + } else { + None + }; + let now_ms = now.timestamp_millis(); + + let mut connection = io::helpers::conn()?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|err| format!("routine bridge tx: {err}"))?; + // The removed legacy scheduler/executor must not be resurrected by an + // old queued fire after handover. A legacy fire that already started is + // retained and is treated as active by the portable concurrency gate; + // work that never started is terminalized as migration history. + tx.execute( + "UPDATE routine_fires + SET status = 'skipped', completed_at = ?2, + error = 'Superseded by portable Routine runtime' + WHERE routine_id = ?1 AND status IN ('pending', 'queued')", + params![definition.id, now_ms], + ) + .map_err(|err| format!("routine bridge retire legacy fires: {err}"))?; + let mapped_name: Option = tx + .query_row( + "SELECT portable_name FROM pm_routine_legacy_bindings + WHERE legacy_routine_id = ?1", + params![definition.id], + |row| row.get(0), + ) + .optional() + .map_err(|err| format!("routine bridge binding: {err}"))?; + let adopted_name = if mapped_name.is_none() { + tx.query_row( + "SELECT name FROM pm_routines + WHERE routine_id = ?1 + OR (name = ?2 AND routine_id = ?3) + ORDER BY CASE WHEN routine_id = ?1 THEN 0 ELSE 1 END + LIMIT 1", + params![definition.id, file.metadata.name, pre_bridge_routine_id], + |row| row.get(0), + ) + .optional() + .map_err(|err| format!("routine bridge adopt: {err}"))? + } else { + None + }; + let old_name = mapped_name.or(adopted_name); + + if old_name.as_deref() != Some(file.metadata.name.as_str()) { + let conflicting_owner: Option = tx + .query_row( + "SELECT legacy_routine_id FROM pm_routine_legacy_bindings + WHERE portable_name = ?1 AND legacy_routine_id != ?2 + AND archived_at IS NULL", + params![file.metadata.name, definition.id], + |row| row.get(0), + ) + .optional() + .map_err(|err| format!("routine bridge rename: {err}"))?; + let target_exists = tx + .query_row( + "SELECT 1 FROM pm_routines WHERE name = ?1", + params![file.metadata.name], + |_| Ok(()), + ) + .optional() + .map_err(|err| format!("routine bridge rename: {err}"))? + .is_some(); + if conflicting_owner.is_some() || target_exists { + return Err(format!( + "Portable Routine name '{}' is already owned", + file.metadata.name + )); + } + } + + if let Some(old_name) = old_name.as_deref().filter(|old| *old != file.metadata.name) { + tx.execute( + "UPDATE pm_routine_runs SET routine_name = ?2 WHERE routine_name = ?1", + params![old_name, file.metadata.name], + ) + .map_err(|err| format!("routine bridge rename runs: {err}"))?; + tx.execute( + "UPDATE pm_routine_webhooks SET routine_name = ?2 WHERE routine_name = ?1", + params![old_name, file.metadata.name], + ) + .map_err(|err| format!("routine bridge rename webhook: {err}"))?; + tx.execute( + "UPDATE pm_routine_webhook_deliveries SET routine_name = ?2 WHERE routine_name = ?1", + params![old_name, file.metadata.name], + ) + .map_err(|err| format!("routine bridge rename deliveries: {err}"))?; + tx.execute( + "UPDATE pm_routine_activation_events SET routine_name = ?2 WHERE routine_name = ?1", + params![old_name, file.metadata.name], + ) + .map_err(|err| format!("routine bridge rename activations: {err}"))?; + tx.execute( + "UPDATE pm_routine_activation_guards SET routine_name = ?2 WHERE routine_name = ?1", + params![old_name, file.metadata.name], + ) + .map_err(|err| format!("routine bridge rename activation guard: {err}"))?; + tx.execute( + "UPDATE pm_routines SET name = ?2 WHERE name = ?1", + params![old_name, file.metadata.name], + ) + .map_err(|err| format!("routine bridge rename definition: {err}"))?; + } + + struct ExistingPortableRoutine { + revision: i64, + spec_hash: String, + enabled: bool, + last_evaluated_at: Option, + next_fire_at: Option, + } + + let existing: Option = tx + .query_row( + "SELECT revision, spec_hash, enabled, last_evaluated_at, next_fire_at + FROM pm_routines WHERE name = ?1", + params![file.metadata.name], + |row| { + Ok(ExistingPortableRoutine { + revision: row.get(0)?, + spec_hash: row.get(1)?, + enabled: row.get::<_, i64>(2)? != 0, + last_evaluated_at: row.get(3)?, + next_fire_at: row.get(4)?, + }) + }, + ) + .optional() + .map_err(|err| format!("routine bridge read portable: {err}"))?; + let revision = match existing { + Some(existing) => { + let changed = existing.spec_hash != hash; + let revision = existing.revision + i64::from(changed); + let reset_schedule = changed || (!existing.enabled && definition.enabled); + let (last_evaluated_at, effective_next) = if !definition.enabled { + (existing.last_evaluated_at, None) + } else if reset_schedule { + (None, next_fire_at) + } else { + (existing.last_evaluated_at, existing.next_fire_at) + }; + tx.execute( + "UPDATE pm_routines + SET routine_id = ?2, spec_json = ?3, spec_hash = ?4, + revision = ?5, enabled = ?6, default_scope = ?7, + last_evaluated_at = ?8, next_fire_at = ?9, updated_at = ?10 + WHERE name = ?1", + params![ + file.metadata.name, + definition.id, + canonical, + hash, + revision, + i64::from(definition.enabled), + target_binding, + last_evaluated_at, + effective_next, + now_ms, + ], + ) + .map_err(|err| format!("routine bridge update portable: {err}"))?; + revision + } + None => { + tx.execute( + "INSERT INTO pm_routines ( + name, routine_id, spec_json, spec_hash, revision, enabled, + default_scope, last_evaluated_at, next_fire_at, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, 1, ?5, ?6, NULL, ?7, ?8, ?8)", + params![ + file.metadata.name, + definition.id, + canonical, + hash, + i64::from(definition.enabled), + target_binding, + next_fire_at, + now_ms, + ], + ) + .map_err(|err| format!("routine bridge insert portable: {err}"))?; + 1 + } + }; + tx.execute( + "INSERT INTO pm_routine_legacy_bindings ( + legacy_routine_id, portable_name, archived_at, created_at, updated_at + ) VALUES (?1, ?2, NULL, ?3, ?3) + ON CONFLICT(legacy_routine_id) DO UPDATE SET + portable_name = excluded.portable_name, + archived_at = NULL, + updated_at = excluded.updated_at", + params![definition.id, file.metadata.name, now_ms], + ) + .map_err(|err| format!("routine bridge bind: {err}"))?; + tx.commit() + .map_err(|err| format!("routine bridge commit: {err}"))?; + + Ok(ConvertedRoutine { + legacy_id: definition.id.clone(), + name: file.metadata.name, + revision, + warnings, + }) +} + +pub fn delete_definition(legacy_id: &str) -> Result { + // Older databases can contain a converted portable row without the + // explicit bridge table. Adopt it before archiving so Delete cannot leave + // an enabled scheduler ghost behind. + if let Ok(definition) = io::read_routine(legacy_id) { + ensure_binding(&definition)?; + } + let mut connection = io::helpers::conn()?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|err| format!("routine bridge delete tx: {err}"))?; + let portable_name: Option = tx + .query_row( + "SELECT portable_name FROM pm_routine_legacy_bindings + WHERE legacy_routine_id = ?1", + params![legacy_id], + |row| row.get(0), + ) + .optional() + .map_err(|err| format!("routine bridge delete binding: {err}"))?; + let now = chrono::Utc::now().timestamp_millis(); + let changed = tx + .execute( + "UPDATE routine_definitions + SET enabled = 0, next_fire_at = NULL, archived_at = ?2, updated_at = ?2 + WHERE id = ?1 AND archived_at IS NULL", + params![legacy_id, now], + ) + .map_err(|err| format!("routine bridge archive mirror: {err}"))?; + if let Some(name) = portable_name { + tx.execute( + "UPDATE pm_routine_activation_events + SET status = 'skipped', error = 'Routine deleted', updated_at = ?2 + WHERE routine_name = ?1 AND status = 'queued'", + params![name, now], + ) + .map_err(|err| format!("routine bridge cancel queue: {err}"))?; + tx.execute( + "DELETE FROM pm_routine_webhooks WHERE routine_name = ?1", + params![name], + ) + .map_err(|err| format!("routine bridge delete webhook: {err}"))?; + tx.execute( + "DELETE FROM pm_routine_activation_guards WHERE routine_name = ?1", + params![name], + ) + .map_err(|err| format!("routine bridge delete activation guard: {err}"))?; + tx.execute("DELETE FROM pm_routines WHERE name = ?1", params![name]) + .map_err(|err| format!("routine bridge delete portable: {err}"))?; + tx.execute( + "UPDATE pm_routine_legacy_bindings + SET archived_at = ?2, updated_at = ?2 + WHERE legacy_routine_id = ?1", + params![legacy_id, now], + ) + .map_err(|err| format!("routine bridge archive binding: {err}"))?; + } + tx.commit() + .map_err(|err| format!("routine bridge delete commit: {err}"))?; + Ok(changed > 0) +} + +pub fn disable_one_time(name: &str) -> Result<(), String> { + let connection = io::helpers::conn()?; + let now = chrono::Utc::now().timestamp_millis(); + connection + .execute( + "UPDATE pm_routines + SET enabled = 0, next_fire_at = NULL, updated_at = ?2 + WHERE name = ?1", + params![name, now], + ) + .map_err(|err| format!("routine one-time disable portable: {err}"))?; + connection + .execute( + "UPDATE routine_definitions + SET enabled = 0, next_fire_at = NULL, updated_at = ?2 + WHERE id = ( + SELECT legacy_routine_id FROM pm_routine_legacy_bindings + WHERE portable_name = ?1 AND archived_at IS NULL + )", + params![name, now], + ) + .map_err(|err| format!("routine one-time disable mirror: {err}"))?; + Ok(()) +} + +fn target_for_name(name: &str) -> Result { + let connection = io::helpers::conn()?; + let binding: Option = connection + .query_row( + "SELECT default_scope FROM pm_routines WHERE name = ?1", + params![name], + |row| row.get(0), + ) + .optional() + .map_err(|err| format!("routine bridge target: {err}"))?; + Ok(binding + .as_deref() + .map(RoutineInvocationTarget::from_binding) + .transpose()? + .unwrap_or_else(|| RoutineInvocationTarget::standalone(None))) +} + +fn event_fire_status(status: &str) -> Option { + match status { + "queued" => Some(RoutineFireStatus::Queued), + "skipped" => Some(RoutineFireStatus::Skipped), + "coalesced" => Some(RoutineFireStatus::Coalesced), + "failed" => Some(RoutineFireStatus::Failed), + _ => None, + } +} + +pub fn list_fires(legacy_id: &str) -> Result, String> { + let mut fires = io::list_routine_fires(legacy_id)?; + let Some(name) = portable_name(legacy_id)? else { + return Ok(fires); + }; + let connection = io::helpers::conn()?; + let mut statement = connection + .prepare( + "SELECT id, created_at, status, root_work_item_id, updated_at + FROM pm_routine_runs WHERE routine_name = ?1 + ORDER BY created_at DESC LIMIT 100", + ) + .map_err(|err| format!("routine bridge history runs: {err}"))?; + let runs = statement + .query_map(params![name], |row| { + let status: String = row.get(2)?; + let (status, error) = match status.as_str() { + "pending" => (RoutineFireStatus::Pending, None), + "running" => (RoutineFireStatus::Started, None), + "succeeded" => (RoutineFireStatus::Succeeded, None), + "cancelled" => ( + RoutineFireStatus::Failed, + Some("Routine run cancelled".to_string()), + ), + _ => (RoutineFireStatus::Failed, None), + }; + let created_at: i64 = row.get(1)?; + let updated_at: i64 = row.get(4)?; + Ok(RoutineFire { + id: row.get(0)?, + routine_id: legacy_id.to_string(), + fired_at: to_iso8601(created_at), + status: status.clone(), + session_id: None, + agent_org_run_id: None, + work_item_id: row.get(3)?, + coalesced_into_fire_id: None, + idempotency_key: None, + started_at: (status != RoutineFireStatus::Pending).then(|| to_iso8601(created_at)), + completed_at: matches!( + status, + RoutineFireStatus::Succeeded | RoutineFireStatus::Failed + ) + .then(|| to_iso8601(updated_at)), + error, + }) + }) + .map_err(|err| format!("routine bridge history runs: {err}"))? + .collect::, _>>() + .map_err(|err| format!("routine bridge history runs: {err}"))?; + fires.extend(runs); + drop(statement); + + let mut statement = connection + .prepare( + "SELECT id, created_at, status, coalesced_run_id, error, invoke_key, updated_at + FROM pm_routine_activation_events + WHERE routine_name = ?1 AND status != 'dispatched' + ORDER BY created_at DESC LIMIT 100", + ) + .map_err(|err| format!("routine bridge history activations: {err}"))?; + let events = statement + .query_map(params![name], |row| { + let raw_status: String = row.get(2)?; + let Some(status) = event_fire_status(&raw_status) else { + return Ok(None); + }; + let created_at: i64 = row.get(1)?; + let updated_at: i64 = row.get(6)?; + Ok(Some(RoutineFire { + id: row.get(0)?, + routine_id: legacy_id.to_string(), + fired_at: to_iso8601(created_at), + status: status.clone(), + session_id: None, + agent_org_run_id: None, + work_item_id: None, + coalesced_into_fire_id: row.get(3)?, + idempotency_key: row.get(5)?, + started_at: None, + completed_at: (status != RoutineFireStatus::Queued).then(|| to_iso8601(updated_at)), + error: row.get(4)?, + })) + }) + .map_err(|err| format!("routine bridge history activations: {err}"))? + .filter_map(|row| row.transpose()) + .collect::, _>>() + .map_err(|err| format!("routine bridge history activations: {err}"))?; + fires.extend(events); + fires.sort_by(|left, right| right.fired_at.cmp(&left.fired_at)); + fires.truncate(100); + Ok(fires) +} + +pub fn overlay_definition(mut definition: RoutineDefinition) -> Result { + let Some(name) = portable_name(&definition.id)? else { + return Ok(definition); + }; + let connection = io::helpers::conn()?; + if let Some((enabled, next_fire_at)) = connection + .query_row( + "SELECT enabled, next_fire_at FROM pm_routines WHERE name = ?1", + params![name], + |row| Ok((row.get::<_, i64>(0)? != 0, row.get::<_, Option>(1)?)), + ) + .optional() + .map_err(|err| format!("routine bridge overlay: {err}"))? + { + definition.enabled = enabled; + definition.next_fire_at = next_fire_at.map(to_iso8601); + } + if let Some(latest) = list_fires(&definition.id)?.into_iter().next() { + definition.last_fire_at = Some(latest.fired_at); + definition.last_fire_status = Some(latest.status); + definition.last_fire_error = latest.error; + definition.last_fire_session_id = latest.session_id; + definition.last_fire_work_item_id = latest.work_item_id; + } + Ok(definition) +} + +fn portable_policy(policy: &RoutineConcurrencyPolicy) -> super::spec::ConcurrencyPolicy { + match policy { + RoutineConcurrencyPolicy::CoalesceIfActive => super::spec::ConcurrencyPolicy::Coalesce, + RoutineConcurrencyPolicy::SkipIfActive => super::spec::ConcurrencyPolicy::Skip, + RoutineConcurrencyPolicy::QueueIfActive => super::spec::ConcurrencyPolicy::Queue, + RoutineConcurrencyPolicy::AlwaysCreate => super::spec::ConcurrencyPolicy::Always, + } +} + +pub fn fire(legacy_id: &str) -> Result { + let definition = io::read_routine(legacy_id)?; + if !definition.enabled { + return Err(format!("Routine is disabled: {legacy_id}")); + } + let name = portable_name(legacy_id)? + .ok_or_else(|| format!("Portable Routine binding not found: {legacy_id}"))?; + let target = target_for_name(&name)?; + let invoke_key = format!("manual:{}", uuid::Uuid::new_v4().simple()); + let fired_at_ms = chrono::Utc::now().timestamp_millis(); + let outcome = super::request_activation( + &name, + &target, + &BTreeMap::new(), + &invoke_key, + portable_policy(&definition.output_policy.concurrency_policy), + fired_at_ms, + )?; + let fire = match outcome { + RoutineActivationOutcome::Invoked(run) => RoutineFire { + id: run.run_id, + routine_id: legacy_id.to_string(), + fired_at: to_iso8601(fired_at_ms), + status: RoutineFireStatus::Started, + session_id: None, + agent_org_run_id: None, + work_item_id: Some(run.root_short_id), + coalesced_into_fire_id: None, + idempotency_key: Some(invoke_key), + started_at: Some(to_iso8601(fired_at_ms)), + completed_at: None, + error: None, + }, + RoutineActivationOutcome::Deferred(event) => RoutineFire { + id: event.id, + routine_id: legacy_id.to_string(), + fired_at: to_iso8601(event.created_at), + status: event_fire_status(&event.status).unwrap_or(RoutineFireStatus::Failed), + session_id: None, + agent_org_run_id: None, + work_item_id: None, + coalesced_into_fire_id: event.coalesced_run_id, + idempotency_key: Some(event.invoke_key), + started_at: None, + completed_at: (event.status != "queued").then(|| to_iso8601(event.updated_at)), + error: event.error, + }, + }; + Ok(RoutineFireResult { + fire, + session_id: None, + agent_org_run_id: None, + }) +} 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 1c6c458673..bc81be6695 100644 --- a/src-tauri/crates/project-management/src/routine_service/mod.rs +++ b/src-tauri/crates/project-management/src/routine_service/mod.rs @@ -1,4 +1,4 @@ -//! Routine application service (`orgtrack/v1` Phase 4). +//! Routine application service (`orgtrack/v1`). //! //! Owns the portable Routine domain: spec validation/canonicalization //! ([`spec`]), versioned definitions with immutable per-run snapshots, @@ -7,17 +7,93 @@ //! //! Storage: `pm_routines` (current definition + revision) and //! `pm_routine_runs` (immutable occurrence: revision, snapshot, hash, -//! status projection inputs). The legacy `routine_definitions` / -//! `routine_fires` tables stay readable until the Phase 4 conversion -//! completes; conversion is one-way and disables definitions it cannot -//! express portably, with a written report. +//! status projection inputs). Legacy `routine_definitions` rows remain UI +//! control-plane mirrors; execution and automatic activation are portable. pub mod convert; +pub mod legacy_bridge; pub mod spec; use crate::projects::io as project_io; +use crate::projects::types::PERSONAL_ORG_ID; use crate::work_service; +pub fn activation_from_trigger( + trigger: &crate::projects::types::RoutineTrigger, +) -> spec::Activation { + match trigger { + crate::projects::types::RoutineTrigger::Cron { cron, timezone } => { + spec::Activation::Schedule { + cron: cron.clone(), + timezone: timezone.clone(), + policies: spec::ActivationPolicies::default(), + } + } + crate::projects::types::RoutineTrigger::OneTime { at } => spec::Activation::OneTime { + at: at.clone(), + policies: spec::ActivationPolicies::default(), + }, + } +} + +pub fn trigger_from_activations( + activations: &[spec::Activation], +) -> Option { + activations.iter().find_map(activation_trigger) +} + +pub fn next_occurrence_of_activations( + activations: &[spec::Activation], + now: &chrono::DateTime, +) -> Result>, String> { + let mut earliest = None; + for activation in activations { + let Some(trigger) = activation_trigger(activation) else { + continue; + }; + let Some(next) = crate::projects::routine_schedule::next_occurrence(&trigger, now)? else { + continue; + }; + earliest = Some(match earliest { + Some(current) if current <= next => current, + _ => next, + }); + } + Ok(earliest) +} + +fn activation_trigger( + activation: &spec::Activation, +) -> Option { + match activation { + spec::Activation::Schedule { cron, timezone, .. } => { + Some(crate::projects::types::RoutineTrigger::Cron { + cron: cron.clone(), + timezone: timezone.clone(), + }) + } + spec::Activation::OneTime { at, .. } => { + Some(crate::projects::types::RoutineTrigger::OneTime { at: at.clone() }) + } + spec::Activation::Manual { .. } | spec::Activation::ProviderEvent { .. } => None, + } +} + +pub(crate) fn next_activation_at( + file: &spec::RoutineSpecFile, + after: &chrono::DateTime, +) -> Result, String> { + let mut next = None; + for trigger in file.spec.activations.iter().filter_map(activation_trigger) { + let candidate = crate::projects::routine_schedule::next_occurrence(&trigger, after)? + .map(|value| value.timestamp_millis()); + if let Some(candidate) = candidate { + next = Some(next.map_or(candidate, |current: i64| current.min(candidate))); + } + } + Ok(next) +} + /// Compute the immutable snapshot hash for a canonical spec body. pub fn snapshot_hash(canonical: &str) -> String { // FNV-1a 64 over the canonical bytes, doubled for width. Not @@ -54,11 +130,11 @@ pub fn apply(spec_file: &spec::RoutineSpecFile) -> Result = tx + let existing: Option<(i64, String, bool)> = tx .query_row( - "SELECT revision, spec_hash FROM pm_routines WHERE name = ?1", + "SELECT revision, spec_hash, enabled FROM pm_routines WHERE name = ?1", rusqlite::params![spec_file.metadata.name], - |row| Ok((row.get(0)?, row.get(1)?)), + |row| Ok((row.get(0)?, row.get(1)?, row.get::<_, i64>(2)? != 0)), ) .map(Some) .or_else(|err| match err { @@ -67,18 +143,25 @@ pub fn apply(spec_file: &spec::RoutineSpecFile) -> Result (revision, false), - Some((revision, _)) => { + Some((revision, ref stored_hash, _)) if stored_hash == &hash => (revision, false), + Some((revision, _, enabled)) => { let next = revision + 1; + let next_fire_at = if enabled { + next_activation_at(spec_file, &chrono::Utc::now())? + } else { + None + }; tx.execute( "UPDATE pm_routines - SET spec_json = ?2, spec_hash = ?3, revision = ?4, updated_at = ?5 + SET spec_json = ?2, spec_hash = ?3, revision = ?4, + last_evaluated_at = NULL, next_fire_at = ?5, updated_at = ?6 WHERE name = ?1", rusqlite::params![ spec_file.metadata.name, canonical, hash, next, + next_fire_at, chrono::Utc::now().timestamp_millis(), ], ) @@ -86,15 +169,18 @@ pub fn apply(spec_file: &spec::RoutineSpecFile) -> Result { + let next_fire_at = next_activation_at(spec_file, &chrono::Utc::now())?; tx.execute( "INSERT INTO pm_routines - (name, routine_id, spec_json, spec_hash, revision, enabled, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, 1, 1, ?5, ?5)", + (name, routine_id, spec_json, spec_hash, revision, enabled, + next_fire_at, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, 1, 1, ?5, ?6, ?6)", rusqlite::params![ spec_file.metadata.name, spec_file.metadata.id, canonical, hash, + next_fire_at, chrono::Utc::now().timestamp_millis(), ], ) @@ -172,6 +258,230 @@ pub struct InvokedRun { pub steps: Vec<(String, String)>, } +/// Host-local target for one Routine invocation. The portable spec remains +/// deployment-agnostic; this value is supplied by CLI context, a scheduler +/// binding, or a webhook installation. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde( + tag = "kind", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +pub enum RoutineInvocationTarget { + Project { + project_slug: String, + }, + Standalone { + org_id: String, + }, + ExistingProjectWork { + project_slug: String, + root_work_item_id: String, + }, + ExistingStandaloneWork { + org_id: String, + root_work_item_id: String, + }, +} + +impl RoutineInvocationTarget { + pub fn project(project_slug: impl Into) -> Self { + Self::Project { + project_slug: project_slug.into(), + } + } + + pub fn standalone(org_id: Option<&str>) -> Self { + Self::Standalone { + org_id: org_id.unwrap_or(PERSONAL_ORG_ID).to_string(), + } + } + + /// Stable host-binding representation persisted in + /// `pm_routines.default_scope`. Plain strings remain compatible with the + /// original project-slug binding. + pub fn to_binding(&self) -> String { + match self { + Self::Project { project_slug } => project_slug.clone(), + Self::Standalone { org_id } => format!("org:{org_id}"), + Self::ExistingProjectWork { + project_slug, + root_work_item_id, + } => format!("work://project/{project_slug}/{root_work_item_id}"), + Self::ExistingStandaloneWork { + org_id, + root_work_item_id, + } => format!("work://org/{org_id}/{root_work_item_id}"), + } + } + + pub fn from_binding(binding: &str) -> Result { + if let Some(rest) = binding.strip_prefix("work://project/") { + let (project_slug, root_work_item_id) = rest + .rsplit_once('/') + .ok_or_else(|| format!("invalid project root-work Routine binding '{binding}'"))?; + if project_slug.trim().is_empty() || root_work_item_id.trim().is_empty() { + return Err(format!( + "invalid project root-work Routine binding '{binding}'" + )); + } + return Ok(Self::ExistingProjectWork { + project_slug: project_slug.to_string(), + root_work_item_id: root_work_item_id.to_string(), + }); + } + if let Some(rest) = binding.strip_prefix("work://org/") { + let (org_id, root_work_item_id) = rest + .rsplit_once('/') + .ok_or_else(|| format!("invalid org root-work Routine binding '{binding}'"))?; + if org_id.trim().is_empty() || root_work_item_id.trim().is_empty() { + return Err(format!("invalid org root-work Routine binding '{binding}'")); + } + return Ok(Self::ExistingStandaloneWork { + org_id: org_id.to_string(), + root_work_item_id: root_work_item_id.to_string(), + }); + } + if let Some(org_id) = binding.strip_prefix("org:") { + if org_id.trim().is_empty() { + return Err(format!("invalid org Routine binding '{binding}'")); + } + return Ok(Self::Standalone { + org_id: org_id.to_string(), + }); + } + if binding.trim().is_empty() { + return Err("Routine target binding must not be empty".to_string()); + } + Ok(Self::project(binding)) + } +} + +#[derive(Debug)] +struct ResolvedInvocationScope { + scope_id: String, + project_slug: Option, + project_id: Option, + org_id: String, + existing_root: Option, +} + +fn canonical_standalone_org_id( + tx: &rusqlite::Transaction<'_>, + raw_org_id: &str, +) -> Result { + use rusqlite::OptionalExtension; + + 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 = tx + .query_row( + "SELECT 1 FROM project_orgs WHERE id = ?1", + rusqlite::params![bare], + |_| Ok(()), + ) + .optional() + .map_err(|err| format!("routine target org lookup: {err}"))? + .is_some(); + Ok(if exists { + bare.to_string() + } else { + PERSONAL_ORG_ID.to_string() + }) +} + +fn resolve_invocation_scope( + tx: &rusqlite::Transaction<'_>, + target: &RoutineInvocationTarget, +) -> Result { + use rusqlite::OptionalExtension; + + match target { + RoutineInvocationTarget::Project { project_slug } + | RoutineInvocationTarget::ExistingProjectWork { + project_slug, + root_work_item_id: _, + } => { + let (project_id, org_id) = project_io::resolve_project_scope_in_tx(tx, project_slug)?; + let existing_root = match target { + RoutineInvocationTarget::ExistingProjectWork { + root_work_item_id, .. + } => { + let exists = tx + .query_row( + "SELECT 1 FROM workitems + WHERE project_id = ?1 AND short_id = ?2 AND deleted_at IS NULL", + rusqlite::params![project_id, root_work_item_id], + |_| Ok(()), + ) + .optional() + .map_err(|err| format!("routine root-work lookup: {err}"))? + .is_some(); + if !exists { + return Err(format!( + "Root Work Item '{}' not found in project '{}'", + root_work_item_id, project_slug + )); + } + Some(root_work_item_id.clone()) + } + _ => None, + }; + Ok(ResolvedInvocationScope { + scope_id: project_slug.clone(), + project_slug: Some(project_slug.clone()), + project_id: Some(project_id), + org_id, + existing_root, + }) + } + RoutineInvocationTarget::Standalone { org_id } + | RoutineInvocationTarget::ExistingStandaloneWork { + org_id, + root_work_item_id: _, + } => { + let org_id = canonical_standalone_org_id(tx, org_id)?; + let existing_root = match target { + RoutineInvocationTarget::ExistingStandaloneWork { + root_work_item_id, .. + } => { + let exists = tx + .query_row( + "SELECT 1 FROM workitems + WHERE project_id IS NULL AND org_id = ?1 + AND short_id = ?2 AND deleted_at IS NULL", + rusqlite::params![org_id, root_work_item_id], + |_| Ok(()), + ) + .optional() + .map_err(|err| format!("routine root-work lookup: {err}"))? + .is_some(); + if !exists { + return Err(format!( + "Root Work Item '{}' not found in org '{}'", + root_work_item_id, org_id + )); + } + Some(root_work_item_id.clone()) + } + _ => None, + }; + Ok(ResolvedInvocationScope { + scope_id: format!("org:{org_id}"), + project_slug: None, + project_id: None, + org_id, + existing_root, + }) + } + } +} + /// `routine.invoke` (§12.2): snapshot the current revision, create the /// RoutineRun, materialize the root WorkItem and one generated child per /// step through the canonical `work.create` handler, and record the @@ -183,6 +493,24 @@ pub fn invoke( inputs: &std::collections::BTreeMap, created_by: Option<&crate::projects::types::WorkItemMutationActor>, invoke_key: Option<&str>, +) -> Result { + invoke_target( + routine_name, + &RoutineInvocationTarget::project(scope_project_slug), + inputs, + created_by, + invoke_key, + ) +} + +/// Target-aware Routine invoke used by `--root-work`, projectless webhook +/// delivery, and scheduled host bindings. +pub fn invoke_target( + routine_name: &str, + target: &RoutineInvocationTarget, + inputs: &std::collections::BTreeMap, + created_by: Option<&crate::projects::types::WorkItemMutationActor>, + invoke_key: Option<&str>, ) -> Result { let connection = project_io::helpers::conn()?; let (spec_json, spec_hash, revision): (String, String, i64) = connection @@ -221,13 +549,16 @@ pub fn invoke( } let now = chrono::Utc::now().timestamp_millis(); - let run_id = format!("run_{}{:05}", now, std::process::id() % 100_000); + // UUID identity is independent of wall-clock resolution. In particular, + // `always` activations may legitimately materialize several runs in the + // same millisecond and must never collide on the primary key. + let run_id = format!("run_{}", uuid::Uuid::new_v4().simple()); 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, + "target": target, "inputs": inputs, }); let canonical = serde_json::to_string(&canonical_request) @@ -241,12 +572,13 @@ pub fn invoke( .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) .map_err(|err| format!("routine invoke tx: {err}"))?; + let resolved_scope = resolve_invocation_scope(&tx, target)?; if let Some(key) = invoke_key { let existing: Option<(String, Option)> = tx .query_row( "SELECT request_hash, response_json FROM pm_idempotency WHERE actor_id = ?1 AND operation = 'routine.invoke' AND scope_id = ?2 AND idem_key = ?3", - rusqlite::params![actor_id, scope_project_slug, key], + rusqlite::params![actor_id, resolved_scope.scope_id, key], |row| Ok((row.get(0)?, row.get(1)?)), ) .map(Some) @@ -270,7 +602,6 @@ 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 = @@ -279,8 +610,8 @@ pub fn invoke( 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, + resolved_scope.project_id.clone(), + &resolved_scope.org_id, short_id, &frontmatter, &request.body, @@ -292,36 +623,68 @@ pub fn invoke( operation: "work.create", entity_type: "work_item", entity_id: short_id, - project_slug: Some(scope_project_slug), - org_id: None, + project_slug: resolved_scope.project_slug.as_deref(), + org_id: resolved_scope + .project_slug + .is_none() + .then_some(resolved_scope.org_id.as_str()), actor: created_by, revision: 0, seq, payload: serde_json::json!({}), }, - ) + )?; + if let Some(actor) = created_by { + let scope_key = resolved_scope + .project_slug + .as_deref() + .map(|slug| format!("project:{slug}")) + .unwrap_or_else(|| format!("org:{}", resolved_scope.org_id)); + tx.execute( + "INSERT INTO pm_work_item_subscriptions ( + scope_key, work_item_id, subscriber_id, reason, created_at, muted_at + ) VALUES (?1, ?2, ?3, 'creator', ?4, NULL) + ON CONFLICT(scope_key, work_item_id, subscriber_id) DO UPDATE SET + muted_at = NULL", + rusqlite::params![scope_key, short_id, actor.id, now], + ) + .map_err(|err| format!("routine creator subscription: {err}"))?; + } + Ok(()) }; - let root_short_id = project_io::allocate_short_id_in_tx(&tx, scope_project_slug)?; - let root_request = work_service::CreateWorkItemRequest { - title: substitute_inputs(&snapshot.spec.root_work.title, inputs), - body: snapshot - .spec - .root_work - .body - .as_deref() - .map(|body| substitute_inputs(body, inputs)) - .unwrap_or_default(), - priority: snapshot.spec.root_work.priority.clone(), - labels: snapshot.spec.root_work.labels.clone(), - created_by: created_by.map(|actor| actor.id.clone()), - ..Default::default() + let allocate_short_id = || -> Result { + match resolved_scope.project_slug.as_deref() { + Some(project_slug) => project_io::allocate_short_id_in_tx(&tx, project_slug), + None => project_io::allocate_standalone_short_id_in_tx(&tx, &resolved_scope.org_id), + } + }; + + let root_short_id = if let Some(root_work_item_id) = &resolved_scope.existing_root { + root_work_item_id.clone() + } else { + let root_short_id = allocate_short_id()?; + let root_request = work_service::CreateWorkItemRequest { + title: substitute_inputs(&snapshot.spec.root_work.title, inputs), + body: snapshot + .spec + .root_work + .body + .as_deref() + .map(|body| substitute_inputs(body, inputs)) + .unwrap_or_default(), + priority: snapshot.spec.root_work.priority.clone(), + labels: snapshot.spec.root_work.labels.clone(), + created_by: created_by.map(|actor| actor.id.clone()), + ..Default::default() + }; + create_item(&root_short_id, &root_request)?; + root_short_id }; - create_item(&root_short_id, &root_request)?; let mut step_ids: Vec<(String, String)> = Vec::new(); for step in &snapshot.spec.steps { - let child_short_id = project_io::allocate_short_id_in_tx(&tx, scope_project_slug)?; + let child_short_id = allocate_short_id()?; let mut body = step .instruction .as_deref() @@ -369,8 +732,11 @@ pub fn invoke( operation: "work.relate", entity_type: "work_item", entity_id, - project_slug: Some(scope_project_slug), - org_id: None, + project_slug: resolved_scope.project_slug.as_deref(), + org_id: resolved_scope + .project_slug + .is_none() + .then_some(resolved_scope.org_id.as_str()), actor: created_by, revision: 0, seq, @@ -384,7 +750,11 @@ pub fn invoke( insert_relation( child, "depends_on", - &format!("work://{}/{}", scope_project_slug, index[need.as_str()]), + &format!( + "work://{}/{}", + resolved_scope.scope_id, + index[need.as_str()] + ), )?; } insert_relation(child, "generated_by", &format!("run://{}", run_id))?; @@ -402,7 +772,7 @@ pub fn invoke( revision, spec_json, spec_hash, - scope_project_slug, + resolved_scope.scope_id, serde_json::to_string(inputs).unwrap_or_default(), root_short_id, created_by.map(|actor| actor.id.as_str()), @@ -416,8 +786,11 @@ pub fn invoke( operation: "routine.invoke", entity_type: "routine_run", entity_id: &run_id, - project_slug: Some(scope_project_slug), - org_id: None, + project_slug: resolved_scope.project_slug.as_deref(), + org_id: resolved_scope + .project_slug + .is_none() + .then_some(resolved_scope.org_id.as_str()), actor: created_by, revision, seq, @@ -443,7 +816,7 @@ pub fn invoke( VALUES (?1, 'routine.invoke', ?2, ?3, ?4, ?5, ?6)", rusqlite::params![ actor_id, - scope_project_slug, + resolved_scope.scope_id, key, canonical, response_raw, @@ -455,16 +828,18 @@ pub fn invoke( tx.commit() .map_err(|err| format!("routine invoke commit: {err}"))?; crate::projects::events::notify_work_item_schedule_changed(); - let _ = crate::sync::collab_bridge::record_work_item_write( - &org_id, - Some(scope_project_slug), - &root_short_id, - false, - ); + if resolved_scope.existing_root.is_none() { + let _ = crate::sync::collab_bridge::record_work_item_write( + &resolved_scope.org_id, + resolved_scope.project_slug.as_deref(), + &root_short_id, + false, + ); + } for (_, child_id) in &invoked.steps { let _ = crate::sync::collab_bridge::record_work_item_write( - &org_id, - Some(scope_project_slug), + &resolved_scope.org_id, + resolved_scope.project_slug.as_deref(), child_id, false, ); @@ -490,57 +865,558 @@ pub fn set_default_scope(name: &str, scope: &str) -> Result<(), String> { Ok(()) } +pub fn set_default_target(name: &str, target: &RoutineInvocationTarget) -> Result<(), String> { + set_default_scope(name, &target.to_binding()) +} + /// One schedule-activation candidate for the host scheduler tick. #[derive(Debug)] pub struct ScheduledCandidate { pub name: String, - pub cron: String, - pub timezone: String, + pub trigger: ScheduledTrigger, pub concurrency: spec::ConcurrencyPolicy, pub catch_up: spec::CatchUpPolicy, - pub default_scope: Option, + pub max_catch_up_runs: u32, + pub target: RoutineInvocationTarget, pub last_evaluated_at: Option, } +#[derive(Debug, Clone)] +pub enum ScheduledTrigger { + Cron { cron: String, timezone: String }, + OneTime { at: String }, +} + +/// A single durable tick never parses or starts more than this many +/// activations. Additional due rows retain their watermark and are picked up +/// by the next 30-second pass. +pub const MAX_SCHEDULE_CANDIDATES_PER_TICK: usize = 256; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RoutineActivationEvent { + pub id: String, + pub routine_name: String, + pub invoke_key: String, + pub status: String, + pub coalesced_run_id: Option, + pub error: Option, + pub scheduled_at: i64, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone)] +pub enum RoutineActivationOutcome { + Invoked(InvokedRun), + Deferred(RoutineActivationEvent), +} + +#[derive(Debug, Clone)] +pub struct QueuedActivation { + pub event_id: String, + pub routine_name: String, + pub invoke_key: String, + pub target: RoutineInvocationTarget, + pub inputs: std::collections::BTreeMap, +} + +fn read_activation_event_in( + connection: &rusqlite::Connection, + routine_name: &str, + invoke_key: &str, +) -> Result, String> { + use rusqlite::OptionalExtension; + connection + .query_row( + "SELECT id, routine_name, invoke_key, status, coalesced_run_id, + error, scheduled_at, created_at, updated_at + FROM pm_routine_activation_events + WHERE routine_name = ?1 AND invoke_key = ?2", + rusqlite::params![routine_name, invoke_key], + |row| { + Ok(RoutineActivationEvent { + id: row.get(0)?, + routine_name: row.get(1)?, + invoke_key: row.get(2)?, + status: row.get(3)?, + coalesced_run_id: row.get(4)?, + error: row.get(5)?, + scheduled_at: row.get(6)?, + created_at: row.get(7)?, + updated_at: row.get(8)?, + }) + }, + ) + .optional() + .map_err(|err| format!("routine activation event: {err}")) +} + +struct DeferredActivation<'a> { + routine_name: &'a str, + target: &'a RoutineInvocationTarget, + inputs: &'a std::collections::BTreeMap, + invoke_key: &'a str, + status: &'a str, + active_run_id: Option<&'a str>, + scheduled_at: i64, +} + +fn record_deferred_activation_in( + connection: &rusqlite::Connection, + activation: DeferredActivation<'_>, +) -> Result { + let DeferredActivation { + routine_name, + target, + inputs, + invoke_key, + status, + active_run_id, + scheduled_at, + } = activation; + if let Some(existing) = read_activation_event_in(connection, routine_name, invoke_key)? { + return Ok(existing); + } + let now = chrono::Utc::now().timestamp_millis(); + let id = format!("rae_{}", uuid::Uuid::new_v4().simple()); + let error = match status { + "queued" => active_run_id.map(|id| format!("Queued behind active run {id}")), + "skipped" => active_run_id.map(|id| format!("Skipped because run {id} is active")), + "coalesced" => active_run_id.map(|id| format!("Coalesced into active run {id}")), + _ => None, + }; + connection + .execute( + "INSERT INTO pm_routine_activation_events ( + id, routine_name, invoke_key, target_binding, inputs_json, + status, coalesced_run_id, error, scheduled_at, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?10) + ON CONFLICT(routine_name, invoke_key) DO NOTHING", + rusqlite::params![ + id, + routine_name, + invoke_key, + target.to_binding(), + serde_json::to_string(inputs).unwrap_or_else(|_| "{}".to_string()), + status, + active_run_id, + error, + scheduled_at, + now, + ], + ) + .map_err(|err| format!("routine activation event: {err}"))?; + read_activation_event_in(connection, routine_name, invoke_key)? + .ok_or_else(|| "routine activation event disappeared after insert".to_string()) +} + +fn record_deferred_activation( + routine_name: &str, + target: &RoutineInvocationTarget, + inputs: &std::collections::BTreeMap, + invoke_key: &str, + status: &str, + active_run_id: Option<&str>, + scheduled_at: i64, +) -> Result { + let connection = project_io::helpers::conn()?; + record_deferred_activation_in( + &connection, + DeferredActivation { + routine_name, + target, + inputs, + invoke_key, + status, + active_run_id, + scheduled_at, + }, + ) +} + +const ACTIVATION_GUARD_LEASE_MS: i64 = 60_000; + +struct ActivationGuard { + routine_name: String, + owner_token: String, + released: bool, +} + +impl ActivationGuard { + fn renew(&mut self) -> Result<(), String> { + let connection = project_io::helpers::conn()?; + let now = chrono::Utc::now().timestamp_millis(); + let changed = connection + .execute( + "UPDATE pm_routine_activation_guards + SET lease_expires_at = ?3 + WHERE routine_name = ?1 AND owner_token = ?2", + rusqlite::params![ + self.routine_name, + self.owner_token, + now + ACTIVATION_GUARD_LEASE_MS + ], + ) + .map_err(|err| format!("routine activation guard renew: {err}"))?; + if changed != 1 { + return Err(format!( + "Routine activation guard ownership lost for '{}'", + self.routine_name + )); + } + Ok(()) + } + + fn release(&mut self) -> Result<(), String> { + if self.released { + return Ok(()); + } + let connection = project_io::helpers::conn()?; + connection + .execute( + "DELETE FROM pm_routine_activation_guards + WHERE routine_name = ?1 AND owner_token = ?2", + rusqlite::params![self.routine_name, self.owner_token], + ) + .map_err(|err| format!("routine activation guard release: {err}"))?; + self.released = true; + Ok(()) + } +} + +impl Drop for ActivationGuard { + fn drop(&mut self) { + let _ = self.release(); + } +} + +enum ActivationGuardClaim { + Acquired(ActivationGuard), + Busy, +} + +/// Linearization point for non-`always` activation decisions. The immediate +/// transaction makes the CAS visible across threads and processes. The guard +/// remains owned through active-check and durable defer/invoke creation; a +/// crashed owner is recoverable after the short lease. +fn claim_activation_guard(routine_name: &str) -> Result { + let mut connection = project_io::helpers::conn()?; + let tx = connection + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|err| format!("routine activation guard tx: {err}"))?; + let now = chrono::Utc::now().timestamp_millis(); + tx.execute( + "DELETE FROM pm_routine_activation_guards + WHERE routine_name = ?1 AND lease_expires_at <= ?2", + rusqlite::params![routine_name, now], + ) + .map_err(|err| format!("routine activation guard expiry: {err}"))?; + let owner_token = uuid::Uuid::new_v4().simple().to_string(); + let inserted = tx + .execute( + "INSERT INTO pm_routine_activation_guards ( + routine_name, owner_token, lease_expires_at, created_at + ) VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(routine_name) DO NOTHING", + rusqlite::params![ + routine_name, + owner_token, + now + ACTIVATION_GUARD_LEASE_MS, + now + ], + ) + .map_err(|err| format!("routine activation guard claim: {err}"))?; + if inserted == 1 { + tx.commit() + .map_err(|err| format!("routine activation guard commit: {err}"))?; + return Ok(ActivationGuardClaim::Acquired(ActivationGuard { + routine_name: routine_name.to_string(), + owner_token, + released: false, + })); + } + tx.commit() + .map_err(|err| format!("routine activation guard commit: {err}"))?; + Ok(ActivationGuardClaim::Busy) +} + +/// Atomically verify the competing CAS owner and persist this activation's +/// losing policy decision. If the owner released between the initial CAS and +/// this transaction, return `None` so the caller retries the decision. +fn defer_behind_activation_guard( + routine_name: &str, + target: &RoutineInvocationTarget, + inputs: &std::collections::BTreeMap, + invoke_key: &str, + status: &str, + scheduled_at: i64, +) -> Result, String> { + use rusqlite::OptionalExtension; + + let mut connection = project_io::helpers::conn()?; + let tx = connection + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|err| format!("routine activation defer tx: {err}"))?; + let owner: Option = tx + .query_row( + "SELECT owner_token FROM pm_routine_activation_guards WHERE routine_name = ?1", + rusqlite::params![routine_name], + |row| row.get(0), + ) + .optional() + .map_err(|err| format!("routine activation guard owner: {err}"))?; + let Some(owner) = owner else { + tx.commit() + .map_err(|err| format!("routine activation defer commit: {err}"))?; + return Ok(None); + }; + let active_decision = format!("activation:{owner}"); + let event = record_deferred_activation_in( + &tx, + DeferredActivation { + routine_name, + target, + inputs, + invoke_key, + status, + active_run_id: Some(&active_decision), + scheduled_at, + }, + )?; + tx.commit() + .map_err(|err| format!("routine activation defer commit: {err}"))?; + Ok(Some(event)) +} + +/// Apply portable concurrency semantics at the owning boundary. Queue, +/// coalesce and skip outcomes are durable; `always` intentionally bypasses +/// the active-run gate. +pub fn request_activation( + routine_name: &str, + target: &RoutineInvocationTarget, + inputs: &std::collections::BTreeMap, + invoke_key: &str, + policy: spec::ConcurrencyPolicy, + scheduled_at: i64, +) -> Result { + let connection = project_io::helpers::conn()?; + if let Some(existing) = read_activation_event_in(&connection, routine_name, invoke_key)? { + return Ok(RoutineActivationOutcome::Deferred(existing)); + } + if policy == spec::ConcurrencyPolicy::Always { + return invoke_target(routine_name, target, inputs, None, Some(invoke_key)) + .map(RoutineActivationOutcome::Invoked); + } + + let status = match policy { + spec::ConcurrencyPolicy::Queue => "queued", + spec::ConcurrencyPolicy::Coalesce => "coalesced", + spec::ConcurrencyPolicy::Skip => "skipped", + spec::ConcurrencyPolicy::Always => unreachable!(), + }; + let mut guard = loop { + match claim_activation_guard(routine_name)? { + ActivationGuardClaim::Acquired(guard) => break guard, + ActivationGuardClaim::Busy => { + if let Some(event) = defer_behind_activation_guard( + routine_name, + target, + inputs, + invoke_key, + status, + scheduled_at, + )? { + return Ok(RoutineActivationOutcome::Deferred(event)); + } + // The owner completed between the CAS and the defer + // transaction. Re-enter the CAS and observe its durable run. + continue; + } + } + }; + let connection = project_io::helpers::conn()?; + let outcome = + if let Some(existing) = read_activation_event_in(&connection, routine_name, invoke_key)? { + Ok(RoutineActivationOutcome::Deferred(existing)) + } else if let Some(active_run_id) = active_run_id(routine_name)? { + record_deferred_activation( + routine_name, + target, + inputs, + invoke_key, + status, + Some(&active_run_id), + scheduled_at, + ) + .map(RoutineActivationOutcome::Deferred) + } else { + guard.renew()?; + invoke_target(routine_name, target, inputs, None, Some(invoke_key)) + .map(RoutineActivationOutcome::Invoked) + }; + let _ = guard.release(); + outcome +} + +/// Promote one queued activation under the same cross-process CAS as a fresh +/// activation decision. `Ok(None)` means another activation owns the guard or +/// a non-terminal run still exists; the durable queue row remains untouched. +pub fn promote_queued_activation(queued: &QueuedActivation) -> Result, String> { + let mut guard = match claim_activation_guard(&queued.routine_name)? { + ActivationGuardClaim::Acquired(guard) => guard, + ActivationGuardClaim::Busy => return Ok(None), + }; + let outcome = if has_active_run(&queued.routine_name)? { + Ok(None) + } else { + guard.renew()?; + let invoked = invoke_target( + &queued.routine_name, + &queued.target, + &queued.inputs, + None, + Some(&queued.invoke_key), + )?; + finish_queued_activation(&queued.event_id, None)?; + Ok(Some(invoked)) + }; + let _ = guard.release(); + outcome +} + +pub fn queued_activations(limit: usize) -> Result, String> { + let connection = project_io::helpers::conn()?; + let mut statement = connection + .prepare( + "SELECT event.id, event.routine_name, event.invoke_key, + event.target_binding, event.inputs_json + FROM pm_routine_activation_events event + JOIN pm_routines routine ON routine.name = event.routine_name + WHERE event.status = 'queued' + ORDER BY event.created_at, event.id + LIMIT ?1", + ) + .map_err(|err| format!("routine activation queue: {err}"))?; + let rows = statement + .query_map(rusqlite::params![limit.clamp(1, 256) as i64], |row| { + let binding: String = row.get(3)?; + let inputs_json: String = row.get(4)?; + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + binding, + inputs_json, + )) + }) + .map_err(|err| format!("routine activation queue: {err}"))? + .collect::, _>>() + .map_err(|err| format!("routine activation queue: {err}"))?; + rows.into_iter() + .map( + |(event_id, routine_name, invoke_key, binding, inputs_json)| { + Ok(QueuedActivation { + event_id, + routine_name, + invoke_key, + target: RoutineInvocationTarget::from_binding(&binding)?, + inputs: serde_json::from_str(&inputs_json) + .map_err(|err| format!("routine activation queue inputs: {err}"))?, + }) + }, + ) + .collect() +} + +pub fn finish_queued_activation(event_id: &str, error: Option<&str>) -> Result<(), String> { + let connection = project_io::helpers::conn()?; + let (status, error) = match error { + Some(error) => ("failed", Some(error)), + None => ("dispatched", None), + }; + connection + .execute( + "UPDATE pm_routine_activation_events + SET status = ?2, error = ?3, updated_at = ?4 + WHERE id = ?1 AND status = 'queued'", + rusqlite::params![ + event_id, + status, + error, + chrono::Utc::now().timestamp_millis() + ], + ) + .map_err(|err| format!("routine activation queue finish: {err}"))?; + Ok(()) +} + /// Enabled routines with schedule activations, for the host scheduler. -pub fn scheduled_candidates() -> Result, String> { +pub fn scheduled_candidates(evaluate_before: i64) -> Result, String> { let connection = project_io::helpers::conn()?; let mut statement = connection .prepare( "SELECT name, spec_json, default_scope, last_evaluated_at - FROM pm_routines WHERE enabled = 1", + FROM pm_routines + WHERE enabled = 1 + AND (instr(spec_json, '\"type\":\"schedule\"') > 0 + OR instr(spec_json, '\"type\":\"one_time\"') > 0) + AND (next_fire_at IS NULL OR next_fire_at <= ?1) + ORDER BY next_fire_at, name + LIMIT ?2", ) .map_err(|err| format!("scheduled candidates: {err}"))?; let rows: Vec<(String, String, Option, Option)> = statement - .query_map([], |row| { - Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) - }) + .query_map( + rusqlite::params![evaluate_before, MAX_SCHEDULE_CANDIDATES_PER_TICK as i64], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) .map_err(|err| format!("scheduled candidates: {err}"))? .collect::, _>>() .map_err(|err| format!("scheduled candidates: {err}"))?; let mut candidates = Vec::new(); for (name, spec_json, default_scope, last_evaluated_at) in rows { + let target = default_scope + .as_deref() + .map(RoutineInvocationTarget::from_binding) + .transpose()? + .unwrap_or_else(|| RoutineInvocationTarget::standalone(None)); let Ok(file) = serde_json::from_str::(&spec_json) else { continue; }; for activation in &file.spec.activations { - if let spec::Activation::Schedule { - cron, - timezone, - policies, - } = activation - { - candidates.push(ScheduledCandidate { - name: name.clone(), - cron: cron.clone(), - timezone: timezone.clone(), - concurrency: policies - .concurrency_policy - .unwrap_or(spec::ConcurrencyPolicy::Skip), - catch_up: policies.catch_up.unwrap_or(spec::CatchUpPolicy::None), - default_scope: default_scope.clone(), - last_evaluated_at, - }); + let (trigger, policies) = match activation { + spec::Activation::Schedule { + cron, + timezone, + policies, + } => ( + ScheduledTrigger::Cron { + cron: cron.clone(), + timezone: timezone.clone(), + }, + policies, + ), + spec::Activation::OneTime { at, policies } => { + (ScheduledTrigger::OneTime { at: at.clone() }, policies) + } + spec::Activation::Manual { .. } | spec::Activation::ProviderEvent { .. } => { + continue + } + }; + candidates.push(ScheduledCandidate { + name: name.clone(), + trigger, + concurrency: policies + .concurrency_policy + .unwrap_or(spec::ConcurrencyPolicy::Skip), + catch_up: policies.catch_up.unwrap_or(spec::CatchUpPolicy::None), + max_catch_up_runs: policies.max_catch_up_runs.unwrap_or(1).max(1), + target: target.clone(), + last_evaluated_at, + }); + if candidates.len() == MAX_SCHEDULE_CANDIDATES_PER_TICK { + return Ok(candidates); } } } @@ -567,6 +1443,10 @@ pub fn mark_evaluated( /// Stored 'running' runs whose generated items are all terminal get their /// outcome written back so they stop suppressing the next scheduled fire. pub fn has_active_run(name: &str) -> Result { + Ok(active_run_id(name)?.is_some()) +} + +pub fn active_run_id(name: &str) -> Result, String> { let connection = project_io::helpers::conn()?; let mut statement = connection .prepare( @@ -586,13 +1466,35 @@ pub fn has_active_run(name: &str) -> Result { for (run_id, status, scope_id) in candidates { if status == "pending" { - return Ok(true); + return Ok(Some(run_id)); } if reconcile_running_run(&run_id, &scope_id)? { - return Ok(true); + return Ok(Some(run_id)); } } - Ok(false) + // During handover a legacy fire that already launched may still be + // running. Keep portable Queue/Skip/Coalesce semantics aware of it so a + // converted Routine cannot double-start. Pending/queued legacy fires are + // terminalized by `legacy_bridge::sync_definition`. + use rusqlite::OptionalExtension; + let connection = project_io::helpers::conn()?; + let legacy_fire_id: Option = connection + .query_row( + "SELECT fire.id + FROM pm_routine_legacy_bindings binding + JOIN routine_fires fire + ON fire.routine_id = binding.legacy_routine_id + WHERE binding.portable_name = ?1 + AND binding.archived_at IS NULL + AND fire.status = 'started' + ORDER BY fire.fired_at DESC + LIMIT 1", + rusqlite::params![name], + |row| row.get(0), + ) + .optional() + .map_err(|err| format!("routine legacy handover activity: {err}"))?; + Ok(legacy_fire_id.map(|id| format!("legacy:{id}"))) } fn reconcile_running_run(run_id: &str, scope_id: &str) -> Result { @@ -622,7 +1524,7 @@ fn reconcile_running_run(run_id: &str, scope_id: &str) -> Result { let mut states = Vec::new(); for child_id in &child_ids { - let Ok(item) = project_io::read_work_item(scope_id, child_id) else { + let Ok(item) = read_scoped_work_item(scope_id, child_id) else { return Ok(true); }; states.push(work_service::state::map_legacy_status( @@ -655,6 +1557,16 @@ fn reconcile_running_run(run_id: &str, scope_id: &str) -> Result { Ok(false) } +fn read_scoped_work_item( + scope_id: &str, + short_id: &str, +) -> Result { + match scope_id.strip_prefix("org:") { + Some(org_id) => project_io::read_standalone_work_item(Some(org_id), short_id), + None => project_io::read_work_item(scope_id, short_id), + } +} + /// Audit a suppressed automatic fire (skip/coalesce/queue while active). pub fn audit_suppressed_fire(name: &str, policy: &str, scheduled_at: i64) -> Result<(), String> { let mut connection = project_io::helpers::conn()?; @@ -722,6 +1634,162 @@ pub fn set_enabled(name: &str, enabled: bool) -> Result<(), String> { Ok(()) } +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CancelledRoutineRun { + pub run_id: String, + pub status: String, + pub changed: bool, + /// Durable Work Item Runs stopped as part of the RoutineRun. The Work + /// Items themselves remain product intent and are not auto-cancelled. + pub cancelled_work_item_runs: usize, + /// Sessions already launched before cancellation. Hosts may use these + /// ids to interrupt provider processes; the durable Run is already + /// terminal even when the original host is offline. + pub session_ids: Vec, +} + +/// Idempotently terminate a portable RoutineRun and every execution episode +/// owned by its generated step items. Product Work Item lifecycle is kept +/// separate: cancellation stops automation, not the user's underlying work. +pub fn cancel_run( + run_id: &str, + actor: Option<&crate::projects::types::WorkItemMutationActor>, +) -> Result { + use rusqlite::OptionalExtension; + + let mut connection = project_io::helpers::conn()?; + let tx = connection + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|err| format!("routine cancel tx: {err}"))?; + let run_row: Option<(String, String)> = tx + .query_row( + "SELECT status, scope_id FROM pm_routine_runs WHERE id = ?1", + rusqlite::params![run_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(|err| format!("routine cancel: {err}"))?; + let Some((stored_status, scope_id)) = run_row else { + return Err(format!("Run '{}' not found", run_id)); + }; + if matches!(stored_status.as_str(), "succeeded" | "failed" | "cancelled") { + tx.commit() + .map_err(|err| format!("routine cancel commit: {err}"))?; + return Ok(CancelledRoutineRun { + run_id: run_id.to_string(), + status: stored_status, + changed: false, + cancelled_work_item_runs: 0, + session_ids: Vec::new(), + }); + } + + let generated_ref = format!("run://{run_id}"); + let mut statement = tx + .prepare( + "SELECT DISTINCT r.id, r.session_id + FROM pm_work_item_runs r + JOIN pm_relations relation + ON relation.entity_type = 'work_item' + AND relation.entity_id = r.work_item_id + AND relation.kind = 'generated_by' + AND relation.target_ref = ?1 + WHERE r.status IN ('queued', 'deferred', 'dispatching', 'running', 'waiting')", + ) + .map_err(|err| format!("routine cancel: {err}"))?; + let owned_runs: Vec<(String, Option)> = statement + .query_map(rusqlite::params![generated_ref], |row| { + Ok((row.get(0)?, row.get(1)?)) + }) + .map_err(|err| format!("routine cancel: {err}"))? + .collect::, _>>() + .map_err(|err| format!("routine cancel: {err}"))?; + drop(statement); + + let now = chrono::Utc::now().timestamp_millis(); + let mut cancelled_work_item_runs = 0usize; + let mut session_ids = Vec::new(); + for (work_run_id, session_id) in owned_runs { + tx.execute( + "UPDATE pm_dispatch_outbox + SET status = 'cancelled', lease_token = NULL, lease_owner = NULL, + lease_expires_at = NULL, updated_at = ?2 + WHERE run_id = ?1 AND status IN ('pending', 'retry_wait', 'leased')", + rusqlite::params![work_run_id, now], + ) + .map_err(|err| format!("routine cancel dispatch: {err}"))?; + let changed = tx + .execute( + "UPDATE pm_work_item_runs + SET status = 'cancelled', completed_at = ?2, updated_at = ?2 + WHERE id = ?1 + AND status IN ('queued', 'deferred', 'dispatching', 'running', 'waiting')", + rusqlite::params![work_run_id, now], + ) + .map_err(|err| format!("routine cancel Work Item Run: {err}"))?; + if changed > 0 { + cancelled_work_item_runs += 1; + if let Some(session_id) = session_id { + session_ids.push(session_id); + } + } + tx.execute( + "DELETE FROM pm_work_item_path_locks WHERE run_id = ?1", + rusqlite::params![work_run_id], + ) + .map_err(|err| format!("routine cancel path lock: {err}"))?; + } + session_ids.sort(); + session_ids.dedup(); + + let changed = tx + .execute( + "UPDATE pm_routine_runs + SET status = 'cancelled', updated_at = ?2 + WHERE id = ?1 + AND status NOT IN ('succeeded', 'failed', 'cancelled')", + rusqlite::params![run_id, now], + ) + .map_err(|err| format!("routine cancel: {err}"))?; + if changed > 0 { + let (project_slug, org_id) = match scope_id.strip_prefix("org:") { + Some(org_id) => (None, Some(org_id)), + None => (Some(scope_id.as_str()), None), + }; + let seq = work_service::audit::bump_change_seq(&tx)?; + work_service::audit::append_audit_event( + &tx, + &work_service::audit::AuditEventRow { + operation: "routine.cancel", + entity_type: "routine_run", + entity_id: run_id, + project_slug, + org_id, + actor, + revision: 0, + seq, + payload: serde_json::json!({ + "cancelledWorkItemRuns": cancelled_work_item_runs, + "sessionIds": session_ids, + }), + }, + )?; + } + tx.commit() + .map_err(|err| format!("routine cancel commit: {err}"))?; + if cancelled_work_item_runs > 0 { + crate::projects::events::notify_work_item_dispatch_ready(); + } + Ok(CancelledRoutineRun { + run_id: run_id.to_string(), + status: "cancelled".to_string(), + changed: changed > 0, + cancelled_work_item_runs, + session_ids, + }) +} + /// 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. @@ -759,8 +1827,7 @@ pub fn list_runs(scope_id: Option<&str>, limit: usize) -> Result Result { let connection = project_io::helpers::conn()?; let (routine_name, revision, snapshot_hash, scope_id, stored_status, root_id): ( @@ -813,7 +1880,7 @@ pub fn run_status(run_id: &str) -> Result { let mut items = Vec::new(); let mut portable_states = Vec::new(); for child_id in &child_ids { - let item = project_io::read_work_item(&scope_id, child_id)?; + let item = read_scoped_work_item(&scope_id, child_id)?; let portable = work_service::state::map_legacy_status(&item.frontmatter.status); portable_states.push(portable); items.push(serde_json::json!({ @@ -840,9 +1907,9 @@ pub fn run_status(run_id: &str) -> Result { })) } -/// Ordered first-match projection (§11). Rules 1-3 (queue pending / -/// cancel) short-circuit to the stored status until Phase 5 lands the -/// cancel machinery; rules 4-7 compute from the generated items. +/// Ordered first-match projection (§11). Pending and terminal cancellation +/// states short-circuit to the durable run status; the remaining rules compute +/// from the generated items. fn project_run_status( stored: &str, portable_states: &[Option], diff --git a/src-tauri/crates/project-management/src/routine_service/spec.rs b/src-tauri/crates/project-management/src/routine_service/spec.rs index 4f2debd26d..59665607af 100644 --- a/src-tauri/crates/project-management/src/routine_service/spec.rs +++ b/src-tauri/crates/project-management/src/routine_service/spec.rs @@ -117,7 +117,7 @@ pub enum OutputType { Reference, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields, tag = "type")] pub enum Activation { #[serde(rename = "manual")] @@ -132,6 +132,12 @@ pub enum Activation { #[serde(flatten)] policies: ActivationPolicies, }, + #[serde(rename = "one_time")] + OneTime { + at: String, + #[serde(flatten)] + policies: ActivationPolicies, + }, #[serde(rename = "provider_event")] ProviderEvent { provider: String, @@ -147,7 +153,7 @@ pub enum Activation { /// Concurrency + catch-up carried by every activation. Defaults preserve /// the legacy `routine_fires` semantics (skip, no catch-up) — the frozen /// no-regression requirement. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct ActivationPolicies { #[serde( rename = "concurrencyPolicy", @@ -157,6 +163,12 @@ pub struct ActivationPolicies { pub concurrency_policy: Option, #[serde(rename = "catchUp", default, skip_serializing_if = "Option::is_none")] pub catch_up: Option, + #[serde( + rename = "maxCatchUpRuns", + default, + skip_serializing_if = "Option::is_none" + )] + pub max_catch_up_runs: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -165,6 +177,7 @@ pub enum ConcurrencyPolicy { Coalesce, Skip, Queue, + Always, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -172,6 +185,7 @@ pub enum ConcurrencyPolicy { pub enum CatchUpPolicy { None, FireOnce, + RunAllLimited, } /// Structured validation failure — stable shape for the CLI error @@ -364,24 +378,51 @@ pub fn validate(file: &RoutineSpecFile) -> Vec { // Activations. for (index, activation) in file.spec.activations.iter().enumerate() { - if let Activation::Schedule { cron, timezone, .. } = activation { - let path = format!("spec.activations[{index}]"); - if cron.split_whitespace().count() != 5 { - push( - &mut violations, - &path, - format!("cron '{cron}' must have 5 fields"), - ); + let path = format!("spec.activations[{index}]"); + match activation { + Activation::Schedule { cron, timezone, .. } => { + if cron.split_whitespace().count() != 5 { + push( + &mut violations, + &path, + format!("cron '{cron}' must have 5 fields"), + ); + } + if timezone.trim().is_empty() { + push(&mut violations, &path, "timezone is required".into()); + } else if timezone.parse::().is_err() { + push( + &mut violations, + &path, + format!("timezone '{timezone}' must be a valid IANA timezone"), + ); + } } - if timezone.trim().is_empty() { - push(&mut violations, &path, "timezone is required".into()); - } else if timezone.parse::().is_err() { - push( - &mut violations, - &path, - format!("timezone '{timezone}' must be a valid IANA timezone"), - ); + Activation::OneTime { at, .. } => { + if chrono::DateTime::parse_from_rfc3339(at).is_err() { + push( + &mut violations, + &path, + format!("one-time activation '{at}' must be RFC 3339"), + ); + } } + Activation::Manual { .. } | Activation::ProviderEvent { .. } => {} + } + let policies = match activation { + Activation::Manual { policies } + | Activation::Schedule { policies, .. } + | Activation::OneTime { policies, .. } + | Activation::ProviderEvent { policies, .. } => policies, + }; + if policies.catch_up == Some(CatchUpPolicy::RunAllLimited) + && policies.max_catch_up_runs.unwrap_or(0) == 0 + { + push( + &mut violations, + &path, + "run_all_limited requires maxCatchUpRuns > 0".into(), + ); } } diff --git a/src-tauri/crates/project-management/src/routine_service/tests.rs b/src-tauri/crates/project-management/src/routine_service/tests.rs index bb500ae6c8..9fdd22e8b2 100644 --- a/src-tauri/crates/project-management/src/routine_service/tests.rs +++ b/src-tauri/crates/project-management/src/routine_service/tests.rs @@ -1,5 +1,4 @@ -//! Integration tests for the routine application service (Phase 4): -//! apply idempotency, revision bumps, and spec-boundary rejection. +//! Integration tests for the portable routine application service. use super::*; use test_helpers::test_env; @@ -13,6 +12,14 @@ fn fixture() -> spec::RoutineSpecFile { serde_json::from_str(&raw).expect("frozen fixture parses") } +fn named_fixture(name: &str) -> spec::RoutineSpecFile { + let mut file = fixture(); + file.metadata.id = format!("routine-{name}"); + file.metadata.name = name.to_string(); + file.metadata.revision = None; + file +} + #[test] fn apply_is_idempotent_for_identical_canonical_bodies() { let _sandbox = test_env::sandbox(); @@ -151,7 +158,7 @@ fn invoke_validates_inputs_against_the_snapshot_contract() { } #[test] -fn legacy_conversion_expresses_create_and_direct_modes_and_skips_updates() { +fn legacy_conversion_expresses_create_direct_and_existing_root_modes() { use crate::projects::types::{ RoutineCatchUpPolicy, RoutineConcurrencyPolicy, RoutineDefinition, RoutineOutputMode, RoutineOutputPolicy, RoutineResourceSelection, RoutineRunTarget, RoutineRunTemplate, @@ -160,14 +167,15 @@ fn legacy_conversion_expresses_create_and_direct_modes_and_skips_updates() { let _sandbox = test_env::sandbox(); let legacy = |mode: RoutineOutputMode, name: &str| RoutineDefinition { + activations: Vec::new(), id: format!("legacy-{name}"), name: name.to_string(), description: "legacy description".to_string(), enabled: true, - trigger: RoutineTrigger::Cron { + trigger: Some(RoutineTrigger::Cron { cron: "0 9 * * 1-5".to_string(), timezone: "America/Vancouver".to_string(), - }, + }), run_template: RoutineRunTemplate { prompt: "Do the thing".to_string(), target: RoutineRunTarget::AgentDefinition { @@ -211,11 +219,20 @@ fn legacy_conversion_expresses_create_and_direct_modes_and_skips_updates() { let applied = apply(&file).expect("apply converted"); assert_eq!(applied.revision, 1); - // Not expressible yet: UpdateExistingWorkItem. + // Existing-root identity remains outside the spec and becomes a host + // invocation binding. let mut updater = legacy(RoutineOutputMode::UpdateExistingWorkItem, "Refresher"); updater.output_policy.update_work_item_short_id = Some("AAA-0009".to_string()); - let reason = convert::convert_definition(&updater).expect_err("must skip"); - assert!(reason.contains("Phase 5"), "{reason}"); + updater.output_policy.update_work_item_project_slug = Some("demo".to_string()); + let (updated_file, _) = convert::convert_definition(&updater).expect("convert update"); + assert!(spec::validate(&updated_file).is_empty()); + assert_eq!( + convert::invocation_target(&updater).expect("target"), + RoutineInvocationTarget::ExistingProjectWork { + project_slug: "demo".to_string(), + root_work_item_id: "AAA-0009".to_string(), + } + ); } fn set_child_status(scope: &str, short_id: &str, status: &str) { @@ -237,6 +254,555 @@ fn stored_run_status(run_id: &str) -> String { .expect("run row") } +fn legacy_definition( + id: &str, + name: &str, + enabled: bool, + trigger: crate::projects::types::RoutineTrigger, +) -> crate::projects::types::RoutineDefinition { + use crate::projects::types::{ + RoutineCatchUpPolicy, RoutineConcurrencyPolicy, RoutineDefinition, RoutineOutputMode, + RoutineOutputPolicy, RoutineResourceSelection, RoutineRunTarget, RoutineRunTemplate, + RoutineWorkspaceTarget, + }; + + RoutineDefinition { + activations: Vec::new(), + id: id.to_string(), + name: name.to_string(), + description: "legacy bridge test".to_string(), + enabled, + trigger: Some(trigger), + run_template: RoutineRunTemplate { + prompt: "Do the bridged work".to_string(), + target: RoutineRunTarget::AgentDefinition { + agent_definition_id: None, + }, + resources: RoutineResourceSelection { + key_source: None, + account_id: None, + model: None, + native_harness_type: None, + }, + workspace: RoutineWorkspaceTarget::None, + mode: None, + name: None, + }, + output_policy: RoutineOutputPolicy { + mode: RoutineOutputMode::CreateWorkItem, + concurrency_policy: RoutineConcurrencyPolicy::QueueIfActive, + catch_up_policy: RoutineCatchUpPolicy::RunOnce, + create_work_item_project_slug: Some("demo".to_string()), + ..RoutineOutputPolicy::default() + }, + last_evaluated_at: None, + next_fire_at: None, + last_fire_at: None, + last_fire_status: None, + last_fire_error: None, + last_fire_session_id: None, + last_fire_work_item_id: None, + created_at: String::new(), + updated_at: String::new(), + } +} + +#[test] +fn legacy_handover_retires_unstarted_fires_and_gates_a_started_fire() { + use crate::projects::types::{RoutineFireStatus, RoutineTrigger}; + + let _sandbox = test_env::sandbox(); + crate::work_service::tests_support::seed_project("demo", "p1"); + let definition = legacy_definition( + "legacy-handover", + "Legacy Handover", + true, + RoutineTrigger::Cron { + cron: "0 9 * * *".to_string(), + timezone: "UTC".to_string(), + }, + ); + let saved = crate::projects::io::upsert_routine(definition).expect("seed mirror"); + let pending = crate::projects::io::create_routine_fire(&saved.id).expect("pending fire"); + let started = crate::projects::io::create_routine_fire(&saved.id).expect("started fire"); + crate::projects::io::mark_routine_fire_started(&started.id, "legacy-session", None) + .expect("mark started"); + + let converted = legacy_bridge::sync_definition(&saved).expect("handover"); + let fires = crate::projects::io::list_routine_fires(&saved.id).expect("legacy history"); + assert!(fires + .iter() + .any(|fire| fire.id == pending.id && fire.status == RoutineFireStatus::Skipped)); + assert!(fires + .iter() + .any(|fire| fire.id == started.id && fire.status == RoutineFireStatus::Started)); + let legacy_active_id = format!("legacy:{}", started.id); + assert_eq!( + active_run_id(&converted.name).expect("active handover fire"), + Some(legacy_active_id.clone()) + ); + let outcome = request_activation( + &converted.name, + &RoutineInvocationTarget::project("demo"), + &Default::default(), + "during-handover", + spec::ConcurrencyPolicy::Queue, + 1, + ) + .expect("portable activation during handover"); + assert!(matches!( + outcome, + RoutineActivationOutcome::Deferred(ref event) + if event.status == "queued" + && event.coalesced_run_id.as_deref() + == Some(legacy_active_id.as_str()) + )); + + crate::projects::io::mark_routine_fire_succeeded(&started.id).expect("legacy completion"); + assert!(!has_active_run(&converted.name).expect("handover settled")); + assert_eq!(queued_activations(10).expect("queued activation").len(), 1); +} + +#[test] +fn legacy_bridge_syncs_toggle_fire_history_rename_and_delete_without_ghosts() { + use crate::projects::types::{RoutineFireStatus, RoutineTrigger}; + + let _sandbox = test_env::sandbox(); + crate::work_service::tests_support::seed_project("demo", "p1"); + let definition = legacy_definition( + "legacy-bridge", + "Daily Bridge", + true, + RoutineTrigger::Cron { + cron: "0 9 * * *".to_string(), + timezone: "UTC".to_string(), + }, + ); + let saved = crate::projects::io::upsert_routine(definition).expect("seed mirror"); + let converted = legacy_bridge::sync_definition(&saved).expect("sync portable"); + assert!( + crate::projects::io::read_routine(&saved.id) + .expect("mirror") + .enabled, + "conversion must not disable the UI mirror" + ); + + let mut disabled = saved.clone(); + disabled.enabled = false; + let disabled = crate::projects::io::upsert_routine(disabled).expect("disable mirror"); + legacy_bridge::sync_definition(&disabled).expect("disable portable"); + let overlay = legacy_bridge::overlay_definition(disabled.clone()).expect("overlay"); + assert!(!overlay.enabled); + let connection = crate::projects::io::helpers::conn().expect("conn"); + let portable_enabled: i64 = connection + .query_row( + "SELECT enabled FROM pm_routines WHERE name = ?1", + rusqlite::params![converted.name], + |row| row.get(0), + ) + .expect("portable enabled"); + assert_eq!(portable_enabled, 0); + drop(connection); + + let mut enabled = disabled; + enabled.enabled = true; + let enabled = crate::projects::io::upsert_routine(enabled).expect("enable mirror"); + legacy_bridge::sync_definition(&enabled).expect("enable portable"); + let fired = legacy_bridge::fire(&enabled.id).expect("portable Fire Now"); + assert_eq!(fired.fire.status, RoutineFireStatus::Started); + let run_id = fired.fire.id.clone(); + let history = legacy_bridge::list_fires(&enabled.id).expect("unified history"); + assert!(history.iter().any(|fire| fire.id == run_id)); + let connection = crate::projects::io::helpers::conn().expect("conn"); + let legacy_fires: i64 = connection + .query_row("SELECT COUNT(*) FROM routine_fires", [], |row| row.get(0)) + .expect("legacy fire count"); + assert_eq!(legacy_fires, 0, "Fire Now must not execute the legacy path"); + connection + .execute( + "INSERT INTO pm_routine_webhooks ( + routine_name, secret_hash, secret_hint, enabled, + consecutive_failures, paused_at, created_at, updated_at + ) VALUES (?1, 'hash', 'hint', 1, 0, NULL, 1, 1)", + rusqlite::params![converted.name], + ) + .expect("seed webhook secret"); + drop(connection); + + let mut renamed = enabled; + renamed.name = "Renamed Bridge".to_string(); + let renamed = crate::projects::io::upsert_routine(renamed).expect("rename mirror"); + let renamed_portable = legacy_bridge::sync_definition(&renamed).expect("rename portable"); + assert_ne!(converted.name, renamed_portable.name); + assert_eq!( + legacy_bridge::portable_name(&renamed.id).expect("binding"), + Some(renamed_portable.name.clone()) + ); + let connection = crate::projects::io::helpers::conn().expect("conn"); + let old_rows: i64 = connection + .query_row( + "SELECT COUNT(*) FROM pm_routines WHERE name = ?1", + rusqlite::params![converted.name], + |row| row.get(0), + ) + .expect("old portable rows"); + let renamed_run: String = connection + .query_row( + "SELECT routine_name FROM pm_routine_runs WHERE id = ?1", + rusqlite::params![run_id], + |row| row.get(0), + ) + .expect("renamed history"); + assert_eq!(old_rows, 0, "rename must not leave a schedulable ghost"); + assert_eq!(renamed_run, renamed_portable.name); + drop(connection); + assert!( + legacy_bridge::list_fires(&renamed.id) + .expect("history after rename") + .iter() + .any(|fire| fire.id == run_id), + "portable history follows the stable legacy id" + ); + + assert!(legacy_bridge::delete_definition(&renamed.id).expect("delete")); + assert!(crate::projects::io::read_routine(&renamed.id).is_err()); + let connection = crate::projects::io::helpers::conn().expect("conn"); + let (portable_rows, webhook_rows, retained_runs): (i64, i64, i64) = ( + connection + .query_row("SELECT COUNT(*) FROM pm_routines", [], |row| row.get(0)) + .expect("portable count"), + connection + .query_row("SELECT COUNT(*) FROM pm_routine_webhooks", [], |row| { + row.get(0) + }) + .expect("webhook count"), + connection + .query_row("SELECT COUNT(*) FROM pm_routine_runs", [], |row| row.get(0)) + .expect("history count"), + ); + assert_eq!(portable_rows, 0, "delete removes the scheduler candidate"); + assert_eq!(webhook_rows, 0, "delete destroys the live webhook secret"); + assert_eq!(retained_runs, 1, "delete preserves portable history"); + drop(connection); + assert!(scheduled_candidates(i64::MAX) + .expect("candidates") + .is_empty()); +} + +#[test] +fn legacy_one_time_and_policy_conversion_remain_exactly_expressible() { + use crate::projects::types::{RoutineCatchUpPolicy, RoutineConcurrencyPolicy, RoutineTrigger}; + + let _sandbox = test_env::sandbox(); + crate::work_service::tests_support::seed_project("demo", "p1"); + let mut definition = legacy_definition( + "legacy-one-time", + "One Time Bridge", + true, + RoutineTrigger::OneTime { + at: "2026-08-19T10:00:00Z".to_string(), + }, + ); + definition.output_policy.concurrency_policy = RoutineConcurrencyPolicy::AlwaysCreate; + definition.output_policy.catch_up_policy = RoutineCatchUpPolicy::RunAllLimited; + definition.output_policy.max_catch_up_runs = 3; + let saved = crate::projects::io::upsert_routine(definition).expect("seed"); + let converted = legacy_bridge::sync_definition(&saved).expect("convert"); + assert!(converted + .warnings + .iter() + .all(|warning| { !warning.contains("one-time") && !warning.contains("concurrency") })); + let candidates = scheduled_candidates(i64::MAX).expect("one-time candidate"); + let candidate = candidates + .iter() + .find(|candidate| candidate.name == converted.name) + .expect("one-time remains automatic"); + assert!(matches!( + candidate.trigger, + ScheduledTrigger::OneTime { .. } + )); + assert_eq!(candidate.concurrency, spec::ConcurrencyPolicy::Always); + assert_eq!(candidate.catch_up, spec::CatchUpPolicy::RunAllLimited); + assert_eq!(candidate.max_catch_up_runs, 3); +} + +#[test] +fn activation_policies_are_durable_and_queue_promotes_exactly_once() { + let _sandbox = test_env::sandbox(); + crate::work_service::tests_support::seed_project("demo", "p1"); + let file = fixture(); + apply(&file).expect("apply"); + let target = RoutineInvocationTarget::project("demo"); + let mut inputs = std::collections::BTreeMap::new(); + inputs.insert("requirement_id".to_string(), "REQ-QUEUE".to_string()); + + let first = match request_activation( + &file.metadata.name, + &target, + &inputs, + "activation-first", + spec::ConcurrencyPolicy::Queue, + 1, + ) + .expect("first activation") + { + RoutineActivationOutcome::Invoked(run) => run, + RoutineActivationOutcome::Deferred(event) => panic!("unexpected {event:?}"), + }; + let queued = match request_activation( + &file.metadata.name, + &target, + &inputs, + "activation-queued", + spec::ConcurrencyPolicy::Queue, + 2, + ) + .expect("queue") + { + RoutineActivationOutcome::Deferred(event) => event, + RoutineActivationOutcome::Invoked(run) => panic!("unexpected {run:?}"), + }; + assert_eq!(queued.status, "queued"); + let queued_replay = match request_activation( + &file.metadata.name, + &target, + &inputs, + "activation-queued", + spec::ConcurrencyPolicy::Queue, + 2, + ) + .expect("queue replay") + { + RoutineActivationOutcome::Deferred(event) => event, + RoutineActivationOutcome::Invoked(run) => panic!("unexpected {run:?}"), + }; + assert_eq!(queued_replay.id, queued.id, "queue insert is idempotent"); + + let skipped = request_activation( + &file.metadata.name, + &target, + &inputs, + "activation-skipped", + spec::ConcurrencyPolicy::Skip, + 3, + ) + .expect("skip"); + assert!(matches!( + skipped, + RoutineActivationOutcome::Deferred(ref event) if event.status == "skipped" + )); + let coalesced = request_activation( + &file.metadata.name, + &target, + &inputs, + "activation-coalesced", + spec::ConcurrencyPolicy::Coalesce, + 4, + ) + .expect("coalesce"); + assert!(matches!( + coalesced, + RoutineActivationOutcome::Deferred(ref event) + if event.status == "coalesced" + && event.coalesced_run_id.as_deref() == Some(first.run_id.as_str()) + )); + let always = match request_activation( + &file.metadata.name, + &target, + &inputs, + "activation-always", + spec::ConcurrencyPolicy::Always, + 5, + ) + .expect("always") + { + RoutineActivationOutcome::Invoked(run) => run, + RoutineActivationOutcome::Deferred(event) => panic!("unexpected {event:?}"), + }; + + for run in [&first, &always] { + for (_, child_id) in &run.steps { + set_child_status("demo", child_id, "done"); + } + } + assert!(!has_active_run(&file.metadata.name).expect("all active runs settled")); + let pending = queued_activations(256).expect("durable queue"); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].event_id, queued.id); + let promoted = promote_queued_activation(&pending[0]) + .expect("promote") + .expect("queue is idle"); + let replay = invoke_target( + &pending[0].routine_name, + &pending[0].target, + &pending[0].inputs, + None, + Some(&pending[0].invoke_key), + ) + .expect("promotion replay"); + assert_eq!(promoted.run_id, replay.run_id); + assert!(queued_activations(256).expect("queue drained").is_empty()); + let connection = crate::projects::io::helpers::conn().expect("conn"); + let runs: i64 = connection + .query_row("SELECT COUNT(*) FROM pm_routine_runs", [], |row| row.get(0)) + .expect("run count"); + assert_eq!(runs, 3, "first + always + one queued promotion only"); +} + +#[test] +fn concurrent_skip_queue_and_coalesce_decisions_cannot_double_invoke() { + use std::sync::{Arc, Barrier}; + + let _sandbox = test_env::sandbox(); + crate::work_service::tests_support::seed_project("demo", "p1"); + for (name, policy, deferred_status) in [ + ("concurrent-skip", spec::ConcurrencyPolicy::Skip, "skipped"), + ("concurrent-queue", spec::ConcurrencyPolicy::Queue, "queued"), + ( + "concurrent-coalesce", + spec::ConcurrencyPolicy::Coalesce, + "coalesced", + ), + ] { + let file = named_fixture(name); + apply(&file).expect("apply concurrent fixture"); + let mut inputs = std::collections::BTreeMap::new(); + inputs.insert("requirement_id".to_string(), "REQ-RACE".to_string()); + let barrier = Arc::new(Barrier::new(2)); + let first_key = format!("{name}-race-first"); + let second_key = format!("{name}-race-second"); + let outcomes = std::thread::scope(|scope| { + let first_barrier = Arc::clone(&barrier); + let first_inputs = inputs.clone(); + let first = scope.spawn(move || { + first_barrier.wait(); + request_activation( + name, + &RoutineInvocationTarget::project("demo"), + &first_inputs, + &first_key, + policy, + 1, + ) + }); + let second_barrier = Arc::clone(&barrier); + let second_inputs = inputs.clone(); + let second = scope.spawn(move || { + second_barrier.wait(); + request_activation( + name, + &RoutineInvocationTarget::project("demo"), + &second_inputs, + &second_key, + policy, + 1, + ) + }); + vec![ + first.join().expect("first thread").expect("first decision"), + second + .join() + .expect("second thread") + .expect("second decision"), + ] + }); + assert_eq!( + outcomes + .iter() + .filter(|outcome| matches!(outcome, RoutineActivationOutcome::Invoked(_))) + .count(), + 1, + "{name} must have exactly one CAS winner" + ); + assert_eq!( + outcomes + .iter() + .filter(|outcome| matches!( + outcome, + RoutineActivationOutcome::Deferred(event) + if event.status == deferred_status + )) + .count(), + 1, + "{name} must durably record the losing decision" + ); + let connection = crate::projects::io::helpers::conn().expect("conn"); + let (runs, guards): (i64, i64) = ( + connection + .query_row( + "SELECT COUNT(*) FROM pm_routine_runs WHERE routine_name = ?1", + rusqlite::params![name], + |row| row.get(0), + ) + .expect("run count"), + connection + .query_row( + "SELECT COUNT(*) FROM pm_routine_activation_guards WHERE routine_name = ?1", + rusqlite::params![name], + |row| row.get(0), + ) + .expect("guard count"), + ); + assert_eq!(runs, 1); + assert_eq!(guards, 0, "the CAS guard releases after the decision"); + } +} + +#[test] +fn concurrent_always_activations_have_collision_free_run_ids() { + use std::collections::HashSet; + use std::sync::{Arc, Barrier}; + + const CONCURRENCY: usize = 8; + let _sandbox = test_env::sandbox(); + crate::work_service::tests_support::seed_project("demo", "p1"); + let file = named_fixture("concurrent-always"); + apply(&file).expect("apply concurrent fixture"); + let mut inputs = std::collections::BTreeMap::new(); + inputs.insert("requirement_id".to_string(), "REQ-ALWAYS".to_string()); + let barrier = Arc::new(Barrier::new(CONCURRENCY)); + let outcomes = std::thread::scope(|scope| { + let handles = (0..CONCURRENCY) + .map(|index| { + let barrier = Arc::clone(&barrier); + let inputs = inputs.clone(); + let routine_name = file.metadata.name.clone(); + scope.spawn(move || { + barrier.wait(); + request_activation( + &routine_name, + &RoutineInvocationTarget::project("demo"), + &inputs, + &format!("always-{index}"), + spec::ConcurrencyPolicy::Always, + 1, + ) + }) + }) + .collect::>(); + handles + .into_iter() + .map(|handle| { + handle + .join() + .expect("always thread") + .expect("always invoke") + }) + .collect::>() + }); + let run_ids = outcomes + .into_iter() + .map(|outcome| match outcome { + RoutineActivationOutcome::Invoked(run) => run.run_id, + RoutineActivationOutcome::Deferred(event) => panic!("unexpected {event:?}"), + }) + .collect::>(); + assert_eq!(run_ids.len(), CONCURRENCY); + assert!(run_ids + .iter() + .all(|run_id| run_id.starts_with("run_") && run_id.len() == 36)); +} + #[test] fn has_active_run_terminalizes_a_finished_run_and_unsuppresses() { let _sandbox = test_env::sandbox(); @@ -291,7 +857,7 @@ fn has_active_run_writes_back_failed_and_cancelled_outcomes() { } #[test] -fn convert_all_keeps_the_legacy_row_enabled_without_a_scope_binding() { +fn convert_all_hands_projectless_and_project_bound_rows_to_one_scheduler() { use crate::projects::types::{ RoutineCatchUpPolicy, RoutineConcurrencyPolicy, RoutineDefinition, RoutineOutputMode, RoutineOutputPolicy, RoutineResourceSelection, RoutineRunTarget, RoutineRunTemplate, @@ -301,14 +867,15 @@ fn convert_all_keeps_the_legacy_row_enabled_without_a_scope_binding() { crate::work_service::tests_support::seed_project("demo", "p1"); let legacy = |name: &str, slug: Option<&str>| RoutineDefinition { + activations: Vec::new(), id: format!("legacy-{name}"), name: name.to_string(), description: "legacy description".to_string(), enabled: true, - trigger: RoutineTrigger::Cron { + trigger: Some(RoutineTrigger::Cron { cron: "0 9 * * 1-5".to_string(), timezone: "UTC".to_string(), - }, + }), run_template: RoutineRunTemplate { prompt: "Do the thing".to_string(), target: RoutineRunTarget::AgentDefinition { @@ -351,15 +918,251 @@ fn convert_all_keeps_the_legacy_row_enabled_without_a_scope_binding() { let unbound_after = crate::projects::io::read_routine(&unbound.id).expect("read"); assert!( unbound_after.enabled, - "scope-less conversion must keep its legacy driver" + "the legacy row remains an enabled UI mirror after scheduler handover" ); let bound_after = crate::projects::io::read_routine(&bound.id).expect("read"); assert!( - !bound_after.enabled, - "scope-bound conversion hands over to the portable pass" + bound_after.enabled, + "scope-bound UI state mirrors the enabled portable activation" + ); + + let connection = crate::projects::io::helpers::conn().expect("conn"); + let unbound_target: String = connection + .query_row( + "SELECT default_scope FROM pm_routines WHERE name = 'unbound'", + [], + |row| row.get(0), + ) + .expect("projectless binding"); + assert_eq!(unbound_target, "org:personal-org"); + let enabled_portable: i64 = connection + .query_row( + "SELECT COUNT(*) FROM pm_routines WHERE enabled = 1", + [], + |row| row.get(0), + ) + .expect("enabled portable rows"); + assert_eq!( + enabled_portable, 2, + "portable is the sole scheduler authority" ); } +#[test] +fn invoke_can_attach_steps_to_an_existing_root_work_item() { + let _sandbox = test_env::sandbox(); + crate::work_service::tests_support::seed_project("demo", "p1"); + let file = fixture(); + apply(&file).expect("apply"); + crate::work_service::create_project_work_item( + "demo", + "AAA-0042", + &crate::work_service::CreateWorkItemRequest { + title: "Existing root".to_string(), + body: "Keep this body".to_string(), + ..Default::default() + }, + None, + ) + .expect("seed root"); + + let mut inputs = std::collections::BTreeMap::new(); + inputs.insert("requirement_id".to_string(), "REQ-ROOT".to_string()); + let actor = crate::projects::types::WorkItemMutationActor { + id: "human:owner".to_string(), + name: "Owner".to_string(), + }; + let run = invoke_target( + &file.metadata.name, + &RoutineInvocationTarget::ExistingProjectWork { + project_slug: "demo".to_string(), + root_work_item_id: "AAA-0042".to_string(), + }, + &inputs, + Some(&actor), + Some("existing-root"), + ) + .expect("invoke existing root"); + assert_eq!(run.root_short_id, "AAA-0042"); + let root = crate::projects::io::read_work_item("demo", "AAA-0042").expect("root"); + assert_eq!(root.frontmatter.title, "Existing root"); + assert_eq!(root.body, "Keep this body"); + for (_, child_id) in &run.steps { + let child = crate::projects::io::read_work_item("demo", child_id).expect("child"); + assert_eq!(child.frontmatter.parent.as_deref(), Some("AAA-0042")); + } + let connection = crate::projects::io::helpers::conn().expect("conn"); + let child_subscriptions: i64 = connection + .query_row( + "SELECT COUNT(*) FROM pm_work_item_subscriptions + WHERE subscriber_id = 'human:owner' AND reason = 'creator'", + [], + |row| row.get(0), + ) + .expect("subscriptions"); + assert_eq!(child_subscriptions, run.steps.len() as i64); +} + +#[test] +fn invoke_without_a_project_materializes_an_org_scoped_graph() { + let _sandbox = test_env::sandbox(); + let file = fixture(); + apply(&file).expect("apply"); + let mut inputs = std::collections::BTreeMap::new(); + inputs.insert("requirement_id".to_string(), "REQ-ORG".to_string()); + + let run = invoke_target( + &file.metadata.name, + &RoutineInvocationTarget::standalone(None), + &inputs, + None, + Some("org-run"), + ) + .expect("projectless invoke"); + crate::projects::io::read_standalone_work_item(None, &run.root_short_id) + .expect("standalone root"); + for (_, child_id) in &run.steps { + crate::projects::io::read_standalone_work_item(None, child_id).expect("standalone child"); + } + let connection = crate::projects::io::helpers::conn().expect("conn"); + let scope_id: String = connection + .query_row( + "SELECT scope_id FROM pm_routine_runs WHERE id = ?1", + rusqlite::params![run.run_id], + |row| row.get(0), + ) + .expect("run scope"); + assert_eq!(scope_id, "org:personal-org"); + let status = run_status(&run.run_id).expect("standalone run status"); + assert_eq!(status["scopeId"], "org:personal-org"); + assert_eq!(status["workItems"].as_array().map(Vec::len), Some(3)); +} + +#[test] +fn cancel_run_is_idempotent_and_stops_owned_execution_without_cancelling_work() { + let _sandbox = test_env::sandbox(); + crate::work_service::tests_support::seed_project("demo", "p1"); + let file = fixture(); + apply(&file).expect("apply"); + let mut inputs = std::collections::BTreeMap::new(); + inputs.insert("requirement_id".to_string(), "REQ-CANCEL".to_string()); + let run = invoke(&file.metadata.name, "demo", &inputs, None, None).expect("invoke"); + let child_id = run.steps[0].1.clone(); + let work_run = + crate::work_run_service::enqueue(crate::projects::types::EnqueueWorkItemRunRequest { + project_slug: Some("demo".to_string()), + org_id: crate::projects::types::PERSONAL_ORG_ID.to_string(), + work_item_id: child_id.clone(), + trigger: crate::projects::types::WorkItemRunTrigger::Routine { + routine_id: file.metadata.id.clone(), + fire_id: run.run_id.clone(), + }, + target_snapshot: crate::projects::types::WorkItemRunTargetSnapshot::new( + crate::projects::types::WorkItemRunTarget::StartWorkItem { + account_id: None, + model_id: None, + }, + ), + input: serde_json::json!({}), + idempotency_key: "routine-cancel-owned-run".to_string(), + max_attempts: 1, + parent_run_id: None, + }) + .expect("enqueue owned run"); + + let first = cancel_run(&run.run_id, None).expect("cancel"); + assert!(first.changed); + assert_eq!(first.cancelled_work_item_runs, 1); + assert_eq!(stored_run_status(&run.run_id), "cancelled"); + assert_eq!( + crate::work_run_service::read(&work_run.id) + .expect("work run") + .status, + crate::projects::types::WorkItemRunStatus::Cancelled + ); + assert_ne!( + crate::projects::io::read_work_item("demo", &child_id) + .expect("child remains") + .frontmatter + .status, + "cancelled", + "Routine cancellation must not silently cancel product intent" + ); + let connection = crate::projects::io::helpers::conn().expect("conn"); + let scoped_cancel_audits: i64 = connection + .query_row( + "SELECT COUNT(*) FROM pm_audit_events + WHERE operation = 'routine.cancel' AND entity_id = ?1 + AND project_slug = 'demo'", + rusqlite::params![run.run_id], + |row| row.get(0), + ) + .expect("cancel audit"); + assert_eq!(scoped_cancel_audits, 1); + drop(connection); + + let second = cancel_run(&run.run_id, None).expect("idempotent cancel"); + assert!(!second.changed); + assert_eq!(second.status, "cancelled"); +} + +#[test] +fn scheduled_candidate_scan_is_due_only_and_hard_bounded() { + let _sandbox = test_env::sandbox(); + let file = fixture(); + let canonical = spec::canonicalize(&file).expect("canonical"); + let hash = snapshot_hash(&canonical); + let mut manual_only = file.clone(); + manual_only + .spec + .activations + .retain(|activation| matches!(activation, spec::Activation::Manual { .. })); + let manual_canonical = spec::canonicalize(&manual_only).expect("manual canonical"); + let manual_hash = snapshot_hash(&manual_canonical); + let mut connection = crate::projects::io::helpers::conn().expect("conn"); + let tx = connection.transaction().expect("tx"); + for index in 0..(MAX_SCHEDULE_CANDIDATES_PER_TICK + 20) { + tx.execute( + "INSERT INTO pm_routines ( + name, routine_id, spec_json, spec_hash, revision, enabled, + default_scope, last_evaluated_at, next_fire_at, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, 1, 1, 'org:personal-org', NULL, NULL, 0, 0)", + rusqlite::params![ + format!("routine-{index:04}"), + format!("routine_id_{index:04}"), + canonical, + hash, + ], + ) + .expect("seed candidate"); + } + tx.execute( + "INSERT INTO pm_routines ( + name, routine_id, spec_json, spec_hash, revision, enabled, + default_scope, last_evaluated_at, next_fire_at, created_at, updated_at + ) VALUES ('manual-only', 'manual-only-id', ?1, ?2, 1, 1, + 'org:personal-org', NULL, NULL, 0, 0)", + rusqlite::params![manual_canonical, manual_hash], + ) + .expect("seed manual-only"); + tx.execute( + "INSERT INTO pm_routines ( + name, routine_id, spec_json, spec_hash, revision, enabled, + default_scope, last_evaluated_at, next_fire_at, created_at, updated_at + ) VALUES ('future', 'future-id', ?1, ?2, 1, 1, + 'org:personal-org', NULL, 9999999999999, 0, 0)", + rusqlite::params![canonical, hash], + ) + .expect("seed future"); + tx.commit().expect("commit"); + + let candidates = scheduled_candidates(1).expect("candidates"); + assert_eq!(candidates.len(), MAX_SCHEDULE_CANDIDATES_PER_TICK); + assert!(candidates + .iter() + .all(|candidate| candidate.name != "future" && candidate.name != "manual-only")); +} + #[test] fn apply_rejects_invalid_specs_with_structured_violations() { let _sandbox = test_env::sandbox(); @@ -468,6 +1271,52 @@ fn invoke_steps_over_a_cross_org_short_id_instead_of_colliding() { ); } +#[test] +fn explicit_activations_replace_the_trigger_and_inherit_policies() { + let _sandbox = test_env::sandbox(); + let mut definition = legacy_definition( + "routine_multi", + "Multi Activation", + true, + crate::projects::types::RoutineTrigger::Cron { + cron: "0 9 * * *".to_string(), + timezone: "UTC".to_string(), + }, + ); + definition.activations = vec![ + spec::Activation::Schedule { + cron: "0 9 * * *".to_string(), + timezone: "UTC".to_string(), + policies: spec::ActivationPolicies::default(), + }, + spec::Activation::ProviderEvent { + provider: "github".to_string(), + event_kind: "pull_request".to_string(), + filter: None, + policies: spec::ActivationPolicies::default(), + }, + ]; + + let (file, _warnings) = convert::convert_definition(&definition).expect("convert"); + assert_eq!(file.spec.activations.len(), 2, "explicit list wins"); + let schedule_policies = match &file.spec.activations[0] { + spec::Activation::Schedule { policies, .. } => policies, + other => panic!("expected schedule first, got {other:?}"), + }; + assert!( + schedule_policies.concurrency_policy.is_some(), + "entries without policies inherit the routine's converted intent" + ); + assert!(matches!( + &file.spec.activations[1], + spec::Activation::ProviderEvent { provider, .. } if provider == "github" + )); + assert!( + spec::validate(&file).is_empty(), + "converted multi-activation spec passes validation" + ); +} + /// `invoke` materialises the whole graph — every work item, every relation, /// the run row, every audit row, the change watermark and the project's id /// counter — inside one transaction, or it leaves nothing behind. 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 335b3764aa..62461d76d6 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 @@ -472,6 +472,10 @@ fn apply_project(org_id: &str, entity: &CollabRemoteEntity) -> Result Result Result { + conn.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, field_path], + |_| Ok(true), + ) + .optional() + .map(|found| found.unwrap_or(false)) + .map_err(|err| format!("{error_context}: {err}")) +} + +#[derive(Clone, Copy)] +enum OrgCatalogKind { + PropertyDefinition, + StatusDefinition, + SavedView, + QuickAction, + OrgSkill, +} + +impl OrgCatalogKind { + fn field_path(self, entity_id: &str) -> String { + let prefix = match self { + Self::PropertyDefinition => "propertyDefinitions", + Self::StatusDefinition => "statusDefinitions", + Self::SavedView => "savedViews", + Self::QuickAction => "quickActions", + Self::OrgSkill => "orgSkills", + }; + format!("{prefix}.{entity_id}") + } + + fn anchor_label(self) -> &'static str { + match self { + Self::PropertyDefinition => "property definition", + Self::StatusDefinition => "status definition", + Self::SavedView => "saved view", + Self::QuickAction => "quick action", + Self::OrgSkill => "org skill", + } + } +} + +/// Enqueue one existing org entity as the carrier for an org-wide catalog +/// mutation. 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 carries the catalog in its full snapshot. +fn record_org_catalog_touch( + conn: &Connection, + org_id: &str, + catalog: OrgCatalogKind, + entity_id: &str, +) -> Result<(), String> { + if !is_collab_org(conn, org_id)? { + return Ok(()); + } + let anchor_label = catalog.anchor_label(); + 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 ({anchor_label} 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 ({anchor_label} Work Item anchor): {err}"))?, + }; + let Some((entity_type, anchor_id, project_slug)) = anchor else { + return Ok(()); + }; + append_collab_row( + conn, + org_id, + &project_slug, + entity_type, + &anchor_id, + OutboxOp::Update, + Some(&catalog.field_path(entity_id)), + ) +} + /// Hook for the atomic work-item update path (called from /// [`crate::sync::io::record_local_update`] when the project has no /// adapter binding). No-op unless the project's org is collab-synced. @@ -184,62 +305,55 @@ pub(crate) fn record_property_definitions_touch( 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( + record_org_catalog_touch( conn, org_id, - &project_slug, - entity_type, - &entity_id, - OutboxOp::Update, - Some(&format!("propertyDefinitions.{property_id}")), + OrgCatalogKind::PropertyDefinition, + property_id, ) } +/// Enqueue one existing org entity as the carrier for org-wide custom +/// status definitions — same anchor selection as +/// [`record_property_definitions_touch`]. +pub(crate) fn record_status_definitions_touch( + conn: &Connection, + org_id: &str, + status_id: &str, +) -> Result<(), String> { + record_org_catalog_touch(conn, org_id, OrgCatalogKind::StatusDefinition, status_id) +} + +/// Enqueue one existing org entity as the carrier for org-wide saved +/// views — same anchor selection as [`record_property_definitions_touch`]. +pub(crate) fn record_saved_views_touch( + conn: &Connection, + org_id: &str, + view_id: &str, +) -> Result<(), String> { + record_org_catalog_touch(conn, org_id, OrgCatalogKind::SavedView, view_id) +} + +/// Enqueue one existing org entity as the carrier for org-wide quick +/// actions — same anchor selection as [`record_property_definitions_touch`]. +pub(crate) fn record_quick_actions_touch( + conn: &Connection, + org_id: &str, + action_id: &str, +) -> Result<(), String> { + record_org_catalog_touch(conn, org_id, OrgCatalogKind::QuickAction, action_id) +} + +/// Enqueue one existing org entity as the carrier for org-shared skills — +/// same anchor selection as [`record_property_definitions_touch`]. +pub(crate) fn record_org_skills_touch( + conn: &Connection, + org_id: &str, + skill_id: &str, +) -> Result<(), String> { + record_org_catalog_touch(conn, org_id, OrgCatalogKind::OrgSkill, skill_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. @@ -497,6 +611,21 @@ pub fn drain_outbox(org_id: &str, max: u32) -> Result, Strin // 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)?; + let status_definitions = + crate::work_item_features::statuses::export_definitions(&conn, org_id)?; + let saved_views = crate::work_item_features::saved_views::export_views(&conn, org_id)?; + let quick_actions = crate::work_item_features::quick_actions::export_actions(&conn, org_id)?; + // Shared skills can be two orders of magnitude larger than the other + // org-wide definitions, so they only ride pushes their own touch + // enqueued instead of every entity snapshot. + let needs_org_skills = groups + .values() + .any(|(_, paths)| paths.iter().any(|path| path.starts_with("orgSkills."))); + let org_skills = if needs_org_skills { + Some(crate::org_skills::export_skills(&conn, org_id)?) + } else { + None + }; // Claim everything we're about to hand out. for (ids, _) in groups.values() { @@ -527,6 +656,10 @@ pub fn drain_outbox(org_id: &str, max: u32) -> Result, Strin entry_ids, field_paths, &property_definitions, + &status_definitions, + &saved_views, + &quick_actions, + org_skills.as_deref(), )?, "work_item" => hydrate_work_item( &conn, @@ -535,6 +668,10 @@ pub fn drain_outbox(org_id: &str, max: u32) -> Result, Strin entry_ids, field_paths, &property_definitions, + &status_definitions, + &saved_views, + &quick_actions, + org_skills.as_deref(), )?, other => { let message = format!("unsupported collab entity_type: {other}"); @@ -553,6 +690,23 @@ pub fn drain_outbox(org_id: &str, max: u32) -> Result, Strin Ok(items) } +/// Shared skills only ride pushes whose field paths asked for them; a +/// `None` keeps the key off the wire so pullers skip the apply entirely. +fn attach_org_skills(payload: Value, org_skills: Option<&[crate::org_skills::OrgSkill]>) -> Value { + let Some(org_skills) = org_skills else { + return payload; + }; + let Value::Object(mut map) = payload else { + return payload; + }; + map.insert( + "orgSkills".to_string(), + serde_json::to_value(org_skills).unwrap_or(Value::Null), + ); + Value::Object(map) +} + +#[allow(clippy::too_many_arguments)] fn hydrate_project( conn: &Connection, org_id: &str, @@ -560,6 +714,10 @@ fn hydrate_project( entry_ids: Vec, field_paths: Vec, property_definitions: &[crate::work_item_features::PropertyDefinition], + status_definitions: &[crate::work_item_features::StatusDefinition], + saved_views: &[crate::work_item_features::SavedView], + quick_actions: &[crate::work_item_features::QuickAction], + org_skills: Option<&[crate::org_skills::OrgSkill]>, ) -> Result { let row = conn .query_row( @@ -641,7 +799,11 @@ fn hydrate_project( "createdAt": to_iso8601(created_at), "updatedAt": to_iso8601(updated_at), "propertyDefinitions": property_definitions, + "statusDefinitions": status_definitions, + "savedViews": saved_views, + "quickActions": quick_actions, }); + let payload = attach_org_skills(payload, org_skills); Ok(CollabPushItem { entry_ids, @@ -666,6 +828,7 @@ fn read_project_remote_version(conn: &Connection, project_id: &str) -> Result, field_paths: Vec, property_definitions: &[crate::work_item_features::PropertyDefinition], + status_definitions: &[crate::work_item_features::StatusDefinition], + saved_views: &[crate::work_item_features::SavedView], + quick_actions: &[crate::work_item_features::QuickAction], + org_skills: Option<&[crate::org_skills::OrgSkill]>, ) -> Result { let base_version: Option = conn .query_row( @@ -707,6 +874,29 @@ fn hydrate_work_item( } else { Vec::new() }; + let carried_status_definitions: &[crate::work_item_features::StatusDefinition] = + if project_slug.is_none() { + status_definitions + } else { + &[] + }; + let carried_saved_views: &[crate::work_item_features::SavedView] = + if project_slug.is_none() { + saved_views + } else { + &[] + }; + let carried_quick_actions: &[crate::work_item_features::QuickAction] = + if project_slug.is_none() { + quick_actions + } else { + &[] + }; + let carried_org_skills = if project_slug.is_none() { + org_skills + } else { + None + }; let property_snapshot = crate::work_item_features::properties::export_work_item_snapshot( conn, @@ -732,6 +922,10 @@ fn hydrate_work_item( &data.body, &field_revisions, &property_snapshot, + carried_status_definitions, + carried_saved_views, + carried_quick_actions, + carried_org_skills, )), ) } @@ -753,11 +947,16 @@ fn hydrate_work_item( /// server's `orgii_upsert_work_item` column extraction exactly; the /// long tail rides in the same object and round-trips through /// [`apply_work_item`]'s typed deserialization. +#[allow(clippy::too_many_arguments)] fn work_item_wire( frontmatter: &WorkItemFrontmatter, body: &str, field_revisions: &std::collections::HashMap, property_snapshot: &crate::work_item_features::TypedPropertyWireSnapshot, + status_definitions: &[crate::work_item_features::StatusDefinition], + saved_views: &[crate::work_item_features::SavedView], + quick_actions: &[crate::work_item_features::QuickAction], + org_skills: Option<&[crate::org_skills::OrgSkill]>, ) -> Value { fn to_value(value: &T) -> Value { serde_json::to_value(value).unwrap_or(Value::Null) @@ -768,7 +967,7 @@ fn work_item_wire( .iter() .map(|(name, rev)| (name.clone(), json!(rev.mtime))) .collect(); - json!({ + let payload = json!({ "_fieldRevisions": field_mtimes, "id": frontmatter.id, "projectId": frontmatter.project, @@ -802,8 +1001,12 @@ fn work_item_wire( "closeOut": to_value(&frontmatter.close_out), "workProducts": to_value(&frontmatter.work_products), "propertyDefinitions": to_value(&property_snapshot.definitions), + "statusDefinitions": to_value(&status_definitions), + "savedViews": to_value(&saved_views), + "quickActions": to_value(&quick_actions), "propertyValues": to_value(&property_snapshot.values), - }) + }); + attach_org_skills(payload, org_skills) } // ============================================================================ diff --git a/src-tauri/crates/project-management/src/team_inbox/commands.rs b/src-tauri/crates/project-management/src/team_inbox/commands.rs index 2b6a5184e8..d824b57b0d 100644 --- a/src-tauri/crates/project-management/src/team_inbox/commands.rs +++ b/src-tauri/crates/project-management/src/team_inbox/commands.rs @@ -53,3 +53,49 @@ pub async fn team_inbox_mark_unread( .await .map_err(|error| format!("Task join error: {error}"))? } + +#[tauri::command] +pub async fn team_inbox_archive( + viewer_member_ids: Vec, + item_id: String, +) -> Result { + tokio::task::spawn_blocking(move || { + crate::team_inbox::set_archived(&viewer_member_ids, &item_id, true) + }) + .await + .map_err(|err| format!("Task join error: {err}"))? +} + +#[tauri::command] +pub async fn team_inbox_unarchive( + viewer_member_ids: Vec, + item_id: String, +) -> Result { + tokio::task::spawn_blocking(move || { + crate::team_inbox::set_archived(&viewer_member_ids, &item_id, false) + }) + .await + .map_err(|err| format!("Task join error: {err}"))? +} + +#[tauri::command] +pub async fn team_inbox_list_muted_kinds(recipient_id: String) -> Result, String> { + tokio::task::spawn_blocking(move || { + crate::work_item_features::subscriptions::list_muted_kinds(&recipient_id) + }) + .await + .map_err(|err| format!("Task join error: {err}"))? +} + +#[tauri::command] +pub async fn team_inbox_set_kind_muted( + recipient_id: String, + kind: String, + muted: bool, +) -> Result, String> { + tokio::task::spawn_blocking(move || { + crate::work_item_features::subscriptions::set_kind_muted(&recipient_id, &kind, muted) + }) + .await + .map_err(|err| format!("Task join error: {err}"))? +} diff --git a/src-tauri/crates/project-management/src/team_inbox/mod.rs b/src-tauri/crates/project-management/src/team_inbox/mod.rs index a20247eaa3..4e80c20249 100644 --- a/src-tauri/crates/project-management/src/team_inbox/mod.rs +++ b/src-tauri/crates/project-management/src/team_inbox/mod.rs @@ -10,7 +10,8 @@ mod store; mod types; pub use store::{ - list_page, mark_all_read, mark_read, mark_unread, unread_count, TeamInboxListOptions, + list_page, mark_all_read, mark_read, mark_unread, set_archived, unread_count, + TeamInboxListOptions, }; pub use types::*; diff --git a/src-tauri/crates/project-management/src/team_inbox/schema.rs b/src-tauri/crates/project-management/src/team_inbox/schema.rs index ab40478089..dbc0642e20 100644 --- a/src-tauri/crates/project-management/src/team_inbox/schema.rs +++ b/src-tauri/crates/project-management/src/team_inbox/schema.rs @@ -17,6 +17,13 @@ pub fn init_team_inbox_tables(conn: &Connection) -> SqliteResult<()> { ); CREATE INDEX IF NOT EXISTS idx_team_inbox_receipts_source ON team_inbox_read_receipts(source_kind, source_id); + CREATE TABLE IF NOT EXISTS team_inbox_archive_receipts ( + viewer_member_id TEXT NOT NULL, + source_kind TEXT NOT NULL, + source_id TEXT NOT NULL, + archived_at INTEGER NOT NULL, + PRIMARY KEY (viewer_member_id, source_kind, source_id) + ); "#, )?; Ok(()) 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 b5615e1e01..5679a511ab 100644 --- a/src-tauri/crates/project-management/src/team_inbox/store.rs +++ b/src-tauri/crates/project-management/src/team_inbox/store.rs @@ -9,6 +9,7 @@ use rusqlite::{ use super::{ schema::init_team_inbox_tables, TeamInboxActor, TeamInboxCursor, TeamInboxFilter, TeamInboxItem, TeamInboxItemKind, TeamInboxPage, TeamInboxPayload, TeamInboxTarget, + TeamInboxUnreadCounts, }; use crate::projects::types::{ WorkItemHandoff, WorkItemHandoffStatus, WorkItemHistoryAction, WorkItemHistoryEvent, @@ -20,7 +21,9 @@ 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 = - "LOWER(TRIM(w.status)) NOT IN ('completed', 'cancelled', 'canceled', 'duplicate', 'closed', 'done')"; + "LOWER(TRIM(COALESCE((SELECT sd.category FROM pm_status_definitions sd \ + WHERE sd.org_id = w.org_id AND sd.key = w.status), w.status))) \ + NOT IN ('completed', 'cancelled', 'canceled', 'duplicate', 'closed', 'done')"; /// Upper bound on the assigned-item summary so a long Work Item body never /// bloats the inbox payload; the detail surface links back to the full item. const SUMMARY_EXCERPT_MAX_CHARS: usize = 240; @@ -163,6 +166,21 @@ pub fn mark_unread(viewer_member_ids: Vec, item_id: &str) -> Result Result { + let mut connection = open_connection()?; + set_archived_with_connection( + &mut connection, + viewer_member_ids, + item_id, + archived, + now_ms(), + ) +} + fn open_connection() -> Result { let connection = get_projects_connection().map_err(db_error)?; init_team_inbox_tables(&connection).map_err(db_error)?; @@ -175,6 +193,7 @@ pub(crate) fn list_page_with_connection( ) -> Result { init_team_inbox_tables(connection).map_err(db_error)?; let viewer_ids = normalized_viewer_ids(&options.viewer_member_ids)?; + let archived_only = options.filter == TeamInboxFilter::Archived; let limit = options.limit.clamp(1, MAX_PAGE_LIMIT); let fetch_limit = limit + 1; let mut items = Vec::new(); @@ -184,6 +203,7 @@ pub(crate) fn list_page_with_connection( &viewer_ids, options.cursor.as_ref(), fetch_limit, + archived_only, )?); } if options.filter != TeamInboxFilter::Assigned { @@ -192,14 +212,19 @@ pub(crate) fn list_page_with_connection( &viewer_ids, options.cursor.as_ref(), fetch_limit, + archived_only, )?); } - if options.filter == TeamInboxFilter::All { + if matches!( + options.filter, + TeamInboxFilter::All | TeamInboxFilter::Archived + ) { items.extend(list_subscription_events( connection, &viewer_ids, options.cursor.as_ref(), fetch_limit, + archived_only, )?); } items.sort_by(|left, right| { @@ -219,12 +244,13 @@ pub(crate) fn list_page_with_connection( item_id: last.id.clone(), } }); - let unread_count = unread_count_with_connection(connection, &viewer_ids, options.filter)?; + let unread_counts = unread_counts_with_connection(connection, &viewer_ids, options.filter)?; Ok(TeamInboxPage { items, next_cursor, - unread_count, + unread_count: unread_counts.all, + unread_counts, }) } @@ -233,10 +259,17 @@ fn list_subscription_events( viewer_ids: &[String], cursor: Option<&TeamInboxCursor>, limit: usize, + archived_only: bool, ) -> 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 archive_predicate = archive_receipt_predicate( + SUBSCRIPTION_SOURCE_KIND, + "event.id", + &receipt_placeholders, + archived_only, + ); let cursor_predicate = if cursor.is_some() { format!( "AND (event.occurred_at < ? OR @@ -264,6 +297,7 @@ fn list_subscription_events( 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)) + AND {archive_predicate} {cursor_predicate} ORDER BY event.occurred_at DESC, {item_id_expression} DESC LIMIT ?" @@ -274,6 +308,7 @@ fn list_subscription_events( .cloned() .map(Value::from) .collect::>(); + values.extend(viewer_ids.iter().cloned().map(Value::from)); if let Some(cursor) = cursor { values.push(Value::from(cursor.occurred_at)); values.push(Value::from(cursor.occurred_at)); @@ -345,10 +380,17 @@ fn list_assigned_items( viewer_ids: &[String], cursor: Option<&TeamInboxCursor>, limit: usize, + archived_only: bool, ) -> Result, String> { let viewer_placeholders = sql_placeholders(viewer_ids.len()); let assignment_predicate = assignment_predicate(&viewer_placeholders); let receipt_viewer_predicate = format!("r.viewer_member_id IN ({viewer_placeholders})"); + let archive_predicate = archive_receipt_predicate( + ASSIGNED_SOURCE_KIND, + "w.id", + &viewer_placeholders, + archived_only, + ); let cursor_predicate = if cursor.is_some() { format!( "AND (w.updated_at < ? OR @@ -375,6 +417,7 @@ fn list_assigned_items( WHERE w.deleted_at IS NULL AND {ACTIONABLE_ASSIGNMENT_PREDICATE} AND {assignment_predicate} + AND {archive_predicate} {cursor_predicate} ORDER BY w.updated_at DESC, w.id DESC LIMIT ?" @@ -382,6 +425,7 @@ fn list_assigned_items( let mut values = assignment_values(viewer_ids); values.extend(viewer_ids.iter().cloned().map(Value::from)); + values.extend(viewer_ids.iter().cloned().map(Value::from)); if let Some(cursor) = cursor { values.push(Value::from(cursor.occurred_at)); values.push(Value::from(cursor.occurred_at)); @@ -434,6 +478,7 @@ fn list_work_item_comment_mentions( viewer_ids: &[String], cursor: Option<&TeamInboxCursor>, limit: usize, + archived_only: bool, ) -> Result, String> { let placeholders = sql_placeholders(viewer_ids.len()); let receipt_viewer_predicate = format!("r.viewer_member_id IN ({placeholders})"); @@ -441,6 +486,12 @@ fn list_work_item_comment_mentions( "CAST((julianday(json_extract(c.value, '$.created_at')) - 2440587.5) * 86400000 AS INTEGER)"; let item_id_expression = format!("'{COMMENT_MENTION_SOURCE_KIND}:' || w.id || ':' || json_extract(c.value, '$.id')"); + let archive_predicate = archive_receipt_predicate( + COMMENT_MENTION_SOURCE_KIND, + "w.id || ':' || json_extract(c.value, '$.id')", + &placeholders, + archived_only, + ); let cursor_predicate = if cursor.is_some() { format!( "AND ({occurred_expression} < ? OR @@ -470,6 +521,7 @@ fn list_work_item_comment_mentions( FROM json_each(COALESCE(json_extract(c.value, '$.mentioned_user_ids'), '[]')) m WHERE CAST(m.value AS TEXT) IN ({placeholders}) ) + AND {archive_predicate} {cursor_predicate} ORDER BY occurred_at DESC, {item_id_expression} DESC LIMIT ?" @@ -480,6 +532,7 @@ fn list_work_item_comment_mentions( .map(Value::from) .collect::>(); values.extend(viewer_ids.iter().cloned().map(Value::from)); + values.extend(viewer_ids.iter().cloned().map(Value::from)); if let Some(cursor) = cursor { values.push(Value::from(cursor.occurred_at)); values.push(Value::from(cursor.occurred_at)); @@ -528,8 +581,19 @@ pub(crate) fn unread_count_with_connection( viewer_member_ids: &[String], filter: TeamInboxFilter, ) -> Result { + Ok(unread_counts_with_connection(connection, viewer_member_ids, filter)?.all) +} + +fn unread_counts_with_connection( + connection: &Connection, + viewer_member_ids: &[String], + filter: TeamInboxFilter, +) -> Result { init_team_inbox_tables(connection).map_err(db_error)?; let viewer_ids = normalized_viewer_ids(viewer_member_ids)?; + if filter == TeamInboxFilter::Archived { + return Ok(TeamInboxUnreadCounts::default()); + } let assigned_count = if filter == TeamInboxFilter::Mentions { 0 } else { @@ -540,12 +604,17 @@ pub(crate) fn unread_count_with_connection( } else { comment_mention_unread_count(connection, &viewer_ids)? }; - let subscription_count = if filter == TeamInboxFilter::All { + let updates_count = if filter == TeamInboxFilter::All { subscription_event_unread_count(connection, &viewer_ids)? } else { 0 }; - Ok(assigned_count + mention_count + subscription_count) + Ok(TeamInboxUnreadCounts { + all: assigned_count + mention_count + updates_count, + mentions: mention_count, + assigned: assigned_count, + updates: updates_count, + }) } fn subscription_event_unread_count( @@ -555,10 +624,16 @@ fn subscription_event_unread_count( 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 + "SELECT COUNT(*) + 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)) AND NOT EXISTS ( SELECT 1 FROM team_inbox_read_receipts receipt WHERE receipt.source_kind = '{SUBSCRIPTION_SOURCE_KIND}' @@ -636,17 +711,22 @@ fn comment_mention_unread_count( Ok(count.max(0) as u64) } -pub(crate) fn mark_read_with_connection( - connection: &mut Connection, - viewer_member_ids: &[String], +struct AccessibleInboxSource { + kind: &'static str, + id: String, + sql: String, + values: Vec, +} + +/// Resolve an inbox item id back to an authoritative, currently visible source. +/// Receipt writers use this inside their transaction so stale, orphaned, or +/// cross-viewer ids cannot manufacture durable read/archive state. +fn accessible_inbox_source( + viewer_ids: &[String], item_id: &str, - read_at: i64, -) -> Result { - init_team_inbox_tables(connection).map_err(db_error)?; - let viewer_ids = normalized_viewer_ids(viewer_member_ids)?; +) -> Result { let placeholders = sql_placeholders(viewer_ids.len()); - let (source_kind, source_id, sql, values) = if let Some(source_id) = assigned_source_id(item_id) - { + if let Some(source_id) = assigned_source_id(item_id) { let sql = format!( "SELECT 1 FROM workitems w WHERE w.id = ? @@ -656,47 +736,75 @@ pub(crate) fn mark_read_with_connection( assignment_predicate(&placeholders) ); let mut values = vec![Value::from(source_id.to_string())]; - values.extend(assignment_values(&viewer_ids)); - (ASSIGNED_SOURCE_KIND, source_id.to_string(), sql, values) + values.extend(assignment_values(viewer_ids)); + Ok(AccessibleInboxSource { + kind: ASSIGNED_SOURCE_KIND, + id: source_id.to_string(), + sql, + values, + }) } else if let Some(source_id) = comment_mention_source_id(item_id) { let sql = format!( - "SELECT 1 - FROM workitems w - JOIN workitem_extras e ON e.work_item_id = w.id - JOIN json_each(COALESCE(json_extract(e.extras_json, '$.comments'), '[]')) c - WHERE w.deleted_at IS NULL - AND w.id || ':' || json_extract(c.value, '$.id') = ? - AND EXISTS ( - SELECT 1 - FROM json_each(COALESCE(json_extract(c.value, '$.mentioned_user_ids'), '[]')) m - WHERE CAST(m.value AS TEXT) IN ({placeholders}) - )" - ); + "SELECT 1 + FROM workitems w + JOIN workitem_extras e ON e.work_item_id = w.id + JOIN json_each(COALESCE(json_extract(e.extras_json, '$.comments'), '[]')) c + WHERE w.deleted_at IS NULL + AND w.id || ':' || json_extract(c.value, '$.id') = ? + AND EXISTS ( + SELECT 1 + FROM json_each(COALESCE(json_extract(c.value, '$.mentioned_user_ids'), '[]')) m + WHERE CAST(m.value AS TEXT) IN ({placeholders}) + )" + ); let mut values = vec![Value::from(source_id.to_string())]; values.extend(viewer_ids.iter().cloned().map(Value::from)); - ( - COMMENT_MENTION_SOURCE_KIND, - source_id.to_string(), + Ok(AccessibleInboxSource { + kind: COMMENT_MENTION_SOURCE_KIND, + id: source_id.to_string(), 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})" + "SELECT 1 + 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.id = ? + AND event.archived_at IS NULL + 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))" ); 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) + Ok(AccessibleInboxSource { + kind: SUBSCRIPTION_SOURCE_KIND, + id: source_id.to_string(), + sql, + values, + }) } else { - return Err(format!("Unsupported Team Inbox item id: {item_id}")); - }; + Err(format!("Unsupported Team Inbox item id: {item_id}")) + } +} + +pub(crate) fn mark_read_with_connection( + connection: &mut Connection, + viewer_member_ids: &[String], + item_id: &str, + read_at: i64, +) -> Result { + init_team_inbox_tables(connection).map_err(db_error)?; + let viewer_ids = normalized_viewer_ids(viewer_member_ids)?; + let source = accessible_inbox_source(&viewer_ids, item_id)?; let tx = connection .transaction_with_behavior(TransactionBehavior::Immediate) .map_err(db_error)?; let exists = tx - .query_row(&sql, params_from_iter(values), |_| Ok(())) + .query_row(&source.sql, params_from_iter(source.values), |_| Ok(())) .optional() .map_err(db_error)? .is_some(); @@ -712,7 +820,7 @@ pub(crate) fn mark_read_with_connection( VALUES (?1, ?2, ?3, ?4) ON CONFLICT(viewer_member_id, source_kind, source_id) DO UPDATE SET read_at = MAX(read_at, excluded.read_at)", - (viewer_id, source_kind, &source_id, read_at), + (viewer_id, source.kind, &source.id, read_at), ) .map_err(db_error)?; } @@ -728,6 +836,9 @@ pub(crate) fn mark_all_read_with_connection( ) -> Result { init_team_inbox_tables(connection).map_err(db_error)?; let viewer_ids = normalized_viewer_ids(viewer_member_ids)?; + if filter == TeamInboxFilter::Archived { + return Ok(0); + } let tx = connection .transaction_with_behavior(TransactionBehavior::Immediate) .map_err(db_error)?; @@ -800,10 +911,16 @@ pub(crate) fn mark_all_read_with_connection( } if filter == TeamInboxFilter::All { let query = format!( - "SELECT event.id FROM pm_work_item_inbox_events event + "SELECT event.id + 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)) AND NOT EXISTS ( SELECT 1 FROM team_inbox_read_receipts receipt WHERE receipt.source_kind = '{SUBSCRIPTION_SOURCE_KIND}' @@ -884,6 +1001,64 @@ pub(crate) fn mark_unread_with_connection( Ok(affected > 0) } +/// Archive is a per-viewer disposition on an inbox row, keyed exactly like +/// a read receipt. Archiving also marks the row read so unread counts stay +/// consistent without every counting query learning about archives. +pub(crate) fn set_archived_with_connection( + connection: &mut Connection, + viewer_member_ids: &[String], + item_id: &str, + archived: bool, + archived_at: i64, +) -> Result { + init_team_inbox_tables(connection).map_err(db_error)?; + let viewer_ids = normalized_viewer_ids(viewer_member_ids)?; + let source = accessible_inbox_source(&viewer_ids, item_id)?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(db_error)?; + let exists = tx + .query_row(&source.sql, params_from_iter(source.values), |_| Ok(())) + .optional() + .map_err(db_error)? + .is_some(); + if !exists { + tx.commit().map_err(db_error)?; + return Ok(false); + } + for viewer_id in &viewer_ids { + if archived { + tx.execute( + "INSERT INTO team_inbox_archive_receipts + (viewer_member_id, source_kind, source_id, archived_at) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(viewer_member_id, source_kind, source_id) + DO UPDATE SET archived_at = excluded.archived_at", + (viewer_id, source.kind, &source.id, archived_at), + ) + .map_err(db_error)?; + tx.execute( + "INSERT INTO team_inbox_read_receipts + (viewer_member_id, source_kind, source_id, read_at) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(viewer_member_id, source_kind, source_id) + DO UPDATE SET read_at = MAX(read_at, excluded.read_at)", + (viewer_id, source.kind, &source.id, archived_at), + ) + .map_err(db_error)?; + } else { + tx.execute( + "DELETE FROM team_inbox_archive_receipts + WHERE viewer_member_id = ?1 AND source_kind = ?2 AND source_id = ?3", + (viewer_id, source.kind, &source.id), + ) + .map_err(db_error)?; + } + } + tx.commit().map_err(db_error)?; + Ok(!viewer_ids.is_empty()) +} + fn normalized_viewer_ids(viewer_member_ids: &[String]) -> Result, String> { let ids = viewer_member_ids .iter() @@ -922,6 +1097,27 @@ fn sql_placeholders(count: usize) -> String { .join(", ") } +fn archive_receipt_predicate( + source_kind: &str, + source_id_expression: &str, + viewer_placeholders: &str, + archived_only: bool, +) -> String { + let operator = if archived_only { + "EXISTS" + } else { + "NOT EXISTS" + }; + format!( + "{operator} ( + SELECT 1 FROM team_inbox_archive_receipts archive + WHERE archive.source_kind = '{source_kind}' + AND archive.source_id = {source_id_expression} + AND archive.viewer_member_id IN ({viewer_placeholders}) + )" + ) +} + fn assigned_item_id(source_id: &str) -> String { format!("{ASSIGNED_SOURCE_KIND}:{source_id}") } diff --git a/src-tauri/crates/project-management/src/team_inbox/tests.rs b/src-tauri/crates/project-management/src/team_inbox/tests.rs index 17bfff6954..9f34317968 100644 --- a/src-tauri/crates/project-management/src/team_inbox/tests.rs +++ b/src-tauri/crates/project-management/src/team_inbox/tests.rs @@ -3,7 +3,8 @@ use serde_json::json; use super::store::{ list_page_with_connection, mark_all_read_with_connection, mark_read_with_connection, - mark_unread_with_connection, unread_count_with_connection, work_item_summary_excerpt, + mark_unread_with_connection, set_archived_with_connection, unread_count_with_connection, + work_item_summary_excerpt, }; use super::{ schema::init_team_inbox_tables, TeamInboxActor, TeamInboxCursor, TeamInboxFilter, @@ -81,6 +82,25 @@ fn set_work_item_status(connection: &Connection, work_item_id: &str, status: &st .expect("update work item status"); } +fn insert_subscription_event( + connection: &Connection, + id: &str, + scope_key: &str, + work_item_id: &str, + recipient_id: &str, +) { + connection + .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, 'discussion_updated', NULL, + '{\"title\":\"Updated\"}', ?1, 20, NULL)", + (id, scope_key, work_item_id, recipient_id), + ) + .expect("insert subscription inbox event"); +} + fn options(viewers: &[&str], limit: usize) -> TeamInboxListOptions { TeamInboxListOptions { viewer_member_ids: viewers.iter().map(|value| (*value).to_string()).collect(), @@ -90,6 +110,79 @@ fn options(viewers: &[&str], limit: usize) -> TeamInboxListOptions { } } +#[test] +fn archived_filter_is_viewer_scoped_and_not_starved_by_newer_active_rows() { + let mut connection = database(); + insert_project(&connection, "project-1", "alpha"); + for (id, short_id, updated_at) in [ + ("work-new", "TST-3", 30), + ("work-mid", "TST-2", 20), + ("work-old", "TST-1", 10), + ] { + insert_work_item( + &connection, + WorkItemFixture { + id, + short_id, + title: id, + project_id: Some("project-1"), + assigned_human_id: Some("member-a"), + assignee: None, + assignee_type: None, + updated_at, + deleted_at: None, + }, + ); + } + + assert!(set_archived_with_connection( + &mut connection, + &["member-a".into()], + "work_item_assigned:work-old", + true, + 40, + ) + .expect("archive old row")); + + let active = list_page_with_connection(&connection, options(&["member-a"], 10)) + .expect("list active rows"); + assert_eq!( + active + .items + .iter() + .map(|item| item.id.as_str()) + .collect::>(), + ["work_item_assigned:work-new", "work_item_assigned:work-mid"] + ); + + let archived = list_page_with_connection( + &connection, + TeamInboxListOptions { + viewer_member_ids: vec!["member-a".into()], + filter: TeamInboxFilter::Archived, + cursor: None, + limit: 1, + }, + ) + .expect("list archived rows"); + assert_eq!(archived.items.len(), 1); + assert_eq!(archived.items[0].id, "work_item_assigned:work-old"); + assert_eq!(archived.unread_count, 0); + assert_eq!(archived.unread_counts, Default::default()); + + let other_viewer = list_page_with_connection( + &connection, + TeamInboxListOptions { + viewer_member_ids: vec!["member-b".into()], + filter: TeamInboxFilter::Archived, + cursor: None, + limit: 10, + }, + ) + .expect("list another viewer's archive"); + assert!(other_viewer.items.is_empty()); +} + #[test] fn canonical_schema_creates_viewer_scoped_receipts_without_migration() { let connection = Connection::open_in_memory().expect("open database"); @@ -318,6 +411,39 @@ fn terminal_assignments_are_not_actionable_or_counted_as_unread() { ); } +#[test] +fn archived_custom_terminal_status_remains_non_actionable() { + let connection = database(); + connection + .execute( + "INSERT INTO pm_status_definitions ( + id, org_id, key, name, category, position, archived_at, created_at, updated_at + ) VALUES ('status-shipped', 'personal-org', 'shipped', 'Shipped', 'completed', 0, 20, 10, 20)", + [], + ) + .expect("insert archived completed status definition"); + insert_work_item( + &connection, + WorkItemFixture { + id: "work-shipped", + short_id: "TST-1", + title: "Historical shipped assignment", + project_id: None, + assigned_human_id: Some("member-a"), + assignee: None, + assignee_type: None, + updated_at: 30, + deleted_at: None, + }, + ); + set_work_item_status(&connection, "work-shipped", "shipped"); + + let page = list_page_with_connection(&connection, options(&["member-a"], 50)) + .expect("list actionable assignments"); + assert!(page.items.is_empty()); + assert_eq!(page.unread_count, 0); +} + #[test] fn cursor_is_stable_for_equal_timestamps_and_newer_insertions() { let connection = database(); @@ -466,6 +592,156 @@ fn read_receipts_and_bulk_read_are_viewer_scoped_and_idempotent() { ); } +#[test] +fn subscription_receipts_require_a_live_scope_matched_authorized_source() { + let mut connection = database(); + insert_work_item( + &connection, + WorkItemFixture { + id: "work-a", + short_id: "TST-1", + title: "Visible source", + project_id: None, + assigned_human_id: None, + assignee: None, + assignee_type: None, + updated_at: 10, + deleted_at: None, + }, + ); + insert_subscription_event( + &connection, + "event-valid", + "org:personal-org", + "TST-1", + "member-a", + ); + insert_subscription_event( + &connection, + "event-orphan", + "org:personal-org", + "MISSING-1", + "member-a", + ); + insert_subscription_event( + &connection, + "event-wrong-scope", + "org:another-org", + "TST-1", + "member-a", + ); + + let page = list_page_with_connection(&connection, options(&["member-a"], 20)) + .expect("list only authoritative subscription sources"); + let subscription_ids = page + .items + .iter() + .filter(|item| item.id.starts_with("work_item_subscription_event:")) + .map(|item| item.id.as_str()) + .collect::>(); + assert_eq!( + subscription_ids, + ["work_item_subscription_event:event-valid"] + ); + assert_eq!(page.unread_counts.updates, 1); + + for item_id in [ + "work_item_subscription_event:event-orphan", + "work_item_subscription_event:event-wrong-scope", + ] { + assert!( + !mark_read_with_connection(&mut connection, &["member-a".into()], item_id, 100,) + .expect("invalid source cannot be marked read") + ); + assert!(!set_archived_with_connection( + &mut connection, + &["member-a".into()], + item_id, + true, + 100, + ) + .expect("invalid source cannot be archived")); + } + assert!(!mark_read_with_connection( + &mut connection, + &["member-b".into()], + "work_item_subscription_event:event-valid", + 100, + ) + .expect("another recipient cannot mark the source read")); + assert!(!set_archived_with_connection( + &mut connection, + &["member-b".into()], + "work_item_subscription_event:event-valid", + true, + 100, + ) + .expect("another recipient cannot archive the source")); + + assert_eq!( + mark_all_read_with_connection( + &mut connection, + &["member-a".into()], + TeamInboxFilter::All, + 200, + ) + .expect("bulk read only authoritative sources"), + 1 + ); + assert_eq!( + unread_count_with_connection(&connection, &["member-a".into()], TeamInboxFilter::All) + .expect("orphan and mismatched events do not count"), + 0 + ); + let invalid_receipts: i64 = connection + .query_row( + "SELECT COUNT(*) + FROM team_inbox_read_receipts + WHERE source_id IN ('event-orphan', 'event-wrong-scope') + OR viewer_member_id = 'member-b'", + [], + |row| row.get(0), + ) + .expect("count invalid read receipts"); + let invalid_archives: i64 = connection + .query_row( + "SELECT COUNT(*) FROM team_inbox_archive_receipts + WHERE source_id IN ('event-orphan', 'event-wrong-scope') + OR viewer_member_id = 'member-b'", + [], + |row| row.get(0), + ) + .expect("count invalid archive receipts"); + assert_eq!(invalid_receipts, 0); + assert_eq!(invalid_archives, 0); + + assert!(set_archived_with_connection( + &mut connection, + &["member-a".into()], + "work_item_subscription_event:event-valid", + true, + 300, + ) + .expect("recipient archives the valid source")); + assert!(!set_archived_with_connection( + &mut connection, + &["member-b".into()], + "work_item_subscription_event:event-valid", + false, + 301, + ) + .expect("another recipient cannot unarchive the source")); + let owner_archive: i64 = connection + .query_row( + "SELECT COUNT(*) FROM team_inbox_archive_receipts + WHERE viewer_member_id = 'member-a' AND source_id = 'event-valid'", + [], + |row| row.get(0), + ) + .expect("recipient archive remains"); + assert_eq!(owner_archive, 1); +} + #[test] fn work_item_comment_mentions_are_viewer_scoped_and_readable() { let mut connection = database(); 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 f557e6be49..ecd0c2b3ce 100644 --- a/src-tauri/crates/project-management/src/team_inbox/types.rs +++ b/src-tauri/crates/project-management/src/team_inbox/types.rs @@ -10,6 +10,7 @@ pub enum TeamInboxFilter { All, Mentions, Assigned, + Archived, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -131,4 +132,14 @@ pub struct TeamInboxPage { #[serde(skip_serializing_if = "Option::is_none")] pub next_cursor: Option, pub unread_count: u64, + pub unread_counts: TeamInboxUnreadCounts, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TeamInboxUnreadCounts { + pub all: u64, + pub mentions: u64, + pub assigned: u64, + pub updates: u64, } 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 index 1d2db68ce6..fe112e343c 100644 --- a/src-tauri/crates/project-management/src/work_item_features/commands.rs +++ b/src-tauri/crates/project-management/src/work_item_features/commands.rs @@ -1,11 +1,14 @@ use crate::projects::types::CommentEntry; use super::{ - discussion, properties, readiness, routine_webhook, subscriptions, DiscussionPostRequest, + discussion, properties, quick_actions, readiness, routine_webhook, saved_views, statuses, + subscriptions, DiscussionDeleteRequest, DiscussionEditRequest, DiscussionPostRequest, DiscussionPostResult, DiscussionThreadMutation, DiscussionTriggerPreview, - DiscussionTriggerPreviewRequest, PrReadiness, PropertyDefinition, RoutineWebhookDelivery, - RoutineWebhookInstallInfo, RoutineWebhookStatus, SetWorkItemPropertyValueRequest, - SubscriptionMutation, UpsertPropertyDefinitionRequest, WorkItemPropertyValue, WorkItemScope, + DiscussionTriggerPreviewRequest, InvokeQuickActionRequest, PrReadiness, PropertyDefinition, + QuickAction, RoutineWebhookDelivery, RoutineWebhookInstallInfo, RoutineWebhookStatus, + SavedView, ScopePropertyValue, SetWorkItemPropertyValueRequest, StatusDefinition, + SubscriptionMutation, UpsertPropertyDefinitionRequest, UpsertQuickActionRequest, + UpsertSavedViewRequest, UpsertStatusDefinitionRequest, WorkItemPropertyValue, WorkItemScope, WorkItemSubscription, }; @@ -36,6 +39,42 @@ pub async fn project_discussion_post_comment( result } +#[tauri::command] +pub async fn project_discussion_edit_comment( + app: tauri::AppHandle, + request: DiscussionEditRequest, +) -> Result, String> { + let result = tokio::task::spawn_blocking(move || discussion::edit(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_delete_comment( + app: tauri::AppHandle, + request: DiscussionDeleteRequest, +) -> Result, String> { + let result = tokio::task::spawn_blocking(move || discussion::delete(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, @@ -240,3 +279,200 @@ pub async fn project_routine_webhook_replay( .await .map_err(|err| format!("Task join error: {err}"))? } + +#[tauri::command] +pub async fn project_list_status_definitions( + org_id: String, + include_archived: Option, +) -> Result, String> { + tokio::task::spawn_blocking(move || { + statuses::list_definitions(&org_id, include_archived.unwrap_or(false)) + }) + .await + .map_err(|err| format!("Task join error: {err}"))? +} + +#[tauri::command] +pub async fn project_upsert_status_definition( + app: tauri::AppHandle, + request: UpsertStatusDefinitionRequest, +) -> Result { + let result = tokio::task::spawn_blocking(move || statuses::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_set_status_definition_archived( + app: tauri::AppHandle, + org_id: String, + id: String, + archived: bool, +) -> Result { + let result = tokio::task::spawn_blocking(move || { + statuses::set_definition_archived(&org_id, &id, archived) + }) + .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_saved_views( + org_id: String, + project_slug: Option, +) -> Result, String> { + tokio::task::spawn_blocking(move || saved_views::list_views(&org_id, project_slug.as_deref())) + .await + .map_err(|err| format!("Task join error: {err}"))? +} + +#[tauri::command] +pub async fn project_upsert_saved_view( + app: tauri::AppHandle, + request: UpsertSavedViewRequest, +) -> Result { + let result = tokio::task::spawn_blocking(move || saved_views::upsert_view(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_archive_saved_view( + app: tauri::AppHandle, + org_id: String, + id: String, +) -> Result { + let result = tokio::task::spawn_blocking(move || saved_views::archive_view(&org_id, &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_list_scope_property_values( + org_id: String, + project_slug: Option, +) -> Result, String> { + tokio::task::spawn_blocking(move || { + properties::list_values_for_scope(&org_id, project_slug.as_deref()) + }) + .await + .map_err(|err| format!("Task join error: {err}"))? +} + +#[tauri::command] +pub async fn project_batch_set_work_item_property_value( + app: tauri::AppHandle, + org_id: String, + project_slug: Option, + short_ids: Vec, + property_id: String, + value: Option, +) -> Result { + let result = tokio::task::spawn_blocking(move || { + properties::batch_set_values(org_id, project_slug, short_ids, property_id, value) + }) + .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_quick_actions(org_id: String) -> Result, String> { + tokio::task::spawn_blocking(move || quick_actions::list_actions(&org_id)) + .await + .map_err(|err| format!("Task join error: {err}"))? +} + +#[tauri::command] +pub async fn project_upsert_quick_action( + app: tauri::AppHandle, + request: UpsertQuickActionRequest, +) -> Result { + let result = tokio::task::spawn_blocking(move || quick_actions::upsert_action(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_archive_quick_action( + app: tauri::AppHandle, + org_id: String, + id: String, +) -> Result { + let result = tokio::task::spawn_blocking(move || quick_actions::archive_action(&org_id, &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_invoke_quick_action( + app: tauri::AppHandle, + request: InvokeQuickActionRequest, +) -> Result { + let result = tokio::task::spawn_blocking(move || quick_actions::invoke_action(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 +} 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 2bb6578a96..66de34f6e3 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 @@ -3,8 +3,9 @@ use rusqlite::{params, TransactionBehavior}; use super::store::{append_audit, persist_extras, resolve_work_item}; use super::subscriptions; use super::{ - DiscussionPostRequest, DiscussionPostResult, DiscussionThreadMutation, - DiscussionTriggerPreview, DiscussionTriggerPreviewRequest, + DiscussionDeleteRequest, DiscussionEditRequest, DiscussionPostRequest, DiscussionPostResult, + DiscussionThreadMutation, DiscussionTriggerPreview, DiscussionTriggerPreviewRequest, + WorkItemScope, }; use crate::projects::io::helpers::{conn, now_ms}; use crate::projects::types::{ @@ -43,6 +44,25 @@ pub(super) enum RouteTarget { Start, } +/// An execution target supplied by a trusted internal producer. Normal user +/// comments still pass through [`mention_route`], which only permits the Work +/// Item's configured agent. Quick Actions use this override because their +/// saved target is itself the authorized routing decision. +#[derive(Debug, Clone, PartialEq)] +pub(super) enum StartTargetOverride { + AgentDefinition(String), + AgentOrg(String), +} + +impl StartTargetOverride { + fn apply_to_snapshot(&self, snapshot: &mut WorkItemRunTargetSnapshot) { + match self { + Self::AgentDefinition(id) => snapshot.agent_definition_id = Some(id.clone()), + Self::AgentOrg(id) => snapshot.agent_org_id = Some(id.clone()), + } + } +} + #[derive(Debug, Clone)] pub(super) struct RouteDecision { pub will_wake: bool, @@ -73,28 +93,68 @@ impl RouteDecision { _ => None, } } + + fn initial_delay_ms(&self) -> i64 { + if self.reason == "assignee_deferred" { + ASSIGNEE_ESCALATION_DELAY_MS + } else { + DISCUSSION_WAKE_WINDOW_MS + } + } + + fn coalescing_cap_ms(&self) -> Option { + (self.reason != "assignee_deferred").then_some(DISCUSSION_WAKE_CAP_MS) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum MentionAudience<'a> { + Unaddressed, + Humans, + Agent { id: &'a str }, + AgentOrg { id: &'a str }, } -/// Route a mention at the item's configured agent or agent org: resume its -/// latest session when one exists, otherwise start the item. -fn mention_route(mentions: &[MentionTarget], extras: &serde_json::Value) -> Option { - let addressed = mentions.iter().find_map(|mention| match mention { - MentionTarget::Agent { id } => Some(("agent", id.as_str())), - MentionTarget::AgentOrg { id } => Some(("agent_org", id.as_str())), - _ => None, - })?; +/// Classify identity-stable mentions before applying Work Item fallback rules. +/// Explicit Agent targets win mixed audiences; otherwise any member or `@all` +/// target makes this a human conversation and suppresses the assigned Agent. +pub(super) fn mention_audience(mentions: &[MentionTarget]) -> MentionAudience<'_> { + if let Some(agent) = mentions.iter().find_map(|mention| match mention { + MentionTarget::Agent { id } => Some(MentionAudience::Agent { id }), + MentionTarget::AgentOrg { id } => Some(MentionAudience::AgentOrg { id }), + MentionTarget::Member { .. } | MentionTarget::All => None, + }) { + return agent; + } + if mentions + .iter() + .any(|mention| matches!(mention, MentionTarget::Member { .. } | MentionTarget::All)) + { + MentionAudience::Humans + } else { + MentionAudience::Unaddressed + } +} + +/// Route the normalized mention audience. Human-directed comments deliberately +/// return a silent decision instead of falling through to the assigned Agent. +fn mention_audience_route( + mentions: &[MentionTarget], + extras: &serde_json::Value, +) -> Option { let config = orchestrator_config(extras); - let matches_config = match addressed { - ("agent", id) => { + let matches_config = match mention_audience(mentions) { + MentionAudience::Unaddressed => return None, + MentionAudience::Humans => return Some(RouteDecision::silent("member_addressed")), + MentionAudience::Agent { id } => { config .as_ref() .and_then(|config| config.agent_definition_id.as_deref()) == Some(id) } - ("agent_org", id) => { + MentionAudience::AgentOrg { id } => { config.as_ref().and_then(|config| config.org_id.as_deref()) == Some(id) } - _ => false, }; if !matches_config { return Some(RouteDecision::silent("mention_unroutable")); @@ -130,14 +190,18 @@ fn thread_route(comments: &[CommentEntry], parent_id: &str) -> Option Option { @@ -146,14 +210,17 @@ fn assignee_route(extras: &serde_json::Value) -> Option { return None; } Some(match latest_top_level_session(extras) { - Some(session_id) => RouteDecision::wake("assignee", RouteTarget::Resume { session_id }), - None => RouteDecision::wake("assignee_start", RouteTarget::Start), + Some(session_id) => { + RouteDecision::wake("assignee_deferred", RouteTarget::Resume { session_id }) + } + None => RouteDecision::wake("assignee_deferred", RouteTarget::Start), }) } /// The Discussion routing decision: who a comment wakes and why. -/// Precedence: explicit target > typed agent/org mention > reply thread -/// inference > agent assignee > latest linked session. +/// Precedence: `/note` > explicit session target > typed mention audience > +/// reply thread inference > agent assignee > latest linked session. Replies to +/// an explicitly human-addressed root remain human unless an Agent has joined. pub(super) fn route_comment( content: &str, explicit_target: Option<&str>, @@ -176,12 +243,18 @@ pub(super) fn route_comment( }, ); } - if let Some(decision) = mention_route(mentions, extras) { + if let Some(decision) = mention_audience_route(mentions, extras) { return decision; } if let Some(decision) = parent_id.and_then(|parent| thread_route(comments, parent)) { return decision; } + // A reply whose thread has no agent participation is a conversation + // between members; falling through would drag the assignee (or the + // latest session) into a thread nobody addressed to an agent. + if parent_id.is_some() { + return RouteDecision::silent("member_thread"); + } if let Some(decision) = assignee_route(extras) { return decision; } @@ -191,6 +264,35 @@ pub(super) fn route_comment( RouteDecision::silent("no_linked_session") } +/// A context-exhausted Session must never receive another resume turn. Keep +/// the same owning agent from the failed Run snapshot, but route the next +/// Discussion turn through a fresh execution episode. +fn freshen_context_exhausted_target( + connection: &rusqlite::Connection, + mut decision: RouteDecision, +) -> Result<(RouteDecision, Option), String> { + let Some(RouteTarget::Resume { session_id }) = decision.target.as_ref() else { + return Ok((decision, None)); + }; + let Some(snapshot) = + crate::work_run_service::context_exhausted_session_snapshot_in(connection, session_id)? + else { + return Ok((decision, None)); + }; + let target_override = snapshot + .agent_definition_id + .map(StartTargetOverride::AgentDefinition) + .or_else(|| snapshot.agent_org_id.map(StartTargetOverride::AgentOrg)); + decision.target = Some(RouteTarget::Start); + // Keep the assignee fallback's stable reason: it is the durable marker + // used by both the five-minute delay and the cancellation fence. Only the + // target changes from resume to a fresh start after context exhaustion. + if decision.reason != "assignee_deferred" { + decision.reason = format!("{}_fresh_after_context_overflow", decision.reason); + } + Ok((decision, target_override)) +} + fn preview_from_decision(decision: &RouteDecision) -> DiscussionTriggerPreview { DiscussionTriggerPreview { will_wake: decision.will_wake, @@ -227,7 +329,7 @@ fn open_wake_window_exists( }) .map(|rows| { rows.filter_map(Result::ok) - .any(|target_json| same_wake_target(&target_json, target)) + .any(|target_json| same_wake_target(&target_json, target, None)) }) .unwrap_or(false) } @@ -238,18 +340,38 @@ const DISCUSSION_WAKE_WINDOW_MS: i64 = 15_000; /// Hard ceiling from the anchor comment — continuous typing cannot postpone /// the wake forever. const DISCUSSION_WAKE_CAP_MS: i64 = 120_000; +/// Unaddressed top-level comments give the assigned agent time to observe and +/// reply before the fallback run becomes dispatchable. +const ASSIGNEE_ESCALATION_DELAY_MS: i64 = 300_000; -fn same_wake_target(stored_target_json: &str, target: &WorkItemRunTarget) -> bool { +fn same_wake_target( + stored_target_json: &str, + target: &WorkItemRunTarget, + start_target_override: Option<&StartTargetOverride>, +) -> bool { serde_json::from_str::(stored_target_json) - .map(|snapshot| match (&snapshot.target, target) { - ( - WorkItemRunTarget::ResumeSession { session_id: stored }, - WorkItemRunTarget::ResumeSession { session_id }, - ) => stored == session_id, - (WorkItemRunTarget::StartWorkItem { .. }, WorkItemRunTarget::StartWorkItem { .. }) => { - true - } - _ => false, + .map(|snapshot| { + let same_target = match (&snapshot.target, target) { + ( + WorkItemRunTarget::ResumeSession { session_id: stored }, + WorkItemRunTarget::ResumeSession { session_id }, + ) => stored == session_id, + ( + WorkItemRunTarget::StartWorkItem { .. }, + WorkItemRunTarget::StartWorkItem { .. }, + ) => true, + _ => false, + }; + same_target + && match start_target_override { + Some(StartTargetOverride::AgentDefinition(id)) => { + snapshot.agent_definition_id.as_deref() == Some(id) + } + Some(StartTargetOverride::AgentOrg(id)) => { + snapshot.agent_org_id.as_deref() == Some(id) + } + None => true, + } }) .unwrap_or(false) } @@ -264,6 +386,9 @@ fn merge_into_open_wake_window( scope_key: &str, work_item_id: &str, target: &WorkItemRunTarget, + start_target_override: Option<&StartTargetOverride>, + delay_ms: i64, + cap_ms: Option, comment: &CommentEntry, author_name: &str, short_id: &str, @@ -292,17 +417,22 @@ fn merge_into_open_wake_window( drop(statement); for (run_id, input_json, target_json, created_at) in candidates { - if !same_wake_target(&target_json, target) { + if !same_wake_target(&target_json, target, start_target_override) { continue; } - let capped_available_at = (now + DISCUSSION_WAKE_WINDOW_MS) - .min(created_at.saturating_add(DISCUSSION_WAKE_CAP_MS)); + let proposed_available_at = cap_ms.map_or_else( + || now.saturating_add(delay_ms), + |cap| { + now.saturating_add(delay_ms) + .min(created_at.saturating_add(cap)) + }, + ); let window_open = tx .execute( "UPDATE pm_dispatch_outbox - SET available_at = ?2, updated_at = ?3 + SET available_at = MAX(available_at, ?2), updated_at = ?3 WHERE run_id = ?1 AND status = 'pending'", - params![run_id, capped_available_at, now], + params![run_id, proposed_available_at, now], ) .map_err(|err| format!("Discussion wake window extend: {err}"))?; if window_open == 0 { @@ -362,6 +492,7 @@ pub(super) fn preview( &comments, &item.extras, ); + let (decision, _) = freshen_context_exhausted_target(&connection, decision)?; let mut preview = preview_from_decision(&decision); if decision.will_wake { let run_target = match decision.target.clone() { @@ -415,18 +546,30 @@ fn build_forward_message(short_id: &str, comment_id: &str, author: &str, content .join("\n") } -pub(super) fn post(request: DiscussionPostRequest) -> Result { +fn validate_post_request(request: &DiscussionPostRequest) -> Result<(), String> { 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)?; + Ok(()) +} + +fn post_in_transaction( + tx: &rusqlite::Transaction<'_>, + request: DiscussionPostRequest, + start_target_override: Option<&StartTargetOverride>, + preserve_content: bool, + notify_subscribers: bool, +) -> Result<(DiscussionPostResult, bool), String> { + validate_post_request(&request)?; + let persisted_content = if preserve_content { + request.content.clone() + } else { + request.content.trim().to_string() + }; + let item = resolve_work_item(tx, &request.scope)?; let mut extras = item.extras.clone(); let mut comments = comments_from_extras(&extras); @@ -434,20 +577,26 @@ pub(super) fn post(request: DiscussionPostRequest) -> Result Result(0), ) .ok() - .and_then(|run_id| crate::work_run_service::read_in_transaction(&tx, &run_id).ok()); + .and_then(|run_id| crate::work_run_service::read_in_transaction(tx, &run_id).ok()); let result = DiscussionPostResult { comment: existing.clone(), run, thread_reopened: false, wake_reason: decision.reason, }; - tx.commit() - .map_err(|err| format!("Discussion commit: {err}"))?; - return Ok(result); + return Ok((result, false)); } let parent = request @@ -511,20 +658,28 @@ pub(super) fn post(request: DiscussionPostRequest) -> Result Result Result Result Result Result Result, + result: DiscussionPostResult, + dispatch_ready: bool, +) -> Result { tx.commit() .map_err(|err| format!("Discussion commit: {err}"))?; - if run.is_some() { + if dispatch_ready { crate::projects::events::notify_work_item_dispatch_ready(); } - Ok(DiscussionPostResult { - comment, - run, - thread_reopened, - wake_reason: decision.reason, - }) + Ok(result) +} + +pub(super) fn post(request: DiscussionPostRequest) -> Result { + validate_post_request(&request)?; + let mut connection = conn()?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|err| format!("Discussion tx: {err}"))?; + let (result, dispatch_ready) = post_in_transaction(&tx, request, None, false, true)?; + commit_post(tx, result, dispatch_ready) +} + +pub(super) fn post_for_quick_action_in_transaction( + tx: &rusqlite::Transaction<'_>, + request: DiscussionPostRequest, + target: &StartTargetOverride, +) -> Result<(DiscussionPostResult, bool), String> { + post_in_transaction(tx, request, Some(target), true, true) +} + +/// Persist one deterministic parent Discussion comment for a child terminal +/// transition and route it through the same delayed-assignee machinery as a +/// member's top-level comment. The dedicated `child_completed` Inbox event is +/// written separately, so this system comment deliberately skips the generic +/// comment notification fan-out (and never creates a synthetic subscriber). +pub(crate) struct ChildTerminalSystemComment<'a> { + pub(crate) project_slug: Option<&'a str>, + pub(crate) org_id: &'a str, + pub(crate) parent_short_id: &'a str, + pub(crate) child_short_id: &'a str, + pub(crate) child_title: &'a str, + pub(crate) status: &'a str, + pub(crate) child_revision: i64, +} + +pub(crate) fn post_child_terminal_system_comment_in_transaction( + tx: &rusqlite::Transaction<'_>, + comment: ChildTerminalSystemComment<'_>, +) -> Result { + let ChildTerminalSystemComment { + project_slug, + org_id, + parent_short_id, + child_short_id, + child_title, + status, + child_revision, + } = comment; + let scope = WorkItemScope { + project_slug: project_slug.map(str::to_string), + org_id: org_id.to_string(), + work_item_id: parent_short_id.to_string(), + }; + match resolve_work_item(tx, &scope) { + Ok(_) => {} + Err(error) if error == format!("Work item '{parent_short_id}' not found") => { + return Ok(false) + } + Err(error) => return Err(error), + } + + let (result, dispatch_ready) = post_in_transaction( + tx, + DiscussionPostRequest { + scope, + comment_id: format!("system-child-terminal:{child_short_id}:{child_revision}"), + author_id: "ORGII".to_string(), + author_name: "ORGII".to_string(), + content: format!( + "Child {child_short_id} \u{201c}{child_title}\u{201d} reached {status}." + ), + mentioned_user_ids: Vec::new(), + mentions: Vec::new(), + parent_id: None, + target_session_id: None, + }, + None, + false, + false, + )?; + debug_assert!(result.comment.parent_id.is_none()); + Ok(dispatch_ready) } fn mutate_thread( @@ -720,3 +982,136 @@ pub(super) fn reopen_thread( ) -> Result, String> { mutate_thread(request, false) } + +/// Edit a comment's content in place. Author-only; never re-routes or +/// re-triggers a run — a wake already merged from the original content +/// keeps the text it captured. +pub(super) fn edit(request: DiscussionEditRequest) -> Result, String> { + if request.comment_id.trim().is_empty() + || request.actor_id.trim().is_empty() + || request.content.trim().is_empty() + { + return Err("commentId, actorId, 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); + let now = now_ms(); + let comment = { + let entry = comments + .iter_mut() + .find(|comment| comment.id == request.comment_id) + .ok_or_else(|| format!("Discussion comment '{}' not found", request.comment_id))?; + if entry.deleted_at.is_some() { + return Err(format!( + "Discussion comment '{}' is deleted", + request.comment_id + )); + } + if entry.author != request.actor_id { + return Err("Only the comment author can edit it".to_string()); + } + if let Some(expected_revision) = request.expected_revision { + if expected_revision != entry.revision { + return Err(crate::work_service::error::revision_conflict( + expected_revision, + entry.revision, + )); + } + } + let next = request.content.trim().to_string(); + if entry.content != next { + entry.content = next; + entry.edited_at = Some(super::store::iso8601(now)); + entry.revision += 1; + } + entry.clone() + }; + store_comments(&mut extras, &comments)?; + let revision = persist_extras(&tx, &item, &extras, now)?; + append_audit( + &tx, + &item, + "work.discussion_comment_edited", + revision, + Some(&request.actor_id), + serde_json::json!({ "commentId": 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) +} + +/// Tombstone a comment: content and mentions are cleared, the entry stays +/// so thread structure, audit trails, and idempotency keys keep resolving. +pub(super) fn delete(request: DiscussionDeleteRequest) -> Result, String> { + if request.comment_id.trim().is_empty() || request.actor_id.trim().is_empty() { + return Err("commentId and actorId 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); + let now = now_ms(); + let comment = { + let entry = comments + .iter_mut() + .find(|comment| comment.id == request.comment_id) + .ok_or_else(|| format!("Discussion comment '{}' not found", request.comment_id))?; + if let Some(expected_revision) = request.expected_revision { + if expected_revision != entry.revision { + return Err(crate::work_service::error::revision_conflict( + expected_revision, + entry.revision, + )); + } + } + if entry.deleted_at.is_some() { + drop(tx); + return Ok(comments); + } + if entry.author != request.actor_id { + return Err("Only the comment author can delete it".to_string()); + } + entry.content = String::new(); + entry.mentioned_user_ids = Vec::new(); + entry.mentions = Vec::new(); + entry.deleted_at = Some(super::store::iso8601(now)); + entry.revision += 1; + entry.clone() + }; + store_comments(&mut extras, &comments)?; + let revision = persist_extras(&tx, &item, &extras, now)?; + append_audit( + &tx, + &item, + "work.discussion_comment_deleted", + revision, + Some(&request.actor_id), + serde_json::json!({ "commentId": 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) +} 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 index 16a50b75d8..de774f4df4 100644 --- a/src-tauri/crates/project-management/src/work_item_features/mod.rs +++ b/src-tauri/crates/project-management/src/work_item_features/mod.rs @@ -7,13 +7,25 @@ mod commands; mod discussion; pub(crate) mod properties; +pub(crate) mod quick_actions; pub(crate) mod readiness; pub mod routine_webhook; +pub(crate) mod saved_views; +pub(crate) mod statuses; mod store; pub(crate) mod subscriptions; mod types; pub use commands::*; +pub(crate) use discussion::{ + post_child_terminal_system_comment_in_transaction, ChildTerminalSystemComment, +}; +pub use quick_actions::{InvokeQuickActionRequest, QuickAction, UpsertQuickActionRequest}; +pub use saved_views::{SavedView, UpsertSavedViewRequest}; +pub use statuses::{ + find_active_status_definition, render_status_catalog, StatusDefinition, + UpsertStatusDefinitionRequest, STATUS_CATALOG_BRIEF_CAP, STATUS_CATEGORIES, +}; pub use types::*; #[cfg(test)] 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 index 48e8ab3a4e..ea048961bb 100644 --- a/src-tauri/crates/project-management/src/work_item_features/properties.rs +++ b/src-tauri/crates/project-management/src/work_item_features/properties.rs @@ -12,6 +12,18 @@ use crate::projects::io::helpers::{conn, now_ms}; const MAX_PROPERTY_NAME_CHARS: usize = 80; const MAX_TEXT_CHARS: usize = 20_000; +const ORG_SCOPE_MISMATCH: &str = "PM_ERR:ORG_SCOPE_MISMATCH"; +const PROPERTY_MEMBER_INVALID: &str = "PM_ERR:PROPERTY_MEMBER_INVALID"; + +fn member_reference_id(value: &str) -> Option<&str> { + value + .strip_prefix("member:") + .filter(|member_id| !member_id.trim().is_empty() && member_id.trim() == *member_id) +} + +fn org_scope_mismatch(entity: &str, id: &str) -> String { + format!("{ORG_SCOPE_MISMATCH}:{entity}:{id}") +} fn decode_definition(row: &rusqlite::Row<'_>) -> rusqlite::Result { let property_type: String = row.get(3)?; @@ -36,10 +48,10 @@ fn decode_definition(row: &rusqlite::Row<'_>) -> rusqlite::Result Result { +) -> Result, String> { connection .query_row( "SELECT id, org_id, name, property_type, description, config_json, @@ -49,7 +61,14 @@ fn read_definition( decode_definition, ) .optional() - .map_err(|err| format!("typed property store: {err}"))? + .map_err(|err| format!("typed property store: {err}")) +} + +fn read_definition( + connection: &Connection, + property_id: &str, +) -> Result { + find_definition(connection, property_id)? .ok_or_else(|| format!("Property definition '{property_id}' not found")) } @@ -98,16 +117,23 @@ pub(crate) fn upsert_definition( .clone() .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| format!("prop_{}", uuid::Uuid::new_v4().simple())); - let existing_type: Option = tx + let existing: Option<(String, String)> = tx .query_row( - "SELECT property_type FROM pm_property_definitions WHERE id = ?1", + "SELECT org_id, property_type FROM pm_property_definitions WHERE id = ?1", params![id], - |row| row.get(0), + |row| Ok((row.get(0)?, row.get(1)?)), ) .optional() .map_err(|err| format!("typed property store: {err}"))?; - if existing_type - .as_deref() + if existing + .as_ref() + .is_some_and(|(stored_org, _)| stored_org != &request.org_id) + { + return Err(org_scope_mismatch("property_definition", &id)); + } + if existing + .as_ref() + .map(|(_, stored_type)| stored_type.as_str()) .is_some_and(|stored| stored != request.property_type.as_str()) { let value_count: i64 = tx @@ -138,7 +164,8 @@ pub(crate) fn upsert_definition( config_json = excluded.config_json, position = excluded.position, archived_at = NULL, - updated_at = excluded.updated_at", + updated_at = excluded.updated_at + WHERE pm_property_definitions.org_id = excluded.org_id", params![ id, request.org_id, @@ -152,10 +179,10 @@ pub(crate) fn upsert_definition( ) .map_err(|err| format!("typed property store: {err}"))?; crate::sync::collab_bridge::record_property_definitions_touch(&tx, &request.org_id, &id)?; + let definition = read_definition(&tx, &id)?; tx.commit() .map_err(|err| format!("typed property commit: {err}"))?; - let connection = conn()?; - read_definition(&connection, &id) + Ok(definition) } pub(crate) fn list_definitions( @@ -309,6 +336,77 @@ fn validate_value( return invalid("an http(s) URL"); } } + PropertyType::Actor => { + let Some(actor) = value.as_str() else { + return invalid("a member reference"); + }; + if member_reference_id(actor).is_none() { + return invalid("a member reference in the form member:"); + } + } + PropertyType::MultiActor => { + let Some(values) = value.as_array() else { + return invalid("an array of member references"); + }; + let mut seen = BTreeSet::new(); + for item in values { + let Some(actor) = item.as_str() else { + return invalid("an array of member references"); + }; + if member_reference_id(actor).is_none() { + return invalid("member references in the form member:"); + } + if !seen.insert(actor) { + return Err(format!( + "Duplicate member reference '{actor}' for '{}'", + definition.name + )); + } + } + } + } + Ok(()) +} + +fn validate_member_ownership( + connection: &Connection, + definition: &PropertyDefinition, + value: &serde_json::Value, + org_id: &str, + project_slug: Option<&str>, +) -> Result<(), String> { + let member_ids = match definition.property_type { + PropertyType::Actor => value + .as_str() + .and_then(member_reference_id) + .into_iter() + .collect::>(), + PropertyType::MultiActor => value + .as_array() + .into_iter() + .flatten() + .filter_map(|item| item.as_str().and_then(member_reference_id)) + .collect::>(), + _ => return Ok(()), + }; + for member_id in member_ids { + let belongs = connection + .query_row( + "SELECT 1 + FROM members m + JOIN projects p ON p.id = m.project_id + WHERE m.id = ?1 AND p.org_id = ?2 + AND (?3 IS NULL OR p.slug = ?3) + LIMIT 1", + params![member_id, org_id, project_slug], + |_| Ok(true), + ) + .optional() + .map_err(|err| format!("typed property member ownership: {err}"))? + .unwrap_or(false); + if !belongs { + return Err(format!("{PROPERTY_MEMBER_INVALID}:{member_id}")); + } } Ok(()) } @@ -320,17 +418,36 @@ pub(crate) fn set_value( 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)?; + let now = now_ms(); + let result = set_value_in_transaction(&tx, &request, &definition, now)?; + tx.commit() + .map_err(|err| format!("typed property commit: {err}"))?; + Ok(result) +} + +fn set_value_in_transaction( + tx: &rusqlite::Transaction<'_>, + request: &SetWorkItemPropertyValueRequest, + definition: &PropertyDefinition, + now: i64, +) -> Result, String> { + let item = resolve_work_item(tx, &request.scope)?; 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)?; + validate_value(definition, value)?; + validate_member_ownership( + tx, + definition, + value, + &item.org_id, + item.project_slug.as_deref(), + )?; let raw = serde_json::to_string(value) .map_err(|err| format!("typed property value serialization: {err}"))?; tx.execute( @@ -358,14 +475,14 @@ pub(crate) fn set_value( .map_err(|err| format!("typed property store: {err}"))?; } crate::sync::collab_bridge::record_work_item_payload_touch_in_connection( - &tx, + tx, &item.org_id, item.project_slug.as_deref(), &item.row_id, &format!("propertyValues.{}", request.property_id), )?; append_audit( - &tx, + tx, &item, if request.value.is_some() { "work.property_set" @@ -380,15 +497,69 @@ pub(crate) fn set_value( "value": request.value, }), )?; - tx.commit() - .map_err(|err| format!("typed property commit: {err}"))?; - Ok(request.value.map(|value| WorkItemPropertyValue { - definition, + Ok(request.value.clone().map(|value| WorkItemPropertyValue { + definition: definition.clone(), value, updated_at: iso8601(now), })) } +/// Apply one property value to a set of Work Items atomically. All target +/// scopes are resolved before commit and every write, audit row, and sync +/// touch shares the same `IMMEDIATE` transaction. +pub(crate) fn batch_set_values( + org_id: String, + project_slug: Option, + short_ids: Vec, + property_id: String, + value: Option, +) -> Result { + let short_ids = short_ids + .into_iter() + .map(|short_id| short_id.trim().to_string()) + .filter(|short_id| !short_id.is_empty()) + .collect::>(); + if short_ids.is_empty() { + return Ok(0); + } + + let mut connection = conn()?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|err| format!("typed property batch tx: {err}"))?; + let definition = read_definition(&tx, &property_id)?; + if definition.org_id != 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()); + } + if let Some(value) = value.as_ref() { + validate_value(&definition, value)?; + } + + let now = now_ms(); + for short_id in &short_ids { + set_value_in_transaction( + &tx, + &SetWorkItemPropertyValueRequest { + scope: WorkItemScope { + project_slug: project_slug.clone(), + org_id: org_id.clone(), + work_item_id: short_id.clone(), + }, + property_id: property_id.clone(), + value: value.clone(), + }, + &definition, + now, + )?; + } + tx.commit() + .map_err(|err| format!("typed property batch commit: {err}"))?; + Ok(short_ids.len()) +} + pub(crate) fn list_values(scope: &WorkItemScope) -> Result, String> { let connection = conn()?; let item = resolve_work_item(&connection, scope)?; @@ -426,6 +597,43 @@ pub(crate) fn list_values(scope: &WorkItemScope) -> Result, +) -> Result, String> { + let connection = conn()?; + let scope_key = super::store::scope_key(project_slug, org_id); + let mut statement = connection + .prepare( + "SELECT property_id, work_item_id, value_json + FROM pm_work_item_property_values + WHERE scope_key = ?1 + ORDER BY work_item_id ASC, property_id ASC", + ) + .map_err(|err| format!("typed property store: {err}"))?; + let values = statement + .query_map(params![scope_key], |row| { + let raw: String = row.get(2)?; + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, raw)) + }) + .map_err(|err| format!("typed property store: {err}"))? + .collect::, _>>() + .map_err(|err| format!("typed property store: {err}"))? + .into_iter() + .map(|(property_id, work_item_id, raw)| { + Ok(super::ScopePropertyValue { + property_id, + work_item_id, + value: serde_json::from_str(&raw) + .map_err(|err| format!("typed property value: {err}"))?, + }) + }) + .collect::, String>>()?; + Ok(values) +} + pub(crate) fn export_definitions( connection: &Connection, org_id: &str, @@ -512,26 +720,6 @@ fn timestamp_ms(value: &str) -> Result { .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, @@ -544,27 +732,32 @@ pub(crate) fn apply_wire_definitions( .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 - )); + continue; } - let remote_updated_at = timestamp_ms(&definition.updated_at)?; - let local_updated_at: Option = connection + let local: Option<(String, i64)> = connection .query_row( - "SELECT updated_at FROM pm_property_definitions WHERE id = ?1", + "SELECT org_id, updated_at FROM pm_property_definitions WHERE id = ?1", params![definition.id], - |row| row.get(0), + |row| Ok((row.get(0)?, row.get(1)?)), ) .optional() .map_err(|err| format!("typed property definition watermark: {err}"))?; + if local + .as_ref() + .is_some_and(|(stored_org, _)| stored_org != org_id) + { + continue; + } + let remote_updated_at = timestamp_ms(&definition.updated_at)?; + let local_updated_at = local.map(|(_, updated_at)| updated_at); if local_updated_at.is_some_and(|local| local >= remote_updated_at) { continue; } - if pending_property_path( + if crate::sync::collab_bridge::has_pending_collab_field_path( connection, org_id, &format!("propertyDefinitions.{}", definition.id), + "typed property pending-path probe", )? { continue; } @@ -583,7 +776,8 @@ pub(crate) fn apply_wire_definitions( position = excluded.position, archived_at = excluded.archived_at, updated_at = excluded.updated_at - WHERE excluded.updated_at >= pm_property_definitions.updated_at", + WHERE pm_property_definitions.org_id = excluded.org_id + AND excluded.updated_at >= pm_property_definitions.updated_at", params![ definition.id, definition.org_id, @@ -618,27 +812,42 @@ pub(crate) fn apply_work_item_wire_snapshot( }; let values: Vec = serde_json::from_value(raw.clone()) .map_err(|err| format!("typed property wire values: {err}"))?; - let scope: Option<(String, String)> = connection + let scope: Option<(String, String, Option)> = connection .query_row( "SELECT CASE WHEN p.slug IS NULL THEN 'org:' || w.org_id ELSE 'project:' || p.slug END, - w.short_id + w.short_id, + p.slug 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)?)), + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), ) .optional() .map_err(|err| format!("typed property apply scope: {err}"))?; - let Some((scope_key, short_id)) = scope else { + let Some((scope_key, short_id, project_slug)) = scope else { return Err(format!( "typed property apply Work Item '{work_item_row_id}' not found" )); }; for value in values { + let definition = match find_definition(connection, &value.property_id)? { + Some(definition) if definition.org_id == org_id => definition, + Some(_) | None => continue, + }; + if !value.value.is_null() { + validate_value(&definition, &value.value)?; + validate_member_ownership( + connection, + &definition, + &value.value, + org_id, + project_slug.as_deref(), + )?; + } let remote_updated_at = timestamp_ms(&value.updated_at)?; let local_updated_at: Option = connection .query_row( @@ -652,10 +861,11 @@ pub(crate) fn apply_work_item_wire_snapshot( if local_updated_at.is_some_and(|local| local >= remote_updated_at) { continue; } - if pending_property_path( + if crate::sync::collab_bridge::has_pending_collab_field_path( connection, org_id, &format!("propertyValues.{}", value.property_id), + "typed property pending-path probe", )? { continue; } diff --git a/src-tauri/crates/project-management/src/work_item_features/quick_actions.rs b/src-tauri/crates/project-management/src/work_item_features/quick_actions.rs new file mode 100644 index 0000000000..37bc66f633 --- /dev/null +++ b/src-tauri/crates/project-management/src/work_item_features/quick_actions.rs @@ -0,0 +1,475 @@ +//! Org-level quick actions: a saved "who to call and what to say" preset +//! for existing work items. +//! +//! Invoking one posts an ordinary Discussion comment carrying a typed +//! mention of the target, so routing, coalescing, preview verdicts, audit, +//! and run enqueueing are all inherited from the comment path — there is +//! no separate dispatch engine. Ordering is `use_count DESC` everywhere; +//! actions archive instead of deleting so history stays resolvable, and +//! archival propagates through the org-entity sync carrier. +//! +//! `QuickAction::org_id` is a PM/project organization. `target_kind = +//! "agent_org"` instead addresses the separate, global Agent Org registry; +//! like global agent definitions, those targets are intentionally reusable +//! from every PM org after their registry existence is verified. + +use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior}; +use serde::{Deserialize, Serialize}; + +use super::{discussion, DiscussionPostRequest, DiscussionPostResult, WorkItemScope}; +use crate::projects::io::helpers::{conn, now_ms}; +use crate::projects::types::MentionTarget; + +const ORG_SCOPE_MISMATCH: &str = "PM_ERR:ORG_SCOPE_MISMATCH"; +const QUICK_ACTION_TARGET_NOT_FOUND: &str = "PM_ERR:QUICK_ACTION_TARGET_NOT_FOUND"; +// Agent definitions are global, so a known definition is intentionally +// addressable from every PM org. Keep this list aligned with agent-core's +// compiled builtin registry; user definitions are resolved from its +// authoritative JSON store below. +const BUILTIN_AGENT_IDS: [&str; 11] = [ + "builtin:agent-architect", + "builtin:base", + "builtin:sde", + "builtin:ds", + "builtin:os", + "builtin:ai-research", + "builtin:wingman", + "builtin:explore", + "builtin:general", + "builtin:memory-extractor", + "builtin:memory-consolidator", +]; + +fn org_scope_mismatch(entity: &str, id: &str) -> String { + format!("{ORG_SCOPE_MISMATCH}:{entity}:{id}") +} + +#[derive(Deserialize)] +struct AgentRegistryEntry { + id: String, + // `name` is required by the authoritative AgentDefinition schema. Keep it + // in this boundary mirror so a malformed `{ "id": ... }` row is not + // treated as a resolvable target. + #[allow(dead_code)] + name: String, +} + +#[derive(Deserialize)] +struct AgentOrgRegistryEntry { + id: String, + // Both fields are required by OrgDefinition; other fields have serde + // defaults and are irrelevant to target identity. + #[allow(dead_code)] + name: String, + #[allow(dead_code)] + role: String, + #[serde(rename = "agentId", default)] + agent_id: String, +} + +fn registry_entries( + path: &std::path::Path, + label: &str, +) -> Result, String> { + if !path.exists() { + return Ok(Vec::new()); + } + let raw = std::fs::read_to_string(path) + .map_err(|err| format!("quick action {label} registry read: {err}"))?; + let raw_entries: Vec = serde_json::from_str(&raw) + .map_err(|err| format!("quick action {label} registry parse: {err}"))?; + Ok(raw_entries + .into_iter() + .filter_map(|entry| serde_json::from_value(entry).ok()) + .collect()) +} + +fn target_exists(target_kind: &str, target_id: &str) -> Result { + match target_kind { + "agent" => { + if BUILTIN_AGENT_IDS.contains(&target_id) { + return Ok(true); + } + registry_entries::( + &app_paths::agent_definitions(), + "agent definition", + ) + .map(|entries| entries.into_iter().any(|entry| entry.id == target_id)) + } + "agent_org" => { + let org = + registry_entries::(&app_paths::agent_orgs(), "agent org")? + .into_iter() + .find(|entry| entry.id == target_id); + let Some(org) = org else { + return Ok(false); + }; + let coordinator_agent_id = org.agent_id.trim(); + Ok(!coordinator_agent_id.is_empty() && target_exists("agent", coordinator_agent_id)?) + } + _ => Ok(false), + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QuickAction { + pub id: String, + pub org_id: String, + pub name: String, + pub description: String, + pub target_kind: String, + pub target_id: String, + pub prompt: String, + pub use_count: i64, + pub created_by: Option, + pub archived_at: Option, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpsertQuickActionRequest { + pub id: Option, + pub org_id: String, + pub name: String, + #[serde(default)] + pub description: String, + pub target_kind: String, + pub target_id: String, + pub prompt: String, + pub created_by: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InvokeQuickActionRequest { + #[serde(flatten)] + pub scope: WorkItemScope, + pub action_id: String, + pub actor_id: String, + pub actor_name: String, +} + +pub(crate) fn upsert_action(request: UpsertQuickActionRequest) -> Result { + let name = request.name.trim().to_string(); + let prompt = request.prompt.clone(); + if name.is_empty() || prompt.trim().is_empty() { + return Err("Quick action name and prompt are required".to_string()); + } + let target_kind = request.target_kind.trim().to_string(); + if !matches!(target_kind.as_str(), "agent" | "agent_org") { + return Err("PM_ERR:QUICK_ACTION_TARGET_INVALID".to_string()); + } + let target_id = request.target_id.trim().to_string(); + if target_id.is_empty() { + return Err("Quick action target is required".to_string()); + } + let mut connection = conn()?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|err| format!("quick action tx: {err}"))?; + let now = now_ms(); + let id = request + .id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("wiq_{}", uuid::Uuid::new_v4().simple())); + let existing_org: Option = tx + .query_row( + "SELECT org_id FROM pm_quick_actions WHERE id = ?1", + params![id], + |row| row.get(0), + ) + .optional() + .map_err(|err| format!("quick action store: {err}"))?; + if existing_org + .as_deref() + .is_some_and(|stored_org| stored_org != request.org_id) + { + return Err(org_scope_mismatch("quick_action", &id)); + } + if !target_exists(&target_kind, &target_id)? { + return Err(format!( + "{QUICK_ACTION_TARGET_NOT_FOUND}:{target_kind}:{target_id}" + )); + } + tx.execute( + "INSERT INTO pm_quick_actions ( + id, org_id, name, description, target_kind, target_id, + prompt, use_count, created_by, archived_at, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 0, ?8, NULL, ?9, ?9) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + target_kind = excluded.target_kind, + target_id = excluded.target_id, + prompt = excluded.prompt, + archived_at = NULL, + updated_at = excluded.updated_at + WHERE pm_quick_actions.org_id = excluded.org_id", + params![ + id, + request.org_id, + name, + request.description.trim(), + target_kind, + target_id, + prompt, + request.created_by, + now + ], + ) + .map_err(|err| format!("quick action store: {err}"))?; + crate::sync::collab_bridge::record_quick_actions_touch(&tx, &request.org_id, &id)?; + let action = read_action(&tx, &request.org_id, &id)?; + tx.commit() + .map_err(|err| format!("quick action commit: {err}"))?; + Ok(action) +} + +pub(crate) fn archive_action(org_id: &str, id: &str) -> Result { + let connection = conn()?; + let now = now_ms(); + let changed = connection + .execute( + "UPDATE pm_quick_actions + SET archived_at = COALESCE(archived_at, ?3), updated_at = ?3 + WHERE org_id = ?1 AND id = ?2", + params![org_id, id, now], + ) + .map_err(|err| format!("quick action store: {err}"))?; + if changed == 0 { + return Err(format!("Quick action '{id}' not found")); + } + crate::sync::collab_bridge::record_quick_actions_touch(&connection, org_id, id)?; + read_action(&connection, org_id, id) +} + +pub(crate) fn list_actions(org_id: &str) -> Result, String> { + let connection = conn()?; + let mut statement = connection + .prepare( + "SELECT id, org_id, name, description, target_kind, target_id, + prompt, use_count, created_by, archived_at, created_at, updated_at + FROM pm_quick_actions + WHERE org_id = ?1 AND archived_at IS NULL + ORDER BY use_count DESC, created_at ASC, id ASC", + ) + .map_err(|err| format!("quick action store: {err}"))?; + let actions = statement + .query_map(params![org_id], decode_action) + .map_err(|err| format!("quick action store: {err}"))? + .collect::, _>>() + .map_err(|err| format!("quick action store: {err}"))?; + Ok(actions) +} + +/// Invoke the saved target through the ordinary Discussion persistence and +/// enqueue path. The comment, Run/outbox row, use count, and collaboration +/// touches share one transaction so a failure cannot leave a phantom use or +/// a comment without its dispatch. +pub(crate) fn invoke_action( + request: InvokeQuickActionRequest, +) -> Result { + if request.actor_id.trim().is_empty() { + return Err("actorId is required".to_string()); + } + let mut connection = conn()?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|err| format!("quick action tx: {err}"))?; + let action = read_action(&tx, &request.scope.org_id, &request.action_id)?; + if action.archived_at.is_some() { + return Err(format!("Quick action '{}' is archived", request.action_id)); + } + if !target_exists(&action.target_kind, action.target_id.trim())? { + return Err(format!( + "{QUICK_ACTION_TARGET_NOT_FOUND}:{}:{}", + action.target_kind, action.target_id + )); + } + let (mention, target) = match action.target_kind.as_str() { + "agent" => ( + MentionTarget::Agent { + id: action.target_id.clone(), + }, + discussion::StartTargetOverride::AgentDefinition(action.target_id.clone()), + ), + "agent_org" => ( + MentionTarget::AgentOrg { + id: action.target_id.clone(), + }, + discussion::StartTargetOverride::AgentOrg(action.target_id.clone()), + ), + other => return Err(format!("unknown quick action target kind '{other}'")), + }; + let (result, dispatch_ready) = discussion::post_for_quick_action_in_transaction( + &tx, + DiscussionPostRequest { + scope: request.scope, + comment_id: format!("qa-{}-{}", action.id, uuid::Uuid::new_v4().simple()), + author_id: request.actor_id, + author_name: request.actor_name, + content: action.prompt.clone(), + mentioned_user_ids: Vec::new(), + mentions: vec![mention], + parent_id: None, + target_session_id: None, + }, + &target, + )?; + tx.execute( + "UPDATE pm_quick_actions SET use_count = use_count + 1, updated_at = ?3 + WHERE org_id = ?1 AND id = ?2", + params![action.org_id, action.id, now_ms()], + ) + .map_err(|err| format!("quick action store: {err}"))?; + crate::sync::collab_bridge::record_quick_actions_touch(&tx, &action.org_id, &action.id)?; + tx.commit() + .map_err(|err| format!("quick action commit: {err}"))?; + if dispatch_ready { + crate::projects::events::notify_work_item_dispatch_ready(); + } + Ok(result) +} + +fn read_action(connection: &Connection, org_id: &str, id: &str) -> Result { + connection + .query_row( + "SELECT id, org_id, name, description, target_kind, target_id, + prompt, use_count, created_by, archived_at, created_at, updated_at + FROM pm_quick_actions + WHERE org_id = ?1 AND id = ?2", + params![org_id, id], + decode_action, + ) + .map_err(|err| format!("quick action store: {err}")) +} + +fn decode_action(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(QuickAction { + id: row.get(0)?, + org_id: row.get(1)?, + name: row.get(2)?, + description: row.get(3)?, + target_kind: row.get(4)?, + target_id: row.get(5)?, + prompt: row.get(6)?, + use_count: row.get(7)?, + created_by: row.get(8)?, + archived_at: row.get(9)?, + created_at: row.get(10)?, + updated_at: row.get(11)?, + }) +} + +/// Every action (archived included) so removals propagate. +pub(crate) fn export_actions( + connection: &Connection, + org_id: &str, +) -> Result, String> { + let mut statement = connection + .prepare( + "SELECT id, org_id, name, description, target_kind, target_id, + prompt, use_count, created_by, archived_at, created_at, updated_at + FROM pm_quick_actions + WHERE org_id = ?1 + ORDER BY created_at ASC, id ASC", + ) + .map_err(|err| format!("quick action export: {err}"))?; + let actions = statement + .query_map(params![org_id], decode_action) + .map_err(|err| format!("quick action export: {err}"))? + .collect::, _>>() + .map_err(|err| format!("quick action export: {err}"))?; + Ok(actions) +} + +/// Apply org-wide quick actions carried on a pulled entity snapshot. +/// `use_count` merges by MAX so popularity ordering survives both sides. +pub(crate) fn apply_wire_actions( + connection: &Connection, + org_id: &str, + payload: &serde_json::Value, +) -> Result<(), String> { + let Some(raw) = payload.get("quickActions") else { + return Ok(()); + }; + let actions: Vec = + serde_json::from_value(raw.clone()).map_err(|err| format!("quick action wire: {err}"))?; + for action in actions { + if action.org_id != org_id { + continue; + } + let local: Option<(String, i64)> = connection + .query_row( + "SELECT org_id, updated_at FROM pm_quick_actions WHERE id = ?1", + params![action.id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(|err| format!("quick action watermark: {err}"))?; + if local + .as_ref() + .is_some_and(|(stored_org, _)| stored_org != org_id) + { + continue; + } + if !matches!(action.target_kind.as_str(), "agent" | "agent_org") + || action.target_id.trim().is_empty() + || !target_exists(&action.target_kind, action.target_id.trim())? + { + continue; + } + let local_updated_at = local.map(|(_, updated_at)| updated_at); + if local_updated_at.is_some_and(|local| local >= action.updated_at) { + continue; + } + if crate::sync::collab_bridge::has_pending_collab_field_path( + connection, + org_id, + &format!("quickActions.{}", action.id), + "quick action pending-path probe", + )? { + continue; + } + connection + .execute( + "INSERT INTO pm_quick_actions ( + id, org_id, name, description, target_kind, target_id, + prompt, use_count, created_by, archived_at, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + target_kind = excluded.target_kind, + target_id = excluded.target_id, + prompt = excluded.prompt, + use_count = MAX(pm_quick_actions.use_count, excluded.use_count), + archived_at = excluded.archived_at, + updated_at = excluded.updated_at + WHERE pm_quick_actions.org_id = excluded.org_id + AND excluded.updated_at >= pm_quick_actions.updated_at", + params![ + action.id, + action.org_id, + action.name, + action.description, + action.target_kind, + action.target_id, + action.prompt, + action.use_count, + action.created_by, + action.archived_at, + action.created_at, + action.updated_at, + ], + ) + .map_err(|err| format!("quick action apply: {err}"))?; + } + Ok(()) +} 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 index 0029627cb3..3787efb63b 100644 --- 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 @@ -355,34 +355,20 @@ fn ingest_verified( .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); - }; + let target = default_scope + .as_deref() + .map(crate::routine_service::RoutineInvocationTarget::from_binding) + .transpose()? + .unwrap_or_else(|| crate::routine_service::RoutineInvocationTarget::standalone(None)); 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( + match crate::routine_service::invoke_target( routine_name, - &scope, + &target, &scalar_inputs(&payload), None, Some(&invoke_key), @@ -616,3 +602,52 @@ pub async fn handle_http( Err(error) => (StatusCode::BAD_REQUEST, error).into_response(), } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use test_helpers::test_env; + + fn projectless_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("frozen 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: None, + policies: Default::default(), + }); + file + } + + #[test] + fn projectless_delivery_invokes_an_org_scoped_root_work_graph() { + let _sandbox = test_env::sandbox(); + let fixture = projectless_fixture(); + crate::routine_service::apply(&fixture).expect("apply Routine"); + install(&fixture.metadata.name).expect("install webhook"); + + let delivery = ingest_verified( + &fixture.metadata.name, + "github", + "pull_request", + "projectless-delivery", + json!({"inputs": {"requirement_id": "REQ-WEBHOOK"}}), + None, + ) + .expect("ingest delivery"); + assert_eq!(delivery.status, "accepted"); + + let run_id = delivery.routine_run_id.expect("routine run"); + let status = crate::routine_service::run_status(&run_id).expect("run status"); + assert_eq!(status["scopeId"], "org:personal-org"); + let root_id = status["rootWorkItemId"].as_str().expect("root id"); + crate::projects::io::read_standalone_work_item(None, root_id) + .expect("standalone root readable"); + } +} diff --git a/src-tauri/crates/project-management/src/work_item_features/saved_views.rs b/src-tauri/crates/project-management/src/work_item_features/saved_views.rs new file mode 100644 index 0000000000..f796558558 --- /dev/null +++ b/src-tauri/crates/project-management/src/work_item_features/saved_views.rs @@ -0,0 +1,319 @@ +//! Org-shared saved views over the Work Items surface. +//! +//! A saved view's `query` (filters) is its shared identity; `display` +//! (view tab, grouping) only seeds the first open on another machine. +//! Views ride the same org-entity carrier as typed-property and status +//! definitions, so teammates receive them without a dedicated sync kind. +//! Deletion is archival so removals propagate through snapshots. + +use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior}; +use serde::{Deserialize, Serialize}; + +use crate::projects::io::helpers::{conn, now_ms}; + +const ORG_SCOPE_MISMATCH: &str = "PM_ERR:ORG_SCOPE_MISMATCH"; + +fn org_scope_mismatch(entity: &str, id: &str) -> String { + format!("{ORG_SCOPE_MISMATCH}:{entity}:{id}") +} + +fn project_belongs_to_org( + connection: &Connection, + org_id: &str, + project_slug: Option<&str>, +) -> Result { + let Some(project_slug) = project_slug else { + return Ok(true); + }; + connection + .query_row( + "SELECT 1 FROM projects WHERE slug = ?1 AND org_id = ?2 LIMIT 1", + params![project_slug, org_id], + |_| Ok(true), + ) + .optional() + .map(|found| found.unwrap_or(false)) + .map_err(|err| format!("saved view project ownership: {err}")) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SavedView { + pub id: String, + pub org_id: String, + pub project_slug: Option, + pub name: String, + pub query: serde_json::Value, + pub display: serde_json::Value, + pub position: i64, + pub created_by: Option, + pub archived_at: Option, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpsertSavedViewRequest { + pub id: Option, + pub org_id: String, + pub project_slug: Option, + pub name: String, + #[serde(default)] + pub query: serde_json::Value, + #[serde(default)] + pub display: serde_json::Value, + pub position: Option, + pub created_by: Option, +} + +pub(crate) fn upsert_view(request: UpsertSavedViewRequest) -> Result { + let name = request.name.trim().to_string(); + if name.is_empty() { + return Err("Saved view name is required".to_string()); + } + let mut connection = conn()?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|err| format!("saved view tx: {err}"))?; + let now = now_ms(); + let id = request + .id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("wiv_{}", uuid::Uuid::new_v4().simple())); + let existing_org: Option = tx + .query_row( + "SELECT org_id FROM pm_saved_views WHERE id = ?1", + params![id], + |row| row.get(0), + ) + .optional() + .map_err(|err| format!("saved view store: {err}"))?; + if existing_org + .as_deref() + .is_some_and(|stored_org| stored_org != request.org_id) + { + return Err(org_scope_mismatch("saved_view", &id)); + } + if !project_belongs_to_org(&tx, &request.org_id, request.project_slug.as_deref())? { + return Err(org_scope_mismatch( + "saved_view_project", + request.project_slug.as_deref().unwrap_or_default(), + )); + } + let query = + serde_json::to_string(&request.query).map_err(|err| format!("saved view query: {err}"))?; + let display = serde_json::to_string(&request.display) + .map_err(|err| format!("saved view display: {err}"))?; + tx.execute( + "INSERT INTO pm_saved_views ( + id, org_id, project_slug, name, query_json, display_json, + position, created_by, archived_at, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, ?9, ?9) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + query_json = excluded.query_json, + display_json = excluded.display_json, + position = excluded.position, + archived_at = NULL, + updated_at = excluded.updated_at + WHERE pm_saved_views.org_id = excluded.org_id", + params![ + id, + request.org_id, + request.project_slug, + name, + query, + display, + request.position.unwrap_or(0), + request.created_by, + now + ], + ) + .map_err(|err| format!("saved view store: {err}"))?; + crate::sync::collab_bridge::record_saved_views_touch(&tx, &request.org_id, &id)?; + let view = read_view(&tx, &request.org_id, &id)?; + tx.commit() + .map_err(|err| format!("saved view commit: {err}"))?; + Ok(view) +} + +pub(crate) fn archive_view(org_id: &str, id: &str) -> Result { + let connection = conn()?; + let now = now_ms(); + let changed = connection + .execute( + "UPDATE pm_saved_views + SET archived_at = COALESCE(archived_at, ?3), updated_at = ?3 + WHERE org_id = ?1 AND id = ?2", + params![org_id, id, now], + ) + .map_err(|err| format!("saved view store: {err}"))?; + if changed == 0 { + return Err(format!("Saved view '{id}' not found")); + } + crate::sync::collab_bridge::record_saved_views_touch(&connection, org_id, id)?; + read_view(&connection, org_id, id) +} + +pub(crate) fn list_views( + org_id: &str, + project_slug: Option<&str>, +) -> Result, String> { + let connection = conn()?; + let mut statement = connection + .prepare( + "SELECT id, org_id, project_slug, name, query_json, display_json, + position, created_by, archived_at, created_at, updated_at + FROM pm_saved_views + WHERE org_id = ?1 + AND archived_at IS NULL + AND (project_slug IS NULL OR project_slug = ?2) + ORDER BY position ASC, created_at ASC, id ASC", + ) + .map_err(|err| format!("saved view store: {err}"))?; + let views = statement + .query_map(params![org_id, project_slug], decode_view) + .map_err(|err| format!("saved view store: {err}"))? + .collect::, _>>() + .map_err(|err| format!("saved view store: {err}"))?; + Ok(views) +} + +fn read_view(connection: &Connection, org_id: &str, id: &str) -> Result { + connection + .query_row( + "SELECT id, org_id, project_slug, name, query_json, display_json, + position, created_by, archived_at, created_at, updated_at + FROM pm_saved_views + WHERE org_id = ?1 AND id = ?2", + params![org_id, id], + decode_view, + ) + .map_err(|err| format!("saved view store: {err}")) +} + +fn decode_view(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let query_raw: String = row.get(4)?; + let display_raw: String = row.get(5)?; + Ok(SavedView { + id: row.get(0)?, + org_id: row.get(1)?, + project_slug: row.get(2)?, + name: row.get(3)?, + query: serde_json::from_str(&query_raw).unwrap_or(serde_json::Value::Null), + display: serde_json::from_str(&display_raw).unwrap_or(serde_json::Value::Null), + position: row.get(6)?, + created_by: row.get(7)?, + archived_at: row.get(8)?, + created_at: row.get(9)?, + updated_at: row.get(10)?, + }) +} + +/// Every view (archived included) so removals propagate. +pub(crate) fn export_views( + connection: &Connection, + org_id: &str, +) -> Result, String> { + let mut statement = connection + .prepare( + "SELECT id, org_id, project_slug, name, query_json, display_json, + position, created_by, archived_at, created_at, updated_at + FROM pm_saved_views + WHERE org_id = ?1 + ORDER BY position ASC, created_at ASC, id ASC", + ) + .map_err(|err| format!("saved view export: {err}"))?; + let views = statement + .query_map(params![org_id], decode_view) + .map_err(|err| format!("saved view export: {err}"))? + .collect::, _>>() + .map_err(|err| format!("saved view export: {err}"))?; + Ok(views) +} + +/// Apply org-wide saved views carried on a pulled entity snapshot. +pub(crate) fn apply_wire_views( + connection: &Connection, + org_id: &str, + payload: &serde_json::Value, +) -> Result<(), String> { + let Some(raw) = payload.get("savedViews") else { + return Ok(()); + }; + let views: Vec = + serde_json::from_value(raw.clone()).map_err(|err| format!("saved view wire: {err}"))?; + for view in views { + if view.org_id != org_id { + continue; + } + let local: Option<(String, i64)> = connection + .query_row( + "SELECT org_id, updated_at FROM pm_saved_views WHERE id = ?1", + params![view.id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(|err| format!("saved view watermark: {err}"))?; + if local + .as_ref() + .is_some_and(|(stored_org, _)| stored_org != org_id) + { + continue; + } + if !project_belongs_to_org(connection, org_id, view.project_slug.as_deref())? { + continue; + } + let local_updated_at = local.map(|(_, updated_at)| updated_at); + if local_updated_at.is_some_and(|local| local >= view.updated_at) { + continue; + } + if crate::sync::collab_bridge::has_pending_collab_field_path( + connection, + org_id, + &format!("savedViews.{}", view.id), + "saved view pending-path probe", + )? { + continue; + } + let query = serde_json::to_string(&view.query) + .map_err(|err| format!("saved view wire query: {err}"))?; + let display = serde_json::to_string(&view.display) + .map_err(|err| format!("saved view wire display: {err}"))?; + connection + .execute( + "INSERT INTO pm_saved_views ( + id, org_id, project_slug, name, query_json, display_json, + position, created_by, archived_at, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + query_json = excluded.query_json, + display_json = excluded.display_json, + position = excluded.position, + archived_at = excluded.archived_at, + updated_at = excluded.updated_at + WHERE pm_saved_views.org_id = excluded.org_id + AND excluded.updated_at >= pm_saved_views.updated_at", + params![ + view.id, + view.org_id, + view.project_slug, + view.name, + query, + display, + view.position, + view.created_by, + view.archived_at, + view.created_at, + view.updated_at, + ], + ) + .map_err(|err| format!("saved view apply: {err}"))?; + } + Ok(()) +} diff --git a/src-tauri/crates/project-management/src/work_item_features/statuses.rs b/src-tauri/crates/project-management/src/work_item_features/statuses.rs new file mode 100644 index 0000000000..d2df6e0dda --- /dev/null +++ b/src-tauri/crates/project-management/src/work_item_features/statuses.rs @@ -0,0 +1,693 @@ +//! Org-scoped custom work-item statuses. +//! +//! A custom status is a named alias over one of the built-in status +//! buckets (its `category`). `workitems.status` keeps storing the raw +//! key — no migration — and every surface that interprets a status +//! resolves it through its definition first, so a custom status inherits its +//! category's behavior (filters, counts, kanban columns, terminal archival) +//! wholesale. Archived definitions remain authoritative for historical rows, +//! while creation/selection surfaces expose only active definitions. Built-in +//! statuses are implicit and never stored as rows; their keys are reserved. + +use std::collections::HashMap; + +use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior}; +use serde::{Deserialize, Serialize}; + +use crate::projects::io::helpers::{conn, now_ms}; + +const ORG_SCOPE_MISMATCH: &str = "PM_ERR:ORG_SCOPE_MISMATCH"; + +fn org_scope_mismatch(entity: &str, id: &str) -> String { + format!("{ORG_SCOPE_MISMATCH}:{entity}:{id}") +} + +/// The built-in buckets a custom status can map onto. +pub const STATUS_CATEGORIES: [&str; 7] = [ + "backlog", + "planned", + "in_progress", + "in_review", + "blocked", + "completed", + "cancelled", +]; + +/// Raw status vocabulary already interpreted by the app; reserved so a +/// custom key can never shadow a built-in. +const RESERVED_STATUS_KEYS: [&str; 13] = [ + "backlog", + "planned", + "todo", + "open", + "in_progress", + "in_review", + "blocked", + "completed", + "done", + "closed", + "cancelled", + "canceled", + "duplicate", +]; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StatusDefinition { + pub id: String, + pub org_id: String, + pub key: String, + pub name: String, + pub category: String, + pub color: Option, + pub description: Option, + pub position: i64, + pub archived_at: Option, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpsertStatusDefinitionRequest { + pub id: Option, + pub org_id: String, + pub key: Option, + pub name: String, + pub category: Option, + pub color: Option, + pub description: Option, + pub position: Option, +} + +fn valid_key(key: &str) -> bool { + !key.is_empty() + && key.len() <= 32 + && key + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') + && key + .chars() + .next() + .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) +} + +pub(crate) fn upsert_definition( + request: UpsertStatusDefinitionRequest, +) -> Result { + let name = request.name.trim().to_string(); + if name.is_empty() { + return Err("Status name is required".to_string()); + } + let mut connection = conn()?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|err| format!("status definition tx: {err}"))?; + let now = now_ms(); + + if let Some(id) = request.id.as_deref().filter(|id| !id.trim().is_empty()) { + let existing_org: Option = tx + .query_row( + "SELECT org_id FROM pm_status_definitions WHERE id = ?1", + params![id], + |row| row.get(0), + ) + .optional() + .map_err(|err| format!("status definition store: {err}"))?; + if existing_org + .as_deref() + .is_some_and(|stored_org| stored_org != request.org_id) + { + return Err(org_scope_mismatch("status_definition", id)); + } + let existing = read_definition(&tx, &request.org_id, id)?; + if let Some(key) = request.key.as_deref() { + if key != existing.key { + return Err("PM_ERR:STATUS_KEY_IMMUTABLE".to_string()); + } + } + if let Some(category) = request.category.as_deref() { + if category != existing.category { + return Err("PM_ERR:STATUS_CATEGORY_IMMUTABLE".to_string()); + } + } + tx.execute( + "UPDATE pm_status_definitions + SET name = ?3, color = ?4, description = ?5, + position = ?6, updated_at = ?7 + WHERE org_id = ?1 AND id = ?2", + params![ + request.org_id, + id, + name, + request.color, + request.description, + request.position.unwrap_or(existing.position), + now + ], + ) + .map_err(|err| format!("status definition store: {err}"))?; + crate::sync::collab_bridge::record_status_definitions_touch(&tx, &request.org_id, id)?; + let definition = read_definition(&tx, &request.org_id, id)?; + tx.commit() + .map_err(|err| format!("status definition commit: {err}"))?; + return Ok(definition); + } + + let key = request + .key + .as_deref() + .map(str::trim) + .filter(|key| !key.is_empty()) + .ok_or_else(|| "Status key is required".to_string())? + .to_string(); + if !valid_key(&key) { + return Err("PM_ERR:STATUS_KEY_INVALID".to_string()); + } + if RESERVED_STATUS_KEYS.contains(&key.as_str()) { + return Err("PM_ERR:STATUS_KEY_RESERVED".to_string()); + } + let category = request + .category + .as_deref() + .ok_or_else(|| "Status category is required".to_string())? + .to_string(); + if !STATUS_CATEGORIES.contains(&category.as_str()) { + return Err("PM_ERR:STATUS_CATEGORY_INVALID".to_string()); + } + let id = format!("wis_{}", uuid::Uuid::new_v4().simple()); + tx.execute( + "INSERT INTO pm_status_definitions ( + id, org_id, key, name, category, color, description, + position, archived_at, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, ?9, ?9)", + params![ + id, + request.org_id, + key, + name, + category, + request.color, + request.description, + request.position.unwrap_or(0), + now + ], + ) + .map_err(|err| { + if err.to_string().contains("UNIQUE") { + format!("PM_ERR:ALREADY_EXISTS:{key}") + } else { + format!("status definition store: {err}") + } + })?; + crate::sync::collab_bridge::record_status_definitions_touch(&tx, &request.org_id, &id)?; + let definition = read_definition(&tx, &request.org_id, &id)?; + tx.commit() + .map_err(|err| format!("status definition commit: {err}"))?; + Ok(definition) +} + +pub(crate) fn set_definition_archived( + org_id: &str, + id: &str, + archived: bool, +) -> Result { + let connection = conn()?; + let now = now_ms(); + let changed = connection + .execute( + "UPDATE pm_status_definitions + SET archived_at = CASE WHEN ?3 THEN COALESCE(archived_at, ?4) ELSE NULL END, + updated_at = ?4 + WHERE org_id = ?1 AND id = ?2", + params![org_id, id, archived, now], + ) + .map_err(|err| format!("status definition store: {err}"))?; + if changed == 0 { + return Err(format!("Status definition '{id}' not found")); + } + crate::sync::collab_bridge::record_status_definitions_touch(&connection, org_id, id)?; + read_definition(&connection, org_id, id) +} + +pub(crate) fn list_definitions( + org_id: &str, + include_archived: bool, +) -> Result, String> { + let connection = conn()?; + list_definitions_in(&connection, org_id, include_archived) +} + +pub(crate) fn list_definitions_in( + connection: &Connection, + org_id: &str, + include_archived: bool, +) -> Result, String> { + let archived_predicate = if include_archived { + "" + } else { + "AND archived_at IS NULL" + }; + let sql = format!( + "SELECT id, org_id, key, name, category, color, description, + position, archived_at, created_at, updated_at + FROM pm_status_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!("status definition store: {err}"))?; + let definitions = statement + .query_map(params![org_id], decode_definition) + .map_err(|err| format!("status definition store: {err}"))? + .collect::, _>>() + .map_err(|err| format!("status definition store: {err}"))?; + Ok(definitions) +} + +fn read_definition( + connection: &Connection, + org_id: &str, + id: &str, +) -> Result { + connection + .query_row( + "SELECT id, org_id, key, name, category, color, description, + position, archived_at, created_at, updated_at + FROM pm_status_definitions + WHERE org_id = ?1 AND id = ?2", + params![org_id, id], + decode_definition, + ) + .map_err(|err| format!("status definition store: {err}")) +} + +fn decode_definition(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(StatusDefinition { + id: row.get(0)?, + org_id: row.get(1)?, + key: row.get(2)?, + name: row.get(3)?, + category: row.get(4)?, + color: row.get(5)?, + description: row.get(6)?, + position: row.get(7)?, + archived_at: row.get(8)?, + created_at: row.get(9)?, + updated_at: row.get(10)?, + }) +} + +/// Custom-status key → category, for one org. +/// +/// Archived definitions deliberately remain in this resolver. Existing work +/// items keep their raw status key after a definition is retired, so dropping +/// archived rows here would silently change their filtering, board column, and +/// terminal behavior. Selection surfaces filter archived definitions instead. +pub(crate) fn category_map_in(connection: &Connection, org_id: &str) -> HashMap { + let Ok(mut statement) = connection.prepare( + "SELECT key, category FROM pm_status_definitions + WHERE org_id = ?1", + ) else { + return HashMap::new(); + }; + statement + .query_map(params![org_id], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map(|rows| rows.filter_map(Result::ok).collect()) + .unwrap_or_default() +} + +/// Resolve a raw stored status to the bucket the app should interpret: +/// custom keys fold into their category, everything else passes through. +pub(crate) fn effective_status_in(connection: &Connection, org_id: &str, raw: &str) -> String { + connection + .query_row( + "SELECT category FROM pm_status_definitions + WHERE org_id = ?1 AND key = ?2", + params![org_id, raw], + |row| row.get::<_, String>(0), + ) + .unwrap_or_else(|_| raw.to_string()) +} + +/// Reject a newly assigned archived custom status while preserving existing +/// historical rows that already carry the same raw key. Built-in and unknown +/// legacy values keep their existing compatibility behavior. +pub(crate) fn ensure_status_assignable_in( + connection: &Connection, + org_id: &str, + raw: &str, + previous_raw: Option<&str>, +) -> Result<(), String> { + if previous_raw == Some(raw) { + return Ok(()); + } + let archived_at = connection + .query_row( + "SELECT archived_at FROM pm_status_definitions + WHERE org_id = ?1 AND key = ?2", + params![org_id, raw], + |row| row.get::<_, Option>(0), + ) + .optional() + .map_err(|err| format!("status definition store: {err}"))?; + if archived_at.flatten().is_some() { + return Err(format!("PM_ERR:STATUS_ARCHIVED:{raw}")); + } + Ok(()) +} + +/// Every definition (archived included) so remote archives propagate. +pub(crate) fn export_definitions( + connection: &Connection, + org_id: &str, +) -> Result, String> { + list_definitions_in(connection, org_id, true) +} + +/// Apply org-wide status definitions carried on a pulled entity snapshot. +/// Last-writer-wins per definition on `updated_at`; a definition with a +/// pending local push is left alone so the local edit is not clobbered. +pub(crate) fn apply_wire_definitions( + connection: &Connection, + org_id: &str, + payload: &serde_json::Value, +) -> Result<(), String> { + let Some(raw) = payload.get("statusDefinitions") else { + return Ok(()); + }; + let definitions: Vec = serde_json::from_value(raw.clone()) + .map_err(|err| format!("status wire definitions: {err}"))?; + for definition in definitions { + if definition.org_id != org_id { + continue; + } + let local: Option<(String, i64)> = connection + .query_row( + "SELECT org_id, updated_at FROM pm_status_definitions WHERE id = ?1", + params![definition.id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(|err| format!("status definition watermark: {err}"))?; + if local + .as_ref() + .is_some_and(|(stored_org, _)| stored_org != org_id) + { + continue; + } + let local_updated_at = local.map(|(_, updated_at)| updated_at); + if local_updated_at.is_some_and(|local| local >= definition.updated_at) { + continue; + } + if crate::sync::collab_bridge::has_pending_collab_field_path( + connection, + org_id, + &format!("statusDefinitions.{}", definition.id), + "status definition pending-path probe", + )? { + continue; + } + connection + .execute( + "INSERT INTO pm_status_definitions ( + id, org_id, key, name, category, color, description, + position, archived_at, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + color = excluded.color, + description = excluded.description, + position = excluded.position, + archived_at = excluded.archived_at, + updated_at = excluded.updated_at + WHERE pm_status_definitions.org_id = excluded.org_id + AND excluded.updated_at >= pm_status_definitions.updated_at", + params![ + definition.id, + definition.org_id, + definition.key, + definition.name, + definition.category, + definition.color, + definition.description, + definition.position, + definition.archived_at, + definition.created_at, + definition.updated_at, + ], + ) + .map_err(|err| format!("status definition apply: {err}"))?; + } + Ok(()) +} + +/// Category map for a project view read: the explicit org when given, +/// otherwise the project row's org. Failures degrade to "no custom +/// statuses" rather than failing the view. +pub(crate) fn category_map_for_project( + project_slug: &str, + org_id: Option<&str>, +) -> HashMap { + let Ok(connection) = conn() else { + return HashMap::new(); + }; + let resolved_org = org_id.map(str::to_string).or_else(|| { + connection + .query_row( + "SELECT org_id FROM projects WHERE slug = ?1", + params![project_slug], + |row| row.get::<_, String>(0), + ) + .ok() + }); + match resolved_org { + Some(org) => category_map_in(&connection, &org), + None => HashMap::new(), + } +} + +pub const STATUS_CATALOG_BRIEF_CAP: usize = 30; + +/// Active custom definition for `key` in `org_id`, if one exists. +pub fn find_active_status_definition( + org_id: Option<&str>, + key: &str, +) -> Result, String> { + let connection = conn()?; + find_active_status_definition_in( + &connection, + org_id.unwrap_or(crate::projects::types::PERSONAL_ORG_ID), + key, + ) +} + +pub(crate) fn find_active_status_definition_in( + connection: &Connection, + org_id: &str, + key: &str, +) -> Result, String> { + connection + .query_row( + "SELECT id, org_id, key, name, category, color, description, + position, archived_at, created_at, updated_at + FROM pm_status_definitions + WHERE org_id = ?1 AND key = ?2 AND archived_at IS NULL", + params![org_id, key], + decode_definition, + ) + .optional() + .map_err(|err| format!("status definition store: {err}")) +} + +/// Agent-facing catalog of the org's active custom statuses, grouped by +/// category in canonical order. `None` when the org defines none so briefs +/// that embed it stay byte-identical for orgs on built-in statuses only. +pub fn render_status_catalog(org_id: Option<&str>) -> Option { + let connection = conn().ok()?; + render_status_catalog_in( + &connection, + org_id.unwrap_or(crate::projects::types::PERSONAL_ORG_ID), + ) +} + +pub(crate) fn render_status_catalog_in(connection: &Connection, org_id: &str) -> Option { + let definitions = list_definitions_in(connection, org_id, false).ok()?; + if definitions.is_empty() { + return None; + } + let total = definitions.len(); + let mut lines = vec![ + "Custom statuses defined by this organization (pass the key to `work transition --to `; each behaves as its category):".to_string(), + ]; + let mut shown = 0usize; + for category in STATUS_CATEGORIES { + let entries = definitions + .iter() + .filter(|definition| definition.category == category) + .take(STATUS_CATALOG_BRIEF_CAP.saturating_sub(shown)) + .map(|definition| format!("`{}` ({})", definition.key, definition.name)) + .collect::>(); + if entries.is_empty() { + continue; + } + shown += entries.len(); + lines.push(format!("- {category}: {}", entries.join(", "))); + if shown >= STATUS_CATALOG_BRIEF_CAP { + break; + } + } + if total > shown { + lines.push(format!("- … and {} more", total - shown)); + } + Some(lines.join("\n")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn status_connection() -> Connection { + let connection = Connection::open_in_memory().expect("in-memory status db"); + connection + .execute_batch( + "CREATE TABLE pm_status_definitions ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL, + key TEXT NOT NULL, + name TEXT NOT NULL, + category TEXT NOT NULL, + color TEXT, + description TEXT, + position INTEGER NOT NULL, + archived_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + );", + ) + .expect("status schema"); + connection + } + + fn insert_definition( + connection: &Connection, + key: &str, + name: &str, + category: &str, + position: i64, + archived_at: Option, + ) { + connection + .execute( + "INSERT INTO pm_status_definitions ( + id, org_id, key, name, category, position, + archived_at, created_at, updated_at + ) VALUES (?1, 'org-1', ?2, ?3, ?4, ?5, ?6, 1, 1)", + params![ + format!("wis_{key}"), + key, + name, + category, + position, + archived_at + ], + ) + .expect("insert definition"); + } + + #[test] + fn status_catalog_is_absent_without_custom_definitions() { + let connection = status_connection(); + assert_eq!(render_status_catalog_in(&connection, "org-1"), None); + insert_definition(&connection, "old", "Old", "completed", 0, Some(5)); + assert_eq!(render_status_catalog_in(&connection, "org-1"), None); + } + + #[test] + fn status_catalog_groups_active_keys_by_category_in_canonical_order() { + let connection = status_connection(); + insert_definition(&connection, "shipped", "Shipped", "completed", 0, None); + insert_definition(&connection, "qa", "QA", "in_progress", 1, None); + insert_definition(&connection, "staging", "Staging", "in_progress", 2, None); + insert_definition(&connection, "retired", "Retired", "cancelled", 3, Some(9)); + + let catalog = render_status_catalog_in(&connection, "org-1").expect("catalog"); + let lines = catalog.lines().collect::>(); + assert!(lines[0].contains("work transition --to ")); + assert_eq!(lines[1], "- in_progress: `qa` (QA), `staging` (Staging)"); + assert_eq!(lines[2], "- completed: `shipped` (Shipped)"); + assert_eq!(lines.len(), 3); + assert!(!catalog.contains("retired")); + assert_eq!(render_status_catalog_in(&connection, "org-2"), None); + } + + #[test] + fn status_catalog_caps_the_listing_and_counts_the_rest() { + let connection = status_connection(); + for index in 0..(STATUS_CATALOG_BRIEF_CAP + 4) { + insert_definition( + &connection, + &format!("k{index}"), + &format!("K{index}"), + "planned", + index as i64, + None, + ); + } + let catalog = render_status_catalog_in(&connection, "org-1").expect("catalog"); + assert_eq!(catalog.matches("`k").count(), STATUS_CATALOG_BRIEF_CAP); + assert!(catalog.ends_with("- … and 4 more")); + } + + #[test] + fn active_definition_lookup_ignores_archived_rows() { + let connection = status_connection(); + insert_definition(&connection, "qa", "QA", "in_progress", 0, None); + insert_definition(&connection, "old", "Old", "completed", 1, Some(5)); + let qa = find_active_status_definition_in(&connection, "org-1", "qa") + .expect("lookup") + .expect("active"); + assert_eq!(qa.category, "in_progress"); + assert!( + find_active_status_definition_in(&connection, "org-1", "old") + .expect("lookup") + .is_none() + ); + assert!(find_active_status_definition_in(&connection, "org-2", "qa") + .expect("lookup") + .is_none()); + } + + #[test] + fn blocked_is_a_reserved_canonical_category() { + assert!(STATUS_CATEGORIES.contains(&"blocked")); + assert!(RESERVED_STATUS_KEYS.contains(&"blocked")); + } + + #[test] + fn archived_definitions_still_resolve_historical_statuses() { + let connection = status_connection(); + connection + .execute( + "INSERT INTO pm_status_definitions ( + id, org_id, key, name, category, position, + archived_at, created_at, updated_at + ) VALUES ('wis_waiting', 'org-1', 'waiting', 'Waiting', + 'blocked', 0, 42, 1, 42)", + [], + ) + .expect("archived definition"); + + assert_eq!( + effective_status_in(&connection, "org-1", "waiting"), + "blocked" + ); + assert_eq!( + category_map_in(&connection, "org-1").get("waiting"), + Some(&"blocked".to_string()) + ); + } +} 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 index 0de47021fd..25bea6439b 100644 --- a/src-tauri/crates/project-management/src/work_item_features/subscriptions.rs +++ b/src-tauri/crates/project-management/src/work_item_features/subscriptions.rs @@ -1,6 +1,6 @@ use std::collections::BTreeSet; -use rusqlite::{params, Transaction, TransactionBehavior}; +use rusqlite::{params, OptionalExtension, Transaction, TransactionBehavior}; use super::store::{iso8601, resolve_work_item}; use super::{SubscriptionMutation, SubscriptionReason, WorkItemScope, WorkItemSubscription}; @@ -79,8 +79,9 @@ fn bootstrap_implicit_subscriptions( now, )?; } + let effective_status = super::statuses::effective_status_in(tx, &item.org_id, &item.status); if matches!( - item.status.trim().to_ascii_lowercase().as_str(), + effective_status.trim().to_ascii_lowercase().as_str(), "completed" | "closed" | "cancelled" | "canceled" | "duplicate" ) { tx.execute( @@ -239,6 +240,21 @@ pub(super) struct CommentNotification<'a> { pub(super) now: i64, } +fn recipient_muted_kind( + tx: &Transaction<'_>, + recipient_id: &str, + kind: &str, +) -> Result { + tx.query_row( + "SELECT 1 FROM pm_inbox_prefs WHERE recipient_id = ?1 AND kind = ?2", + params![recipient_id, kind], + |_| Ok(true), + ) + .optional() + .map(|found| found.unwrap_or(false)) + .map_err(|err| format!("inbox prefs: {err}")) +} + fn upsert_inbox_event(tx: &Transaction<'_>, event: InboxEvent<'_>) -> Result<(), String> { let InboxEvent { scope_key, @@ -250,6 +266,9 @@ fn upsert_inbox_event(tx: &Transaction<'_>, event: InboxEvent<'_>) -> Result<(), coalesce_key, now, } = event; + if recipient_muted_kind(tx, recipient_id, kind)? { + return Ok(()); + } let raw = serde_json::to_string(payload) .map_err(|err| format!("inbox event payload serialization: {err}"))?; tx.execute( @@ -259,6 +278,8 @@ fn upsert_inbox_event(tx: &Transaction<'_>, event: InboxEvent<'_>) -> Result<(), ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, NULL) ON CONFLICT(recipient_id, coalesce_key) DO UPDATE SET id = excluded.id, + scope_key = excluded.scope_key, + work_item_id = excluded.work_item_id, kind = excluded.kind, actor_id = excluded.actor_id, payload_json = excluded.payload_json, @@ -323,7 +344,7 @@ pub(super) fn notify_comment( "comment": content, "mentioned": true, }); - let coalesce_key = format!("mention:{comment_id}:{recipient}"); + let coalesce_key = format!("mention:{scope_key}:{work_item_id}:{comment_id}"); upsert_inbox_event( tx, InboxEvent { @@ -435,3 +456,204 @@ pub(crate) fn notify_run_terminal(run: &WorkItemRun) -> Result<(), String> { .map_err(|err| format!("work item inbox commit: {err}"))?; Ok(()) } + +pub(crate) struct FieldChangeNotification<'a> { + pub scope_key: &'a str, + pub work_item_id: &'a str, + pub title: &'a str, + pub actor_id: Option<&'a str>, + pub status_change: Option<(&'a str, &'a str)>, + pub assignee_change: Option<(Option<&'a str>, Option<&'a str>)>, + pub priority_change: Option<(&'a str, &'a str)>, + pub dates_changed: bool, + pub now: i64, +} + +fn unmuted_subscribers( + tx: &Transaction<'_>, + scope_key: &str, + work_item_id: &str, +) -> Result, String> { + 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}"))?; + Ok(subscribers) +} + +/// Inbox events for status / assignee / priority / date edits, written in +/// the same transaction as the mutation. The generic coalesce key keeps +/// one live row per item per recipient, so one edit emits ONE event whose +/// kind names its most significant change and whose payload carries all of +/// them — writing one event per field would just overwrite itself down to +/// the last field. The actor never notifies themselves. +pub(crate) fn notify_field_changes( + tx: &Transaction<'_>, + notification: FieldChangeNotification<'_>, +) -> Result<(), String> { + let mut changes = serde_json::Map::new(); + if let Some((from, to)) = notification.status_change { + changes.insert( + "status".to_string(), + serde_json::json!({ "from": from, "to": to }), + ); + } + if let Some((from, to)) = notification.assignee_change { + changes.insert( + "assignee".to_string(), + serde_json::json!({ "from": from, "to": to }), + ); + } + if let Some((from, to)) = notification.priority_change { + changes.insert( + "priority".to_string(), + serde_json::json!({ "from": from, "to": to }), + ); + } + if notification.dates_changed { + changes.insert("dates".to_string(), serde_json::Value::Bool(true)); + } + if changes.is_empty() { + return Ok(()); + } + let kind = if notification.status_change.is_some() { + "status_changed" + } else if notification.assignee_change.is_some() { + "assignee_changed" + } else if notification.priority_change.is_some() { + "priority_changed" + } else { + "dates_changed" + }; + let payload = serde_json::json!({ + "title": notification.title, + "changes": serde_json::Value::Object(changes), + }); + let subscribers = unmuted_subscribers(tx, notification.scope_key, notification.work_item_id)?; + let coalesce_key = format!( + "work-item:{}:{}", + notification.scope_key, notification.work_item_id + ); + for recipient in subscribers { + if Some(recipient.as_str()) == notification.actor_id { + continue; + } + upsert_inbox_event( + tx, + InboxEvent { + scope_key: notification.scope_key, + work_item_id: notification.work_item_id, + recipient_id: &recipient, + kind, + actor_id: notification.actor_id, + payload: &payload, + coalesce_key: &coalesce_key, + now: notification.now, + }, + )?; + } + Ok(()) +} + +pub(crate) struct ChildTerminalNotification<'a> { + pub scope_key: &'a str, + pub parent_short_id: &'a str, + pub child_short_id: &'a str, + pub child_title: &'a str, + pub status: &'a str, + pub actor_id: Option<&'a str>, + pub now: i64, +} + +/// A child reaching a terminal status notifies the parent's subscribers. +/// Keyed per child so two finishing children never coalesce away. +pub(crate) fn notify_child_terminal( + tx: &Transaction<'_>, + notification: ChildTerminalNotification<'_>, +) -> Result<(), String> { + let ChildTerminalNotification { + scope_key, + parent_short_id, + child_short_id, + child_title, + status, + actor_id, + now, + } = notification; + let subscribers = unmuted_subscribers(tx, scope_key, parent_short_id)?; + let payload = serde_json::json!({ + "title": child_title, + "childShortId": child_short_id, + "status": status, + }); + for recipient in subscribers { + if Some(recipient.as_str()) == actor_id { + continue; + } + let coalesce_key = format!("child:{scope_key}:{parent_short_id}:{child_short_id}"); + upsert_inbox_event( + tx, + InboxEvent { + scope_key, + work_item_id: parent_short_id, + recipient_id: &recipient, + kind: "child_completed", + actor_id, + payload: &payload, + coalesce_key: &coalesce_key, + now, + }, + )?; + } + Ok(()) +} + +/// Per-recipient inbox category mutes. `kind` matches the event kinds +/// written above plus `mention` / `discussion_updated` / `run_failed`. +pub(crate) fn list_muted_kinds(recipient_id: &str) -> Result, String> { + let connection = conn()?; + let mut statement = connection + .prepare("SELECT kind FROM pm_inbox_prefs WHERE recipient_id = ?1 ORDER BY kind ASC") + .map_err(|err| format!("inbox prefs: {err}"))?; + let kinds = statement + .query_map(params![recipient_id], |row| row.get::<_, String>(0)) + .map_err(|err| format!("inbox prefs: {err}"))? + .collect::, _>>() + .map_err(|err| format!("inbox prefs: {err}"))?; + Ok(kinds) +} + +pub(crate) fn set_kind_muted( + recipient_id: &str, + kind: &str, + muted: bool, +) -> Result, String> { + let connection = conn()?; + if muted { + connection + .execute( + "INSERT INTO pm_inbox_prefs (recipient_id, kind, muted_at) + VALUES (?1, ?2, ?3) + ON CONFLICT(recipient_id, kind) DO UPDATE SET muted_at = excluded.muted_at", + params![recipient_id, kind, now_ms()], + ) + .map_err(|err| format!("inbox prefs: {err}"))?; + } else { + connection + .execute( + "DELETE FROM pm_inbox_prefs WHERE recipient_id = ?1 AND kind = ?2", + params![recipient_id, kind], + ) + .map_err(|err| format!("inbox prefs: {err}"))?; + } + list_muted_kinds(recipient_id) +} 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 index 69f437acae..4f2748ca2f 100644 --- a/src-tauri/crates/project-management/src/work_item_features/tests.rs +++ b/src-tauri/crates/project-management/src/work_item_features/tests.rs @@ -8,8 +8,8 @@ use super::*; use crate::projects::io::helpers::conn; use crate::projects::types::{ AgentRole, LinkedSession, LinkedSessionStatus, LinkedSessionType, MentionTarget, - OrchestratorConfig, WorkItemCloseOut, WorkItemCloseOutStatus, WorkItemWorkProduct, - WorkItemWorkProductStatus, WorkItemWorkProductType, + OrchestratorConfig, WorkItemCloseOut, WorkItemCloseOutStatus, WorkItemMutationActor, + WorkItemWorkProduct, WorkItemWorkProductStatus, WorkItemWorkProductType, }; use crate::routine_service::spec::{Activation, ActivationPolicies, RoutineSpecFile}; use crate::work_service::{self, CreateWorkItemRequest}; @@ -22,6 +22,13 @@ fn scope() -> WorkItemScope { } } +fn agent_actor(agent_definition_id: &str) -> WorkItemMutationActor { + WorkItemMutationActor { + id: format!("agent:{agent_definition_id}"), + name: agent_definition_id.to_string(), + } +} + fn seed(linked_session: bool) { work_service::tests_support::seed_project("demo", "project-1"); work_service::create_project_work_item( @@ -79,6 +86,43 @@ fn post_with_mentions( .expect("post Discussion comment") } +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct AudienceContractCase { + name: String, + surface: String, + targets: Vec, + expected: AudienceContractExpectation, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct AudienceContractExpectation { + agent_mode: String, +} + +#[test] +fn work_item_execution_matches_the_shared_audience_contract() { + let contract: Vec = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../src/features/TeamCollaboration/messageAudienceRouting.contract.json" + ))) + .expect("parse shared audience contract"); + + for case in contract + .into_iter() + .filter(|case| case.surface == "work_item_comment") + { + let actual_mode = match discussion::mention_audience(&case.targets) { + discussion::MentionAudience::Unaddressed => "assigned", + discussion::MentionAudience::Humans => "none", + discussion::MentionAudience::Agent { .. } + | discussion::MentionAudience::AgentOrg { .. } => "explicit", + }; + assert_eq!(actual_mode, case.expected.agent_mode, "{}", case.name); + } +} + #[test] fn discussion_comment_and_run_are_atomic_and_threads_reopen_on_reply() { let _sandbox = test_env::sandbox(); @@ -98,6 +142,7 @@ fn discussion_comment_and_run_are_atomic_and_threads_reopen_on_reply() { Some("comment-root"), None, Some("session-1"), + None, ) .expect("append agent receipt in the same thread"); @@ -330,18 +375,471 @@ fn discussion_routing_resumes_the_configured_agent_on_mention() { assert!(mentioned.run.is_some()); } +#[test] +fn discussion_starts_fresh_after_the_target_session_exhausts_context() { + let _sandbox = test_env::sandbox(); + seed_with_config(true, "builtin:sde"); + + let failed = + crate::work_run_service::enqueue(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::ResumeSession { + session_id: "session-1".to_string(), + }, + ), + input: json!({ "content": "oversized turn" }), + idempotency_key: "context-overflow-fixture".to_string(), + max_attempts: 1, + parent_run_id: None, + }) + .expect("enqueue context overflow fixture"); + let lease = crate::work_run_service::claim_next_dispatch("test-worker", 30_000) + .expect("claim context overflow fixture") + .expect("dispatch fixture"); + crate::work_run_service::acknowledge_dispatch_started( + &lease.dispatch_id, + &lease.lease_token, + "session-1", + ) + .expect("acknowledge context overflow fixture"); + crate::work_run_service::record_run_terminal( + &failed.id, + Some("session-1"), + crate::work_run_service::WorkItemRunTerminalOutcome::Failed, + crate::projects::types::WorkItemRunUsage::default(), + Some(r#"{\"terminal_reason\":\"prompt_too_long\"}"#), + ) + .expect("record context overflow"); + + let mentioned = post_with_mentions( + "comment-after-context-overflow", + "Please continue with a clean context.", + None, + vec![MentionTarget::Agent { + id: "builtin:sde".to_string(), + }], + ); + assert_eq!( + mentioned.wake_reason, + "mention_fresh_after_context_overflow" + ); + assert!(mentioned.comment.agent_session_id.is_none()); + let run = mentioned.run.expect("fresh start run"); + assert!(matches!( + run.target_snapshot.target, + crate::projects::types::WorkItemRunTarget::StartWorkItem { .. } + )); + assert_eq!( + run.target_snapshot.agent_definition_id.as_deref(), + Some("builtin:sde") + ); +} + +#[test] +fn context_exhaustion_preserves_deferred_assignee_delay_and_cancellation() { + let _sandbox = test_env::sandbox(); + seed_with_config(true, "builtin:sde"); + + let failed = + crate::work_run_service::enqueue(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::ResumeSession { + session_id: "session-1".to_string(), + }, + ), + input: json!({ "content": "oversized turn" }), + idempotency_key: "context-overflow-assignee-fixture".to_string(), + max_attempts: 1, + parent_run_id: None, + }) + .expect("enqueue context overflow fixture"); + let lease = crate::work_run_service::claim_next_dispatch("test-worker", 30_000) + .expect("claim context overflow fixture") + .expect("dispatch fixture"); + crate::work_run_service::acknowledge_dispatch_started( + &lease.dispatch_id, + &lease.lease_token, + "session-1", + ) + .expect("acknowledge context overflow fixture"); + crate::work_run_service::record_run_terminal( + &failed.id, + Some("session-1"), + crate::work_run_service::WorkItemRunTerminalOutcome::Failed, + crate::projects::types::WorkItemRunUsage::default(), + Some(r#"{\"terminal_reason\":\"prompt_too_long\"}"#), + ) + .expect("record context overflow"); + + let posted = post( + "comment-assignee-after-context-overflow", + "Please pick this up with a clean context.", + None, + ); + assert_eq!(posted.wake_reason, "assignee_deferred"); + assert!(posted.comment.agent_session_id.is_none()); + let deferred_run = posted.run.expect("fresh deferred run"); + assert!(matches!( + deferred_run.target_snapshot.target, + crate::projects::types::WorkItemRunTarget::StartWorkItem { .. } + )); + + let connection = conn().expect("connection"); + let (available_at, created_at): (i64, i64) = connection + .query_row( + "SELECT available_at, created_at FROM pm_dispatch_outbox WHERE run_id = ?1", + rusqlite::params![&deferred_run.id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .expect("deferred outbox row"); + assert!( + available_at - created_at >= 300_000, + "fresh assignee fallback must retain the five-minute delay" + ); + drop(connection); + + work_service::note_project_work_item_threaded( + "demo", + "AAA-0001", + "comment", + "I picked this up.", + Some(&posted.comment.id), + Some(&agent_actor("builtin:sde")), + Some("session-1"), + None, + ) + .expect("agent reply"); + + let connection = conn().expect("connection"); + let (run_status, outbox_status): (String, String) = connection + .query_row( + "SELECT r.status, d.status + FROM pm_work_item_runs r + JOIN pm_dispatch_outbox d ON d.run_id = r.id + WHERE r.id = ?1", + rusqlite::params![deferred_run.id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .expect("cancelled escalation"); + assert_eq!(run_status, "cancelled"); + assert_eq!(outbox_status, "cancelled"); +} + +#[test] +fn member_only_mention_stays_silent_instead_of_waking_the_assignee() { + let _sandbox = test_env::sandbox(); + seed_with_config(false, "builtin:sde"); + + let addressed = post_with_mentions( + "comment-member-only", + "<@member-2> can you take a look?", + None, + vec![MentionTarget::Member { + id: "member-2".to_string(), + }], + ); + assert_eq!(addressed.wake_reason, "member_addressed"); + assert!( + addressed.run.is_none(), + "an explicit @person comment must not start the assigned agent" + ); + + let default_comment = post("comment-default", "Kick this off please.", None); + assert_eq!(default_comment.wake_reason, "assignee_deferred"); + assert!(default_comment.run.is_some()); +} + #[test] fn discussion_routing_starts_the_assigned_agent_without_sessions() { let _sandbox = test_env::sandbox(); seed_with_config(false, "builtin:sde"); let root = post("comment-root", "Kick this off please.", None); - assert_eq!(root.wake_reason, "assignee_start"); + assert_eq!(root.wake_reason, "assignee_deferred"); assert!( root.run.is_some(), "assigned agent must be started through a Run" ); assert!(root.comment.agent_session_id.is_none()); + + let connection = conn().expect("connection"); + let (available_at, created_at, outbox_status): (i64, i64, String) = connection + .query_row( + "SELECT available_at, created_at, status FROM pm_dispatch_outbox WHERE run_id = ?1", + rusqlite::params![root.run.as_ref().expect("deferred run").id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .expect("deferred outbox row"); + assert_eq!(outbox_status, "pending"); + assert!( + available_at - created_at >= 300_000, + "assignee fallback must wait at least five minutes" + ); + + work_service::note_project_work_item_threaded( + "demo", + "AAA-0001", + "comment", + "Another agent is only leaving context.", + Some(&root.comment.id), + Some(&agent_actor("other-agent")), + Some("other-agent-session"), + None, + ) + .expect("unrelated agent note"); + let (run_status, outbox_status): (String, String) = connection + .query_row( + "SELECT r.status, d.status + FROM pm_work_item_runs r + JOIN pm_dispatch_outbox d ON d.run_id = r.id + WHERE r.id = ?1", + rusqlite::params![root.run.as_ref().expect("deferred run").id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .expect("pending escalation after unrelated agent note"); + assert_eq!(run_status, "queued"); + assert_eq!(outbox_status, "pending"); + + work_service::note_project_work_item_threaded( + "demo", + "AAA-0001", + "comment", + "I picked this up.", + Some(&root.comment.id), + Some(&agent_actor("builtin:sde")), + Some("agent-session-1"), + None, + ) + .expect("assigned agent reply"); + let (run_status, outbox_status): (String, String) = connection + .query_row( + "SELECT r.status, d.status + FROM pm_work_item_runs r + JOIN pm_dispatch_outbox d ON d.run_id = r.id + WHERE r.id = ?1", + rusqlite::params![root.run.expect("deferred run").id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .expect("cancelled escalation"); + assert_eq!(run_status, "cancelled"); + assert_eq!(outbox_status, "cancelled"); +} + +#[test] +fn only_the_assigned_agent_run_terminal_cancels_the_deferred_escalation() { + let _sandbox = test_env::sandbox(); + seed_with_config(false, "builtin:sde"); + + let posted = post("comment-terminal-fence", "Please pick this up.", None); + let deferred_run = posted.run.expect("deferred assignee run"); + let mut unrelated_target = crate::projects::types::WorkItemRunTargetSnapshot::new( + crate::projects::types::WorkItemRunTarget::StartWorkItem { + account_id: None, + model_id: None, + }, + ); + unrelated_target.agent_definition_id = Some("other-agent".to_string()); + let unrelated = + crate::work_run_service::enqueue(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: unrelated_target, + input: json!({ "content": "unrelated action" }), + idempotency_key: "unrelated-terminal-fence".to_string(), + max_attempts: 1, + parent_run_id: None, + }) + .expect("enqueue unrelated run"); + let lease = crate::work_run_service::claim_next_dispatch("test-worker", 30_000) + .expect("claim unrelated run") + .expect("dispatch unrelated run"); + assert_eq!(lease.run.id, unrelated.id); + crate::work_run_service::acknowledge_dispatch_started( + &lease.dispatch_id, + &lease.lease_token, + "other-agent-session", + ) + .expect("start unrelated run"); + crate::work_run_service::record_run_terminal( + &unrelated.id, + Some("other-agent-session"), + crate::work_run_service::WorkItemRunTerminalOutcome::Succeeded, + crate::projects::types::WorkItemRunUsage::default(), + None, + ) + .expect("finish unrelated run"); + + let connection = conn().expect("connection"); + let (run_status, outbox_status): (String, String) = connection + .query_row( + "SELECT r.status, d.status + FROM pm_work_item_runs r + JOIN pm_dispatch_outbox d ON d.run_id = r.id + WHERE r.id = ?1", + rusqlite::params![deferred_run.id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .expect("pending escalation after unrelated terminal run"); + assert_eq!(run_status, "queued"); + assert_eq!(outbox_status, "pending"); + + let mut assigned_target = crate::projects::types::WorkItemRunTargetSnapshot::new( + crate::projects::types::WorkItemRunTarget::StartWorkItem { + account_id: None, + model_id: None, + }, + ); + assigned_target.agent_definition_id = Some("builtin:sde".to_string()); + let assigned = + crate::work_run_service::enqueue(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: assigned_target, + input: json!({ "content": "assigned agent work" }), + idempotency_key: "assigned-terminal-fence".to_string(), + max_attempts: 1, + parent_run_id: None, + }) + .expect("enqueue assigned agent run"); + let lease = crate::work_run_service::claim_next_dispatch("test-worker", 30_000) + .expect("claim assigned agent run") + .expect("dispatch assigned agent run"); + assert_eq!(lease.run.id, assigned.id); + crate::work_run_service::acknowledge_dispatch_started( + &lease.dispatch_id, + &lease.lease_token, + "assigned-agent-session", + ) + .expect("start assigned agent run"); + crate::work_run_service::record_run_terminal( + &assigned.id, + Some("assigned-agent-session"), + crate::work_run_service::WorkItemRunTerminalOutcome::Succeeded, + crate::projects::types::WorkItemRunUsage::default(), + None, + ) + .expect("finish assigned agent run"); + + let (run_status, outbox_status): (String, String) = connection + .query_row( + "SELECT r.status, d.status + FROM pm_work_item_runs r + JOIN pm_dispatch_outbox d ON d.run_id = r.id + WHERE r.id = ?1", + rusqlite::params![deferred_run.id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .expect("cancelled escalation after assigned agent terminal run"); + assert_eq!(run_status, "cancelled"); + assert_eq!(outbox_status, "cancelled"); +} + +#[test] +fn standalone_agent_reply_cancels_the_deferred_assignee_escalation() { + let _sandbox = test_env::sandbox(); + work_service::create_standalone_work_item( + None, + "ORG-0001", + &CreateWorkItemRequest { + title: "Standalone routing fixture".to_string(), + body: "Route this discussion.".to_string(), + orchestrator_config: Some(OrchestratorConfig { + agent_definition_id: Some("builtin:sde".to_string()), + ..Default::default() + }), + ..Default::default() + }, + None, + ) + .expect("seed standalone Work Item"); + let scope = WorkItemScope { + project_slug: None, + org_id: "personal-org".to_string(), + work_item_id: "ORG-0001".to_string(), + }; + let posted = discussion::post(DiscussionPostRequest { + scope, + comment_id: "standalone-comment".to_string(), + author_id: "member-1".to_string(), + author_name: "Member One".to_string(), + content: "Please pick this up.".to_string(), + mentioned_user_ids: Vec::new(), + mentions: Vec::new(), + parent_id: None, + target_session_id: None, + }) + .expect("post standalone comment"); + assert_eq!(posted.wake_reason, "assignee_deferred"); + let deferred_run = posted.run.expect("deferred standalone run"); + + work_service::note_standalone_work_item_threaded( + None, + "ORG-0001", + "comment", + "I picked this up.", + Some(&posted.comment.id), + Some(&agent_actor("builtin:sde")), + Some("agent-session-standalone"), + None, + ) + .expect("standalone agent reply"); + + let connection = conn().expect("connection"); + let (run_status, outbox_status): (String, String) = connection + .query_row( + "SELECT r.status, d.status + FROM pm_work_item_runs r + JOIN pm_dispatch_outbox d ON d.run_id = r.id + WHERE r.id = ?1", + rusqlite::params![deferred_run.id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .expect("cancelled standalone escalation"); + assert_eq!(run_status, "cancelled"); + assert_eq!(outbox_status, "cancelled"); +} + +#[test] +fn all_and_mixed_audiences_do_not_fall_through_to_the_assigned_agent() { + let _sandbox = test_env::sandbox(); + seed_with_config(false, "builtin:sde"); + + let everyone = post_with_mentions( + "comment-all", + "Everyone should see this.", + None, + vec![MentionTarget::All], + ); + assert_eq!(everyone.wake_reason, "member_addressed"); + assert!(everyone.run.is_none()); + + let mixed = post_with_mentions( + "comment-mixed", + "<@member-2> and the assigned Agent should both see this.", + None, + vec![ + MentionTarget::Member { + id: "member-2".to_string(), + }, + MentionTarget::Agent { + id: "builtin:sde".to_string(), + }, + ], + ); + assert_eq!(mixed.wake_reason, "mention_start"); + assert!(mixed.run.is_some()); } #[test] @@ -358,7 +856,7 @@ fn discussion_preview_reports_assignee_start() { }) .expect("preview"); assert!(preview.will_wake); - assert_eq!(preview.reason, "assignee_start"); + assert_eq!(preview.reason, "assignee_deferred"); assert_eq!(preview.target_kind.as_deref(), Some("start")); assert!(!preview.will_coalesce); } @@ -425,6 +923,128 @@ fn subscriptions_coalesce_updates_but_keep_mentions_separate() { })); } +#[test] +fn inbox_event_coalescing_is_scoped_to_the_authoritative_work_item() { + let _sandbox = test_env::sandbox(); + seed(false); + let mut connection = conn().expect("connection"); + let tx = connection.transaction().expect("begin transaction"); + + 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 ( + 'stale-event', 'org:wrong', 'WRONG-1', 'mentioned-1', 'mention', NULL, + '{}', 'mention:org:alpha:ALPHA-1:comment-shared', 1, NULL + )", + [], + ) + .expect("seed stale conflicting event"); + + let mentioned = vec!["mentioned-1".to_string()]; + for (scope_key, work_item_id, now) in [("org:alpha", "ALPHA-1", 10), ("org:beta", "BETA-1", 20)] + { + subscriptions::notify_comment( + &tx, + subscriptions::CommentNotification { + scope_key, + work_item_id, + title: "Scoped mention", + comment_id: "comment-shared", + author_id: "author-1", + content: "Please review", + mentioned_user_ids: &mentioned, + now, + }, + ) + .expect("write scoped mention event"); + } + + for (scope_key, parent_short_id, now) in [ + ("org:alpha", "ALPHA-PARENT", 30), + ("org:beta", "BETA-PARENT", 40), + ] { + subscriptions::ensure_subscription( + &tx, + scope_key, + parent_short_id, + "watcher-1", + SubscriptionReason::Manual, + now, + ) + .expect("subscribe parent watcher"); + subscriptions::notify_child_terminal( + &tx, + subscriptions::ChildTerminalNotification { + scope_key, + parent_short_id, + child_short_id: "CHILD-1", + child_title: "Shared child id", + status: "completed", + actor_id: None, + now, + }, + ) + .expect("write scoped child event"); + } + tx.commit().expect("commit scoped events"); + + let mentions = connection + .prepare( + "SELECT scope_key, work_item_id, coalesce_key + FROM pm_work_item_inbox_events + WHERE recipient_id = 'mentioned-1' AND kind = 'mention' + ORDER BY scope_key", + ) + .expect("prepare mention rows") + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }) + .expect("query mention rows") + .collect::, _>>() + .expect("collect mention rows"); + assert_eq!( + mentions, + vec![ + ( + "org:alpha".to_string(), + "ALPHA-1".to_string(), + "mention:org:alpha:ALPHA-1:comment-shared".to_string(), + ), + ( + "org:beta".to_string(), + "BETA-1".to_string(), + "mention:org:beta:BETA-1:comment-shared".to_string(), + ), + ] + ); + + let child_keys = connection + .prepare( + "SELECT coalesce_key + FROM pm_work_item_inbox_events + WHERE recipient_id = 'watcher-1' AND kind = 'child_completed' + ORDER BY scope_key", + ) + .expect("prepare child rows") + .query_map([], |row| row.get::<_, String>(0)) + .expect("query child rows") + .collect::, _>>() + .expect("collect child rows"); + assert_eq!( + child_keys, + [ + "child:org:alpha:ALPHA-PARENT:CHILD-1", + "child:org:beta:BETA-PARENT:CHILD-1", + ] + ); +} + #[test] fn typed_properties_validate_values_and_keep_archived_history() { let _sandbox = test_env::sandbox(); @@ -477,54 +1097,192 @@ fn typed_properties_validate_values_and_keep_archived_history() { } #[test] -fn pr_readiness_requires_current_execution_evidence_and_close_intent() { +fn batch_property_update_rolls_back_every_item_when_one_target_is_invalid() { 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, + let definition = properties::upsert_definition(UpsertPropertyDefinitionRequest { + id: Some("prop_batch_effort".to_string()), + org_id: "personal-org".to_string(), + name: "Batch effort".to_string(), + property_type: PropertyType::Number, + description: None, + config: PropertyConfig::default(), + position: 0, + }) + .expect("create property"); + + let failed = properties::batch_set_values( + "personal-org".to_string(), + Some("demo".to_string()), + vec!["AAA-0001".to_string(), "AAA-missing".to_string()], + definition.id, + Some(json!(3)), + ); + assert!(failed.is_err(), "an invalid target must reject the batch"); + assert!( + properties::list_values(&scope()) + .expect("list values after rollback") + .is_empty(), + "the earlier target must not retain a partial write" + ); +} + +#[test] +fn actor_properties_require_canonical_member_references() { + let _sandbox = test_env::sandbox(); + seed(false); + work_service::tests_support::seed_project("other-project", "project-2"); + let connection = conn().expect("connection"); + connection + .execute( + "INSERT INTO members (id, project_id, display_name, kind, created_at) + VALUES ('member-1', 'project-1', 'Member One', 'member', 1), + ('member-foreign', 'project-2', 'Foreign Member', 'member', 1)", + [], + ) + .expect("seed scoped members"); + let actor = properties::upsert_definition(UpsertPropertyDefinitionRequest { + id: Some("prop_reviewer".to_string()), + org_id: "personal-org".to_string(), + name: "Reviewer".to_string(), + property_type: PropertyType::Actor, + description: None, + config: PropertyConfig::default(), + position: 0, + }) + .expect("create actor property"); + + let invalid = properties::set_value(SetWorkItemPropertyValueRequest { + scope: scope(), + property_id: actor.id.clone(), + value: Some(json!("member-1")), + }); + assert!( + invalid.is_err_and(|error| error.contains("member:")), + "bare ids must not enter the actor value domain" + ); + let foreign = properties::set_value(SetWorkItemPropertyValueRequest { + scope: scope(), + property_id: actor.id.clone(), + value: Some(json!("member:member-foreign")), + }) + .expect_err("members from another project scope must be rejected"); + assert_eq!( + foreign, "PM_ERR:PROPERTY_MEMBER_INVALID:member-foreign", + "the producing boundary returns a stable member ownership error" + ); + properties::set_value(SetWorkItemPropertyValueRequest { + scope: scope(), + property_id: actor.id.clone(), + value: Some(json!("member:member-1")), + }) + .expect("set canonical member reference"); + assert_eq!( + properties::list_values(&scope()).expect("list actor value")[0].value, + json!("member:member-1") + ); + + let multi_actor = properties::upsert_definition(UpsertPropertyDefinitionRequest { + id: Some("prop_reviewers".to_string()), + org_id: "personal-org".to_string(), + name: "Reviewers".to_string(), + property_type: PropertyType::MultiActor, + description: None, + config: PropertyConfig::default(), + position: 1, + }) + .expect("create multi-actor property"); + let invalid_multi = properties::set_value(SetWorkItemPropertyValueRequest { + scope: scope(), + property_id: multi_actor.id, + value: Some(json!(["member:member-1", "member:member-foreign"])), + }) + .expect_err("every multi-actor member must belong to the Work Item scope"); + assert_eq!( + invalid_multi, + "PM_ERR:PROPERTY_MEMBER_INVALID:member-foreign" + ); + + let work_item_row_id: String = connection + .query_row( + "SELECT id FROM workitems WHERE short_id = 'AAA-0001'", + [], + |row| row.get(0), + ) + .expect("Work Item row id"); + let remote_error = properties::apply_work_item_wire_snapshot( + &connection, + "personal-org", + &work_item_row_id, + &json!({ + "propertyValues": [{ + "propertyId": actor.id, + "value": "member:member-foreign", + "updatedAt": "2099-01-01T00:00:00Z" + }] + }), + ) + .expect_err("remote values use the same member ownership invariant"); + assert_eq!( + remote_error, + "PM_ERR:PROPERTY_MEMBER_INVALID:member-foreign" + ); + assert_eq!( + properties::list_values(&scope()).expect("value after rejected remote write")[0].value, + json!("member:member-1"), + "a rejected remote value must leave the local value intact" + ); +} + +#[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() ], @@ -650,3 +1408,1002 @@ async fn provider_webhook_authenticates_filters_and_deduplicates_deliveries() { 1 ); } + +#[test] +fn reply_in_agent_free_thread_stays_silent() { + let _sandbox = test_env::sandbox(); + seed(true); + + let root = post("comment-note-root", "/note capturing context only", None); + assert_eq!(root.wake_reason, "note_only"); + assert!(root.run.is_none()); + + let reply = post( + "comment-note-reply", + "Agreed, thanks!", + Some("comment-note-root"), + ); + assert_eq!( + reply.wake_reason, "member_thread", + "a reply in a thread without agent participation must not wake anyone" + ); + assert!(reply.run.is_none()); + + 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"); + assert_eq!(run_count, 0, "member threads never enqueue runs"); +} + +#[test] +fn edit_comment_updates_content_without_retrigger() { + let _sandbox = test_env::sandbox(); + seed(true); + + let posted = post("comment-editable", "Original wording.", None); + assert!(posted.run.is_some()); + + let comments = discussion::edit(DiscussionEditRequest { + scope: scope(), + comment_id: "comment-editable".to_string(), + actor_id: "member-1".to_string(), + content: "Corrected wording.".to_string(), + expected_revision: None, + }) + .expect("edit comment"); + let edited = comments + .iter() + .find(|comment| comment.id == "comment-editable") + .expect("edited comment present"); + assert_eq!(edited.content, "Corrected wording."); + assert!(edited.edited_at.is_some()); + + let stranger = discussion::edit(DiscussionEditRequest { + scope: scope(), + comment_id: "comment-editable".to_string(), + actor_id: "member-2".to_string(), + content: "Hijacked.".to_string(), + expected_revision: None, + }); + assert!(stranger.is_err(), "only the author can edit"); + + 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"); + assert_eq!(run_count, 1, "editing must not enqueue another run"); +} + +#[test] +fn comment_revision_conflicts_are_scoped_to_the_target_comment() { + let _sandbox = test_env::sandbox(); + seed(false); + + let first = post("comment-first", "/note first draft", None).comment; + let second = post("comment-second", "/note second draft", None).comment; + assert_eq!(first.revision, 0); + assert_eq!(second.revision, 0); + + let after_first_edit = discussion::edit(DiscussionEditRequest { + scope: scope(), + comment_id: first.id.clone(), + actor_id: "member-1".to_string(), + content: "first corrected".to_string(), + expected_revision: Some(first.revision), + }) + .expect("first comment edit"); + assert_eq!( + after_first_edit + .iter() + .find(|comment| comment.id == first.id) + .expect("first comment") + .revision, + 1 + ); + + let second_edit = discussion::edit(DiscussionEditRequest { + scope: scope(), + comment_id: second.id.clone(), + actor_id: "member-1".to_string(), + content: "second corrected".to_string(), + expected_revision: Some(second.revision), + }) + .expect("a write to another comment must not conflict"); + assert_eq!( + second_edit + .iter() + .find(|comment| comment.id == second.id) + .expect("second comment") + .revision, + 1 + ); + + let stale = discussion::edit(DiscussionEditRequest { + scope: scope(), + comment_id: first.id, + actor_id: "member-1".to_string(), + content: "stale overwrite".to_string(), + expected_revision: Some(0), + }) + .expect_err("stale edit must conflict"); + assert_eq!(stale, "PM_ERR:REVISION_CONFLICT:expected=0:actual=1"); +} + +#[test] +fn legacy_comments_and_mutation_requests_default_revision_tokens() { + let comment: crate::projects::types::CommentEntry = serde_json::from_value(json!({ + "id": "legacy-comment", + "author": "member-1", + "content": "legacy body", + "created_at": "2026-08-01T00:00:00Z" + })) + .expect("legacy CommentEntry"); + assert_eq!(comment.revision, 0); + + let edit: DiscussionEditRequest = serde_json::from_value(json!({ + "projectSlug": "demo", + "orgId": "personal-org", + "workItemId": "AAA-0001", + "commentId": "legacy-comment", + "actorId": "member-1", + "content": "next body" + })) + .expect("legacy edit request"); + assert_eq!(edit.expected_revision, None); +} + +#[test] +fn delete_comment_tombstones_and_strips_mentions() { + let _sandbox = test_env::sandbox(); + seed(false); + + discussion::post(DiscussionPostRequest { + scope: scope(), + comment_id: "comment-doomed".to_string(), + author_id: "member-1".to_string(), + author_name: "Member One".to_string(), + content: "/note ping <@member-2>".to_string(), + mentioned_user_ids: vec!["member-2".to_string()], + mentions: vec![MentionTarget::Member { + id: "member-2".to_string(), + }], + parent_id: None, + target_session_id: None, + }) + .expect("post comment"); + + let stranger = discussion::delete(DiscussionDeleteRequest { + scope: scope(), + comment_id: "comment-doomed".to_string(), + actor_id: "member-2".to_string(), + expected_revision: None, + }); + assert!(stranger.is_err(), "only the author can delete"); + + let comments = discussion::delete(DiscussionDeleteRequest { + scope: scope(), + comment_id: "comment-doomed".to_string(), + actor_id: "member-1".to_string(), + expected_revision: Some(0), + }) + .expect("delete comment"); + let deleted = comments + .iter() + .find(|comment| comment.id == "comment-doomed") + .expect("tombstone present") + .clone(); + assert!(deleted.deleted_at.is_some()); + assert!(deleted.content.is_empty()); + assert!(deleted.mentioned_user_ids.is_empty()); + assert!(deleted.mentions.is_empty()); + assert_eq!(deleted.revision, 1); + + let repeat = discussion::delete(DiscussionDeleteRequest { + scope: scope(), + comment_id: "comment-doomed".to_string(), + actor_id: "member-1".to_string(), + expected_revision: None, + }) + .expect("repeat delete is idempotent"); + let again = repeat + .iter() + .find(|comment| comment.id == "comment-doomed") + .expect("tombstone still present"); + assert_eq!(again.deleted_at, deleted.deleted_at); +} + +#[test] +fn status_definition_crud_enforces_key_and_category_rules() { + let _sandbox = test_env::sandbox(); + + let created = statuses::upsert_definition(statuses::UpsertStatusDefinitionRequest { + id: None, + org_id: "personal-org".to_string(), + key: Some("shipping".to_string()), + name: "Shipping".to_string(), + category: Some("completed".to_string()), + color: Some("#22c55e".to_string()), + description: None, + position: None, + }) + .expect("create custom status"); + assert_eq!(created.category, "completed"); + + let reserved = statuses::upsert_definition(statuses::UpsertStatusDefinitionRequest { + id: None, + org_id: "personal-org".to_string(), + key: Some("in_progress".to_string()), + name: "Doing".to_string(), + category: Some("planned".to_string()), + color: None, + description: None, + position: None, + }); + assert!( + reserved.is_err_and(|err| err.contains("STATUS_KEY_RESERVED")), + "built-in keys must stay reserved" + ); + + let recategorized = statuses::upsert_definition(statuses::UpsertStatusDefinitionRequest { + id: Some(created.id.clone()), + org_id: "personal-org".to_string(), + key: None, + name: "Shipping".to_string(), + category: Some("backlog".to_string()), + color: None, + description: None, + position: None, + }); + assert!( + recategorized.is_err_and(|err| err.contains("STATUS_CATEGORY_IMMUTABLE")), + "category is immutable after creation" + ); + + let archived = statuses::set_definition_archived("personal-org", &created.id, true) + .expect("archive status"); + assert!(archived.archived_at.is_some()); + assert!( + statuses::list_definitions("personal-org", false) + .expect("list active") + .is_empty(), + "archived statuses leave the active list" + ); +} + +#[test] +fn custom_status_folds_into_its_category_for_views() { + let _sandbox = test_env::sandbox(); + seed(false); + + statuses::upsert_definition(statuses::UpsertStatusDefinitionRequest { + id: None, + org_id: "personal-org".to_string(), + key: Some("shipping".to_string()), + name: "Shipping".to_string(), + category: Some("completed".to_string()), + color: None, + description: None, + position: None, + }) + .expect("create custom status"); + + assert_eq!( + statuses::find_active_status_definition(Some("personal-org"), "shipping") + .expect("lookup") + .map(|definition| definition.category), + Some("completed".to_string()), + "the CLI accepts the key because the org defines it" + ); + assert!(statuses::render_status_catalog(Some("personal-org")) + .expect("catalog") + .contains("- completed: `shipping` (Shipping)")); + assert_eq!(statuses::render_status_catalog(Some("other-org")), None); + + work_service::transition_project_work_item("demo", "AAA-0001", "shipping", None, None, None) + .expect("transition to custom status"); + + let view = crate::projects::io::read_work_items_view_data("demo", Some("completed"), None) + .expect("read view data"); + assert_eq!( + view.counts.completed, 1, + "custom status counts as its category" + ); + assert_eq!( + view.items.len(), + 1, + "category filter matches the custom status" + ); + + let connection = conn().expect("connection"); + let effective = crate::work_item_features::statuses::effective_status_in( + &connection, + "personal-org", + "shipping", + ); + assert_eq!(effective, "completed"); +} + +#[test] +fn saved_views_upsert_list_and_archive() { + let _sandbox = test_env::sandbox(); + seed(false); + + let view = saved_views::upsert_view(saved_views::UpsertSavedViewRequest { + id: None, + org_id: "personal-org".to_string(), + project_slug: Some("demo".to_string()), + name: "My review queue".to_string(), + query: json!({ "statusFilter": "in_review", "searchQuery": "" }), + display: json!({ "viewTab": "Kanban" }), + position: None, + created_by: Some("member-1".to_string()), + }) + .expect("create saved view"); + assert_eq!(view.query["statusFilter"], "in_review"); + + let renamed = saved_views::upsert_view(saved_views::UpsertSavedViewRequest { + id: Some(view.id.clone()), + org_id: "personal-org".to_string(), + project_slug: Some("demo".to_string()), + name: "Review queue".to_string(), + query: view.query.clone(), + display: view.display.clone(), + position: Some(2), + created_by: None, + }) + .expect("rename saved view"); + assert_eq!(renamed.name, "Review queue"); + assert_eq!(renamed.position, 2); + + let listed = saved_views::list_views("personal-org", Some("demo")).expect("list"); + assert_eq!(listed.len(), 1); + + let other_project = saved_views::list_views("personal-org", Some("elsewhere")).expect("list"); + assert!( + other_project.is_empty(), + "project-scoped views stay out of other projects" + ); + + saved_views::archive_view("personal-org", &view.id).expect("archive"); + assert!( + saved_views::list_views("personal-org", Some("demo")) + .expect("list") + .is_empty(), + "archived views leave the list" + ); +} + +#[test] +fn saved_views_wire_round_trip_applies_newer_snapshots() { + let _sandbox = test_env::sandbox(); + + let view = saved_views::upsert_view(saved_views::UpsertSavedViewRequest { + id: None, + org_id: "personal-org".to_string(), + project_slug: None, + name: "Org wide".to_string(), + query: json!({ "statusFilter": "all" }), + display: json!({}), + position: None, + created_by: None, + }) + .expect("create saved view"); + + let connection = conn().expect("connection"); + let exported = saved_views::export_views(&connection, "personal-org").expect("export"); + assert_eq!(exported.len(), 1); + + let mut remote = exported[0].clone(); + remote.name = "Org wide (remote rename)".to_string(); + remote.updated_at += 1_000; + let payload = json!({ "savedViews": [remote] }); + saved_views::apply_wire_views(&connection, "personal-org", &payload).expect("apply"); + + let listed = saved_views::list_views("personal-org", Some("demo")).expect("list"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].name, "Org wide (remote rename)"); + assert!(listed[0].id == view.id); +} + +#[test] +fn quick_action_invoke_posts_mention_comment_and_bumps_use_count() { + let _sandbox = test_env::sandbox(); + seed(true); + let prompt = " Investigate the failing CI run for this item\nand fix it verbatim. "; + + let action = quick_actions::upsert_action(quick_actions::UpsertQuickActionRequest { + id: None, + org_id: "personal-org".to_string(), + name: "Fix CI".to_string(), + description: "Ask the build agent to repair CI".to_string(), + target_kind: "agent".to_string(), + target_id: "builtin:sde".to_string(), + prompt: prompt.to_string(), + created_by: Some("member-1".to_string()), + }) + .expect("create quick action"); + + let result = quick_actions::invoke_action(quick_actions::InvokeQuickActionRequest { + scope: scope(), + action_id: action.id.clone(), + actor_id: "member-1".to_string(), + actor_name: "Member One".to_string(), + }) + .expect("invoke quick action"); + assert_eq!( + result.comment.mentions, + vec![MentionTarget::Agent { + id: "builtin:sde".to_string() + }] + ); + assert_eq!(result.wake_reason, "quick_action"); + assert_eq!( + result.comment.content, prompt, + "saved prompts are sent byte-for-byte without interpolation or trimming" + ); + let run = result + .run + .as_ref() + .expect("a Quick Action must enqueue its saved target"); + assert_eq!( + run.target_snapshot.agent_definition_id.as_deref(), + Some("builtin:sde") + ); + assert_eq!(run.target_snapshot.agent_org_id, None); + + let listed = quick_actions::list_actions("personal-org").expect("list"); + assert_eq!(listed[0].use_count, 1); + + let failed = quick_actions::invoke_action(quick_actions::InvokeQuickActionRequest { + scope: WorkItemScope { + work_item_id: "AAA-missing".to_string(), + ..scope() + }, + action_id: action.id.clone(), + actor_id: "member-1".to_string(), + actor_name: "Member One".to_string(), + }); + assert!( + failed.is_err(), + "a missing Work Item must reject invocation" + ); + assert_eq!( + quick_actions::list_actions("personal-org").expect("list")[0].use_count, + 1, + "failed invocation must roll back its use count" + ); + + quick_actions::archive_action("personal-org", &action.id).expect("archive"); + assert!(quick_actions::list_actions("personal-org") + .expect("list") + .is_empty()); + let archived_invoke = quick_actions::invoke_action(quick_actions::InvokeQuickActionRequest { + scope: scope(), + action_id: action.id, + actor_id: "member-1".to_string(), + actor_name: "Member One".to_string(), + }); + assert!(archived_invoke.is_err(), "archived actions cannot fire"); +} + +#[test] +fn org_scoped_definition_ids_cannot_cross_organization_boundaries() { + let _sandbox = test_env::sandbox(); + let connection = conn().expect("connection"); + connection + .execute( + "INSERT INTO project_orgs ( + id, name, slug, org_key, source, sync_provider, created_at, updated_at + ) VALUES ('org-two', 'Org Two', 'org-two', 'TWO', 'local', 'none', 1, 1)", + [], + ) + .expect("second PM org"); + + let property = properties::upsert_definition(UpsertPropertyDefinitionRequest { + id: Some("prop_global_collision".to_string()), + org_id: "personal-org".to_string(), + name: "Owner property".to_string(), + property_type: PropertyType::Text, + description: None, + config: PropertyConfig::default(), + position: 0, + }) + .expect("owner property"); + let view = saved_views::upsert_view(saved_views::UpsertSavedViewRequest { + id: Some("view_global_collision".to_string()), + org_id: "personal-org".to_string(), + project_slug: None, + name: "Owner view".to_string(), + query: json!({}), + display: json!({}), + position: None, + created_by: None, + }) + .expect("owner view"); + let action = quick_actions::upsert_action(quick_actions::UpsertQuickActionRequest { + id: Some("action_global_collision".to_string()), + org_id: "personal-org".to_string(), + name: "Owner action".to_string(), + description: String::new(), + target_kind: "agent".to_string(), + target_id: "builtin:sde".to_string(), + prompt: "Keep the owner".to_string(), + created_by: None, + }) + .expect("owner action"); + let status = statuses::upsert_definition(statuses::UpsertStatusDefinitionRequest { + id: None, + org_id: "personal-org".to_string(), + key: Some("owner_status".to_string()), + name: "Owner status".to_string(), + category: Some("planned".to_string()), + color: None, + description: None, + position: None, + }) + .expect("owner status"); + + let property_error = properties::upsert_definition(UpsertPropertyDefinitionRequest { + id: Some(property.id.clone()), + org_id: "org-two".to_string(), + name: "Hijacked property".to_string(), + property_type: PropertyType::Text, + description: None, + config: PropertyConfig::default(), + position: 0, + }) + .expect_err("cross-org property id"); + let view_error = saved_views::upsert_view(saved_views::UpsertSavedViewRequest { + id: Some(view.id.clone()), + org_id: "org-two".to_string(), + project_slug: None, + name: "Hijacked view".to_string(), + query: json!({}), + display: json!({}), + position: None, + created_by: None, + }) + .expect_err("cross-org view id"); + let action_error = quick_actions::upsert_action(quick_actions::UpsertQuickActionRequest { + id: Some(action.id.clone()), + org_id: "org-two".to_string(), + name: "Hijacked action".to_string(), + description: String::new(), + target_kind: "agent".to_string(), + target_id: "builtin:sde".to_string(), + prompt: "Overwrite".to_string(), + created_by: None, + }) + .expect_err("cross-org action id"); + let status_error = statuses::upsert_definition(statuses::UpsertStatusDefinitionRequest { + id: Some(status.id.clone()), + org_id: "org-two".to_string(), + key: None, + name: "Hijacked status".to_string(), + category: None, + color: None, + description: None, + position: None, + }) + .expect_err("cross-org status id"); + for error in [property_error, view_error, action_error, status_error] { + assert!( + error.starts_with("PM_ERR:ORG_SCOPE_MISMATCH:"), + "stable ownership error expected, got {error}" + ); + } + + let mut remote_property = property.clone(); + remote_property.org_id = "org-two".to_string(); + remote_property.name = "Remote hijack property".to_string(); + remote_property.updated_at = "2099-01-01T00:00:00Z".to_string(); + properties::apply_wire_definitions( + &connection, + "org-two", + &json!({ "propertyDefinitions": [remote_property] }), + ) + .expect("cross-org remote property is skipped"); + + let mut remote_view = view.clone(); + remote_view.org_id = "org-two".to_string(); + remote_view.name = "Remote hijack view".to_string(); + remote_view.updated_at += 10_000; + saved_views::apply_wire_views( + &connection, + "org-two", + &json!({ "savedViews": [remote_view] }), + ) + .expect("cross-org remote view is skipped"); + + let mut remote_action = action.clone(); + remote_action.org_id = "org-two".to_string(); + remote_action.name = "Remote hijack action".to_string(); + remote_action.updated_at += 10_000; + quick_actions::apply_wire_actions( + &connection, + "org-two", + &json!({ "quickActions": [remote_action] }), + ) + .expect("cross-org remote action is skipped"); + + let mut remote_status = status.clone(); + remote_status.org_id = "org-two".to_string(); + remote_status.name = "Remote hijack status".to_string(); + remote_status.updated_at += 10_000; + statuses::apply_wire_definitions( + &connection, + "org-two", + &json!({ "statusDefinitions": [remote_status] }), + ) + .expect("cross-org remote status is skipped"); + + assert_eq!( + properties::list_definitions("personal-org", true).expect("owner properties")[0].name, + "Owner property" + ); + assert_eq!( + saved_views::list_views("personal-org", None).expect("owner views")[0].name, + "Owner view" + ); + assert_eq!( + quick_actions::list_actions("personal-org").expect("owner actions")[0].name, + "Owner action" + ); + assert_eq!( + statuses::list_definitions("personal-org", true).expect("owner statuses")[0].name, + "Owner status" + ); + assert!( + properties::list_definitions("org-two", true) + .expect("other properties") + .is_empty() + && saved_views::list_views("org-two", None) + .expect("other views") + .is_empty() + && quick_actions::list_actions("org-two") + .expect("other actions") + .is_empty() + && statuses::list_definitions("org-two", true) + .expect("other statuses") + .is_empty(), + "remote collision records must not be re-owned by the receiving org" + ); +} + +#[test] +fn quick_action_targets_are_validated_before_persistence() { + let _sandbox = test_env::sandbox(); + let error = quick_actions::upsert_action(quick_actions::UpsertQuickActionRequest { + id: Some("action_missing_target".to_string()), + org_id: "personal-org".to_string(), + name: "Missing target".to_string(), + description: String::new(), + target_kind: "agent".to_string(), + target_id: "custom:missing".to_string(), + prompt: "Do work".to_string(), + created_by: None, + }) + .expect_err("unknown agent definitions must be rejected"); + assert_eq!( + error, + "PM_ERR:QUICK_ACTION_TARGET_NOT_FOUND:agent:custom:missing" + ); + + let connection = conn().expect("connection"); + let invalid_remote = quick_actions::QuickAction { + id: "action_remote_missing_target".to_string(), + org_id: "personal-org".to_string(), + name: "Remote missing target".to_string(), + description: String::new(), + target_kind: "agent_org".to_string(), + target_id: "missing-agent-org".to_string(), + prompt: "Do work".to_string(), + use_count: 0, + created_by: None, + archived_at: None, + created_at: 1, + updated_at: 1, + }; + quick_actions::apply_wire_actions( + &connection, + "personal-org", + &json!({ "quickActions": [invalid_remote] }), + ) + .expect("invalid remote targets are skipped without poisoning the snapshot"); + assert!( + quick_actions::list_actions("personal-org") + .expect("actions after rejected writes") + .is_empty(), + "target validation must run before either local or remote persistence" + ); + + let agent_orgs_path = app_paths::agent_orgs(); + std::fs::create_dir_all(agent_orgs_path.parent().expect("agent orgs parent")) + .expect("create agent org registry directory"); + std::fs::write( + &agent_orgs_path, + r#"[{"id":"team-valid","name":"Valid team","role":"Coordinator","agentId":"builtin:sde"}]"#, + ) + .expect("seed authoritative Agent Org registry"); + let valid_org_action = quick_actions::upsert_action(quick_actions::UpsertQuickActionRequest { + id: Some("action_valid_agent_org".to_string()), + org_id: "personal-org".to_string(), + name: "Valid Agent Org".to_string(), + description: String::new(), + target_kind: "agent_org".to_string(), + target_id: "team-valid".to_string(), + prompt: "Do team work".to_string(), + created_by: None, + }) + .expect("registered Agent Org target is accepted"); + assert_eq!(valid_org_action.target_id, "team-valid"); +} + +#[test] +fn field_changes_notify_subscribers_and_honor_category_mutes() { + let _sandbox = test_env::sandbox(); + seed(false); + subscriptions::list(&scope()).expect("bootstrap implicit subscriptions"); + + let actor = crate::projects::types::WorkItemMutationActor { + id: "member-9".to_string(), + name: "Member Nine".to_string(), + }; + work_service::transition_project_work_item( + "demo", + "AAA-0001", + "in_progress", + None, + Some(&actor), + None, + ) + .expect("transition"); + + let connection = conn().expect("connection"); + let read_event = |recipient: &str| -> Option<(String, String)> { + connection + .query_row( + "SELECT kind, payload_json FROM pm_work_item_inbox_events + WHERE recipient_id = ?1 AND work_item_id = 'AAA-0001'", + rusqlite::params![recipient], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .ok() + }; + let (kind, payload) = read_event("creator-1").expect("creator is notified"); + assert_eq!(kind, "status_changed"); + let decoded: serde_json::Value = serde_json::from_str(&payload).expect("payload json"); + assert_eq!(decoded["changes"]["status"]["to"], "in_progress"); + assert!( + read_event("member-9").is_none(), + "the actor never notifies themselves" + ); + + subscriptions::set_kind_muted("creator-1", "priority_changed", true).expect("mute kind"); + connection + .execute( + "DELETE FROM pm_work_item_inbox_events WHERE recipient_id = 'creator-1'", + [], + ) + .expect("clear inbox"); + crate::projects::io::update_work_item_partial( + "demo", + "AAA-0001", + &crate::projects::types::WorkItemPartialUpdate { + priority: Some("high".to_string()), + actor: Some(actor.clone()), + ..Default::default() + }, + ) + .expect("priority update"); + assert!( + read_event("creator-1").is_none(), + "a muted category writes no inbox row" + ); +} + +#[test] +fn child_terminal_status_notifies_the_parent_subscribers() { + let _sandbox = test_env::sandbox(); + seed_with_config(false, "builtin:sde"); + subscriptions::list(&scope()).expect("bootstrap implicit subscriptions"); + + work_service::create_project_work_item( + "demo", + "AAA-0002", + &CreateWorkItemRequest { + title: "Child work".to_string(), + body: String::new(), + created_by: Some("creator-1".to_string()), + parent: Some("AAA-0001".to_string()), + ..Default::default() + }, + None, + ) + .expect("seed child"); + + let actor = crate::projects::types::WorkItemMutationActor { + id: "member-9".to_string(), + name: "Member Nine".to_string(), + }; + work_service::transition_project_work_item( + "demo", + "AAA-0002", + "in_progress", + None, + Some(&actor), + None, + ) + .expect("start the child"); + work_service::transition_project_work_item( + "demo", + "AAA-0002", + "completed", + None, + Some(&actor), + None, + ) + .expect("complete the child"); + + let connection = conn().expect("connection"); + let kind: String = connection + .query_row( + "SELECT kind FROM pm_work_item_inbox_events + WHERE recipient_id = 'creator-1' AND work_item_id = 'AAA-0001' + AND kind = 'child_completed'", + [], + |row| row.get(0), + ) + .expect("parent subscriber is notified about the finished child"); + assert_eq!(kind, "child_completed"); + + let parent = crate::projects::io::read_work_item("demo", "AAA-0001") + .expect("read parent after child completion"); + let system_comments = parent + .frontmatter + .comments + .iter() + .filter(|comment| comment.author == "ORGII") + .collect::>(); + assert_eq!(system_comments.len(), 1); + assert_eq!( + system_comments[0].content, + "Child AAA-0002 “Child work” reached completed." + ); + + let parent_runs: i64 = connection + .query_row( + "SELECT COUNT(*) FROM pm_work_item_runs + WHERE work_item_id = 'AAA-0001' AND trigger_json LIKE '%system-child-terminal:%'", + [], + |row| row.get(0), + ) + .expect("system comment routes through the parent Discussion"); + assert_eq!(parent_runs, 1); +} + +#[test] +fn child_terminal_system_comment_is_idempotent_per_transition() { + let _sandbox = test_env::sandbox(); + seed_with_config(false, "builtin:sde"); + let mut connection = conn().expect("connection"); + let tx = connection.transaction().expect("transaction"); + + let first = super::post_child_terminal_system_comment_in_transaction( + &tx, + super::ChildTerminalSystemComment { + project_slug: Some("demo"), + org_id: "personal-org", + parent_short_id: "AAA-0001", + child_short_id: "AAA-0002", + child_title: "Child work", + status: "completed", + child_revision: 7, + }, + ) + .expect("first system comment"); + let replay = super::post_child_terminal_system_comment_in_transaction( + &tx, + super::ChildTerminalSystemComment { + project_slug: Some("demo"), + org_id: "personal-org", + parent_short_id: "AAA-0001", + child_short_id: "AAA-0002", + child_title: "Child work", + status: "completed", + child_revision: 7, + }, + ) + .expect("idempotent replay"); + let missing_parent = super::post_child_terminal_system_comment_in_transaction( + &tx, + super::ChildTerminalSystemComment { + project_slug: Some("demo"), + org_id: "personal-org", + parent_short_id: "AAA-missing", + child_short_id: "AAA-0002", + child_title: "Child work", + status: "completed", + child_revision: 8, + }, + ) + .expect("a stale parent link must not block child completion"); + tx.commit().expect("commit"); + + assert!(first); + assert!(!replay); + assert!(!missing_parent); + let parent = crate::projects::io::read_work_item("demo", "AAA-0001").expect("read parent"); + assert_eq!( + parent + .frontmatter + .comments + .iter() + .filter(|comment| comment.id == "system-child-terminal:AAA-0002:7") + .count(), + 1 + ); +} + +#[test] +fn archiving_an_inbox_item_hides_it_and_marks_it_read() { + let _sandbox = test_env::sandbox(); + seed(false); + subscriptions::list(&scope()).expect("bootstrap implicit subscriptions"); + + let actor = crate::projects::types::WorkItemMutationActor { + id: "member-9".to_string(), + name: "Member Nine".to_string(), + }; + work_service::transition_project_work_item( + "demo", + "AAA-0001", + "in_progress", + None, + Some(&actor), + None, + ) + .expect("transition"); + + let viewers = vec!["creator-1".to_string()]; + let page = crate::team_inbox::list_page(crate::team_inbox::TeamInboxListOptions::new( + viewers.clone(), + )) + .expect("inbox page"); + let target = page + .items + .iter() + .find(|item| item.id.starts_with("work_item_subscription_event:")) + .expect("the change event is listed") + .id + .clone(); + + crate::team_inbox::set_archived(&viewers, &target, true).expect("archive"); + let after = crate::team_inbox::list_page(crate::team_inbox::TeamInboxListOptions::new( + viewers.clone(), + )) + .expect("inbox page"); + assert!( + !after.items.iter().any(|item| item.id == target), + "archived rows leave the page" + ); + assert_eq!(after.unread_count, 0, "archiving also acknowledges the row"); + + crate::team_inbox::set_archived(&viewers, &target, false).expect("unarchive"); + let restored = + crate::team_inbox::list_page(crate::team_inbox::TeamInboxListOptions::new(viewers)) + .expect("inbox page"); + assert!( + restored.items.iter().any(|item| item.id == target), + "unarchive restores the row" + ); +} 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 index 4342cb8828..f7919377a3 100644 --- a/src-tauri/crates/project-management/src/work_item_features/types.rs +++ b/src-tauri/crates/project-management/src/work_item_features/types.rs @@ -41,6 +41,29 @@ pub struct DiscussionPostResult { pub wake_reason: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DiscussionEditRequest { + #[serde(flatten)] + pub scope: WorkItemScope, + pub comment_id: String, + pub actor_id: String, + pub content: String, + #[serde(default)] + pub expected_revision: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DiscussionDeleteRequest { + #[serde(flatten)] + pub scope: WorkItemScope, + pub comment_id: String, + pub actor_id: String, + #[serde(default)] + pub expected_revision: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DiscussionTriggerPreview { @@ -129,6 +152,8 @@ pub enum PropertyType { Date, Checkbox, Url, + Actor, + MultiActor, } impl PropertyType { @@ -141,6 +166,8 @@ impl PropertyType { Self::Date => "date", Self::Checkbox => "checkbox", Self::Url => "url", + Self::Actor => "actor", + Self::MultiActor => "multi_actor", } } } @@ -157,6 +184,8 @@ impl TryFrom<&str> for PropertyType { "date" => Ok(Self::Date), "checkbox" => Ok(Self::Checkbox), "url" => Ok(Self::Url), + "actor" => Ok(Self::Actor), + "multi_actor" => Ok(Self::MultiActor), other => Err(format!("unknown property type '{other}'")), } } @@ -214,6 +243,15 @@ pub struct WorkItemPropertyValue { pub updated_at: String, } +/// One typed-property value row for a whole-scope read (table columns). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScopePropertyValue { + pub property_id: String, + pub work_item_id: String, + pub value: serde_json::Value, +} + /// Durable collaboration projection for one typed-property value. /// /// A JSON `null` value is a tombstone. Keeping clears on the wire avoids diff --git a/src-tauri/crates/project-management/src/work_run_service/enqueue.rs b/src-tauri/crates/project-management/src/work_run_service/enqueue.rs index 6341a855f3..63024a2a2a 100644 --- a/src-tauri/crates/project-management/src/work_run_service/enqueue.rs +++ b/src-tauri/crates/project-management/src/work_run_service/enqueue.rs @@ -139,7 +139,7 @@ fn git_value(workspace_path: &str, args: &[&str]) -> Option { fn hydrate_target_snapshot( request: &mut EnqueueWorkItemRunRequest, context: WorkItemExecutionContext, -) { +) -> Result<(), String> { request.org_id = context.org_id; let snapshot = &mut request.target_snapshot; snapshot.work_item_revision = context.revision; @@ -196,6 +196,11 @@ fn hydrate_target_snapshot( if snapshot.agent_org_id.is_none() { snapshot.agent_org_id = context.agent_org_id; } + // Never trust a client-supplied manifest. Freeze effective skill consent + // at the durable enqueue boundary. + snapshot.skill_manifest = super::resolve_skill_manifest(snapshot)?; + snapshot.skill_manifest_digest = Some(super::skill_manifest_digest(&snapshot.skill_manifest)?); + Ok(()) } /// Atomically create one Work Item Run and its first dispatch row. /// @@ -252,7 +257,7 @@ pub(crate) fn enqueue_in_transaction( } let execution_context = resolve_work_item_scope(tx, &request)?; - hydrate_target_snapshot(&mut request, execution_context); + 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); 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 52e022f035..af5fe7e76d 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 @@ -14,6 +14,12 @@ mod read; mod store; mod terminal; +use std::sync::OnceLock; + +use sha2::{Digest, Sha256}; + +use crate::projects::types::{WorkItemRunSkillManifestEntry, WorkItemRunTargetSnapshot}; + #[cfg(test)] #[path = "tests.rs"] mod tests; @@ -29,6 +35,9 @@ pub(crate) use read::read_in_transaction; pub use read::{ latest_for_session, list_active_session_runs, list_for_work_item, read, routine_origin, }; +pub(crate) use terminal::{ + cancel_pending_assignee_escalations_for_agent_reply, context_exhausted_session_snapshot_in, +}; pub use terminal::{ classify_failure, mark_waiting, record_dispatch_failure, record_run_terminal, record_session_terminal, retry, @@ -54,11 +63,37 @@ pub mod error { 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"; + pub const RUN_QUEUED: &str = "PM_RUN_ERR:RUN_QUEUED"; } const DEFAULT_LEASE_MS: i64 = 30_000; const MAX_RUN_ATTEMPTS: u32 = 10; +/// Dependency-inverted resolver supplied by `agent_core` at app startup. +pub type WorkItemRunSkillManifestResolver = + fn(&WorkItemRunTargetSnapshot) -> Result, String>; +static WORK_ITEM_RUN_SKILL_MANIFEST_RESOLVER: OnceLock = + OnceLock::new(); + +pub fn register_skill_manifest_resolver(resolver: WorkItemRunSkillManifestResolver) { + let _ = WORK_ITEM_RUN_SKILL_MANIFEST_RESOLVER.set(resolver); +} + +pub fn skill_manifest_digest(manifest: &[WorkItemRunSkillManifestEntry]) -> Result { + let json = serde_json::to_vec(manifest) + .map_err(|err| format!("work run skill manifest serialization: {err}"))?; + Ok(format!("sha256:{}", hex::encode(Sha256::digest(json)))) +} + +fn resolve_skill_manifest( + snapshot: &WorkItemRunTargetSnapshot, +) -> Result, String> { + match WORK_ITEM_RUN_SKILL_MANIFEST_RESOLVER.get() { + Some(resolve) => resolve(snapshot), + None => Ok(Vec::new()), + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WorkItemRunTerminalOutcome { Succeeded, diff --git a/src-tauri/crates/project-management/src/work_run_service/terminal.rs b/src-tauri/crates/project-management/src/work_run_service/terminal.rs index c2965d9a8b..f9bf7e5ad9 100644 --- a/src-tauri/crates/project-management/src/work_run_service/terminal.rs +++ b/src-tauri/crates/project-management/src/work_run_service/terminal.rs @@ -1,9 +1,12 @@ -use rusqlite::{params, OptionalExtension, TransactionBehavior}; +use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior}; + +use app_utils::runtime_errors::is_context_exhausted_message; use crate::projects::io::helpers::{conn, now_ms}; use crate::projects::types::{ EnqueueWorkItemRunRequest, WorkItemRun, WorkItemRunFailure, WorkItemRunFailureClass, - WorkItemRunRetryDisposition, WorkItemRunStatus, WorkItemRunTarget, WorkItemRunUsage, + WorkItemRunRetryDisposition, WorkItemRunStatus, WorkItemRunTarget, WorkItemRunTargetSnapshot, + WorkItemRunUsage, }; use crate::work_service; @@ -11,7 +14,7 @@ use super::dispatch::leased_run_id; use super::enqueue::enqueue; use super::path_lock::release_path_lock; use super::read::read; -use super::store::{append_audit, db, require_run}; +use super::store::{append_audit, db, require_run, scope_key}; use super::{error, WorkItemRunTerminalOutcome}; const REVIEW_PROJECTION_SETTLED_OPERATION: &str = "work_run.review_projection_settled"; @@ -34,16 +37,12 @@ pub fn classify_failure(message: &str, has_session: bool) -> WorkItemRunFailure false, WorkItemRunRetryDisposition::DoNotRetry, ) - } else if normalized.contains("context length") - || normalized.contains("context window") - || normalized.contains("too many tokens") - || normalized.contains("maximum context") - { + } else if is_context_exhausted_message(message) { ( WorkItemRunFailureClass::ContextOverflow, "context_overflow", false, - WorkItemRunRetryDisposition::ManualReview, + WorkItemRunRetryDisposition::StartNewSession, ) } else if normalized.contains("unauthorized") || normalized.contains("authentication") @@ -182,6 +181,233 @@ pub fn classify_failure(message: &str, has_session: bool) -> WorkItemRunFailure } } +/// Return the target snapshot from the latest episode attached to a Session +/// only when that episode exhausted the provider context window. +pub(crate) fn context_exhausted_session_snapshot_in( + connection: &Connection, + session_id: &str, +) -> Result, String> { + let row = db(connection + .query_row( + "SELECT status, failure_json, target_json + FROM pm_work_item_runs + WHERE session_id = ?1 + ORDER BY updated_at DESC, created_at DESC + LIMIT 1", + params![session_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, String>(2)?, + )) + }, + ) + .optional())?; + let Some((status, failure_json, target_json)) = row else { + return Ok(None); + }; + if status != "failed" { + return Ok(None); + } + let Some(failure_json) = failure_json else { + return Ok(None); + }; + let failure: WorkItemRunFailure = serde_json::from_str(&failure_json) + .map_err(|err| format!("work run context failure snapshot: {err}"))?; + if failure.class != WorkItemRunFailureClass::ContextOverflow { + return Ok(None); + } + serde_json::from_str(&target_json) + .map(Some) + .map_err(|err| format!("work run context target snapshot: {err}")) +} + +struct AssigneeEscalationEvidence<'a> { + session_id: Option<&'a str>, + agent_definition_id: Option<&'a str>, + target_snapshot: Option<&'a WorkItemRunTargetSnapshot>, +} + +fn same_bound_id(left: Option<&str>, right: Option<&str>) -> bool { + matches!((left, right), (Some(left), Some(right)) if left == right) +} + +fn escalation_matches_evidence( + deferred: &WorkItemRunTargetSnapshot, + evidence: &AssigneeEscalationEvidence<'_>, +) -> bool { + if let ( + WorkItemRunTarget::ResumeSession { + session_id: deferred_session, + }, + Some(evidence_session), + ) = (&deferred.target, evidence.session_id) + { + if deferred_session == evidence_session { + return true; + } + } + + if same_bound_id( + deferred.agent_definition_id.as_deref(), + evidence.agent_definition_id, + ) { + return true; + } + + evidence.target_snapshot.is_some_and(|target| { + same_bound_id( + deferred.agent_definition_id.as_deref(), + target.agent_definition_id.as_deref(), + ) || same_bound_id( + deferred.agent_org_id.as_deref(), + target.agent_org_id.as_deref(), + ) + }) +} + +fn latest_target_for_session_in( + connection: &Connection, + session_id: &str, +) -> Result, String> { + let raw = db(connection + .query_row( + "SELECT target_json FROM pm_work_item_runs + WHERE session_id = ?1 + ORDER BY updated_at DESC, created_at DESC + LIMIT 1", + params![session_id], + |row| row.get::<_, String>(0), + ) + .optional())?; + raw.map(|raw| { + serde_json::from_str(&raw).map_err(|error| format!("work run target snapshot: {error}")) + }) + .transpose() +} + +fn cancel_pending_assignee_escalations_matching( + project_slug: Option<&str>, + org_id: &str, + work_item_id: &str, + reason: &str, + evidence: AssigneeEscalationEvidence<'_>, +) -> Result { + let mut connection = conn()?; + let tx = db(connection.transaction_with_behavior(TransactionBehavior::Immediate))?; + let scope = scope_key(project_slug, org_id); + let mut statement = db(tx.prepare( + "SELECT r.id, r.target_json + FROM pm_work_item_runs r + JOIN pm_dispatch_outbox d ON d.run_id = r.id + WHERE r.scope_key = ?1 AND r.work_item_id = ?2 + AND r.status = 'queued' AND d.status = 'pending' + AND r.input_json LIKE '%\"discussionWakeReason\":\"assignee_deferred\"%'", + ))?; + let candidates = db(statement.query_map(params![scope, work_item_id], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }))? + .collect::, _>>() + .map_err(|error| format!("work run deferred escalation query: {error}"))?; + drop(statement); + + let now = now_ms(); + let mut cancelled = 0usize; + for (run_id, target_json) in candidates { + let deferred: WorkItemRunTargetSnapshot = serde_json::from_str(&target_json) + .map_err(|error| format!("work run deferred escalation target: {error}"))?; + if !escalation_matches_evidence(&deferred, &evidence) { + continue; + } + let outbox_changed = db(tx.execute( + "UPDATE pm_dispatch_outbox + SET status = 'cancelled', updated_at = ?2 + WHERE run_id = ?1 AND status = 'pending'", + params![run_id, now], + ))?; + if outbox_changed == 0 { + continue; + } + let run_changed = db(tx.execute( + "UPDATE pm_work_item_runs + SET status = 'cancelled', completed_at = ?2, updated_at = ?2 + WHERE id = ?1 AND status = 'queued'", + params![run_id, now], + ))?; + if run_changed == 0 { + return Err(format!( + "{}:{} changed while cancelling deferred escalation", + error::INVALID_TRANSITION, + run_id + )); + } + let run = require_run(&tx, &run_id)?; + append_audit( + &tx, + &run_id, + "work_run.assignee_escalation_cancelled", + run.generation as i64, + run.project_slug.as_deref(), + &run.org_id, + serde_json::json!({ "reason": reason }), + )?; + cancelled += 1; + } + db(tx.commit())?; + if cancelled > 0 { + crate::projects::events::notify_work_item_dispatch_ready(); + } + Ok(cancelled) +} + +pub(crate) fn cancel_pending_assignee_escalations_for_agent_reply( + project_slug: Option<&str>, + org_id: &str, + work_item_id: &str, + agent_session_id: &str, + agent_definition_id: &str, +) -> Result { + let connection = conn()?; + let reply_target = latest_target_for_session_in(&connection, agent_session_id)?; + drop(connection); + cancel_pending_assignee_escalations_matching( + project_slug, + org_id, + work_item_id, + "agent_reply", + AssigneeEscalationEvidence { + session_id: Some(agent_session_id), + agent_definition_id: Some(agent_definition_id), + target_snapshot: reply_target.as_ref(), + }, + ) +} + +fn cancel_assignee_escalations_after_terminal(run: &WorkItemRun) { + if !run.status.is_terminal() { + return; + } + if let Err(error) = cancel_pending_assignee_escalations_matching( + run.project_slug.as_deref(), + &run.org_id, + &run.work_item_id, + "work_item_run_terminal", + AssigneeEscalationEvidence { + session_id: run.session_id.as_deref(), + agent_definition_id: run.target_snapshot.agent_definition_id.as_deref(), + target_snapshot: Some(&run.target_snapshot), + }, + ) { + tracing::warn!( + run_id = %run.id, + work_item_id = %run.work_item_id, + error = %error, + "failed to cancel deferred assignee escalation after terminal Run" + ); + } +} + /// 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( @@ -259,6 +485,7 @@ pub fn record_dispatch_failure( db(tx.commit())?; crate::projects::events::notify_work_item_dispatch_ready(); let persisted = read(&run_id)?; + cancel_assignee_escalations_after_terminal(&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"); } @@ -310,10 +537,22 @@ pub fn record_run_terminal( ), } } + cancel_assignee_escalations_after_terminal(&existing); return Ok(existing); } let (status, failure) = match outcome { + WorkItemRunTerminalOutcome::Succeeded + if error_message.is_some_and(is_context_exhausted_message) => + { + ( + WorkItemRunStatus::Failed, + Some(classify_failure( + error_message.expect("guarded context overflow message"), + true, + )), + ) + } WorkItemRunTerminalOutcome::Succeeded => (WorkItemRunStatus::Succeeded, None), WorkItemRunTerminalOutcome::Failed => ( WorkItemRunStatus::Failed, @@ -372,6 +611,7 @@ pub fn record_run_terminal( db(tx.commit())?; crate::projects::events::notify_work_item_dispatch_ready(); let persisted = read(run_id)?; + cancel_assignee_escalations_after_terminal(&persisted); if persisted.status == WorkItemRunStatus::Succeeded { project_succeeded_run_for_review(&persisted); } @@ -535,10 +775,33 @@ pub fn mark_waiting(run_id: &str) -> Result { read(run_id) } +/// The open retry episode already spawned from `parent_run_id`, if any. +fn open_retry_child(parent_run_id: &str) -> Result, String> { + let connection = conn()?; + let child_id: Option = db(connection + .query_row( + "SELECT id FROM pm_work_item_runs + WHERE parent_run_id = ?1 + AND status IN ('queued', 'deferred', 'dispatching', 'running') + ORDER BY created_at DESC, id DESC + LIMIT 1", + params![parent_run_id], + |row| row.get(0), + ) + .optional())?; + child_id + .map(|child_id| require_run(&connection, &child_id)) + .transpose() +} + /// Create the next execution episode from a failed Run according to the -/// typed failure policy. This never mutates or reopens the previous Run. +/// typed failure policy. Repeated retry requests converge on the same open +/// child instead of stacking duplicate episodes. pub fn retry(run_id: &str, idempotency_key: &str) -> Result { let previous = read(run_id)?; + if let Some(existing) = open_retry_child(&previous.id)? { + return Ok(existing); + } if previous.status != WorkItemRunStatus::Failed { return Err(format!( "{}:{} is not failed", 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 eb35c09235..4f23089c20 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 @@ -5,6 +5,7 @@ use crate::projects::types::{ WorkItemRunTrigger, }; use crate::work_service::{self, CreateWorkItemRequest}; +use rusqlite::params; use test_helpers::test_env; fn seed() { @@ -204,6 +205,41 @@ fn path_lock_serializes_runs_until_terminal_release() { assert_eq!(second_lease.run.id, second.id); } +#[test] +fn retry_converges_on_the_open_child_run() { + let _sandbox = test_env::sandbox(); + seed(); + let run = enqueue(request("manual:retry:1")).expect("enqueue"); + let lease = claim_next_dispatch("worker-1", 30_000) + .expect("claim") + .expect("lease"); + acknowledge_dispatch_started(&lease.dispatch_id, &lease.lease_token, "session-1") + .expect("start"); + record_run_terminal( + &run.id, + Some("session-1"), + WorkItemRunTerminalOutcome::Failed, + WorkItemRunUsage::default(), + Some("request timed out"), + ) + .expect("fail"); + + let first = retry(&run.id, "retry:a").expect("first retry"); + let second = retry(&run.id, "retry:b").expect("second retry converges"); + assert_eq!(first.id, second.id); + assert_eq!(first.parent_run_id.as_deref(), Some(run.id.as_str())); + + let connection = conn().expect("connection"); + let children: i64 = connection + .query_row( + "SELECT COUNT(*) FROM pm_work_item_runs WHERE parent_run_id = ?1", + params![run.id], + |row| row.get(0), + ) + .expect("child count"); + assert_eq!(children, 1); +} + #[test] fn enqueue_is_atomic_and_idempotent() { let _sandbox = test_env::sandbox(); @@ -711,7 +747,64 @@ fn failure_classifier_is_conservative_and_typed() { assert_eq!(quota.class, WorkItemRunFailureClass::Quota); assert!(!quota.retryable); + let context = classify_failure( + r#"{\"terminal_reason\":\"prompt_too_long\",\"message\":\"provider stopped\"}"#, + true, + ); + assert_eq!(context.class, WorkItemRunFailureClass::ContextOverflow); + assert!(!context.retryable); + assert_eq!( + context.retry_disposition, + WorkItemRunRetryDisposition::StartNewSession + ); + + let cli_context = classify_failure("Error: prompt is too long", true); + assert_eq!(cli_context.class, WorkItemRunFailureClass::ContextOverflow); + + for non_context in [ + "failed while documenting context window behavior", + &format!("{} prompt is too long", "x".repeat(321)), + ] { + assert_eq!( + classify_failure(non_context, true).class, + WorkItemRunFailureClass::Unknown, + "message={non_context}" + ); + } + let unknown = classify_failure("something surprising", false); assert_eq!(unknown.class, WorkItemRunFailureClass::Unknown); assert!(!unknown.retryable); } + +#[test] +fn structured_context_terminal_overrides_provider_false_success() { + let _sandbox = test_env::sandbox(); + seed(); + let run = enqueue(request("manual:false-success-context")).expect("enqueue"); + let lease = claim_next_dispatch("desktop-context", 30_000) + .expect("claim") + .expect("dispatch"); + acknowledge_dispatch_started( + &lease.dispatch_id, + &lease.lease_token, + "session-false-success-context", + ) + .expect("ack"); + + let terminal = record_run_terminal( + &run.id, + Some("session-false-success-context"), + WorkItemRunTerminalOutcome::Succeeded, + WorkItemRunUsage::default(), + Some(r#"{"terminal_reason":"prompt_too_long"}"#), + ) + .expect("terminal"); + assert_eq!(terminal.status, WorkItemRunStatus::Failed); + let failure = terminal.failure.expect("typed failure"); + assert_eq!(failure.class, WorkItemRunFailureClass::ContextOverflow); + assert_eq!( + failure.retry_disposition, + WorkItemRunRetryDisposition::StartNewSession + ); +} diff --git a/src-tauri/crates/project-management/src/work_service/error.rs b/src-tauri/crates/project-management/src/work_service/error.rs index c3a6af3b24..0103b49879 100644 --- a/src-tauri/crates/project-management/src/work_service/error.rs +++ b/src-tauri/crates/project-management/src/work_service/error.rs @@ -7,7 +7,10 @@ pub const IDEMPOTENCY_CONFLICT: &str = "PM_ERR:IDEMPOTENCY_CONFLICT"; pub const ALREADY_EXISTS: &str = "PM_ERR:ALREADY_EXISTS"; pub fn revision_conflict(expected: i64, current: i64) -> String { - format!("{}:{}:{}", REVISION_CONFLICT, expected, current) + format!( + "{}:expected={}:actual={}", + REVISION_CONFLICT, expected, current + ) } pub fn invalid_transition(from: &str, to: &str) -> String { 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 0ff3880d79..77769a3d00 100644 --- a/src-tauri/crates/project-management/src/work_service/mod.rs +++ b/src-tauri/crates/project-management/src/work_service/mod.rs @@ -19,6 +19,7 @@ mod notes; mod relations; mod run_review; pub mod state; +pub mod timeline; #[cfg(test)] #[path = "tests.rs"] diff --git a/src-tauri/crates/project-management/src/work_service/notes.rs b/src-tauri/crates/project-management/src/work_service/notes.rs index b1f9fe3753..707cc25c29 100644 --- a/src-tauri/crates/project-management/src/work_service/notes.rs +++ b/src-tauri/crates/project-management/src/work_service/notes.rs @@ -9,11 +9,12 @@ pub fn note_project_work_item( body: &str, actor: Option<&WorkItemMutationActor>, ) -> Result<(), String> { - note_project_work_item_threaded(project_slug, short_id, kind, body, None, actor, None) + note_project_work_item_threaded(project_slug, short_id, kind, body, None, actor, None, None) } /// Append a note as a reply in a persisted Discussion thread without waking /// the linked Session again. +#[allow(clippy::too_many_arguments)] pub fn note_project_work_item_threaded( project_slug: &str, short_id: &str, @@ -22,6 +23,7 @@ pub fn note_project_work_item_threaded( parent_id: Option<&str>, actor: Option<&WorkItemMutationActor>, agent_session_id: Option<&str>, + originator: Option<&str>, ) -> Result<(), String> { let author = actor .map(|a| a.name.clone()) @@ -34,8 +36,14 @@ pub fn note_project_work_item_threaded( let reason = Some(kind.to_string()); let body_owned = note_body; let parent_id = parent_id.map(str::to_string); + let agent_receipt = agent_session_id + .zip(actor.and_then(|value| value.id.strip_prefix("agent:"))) + .map(|(session_id, agent_definition_id)| { + (session_id.to_string(), agent_definition_id.to_string()) + }); let agent_session_id = agent_session_id.map(str::to_string); - project_io::update_work_item_atomic_serviced( + let originator = originator.map(str::to_string); + let result = project_io::update_work_item_atomic_serviced( project_slug, short_id, actor, @@ -73,11 +81,31 @@ pub fn note_project_work_item_threaded( parent_id, thread_id, agent_session_id, + originator, ..Default::default() }); Ok(()) }, - ) + ); + if let (Ok(()), Some((session_id, agent_definition_id))) = (&result, agent_receipt) { + if let Err(error) = + crate::work_run_service::cancel_pending_assignee_escalations_for_agent_reply( + Some(project_slug), + "", + short_id, + &session_id, + &agent_definition_id, + ) + { + tracing::warn!( + project_slug, + work_item_id = short_id, + error = %error, + "failed to cancel deferred assignee escalation after agent reply" + ); + } + } + result } /// Idempotent form of [`note_project_work_item`] for durable consumers. @@ -140,9 +168,10 @@ pub fn note_standalone_work_item( body: &str, actor: Option<&WorkItemMutationActor>, ) -> Result<(), String> { - note_standalone_work_item_threaded(org_id, short_id, kind, body, None, actor, None) + note_standalone_work_item_threaded(org_id, short_id, kind, body, None, actor, None, None) } +#[allow(clippy::too_many_arguments)] pub fn note_standalone_work_item_threaded( org_id: Option<&str>, short_id: &str, @@ -151,6 +180,7 @@ pub fn note_standalone_work_item_threaded( parent_id: Option<&str>, actor: Option<&WorkItemMutationActor>, agent_session_id: Option<&str>, + originator: Option<&str>, ) -> Result<(), String> { let author = actor .map(|a| a.name.clone()) @@ -161,8 +191,14 @@ pub fn note_standalone_work_item_threaded( format!("[{}] {}", kind, body) }; let parent_id = parent_id.map(str::to_string); + let agent_receipt = agent_session_id + .zip(actor.and_then(|value| value.id.strip_prefix("agent:"))) + .map(|(session_id, agent_definition_id)| { + (session_id.to_string(), agent_definition_id.to_string()) + }); let agent_session_id = agent_session_id.map(str::to_string); - project_io::update_standalone_work_item_atomic_serviced( + let originator = originator.map(str::to_string); + let result = project_io::update_standalone_work_item_atomic_serviced( org_id, actor, project_io::AtomicServiceOptions { @@ -200,11 +236,32 @@ pub fn note_standalone_work_item_threaded( parent_id, thread_id, agent_session_id, + originator, ..Default::default() }); Ok(()) }, - ) + ); + if let (Ok(()), Some((session_id, agent_definition_id))) = (&result, agent_receipt) { + let org_id = org_id.unwrap_or("personal-org"); + if let Err(error) = + crate::work_run_service::cancel_pending_assignee_escalations_for_agent_reply( + None, + org_id, + short_id, + &session_id, + &agent_definition_id, + ) + { + tracing::warn!( + org_id, + work_item_id = short_id, + error = %error, + "failed to cancel deferred standalone assignee escalation after agent reply" + ); + } + } + result } /// Standalone counterpart to [`note_project_work_item_idempotent`]. 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 a4ab3a70ce..e54c83510c 100644 --- a/src-tauri/crates/project-management/src/work_service/tests.rs +++ b/src-tauri/crates/project-management/src/work_service/tests.rs @@ -159,7 +159,10 @@ fn expected_revision_mismatch_is_a_typed_conflict() { err.starts_with(error::REVISION_CONFLICT), "unexpected error: {err}" ); - assert!(err.ends_with(":7:0"), "carries expected/current: {err}"); + assert!( + err.ends_with(":expected=7:actual=0"), + "carries expected/actual: {err}" + ); let unchanged = read_work_item("demo", "AAA-0001").expect("read"); assert_eq!(unchanged.frontmatter.status, "backlog"); @@ -505,3 +508,150 @@ fn standalone_note_audits_as_work_note() { assert_eq!(operation, "work.note"); assert!(work_item_noted_by_actor_since("SA-0001", "agent:os", before_ms).expect("query")); } + +#[test] +fn threaded_note_stamps_the_originator_chain_on_the_comment() { + let _sandbox = test_env::sandbox(); + let fm = work_item_fixture("SA-0002", "SA-0002", "Originator probe"); + crate::projects::io::write_standalone_work_item(None, "SA-0002", &fm, "body") + .expect("seed standalone item"); + let actor = crate::projects::types::WorkItemMutationActor { + id: "agent:builtin:sde".to_string(), + name: "sde".to_string(), + }; + + note_standalone_work_item_threaded( + None, + "SA-0002", + "comment", + "reporting back", + None, + Some(&actor), + Some("session-orig"), + Some("member:m-42"), + ) + .expect("note with originator"); + + let item = crate::projects::io::read_standalone_work_item(None, "SA-0002").expect("read"); + let comment = item.frontmatter.comments.last().expect("comment appended"); + assert_eq!(comment.originator.as_deref(), Some("member:m-42")); + assert_eq!(comment.agent_session_id.as_deref(), Some("session-orig")); + + let wire = serde_json::to_value(comment).expect("wire"); + assert_eq!(wire["originator"], "member:m-42"); + + note_standalone_work_item(None, "SA-0002", "comment", "no chain", Some(&actor)) + .expect("note without originator"); + let item = crate::projects::io::read_standalone_work_item(None, "SA-0002").expect("read"); + let plain = item.frontmatter.comments.last().expect("second comment"); + assert!(plain.originator.is_none()); + let wire = serde_json::to_value(plain).expect("wire"); + assert!(wire.get("originator").is_none(), "{wire}"); +} + +#[test] +fn timeline_merges_history_and_live_comments_in_time_order() { + use crate::projects::types::{ + CommentEntry, WorkItemHistoryAction, WorkItemHistoryChange, WorkItemHistoryEvent, + }; + use crate::work_service::timeline::{work_item_timeline, TimelineEntry, TimelineFilter}; + + let _sandbox = test_env::sandbox(); + seed("demo", "p1"); + let mut item = crate::projects::io::read_work_item("demo", "AAA-0001").expect("work item"); + let history = |id: &str, at: &str, field: &str| WorkItemHistoryEvent { + id: id.to_string(), + action: WorkItemHistoryAction::Updated, + timestamp: at.to_string(), + actor_id: Some("user-a".to_string()), + actor_name: Some("Alice".to_string()), + changes: vec![WorkItemHistoryChange { + field: field.to_string(), + old_value: serde_json::json!("backlog"), + new_value: serde_json::json!("in_progress"), + }], + summary: None, + }; + let comment = |id: &str, at: &str, deleted: bool| CommentEntry { + id: id.to_string(), + author: "user-b".to_string(), + content: format!("body {id}"), + created_at: at.to_string(), + revision: 0, + mentioned_user_ids: Vec::new(), + mentions: Vec::new(), + parent_id: None, + thread_id: Some(id.to_string()), + resolved_at: None, + resolved_by: None, + conclusion: false, + agent_session_id: None, + originator: None, + edited_at: None, + deleted_at: deleted.then(|| "2026-08-23T09:00:00.000Z".to_string()), + }; + item.frontmatter.history = vec![ + history("h2", "2026-08-23T08:30:00.000Z", "priority"), + history("h1", "2026-08-23T08:00:00.000Z", "status"), + ]; + item.frontmatter.comments = vec![ + comment("c1", "2026-08-23T08:15:00.000Z", false), + comment("c-gone", "2026-08-23T08:20:00.000Z", true), + comment("c2", "2026-08-23T08:45:00+00:00", false), + ]; + + let ids = |entries: &[TimelineEntry]| { + entries + .iter() + .map(|entry| match entry { + TimelineEntry::Activity { id, .. } | TimelineEntry::Comment { id, .. } => { + id.clone() + } + }) + .collect::>() + }; + + let all = work_item_timeline(&item, TimelineFilter::default()); + assert_eq!(ids(&all), vec!["h1", "c1", "h2", "c2"]); + + let since = work_item_timeline( + &item, + TimelineFilter { + since: Some("2026-08-23T08:30:00Z"), + ..Default::default() + }, + ); + assert_eq!(ids(&since), vec!["h2", "c2"]); + + let tail = work_item_timeline( + &item, + TimelineFilter { + tail: Some(1), + ..Default::default() + }, + ); + assert_eq!(ids(&tail), vec!["c2"]); + + let activity = work_item_timeline( + &item, + TimelineFilter { + activity_only: true, + ..Default::default() + }, + ); + assert_eq!(ids(&activity), vec!["h1", "h2"]); + + let comments = work_item_timeline( + &item, + TimelineFilter { + comments_only: true, + ..Default::default() + }, + ); + assert_eq!(ids(&comments), vec!["c1", "c2"]); + + let wire = serde_json::to_value(&all[1]).expect("wire"); + assert_eq!(wire["kind"], "comment", "{wire}"); + assert_eq!(wire["author"], "user-b"); + assert_eq!(wire["threadId"], "c1", "{wire}"); +} diff --git a/src-tauri/crates/project-management/src/work_service/timeline.rs b/src-tauri/crates/project-management/src/work_service/timeline.rs new file mode 100644 index 0000000000..8e5616905b --- /dev/null +++ b/src-tauri/crates/project-management/src/work_service/timeline.rs @@ -0,0 +1,134 @@ +//! Merged Work Item timeline: field history and Discussion comments in one +//! time-ordered stream for the `org2-pm work timeline` CLI. + +use serde::Serialize; + +use crate::projects::types::{CommentEntry, WorkItemData, WorkItemHistoryEvent}; + +#[derive(Debug, Clone, Copy, Default)] +pub struct TimelineFilter<'a> { + pub since: Option<&'a str>, + pub tail: Option, + pub activity_only: bool, + pub comments_only: bool, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "snake_case", tag = "kind")] +pub enum TimelineEntry { + #[serde(rename_all = "camelCase")] + Activity { + id: String, + at: String, + #[serde(skip_serializing_if = "Option::is_none")] + actor_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + actor_name: Option, + action: crate::projects::types::WorkItemHistoryAction, + #[serde(skip_serializing_if = "Option::is_none")] + summary: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + changes: Vec, + }, + #[serde(rename_all = "camelCase")] + Comment { + id: String, + at: String, + author: String, + content: String, + #[serde(skip_serializing_if = "Option::is_none")] + parent_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + thread_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + originator: Option, + #[serde(skip_serializing_if = "Option::is_none")] + resolved_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + edited_at: Option, + }, +} + +impl TimelineEntry { + fn at(&self) -> &str { + match self { + TimelineEntry::Activity { at, .. } | TimelineEntry::Comment { at, .. } => at, + } + } + + fn from_history(event: &WorkItemHistoryEvent) -> Self { + TimelineEntry::Activity { + id: event.id.clone(), + at: event.timestamp.clone(), + actor_id: event.actor_id.clone(), + actor_name: event.actor_name.clone(), + action: event.action.clone(), + summary: event.summary.clone(), + changes: event.changes.clone(), + } + } + + fn from_comment(comment: &CommentEntry) -> Self { + TimelineEntry::Comment { + id: comment.id.clone(), + at: comment.created_at.clone(), + author: comment.author.clone(), + content: comment.content.clone(), + parent_id: comment.parent_id.clone(), + thread_id: comment.thread_id.clone(), + originator: comment.originator.clone(), + resolved_at: comment.resolved_at.clone(), + edited_at: comment.edited_at.clone(), + } + } +} + +fn instant_ms(raw: &str) -> Option { + chrono::DateTime::parse_from_rfc3339(raw) + .ok() + .map(|value| value.timestamp_millis()) +} + +fn is_at_or_after(at: &str, since: &str) -> bool { + match (instant_ms(at), instant_ms(since)) { + (Some(at), Some(since)) => at >= since, + _ => at >= since, + } +} + +/// Stable sort keeps history ahead of comments for equal instants so the +/// stream mirrors the desktop timeline ordering. +pub fn work_item_timeline(item: &WorkItemData, filter: TimelineFilter<'_>) -> Vec { + let mut entries = Vec::new(); + if !filter.comments_only { + entries.extend( + item.frontmatter + .history + .iter() + .map(TimelineEntry::from_history), + ); + } + if !filter.activity_only { + entries.extend( + item.frontmatter + .comments + .iter() + .filter(|comment| comment.deleted_at.is_none()) + .map(TimelineEntry::from_comment), + ); + } + entries.sort_by( + |left, right| match (instant_ms(left.at()), instant_ms(right.at())) { + (Some(left), Some(right)) => left.cmp(&right), + _ => left.at().cmp(right.at()), + }, + ); + if let Some(since) = filter.since { + entries.retain(|entry| is_at_or_after(entry.at(), since)); + } + if let Some(tail) = filter.tail { + let skip = entries.len().saturating_sub(tail); + entries.drain(..skip); + } + entries +} diff --git a/src/api/http/project/adapters.test.ts b/src/api/http/project/adapters.test.ts index 60ea9b8594..4a84c0d7b3 100644 --- a/src/api/http/project/adapters.test.ts +++ b/src/api/http/project/adapters.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vitest"; -import { projectDataToUI, standaloneWorkItemDataToEnriched } from "./adapters"; +import { + normalizeWorkItemStatus, + projectDataToUI, + standaloneWorkItemDataToEnriched, + uiWorkItemToFrontmatter, + workItemCommentToEntry, +} from "./adapters"; import type { LinkedSession, ProjectData, @@ -134,3 +140,68 @@ describe("projectDataToUI", () => { expect(project).not.toHaveProperty("syncConnectionId"); }); }); + +describe("normalizeWorkItemStatus", () => { + it("preserves custom and blocked status identities", () => { + expect(normalizeWorkItemStatus("waiting_external")).toBe( + "waiting_external" + ); + expect(normalizeWorkItemStatus("blocked")).toBe("blocked"); + }); + + it("uses backlog only when no status exists", () => { + expect(normalizeWorkItemStatus(undefined)).toBe("backlog"); + }); +}); + +describe("workItemCommentToEntry", () => { + it("preserves the complete per-comment identity and revision payload", () => { + const comment = { + id: "comment-1", + author: "member-1", + content: "Updated body", + created_at: "2026-08-19T10:00:00.000Z", + revision: 3, + mentioned_user_ids: ["member-2"], + mentions: [{ kind: "member" as const, id: "member-2" }], + parent_id: "comment-parent", + thread_id: "thread-1", + resolved_at: "2026-08-19T10:05:00.000Z", + resolved_by: "member-3", + conclusion: true, + agent_session_id: "session-1", + edited_at: "2026-08-19T10:03:00.000Z", + deleted_at: "2026-08-19T10:06:00.000Z", + }; + + expect(workItemCommentToEntry(comment)).toEqual(comment); + }); +}); + +describe("uiWorkItemToFrontmatter custom statuses", () => { + it("keeps a custom status key on the outbound frontmatter instead of dropping it", () => { + const frontmatter = uiWorkItemToFrontmatter( + { + session_id: "wi-1", + name: "Custom status item", + workItemStatus: "code-review" as never, + priority: "none", + } as never, + undefined as never + ); + expect(frontmatter.status).toBe("code-review"); + }); + + it("still maps built-in statuses through the file vocabulary", () => { + const frontmatter = uiWorkItemToFrontmatter( + { + session_id: "wi-2", + name: "Builtin status item", + workItemStatus: "in_progress", + priority: "none", + } as never, + undefined as never + ); + expect(frontmatter.status).toBe("in_progress"); + }); +}); diff --git a/src/api/http/project/adapters.ts b/src/api/http/project/adapters.ts index 740d77adfd..369d924b3e 100644 --- a/src/api/http/project/adapters.ts +++ b/src/api/http/project/adapters.ts @@ -16,11 +16,13 @@ import type { import { GITHUB_ISSUE_STATUS, type WorkItem as UIWorkItem, + type WorkItemComment, type WorkItemPriority, type WorkItemStatus, } from "@src/types/core/workItem"; import type { + CommentEntry, EnrichedWorkItem, LabelEntry, MemberEntry, @@ -29,6 +31,30 @@ import type { WorkItemFrontmatter, } from "./types"; +/** Preserve the complete Discussion identity/concurrency payload on any + * compatibility whole-row writer. Direct Discussion commands remain the + * canonical mutation path. */ +export function workItemCommentToEntry(comment: WorkItemComment): CommentEntry { + return { + id: comment.id, + author: comment.author, + content: comment.content, + created_at: comment.created_at, + revision: comment.revision, + mentioned_user_ids: comment.mentioned_user_ids, + mentions: comment.mentions, + 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, + originator: comment.originator, + edited_at: comment.edited_at, + deleted_at: comment.deleted_at, + }; +} + // ============================================ // Validation // ============================================ @@ -70,14 +96,20 @@ function validateEnum( return fallback; } -function mapWorkItemStatus(status: string | undefined): WorkItemStatus { +/** Preserve custom status keys while normalizing the two external GitHub + * states. Category semantics are resolved separately from the cached org + * definitions; collapsing unknown keys to backlog loses that identity. */ +export function normalizeWorkItemStatus( + status: string | undefined +): WorkItemStatus { if ( status === GITHUB_ISSUE_STATUS.OPEN || status === GITHUB_ISSUE_STATUS.CLOSED ) { return status; } - return FILE_TO_UI_STATUS[status ?? ""] ?? "backlog"; + if (!status) return "backlog"; + return FILE_TO_UI_STATUS[status] ?? (status as WorkItemStatus); } // ============================================ @@ -139,6 +171,7 @@ const FILE_TO_UI_STATUS: Record = { todo: "planned", in_progress: "in_progress", in_review: "in_review", + blocked: "blocked", completed: "completed", cancelled: "cancelled", duplicate: "duplicate", @@ -149,6 +182,7 @@ const UI_TO_FILE_STATUS: Record = { planned: "planned", in_progress: "in_progress", in_review: "in_review", + blocked: "blocked", completed: "completed", cancelled: "cancelled", duplicate: "duplicate", @@ -218,6 +252,7 @@ export function projectDataToUI( planned: 0, in_progress: 0, in_review: 0, + blocked: 0, completed: 0, cancelled: 0, }, @@ -255,6 +290,7 @@ export function workItemDataToUI( return { session_id: frontmatter.id, + revision: itemData.revision, shortId: frontmatter.short_id, user_id: frontmatter.created_by ?? "", name: frontmatter.title, @@ -264,7 +300,7 @@ export function workItemDataToUI( star: frontmatter.starred, spec: itemData.body, status: frontmatter.status, - workItemStatus: mapWorkItemStatus(frontmatter.status), + workItemStatus: normalizeWorkItemStatus(frontmatter.status), priority: validateEnum( frontmatter.priority, VALID_WORK_ITEM_PRIORITIES, @@ -318,6 +354,7 @@ export function standaloneWorkItemDataToEnriched( title: frontmatter.title, body: itemData.body, filename: itemData.filename, + revision: itemData.revision ?? 0, status: frontmatter.status, priority: frontmatter.priority, starred: frontmatter.starred, @@ -375,12 +412,7 @@ export function uiWorkItemToFrontmatter( existingFrontmatter?.todos ?? []; const resolvedComments = - workItem.comments?.map((comment) => ({ - id: comment.id, - author: comment.author, - content: comment.content, - created_at: comment.created_at, - })) ?? + workItem.comments?.map(workItemCommentToEntry) ?? existingFrontmatter?.comments ?? []; @@ -390,7 +422,7 @@ export function uiWorkItemToFrontmatter( title: workItem.name, project: workItem.project?.id, status: workItem.workItemStatus - ? UI_TO_FILE_STATUS[workItem.workItemStatus] + ? (UI_TO_FILE_STATUS[workItem.workItemStatus] ?? workItem.workItemStatus) : (existingFrontmatter?.status ?? "backlog"), priority: workItem.priority ?? "none", assignee: workItem.assignee?.id, @@ -455,6 +487,7 @@ export function buildMemberMap( export function enrichedWorkItemToUI(item: EnrichedWorkItem): UIWorkItem { return { session_id: item.id, + revision: item.revision, shortId: item.shortId, user_id: item.createdBy ?? "", name: item.title, @@ -465,7 +498,7 @@ export function enrichedWorkItemToUI(item: EnrichedWorkItem): UIWorkItem { star: item.starred, spec: item.body, status: item.status, - workItemStatus: mapWorkItemStatus(item.status), + workItemStatus: normalizeWorkItemStatus(item.status), priority: validateEnum( item.priority, VALID_WORK_ITEM_PRIORITIES, diff --git a/src/api/http/project/client/discussions.ts b/src/api/http/project/client/discussions.ts index 2ba3e5c017..80e814241b 100644 --- a/src/api/http/project/client/discussions.ts +++ b/src/api/http/project/client/discussions.ts @@ -89,3 +89,38 @@ export async function getWorkItemPrReadiness( ): Promise { return invoke("project_get_work_item_pr_readiness", { scope }); } + +export async function editDiscussionComment(input: { + scope: WorkItemScope; + commentId: string; + actorId: string; + content: string; + expectedRevision?: number; +}): Promise { + const { scope, ...mutation } = input; + try { + return await invoke( + "project_discussion_edit_comment", + { request: { ...scope, ...mutation } } + ); + } finally { + invalidateCache(); + } +} + +export async function deleteDiscussionComment(input: { + scope: WorkItemScope; + commentId: string; + actorId: string; + expectedRevision?: number; +}): Promise { + const { scope, ...mutation } = input; + try { + return await invoke( + "project_discussion_delete_comment", + { request: { ...scope, ...mutation } } + ); + } finally { + invalidateCache(); + } +} diff --git a/src/api/http/project/client/index.ts b/src/api/http/project/client/index.ts index 8c0306eead..5225d109da 100644 --- a/src/api/http/project/client/index.ts +++ b/src/api/http/project/client/index.ts @@ -17,8 +17,11 @@ export * from "./members"; export * from "./milestones"; export * from "./orgs"; export * from "./projects"; +export * from "./quickActions"; export * from "./routineWebhooks"; export * from "./routines"; +export * from "./savedViews"; +export * from "./statusDefinitions"; export * from "./workItemProperties"; export * from "./workItems"; export * from "./workRuns"; diff --git a/src/api/http/project/client/quickActions.ts b/src/api/http/project/client/quickActions.ts new file mode 100644 index 0000000000..e54a70f580 --- /dev/null +++ b/src/api/http/project/client/quickActions.ts @@ -0,0 +1,45 @@ +/** + * Org-scoped Quick Actions: saved mention-comment templates that wake the + * discussion route on a work item. + */ +import { invoke } from "@tauri-apps/api/core"; + +import { invalidateCache } from "../cache"; +import type { + DiscussionPostResult, + QuickAction, + UpsertQuickActionRequest, +} from "../types"; + +export async function listQuickActions(orgId: string): Promise { + return invoke("project_list_quick_actions", { orgId }); +} + +export async function upsertQuickAction( + request: UpsertQuickActionRequest +): Promise { + return invoke("project_upsert_quick_action", { request }); +} + +export async function archiveQuickAction( + orgId: string, + id: string +): Promise { + return invoke("project_archive_quick_action", { orgId, id }); +} + +export async function invokeQuickAction(input: { + projectSlug: string | null; + orgId: string; + workItemId: string; + actionId: string; + actorId: string; + actorName: string; +}): Promise { + const result = await invoke( + "project_invoke_quick_action", + { request: input } + ); + invalidateCache(); + return result; +} diff --git a/src/api/http/project/client/savedViews.ts b/src/api/http/project/client/savedViews.ts new file mode 100644 index 0000000000..d966122ec8 --- /dev/null +++ b/src/api/http/project/client/savedViews.ts @@ -0,0 +1,29 @@ +/** + * Org-shared saved views over the work item Table / Board. + */ +import { invoke } from "@tauri-apps/api/core"; + +import type { SavedView, UpsertSavedViewRequest } from "../types"; + +export async function listSavedViews( + orgId: string, + projectSlug?: string | null +): Promise { + return invoke("project_list_saved_views", { + orgId, + projectSlug: projectSlug ?? null, + }); +} + +export async function upsertSavedView( + request: UpsertSavedViewRequest +): Promise { + return invoke("project_upsert_saved_view", { request }); +} + +export async function archiveSavedView( + orgId: string, + id: string +): Promise { + return invoke("project_archive_saved_view", { orgId, id }); +} diff --git a/src/api/http/project/client/statusDefinitions.ts b/src/api/http/project/client/statusDefinitions.ts new file mode 100644 index 0000000000..121257d3f9 --- /dev/null +++ b/src/api/http/project/client/statusDefinitions.ts @@ -0,0 +1,41 @@ +/** + * Org-scoped custom status catalog (pm_status_definitions). + */ +import { invoke } from "@tauri-apps/api/core"; + +import { invalidateCache } from "../cache"; +import type { StatusDefinition, UpsertStatusDefinitionRequest } from "../types"; + +export async function listStatusDefinitions( + orgId: string, + includeArchived = false +): Promise { + return invoke("project_list_status_definitions", { + orgId, + includeArchived, + }); +} + +export async function upsertStatusDefinition( + request: UpsertStatusDefinitionRequest +): Promise { + const result = await invoke( + "project_upsert_status_definition", + { request } + ); + invalidateCache(); + return result; +} + +export async function setStatusDefinitionArchived( + orgId: string, + id: string, + archived: boolean +): Promise { + const result = await invoke( + "project_set_status_definition_archived", + { orgId, id, archived } + ); + invalidateCache(); + return result; +} diff --git a/src/api/http/project/client/workItemProperties.ts b/src/api/http/project/client/workItemProperties.ts index 35cbb9fa73..1785d70205 100644 --- a/src/api/http/project/client/workItemProperties.ts +++ b/src/api/http/project/client/workItemProperties.ts @@ -3,6 +3,7 @@ */ import { invoke } from "@tauri-apps/api/core"; +import { invalidateCache } from "../cache"; import type { PropertyDefinition, UpsertPropertyDefinitionRequest, @@ -47,3 +48,34 @@ export async function setWorkItemPropertyValue( request: { ...scope, propertyId, value }, }); } + +export async function listScopePropertyValues( + orgId: string, + projectSlug?: string | null +): Promise { + return invoke("project_list_scope_property_values", { + orgId, + projectSlug: projectSlug ?? null, + }); +} + +export async function batchSetWorkItemPropertyValue(input: { + orgId: string; + projectSlug?: string | null; + shortIds: string[]; + propertyId: string; + value: unknown | null; +}): Promise { + const result = await invoke( + "project_batch_set_work_item_property_value", + { + orgId: input.orgId, + projectSlug: input.projectSlug ?? null, + shortIds: input.shortIds, + propertyId: input.propertyId, + value: input.value, + } + ); + invalidateCache(input.projectSlug ?? undefined); + return result; +} diff --git a/src/api/http/project/client/workItems.test.ts b/src/api/http/project/client/workItems.test.ts index c370f1b222..6069a8d19e 100644 --- a/src/api/http/project/client/workItems.test.ts +++ b/src/api/http/project/client/workItems.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { deleteDiscussionComment, editDiscussionComment } from "./discussions"; import { readWorkspaceWorkItemsData } from "./workItems"; const { invokeMock } = vi.hoisted(() => ({ @@ -49,4 +50,51 @@ describe("project client", () => { expect.objectContaining({ readBucket: "active" }) ); }); + + it("passes per-comment revision preconditions through the IPC boundary", async () => { + invokeMock.mockResolvedValue([]); + const scope = { + projectSlug: "demo", + orgId: "personal-org", + workItemId: "WI-1", + }; + + await editDiscussionComment({ + scope, + commentId: "comment-1", + actorId: "member-1", + content: "my version", + expectedRevision: 4, + }); + expect(invokeMock).toHaveBeenLastCalledWith( + "project_discussion_edit_comment", + { + request: { + ...scope, + commentId: "comment-1", + actorId: "member-1", + content: "my version", + expectedRevision: 4, + }, + } + ); + + await deleteDiscussionComment({ + scope, + commentId: "comment-1", + actorId: "member-1", + expectedRevision: 5, + }); + expect(invokeMock).toHaveBeenLastCalledWith( + "project_discussion_delete_comment", + { + request: { + ...scope, + commentId: "comment-1", + actorId: "member-1", + expectedRevision: 5, + }, + } + ); + }); }); diff --git a/src/api/http/project/client/workItems.ts b/src/api/http/project/client/workItems.ts index 54b44a96cd..fae0fc5134 100644 --- a/src/api/http/project/client/workItems.ts +++ b/src/api/http/project/client/workItems.ts @@ -345,35 +345,38 @@ export async function purgeExpiredDeletedWorkItems( export async function updateWorkItemPartial( projectSlug: string, shortId: string, - updates: WorkItemPartialUpdate + updates: WorkItemPartialUpdate, + expectedRevision?: number ): Promise { - const result = await invoke( - "project_update_work_item_partial", - { + try { + return await invoke("project_update_work_item_partial", { projectSlug, shortId, updates, - } - ); - invalidateCache(); - return result; + expectedRevision, + }); + } finally { + // A rejected CAS proves the caller's cached snapshot is stale too. + invalidateCache(); + } } export async function updateStandaloneWorkItemPartial( shortId: string, updates: WorkItemPartialUpdate, - options?: ProjectScopeOptions + options?: ProjectScopeOptions, + expectedRevision?: number ): Promise { - const result = await invoke( - "work_item_update_standalone_partial", - { + try { + return await invoke("work_item_update_standalone_partial", { ...scopeInvokePayload(options), shortId, updates, - } - ); - invalidateCache(); - return result; + expectedRevision, + }); + } finally { + invalidateCache(); + } } export async function transitionWorkItemHandoff( diff --git a/src/api/http/project/index.ts b/src/api/http/project/index.ts index d5746ff57e..9c41995f08 100644 --- a/src/api/http/project/index.ts +++ b/src/api/http/project/index.ts @@ -41,10 +41,17 @@ export { enrichedWorkItemToUI, projectDataToUI, standaloneWorkItemDataToEnriched, + uiWorkItemToFrontmatter, + workItemCommentToEntry, workItemDataToUI, } from "./adapters"; export { invalidateCache as invalidateProjectCache } from "./cache"; +export { + REVISION_CONFLICT_CODE, + parseRevisionConflict, +} from "./revisionConflict"; +export type { RevisionConflictDetails } from "./revisionConflict"; export const projectApi = { // Init @@ -100,6 +107,8 @@ export const projectApi = { retryLatestWorkItemRun: client.retryLatestWorkItemRun, previewDiscussionTrigger: client.previewDiscussionTrigger, postDiscussionComment: client.postDiscussionComment, + editDiscussionComment: client.editDiscussionComment, + deleteDiscussionComment: client.deleteDiscussionComment, resolveDiscussionThread: client.resolveDiscussionThread, reopenDiscussionThread: client.reopenDiscussionThread, listWorkItemSubscriptions: client.listWorkItemSubscriptions, @@ -109,6 +118,8 @@ export const projectApi = { upsertPropertyDefinition: client.upsertPropertyDefinition, archivePropertyDefinition: client.archivePropertyDefinition, listWorkItemPropertyValues: client.listWorkItemPropertyValues, + listScopePropertyValues: client.listScopePropertyValues, + batchSetWorkItemPropertyValue: client.batchSetWorkItemPropertyValue, setWorkItemPropertyValue: client.setWorkItemPropertyValue, updateStandaloneWorkItemPartial: client.updateStandaloneWorkItemPartial, transitionWorkItemHandoff: client.transitionWorkItemHandoff, @@ -136,6 +147,19 @@ export const projectApi = { // Batch batchDeleteWorkItems: client.batchDeleteWorkItems, batchUpdateWorkItems: client.batchUpdateWorkItems, + // Quick actions + listQuickActions: client.listQuickActions, + upsertQuickAction: client.upsertQuickAction, + archiveQuickAction: client.archiveQuickAction, + invokeQuickAction: client.invokeQuickAction, + // Saved views + listSavedViews: client.listSavedViews, + upsertSavedView: client.upsertSavedView, + archiveSavedView: client.archiveSavedView, + // Custom statuses + listStatusDefinitions: client.listStatusDefinitions, + upsertStatusDefinition: client.upsertStatusDefinition, + setStatusDefinitionArchived: client.setStatusDefinitionArchived, // Assets saveAsset: client.saveAsset, deleteAsset: client.deleteAsset, diff --git a/src/api/http/project/revisionConflict.test.ts b/src/api/http/project/revisionConflict.test.ts new file mode 100644 index 0000000000..7048255a6d --- /dev/null +++ b/src/api/http/project/revisionConflict.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { parseRevisionConflict } from "./revisionConflict"; + +describe("parseRevisionConflict", () => { + it("parses the named expected/actual contract", () => { + expect( + parseRevisionConflict( + "invoke failed: PM_ERR:REVISION_CONFLICT:expected=4:actual=7" + ) + ).toEqual({ expected: 4, actual: 7 }); + }); + + it("keeps rolling compatibility with the legacy positional contract", () => { + expect(parseRevisionConflict("PM_ERR:REVISION_CONFLICT:4:7")).toEqual({ + expected: 4, + actual: 7, + }); + }); + + it("rejects unrelated errors", () => { + expect(parseRevisionConflict("network offline")).toBeNull(); + }); +}); diff --git a/src/api/http/project/revisionConflict.ts b/src/api/http/project/revisionConflict.ts new file mode 100644 index 0000000000..4cf107e381 --- /dev/null +++ b/src/api/http/project/revisionConflict.ts @@ -0,0 +1,27 @@ +export const REVISION_CONFLICT_CODE = "PM_ERR:REVISION_CONFLICT"; + +export interface RevisionConflictDetails { + expected: number; + actual: number; +} + +/** + * Parse the stable named OCC error contract. The positional form is accepted + * for compatibility with older backends during a rolling desktop upgrade. + */ +export function parseRevisionConflict( + error: unknown +): RevisionConflictDetails | null { + const message = error instanceof Error ? error.message : String(error); + const named = message.match( + /PM_ERR:REVISION_CONFLICT:expected=(-?\d+):actual=(-?\d+)/ + ); + if (named) { + return { expected: Number(named[1]), actual: Number(named[2]) }; + } + + const legacy = message.match(/PM_ERR:REVISION_CONFLICT:(-?\d+):(-?\d+)/); + return legacy + ? { expected: Number(legacy[1]), actual: Number(legacy[2]) } + : null; +} diff --git a/src/api/http/project/types/common.ts b/src/api/http/project/types/common.ts index f78784eaf3..2dea9aae49 100644 --- a/src/api/http/project/types/common.ts +++ b/src/api/http/project/types/common.ts @@ -10,12 +10,25 @@ export interface CommentEntry { author: string; content: string; created_at: string; + /** Per-comment optimistic concurrency token; legacy comments start at 0. */ + revision?: number; /** Canonical member ids explicitly notified by this comment. */ mentioned_user_ids?: string[]; + mentions?: Array< + | { kind: "member"; id: string } + | { kind: "agent"; id: string } + | { kind: "agent_org"; id: string } + | { kind: "all" } + >; parent_id?: string; thread_id?: string; resolved_at?: string; resolved_by?: string; conclusion?: boolean; agent_session_id?: string; + /** A2A chain: who caused the authoring agent's run. */ + originator?: string; + edited_at?: string; + /** Tombstone: content and mentions are cleared, the entry stays. */ + deleted_at?: string; } diff --git a/src/api/http/project/types/routines.ts b/src/api/http/project/types/routines.ts index bbb15f8fc6..97e35a5b85 100644 --- a/src/api/http/project/types/routines.ts +++ b/src/api/http/project/types/routines.ts @@ -108,12 +108,38 @@ export interface RoutineRunTemplate { name?: string; } +export interface RoutineActivationPolicies { + concurrencyPolicy?: "coalesce" | "skip" | "queue" | "always"; + catchUp?: "none" | "fire_once" | "run_all_limited"; + maxCatchUpRuns?: number; +} + +/** Portable activation entry; mirrors the Rust `Activation` wire shape. */ +export type RoutineActivation = RoutineActivationPolicies & + ( + | { type: "manual" } + | { type: "schedule"; cron: string; timezone: string } + | { type: "one_time"; at: string } + | { + type: "provider_event"; + provider: string; + eventKind: string; + filter?: unknown; + } + ); + export interface RoutineDefinition { id: string; name: string; description: string; enabled: boolean; - trigger: RoutineTrigger; + /** + * Derived by the backend from the first schedulable activation; read + * only. `activations` is the single source of truth. + */ + trigger?: RoutineTrigger; + /** Complete portable activation list; entry 0 is the primary trigger. */ + activations?: RoutineActivation[]; runTemplate: RoutineRunTemplate; outputPolicy: RoutineOutputPolicy; /** Scheduler evaluation watermark (ISO 8601), backend-managed. */ diff --git a/src/api/http/project/types/workItemFeatures.ts b/src/api/http/project/types/workItemFeatures.ts index 5482e78e8f..bbde898a6a 100644 --- a/src/api/http/project/types/workItemFeatures.ts +++ b/src/api/http/project/types/workItemFeatures.ts @@ -62,7 +62,9 @@ export type PropertyType = | "multi_select" | "date" | "checkbox" - | "url"; + | "url" + | "actor" + | "multi_actor"; export interface PropertyOption { id: string; @@ -153,3 +155,116 @@ export interface RoutineWebhookDelivery { createdAt: string; updatedAt: string; } + +export const WORK_ITEM_STATUS_CATEGORIES = [ + "backlog", + "planned", + "in_progress", + "in_review", + "blocked", + "completed", + "cancelled", +] as const; + +export type WorkItemStatusCategory = + (typeof WORK_ITEM_STATUS_CATEGORIES)[number]; + +export interface StatusDefinition { + id: string; + orgId: string; + key: string; + name: string; + category: WorkItemStatusCategory; + color?: string | null; + description?: string | null; + position: number; + archivedAt?: number | null; + createdAt: number; + updatedAt: number; +} + +export interface UpsertStatusDefinitionRequest { + id?: string | null; + orgId: string; + key?: string | null; + name: string; + category?: WorkItemStatusCategory | null; + color?: string | null; + description?: string | null; + position?: number | null; +} + +export interface SavedViewQuery { + statusFilter?: string; + searchQuery?: string; + propertyFilter?: { + propertyId: string; + valueToken: string; + }; +} + +export interface SavedViewDisplay { + viewTab?: string; + kanbanGroupBy?: string; + tableColumns?: string[]; + propertyGroupBy?: string; + sortBy?: string; + sortDirection?: "asc" | "desc"; +} + +export interface SavedView { + id: string; + orgId: string; + projectSlug?: string | null; + name: string; + query: SavedViewQuery | null; + display: SavedViewDisplay | null; + position: number; + createdBy?: string | null; + archivedAt?: number | null; + createdAt: number; + updatedAt: number; +} + +export interface UpsertSavedViewRequest { + id?: string | null; + orgId: string; + projectSlug?: string | null; + name: string; + query?: SavedViewQuery; + display?: SavedViewDisplay; + position?: number | null; + createdBy?: string | null; +} + +export interface ScopePropertyValue { + propertyId: string; + workItemId: string; + value: unknown; +} + +export interface QuickAction { + id: string; + orgId: string; + name: string; + description: string; + targetKind: string; + targetId: string; + prompt: string; + useCount: number; + createdBy?: string | null; + archivedAt?: number | null; + createdAt: number; + updatedAt: number; +} + +export interface UpsertQuickActionRequest { + id?: string | null; + orgId: string; + name: string; + description?: string; + targetKind: string; + targetId: string; + prompt: string; + createdBy?: string | null; +} diff --git a/src/api/http/project/types/workItems.ts b/src/api/http/project/types/workItems.ts index 8d6e9870a3..05d7034e1f 100644 --- a/src/api/http/project/types/workItems.ts +++ b/src/api/http/project/types/workItems.ts @@ -227,6 +227,8 @@ export interface WorkItemData { frontmatter: WorkItemFrontmatter; body: string; filename: string; + /** Present on authoritative database reads; absent on file/import drafts. */ + revision?: number; } /** @@ -302,6 +304,7 @@ export interface EnrichedWorkItem { title: string; body: string; filename: string; + revision: number; status: string; priority: string; @@ -344,6 +347,7 @@ export type RustKanbanStatus = | "planned" | "in_progress" | "in_review" + | "blocked" | "completed" | "cancelled" | "duplicate"; @@ -392,6 +396,7 @@ export interface StatusCounts { planned: number; inProgress: number; inReview: number; + blocked: number; completed: number; cancelled: number; duplicate: number; diff --git a/src/api/http/project/types/workRuns.ts b/src/api/http/project/types/workRuns.ts index e80841656b..e010df64b1 100644 --- a/src/api/http/project/types/workRuns.ts +++ b/src/api/http/project/types/workRuns.ts @@ -35,6 +35,16 @@ export type WorkItemRunTarget = } | { kind: "resume_session"; sessionId: string }; +export interface WorkItemRunSkillManifestEntry { + id: string; + name: string; + source: string; + origin?: { provider: string; locator: string }; + identityDigest: string; + contentDigest: string; + schemaDigest: string; +} + export interface WorkItemRunTargetSnapshot { target: WorkItemRunTarget; workItemRevision: number; @@ -50,6 +60,10 @@ export interface WorkItemRunTargetSnapshot { workspaceMode?: "local_workspace" | "worktree" | null; agentDefinitionId?: string | null; agentOrgId?: string | null; + /** Effective consent metadata only; full skill bodies are not pinned. */ + skillManifest?: WorkItemRunSkillManifestEntry[]; + /** Present even for an empty captured set; absent only on legacy Runs. */ + skillManifestDigest?: string; } export interface WorkItemRunUsage { diff --git a/src/components/Table/index.tsx b/src/components/Table/index.tsx index 730303e951..d199c67a52 100644 --- a/src/components/Table/index.tsx +++ b/src/components/Table/index.tsx @@ -55,10 +55,26 @@ import { TableBody } from "./TableBody"; import { TableColGroup } from "./TableColGroup"; import { TableHeader } from "./TableHeader"; import "./index.scss"; -import type { TableProps } from "./types"; +import type { TableProps, TableSorting } from "./types"; import { useTableColumns } from "./useTableColumns"; -export type { TableColumn, TablePagination, TableProps } from "./types"; +export type { + TableColumn, + TablePagination, + TableProps, + TableSorting, +} from "./types"; + +function fromPublicSorting(value: TableSorting | null): SortingState { + return value ? [{ id: value.column, desc: value.order === "descend" }] : []; +} + +function toPublicSorting(value: SortingState): TableSorting | null { + const first = value[0]; + return first + ? { column: first.id, order: first.desc ? "descend" : "ascend" } + : null; +} function TableComponent( { @@ -68,6 +84,8 @@ function TableComponent( loading: _loading = false, showHeader = true, pagination, + sorting: controlledSorting, + onSortingChange, onChange, rowSelection, hover = true, @@ -89,7 +107,21 @@ function TableComponent( ref: React.ForwardedRef ) { const { isDark } = useCurrentTheme(); - const [sorting, setSorting] = useState([]); + const [internalSorting, setInternalSorting] = useState([]); + const sorting = + controlledSorting === undefined + ? internalSorting + : fromPublicSorting(controlledSorting); + const handleSortingChange = useCallback( + (updater: SortingState | ((previous: SortingState) => SortingState)) => { + const next = typeof updater === "function" ? updater(sorting) : updater; + if (controlledSorting === undefined) { + setInternalSorting(next); + } + onSortingChange?.(toPublicSorting(next)); + }, + [controlledSorting, onSortingChange, sorting] + ); const [internalExpandedRows, setInternalExpandedRows] = useState>( new Set() ); @@ -190,7 +222,7 @@ function TableComponent( rowSelection: rowSelectionState, pagination: paginationState, }, - onSortingChange: setSorting, + onSortingChange: handleSortingChange, onColumnFiltersChange: setColumnFilters, onColumnVisibilityChange: setColumnVisibility, onRowSelectionChange: setRowSelectionState, diff --git a/src/components/Table/types.ts b/src/components/Table/types.ts index 4e2601303e..79b05ede02 100644 --- a/src/components/Table/types.ts +++ b/src/components/Table/types.ts @@ -48,6 +48,11 @@ export interface TablePagination { position?: "top" | "bottom" | "both"; } +export interface TableSorting { + column: string; + order: "ascend" | "descend"; +} + export interface PaginationRenderContext { pageIndex: number; pageSize: number; @@ -68,6 +73,9 @@ export interface TableProps { /** @default true */ showHeader?: boolean; pagination?: false | TablePagination; + /** Controlled single-column sort state. Omit to keep Table-local sorting. */ + sorting?: TableSorting | null; + onSortingChange?: (sorting: TableSorting | null) => void; onChange?: ( pagination: TablePagination, filters: Record, diff --git a/src/features/KanbanBoard/config.ts b/src/features/KanbanBoard/config.ts index a8bea6537a..316c3c0e9b 100644 --- a/src/features/KanbanBoard/config.ts +++ b/src/features/KanbanBoard/config.ts @@ -4,6 +4,7 @@ * Defines task statuses, column settings, and icons for the Kanban board. */ import { + BanIcon, CancelCircleIcon, CheckmarkCircle01Icon, CircleDashedIcon, @@ -62,6 +63,15 @@ export const DEFAULT_KANBAN_COLUMNS: KanbanColumnConfig[] = [ dotColor: "var(--color-warning-6)", headerBgColor: "color-mix(in srgb, var(--color-warning-6) 8%, transparent)", }, + { + id: WORK_ITEM_STATUS.BLOCKED, + title: "Blocked", + icon: BanIcon, + color: "var(--color-danger-6)", + bgColor: "color-mix(in srgb, var(--color-danger-6) 10%, transparent)", + dotColor: "var(--color-danger-6)", + headerBgColor: "color-mix(in srgb, var(--color-danger-6) 8%, transparent)", + }, { id: WORK_ITEM_STATUS.COMPLETED, title: "projects:workItems.statusLabels.completed", diff --git a/src/i18n/locales/de/projects.json b/src/i18n/locales/de/projects.json index b24dff3c0f..5912a97fb8 100644 --- a/src/i18n/locales/de/projects.json +++ b/src/i18n/locales/de/projects.json @@ -15,7 +15,8 @@ "person": "Nach Person", "assignedTo": "Zugewiesen an", "createdBy": "Erstellt von", - "targetDate": "Nach Zieldatum" + "targetDate": "Nach Zieldatum", + "property": "Nach Eigenschaft" }, "source": { "localOnly": "Nur lokal", @@ -540,7 +541,19 @@ "cycles": "Zyklen" }, "viewWorkItems": "Work Items anzeigen", - "fromRoutine": "From routine: {{name}}" + "fromRoutine": "From routine: {{name}}", + "batchField": { + "hint": "Gilt für {{count}} ausgewählte Elemente." + }, + "batchStatus": { + "title": "Status setzen" + }, + "batchPriority": { + "title": "Priorität setzen" + }, + "batchAssignee": { + "title": "Zuständigen festlegen" + } }, "settings": { "sidebarGeneral": "Allgemein", diff --git a/src/i18n/locales/en/projects.json b/src/i18n/locales/en/projects.json index 5aee5d72fb..a06e4348a9 100644 --- a/src/i18n/locales/en/projects.json +++ b/src/i18n/locales/en/projects.json @@ -15,7 +15,8 @@ "person": "By person", "assignedTo": "Assigned to", "createdBy": "Created by", - "targetDate": "By target date" + "targetDate": "By target date", + "property": "By property" }, "source": { "localOnly": "Local only", @@ -263,7 +264,8 @@ "kanban": "Kanban", "gantt": "Gantt", "calendar": "Calendar", - "settings": "Settings" + "settings": "Settings", + "table": "Table" }, "statusFilters": { "all": "All", @@ -314,6 +316,9 @@ "properties": { "title": "Work Item Properties", "propertiesSection": "Properties", + "filter": "Property filter", + "filterValue": "Property filter value", + "noValue": "No value", "assignment": "Assignment", "noAssignee": "No assignee", "noMembersHint": "No members yet. Go to Settings > Members to sync from git", @@ -362,7 +367,8 @@ "previewThread": "Will wake the agent from this thread", "previewAssignee": "Will wake the assigned agent", "previewAssigneeStart": "Will start the assigned agent", - "previewCoalesce": "joins the pending wake" + "previewCoalesce": "joins the pending wake", + "previewMemberThread": "Member thread — no agent will be woken" }, "activity": { "title": "Activity", @@ -430,7 +436,11 @@ "schedule": "schedule", "orchestratorConfig": "orchestrator config", "handoff": "handoff" - } + }, + "edited": "(edited)", + "commentDeleted": "This comment was deleted.", + "deleteComment": "Delete comment", + "deleteCommentConfirm": "Delete this comment? Replies stay, the comment body is removed." }, "contextMenu": { "status": "Status", @@ -578,6 +588,71 @@ "notOnDeviceTitle": "Session not on this device", "notOnDeviceHint": "This session ran on another device. Its transcript is not synced here.", "finalOutput": "Final output" + }, + "savedViews": { + "placeholder": "Views", + "save": "Save current view", + "saveTitle": "Save view", + "namePlaceholder": "View name", + "saveHint": "Captures the current filters; layout seeds the first open.", + "delete": "Delete view" + }, + "table": { + "columnsPicker": "Columns", + "groupByProperty": "Group by property", + "columns": { + "shortId": "ID", + "title": "Title", + "status": "Status", + "priority": "Priority", + "assignee": "Assignee", + "targetDate": "Due", + "labels": "Labels" + } + }, + "batchProperty": { + "title": "Set property", + "hint": "Applies one property value to {{count}} selected items. Leave the value empty to clear it.", + "propertyPlaceholder": "Property", + "valuePlaceholder": "Value", + "applied": "Updated {{count}} items" + }, + "batchField": { + "hint": "Applies to {{count}} selected items." + }, + "batchStatus": { + "title": "Set status" + }, + "batchPriority": { + "title": "Set priority" + }, + "batchAssignee": { + "title": "Set assignee" + }, + "revisionConflict": { + "title": "Resolve edit conflict", + "description": "{{field}} changed after revision {{expected}}. Compare your edit with revision {{actual}}, then choose which version to keep.", + "mine": "My version", + "latest": "Latest version", + "useLatest": "Use latest", + "keepMine": "Keep mine", + "titleField": "Title", + "descriptionField": "Description", + "commentField": "Comment", + "reloadNotice": "This Work Item changed elsewhere. The latest version was reloaded.", + "retryFailed": "The latest version changed again. Review both versions before retrying." + }, + "quickActions": { + "title": "Quick actions", + "empty": "No quick actions yet. Save a reusable prompt for an agent.", + "emptyManage": "Nothing saved yet.", + "manageTitle": "Manage quick actions", + "namePlaceholder": "Action name (e.g. Fix CI)", + "targetPlaceholder": "Target agent", + "promptPlaceholder": "What should the agent do?", + "archive": "Archive action", + "scopeChanged": "Quick action is no longer in this Work Item scope", + "started": "Quick action “{{name}}” started" } }, "settings": { @@ -857,7 +932,16 @@ "dismissFailed": "Failed to dismiss conflict: {{error}}" } } - } + }, + "sidebarStatuses": "Statuses", + "statusesBuiltin": "Built-in statuses", + "statusesBuiltinDescription": "The standard workflow buckets. Always available.", + "statusesCustom": "Custom statuses", + "statusesCustomDescription": "Named aliases over a built-in bucket. Filters, counts, and boards treat them as their bucket.", + "statusNamePlaceholder": "Status name", + "noCustomStatuses": "No custom statuses yet.", + "statusArchive": "Archive status", + "statusRestore": "Restore status" }, "statusBar": { "workItemCount": "{{count}} items", diff --git a/src/i18n/locales/es/projects.json b/src/i18n/locales/es/projects.json index 6680eb9cc9..ec4ee22037 100644 --- a/src/i18n/locales/es/projects.json +++ b/src/i18n/locales/es/projects.json @@ -15,7 +15,8 @@ "person": "Por persona", "assignedTo": "Asignado a", "createdBy": "Creado por", - "targetDate": "Por fecha objetivo" + "targetDate": "Por fecha objetivo", + "property": "Por propiedad" }, "source": { "localOnly": "Solo local", @@ -540,7 +541,19 @@ "cycles": "ciclos" }, "viewWorkItems": "Ver Work Items", - "fromRoutine": "From routine: {{name}}" + "fromRoutine": "From routine: {{name}}", + "batchField": { + "hint": "Se aplica a {{count}} elementos seleccionados." + }, + "batchStatus": { + "title": "Establecer estado" + }, + "batchPriority": { + "title": "Establecer prioridad" + }, + "batchAssignee": { + "title": "Establecer responsable" + } }, "settings": { "sidebarGeneral": "General", diff --git a/src/i18n/locales/fr/projects.json b/src/i18n/locales/fr/projects.json index 7ac69b90fa..f06d120c42 100644 --- a/src/i18n/locales/fr/projects.json +++ b/src/i18n/locales/fr/projects.json @@ -15,7 +15,8 @@ "person": "Par personne", "assignedTo": "Assigné à", "createdBy": "Créé par", - "targetDate": "Par date cible" + "targetDate": "Par date cible", + "property": "Par propriété" }, "source": { "localOnly": "Local uniquement", @@ -540,7 +541,19 @@ "cycles": "cycles" }, "viewWorkItems": "Voir les Work Items", - "fromRoutine": "From routine: {{name}}" + "fromRoutine": "From routine: {{name}}", + "batchField": { + "hint": "S'applique à {{count}} éléments sélectionnés." + }, + "batchStatus": { + "title": "Définir le statut" + }, + "batchPriority": { + "title": "Définir la priorité" + }, + "batchAssignee": { + "title": "Définir le responsable" + } }, "settings": { "sidebarGeneral": "Général", diff --git a/src/i18n/locales/ja/projects.json b/src/i18n/locales/ja/projects.json index c56049b501..2842395445 100644 --- a/src/i18n/locales/ja/projects.json +++ b/src/i18n/locales/ja/projects.json @@ -15,7 +15,8 @@ "person": "担当者別", "assignedTo": "割り当て先別", "createdBy": "作成者別", - "targetDate": "目標日別" + "targetDate": "目標日別", + "property": "プロパティ別" }, "source": { "localOnly": "ローカルのみ", @@ -540,7 +541,19 @@ "cycles": "サイクル" }, "viewWorkItems": "Work Item を表示", - "fromRoutine": "From routine: {{name}}" + "fromRoutine": "From routine: {{name}}", + "batchField": { + "hint": "選択した{{count}}件のアイテムに適用します。" + }, + "batchStatus": { + "title": "ステータスを設定" + }, + "batchPriority": { + "title": "優先度を設定" + }, + "batchAssignee": { + "title": "担当者を設定" + } }, "settings": { "sidebarGeneral": "一般", diff --git a/src/i18n/locales/ko/projects.json b/src/i18n/locales/ko/projects.json index caa9ae4999..7ad77a5684 100644 --- a/src/i18n/locales/ko/projects.json +++ b/src/i18n/locales/ko/projects.json @@ -15,7 +15,8 @@ "person": "사람별", "assignedTo": "담당자별", "createdBy": "작성자별", - "targetDate": "목표 날짜별" + "targetDate": "목표 날짜별", + "property": "속성별" }, "source": { "localOnly": "로컬만", @@ -540,7 +541,19 @@ "cycles": "사이클" }, "viewWorkItems": "Work Item 보기", - "fromRoutine": "From routine: {{name}}" + "fromRoutine": "From routine: {{name}}", + "batchField": { + "hint": "선택한 {{count}}개 항목에 적용됩니다." + }, + "batchStatus": { + "title": "상태 설정" + }, + "batchPriority": { + "title": "우선순위 설정" + }, + "batchAssignee": { + "title": "담당자 설정" + } }, "settings": { "sidebarGeneral": "일반", diff --git a/src/i18n/locales/pl/projects.json b/src/i18n/locales/pl/projects.json index 438acb3626..1e04bd87a5 100644 --- a/src/i18n/locales/pl/projects.json +++ b/src/i18n/locales/pl/projects.json @@ -15,7 +15,8 @@ "person": "Według osoby", "assignedTo": "Przypisane do", "createdBy": "Utworzone przez", - "targetDate": "Według daty docelowej" + "targetDate": "Według daty docelowej", + "property": "Według właściwości" }, "source": { "localOnly": "Tylko lokalne", @@ -540,7 +541,19 @@ "cycles": "cykle" }, "viewWorkItems": "Pokaż Work Items", - "fromRoutine": "From routine: {{name}}" + "fromRoutine": "From routine: {{name}}", + "batchField": { + "hint": "Dotyczy {{count}} wybranych elementów." + }, + "batchStatus": { + "title": "Ustaw status" + }, + "batchPriority": { + "title": "Ustaw priorytet" + }, + "batchAssignee": { + "title": "Ustaw osobę przypisaną" + } }, "settings": { "sidebarGeneral": "Ogólne", diff --git a/src/i18n/locales/pt/projects.json b/src/i18n/locales/pt/projects.json index ae0f6ed742..06bfdf9378 100644 --- a/src/i18n/locales/pt/projects.json +++ b/src/i18n/locales/pt/projects.json @@ -15,7 +15,8 @@ "person": "Por pessoa", "assignedTo": "Atribuído a", "createdBy": "Criado por", - "targetDate": "Por data-alvo" + "targetDate": "Por data-alvo", + "property": "Por propriedade" }, "source": { "localOnly": "Somente local", @@ -540,7 +541,19 @@ "cycles": "ciclos" }, "viewWorkItems": "Ver itens de trabalho", - "fromRoutine": "From routine: {{name}}" + "fromRoutine": "From routine: {{name}}", + "batchField": { + "hint": "Aplica-se a {{count}} itens selecionados." + }, + "batchStatus": { + "title": "Definir status" + }, + "batchPriority": { + "title": "Definir prioridade" + }, + "batchAssignee": { + "title": "Definir responsável" + } }, "settings": { "sidebarGeneral": "Geral", diff --git a/src/i18n/locales/ru/projects.json b/src/i18n/locales/ru/projects.json index 606a4a8e1c..54823b6129 100644 --- a/src/i18n/locales/ru/projects.json +++ b/src/i18n/locales/ru/projects.json @@ -15,7 +15,8 @@ "person": "По человеку", "assignedTo": "Назначено", "createdBy": "Создано", - "targetDate": "По целевой дате" + "targetDate": "По целевой дате", + "property": "По свойству" }, "source": { "localOnly": "Только локально", @@ -540,7 +541,19 @@ "cycles": "циклов" }, "viewWorkItems": "Смотреть Work Items", - "fromRoutine": "From routine: {{name}}" + "fromRoutine": "From routine: {{name}}", + "batchField": { + "hint": "Применяется к {{count}} выбранным элементам." + }, + "batchStatus": { + "title": "Установить статус" + }, + "batchPriority": { + "title": "Установить приоритет" + }, + "batchAssignee": { + "title": "Назначить исполнителя" + } }, "settings": { "sidebarGeneral": "Общие", diff --git a/src/i18n/locales/tr/projects.json b/src/i18n/locales/tr/projects.json index 5845bacf4c..78bd3aad9d 100644 --- a/src/i18n/locales/tr/projects.json +++ b/src/i18n/locales/tr/projects.json @@ -15,7 +15,8 @@ "person": "Kişiye göre", "assignedTo": "Atanana göre", "createdBy": "Oluşturan göre", - "targetDate": "Hedef tarihe göre" + "targetDate": "Hedef tarihe göre", + "property": "Özelliğe göre" }, "source": { "localOnly": "Yalnızca yerel", @@ -540,7 +541,19 @@ "cycles": "döngü" }, "viewWorkItems": "Work Item'ları görüntüle", - "fromRoutine": "From routine: {{name}}" + "fromRoutine": "From routine: {{name}}", + "batchField": { + "hint": "Seçilen {{count}} öğeye uygulanır." + }, + "batchStatus": { + "title": "Durumu ayarla" + }, + "batchPriority": { + "title": "Önceliği ayarla" + }, + "batchAssignee": { + "title": "Atanan kişiyi ayarla" + } }, "settings": { "sidebarGeneral": "Genel", diff --git a/src/i18n/locales/vi/projects.json b/src/i18n/locales/vi/projects.json index e1384dca1b..7344a9f916 100644 --- a/src/i18n/locales/vi/projects.json +++ b/src/i18n/locales/vi/projects.json @@ -15,7 +15,8 @@ "person": "Theo người", "assignedTo": "Theo người được giao", "createdBy": "Theo người tạo", - "targetDate": "Theo ngày mục tiêu" + "targetDate": "Theo ngày mục tiêu", + "property": "Theo thuộc tính" }, "source": { "localOnly": "Chỉ cục bộ", @@ -540,7 +541,19 @@ "cycles": "chu kỳ" }, "viewWorkItems": "Xem Work Items", - "fromRoutine": "From routine: {{name}}" + "fromRoutine": "From routine: {{name}}", + "batchField": { + "hint": "Áp dụng cho {{count}} mục đã chọn." + }, + "batchStatus": { + "title": "Đặt trạng thái" + }, + "batchPriority": { + "title": "Đặt độ ưu tiên" + }, + "batchAssignee": { + "title": "Đặt người được giao" + } }, "settings": { "sidebarGeneral": "Chung", diff --git a/src/i18n/locales/zh-Hant/projects.json b/src/i18n/locales/zh-Hant/projects.json index 3107bb8791..7e5d7f6acf 100644 --- a/src/i18n/locales/zh-Hant/projects.json +++ b/src/i18n/locales/zh-Hant/projects.json @@ -15,7 +15,8 @@ "person": "按人員", "assignedTo": "按指派對象", "createdBy": "按建立者", - "targetDate": "按目標日期" + "targetDate": "按目標日期", + "property": "按屬性" }, "source": { "localOnly": "僅本機", @@ -560,6 +561,18 @@ "notOnDeviceTitle": "會話不在此裝置", "notOnDeviceHint": "該會話在其他裝置上執行,轉錄未同步到本機。", "finalOutput": "最終輸出" + }, + "batchField": { + "hint": "套用到選取的 {{count}} 個事項。" + }, + "batchStatus": { + "title": "設定狀態" + }, + "batchPriority": { + "title": "設定優先順序" + }, + "batchAssignee": { + "title": "設定負責人" } }, "settings": { diff --git a/src/i18n/locales/zh/projects.json b/src/i18n/locales/zh/projects.json index 19d533c0f9..fe3ef14ed6 100644 --- a/src/i18n/locales/zh/projects.json +++ b/src/i18n/locales/zh/projects.json @@ -15,7 +15,8 @@ "person": "按人员", "assignedTo": "按指派对象", "createdBy": "按创建者", - "targetDate": "按目标日期" + "targetDate": "按目标日期", + "property": "按属性" }, "source": { "localOnly": "仅本地", @@ -264,7 +265,8 @@ "kanban": "看板", "gantt": "甘特图", "calendar": "日历", - "settings": "设置" + "settings": "设置", + "table": "表格" }, "statusFilters": { "all": "全部", @@ -315,6 +317,9 @@ "properties": { "title": "工作项属性", "propertiesSection": "属性", + "filter": "属性筛选", + "filterValue": "属性筛选值", + "noValue": "无值", "assignment": "分配", "noAssignee": "未分配", "noMembersHint": "暂无成员。前往 Settings > Members 从 Git 同步", @@ -363,7 +368,8 @@ "previewThread": "将唤醒该 thread 的 agent", "previewAssignee": "将唤醒被指派的 agent", "previewAssigneeStart": "将启动被指派的 agent", - "previewCoalesce": "并入待处理的唤醒" + "previewCoalesce": "并入待处理的唤醒", + "previewMemberThread": "成员讨论串——不会唤醒任何 agent" }, "activity": { "title": "动态", @@ -431,7 +437,11 @@ "schedule": "计划", "orchestratorConfig": "Orchestrator 配置", "handoff": "工作交接" - } + }, + "edited": "(已编辑)", + "commentDeleted": "该评论已删除。", + "deleteComment": "删除评论", + "deleteCommentConfirm": "确定删除该评论?回复会保留,评论内容将被移除。" }, "contextMenu": { "status": "状态", @@ -579,6 +589,71 @@ "notOnDeviceTitle": "会话不在此设备", "notOnDeviceHint": "该会话在其他设备上运行,转录未同步到本机。", "finalOutput": "最终输出" + }, + "savedViews": { + "placeholder": "视图", + "save": "保存当前视图", + "saveTitle": "保存视图", + "namePlaceholder": "视图名称", + "saveHint": "保存当前筛选条件;布局只作为首次打开的种子。", + "delete": "删除视图" + }, + "table": { + "columnsPicker": "列", + "groupByProperty": "按属性分组", + "columns": { + "shortId": "编号", + "title": "标题", + "status": "状态", + "priority": "优先级", + "assignee": "负责人", + "targetDate": "截止", + "labels": "标签" + } + }, + "batchProperty": { + "title": "设置属性", + "hint": "将一个属性值应用到选中的 {{count}} 个事项;留空表示清除该属性。", + "propertyPlaceholder": "属性", + "valuePlaceholder": "值", + "applied": "已更新 {{count}} 个事项" + }, + "revisionConflict": { + "title": "处理编辑冲突", + "description": "{{field}} 在版本 {{expected}} 之后已被修改。请将你的编辑与最新版本 {{actual}} 对比,再选择保留哪一版。", + "mine": "我的版本", + "latest": "最新版本", + "useLatest": "使用最新版本", + "keepMine": "保留我的版本", + "titleField": "标题", + "descriptionField": "描述", + "commentField": "评论", + "reloadNotice": "此工作项已在其他位置更新,现已载入最新版本。", + "retryFailed": "最新版本又发生了变化,请重新对比后再试。" + }, + "quickActions": { + "title": "快捷指令", + "empty": "还没有快捷指令。为常用的 agent 任务保存一条可复用的提示。", + "emptyManage": "还没有保存任何指令。", + "manageTitle": "管理快捷指令", + "namePlaceholder": "指令名称(如:修复 CI)", + "targetPlaceholder": "目标 agent", + "promptPlaceholder": "希望 agent 做什么?", + "archive": "归档指令", + "scopeChanged": "快捷指令已不属于当前工作项范围", + "started": "快捷指令“{{name}}”已启动" + }, + "batchField": { + "hint": "应用到选中的 {{count}} 个事项。" + }, + "batchStatus": { + "title": "设置状态" + }, + "batchPriority": { + "title": "设置优先级" + }, + "batchAssignee": { + "title": "设置负责人" } }, "settings": { @@ -858,7 +933,16 @@ "dismissFailed": "忽略冲突失败:{{error}}" } } - } + }, + "sidebarStatuses": "状态", + "statusesBuiltin": "内置状态", + "statusesBuiltinDescription": "标准工作流状态,始终可用。", + "statusesCustom": "自定义状态", + "statusesCustomDescription": "映射到某个内置状态桶的别名;筛选、计数与看板按其所属桶处理。", + "statusNamePlaceholder": "状态名称", + "noCustomStatuses": "还没有自定义状态。", + "statusArchive": "归档状态", + "statusRestore": "恢复状态" }, "statusBar": { "workItemCount": "{{count}}个项目", diff --git a/src/modules/ProjectManager/LinearProjects/utils.ts b/src/modules/ProjectManager/LinearProjects/utils.ts index 315b60e670..b8158cf0c0 100644 --- a/src/modules/ProjectManager/LinearProjects/utils.ts +++ b/src/modules/ProjectManager/LinearProjects/utils.ts @@ -28,6 +28,7 @@ const WORK_ITEM_STATUS_TO_LINEAR_TYPE: Partial< planned: "unstarted", in_progress: "started", in_review: "started", + blocked: "started", completed: "completed", cancelled: "canceled", }; diff --git a/src/modules/ProjectManager/ProjectManagerLayout/components/useProjectWorkItemsTabContentInteractions.tsx b/src/modules/ProjectManager/ProjectManagerLayout/components/useProjectWorkItemsTabContentInteractions.tsx index 7a4119a284..15be2a3105 100644 --- a/src/modules/ProjectManager/ProjectManagerLayout/components/useProjectWorkItemsTabContentInteractions.tsx +++ b/src/modules/ProjectManager/ProjectManagerLayout/components/useProjectWorkItemsTabContentInteractions.tsx @@ -208,7 +208,8 @@ export function useProjectWorkItemsTabContentInteractions({ entry.project.slug, entry.item.session_id, updates, - currentUser + currentUser, + entry.item.revision ); if (!updated) return; const updatedItem = { diff --git a/src/modules/ProjectManager/WorkItems/components/BatchPropertyDialog.tsx b/src/modules/ProjectManager/WorkItems/components/BatchPropertyDialog.tsx new file mode 100644 index 0000000000..ce75f180a6 --- /dev/null +++ b/src/modules/ProjectManager/WorkItems/components/BatchPropertyDialog.tsx @@ -0,0 +1,218 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { type PropertyDefinition, projectApi } from "@src/api/http/project"; +import Input from "@src/components/Input"; +import Message from "@src/components/Message"; +import Select from "@src/components/Select"; +import Switch from "@src/components/Switch"; +import Modal from "@src/scaffold/ModalSystem"; +import type { Person } from "@src/types/core/shared"; + +export interface BatchPropertyDialogProps { + open: boolean; + orgId: string; + projectSlug: string | null; + shortIds: string[]; + members: Person[]; + onClose: () => void; + onApplied: () => void; +} + +export const BatchPropertyDialog: React.FC = ({ + open, + orgId, + projectSlug, + shortIds, + members, + onClose, + onApplied, +}) => { + const { t } = useTranslation("projects"); + const [definitions, setDefinitions] = useState([]); + const [propertyId, setPropertyId] = useState(null); + const [textValue, setTextValue] = useState(""); + const [multiValues, setMultiValues] = useState([]); + const [boolValue, setBoolValue] = useState(false); + const [applying, setApplying] = useState(false); + + useEffect(() => { + if (!open) return; + projectApi + .listPropertyDefinitions(orgId) + .then(setDefinitions) + .catch(() => undefined); + }, [open, orgId]); + + const selected = useMemo( + () => definitions.find((definition) => definition.id === propertyId), + [definitions, propertyId] + ); + + const propertyOptions = useMemo( + () => + definitions.map((definition) => ({ + value: definition.id, + label: definition.name, + })), + [definitions] + ); + + const selectValueOptions = useMemo(() => { + if ( + selected?.propertyType === "actor" || + selected?.propertyType === "multi_actor" + ) { + return members.map((member) => ({ + value: `member:${member.id}`, + label: member.name, + })); + } + return (selected?.config.options ?? []).map((option) => ({ + value: option.id, + label: option.name, + })); + }, [members, selected]); + + const buildValue = (): unknown | null => { + if (!selected) return null; + switch (selected.propertyType) { + case "checkbox": + return boolValue; + case "number": { + const parsed = Number(textValue); + return Number.isFinite(parsed) ? parsed : null; + } + case "select": + case "actor": + return textValue || null; + case "multi_select": + case "multi_actor": + return multiValues.length > 0 ? multiValues : null; + default: + return textValue.trim() || null; + } + }; + + const handleApply = async () => { + if (!selected) return; + setApplying(true); + try { + const updated = await projectApi.batchSetWorkItemPropertyValue({ + orgId, + projectSlug, + shortIds, + propertyId: selected.id, + value: buildValue(), + }); + Message.success( + t("workItems.batchProperty.applied", { + defaultValue: `Updated ${updated} items`, + count: updated, + }) + ); + onApplied(); + onClose(); + } catch (error) { + Message.error(String(error)); + } finally { + setApplying(false); + } + }; + + const valueInput = () => { + if (!selected) return null; + switch (selected.propertyType) { + case "checkbox": + return ( + + ); + case "select": + case "actor": + return ( + + setMultiValues((value as Array).map(String)) + } + placeholder={t("workItems.batchProperty.valuePlaceholder", { + defaultValue: "Value", + })} + size="small" + /> + ); + default: + return ( + setTextValue(value)} + placeholder={t("workItems.batchProperty.valuePlaceholder", { + defaultValue: "Value", + })} + size="small" + /> + ); + } + }; + + return ( + void handleApply()} + okText={t("common:actions.apply", { defaultValue: "Apply" })} + cancelText={t("common:actions.cancel", { defaultValue: "Cancel" })} + okButtonProps={{ disabled: !selected, loading: applying }} + > +
+

+ {t("workItems.batchProperty.hint", { + defaultValue: `Applies one property value to ${shortIds.length} selected items. Leave the value empty to clear it.`, + count: shortIds.length, + })} +

+ setValue(next as string)} + placeholder={t("workItems.batchProperty.valuePlaceholder", { + defaultValue: "Value", + })} + size="small" + dataTestId={`work-items-batch-${field}-select`} + /> +
+
+ ); +}; + +export default BatchQuickFieldDialog; diff --git a/src/modules/ProjectManager/WorkItems/components/EmbeddedWorkItemDetail/index.tsx b/src/modules/ProjectManager/WorkItems/components/EmbeddedWorkItemDetail/index.tsx index 0ed6c1a772..015ebef752 100644 --- a/src/modules/ProjectManager/WorkItems/components/EmbeddedWorkItemDetail/index.tsx +++ b/src/modules/ProjectManager/WorkItems/components/EmbeddedWorkItemDetail/index.tsx @@ -1,4 +1,4 @@ -import React, { Suspense, useCallback } from "react"; +import React, { Suspense, useCallback, useEffect } from "react"; import { Placeholder } from "@src/components/Placeholder"; import type { WorkstationTabHeaderHost } from "@src/hooks/tabHost/useWorkstationTabHeader"; @@ -37,6 +37,7 @@ interface EmbeddedWorkItemDetailProps { onRegisterActions?: (actions: WorkItemDetailActions) => void; repoPath: string | null; projectSlug: string | null; + orgId: string; shortId: string | null; onRefreshWorkItem: () => Promise; onOpenSession?: (sessionId: string, title?: string) => void; @@ -67,6 +68,7 @@ const EmbeddedWorkItemDetail: React.FC = ({ onRegisterActions, repoPath, projectSlug, + orgId, shortId, onRefreshWorkItem, onOpenSession, @@ -83,14 +85,17 @@ const EmbeddedWorkItemDetail: React.FC = ({ const handleUpdateWorkItem = useCallback( (updates: Partial) => { if (!workItem) return; - if (updates.name !== undefined) { - onWorkItemNameUpdated?.(updates.name); - } onUpdateWorkItem(workItem.session_id, updates); }, - [onUpdateWorkItem, onWorkItemNameUpdated, workItem] + [onUpdateWorkItem, workItem] ); + useEffect(() => { + if (workItem?.name !== undefined) { + onWorkItemNameUpdated?.(workItem.name); + } + }, [onWorkItemNameUpdated, workItem?.name]); + if (!workItem) return null; return ( @@ -113,6 +118,7 @@ const EmbeddedWorkItemDetail: React.FC = ({ onRegisterActions={onRegisterActions} repoPath={repoPath} projectSlug={projectSlug} + orgId={orgId} shortId={shortId} onRefreshWorkItem={onRefreshWorkItem} onOpenSession={onOpenSession} diff --git a/src/modules/ProjectManager/WorkItems/components/PropertyFilterControl.tsx b/src/modules/ProjectManager/WorkItems/components/PropertyFilterControl.tsx new file mode 100644 index 0000000000..dd7b52863f --- /dev/null +++ b/src/modules/ProjectManager/WorkItems/components/PropertyFilterControl.tsx @@ -0,0 +1,133 @@ +import React, { useMemo } from "react"; +import { useTranslation } from "react-i18next"; + +import type { + PropertyDefinition, + ScopePropertyValue, +} from "@src/api/http/project"; +import Select from "@src/components/Select"; +import { FilterIcon, HugeiconsIcon } from "@src/icons"; +import type { Person } from "@src/types/core/shared"; + +import { + PROPERTY_FILTER_NONE_VALUE, + type WorkItemPropertyFilter, + propertyFilterOptions, +} from "../propertyViewModel"; + +interface PropertyFilterControlProps { + definitions: PropertyDefinition[]; + values: ScopePropertyValue[]; + members: Person[]; + selectedPropertyId: string | null; + filter: WorkItemPropertyFilter | null; + onSelectedPropertyIdChange: (propertyId: string | null) => void; + onFilterChange: (filter: WorkItemPropertyFilter | null) => void; +} + +export const PropertyFilterControl: React.FC = ({ + definitions, + values, + members, + selectedPropertyId, + filter, + onSelectedPropertyIdChange, + onFilterChange, +}) => { + const { t } = useTranslation("projects"); + const selectedDefinition = definitions.find( + (definition) => definition.id === selectedPropertyId + ); + const definitionOptions = useMemo( + () => + definitions.map((definition) => ({ + value: definition.id, + label: definition.name, + })), + [definitions] + ); + const valueOptions = useMemo( + () => + selectedDefinition + ? propertyFilterOptions(selectedDefinition, values, members).map( + (option) => ({ + ...option, + label: + option.value === PROPERTY_FILTER_NONE_VALUE + ? t("workItems.properties.noValue", { + defaultValue: "No value", + }) + : option.label, + }) + ) + : [], + [members, selectedDefinition, t, values] + ); + + if (definitions.length === 0) return null; + + return ( +
+ + onFilterChange({ + propertyId: selectedDefinition.id, + valueToken: String(value), + }) + } + onClear={() => onFilterChange(null)} + allowClear + showSearch + appearance="ghost" + size="small" + placeholder={t("workItems.properties.filterValue", { + defaultValue: "Value", + })} + ariaLabel={t("workItems.properties.filterValue", { + defaultValue: "Property filter value", + })} + dataTestId="work-items-property-filter-value" + /> + ) : null} +
+ ); +}; + +export default PropertyFilterControl; diff --git a/src/modules/ProjectManager/WorkItems/components/RevisionConflictModal.test.ts b/src/modules/ProjectManager/WorkItems/components/RevisionConflictModal.test.ts new file mode 100644 index 0000000000..c47dd51e1f --- /dev/null +++ b/src/modules/ProjectManager/WorkItems/components/RevisionConflictModal.test.ts @@ -0,0 +1,128 @@ +// @vitest-environment jsdom +import React, { act, createElement } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import RevisionConflictModal from "./RevisionConflictModal"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock("@src/components/Textarea", () => ({ + default: ({ + value, + autoSize: _autoSize, + resize: _resize, + ...props + }: { + value?: string; + autoSize?: unknown; + resize?: unknown; + }) => createElement("textarea", { ...props, value, readOnly: true }), +})); + +vi.mock("@src/scaffold/ModalSystem", () => ({ + default: ({ + visible, + children, + onCancel, + onOk, + cancelText, + okText, + }: { + visible: boolean; + children?: React.ReactNode; + onCancel?: () => void; + onOk?: () => void | Promise; + cancelText?: string; + okText?: string; + }) => + visible + ? createElement( + "section", + null, + children, + createElement("button", { onClick: onCancel }, cancelText), + createElement("button", { onClick: () => void onOk?.() }, okText) + ) + : null, +})); + +describe("RevisionConflictModal", () => { + let container: HTMLDivElement; + let root: Root; + + beforeAll(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + }); + + afterAll(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = false; + }); + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it("shows both versions and exposes explicit latest/mine exits", () => { + const useLatest = vi.fn(); + const keepMine = vi.fn(); + act(() => { + root.render( + createElement(RevisionConflictModal, { + conflict: { + fieldLabel: "Comment", + mine: "my edit", + latest: "teammate edit", + expectedRevision: 2, + actualRevision: 3, + }, + onUseLatest: useLatest, + onKeepMine: keepMine, + }) + ); + }); + + expect( + container.querySelector( + "[data-testid='work-item-revision-conflict-mine']" + )?.value + ).toBe("my edit"); + expect( + container.querySelector( + "[data-testid='work-item-revision-conflict-latest']" + )?.value + ).toBe("teammate edit"); + + const buttons = container.querySelectorAll("button"); + act(() => buttons[0].click()); + act(() => buttons[1].click()); + expect(useLatest).toHaveBeenCalledTimes(1); + expect(keepMine).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/modules/ProjectManager/WorkItems/components/RevisionConflictModal.tsx b/src/modules/ProjectManager/WorkItems/components/RevisionConflictModal.tsx new file mode 100644 index 0000000000..6814b27434 --- /dev/null +++ b/src/modules/ProjectManager/WorkItems/components/RevisionConflictModal.tsx @@ -0,0 +1,86 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; + +import Textarea from "@src/components/Textarea"; +import Modal from "@src/scaffold/ModalSystem"; + +export interface RevisionConflictValue { + fieldLabel: string; + mine: string; + latest: string; + expectedRevision: number; + actualRevision: number; +} + +export interface RevisionConflictModalProps { + conflict: RevisionConflictValue | null; + onUseLatest: () => void; + onKeepMine: () => void | Promise; +} + +/** + * Explicit two-version exit for a stale text edit. "Keep mine" is supplied + * by the owning data boundary and must retry against `actualRevision`. + */ +export const RevisionConflictModal: React.FC = ({ + conflict, + onUseLatest, + onKeepMine, +}) => { + const { t } = useTranslation("projects"); + + return ( + + {conflict ? ( +
+

+ {t("workItems.revisionConflict.description", { + field: conflict.fieldLabel, + expected: conflict.expectedRevision, + actual: conflict.actualRevision, + })} +

+
+
"`; +exports[`ProjectPanelView behavior contract > retains the overview editor and a pending 500ms save when another tab is selected > overview 1`] = `"
"`; diff --git a/src/features/Org2Cloud/SessionComments/SessionCommentsContext.test.ts b/src/features/Org2Cloud/SessionComments/SessionCommentsContext.test.ts index a7541d18bf..eb3fca5fa7 100644 --- a/src/features/Org2Cloud/SessionComments/SessionCommentsContext.test.ts +++ b/src/features/Org2Cloud/SessionComments/SessionCommentsContext.test.ts @@ -1,6 +1,10 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; -import { buildCloudCommentSourceEventIdMap } from "./SessionCommentsContext"; +import { Org2CloudCommentError } from "../org2CloudCommentsClient"; +import { + addCommentWithSessionAdmissionRecovery, + buildCloudCommentSourceEventIdMap, +} from "./SessionCommentsContext"; const LIVE_MESSAGE_ID = "70c0418c-eb0c-4a84-8a52-1bca10e605b7"; @@ -51,3 +55,34 @@ describe("buildCloudCommentSourceEventIdMap", () => { ); }); }); + +describe("addCommentWithSessionAdmissionRecovery", () => { + it("repairs an owner admission race and retries the same Team Chat comment once", async () => { + const comment = { id: "comment-1" } as never; + const add = vi + .fn<() => Promise>() + .mockRejectedValueOnce( + new Org2CloudCommentError("ORG2_SESSION_NOT_FOUND", 404) + ) + .mockResolvedValueOnce(comment); + const repair = vi.fn(async () => undefined); + + await expect( + addCommentWithSessionAdmissionRecovery(add, repair) + ).resolves.toBe(comment); + expect(repair).toHaveBeenCalledOnce(); + expect(add).toHaveBeenCalledTimes(2); + }); + + it("does not recreate a missing imported teammate session", async () => { + const error = new Org2CloudCommentError("ORG2_SESSION_NOT_FOUND", 404); + const add = vi.fn(async () => { + throw error; + }); + + await expect( + addCommentWithSessionAdmissionRecovery(add, null) + ).rejects.toBe(error); + expect(add).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/features/Org2Cloud/SessionComments/SessionCommentsContext.tsx b/src/features/Org2Cloud/SessionComments/SessionCommentsContext.tsx index 4bf70564dc..5bf1a7bac9 100644 --- a/src/features/Org2Cloud/SessionComments/SessionCommentsContext.tsx +++ b/src/features/Org2Cloud/SessionComments/SessionCommentsContext.tsx @@ -45,6 +45,7 @@ import type { CloudCommentResolution, CloudSessionComment, } from "../org2CloudCommentsClient"; +import { isOrg2CommentErrorCode } from "../org2CloudCommentsClient"; import { loadCloudOrgMembers } from "../org2CloudMembersCoordinator"; import { org2CloudOrgsAtom, @@ -61,6 +62,7 @@ import { groupCommentThreads, useSessionComments, } from "../org2CloudSessionCommentsAtom"; +import { org2CloudSyncEngine } from "../org2CloudSyncEngine"; import { type SessionCommentTarget, useSessionCommentTarget, @@ -74,6 +76,27 @@ const RUST_NATIVE_TRANSIENT_USER_EVENT_ID = export type { CommentAnchorEventIdentity }; +/** + * A repo-scope/tag can make Team Chat available a few milliseconds before + * the owner push creates the Cloud session row. Repair that one admission + * race through the existing sync engine, then retry the exact comment once. + * Imported teammate sessions deliberately pass no repair callback. + */ +export async function addCommentWithSessionAdmissionRecovery( + add: () => Promise, + repair: (() => Promise) | null +): Promise { + try { + return await add(); + } catch (error) { + if (!repair || !isOrg2CommentErrorCode(error, "ORG2_SESSION_NOT_FOUND")) { + throw error; + } + await repair(); + return add(); + } +} + /** * Build the local-render id -> durable cloud-anchor id projection once per * transcript. Rust-native live broadcasts briefly expose a bare message UUID, @@ -349,6 +372,30 @@ export const SessionCommentsProvider: React.FC< target?.sessionId ?? null, originSessionId ); + const addCommentWithRecovery = useCallback( + (input: AddCommentInput): Promise => { + const locallyOwnedTarget = Boolean( + session && + target && + session.session_id === target.sessionId && + !session.importedFrom && + !getSessionForkedFrom(session) + ); + return addCommentWithSessionAdmissionRecovery( + () => addComment(input), + locallyOwnedTarget && target + ? async () => { + org2CloudSyncEngine.invalidatePushedMetadataHash( + target.orgId, + target.sessionId + ); + await org2CloudSyncEngine.runSyncPassAndWaitForDrain(); + } + : null + ); + }, + [addComment, session, target] + ); const viewer = useSessionCommentViewer(target); const mentionableMembers = useSessionCommentMentionableMembers(target); const setPresentRegistry = useSetAtom(sessionCommentPresentEventIdsAtom); @@ -445,7 +492,7 @@ export const SessionCommentsProvider: React.FC< viewerIsAdmin: viewer.viewerIsAdmin, mentionableMembers, refresh, - addComment, + addComment: addCommentWithRecovery, editComment, deleteComment, resolveComment, @@ -465,7 +512,7 @@ export const SessionCommentsProvider: React.FC< viewer, mentionableMembers, refresh, - addComment, + addCommentWithRecovery, editComment, deleteComment, resolveComment, diff --git a/src/features/Org2Cloud/SessionConversation/useConversationComposer.ts b/src/features/Org2Cloud/SessionConversation/useConversationComposer.ts index e0400af430..ec739084cd 100644 --- a/src/features/Org2Cloud/SessionConversation/useConversationComposer.ts +++ b/src/features/Org2Cloud/SessionConversation/useConversationComposer.ts @@ -3,6 +3,7 @@ import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import type { SubmitOverrideInput } from "@src/engines/ChatPanel/hooks/useInputArea/types"; +import { resolveMessageAudience } from "@src/features/TeamCollaboration/messageAudienceRouting"; import { useSessionCommentsContext } from "../SessionComments/SessionCommentsContext"; import { @@ -51,13 +52,20 @@ export function useConversationSubmitOverride( } const body = input.displayText.trim(); if (!body) return true; - const mentionedUserIds = resolveTeamChatMentions( - body, - comments.mentionableMembers + const audience = resolveMessageAudience( + "team_chat", + resolveTeamChatMentions(body, comments.mentionableMembers).map( + (id) => ({ + kind: "member" as const, + id, + }) + ) ); await comments.addComment({ body, - ...(mentionedUserIds.length > 0 ? { mentionedUserIds } : {}), + ...(audience.human.scope === "members" + ? { mentionedUserIds: audience.human.memberIds } + : {}), }); return true; }, diff --git a/src/features/Org2Cloud/addressCommentsRun.test.ts b/src/features/Org2Cloud/addressCommentsRun.test.ts index f29339b350..c53ee63b4f 100644 --- a/src/features/Org2Cloud/addressCommentsRun.test.ts +++ b/src/features/Org2Cloud/addressCommentsRun.test.ts @@ -122,6 +122,7 @@ describe("replyViaActiveAddressRun", () => { orgId: "org-1", cloudSessionId: "cloud-session-1", localSessionId: "local-1", + turnIntentId: "turn-1", validHeadIds: new Set(["c-1"]), replied: new Map(), }; @@ -136,6 +137,7 @@ describe("replyViaActiveAddressRun", () => { parentId: "c-1", body: "fixed", kind: "agent_report", + clientMessageKey: "agent-report:turn-1:c-1", }); expect( await replyViaActiveAddressRun("c-1", "again", "local-1") @@ -150,6 +152,7 @@ describe("replyViaActiveAddressRun", () => { orgId: "org-1", cloudSessionId: "local-1", localSessionId: "local-1", + turnIntentId: "turn-2", validHeadIds: new Set(["c-1"]), replied: new Map(), }; diff --git a/src/features/Org2Cloud/addressCommentsRun.ts b/src/features/Org2Cloud/addressCommentsRun.ts index 5e3c5b7d08..6694a5739b 100644 --- a/src/features/Org2Cloud/addressCommentsRun.ts +++ b/src/features/Org2Cloud/addressCommentsRun.ts @@ -54,6 +54,8 @@ export interface ActiveAddressRun { orgId: string; cloudSessionId: string; localSessionId: string; + /** Stable idempotency namespace for every reply produced by this turn. */ + turnIntentId: string; validHeadIds: ReadonlySet; replied: Map; } @@ -189,6 +191,7 @@ export async function replyViaActiveAddressRun( body: trimmedBody, parentId: commentId, kind: "agent_report", + clientMessageKey: `agent-report:${run.turnIntentId}:${commentId}`, }); run.replied.set(commentId, trimmedBody); broadcastCommentsChanged(run.orgId, run.cloudSessionId); @@ -421,6 +424,7 @@ async function executeAddressCommentsRound( orgId, cloudSessionId, localSessionId, + turnIntentId, validHeadIds: new Set(threads.map((thread) => thread.headId)), replied: new Map(), }; diff --git a/src/features/Org2Cloud/org2CloudCommentsClient.test.ts b/src/features/Org2Cloud/org2CloudCommentsClient.test.ts index 27dc339014..d65415858c 100644 --- a/src/features/Org2Cloud/org2CloudCommentsClient.test.ts +++ b/src/features/Org2Cloud/org2CloudCommentsClient.test.ts @@ -197,6 +197,44 @@ describe("addSessionComment", () => { expect(comment.mentionedUserIds).toEqual(["user-2", "user-3"]); }); + it("uses the retry-safe RPC when a stable client message key is present", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ comment: WIRE_COMMENT })); + + await addSessionComment("jwt-1", { + orgId: "org-1", + sessionId: "sess-1", + body: "Please review", + clientMessageKey: "agent-report:turn-1:c-1", + mentionedUserIds: ["user-2", "user-2"], + }); + + expect(lastCall().url).toBe( + `${ORG2_CLOUD_OFFICIAL_SUPABASE_URL}/rest/v1/rpc/cloud_add_session_comment_idempotent` + ); + expect(lastBody()).toMatchObject({ + p_client_message_key: "agent-report:turn-1:c-1", + p_replace_existing: false, + p_mentioned_user_ids: ["user-2"], + }); + }); + + it("maps a mismatched retry key into a coded conflict", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ message: "ORG2_IDEMPOTENCY_CONFLICT" }, 400) + ); + + const error = await addSessionComment("jwt-1", { + orgId: "org-1", + sessionId: "sess-1", + body: "changed payload", + clientMessageKey: "agent-report:turn-1:c-1", + }).catch((caught: unknown) => caught); + + expect(isOrg2CommentErrorCode(error, "ORG2_IDEMPOTENCY_CONFLICT")).toBe( + true + ); + }); + it("sends JWT bearer + Content-Profile", async () => { fetchMock.mockResolvedValueOnce(jsonResponse({ comment: WIRE_COMMENT })); await addSessionComment("jwt-9", { diff --git a/src/features/Org2Cloud/org2CloudCommentsClient.ts b/src/features/Org2Cloud/org2CloudCommentsClient.ts index 3d8f200a35..39c5a0b347 100644 --- a/src/features/Org2Cloud/org2CloudCommentsClient.ts +++ b/src/features/Org2Cloud/org2CloudCommentsClient.ts @@ -46,6 +46,7 @@ export const ORG2_COMMENT_ERROR_CODES = [ "ORG2_FORBIDDEN", "ORG2_REPLAY_NOT_AVAILABLE", "ORG2_QUOTA_EXCEEDED", + "ORG2_IDEMPOTENCY_CONFLICT", "ORG2_AUTH_REQUIRED", "ORG2_MEMBER_REQUIRED", ] as const; @@ -287,6 +288,12 @@ export interface AddSessionCommentInput { * null keeps the comment counted on the source plane. */ originSessionId?: string | null; + /** Stable retry key; identical retries return the same durable comment. */ + clientMessageKey?: string; + /** Explicit edited-retry intent for compare-and-swap replacement. */ + replaceExisting?: boolean; + expectedBody?: string; + expectedMentionedUserIds?: string[]; } /** @@ -322,6 +329,24 @@ export async function addSessionComment( body.p_mentioned_user_ids = mentionedUserIds; } let payload: unknown; + if (input.clientMessageKey) { + // Never fall back to an unkeyed write: a lost response would become a + // duplicate comment. The visible failed row remains retryable until the + // idempotent Cloud RPC is deployed. + payload = await callCommentRpc( + "cloud_add_session_comment_idempotent", + accessToken, + { + ...body, + p_client_message_key: input.clientMessageKey, + p_replace_existing: input.replaceExisting ?? false, + p_expected_body: input.expectedBody ?? null, + p_expected_mentioned_user_ids: input.expectedMentionedUserIds ?? null, + p_mentioned_user_ids: mentionedUserIds, + } + ); + return AddCommentResultSchema.parse(payload).comment; + } try { payload = await callCommentRpc( mentionedUserIds.length > 0 diff --git a/src/features/Org2Cloud/org2CloudSessionCommentsAtom.commentTransforms.ts b/src/features/Org2Cloud/org2CloudSessionCommentsAtom.commentTransforms.ts index ff689f7f0a..a87211b868 100644 --- a/src/features/Org2Cloud/org2CloudSessionCommentsAtom.commentTransforms.ts +++ b/src/features/Org2Cloud/org2CloudSessionCommentsAtom.commentTransforms.ts @@ -18,6 +18,11 @@ import type { const SESSION_COMMENTS_TTL_MS = 30_000; export const MAX_SESSION_COMMENT_CACHE_ENTRIES = 128; +export const OPTIMISTIC_SESSION_COMMENT_ID_PREFIX = "local-comment-"; + +export function isOptimisticSessionCommentId(id: string): boolean { + return id.startsWith(OPTIMISTIC_SESSION_COMMENT_ID_PREFIX); +} /** * A transient listing failure (network blip, or the session row sitting in @@ -173,7 +178,9 @@ export function mergeFullSessionComments( return existing .filter( (comment) => - !fetchedIds.has(comment.id) && !knownIdsAtStart.has(comment.id) + !fetchedIds.has(comment.id) && + (isOptimisticSessionCommentId(comment.id) || + !knownIdsAtStart.has(comment.id)) ) .reduce((list, comment) => insertComment(list, comment), [...fetched]); } diff --git a/src/features/Org2Cloud/org2CloudSessionCommentsAtom.test.ts b/src/features/Org2Cloud/org2CloudSessionCommentsAtom.test.ts index 92d0dc7828..dcb417daaa 100644 --- a/src/features/Org2Cloud/org2CloudSessionCommentsAtom.test.ts +++ b/src/features/Org2Cloud/org2CloudSessionCommentsAtom.test.ts @@ -6,6 +6,7 @@ import type { CloudSessionComment } from "./org2CloudCommentsClient"; import { type CloudSessionCommentsEntry, MAX_SESSION_COMMENT_CACHE_ENTRIES, + OPTIMISTIC_SESSION_COMMENT_ID_PREFIX, SESSION_COMMENTS_DELTA_OVERLAP_MS, countLiveComments, decideSessionCommentsFetch, @@ -474,6 +475,19 @@ describe("mergeFullSessionComments", () => { ); expect(merged.map((entry) => entry.id)).toEqual(["kept", "new"]); }); + + it("keeps an in-flight optimistic Team Chat row across a full refresh", () => { + const optimistic = comment({ + id: `${OPTIMISTIC_SESSION_COMMENT_ID_PREFIX}pending-1`, + createdAt: "2026-07-24T05:00:00Z", + }); + const merged = mergeFullSessionComments( + [optimistic], + [], + new Set([optimistic.id]) + ); + expect(merged).toEqual([optimistic]); + }); }); describe("sessionCommentsKey", () => { diff --git a/src/features/Org2Cloud/org2CloudSessionCommentsAtom.ts b/src/features/Org2Cloud/org2CloudSessionCommentsAtom.ts index 5cc24229a8..c310a021b1 100644 --- a/src/features/Org2Cloud/org2CloudSessionCommentsAtom.ts +++ b/src/features/Org2Cloud/org2CloudSessionCommentsAtom.ts @@ -37,6 +37,7 @@ import { } from "./org2CloudCommentsClient"; import { EMPTY_ENTRY, + OPTIMISTIC_SESSION_COMMENT_ID_PREFIX, decideSessionCommentsFetch, insertComment, mergeDeltaSessionComments, @@ -78,6 +79,7 @@ export type { } from "./org2CloudSessionCommentsAtom.types"; export { MAX_SESSION_COMMENT_CACHE_ENTRIES, + OPTIMISTIC_SESSION_COMMENT_ID_PREFIX, SESSION_COMMENTS_DELTA_OVERLAP_MS, SESSION_COMMENTS_ERROR_RETRY_MS, SESSION_COMMENTS_ERROR_RETRY_MAX_MS, @@ -88,6 +90,7 @@ export { getThreadResolution, groupCommentThreads, insertComment, + isOptimisticSessionCommentId, isThreadResolved, mergeDeltaSessionComments, mergeFullSessionComments, @@ -496,23 +499,50 @@ export function useSessionComments( if (!orgId || !sessionId || !key) { throw new Error("no cloud comment target"); } - const { accessToken, identityKey } = await freshTokenForCurrentIdentity(); - const comment = await addSessionComment(accessToken, { - orgId, - sessionId, - body: input.body, + const optimistic: CloudSessionComment = { + id: `${OPTIMISTIC_SESSION_COMMENT_ID_PREFIX}${crypto.randomUUID()}`, eventId: input.eventId, parentId: input.parentId, - mentionedUserIds: input.mentionedUserIds, - ...(originSessionId && originSessionId !== sessionId - ? { originSessionId } - : {}), - }); - if (!isCurrentIdentity(identityKey)) return comment; - // The RPC returns the row in listing shape — insert without a refetch. - patchEntry(key, (comments) => insertComment(comments, comment)); - broadcastCommentsChangedToPeers(orgId, sessionId); - return comment; + authorUserId: authRef.current?.userId ?? "", + authorDisplayName: authRef.current?.profile?.displayName ?? undefined, + body: input.body, + createdAt: new Date().toISOString(), + kind: "user", + mentionedUserIds: input.mentionedUserIds ?? [], + }; + patchEntry(key, (comments) => insertComment(comments, optimistic)); + try { + const { accessToken, identityKey } = + await freshTokenForCurrentIdentity(); + const comment = await addSessionComment(accessToken, { + orgId, + sessionId, + body: input.body, + eventId: input.eventId, + parentId: input.parentId, + mentionedUserIds: input.mentionedUserIds, + ...(originSessionId && originSessionId !== sessionId + ? { originSessionId } + : {}), + }); + if (!isCurrentIdentity(identityKey)) return comment; + // Replace the local echo with the server-authored row atomically. + patchEntry(key, (comments) => + insertComment( + comments.filter((candidate) => candidate.id !== optimistic.id), + comment + ) + ); + broadcastCommentsChangedToPeers(orgId, sessionId); + return comment; + } catch (error) { + patchEntry(key, (comments) => + comments.filter((candidate) => candidate.id !== optimistic.id) + ); + // Rejection is observed by useSubmitMessage, which restores the + // exact editor snapshot (including structured mention pills). + throw error; + } }, [ orgId, @@ -562,6 +592,7 @@ export function useSessionComments( patchComment(comments, commentId, { deletedAt: new Date().toISOString(), body: "", + mentionedUserIds: [], }) ); if (sessionId) broadcastCommentsChangedToPeers(orgId, sessionId); diff --git a/src/features/TeamCollaboration/messageAudienceRouting.contract.json b/src/features/TeamCollaboration/messageAudienceRouting.contract.json new file mode 100644 index 0000000000..3eeccd2289 --- /dev/null +++ b/src/features/TeamCollaboration/messageAudienceRouting.contract.json @@ -0,0 +1,86 @@ +[ + { + "name": "team chat defaults to the human channel", + "surface": "team_chat", + "targets": [], + "expected": { + "humanScope": "channel", + "memberIds": [], + "agentMode": "none" + } + }, + { + "name": "team chat can address explicit members", + "surface": "team_chat", + "targets": [{ "kind": "member", "id": "member-2" }], + "expected": { + "humanScope": "members", + "memberIds": ["member-2"], + "agentMode": "none" + } + }, + { + "name": "team chat never turns an injected agent target into execution", + "surface": "team_chat", + "targets": [{ "kind": "agent", "id": "builtin:sde" }], + "expected": { + "humanScope": "channel", + "memberIds": [], + "agentMode": "none" + } + }, + { + "name": "plain Work Item comments default to the assigned Agent", + "surface": "work_item_comment", + "targets": [], + "expected": { + "humanScope": "none", + "memberIds": [], + "agentMode": "assigned" + } + }, + { + "name": "Work Item member mentions replace the assigned Agent default", + "surface": "work_item_comment", + "targets": [{ "kind": "member", "id": "member-2" }], + "expected": { + "humanScope": "members", + "memberIds": ["member-2"], + "agentMode": "none" + } + }, + { + "name": "Work Item all mentions are human channel audience", + "surface": "work_item_comment", + "targets": [{ "kind": "all" }], + "expected": { + "humanScope": "channel", + "memberIds": [], + "agentMode": "none" + } + }, + { + "name": "Work Item agent mentions explicitly execute that Agent", + "surface": "work_item_comment", + "targets": [{ "kind": "agent", "id": "builtin:sde" }], + "expected": { + "humanScope": "none", + "memberIds": [], + "agentMode": "explicit" + } + }, + { + "name": "mixed Work Item audiences notify humans and execute the explicit Agent", + "surface": "work_item_comment", + "targets": [ + { "kind": "member", "id": "member-2" }, + { "kind": "agent", "id": "builtin:sde" }, + { "kind": "member", "id": "member-2" } + ], + "expected": { + "humanScope": "members", + "memberIds": ["member-2"], + "agentMode": "explicit" + } + } +] diff --git a/src/features/TeamCollaboration/messageAudienceRouting.test.ts b/src/features/TeamCollaboration/messageAudienceRouting.test.ts new file mode 100644 index 0000000000..65e477e9a6 --- /dev/null +++ b/src/features/TeamCollaboration/messageAudienceRouting.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { + type MessageAudienceSurface, + type MessageAudienceTarget, + resolveMessageAudience, +} from "./messageAudienceRouting"; +import contractCases from "./messageAudienceRouting.contract.json"; + +interface AudienceContractCase { + name: string; + surface: MessageAudienceSurface; + targets: MessageAudienceTarget[]; + expected: { + humanScope: "none" | "channel" | "members"; + memberIds: string[]; + agentMode: "none" | "assigned" | "explicit"; + }; +} + +describe("resolveMessageAudience", () => { + for (const contractCase of contractCases as AudienceContractCase[]) { + it(contractCase.name, () => { + const route = resolveMessageAudience( + contractCase.surface, + contractCase.targets + ); + expect({ + humanScope: route.human.scope, + memberIds: route.human.memberIds, + agentMode: route.agent.mode, + }).toEqual(contractCase.expected); + }); + } +}); diff --git a/src/features/TeamCollaboration/messageAudienceRouting.ts b/src/features/TeamCollaboration/messageAudienceRouting.ts new file mode 100644 index 0000000000..789021fe2c --- /dev/null +++ b/src/features/TeamCollaboration/messageAudienceRouting.ts @@ -0,0 +1,89 @@ +/** + * Canonical message-audience policy for collaboration composers. + * + * Identity parsing stays at each surface boundary (Team Chat resolves @labels + * against the cloud roster; Work Items decode typed mention refs). Once targets + * are identity-stable, this function is the only frontend owner of the policy: + * Team Chat is always human conversation, while a Work Item comment defaults to + * its assigned Agent unless an explicit human audience replaces that default. + */ + +export type MessageAudienceSurface = "team_chat" | "work_item_comment"; + +export type MessageAudienceTarget = + | { kind: "member"; id: string } + | { kind: "agent"; id: string } + | { kind: "agent_org"; id: string } + | { kind: "all" }; + +export type HumanAudience = + | { scope: "none"; memberIds: [] } + | { scope: "channel"; memberIds: string[] } + | { scope: "members"; memberIds: string[] }; + +export type AgentAudience = + | { mode: "none" } + | { mode: "assigned" } + | { + mode: "explicit"; + target: Extract; + }; + +export interface MessageAudienceRoute { + human: HumanAudience; + agent: AgentAudience; +} + +function uniqueMemberIds(targets: readonly MessageAudienceTarget[]): string[] { + const seen = new Set(); + const memberIds: string[] = []; + for (const target of targets) { + if (target.kind !== "member") continue; + const id = target.id.trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + memberIds.push(id); + } + return memberIds; +} + +export function resolveMessageAudience( + surface: MessageAudienceSurface, + targets: readonly MessageAudienceTarget[] +): MessageAudienceRoute { + const memberIds = uniqueMemberIds(targets); + const addressesChannel = targets.some((target) => target.kind === "all"); + + if (surface === "team_chat") { + return { + human: + addressesChannel || memberIds.length === 0 + ? { scope: "channel", memberIds } + : { scope: "members", memberIds }, + agent: { mode: "none" }, + }; + } + + const explicitAgent = targets.find( + ( + target + ): target is Extract< + MessageAudienceTarget, + { kind: "agent" | "agent_org" } + > => target.kind === "agent" || target.kind === "agent_org" + ); + const human: HumanAudience = addressesChannel + ? { scope: "channel", memberIds } + : memberIds.length > 0 + ? { scope: "members", memberIds } + : { scope: "none", memberIds: [] }; + + return { + human, + agent: explicitAgent + ? { mode: "explicit", target: explicitAgent } + : human.scope === "none" + ? { mode: "assigned" } + : { mode: "none" }, + }; +} diff --git a/src/i18n/locales/de/sessions.json b/src/i18n/locales/de/sessions.json index 949c18a3c6..ff17f67176 100644 --- a/src/i18n/locales/de/sessions.json +++ b/src/i18n/locales/de/sessions.json @@ -2524,7 +2524,8 @@ "failedToInterrupt": "Session konnte nicht unterbrochen werden", "failedToStop": "Agent konnte nicht gestoppt werden", "failedToCheckChanges": "Dateiänderungen konnten nicht geprüft werden. Änderungen werden rückgängig gemacht.", - "insufficientBalance": "Unzureichendes Guthaben für den gehosteten ORGII-Dienst. Aufladen unter orgii.ai/wallet." + "insufficientBalance": "Unzureichendes Guthaben für den gehosteten ORGII-Dienst. Aufladen unter orgii.ai/wallet.", + "runQueuedBehindCheckout": "In Warteschlange: ein anderer Lauf verwendet dieses Checkout. Er startet, sobald dieser Lauf beendet ist." }, "planning": { "agentTyping": "Agent tippt...", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index ecfcfdd9db..e411624d5e 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -2347,11 +2347,13 @@ "filters": { "all": "All", "mentions": "Mentions", - "assigned": "Assigned to me" + "assigned": "Assigned to me", + "archived": "Archived" }, "sections": { "reviewRequested": "Review requested", - "authoredByMe": "Authored by me" + "authoredByMe": "Authored by me", + "updates": "Updates" }, "status": { "read": "Read", @@ -2385,6 +2387,10 @@ "noResults": { "title": "No matches", "subtitle": "No items match “{{query}}”." + }, + "archived": { + "title": "No archived items", + "subtitle": "Items you archive will appear here." } }, "loading": "Loading Team Inbox…", @@ -2461,7 +2467,10 @@ "pullRequestsPartialLoadHelp": "Available items are still shown; refresh to try loading the missing pull requests again", "workItemContext": "Some project context is unavailable. The work item remains usable.", "workItemLoad": "Unable to load this work item. Try again.", - "workItemUpdate": "Unable to save the latest work item change. Try again." + "workItemUpdate": "Unable to save the latest work item change. Try again.", + "archive": "Unable to archive this item. Try again.", + "unarchive": "Unable to restore this item. Try again.", + "mutePreferences": "Unable to update notification categories. Try again." }, "detail": { "assignedSubtitle": "Assigned work item", @@ -2474,7 +2483,9 @@ "actions": { "markRead": "Mark as read", "markUnread": "Mark as unread", - "openWorkItem": "Open work item" + "openWorkItem": "Open work item", + "archive": "Archive", + "unarchive": "Restore to inbox" }, "workItemStatus": { "backlog": "Backlog", @@ -2491,6 +2502,19 @@ "medium": "Medium", "high": "High", "urgent": "Urgent" + }, + "mute": { + "title": "Mute update categories" + }, + "events": { + "mention": "Mentioned in a comment", + "discussion_updated": "Discussion updated", + "run_failed": "Run failed", + "status_changed": "Status changed", + "assignee_changed": "Assignee changed", + "priority_changed": "Priority changed", + "dates_changed": "Dates changed", + "child_completed": "Child work item completed" } }, "globalToolbar": {}, diff --git a/src/i18n/locales/en/sessions.json b/src/i18n/locales/en/sessions.json index 70e03a26dc..7577d64968 100644 --- a/src/i18n/locales/en/sessions.json +++ b/src/i18n/locales/en/sessions.json @@ -894,7 +894,10 @@ "compactCommandDescription": "Summarize older context to free space. Optional: /compact ", "canvasCommandDescription": "Create a new interactive Canvas. Optional: /canvas ", "canvasArgHint": "what to build", - "newCanvasAction": "New Canvas" + "newCanvasAction": "New Canvas", + "followUpSuggestions": { + "label": "Suggested next steps" + } }, "listPanel": { "showingOf": "Showing {{filtered}} of {{total}}", @@ -2658,7 +2661,8 @@ "failedToInterrupt": "Failed to interrupt session", "failedToStop": "Failed to stop agent", "failedToCheckChanges": "Could not check file changes. Proceeding with revert.", - "insufficientBalance": "Insufficient balance for the hosted ORGII service. Top up at orgii.ai/wallet." + "insufficientBalance": "Insufficient balance for the hosted ORGII service. Top up at orgii.ai/wallet.", + "runQueuedBehindCheckout": "Queued: another run is using this checkout. It will start as soon as that run finishes." }, "revertConfirm": { "title": "Unsaved file changes", diff --git a/src/i18n/locales/es/sessions.json b/src/i18n/locales/es/sessions.json index 978a4daf3f..4de3070854 100644 --- a/src/i18n/locales/es/sessions.json +++ b/src/i18n/locales/es/sessions.json @@ -2526,7 +2526,8 @@ "failedToInterrupt": "Error al interrumpir la Session", "failedToStop": "Error al detener el Agent", "failedToCheckChanges": "No se pudieron verificar los cambios de archivo. Se procederá con la reversión.", - "insufficientBalance": "Saldo insuficiente para el servicio alojado de ORGII. Recarga en orgii.ai/wallet." + "insufficientBalance": "Saldo insuficiente para el servicio alojado de ORGII. Recarga en orgii.ai/wallet.", + "runQueuedBehindCheckout": "En cola: otra ejecución está usando este checkout. Comenzará en cuanto termine." }, "planning": { "agentTyping": "Agent está escribiendo...", diff --git a/src/i18n/locales/fr/sessions.json b/src/i18n/locales/fr/sessions.json index f0ec8f32e0..53510663fa 100644 --- a/src/i18n/locales/fr/sessions.json +++ b/src/i18n/locales/fr/sessions.json @@ -2526,7 +2526,8 @@ "failedToInterrupt": "Échec de l'interruption de la Session", "failedToStop": "Échec de l'arrêt de l'Agent", "failedToCheckChanges": "Impossible de vérifier les modifications de fichiers. La restauration sera effectuée.", - "insufficientBalance": "Solde insuffisant pour le service ORGII hébergé. Rechargez sur orgii.ai/wallet." + "insufficientBalance": "Solde insuffisant pour le service ORGII hébergé. Rechargez sur orgii.ai/wallet.", + "runQueuedBehindCheckout": "En file d'attente : une autre exécution utilise ce checkout. Elle démarrera dès que celle-ci sera terminée." }, "planning": { "agentTyping": "Agent est en train d’écrire...", diff --git a/src/i18n/locales/ja/sessions.json b/src/i18n/locales/ja/sessions.json index 7f0bd8ecfc..62ea7c63d5 100644 --- a/src/i18n/locales/ja/sessions.json +++ b/src/i18n/locales/ja/sessions.json @@ -2525,7 +2525,8 @@ "failedToInterrupt": "Session の中断に失敗しました", "failedToStop": "Agent の停止に失敗しました", "failedToCheckChanges": "ファイルの変更を確認できませんでした。変更を元に戻して続行します。", - "insufficientBalance": "ホスト型 ORGII サービスの残高が不足しています。orgii.ai/wallet でチャージしてください。" + "insufficientBalance": "ホスト型 ORGII サービスの残高が不足しています。orgii.ai/wallet でチャージしてください。", + "runQueuedBehindCheckout": "キュー待ち: 別の実行がこのチェックアウトを使用中です。完了次第開始します。" }, "planning": { "agentTyping": "Agent が入力中...", diff --git a/src/i18n/locales/ko/sessions.json b/src/i18n/locales/ko/sessions.json index 006f708065..e5992bc240 100644 --- a/src/i18n/locales/ko/sessions.json +++ b/src/i18n/locales/ko/sessions.json @@ -2526,7 +2526,8 @@ "failedToInterrupt": "Session 중단에 실패했습니다", "failedToStop": "Agent 중지에 실패했습니다", "failedToCheckChanges": "파일 변경사항을 확인할 수 없습니다. 변경사항을 되돌립니다.", - "insufficientBalance": "ORGII 호스팅 서비스의 잔액이 부족합니다. orgii.ai/wallet에서 충전하세요." + "insufficientBalance": "ORGII 호스팅 서비스의 잔액이 부족합니다. orgii.ai/wallet에서 충전하세요.", + "runQueuedBehindCheckout": "대기 중: 다른 실행이 이 체크아웃을 사용 중입니다. 해당 실행이 끝나면 시작됩니다." }, "planning": { "agentTyping": "Agent 입력 중...", diff --git a/src/i18n/locales/pl/sessions.json b/src/i18n/locales/pl/sessions.json index adfc36f662..a32c130ce8 100644 --- a/src/i18n/locales/pl/sessions.json +++ b/src/i18n/locales/pl/sessions.json @@ -2588,7 +2588,8 @@ "failedToInterrupt": "Nie udało się przerwać sesji", "failedToStop": "Nie udało się zatrzymać Agenta", "failedToCheckChanges": "Nie można sprawdzić zmian w plikach. Kontynuowanie cofania.", - "insufficientBalance": "Niewystarczające środki dla hostowanej usługi ORGII. Doładuj na orgii.ai/wallet." + "insufficientBalance": "Niewystarczające środki dla hostowanej usługi ORGII. Doładuj na orgii.ai/wallet.", + "runQueuedBehindCheckout": "W kolejce: inne uruchomienie korzysta z tego checkoutu. Rozpocznie się, gdy tamto się zakończy." }, "revertConfirm": { "title": "Niezapisane zmiany w plikach", diff --git a/src/i18n/locales/pt/sessions.json b/src/i18n/locales/pt/sessions.json index dbe4ff8332..76a2cdb9d3 100644 --- a/src/i18n/locales/pt/sessions.json +++ b/src/i18n/locales/pt/sessions.json @@ -2550,7 +2550,8 @@ "failedToInterrupt": "Falha ao interromper a sessão", "failedToStop": "Falha ao parar o Agent", "failedToCheckChanges": "Não foi possível verificar as alterações de arquivo. Prosseguindo com a reversão.", - "insufficientBalance": "Saldo insuficiente para o serviço hospedado ORGII. Recarregue em orgii.ai/wallet." + "insufficientBalance": "Saldo insuficiente para o serviço hospedado ORGII. Recarregue em orgii.ai/wallet.", + "runQueuedBehindCheckout": "Na fila: outra execução está usando este checkout. Começará assim que ela terminar." }, "revertConfirm": { "title": "Alterações de arquivo não salvas", diff --git a/src/i18n/locales/ru/sessions.json b/src/i18n/locales/ru/sessions.json index 921219cd6a..adbf06b856 100644 --- a/src/i18n/locales/ru/sessions.json +++ b/src/i18n/locales/ru/sessions.json @@ -2570,7 +2570,8 @@ "failedToInterrupt": "Не удалось прервать Session", "failedToStop": "Не удалось остановить Agent", "failedToCheckChanges": "Не удалось проверить изменения файлов. Будет выполнен откат.", - "insufficientBalance": "Недостаточно средств для использования хостингового сервиса ORGII. Пополните счёт на orgii.ai/wallet." + "insufficientBalance": "Недостаточно средств для использования хостингового сервиса ORGII. Пополните счёт на orgii.ai/wallet.", + "runQueuedBehindCheckout": "В очереди: этот checkout занят другим запуском. Запуск начнётся, как только он завершится." }, "planning": { "agentTyping": "Agent печатает...", diff --git a/src/i18n/locales/tr/sessions.json b/src/i18n/locales/tr/sessions.json index efc2715e48..ff8a07583b 100644 --- a/src/i18n/locales/tr/sessions.json +++ b/src/i18n/locales/tr/sessions.json @@ -2527,7 +2527,8 @@ "failedToInterrupt": "Session kesintiye uğratılamadı", "failedToStop": "Agent durdurulamadı", "failedToCheckChanges": "Dosya değişiklikleri kontrol edilemedi. Geri alma işlemi gerçekleştirilecek.", - "insufficientBalance": "Barındırılan ORGII hizmeti için yetersiz bakiye. orgii.ai/wallet adresinden yükleyin." + "insufficientBalance": "Barındırılan ORGII hizmeti için yetersiz bakiye. orgii.ai/wallet adresinden yükleyin.", + "runQueuedBehindCheckout": "Kuyrukta: başka bir çalıştırma bu checkout'u kullanıyor. O bitince başlayacak." }, "planning": { "agentTyping": "Agent yazıyor...", diff --git a/src/i18n/locales/vi/sessions.json b/src/i18n/locales/vi/sessions.json index a9be89a9a1..d0d3644792 100644 --- a/src/i18n/locales/vi/sessions.json +++ b/src/i18n/locales/vi/sessions.json @@ -2523,7 +2523,8 @@ "failedToInterrupt": "Ngắt Session thất bại", "failedToStop": "Dừng Agent thất bại", "failedToCheckChanges": "Không thể kiểm tra thay đổi tệp. Sẽ tiến hành hoàn tác.", - "insufficientBalance": "Số dư không đủ cho dịch vụ ORGII được lưu trữ. Nạp tiền tại orgii.ai/wallet." + "insufficientBalance": "Số dư không đủ cho dịch vụ ORGII được lưu trữ. Nạp tiền tại orgii.ai/wallet.", + "runQueuedBehindCheckout": "Đang xếp hàng: một lần chạy khác đang dùng checkout này. Sẽ bắt đầu ngay khi lần chạy đó kết thúc." }, "planning": { "agentTyping": "Agent đang nhập...", diff --git a/src/i18n/locales/zh-Hant/sessions.json b/src/i18n/locales/zh-Hant/sessions.json index 207fc63139..ebef3a2201 100644 --- a/src/i18n/locales/zh-Hant/sessions.json +++ b/src/i18n/locales/zh-Hant/sessions.json @@ -2541,7 +2541,8 @@ "failedToInterrupt": "中斷 Session 失敗", "failedToStop": "停止 Agent 失敗", "failedToCheckChanges": "無法檢查文件變更,將自動還原。", - "insufficientBalance": "ORGII 托管服務餘額不足,請前往 orgii.ai/wallet 儲值。" + "insufficientBalance": "ORGII 托管服務餘額不足,請前往 orgii.ai/wallet 儲值。", + "runQueuedBehindCheckout": "已排隊:另一個執行正在使用此檢出目錄,待其結束後將自動開始。" }, "planning": { "agentTyping": "Agent 正在輸入...", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index f846645938..1772d9b386 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -2280,11 +2280,13 @@ "filters": { "all": "全部", "mentions": "提及", - "assigned": "分配给我" + "assigned": "分配给我", + "archived": "已归档" }, "sections": { "reviewRequested": "已请求审查", - "authoredByMe": "由我创建" + "authoredByMe": "由我创建", + "updates": "动态" }, "status": { "read": "已读", @@ -2316,6 +2318,10 @@ "noResults": { "title": "无匹配结果", "subtitle": "没有与「{{query}}」匹配的事项。" + }, + "archived": { + "title": "暂无已归档事项", + "subtitle": "你归档的事项会显示在这里。" } }, "loading": "正在加载团队收件箱…", @@ -2391,7 +2397,10 @@ "pullRequestsPartialLoadHelp": "当前可用事项仍会保留显示;请刷新以重新加载缺少的 pull request", "workItemContext": "部分项目上下文暂不可用,工作项仍可继续查看和操作。", "workItemLoad": "无法加载此工作项,请重试。", - "workItemUpdate": "无法保存刚才的工作项修改,请重试。" + "workItemUpdate": "无法保存刚才的工作项修改,请重试。", + "archive": "归档失败,请重试。", + "unarchive": "恢复到收件箱失败,请重试。", + "mutePreferences": "更新通知分类失败,请重试。" }, "detail": { "assignedSubtitle": "分配给你的工作项", @@ -2403,7 +2412,9 @@ "actions": { "markRead": "标记已读", "markUnread": "标记未读", - "openWorkItem": "打开工作项" + "openWorkItem": "打开工作项", + "archive": "归档", + "unarchive": "恢复到收件箱" }, "workItemStatus": { "backlog": "待办池", @@ -2420,6 +2431,19 @@ "medium": "中", "high": "高", "urgent": "紧急" + }, + "mute": { + "title": "静音动态分类" + }, + "events": { + "mention": "评论中提及了你", + "discussion_updated": "讨论有更新", + "run_failed": "运行失败", + "status_changed": "状态已变更", + "assignee_changed": "负责人已变更", + "priority_changed": "优先级已变更", + "dates_changed": "日期已变更", + "child_completed": "子工作项已完成" } }, "globalToolbar": {}, diff --git a/src/i18n/locales/zh/sessions.json b/src/i18n/locales/zh/sessions.json index 17399f639e..fe96a85c08 100644 --- a/src/i18n/locales/zh/sessions.json +++ b/src/i18n/locales/zh/sessions.json @@ -885,7 +885,10 @@ "compactCommandDescription": "总结较早的上下文以释放空间。可选:/compact <本次总结的重点>", "canvasCommandDescription": "创建一个新的交互式 Canvas。可选:/canvas <要创建的内容>", "canvasArgHint": "要创建的内容", - "newCanvasAction": "新建 Canvas" + "newCanvasAction": "新建 Canvas", + "followUpSuggestions": { + "label": "建议的下一步" + } }, "listPanel": { "showingOf": "显示 {{filtered}} / {{total}}", @@ -2623,7 +2626,8 @@ "failedToInterrupt": "中断 Session 失败", "failedToStop": "停止 Agent 失败", "failedToCheckChanges": "无法检查文件变更,将自动还原。", - "insufficientBalance": "ORGII 托管服务余额不足,请前往 orgii.ai/wallet 充值。" + "insufficientBalance": "ORGII 托管服务余额不足,请前往 orgii.ai/wallet 充值。", + "runQueuedBehindCheckout": "已排队:另一个运行正在使用此检出目录,待其结束后将自动开始。" }, "planning": { "agentTyping": "Agent 正在输入...", diff --git a/src/modules/MainApp/TeamInbox/TeamInboxView.tsx b/src/modules/MainApp/TeamInbox/TeamInboxView.tsx index 971523c168..e988224a09 100644 --- a/src/modules/MainApp/TeamInbox/TeamInboxView.tsx +++ b/src/modules/MainApp/TeamInbox/TeamInboxView.tsx @@ -1,4 +1,10 @@ -import React, { useCallback, useMemo, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { useTranslation } from "react-i18next"; import InlineAlert from "@src/components/InlineAlert"; @@ -32,6 +38,7 @@ import { type TeamInboxItemFocusRequest, type TeamInboxViewState, } from "./store"; +import { useTeamInboxMutePreferences } from "./useTeamInboxMutePreferences"; import { useTeamInboxPagination } from "./useTeamInboxPagination"; import { useTeamInboxReadActions } from "./useTeamInboxReadActions"; @@ -89,9 +96,35 @@ const TeamInboxView: React.FC = ({ }, [t] ); + const [internalViewState, setInternalViewState] = + useState(() => ({ + ...INITIAL_TEAM_INBOX_VIEW_STATE, + filter: initialFilter, + })); + const viewState = controlledViewState ?? internalViewState; + const focusRequestActive = + focusRequest !== null && + focusRequest.requestId !== viewState.supersededFocusRequestId; + const listMode = + !focusRequestActive && viewState.filter === "archived" + ? "archived" + : "active"; + const updateViewState = useCallback( + (update: React.SetStateAction) => { + if (controlledViewState) { + const nextState = + typeof update === "function" ? update(controlledViewState) : update; + onViewStateChange?.(nextState); + return; + } + setInternalViewState(update); + }, + [controlledViewState, onViewStateChange] + ); const { items, setItems, + itemsMode, authoritativeUnreadCounts, loadState, setLoadState, @@ -101,44 +134,45 @@ const TeamInboxView: React.FC = ({ loadingMore, handleLoadMore, handleRefresh, + resetForModeSwitch, } = useTeamInboxPagination({ dataSource, + listMode, pageSize, issueMessage, t, onRefreshPullRequests, }); - const [internalViewState, setInternalViewState] = - useState(() => ({ - ...INITIAL_TEAM_INBOX_VIEW_STATE, - filter: initialFilter, - })); - const viewState = controlledViewState ?? internalViewState; - const updateViewState = useCallback( - (update: React.SetStateAction) => { - if (controlledViewState) { - const nextState = - typeof update === "function" ? update(controlledViewState) : update; - onViewStateChange?.(nextState); - return; - } - setInternalViewState(update); - }, - [controlledViewState, onViewStateChange] - ); + const { + mutedKinds, + mutePreferencesLoading, + handleLoadMutePreferences, + handleSetKindMuted, + } = useTeamInboxMutePreferences({ dataSource, t, setLoadState }); + const [dispositionPendingKey, setDispositionPendingKey] = useState< + string | null + >(null); const [dismissedLoadNoticeKey, setDismissedLoadNoticeKey] = useState< string | null >(null); const initialCombinedLoadPending = inboxInitialLoading || pullRequestsInitialLoading; const presentedItems = useMemo( - () => (initialCombinedLoadPending ? [] : items), - [initialCombinedLoadPending, items] + () => (initialCombinedLoadPending || itemsMode !== listMode ? [] : items), + [initialCombinedLoadPending, items, itemsMode, listMode] ); const presentedPullRequests = useMemo( () => (initialCombinedLoadPending ? [] : pullRequests), [initialCombinedLoadPending, pullRequests] ); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); const loadNoticeKey = (loadState.status === "error" || loadState.status === "warning") && @@ -150,9 +184,6 @@ const TeamInboxView: React.FC = ({ setDismissedLoadNoticeKey(loadNoticeKey); }, [loadNoticeKey]); - const focusRequestActive = - focusRequest !== null && - focusRequest.requestId !== viewState.supersededFocusRequestId; const visibleFilter = focusRequestActive ? "all" : viewState.filter; const visibleQuery = focusRequestActive ? "" : viewState.query; const requestedItemId = focusRequestActive @@ -237,11 +268,19 @@ const TeamInboxView: React.FC = ({ }; const handleFilterChange = (nextFilter: TeamInboxFilter) => { + const crossesArchiveBoundary = + (visibleFilter === "archived") !== (nextFilter === "archived"); + if (crossesArchiveBoundary) { + resetForModeSwitch(); + } updateViewState((current) => ({ ...current, filter: nextFilter, query: focusRequestActive ? "" : current.query, supersededFocusRequestId: focusRequest?.requestId ?? null, + ...(crossesArchiveBoundary + ? { selectedItemId: null, selectedPullRequestKey: null } + : {}), })); }; @@ -262,6 +301,55 @@ const TeamInboxView: React.FC = ({ })); }; + const handleDisposition = useCallback( + (item: TeamInboxItem, archived: boolean) => { + const mutate = archived + ? dataSource.archiveItem + : dataSource.unarchiveItem; + if (!mutate || dispositionPendingKey) return; + const itemKey = getTeamInboxItemKey(item); + setDispositionPendingKey(itemKey); + void mutate(item) + .then(() => { + if (!mountedRef.current) return; + setItems((current) => + current.filter( + (candidate) => getTeamInboxItemKey(candidate) !== itemKey + ) + ); + updateViewState((current) => ({ + ...current, + selectedItemId: + current.selectedItemId === itemKey + ? null + : current.selectedItemId, + })); + }) + .catch(() => { + if (!mountedRef.current) return; + setLoadState({ + status: "error", + message: t( + archived + ? "teamInbox.errors.archive" + : "teamInbox.errors.unarchive" + ), + }); + }) + .finally(() => { + if (mountedRef.current) setDispositionPendingKey(null); + }); + }, + [ + dataSource, + dispositionPendingKey, + setItems, + setLoadState, + t, + updateViewState, + ] + ); + const handleWorkItemUpdated = useCallback( (sourceItem: TeamInboxItem, workItem: WorkItem) => { if (sourceItem.kind !== "assigned_work_item") return; @@ -306,6 +394,9 @@ const TeamInboxView: React.FC = ({ onMarkUnread={handleMarkUnread} onRefresh={handleRefresh} onWorkItemUpdated={handleWorkItemUpdated} + archived={listMode === "archived"} + dispositionPendingKey={dispositionPendingKey} + onDisposition={handleDisposition} /> ); @@ -375,7 +466,7 @@ const TeamInboxView: React.FC = ({ loading={ initialCombinedLoadPending || loadState.status === "loading" || - pullRequestsLoading + (listMode === "active" && pullRequestsLoading) } pullRequests={presentedPullRequests} pullRequestsLoading={pullRequestsLoading} @@ -387,12 +478,30 @@ const TeamInboxView: React.FC = ({ onSelectPullRequest={handleSelectPullRequest} onRefresh={handleRefresh} onMarkAllRead={ - dataSource.markAllRead ? handleMarkAllRead : undefined + visibleFilter !== "archived" && dataSource.markAllRead + ? handleMarkAllRead + : undefined + } + mutedKinds={mutedKinds} + mutePreferencesLoading={mutePreferencesLoading} + onLoadMutePreferences={ + dataSource.listMutedKinds + ? handleLoadMutePreferences + : undefined + } + onSetKindMuted={ + dataSource.setKindMuted ? handleSetKindMuted : undefined } hasMore={hasMore} loadingMore={loadingMore} onLoadMore={ - dataSource.loadMore ? handleLoadMore : undefined + listMode === "archived" + ? dataSource.listArchivedPage + ? handleLoadMore + : undefined + : dataSource.loadMore + ? handleLoadMore + : undefined } /> diff --git a/src/modules/MainApp/TeamInbox/__tests__/TeamInboxList.test.ts b/src/modules/MainApp/TeamInbox/__tests__/TeamInboxList.test.ts index 51af97679a..88f2dcc94c 100644 --- a/src/modules/MainApp/TeamInbox/__tests__/TeamInboxList.test.ts +++ b/src/modules/MainApp/TeamInbox/__tests__/TeamInboxList.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import type { ManagedPrItem } from "../../WorkManagement/githubManagedItemModel"; import TeamInboxList from "../components/TeamInboxList"; -import type { AssignedWorkItem } from "../domain"; +import type { AssignedWorkItem, WorkItemUpdateItem } from "../domain"; vi.mock("react-i18next", () => ({ useTranslation: () => ({ @@ -115,6 +115,7 @@ describe("TeamInboxList pagination", () => { expect(markup).toContain('data-testid="team-inbox-filter-all"'); expect(markup).toContain('data-testid="team-inbox-filter-mentions"'); expect(markup).toContain('data-testid="team-inbox-filter-assigned"'); + expect(markup).toContain('data-testid="team-inbox-filter-archived"'); expect(markup).toContain('aria-pressed="true"'); expect(markup).toContain("bg-fill-2! text-text-1!"); expect(markup).toContain("border-0 bg-transparent text-text-2"); @@ -263,4 +264,47 @@ describe("TeamInboxList pagination", () => { expect(markup).toContain("hover:bg-surface-hover"); expect(markup).not.toContain("min-h-[72px]"); }); + + it("keeps Work Item events in a semantic updates section", () => { + const event: WorkItemUpdateItem = { + id: "event-1", + kind: "child_completed", + source: "local", + occurredAt: "2026-08-08T10:00:00.000Z", + readAt: null, + actor: { id: "member-2", displayName: "Lin" }, + target: { + kind: "work_item", + projectId: "demo", + workItemId: "AAA-0001", + }, + payload: { + title: "Child task", + eventKind: "child_completed", + status: "in_progress", + priority: "medium", + recipientMemberId: "member-1", + updatedAt: "2026-08-08T10:00:00.000Z", + }, + }; + + const markup = renderToStaticMarkup( + createElement(TeamInboxList, { + filter: "all", + items: [event], + selectedItemId: null, + totalUnread: 1, + unreadCounts: { all: 1, mentions: 0, assigned: 0 }, + query: "", + loading: false, + onQueryChange: vi.fn(), + onFilterChange: vi.fn(), + onSelectItem: vi.fn(), + }) + ); + + expect(markup).toContain('data-testid="team-inbox-updates"'); + expect(markup).toContain('data-item-kind="child_completed"'); + expect(markup).not.toContain('data-testid="team-inbox-assigned"'); + }); }); diff --git a/src/modules/MainApp/TeamInbox/__tests__/TeamInboxRow.test.ts b/src/modules/MainApp/TeamInbox/__tests__/TeamInboxRow.test.ts index 13d1295a39..a117e7fdd2 100644 --- a/src/modules/MainApp/TeamInbox/__tests__/TeamInboxRow.test.ts +++ b/src/modules/MainApp/TeamInbox/__tests__/TeamInboxRow.test.ts @@ -13,7 +13,11 @@ import { } from "vitest"; import TeamInboxRow from "../components/TeamInboxRow"; -import type { AssignedWorkItem, CommentMentionItem } from "../domain"; +import type { + AssignedWorkItem, + CommentMentionItem, + WorkItemUpdateItem, +} from "../domain"; vi.mock("react-i18next", () => ({ useTranslation: () => ({ @@ -89,6 +93,30 @@ const mentionItem: CommentMentionItem = { }, }; +const failedRunItem: WorkItemUpdateItem = { + id: "event-1", + kind: "work_item_run_failed", + source: "local", + occurredAt: new Date().toISOString(), + readAt: null, + actor: { id: "system", displayName: "" }, + target: { + kind: "work_item", + projectId: "orgii-issues-project", + workItemId: "AAA-0001", + repository: "https://github.com/org2AI/ORG2.git", + }, + payload: { + title: "Durable dispatch", + eventKind: "run_failed", + status: "in_progress", + priority: "high", + recipientMemberId: "member-1", + summary: "The latest Run failed after the provider disconnected.", + updatedAt: "2026-08-08T10:00:00.000Z", + }, +}; + describe("TeamInboxRow", () => { let container: HTMLDivElement; let root: Root; @@ -280,4 +308,29 @@ describe("TeamInboxRow", () => { expect(container.textContent).not.toContain("Please verify the sync path."); expect(container.querySelector("[title]")).toBeNull(); }); + + it("renders a Run failure as an update instead of an assignment", () => { + act(() => { + root.render( + createElement(TeamInboxRow, { + item: failedRunItem, + itemKey: "work_item_run_failed:event-1", + selected: false, + onSelect: vi.fn(), + }) + ); + }); + + expect(container.textContent).toContain("Run failed · ORG2 issue"); + expect(container.textContent).toContain( + "The latest Run failed after the provider disconnected." + ); + expect(container.textContent).not.toContain("Assigned to me"); + expect( + container.querySelector('[data-icon="circle-alert"]') + ).not.toBeNull(); + expect( + container.querySelector('[data-item-kind="work_item_run_failed"]') + ).not.toBeNull(); + }); }); diff --git a/src/modules/MainApp/TeamInbox/__tests__/TeamInboxView.layout.test.ts b/src/modules/MainApp/TeamInbox/__tests__/TeamInboxView.layout.test.ts index 81a74c66b9..b7e839ce73 100644 --- a/src/modules/MainApp/TeamInbox/__tests__/TeamInboxView.layout.test.ts +++ b/src/modules/MainApp/TeamInbox/__tests__/TeamInboxView.layout.test.ts @@ -17,7 +17,7 @@ import type { WorkItem } from "@src/types/core/workItem"; import type { ManagedPrItem } from "../../WorkManagement/githubManagedItemModel"; import TeamInboxView from "../TeamInboxView"; -import type { AssignedWorkItem } from "../domain"; +import type { AssignedWorkItem, ListTeamInboxInput } from "../domain"; import { INITIAL_TEAM_INBOX_VIEW_STATE, type TeamInboxViewState, @@ -28,6 +28,7 @@ const splitViewProps = vi.hoisted(() => ({ })); const componentProps = vi.hoisted(() => ({ assignedDetail: null as Record | null, + eventDetail: null as Record | null, list: null as Record | null, listRenderCount: 0, placeholder: null as Record | null, @@ -79,6 +80,10 @@ vi.mock("../components", () => ({ return null; }, CommentMentionDetail: () => null, + WorkItemEventDetail: (props: Record) => { + componentProps.eventDetail = props; + return null; + }, TeamInboxList: (props: Record) => { componentProps.list = props; componentProps.listRenderCount += 1; @@ -167,6 +172,7 @@ describe("TeamInboxView split layout", () => { beforeEach(() => { splitViewProps.current = null; componentProps.assignedDetail = null; + componentProps.eventDetail = null; componentProps.list = null; componentProps.listRenderCount = 0; componentProps.placeholder = null; @@ -829,6 +835,185 @@ describe("TeamInboxView split layout", () => { }); }); + it("archives an active row and removes it from the actionable list", async () => { + const archiveItem = vi.fn(async () => undefined); + const item = { ...partialLoadItem, readAt: "2026-08-05T00:01:00Z" }; + + await act(async () => { + root.render( + createElement(TeamInboxView, { + dataSource: { + listPage: async () => ({ items: [item], nextCursor: null }), + archiveItem, + }, + }) + ); + await Promise.resolve(); + }); + + act(() => { + const onSelectItem = componentProps.list?.onSelectItem as + | ((selected: AssignedWorkItem) => void) + | undefined; + onSelectItem?.(item); + }); + expect(componentProps.assignedDetail?.onArchive).toBeTypeOf("function"); + + await act(async () => { + const onArchive = componentProps.assignedDetail?.onArchive as + | ((selected: AssignedWorkItem) => void) + | undefined; + onArchive?.(item); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(archiveItem).toHaveBeenCalledWith(item); + expect(componentProps.list?.items).toEqual([]); + }); + + it("loads archived rows on demand and restores them without retaining the row", async () => { + const listPage = vi.fn(async () => ({ items: [], nextCursor: null })); + const item = { ...partialLoadItem, readAt: "2026-08-05T00:01:00Z" }; + const listArchivedPage = vi.fn(async () => ({ + items: [item], + nextCursor: null, + })); + const unarchiveItem = vi.fn(async () => undefined); + + await act(async () => { + root.render( + createElement(TeamInboxView, { + initialFilter: "archived", + dataSource: { listPage, listArchivedPage, unarchiveItem }, + }) + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(listPage).not.toHaveBeenCalled(); + expect(listArchivedPage).toHaveBeenCalledOnce(); + expect(componentProps.list?.filter).toBe("archived"); + act(() => { + const onSelectItem = componentProps.list?.onSelectItem as + | ((selected: AssignedWorkItem) => void) + | undefined; + onSelectItem?.(item); + }); + expect(componentProps.assignedDetail?.onUnarchive).toBeTypeOf("function"); + + await act(async () => { + const onUnarchive = componentProps.assignedDetail?.onUnarchive as + | ((selected: AssignedWorkItem) => void) + | undefined; + onUnarchive?.(item); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(unarchiveItem).toHaveBeenCalledWith(item); + expect(componentProps.list?.items).toEqual([]); + }); + + it("aborts an in-flight archived page request when the Inbox unmounts", async () => { + let requestSignal: AbortSignal | undefined; + const listArchivedPage = vi.fn( + ({ signal }: ListTeamInboxInput) => + new Promise(() => { + requestSignal = signal; + }) + ); + + await act(async () => { + root.render( + createElement(TeamInboxView, { + initialFilter: "archived", + dataSource: { + listPage: async () => ({ items: [], nextCursor: null }), + listArchivedPage, + }, + }) + ); + await Promise.resolve(); + }); + + expect(listArchivedPage).toHaveBeenCalledOnce(); + expect(requestSignal?.aborted).toBe(false); + + act(() => root.unmount()); + expect(requestSignal?.aborted).toBe(true); + root = createRoot(container); + }); + + it("loads mute preferences only on demand and applies one category", async () => { + const listMutedKinds = vi.fn(async () => ["run_failed" as const]); + const setKindMuted = vi.fn(async () => [ + "run_failed" as const, + "child_completed" as const, + ]); + + await act(async () => { + root.render( + createElement(TeamInboxView, { + dataSource: { + listPage: async () => ({ items: [], nextCursor: null }), + listMutedKinds, + setKindMuted, + }, + }) + ); + await Promise.resolve(); + }); + expect(listMutedKinds).not.toHaveBeenCalled(); + + await act(async () => { + const onLoadMutePreferences = componentProps.list + ?.onLoadMutePreferences as (() => void) | undefined; + onLoadMutePreferences?.(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(listMutedKinds).toHaveBeenCalledOnce(); + expect(componentProps.list?.mutedKinds).toEqual(["run_failed"]); + + await act(async () => { + const onSetKindMuted = componentProps.list?.onSetKindMuted as + | ((kind: "child_completed", muted: boolean) => void) + | undefined; + onSetKindMuted?.("child_completed", true); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(setKindMuted).toHaveBeenCalledWith("child_completed", true); + expect(componentProps.list?.mutedKinds).toEqual([ + "run_failed", + "child_completed", + ]); + }); + + it("disposes the data-source subscription when the Inbox unmounts", async () => { + const dispose = vi.fn(); + const subscribe = vi.fn(() => dispose); + + await act(async () => { + root.render( + createElement(TeamInboxView, { + dataSource: { + listPage: async () => ({ items: [], nextCursor: null }), + subscribe, + }, + }) + ); + await Promise.resolve(); + }); + expect(subscribe).toHaveBeenCalledOnce(); + + act(() => root.unmount()); + expect(dispose).toHaveBeenCalledOnce(); + root = createRoot(container); + }); + it("focuses and reads only the item explicitly requested by a notification", async () => { const firstItem: AssignedWorkItem = { id: "first", diff --git a/src/modules/MainApp/TeamInbox/__tests__/api.test.ts b/src/modules/MainApp/TeamInbox/__tests__/api.test.ts index 78d1b51ffa..fe5b1d3bfa 100644 --- a/src/modules/MainApp/TeamInbox/__tests__/api.test.ts +++ b/src/modules/MainApp/TeamInbox/__tests__/api.test.ts @@ -1,7 +1,13 @@ import { invoke } from "@tauri-apps/api/core"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { listLocalTeamInboxPage } from "../api"; +import { + archiveLocalTeamInboxItem, + listLocalTeamInboxMutedKinds, + listLocalTeamInboxPage, + setLocalTeamInboxKindMuted, + unarchiveLocalTeamInboxItem, +} from "../api"; vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(), @@ -90,7 +96,7 @@ describe("Team Inbox API mapping", () => { }); }); - it("projects subscription and Run failure notifications onto Work Item rows", async () => { + it("preserves subscription and Run failure semantics on Work Item rows", async () => { vi.mocked(invoke).mockResolvedValue({ items: [ { @@ -122,7 +128,7 @@ describe("Team Inbox API mapping", () => { expect(result.page.items[0]).toMatchObject({ id: "work-item-event:1", - kind: "assigned_work_item", + kind: "work_item_run_failed", target: { kind: "work_item", orgId: "personal-org", @@ -131,9 +137,113 @@ describe("Team Inbox API mapping", () => { }, payload: { title: "Durable dispatch", - assigneeMemberId: "member-1", + eventKind: "run_failed", + recipientMemberId: "member-1", summary: "The latest Run failed", }, }); }); + + it("promotes child completion into its own frontend event kind", async () => { + vi.mocked(invoke).mockResolvedValue({ + items: [ + { + id: "work_item_subscription_event:event-child", + kind: "work_item_updated", + occurredAt: Date.parse("2026-08-08T10:00:00.000Z"), + target: { + type: "work_item", + orgId: "personal-org", + projectSlug: "demo", + workItemId: "parent-row", + shortId: "AAA-0001", + }, + payload: { + type: "work_item_updated", + title: "Child task", + eventKind: "child_completed", + status: "in_progress", + priority: "high", + recipientMemberId: "member-1", + }, + }, + ], + unreadCount: 1, + }); + + const result = await listLocalTeamInboxPage(["member-1"], "all"); + + expect(result.page.items[0]).toMatchObject({ + kind: "child_completed", + source: "local", + payload: { + eventKind: "child_completed", + recipientMemberId: "member-1", + }, + }); + }); + + it("uses viewer-scoped archive commands and intersects category mutes", async () => { + vi.mocked(invoke) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(["run_failed", "child_completed"]) + .mockResolvedValueOnce(["run_failed"]); + + await expect( + archiveLocalTeamInboxItem(["member-a"], "work_item_assigned:work-1") + ).resolves.toBe(true); + await expect( + unarchiveLocalTeamInboxItem(["member-a"], "work_item_assigned:work-1") + ).resolves.toBe(true); + await expect( + listLocalTeamInboxMutedKinds(["member-a", "member-alias"]) + ).resolves.toEqual(["run_failed"]); + + expect(vi.mocked(invoke).mock.calls).toEqual([ + [ + "team_inbox_archive", + { + viewerMemberIds: ["member-a"], + itemId: "work_item_assigned:work-1", + }, + ], + [ + "team_inbox_unarchive", + { + viewerMemberIds: ["member-a"], + itemId: "work_item_assigned:work-1", + }, + ], + ["team_inbox_list_muted_kinds", { recipientId: "member-a" }], + ["team_inbox_list_muted_kinds", { recipientId: "member-alias" }], + ]); + }); + + it("applies category mutes to every current viewer identity", async () => { + vi.mocked(invoke) + .mockResolvedValueOnce(["run_failed"]) + .mockResolvedValueOnce(["run_failed"]) + .mockResolvedValueOnce(["run_failed"]) + .mockResolvedValueOnce(["run_failed"]); + + await expect( + setLocalTeamInboxKindMuted( + ["member-a", "member-alias"], + "run_failed", + true + ) + ).resolves.toEqual(["run_failed"]); + + expect(vi.mocked(invoke).mock.calls.slice(0, 2)).toEqual([ + [ + "team_inbox_set_kind_muted", + { recipientId: "member-a", kind: "run_failed", muted: true }, + ], + [ + "team_inbox_set_kind_muted", + { recipientId: "member-alias", kind: "run_failed", muted: true }, + ], + ]); + }); }); diff --git a/src/modules/MainApp/TeamInbox/__tests__/selectors.test.ts b/src/modules/MainApp/TeamInbox/__tests__/selectors.test.ts index 4c4da9739c..484abd7135 100644 --- a/src/modules/MainApp/TeamInbox/__tests__/selectors.test.ts +++ b/src/modules/MainApp/TeamInbox/__tests__/selectors.test.ts @@ -17,6 +17,7 @@ import type { AssignedWorkItem, CommentMentionItem, TeamInboxItem, + WorkItemUpdateItem, } from "../domain/types"; const mention = ( @@ -68,6 +69,31 @@ const assignment = ( ...overrides, }); +const update = ( + overrides: Partial = {} +): WorkItemUpdateItem => ({ + id: "event-1", + kind: "work_item_run_failed", + occurredAt: "2026-07-23T11:00:00.000Z", + readAt: null, + actor: { id: "system", displayName: "" }, + target: { + kind: "work_item", + projectId: "project-1", + workItemId: "work-item-1", + }, + payload: { + title: "Add Team Inbox", + eventKind: "run_failed", + status: "in_progress", + priority: "high", + recipientMemberId: "member-2", + summary: "Provider disconnected", + updatedAt: "2026-07-23T11:00:00.000Z", + }, + ...overrides, +}); + describe("Team Inbox selectors", () => { it("builds identity from kind and canonical id", () => { expect(getTeamInboxItemKey(mention())).toBe("comment_mention:comment-1"); @@ -108,10 +134,12 @@ describe("Team Inbox selectors", () => { }); it("filters mentions and assignments without mutating the input", () => { - const items = [mention(), assignment()]; + const items = [mention(), assignment(), update()]; expect(filterTeamInboxItems(items, "mentions")).toEqual([items[0]]); expect(filterTeamInboxItems(items, "assigned")).toEqual([items[1]]); + expect(filterTeamInboxItems(items, "all")).toEqual(items); + expect(filterTeamInboxItems(items, "archived")).toEqual(items); expect(filterTeamInboxItems(items, "all")).not.toBe(items); }); @@ -175,8 +203,9 @@ describe("Team Inbox selectors", () => { mention(), assignment(), unreadAssignment, + update(), ]) - ).toEqual({ all: 2, mentions: 1, assigned: 1 }); + ).toEqual({ all: 3, mentions: 1, assigned: 1 }); }); it("returns zeroed counts for an empty inbox", () => { @@ -191,6 +220,7 @@ describe("Team Inbox selectors", () => { expect(filterItemKind("all")).toBeNull(); expect(filterItemKind("mentions")).toBe("comment_mention"); expect(filterItemKind("assigned")).toBe("assigned_work_item"); + expect(filterItemKind("archived")).toBeNull(); }); it("maps both targets to typed navigation intents", () => { diff --git a/src/modules/MainApp/TeamInbox/__tests__/sessionHandoffProjects.test.ts b/src/modules/MainApp/TeamInbox/__tests__/sessionHandoffProjects.test.ts index 67ce61ccfb..33849c1a01 100644 --- a/src/modules/MainApp/TeamInbox/__tests__/sessionHandoffProjects.test.ts +++ b/src/modules/MainApp/TeamInbox/__tests__/sessionHandoffProjects.test.ts @@ -6,7 +6,7 @@ import { eligibleSessionHandoffProjects, handoffCloudOrgFromRoster, handoffProjectFromRoster, - teamInboxViewerMemberIds, + teamInboxViewerIdentityIds, } from "../sessionHandoffProjects"; function project(slug: string, name: string, orgId = "org-1"): ProjectData { @@ -134,12 +134,18 @@ describe("Session handoff project resolution", () => { }); }); - it("queries assignments for the cloud account id and exact local aliases", () => { + it("queries events for cloud, local actor, git email, and member aliases", () => { expect( - teamInboxViewerMemberIds( + teamInboxViewerIdentityIds( new Set(["local-git-alias", "cloud-account-1"]), + ["local-actor-id", "dev@example.com", " "], "cloud-account-1" ) - ).toEqual(["cloud-account-1", "local-git-alias"]); + ).toEqual([ + "cloud-account-1", + "dev@example.com", + "local-actor-id", + "local-git-alias", + ]); }); }); diff --git a/src/modules/MainApp/TeamInbox/__tests__/teamInboxCoordinator.test.ts b/src/modules/MainApp/TeamInbox/__tests__/teamInboxCoordinator.test.ts index 5f5b493af3..1a0c6d2738 100644 --- a/src/modules/MainApp/TeamInbox/__tests__/teamInboxCoordinator.test.ts +++ b/src/modules/MainApp/TeamInbox/__tests__/teamInboxCoordinator.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import type { TeamInboxMention } from "@src/features/Org2Cloud/teamInboxMentionsClient"; -import type { AssignedWorkItem } from "../domain"; +import type { AssignedWorkItem, WorkItemUpdateItem } from "../domain"; import { teamInboxCacheAtom } from "../store"; import { TEAM_INBOX_CACHE_LIMIT, @@ -50,6 +50,30 @@ function mention(id: string): TeamInboxMention { }; } +function failedRunItem(id: string): WorkItemUpdateItem { + return { + id, + kind: "work_item_run_failed", + source: "local", + occurredAt: "2026-07-28T11:00:00.000Z", + readAt: null, + actor: { id: "system", displayName: "" }, + target: { + kind: "work_item", + projectId: "project-1", + workItemId: id, + }, + payload: { + title: id, + eventKind: "run_failed", + status: "in_progress", + priority: "high", + recipientMemberId: "viewer-1", + updatedAt: "2026-07-28T11:00:00.000Z", + }, + }; +} + function dependencies( overrides: Partial = {} ): TeamInboxCoordinatorDependencies { @@ -375,6 +399,43 @@ describe("TeamInboxCoordinator", () => { expect(store.get(teamInboxCacheAtom).unreadCount).toBe(1); }); + it("counts Work Item updates in All without inflating Assigned", async () => { + const event = failedRunItem("event-1"); + const markAllLocalRead = vi.fn(async () => 1); + const coordinator = new TeamInboxCoordinator( + dependencies({ + listLocalPage: vi.fn(async () => ({ + page: { + items: [event], + nextCursor: null, + unreadCounts: { all: 1, mentions: 0, assigned: 0 }, + }, + unreadCount: 1, + })), + markAllLocalRead, + }) + ); + const store = createStore(); + const viewerScope = scope(); + + await coordinator.refresh(store, viewerScope, "version-1"); + expect(store.get(teamInboxCacheAtom)).toMatchObject({ + unreadCount: 1, + unreadCounts: { all: 1, mentions: 0, assigned: 0 }, + }); + + await coordinator.markRead(store, viewerScope, event); + expect(store.get(teamInboxCacheAtom)).toMatchObject({ + unreadCount: 0, + unreadCounts: { all: 0, mentions: 0, assigned: 0 }, + }); + + await coordinator.markUnread(store, viewerScope, event); + await coordinator.markAllRead(store, viewerScope, "all"); + expect(markAllLocalRead).toHaveBeenCalledWith(["viewer-1"], "all"); + expect(store.get(teamInboxCacheAtom).unreadCount).toBe(0); + }); + it("caps retained rows and closes cursors at the cache boundary", async () => { const coordinator = new TeamInboxCoordinator( dependencies({ diff --git a/src/modules/MainApp/TeamInbox/__tests__/useTeamInboxDataSource.test.ts b/src/modules/MainApp/TeamInbox/__tests__/useTeamInboxDataSource.test.ts index d1a940ab36..667ab0306f 100644 --- a/src/modules/MainApp/TeamInbox/__tests__/useTeamInboxDataSource.test.ts +++ b/src/modules/MainApp/TeamInbox/__tests__/useTeamInboxDataSource.test.ts @@ -229,7 +229,7 @@ describe("useTeamInboxDataSource orchestration", () => { const latestScope = refreshScopes().at(-1); expect(latestScope).toMatchObject({ - viewerMemberIds: ["viewer-1"], + viewerMemberIds: ["viewer-1", "viewer@example.test"], activeCloudOrgId: null, prerequisiteIssue: { code: "partial_load", @@ -323,7 +323,11 @@ describe("useTeamInboxDataSource orchestration", () => { scope.members.some((member) => member.id === "member-a") ) ).toBe(false); - expect(latestViewerMemberIds).toEqual(["cloud-viewer", "viewer-1"]); + expect(latestViewerMemberIds).toEqual([ + "cloud-viewer", + "viewer-1", + "viewer@example.test", + ]); }); it("rejects a late roster from a previous authenticated identity in the same org", async () => { @@ -408,7 +412,11 @@ describe("useTeamInboxDataSource orchestration", () => { scope.members.some((member) => member.id === "member-a") ) ).toBe(false); - expect(latestViewerMemberIds).toEqual(["cloud-viewer-b", "viewer-1"]); + expect(latestViewerMemberIds).toEqual([ + "cloud-viewer-b", + "viewer-1", + "viewer@example.test", + ]); }); it("shares one local roster request across concurrent mounted consumers", async () => { diff --git a/src/modules/MainApp/TeamInbox/__tests__/useTeamInboxWorkItem.test.ts b/src/modules/MainApp/TeamInbox/__tests__/useTeamInboxWorkItem.test.ts index 0a536ddfd5..f5c7992ea8 100644 --- a/src/modules/MainApp/TeamInbox/__tests__/useTeamInboxWorkItem.test.ts +++ b/src/modules/MainApp/TeamInbox/__tests__/useTeamInboxWorkItem.test.ts @@ -315,7 +315,8 @@ describe("useTeamInboxWorkItem", () => { expect(mocks.updateStandaloneWorkItemPartial).toHaveBeenCalledWith( "AAA-0001", expect.objectContaining({ workItemStatus: "in_review" }), - { orgId: "cloud-org-1" } + { orgId: "cloud-org-1" }, + undefined ); expect(mocks.updateWorkItemPartial).not.toHaveBeenCalled(); }); diff --git a/src/modules/MainApp/TeamInbox/api.ts b/src/modules/MainApp/TeamInbox/api.ts index 62c7aabbdb..4ade3a1e78 100644 --- a/src/modules/MainApp/TeamInbox/api.ts +++ b/src/modules/MainApp/TeamInbox/api.ts @@ -7,6 +7,7 @@ import type { TeamInboxCursor, TeamInboxFilter, TeamInboxItem, + TeamInboxNotificationKind, TeamInboxPage, } from "./domain"; @@ -91,6 +92,12 @@ interface TeamInboxWirePage { items: TeamInboxWireItem[]; nextCursor?: TeamInboxWireCursor; unreadCount: number; + unreadCounts?: { + all: number; + mentions: number; + assigned: number; + updates: number; + }; } function toIso(timestamp: number | undefined): string | null { @@ -112,6 +119,7 @@ function mapWireItem(item: TeamInboxWireItem): TeamInboxItem { return { id: item.id, kind: "comment_mention", + source: "local", occurredAt, readAt: toIso(item.readAt), actor, @@ -135,6 +143,7 @@ function mapWireItem(item: TeamInboxWireItem): TeamInboxItem { return { id: item.id, kind: "comment_mention", + source: "local", occurredAt, readAt: toIso(item.readAt), actor, @@ -159,9 +168,16 @@ function mapWireItem(item: TeamInboxWireItem): TeamInboxItem { item.target.type === "work_item" && item.payload.type === "work_item_updated" ) { + const eventKind = item.payload.eventKind; return { id: item.id, - kind: "assigned_work_item", + kind: + eventKind === "child_completed" + ? "child_completed" + : item.kind === "work_item_run_failed" + ? "work_item_run_failed" + : "work_item_updated", + source: "local", occurredAt, readAt: toIso(item.readAt), actor, @@ -176,9 +192,10 @@ function mapWireItem(item: TeamInboxWireItem): TeamInboxItem { }, payload: { title: item.payload.title, + eventKind, status: item.payload.status, priority: item.payload.priority, - assigneeMemberId: item.payload.recipientMemberId, + recipientMemberId: item.payload.recipientMemberId, summary: item.payload.summary, updatedAt: occurredAt, }, @@ -193,6 +210,7 @@ function mapWireItem(item: TeamInboxWireItem): TeamInboxItem { return { id: item.id, kind: "assigned_work_item", + source: "local", occurredAt, readAt: toIso(item.readAt), actor, @@ -251,11 +269,70 @@ export async function listLocalTeamInboxPage( itemKey: wire.nextCursor.itemId, } : null, + unreadCounts: wire.unreadCounts + ? { + all: wire.unreadCounts.all, + mentions: wire.unreadCounts.mentions, + assigned: wire.unreadCounts.assigned, + } + : undefined, }, unreadCount: wire.unreadCount, }; } +export async function archiveLocalTeamInboxItem( + viewerMemberIds: readonly string[], + itemId: string +): Promise { + return invoke("team_inbox_archive", { + viewerMemberIds: [...viewerMemberIds], + itemId, + }); +} + +export async function unarchiveLocalTeamInboxItem( + viewerMemberIds: readonly string[], + itemId: string +): Promise { + return invoke("team_inbox_unarchive", { + viewerMemberIds: [...viewerMemberIds], + itemId, + }); +} + +export async function listLocalTeamInboxMutedKinds( + viewerMemberIds: readonly string[] +): Promise { + if (viewerMemberIds.length === 0) return []; + const results = await Promise.all( + viewerMemberIds.map((recipientId) => + invoke("team_inbox_list_muted_kinds", { recipientId }) + ) + ); + const mutedForEveryIdentity = results[0].filter((kind) => + results.every((result) => result.includes(kind)) + ); + return mutedForEveryIdentity as TeamInboxNotificationKind[]; +} + +export async function setLocalTeamInboxKindMuted( + viewerMemberIds: readonly string[], + kind: TeamInboxNotificationKind, + muted: boolean +): Promise { + await Promise.all( + viewerMemberIds.map((recipientId) => + invoke("team_inbox_set_kind_muted", { + recipientId, + kind, + muted, + }) + ) + ); + return listLocalTeamInboxMutedKinds(viewerMemberIds); +} + export async function markLocalTeamInboxItemRead( viewerMemberIds: readonly string[], itemId: string diff --git a/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx b/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx index 855807a3b4..dd19afae0c 100644 --- a/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx +++ b/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx @@ -6,6 +6,8 @@ import type { WorkItemHandoffTransition } from "@src/api/http/project"; import type { GitHubIssue } from "@src/api/tauri/github"; import { Placeholder } from "@src/components/Placeholder"; import { + ArchiveArrowUpIcon, + ArchiveIcon, ClipboardListIcon, HugeiconsIcon, InternetIcon, @@ -40,6 +42,10 @@ export interface AssignedWorkItemDetailProps { onMarkRead?: (item: AssignedWorkItem) => void; onMarkUnread?: (item: AssignedWorkItem) => void; onWorkItemUpdated?: (workItem: WorkItem) => void; + archived?: boolean; + dispositionPending?: boolean; + onArchive?: (item: AssignedWorkItem) => void; + onUnarchive?: (item: AssignedWorkItem) => void; } function getGitHubIssueNumber( @@ -222,6 +228,10 @@ const AssignedWorkItemDetail: React.FC = ({ onMarkRead, onMarkUnread, onWorkItemUpdated, + archived = false, + dispositionPending = false, + onArchive, + onUnarchive, }) => { const { t } = useTranslation(); const { @@ -388,6 +398,41 @@ const AssignedWorkItemDetail: React.FC = ({ } : undefined } + headerDispositionAction={ + archived && onUnarchive + ? { + label: t("teamInbox.actions.unarchive"), + icon: ( + + ), + onClick: () => onUnarchive(item), + testId: "team-inbox-unarchive", + disabled: dispositionPending, + } + : !archived && onArchive + ? { + label: t("teamInbox.actions.archive"), + icon: ( + + ), + onClick: () => onArchive(item), + testId: "team-inbox-archive", + disabled: dispositionPending, + } + : undefined + } onMarkRead={onMarkRead ? () => onMarkRead(item) : undefined} onMarkUnread={onMarkUnread ? () => onMarkUnread(item) : undefined} onOpen={ diff --git a/src/modules/MainApp/TeamInbox/components/CommentMentionDetail.tsx b/src/modules/MainApp/TeamInbox/components/CommentMentionDetail.tsx index 6da7078aae..b88c9dc9e6 100644 --- a/src/modules/MainApp/TeamInbox/components/CommentMentionDetail.tsx +++ b/src/modules/MainApp/TeamInbox/components/CommentMentionDetail.tsx @@ -2,7 +2,13 @@ import React from "react"; import { useTranslation } from "react-i18next"; import PersonAvatar from "@src/components/PersonAvatar"; -import { AtIcon, HugeiconsIcon, LinkSquare02Icon } from "@src/icons"; +import { + ArchiveArrowUpIcon, + ArchiveIcon, + AtIcon, + HugeiconsIcon, + LinkSquare02Icon, +} from "@src/icons"; import { WORK_ITEM_THREAD_TOKENS } from "@src/modules/ProjectManager/WorkItems/components/WorkItemThread"; import { ConnectedTimelineItem, @@ -20,6 +26,10 @@ export interface CommentMentionDetailProps { onNavigate?: (intent: TeamInboxNavigationIntent) => void; onMarkRead?: (item: CommentMentionItem) => void; onMarkUnread?: (item: CommentMentionItem) => void; + archived?: boolean; + dispositionPending?: boolean; + onArchive?: (item: CommentMentionItem) => void; + onUnarchive?: (item: CommentMentionItem) => void; } const CommentMentionDetail: React.FC = ({ @@ -27,6 +37,10 @@ const CommentMentionDetail: React.FC = ({ onNavigate, onMarkRead, onMarkUnread, + archived = false, + dispositionPending = false, + onArchive, + onUnarchive, }) => { const { t } = useTranslation(); const targetTitle = @@ -55,6 +69,41 @@ const CommentMentionDetail: React.FC = ({ } onMarkRead={onMarkRead ? () => onMarkRead(item) : undefined} onMarkUnread={onMarkUnread ? () => onMarkUnread(item) : undefined} + headerDispositionAction={ + archived && onUnarchive + ? { + label: t("teamInbox.actions.unarchive"), + icon: ( + + ), + onClick: () => onUnarchive(item), + testId: "team-inbox-unarchive", + disabled: dispositionPending, + } + : !archived && onArchive + ? { + label: t("teamInbox.actions.archive"), + icon: ( + + ), + onClick: () => onArchive(item), + testId: "team-inbox-archive", + disabled: dispositionPending, + } + : undefined + } onOpen={ onNavigate ? () => diff --git a/src/modules/MainApp/TeamInbox/components/TeamInboxDetailLayout.tsx b/src/modules/MainApp/TeamInbox/components/TeamInboxDetailLayout.tsx index 351b85f824..999903ced3 100644 --- a/src/modules/MainApp/TeamInbox/components/TeamInboxDetailLayout.tsx +++ b/src/modules/MainApp/TeamInbox/components/TeamInboxDetailLayout.tsx @@ -27,6 +27,7 @@ export interface TeamInboxDetailLayoutProps { openLabel: string; openIcon: React.ReactNode; headerAuxiliaryAction?: TeamInboxHeaderIconActionProps; + headerDispositionAction?: TeamInboxHeaderIconActionProps; onMarkRead?: () => void; onMarkUnread?: () => void; onOpen?: () => void; @@ -44,6 +45,7 @@ const TeamInboxDetailLayout: React.FC = ({ openLabel, openIcon, headerAuxiliaryAction, + headerDispositionAction, onMarkRead, onMarkUnread, onOpen, @@ -91,6 +93,9 @@ const TeamInboxDetailLayout: React.FC = ({ const auxiliaryAction = headerAuxiliaryAction ? ( ) : null; + const dispositionAction = headerDispositionAction ? ( + + ) : null; return ( @@ -101,12 +106,16 @@ const TeamInboxDetailLayout: React.FC = ({ borderBottom className={DETAIL_PANEL_TOKENS.headerPadding} actions={ - readAction || auxiliaryAction || headerOpenAction ? ( + readAction || + dispositionAction || + auxiliaryAction || + headerOpenAction ? (
{readAction} + {dispositionAction} {auxiliaryAction} {headerOpenAction}
diff --git a/src/modules/MainApp/TeamInbox/components/TeamInboxDetailPane.tsx b/src/modules/MainApp/TeamInbox/components/TeamInboxDetailPane.tsx index db0bd55ea6..78bbdf4836 100644 --- a/src/modules/MainApp/TeamInbox/components/TeamInboxDetailPane.tsx +++ b/src/modules/MainApp/TeamInbox/components/TeamInboxDetailPane.tsx @@ -17,14 +17,18 @@ import type { PrIdentity } from "@src/store/workstation/codeEditor/workstationSe import type { WorkItem } from "@src/types/core/workItem"; import { openExternalLink } from "@src/util/platform/ipcRenderer"; -import { AssignedWorkItemDetail, CommentMentionDetail } from "."; +import { + AssignedWorkItemDetail, + CommentMentionDetail, + WorkItemEventDetail, +} from "."; import type { LoadState, TeamInboxDataSource, TeamInboxItem, TeamInboxNavigationIntent, } from "../domain"; -import { toTeamInboxNavigationIntent } from "../domain"; +import { getTeamInboxItemKey, toTeamInboxNavigationIntent } from "../domain"; import TeamInboxHeaderIconAction from "./TeamInboxHeaderIconAction"; const PullRequestDetailPanel = React.lazy(() => @@ -47,6 +51,11 @@ export interface TeamInboxDetailPaneProps { onMarkUnread: (item: TeamInboxItem) => void; onRefresh: () => void; onWorkItemUpdated: (sourceItem: TeamInboxItem, workItem: WorkItem) => void; + /** Whether the pane is showing the archived list. */ + archived: boolean; + /** Row key of the archive/unarchive mutation in flight, if any. */ + dispositionPendingKey: string | null; + onDisposition: (item: TeamInboxItem, archived: boolean) => void; } export const TeamInboxDetailPane: React.FC = ({ @@ -63,6 +72,9 @@ export const TeamInboxDetailPane: React.FC = ({ onMarkUnread, onRefresh, onWorkItemUpdated, + archived, + dispositionPendingKey, + onDisposition, }) => { if (selectedPullRequest && selectedPullRequestIdentity) { const tabActions = ( @@ -149,12 +161,24 @@ export const TeamInboxDetailPane: React.FC = ({ /> ); } + const dispositionPending = + dispositionPendingKey === getTeamInboxItemKey(selectedItem); + const onArchive = dataSource.archiveItem + ? (item: TeamInboxItem) => onDisposition(item, true) + : undefined; + const onUnarchive = dataSource.unarchiveItem + ? (item: TeamInboxItem) => onDisposition(item, false) + : undefined; if (selectedItem.kind === "comment_mention") { return ( onNavigate(toTeamInboxNavigationIntent(selectedItem)) @@ -163,11 +187,29 @@ export const TeamInboxDetailPane: React.FC = ({ /> ); } + if (selectedItem.kind !== "assigned_work_item") { + return ( + + ); + } return ( onWorkItemUpdated(selectedItem, workItem) diff --git a/src/modules/MainApp/TeamInbox/components/TeamInboxHeaderIconAction.tsx b/src/modules/MainApp/TeamInbox/components/TeamInboxHeaderIconAction.tsx index 3609cbdf31..301291e7d3 100644 --- a/src/modules/MainApp/TeamInbox/components/TeamInboxHeaderIconAction.tsx +++ b/src/modules/MainApp/TeamInbox/components/TeamInboxHeaderIconAction.tsx @@ -8,6 +8,7 @@ export interface TeamInboxHeaderIconActionProps { icon: React.ReactNode; onClick: () => void; testId?: string; + disabled?: boolean; } /** Shared icon-only action used by Inbox detail title rows. */ @@ -16,6 +17,7 @@ const TeamInboxHeaderIconAction: React.FC = ({ icon, onClick, testId, + disabled = false, }) => (