diff --git a/crates/tracedecay-api/src/workflow.rs b/crates/tracedecay-api/src/workflow.rs index 9db388b37..afda1d0ad 100644 --- a/crates/tracedecay-api/src/workflow.rs +++ b/crates/tracedecay-api/src/workflow.rs @@ -122,6 +122,21 @@ impl WorkflowOperation { .find(|operation| operation.operation_key() == key) } + /// Whether the operation reads without producing a durable effect. + /// Parity with the catalog's effect class is pinned by + /// `read_only_operations_mirror_the_catalog_effect_class`. + pub const fn is_read_only(self) -> bool { + matches!( + self, + Self::ValidateDefinition + | Self::GetDefinition + | Self::ListDefinitions + | Self::DefinitionHistory + | Self::DiffDefinition + | Self::GetRun + ) + } + pub fn from_cli_name(name: &str) -> Option { Self::ALL.iter().copied().find(|operation| { operation.operation_key() == name || operation.route_segment() == name @@ -356,6 +371,27 @@ mod tests { } } + #[test] + fn read_only_operations_mirror_the_catalog_effect_class() { + let registry = tracedecay_application::workflow_executable_binding_registry() + .expect("canonical Workflow executable registry"); + for operation in WorkflowOperation::ALL { + let operation_id = + tracedecay_tool_catalog::OperationId::new(operation.operation_id_str().to_owned()) + .expect("catalog operation id"); + let binding = registry + .get(&operation_id) + .and_then(|availability| availability.binding()) + .expect("every mounted Workflow operation has an executable binding"); + assert_eq!( + operation.is_read_only(), + binding.effect() == tracedecay_tool_catalog::EffectClass::Read, + "{} read-only declaration must mirror the catalog effect class", + operation.operation_key() + ); + } + } + #[tokio::test] async fn router_dispatches_every_advertised_definition_and_runtime_operation() { let seen = Arc::new(Mutex::new(Vec::new())); diff --git a/crates/tracedecay-application/src/lib.rs b/crates/tracedecay-application/src/lib.rs index a8b8228ec..f44b1f735 100644 --- a/crates/tracedecay-application/src/lib.rs +++ b/crates/tracedecay-application/src/lib.rs @@ -82,6 +82,7 @@ pub mod work_retry; pub mod work_run_control; pub mod work_synthesis; pub mod work_topology_view; +pub mod workflow_admission; pub mod workflow_catalog; pub mod workflow_coordination; pub mod workflow_effect; @@ -371,6 +372,7 @@ pub use work_retry::*; pub use work_run_control::*; pub use work_synthesis::*; pub use work_topology_view::*; +pub use workflow_admission::*; pub use workflow_catalog::*; pub use workflow_coordination::*; pub use workflow_effect::*; diff --git a/crates/tracedecay-application/src/workflow_admission.rs b/crates/tracedecay-application/src/workflow_admission.rs new file mode 100644 index 000000000..516d91534 --- /dev/null +++ b/crates/tracedecay-application/src/workflow_admission.rs @@ -0,0 +1,109 @@ +//! Tool-catalog semantic admission for workflow definitions. +//! +//! Plan 32: unknown operations and incompatible schemas reject before +//! activation. Structural validation proves only the DAG shape, so activation +//! additionally admits every step operation against the canonical Work +//! executable catalog — the registry fan-out lowers steps into. The schema +//! and capability halves of the check are the catalog digest pin: +//! [`crate::work_executable_catalog_digest`] hashes every capability manifest +//! and schema authority, so a stale `pinned_catalog_digest` is a typed +//! denial, never a silent re-pin. + +use std::fmt::{self, Display}; + +use tracedecay_domain::{ManifestDigest, WorkflowDefinition, WorkflowOperationRef, WorkflowStepId}; +use tracedecay_tool_catalog::{CatalogValidationError, OperationId}; + +use crate::work_catalog::{work_executable_binding_registry, work_executable_catalog_digest}; + +/// Typed denial produced by workflow catalog admission. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum WorkflowCatalogAdmissionError { + /// The step names an operation the executable catalog does not know. + UnknownOperation { + step_id: WorkflowStepId, + operation: WorkflowOperationRef, + }, + /// The operation is cataloged but currently has no executable binding. + OperationUnavailable { + step_id: WorkflowStepId, + operation: WorkflowOperationRef, + }, + /// The definition pins a catalog other than the live executable catalog, + /// so its operations were authored against different schemas or + /// capability contracts. + CatalogPinMismatch { + pinned: ManifestDigest, + current: ManifestDigest, + }, + /// The canonical catalog itself could not be composed. + CatalogUnavailable(CatalogValidationError), +} + +impl Display for WorkflowCatalogAdmissionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnknownOperation { step_id, operation } => write!( + formatter, + "workflow step {step_id} names unknown catalog operation {operation}" + ), + Self::OperationUnavailable { step_id, operation } => write!( + formatter, + "workflow step {step_id} names catalog operation {operation} without an executable binding" + ), + Self::CatalogPinMismatch { pinned, current } => write!( + formatter, + "workflow definition pins catalog {pinned} but the live executable catalog is {current}" + ), + Self::CatalogUnavailable(error) => { + write!( + formatter, + "workflow executable catalog unavailable: {error}" + ) + } + } + } +} + +impl std::error::Error for WorkflowCatalogAdmissionError {} + +/// Admit every step operation of one workflow definition against the +/// canonical Work executable catalog: the definition must pin the live +/// catalog digest and every step operation must resolve to an available +/// executable binding. The first violation is a typed denial naming the +/// offending step and operation. +pub(crate) fn admit_workflow_definition_operations( + definition: &WorkflowDefinition, +) -> Result<(), WorkflowCatalogAdmissionError> { + let registry = work_executable_binding_registry() + .map_err(WorkflowCatalogAdmissionError::CatalogUnavailable)?; + let current = work_executable_catalog_digest() + .map_err(WorkflowCatalogAdmissionError::CatalogUnavailable)?; + if definition.pinned_catalog_digest() != ¤t { + return Err(WorkflowCatalogAdmissionError::CatalogPinMismatch { + pinned: definition.pinned_catalog_digest().clone(), + current, + }); + } + for step in definition.steps() { + let Ok(operation_id) = OperationId::new(step.operation.as_str().to_owned()) else { + return Err(WorkflowCatalogAdmissionError::UnknownOperation { + step_id: step.step_id.clone(), + operation: step.operation.clone(), + }); + }; + let Some(availability) = registry.get(&operation_id) else { + return Err(WorkflowCatalogAdmissionError::UnknownOperation { + step_id: step.step_id.clone(), + operation: step.operation.clone(), + }); + }; + if availability.binding().is_none() { + return Err(WorkflowCatalogAdmissionError::OperationUnavailable { + step_id: step.step_id.clone(), + operation: step.operation.clone(), + }); + } + } + Ok(()) +} diff --git a/crates/tracedecay-application/src/workflow_coordination.rs b/crates/tracedecay-application/src/workflow_coordination.rs index 63bcbf6d9..7d79aea63 100644 --- a/crates/tracedecay-application/src/workflow_coordination.rs +++ b/crates/tracedecay-application/src/workflow_coordination.rs @@ -9,6 +9,9 @@ use std::fmt::{self, Display}; use crate::RequestContext; use crate::work_handoff_frontier::WorkHandoffFrontierV1; +use crate::workflow_admission::{ + WorkflowCatalogAdmissionError, admit_workflow_definition_operations, +}; use schemars::JsonSchema; use serde::{Deserialize, Deserializer, Serialize}; use tracedecay_domain::{ @@ -341,6 +344,7 @@ pub struct WorkflowDefinitionRejectRequest { #[derive(Clone, Debug, PartialEq, Eq)] pub enum WorkflowCoordinationError { InvalidDefinition, + CatalogAdmissionDenied(WorkflowCatalogAdmissionError), ScopeMismatch, ImmutableDefinitionConflict, DefinitionNotFound, @@ -353,6 +357,9 @@ impl Display for WorkflowCoordinationError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidDefinition => formatter.write_str("workflow definition is invalid"), + Self::CatalogAdmissionDenied(denial) => { + write!(formatter, "workflow catalog admission denied: {denial}") + } Self::ScopeMismatch => { formatter.write_str("workflow definition is outside the admitted project") } @@ -413,6 +420,8 @@ where } } + /// The preflight for activation: structural shape plus tool-catalog + /// semantic admission of every step operation. pub fn validate( &self, definition: WorkflowDefinition, @@ -420,6 +429,8 @@ where definition .validate() .map_err(|_| WorkflowCoordinationError::InvalidDefinition)?; + admit_workflow_definition_operations(&definition) + .map_err(WorkflowCoordinationError::CatalogAdmissionDenied)?; Ok(WorkflowDefinitionValidation { definition }) } @@ -452,13 +463,31 @@ where .map_err(coordination_authority_error) } + /// Admission every activation must clear before its lifecycle transition + /// is journaled: structural revalidation plus tool-catalog admission of + /// every step operation. The one authority both activation paths — this + /// service and the daemon's journaled effect — run. + pub fn admit_activation( + &self, + definition_id: &WorkflowDefinitionId, + definition_version: u64, + ) -> Result<(), WorkflowCoordinationError> { + let definition = self.get(definition_id, definition_version)?; + definition + .validate() + .map_err(|_| WorkflowCoordinationError::InvalidDefinition)?; + admit_workflow_definition_operations(&definition) + .map_err(WorkflowCoordinationError::CatalogAdmissionDenied) + } + /// Advances a registered definition version to `active`. /// /// Plan 32: "Unknown operations, cycles, dangling references, incompatible /// schemas, unbounded fan-out, privilege expansion, unsupported effects, /// or recursive generic execution reject before activation." The stored - /// payload is revalidated here, and the `candidate -> validated -> active` - /// path is recorded as immutable history entries by the authority. + /// payload is revalidated and catalog-admitted here, and the + /// `candidate -> validated -> active` path is recorded as immutable + /// history entries by the authority. pub fn activate( &self, definition_id: &WorkflowDefinitionId, @@ -466,10 +495,7 @@ where expected_revision: u64, transitioned_at: UtcMicros, ) -> Result { - let definition = self.get(definition_id, definition_version)?; - definition - .validate() - .map_err(|_| WorkflowCoordinationError::InvalidDefinition)?; + self.admit_activation(definition_id, definition_version)?; self.apply_lifecycle(WorkflowDefinitionLifecycleCommand { definition_id: definition_id.clone(), definition_version, diff --git a/crates/tracedecay-application/tests/workflow_coordination.rs b/crates/tracedecay-application/tests/workflow_coordination.rs index 2ad673d32..797eb1109 100644 --- a/crates/tracedecay-application/tests/workflow_coordination.rs +++ b/crates/tracedecay-application/tests/workflow_coordination.rs @@ -7,11 +7,11 @@ use tracedecay_application::{ RequestId, ResolvedScope, TASK_HANDOFF_LIFETIME_MICROS, TaskHandoffAuthorityError, TaskHandoffAuthorityPort, TaskHandoffConsumeOutcome, TaskHandoffError, TaskHandoffGrant, TaskHandoffIssueRequest, TaskHandoffRedeemRequest, TaskHandoffScope, TaskHandoffService, - TaskHandoffToken, WorkHandoffFrontierV1, WorkHandoffLineageV1, WorkflowCoordinationError, - WorkflowDefinitionAuthorityError, WorkflowDefinitionAuthorityPort, + TaskHandoffToken, WorkHandoffFrontierV1, WorkHandoffLineageV1, WorkflowCatalogAdmissionError, + WorkflowCoordinationError, WorkflowDefinitionAuthorityError, WorkflowDefinitionAuthorityPort, WorkflowDefinitionDisposition, WorkflowDefinitionLifecycleCommand, WorkflowDefinitionLifecycleState, WorkflowDefinitionService, WorkflowDefinitionTransitionEntry, - WorkflowDefinitionTransitionOutcome, + WorkflowDefinitionTransitionOutcome, work_executable_catalog_digest, }; use tracedecay_domain::{ ActorId, ManifestDigest, ProjectId, RepositoryId, RunId, TaskId, ThreadId, UtcMicros, @@ -63,11 +63,14 @@ fn workflow_context( .unwrap() } +/// A mounted Work operation, so fixtures clear catalog admission. +const MOUNTED_OPERATION: &str = "operation.work.start_attempt"; + fn definition(version: u64) -> WorkflowDefinition { definition_for_project( version, id("project.workflow.coordination"), - "operation.graph.workflow_step", + MOUNTED_OPERATION, ) } @@ -79,6 +82,20 @@ fn definition_for_project( version: u64, project_id: ProjectId, operation: &str, +) -> WorkflowDefinition { + definition_with_catalog_pin( + version, + project_id, + operation, + work_executable_catalog_digest().unwrap(), + ) +} + +fn definition_with_catalog_pin( + version: u64, + project_id: ProjectId, + operation: &str, + pinned_catalog_digest: ManifestDigest, ) -> WorkflowDefinition { WorkflowDefinition::new( id("workflow.definition.coordination"), @@ -94,7 +111,7 @@ fn definition_for_project( }], digest('a'), digest('b'), - digest('c'), + pinned_catalog_digest, ) .unwrap() } @@ -949,6 +966,78 @@ fn the_retained_lifecycle_runs_candidate_validated_active_then_retired() { assert!(retired.state.is_terminal()); } +#[test] +fn activation_rejects_a_step_operation_the_catalog_does_not_mount() { + let (authority, service, context) = lifecycle_service(); + let registered = service + .register( + &context, + definition_with_operation(1, "operation.work.not_a_mounted_operation"), + ) + .unwrap(); + let definition_id = registered.definition_id().clone(); + + // Registration stays lenient; Plan 32 rejects "before activation". + let denial = service.validate(registered.clone()).unwrap_err(); + let WorkflowCoordinationError::CatalogAdmissionDenied( + WorkflowCatalogAdmissionError::UnknownOperation { step_id, operation }, + ) = denial + else { + panic!("expected an unknown-operation catalog denial, got {denial:?}"); + }; + assert_eq!(step_id.as_str(), "prepare"); + assert_eq!(operation.as_str(), "operation.work.not_a_mounted_operation"); + + assert_eq!( + service + .activate(&definition_id, 1, 1, UtcMicros(10)) + .unwrap_err(), + WorkflowCoordinationError::CatalogAdmissionDenied( + WorkflowCatalogAdmissionError::UnknownOperation { + step_id: id("prepare"), + operation: id("operation.work.not_a_mounted_operation"), + } + ) + ); + + // The denial precedes the lifecycle authority. + assert_eq!( + service.disposition(&definition_id, 1).unwrap().state, + WorkflowDefinitionLifecycleState::Candidate + ); + assert!(authority.state.lock().unwrap().transitions.is_empty()); +} + +#[test] +fn activation_rejects_a_definition_pinned_to_a_foreign_catalog() { + let (authority, service, context) = lifecycle_service(); + let registered = service + .register( + &context, + definition_with_catalog_pin( + 1, + id("project.workflow.coordination"), + MOUNTED_OPERATION, + digest('c'), + ), + ) + .unwrap(); + let definition_id = registered.definition_id().clone(); + + let denial = service + .activate(&definition_id, 1, 1, UtcMicros(10)) + .unwrap_err(); + let WorkflowCoordinationError::CatalogAdmissionDenied( + WorkflowCatalogAdmissionError::CatalogPinMismatch { pinned, current }, + ) = denial + else { + panic!("expected a catalog-pin denial, got {denial:?}"); + }; + assert_eq!(pinned, digest('c')); + assert_eq!(current, work_executable_catalog_digest().unwrap()); + assert!(authority.state.lock().unwrap().transitions.is_empty()); +} + #[test] fn rejection_is_a_terminal_disposition_for_an_unactivated_version() { let (_authority, service, context) = lifecycle_service(); diff --git a/crates/tracedecay-application/tests/workflow_dag_execution.rs b/crates/tracedecay-application/tests/workflow_dag_execution.rs index 9067296fa..9a3f6a0d3 100644 --- a/crates/tracedecay-application/tests/workflow_dag_execution.rs +++ b/crates/tracedecay-application/tests/workflow_dag_execution.rs @@ -68,7 +68,7 @@ fn definition() -> WorkflowDefinition { vec![ WorkflowStep { step_id: id::("prepare"), - operation: id::("operation.work.attempt_start"), + operation: id::("operation.work.start_attempt"), predecessors: BTreeSet::new(), inputs: Vec::new(), outputs: vec![id::("context")], @@ -76,7 +76,7 @@ fn definition() -> WorkflowDefinition { }, WorkflowStep { step_id: id::("review"), - operation: id::("operation.work.attempt_start"), + operation: id::("operation.work.start_attempt"), predecessors: BTreeSet::from([id::("prepare")]), inputs: vec![WorkflowOutputReference { producer_step_id: id::("prepare"), diff --git a/crates/tracedecay-application/tests/workflow_fan_out_census.rs b/crates/tracedecay-application/tests/workflow_fan_out_census.rs index 0410ccfe6..6fc736d51 100644 --- a/crates/tracedecay-application/tests/workflow_fan_out_census.rs +++ b/crates/tracedecay-application/tests/workflow_fan_out_census.rs @@ -145,7 +145,7 @@ fn fixture() -> Fixture { id::("project.workflow.census"), vec![WorkflowStep { step_id: id::("fan-out"), - operation: id::("operation.work.attempt_start"), + operation: id::("operation.work.start_attempt"), predecessors: BTreeSet::new(), inputs: Vec::new(), outputs: vec![id::("finding")], @@ -444,7 +444,7 @@ fn work_attempt_with_progress( let execution = WorkExecutionEnvelopeV1::new( identity.clone(), binding.clone(), - id::("operation.work.attempt_start"), + id::("operation.work.start_attempt"), snapshot.clone(), id::("project.workflow.census"), id::("repository.workflow.census"), diff --git a/crates/tracedecay-application/tests/workflow_runtime.rs b/crates/tracedecay-application/tests/workflow_runtime.rs index 8877f999f..9815f7934 100644 --- a/crates/tracedecay-application/tests/workflow_runtime.rs +++ b/crates/tracedecay-application/tests/workflow_runtime.rs @@ -151,7 +151,7 @@ fn request(inputs: &[&str], max_width: u32, max_parallel: u32) -> WorkflowFanOut id::("project.workflow.runtime"), vec![WorkflowStep { step_id: id::("fan-out"), - operation: id::("operation.work.attempt_start"), + operation: id::("operation.work.start_attempt"), predecessors: Default::default(), inputs: Vec::new(), outputs: vec![id::("finding")], diff --git a/crates/tracedecay-dashboard-api/src/contract_schema.rs b/crates/tracedecay-dashboard-api/src/contract_schema.rs index 2294d016c..baebc280e 100644 --- a/crates/tracedecay-dashboard-api/src/contract_schema.rs +++ b/crates/tracedecay-dashboard-api/src/contract_schema.rs @@ -26,10 +26,13 @@ use tracedecay_application::{ WorkProductMutationReceiptV1, WorkProductMutationRequestV1, WorkProposalComparisonRequestV1, WorkProposalComparisonV1, WorkRetryAttemptOutcomeV1, WorkRunControlReadingV1, WorkRunControlRequestV1, WorkSynthesisAttemptV1, WorkTopologyViewRequestV1, + WorkflowDefinitionActivateRequest, WorkflowDefinitionDisposition, WorkflowDefinitionGetRequest, + WorkflowDefinitionHistoryRequest, WorkflowDefinitionListRequest, + WorkflowDefinitionRejectRequest, WorkflowDefinitionRetireRequest, WorkflowRunGetRequest, }; use tracedecay_domain::{ WorkAttemptV1, WorkDuplicateAdjudicationCommandV1, WorkPlacementPreflightV1, WorkPlacementV1, - WorkRunControlV1, + WorkRunControlV1, WorkflowDefinition, WorkflowRunProjection, }; use super::analytics_api::{ @@ -174,6 +177,19 @@ struct DashboardContractCatalogV1 { work_duplicate_adjudication_result: WorkDuplicateAdjudicationAppendOutcomeV1, work_leak_adjudication_command: AdjudicateWorkLeakCommandV1, work_leak_adjudication_result: WorkLeakAdjudicationOutcomeV1, + /// The workflow definition/run slice the Workflows workspace consumes. + /// Handoffs and run control stay uncontracted: the browser never holds a + /// bearer or mints fences, command ids, or provider admissions. + workflow_definition: WorkflowDefinition, + workflow_definition_list_request: WorkflowDefinitionListRequest, + workflow_definition_get_request: WorkflowDefinitionGetRequest, + workflow_definition_history_request: WorkflowDefinitionHistoryRequest, + workflow_definition_activate_request: WorkflowDefinitionActivateRequest, + workflow_definition_retire_request: WorkflowDefinitionRetireRequest, + workflow_definition_reject_request: WorkflowDefinitionRejectRequest, + workflow_definition_disposition: WorkflowDefinitionDisposition, + workflow_run_get_request: WorkflowRunGetRequest, + workflow_run_projection: WorkflowRunProjection, multi_root_capability: MultiRootCapabilityV1, multi_root_scope_set_read_request: MultiRootScopeSetReadRequestV1, multi_root_scope_set: Option, @@ -423,6 +439,75 @@ mod tests { } } + #[test] + fn canonical_workflow_contracts_are_registered() { + let schema: serde_json::Value = serde_json::from_str( + &render_dashboard_contract_schema().expect("render validated dashboard contracts"), + ) + .expect("parse dashboard contract schema"); + let definitions = schema["$defs"] + .as_object() + .expect("dashboard contracts expose schema definitions"); + + for (field, contract) in [ + ("workflow_definition", "WorkflowDefinition"), + ( + "workflow_definition_list_request", + "WorkflowDefinitionListRequest", + ), + ( + "workflow_definition_get_request", + "WorkflowDefinitionGetRequest", + ), + ( + "workflow_definition_history_request", + "WorkflowDefinitionHistoryRequest", + ), + ( + "workflow_definition_activate_request", + "WorkflowDefinitionActivateRequest", + ), + ( + "workflow_definition_retire_request", + "WorkflowDefinitionRetireRequest", + ), + ( + "workflow_definition_reject_request", + "WorkflowDefinitionRejectRequest", + ), + ( + "workflow_definition_disposition", + "WorkflowDefinitionDisposition", + ), + ("workflow_run_get_request", "WorkflowRunGetRequest"), + ("workflow_run_projection", "WorkflowRunProjection"), + ] { + assert!( + definitions.contains_key(contract), + "canonical Workflow contract {contract} is absent from the dashboard catalog" + ); + assert_eq!( + schema["properties"][field]["$ref"], + format!("#/$defs/{contract}"), + "dashboard catalog field {field} must directly register {contract}" + ); + } + + for excluded in [ + "TaskHandoffIssueRequest", + "TaskHandoffRedeemRequest", + "WorkflowRunStartRequest", + "WorkflowRunPauseRequest", + "WorkflowRunResumeRequest", + "WorkflowRunCancelRequest", + ] { + assert!( + !definitions.contains_key(excluded), + "{excluded} must not be published to the dashboard contract catalog" + ); + } + } + #[test] fn legacy_dashboard_route_families_are_contracted() { let schema: serde_json::Value = serde_json::from_str( diff --git a/crates/tracedecay-dashboard-api/src/lib.rs b/crates/tracedecay-dashboard-api/src/lib.rs index 9d6eeca93..d04d3eb16 100644 --- a/crates/tracedecay-dashboard-api/src/lib.rs +++ b/crates/tracedecay-dashboard-api/src/lib.rs @@ -147,7 +147,7 @@ use axum::routing::{any, get, patch, post}; use serde_json::{Value, json}; use tower::ServiceExt; -use tracedecay_api::WorkOperation; +use tracedecay_api::{WorkOperation, WorkflowOperation}; use crate::tracedecay::TraceDecay; use tracedecay_agent_hosts::automation::backend; @@ -1609,6 +1609,9 @@ async fn project_scoped_api_gateway( (application.dashboard_feedback_router, "feedback/") } SelectedProjectApplicationRead::Work => (application.dashboard_work_router, "work/"), + // Stripping `application/` leaves the `/workflow/{operation}` + // path the project's canonical application router mounts. + SelectedProjectApplicationRead::Workflow => (application.http_router, "application/"), }; let Some(operation) = tail.strip_prefix(family) else { return ( @@ -1677,6 +1680,7 @@ fn is_profile_owned_automation_skills_route(tail: &str) -> bool { enum SelectedProjectApplicationRead { Feedback, Work, + Workflow, } impl std::fmt::Display for SelectedProjectApplicationRead { @@ -1684,6 +1688,7 @@ impl std::fmt::Display for SelectedProjectApplicationRead { formatter.write_str(match self { Self::Feedback => "feedback", Self::Work => "Work", + Self::Workflow => "Workflow", }) } } @@ -1699,11 +1704,20 @@ fn selected_project_application_read( "feedback/get" | "feedback/expand" | "feedback/list" => { Some(SelectedProjectApplicationRead::Feedback) } - _ => WorkOperation::ALL - .into_iter() - .filter(|operation| operation.is_read_only()) - .any(|operation| tail.strip_prefix("work/") == Some(operation.route_segment())) - .then_some(SelectedProjectApplicationRead::Work), + _ => { + if let Some(segment) = tail.strip_prefix("application/workflow/") { + return WorkflowOperation::ALL + .into_iter() + .filter(|operation| operation.is_read_only()) + .any(|operation| operation.route_segment() == segment) + .then_some(SelectedProjectApplicationRead::Workflow); + } + WorkOperation::ALL + .into_iter() + .filter(|operation| operation.is_read_only()) + .any(|operation| tail.strip_prefix("work/") == Some(operation.route_segment())) + .then_some(SelectedProjectApplicationRead::Work) + } } } @@ -2900,5 +2914,34 @@ mod authority_tests { selected_project_application_read(&Method::POST, "feedback/status"), None ); + + assert_eq!( + selected_project_application_read( + &Method::POST, + "application/workflow/list-definitions" + ), + Some(SelectedProjectApplicationRead::Workflow) + ); + for operation in WorkflowOperation::ALL { + let tail = operation + .application_route_path() + .strip_prefix("/") + .expect("a rooted application route path"); + if operation.is_read_only() { + assert_eq!( + selected_project_application_read(&Method::POST, tail), + Some(SelectedProjectApplicationRead::Workflow), + "{tail} is a read-only Workflow operation a selected project may read" + ); + assert_eq!(selected_project_application_read(&Method::GET, tail), None); + } else { + // Mutations stay refused: the gateway is read-only. + assert_eq!( + selected_project_application_read(&Method::POST, tail), + None, + "{tail} must not be answerable for a selected project" + ); + } + } } } diff --git a/crates/tracedecay-rusqlite-runtime/tests/workflow_run_journal_storage.rs b/crates/tracedecay-rusqlite-runtime/tests/workflow_run_journal_storage.rs index 0a68b21a5..1449699de 100644 --- a/crates/tracedecay-rusqlite-runtime/tests/workflow_run_journal_storage.rs +++ b/crates/tracedecay-rusqlite-runtime/tests/workflow_run_journal_storage.rs @@ -132,7 +132,7 @@ fn definition() -> WorkflowDefinition { id::("project.workflow.journal"), vec![WorkflowStep { step_id: id::("prepare"), - operation: id::("operation.work.attempt_start"), + operation: id::("operation.work.start_attempt"), predecessors: BTreeSet::new(), inputs: Vec::new(), outputs: vec![id::("context")], @@ -152,7 +152,7 @@ fn fan_out_definition() -> WorkflowDefinition { id::("project.workflow.journal"), vec![WorkflowStep { step_id: id::("fan-out"), - operation: id::("operation.work.attempt_start"), + operation: id::("operation.work.start_attempt"), predecessors: BTreeSet::new(), inputs: Vec::new(), outputs: vec![id::("finding")], diff --git a/dashboard/codegen/schemas/dashboard-contracts.schema.json b/dashboard/codegen/schemas/dashboard-contracts.schema.json index 30294e475..4bb7dcec4 100644 --- a/dashboard/codegen/schemas/dashboard-contracts.schema.json +++ b/dashboard/codegen/schemas/dashboard-contracts.schema.json @@ -20648,6 +20648,34 @@ ], "type": "object" }, + "WorkAuthority": { + "additionalProperties": false, + "properties": { + "actor_id": { + "$ref": "#/$defs/ActorId" + }, + "policy_digest": { + "$ref": "#/$defs/ManifestDigest" + }, + "project_id": { + "$ref": "#/$defs/ProjectId" + }, + "repository_id": { + "$ref": "#/$defs/RepositoryId" + }, + "worktree_id": { + "$ref": "#/$defs/WorktreeId" + } + }, + "required": [ + "project_id", + "repository_id", + "worktree_id", + "actor_id", + "policy_digest" + ], + "type": "object" + }, "WorkCalibratedSizingV1": { "additionalProperties": false, "description": "Calibrated sizing. Emitted ONLY when support >= floor. Every field named separately\nper Plan 06 :84-85; `support_floor` carries the governing floor into the record.", @@ -27398,105 +27426,1071 @@ ], "type": "object" }, - "WorkflowOperationRef": { - "description": "Strongly typed canonical identity: `WorkflowOperationRef`.", - "type": "string" - }, - "WorkflowOutputName": { - "description": "Strongly typed canonical identity: `WorkflowOutputName`.", - "type": "string" - }, - "WorkflowSynthesisDraft": { + "WorkflowDefinition": { "additionalProperties": false, - "description": "A provider's claim that one artifact of a fan-out output synthesizes its\nsibling source artifacts.", "properties": { - "cited_source_digests": { - "description": "Content digests of every source artifact the synthesis consumed.", + "definition_id": { + "$ref": "#/$defs/WorkflowDefinitionId" + }, + "definition_version": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "pinned_catalog_digest": { + "$ref": "#/$defs/ManifestDigest" + }, + "pinned_configuration_digest": { + "$ref": "#/$defs/ManifestDigest" + }, + "pinned_policy_digest": { + "$ref": "#/$defs/ManifestDigest" + }, + "project_id": { + "$ref": "#/$defs/ProjectId" + }, + "steps": { "items": { - "$ref": "#/$defs/ManifestDigest" + "$ref": "#/$defs/WorkflowStep" }, - "type": "array", - "uniqueItems": true + "type": "array" + } + }, + "required": [ + "definition_id", + "definition_version", + "project_id", + "steps", + "pinned_policy_digest", + "pinned_configuration_digest", + "pinned_catalog_digest" + ], + "type": "object" + }, + "WorkflowDefinitionActivateRequest": { + "additionalProperties": false, + "description": "Wire request for [`WorkflowDefinitionService::activate`].", + "properties": { + "definition_id": { + "$ref": "#/$defs/WorkflowDefinitionId" }, - "output_name": { - "$ref": "#/$defs/WorkflowOutputName", - "description": "The fan-out output the synthesis belongs to." + "definition_version": { + "format": "uint64", + "minimum": 1, + "type": "integer" }, - "synthesis_attempt": { - "$ref": "#/$defs/WorkAttemptIdentityV1", - "description": "The attempt that produced the synthesis artifact inside that output." + "expected_revision": { + "format": "uint64", + "minimum": 1, + "type": "integer" } }, "required": [ - "output_name", - "synthesis_attempt", - "cited_source_digests" + "definition_id", + "definition_version", + "expected_revision" ], "type": "object" }, - "WorktreeCleanlinessRequirementV1": { - "enum": [ - "require_clean", - "allow_untracked_only_for_preflight", - "read_only_preflight_only" + "WorkflowDefinitionDisposition": { + "additionalProperties": false, + "description": "Revisioned lifecycle disposition of one definition version.", + "properties": { + "definition_id": { + "$ref": "#/$defs/WorkflowDefinitionId" + }, + "definition_version": { + "format": "uint64", + "minimum": 1, + "type": "integer" + }, + "revision": { + "format": "uint64", + "minimum": 1, + "type": "integer" + }, + "state": { + "$ref": "#/$defs/WorkflowDefinitionLifecycleState" + }, + "transitioned_at": { + "$ref": "#/$defs/UtcMicros" + } + }, + "required": [ + "definition_id", + "definition_version", + "state", + "revision", + "transitioned_at" + ], + "type": "object" + }, + "WorkflowDefinitionGetRequest": { + "additionalProperties": false, + "description": "Wire request for [`WorkflowDefinitionService::get`].", + "properties": { + "definition_id": { + "$ref": "#/$defs/WorkflowDefinitionId" + }, + "definition_version": { + "format": "uint64", + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "definition_id", + "definition_version" + ], + "type": "object" + }, + "WorkflowDefinitionHistoryRequest": { + "additionalProperties": false, + "description": "Wire request for [`WorkflowDefinitionService::history`].", + "properties": { + "definition_id": { + "$ref": "#/$defs/WorkflowDefinitionId" + } + }, + "required": [ + "definition_id" ], + "type": "object" + }, + "WorkflowDefinitionId": { + "description": "Strongly typed canonical identity: `WorkflowDefinitionId`.", "type": "string" }, - "WorktreeId": { - "description": "Strongly typed canonical identity: `WorktreeId`.", + "WorkflowDefinitionLifecycleState": { + "description": "Durable lifecycle disposition of one immutable workflow definition version.\n\nPlan 32 (\"Typed workflow definitions\"): \"Lifecycle retains candidate,\nvalidate, activate, retire, reject, list, get, diff, and history operations\nthrough the same application surfaces.\" The definition payload itself stays\nimmutable — \"Editing creates a new version; admitted runs remain pinned\" —\nso the disposition is a separate revisioned aggregate keyed by the same\ndefinition identity.", + "enum": [ + "candidate", + "validated", + "active", + "retired", + "rejected" + ], "type": "string" }, - "WorktreePlacementModeV1": { - "oneOf": [ - { - "properties": { - "kind": { - "const": "existing_worktree_only", - "type": "string" - } - }, - "required": [ - "kind" - ], - "type": "object" + "WorkflowDefinitionListRequest": { + "additionalProperties": false, + "description": "Wire request for [`WorkflowDefinitionService::list`].", + "type": "object" + }, + "WorkflowDefinitionRejectRequest": { + "additionalProperties": false, + "description": "Wire request for [`WorkflowDefinitionService::reject`].", + "properties": { + "definition_id": { + "$ref": "#/$defs/WorkflowDefinitionId" + }, + "definition_version": { + "format": "uint64", + "minimum": 1, + "type": "integer" + }, + "expected_revision": { + "format": "uint64", + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "definition_id", + "definition_version", + "expected_revision" + ], + "type": "object" + }, + "WorkflowDefinitionRetireRequest": { + "additionalProperties": false, + "description": "Wire request for [`WorkflowDefinitionService::retire`].", + "properties": { + "definition_id": { + "$ref": "#/$defs/WorkflowDefinitionId" + }, + "definition_version": { + "format": "uint64", + "minimum": 1, + "type": "integer" + }, + "expected_revision": { + "format": "uint64", + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "definition_id", + "definition_version", + "expected_revision" + ], + "type": "object" + }, + "WorkflowFanOut": { + "additionalProperties": false, + "properties": { + "max_width": { + "format": "uint32", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "max_width" + ], + "type": "object" + }, + "WorkflowFanOutChildPlanV1": { + "additionalProperties": false, + "properties": { + "admit_command_id": { + "$ref": "#/$defs/WorkCommandId" + }, + "attempt_identity": { + "$ref": "#/$defs/WorkAttemptIdentityV1" + }, + "create_command_id": { + "$ref": "#/$defs/WorkCommandId" + }, + "initiative": { + "$ref": "#/$defs/WorkInitiativeV1" + }, + "instructions": { + "type": "string" + }, + "item": { + "$ref": "#/$defs/WorkItemV1" + }, + "milestone": { + "$ref": "#/$defs/WorkMilestoneV1" + }, + "plan": { + "$ref": "#/$defs/WorkPlanV1" + }, + "proposal": { + "$ref": "#/$defs/WorkProposalV1" + }, + "proposal_command_id": { + "$ref": "#/$defs/WorkCommandId" }, + "task_id": { + "$ref": "#/$defs/TaskId" + } + }, + "required": [ + "task_id", + "attempt_identity", + "create_command_id", + "proposal_command_id", + "admit_command_id", + "initiative", + "plan", + "milestone", + "item", + "proposal", + "instructions" + ], + "type": "object" + }, + "WorkflowFanOutFailurePolicyV1": { + "oneOf": [ { "properties": { - "kind": { - "const": "sibling_of_primary_checkout", + "policy": { + "const": "fail_fast", "type": "string" } }, "required": [ - "kind" + "policy" ], "type": "object" }, { "properties": { - "kind": { - "const": "repository_local_root", + "policy": { + "const": "collect", "type": "string" } }, "required": [ - "kind" + "policy" ], "type": "object" }, { "properties": { - "kind": { - "const": "configured_root", + "policy": { + "const": "require_at_least", "type": "string" }, - "root_id": { - "type": "string" + "successes": { + "format": "uint16", + "maximum": 65535, + "minimum": 1, + "type": "integer" } }, "required": [ - "kind", - "root_id" + "policy", + "successes" + ], + "type": "object" + } + ] + }, + "WorkflowFanOutPlanV1": { + "additionalProperties": false, + "properties": { + "admitted_at": { + "$ref": "#/$defs/UtcMicros" + }, + "authority": { + "$ref": "#/$defs/WorkAuthority", + "description": "Exact Work authority that admitted this plan. Recovery may execute the\nplan only from a runtime with this byte-identical authority." + }, + "children": { + "items": { + "$ref": "#/$defs/WorkflowFanOutChildPlanV1" + }, + "type": "array" + }, + "commit": { + "$ref": "#/$defs/CommitId" + }, + "effect_state": { + "$ref": "#/$defs/WorkEffectStateV1" + }, + "execution_snapshot": { + "$ref": "#/$defs/WorkExecutionSnapshot" + }, + "failure_policy": { + "$ref": "#/$defs/WorkflowFanOutFailurePolicyV1" + }, + "maximum_parallel": { + "format": "uint16", + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "operation": { + "$ref": "#/$defs/WorkflowOperationRef" + }, + "plan_digest": { + "$ref": "#/$defs/ManifestDigest" + }, + "reference": { + "anyOf": [ + { + "$ref": "#/$defs/RefId" + }, + { + "type": "null" + } + ] + }, + "step_id": { + "$ref": "#/$defs/WorkflowStepId" + } + }, + "required": [ + "authority", + "step_id", + "operation", + "plan_digest", + "admitted_at", + "maximum_parallel", + "failure_policy", + "execution_snapshot", + "reference", + "commit", + "effect_state", + "children" + ], + "type": "object" + }, + "WorkflowOperationRef": { + "description": "Strongly typed canonical identity: `WorkflowOperationRef`.", + "type": "string" + }, + "WorkflowOutputArtifact": { + "additionalProperties": false, + "properties": { + "artifact": { + "$ref": "#/$defs/WorkArtifactRefV1" + }, + "attempt_identity": { + "$ref": "#/$defs/WorkAttemptIdentityV1" + } + }, + "required": [ + "attempt_identity", + "artifact" + ], + "type": "object" + }, + "WorkflowOutputName": { + "description": "Strongly typed canonical identity: `WorkflowOutputName`.", + "type": "string" + }, + "WorkflowOutputReference": { + "additionalProperties": false, + "properties": { + "output_name": { + "$ref": "#/$defs/WorkflowOutputName" + }, + "producer_step_id": { + "$ref": "#/$defs/WorkflowStepId" + } + }, + "required": [ + "producer_step_id", + "output_name" + ], + "type": "object" + }, + "WorkflowPlacementReceipt": { + "additionalProperties": false, + "properties": { + "backend": { + "$ref": "#/$defs/WorkProviderBackendV1" + }, + "configuration_digest": { + "$ref": "#/$defs/ManifestDigest" + }, + "model": { + "type": "string" + }, + "placement_digest": { + "$ref": "#/$defs/ManifestDigest" + }, + "provider_registry_digest": { + "$ref": "#/$defs/ManifestDigest" + }, + "route": { + "$ref": "#/$defs/WorkProviderRouteV1" + }, + "run_id": { + "$ref": "#/$defs/RunId" + }, + "step_id": { + "$ref": "#/$defs/WorkflowStepId" + }, + "topology_digest": { + "$ref": "#/$defs/ManifestDigest" + }, + "worktree_placement": { + "$ref": "#/$defs/WorktreePlacementModeV1" + } + }, + "required": [ + "run_id", + "step_id", + "route", + "backend", + "model", + "configuration_digest", + "topology_digest", + "provider_registry_digest", + "worktree_placement", + "placement_digest" + ], + "type": "object" + }, + "WorkflowRunEvent": { + "additionalProperties": false, + "properties": { + "command_id": { + "$ref": "#/$defs/WorkCommandId" + }, + "event": { + "$ref": "#/$defs/WorkflowRunEventKind" + }, + "input_digest": { + "$ref": "#/$defs/ManifestDigest" + }, + "occurred_at": { + "$ref": "#/$defs/UtcMicros" + }, + "run_id": { + "$ref": "#/$defs/RunId" + }, + "sequence": { + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "run_id", + "sequence", + "command_id", + "input_digest", + "occurred_at", + "event" + ], + "type": "object" + }, + "WorkflowRunEventKind": { + "oneOf": [ + { + "properties": { + "definition": { + "$ref": "#/$defs/WorkflowDefinition" + }, + "fan_out_plans": { + "default": [], + "items": { + "$ref": "#/$defs/WorkflowFanOutPlanV1" + }, + "type": "array" + }, + "pinned_provider_registry_digest": { + "$ref": "#/$defs/ManifestDigest" + }, + "pinned_topology_digest": { + "$ref": "#/$defs/ManifestDigest" + }, + "type": { + "const": "admitted", + "type": "string" + } + }, + "required": [ + "type", + "definition", + "pinned_topology_digest", + "pinned_provider_registry_digest", + "fan_out_plans" + ], + "type": "object" + }, + { + "properties": { + "attempts": { + "items": { + "$ref": "#/$defs/WorkAttemptIdentityV1" + }, + "type": "array" + }, + "step_id": { + "$ref": "#/$defs/WorkflowStepId" + }, + "type": { + "const": "fan_out_children_released", + "type": "string" + } + }, + "required": [ + "type", + "step_id", + "attempts" + ], + "type": "object" + }, + { + "properties": { + "attempts": { + "items": { + "$ref": "#/$defs/WorkAttemptIdentityV1" + }, + "type": "array" + }, + "step_id": { + "$ref": "#/$defs/WorkflowStepId" + }, + "type": { + "const": "fan_out_children_settled", + "type": "string" + } + }, + "required": [ + "type", + "step_id", + "attempts" + ], + "type": "object" + }, + { + "properties": { + "placement": { + "$ref": "#/$defs/WorkflowPlacementReceipt" + }, + "step_id": { + "$ref": "#/$defs/WorkflowStepId" + }, + "type": { + "const": "step_started", + "type": "string" + } + }, + "required": [ + "type", + "step_id", + "placement" + ], + "type": "object" + }, + { + "properties": { + "effect_receipt": { + "$ref": "#/$defs/WorkflowStepEffectReceipt" + }, + "outputs": { + "items": { + "$ref": "#/$defs/WorkflowStepOutput" + }, + "type": "array" + }, + "step_id": { + "$ref": "#/$defs/WorkflowStepId" + }, + "type": { + "const": "step_completed", + "type": "string" + } + }, + "required": [ + "type", + "step_id", + "outputs", + "effect_receipt" + ], + "type": "object" + }, + { + "properties": { + "effect_receipt": { + "$ref": "#/$defs/WorkflowStepEffectReceipt" + }, + "outputs": { + "items": { + "$ref": "#/$defs/WorkflowStepOutput" + }, + "type": "array" + }, + "step_id": { + "$ref": "#/$defs/WorkflowStepId" + }, + "type": { + "const": "step_failed", + "type": "string" + } + }, + "required": [ + "type", + "step_id", + "outputs", + "effect_receipt" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "paused", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "resumed", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "cancellation_requested", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "cancelled", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + } + ] + }, + "WorkflowRunGetRequest": { + "additionalProperties": false, + "properties": { + "run_id": { + "$ref": "#/$defs/RunId" + } + }, + "required": [ + "run_id" + ], + "type": "object" + }, + "WorkflowRunProjection": { + "additionalProperties": false, + "properties": { + "definition": { + "$ref": "#/$defs/WorkflowDefinition" + }, + "fan_out_plans": { + "additionalProperties": { + "$ref": "#/$defs/WorkflowFanOutPlanV1" + }, + "type": "object" + }, + "history": { + "items": { + "$ref": "#/$defs/WorkflowRunEvent" + }, + "type": "array" + }, + "pinned_provider_registry_digest": { + "$ref": "#/$defs/ManifestDigest" + }, + "pinned_topology_digest": { + "$ref": "#/$defs/ManifestDigest" + }, + "released_fan_out_attempts": { + "items": { + "$ref": "#/$defs/WorkAttemptIdentityV1" + }, + "type": "array", + "uniqueItems": true + }, + "run_id": { + "$ref": "#/$defs/RunId" + }, + "sequence": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "settled_fan_out_attempts": { + "items": { + "$ref": "#/$defs/WorkAttemptIdentityV1" + }, + "type": "array", + "uniqueItems": true + }, + "status": { + "$ref": "#/$defs/WorkflowRunStatus" + }, + "steps": { + "additionalProperties": { + "$ref": "#/$defs/WorkflowStepRunProjection" + }, + "type": "object" + } + }, + "required": [ + "run_id", + "definition", + "pinned_topology_digest", + "pinned_provider_registry_digest", + "status", + "sequence", + "steps", + "fan_out_plans", + "released_fan_out_attempts", + "settled_fan_out_attempts", + "history" + ], + "type": "object" + }, + "WorkflowRunStatus": { + "enum": [ + "running", + "paused", + "cancelling", + "completed", + "failed", + "cancelled" + ], + "type": "string" + }, + "WorkflowStep": { + "additionalProperties": false, + "properties": { + "fan_out": { + "anyOf": [ + { + "$ref": "#/$defs/WorkflowFanOut" + }, + { + "type": "null" + } + ] + }, + "inputs": { + "items": { + "$ref": "#/$defs/WorkflowOutputReference" + }, + "type": "array" + }, + "operation": { + "$ref": "#/$defs/WorkflowOperationRef" + }, + "outputs": { + "items": { + "$ref": "#/$defs/WorkflowOutputName" + }, + "type": "array" + }, + "predecessors": { + "items": { + "$ref": "#/$defs/WorkflowStepId" + }, + "type": "array", + "uniqueItems": true + }, + "step_id": { + "$ref": "#/$defs/WorkflowStepId" + } + }, + "required": [ + "step_id", + "operation", + "predecessors", + "inputs", + "outputs", + "fan_out" + ], + "type": "object" + }, + "WorkflowStepEffectOutcome": { + "enum": [ + "completed", + "failed", + "cancelled", + "timed_out", + "unknown" + ], + "type": "string" + }, + "WorkflowStepEffectReceipt": { + "additionalProperties": false, + "properties": { + "effect_digest": { + "$ref": "#/$defs/ManifestDigest" + }, + "outcome": { + "$ref": "#/$defs/WorkflowStepEffectOutcome" + }, + "output_set_digest": { + "$ref": "#/$defs/ManifestDigest" + }, + "placement_digest": { + "$ref": "#/$defs/ManifestDigest" + }, + "receipt_digest": { + "$ref": "#/$defs/ManifestDigest" + }, + "run_id": { + "$ref": "#/$defs/RunId" + }, + "step_id": { + "$ref": "#/$defs/WorkflowStepId" + } + }, + "required": [ + "run_id", + "step_id", + "placement_digest", + "outcome", + "effect_digest", + "output_set_digest", + "receipt_digest" + ], + "type": "object" + }, + "WorkflowStepId": { + "description": "Strongly typed canonical identity: `WorkflowStepId`.", + "type": "string" + }, + "WorkflowStepOutput": { + "additionalProperties": false, + "properties": { + "artifacts": { + "items": { + "$ref": "#/$defs/WorkflowOutputArtifact" + }, + "type": "array" + }, + "output_name": { + "$ref": "#/$defs/WorkflowOutputName" + } + }, + "required": [ + "output_name", + "artifacts" + ], + "type": "object" + }, + "WorkflowStepRunProjection": { + "additionalProperties": false, + "properties": { + "effect_receipt": { + "anyOf": [ + { + "$ref": "#/$defs/WorkflowStepEffectReceipt" + }, + { + "type": "null" + } + ] + }, + "outputs": { + "additionalProperties": { + "$ref": "#/$defs/WorkflowStepOutput" + }, + "type": "object" + }, + "placement_receipt": { + "anyOf": [ + { + "$ref": "#/$defs/WorkflowPlacementReceipt" + }, + { + "type": "null" + } + ] + }, + "status": { + "$ref": "#/$defs/WorkflowStepStatus" + } + }, + "required": [ + "status", + "outputs", + "placement_receipt", + "effect_receipt" + ], + "type": "object" + }, + "WorkflowStepStatus": { + "enum": [ + "blocked", + "ready", + "running", + "succeeded", + "failed", + "cancelled" + ], + "type": "string" + }, + "WorkflowSynthesisDraft": { + "additionalProperties": false, + "description": "A provider's claim that one artifact of a fan-out output synthesizes its\nsibling source artifacts.", + "properties": { + "cited_source_digests": { + "description": "Content digests of every source artifact the synthesis consumed.", + "items": { + "$ref": "#/$defs/ManifestDigest" + }, + "type": "array", + "uniqueItems": true + }, + "output_name": { + "$ref": "#/$defs/WorkflowOutputName", + "description": "The fan-out output the synthesis belongs to." + }, + "synthesis_attempt": { + "$ref": "#/$defs/WorkAttemptIdentityV1", + "description": "The attempt that produced the synthesis artifact inside that output." + } + }, + "required": [ + "output_name", + "synthesis_attempt", + "cited_source_digests" + ], + "type": "object" + }, + "WorktreeCleanlinessRequirementV1": { + "enum": [ + "require_clean", + "allow_untracked_only_for_preflight", + "read_only_preflight_only" + ], + "type": "string" + }, + "WorktreeId": { + "description": "Strongly typed canonical identity: `WorktreeId`.", + "type": "string" + }, + "WorktreePlacementModeV1": { + "oneOf": [ + { + "properties": { + "kind": { + "const": "existing_worktree_only", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "properties": { + "kind": { + "const": "sibling_of_primary_checkout", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "properties": { + "kind": { + "const": "repository_local_root", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "properties": { + "kind": { + "const": "configured_root", + "type": "string" + }, + "root_id": { + "type": "string" + } + }, + "required": [ + "kind", + "root_id" ], "type": "object" } @@ -27913,6 +28907,37 @@ }, "work_topology_view_request": { "$ref": "#/$defs/WorkTopologyViewRequestV1" + }, + "workflow_definition": { + "$ref": "#/$defs/WorkflowDefinition", + "description": "The workflow definition/run slice the Workflows workspace consumes off\nthe mounted `/api/application/workflow` routes. Handoff issue/redeem\nstay uncontracted for the dashboard because it never holds a bearer;\nrun start/pause/resume/cancel stay uncontracted because the browser\nmust not mint fences, command ids, or provider admissions." + }, + "workflow_definition_activate_request": { + "$ref": "#/$defs/WorkflowDefinitionActivateRequest" + }, + "workflow_definition_disposition": { + "$ref": "#/$defs/WorkflowDefinitionDisposition" + }, + "workflow_definition_get_request": { + "$ref": "#/$defs/WorkflowDefinitionGetRequest" + }, + "workflow_definition_history_request": { + "$ref": "#/$defs/WorkflowDefinitionHistoryRequest" + }, + "workflow_definition_list_request": { + "$ref": "#/$defs/WorkflowDefinitionListRequest" + }, + "workflow_definition_reject_request": { + "$ref": "#/$defs/WorkflowDefinitionRejectRequest" + }, + "workflow_definition_retire_request": { + "$ref": "#/$defs/WorkflowDefinitionRetireRequest" + }, + "workflow_run_get_request": { + "$ref": "#/$defs/WorkflowRunGetRequest" + }, + "workflow_run_projection": { + "$ref": "#/$defs/WorkflowRunProjection" } }, "required": [ @@ -28016,6 +29041,16 @@ "work_duplicate_adjudication_result", "work_leak_adjudication_command", "work_leak_adjudication_result", + "workflow_definition", + "workflow_definition_list_request", + "workflow_definition_get_request", + "workflow_definition_history_request", + "workflow_definition_activate_request", + "workflow_definition_retire_request", + "workflow_definition_reject_request", + "workflow_definition_disposition", + "workflow_run_get_request", + "workflow_run_projection", "multi_root_capability", "multi_root_scope_set_read_request", "multi_root_scope_set", diff --git a/dashboard/src/app/channels.ts b/dashboard/src/app/channels.ts index 69885da15..348c75d54 100644 --- a/dashboard/src/app/channels.ts +++ b/dashboard/src/app/channels.ts @@ -1,5 +1,5 @@ /** - * The instrument's channel list: the thirteen workspaces in their fixed panel + * The instrument's channel list: the fourteen workspaces in their fixed panel * order. A workspace's channel number is part of its identity in this design * (the nav rail numbers them, every workspace header repeats the number), so * the order lives in exactly one place. @@ -27,6 +27,7 @@ export const CHANNELS: readonly Channel[] = [ { path: 'costs', label: 'Costs' }, { path: 'settings', label: 'Settings' }, { path: 'work', label: 'Work' }, + { path: 'workflows', label: 'Workflows' }, ] as const; /** Zero-padded channel number for a workspace path (`code` → `06`). Unknown diff --git a/dashboard/src/app/routes.tsx b/dashboard/src/app/routes.tsx index 468992760..138cef817 100644 --- a/dashboard/src/app/routes.tsx +++ b/dashboard/src/app/routes.tsx @@ -6,12 +6,11 @@ function page(path: T, label: string, load: RouteChunkLoader) return { path, label, load } as const; } -// The thirteen workspaces, each its own lazy code-split chunk: the shell stays -// light and a surface loads on first navigation. All thirteen read real routes; -// Work was the last gated one, and its nine routes are mounted. What has not -// changed is the rule the gate enforced: a surface renders what its contract -// answered, and never substitutes fixture or browser-owned state for a read -// that did not land. +// The fourteen workspaces, each its own lazy code-split chunk: the shell stays +// light and a surface loads on first navigation. All fourteen read real routes. +// What has not changed is the rule the original Work gate enforced: a surface +// renders what its contract answered, and never substitutes fixture or +// browser-owned state for a read that did not land. export const WORKSPACES = [ page('brain', 'Brain', () => import('../workspaces/brain/BrainPage.tsx').then((m) => ({ default: m.BrainPage }))), @@ -39,6 +38,8 @@ export const WORKSPACES = [ import('../workspaces/settings/SettingsPage.tsx').then((m) => ({ default: m.SettingsPage }))), page('work', 'Work', () => import('../workspaces/work/WorkPage.tsx').then((m) => ({ default: m.WorkPage }))), + page('workflows', 'Workflows', () => + import('../workspaces/workflows/WorkflowsPage.tsx').then((m) => ({ default: m.WorkflowsPage }))), ] as const; export const router = createBrowserRouter([ diff --git a/dashboard/src/app/shell/NavRail.tsx b/dashboard/src/app/shell/NavRail.tsx index 4df774c68..0d9777dd5 100644 --- a/dashboard/src/app/shell/NavRail.tsx +++ b/dashboard/src/app/shell/NavRail.tsx @@ -12,6 +12,7 @@ import { MessagesSquare, Settings, Wallet, + Waypoints, Workflow, } from 'lucide-react'; import { NavLink } from 'react-router'; @@ -34,6 +35,9 @@ const ICONS: Record = { costs: Wallet, settings: Settings, work: ListTodo, + // `Workflow` is Loom's icon; the Workflows workspace uses the DAG waypoints + // mark so the two channels stay distinguishable at a glance. + workflows: Waypoints, }; const MAIN = CHANNELS.filter((channel) => channel.path !== 'settings'); diff --git a/dashboard/src/app/workspaceRegistry.test.ts b/dashboard/src/app/workspaceRegistry.test.ts index bed8935e3..0d48792c9 100644 --- a/dashboard/src/app/workspaceRegistry.test.ts +++ b/dashboard/src/app/workspaceRegistry.test.ts @@ -27,6 +27,7 @@ const CHANNEL_ORDER = [ ['costs', 'Costs'], ['settings', 'Settings'], ['work', 'Work'], + ['workflows', 'Workflows'], ] as const; function descriptors(items: readonly { path: string; label: string }[]) { @@ -34,7 +35,7 @@ function descriptors(items: readonly { path: string; label: string }[]) { } describe('workspace registry', () => { - it('routes the thirteen workspaces in their fixed channel order', () => { + it('routes the fourteen workspaces in their fixed channel order', () => { expect(descriptors(WORKSPACES)).toEqual(CHANNEL_ORDER); }); diff --git a/dashboard/src/contracts/generated.ts b/dashboard/src/contracts/generated.ts index 02110f0cb..8669e70d1 100644 --- a/dashboard/src/contracts/generated.ts +++ b/dashboard/src/contracts/generated.ts @@ -5064,6 +5064,15 @@ export const WorkAttemptV1Schema = z.object({ }); export type WorkAttemptV1 = z.infer; +export const WorkAuthoritySchema = z.object({ + actor_id: z.lazy(() => ActorIdSchema), + policy_digest: z.lazy(() => ManifestDigestSchema), + project_id: z.lazy(() => ProjectIdSchema), + repository_id: z.lazy(() => RepositoryIdSchema), + worktree_id: z.lazy(() => WorktreeIdSchema), +}).strict(); +export type WorkAuthority = z.infer; + /** Calibrated sizing. Emitted ONLY when support >= floor. Every field named separately per Plan 06 :84-85; `support_floor` carries the governing floor into the record. */ export const WorkCalibratedSizingV1Schema = z.object({ @@ -5535,14 +5544,280 @@ export type WorkFenceEpochV1 = z.infer; export const WorkFilesystemPolicySchema = z.enum(["read_only", "workspace_write"]); export type WorkFilesystemPolicy = z.infer; +export const WorkflowDefinitionSchema = z.object({ + definition_id: z.lazy(() => WorkflowDefinitionIdSchema), + definition_version: z.number().int().safe().min(0), + pinned_catalog_digest: z.lazy(() => ManifestDigestSchema), + pinned_configuration_digest: z.lazy(() => ManifestDigestSchema), + pinned_policy_digest: z.lazy(() => ManifestDigestSchema), + project_id: z.lazy(() => ProjectIdSchema), + steps: z.array(z.lazy(() => WorkflowStepSchema)), +}).strict(); +export type WorkflowDefinition = z.infer; + +/** Wire request for [`WorkflowDefinitionService::activate`]. */ +export const WorkflowDefinitionActivateRequestSchema = z.object({ + definition_id: z.lazy(() => WorkflowDefinitionIdSchema), + definition_version: z.number().int().safe().min(1), + expected_revision: z.number().int().safe().min(1), +}).strict(); +export type WorkflowDefinitionActivateRequest = z.infer; + +/** Revisioned lifecycle disposition of one definition version. */ +export const WorkflowDefinitionDispositionSchema = z.object({ + definition_id: z.lazy(() => WorkflowDefinitionIdSchema), + definition_version: z.number().int().safe().min(1), + revision: z.number().int().safe().min(1), + state: z.lazy(() => WorkflowDefinitionLifecycleStateSchema), + transitioned_at: z.lazy(() => UtcMicrosSchema), +}).strict(); +export type WorkflowDefinitionDisposition = z.infer; + +/** Wire request for [`WorkflowDefinitionService::get`]. */ +export const WorkflowDefinitionGetRequestSchema = z.object({ + definition_id: z.lazy(() => WorkflowDefinitionIdSchema), + definition_version: z.number().int().safe().min(1), +}).strict(); +export type WorkflowDefinitionGetRequest = z.infer; + +/** Wire request for [`WorkflowDefinitionService::history`]. */ +export const WorkflowDefinitionHistoryRequestSchema = z.object({ + definition_id: z.lazy(() => WorkflowDefinitionIdSchema), +}).strict(); +export type WorkflowDefinitionHistoryRequest = z.infer; + +/** Strongly typed canonical identity: `WorkflowDefinitionId`. */ +export const WorkflowDefinitionIdSchema = z.string(); +export type WorkflowDefinitionId = z.infer; + +/** Durable lifecycle disposition of one immutable workflow definition version. + +Plan 32 ("Typed workflow definitions"): "Lifecycle retains candidate, +validate, activate, retire, reject, list, get, diff, and history operations +through the same application surfaces." The definition payload itself stays +immutable — "Editing creates a new version; admitted runs remain pinned" — +so the disposition is a separate revisioned aggregate keyed by the same +definition identity. */ +export const WorkflowDefinitionLifecycleStateSchema = z.enum(["active", "candidate", "rejected", "retired", "validated"]); +export type WorkflowDefinitionLifecycleState = z.infer; + +/** Wire request for [`WorkflowDefinitionService::list`]. */ +export const WorkflowDefinitionListRequestSchema = z.object({}).strict(); +export type WorkflowDefinitionListRequest = z.infer; + +/** Wire request for [`WorkflowDefinitionService::reject`]. */ +export const WorkflowDefinitionRejectRequestSchema = z.object({ + definition_id: z.lazy(() => WorkflowDefinitionIdSchema), + definition_version: z.number().int().safe().min(1), + expected_revision: z.number().int().safe().min(1), +}).strict(); +export type WorkflowDefinitionRejectRequest = z.infer; + +/** Wire request for [`WorkflowDefinitionService::retire`]. */ +export const WorkflowDefinitionRetireRequestSchema = z.object({ + definition_id: z.lazy(() => WorkflowDefinitionIdSchema), + definition_version: z.number().int().safe().min(1), + expected_revision: z.number().int().safe().min(1), +}).strict(); +export type WorkflowDefinitionRetireRequest = z.infer; + +export const WorkflowFanOutSchema = z.object({ + max_width: z.number().int().min(0), +}).strict(); +export type WorkflowFanOut = z.infer; + +export const WorkflowFanOutChildPlanV1Schema = z.object({ + admit_command_id: z.lazy(() => WorkCommandIdSchema), + attempt_identity: z.lazy(() => WorkAttemptIdentityV1Schema), + create_command_id: z.lazy(() => WorkCommandIdSchema), + initiative: z.lazy(() => WorkInitiativeV1Schema), + instructions: z.string(), + item: z.lazy(() => WorkItemV1Schema), + milestone: z.lazy(() => WorkMilestoneV1Schema), + plan: z.lazy(() => WorkPlanV1Schema), + proposal: z.lazy(() => WorkProposalV1Schema), + proposal_command_id: z.lazy(() => WorkCommandIdSchema), + task_id: z.lazy(() => TaskIdSchema), +}).strict(); +export type WorkflowFanOutChildPlanV1 = z.infer; + +export const WorkflowFanOutFailurePolicyV1Schema = z.discriminatedUnion("policy", [z.object({ + policy: z.literal("collect"), +}), z.object({ + policy: z.literal("fail_fast"), +}), z.object({ + policy: z.literal("require_at_least"), + successes: z.number().int().min(1).max(65535), +})]); +export type WorkflowFanOutFailurePolicyV1 = z.infer; + +export const WorkflowFanOutPlanV1Schema = z.object({ + admitted_at: z.lazy(() => UtcMicrosSchema), + authority: z.lazy(() => WorkAuthoritySchema), + children: z.array(z.lazy(() => WorkflowFanOutChildPlanV1Schema)), + commit: z.lazy(() => CommitIdSchema), + effect_state: z.lazy(() => WorkEffectStateV1Schema), + execution_snapshot: z.lazy(() => WorkExecutionSnapshotSchema), + failure_policy: z.lazy(() => WorkflowFanOutFailurePolicyV1Schema), + maximum_parallel: z.number().int().min(1).max(65535), + operation: z.lazy(() => WorkflowOperationRefSchema), + plan_digest: z.lazy(() => ManifestDigestSchema), + reference: z.union([z.lazy(() => RefIdSchema), z.null()]), + step_id: z.lazy(() => WorkflowStepIdSchema), +}).strict(); +export type WorkflowFanOutPlanV1 = z.infer; + /** Strongly typed canonical identity: `WorkflowOperationRef`. */ export const WorkflowOperationRefSchema = z.string(); export type WorkflowOperationRef = z.infer; +export const WorkflowOutputArtifactSchema = z.object({ + artifact: z.lazy(() => WorkArtifactRefV1Schema), + attempt_identity: z.lazy(() => WorkAttemptIdentityV1Schema), +}).strict(); +export type WorkflowOutputArtifact = z.infer; + /** Strongly typed canonical identity: `WorkflowOutputName`. */ export const WorkflowOutputNameSchema = z.string(); export type WorkflowOutputName = z.infer; +export const WorkflowOutputReferenceSchema = z.object({ + output_name: z.lazy(() => WorkflowOutputNameSchema), + producer_step_id: z.lazy(() => WorkflowStepIdSchema), +}).strict(); +export type WorkflowOutputReference = z.infer; + +export const WorkflowPlacementReceiptSchema = z.object({ + backend: z.lazy(() => WorkProviderBackendV1Schema), + configuration_digest: z.lazy(() => ManifestDigestSchema), + model: z.string(), + placement_digest: z.lazy(() => ManifestDigestSchema), + provider_registry_digest: z.lazy(() => ManifestDigestSchema), + route: z.lazy(() => WorkProviderRouteV1Schema), + run_id: z.lazy(() => RunIdSchema), + step_id: z.lazy(() => WorkflowStepIdSchema), + topology_digest: z.lazy(() => ManifestDigestSchema), + worktree_placement: z.lazy(() => WorktreePlacementModeV1Schema), +}).strict(); +export type WorkflowPlacementReceipt = z.infer; + +export const WorkflowRunEventSchema = z.object({ + command_id: z.lazy(() => WorkCommandIdSchema), + event: z.lazy(() => WorkflowRunEventKindSchema), + input_digest: z.lazy(() => ManifestDigestSchema), + occurred_at: z.lazy(() => UtcMicrosSchema), + run_id: z.lazy(() => RunIdSchema), + sequence: z.number().int().safe().min(0), +}).strict(); +export type WorkflowRunEvent = z.infer; + +export const WorkflowRunEventKindSchema = z.discriminatedUnion("type", [z.object({ + definition: z.lazy(() => WorkflowDefinitionSchema), + fan_out_plans: z.array(z.lazy(() => WorkflowFanOutPlanV1Schema)), + pinned_provider_registry_digest: z.lazy(() => ManifestDigestSchema), + pinned_topology_digest: z.lazy(() => ManifestDigestSchema), + type: z.literal("admitted"), +}), z.object({ + type: z.literal("cancellation_requested"), +}), z.object({ + type: z.literal("cancelled"), +}), z.object({ + attempts: z.array(z.lazy(() => WorkAttemptIdentityV1Schema)), + step_id: z.lazy(() => WorkflowStepIdSchema), + type: z.literal("fan_out_children_released"), +}), z.object({ + attempts: z.array(z.lazy(() => WorkAttemptIdentityV1Schema)), + step_id: z.lazy(() => WorkflowStepIdSchema), + type: z.literal("fan_out_children_settled"), +}), z.object({ + type: z.literal("paused"), +}), z.object({ + type: z.literal("resumed"), +}), z.object({ + effect_receipt: z.lazy(() => WorkflowStepEffectReceiptSchema), + outputs: z.array(z.lazy(() => WorkflowStepOutputSchema)), + step_id: z.lazy(() => WorkflowStepIdSchema), + type: z.literal("step_completed"), +}), z.object({ + effect_receipt: z.lazy(() => WorkflowStepEffectReceiptSchema), + outputs: z.array(z.lazy(() => WorkflowStepOutputSchema)), + step_id: z.lazy(() => WorkflowStepIdSchema), + type: z.literal("step_failed"), +}), z.object({ + placement: z.lazy(() => WorkflowPlacementReceiptSchema), + step_id: z.lazy(() => WorkflowStepIdSchema), + type: z.literal("step_started"), +})]); +export type WorkflowRunEventKind = z.infer; + +export const WorkflowRunGetRequestSchema = z.object({ + run_id: z.lazy(() => RunIdSchema), +}).strict(); +export type WorkflowRunGetRequest = z.infer; + +export const WorkflowRunProjectionSchema = z.object({ + definition: z.lazy(() => WorkflowDefinitionSchema), + fan_out_plans: z.record(z.lazy(() => WorkflowFanOutPlanV1Schema)), + history: z.array(z.lazy(() => WorkflowRunEventSchema)), + pinned_provider_registry_digest: z.lazy(() => ManifestDigestSchema), + pinned_topology_digest: z.lazy(() => ManifestDigestSchema), + released_fan_out_attempts: z.array(z.lazy(() => WorkAttemptIdentityV1Schema)), + run_id: z.lazy(() => RunIdSchema), + sequence: z.number().int().safe().min(0), + settled_fan_out_attempts: z.array(z.lazy(() => WorkAttemptIdentityV1Schema)), + status: z.lazy(() => WorkflowRunStatusSchema), + steps: z.record(z.lazy(() => WorkflowStepRunProjectionSchema)), +}).strict(); +export type WorkflowRunProjection = z.infer; + +export const WorkflowRunStatusSchema = z.enum(["cancelled", "cancelling", "completed", "failed", "paused", "running"]); +export type WorkflowRunStatus = z.infer; + +export const WorkflowStepSchema = z.object({ + fan_out: z.union([z.lazy(() => WorkflowFanOutSchema), z.null()]), + inputs: z.array(z.lazy(() => WorkflowOutputReferenceSchema)), + operation: z.lazy(() => WorkflowOperationRefSchema), + outputs: z.array(z.lazy(() => WorkflowOutputNameSchema)), + predecessors: z.array(z.lazy(() => WorkflowStepIdSchema)), + step_id: z.lazy(() => WorkflowStepIdSchema), +}).strict(); +export type WorkflowStep = z.infer; + +export const WorkflowStepEffectOutcomeSchema = z.enum(["cancelled", "completed", "failed", "timed_out", "unknown"]); +export type WorkflowStepEffectOutcome = z.infer; + +export const WorkflowStepEffectReceiptSchema = z.object({ + effect_digest: z.lazy(() => ManifestDigestSchema), + outcome: z.lazy(() => WorkflowStepEffectOutcomeSchema), + output_set_digest: z.lazy(() => ManifestDigestSchema), + placement_digest: z.lazy(() => ManifestDigestSchema), + receipt_digest: z.lazy(() => ManifestDigestSchema), + run_id: z.lazy(() => RunIdSchema), + step_id: z.lazy(() => WorkflowStepIdSchema), +}).strict(); +export type WorkflowStepEffectReceipt = z.infer; + +/** Strongly typed canonical identity: `WorkflowStepId`. */ +export const WorkflowStepIdSchema = z.string(); +export type WorkflowStepId = z.infer; + +export const WorkflowStepOutputSchema = z.object({ + artifacts: z.array(z.lazy(() => WorkflowOutputArtifactSchema)), + output_name: z.lazy(() => WorkflowOutputNameSchema), +}).strict(); +export type WorkflowStepOutput = z.infer; + +export const WorkflowStepRunProjectionSchema = z.object({ + effect_receipt: z.union([z.lazy(() => WorkflowStepEffectReceiptSchema), z.null()]), + outputs: z.record(z.lazy(() => WorkflowStepOutputSchema)), + placement_receipt: z.union([z.lazy(() => WorkflowPlacementReceiptSchema), z.null()]), + status: z.lazy(() => WorkflowStepStatusSchema), +}).strict(); +export type WorkflowStepRunProjection = z.infer; + +export const WorkflowStepStatusSchema = z.enum(["blocked", "cancelled", "failed", "ready", "running", "succeeded"]); +export type WorkflowStepStatus = z.infer; + /** A provider's claim that one artifact of a fan-out output synthesizes its sibling source artifacts. */ export const WorkflowSynthesisDraftSchema = z.object({ diff --git a/dashboard/src/test/workTopologyMetricsFixture.ts b/dashboard/src/test/workTopologyMetricsFixture.ts new file mode 100644 index 000000000..4f93bb90c --- /dev/null +++ b/dashboard/src/test/workTopologyMetricsFixture.ts @@ -0,0 +1,104 @@ +/** `ExecutionTopologyMetricsV1` fixtures, shaped like the Rust projector's + * output; consumers parse them with the generated schema. */ + +const HORIZON = { + since_micros: 1_753_000_000_000_000, + until_micros: 1_753_003_600_000_000, +}; + +interface CoverageSpec { + eligible?: number | null; + observed?: number; + completed?: number; + censored?: number; + unknown?: number; + state?: string; +} + +function coverage(spec: CoverageSpec = {}) { + return { + eligible: spec.eligible ?? null, + observed: spec.observed ?? 0, + completed: spec.completed ?? 0, + censored: spec.censored ?? 0, + unknown: spec.unknown ?? 0, + excluded: 0, + state: spec.state ?? 'unknown', + }; +} + +export function topologyMeasurement(spec: { + metric: string; + value: number | null; + unit: string; + denominator: string; + dimensions: readonly { dimension: string; value: string }[]; + coverage?: CoverageSpec; + unavailable?: string; +}) { + const unavailable = spec.unavailable ?? null; + const cellCoverage = coverage(spec.coverage); + return { + dimensions: spec.dimensions, + unavailable, + value: { + descriptor_revision: 'execution-topology-metrics.v1', + metric: spec.metric, + value: spec.value, + unit: spec.unit, + denominator: spec.denominator, + denominator_value: cellCoverage.eligible, + coverage: cellCoverage, + evidence_class: 'measurement', + provenance: { + source: 'observability_envelope', + source_revision: 'observability-envelope.v1', + projector_revision: 'execution-topology-projector.v1', + watermark: 'observability:topology:41', + }, + cohort: { + descriptor_revision: `${spec.denominator}.v1`, + eligible_population: spec.denominator, + }, + temporal: { horizon: HORIZON, baseline_watermark: null, delta: null }, + uncertainty: { lower: spec.value, upper: spec.value, reason: unavailable }, + calibration: null, + unavailable_reason: unavailable, + }, + }; +} + +export interface TopologyMetricsSpec { + measurements?: readonly ReturnType[]; + githubStackCapability?: { + capability: string | null; + standard_git_fallback_available: boolean | null; + other_forge_fallback_available: boolean | null; + coverage?: CoverageSpec; + unavailable?: string | null; + }; + coverage?: CoverageSpec; +} + +export function topologyMetricsModel(spec: TopologyMetricsSpec = {}) { + const capability = spec.githubStackCapability; + return { + authorized_scope_ref: 'project.tracedecay', + horizon: HORIZON, + watermark: 'observability:topology:41', + observed_at_micros: HORIZON.until_micros, + current: true, + coverage: coverage(spec.coverage ?? { observed: 9, completed: 9, state: 'known' }), + emission_coverage: { emitted: 9, delayed: 0, dropped: 0, sampled_events: 0 }, + github_stack_capability: { + capability: capability?.capability ?? null, + standard_git_fallback_available: capability?.standard_git_fallback_available ?? null, + other_forge_fallback_available: capability?.other_forge_fallback_available ?? null, + coverage: coverage(capability?.coverage ?? { unknown: 1 }), + unavailable: + capability === undefined ? 'no_eligible_evidence' : (capability.unavailable ?? null), + }, + drill_anchors: [{ cursor: 'topology-observation-41' }], + measurements: spec.measurements ?? [], + }; +} diff --git a/dashboard/src/workspaces/work/WorkPage.tsx b/dashboard/src/workspaces/work/WorkPage.tsx index d49932802..52853af0a 100644 --- a/dashboard/src/workspaces/work/WorkPage.tsx +++ b/dashboard/src/workspaces/work/WorkPage.tsx @@ -1,4 +1,5 @@ import type { + ExecutionTopologyMetricsV1, ExecutionTopologyViewV1, WorkAttemptListV1, } from '../../contracts/index.ts'; @@ -9,7 +10,12 @@ import { WorkBoard, useSelectedTask } from './WorkBoard.tsx'; import { WorkCommands, WorkCreate } from './WorkCommands.tsx'; import { WorkEvidencePanel } from './WorkEvidencePanel.tsx'; import { WorkTaskActivity } from './WorkTaskActivity.tsx'; -import { useWorkAttempts, useWorkGraphViews, useWorkTopology } from './workViewsQueries.ts'; +import { + useWorkAttempts, + useWorkGraphViews, + useWorkTopology, + useWorkTopologyMetrics, +} from './workViewsQueries.ts'; import { workAttemptReading, type WorkAttemptReading } from './workAttemptModel.ts'; import { workGraphReading, type WorkGraphReading } from './workGraphModel.ts'; import { WorkCausalView } from './views/WorkCausalView.tsx'; @@ -87,6 +93,7 @@ function WorkProjectionView({ attempts, attemptList, topology, + topologyMetrics, graph, selected, onSelect, @@ -99,6 +106,7 @@ function WorkProjectionView({ * reading deliberately does not restate. */ attemptList: WorkResult | undefined; topology: WorkResult | undefined; + topologyMetrics: WorkResult | undefined; graph: WorkGraphReading; selected: string | null; onSelect: (taskId: string) => void; @@ -139,6 +147,7 @@ function WorkProjectionView({ snapshot={snapshot} attemptList={attemptList} topology={topology} + metrics={topologyMetrics} graph={graph} selected={selected} onSelect={onSelect} @@ -160,6 +169,9 @@ export function WorkPage() { // not on every visit to the page. const attempts = useWorkAttempts(projection === 'timeline' || projection === 'topology'); const topology = useWorkTopology(projection === 'topology'); + // The accounting read behind the topology lens's integration and stack + // cards; issued only when that lens is the camera. + const topologyMetrics = useWorkTopologyMetrics(projection === 'topology'); const attemptReading = workAttemptReading(attempts.data); // The graph hook bootstraps against profile ownership, then re-reads against // the exact repository scope returned in the daemon's response envelope. @@ -252,6 +264,7 @@ export function WorkPage() { attempts={attemptReading} attemptList={attempts.data} topology={topology.data} + topologyMetrics={topologyMetrics.data} graph={graphReading} selected={selected} onSelect={setSelected} diff --git a/dashboard/src/workspaces/work/views/WorkTopologyAccounting.tsx b/dashboard/src/workspaces/work/views/WorkTopologyAccounting.tsx index 18451ec54..527f7c6f0 100644 --- a/dashboard/src/workspaces/work/views/WorkTopologyAccounting.tsx +++ b/dashboard/src/workspaces/work/views/WorkTopologyAccounting.tsx @@ -1,4 +1,8 @@ -import type { ExecutionTopologyViewV1, WorkAttemptListV1 } from '../../../contracts/index.ts'; +import type { + ExecutionTopologyMetricsV1, + ExecutionTopologyViewV1, + WorkAttemptListV1, +} from '../../../contracts/index.ts'; import { StateChip } from '../../../ui/StateChip.tsx'; import { Meter, Panel } from '../../../ui/instrument.tsx'; import { cn } from '../../../ui/cn.ts'; @@ -20,9 +24,9 @@ import { ChannelAbsence, ViewCaption } from './WorkViewChannel.tsx'; * Plan 26's execution-topology accounting, drawn on the landed topology lens. * * One ledger, twelve cards, and the same seven-facet footer under every one of - * them. `workTopologyAccounting.ts` explains which three cards have a mounted - * source and why the other nine cannot; this file's whole job is to render what - * that structure holds and to be incapable of rendering anything else. + * them. `workTopologyAccounting.ts` explains which cards have a mounted source + * and why the rest stay stated absences; this file's whole job is to render + * what that structure holds and to be incapable of rendering anything else. * * Two rendering rules carry the plan's invariant, and both are structural * rather than stylistic: @@ -47,12 +51,14 @@ export function WorkTopologyAccounting({ attemptList, topology, graph, + metrics, }: { attemptList: WorkResult | undefined; topology?: WorkResult | undefined; graph: WorkGraphReading; + metrics?: WorkResult | undefined; }) { - const reading = workTopologyAccounting(attemptList, graph, topology); + const reading = workTopologyAccounting(attemptList, graph, topology, metrics); return (
diff --git a/dashboard/src/workspaces/work/views/WorkTopologyView.tsx b/dashboard/src/workspaces/work/views/WorkTopologyView.tsx index c2d80e8f1..eb4ba69d0 100644 --- a/dashboard/src/workspaces/work/views/WorkTopologyView.tsx +++ b/dashboard/src/workspaces/work/views/WorkTopologyView.tsx @@ -1,4 +1,5 @@ import type { + ExecutionTopologyMetricsV1, ExecutionTopologyViewV1, WorkAttemptListCoverageV1, WorkAttemptListV1, @@ -55,6 +56,7 @@ export function WorkTopologyView({ attemptList, topology, graph, + metrics, selected, onSelect, }: { @@ -64,6 +66,7 @@ export function WorkTopologyView({ attemptList: WorkResult | undefined; topology: WorkResult | undefined; graph: WorkGraphReading; + metrics: WorkResult | undefined; selected: string | null; onSelect: (taskId: string) => void; }) { @@ -87,7 +90,12 @@ export function WorkTopologyView({
- + ); } diff --git a/dashboard/src/workspaces/work/workAccountingMetrics.ts b/dashboard/src/workspaces/work/workAccountingMetrics.ts new file mode 100644 index 000000000..1ed918ef0 --- /dev/null +++ b/dashboard/src/workspaces/work/workAccountingMetrics.ts @@ -0,0 +1,346 @@ +import type { + ExecutionTopologyMeasurementV1, + ExecutionTopologyMetricsV1, + MetricCoverageV1, +} from '../../contracts/index.ts'; +import { formatMicrosUtc } from '../../ui/format.ts'; +import { humanizeMetric } from '../../ui/metricModel.ts'; +import type { WorkResult } from './workApi.ts'; +import type { WorkChannel } from './workChannel.ts'; +import { + accountingDimensionTitle, + type WorkAccountingCard, + type WorkAccountingDimension, + type WorkAccountingProvenance, + type WorkAccountingRow, +} from './workAccountingModel.ts'; + +/** + * The Plan 24 integration/stack cards, fed from the mounted + * `operation.work.topology_metrics` read. + * + * Work mounts no integration apply/review/stack mutation operation — Plan 24 + * keeps accepted integration lowered only through the Plan 36 + * native-integration family. These builders decode the observed accounting + * the projection publishes, cell by cell: nothing here derives a rate, sums a + * family, or joins the horizon aggregate to the topology generation. + */ + +/** The Plan 26 descriptor the integration-outcome cells carry. */ +export const MERGE_ATTEMPTS_METRIC = 'work_merge_attempts_total'; + +/** The metrics read's own reason, phrased for a channel. */ +function metricsAbsence( + metrics: WorkResult | undefined, + measure: string, +): WorkChannel { + if (metrics === undefined) { + return { + available: false, + state: 'loading', + detail: `the topology-metrics read has not answered yet, so ${measure} is not drawn`, + }; + } + if (metrics.outcome === 'refused') { + return { + available: false, + state: metrics.state, + detail: `${measure} is read from the mounted topology-metrics operation, and that read was refused: ${metrics.detail}`, + }; + } + return { + available: false, + state: 'unknown', + detail: `the topology-metrics read answered without ${measure}`, + }; +} + +function modelOf( + metrics: WorkResult | undefined, +): ExecutionTopologyMetricsV1 | null { + return metrics !== undefined && metrics.outcome === 'value' ? metrics.value : null; +} + +/** The daemon's typed absence for one measurement cell, verbatim. */ +function cellAbsence(measurement: ExecutionTopologyMeasurementV1): WorkChannel { + const reason = + measurement.unavailable ?? + measurement.value.unavailable_reason ?? + 'the projector published no reason'; + return { + available: false, + state: reason === 'store_unavailable' ? 'unavailable' : 'unknown', + detail: `the projector published this cell as a typed absence: ${humanizeMetric(reason)}`, + }; +} + +function horizonSentence(model: ExecutionTopologyMetricsV1): string { + const stamp = (micros: number) => formatMicrosUtc(micros, { zeroAs: 'unbounded' }); + return `${stamp(model.horizon.since_micros)} → ${stamp(model.horizon.until_micros)} · watermark ${model.watermark}`; +} + +const ANCHORS_ABSENCE: WorkChannel = { + available: false, + state: 'redacted', + detail: + 'the metrics read publishes registered observation cursors, not task/run/attempt identities; drill-down resolves them only through the authorized local observability boundary', +}; + +/** The seven facets, decoded from one metric envelope's own coverage. The + * descriptor revision is a channel: a reading without one states the absence + * rather than inventing a revision. */ +function metricsProvenance( + model: ExecutionTopologyMetricsV1, + coverage: MetricCoverageV1, + descriptorRevision: WorkAccountingProvenance['descriptorRevision'], + population: string, +): WorkAccountingProvenance { + return { + support: { + available: true, + value: { + value: coverage.observed, + unit: 'cases', + note: `${population} the projector observed in the horizon`, + }, + }, + eligible: + coverage.eligible == null + ? { + available: false, + state: 'partial', + detail: `the projector did not prove the eligible denominator for ${population}, so the observed count is a floor rather than a total`, + } + : { + available: true, + value: { value: coverage.eligible, unit: 'cases', note: population }, + }, + censoring: { + available: true, + value: { + censored: coverage.censored, + unknown: coverage.unknown, + note: 'censored and unknown counts are the projector\u2019s own, decoded from the metric envelope', + }, + }, + intervalCoverage: { + available: true, + value: `${coverage.state} coverage · ${coverage.observed} observed · ${coverage.completed} completed`, + }, + horizon: { available: true, value: horizonSentence(model) }, + descriptorRevision, + anchors: ANCHORS_ABSENCE, + }; +} + +/** Facets for a model whose every cell is a typed absence: suppression wipes + * a cell's value and coverage, so the counted facets carry the projector's + * reason while the untouched horizon and descriptor revision stay real. */ +function unreadableCellProvenance( + model: ExecutionTopologyMetricsV1, + reason: WorkChannel, + descriptorRevision: WorkAccountingProvenance['descriptorRevision'], +): WorkAccountingProvenance { + return { + support: reason, + eligible: reason, + censoring: reason, + intervalCoverage: reason, + horizon: { available: true, value: horizonSentence(model) }, + descriptorRevision, + anchors: ANCHORS_ABSENCE, + }; +} + +/** Every facet carrying the metrics read's own absence. */ +function absentMetricsProvenance( + metrics: WorkResult | undefined, + population: string, +): WorkAccountingProvenance { + return { + support: metricsAbsence(metrics, `the ${population} support count`), + eligible: metricsAbsence(metrics, `the ${population} eligible denominator`), + censoring: metricsAbsence(metrics, 'the censored and unknown counts'), + intervalCoverage: metricsAbsence(metrics, 'interval coverage'), + horizon: metricsAbsence(metrics, 'the observation horizon'), + descriptorRevision: metricsAbsence(metrics, 'the descriptor revision'), + anchors: metricsAbsence(metrics, 'safe drill anchors'), + }; +} + +function card( + dimension: WorkAccountingDimension, + mandate: string, + reading: WorkChannel, + rows: readonly WorkAccountingRow[], + provenance: WorkAccountingProvenance, +): WorkAccountingCard { + return { + dimension, + title: accountingDimensionTitle(dimension), + mandate, + reading, + rows, + matrices: null, + contradictions: [], + provenance, + }; +} + +const INTEGRATION_MANDATE = 'observed native fast-forward/merge/cherry-pick outcomes'; + +/** + * Observed integration outcomes: one row per `work_merge_attempts_total` + * kind × outcome cell. Suppressed cells (null value, wiped coverage) stay the + * projector's typed absences; only a readable cell may lend the card its + * headline and coverage authority, and no cell is ever summed. + */ +export function integrationOutcomesCard( + metrics: WorkResult | undefined, +): WorkAccountingCard { + const dimension: WorkAccountingDimension = 'integration_outcomes'; + const model = modelOf(metrics); + + const cells = + model?.measurements.filter( + (measurement) => measurement.value.metric === MERGE_ATTEMPTS_METRIC, + ) ?? []; + const dimensionalCells = cells.filter((measurement) => measurement.dimensions.length > 0); + const readableCells = dimensionalCells.filter((measurement) => measurement.value.value != null); + const authority = readableCells[0]; + + const rows: WorkAccountingRow[] = dimensionalCells.map((measurement) => ({ + key: measurement.dimensions.map((cell) => String(cell.value)).join('_'), + label: measurement.dimensions.map((cell) => humanizeMetric(cell.value)).join(' · '), + channel: + measurement.value.value == null + ? cellAbsence(measurement) + : { + available: true, + value: { + value: measurement.value.value, + unit: 'cases', + note: 'observed native integrations with this kind and outcome, decoded from one projector cell', + }, + }, + })); + + // Suppression never wipes a cell's descriptor revision, so it stays real + // whenever any cell exists. + const descriptorRevision: WorkAccountingProvenance['descriptorRevision'] = + cells[0] === undefined + ? { + available: false, + state: 'unknown', + detail: 'the projection carried no merge-attempt cell to read a descriptor revision from', + } + : { + available: true, + value: { kind: 'metric_descriptor', value: cells[0].value.descriptor_revision }, + }; + + if (model === null) { + return card( + dimension, + INTEGRATION_MANDATE, + metricsAbsence(metrics, 'integration-outcome cells'), + rows, + absentMetricsProvenance(metrics, 'observed native integrations'), + ); + } + + if (authority === undefined) { + const first = dimensionalCells[0] ?? cells[0]; + const reason: WorkChannel = + first !== undefined + ? cellAbsence(first) + : { + available: false, + state: 'unknown', + detail: 'the projection carried no integration-outcome cells', + }; + return card( + dimension, + INTEGRATION_MANDATE, + reason, + rows, + unreadableCellProvenance(model, reason, descriptorRevision), + ); + } + + const coverage = authority.value.coverage; + const suppressed = dimensionalCells.length - readableCells.length; + const suppressedNote = + suppressed === 0 + ? '' + : ` · ${suppressed} ${suppressed === 1 ? 'cell stays a typed absence' : 'cells stay typed absences'}`; + return card( + dimension, + INTEGRATION_MANDATE, + { + available: true, + value: `${coverage.observed} observed native integrations across ${readableCells.length} readable kind/outcome ${readableCells.length === 1 ? 'cell' : 'cells'}${suppressedNote} — counts are the projector's own cells, never summed here`, + }, + rows, + metricsProvenance(model, coverage, descriptorRevision, 'observed native integrations'), + ); +} + +const STACK_CAPABILITY_MANDATE = 'GitHub stack capability state and generic-fallback availability'; + +/** + * The latest trustworthy GitHub stacked-PR capability observation: a typed + * operational state, not a count, so it is the headline and carries no + * metered rows. A null field is unobserved, never coerced to off or on. + * `WorkFallbackTopology` is the provider-executable fallback and is never + * read into this card. + */ +export function githubStackCapabilityCard( + metrics: WorkResult | undefined, +): WorkAccountingCard { + const dimension: WorkAccountingDimension = 'github_stack_capability'; + const model = modelOf(metrics); + + if (model === null) { + return card( + dimension, + STACK_CAPABILITY_MANDATE, + metricsAbsence(metrics, 'the capability observation'), + [], + absentMetricsProvenance(metrics, 'capability observations'), + ); + } + + const capability = model.github_stack_capability; + const fallback = (value: boolean | null, name: string) => + value == null ? `${name} unobserved` : `${name} ${value ? 'available' : 'not available'}`; + const reading: WorkChannel = + capability.capability == null + ? { + available: false, + state: capability.unavailable === 'store_unavailable' ? 'unavailable' : 'unknown', + detail: `no trustworthy capability observation exists in the horizon: ${humanizeMetric(capability.unavailable ?? 'the projector published no reason')}`, + } + : { + available: true, + value: `capability ${humanizeMetric(capability.capability)} · ${fallback(capability.standard_git_fallback_available, 'standard-git fallback')} · ${fallback(capability.other_forge_fallback_available, 'other-forge fallback')}`, + }; + + return card( + dimension, + STACK_CAPABILITY_MANDATE, + reading, + [], + metricsProvenance( + model, + capability.coverage, + { + available: false, + state: 'unknown', + detail: + 'the GitHub stack capability reading is a model-level typed state and carries no metric descriptor revision', + }, + 'capability observations', + ), + ); +} diff --git a/dashboard/src/workspaces/work/workAccountingModel.ts b/dashboard/src/workspaces/work/workAccountingModel.ts index 916433913..509b28035 100644 --- a/dashboard/src/workspaces/work/workAccountingModel.ts +++ b/dashboard/src/workspaces/work/workAccountingModel.ts @@ -14,9 +14,9 @@ import type { WorkChannel } from './workChannel.ts'; * walks the attempt page, `workAccountingCards.ts` builds the sourced cards, * and `workTopologyAccounting.ts` assembles the twelve. * - * `metricsGap` says `unsupported_schema` rather than `unavailable` on purpose; - * the reasoning is in the assembler's module doc, which explains which three - * dimensions have a mounted source and why the other nine cannot. + * `metricsGap` says `unsupported` rather than `unavailable` on purpose; the + * reasoning is in the assembler's module doc, which explains which dimensions + * are decoded from the mounted reads and why the rest stay stated absences. */ /** The one `WorkAttemptListV1` variant that carries a page. */ @@ -90,13 +90,14 @@ export function accountingDimensionTitle(dimension: WorkAccountingDimension): st } /** - * The persisted event kind that would feed each dimension. + * The persisted event kind that feeds each dimension. * * Copied from Plan 26's execution-topology event family and from * `EXECUTION_TOPOLOGY_EVENT_KINDS_V1` in the Rust projector, which spell them - * identically. Naming the kind rather than the route is deliberate: the gap is - * a read model that is not published, not a transport that is down, and a - * reviewer who greps the kind lands on the projector rather than on a router. + * identically. Naming the kind rather than the route is deliberate: an + * absence here is a descriptor this ledger does not decode, not a transport + * that is down, and a reviewer who greps the kind lands on the projector + * rather than on a router. */ export function accountingEventKind(dimension: WorkAccountingDimension): string { switch (dimension) { @@ -282,12 +283,14 @@ export interface WorkTopologyAccountingReading { // --- Absences ---------------------------------------------------------------- /** - * The absence every unpublished dimension wears. + * The absence a dimension wears when this ledger takes no measurement for it. * - * `unsupported_schema` rather than `unavailable`: the read model exists and - * runs server-side, and what is missing is its publication to the dashboard - * contract catalog. Saying `unavailable` would tell a reader a reachable - * source refused, which is a different and fixable-in-a-different-place thing. + * `unsupported` rather than `unavailable`: `ExecutionTopologyMetricsV1` is + * published and mounted at `operation.work.topology_metrics`, and this ledger + * decodes its integration and stack families, but it does not decode a + * descriptor for this measure. Saying `unavailable` would tell a reader a + * reachable source refused, which is a different and + * fixable-in-a-different-place thing. */ export function metricsGap( dimension: WorkAccountingDimension, @@ -298,8 +301,8 @@ export function metricsGap( const tail = extra === undefined ? '' : ` ${extra}`; return { available: false, - state: 'unsupported_schema', - detail: `${measure} is projected by ExecutionTopologyMetricsV1 from ${kind}, and that read model is not published to this build — it has no entry in the dashboard contract schema and therefore no generated DTO and no mounted route, so this is a measurement no read here can take rather than one measured as zero.${tail}`, + state: 'unsupported', + detail: `${measure} belongs to the ${kind} event family Plan 26 projects through ExecutionTopologyMetricsV1; this ledger does not decode a descriptor for it, so it is a measurement not taken here rather than one measured as zero.${tail}`, }; } diff --git a/dashboard/src/workspaces/work/workTopologyAccounting.test.ts b/dashboard/src/workspaces/work/workTopologyAccounting.test.ts index da89138e5..da2f8a8e7 100644 --- a/dashboard/src/workspaces/work/workTopologyAccounting.test.ts +++ b/dashboard/src/workspaces/work/workTopologyAccounting.test.ts @@ -1,12 +1,19 @@ import { describe, expect, it } from 'vitest'; import { + ExecutionTopologyMetricsV1Schema, WorkAttemptListV1Schema, WorkGraphReadV1Schema, + type ExecutionTopologyMetricsV1, type WorkAttemptListV1, } from '../../contracts/index.ts'; import { workAttempt as attempt, workAttemptList } from '../../test/workAttemptFixture.ts'; import { workGraphRead, type WorkGraphVersionSpec } from '../../test/workGraphFixture.ts'; +import { + topologyMeasurement, + topologyMetricsModel, + type TopologyMetricsSpec, +} from '../../test/workTopologyMetricsFixture.ts'; import type { WorkResult } from './workApi.ts'; import type { WorkChannel } from './workChannel.ts'; import { workGraphReading, type WorkGraphReading } from './workGraphModel.ts'; @@ -52,6 +59,13 @@ function graphOf(spec: WorkGraphVersionSpec = BASE_GRAPH): WorkGraphReading { }); } +function metricsOf(spec: TopologyMetricsSpec = {}): WorkResult { + return { + outcome: 'value', + value: ExecutionTopologyMetricsV1Schema.parse(topologyMetricsModel(spec)), + }; +} + function graphWithRuntimeGeneration(generationId: string): WorkGraphReading { const graph = graphOf(); if (graph.state !== 'read' || graph.page.entry === null) { @@ -160,31 +174,30 @@ describe('the shape of the ledger', () => { /** * The no-falsified-UI invariant, asserted over the whole ledger at once. * - * Nine of the twelve dimensions have no published read model. Every row of - * every one of them must be an absence that names the event kind that would - * feed it — not a zero, and not a silently omitted row. + * The undecoded dimensions must render as absences that name the event kind + * that feeds them — not a zero, and not a silently omitted row — and the two + * metrics-fed cards must wear the metrics read's own state when that read + * has not answered. */ - it('renders every unsupported dimension as a stated absence rather than a zero', () => { + it('renders every undecoded dimension as a stated absence rather than a zero', () => { const reading = workTopologyAccounting( listed([attempt({ taskId: 'alpha', runId: 'run-1', attemptId: 'a-1' })]), graphOf(), ); - const unsupported: readonly WorkAccountingDimension[] = [ + const undecoded: readonly WorkAccountingDimension[] = [ 'duplicate_work', 'conflict_confusion', 'ready_to_integrated_latency', - 'integration_outcomes', 'stale_stack_age', - 'github_stack_capability', 'operational_leaks', 'delivery_fanout', ]; - for (const dimension of unsupported) { + for (const dimension of undecoded) { const card = cardOf(reading, dimension); const stated = absence(card.reading); - expect(stated.state, dimension).toBe('unsupported_schema'); + expect(stated.state, dimension).toBe('unsupported'); expect(stated.detail, dimension).toContain('ExecutionTopologyMetricsV1'); // The event kind a reviewer greps for, on the card itself. expect(stated.detail, dimension).toMatch(/work\.[a-z_]+\.[a-z_.]*v1/); @@ -193,6 +206,18 @@ describe('the shape of the ledger', () => { } } + // The metrics-fed cards carry the read's own state — here, unread — and + // never a zero. + for (const dimension of ['integration_outcomes', 'github_stack_capability'] as const) { + const card = cardOf(reading, dimension); + const stated = absence(card.reading); + expect(stated.state, dimension).toBe('loading'); + expect(stated.detail, dimension).toContain('topology-metrics read has not answered'); + for (const row of card.rows) { + expect(row.channel.available, `${dimension} · ${row.key}`).toBe(false); + } + } + expect(reading.measured).toBe(1); }); }); @@ -211,7 +236,7 @@ describe('the concurrency ladder', () => { // The three rungs no field carries. Each names why, and none is a zero. for (const key of ['accepted', 'admitted', 'useful', 'fanout']) { const stated = absence(rows.get(key)!); - expect(stated.state, key).toBe('unsupported_schema'); + expect(stated.state, key).toBe('unsupported'); } expect(absence(rows.get('admitted')!).detail).toContain('a count and not a width'); expect(absence(rows.get('useful')!).detail).toContain('ProgressFrontier'); @@ -400,7 +425,7 @@ describe('the rerun census', () => { // A measured zero: this page holds no attempt restarted for this cause. expect(figure(rows.get('reason_process_lost')!).value).toBe(0); - expect(absence(card.reading).state).toBe('unsupported_schema'); + expect(absence(card.reading).state).toBe('unsupported'); expect(absence(card.reading).detail).toContain('completed runtime rerun total'); }); @@ -412,10 +437,10 @@ describe('the rerun census', () => { const rows = new Map(card.rows.map((row) => [row.key, row.channel])); for (const key of ['test_reruns', 'ci_reruns']) { const stated = absence(rows.get(key)!); - expect(stated.state, key).toBe('unsupported_schema'); + expect(stated.state, key).toBe('unsupported'); expect(stated.detail, key).toContain('never summed'); } - expect(absence(card.reading).state).toBe('unsupported_schema'); + expect(absence(card.reading).state).toBe('unsupported'); expect(absence(card.reading).detail).toContain('Recovery-required is a rerun owed'); }); @@ -487,7 +512,7 @@ describe('the rerun census', () => { ), }; const card = cardOf(workTopologyAccounting(capped, graphOf()), 'duplicate_effects'); - expect(absence(card.provenance.support).state).toBe('unsupported_schema'); + expect(absence(card.provenance.support).state).toBe('unsupported'); expect(absence(card.provenance.eligible).state).toBe('partial'); expect(absence(card.provenance.eligible).detail).toContain('not a full eligible denominator'); const coverage = card.provenance.intervalCoverage; @@ -527,7 +552,7 @@ describe('duplicate effects', () => { 'duplicate_effects', ); - expect(absence(card.reading).state).toBe('unsupported_schema'); + expect(absence(card.reading).state).toBe('unsupported'); expect(figure(card.provenance.eligible)).toEqual({ value: 2, unit: 'attempts', @@ -536,14 +561,14 @@ describe('duplicate effects', () => { // No adjudication read exists. Its support is unknown, not a case count of // zero; zero would be an observed answer the contract never supplied. - expect(absence(card.provenance.support).state).toBe('unsupported_schema'); + expect(absence(card.provenance.support).state).toBe('unsupported'); expect(absence(card.provenance.support).detail).toContain('adjudication support'); const rows = new Map(card.rows.map((row) => [row.key, row.channel])); expect(figure(rows.get('effect_compound_non_repeatable')!).value).toBe(2); expect(figure(rows.get('effect_intercepted')!).value).toBe(1); expect(figure(rows.get('effect_observational')!).value).toBe(1); - expect(absence(rows.get('adjudicated_duplicates')!).state).toBe('unsupported_schema'); + expect(absence(rows.get('adjudicated_duplicates')!).state).toBe('unsupported'); }); }); @@ -555,8 +580,8 @@ describe('blocked time', () => { ); const rows = new Map(card.rows.map((row) => [row.key, row.channel])); - expect(absence(rows.get('unioned_blocked_time')!).state).toBe('unsupported_schema'); - expect(absence(rows.get('attributed_blocked_time')!).state).toBe('unsupported_schema'); + expect(absence(rows.get('unioned_blocked_time')!).state).toBe('unsupported'); + expect(absence(rows.get('attributed_blocked_time')!).state).toBe('unsupported'); const effort = figure(rows.get('blocked_effort')!); expect(effort.value).toBe(7); @@ -596,28 +621,197 @@ describe('the conflict confusion matrices', () => { }); }); -describe('the near misses', () => { - /** `WorkFallbackTopology` is the provider-executable fallback and looks like - * the thing this card wants. Naming it in the absence is what stops the next - * reader from wiring the wrong contract into the right-sounding slot. */ - it('names the provider fallback as NOT the GitHub generic fallback', () => { +describe('observed integration outcomes', () => { + const KNOWN = { eligible: 8, observed: 7, completed: 7, unknown: 1, state: 'known' }; + const WIPED = { eligible: null, observed: 0, unknown: 1, state: 'unknown' }; + + /** One `work_merge_attempts_total` kind × outcome cell. A null value with a + * reason models projector suppression, which also wipes the coverage. */ + function mergeCell(kind: string, outcome: string, value: number | null, unavailable?: string) { + return topologyMeasurement({ + metric: 'work_merge_attempts_total', + value, + unit: 'events', + denominator: 'observed_native_integrations', + dimensions: [ + { dimension: 'integration_kind', value: kind }, + { dimension: 'integration_outcome', value: outcome }, + ], + coverage: value == null ? WIPED : KNOWN, + unavailable, + }); + } + + const MERGE_CELLS = metricsOf({ + measurements: [ + mergeCell('fast_forward', 'succeeded', 6), + mergeCell('cherry_pick', 'conflicted', 1), + // A sibling descriptor this card must not decode into a count row. + topologyMeasurement({ + metric: 'work_merge_success_ratio', + value: 0.75, + unit: 'ratio', + denominator: 'observed_native_integrations', + dimensions: [{ dimension: 'integration_kind', value: 'fast_forward' }], + coverage: KNOWN, + }), + ], + }); + + it('decodes the projector’s kind × outcome cells verbatim', () => { const card = cardOf( - workTopologyAccounting(listed([]), graphOf()), - 'github_stack_capability', + workTopologyAccounting(listed([]), graphOf(), undefined, MERGE_CELLS), + 'integration_outcomes', ); - expect(absence(card.reading).detail).toContain('WorkFallbackTopology'); - expect(absence(card.reading).detail).toContain('never counted into this card'); + + const rows = new Map(card.rows.map((row) => [row.key, row.channel])); + expect(rows.size).toBe(2); + expect(figure(rows.get('fast_forward_succeeded')!).value).toBe(6); + expect(figure(rows.get('cherry_pick_conflicted')!).value).toBe(1); + + // The headline states the projector's own observed count; nothing sums + // the cells into a total the projection never published. + expect(card.reading.available).toBe(true); + if (!card.reading.available) throw new Error('unreachable'); + expect(card.reading.value).toContain('7 observed native integrations'); + expect(card.reading.value).toContain('never summed here'); + + // Provenance is the metric envelope's own coverage and revision. + expect(figure(card.provenance.support).value).toBe(7); + expect(figure(card.provenance.eligible).value).toBe(8); + const revision = card.provenance.descriptorRevision; + if (!revision.available) throw new Error('expected a metric descriptor revision'); + expect(revision.value.kind).toBe('metric_descriptor'); + expect(revision.value.value).toBe('execution-topology-metrics.v1'); }); - it('refuses to read an observed integration outcome off the pinned policy', () => { - const card = cardOf(workTopologyAccounting(listed([]), graphOf()), 'integration_outcomes'); - expect(absence(card.reading).detail).toContain('integration STRATEGY'); - for (const key of ['fast_forward', 'merge', 'cherry_pick']) { - const row = card.rows.find((entry) => entry.key === key); - expect(row?.channel.available, key).toBe(false); + it('propagates the typed absence when the support floor suppresses every cell', () => { + // A card over nothing but suppressed cells wears the projector's reason — + // never an available "0 observed" headline read off a wiped envelope. + const suppressed = metricsOf({ + measurements: [ + mergeCell('fast_forward', 'succeeded', null, 'support_floor_unmet'), + mergeCell('cherry_pick', 'conflicted', null, 'support_floor_unmet'), + ], + }); + const card = cardOf( + workTopologyAccounting(listed([]), graphOf(), undefined, suppressed), + 'integration_outcomes', + ); + + const stated = absence(card.reading); + expect(stated.detail).toContain('typed absence'); + expect(stated.detail).toContain('support floor unmet'); + expect(card.rows).toHaveLength(2); + for (const row of card.rows) { + expect(row.channel.available, row.key).toBe(false); } + // Counted facets carry the same typed reason; the horizon and the + // untouched descriptor revision stay real. + expect(absence(card.provenance.support).detail).toContain('support floor unmet'); + expect(absence(card.provenance.eligible).detail).toContain('support floor unmet'); + expect(card.provenance.horizon.available).toBe(true); + const revision = card.provenance.descriptorRevision; + if (!revision.available) throw new Error('expected the cell descriptor revision'); + expect(revision.value.value).toBe('execution-topology-metrics.v1'); }); + it('states readable and suppressed cells separately when they coexist', () => { + const mixed = metricsOf({ + measurements: [ + mergeCell('fast_forward', 'succeeded', 6), + mergeCell('cherry_pick', 'conflicted', null, 'support_floor_unmet'), + ], + }); + const card = cardOf( + workTopologyAccounting(listed([]), graphOf(), undefined, mixed), + 'integration_outcomes', + ); + + expect(card.reading.available).toBe(true); + if (!card.reading.available) throw new Error('unreachable'); + expect(card.reading.value).toContain('1 readable kind/outcome cell'); + expect(card.reading.value).toContain('1 cell stays a typed absence'); + // Headline coverage comes from a readable cell's envelope, never a + // suppressed one. + expect(figure(card.provenance.support).value).toBe(7); + }); + + it('carries the projector’s typed absence for an empty horizon rather than zero cells', () => { + const empty = metricsOf({ + measurements: [ + topologyMeasurement({ + metric: 'work_merge_attempts_total', + value: null, + unit: 'events', + denominator: 'observed_native_integrations', + dimensions: [], + unavailable: 'no_eligible_evidence', + }), + ], + }); + const card = cardOf( + workTopologyAccounting(listed([]), graphOf(), undefined, empty), + 'integration_outcomes', + ); + expect(card.rows).toHaveLength(0); + const stated = absence(card.reading); + expect(stated.detail).toContain('typed absence'); + expect(stated.detail).toContain('no eligible evidence'); + }); + + it('carries the metrics read’s refusal rather than an empty ledger', () => { + const card = cardOf( + workTopologyAccounting(listed([]), graphOf(), undefined, { + outcome: 'refused', + state: 'unavailable', + detail: 'the observation store is unavailable', + }), + 'integration_outcomes', + ); + const stated = absence(card.reading); + expect(stated.state).toBe('unavailable'); + expect(stated.detail).toContain('the observation store is unavailable'); + for (const facet of WORK_ACCOUNTING_FACETS) { + expect(card.provenance[facet].available, facet).toBe(false); + } + }); +}); + +describe('GitHub stack capability', () => { + it('reads the projection’s own capability state and fallback observations', () => { + const card = cardOf( + workTopologyAccounting(listed([]), graphOf(), undefined, metricsOf({ + githubStackCapability: { + capability: 'enabled', + standard_git_fallback_available: true, + other_forge_fallback_available: null, + coverage: { eligible: 3, observed: 3, completed: 3, state: 'known' }, + }, + })), + 'github_stack_capability', + ); + + expect(card.reading.available).toBe(true); + if (!card.reading.available) throw new Error('unreachable'); + expect(card.reading.value).toContain('capability enabled'); + expect(card.reading.value).toContain('standard-git fallback available'); + // A null field is unobserved, never coerced into off or on. + expect(card.reading.value).toContain('other-forge fallback unobserved'); + }); + + it('states the projector’s typed reason when no trustworthy observation exists', () => { + const card = cardOf( + workTopologyAccounting(listed([]), graphOf(), undefined, metricsOf()), + 'github_stack_capability', + ); + const stated = absence(card.reading); + expect(stated.detail).toContain('no trustworthy capability observation'); + expect(stated.detail).toContain('no eligible evidence'); + }); +}); + +describe('the near misses', () => { it('does not borrow the retry weave for adjudicated duplicate work', () => { const card = cardOf(workTopologyAccounting(listed([]), graphOf()), 'duplicate_work'); expect(absence(card.reading).detail).toContain('retry chain and not duplicate work'); diff --git a/dashboard/src/workspaces/work/workTopologyAccounting.ts b/dashboard/src/workspaces/work/workTopologyAccounting.ts index 41aa475c6..58c17c750 100644 --- a/dashboard/src/workspaces/work/workTopologyAccounting.ts +++ b/dashboard/src/workspaces/work/workTopologyAccounting.ts @@ -1,4 +1,5 @@ import type { + ExecutionTopologyMetricsV1, ExecutionTopologyViewV1, WorkAttemptListV1, WorkAttemptTopologyBindingV1, @@ -14,6 +15,10 @@ import { duplicateEffectCard, rerunCard, } from './workAccountingCards.ts'; +import { + githubStackCapabilityCard, + integrationOutcomesCard, +} from './workAccountingMetrics.ts'; import { unavailableCard, type WorkAccountingCard, @@ -42,19 +47,18 @@ import { * * WHERE THE NUMBERS COME FROM, AND WHERE THEY DO NOT * - * Plan 26 owns eleven persisted execution-topology events and projects them - * into `ExecutionTopologyMetricsV1` - * (`crates/tracedecay-application/src/execution_topology_metrics.rs`, whose - * `EXECUTION_TOPOLOGY_EVENT_KINDS_V1` is the exact list this module names in - * its absences). That read model is NOT published to the dashboard: it has no - * entry in `crates/tracedecay-dashboard-api/src/contract_schema.rs`, therefore - * no schema in `contracts/generated.ts`, therefore no route this build could - * call even if one were mounted. Nine of the twelve dimensions are fed by - * nothing else, and they render as typed absences that name the event kind a - * reviewer can grep for. None of them renders a zero. + * `ExecutionTopologyMetricsV1` is published at `operation.work.topology_metrics` + * (`/api/work/topology-metrics`), and this ledger consumes its integration and + * stack families through `workAccountingMetrics.ts`: the + * `work_merge_attempts_total` kind × outcome cells and the + * `github_stack_capability` reading, each cell decoded verbatim with the + * projector's own typed absences. The remaining event-fed dimensions render + * as typed absences naming the event kind a reviewer can grep for + * (`EXECUTION_TOPOLOGY_EVENT_KINDS_V1`): this lens does not decode their + * descriptors, and an absence stated is not a zero shown. * - * Three dimensions have a real, mounted source, and those are the cards this - * module adds to the landed lens: + * Three further dimensions have a real, mounted source on the attempt and + * graph reads, and those are the cards this module adds to the landed lens: * * concurrency `operation.work.views` → * `WorkWorkloadProjectionV1.requested_concurrency` and @@ -113,6 +117,7 @@ export function workTopologyAccounting( result: WorkResult | undefined, graph: WorkGraphReading, topology?: WorkResult | undefined, + metrics?: WorkResult | undefined, ): WorkTopologyAccountingReading { const canonicalTopology = topologyBinding(topology); const boundAttempts = attemptsBoundToTopology(result, canonicalTopology); @@ -141,23 +146,11 @@ export function workTopologyAccounting( unavailableCard('ready_to_integrated_latency', 'ready-to-integrated latency', [ { key: 'latency_distribution', label: 'Latency distribution', measure: 'the ready-to-integrated latency distribution' }, ], 'No read in this build carries a duration at all: an attempt records the instant it finished and never the instant it started.'), - unavailableCard('integration_outcomes', 'observed native fast-forward/merge/cherry-pick outcomes', [ - { key: 'fast_forward', label: 'Fast-forward', measure: 'observed fast-forward outcomes' }, - { key: 'merge', label: 'Merge commit', measure: 'observed merge outcomes' }, - { key: 'cherry_pick', label: 'Cherry-pick', measure: 'observed cherry-pick outcomes' }, - ], 'The landed lens reads the integration STRATEGY the policy pins; what an integration was observed to do is a different fact and is not read off the policy.'), + integrationOutcomesCard(metrics), unavailableCard('stale_stack_age', 'stale-stack age', [ { key: 'stack_age', label: 'Stack age distribution', measure: 'the stale-stack age distribution' }, ]), - unavailableCard( - 'github_stack_capability', - 'GitHub stack capability state and generic-fallback availability', - [ - { key: 'capability_state', label: 'Capability state', measure: 'the GitHub stack capability state' }, - { key: 'generic_fallback', label: 'Generic-fallback availability', measure: 'generic-fallback availability' }, - ], - 'WorkFallbackTopology on the execution snapshot is the provider-EXECUTABLE fallback (codex_cli or disabled) and is not the review-surface generic fallback; it is named here so it is never counted into this card.', - ), + githubStackCapabilityCard(metrics), blockedTimeCard(boundGraph), rerunCard(reading, page, census), duplicateEffectCard(reading, page, census), diff --git a/dashboard/src/workspaces/workflows/WorkflowsPage.dom.test.tsx b/dashboard/src/workspaces/workflows/WorkflowsPage.dom.test.tsx new file mode 100644 index 000000000..9e178c79c --- /dev/null +++ b/dashboard/src/workspaces/workflows/WorkflowsPage.dom.test.tsx @@ -0,0 +1,359 @@ +/** The Workflows page over the mounted `/application/workflow` routes: a + * refusal is never an empty registry, and every rendered figure is a decoded + * generated contract. */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useScope } from '../../data/scope/store.ts'; +import { WorkflowsPage } from './WorkflowsPage.tsx'; + +const DIGEST = `sha256:${'a'.repeat(64)}`; + +function definition(version = 1) { + return { + definition_id: 'workflow.release-train', + definition_version: version, + project_id: 'project.workflows', + steps: [ + { + step_id: 'fan-out', + operation: 'operation.work.start_attempt', + predecessors: [], + inputs: [], + outputs: ['finding'], + fan_out: { max_width: 3 }, + }, + { + step_id: 'collect', + operation: 'operation.work.synthesize', + predecessors: ['fan-out'], + inputs: [{ producer_step_id: 'fan-out', output_name: 'finding' }], + outputs: [], + fan_out: null, + }, + ], + pinned_policy_digest: DIGEST, + pinned_configuration_digest: DIGEST, + pinned_catalog_digest: DIGEST, + }; +} + +function envelope(payload: unknown) { + return { + kind: 'success', + value: { + binding_id: 'binding.http.workflow.test', + contract: { schema_id: 'schema.workflow.result', schema_revision: 1 }, + request_id: 'request-1', + scope: { + project_id: 'project.workflows', + repository_id: 'repository.workflows', + worktree_id: 'worktree.workflows', + reference: null, + scope_digest: 'sha256:scope', + }, + outcome: { outcome: 'evidence', value: { payload } }, + }, + }; +} + +/** Answers exactly the routes a test names; anything else fails loudly. */ +function serve(handler: (url: string, init?: RequestInit) => { status: number; body: unknown }) { + const calls: { url: string; body: unknown }[] = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string, init?: RequestInit) => { + calls.push({ + url: String(url), + body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined, + }); + const { status, body } = handler(String(url), init); + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); + }), + ); + return calls; +} + +function renderPage() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + + + + , + ); +} + +afterEach(() => { + useScope.setState({ scope: { kind: 'all' } }); + vi.unstubAllGlobals(); +}); + +describe('the Workflows page over mounted routes', () => { + it('lists registered definitions and opens the decoded step table', async () => { + serve((url) => + url.includes('/application/workflow/list-definitions') + ? { status: 200, body: envelope([definition()]) } + : { status: 503, body: { kind: 'problem', value: { problem: {} } } }, + ); + renderPage(); + + const row = await screen.findByRole('button', { name: /workflow\.release-train/ }); + expect(row.textContent).toContain('version 1'); + expect(row.textContent).toContain('2 steps'); + + await userEvent.click(row); + const detail = await screen.findByRole('region', { + name: 'Definition workflow.release-train · version 1', + }); + expect(detail.textContent).toContain('operation.work.start_attempt'); + expect(detail.textContent).toContain('operation.work.synthesize'); + expect(detail.textContent).toContain('max width 3'); + expect(detail.textContent).toContain('— entry step'); + }); + + it('renders a refusal as the daemon’s own state, never as an empty registry', async () => { + serve(() => ({ status: 503, body: { kind: 'problem', value: { problem: {} } } })); + renderPage(); + + expect(await screen.findByText(/the Work runtime is unavailable/)).toBeTruthy(); + expect(screen.queryByText(/no workflow definitions are registered/)).toBeNull(); + expect(document.querySelector('[data-workflow-definitions]')).toBeNull(); + }); + + it('draws the empty registry only when the daemon answered one', async () => { + serve((url) => + url.includes('/application/workflow/list-definitions') + ? { status: 200, body: envelope([]) } + : { status: 503, body: { kind: 'problem', value: { problem: {} } } }, + ); + renderPage(); + + expect( + await screen.findByText( + /the daemon answered: no workflow definitions are registered in this scope/, + ), + ).toBeTruthy(); + }); + + it('sends the compare-and-swap activation and renders the returned disposition', async () => { + const calls = serve((url) => { + if (url.includes('/application/workflow/list-definitions')) { + return { status: 200, body: envelope([definition()]) }; + } + if (url.includes('/application/workflow/activate-definition')) { + return { + status: 200, + body: envelope({ + definition_id: 'workflow.release-train', + definition_version: 1, + state: 'active', + revision: 3, + transitioned_at: 10, + }), + }; + } + return { status: 503, body: { kind: 'problem', value: { problem: {} } } }; + }); + renderPage(); + + await userEvent.click(await screen.findByRole('button', { name: /workflow\.release-train/ })); + await userEvent.click(await screen.findByRole('button', { name: 'activate' })); + + expect(await screen.findByText(/disposition active · revision 3/)).toBeTruthy(); + const activation = calls.find((call) => + call.url.includes('/application/workflow/activate-definition'), + ); + expect(activation?.body).toEqual({ + definition_id: 'workflow.release-train', + definition_version: 1, + expected_revision: 1, + }); + }); + + it('resets the lifecycle controls when the operator switches definitions', async () => { + const second = { ...definition(2), definition_id: 'workflow.nightly-sweep' }; + serve((url) => { + if (url.includes('/application/workflow/list-definitions')) { + return { status: 200, body: envelope([definition(), second]) }; + } + if (url.includes('/application/workflow/activate-definition')) { + return { + status: 200, + body: envelope({ + definition_id: 'workflow.release-train', + definition_version: 1, + state: 'active', + revision: 3, + transitioned_at: 10, + }), + }; + } + return { status: 503, body: { kind: 'problem', value: { problem: {} } } }; + }); + renderPage(); + + await userEvent.click(await screen.findByRole('button', { name: /workflow\.release-train/ })); + await userEvent.click(await screen.findByRole('button', { name: 'activate' })); + expect(await screen.findByText(/disposition active · revision 3/)).toBeTruthy(); + + const draft = screen.getByLabelText('Expected revision'); + await userEvent.clear(draft); + await userEvent.type(draft, '7'); + await userEvent.click(await screen.findByRole('button', { name: /workflow\.nightly-sweep/ })); + + expect( + await screen.findByRole('region', { name: 'Definition workflow.nightly-sweep · version 2' }), + ).toBeTruthy(); + expect(screen.queryByText(/disposition active · revision 3/)).toBeNull(); + expect((screen.getByLabelText('Expected revision') as HTMLInputElement).value).toBe('1'); + }); + + it('refuses a lifecycle command under a read-only scope without dispatching it', async () => { + useScope.setState({ + scope: { + kind: 'project', + projectId: 'proj_other', + label: 'Other project', + activation: 'selected', + }, + }); + const calls = serve((url) => + url.includes('/application/workflow/list-definitions') + ? { status: 200, body: envelope([definition()]) } + : { status: 503, body: { kind: 'problem', value: { problem: {} } } }, + ); + renderPage(); + + await userEvent.click(await screen.findByRole('button', { name: /workflow\.release-train/ })); + await userEvent.click(await screen.findByRole('button', { name: 'activate' })); + + // The scope authority's own reason, answered without a request: the + // gateway serves every non-active project read-only. + expect(await screen.findByText(/is not the active project/)).toBeTruthy(); + expect( + calls.find((call) => call.url.includes('/application/workflow/activate-definition')), + ).toBeUndefined(); + // The definitions read did dispatch, through the selected project's gateway. + expect( + calls.some((call) => + call.url.startsWith('/api/projects/proj_other/application/workflow/list-definitions'), + ), + ).toBe(true); + }); + + it('renders a lifecycle conflict verbatim rather than pretending the transition landed', async () => { + serve((url) => { + if (url.includes('/application/workflow/list-definitions')) { + return { status: 200, body: envelope([definition()]) }; + } + if (url.includes('/application/workflow/retire-definition')) { + return { status: 409, body: { kind: 'problem', value: { problem: {} } } }; + } + return { status: 503, body: { kind: 'problem', value: { problem: {} } } }; + }); + renderPage(); + + await userEvent.click(await screen.findByRole('button', { name: /workflow\.release-train/ })); + await userEvent.click(await screen.findByRole('button', { name: 'retire' })); + + expect(await screen.findByText(/the task moved since it was read/)).toBeTruthy(); + expect(screen.queryByText(/disposition \w+ · revision/)).toBeNull(); + }); + + it('reads one run projection and renders its decoded step states', async () => { + serve((url) => { + if (url.includes('/application/workflow/list-definitions')) { + return { status: 200, body: envelope([]) }; + } + if (url.includes('/application/workflow/get-run')) { + return { + status: 200, + body: envelope({ + run_id: 'run.release-train.1', + definition: definition(), + pinned_topology_digest: DIGEST, + pinned_provider_registry_digest: DIGEST, + status: 'running', + sequence: 4, + steps: { + 'fan-out': { + status: 'running', + outputs: {}, + placement_receipt: null, + effect_receipt: null, + }, + collect: { + status: 'blocked', + outputs: {}, + placement_receipt: null, + effect_receipt: null, + }, + }, + fan_out_plans: {}, + released_fan_out_attempts: [], + settled_fan_out_attempts: [], + history: [ + { + run_id: 'run.release-train.1', + sequence: 1, + command_id: 'workflow-admit:1', + input_digest: DIGEST, + occurred_at: 5, + event: { + type: 'admitted', + definition: definition(), + pinned_topology_digest: DIGEST, + pinned_provider_registry_digest: DIGEST, + fan_out_plans: [], + }, + }, + ], + }), + }; + } + return { status: 503, body: { kind: 'problem', value: { problem: {} } } }; + }); + renderPage(); + + await userEvent.type(await screen.findByLabelText('Run id'), 'run.release-train.1'); + await userEvent.click(screen.getByRole('button', { name: 'Read run' })); + + await waitFor(() => { + expect(document.querySelector('[data-workflow-run="run.release-train.1"]')).toBeTruthy(); + }); + expect(screen.getByText('status running')).toBeTruthy(); + expect(screen.getByText('sequence 4')).toBeTruthy(); + const fanOut = document.querySelector('[data-workflow-run-step="fan-out"]'); + expect(fanOut?.textContent).toContain('running'); + expect(fanOut?.textContent).toContain('no effect receipt yet'); + }); + + it('refuses a run the daemon conceals rather than inventing an empty projection', async () => { + serve((url) => { + if (url.includes('/application/workflow/list-definitions')) { + return { status: 200, body: envelope([]) }; + } + if (url.includes('/application/workflow/get-run')) { + return { status: 404, body: { kind: 'problem', value: { problem: {} } } }; + } + return { status: 503, body: { kind: 'problem', value: { problem: {} } } }; + }); + renderPage(); + + await userEvent.type(await screen.findByLabelText('Run id'), 'run.unknown'); + await userEvent.click(screen.getByRole('button', { name: 'Read run' })); + + expect(await screen.findByText(/not found, or not authorized for this actor/)).toBeTruthy(); + expect(document.querySelector('[data-workflow-run]')).toBeNull(); + }); +}); diff --git a/dashboard/src/workspaces/workflows/WorkflowsPage.tsx b/dashboard/src/workspaces/workflows/WorkflowsPage.tsx new file mode 100644 index 000000000..c94961ee8 --- /dev/null +++ b/dashboard/src/workspaces/workflows/WorkflowsPage.tsx @@ -0,0 +1,378 @@ +import { useState } from 'react'; +import type { + WorkflowDefinition, + WorkflowRunProjection, + WorkflowStep, +} from '../../contracts/index.ts'; +import { StateChip, type DomainStateKind } from '../../ui/StateChip.tsx'; +import { Corners, Panel, Ticks, WorkspaceHeader } from '../../ui/instrument.tsx'; +import { cn } from '../../ui/cn.ts'; +import type { WorkResult } from '../work/workApi.ts'; +import { + useWorkflowDefinitions, + useWorkflowLifecycle, + useWorkflowRun, + type WorkflowLifecycleAction, +} from './workflowQueries.ts'; + +/** + * Workflows — channel fourteen: definitions, compare-and-swap lifecycle + * transitions, and run projections over the canonical `/application/workflow` + * routes. Everything rendered is a decoded generated contract; refusals wear + * the daemon's own typed state. Handoffs and run control are deliberately + * absent: the browser never holds a bearer or mints fences/command ids. + */ + +const INPUT_CLASS = + 'min-h-[36px] rounded-sm border border-edge bg-surface-1 px-2 text-2xs text-text-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent'; + +const BUTTON_CLASS = + 'min-h-[36px] rounded-sm border border-edge px-2 py-1 text-2xs text-text-primary hover:bg-surface-3 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent disabled:cursor-not-allowed disabled:text-text-muted'; + +export function WorkflowsPage() { + const definitions = useWorkflowDefinitions(); + const [selected, setSelected] = useState(null); + + const listed = definitions.data?.outcome === 'value' ? definitions.data.value : null; + const selectedDefinition = + listed?.find( + (definition) => + `${definition.definition_id}@${definition.definition_version}` === selected, + ) ?? null; + + return ( +
+ + +
+ + + +
+ + + {selectedDefinition === null ? null : ( + // Keyed so lifecycle state resets when switching definitions. + + )} + + +
+
+
+ ); +} + +function DefinitionsPanel({ + result, + pending, + selected, + onSelect, +}: { + result: WorkResult | undefined; + pending: boolean; + selected: string | null; + onSelect: (key: string | null) => void; +}) { + return ( + +
+ {pending ? ( + + ) : result === undefined ? ( + + ) : result.outcome === 'refused' ? ( + // A refused registry and an empty registry must never render alike. + + ) : result.value.length === 0 ? ( + + ) : ( +
    + {result.value.map((definition) => { + const key = `${definition.definition_id}@${definition.definition_version}`; + const active = key === selected; + return ( +
  • + +
  • + ); + })} +
+ )} +
+
+ ); +} + +function DefinitionDetail({ definition }: { definition: WorkflowDefinition }) { + return ( + +
+
+ + + +
+ +
+ + + + + {['step', 'operation', 'predecessors', 'outputs', 'fan-out'].map((column) => ( + + ))} + + + + {definition.steps.map((step) => ( + + ))} + +
+ steps · operations are catalog ids, admitted on activation +
+ {column} +
+
+ + +
+
+ ); +} + +function PinnedDigest({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +function StepRow({ step }: { step: WorkflowStep }) { + return ( + + + {step.step_id} + + {step.operation} + + {step.predecessors.length === 0 ? '— entry step' : step.predecessors.join(', ')} + + + {step.outputs.length === 0 ? '— none declared' : step.outputs.join(', ')} + + + {step.fan_out === null ? 'no fan-out' : `max width ${step.fan_out.max_width}`} + + + ); +} + +/** A finished lifecycle command, read as a chip. `undefined` while nothing has + * been sent, so controls that never ran say nothing rather than success. */ +function lifecycleReading( + result: WorkResult<{ state: string; revision: number }> | undefined, + pending: boolean, +): { state: DomainStateKind; detail: string } | undefined { + if (pending) return { state: 'loading', detail: 'sending' }; + if (result === undefined) return undefined; + if (result.outcome === 'value') { + return { + state: 'ready', + detail: `disposition ${result.value.state} · revision ${result.value.revision}`, + }; + } + return { state: result.state, detail: result.detail }; +} + +function LifecycleControls({ definition }: { definition: WorkflowDefinition }) { + const lifecycle = useWorkflowLifecycle(); + const [revision, setRevision] = useState('1'); + const parsedRevision = Number.parseInt(revision, 10); + const validRevision = Number.isInteger(parsedRevision) && parsedRevision >= 1; + const reading = lifecycleReading(lifecycle.data, lifecycle.isPending); + + const run = (action: WorkflowLifecycleAction) => { + if (!validRevision) return; + lifecycle.mutate({ + action, + definitionId: definition.definition_id, + definitionVersion: definition.definition_version, + expectedRevision: parsedRevision, + }); + }; + + return ( +
+

+ Compare-and-swap against the disposition revision (a registered candidate starts at 1); + the daemon answers with the stored disposition or a typed conflict. +

+
+ + {(['activate', 'retire', 'reject'] as const).map((action) => ( + + ))} + {reading === undefined ? null : } +
+
+ ); +} + +function RunPanel() { + const [draft, setDraft] = useState(''); + const [runId, setRunId] = useState(null); + const run = useWorkflowRun(runId); + + return ( + +
+
{ + event.preventDefault(); + setRunId(draft.trim() === '' ? null : draft.trim()); + }} + > + + +
+ + {runId === null ? null : run.isPending ? ( + + ) : run.data === undefined ? ( + + ) : run.data.outcome === 'refused' ? ( + + ) : ( + + )} +
+
+ ); +} + +function RunProjection({ projection }: { projection: WorkflowRunProjection }) { + const steps = Object.entries(projection.steps); + return ( +
+
+ {projection.run_id} + status {projection.status} + sequence {projection.sequence} + + definition {projection.definition.definition_id} · version{' '} + {projection.definition.definition_version} + + + fan-out attempts {projection.released_fan_out_attempts.length} released ·{' '} + {projection.settled_fan_out_attempts.length} settled + +
+
    + {steps.map(([stepId, step]) => ( +
  • + {stepId} + {step.status} + + {step.effect_receipt === null + ? 'no effect receipt yet' + : `effect ${step.effect_receipt.outcome}`} + + + {step.placement_receipt === null + ? 'no placement receipt yet' + : `placed on ${step.placement_receipt.backend} · ${step.placement_receipt.model}`} + +
  • + ))} +
+
+ ); +} diff --git a/dashboard/src/workspaces/workflows/workflowQueries.ts b/dashboard/src/workspaces/workflows/workflowQueries.ts new file mode 100644 index 000000000..58be55f50 --- /dev/null +++ b/dashboard/src/workspaces/workflows/workflowQueries.ts @@ -0,0 +1,117 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { + WorkflowDefinition, + WorkflowDefinitionDisposition, + WorkflowRunProjection, +} from '../../contracts/index.ts'; +import { scopeKey, scopedUrl, scopeWritable, useScope } from '../../data/scope/store.ts'; +import { callWork, type WorkResult } from '../work/workApi.ts'; +import { + WORKFLOW_ACTIVATE_DEFINITION_ROUTE, + WORKFLOW_GET_RUN_ROUTE, + WORKFLOW_LIST_DEFINITIONS_ROUTE, + WORKFLOW_REJECT_DEFINITION_ROUTE, + WORKFLOW_RETIRE_DEFINITION_ROUTE, +} from './workflowRoutes.ts'; + +/** + * The reads and lifecycle commands behind the Workflows workspace. Every call + * goes through the same application envelope walker the Work surface uses + * (`callWork`) and the same `scopedUrl` project-gateway rewrite. + */ + +function workflowQueryKey(scope: string, ...parts: readonly (string | number)[]) { + return ['workflow', scope, ...parts] as const; +} + +export function useWorkflowDefinitions() { + const scope = useScope((state) => state.scope); + const key = scopeKey(scope); + return useQuery>({ + queryKey: workflowQueryKey(key, 'list-definitions'), + queryFn: () => + callWork( + WORKFLOW_LIST_DEFINITIONS_ROUTE, + {}, + scopedUrl(scope, WORKFLOW_LIST_DEFINITIONS_ROUTE.path), + ), + }); +} + +/** One run's projection, read on demand; disabled until a run id is named. */ +export function useWorkflowRun(runId: string | null) { + const scope = useScope((state) => state.scope); + const key = scopeKey(scope); + return useQuery>({ + queryKey: workflowQueryKey(key, 'get-run', runId ?? ''), + enabled: runId !== null, + queryFn: () => + callWork( + WORKFLOW_GET_RUN_ROUTE, + { run_id: runId ?? '' }, + scopedUrl(scope, WORKFLOW_GET_RUN_ROUTE.path), + ), + }); +} + +export type WorkflowLifecycleAction = 'activate' | 'retire' | 'reject'; + +export interface WorkflowLifecycleCommand { + readonly action: WorkflowLifecycleAction; + readonly definitionId: string; + readonly definitionVersion: number; + readonly expectedRevision: number; +} + +function lifecycleRoute(action: WorkflowLifecycleAction) { + switch (action) { + case 'activate': + return WORKFLOW_ACTIVATE_DEFINITION_ROUTE; + case 'retire': + return WORKFLOW_RETIRE_DEFINITION_ROUTE; + case 'reject': + return WORKFLOW_REJECT_DEFINITION_ROUTE; + default: { + const unhandled: never = action; + return unhandled; + } + } +} + +/** The refusal a lifecycle command outside the writable scope reports without + * issuing a request — the same `locked` reading Work commands answer, because + * the gateway rule it repeats is the same one (`scopeWritable`). */ +function notWritable(reason: string): WorkResult { + return { outcome: 'refused', state: 'locked', detail: reason }; +} + +/** One compare-and-swap lifecycle transition; resolves to the daemon's own + * `WorkResult` and re-reads the definitions list afterwards. A scope the + * gateway serves read-only is refused here without dispatching, exactly as + * Work commands are. */ +export function useWorkflowLifecycle() { + const scope = useScope((state) => state.scope); + const key = scopeKey(scope); + const client = useQueryClient(); + const writability = scopeWritable(scope); + return useMutation, never, WorkflowLifecycleCommand>({ + mutationFn: (command) => { + if (writability.state !== 'writable') { + return Promise.resolve(notWritable(writability.reason)); + } + const route = lifecycleRoute(command.action); + return callWork( + route, + { + definition_id: command.definitionId, + definition_version: command.definitionVersion, + expected_revision: command.expectedRevision, + }, + scopedUrl(scope, route.path), + ); + }, + onSettled: () => { + void client.invalidateQueries({ queryKey: workflowQueryKey(key, 'list-definitions') }); + }, + }); +} diff --git a/dashboard/src/workspaces/workflows/workflowRoutes.ts b/dashboard/src/workspaces/workflows/workflowRoutes.ts new file mode 100644 index 000000000..eb41ded54 --- /dev/null +++ b/dashboard/src/workspaces/workflows/workflowRoutes.ts @@ -0,0 +1,68 @@ +import { z } from 'zod'; +import { + WorkflowDefinitionActivateRequestSchema, + WorkflowDefinitionDispositionSchema, + WorkflowDefinitionHistoryRequestSchema, + WorkflowDefinitionListRequestSchema, + WorkflowDefinitionRejectRequestSchema, + WorkflowDefinitionRetireRequestSchema, + WorkflowDefinitionSchema, + WorkflowRunGetRequestSchema, + WorkflowRunProjectionSchema, +} from '../../contracts/index.ts'; +import type { WorkRoute } from '../work/workApi.ts'; + +/** + * The Workflow routes this dashboard calls: same operation ids and + * `/application/workflow/` paths as the canonical `WorkflowOperation` + * descriptor (`crates/tracedecay-api/src/workflow.rs`). Handoffs and run + * control are deliberately undeclared — the browser never holds a bearer or + * mints fences/command ids — and register/validate/get/diff stay undeclared + * until an authoring journey exists. + */ + +export const WORKFLOW_LIST_DEFINITIONS_ROUTE = { + operation: 'operation.workflow.list_definitions', + path: '/api/application/workflow/list-definitions', + request: WorkflowDefinitionListRequestSchema, + response: z.array(WorkflowDefinitionSchema), +} as const satisfies WorkRoute; + +/** Every immutable version of one definition identity, oldest first. */ +export const WORKFLOW_DEFINITION_HISTORY_ROUTE = { + operation: 'operation.workflow.definition_history', + path: '/api/application/workflow/definition-history', + request: WorkflowDefinitionHistoryRequestSchema, + response: z.array(WorkflowDefinitionSchema), +} as const satisfies WorkRoute; + +/** The three lifecycle compare-and-swaps; catalog admission gates activate + * on the daemon before the transition is journaled. */ +export const WORKFLOW_ACTIVATE_DEFINITION_ROUTE = { + operation: 'operation.workflow.activate_definition', + path: '/api/application/workflow/activate-definition', + request: WorkflowDefinitionActivateRequestSchema, + response: WorkflowDefinitionDispositionSchema, +} as const satisfies WorkRoute; + +export const WORKFLOW_RETIRE_DEFINITION_ROUTE = { + operation: 'operation.workflow.retire_definition', + path: '/api/application/workflow/retire-definition', + request: WorkflowDefinitionRetireRequestSchema, + response: WorkflowDefinitionDispositionSchema, +} as const satisfies WorkRoute; + +export const WORKFLOW_REJECT_DEFINITION_ROUTE = { + operation: 'operation.workflow.reject_definition', + path: '/api/application/workflow/reject-definition', + request: WorkflowDefinitionRejectRequestSchema, + response: WorkflowDefinitionDispositionSchema, +} as const satisfies WorkRoute; + +/** One run's projection, rebuilt from the run's own event journal. */ +export const WORKFLOW_GET_RUN_ROUTE = { + operation: 'operation.workflow.get_run', + path: '/api/application/workflow/get-run', + request: WorkflowRunGetRequestSchema, + response: WorkflowRunProjectionSchema, +} as const satisfies WorkRoute; diff --git a/dashboard/stories/registry.ts b/dashboard/stories/registry.ts index 3eb844d7f..04d685d1d 100644 --- a/dashboard/stories/registry.ts +++ b/dashboard/stories/registry.ts @@ -126,6 +126,14 @@ export const STORY_SURFACES: readonly StorySurface[] = [ 'The canonical task graph for the active project, over nine mounted routes.', wired: true, }, + { + id: 'workflows', + path: '/workflows', + label: 'Workflows', + description: + 'Registered workflow definitions, lifecycle control, and run projections.', + wired: true, + }, ] as const; export type StorySurfaceId = (typeof STORY_SURFACES)[number]['id']; diff --git a/docs/plans/tracedecay-v2/NEXT.md b/docs/plans/tracedecay-v2/NEXT.md index 20912a94a..dd5f9f868 100644 --- a/docs/plans/tracedecay-v2/NEXT.md +++ b/docs/plans/tracedecay-v2/NEXT.md @@ -399,8 +399,24 @@ the commit as attribution evidence. (`c5c0a7663`); LSP protocol sessions register a warming owner before a sealed census so initialize/shutdown/exit admit during warming (`5e222426e`); Windows getrandom mapping, `large_enum_variant` boxing, - and byte-identical consecutive dashboard builds (`28973da32`, - `fd5b1dfe8`, `66c69e034`) close the remaining CI job classes. + and byte-identical consecutive dashboard builds (`28973da32`, + `fd5b1dfe8`, `66c69e034`) close the remaining CI job classes. +- The remaining Work/workflow product surface is mounted (2026-08-19, PR + branch `cursor/mount-workflow-product-surface-2353`). Workflow activation + runs tool-catalog semantic admission before the lifecycle transition is + journaled (`workflow_admission`: step operations must resolve in the Work + executable catalog and `pinned_catalog_digest` must name the live digest; + `WorkflowDefinitionService::admit_activation` is the one authority both + activation paths run, and the daemon journey asserts the mounted route + refuses an uncataloged candidate). The dashboard gained the fourteenth + workspace, Workflows — definitions, lifecycle compare-and-swaps, and + `get_run` projections over the mounted `/application/workflow` routes and + newly generated contracts; handoff and run-control wire types stay + uncontracted. A19 outcome: Work mounts no integration apply/review/stack + mutation operation and must not (Plan 36 owns apply/receipt); the Work + workspace's integration-outcome and stack-capability accounting cards now + decode the mounted `operation.work.topology_metrics` projection cell by + cell instead of wearing a stale "read model is not published" absence. ## Remaining work by lane diff --git a/src/daemon/service/invocation/work/workflow_dispatch.rs b/src/daemon/service/invocation/work/workflow_dispatch.rs index 8893a2420..cad3877c9 100644 --- a/src/daemon/service/invocation/work/workflow_dispatch.rs +++ b/src/daemon/service/invocation/work/workflow_dispatch.rs @@ -119,16 +119,28 @@ pub(in crate::daemon::service::invocation) async fn execute_workflow_application ) } WorkflowApplicationInvocation::ActivateDefinition(request) => { - let prepared = WorkflowEffectPreparedV1::activate_definition( - input_digest.clone(), - WorkflowDefinitionLifecycleCommand { - definition_id: request.definition_id, - definition_version: request.definition_version, - operation: WorkflowLifecycleOperation::Activate, - expected_revision: request.expected_revision, - transitioned_at: observed_at, - }, - ); + // Plan 32: catalog admission rejects before the lifecycle + // command is journaled; a denial is the same canonical problem + // effect every other refused mutation records. + let prepared = match services + .definitions() + .admit_activation(&request.definition_id, request.definition_version) + { + Ok(()) => WorkflowEffectPreparedV1::activate_definition( + input_digest.clone(), + WorkflowDefinitionLifecycleCommand { + definition_id: request.definition_id, + definition_version: request.definition_version, + operation: WorkflowLifecycleOperation::Activate, + expected_revision: request.expected_revision, + transitioned_at: observed_at, + }, + ), + Err(error) => WorkflowEffectPreparedV1::problem( + input_digest.clone(), + workflow_effect_problem(workflow_coordination_problem(error)), + ), + }; execute_journaled_workflow_effect( ®istered, services.effects(), diff --git a/src/daemon/service/invocation/work/workflow_run_control.rs b/src/daemon/service/invocation/work/workflow_run_control.rs index 31a0f0c2f..99e4d6021 100644 --- a/src/daemon/service/invocation/work/workflow_run_control.rs +++ b/src/daemon/service/invocation/work/workflow_run_control.rs @@ -329,11 +329,18 @@ pub(super) fn workflow_coordination_problem( tracedecay_application::WorkflowCoordinationError::AuthorityUnavailable(_) => { DaemonInvocationProblem::Unavailable } + // A catalog that could not be composed is an unavailable authority, + // not a caller mistake; only a definition the live catalog actually + // refused is an invalid request. + tracedecay_application::WorkflowCoordinationError::CatalogAdmissionDenied( + tracedecay_application::WorkflowCatalogAdmissionError::CatalogUnavailable(_), + ) => DaemonInvocationProblem::Unavailable, tracedecay_application::WorkflowCoordinationError::DefinitionNotFound | tracedecay_application::WorkflowCoordinationError::ScopeMismatch => { DaemonInvocationProblem::NotFoundOrNotAuthorized } tracedecay_application::WorkflowCoordinationError::InvalidDefinition + | tracedecay_application::WorkflowCoordinationError::CatalogAdmissionDenied(_) | tracedecay_application::WorkflowCoordinationError::ImmutableDefinitionConflict | tracedecay_application::WorkflowCoordinationError::IllegalLifecycleTransition | tracedecay_application::WorkflowCoordinationError::LifecycleRevisionConflict => { diff --git a/tests/daemon_suite/advanced_workflow_journey_test.rs b/tests/daemon_suite/advanced_workflow_journey_test.rs index 0a48240e8..351839ef5 100644 --- a/tests/daemon_suite/advanced_workflow_journey_test.rs +++ b/tests/daemon_suite/advanced_workflow_journey_test.rs @@ -576,7 +576,7 @@ fn mounted_fan_out_recovers_then_synthesizes_and_hands_off() { // to select the runnable entry step. WorkflowStep { step_id: downstream_step_id.clone(), - operation: id::("operation.work.attempt_start"), + operation: id::("operation.work.start_attempt"), predecessors: BTreeSet::from([step_id.clone()]), inputs: Vec::new(), outputs: Vec::new(), @@ -587,7 +587,7 @@ fn mounted_fan_out_recovers_then_synthesizes_and_hands_off() { }, WorkflowStep { step_id: step_id.clone(), - operation: id::("operation.work.attempt_start"), + operation: id::("operation.work.start_attempt"), predecessors: BTreeSet::new(), inputs: Vec::new(), outputs: vec![id::("finding")], @@ -604,6 +604,48 @@ fn mounted_fan_out_recovers_then_synthesizes_and_hands_off() { definition: definition.clone(), }) .expect("mounted workflow definition registration"); + + // Catalog admission refuses an uncataloged step operation before the + // lifecycle transition is journaled (Plan 32). + let uncataloged_definition_id: WorkflowDefinitionId = + id("workflow.advanced-production-journey.uncataloged"); + let uncataloged = WorkflowDefinition::new( + uncataloged_definition_id.clone(), + 1, + project_id.clone(), + vec![WorkflowStep { + step_id: id("fan-out"), + operation: id::("operation.work.not_a_mounted_operation"), + predecessors: BTreeSet::new(), + inputs: Vec::new(), + outputs: Vec::new(), + fan_out: Some(WorkflowFanOut { max_width: 3 }), + }], + definition.pinned_policy_digest().clone(), + definition.pinned_configuration_digest().clone(), + definition.pinned_catalog_digest().clone(), + ) + .expect("uncataloged workflow definition"); + client + .execute::(&WorkflowDefinitionRegisterRequest { + definition: uncataloged, + }) + .expect("candidate registration stays lenient before activation"); + let admission_denial = client + .execute::(&WorkflowDefinitionActivateRequest { + definition_id: uncataloged_definition_id, + definition_version: 1, + expected_revision: 1, + }) + .expect_err("activation must refuse an operation the catalog does not mount"); + assert!( + matches!( + admission_denial, + ClientError::Problem(ref problem) if problem.kind == "invalid_request" + ), + "catalog admission denial must be a typed refusal: {admission_denial}" + ); + client .execute::(&WorkflowDefinitionActivateRequest { definition_id: definition_id.clone(),