From 2fc7c3a0197ed93bb594d84035b67db9924916fd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 02:56:04 +0000 Subject: [PATCH 01/12] feat(workflow): admit step operations against the catalog on activate Co-authored-by: Zack Jackson --- crates/tracedecay-application/src/lib.rs | 2 + .../src/workflow_admission.rs | 118 ++++++++++++++++++ .../src/workflow_coordination.rs | 40 +++++- .../tests/workflow_coordination.rs | 107 ++++++++++++++-- .../tests/workflow_dag_execution.rs | 4 +- .../tests/workflow_fan_out_census.rs | 4 +- .../tests/workflow_runtime.rs | 2 +- .../tests/workflow_run_journal_storage.rs | 4 +- .../invocation/work/workflow_dispatch.rs | 34 +++-- .../invocation/work/workflow_run_control.rs | 1 + .../advanced_workflow_journey_test.rs | 47 ++++++- 11 files changed, 329 insertions(+), 34 deletions(-) create mode 100644 crates/tracedecay-application/src/workflow_admission.rs diff --git a/crates/tracedecay-application/src/lib.rs b/crates/tracedecay-application/src/lib.rs index a8b8228ec6..f44b1f7359 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 0000000000..e0bc3c6f64 --- /dev/null +++ b/crates/tracedecay-application/src/workflow_admission.rs @@ -0,0 +1,118 @@ +//! Tool-catalog semantic admission for workflow definitions. +//! +//! Structural validation ([`tracedecay_domain::WorkflowDefinition::validate`]) +//! proves the DAG shape; it says nothing about whether a step's operation is +//! real. Plan 32 requires that "unknown operations, cycles, dangling +//! references, incompatible schemas, unbounded fan-out, privilege expansion, +//! unsupported effects, or recursive generic execution reject before +//! activation", so activation additionally admits every step operation +//! against the canonical Work executable catalog — the registry whose +//! operations workflow fan-out actually lowers steps into +//! ([`crate::prepare_workflow_fan_out`] copies `step.operation` onto the +//! durable plan, and the daemon starts the child Work attempts under it). +//! +//! The schema and capability halves of the check are carried by the catalog +//! digest pin: [`crate::work_executable_catalog_digest`] hashes the complete +//! registry, including every operation's capability manifest and request and +//! result schema authorities, so a definition whose `pinned_catalog_digest` +//! names the live digest was authored against exactly the schemas and +//! capability contracts this build executes. A stale pin 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. +/// +/// Admission holds exactly when the definition pins the live executable +/// catalog digest and every step operation resolves to an available +/// executable binding in that catalog. The first violation is returned as a +/// typed denial naming the offending step and operation. +pub 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 63bcbf6d9e..d82fde285f 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,9 @@ where } } + /// Validation is the preflight for activation and answers exactly what + /// activation would decide: structural shape plus tool-catalog semantic + /// admission of every step operation. pub fn validate( &self, definition: WorkflowDefinition, @@ -420,6 +430,8 @@ where definition .validate() .map_err(|_| WorkflowCoordinationError::InvalidDefinition)?; + admit_workflow_definition_operations(&definition) + .map_err(WorkflowCoordinationError::CatalogAdmissionDenied)?; Ok(WorkflowDefinitionValidation { definition }) } @@ -452,13 +464,32 @@ where .map_err(coordination_authority_error) } + /// Admission every activation must clear before its lifecycle transition + /// is journaled: the stored payload is structurally revalidated and every + /// step operation is admitted against the tool catalog. This is the one + /// authority both activation paths — this service and the daemon's + /// journaled effect — run, so they cannot drift. + 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 +497,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 2ad673d329..709028fed0 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,12 +63,12 @@ fn workflow_context( .unwrap() } +/// A canonical mounted Work operation, so a fixture definition clears catalog +/// admission unless a test deliberately names an unknown one. +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", - ) + definition_for_project(version, id("project.workflow.coordination"), MOUNTED_OPERATION) } fn definition_with_operation(version: u64, operation: &str) -> WorkflowDefinition { @@ -79,6 +79,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 +108,7 @@ fn definition_for_project( }], digest('a'), digest('b'), - digest('c'), + pinned_catalog_digest, ) .unwrap() } @@ -949,6 +963,81 @@ 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" — so + // the unknown operation must be refused by validate and activate, not by + // the candidate insert above. + 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 happens before the lifecycle authority: the disposition + // stays candidate and no transition history is appended. + 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 9067296fab..9a3f6a0d38 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 0410ccfe6a..6fc736d512 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 8877f999fc..9815f79340 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-rusqlite-runtime/tests/workflow_run_journal_storage.rs b/crates/tracedecay-rusqlite-runtime/tests/workflow_run_journal_storage.rs index 0a68b21a5d..1449699de7 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/src/daemon/service/invocation/work/workflow_dispatch.rs b/src/daemon/service/invocation/work/workflow_dispatch.rs index 8893a24206..b4da69a6a5 100644 --- a/src/daemon/service/invocation/work/workflow_dispatch.rs +++ b/src/daemon/service/invocation/work/workflow_dispatch.rs @@ -119,16 +119,30 @@ 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: unknown operations and incompatible schemas reject + // before activation. Admission runs against the stored payload + // before the lifecycle command is journaled, so 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 31a0f0c2f0..ab311c95c3 100644 --- a/src/daemon/service/invocation/work/workflow_run_control.rs +++ b/src/daemon/service/invocation/work/workflow_run_control.rs @@ -334,6 +334,7 @@ pub(super) fn workflow_coordination_problem( 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 0a48240e87..f16e194e02 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,49 @@ fn mounted_fan_out_recovers_then_synthesizes_and_hands_off() { definition: definition.clone(), }) .expect("mounted workflow definition registration"); + + // Catalog admission gates production activation: a registered candidate + // whose step names an operation the executable catalog does not mount is + // refused before its 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(), From ce079e7b920f7a77a0ff009192633b31c3166e72 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 03:07:42 +0000 Subject: [PATCH 02/12] feat(dashboard): read integration and stack cards from topology metrics Co-authored-by: Zack Jackson --- .../src/test/workTopologyMetricsFixture.ts | 117 +++++++ dashboard/src/workspaces/work/WorkPage.tsx | 17 +- .../work/views/WorkTopologyAccounting.tsx | 18 +- .../work/views/WorkTopologyView.tsx | 11 +- .../workspaces/work/workAccountingMetrics.ts | 311 ++++++++++++++++++ .../workspaces/work/workAccountingModel.ts | 31 +- .../work/workTopologyAccounting.test.ts | 210 ++++++++++-- .../workspaces/work/workTopologyAccounting.ts | 48 +-- 8 files changed, 685 insertions(+), 78 deletions(-) create mode 100644 dashboard/src/test/workTopologyMetricsFixture.ts create mode 100644 dashboard/src/workspaces/work/workAccountingMetrics.ts diff --git a/dashboard/src/test/workTopologyMetricsFixture.ts b/dashboard/src/test/workTopologyMetricsFixture.ts new file mode 100644 index 0000000000..7274c72a45 --- /dev/null +++ b/dashboard/src/test/workTopologyMetricsFixture.ts @@ -0,0 +1,117 @@ +/** + * One canonical `ExecutionTopologyMetricsV1` fixture, shaped like the Rust + * projector's output and parsed with the generated schema by consumers so a + * hand-shaped object the daemon could never send cannot keep a test green. + */ + +const HORIZON = { + since_micros: 1_753_000_000_000_000, + until_micros: 1_753_003_600_000_000, +}; + +export interface TopologyMetricsCoverageSpec { + eligible: number | null; + observed: number; + completed: number; + censored: number; + unknown: number; + state: string; +} + +export function topologyMetricsCoverage(spec: Partial = {}) { + 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 interface TopologyMeasurementSpec { + metric: string; + value: number | null; + unit: string; + denominator: string; + dimensions: readonly { dimension: string; value: string }[]; + coverage?: Partial; + unavailable?: string; +} + +export function topologyMeasurement(spec: TopologyMeasurementSpec) { + const unavailable = spec.unavailable ?? null; + const coverage = topologyMetricsCoverage(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: coverage.eligible, + coverage, + 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?: Partial; + unavailable?: string | null; + }; + coverage?: Partial; +} + +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: topologyMetricsCoverage(spec.coverage ?? { observed: 9, completed: 9, state: 'known' }), + emission_coverage: { emitted: 9, delayed: 0, dropped: 0, sampled_events: 0 }, + github_stack_capability: + capability === undefined + ? { + capability: null, + standard_git_fallback_available: null, + other_forge_fallback_available: null, + coverage: topologyMetricsCoverage({ unknown: 1 }), + unavailable: 'no_eligible_evidence', + } + : { + capability: capability.capability, + standard_git_fallback_available: capability.standard_git_fallback_available, + other_forge_fallback_available: capability.other_forge_fallback_available, + coverage: topologyMetricsCoverage(capability.coverage), + unavailable: 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 d49932802d..e6c83c7c56 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,9 @@ function WorkProjectionView({ * reading deliberately does not restate. */ attemptList: WorkResult | undefined; topology: WorkResult | undefined; + /** The bounded accounting read behind the topology lens's integration and + * stack cards. */ + topologyMetrics: WorkResult | undefined; graph: WorkGraphReading; selected: string | null; onSelect: (taskId: string) => void; @@ -139,6 +149,7 @@ function WorkProjectionView({ snapshot={snapshot} attemptList={attemptList} topology={topology} + metrics={topologyMetrics} graph={graph} selected={selected} onSelect={onSelect} @@ -160,6 +171,9 @@ export function WorkPage() { // not on every visit to the page. const attempts = useWorkAttempts(projection === 'timeline' || projection === 'topology'); const topology = useWorkTopology(projection === 'topology'); + // The bounded 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 +266,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 18451ec548..60e2b7aa02 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,16 @@ export function WorkTopologyAccounting({ attemptList, topology, graph, + metrics, }: { attemptList: WorkResult | undefined; topology?: WorkResult | undefined; graph: WorkGraphReading; + /** The mounted `operation.work.topology_metrics` read behind the + * integration-outcome and stack-capability cards. */ + 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 c2d80e8f13..25029291fb 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,8 @@ export function WorkTopologyView({ attemptList: WorkResult | undefined; topology: WorkResult | undefined; graph: WorkGraphReading; + /** The bounded accounting read behind the integration and stack cards. */ + metrics: WorkResult | undefined; selected: string | null; onSelect: (taskId: string) => void; }) { @@ -87,7 +91,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 0000000000..02c54987b0 --- /dev/null +++ b/dashboard/src/workspaces/work/workAccountingMetrics.ts @@ -0,0 +1,311 @@ +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 two Plan 24 integration/stack cards, fed from the mounted + * `operation.work.topology_metrics` read. + * + * Work deliberately mounts no integration apply/review/stack mutation + * operation: Plan 24 keeps accepted integration lowered only through the + * Plan 36 native-integration family (typed preflight/approve/apply with + * durable receipts on its own CLI/MCP surfaces). What the Work workspace owns + * is the OBSERVED accounting of those receipts — the + * `work.integration.transition.observed.v1` and + * `work.github_stack_capability.observed.v1` events Plan 26 projects into + * `ExecutionTopologyMetricsV1`. These builders decode that projection's own + * cells and typed absences; nothing here derives a rate, sums a family, or + * substitutes a policy-carried dimension for measured evidence. + * + * The metrics read is a horizon aggregate and is deliberately NOT bound to + * the topology generation the structural cards join on: the Rust projector is + * explicit that the two share a name family and nothing else, and joining + * them would let a policy-carried dimension stand in for measured evidence. + */ + +/** The exact 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. Kept local so this + * module never invents a state the read did not report. */ +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}`; +} + +function coverageSentence(coverage: MetricCoverageV1): string { + return `${coverage.state} coverage · ${coverage.observed} observed · ${coverage.completed} completed`; +} + +/** The seven facets, decoded from one metric envelope's own coverage. */ +function metricsProvenance( + model: ExecutionTopologyMetricsV1, + coverage: MetricCoverageV1, + descriptorRevision: string, + 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: coverageSentence(coverage) }, + horizon: { available: true, value: horizonSentence(model) }, + descriptorRevision: { + available: true, + value: { kind: 'metric_descriptor', value: descriptorRevision }, + }, + anchors: { + 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', + }, + }; +} + +const INTEGRATION_MANDATE = 'observed native fast-forward/merge/cherry-pick outcomes'; + +/** + * Observed integration outcomes, cell by cell. + * + * Every row is one `work_merge_attempts_total` cell grouped by the + * projector's own integration kind × outcome dimensions. No cell is summed: + * the headline states the family's observed and eligible counts off the + * decoded coverage envelope, never a total this module added up. + */ +export function integrationOutcomesCard( + metrics: WorkResult | undefined, +): WorkAccountingCard { + const dimension: WorkAccountingDimension = 'integration_outcomes'; + const model = modelOf(metrics); + const absence = (measure: string) => + model === null + ? metricsAbsence(metrics, measure) + : ({ + available: false, + state: 'unknown', + detail: `the projection carried no ${measure}`, + } as const); + + const cells = + model?.measurements.filter( + (measurement) => measurement.value.metric === MERGE_ATTEMPTS_METRIC, + ) ?? []; + const dimensionalCells = cells.filter((measurement) => measurement.dimensions.length > 0); + const coverage = cells[0]?.value.coverage; + + const rows: WorkAccountingRow[] = dimensionalCells.map((measurement) => { + const label = measurement.dimensions + .map((cellDimension) => humanizeMetric(cellDimension.value)) + .join(' · '); + const key = measurement.dimensions + .map((cellDimension) => String(cellDimension.value)) + .join('_'); + return { + key, + label, + 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', + }, + }, + }; + }); + + const reading: WorkChannel = + model === null || coverage === undefined + ? absence('integration-outcome cells') + : dimensionalCells.length === 0 + ? (() => { + const empty = cells[0]; + return empty === undefined + ? absence('integration-outcome cells') + : cellAbsence(empty); + })() + : { + available: true, + value: `${coverage.observed} observed native integrations across ${dimensionalCells.length} kind/outcome ${dimensionalCells.length === 1 ? 'cell' : 'cells'} — counts are the projector's own cells, never summed here`, + }; + + return { + dimension, + title: accountingDimensionTitle(dimension), + mandate: INTEGRATION_MANDATE, + reading, + rows, + matrices: null, + contradictions: [], + provenance: + model === null || coverage === undefined + ? absentMetricsProvenance(metrics, 'observed native integrations') + : metricsProvenance( + model, + coverage, + cells[0]?.value.descriptor_revision ?? 'execution-topology-metrics.v1', + '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 lives in the headline rather + * than a metered row. A null field is stated as unobserved — the projector's + * `None` means no trustworthy observation exists in the horizon, which is a + * different fact from a fallback that is off. + * + * `WorkFallbackTopology` on the execution snapshot is the provider-EXECUTABLE + * fallback (codex_cli or disabled) and looks like the thing this card wants; + * it is never read into it. The generic-fallback figures here are the + * projection's own standard-git and other-forge observations. + */ +export function githubStackCapabilityCard( + metrics: WorkResult | undefined, +): WorkAccountingCard { + const dimension: WorkAccountingDimension = 'github_stack_capability'; + const model = modelOf(metrics); + const readingOf = (): WorkChannel => { + if (model === null) return metricsAbsence(metrics, 'the capability observation'); + const capability = model.github_stack_capability; + if (capability.capability == null) { + return { + 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')}`, + }; + } + const fallback = (value: boolean | null, name: string) => + value == null ? `${name} unobserved` : `${name} ${value ? 'available' : 'not available'}`; + return { + 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 { + dimension, + title: accountingDimensionTitle(dimension), + mandate: STACK_CAPABILITY_MANDATE, + reading: readingOf(), + // A capability state is not a countable figure, so this card carries no + // metered rows; the whole observation is the headline sentence above. + rows: [], + matrices: null, + contradictions: [], + provenance: + model === null + ? absentMetricsProvenance(metrics, 'capability observations') + : metricsProvenance( + model, + model.github_stack_capability.coverage, + 'execution-topology-metrics.v1', + 'capability observations', + ), + }; +} + +/** 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'), + }; +} diff --git a/dashboard/src/workspaces/work/workAccountingModel.ts b/dashboard/src/workspaces/work/workAccountingModel.ts index 9164339131..509b280352 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 da89138e50..88be9b7731 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,145 @@ 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 MERGE_CELLS = metricsOf({ + measurements: [ + topologyMeasurement({ + metric: 'work_merge_attempts_total', + value: 6, + unit: 'events', + denominator: 'observed_native_integrations', + dimensions: [ + { dimension: 'integration_kind', value: 'fast_forward' }, + { dimension: 'integration_outcome', value: 'succeeded' }, + ], + coverage: { eligible: 8, observed: 7, completed: 7, unknown: 1, state: 'known' }, + }), + topologyMeasurement({ + metric: 'work_merge_attempts_total', + value: 1, + unit: 'events', + denominator: 'observed_native_integrations', + dimensions: [ + { dimension: 'integration_kind', value: 'cherry_pick' }, + { dimension: 'integration_outcome', value: 'conflicted' }, + ], + coverage: { eligible: 8, observed: 7, completed: 7, unknown: 1, state: 'known' }, + }), + // 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: { eligible: 8, observed: 7, completed: 7, unknown: 1, state: '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('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 41aa475c62..4df7cd5066 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, @@ -44,17 +49,25 @@ import { * * Plan 26 owns eleven persisted execution-topology events and projects them * into `ExecutionTopologyMetricsV1` - * (`crates/tracedecay-application/src/execution_topology_metrics.rs`, whose + * (`crates/tracedecay-application/src/execution_topology_metrics/`, 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. + * its absences). That read model IS published — `operation.work.topology_metrics` + * is mounted at `/api/work/topology-metrics` and its contract is generated — + * and this ledger consumes its integration and stack families: + * + * integration outcomes the `work_merge_attempts_total` kind × outcome + * cells, decoded verbatim in + * `workAccountingMetrics.ts`; a typed-absent cell + * stays the projector's own absence. + * stack capability the model's `github_stack_capability` reading, a + * typed operational state rather than a count. * - * Three dimensions have a real, mounted source, and those are the cards this - * module adds to the landed lens: + * The remaining event-fed dimensions render as typed absences naming the + * event kind a reviewer can grep for: this lens does not decode their + * descriptors, and an absence stated is not a zero shown. + * + * 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 +126,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 +155,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), From b5bf05b6b67652a0937a1858b26431931363a3d3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 03:29:25 +0000 Subject: [PATCH 03/12] feat(dashboard): mount the workflow definition/run workspace Co-authored-by: Zack Jackson --- .../src/contract_schema.rs | 91 +- .../schemas/dashboard-contracts.schema.json | 1147 ++++++++++++++++- dashboard/src/app/channels.ts | 3 +- dashboard/src/app/routes.tsx | 13 +- dashboard/src/app/shell/NavRail.tsx | 4 + dashboard/src/app/workspaceRegistry.test.ts | 3 +- dashboard/src/contracts/generated.ts | 275 ++++ .../workflows/WorkflowsPage.dom.test.tsx | 294 +++++ .../workspaces/workflows/WorkflowsPage.tsx | 402 ++++++ .../workspaces/workflows/workflowQueries.ts | 115 ++ .../workspaces/workflows/workflowRoutes.ts | 95 ++ dashboard/stories/registry.ts | 8 + 12 files changed, 2385 insertions(+), 65 deletions(-) create mode 100644 dashboard/src/workspaces/workflows/WorkflowsPage.dom.test.tsx create mode 100644 dashboard/src/workspaces/workflows/WorkflowsPage.tsx create mode 100644 dashboard/src/workspaces/workflows/workflowQueries.ts create mode 100644 dashboard/src/workspaces/workflows/workflowRoutes.ts diff --git a/crates/tracedecay-dashboard-api/src/contract_schema.rs b/crates/tracedecay-dashboard-api/src/contract_schema.rs index f4ef397336..961aa72ab1 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,21 @@ 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 off + /// the mounted `/api/application/workflow` routes. Handoff issue/redeem + /// stay uncontracted for the dashboard because it never holds a bearer; + /// run start/pause/resume/cancel stay uncontracted because the browser + /// must not mint 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 +441,77 @@ 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}" + ); + } + + // The dashboard never holds a handoff bearer and never mints run + // fences or command ids, so those wire types stay uncontracted here. + 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/dashboard/codegen/schemas/dashboard-contracts.schema.json b/dashboard/codegen/schemas/dashboard-contracts.schema.json index 30294e475a..4bb7dcec48 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 69885da155..348c75d545 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 468992760a..138cef817f 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 4df774c686..0d9777dd56 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 bed8935e3e..0d48792c91 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 02110f0cb1..8669e70d19 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/workspaces/workflows/WorkflowsPage.dom.test.tsx b/dashboard/src/workspaces/workflows/WorkflowsPage.dom.test.tsx new file mode 100644 index 0000000000..ab3cce32fa --- /dev/null +++ b/dashboard/src/workspaces/workflows/WorkflowsPage.dom.test.tsx @@ -0,0 +1,294 @@ +/** + * The Workflows page over the mounted `/application/workflow` routes. + * + * The invariant under test is the same one every workspace carries: a refusal + * is never an empty registry, an empty registry is drawn only when the daemon + * actually answered one, and every rendered figure is a decoded generated + * contract rather than a browser-owned substitute. + */ +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 and refuses anything else, so a + * test that accidentally depends on another route 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('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 0000000000..9d6caecd60 --- /dev/null +++ b/dashboard/src/workspaces/workflows/WorkflowsPage.tsx @@ -0,0 +1,402 @@ +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. + * + * The definition/run consumer of the canonical `/application/workflow` routes: + * registered definition versions off `list_definitions`, per-version step + * tables, the three compare-and-swap lifecycle transitions, and run + * projections off `get_run`. Everything rendered here is a decoded generated + * contract; a refusal renders the daemon's own typed state, and the only + * empty registry drawn is one the daemon actually answered as empty. + * + * What this page deliberately does not do: it never issues or redeems a task + * handoff (the browser must not hold a bearer), and it never starts, pauses, + * resumes, or cancels a run (the browser must not mint fences, command ids, + * or provider admissions). Runs are observed here and controlled by their + * owning surfaces. + */ + +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 : ( + + )} + + +
+
+
+ ); +} + +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' ? ( + <> + {/* The daemon's own reason. An unavailable registry and an empty + * registry are different facts and must never render alike. */} + +

+ No definition list is drawn. This build reads the mounted Workflow routes and does + not infer their contents when they refuse. +

+ + ) : 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 · every row is one decoded `WorkflowStep`; operations are catalog operation + ids and are admitted against the executable catalog 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 ( +
+

+ Lifecycle transitions are compare-and-swaps against the disposition revision. No + disposition read is mounted, so the expected revision is entered here and the daemon + answers with the stored disposition or a typed conflict — a registered candidate starts + at revision 1. Activation additionally runs tool-catalog admission over every step + operation on the daemon. +

+
+ + {(['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 ( + +
+

+ One run's projection off `get_run`: status, sequence, and per-step states rebuilt + from the run's own event journal. Runs are started and controlled by their owning + surfaces; this panel observes them. +

+
{ + 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 0000000000..1c21b25388 --- /dev/null +++ b/dashboard/src/workspaces/workflows/workflowQueries.ts @@ -0,0 +1,115 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { + WorkflowDefinition, + WorkflowDefinitionDisposition, + WorkflowRunProjection, +} from '../../contracts/index.ts'; +import { scopeKey, scopedUrl, 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`), so a Workflow refusal is reported in exactly the + * words every other application refusal uses and an absent value can never + * render as an empty success. Scoping matches Work: `scopedUrl` rewrites to + * the project gateway when the scope bar names a project. + */ + +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 for an operator-named run id. The + * query is disabled until a run id is named, and a disabled query has no data + * — which the view reports as unasked, not as a run that does not exist. */ +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; + } + } +} + +/** + * One compare-and-swap lifecycle transition. The mutation resolves to the + * daemon's own `WorkResult` — the returned disposition on success, or the + * typed refusal (conflict, denial, catalog admission) verbatim — and the + * definitions list is re-read afterwards so the panel never carries a state + * the daemon did not answer. + */ +export function useWorkflowLifecycle() { + const scope = useScope((state) => state.scope); + const key = scopeKey(scope); + const client = useQueryClient(); + return useMutation, never, WorkflowLifecycleCommand>({ + mutationFn: (command) => { + 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 0000000000..e4ad3e0aea --- /dev/null +++ b/dashboard/src/workspaces/workflows/workflowRoutes.ts @@ -0,0 +1,95 @@ +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 canonical Workflow routes this dashboard calls or documents. + * + * Each one names an operation of the canonical `WorkflowOperation` descriptor + * (`crates/tracedecay-api/src/workflow.rs`): same operation id, same + * `/application/workflow/` path the catalog advertises + * (`workflow_executable_binding_registry`), reached through the dashboard's + * `/api/application` nest. They are written out rather than derived because + * there is no generated route table on the dashboard side, and a route + * invented here would be a request the daemon has never mounted. + * + * Declared is not the same as mounted-for-the-browser. Six of the sixteen + * Workflow operations are deliberately NOT declared here: + * + * handoff-issue / handoff-redeem the dashboard never holds a bearer + * token and must not grow a client that + * could redeem one. + * start-run / pause-run / resume-run the browser must not mint execution + * / cancel-run fences, command ids, or provider + * admissions; runs are started and + * controlled by their owning surfaces + * and observed here through `get-run`. + * + * Register, validate, get, and diff stay undeclared until the workspace grows + * a definition-authoring journey; a declared-but-uncalled route would be + * advertising the dashboard does not back. + */ + +/** Every registered definition version, newest data straight off the durable + * authority. The response is the daemon's own list; an empty array is a real + * empty registry, never a substitute for a refusal. */ +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 transitions, each a compare-and-swap against the + * disposition revision the caller last saw. A stale revision is a typed + * conflict, never a silent overwrite; 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: status, sequence, per-step states and receipts, + * 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 3eb844d7fa..04d685d1dc 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']; From b565cb67d57925a24fd9cea19f40e0ec0e7f0d1a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 03:41:41 +0000 Subject: [PATCH 04/12] docs(plans): record workflow admission and dashboard mount outcomes Co-authored-by: Zack Jackson --- docs/plans/tracedecay-v2/NEXT.md | 33 ++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/docs/plans/tracedecay-v2/NEXT.md b/docs/plans/tracedecay-v2/NEXT.md index 20912a94ab..0055db5af4 100644 --- a/docs/plans/tracedecay-v2/NEXT.md +++ b/docs/plans/tracedecay-v2/NEXT.md @@ -399,8 +399,37 @@ 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 + now runs tool-catalog semantic admission before the lifecycle transition is + journaled: `tracedecay_application::workflow_admission` resolves every step + operation against the canonical Work executable catalog and requires the + definition's `pinned_catalog_digest` to name the live executable catalog + digest; `WorkflowDefinitionService::admit_activation` is the one authority + the service and the daemon's journaled activate both run, and + `validate_definition` answers the same admission. Coordination tests cover + unknown-operation and foreign-catalog-pin denials before any journaled + transition, and the daemon journey asserts the mounted activate route + refuses an uncataloged candidate as a typed `invalid_request` (fixtures + that named the never-cataloged `operation.work.attempt_start` now name the + mounted `operation.work.start_attempt`). The dashboard gained the + fourteenth workspace, Workflows: definitions list, decoded step tables, + the three compare-and-swap lifecycle transitions, and `get_run` + projections over the mounted `/application/workflow` routes and newly + generated contracts; handoff and run-control wire types are asserted + absent from the dashboard contract catalog because the browser never holds + a bearer or mints fences/command ids. A19 investigation outcome: Work + mounts no integration apply/review/stack mutation operation and must not — + Plan 24 keeps accepted integration lowered only through the Plan 36 + native-integration family; the "advertised" Work integration ops exist + only in stale dated mount lists in Plans 24/11c. The Work workspace's + observed integration-outcome and GitHub-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; + undecoded dimensions keep truthful `unsupported` wording naming their + event family. ## Remaining work by lane From e4c8b516909719e7a68c6c7732ba03806f1e215d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 05:11:28 +0000 Subject: [PATCH 05/12] fix(dashboard-api): serve selected-project workflow reads Co-authored-by: Zack Jackson --- crates/tracedecay-api/src/workflow.rs | 41 +++++++++++++++ crates/tracedecay-dashboard-api/src/lib.rs | 61 +++++++++++++++++++--- 2 files changed, 96 insertions(+), 6 deletions(-) diff --git a/crates/tracedecay-api/src/workflow.rs b/crates/tracedecay-api/src/workflow.rs index 9db388b375..fd7c34c95c 100644 --- a/crates/tracedecay-api/src/workflow.rs +++ b/crates/tracedecay-api/src/workflow.rs @@ -122,6 +122,25 @@ impl WorkflowOperation { .find(|operation| operation.operation_key() == key) } + /// Whether the operation reads without producing a durable effect. + /// + /// Mirrors the catalog's effect class for each Workflow operation + /// (`workflow_executable_binding_registry`), and the parity is pinned by + /// `read_only_operations_mirror_the_catalog_effect_class` below so the two + /// cannot drift. The selected-project dashboard gateway admits exactly + /// this set by POST. + 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 +375,28 @@ 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-dashboard-api/src/lib.rs b/crates/tracedecay-dashboard-api/src/lib.rs index 4d91f14a58..dc3f3cdd3c 100644 --- a/crates/tracedecay-dashboard-api/src/lib.rs +++ b/crates/tracedecay-dashboard-api/src/lib.rs @@ -145,7 +145,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; @@ -1607,6 +1607,10 @@ async fn project_scoped_api_gateway( (application.dashboard_feedback_router, "feedback/") } SelectedProjectApplicationRead::Work => (application.dashboard_work_router, "work/"), + // Workflow reads answer from the selected project's own canonical + // application router: stripping `application/` leaves the + // `/workflow/{operation}` path that router mounts. + SelectedProjectApplicationRead::Workflow => (application.http_router, "application/"), }; let Some(operation) = tail.strip_prefix(family) else { return ( @@ -1675,6 +1679,7 @@ fn is_profile_owned_automation_skills_route(tail: &str) -> bool { enum SelectedProjectApplicationRead { Feedback, Work, + Workflow, } impl std::fmt::Display for SelectedProjectApplicationRead { @@ -1682,6 +1687,7 @@ impl std::fmt::Display for SelectedProjectApplicationRead { formatter.write_str(match self { Self::Feedback => "feedback", Self::Work => "Work", + Self::Workflow => "Workflow", }) } } @@ -1697,11 +1703,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) + } } } @@ -2898,5 +2913,39 @@ mod authority_tests { selected_project_application_read(&Method::POST, "feedback/status"), None ); + + // `list-definitions` is the canonical Workflow read the Workflows + // workspace issues under a selected project; naming it keeps the + // intent legible beside the derived loop below. + 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 { + // Lifecycle transitions, handoffs, and run control stay + // refused: a selected project is read-only through this + // gateway. + assert_eq!( + selected_project_application_read(&Method::POST, tail), + None, + "{tail} must not be answerable for a selected project" + ); + } + } } } From ed852d4c5acc5c49a1748ff1d23d494fff9c242f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 05:11:28 +0000 Subject: [PATCH 06/12] fix(dashboard): require a readable cell before an integration headline Co-authored-by: Zack Jackson --- .../workspaces/work/workAccountingMetrics.ts | 162 +++++++++++++----- .../work/workTopologyAccounting.test.ts | 100 +++++++++++ 2 files changed, 216 insertions(+), 46 deletions(-) diff --git a/dashboard/src/workspaces/work/workAccountingMetrics.ts b/dashboard/src/workspaces/work/workAccountingMetrics.ts index 02c54987b0..ce2d808587 100644 --- a/dashboard/src/workspaces/work/workAccountingMetrics.ts +++ b/dashboard/src/workspaces/work/workAccountingMetrics.ts @@ -94,11 +94,20 @@ function coverageSentence(coverage: MetricCoverageV1): string { return `${coverage.state} coverage · ${coverage.observed} observed · ${coverage.completed} completed`; } -/** The seven facets, decoded from one metric envelope's own coverage. */ +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 passed as a channel because not every reading carries + * one: a card must state that absence rather than invent a revision. */ function metricsProvenance( model: ExecutionTopologyMetricsV1, coverage: MetricCoverageV1, - descriptorRevision: string, + descriptorRevision: WorkAccountingProvenance['descriptorRevision'], population: string, ): WorkAccountingProvenance { return { @@ -131,16 +140,31 @@ function metricsProvenance( }, intervalCoverage: { available: true, value: coverageSentence(coverage) }, horizon: { available: true, value: horizonSentence(model) }, - descriptorRevision: { - available: true, - value: { kind: 'metric_descriptor', value: descriptorRevision }, - }, - anchors: { - 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', - }, + descriptorRevision, + anchors: ANCHORS_ABSENCE, + }; +} + +/** + * The facets for a model that answered but whose every cell is a typed + * absence (support-floor suppression wipes the suppressed cell's own coverage + * envelope, so its counts are not measurements to print). Each measured facet + * carries the projector's own reason; the horizon and descriptor revision are + * model-level facts suppression does not touch, so they 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, }; } @@ -159,21 +183,18 @@ export function integrationOutcomesCard( ): WorkAccountingCard { const dimension: WorkAccountingDimension = 'integration_outcomes'; const model = modelOf(metrics); - const absence = (measure: string) => - model === null - ? metricsAbsence(metrics, measure) - : ({ - available: false, - state: 'unknown', - detail: `the projection carried no ${measure}`, - } as const); const cells = model?.measurements.filter( (measurement) => measurement.value.metric === MERGE_ATTEMPTS_METRIC, ) ?? []; const dimensionalCells = cells.filter((measurement) => measurement.dimensions.length > 0); - const coverage = cells[0]?.value.coverage; + // A cell whose value the projector suppressed (support floor, coverage + // floor, no eligible evidence) is a typed absence, and its own coverage + // envelope was wiped along with the value. Only a readable cell may lend + // the card its headline and coverage authority. + const readableCells = dimensionalCells.filter((measurement) => measurement.value.value != null); + const authority = readableCells[0]; const rows: WorkAccountingRow[] = dimensionalCells.map((measurement) => { const label = measurement.dimensions @@ -199,38 +220,79 @@ export function integrationOutcomesCard( }; }); - const reading: WorkChannel = - model === null || coverage === undefined - ? absence('integration-outcome cells') - : dimensionalCells.length === 0 - ? (() => { - const empty = cells[0]; - return empty === undefined - ? absence('integration-outcome cells') - : cellAbsence(empty); - })() - : { - available: true, - value: `${coverage.observed} observed native integrations across ${dimensionalCells.length} kind/outcome ${dimensionalCells.length === 1 ? 'cell' : 'cells'} — counts are the projector's own cells, never summed here`, - }; + // The exact descriptor revision every merge cell is stamped with — + // suppression wipes a cell's value and coverage but never its revision, so + // this stays real whenever any cell exists at all. + 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 { + dimension, + title: accountingDimensionTitle(dimension), + mandate: INTEGRATION_MANDATE, + reading: metricsAbsence(metrics, 'integration-outcome cells'), + rows, + matrices: null, + contradictions: [], + provenance: absentMetricsProvenance(metrics, 'observed native integrations'), + }; + } + + if (authority === undefined) { + // Every cell is a typed absence (or none exists). The headline is the + // projector's own reason, never an available reading over zero readable + // cells. + const reason: WorkChannel = + dimensionalCells[0] !== undefined + ? cellAbsence(dimensionalCells[0]) + : cells[0] !== undefined + ? cellAbsence(cells[0]) + : { + available: false, + state: 'unknown', + detail: 'the projection carried no integration-outcome cells', + }; + return { + dimension, + title: accountingDimensionTitle(dimension), + mandate: INTEGRATION_MANDATE, + reading: reason, + rows, + matrices: null, + contradictions: [], + provenance: unreadableCellProvenance(model, reason, descriptorRevision), + }; + } + const coverage = authority.value.coverage; + const suppressed = dimensionalCells.length - readableCells.length; return { dimension, title: accountingDimensionTitle(dimension), mandate: INTEGRATION_MANDATE, - reading, + reading: { + available: true, + value: `${coverage.observed} observed native integrations across ${readableCells.length} readable kind/outcome ${readableCells.length === 1 ? 'cell' : 'cells'}${suppressed > 0 ? ` · ${suppressed} ${suppressed === 1 ? 'cell stays a' : 'cells stay'} typed ${suppressed === 1 ? 'absence' : 'absences'}` : ''} — counts are the projector's own cells, never summed here`, + }, rows, matrices: null, contradictions: [], - provenance: - model === null || coverage === undefined - ? absentMetricsProvenance(metrics, 'observed native integrations') - : metricsProvenance( - model, - coverage, - cells[0]?.value.descriptor_revision ?? 'execution-topology-metrics.v1', - 'observed native integrations', - ), + provenance: metricsProvenance( + model, + coverage, + descriptorRevision, + 'observed native integrations', + ), }; } @@ -288,7 +350,15 @@ export function githubStackCapabilityCard( : metricsProvenance( model, model.github_stack_capability.coverage, - 'execution-topology-metrics.v1', + { + // The capability reading is a typed operational state on the + // model envelope, not a descriptor cell; it carries no metric + // descriptor revision and this card does not invent one. + 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/workTopologyAccounting.test.ts b/dashboard/src/workspaces/work/workTopologyAccounting.test.ts index 88be9b7731..8c83ee5365 100644 --- a/dashboard/src/workspaces/work/workTopologyAccounting.test.ts +++ b/dashboard/src/workspaces/work/workTopologyAccounting.test.ts @@ -685,6 +685,106 @@ describe('observed integration outcomes', () => { expect(revision.value.value).toBe('execution-topology-metrics.v1'); }); + it('propagates the typed absence when the support floor suppresses every cell', () => { + // Support-floor suppression publishes the cell with a null value, a + // `support_floor_unmet` reason, and a wiped coverage envelope. A card + // over nothing but suppressed cells must wear that reason — never an + // available "0 observed across N cells" headline read off a wiped + // envelope. + const suppressed = metricsOf({ + measurements: [ + topologyMeasurement({ + metric: 'work_merge_attempts_total', + value: null, + unit: 'events', + denominator: 'observed_native_integrations', + dimensions: [ + { dimension: 'integration_kind', value: 'fast_forward' }, + { dimension: 'integration_outcome', value: 'succeeded' }, + ], + coverage: { eligible: null, observed: 0, unknown: 1, state: 'unknown' }, + unavailable: 'support_floor_unmet', + }), + topologyMeasurement({ + metric: 'work_merge_attempts_total', + value: null, + unit: 'events', + denominator: 'observed_native_integrations', + dimensions: [ + { dimension: 'integration_kind', value: 'cherry_pick' }, + { dimension: 'integration_outcome', value: 'conflicted' }, + ], + coverage: { eligible: null, observed: 0, unknown: 1, state: 'unknown' }, + unavailable: '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'); + // The rows stay, each wearing its own suppression. + expect(card.rows).toHaveLength(2); + for (const row of card.rows) { + expect(row.channel.available, row.key).toBe(false); + } + // The wiped coverage envelope is not presented as a measurement: the + // counted facets carry the same typed reason, while 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: [ + topologyMeasurement({ + metric: 'work_merge_attempts_total', + value: 6, + unit: 'events', + denominator: 'observed_native_integrations', + dimensions: [ + { dimension: 'integration_kind', value: 'fast_forward' }, + { dimension: 'integration_outcome', value: 'succeeded' }, + ], + coverage: { eligible: 8, observed: 7, completed: 7, unknown: 1, state: 'known' }, + }), + topologyMeasurement({ + metric: 'work_merge_attempts_total', + value: null, + unit: 'events', + denominator: 'observed_native_integrations', + dimensions: [ + { dimension: 'integration_kind', value: 'cherry_pick' }, + { dimension: 'integration_outcome', value: 'conflicted' }, + ], + coverage: { eligible: null, observed: 0, unknown: 1, state: 'unknown' }, + unavailable: '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'); + // The headline coverage comes from a readable cell's envelope, never + // from 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: [ From 997d897342643aad0e780f68638f89e26c07304c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 05:11:28 +0000 Subject: [PATCH 07/12] fix(daemon): report an uncomposable catalog as unavailable on activate Co-authored-by: Zack Jackson --- src/daemon/service/invocation/work/workflow_run_control.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/daemon/service/invocation/work/workflow_run_control.rs b/src/daemon/service/invocation/work/workflow_run_control.rs index ab311c95c3..99e4d60218 100644 --- a/src/daemon/service/invocation/work/workflow_run_control.rs +++ b/src/daemon/service/invocation/work/workflow_run_control.rs @@ -329,6 +329,12 @@ 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 From fe230dea09571b919662d409fe11b5cf3ad94d3e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 05:11:28 +0000 Subject: [PATCH 08/12] fix(dashboard): reset workflow lifecycle controls per definition Co-authored-by: Zack Jackson --- .../workflows/WorkflowsPage.dom.test.tsx | 40 +++++++++++++++++++ .../workspaces/workflows/WorkflowsPage.tsx | 6 ++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/dashboard/src/workspaces/workflows/WorkflowsPage.dom.test.tsx b/dashboard/src/workspaces/workflows/WorkflowsPage.dom.test.tsx index ab3cce32fa..799b918902 100644 --- a/dashboard/src/workspaces/workflows/WorkflowsPage.dom.test.tsx +++ b/dashboard/src/workspaces/workflows/WorkflowsPage.dom.test.tsx @@ -186,6 +186,46 @@ describe('the Workflows page over mounted routes', () => { }); }); + 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(); + + // Switching to another definition must not carry the first definition's + // transition result or revision draft under the new heading. + 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('renders a lifecycle conflict verbatim rather than pretending the transition landed', async () => { serve((url) => { if (url.includes('/application/workflow/list-definitions')) { diff --git a/dashboard/src/workspaces/workflows/WorkflowsPage.tsx b/dashboard/src/workspaces/workflows/WorkflowsPage.tsx index 9d6caecd60..2e928c404c 100644 --- a/dashboard/src/workspaces/workflows/WorkflowsPage.tsx +++ b/dashboard/src/workspaces/workflows/WorkflowsPage.tsx @@ -75,7 +75,11 @@ export function WorkflowsPage() { /> {selectedDefinition === null ? null : ( - + // Keyed by the selected identity so the lifecycle controls — + // revision draft and the last transition's result — reset when + // the operator switches definitions instead of carrying one + // definition's state under another's heading. + )} From 6bc365046fd178b7ad631d5b7eb8d4069e2900e3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 05:22:23 +0000 Subject: [PATCH 09/12] refactor(workflow): deslop the workflow mount Co-authored-by: Zack Jackson --- crates/tracedecay-api/src/workflow.rs | 8 +- .../src/workflow_admission.rs | 36 +- .../src/workflow_coordination.rs | 12 +- .../tests/workflow_coordination.rs | 10 +- .../src/contract_schema.rs | 10 +- crates/tracedecay-dashboard-api/src/lib.rs | 12 +- .../src/test/workTopologyMetricsFixture.ts | 67 ++-- dashboard/src/workspaces/work/WorkPage.tsx | 6 +- .../work/views/WorkTopologyAccounting.tsx | 2 - .../work/views/WorkTopologyView.tsx | 1 - .../workspaces/work/workAccountingMetrics.ts | 327 ++++++++---------- .../work/workTopologyAccounting.test.ts | 112 ++---- .../workspaces/work/workTopologyAccounting.ts | 25 +- .../workflows/WorkflowsPage.dom.test.tsx | 16 +- .../workspaces/workflows/WorkflowsPage.tsx | 50 +-- .../workspaces/workflows/workflowQueries.ts | 23 +- .../workspaces/workflows/workflowRoutes.ts | 45 +-- docs/plans/tracedecay-v2/NEXT.md | 41 +-- .../invocation/work/workflow_dispatch.rs | 8 +- .../advanced_workflow_journey_test.rs | 5 +- 20 files changed, 292 insertions(+), 524 deletions(-) diff --git a/crates/tracedecay-api/src/workflow.rs b/crates/tracedecay-api/src/workflow.rs index fd7c34c95c..e24ba7f1f5 100644 --- a/crates/tracedecay-api/src/workflow.rs +++ b/crates/tracedecay-api/src/workflow.rs @@ -123,12 +123,8 @@ impl WorkflowOperation { } /// Whether the operation reads without producing a durable effect. - /// - /// Mirrors the catalog's effect class for each Workflow operation - /// (`workflow_executable_binding_registry`), and the parity is pinned by - /// `read_only_operations_mirror_the_catalog_effect_class` below so the two - /// cannot drift. The selected-project dashboard gateway admits exactly - /// this set by POST. + /// 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, diff --git a/crates/tracedecay-application/src/workflow_admission.rs b/crates/tracedecay-application/src/workflow_admission.rs index e0bc3c6f64..18419c43d3 100644 --- a/crates/tracedecay-application/src/workflow_admission.rs +++ b/crates/tracedecay-application/src/workflow_admission.rs @@ -1,23 +1,13 @@ //! Tool-catalog semantic admission for workflow definitions. //! -//! Structural validation ([`tracedecay_domain::WorkflowDefinition::validate`]) -//! proves the DAG shape; it says nothing about whether a step's operation is -//! real. Plan 32 requires that "unknown operations, cycles, dangling -//! references, incompatible schemas, unbounded fan-out, privilege expansion, -//! unsupported effects, or recursive generic execution reject before -//! activation", so activation additionally admits every step operation -//! against the canonical Work executable catalog — the registry whose -//! operations workflow fan-out actually lowers steps into -//! ([`crate::prepare_workflow_fan_out`] copies `step.operation` onto the -//! durable plan, and the daemon starts the child Work attempts under it). -//! -//! The schema and capability halves of the check are carried by the catalog -//! digest pin: [`crate::work_executable_catalog_digest`] hashes the complete -//! registry, including every operation's capability manifest and request and -//! result schema authorities, so a definition whose `pinned_catalog_digest` -//! names the live digest was authored against exactly the schemas and -//! capability contracts this build executes. A stale pin is a typed denial, -//! never a silent re-pin. +//! 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}; @@ -75,12 +65,10 @@ impl Display for WorkflowCatalogAdmissionError { impl std::error::Error for WorkflowCatalogAdmissionError {} /// Admit every step operation of one workflow definition against the -/// canonical Work executable catalog. -/// -/// Admission holds exactly when the definition pins the live executable -/// catalog digest and every step operation resolves to an available -/// executable binding in that catalog. The first violation is returned as a -/// typed denial naming the offending step and operation. +/// 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 fn admit_workflow_definition_operations( definition: &WorkflowDefinition, ) -> Result<(), WorkflowCatalogAdmissionError> { diff --git a/crates/tracedecay-application/src/workflow_coordination.rs b/crates/tracedecay-application/src/workflow_coordination.rs index d82fde285f..7d79aea63b 100644 --- a/crates/tracedecay-application/src/workflow_coordination.rs +++ b/crates/tracedecay-application/src/workflow_coordination.rs @@ -420,9 +420,8 @@ where } } - /// Validation is the preflight for activation and answers exactly what - /// activation would decide: structural shape plus tool-catalog semantic - /// admission of every step operation. + /// The preflight for activation: structural shape plus tool-catalog + /// semantic admission of every step operation. pub fn validate( &self, definition: WorkflowDefinition, @@ -465,10 +464,9 @@ where } /// Admission every activation must clear before its lifecycle transition - /// is journaled: the stored payload is structurally revalidated and every - /// step operation is admitted against the tool catalog. This is the one - /// authority both activation paths — this service and the daemon's - /// journaled effect — run, so they cannot drift. + /// 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, diff --git a/crates/tracedecay-application/tests/workflow_coordination.rs b/crates/tracedecay-application/tests/workflow_coordination.rs index 709028fed0..54937fcbdd 100644 --- a/crates/tracedecay-application/tests/workflow_coordination.rs +++ b/crates/tracedecay-application/tests/workflow_coordination.rs @@ -63,8 +63,7 @@ fn workflow_context( .unwrap() } -/// A canonical mounted Work operation, so a fixture definition clears catalog -/// admission unless a test deliberately names an unknown one. +/// A mounted Work operation, so fixtures clear catalog admission. const MOUNTED_OPERATION: &str = "operation.work.start_attempt"; fn definition(version: u64) -> WorkflowDefinition { @@ -974,9 +973,7 @@ fn activation_rejects_a_step_operation_the_catalog_does_not_mount() { .unwrap(); let definition_id = registered.definition_id().clone(); - // Registration stays lenient — Plan 32 rejects "before activation" — so - // the unknown operation must be refused by validate and activate, not by - // the candidate insert above. + // Registration stays lenient; Plan 32 rejects "before activation". let denial = service.validate(registered.clone()).unwrap_err(); let WorkflowCoordinationError::CatalogAdmissionDenied( WorkflowCatalogAdmissionError::UnknownOperation { step_id, operation }, @@ -999,8 +996,7 @@ fn activation_rejects_a_step_operation_the_catalog_does_not_mount() { ) ); - // The denial happens before the lifecycle authority: the disposition - // stays candidate and no transition history is appended. + // The denial precedes the lifecycle authority. assert_eq!( service.disposition(&definition_id, 1).unwrap().state, WorkflowDefinitionLifecycleState::Candidate diff --git a/crates/tracedecay-dashboard-api/src/contract_schema.rs b/crates/tracedecay-dashboard-api/src/contract_schema.rs index 961aa72ab1..88e44c2ede 100644 --- a/crates/tracedecay-dashboard-api/src/contract_schema.rs +++ b/crates/tracedecay-dashboard-api/src/contract_schema.rs @@ -177,11 +177,9 @@ 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 off - /// the mounted `/api/application/workflow` routes. Handoff issue/redeem - /// stay uncontracted for the dashboard because it never holds a bearer; - /// run start/pause/resume/cancel stay uncontracted because the browser - /// must not mint fences, command ids, or provider admissions. + /// 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, @@ -495,8 +493,6 @@ mod tests { ); } - // The dashboard never holds a handoff bearer and never mints run - // fences or command ids, so those wire types stay uncontracted here. for excluded in [ "TaskHandoffIssueRequest", "TaskHandoffRedeemRequest", diff --git a/crates/tracedecay-dashboard-api/src/lib.rs b/crates/tracedecay-dashboard-api/src/lib.rs index dc3f3cdd3c..44aefa7de4 100644 --- a/crates/tracedecay-dashboard-api/src/lib.rs +++ b/crates/tracedecay-dashboard-api/src/lib.rs @@ -1607,9 +1607,8 @@ async fn project_scoped_api_gateway( (application.dashboard_feedback_router, "feedback/") } SelectedProjectApplicationRead::Work => (application.dashboard_work_router, "work/"), - // Workflow reads answer from the selected project's own canonical - // application router: stripping `application/` leaves the - // `/workflow/{operation}` path that router mounts. + // 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 { @@ -2914,9 +2913,6 @@ mod authority_tests { None ); - // `list-definitions` is the canonical Workflow read the Workflows - // workspace issues under a selected project; naming it keeps the - // intent legible beside the derived loop below. assert_eq!( selected_project_application_read( &Method::POST, @@ -2937,9 +2933,7 @@ mod authority_tests { ); assert_eq!(selected_project_application_read(&Method::GET, tail), None); } else { - // Lifecycle transitions, handoffs, and run control stay - // refused: a selected project is read-only through this - // gateway. + // Mutations stay refused: the gateway is read-only. assert_eq!( selected_project_application_read(&Method::POST, tail), None, diff --git a/dashboard/src/test/workTopologyMetricsFixture.ts b/dashboard/src/test/workTopologyMetricsFixture.ts index 7274c72a45..4f93bb90ca 100644 --- a/dashboard/src/test/workTopologyMetricsFixture.ts +++ b/dashboard/src/test/workTopologyMetricsFixture.ts @@ -1,24 +1,21 @@ -/** - * One canonical `ExecutionTopologyMetricsV1` fixture, shaped like the Rust - * projector's output and parsed with the generated schema by consumers so a - * hand-shaped object the daemon could never send cannot keep a test green. - */ +/** `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, }; -export interface TopologyMetricsCoverageSpec { - eligible: number | null; - observed: number; - completed: number; - censored: number; - unknown: number; - state: string; +interface CoverageSpec { + eligible?: number | null; + observed?: number; + completed?: number; + censored?: number; + unknown?: number; + state?: string; } -export function topologyMetricsCoverage(spec: Partial = {}) { +function coverage(spec: CoverageSpec = {}) { return { eligible: spec.eligible ?? null, observed: spec.observed ?? 0, @@ -30,19 +27,17 @@ export function topologyMetricsCoverage(spec: Partial; + coverage?: CoverageSpec; unavailable?: string; -} - -export function topologyMeasurement(spec: TopologyMeasurementSpec) { +}) { const unavailable = spec.unavailable ?? null; - const coverage = topologyMetricsCoverage(spec.coverage); + const cellCoverage = coverage(spec.coverage); return { dimensions: spec.dimensions, unavailable, @@ -52,8 +47,8 @@ export function topologyMeasurement(spec: TopologyMeasurementSpec) { value: spec.value, unit: spec.unit, denominator: spec.denominator, - denominator_value: coverage.eligible, - coverage, + denominator_value: cellCoverage.eligible, + coverage: cellCoverage, evidence_class: 'measurement', provenance: { source: 'observability_envelope', @@ -79,10 +74,10 @@ export interface TopologyMetricsSpec { capability: string | null; standard_git_fallback_available: boolean | null; other_forge_fallback_available: boolean | null; - coverage?: Partial; + coverage?: CoverageSpec; unavailable?: string | null; }; - coverage?: Partial; + coverage?: CoverageSpec; } export function topologyMetricsModel(spec: TopologyMetricsSpec = {}) { @@ -93,24 +88,16 @@ export function topologyMetricsModel(spec: TopologyMetricsSpec = {}) { watermark: 'observability:topology:41', observed_at_micros: HORIZON.until_micros, current: true, - coverage: topologyMetricsCoverage(spec.coverage ?? { observed: 9, completed: 9, state: 'known' }), + 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 === undefined - ? { - capability: null, - standard_git_fallback_available: null, - other_forge_fallback_available: null, - coverage: topologyMetricsCoverage({ unknown: 1 }), - unavailable: 'no_eligible_evidence', - } - : { - capability: capability.capability, - standard_git_fallback_available: capability.standard_git_fallback_available, - other_forge_fallback_available: capability.other_forge_fallback_available, - coverage: topologyMetricsCoverage(capability.coverage), - unavailable: capability.unavailable ?? null, - }, + 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 e6c83c7c56..52853af0a5 100644 --- a/dashboard/src/workspaces/work/WorkPage.tsx +++ b/dashboard/src/workspaces/work/WorkPage.tsx @@ -106,8 +106,6 @@ function WorkProjectionView({ * reading deliberately does not restate. */ attemptList: WorkResult | undefined; topology: WorkResult | undefined; - /** The bounded accounting read behind the topology lens's integration and - * stack cards. */ topologyMetrics: WorkResult | undefined; graph: WorkGraphReading; selected: string | null; @@ -171,8 +169,8 @@ export function WorkPage() { // not on every visit to the page. const attempts = useWorkAttempts(projection === 'timeline' || projection === 'topology'); const topology = useWorkTopology(projection === 'topology'); - // The bounded accounting read behind the topology lens's integration and - // stack cards; issued only when that lens is the camera. + // 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 diff --git a/dashboard/src/workspaces/work/views/WorkTopologyAccounting.tsx b/dashboard/src/workspaces/work/views/WorkTopologyAccounting.tsx index 60e2b7aa02..527f7c6f06 100644 --- a/dashboard/src/workspaces/work/views/WorkTopologyAccounting.tsx +++ b/dashboard/src/workspaces/work/views/WorkTopologyAccounting.tsx @@ -56,8 +56,6 @@ export function WorkTopologyAccounting({ attemptList: WorkResult | undefined; topology?: WorkResult | undefined; graph: WorkGraphReading; - /** The mounted `operation.work.topology_metrics` read behind the - * integration-outcome and stack-capability cards. */ metrics?: WorkResult | undefined; }) { const reading = workTopologyAccounting(attemptList, graph, topology, metrics); diff --git a/dashboard/src/workspaces/work/views/WorkTopologyView.tsx b/dashboard/src/workspaces/work/views/WorkTopologyView.tsx index 25029291fb..eb4ba69d08 100644 --- a/dashboard/src/workspaces/work/views/WorkTopologyView.tsx +++ b/dashboard/src/workspaces/work/views/WorkTopologyView.tsx @@ -66,7 +66,6 @@ export function WorkTopologyView({ attemptList: WorkResult | undefined; topology: WorkResult | undefined; graph: WorkGraphReading; - /** The bounded accounting read behind the integration and stack cards. */ metrics: WorkResult | undefined; selected: string | null; onSelect: (taskId: string) => void; diff --git a/dashboard/src/workspaces/work/workAccountingMetrics.ts b/dashboard/src/workspaces/work/workAccountingMetrics.ts index ce2d808587..1ed918ef09 100644 --- a/dashboard/src/workspaces/work/workAccountingMetrics.ts +++ b/dashboard/src/workspaces/work/workAccountingMetrics.ts @@ -16,31 +16,20 @@ import { } from './workAccountingModel.ts'; /** - * The two Plan 24 integration/stack cards, fed from the mounted + * The Plan 24 integration/stack cards, fed from the mounted * `operation.work.topology_metrics` read. * - * Work deliberately mounts no integration apply/review/stack mutation - * operation: Plan 24 keeps accepted integration lowered only through the - * Plan 36 native-integration family (typed preflight/approve/apply with - * durable receipts on its own CLI/MCP surfaces). What the Work workspace owns - * is the OBSERVED accounting of those receipts — the - * `work.integration.transition.observed.v1` and - * `work.github_stack_capability.observed.v1` events Plan 26 projects into - * `ExecutionTopologyMetricsV1`. These builders decode that projection's own - * cells and typed absences; nothing here derives a rate, sums a family, or - * substitutes a policy-carried dimension for measured evidence. - * - * The metrics read is a horizon aggregate and is deliberately NOT bound to - * the topology generation the structural cards join on: the Rust projector is - * explicit that the two share a name family and nothing else, and joining - * them would let a policy-carried dimension stand in for measured evidence. + * 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 exact Plan 26 descriptor the integration-outcome cells carry. */ +/** 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. Kept local so this - * module never invents a state the read did not report. */ +/** The metrics read's own reason, phrased for a channel. */ function metricsAbsence( metrics: WorkResult | undefined, measure: string, @@ -90,10 +79,6 @@ function horizonSentence(model: ExecutionTopologyMetricsV1): string { return `${stamp(model.horizon.since_micros)} → ${stamp(model.horizon.until_micros)} · watermark ${model.watermark}`; } -function coverageSentence(coverage: MetricCoverageV1): string { - return `${coverage.state} coverage · ${coverage.observed} observed · ${coverage.completed} completed`; -} - const ANCHORS_ABSENCE: WorkChannel = { available: false, state: 'redacted', @@ -102,8 +87,8 @@ const ANCHORS_ABSENCE: WorkChannel = { }; /** The seven facets, decoded from one metric envelope's own coverage. The - * descriptor revision is passed as a channel because not every reading carries - * one: a card must state that absence rather than invent a revision. */ + * descriptor revision is a channel: a reading without one states the absence + * rather than inventing a revision. */ function metricsProvenance( model: ExecutionTopologyMetricsV1, coverage: MetricCoverageV1, @@ -138,20 +123,19 @@ function metricsProvenance( note: 'censored and unknown counts are the projector\u2019s own, decoded from the metric envelope', }, }, - intervalCoverage: { available: true, value: coverageSentence(coverage) }, + intervalCoverage: { + available: true, + value: `${coverage.state} coverage · ${coverage.observed} observed · ${coverage.completed} completed`, + }, horizon: { available: true, value: horizonSentence(model) }, descriptorRevision, anchors: ANCHORS_ABSENCE, }; } -/** - * The facets for a model that answered but whose every cell is a typed - * absence (support-floor suppression wipes the suppressed cell's own coverage - * envelope, so its counts are not measurements to print). Each measured facet - * carries the projector's own reason; the horizon and descriptor revision are - * model-level facts suppression does not touch, so they stay real. - */ +/** 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, @@ -168,15 +152,48 @@ function unreadableCellProvenance( }; } +/** 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, cell by cell. - * - * Every row is one `work_merge_attempts_total` cell grouped by the - * projector's own integration kind × outcome dimensions. No cell is summed: - * the headline states the family's observed and eligible counts off the - * decoded coverage envelope, never a total this module added up. + * 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, @@ -189,40 +206,27 @@ export function integrationOutcomesCard( (measurement) => measurement.value.metric === MERGE_ATTEMPTS_METRIC, ) ?? []; const dimensionalCells = cells.filter((measurement) => measurement.dimensions.length > 0); - // A cell whose value the projector suppressed (support floor, coverage - // floor, no eligible evidence) is a typed absence, and its own coverage - // envelope was wiped along with the value. Only a readable cell may lend - // the card its headline and coverage authority. const readableCells = dimensionalCells.filter((measurement) => measurement.value.value != null); const authority = readableCells[0]; - const rows: WorkAccountingRow[] = dimensionalCells.map((measurement) => { - const label = measurement.dimensions - .map((cellDimension) => humanizeMetric(cellDimension.value)) - .join(' · '); - const key = measurement.dimensions - .map((cellDimension) => String(cellDimension.value)) - .join('_'); - return { - key, - label, - 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', - }, + 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', }, - }; - }); + }, + })); - // The exact descriptor revision every merge cell is stamped with — - // suppression wipes a cell's value and coverage but never its revision, so - // this stays real whenever any cell exists at all. + // Suppression never wipes a cell's descriptor revision, so it stays real + // whenever any cell exists. const descriptorRevision: WorkAccountingProvenance['descriptorRevision'] = cells[0] === undefined ? { @@ -236,146 +240,107 @@ export function integrationOutcomesCard( }; if (model === null) { - return { + return card( dimension, - title: accountingDimensionTitle(dimension), - mandate: INTEGRATION_MANDATE, - reading: metricsAbsence(metrics, 'integration-outcome cells'), + INTEGRATION_MANDATE, + metricsAbsence(metrics, 'integration-outcome cells'), rows, - matrices: null, - contradictions: [], - provenance: absentMetricsProvenance(metrics, 'observed native integrations'), - }; + absentMetricsProvenance(metrics, 'observed native integrations'), + ); } if (authority === undefined) { - // Every cell is a typed absence (or none exists). The headline is the - // projector's own reason, never an available reading over zero readable - // cells. + const first = dimensionalCells[0] ?? cells[0]; const reason: WorkChannel = - dimensionalCells[0] !== undefined - ? cellAbsence(dimensionalCells[0]) - : cells[0] !== undefined - ? cellAbsence(cells[0]) - : { - available: false, - state: 'unknown', - detail: 'the projection carried no integration-outcome cells', - }; - return { + first !== undefined + ? cellAbsence(first) + : { + available: false, + state: 'unknown', + detail: 'the projection carried no integration-outcome cells', + }; + return card( dimension, - title: accountingDimensionTitle(dimension), - mandate: INTEGRATION_MANDATE, - reading: reason, + INTEGRATION_MANDATE, + reason, rows, - matrices: null, - contradictions: [], - provenance: unreadableCellProvenance(model, reason, descriptorRevision), - }; + unreadableCellProvenance(model, reason, descriptorRevision), + ); } const coverage = authority.value.coverage; const suppressed = dimensionalCells.length - readableCells.length; - return { + const suppressedNote = + suppressed === 0 + ? '' + : ` · ${suppressed} ${suppressed === 1 ? 'cell stays a typed absence' : 'cells stay typed absences'}`; + return card( dimension, - title: accountingDimensionTitle(dimension), - mandate: INTEGRATION_MANDATE, - reading: { + INTEGRATION_MANDATE, + { available: true, - value: `${coverage.observed} observed native integrations across ${readableCells.length} readable kind/outcome ${readableCells.length === 1 ? 'cell' : 'cells'}${suppressed > 0 ? ` · ${suppressed} ${suppressed === 1 ? 'cell stays a' : 'cells stay'} typed ${suppressed === 1 ? 'absence' : 'absences'}` : ''} — counts are the projector's own cells, never summed here`, + 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, - matrices: null, - contradictions: [], - provenance: metricsProvenance( - model, - coverage, - descriptorRevision, - 'observed native integrations', - ), - }; + 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 lives in the headline rather - * than a metered row. A null field is stated as unobserved — the projector's - * `None` means no trustworthy observation exists in the horizon, which is a - * different fact from a fallback that is off. - * - * `WorkFallbackTopology` on the execution snapshot is the provider-EXECUTABLE - * fallback (codex_cli or disabled) and looks like the thing this card wants; - * it is never read into it. The generic-fallback figures here are the - * projection's own standard-git and other-forge observations. + * 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); - const readingOf = (): WorkChannel => { - if (model === null) return metricsAbsence(metrics, 'the capability observation'); - const capability = model.github_stack_capability; - if (capability.capability == null) { - return { - 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')}`, - }; - } - const fallback = (value: boolean | null, name: string) => - value == null ? `${name} unobserved` : `${name} ${value ? 'available' : 'not available'}`; - return { - 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 { - dimension, - title: accountingDimensionTitle(dimension), - mandate: STACK_CAPABILITY_MANDATE, - reading: readingOf(), - // A capability state is not a countable figure, so this card carries no - // metered rows; the whole observation is the headline sentence above. - rows: [], - matrices: null, - contradictions: [], - provenance: - model === null - ? absentMetricsProvenance(metrics, 'capability observations') - : metricsProvenance( - model, - model.github_stack_capability.coverage, - { - // The capability reading is a typed operational state on the - // model envelope, not a descriptor cell; it carries no metric - // descriptor revision and this card does not invent one. - available: false, - state: 'unknown', - detail: - 'the GitHub stack capability reading is a model-level typed state and carries no metric descriptor revision', - }, - 'capability observations', - ), - }; -} + if (model === null) { + return card( + dimension, + STACK_CAPABILITY_MANDATE, + metricsAbsence(metrics, 'the capability observation'), + [], + absentMetricsProvenance(metrics, 'capability observations'), + ); + } -/** 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'), - }; + 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/workTopologyAccounting.test.ts b/dashboard/src/workspaces/work/workTopologyAccounting.test.ts index 8c83ee5365..da2f8a8e7c 100644 --- a/dashboard/src/workspaces/work/workTopologyAccounting.test.ts +++ b/dashboard/src/workspaces/work/workTopologyAccounting.test.ts @@ -622,30 +622,30 @@ describe('the conflict confusion matrices', () => { }); 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: [ - topologyMeasurement({ - metric: 'work_merge_attempts_total', - value: 6, - unit: 'events', - denominator: 'observed_native_integrations', - dimensions: [ - { dimension: 'integration_kind', value: 'fast_forward' }, - { dimension: 'integration_outcome', value: 'succeeded' }, - ], - coverage: { eligible: 8, observed: 7, completed: 7, unknown: 1, state: 'known' }, - }), - topologyMeasurement({ - metric: 'work_merge_attempts_total', - value: 1, - unit: 'events', - denominator: 'observed_native_integrations', - dimensions: [ - { dimension: 'integration_kind', value: 'cherry_pick' }, - { dimension: 'integration_outcome', value: 'conflicted' }, - ], - coverage: { eligible: 8, observed: 7, completed: 7, unknown: 1, state: 'known' }, - }), + 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', @@ -653,7 +653,7 @@ describe('observed integration outcomes', () => { unit: 'ratio', denominator: 'observed_native_integrations', dimensions: [{ dimension: 'integration_kind', value: 'fast_forward' }], - coverage: { eligible: 8, observed: 7, completed: 7, unknown: 1, state: 'known' }, + coverage: KNOWN, }), ], }); @@ -686,37 +686,12 @@ describe('observed integration outcomes', () => { }); it('propagates the typed absence when the support floor suppresses every cell', () => { - // Support-floor suppression publishes the cell with a null value, a - // `support_floor_unmet` reason, and a wiped coverage envelope. A card - // over nothing but suppressed cells must wear that reason — never an - // available "0 observed across N cells" headline read off a wiped - // envelope. + // 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: [ - topologyMeasurement({ - metric: 'work_merge_attempts_total', - value: null, - unit: 'events', - denominator: 'observed_native_integrations', - dimensions: [ - { dimension: 'integration_kind', value: 'fast_forward' }, - { dimension: 'integration_outcome', value: 'succeeded' }, - ], - coverage: { eligible: null, observed: 0, unknown: 1, state: 'unknown' }, - unavailable: 'support_floor_unmet', - }), - topologyMeasurement({ - metric: 'work_merge_attempts_total', - value: null, - unit: 'events', - denominator: 'observed_native_integrations', - dimensions: [ - { dimension: 'integration_kind', value: 'cherry_pick' }, - { dimension: 'integration_outcome', value: 'conflicted' }, - ], - coverage: { eligible: null, observed: 0, unknown: 1, state: 'unknown' }, - unavailable: 'support_floor_unmet', - }), + mergeCell('fast_forward', 'succeeded', null, 'support_floor_unmet'), + mergeCell('cherry_pick', 'conflicted', null, 'support_floor_unmet'), ], }); const card = cardOf( @@ -727,13 +702,11 @@ describe('observed integration outcomes', () => { const stated = absence(card.reading); expect(stated.detail).toContain('typed absence'); expect(stated.detail).toContain('support floor unmet'); - // The rows stay, each wearing its own suppression. expect(card.rows).toHaveLength(2); for (const row of card.rows) { expect(row.channel.available, row.key).toBe(false); } - // The wiped coverage envelope is not presented as a measurement: the - // counted facets carry the same typed reason, while the horizon and the + // 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'); @@ -746,29 +719,8 @@ describe('observed integration outcomes', () => { it('states readable and suppressed cells separately when they coexist', () => { const mixed = metricsOf({ measurements: [ - topologyMeasurement({ - metric: 'work_merge_attempts_total', - value: 6, - unit: 'events', - denominator: 'observed_native_integrations', - dimensions: [ - { dimension: 'integration_kind', value: 'fast_forward' }, - { dimension: 'integration_outcome', value: 'succeeded' }, - ], - coverage: { eligible: 8, observed: 7, completed: 7, unknown: 1, state: 'known' }, - }), - topologyMeasurement({ - metric: 'work_merge_attempts_total', - value: null, - unit: 'events', - denominator: 'observed_native_integrations', - dimensions: [ - { dimension: 'integration_kind', value: 'cherry_pick' }, - { dimension: 'integration_outcome', value: 'conflicted' }, - ], - coverage: { eligible: null, observed: 0, unknown: 1, state: 'unknown' }, - unavailable: 'support_floor_unmet', - }), + mergeCell('fast_forward', 'succeeded', 6), + mergeCell('cherry_pick', 'conflicted', null, 'support_floor_unmet'), ], }); const card = cardOf( @@ -780,8 +732,8 @@ describe('observed integration outcomes', () => { 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'); - // The headline coverage comes from a readable cell's envelope, never - // from a suppressed one. + // Headline coverage comes from a readable cell's envelope, never a + // suppressed one. expect(figure(card.provenance.support).value).toBe(7); }); diff --git a/dashboard/src/workspaces/work/workTopologyAccounting.ts b/dashboard/src/workspaces/work/workTopologyAccounting.ts index 4df7cd5066..58c17c7508 100644 --- a/dashboard/src/workspaces/work/workTopologyAccounting.ts +++ b/dashboard/src/workspaces/work/workTopologyAccounting.ts @@ -47,23 +47,14 @@ 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/`, whose - * `EXECUTION_TOPOLOGY_EVENT_KINDS_V1` is the exact list this module names in - * its absences). That read model IS published — `operation.work.topology_metrics` - * is mounted at `/api/work/topology-metrics` and its contract is generated — - * and this ledger consumes its integration and stack families: - * - * integration outcomes the `work_merge_attempts_total` kind × outcome - * cells, decoded verbatim in - * `workAccountingMetrics.ts`; a typed-absent cell - * stays the projector's own absence. - * stack capability the model's `github_stack_capability` reading, a - * typed operational state rather than a count. - * - * The remaining event-fed dimensions render as typed absences naming the - * event kind a reviewer can grep for: this lens does not decode their + * `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 further dimensions have a real, mounted source on the attempt and diff --git a/dashboard/src/workspaces/workflows/WorkflowsPage.dom.test.tsx b/dashboard/src/workspaces/workflows/WorkflowsPage.dom.test.tsx index 799b918902..52b91293bb 100644 --- a/dashboard/src/workspaces/workflows/WorkflowsPage.dom.test.tsx +++ b/dashboard/src/workspaces/workflows/WorkflowsPage.dom.test.tsx @@ -1,11 +1,6 @@ -/** - * The Workflows page over the mounted `/application/workflow` routes. - * - * The invariant under test is the same one every workspace carries: a refusal - * is never an empty registry, an empty registry is drawn only when the daemon - * actually answered one, and every rendered figure is a decoded generated - * contract rather than a browser-owned substitute. - */ +/** 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'; @@ -65,8 +60,7 @@ function envelope(payload: unknown) { }; } -/** Answers exactly the routes a test names and refuses anything else, so a - * test that accidentally depends on another route fails loudly. */ +/** 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( @@ -212,8 +206,6 @@ describe('the Workflows page over mounted routes', () => { await userEvent.click(await screen.findByRole('button', { name: 'activate' })); expect(await screen.findByText(/disposition active · revision 3/)).toBeTruthy(); - // Switching to another definition must not carry the first definition's - // transition result or revision draft under the new heading. const draft = screen.getByLabelText('Expected revision'); await userEvent.clear(draft); await userEvent.type(draft, '7'); diff --git a/dashboard/src/workspaces/workflows/WorkflowsPage.tsx b/dashboard/src/workspaces/workflows/WorkflowsPage.tsx index 2e928c404c..c94961ee84 100644 --- a/dashboard/src/workspaces/workflows/WorkflowsPage.tsx +++ b/dashboard/src/workspaces/workflows/WorkflowsPage.tsx @@ -16,20 +16,11 @@ import { } from './workflowQueries.ts'; /** - * Workflows — channel fourteen. - * - * The definition/run consumer of the canonical `/application/workflow` routes: - * registered definition versions off `list_definitions`, per-version step - * tables, the three compare-and-swap lifecycle transitions, and run - * projections off `get_run`. Everything rendered here is a decoded generated - * contract; a refusal renders the daemon's own typed state, and the only - * empty registry drawn is one the daemon actually answered as empty. - * - * What this page deliberately does not do: it never issues or redeems a task - * handoff (the browser must not hold a bearer), and it never starts, pauses, - * resumes, or cancels a run (the browser must not mint fences, command ids, - * or provider admissions). Runs are observed here and controlled by their - * owning surfaces. + * 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 = @@ -75,10 +66,7 @@ export function WorkflowsPage() { /> {selectedDefinition === null ? null : ( - // Keyed by the selected identity so the lifecycle controls — - // revision draft and the last transition's result — reset when - // the operator switches definitions instead of carrying one - // definition's state under another's heading. + // Keyed so lifecycle state resets when switching definitions. )} @@ -108,15 +96,8 @@ function DefinitionsPanel({ ) : result === undefined ? ( ) : result.outcome === 'refused' ? ( - <> - {/* The daemon's own reason. An unavailable registry and an empty - * registry are different facts and must never render alike. */} - -

- No definition list is drawn. This build reads the mounted Workflow routes and does - not infer their contents when they refuse. -

- + // A refused registry and an empty registry must never render alike. + ) : result.value.length === 0 ? ( @@ -281,11 +261,8 @@ function LifecycleControls({ definition }: { definition: WorkflowDefinition }) { return (

- Lifecycle transitions are compare-and-swaps against the disposition revision. No - disposition read is mounted, so the expected revision is entered here and the daemon - answers with the stored disposition or a typed conflict — a registered candidate starts - at revision 1. Activation additionally runs tool-catalog admission over every step - operation on the daemon. + Compare-and-swap against the disposition revision (a registered candidate starts at 1); + the daemon answers with the stored disposition or a typed conflict.

- steps · every row is one decoded `WorkflowStep`; operations are catalog operation - ids and are admitted against the executable catalog on activation + steps · operations are catalog ids, admitted on activation