diff --git a/docs/docs.go b/docs/docs.go index a69b4769..46a64e53 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -41147,6 +41147,12 @@ const docTemplate = `{ "due_date": { "type": "string" }, + "execution_stream_uuid": { + "description": "Computed field (not persisted)", + "type": "string", + "format": "uuid", + "readOnly": true + }, "failed-at": { "type": "string" }, diff --git a/docs/swagger.json b/docs/swagger.json index 9f937e1c..6fa337f6 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -41141,6 +41141,12 @@ "due_date": { "type": "string" }, + "execution_stream_uuid": { + "description": "Computed field (not persisted)", + "type": "string", + "format": "uuid", + "readOnly": true + }, "failed-at": { "type": "string" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 081d2fac..b03b756d 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -9307,6 +9307,11 @@ definitions: $ref: '#/definitions/gorm.DeletedAt' due_date: type: string + execution_stream_uuid: + description: Computed field (not persisted) + format: uuid + readOnly: true + type: string failed-at: type: string failure_reason: diff --git a/internal/service/relational/workflows/stream_uuid.go b/internal/service/relational/workflows/stream_uuid.go new file mode 100644 index 00000000..95d25dbf --- /dev/null +++ b/internal/service/relational/workflows/stream_uuid.go @@ -0,0 +1,24 @@ +package workflows + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + + "github.com/google/uuid" +) + +// ComputeExecutionStreamUUID derives the deterministic evidence stream UUID for a workflow +// execution. The same seed algorithm is used by EvidenceIntegration so both sides always +// agree on the UUID without a database round-trip. +func ComputeExecutionStreamUUID(definitionID, instanceID, executionID uuid.UUID) (uuid.UUID, error) { + seed := fmt.Sprintf("execution:%s:%s:%s:%s", + definitionID.String(), + instanceID.String(), + executionID.String(), + "v1", + ) + hash := sha256.Sum256([]byte(seed)) + h := hex.EncodeToString(hash[:16]) + return uuid.Parse(h[:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32]) +} diff --git a/internal/service/relational/workflows/workflow_execution.go b/internal/service/relational/workflows/workflow_execution.go index fda6a78f..e5078002 100644 --- a/internal/service/relational/workflows/workflow_execution.go +++ b/internal/service/relational/workflows/workflow_execution.go @@ -41,6 +41,9 @@ type WorkflowExecution struct { // Relationships WorkflowInstance *WorkflowInstance `gorm:"foreignKey:WorkflowInstanceID" json:"workflow_instance,omitempty"` StepExecutions []StepExecution `gorm:"foreignKey:WorkflowExecutionID;constraint:OnDelete:CASCADE" json:"step_executions,omitempty"` + + // Computed field (not persisted) + ExecutionStreamUUID *uuid.UUID `gorm:"-" json:"execution_stream_uuid,omitempty" format:"uuid" readonly:"true"` } // TableName specifies the table name for WorkflowExecution diff --git a/internal/service/relational/workflows/workflow_execution_service.go b/internal/service/relational/workflows/workflow_execution_service.go index 5cd2a3dc..e166da1e 100644 --- a/internal/service/relational/workflows/workflow_execution_service.go +++ b/internal/service/relational/workflows/workflow_execution_service.go @@ -64,6 +64,18 @@ func (s *WorkflowExecutionService) GetByID(id *uuid.UUID) (*WorkflowExecution, e if err != nil { return nil, err } + if execution.WorkflowInstance != nil && execution.WorkflowInstance.WorkflowDefinition != nil && + execution.WorkflowInstance.WorkflowDefinition.ID != nil && execution.WorkflowInstanceID != nil && execution.ID != nil { + streamUUID, err := ComputeExecutionStreamUUID( + *execution.WorkflowInstance.WorkflowDefinition.ID, + *execution.WorkflowInstanceID, + *execution.ID, + ) + if err != nil { + return nil, fmt.Errorf("failed to compute execution stream UUID: %w", err) + } + execution.ExecutionStreamUUID = &streamUUID + } return &execution, nil } diff --git a/internal/service/relational/workflows/workflow_execution_service_test.go b/internal/service/relational/workflows/workflow_execution_service_test.go index 8f911dee..05f96067 100644 --- a/internal/service/relational/workflows/workflow_execution_service_test.go +++ b/internal/service/relational/workflows/workflow_execution_service_test.go @@ -97,6 +97,39 @@ func TestWorkflowExecutionService_GetByID(t *testing.T) { assert.Contains(t, err.Error(), "not found") } +// TestWorkflowExecutionService_GetByID_PopulatesExecutionStreamUUID tests that GetByID computes +// and populates ExecutionStreamUUID so callers can link to the evidence stream without extra queries. +// BCH-1155: terminal evidence is created in a stream but there was no navigable link to it. +func TestWorkflowExecutionService_GetByID_PopulatesExecutionStreamUUID(t *testing.T) { + db := setupTestDB(t) + service := NewWorkflowExecutionService(db) + + workflowDef := createTestWorkflowDefinition() + require.NoError(t, db.Create(workflowDef).Error) + + instance := createTestWorkflowInstance(workflowDef.ID) + require.NoError(t, db.Create(instance).Error) + + execution := createTestWorkflowExecution(instance.ID) + require.NoError(t, db.Create(execution).Error) + + retrieved, err := service.GetByID(execution.ID) + require.NoError(t, err) + + // ExecutionStreamUUID must equal the value produced by the canonical algorithm + // for the same definition/instance/execution IDs. + expectedUUID, err := ComputeExecutionStreamUUID(*workflowDef.ID, *instance.ID, *execution.ID) + require.NoError(t, err) + require.NotNil(t, retrieved.ExecutionStreamUUID) + assert.Equal(t, expectedUUID, *retrieved.ExecutionStreamUUID) + + // Calling again must produce the same UUID (determinism check). + retrieved2, err := service.GetByID(execution.ID) + require.NoError(t, err) + require.NotNil(t, retrieved2.ExecutionStreamUUID) + assert.Equal(t, expectedUUID, *retrieved2.ExecutionStreamUUID) +} + // TestWorkflowExecutionService_GetByWorkflowInstanceID tests the GetByWorkflowInstanceID method func TestWorkflowExecutionService_GetByWorkflowInstanceID(t *testing.T) { db := setupTestDB(t)